Subgraph/workflow breadcrumbs menu updates (#7852)

## Summary
For users who don't use subgraphs, the workflow name in the top left can
be unnecessarily obstructive so this updated collapses it to a simple
icon until a subgraph is entered.

## Changes

- Add menu button to WorkflowTab for quick workflow actions
- Add menu and back button to SubgraphBreadcrumb
- Extract shared menu items to useBreadcrumbMenu composable
- Add Comfy.RenameWorkflow command for renaming persisted workflows
- Menu always shows root workflow menu, even when in subgraph

## Screenshots (if applicable)

<img width="399" height="396" alt="image"
src="https://github.com/user-attachments/assets/701ab60e-790f-4d1e-a817-dc42b2d98712"
/>
<img width="569" height="381" alt="image"
src="https://github.com/user-attachments/assets/fcea3ab0-8388-4c72-a649-1428c1defd6a"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7852-Subgraph-workflow-breadcrumbs-menu-updates-2df6d73d3650815b8490ca0a9a92d540)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
pythongosssss
2026-01-12 23:08:28 +00:00
committed by GitHub
parent 965ab674d5
commit dfb78b2e87
33 changed files with 479 additions and 146 deletions

View File

@@ -133,8 +133,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()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 51 KiB

View File

@@ -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,

View File

@@ -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>

View 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>

View File

@@ -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 (

View File

@@ -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) => {

View File

@@ -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',

View 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
}
}