Merge branch 'main' into drjkl/lowered-expectations
@@ -135,8 +135,11 @@ test.describe('Menu', () => {
|
||||
// Checkmark should be invisible again (panel is hidden)
|
||||
await expect(checkmark).toHaveClass(/invisible/)
|
||||
|
||||
// Click outside to close menu
|
||||
await comfyPage.page.locator('body').click({ position: { x: 10, y: 10 } })
|
||||
// Click in top-right corner to close menu (avoid hamburger menu at top-left)
|
||||
const viewport = comfyPage.page.viewportSize()!
|
||||
await comfyPage.page
|
||||
.locator('body')
|
||||
.click({ position: { x: viewport.width - 10, y: 10 } })
|
||||
|
||||
// Verify menu is now closed
|
||||
await expect(menu).not.toBeVisible()
|
||||
|
||||
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 114 KiB After Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 51 KiB |
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
class="subgraph-breadcrumb w-auto drop-shadow-[var(--interface-panel-drop-shadow)]"
|
||||
class="subgraph-breadcrumb flex w-auto drop-shadow-[var(--interface-panel-drop-shadow)]"
|
||||
:class="{
|
||||
'subgraph-breadcrumb-collapse': collapseTabs,
|
||||
'subgraph-breadcrumb-overflow': overflowingTabs
|
||||
@@ -13,17 +13,37 @@
|
||||
'--p-breadcrumb-icon-width': `${ICON_WIDTH}px`
|
||||
}"
|
||||
>
|
||||
<Button
|
||||
class="context-menu-button pointer-events-auto h-8 w-8 shrink-0 border border-transparent bg-transparent p-0 transition-all hover:rounded-lg hover:border-interface-stroke hover:bg-comfy-menu-bg"
|
||||
icon="pi pi-bars"
|
||||
text
|
||||
severity="secondary"
|
||||
size="small"
|
||||
@click="handleMenuClick"
|
||||
/>
|
||||
<Button
|
||||
v-if="isInSubgraph"
|
||||
class="back-button pointer-events-auto h-8 w-8 shrink-0 border border-transparent bg-transparent p-0 transition-all hover:rounded-lg hover:border-interface-stroke hover:bg-comfy-menu-bg"
|
||||
text
|
||||
severity="secondary"
|
||||
size="small"
|
||||
@click="handleBackClick"
|
||||
>
|
||||
<i class="icon-[lucide--undo-2]" />
|
||||
</Button>
|
||||
<Breadcrumb
|
||||
ref="breadcrumbRef"
|
||||
class="w-fit rounded-lg p-0"
|
||||
:class="{ hidden: !isInSubgraph }"
|
||||
:model="items"
|
||||
:pt="{ item: { class: 'pointer-events-auto' } }"
|
||||
:aria-label="$t('g.graphNavigation')"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<SubgraphBreadcrumbItem
|
||||
:ref="(el) => setItemRef(item, el)"
|
||||
:item="item"
|
||||
:is-active="item === items.at(-1)"
|
||||
:is-active="item.key === activeItemKey"
|
||||
/>
|
||||
</template>
|
||||
<template #separator
|
||||
@@ -35,6 +55,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import Breadcrumb from 'primevue/breadcrumb'
|
||||
import Button from 'primevue/button'
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import { computed, onUpdated, ref, watch } from 'vue'
|
||||
|
||||
@@ -43,6 +64,7 @@ import { useOverflowObserver } from '@/composables/element/useOverflowObserver'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
|
||||
import { useSubgraphStore } from '@/stores/subgraphStore'
|
||||
import { forEachSubgraphNode } from '@/utils/graphTraversalUtil'
|
||||
@@ -55,6 +77,12 @@ const ICON_WIDTH = 20
|
||||
const workflowStore = useWorkflowStore()
|
||||
const navigationStore = useSubgraphNavigationStore()
|
||||
const breadcrumbRef = ref<InstanceType<typeof Breadcrumb>>()
|
||||
const rootItemRef = ref<InstanceType<typeof SubgraphBreadcrumbItem>>()
|
||||
const setItemRef = (item: MenuItem, el: unknown) => {
|
||||
if (item.key === 'root') {
|
||||
rootItemRef.value = el as InstanceType<typeof SubgraphBreadcrumbItem>
|
||||
}
|
||||
}
|
||||
const workflowName = computed(() => workflowStore.activeWorkflow?.filename)
|
||||
const isBlueprint = computed(() =>
|
||||
useSubgraphStore().isSubgraphBlueprint(workflowStore.activeWorkflow)
|
||||
@@ -62,17 +90,28 @@ const isBlueprint = computed(() =>
|
||||
const collapseTabs = ref(false)
|
||||
const overflowingTabs = ref(false)
|
||||
|
||||
const breadcrumbElement = computed(() => {
|
||||
if (!breadcrumbRef.value) return null
|
||||
const isInSubgraph = computed(() => navigationStore.navigationStack.length > 0)
|
||||
|
||||
const el = (breadcrumbRef.value as unknown as { $el: HTMLElement }).$el
|
||||
const list = el?.querySelector('.p-breadcrumb-list') as HTMLElement
|
||||
return list
|
||||
})
|
||||
const home = computed(() => ({
|
||||
label: workflowName.value,
|
||||
icon: 'pi pi-home',
|
||||
key: 'root',
|
||||
isBlueprint: isBlueprint.value,
|
||||
command: () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'breadcrumb_subgraph_root_selected'
|
||||
})
|
||||
const canvas = useCanvasStore().getCanvas()
|
||||
if (!canvas.graph) throw new TypeError('Canvas has no graph')
|
||||
|
||||
canvas.setGraph(canvas.graph.rootGraph)
|
||||
}
|
||||
}))
|
||||
|
||||
const items = computed(() => {
|
||||
const items = navigationStore.navigationStack.map<MenuItem>((subgraph) => ({
|
||||
label: subgraph.name,
|
||||
key: `subgraph-${subgraph.id}`,
|
||||
command: () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'breadcrumb_subgraph_item_selected'
|
||||
@@ -95,21 +134,26 @@ const items = computed(() => {
|
||||
return [home.value, ...items]
|
||||
})
|
||||
|
||||
const home = computed(() => ({
|
||||
label: workflowName.value,
|
||||
icon: 'pi pi-home',
|
||||
key: 'root',
|
||||
isBlueprint: isBlueprint.value,
|
||||
command: () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'breadcrumb_subgraph_root_selected'
|
||||
})
|
||||
const canvas = useCanvasStore().getCanvas()
|
||||
if (!canvas.graph) throw new TypeError('Canvas has no graph')
|
||||
const activeItemKey = computed(() => items.value.at(-1)?.key)
|
||||
|
||||
canvas.setGraph(canvas.graph.rootGraph)
|
||||
}
|
||||
}))
|
||||
const handleMenuClick = (event: MouseEvent) => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'breadcrumb_subgraph_menu_selected'
|
||||
})
|
||||
rootItemRef.value?.toggleMenu(event)
|
||||
}
|
||||
|
||||
const handleBackClick = () => {
|
||||
void useCommandStore().execute('Comfy.Graph.ExitSubgraph')
|
||||
}
|
||||
|
||||
const breadcrumbElement = computed(() => {
|
||||
if (!breadcrumbRef.value) return null
|
||||
|
||||
const el = (breadcrumbRef.value as unknown as { $el: HTMLElement }).$el
|
||||
const list = el?.querySelector('.p-breadcrumb-list') as HTMLElement
|
||||
return list
|
||||
})
|
||||
|
||||
// Check for overflow on breadcrumb items and collapse/expand the breadcrumb to fit
|
||||
let overflowObserver: ReturnType<typeof useOverflowObserver> | undefined
|
||||
@@ -189,13 +233,18 @@ onUpdated(() => {
|
||||
}
|
||||
|
||||
:deep(.p-breadcrumb-item) {
|
||||
@apply flex items-center overflow-hidden;
|
||||
@apply flex items-center overflow-hidden h-8;
|
||||
min-width: calc(var(--p-breadcrumb-item-min-width) + 1rem);
|
||||
border: 1px solid transparent;
|
||||
background-color: transparent;
|
||||
transition: all 0.2s;
|
||||
/* Collapse middle items first */
|
||||
flex-shrink: 10000;
|
||||
}
|
||||
|
||||
:deep(.p-breadcrumb-separator) {
|
||||
border: 1px solid transparent;
|
||||
background-color: transparent;
|
||||
display: flex;
|
||||
padding: 0 var(--p-breadcrumb-item-margin);
|
||||
}
|
||||
@@ -205,11 +254,9 @@ onUpdated(() => {
|
||||
calc(var(--p-breadcrumb-item-margin) + var(--p-breadcrumb-item-padding));
|
||||
}
|
||||
|
||||
:deep(.p-breadcrumb-separator),
|
||||
:deep(.p-breadcrumb-item) {
|
||||
@apply h-12;
|
||||
border-top: 1px solid var(--interface-stroke);
|
||||
border-bottom: 1px solid var(--interface-stroke);
|
||||
:deep(.p-breadcrumb-item:hover) {
|
||||
@apply rounded-lg;
|
||||
border-color: var(--interface-stroke);
|
||||
background-color: var(--comfy-menu-bg);
|
||||
}
|
||||
|
||||
@@ -218,10 +265,8 @@ onUpdated(() => {
|
||||
}
|
||||
|
||||
:deep(.p-breadcrumb-item:first-child) {
|
||||
@apply rounded-l-lg;
|
||||
/* Then collapse the root workflow */
|
||||
flex-shrink: 5000;
|
||||
border-left: 1px solid var(--interface-stroke);
|
||||
|
||||
.p-breadcrumb-item-link {
|
||||
padding-left: var(--p-breadcrumb-item-padding);
|
||||
@@ -229,13 +274,10 @@ onUpdated(() => {
|
||||
}
|
||||
|
||||
:deep(.p-breadcrumb-item:last-child) {
|
||||
@apply rounded-r-lg;
|
||||
/* Then collapse the active item */
|
||||
flex-shrink: 1;
|
||||
border-right: 1px solid var(--interface-stroke);
|
||||
}
|
||||
|
||||
:deep(.p-breadcrumb-item-link:hover),
|
||||
:deep(.p-breadcrumb-item-link-menu-visible) {
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
}"
|
||||
draggable="false"
|
||||
href="#"
|
||||
class="p-breadcrumb-item-link h-12 cursor-pointer px-2"
|
||||
class="p-breadcrumb-item-link h-8 cursor-pointer px-2"
|
||||
:class="{
|
||||
'flex items-center gap-1': isActive,
|
||||
'p-breadcrumb-item-link-menu-visible': menu?.overlayVisible,
|
||||
@@ -25,7 +25,7 @@
|
||||
<i v-if="isActive" class="pi pi-angle-down text-[10px]"></i>
|
||||
</a>
|
||||
<Menu
|
||||
v-if="isActive"
|
||||
v-if="isActive || isRoot"
|
||||
ref="menu"
|
||||
:model="menuItems"
|
||||
:popup="true"
|
||||
@@ -59,6 +59,7 @@ import Tag from 'primevue/tag'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useWorkflowActionsMenu } from '@/composables/useWorkflowActionsMenu'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import {
|
||||
ComfyWorkflow,
|
||||
@@ -135,79 +136,28 @@ const tooltipText = computed(() => {
|
||||
return props.item.label
|
||||
})
|
||||
|
||||
const menuItems = computed<MenuItem[]>(() => {
|
||||
return [
|
||||
{
|
||||
label: t('g.rename'),
|
||||
icon: 'pi pi-pencil',
|
||||
command: startRename
|
||||
},
|
||||
{
|
||||
label: t('breadcrumbsMenu.duplicate'),
|
||||
icon: 'pi pi-copy',
|
||||
command: async () => {
|
||||
await workflowService.duplicateWorkflow(workflowStore.activeWorkflow!)
|
||||
},
|
||||
visible: isRoot && !props.item.isBlueprint
|
||||
},
|
||||
{
|
||||
separator: true,
|
||||
visible: isRoot
|
||||
},
|
||||
{
|
||||
label: t('menuLabels.Save'),
|
||||
icon: 'pi pi-save',
|
||||
command: async () => {
|
||||
await useCommandStore().execute('Comfy.SaveWorkflow')
|
||||
},
|
||||
visible: isRoot
|
||||
},
|
||||
{
|
||||
label: t('menuLabels.Save As'),
|
||||
icon: 'pi pi-save',
|
||||
command: async () => {
|
||||
await useCommandStore().execute('Comfy.SaveWorkflowAs')
|
||||
},
|
||||
visible: isRoot
|
||||
},
|
||||
{
|
||||
separator: true
|
||||
},
|
||||
{
|
||||
label: t('breadcrumbsMenu.clearWorkflow'),
|
||||
icon: 'pi pi-trash',
|
||||
command: async () => {
|
||||
await useCommandStore().execute('Comfy.ClearWorkflow')
|
||||
const startRename = async () => {
|
||||
// Check if element is hidden (collapsed breadcrumb)
|
||||
// When collapsed, root item is hidden via CSS display:none, so use rename command
|
||||
if (isRoot && wrapperRef.value?.offsetParent === null) {
|
||||
await useCommandStore().execute('Comfy.RenameWorkflow')
|
||||
return
|
||||
}
|
||||
|
||||
isEditing.value = true
|
||||
itemLabel.value = props.item.label as string
|
||||
void nextTick(() => {
|
||||
if (itemInputRef.value?.$el) {
|
||||
itemInputRef.value.$el.focus()
|
||||
itemInputRef.value.$el.select()
|
||||
if (wrapperRef.value) {
|
||||
itemInputRef.value.$el.style.width = `${Math.max(200, wrapperRef.value.offsetWidth)}px`
|
||||
}
|
||||
},
|
||||
{
|
||||
separator: true,
|
||||
visible: props.item.key === 'root' && props.item.isBlueprint
|
||||
},
|
||||
{
|
||||
label: t('subgraphStore.publish'),
|
||||
icon: 'pi pi-copy',
|
||||
command: async () => {
|
||||
await workflowService.saveWorkflowAs(workflowStore.activeWorkflow!)
|
||||
},
|
||||
visible: props.item.key === 'root' && props.item.isBlueprint
|
||||
},
|
||||
{
|
||||
separator: true,
|
||||
visible: isRoot
|
||||
},
|
||||
{
|
||||
label: props.item.isBlueprint
|
||||
? t('breadcrumbsMenu.deleteBlueprint')
|
||||
: t('breadcrumbsMenu.deleteWorkflow'),
|
||||
icon: 'pi pi-times',
|
||||
command: async () => {
|
||||
await workflowService.deleteWorkflow(workflowStore.activeWorkflow!)
|
||||
},
|
||||
visible: isRoot
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const { menuItems } = useWorkflowActionsMenu(startRename, { isRoot })
|
||||
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (isEditing.value) {
|
||||
@@ -228,20 +178,6 @@ const handleClick = (event: MouseEvent) => {
|
||||
}
|
||||
}
|
||||
|
||||
const startRename = () => {
|
||||
isEditing.value = true
|
||||
itemLabel.value = props.item.label as string
|
||||
void nextTick(() => {
|
||||
if (itemInputRef.value?.$el) {
|
||||
itemInputRef.value.$el.focus()
|
||||
itemInputRef.value.$el.select()
|
||||
if (wrapperRef.value) {
|
||||
itemInputRef.value.$el.style.width = `${Math.max(200, wrapperRef.value.offsetWidth)}px`
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const inputBlur = async (doRename: boolean) => {
|
||||
if (doRename) {
|
||||
await rename(itemLabel.value, props.item.label as string)
|
||||
@@ -249,6 +185,14 @@ const inputBlur = async (doRename: boolean) => {
|
||||
|
||||
isEditing.value = false
|
||||
}
|
||||
|
||||
const toggleMenu = (event: MouseEvent) => {
|
||||
menu.value?.toggle(event)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
toggleMenu
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
65
src/components/common/OverlayIcon.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<span class="relative inline-flex items-center justify-center size-[1em]">
|
||||
<i :class="mainIcon" class="text-[1em]" />
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
subIcon,
|
||||
'absolute leading-none pointer-events-none',
|
||||
positionX === 'left' ? 'left-0' : 'right-0',
|
||||
positionY === 'top' ? 'top-0' : 'bottom-0'
|
||||
)
|
||||
"
|
||||
:style="subIconStyle"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
type Position = 'top' | 'bottom' | 'left' | 'right'
|
||||
|
||||
export interface OverlayIconProps {
|
||||
mainIcon: string
|
||||
subIcon: string
|
||||
positionX?: Position
|
||||
positionY?: Position
|
||||
offsetX?: number
|
||||
offsetY?: number
|
||||
subIconScale?: number
|
||||
}
|
||||
const {
|
||||
mainIcon,
|
||||
subIcon,
|
||||
positionX = 'right',
|
||||
positionY = 'bottom',
|
||||
offsetX = 0,
|
||||
offsetY = 0,
|
||||
subIconScale = 0.6
|
||||
} = defineProps<OverlayIconProps>()
|
||||
|
||||
const textShadow = [
|
||||
`-1px -1px 0 rgba(0, 0, 0, 0.7)`,
|
||||
`1px -1px 0 rgba(0, 0, 0, 0.7)`,
|
||||
`-1px 1px 0 rgba(0, 0, 0, 0.7)`,
|
||||
`1px 1px 0 rgba(0, 0, 0, 0.7)`,
|
||||
`-1px 0 0 rgba(0, 0, 0, 0.7)`,
|
||||
`1px 0 0 rgba(0, 0, 0, 0.7)`,
|
||||
`0 -1px 0 rgba(0, 0, 0, 0.7)`,
|
||||
`0 1px 0 rgba(0, 0, 0, 0.7)`
|
||||
].join(', ')
|
||||
|
||||
const subIconStyle = computed(() => ({
|
||||
fontSize: `${subIconScale}em`,
|
||||
textShadow,
|
||||
...(offsetX !== 0 && {
|
||||
[positionX === 'left' ? 'left' : 'right']: `${offsetX}px`
|
||||
}),
|
||||
...(offsetY !== 0 && {
|
||||
[positionY === 'top' ? 'top' : 'bottom']: `${offsetY}px`
|
||||
})
|
||||
}))
|
||||
</script>
|
||||
@@ -7,6 +7,15 @@
|
||||
@mouseleave="handleMouseLeave"
|
||||
@click="handleClick"
|
||||
>
|
||||
<Button
|
||||
v-if="isActiveTab"
|
||||
class="context-menu-button -mx-1 w-auto px-1 py-0"
|
||||
variant="muted-textonly"
|
||||
size="icon-sm"
|
||||
@click.stop="handleMenuClick"
|
||||
>
|
||||
<i class="pi pi-bars" />
|
||||
</Button>
|
||||
<span class="workflow-label inline-block max-w-[150px] truncate text-sm">
|
||||
{{ workflowOption.workflow.filename }}
|
||||
</span>
|
||||
@@ -34,9 +43,26 @@
|
||||
:thumbnail-url="thumbnailUrl"
|
||||
:is-active-tab="isActiveTab"
|
||||
/>
|
||||
|
||||
<Menu
|
||||
v-if="isActiveTab"
|
||||
ref="menu"
|
||||
:model="menuItems"
|
||||
:popup="true"
|
||||
:pt="{
|
||||
root: {
|
||||
style: 'background-color: var(--comfy-menu-bg)'
|
||||
},
|
||||
itemLink: {
|
||||
class: 'py-2'
|
||||
}
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { MenuState } from 'primevue/menu'
|
||||
import Menu from 'primevue/menu'
|
||||
import { computed, onUnmounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -45,11 +71,14 @@ import {
|
||||
usePragmaticDraggable,
|
||||
usePragmaticDroppable
|
||||
} from '@/composables/usePragmaticDragAndDrop'
|
||||
import { useWorkflowActionsMenu } from '@/composables/useWorkflowActionsMenu'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowThumbnail } from '@/renderer/core/thumbnail/useWorkflowThumbnail'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
|
||||
import WorkflowTabPopover from './WorkflowTabPopover.vue'
|
||||
@@ -114,6 +143,12 @@ const thumbnailUrl = computed(() => {
|
||||
return workflowThumbnail.getThumbnail(props.workflowOption.workflow.key)
|
||||
})
|
||||
|
||||
const menu = ref<InstanceType<typeof Menu> & MenuState>()
|
||||
|
||||
const { menuItems } = useWorkflowActionsMenu(() =>
|
||||
useCommandStore().execute('Comfy.RenameWorkflow')
|
||||
)
|
||||
|
||||
// Event handlers that delegate to the popover component
|
||||
const handleMouseEnter = (event: Event) => {
|
||||
popoverRef.value?.showPopover(event)
|
||||
@@ -127,6 +162,14 @@ const handleClick = (event: Event) => {
|
||||
popoverRef.value?.togglePopover(event)
|
||||
}
|
||||
|
||||
const handleMenuClick = (event: MouseEvent) => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'workflow_tab_menu_selected'
|
||||
})
|
||||
// Show breadcrumb menu instead of emitting context click
|
||||
menu.value?.toggle(event)
|
||||
}
|
||||
|
||||
const closeWorkflows = async (options: WorkflowOption[]) => {
|
||||
for (const opt of options) {
|
||||
if (
|
||||
|
||||
@@ -80,7 +80,12 @@
|
||||
/>
|
||||
<LoginButton v-else-if="isDesktop" class="p-1" />
|
||||
</div>
|
||||
<ContextMenu ref="menu" :model="contextMenuItems" />
|
||||
<ContextMenu ref="menu" :model="contextMenuItems">
|
||||
<template #itemicon="{ item }">
|
||||
<OverlayIcon v-if="item.overlayIcon" v-bind="item.overlayIcon" />
|
||||
<i v-else-if="item.icon" :class="item.icon" />
|
||||
</template>
|
||||
</ContextMenu>
|
||||
<div v-if="isDesktop" class="window-actions-spacer app-drag shrink-0" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -94,6 +99,8 @@ import { computed, nextTick, onUpdated, ref, watch } from 'vue'
|
||||
import type { WatchStopHandle } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import OverlayIcon from '@/components/common/OverlayIcon.vue'
|
||||
import type { OverlayIconProps } from '@/components/common/OverlayIcon.vue'
|
||||
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
|
||||
import LoginButton from '@/components/topbar/LoginButton.vue'
|
||||
import TopMenuHelpButton from '@/components/topbar/TopMenuHelpButton.vue'
|
||||
@@ -101,13 +108,11 @@ import WorkflowTab from '@/components/topbar/WorkflowTab.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useOverflowObserver } from '@/composables/element/useOverflowObserver'
|
||||
import { useWorkflowActionsMenu } from '@/composables/useWorkflowActionsMenu'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import {
|
||||
useWorkflowBookmarkStore,
|
||||
useWorkflowStore
|
||||
} from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { isElectron } from '@/utils/envUtil'
|
||||
@@ -128,8 +133,8 @@ const { t } = useI18n()
|
||||
const settingStore = useSettingStore()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const workflowBookmarkStore = useWorkflowBookmarkStore()
|
||||
const workflowService = useWorkflowService()
|
||||
const commandStore = useCommandStore()
|
||||
const { isLoggedIn } = useCurrentUser()
|
||||
|
||||
const isIntegratedTabBar = computed(
|
||||
@@ -193,54 +198,73 @@ const showContextMenu = (event: MouseEvent, option: WorkflowOption) => {
|
||||
rightClickedTab.value = option
|
||||
menu.value.show(event)
|
||||
}
|
||||
|
||||
const rightClickedWorkflow = computed(
|
||||
() => rightClickedTab.value?.workflow ?? null
|
||||
)
|
||||
|
||||
const { menuItems: baseMenuItems } = useWorkflowActionsMenu(
|
||||
() => commandStore.execute('Comfy.RenameWorkflow'),
|
||||
{
|
||||
includeDelete: false,
|
||||
workflow: rightClickedWorkflow
|
||||
}
|
||||
)
|
||||
|
||||
const contextMenuItems = computed(() => {
|
||||
const tab = rightClickedTab.value as WorkflowOption
|
||||
const tab = rightClickedTab.value
|
||||
if (!tab) return []
|
||||
const index = options.value.findIndex((v) => v.workflow === tab.workflow)
|
||||
|
||||
return [
|
||||
{
|
||||
label: t('tabMenu.duplicateTab'),
|
||||
command: async () => {
|
||||
await workflowService.duplicateWorkflow(tab.workflow)
|
||||
}
|
||||
},
|
||||
{
|
||||
separator: true
|
||||
},
|
||||
...baseMenuItems.value,
|
||||
{
|
||||
label: t('tabMenu.closeTab'),
|
||||
icon: 'pi pi-times',
|
||||
command: () => onCloseWorkflow(tab)
|
||||
},
|
||||
{
|
||||
label: t('tabMenu.closeTabsToLeft'),
|
||||
overlayIcon: {
|
||||
mainIcon: 'pi pi-times',
|
||||
subIcon: 'pi pi-arrow-left',
|
||||
positionX: 'right',
|
||||
positionY: 'bottom',
|
||||
subIconScale: 0.5
|
||||
} as OverlayIconProps,
|
||||
command: () => closeWorkflows(options.value.slice(0, index)),
|
||||
disabled: index <= 0
|
||||
},
|
||||
{
|
||||
label: t('tabMenu.closeTabsToRight'),
|
||||
overlayIcon: {
|
||||
mainIcon: 'pi pi-times',
|
||||
subIcon: 'pi pi-arrow-right',
|
||||
positionX: 'right',
|
||||
positionY: 'bottom',
|
||||
subIconScale: 0.5
|
||||
} as OverlayIconProps,
|
||||
command: () => closeWorkflows(options.value.slice(index + 1)),
|
||||
disabled: index === options.value.length - 1
|
||||
},
|
||||
{
|
||||
label: t('tabMenu.closeOtherTabs'),
|
||||
overlayIcon: {
|
||||
mainIcon: 'pi pi-times',
|
||||
subIcon: 'pi pi-arrows-h',
|
||||
positionX: 'right',
|
||||
positionY: 'bottom',
|
||||
subIconScale: 0.5
|
||||
} as OverlayIconProps,
|
||||
command: () =>
|
||||
closeWorkflows([
|
||||
...options.value.slice(index + 1),
|
||||
...options.value.slice(0, index)
|
||||
]),
|
||||
disabled: options.value.length <= 1
|
||||
},
|
||||
{
|
||||
label: workflowBookmarkStore.isBookmarked(tab.workflow.path)
|
||||
? t('tabMenu.removeFromBookmarks')
|
||||
: t('tabMenu.addToBookmarks'),
|
||||
command: () => workflowBookmarkStore.toggleBookmarked(tab.workflow.path),
|
||||
disabled: tab.workflow.isTemporary
|
||||
}
|
||||
]
|
||||
})
|
||||
const commandStore = useCommandStore()
|
||||
|
||||
// Horizontal scroll on wheel
|
||||
const handleWheel = (event: WheelEvent) => {
|
||||
|
||||
@@ -188,6 +188,26 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
await workflowService.saveWorkflowAs(workflow)
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.RenameWorkflow',
|
||||
icon: 'pi pi-pencil',
|
||||
label: 'Rename Workflow',
|
||||
menubarLabel: 'Rename',
|
||||
function: async () => {
|
||||
const workflow = workflowStore.activeWorkflow
|
||||
if (!workflow || !workflow.isPersisted) return
|
||||
|
||||
const newName = await dialogService.prompt({
|
||||
title: t('g.rename'),
|
||||
message: t('workflowService.enterFilename') + ':',
|
||||
defaultValue: workflow.filename
|
||||
})
|
||||
if (!newName || newName === workflow.filename) return
|
||||
|
||||
const newPath = workflow.directory + '/' + newName + '.json'
|
||||
await workflowService.renameWorkflow(workflow, newPath)
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.ExportWorkflow',
|
||||
icon: 'pi pi-download',
|
||||
|
||||
192
src/composables/useWorkflowActionsMenu.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import {
|
||||
useWorkflowBookmarkStore,
|
||||
useWorkflowStore
|
||||
} from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useSubgraphStore } from '@/stores/subgraphStore'
|
||||
|
||||
interface WorkflowActionsMenuOptions {
|
||||
/** Whether this is the root workflow level. Defaults to true. */
|
||||
isRoot?: boolean
|
||||
/** Whether to include the delete workflow action. Defaults to true. */
|
||||
includeDelete?: boolean
|
||||
/** Override the workflow to operate on. If not provided, uses activeWorkflow. */
|
||||
workflow?: Ref<ComfyWorkflow | null> | ComputedRef<ComfyWorkflow | null>
|
||||
}
|
||||
|
||||
export function useWorkflowActionsMenu(
|
||||
startRename: () => void,
|
||||
options: WorkflowActionsMenuOptions = {}
|
||||
) {
|
||||
const { isRoot = true, includeDelete = true, workflow } = options
|
||||
const { t } = useI18n()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const workflowService = useWorkflowService()
|
||||
const bookmarkStore = useWorkflowBookmarkStore()
|
||||
const commandStore = useCommandStore()
|
||||
const subgraphStore = useSubgraphStore()
|
||||
|
||||
const targetWorkflow = computed(
|
||||
() => workflow?.value ?? workflowStore.activeWorkflow
|
||||
)
|
||||
|
||||
/** Switch to the target workflow tab if it's not already active */
|
||||
const ensureWorkflowActive = async (wf: ComfyWorkflow | null) => {
|
||||
if (!wf || wf === workflowStore.activeWorkflow) return
|
||||
await workflowService.openWorkflow(wf)
|
||||
}
|
||||
|
||||
const menuItems = computed<MenuItem[]>(() => {
|
||||
const workflow = targetWorkflow.value
|
||||
const isBlueprint = workflow
|
||||
? subgraphStore.isSubgraphBlueprint(workflow)
|
||||
: false
|
||||
|
||||
const items: MenuItem[] = []
|
||||
|
||||
const addItem = (
|
||||
label: string,
|
||||
icon: string,
|
||||
command: () => void,
|
||||
visible = true,
|
||||
disabled = false,
|
||||
separator = false
|
||||
) => {
|
||||
if (!visible) return
|
||||
if (separator) items.push({ separator: true })
|
||||
items.push({ label, icon, command, disabled })
|
||||
}
|
||||
|
||||
addItem(
|
||||
t('g.rename'),
|
||||
'pi pi-pencil',
|
||||
async () => {
|
||||
await ensureWorkflowActive(targetWorkflow.value)
|
||||
startRename()
|
||||
},
|
||||
true,
|
||||
isRoot && !workflow?.isPersisted
|
||||
)
|
||||
|
||||
addItem(
|
||||
t('breadcrumbsMenu.duplicate'),
|
||||
'pi pi-copy',
|
||||
async () => {
|
||||
if (workflow) {
|
||||
await workflowService.duplicateWorkflow(workflow)
|
||||
}
|
||||
},
|
||||
isRoot && !isBlueprint
|
||||
)
|
||||
|
||||
addItem(
|
||||
t('menuLabels.Save'),
|
||||
'pi pi-save',
|
||||
async () => {
|
||||
await ensureWorkflowActive(workflow)
|
||||
await commandStore.execute('Comfy.SaveWorkflow')
|
||||
},
|
||||
isRoot,
|
||||
false,
|
||||
true
|
||||
)
|
||||
|
||||
addItem(
|
||||
t('menuLabels.Save As'),
|
||||
'pi pi-save',
|
||||
async () => {
|
||||
await ensureWorkflowActive(workflow)
|
||||
await commandStore.execute('Comfy.SaveWorkflowAs')
|
||||
},
|
||||
isRoot
|
||||
)
|
||||
|
||||
addItem(
|
||||
bookmarkStore.isBookmarked(workflow?.path ?? '')
|
||||
? t('tabMenu.removeFromBookmarks')
|
||||
: t('tabMenu.addToBookmarks'),
|
||||
'pi pi-bookmark' +
|
||||
(bookmarkStore.isBookmarked(workflow?.path ?? '') ? '-fill' : ''),
|
||||
async () => {
|
||||
if (workflow?.path) {
|
||||
await bookmarkStore.toggleBookmarked(workflow.path)
|
||||
}
|
||||
},
|
||||
isRoot,
|
||||
workflow?.isTemporary ?? false
|
||||
)
|
||||
|
||||
addItem(
|
||||
t('menuLabels.Export'),
|
||||
'pi pi-download',
|
||||
async () => {
|
||||
await ensureWorkflowActive(workflow)
|
||||
await commandStore.execute('Comfy.ExportWorkflow')
|
||||
},
|
||||
isRoot
|
||||
)
|
||||
|
||||
addItem(
|
||||
t('menuLabels.Export (API)'),
|
||||
'pi pi-download',
|
||||
async () => {
|
||||
await ensureWorkflowActive(workflow)
|
||||
await commandStore.execute('Comfy.ExportWorkflowAPI')
|
||||
},
|
||||
isRoot
|
||||
)
|
||||
|
||||
addItem(
|
||||
t('breadcrumbsMenu.clearWorkflow'),
|
||||
'pi pi-trash',
|
||||
async () => {
|
||||
await ensureWorkflowActive(workflow)
|
||||
await commandStore.execute('Comfy.ClearWorkflow')
|
||||
},
|
||||
true,
|
||||
false,
|
||||
true
|
||||
)
|
||||
|
||||
addItem(
|
||||
t('subgraphStore.publish'),
|
||||
'pi pi-upload',
|
||||
async () => {
|
||||
if (workflow) {
|
||||
await workflowService.saveWorkflowAs(workflow)
|
||||
}
|
||||
},
|
||||
isRoot && isBlueprint,
|
||||
false,
|
||||
true
|
||||
)
|
||||
|
||||
addItem(
|
||||
isBlueprint
|
||||
? t('breadcrumbsMenu.deleteBlueprint')
|
||||
: t('breadcrumbsMenu.deleteWorkflow'),
|
||||
'pi pi-times',
|
||||
async () => {
|
||||
if (workflow) {
|
||||
await workflowService.deleteWorkflow(workflow)
|
||||
}
|
||||
},
|
||||
isRoot && includeDelete,
|
||||
false,
|
||||
true
|
||||
)
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
return {
|
||||
menuItems
|
||||
}
|
||||
}
|
||||
@@ -2311,6 +2311,7 @@
|
||||
"filterBy": "Filter by",
|
||||
"findInLibrary": "Find it in the {type} section of the models library.",
|
||||
"finish": "Finish",
|
||||
"importAnother": "Import Another",
|
||||
"genericLinkPlaceholder": "Paste link here",
|
||||
"jobId": "Job ID",
|
||||
"loadingModels": "Loading {type}...",
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
:assets="filteredAssets"
|
||||
:loading="isLoading"
|
||||
@asset-select="handleAssetSelectAndEmit"
|
||||
@asset-deleted="refreshAssets"
|
||||
/>
|
||||
</template>
|
||||
</BaseModalLayout>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="!deletedLocal"
|
||||
data-component-id="AssetCard"
|
||||
:data-asset-id="asset.id"
|
||||
:aria-labelledby="titleId"
|
||||
@@ -139,8 +138,9 @@ const { asset, interactive } = defineProps<{
|
||||
interactive?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
const emit = defineEmits<{
|
||||
select: [asset: AssetDisplayItem]
|
||||
deleted: [asset: AssetDisplayItem]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -158,7 +158,6 @@ const descId = useId()
|
||||
|
||||
const isEditing = ref(false)
|
||||
const newNameRef = ref<string>()
|
||||
const deletedLocal = ref(false)
|
||||
|
||||
const displayName = computed(() => newNameRef.value ?? asset.name)
|
||||
|
||||
@@ -211,7 +210,7 @@ function confirmDeletion() {
|
||||
})
|
||||
// Give a second for the completion message
|
||||
await new Promise((resolve) => setTimeout(resolve, 1_000))
|
||||
deletedLocal.value = true
|
||||
emit('deleted', asset)
|
||||
} catch (err: unknown) {
|
||||
console.error(err)
|
||||
promptText.value = t('assetBrowser.deletion.failed', {
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
:asset="item"
|
||||
:interactive="true"
|
||||
@select="$emit('assetSelect', $event)"
|
||||
@deleted="$emit('assetDeleted', $event)"
|
||||
/>
|
||||
</template>
|
||||
</VirtualGrid>
|
||||
@@ -56,6 +57,7 @@ const { assets } = defineProps<{
|
||||
|
||||
defineEmits<{
|
||||
assetSelect: [asset: AssetDisplayItem]
|
||||
assetDeleted: [asset: AssetDisplayItem]
|
||||
}>()
|
||||
|
||||
const assetsWithKey = computed(() =>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{{ $t('assetBrowser.modelAssociatedWithLink') }}
|
||||
</p>
|
||||
<div
|
||||
class="flex items-center gap-3 rounded-lg bg-secondary-background p-3"
|
||||
class="flex items-center gap-3 rounded-lg bg-secondary-background px-4 py-2"
|
||||
>
|
||||
<img
|
||||
v-if="previewImage"
|
||||
@@ -21,9 +21,15 @@
|
||||
|
||||
<!-- Model Type Selection -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="">
|
||||
{{ $t('assetBrowser.modelTypeSelectorLabel') }}
|
||||
</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<label>
|
||||
{{ $t('assetBrowser.modelTypeSelectorLabel') }}
|
||||
</label>
|
||||
<i class="icon-[lucide--circle-question-mark] text-muted-foreground" />
|
||||
<span class="text-muted-foreground">
|
||||
{{ $t('assetBrowser.notSureLeaveAsIs') }}
|
||||
</span>
|
||||
</div>
|
||||
<SingleSelect
|
||||
v-model="modelValue"
|
||||
:label="
|
||||
@@ -35,10 +41,6 @@
|
||||
:disabled="isLoading"
|
||||
data-attr="upload-model-step2-type-selector"
|
||||
/>
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="icon-[lucide--circle-question-mark]" />
|
||||
<span>{{ $t('assetBrowser.notSureLeaveAsIs') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
@fetch-metadata="handleFetchMetadata"
|
||||
@upload="handleUploadModel"
|
||||
@close="handleClose"
|
||||
@import-another="resetWizard"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -85,7 +86,8 @@ const {
|
||||
canUploadModel,
|
||||
fetchMetadata,
|
||||
uploadModel,
|
||||
goToPreviousStep
|
||||
goToPreviousStep,
|
||||
resetWizard
|
||||
} = useUploadModelWizard(modelTypes)
|
||||
|
||||
async function handleFetchMetadata() {
|
||||
|
||||
@@ -80,21 +80,33 @@
|
||||
<i v-if="isUploading" class="icon-[lucide--loader-circle] animate-spin" />
|
||||
<span>{{ $t('assetBrowser.upload') }}</span>
|
||||
</Button>
|
||||
<Button
|
||||
<template
|
||||
v-else-if="
|
||||
currentStep === 3 &&
|
||||
(uploadStatus === 'success' || uploadStatus === 'processing')
|
||||
"
|
||||
variant="secondary"
|
||||
data-attr="upload-model-step3-finish-button"
|
||||
@click="emit('close')"
|
||||
>
|
||||
{{
|
||||
uploadStatus === 'processing'
|
||||
? $t('g.close')
|
||||
: $t('assetBrowser.finish')
|
||||
}}
|
||||
</Button>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="lg"
|
||||
data-attr="upload-model-step3-import-another-button"
|
||||
@click="emit('importAnother')"
|
||||
>
|
||||
{{ $t('assetBrowser.importAnother') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
data-attr="upload-model-step3-finish-button"
|
||||
@click="emit('close')"
|
||||
>
|
||||
{{
|
||||
uploadStatus === 'processing'
|
||||
? $t('g.close')
|
||||
: $t('assetBrowser.finish')
|
||||
}}
|
||||
</Button>
|
||||
</template>
|
||||
<VideoHelpDialog
|
||||
v-model="showCivitaiHelp"
|
||||
video-url="https://media.comfy.org/compressed_768/civitai_howto.webm"
|
||||
@@ -134,5 +146,6 @@ const emit = defineEmits<{
|
||||
(e: 'fetchMetadata'): void
|
||||
(e: 'upload'): void
|
||||
(e: 'close'): void
|
||||
(e: 'importAnother'): void
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
:href="civitaiUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-muted underline"
|
||||
class="text-muted-foreground underline"
|
||||
>
|
||||
{{ $t('assetBrowser.providerCivitai') }}</a
|
||||
><span>,</span>
|
||||
@@ -35,7 +35,7 @@
|
||||
:href="huggingFaceUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-muted underline"
|
||||
class="text-muted-foreground underline"
|
||||
>
|
||||
{{ $t('assetBrowser.providerHuggingFace') }}
|
||||
</a>
|
||||
@@ -58,7 +58,7 @@
|
||||
class="icon-[lucide--circle-check-big] absolute top-1/2 right-3 size-5 -translate-y-1/2 text-green-500"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="error" class="text-xs text-error">
|
||||
<p v-if="error" class="text-sm text-error">
|
||||
{{ error }}
|
||||
</p>
|
||||
<p v-else-if="!flags.asyncModelUploadEnabled" class="text-foreground">
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<a
|
||||
href="https://civitai.com/models"
|
||||
target="_blank"
|
||||
class="text-muted-foreground"
|
||||
class="text-muted-foreground underline"
|
||||
>
|
||||
{{ $t('assetBrowser.uploadModelDescription2Link') }}
|
||||
</a>
|
||||
@@ -51,14 +51,14 @@
|
||||
class="icon-[lucide--circle-check-big] absolute top-1/2 right-3 size-5 -translate-y-1/2 text-green-500"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="error" class="text-xs text-error">
|
||||
<p v-if="error" class="text-sm text-error">
|
||||
{{ error }}
|
||||
</p>
|
||||
<i18n-t
|
||||
v-else
|
||||
keypath="assetBrowser.civitaiLinkExample"
|
||||
tag="p"
|
||||
class="text-xs"
|
||||
class="text-sm"
|
||||
>
|
||||
<template #example>
|
||||
<strong>{{ $t('assetBrowser.civitaiLinkExampleStrong') }}</strong>
|
||||
@@ -67,7 +67,7 @@
|
||||
<a
|
||||
href="https://civitai.com/models/10706/luisap-z-image-and-qwen-pixel-art-refiner?modelVersionId=2225295"
|
||||
target="_blank"
|
||||
class="text-muted-foreground"
|
||||
class="text-muted-foreground underline"
|
||||
>
|
||||
{{ $t('assetBrowser.civitaiLinkExampleUrl') }}
|
||||
</a>
|
||||
|
||||
@@ -245,7 +245,8 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
|
||||
if (selectedModelType.value) {
|
||||
assetDownloadStore.trackDownload(
|
||||
result.task.task_id,
|
||||
selectedModelType.value
|
||||
selectedModelType.value,
|
||||
filename
|
||||
)
|
||||
}
|
||||
uploadStatus.value = 'processing'
|
||||
@@ -284,6 +285,20 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
|
||||
}
|
||||
}
|
||||
|
||||
function resetWizard() {
|
||||
currentStep.value = 1
|
||||
isFetchingMetadata.value = false
|
||||
isUploading.value = false
|
||||
uploadStatus.value = undefined
|
||||
uploadError.value = ''
|
||||
wizardData.value = {
|
||||
url: '',
|
||||
name: '',
|
||||
tags: []
|
||||
}
|
||||
selectedModelType.value = undefined
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
currentStep,
|
||||
@@ -302,6 +317,7 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
|
||||
// Actions
|
||||
fetchMetadata,
|
||||
uploadModel,
|
||||
goToPreviousStep
|
||||
goToPreviousStep,
|
||||
resetWizard
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,15 +117,29 @@ describe('useAssetDownloadStore', () => {
|
||||
it('associates task with model type for completion tracking', () => {
|
||||
const store = useAssetDownloadStore()
|
||||
|
||||
store.trackDownload('task-123', 'checkpoints')
|
||||
store.trackDownload('task-123', 'checkpoints', 'model.safetensors')
|
||||
dispatch(createDownloadMessage({ status: 'completed', progress: 100 }))
|
||||
|
||||
expect(store.completedDownloads).toHaveLength(1)
|
||||
expect(store.completedDownloads[0]).toMatchObject({
|
||||
expect(store.lastCompletedDownload).toMatchObject({
|
||||
taskId: 'task-123',
|
||||
modelType: 'checkpoints'
|
||||
})
|
||||
})
|
||||
|
||||
it('handles out-of-order messages where completed arrives before progress', () => {
|
||||
const store = useAssetDownloadStore()
|
||||
|
||||
store.trackDownload('task-123', 'checkpoints', 'model.safetensors')
|
||||
|
||||
dispatch(createDownloadMessage({ status: 'completed', progress: 100 }))
|
||||
|
||||
dispatch(createDownloadMessage({ status: 'running', progress: 50 }))
|
||||
|
||||
expect(store.activeDownloads).toHaveLength(0)
|
||||
expect(store.finishedDownloads).toHaveLength(1)
|
||||
expect(store.finishedDownloads[0].status).toBe('completed')
|
||||
expect(store.lastCompletedDownload?.modelType).toBe('checkpoints')
|
||||
})
|
||||
})
|
||||
|
||||
describe('stale download polling', () => {
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface AssetDownload {
|
||||
lastUpdate: number
|
||||
assetId?: string
|
||||
error?: string
|
||||
modelType?: string
|
||||
}
|
||||
|
||||
interface CompletedDownload {
|
||||
@@ -23,15 +24,29 @@ interface CompletedDownload {
|
||||
modelType: string
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
const MAX_COMPLETED_DOWNLOADS = 10
|
||||
const STALE_THRESHOLD_MS = 10_000
|
||||
const POLL_INTERVAL_MS = 10_000
|
||||
|
||||
function generateDownloadTrackingPlaceholder(
|
||||
taskId: string,
|
||||
modelType: string,
|
||||
assetName: string
|
||||
): AssetDownload {
|
||||
return {
|
||||
taskId,
|
||||
modelType,
|
||||
assetName,
|
||||
bytesTotal: 0,
|
||||
bytesDownloaded: 0,
|
||||
progress: 0,
|
||||
status: 'created',
|
||||
lastUpdate: Date.now()
|
||||
}
|
||||
}
|
||||
|
||||
export const useAssetDownloadStore = defineStore('assetDownload', () => {
|
||||
const downloads = ref<Map<string, AssetDownload>>(new Map())
|
||||
const pendingModelTypes = new Map<string, string>()
|
||||
const completedDownloads = ref<CompletedDownload[]>([])
|
||||
const lastCompletedDownload = ref<CompletedDownload | null>(null)
|
||||
|
||||
const downloadList = computed(() => Array.from(downloads.value.values()))
|
||||
const activeDownloads = computed(() =>
|
||||
@@ -47,8 +62,13 @@ export const useAssetDownloadStore = defineStore('assetDownload', () => {
|
||||
const hasActiveDownloads = computed(() => activeDownloads.value.length > 0)
|
||||
const hasDownloads = computed(() => downloads.value.size > 0)
|
||||
|
||||
function trackDownload(taskId: string, modelType: string) {
|
||||
pendingModelTypes.set(taskId, modelType)
|
||||
function trackDownload(taskId: string, modelType: string, assetName: string) {
|
||||
if (downloads.value.has(taskId)) return
|
||||
|
||||
downloads.value.set(
|
||||
taskId,
|
||||
generateDownloadTrackingPlaceholder(taskId, modelType, assetName)
|
||||
)
|
||||
}
|
||||
|
||||
function handleAssetDownload(e: CustomEvent<AssetDownloadWsMessage>) {
|
||||
@@ -69,24 +89,18 @@ export const useAssetDownloadStore = defineStore('assetDownload', () => {
|
||||
progress: data.progress,
|
||||
status: data.status,
|
||||
error: data.error,
|
||||
lastUpdate: Date.now()
|
||||
lastUpdate: Date.now(),
|
||||
modelType: existing?.modelType
|
||||
}
|
||||
|
||||
downloads.value.set(data.task_id, download)
|
||||
|
||||
if (data.status === 'completed') {
|
||||
const modelType = pendingModelTypes.get(data.task_id)
|
||||
if (modelType) {
|
||||
const updated = [
|
||||
...completedDownloads.value,
|
||||
{ taskId: data.task_id, modelType, timestamp: Date.now() }
|
||||
]
|
||||
if (updated.length > MAX_COMPLETED_DOWNLOADS) updated.shift()
|
||||
completedDownloads.value = updated
|
||||
pendingModelTypes.delete(data.task_id)
|
||||
if (data.status === 'completed' && download.modelType) {
|
||||
lastCompletedDownload.value = {
|
||||
taskId: data.task_id,
|
||||
modelType: download.modelType,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
} else if (data.status === 'failed') {
|
||||
pendingModelTypes.delete(data.task_id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,7 +171,7 @@ export const useAssetDownloadStore = defineStore('assetDownload', () => {
|
||||
hasActiveDownloads,
|
||||
hasDownloads,
|
||||
downloadList,
|
||||
completedDownloads,
|
||||
lastCompletedDownload,
|
||||
trackDownload,
|
||||
clearFinishedDownloads
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useAsyncState } from '@vueuse/core'
|
||||
import { useAsyncState, whenever } from '@vueuse/core'
|
||||
import { isEqual } from 'es-toolkit'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, shallowReactive, ref, watch } from 'vue'
|
||||
import { computed, shallowReactive, ref } from 'vue'
|
||||
import {
|
||||
mapInputFileToAssetItem,
|
||||
mapTaskOutputToAssetItem
|
||||
@@ -376,24 +376,32 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
} = getModelState()
|
||||
|
||||
// Watch for completed downloads and refresh model caches
|
||||
watch(
|
||||
() => assetDownloadStore.completedDownloads.at(-1),
|
||||
whenever(
|
||||
() => assetDownloadStore.lastCompletedDownload,
|
||||
async (latestDownload) => {
|
||||
if (!latestDownload) return
|
||||
|
||||
const { modelType } = latestDownload
|
||||
|
||||
const providers = modelToNodeStore
|
||||
.getAllNodeProviders(modelType)
|
||||
.filter((provider) => provider.nodeDef?.name)
|
||||
const results = await Promise.allSettled(
|
||||
providers.map((provider) =>
|
||||
updateModelsForNodeType(provider.nodeDef.name).then(
|
||||
() => provider.nodeDef.name
|
||||
)
|
||||
|
||||
const nodeTypeUpdates = providers.map((provider) =>
|
||||
updateModelsForNodeType(provider.nodeDef.name).then(
|
||||
() => provider.nodeDef.name
|
||||
)
|
||||
)
|
||||
|
||||
// Also update by tag in case modal was opened with assetType
|
||||
const tagUpdates = [
|
||||
updateModelsForTag(modelType),
|
||||
updateModelsForTag('models')
|
||||
]
|
||||
|
||||
const results = await Promise.allSettled([
|
||||
...nodeTypeUpdates,
|
||||
...tagUpdates
|
||||
])
|
||||
|
||||
for (const result of results) {
|
||||
if (result.status === 'rejected') {
|
||||
console.error(
|
||||
|
||||