App mode - Unify menus - 2 (#9023)
## Summary Updates subgraph breadcrumbs menu, workflow tabs context menu & linear mode menu to use a single implementation. Adds new menu items for enter/exit app mode Hides menu when in builder mode ## Changes - **What**: Changes the components to use either a reka-ui context menu or dropdown, with a standard inner list - **Breaking**: Remove existing linear toggle from sidebar as it is now in the menu ## Screenshots (if applicable) It looks basically identical other than the icon changes based on mode: In Graph Mode: <img width="261" height="497" alt="image" src="https://github.com/user-attachments/assets/eb9968a2-b528-4e21-9e14-ab4a67e717ae" /> In App Mode: <img width="254" height="499" alt="image" src="https://github.com/user-attachments/assets/54a89fab-e7b2-4cb0-bcb7-43d6d076ac83" /> Right click tab: <img width="321" height="564" alt="image" src="https://github.com/user-attachments/assets/c12c7d64-2dba-45bb-be76-2615f3e38cc6" /> ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-9023-App-mode-Unify-menus-2-30d6d73d36508162bfc0e308d5f705de) by [Unito](https://www.unito.io) --------- Co-authored-by: github-actions <github-actions@github.com>
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 135 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 137 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 137 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 80 KiB |
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
class="subgraph-breadcrumb flex w-auto drop-shadow-[var(--interface-panel-drop-shadow)]"
|
||||
class="subgraph-breadcrumb flex w-auto drop-shadow-[var(--interface-panel-drop-shadow)] items-center"
|
||||
:class="{
|
||||
'subgraph-breadcrumb-collapse': collapseTabs,
|
||||
'subgraph-breadcrumb-overflow': overflowingTabs
|
||||
@@ -13,17 +13,10 @@
|
||||
'--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"
|
||||
/>
|
||||
<WorkflowActionsDropdown source="breadcrumb_subgraph_menu_selected" />
|
||||
<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"
|
||||
class="back-button pointer-events-auto h-8 w-8 shrink-0 border border-transparent bg-transparent p-0 ml-1.5 transition-all hover:rounded-lg hover:border-interface-stroke hover:bg-comfy-menu-bg"
|
||||
text
|
||||
severity="secondary"
|
||||
size="small"
|
||||
@@ -41,7 +34,6 @@
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<SubgraphBreadcrumbItem
|
||||
:ref="(el) => setItemRef(item, el)"
|
||||
:item="item"
|
||||
:is-active="item.key === activeItemKey"
|
||||
/>
|
||||
@@ -60,6 +52,7 @@ import type { MenuItem } from 'primevue/menuitem'
|
||||
import { computed, onUpdated, ref, watch } from 'vue'
|
||||
|
||||
import SubgraphBreadcrumbItem from '@/components/breadcrumb/SubgraphBreadcrumbItem.vue'
|
||||
import WorkflowActionsDropdown from '@/components/common/WorkflowActionsDropdown.vue'
|
||||
import { useOverflowObserver } from '@/composables/element/useOverflowObserver'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
@@ -77,12 +70,6 @@ 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)
|
||||
@@ -136,13 +123,6 @@ const items = computed(() => {
|
||||
|
||||
const activeItemKey = computed(() => items.value.at(-1)?.key)
|
||||
|
||||
const handleMenuClick = (event: MouseEvent) => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'breadcrumb_subgraph_menu_selected'
|
||||
})
|
||||
rootItemRef.value?.toggleMenu(event)
|
||||
}
|
||||
|
||||
const handleBackClick = () => {
|
||||
void useCommandStore().execute('Comfy.Graph.ExitSubgraph')
|
||||
}
|
||||
|
||||
@@ -185,14 +185,6 @@ const inputBlur = async (doRename: boolean) => {
|
||||
|
||||
isEditing.value = false
|
||||
}
|
||||
|
||||
const toggleMenu = (event: MouseEvent) => {
|
||||
menu.value?.toggle(event)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
toggleMenu
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
77
src/components/common/WorkflowActionsDropdown.vue
Normal file
@@ -0,0 +1,77 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRoot,
|
||||
DropdownMenuTrigger
|
||||
} from 'reka-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import WorkflowActionsList from '@/components/common/WorkflowActionsList.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useWorkflowActionsMenu } from '@/composables/useWorkflowActionsMenu'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
const { source, align = 'start' } = defineProps<{
|
||||
source: string
|
||||
align?: 'start' | 'center' | 'end'
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
const { menuItems } = useWorkflowActionsMenu(
|
||||
() => useCommandStore().execute('Comfy.RenameWorkflow'),
|
||||
{ isRoot: true }
|
||||
)
|
||||
|
||||
function handleOpen(open: boolean) {
|
||||
if (open) {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: source
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuRoot @update:open="handleOpen">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<slot name="button">
|
||||
<Button
|
||||
v-tooltip="{
|
||||
value: t('breadcrumbsMenu.workflowActions'),
|
||||
showDelay: 300,
|
||||
hideDelay: 300
|
||||
}"
|
||||
variant="secondary"
|
||||
size="unset"
|
||||
:aria-label="t('breadcrumbsMenu.workflowActions')"
|
||||
class="h-10 rounded-lg pl-3 pr-2 pointer-events-auto gap-1 data-[state=open]:bg-secondary-background-hover data-[state=open]:shadow-interface"
|
||||
>
|
||||
<i
|
||||
class="size-4"
|
||||
:class="
|
||||
canvasStore.linearMode
|
||||
? 'icon-[lucide--panels-top-left]'
|
||||
: 'icon-[comfy--workflow]'
|
||||
"
|
||||
/>
|
||||
<i class="icon-[lucide--chevron-down] size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</slot>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent
|
||||
:align
|
||||
:side-offset="5"
|
||||
:collision-padding="10"
|
||||
class="z-1000 rounded-lg px-2 py-3 min-w-56 bg-base-background shadow-interface border border-border-subtle"
|
||||
>
|
||||
<WorkflowActionsList :items="menuItems" />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuRoot>
|
||||
</template>
|
||||
81
src/components/common/WorkflowActionsList.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { shallowMount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import WorkflowActionsList from '@/components/common/WorkflowActionsList.vue'
|
||||
import type {
|
||||
WorkflowMenuAction,
|
||||
WorkflowMenuItem
|
||||
} from '@/types/workflowMenuItem'
|
||||
|
||||
function createWrapper(items: WorkflowMenuItem[]) {
|
||||
return shallowMount(WorkflowActionsList, {
|
||||
props: { items },
|
||||
global: { renderStubDefaultSlot: true }
|
||||
})
|
||||
}
|
||||
|
||||
describe('WorkflowActionsList', () => {
|
||||
it('renders action items with label and icon', () => {
|
||||
const items: WorkflowMenuItem[] = [
|
||||
{ label: 'Save', icon: 'pi pi-save', command: vi.fn() }
|
||||
]
|
||||
|
||||
const wrapper = createWrapper(items)
|
||||
|
||||
expect(wrapper.text()).toContain('Save')
|
||||
expect(wrapper.find('.pi-save').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders separator items', () => {
|
||||
const items: WorkflowMenuItem[] = [
|
||||
{ label: 'Before', icon: 'pi pi-a', command: vi.fn() },
|
||||
{ separator: true },
|
||||
{ label: 'After', icon: 'pi pi-b', command: vi.fn() }
|
||||
]
|
||||
|
||||
const wrapper = createWrapper(items)
|
||||
const html = wrapper.html()
|
||||
|
||||
expect(html).toContain('dropdown-menu-separator-stub')
|
||||
expect(wrapper.text()).toContain('Before')
|
||||
expect(wrapper.text()).toContain('After')
|
||||
})
|
||||
|
||||
it('dispatches command on select', async () => {
|
||||
const command = vi.fn()
|
||||
const items: WorkflowMenuItem[] = [
|
||||
{ label: 'Action', icon: 'pi pi-play', command }
|
||||
]
|
||||
|
||||
const wrapper = createWrapper(items)
|
||||
const item = wrapper.findComponent({ name: 'DropdownMenuItem' })
|
||||
await item.vm.$emit('select')
|
||||
|
||||
expect(command).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('renders badge when present', () => {
|
||||
const items: WorkflowMenuItem[] = [
|
||||
{
|
||||
label: 'New Feature',
|
||||
icon: 'pi pi-star',
|
||||
command: vi.fn(),
|
||||
badge: 'NEW'
|
||||
}
|
||||
]
|
||||
|
||||
const wrapper = createWrapper(items)
|
||||
|
||||
expect(wrapper.text()).toContain('NEW')
|
||||
})
|
||||
|
||||
it('does not render badge when absent', () => {
|
||||
const items: WorkflowMenuAction[] = [
|
||||
{ label: 'Plain', icon: 'pi pi-check', command: vi.fn() }
|
||||
]
|
||||
|
||||
const wrapper = createWrapper(items)
|
||||
|
||||
expect(wrapper.text()).not.toContain('NEW')
|
||||
})
|
||||
})
|
||||
53
src/components/common/WorkflowActionsList.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import { DropdownMenuItem, DropdownMenuSeparator } from 'reka-ui'
|
||||
import type { Component } from 'vue'
|
||||
|
||||
import OverlayIcon from '@/components/common/OverlayIcon.vue'
|
||||
import type { WorkflowMenuItem } from '@/types/workflowMenuItem'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const {
|
||||
items,
|
||||
itemComponent = DropdownMenuItem,
|
||||
separatorComponent = DropdownMenuSeparator
|
||||
} = defineProps<{
|
||||
items: WorkflowMenuItem[]
|
||||
itemComponent?: Component
|
||||
separatorComponent?: Component
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-for="(item, index) in items" :key="index">
|
||||
<component
|
||||
:is="separatorComponent"
|
||||
v-if="item.separator"
|
||||
class="border-b w-full border-border-subtle my-1"
|
||||
/>
|
||||
<component
|
||||
:is="itemComponent"
|
||||
v-else
|
||||
:disabled="item.disabled"
|
||||
:class="
|
||||
cn(
|
||||
'flex min-h-6 p-2 items-center gap-2 self-stretch rounded-sm outline-none',
|
||||
!item.disabled && item.command && 'cursor-pointer',
|
||||
'data-[highlighted]:bg-secondary-background-hover',
|
||||
!item.disabled && 'hover:bg-secondary-background-hover',
|
||||
'data-[disabled]:opacity-50 data-[disabled]:cursor-default'
|
||||
)
|
||||
"
|
||||
@select="() => item.command?.()"
|
||||
>
|
||||
<OverlayIcon v-if="item.overlayIcon" v-bind="item.overlayIcon" />
|
||||
<i v-else-if="item.icon" :class="item.icon" />
|
||||
<span class="flex-1">{{ item.label }}</span>
|
||||
<span
|
||||
v-if="item.badge"
|
||||
class="rounded-full uppercase ml-3 flex items-center gap-1 bg-[var(--primary-background)] px-1.5 py-0.5 text-xxs text-base-foreground"
|
||||
>
|
||||
{{ item.badge }}
|
||||
</span>
|
||||
</component>
|
||||
</template>
|
||||
</template>
|
||||
@@ -45,9 +45,6 @@
|
||||
<SidebarBottomPanelToggleButton v-if="!isCloud" :is-small="isSmall" />
|
||||
<SidebarShortcutsToggleButton :is-small="isSmall" />
|
||||
<SidebarSettingsButton :is-small="isSmall" />
|
||||
<ModeToggle
|
||||
v-if="menuItemStore.hasSeenLinear || flags.linearToggleEnabled"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<HelpCenterPopups :is-small="isSmall" />
|
||||
@@ -62,18 +59,15 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import HelpCenterPopups from '@/components/helpcenter/HelpCenterPopups.vue'
|
||||
import ComfyMenuButton from '@/components/sidebar/ComfyMenuButton.vue'
|
||||
import ModeToggle from '@/components/sidebar/ModeToggle.vue'
|
||||
import SidebarBottomPanelToggleButton from '@/components/sidebar/SidebarBottomPanelToggleButton.vue'
|
||||
import SidebarSettingsButton from '@/components/sidebar/SidebarSettingsButton.vue'
|
||||
import SidebarShortcutsToggleButton from '@/components/sidebar/SidebarShortcutsToggleButton.vue'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
|
||||
import { useMenuItemStore } from '@/stores/menuItemStore'
|
||||
import { useUserStore } from '@/stores/userStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import type { SidebarTabExtension } from '@/types/extensionTypes'
|
||||
@@ -90,11 +84,9 @@ const settingStore = useSettingStore()
|
||||
const userStore = useUserStore()
|
||||
const commandStore = useCommandStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const menuItemStore = useMenuItemStore()
|
||||
const sideToolbarRef = ref<HTMLElement>()
|
||||
const topToolbarRef = ref<HTMLElement>()
|
||||
const bottomToolbarRef = ref<HTMLElement>()
|
||||
const { flags } = useFeatureFlags()
|
||||
|
||||
const isSmall = computed(
|
||||
() => settingStore.get('Comfy.Sidebar.Size') === 'small'
|
||||
|
||||
@@ -1,36 +1,53 @@
|
||||
<template>
|
||||
<div
|
||||
ref="workflowTabRef"
|
||||
class="workflow-tab group flex gap-2 p-2"
|
||||
v-bind="$attrs"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
@click="handleClick"
|
||||
>
|
||||
<i
|
||||
v-if="workflowOption.workflow.activeState?.extra?.linearMode"
|
||||
class="icon-[lucide--panels-top-left] bg-primary-background"
|
||||
/>
|
||||
<span class="workflow-label inline-block max-w-[150px] truncate text-sm">
|
||||
{{ workflowOption.workflow.filename }}
|
||||
</span>
|
||||
<div class="relative">
|
||||
<span
|
||||
v-if="shouldShowStatusIndicator"
|
||||
class="absolute top-1/2 left-1/2 z-10 w-4 -translate-1/2 bg-(--comfy-menu-bg) text-2xl font-bold group-hover:hidden"
|
||||
>•</span
|
||||
<ContextMenuRoot>
|
||||
<ContextMenuTrigger as-child>
|
||||
<div
|
||||
ref="workflowTabRef"
|
||||
class="workflow-tab group flex gap-2 p-2"
|
||||
v-bind="$attrs"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
@click="handleClick"
|
||||
>
|
||||
<Button
|
||||
class="close-button invisible w-auto p-0"
|
||||
variant="muted-textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="t('g.close')"
|
||||
@click.stop="onCloseWorkflow(workflowOption)"
|
||||
<i
|
||||
v-if="workflowOption.workflow.activeState?.extra?.linearMode"
|
||||
class="icon-[lucide--panels-top-left] bg-primary-background"
|
||||
/>
|
||||
<span
|
||||
class="workflow-label inline-block max-w-[150px] truncate text-sm"
|
||||
>
|
||||
{{ workflowOption.workflow.filename }}
|
||||
</span>
|
||||
<div class="relative">
|
||||
<span
|
||||
v-if="shouldShowStatusIndicator"
|
||||
class="absolute top-1/2 left-1/2 z-10 w-4 -translate-1/2 bg-(--comfy-menu-bg) text-2xl font-bold group-hover:hidden"
|
||||
>•</span
|
||||
>
|
||||
<Button
|
||||
class="close-button invisible w-auto p-0"
|
||||
variant="muted-textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="t('g.close')"
|
||||
@click.stop="onCloseWorkflow(workflowOption)"
|
||||
>
|
||||
<i class="pi pi-times" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuPortal>
|
||||
<ContextMenuContent
|
||||
class="z-1000 rounded-lg px-2 py-3 min-w-56 bg-base-background shadow-interface border border-border-subtle"
|
||||
>
|
||||
<i class="pi pi-times" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<WorkflowActionsList
|
||||
:items="contextMenuItems"
|
||||
:item-component="ContextMenuItem"
|
||||
:separator-component="ContextMenuSeparator"
|
||||
/>
|
||||
</ContextMenuContent>
|
||||
</ContextMenuPortal>
|
||||
</ContextMenuRoot>
|
||||
|
||||
<WorkflowTabPopover
|
||||
ref="popoverRef"
|
||||
@@ -41,20 +58,32 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuPortal,
|
||||
ContextMenuRoot,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger
|
||||
} from 'reka-ui'
|
||||
import { computed, onUnmounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import WorkflowActionsList from '@/components/common/WorkflowActionsList.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import {
|
||||
usePragmaticDraggable,
|
||||
usePragmaticDroppable
|
||||
} from '@/composables/usePragmaticDragAndDrop'
|
||||
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 { 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 type { WorkflowMenuItem } from '@/types/workflowMenuItem'
|
||||
|
||||
import WorkflowTabPopover from './WorkflowTabPopover.vue'
|
||||
|
||||
@@ -65,6 +94,8 @@ interface WorkflowOption {
|
||||
|
||||
const props = defineProps<{
|
||||
workflowOption: WorkflowOption
|
||||
isFirst: boolean
|
||||
isLast: boolean
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -148,6 +179,67 @@ const closeWorkflows = async (options: WorkflowOption[]) => {
|
||||
const onCloseWorkflow = async (option: WorkflowOption) => {
|
||||
await closeWorkflows([option])
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
closeToLeft: []
|
||||
closeToRight: []
|
||||
closeOthers: []
|
||||
}>()
|
||||
|
||||
const commandStore = useCommandStore()
|
||||
const workflow = computed(() => props.workflowOption.workflow)
|
||||
|
||||
const { menuItems: baseMenuItems } = useWorkflowActionsMenu(
|
||||
() => commandStore.execute('Comfy.RenameWorkflow'),
|
||||
{ includeDelete: false, workflow }
|
||||
)
|
||||
|
||||
const contextMenuItems = computed<WorkflowMenuItem[]>(() => [
|
||||
...baseMenuItems.value,
|
||||
{ separator: true },
|
||||
{
|
||||
label: t('tabMenu.closeTab'),
|
||||
icon: 'pi pi-times',
|
||||
command: () => onCloseWorkflow(props.workflowOption)
|
||||
},
|
||||
{
|
||||
label: t('tabMenu.closeTabsToLeft'),
|
||||
overlayIcon: {
|
||||
mainIcon: 'pi pi-times',
|
||||
subIcon: 'pi pi-arrow-left',
|
||||
positionX: 'right',
|
||||
positionY: 'bottom',
|
||||
subIconScale: 0.5
|
||||
},
|
||||
command: () => emit('closeToLeft'),
|
||||
disabled: props.isFirst
|
||||
},
|
||||
{
|
||||
label: t('tabMenu.closeTabsToRight'),
|
||||
overlayIcon: {
|
||||
mainIcon: 'pi pi-times',
|
||||
subIcon: 'pi pi-arrow-right',
|
||||
positionX: 'right',
|
||||
positionY: 'bottom',
|
||||
subIconScale: 0.5
|
||||
},
|
||||
command: () => emit('closeToRight'),
|
||||
disabled: props.isLast
|
||||
},
|
||||
{
|
||||
label: t('tabMenu.closeOtherTabs'),
|
||||
overlayIcon: {
|
||||
mainIcon: 'pi pi-times',
|
||||
subIcon: 'pi pi-arrows-h',
|
||||
positionX: 'right',
|
||||
positionY: 'bottom',
|
||||
subIconScale: 0.5
|
||||
},
|
||||
command: () => emit('closeOthers'),
|
||||
disabled: props.isFirst && props.isLast
|
||||
}
|
||||
])
|
||||
|
||||
const tabGetter = () => workflowTabRef.value as HTMLElement
|
||||
|
||||
usePragmaticDraggable(tabGetter, {
|
||||
|
||||
@@ -32,11 +32,20 @@
|
||||
data-key="value"
|
||||
@update:model-value="onWorkflowChange"
|
||||
>
|
||||
<template #option="{ option }">
|
||||
<template #option="{ option, index }">
|
||||
<WorkflowTab
|
||||
:workflow-option="option"
|
||||
@contextmenu="showContextMenu($event, option)"
|
||||
:is-first="index === 0"
|
||||
:is-last="index === options.length - 1"
|
||||
@click.middle="onCloseWorkflow(option)"
|
||||
@close-to-left="closeWorkflows(options.slice(0, index))"
|
||||
@close-to-right="closeWorkflows(options.slice(index + 1))"
|
||||
@close-others="
|
||||
closeWorkflows([
|
||||
...options.slice(index + 1),
|
||||
...options.slice(0, index)
|
||||
])
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
</SelectButton>
|
||||
@@ -58,7 +67,10 @@
|
||||
:active-workflow="workflowStore.activeWorkflow"
|
||||
/>
|
||||
<Button
|
||||
v-tooltip="{ value: $t('sideToolbar.newBlankWorkflow'), showDelay: 300 }"
|
||||
v-tooltip="{
|
||||
value: $t('sideToolbar.newBlankWorkflow'),
|
||||
showDelay: 300
|
||||
}"
|
||||
class="new-blank-workflow-button no-drag shrink-0 rounded-none h-full w-auto aspect-square"
|
||||
variant="muted-textonly"
|
||||
size="icon"
|
||||
@@ -80,27 +92,17 @@
|
||||
/>
|
||||
<LoginButton v-else-if="isDesktop" class="p-1" />
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useScroll } from '@vueuse/core'
|
||||
import ContextMenu from 'primevue/contextmenu'
|
||||
import ScrollPanel from 'primevue/scrollpanel'
|
||||
import SelectButton from 'primevue/selectbutton'
|
||||
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'
|
||||
@@ -108,7 +110,6 @@ 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'
|
||||
@@ -129,7 +130,6 @@ const props = defineProps<{
|
||||
class?: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const settingStore = useSettingStore()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
@@ -141,8 +141,6 @@ const isIntegratedTabBar = computed(
|
||||
() => settingStore.get('Comfy.UI.TabBarLayout') === 'Integrated'
|
||||
)
|
||||
|
||||
const rightClickedTab = ref<WorkflowOption | undefined>()
|
||||
const menu = ref()
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
const showOverflowArrows = ref(false)
|
||||
const leftArrowEnabled = ref(false)
|
||||
@@ -192,78 +190,6 @@ const onCloseWorkflow = async (option: WorkflowOption) => {
|
||||
await closeWorkflows([option])
|
||||
}
|
||||
|
||||
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
|
||||
if (!tab) return []
|
||||
const index = options.value.findIndex((v) => v.workflow === tab.workflow)
|
||||
|
||||
return [
|
||||
...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
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
// Horizontal scroll on wheel
|
||||
const handleWheel = (event: WheelEvent) => {
|
||||
const scrollElement = event.currentTarget as HTMLElement
|
||||
|
||||
346
src/composables/useWorkflowActionsMenu.test.ts
Normal file
@@ -0,0 +1,346 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useWorkflowActionsMenu } from '@/composables/useWorkflowActionsMenu'
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type { WorkflowMenuAction } from '@/types/workflowMenuItem'
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: vi.fn(() => ({
|
||||
t: (key: string) => key
|
||||
}))
|
||||
}))
|
||||
|
||||
const mockBookmarkStore = vi.hoisted(() => ({
|
||||
isBookmarked: vi.fn(() => false),
|
||||
toggleBookmarked: vi.fn()
|
||||
}))
|
||||
|
||||
const mockWorkflowStore = vi.hoisted(() => ({
|
||||
activeWorkflow: { path: 'test.json', isPersisted: true } as ComfyWorkflow
|
||||
}))
|
||||
|
||||
const mockWorkflowService = vi.hoisted(() => ({
|
||||
openWorkflow: vi.fn(),
|
||||
duplicateWorkflow: vi.fn(),
|
||||
saveWorkflowAs: vi.fn(),
|
||||
deleteWorkflow: vi.fn()
|
||||
}))
|
||||
|
||||
const mockCommandStore = vi.hoisted(() => ({
|
||||
execute: vi.fn()
|
||||
}))
|
||||
|
||||
const mockSubgraphStore = vi.hoisted(() => ({
|
||||
isSubgraphBlueprint: vi.fn(() => false)
|
||||
}))
|
||||
|
||||
const mockMenuItemStore = vi.hoisted(() => ({
|
||||
hasSeenLinear: false
|
||||
}))
|
||||
|
||||
const mockCanvasStore = vi.hoisted(() => ({
|
||||
linearMode: false
|
||||
}))
|
||||
|
||||
const mockFeatureFlags = vi.hoisted(() => ({
|
||||
flags: { linearToggleEnabled: false }
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: vi.fn(() => mockWorkflowStore),
|
||||
useWorkflowBookmarkStore: vi.fn(() => mockBookmarkStore)
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
|
||||
useWorkflowService: vi.fn(() => mockWorkflowService)
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: vi.fn(() => mockCommandStore)
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/subgraphStore', () => ({
|
||||
useSubgraphStore: vi.fn(() => mockSubgraphStore)
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/menuItemStore', () => ({
|
||||
useMenuItemStore: vi.fn(() => mockMenuItemStore)
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: vi.fn(() => mockCanvasStore)
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: vi.fn(() => mockFeatureFlags)
|
||||
}))
|
||||
|
||||
type MenuItems = ReturnType<typeof useWorkflowActionsMenu>['menuItems']['value']
|
||||
|
||||
function actionItems(items: MenuItems): WorkflowMenuAction[] {
|
||||
return items.filter((i): i is WorkflowMenuAction => !i.separator)
|
||||
}
|
||||
|
||||
function menuLabels(items: MenuItems) {
|
||||
return actionItems(items).map((i) => i.label)
|
||||
}
|
||||
|
||||
function findItem(items: MenuItems, label: string): WorkflowMenuAction {
|
||||
const item = actionItems(items).find((i) => i.label === label)
|
||||
if (!item) throw new Error(`Menu item "${label}" not found`)
|
||||
return item
|
||||
}
|
||||
|
||||
describe('useWorkflowActionsMenu', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
mockBookmarkStore.isBookmarked.mockReturnValue(false)
|
||||
mockSubgraphStore.isSubgraphBlueprint.mockReturnValue(false)
|
||||
mockMenuItemStore.hasSeenLinear = false
|
||||
mockCanvasStore.linearMode = false
|
||||
mockFeatureFlags.flags.linearToggleEnabled = false
|
||||
mockWorkflowStore.activeWorkflow = {
|
||||
path: 'test.json',
|
||||
isPersisted: true
|
||||
} as ComfyWorkflow
|
||||
})
|
||||
|
||||
it('shows root-level items by default', () => {
|
||||
const { menuItems } = useWorkflowActionsMenu(vi.fn(), { isRoot: true })
|
||||
const labels = menuLabels(menuItems.value)
|
||||
|
||||
expect(labels).toContain('g.rename')
|
||||
expect(labels).toContain('breadcrumbsMenu.duplicate')
|
||||
expect(labels).toContain('menuLabels.Save')
|
||||
expect(labels).toContain('menuLabels.Save As')
|
||||
expect(labels).toContain('menuLabels.Export')
|
||||
expect(labels).toContain('menuLabels.Export (API)')
|
||||
expect(labels).toContain('breadcrumbsMenu.clearWorkflow')
|
||||
expect(labels).toContain('breadcrumbsMenu.deleteWorkflow')
|
||||
})
|
||||
|
||||
it('hides root-only items when isRoot is false', () => {
|
||||
const { menuItems } = useWorkflowActionsMenu(vi.fn(), { isRoot: false })
|
||||
const labels = menuLabels(menuItems.value)
|
||||
|
||||
expect(labels).toContain('g.rename')
|
||||
expect(labels).toContain('breadcrumbsMenu.clearWorkflow')
|
||||
expect(labels).not.toContain('breadcrumbsMenu.duplicate')
|
||||
expect(labels).not.toContain('menuLabels.Save')
|
||||
expect(labels).not.toContain('menuLabels.Save As')
|
||||
})
|
||||
|
||||
it('hides delete item when includeDelete is false', () => {
|
||||
const { menuItems } = useWorkflowActionsMenu(vi.fn(), {
|
||||
isRoot: true,
|
||||
includeDelete: false
|
||||
})
|
||||
const labels = menuLabels(menuItems.value)
|
||||
|
||||
expect(labels).not.toContain('breadcrumbsMenu.deleteWorkflow')
|
||||
})
|
||||
|
||||
it('shows app mode items when linearToggleEnabled flag is set', () => {
|
||||
mockFeatureFlags.flags.linearToggleEnabled = true
|
||||
|
||||
const { menuItems } = useWorkflowActionsMenu(vi.fn(), { isRoot: true })
|
||||
const labels = menuLabels(menuItems.value)
|
||||
|
||||
expect(labels).toContain('breadcrumbsMenu.enterAppMode')
|
||||
})
|
||||
|
||||
it('shows app mode items when user has seen linear mode', () => {
|
||||
mockMenuItemStore.hasSeenLinear = true
|
||||
|
||||
const { menuItems } = useWorkflowActionsMenu(vi.fn(), { isRoot: true })
|
||||
const labels = menuLabels(menuItems.value)
|
||||
|
||||
expect(labels).toContain('breadcrumbsMenu.enterAppMode')
|
||||
})
|
||||
|
||||
it('hides app mode items when conditions not met', () => {
|
||||
mockMenuItemStore.hasSeenLinear = false
|
||||
mockFeatureFlags.flags.linearToggleEnabled = false
|
||||
|
||||
const { menuItems } = useWorkflowActionsMenu(vi.fn(), { isRoot: true })
|
||||
const labels = menuLabels(menuItems.value)
|
||||
|
||||
expect(labels).not.toContain('breadcrumbsMenu.enterAppMode')
|
||||
})
|
||||
|
||||
it('hides app mode items when not root', () => {
|
||||
mockFeatureFlags.flags.linearToggleEnabled = true
|
||||
|
||||
const { menuItems } = useWorkflowActionsMenu(vi.fn(), { isRoot: false })
|
||||
const labels = menuLabels(menuItems.value)
|
||||
|
||||
expect(labels).not.toContain('breadcrumbsMenu.enterAppMode')
|
||||
})
|
||||
|
||||
it('shows "go to workflow mode" when in linear mode', () => {
|
||||
mockFeatureFlags.flags.linearToggleEnabled = true
|
||||
mockCanvasStore.linearMode = true
|
||||
|
||||
const { menuItems } = useWorkflowActionsMenu(vi.fn(), { isRoot: true })
|
||||
const labels = menuLabels(menuItems.value)
|
||||
|
||||
expect(labels).toContain('breadcrumbsMenu.exitAppMode')
|
||||
expect(labels).not.toContain('breadcrumbsMenu.enterAppMode')
|
||||
})
|
||||
|
||||
it('shows bookmark label based on bookmark state', () => {
|
||||
mockBookmarkStore.isBookmarked.mockReturnValue(true)
|
||||
|
||||
const { menuItems } = useWorkflowActionsMenu(vi.fn(), { isRoot: true })
|
||||
const labels = menuLabels(menuItems.value)
|
||||
|
||||
expect(labels).toContain('tabMenu.removeFromBookmarks')
|
||||
expect(labels).not.toContain('tabMenu.addToBookmarks')
|
||||
})
|
||||
|
||||
it('adds badge to app mode items', () => {
|
||||
mockFeatureFlags.flags.linearToggleEnabled = true
|
||||
|
||||
const { menuItems } = useWorkflowActionsMenu(vi.fn(), { isRoot: true })
|
||||
const appModeItem = findItem(
|
||||
menuItems.value,
|
||||
'breadcrumbsMenu.enterAppMode'
|
||||
)
|
||||
|
||||
expect(appModeItem.badge).toBeDefined()
|
||||
})
|
||||
|
||||
it('calls startRename when rename command is invoked', async () => {
|
||||
const startRename = vi.fn()
|
||||
const { menuItems } = useWorkflowActionsMenu(startRename, {
|
||||
isRoot: true
|
||||
})
|
||||
|
||||
await findItem(menuItems.value, 'g.rename').command?.()
|
||||
|
||||
expect(startRename).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses provided workflow ref instead of activeWorkflow', () => {
|
||||
const customWorkflow = ref({
|
||||
path: 'custom.json',
|
||||
isPersisted: true,
|
||||
isTemporary: false
|
||||
} as ComfyWorkflow)
|
||||
|
||||
mockBookmarkStore.isBookmarked.mockReturnValue(false)
|
||||
|
||||
const { menuItems } = useWorkflowActionsMenu(vi.fn(), {
|
||||
isRoot: true,
|
||||
workflow: customWorkflow
|
||||
})
|
||||
|
||||
expect(menuItems.value.length).toBeGreaterThan(0)
|
||||
expect(mockBookmarkStore.isBookmarked).toHaveBeenCalledWith('custom.json')
|
||||
})
|
||||
|
||||
it('shows publish item for blueprints', () => {
|
||||
mockSubgraphStore.isSubgraphBlueprint.mockReturnValue(true)
|
||||
|
||||
const { menuItems } = useWorkflowActionsMenu(vi.fn(), { isRoot: true })
|
||||
const labels = menuLabels(menuItems.value)
|
||||
|
||||
expect(labels).toContain('subgraphStore.publish')
|
||||
expect(labels).toContain('breadcrumbsMenu.deleteBlueprint')
|
||||
expect(labels).not.toContain('breadcrumbsMenu.duplicate')
|
||||
})
|
||||
|
||||
it('duplicate command calls workflowService.duplicateWorkflow', async () => {
|
||||
const { menuItems } = useWorkflowActionsMenu(vi.fn(), { isRoot: true })
|
||||
await findItem(menuItems.value, 'breadcrumbsMenu.duplicate').command?.()
|
||||
|
||||
expect(mockWorkflowService.duplicateWorkflow).toHaveBeenCalledWith(
|
||||
mockWorkflowStore.activeWorkflow
|
||||
)
|
||||
})
|
||||
|
||||
it('save command executes Comfy.SaveWorkflow', async () => {
|
||||
const { menuItems } = useWorkflowActionsMenu(vi.fn(), { isRoot: true })
|
||||
await findItem(menuItems.value, 'menuLabels.Save').command?.()
|
||||
|
||||
expect(mockCommandStore.execute).toHaveBeenCalledWith('Comfy.SaveWorkflow')
|
||||
})
|
||||
|
||||
it('delete command calls workflowService.deleteWorkflow', async () => {
|
||||
const { menuItems } = useWorkflowActionsMenu(vi.fn(), { isRoot: true })
|
||||
await findItem(
|
||||
menuItems.value,
|
||||
'breadcrumbsMenu.deleteWorkflow'
|
||||
).command?.()
|
||||
|
||||
expect(mockWorkflowService.deleteWorkflow).toHaveBeenCalledWith(
|
||||
mockWorkflowStore.activeWorkflow
|
||||
)
|
||||
})
|
||||
|
||||
it('bookmark toggle calls bookmarkStore.toggleBookmarked', async () => {
|
||||
const { menuItems } = useWorkflowActionsMenu(vi.fn(), { isRoot: true })
|
||||
await findItem(menuItems.value, 'tabMenu.addToBookmarks').command?.()
|
||||
|
||||
expect(mockBookmarkStore.toggleBookmarked).toHaveBeenCalledWith('test.json')
|
||||
})
|
||||
|
||||
it('app mode toggle executes Comfy.ToggleLinear', async () => {
|
||||
mockFeatureFlags.flags.linearToggleEnabled = true
|
||||
|
||||
const { menuItems } = useWorkflowActionsMenu(vi.fn(), { isRoot: true })
|
||||
await findItem(menuItems.value, 'breadcrumbsMenu.enterAppMode').command?.()
|
||||
|
||||
expect(mockCommandStore.execute).toHaveBeenCalledWith(
|
||||
'Comfy.ToggleLinear',
|
||||
{ metadata: { source: 'breadcrumb_menu' } }
|
||||
)
|
||||
})
|
||||
|
||||
it('rename is disabled for unpersisted root workflows', () => {
|
||||
mockWorkflowStore.activeWorkflow = {
|
||||
path: 'test.json',
|
||||
isPersisted: false
|
||||
} as ComfyWorkflow
|
||||
|
||||
const { menuItems } = useWorkflowActionsMenu(vi.fn(), { isRoot: true })
|
||||
const rename = findItem(menuItems.value, 'g.rename')
|
||||
|
||||
expect(rename.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('bookmark is disabled for temporary workflows', () => {
|
||||
mockWorkflowStore.activeWorkflow = {
|
||||
path: 'test.json',
|
||||
isPersisted: true,
|
||||
isTemporary: true
|
||||
} as ComfyWorkflow
|
||||
|
||||
const { menuItems } = useWorkflowActionsMenu(vi.fn(), { isRoot: true })
|
||||
const bookmark = findItem(menuItems.value, 'tabMenu.addToBookmarks')
|
||||
|
||||
expect(bookmark.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('switches to custom workflow before executing rename', async () => {
|
||||
const customWorkflow = ref({
|
||||
path: 'other.json',
|
||||
isPersisted: true
|
||||
} as ComfyWorkflow)
|
||||
const startRename = vi.fn()
|
||||
|
||||
const { menuItems } = useWorkflowActionsMenu(startRename, {
|
||||
isRoot: true,
|
||||
workflow: customWorkflow
|
||||
})
|
||||
await findItem(menuItems.value, 'g.rename').command?.()
|
||||
|
||||
expect(mockWorkflowService.openWorkflow).toHaveBeenCalledWith(
|
||||
customWorkflow.value
|
||||
)
|
||||
expect(startRename).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,16 +1,22 @@
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
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 { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useMenuItemStore } from '@/stores/menuItemStore'
|
||||
import { useSubgraphStore } from '@/stores/subgraphStore'
|
||||
import type {
|
||||
WorkflowMenuAction,
|
||||
WorkflowMenuItem
|
||||
} from '@/types/workflowMenuItem'
|
||||
|
||||
interface WorkflowActionsMenuOptions {
|
||||
/** Whether this is the root workflow level. Defaults to true. */
|
||||
@@ -21,6 +27,16 @@ interface WorkflowActionsMenuOptions {
|
||||
workflow?: Ref<ComfyWorkflow | null> | ComputedRef<ComfyWorkflow | null>
|
||||
}
|
||||
|
||||
interface AddItemOptions {
|
||||
label: string
|
||||
icon: string
|
||||
command: () => void
|
||||
visible?: boolean
|
||||
disabled?: boolean
|
||||
prependSeparator?: boolean
|
||||
isNew?: boolean
|
||||
}
|
||||
|
||||
export function useWorkflowActionsMenu(
|
||||
startRename: () => void,
|
||||
options: WorkflowActionsMenuOptions = {}
|
||||
@@ -32,6 +48,9 @@ export function useWorkflowActionsMenu(
|
||||
const bookmarkStore = useWorkflowBookmarkStore()
|
||||
const commandStore = useCommandStore()
|
||||
const subgraphStore = useSubgraphStore()
|
||||
const menuItemStore = useMenuItemStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const { flags } = useFeatureFlags()
|
||||
|
||||
const targetWorkflow = computed(
|
||||
() => workflow?.value ?? workflowStore.activeWorkflow
|
||||
@@ -43,145 +62,166 @@ export function useWorkflowActionsMenu(
|
||||
await workflowService.openWorkflow(wf)
|
||||
}
|
||||
|
||||
const menuItems = computed<MenuItem[]>(() => {
|
||||
const menuItems = computed<WorkflowMenuItem[]>(() => {
|
||||
const workflow = targetWorkflow.value
|
||||
const isBlueprint = workflow
|
||||
? subgraphStore.isSubgraphBlueprint(workflow)
|
||||
: false
|
||||
|
||||
const items: MenuItem[] = []
|
||||
const items: WorkflowMenuItem[] = []
|
||||
|
||||
const addItem = (
|
||||
label: string,
|
||||
icon: string,
|
||||
command: () => void,
|
||||
const addItem = ({
|
||||
label,
|
||||
icon,
|
||||
command,
|
||||
visible = true,
|
||||
disabled = false,
|
||||
separator = false
|
||||
) => {
|
||||
prependSeparator = false,
|
||||
isNew = false
|
||||
}: AddItemOptions) => {
|
||||
if (!visible) return
|
||||
if (separator) items.push({ separator: true })
|
||||
items.push({ label, icon, command, disabled })
|
||||
if (prependSeparator) items.push({ separator: true })
|
||||
const item: WorkflowMenuAction = { label, icon, command, disabled }
|
||||
if (isNew) {
|
||||
item.badge = t('contextMenu.new')
|
||||
}
|
||||
items.push(item)
|
||||
}
|
||||
|
||||
addItem(
|
||||
t('g.rename'),
|
||||
'pi pi-pencil',
|
||||
async () => {
|
||||
const isLinearMode = canvasStore.linearMode
|
||||
const showAppModeItems =
|
||||
isRoot && (menuItemStore.hasSeenLinear || flags.linearToggleEnabled)
|
||||
const isBookmarked = bookmarkStore.isBookmarked(workflow?.path ?? '')
|
||||
|
||||
addItem({
|
||||
label: t('g.rename'),
|
||||
icon: 'pi pi-pencil',
|
||||
command: async () => {
|
||||
await ensureWorkflowActive(targetWorkflow.value)
|
||||
startRename()
|
||||
},
|
||||
true,
|
||||
isRoot && !workflow?.isPersisted
|
||||
)
|
||||
disabled: isRoot && !workflow?.isPersisted
|
||||
})
|
||||
|
||||
addItem(
|
||||
t('breadcrumbsMenu.duplicate'),
|
||||
'pi pi-copy',
|
||||
async () => {
|
||||
addItem({
|
||||
label: t('breadcrumbsMenu.duplicate'),
|
||||
icon: 'pi pi-copy',
|
||||
command: async () => {
|
||||
if (workflow) {
|
||||
await workflowService.duplicateWorkflow(workflow)
|
||||
}
|
||||
},
|
||||
isRoot && !isBlueprint
|
||||
)
|
||||
visible: 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 ?? '')
|
||||
addItem({
|
||||
label: isBookmarked
|
||||
? t('tabMenu.removeFromBookmarks')
|
||||
: t('tabMenu.addToBookmarks'),
|
||||
'pi pi-bookmark' +
|
||||
(bookmarkStore.isBookmarked(workflow?.path ?? '') ? '-fill' : ''),
|
||||
async () => {
|
||||
icon: 'pi pi-bookmark' + (isBookmarked ? '-fill' : ''),
|
||||
command: async () => {
|
||||
if (workflow?.path) {
|
||||
await bookmarkStore.toggleBookmarked(workflow.path)
|
||||
}
|
||||
},
|
||||
isRoot,
|
||||
workflow?.isTemporary ?? false
|
||||
)
|
||||
visible: isRoot,
|
||||
disabled: workflow?.isTemporary ?? false
|
||||
})
|
||||
|
||||
addItem(
|
||||
t('menuLabels.Export'),
|
||||
'pi pi-download',
|
||||
async () => {
|
||||
addItem({
|
||||
label: t('menuLabels.Save'),
|
||||
icon: 'pi pi-save',
|
||||
command: async () => {
|
||||
await ensureWorkflowActive(workflow)
|
||||
await commandStore.execute('Comfy.SaveWorkflow')
|
||||
},
|
||||
visible: isRoot,
|
||||
prependSeparator: true
|
||||
})
|
||||
|
||||
addItem({
|
||||
label: t('menuLabels.Save As'),
|
||||
icon: 'pi pi-save',
|
||||
command: async () => {
|
||||
await ensureWorkflowActive(workflow)
|
||||
await commandStore.execute('Comfy.SaveWorkflowAs')
|
||||
},
|
||||
visible: isRoot
|
||||
})
|
||||
|
||||
addItem({
|
||||
label: t('menuLabels.Export'),
|
||||
icon: 'pi pi-download',
|
||||
command: async () => {
|
||||
await ensureWorkflowActive(workflow)
|
||||
await commandStore.execute('Comfy.ExportWorkflow')
|
||||
},
|
||||
isRoot
|
||||
)
|
||||
visible: isRoot,
|
||||
prependSeparator: true
|
||||
})
|
||||
|
||||
addItem(
|
||||
t('menuLabels.Export (API)'),
|
||||
'pi pi-download',
|
||||
async () => {
|
||||
addItem({
|
||||
label: t('menuLabels.Export (API)'),
|
||||
icon: 'pi pi-download',
|
||||
command: async () => {
|
||||
await ensureWorkflowActive(workflow)
|
||||
await commandStore.execute('Comfy.ExportWorkflowAPI')
|
||||
},
|
||||
isRoot
|
||||
)
|
||||
visible: isRoot
|
||||
})
|
||||
|
||||
addItem(
|
||||
t('breadcrumbsMenu.clearWorkflow'),
|
||||
'pi pi-trash',
|
||||
async () => {
|
||||
addItem({
|
||||
label: isLinearMode
|
||||
? t('breadcrumbsMenu.exitAppMode')
|
||||
: t('breadcrumbsMenu.enterAppMode'),
|
||||
icon: isLinearMode
|
||||
? 'icon-[comfy--workflow]'
|
||||
: 'icon-[lucide--panels-top-left]',
|
||||
command: async () => {
|
||||
await commandStore.execute('Comfy.ToggleLinear', {
|
||||
metadata: { source: 'breadcrumb_menu' }
|
||||
})
|
||||
},
|
||||
visible: showAppModeItems,
|
||||
prependSeparator: true,
|
||||
isNew: !isLinearMode
|
||||
})
|
||||
|
||||
addItem({
|
||||
label: t('breadcrumbsMenu.clearWorkflow'),
|
||||
icon: 'pi pi-trash',
|
||||
command: async () => {
|
||||
await ensureWorkflowActive(workflow)
|
||||
await commandStore.execute('Comfy.ClearWorkflow')
|
||||
},
|
||||
true,
|
||||
false,
|
||||
true
|
||||
)
|
||||
prependSeparator: true
|
||||
})
|
||||
|
||||
addItem(
|
||||
t('subgraphStore.publish'),
|
||||
'pi pi-upload',
|
||||
async () => {
|
||||
addItem({
|
||||
label: t('subgraphStore.publish'),
|
||||
icon: 'pi pi-upload',
|
||||
command: async () => {
|
||||
if (workflow) {
|
||||
await workflowService.saveWorkflowAs(workflow)
|
||||
}
|
||||
},
|
||||
isRoot && isBlueprint,
|
||||
false,
|
||||
true
|
||||
)
|
||||
visible: isRoot && isBlueprint,
|
||||
prependSeparator: true
|
||||
})
|
||||
|
||||
addItem(
|
||||
isBlueprint
|
||||
addItem({
|
||||
label: isBlueprint
|
||||
? t('breadcrumbsMenu.deleteBlueprint')
|
||||
: t('breadcrumbsMenu.deleteWorkflow'),
|
||||
'pi pi-times',
|
||||
async () => {
|
||||
icon: 'pi pi-times',
|
||||
command: async () => {
|
||||
if (workflow) {
|
||||
await workflowService.deleteWorkflow(workflow)
|
||||
}
|
||||
},
|
||||
isRoot && includeDelete,
|
||||
false,
|
||||
true
|
||||
)
|
||||
visible: isRoot && includeDelete,
|
||||
prependSeparator: true
|
||||
})
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
@@ -2503,6 +2503,9 @@
|
||||
},
|
||||
"breadcrumbsMenu": {
|
||||
"duplicate": "Duplicate",
|
||||
"enterAppMode": "Enter app mode",
|
||||
"exitAppMode": "Exit app mode",
|
||||
"workflowActions": "Workflow actions",
|
||||
"clearWorkflow": "Clear Workflow",
|
||||
"deleteWorkflow": "Delete Workflow",
|
||||
"deleteBlueprint": "Delete Blueprint",
|
||||
|
||||
@@ -26,7 +26,7 @@ import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useQueueSettingsStore } from '@/stores/queueStore'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
const { t } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
const executionStore = useExecutionStore()
|
||||
@@ -35,6 +35,7 @@ const { batchCount } = storeToRefs(useQueueSettingsStore())
|
||||
const settingStore = useSettingStore()
|
||||
const { isActiveSubscription } = useBillingContext()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const appModeStore = useAppModeStore()
|
||||
|
||||
const props = defineProps<{
|
||||
toastTo?: string | HTMLElement
|
||||
@@ -146,7 +147,10 @@ async function runButtonClick(e: Event) {
|
||||
defineExpose({ runButtonClick })
|
||||
</script>
|
||||
<template>
|
||||
<div class="flex flex-col min-w-80 md:h-full">
|
||||
<div
|
||||
v-if="!appModeStore.isBuilderMode"
|
||||
class="flex flex-col min-w-80 md:h-full"
|
||||
>
|
||||
<section
|
||||
v-if="mobile"
|
||||
data-testid="linear-run-button"
|
||||
|
||||
17
src/types/workflowMenuItem.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { OverlayIconProps } from '@/components/common/OverlayIcon.vue'
|
||||
|
||||
export type WorkflowMenuItem = WorkflowMenuSeparator | WorkflowMenuAction
|
||||
|
||||
interface WorkflowMenuSeparator {
|
||||
separator: true
|
||||
}
|
||||
|
||||
export interface WorkflowMenuAction {
|
||||
separator?: false
|
||||
label: string
|
||||
icon?: string
|
||||
command?: () => void
|
||||
disabled?: boolean
|
||||
badge?: string
|
||||
overlayIcon?: OverlayIconProps
|
||||
}
|
||||
@@ -1,27 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import { breakpointsTailwind, unrefElement, useBreakpoints } from '@vueuse/core'
|
||||
import {
|
||||
breakpointsTailwind,
|
||||
unrefElement,
|
||||
useBreakpoints,
|
||||
whenever
|
||||
} from '@vueuse/core'
|
||||
import Splitter from 'primevue/splitter'
|
||||
import SplitterPanel from 'primevue/splitterpanel'
|
||||
import { computed, useTemplateRef } from 'vue'
|
||||
import { computed, ref, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
|
||||
import WorkflowActionsDropdown from '@/components/common/WorkflowActionsDropdown.vue'
|
||||
import ModeToggle from '@/components/sidebar/ModeToggle.vue'
|
||||
import SideToolbar from '@/components/sidebar/SideToolbar.vue'
|
||||
import TopbarBadges from '@/components/topbar/TopbarBadges.vue'
|
||||
import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import TypeformPopoverButton from '@/components/ui/TypeformPopoverButton.vue'
|
||||
import { useWorkflowActionsMenu } from '@/composables/useWorkflowActionsMenu'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import LinearControls from '@/renderer/extensions/linearMode/LinearControls.vue'
|
||||
import LinearPreview from '@/renderer/extensions/linearMode/LinearPreview.vue'
|
||||
import MobileMenu from '@/renderer/extensions/linearMode/MobileMenu.vue'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
const settingStore = useSettingStore()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
|
||||
@@ -29,11 +35,16 @@ const mobileDisplay = useBreakpoints(breakpointsTailwind).smaller('md')
|
||||
|
||||
const activeTab = computed(() => workspaceStore.sidebarTab.activeSidebarTab)
|
||||
|
||||
const { menuItems } = useWorkflowActionsMenu(
|
||||
() => useCommandStore().execute('Comfy.RenameWorkflow'),
|
||||
{ isRoot: true }
|
||||
const hasPreview = ref(false)
|
||||
whenever(
|
||||
() => nodeOutputStore.latestPreview[0],
|
||||
() => (hasPreview.value = true)
|
||||
)
|
||||
|
||||
const selectedItem = ref<AssetItem>()
|
||||
const selectedOutput = ref<ResultItemImpl>()
|
||||
const canShowPreview = ref(true)
|
||||
|
||||
const topLeftRef = useTemplateRef('topLeftRef')
|
||||
const topRightRef = useTemplateRef('topRightRef')
|
||||
const bottomLeftRef = useTemplateRef('bottomLeftRef')
|
||||
@@ -55,7 +66,14 @@ const linearWorkflowRef = useTemplateRef('linearWorkflowRef')
|
||||
<MobileMenu />
|
||||
<div class="flex flex-col text-muted-foreground">
|
||||
<LinearPreview
|
||||
:latent-preview="
|
||||
canShowPreview && hasPreview
|
||||
? nodeOutputStore.latestPreview[0]
|
||||
: undefined
|
||||
"
|
||||
:run-button-click="linearWorkflowRef?.runButtonClick"
|
||||
:selected-item
|
||||
:selected-output
|
||||
mobile
|
||||
/>
|
||||
</div>
|
||||
@@ -98,15 +116,18 @@ const linearWorkflowRef = useTemplateRef('linearWorkflowRef')
|
||||
:size="98"
|
||||
class="flex flex-col min-w-min gap-4 mx-2 px-10 pt-8 pb-4 relative text-muted-foreground outline-none"
|
||||
>
|
||||
<LinearPreview :run-button-click="linearWorkflowRef?.runButtonClick" />
|
||||
<LinearPreview
|
||||
:latent-preview="
|
||||
canShowPreview && hasPreview
|
||||
? nodeOutputStore.latestPreview[0]
|
||||
: undefined
|
||||
"
|
||||
:run-button-click="linearWorkflowRef?.runButtonClick"
|
||||
:selected-item
|
||||
:selected-output
|
||||
/>
|
||||
<div ref="topLeftRef" class="absolute z-21 top-4 left-4">
|
||||
<Popover :entries="menuItems" align="start">
|
||||
<template #button>
|
||||
<Button size="icon" variant="textonly">
|
||||
<i class="icon-[lucide--menu]" />
|
||||
</Button>
|
||||
</template>
|
||||
</Popover>
|
||||
<WorkflowActionsDropdown source="app_mode_menu_selected" />
|
||||
</div>
|
||||
<div ref="topRightRef" class="absolute z-21 top-4 right-4" />
|
||||
<div ref="bottomLeftRef" class="absolute z-20 bottom-4 left-4" />
|
||||
|
||||