mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-27 01:57:17 +00:00
Compare commits
5 Commits
pysssss/ap
...
codex/cove
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6260c101e5 | ||
|
|
7ab6cb57c5 | ||
|
|
3c3a2ab4e2 | ||
|
|
a07854755f | ||
|
|
2adef5d9f6 |
@@ -28,7 +28,6 @@ import {
|
||||
ModelLibrarySidebarTab,
|
||||
NodeLibrarySidebarTab,
|
||||
NodeLibrarySidebarTabV2,
|
||||
SidebarTab,
|
||||
WorkflowsSidebarTab
|
||||
} from '@e2e/fixtures/components/SidebarTab'
|
||||
import { Topbar } from '@e2e/fixtures/components/Topbar'
|
||||
@@ -71,7 +70,6 @@ class ComfyPropertiesPanel {
|
||||
}
|
||||
|
||||
class ComfyMenu {
|
||||
private _appsTab: SidebarTab | null = null
|
||||
private _assetsTab: AssetsSidebarTab | null = null
|
||||
private _modelLibraryTab: ModelLibrarySidebarTab | null = null
|
||||
private _nodeLibraryTab: NodeLibrarySidebarTab | null = null
|
||||
@@ -106,11 +104,6 @@ class ComfyMenu {
|
||||
return this._nodeLibraryTabV2
|
||||
}
|
||||
|
||||
get appsTab() {
|
||||
this._appsTab ??= new SidebarTab(this.page, 'apps')
|
||||
return this._appsTab
|
||||
}
|
||||
|
||||
get assetsTab() {
|
||||
this._assetsTab ??= new AssetsSidebarTab(this.page)
|
||||
return this._assetsTab
|
||||
|
||||
@@ -4,7 +4,7 @@ import { expect } from '@playwright/test'
|
||||
import type { WorkspaceStore } from '@e2e/types/globals'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
export class SidebarTab {
|
||||
class SidebarTab {
|
||||
public readonly tabButton: Locator
|
||||
public readonly selectedTabButton: Locator
|
||||
|
||||
|
||||
@@ -235,9 +235,6 @@ export const TestIds = {
|
||||
renameInput: 'subgraph-breadcrumb-rename-input',
|
||||
menu: (key: string) => `subgraph-breadcrumb-menu-${key}`
|
||||
},
|
||||
workflowActions: {
|
||||
viewModeToggle: 'view-mode-toggle'
|
||||
},
|
||||
templates: {
|
||||
content: 'template-workflows-content',
|
||||
workflowCard: (id: string) => `template-workflow-${id}`
|
||||
|
||||
@@ -137,124 +137,6 @@ test.describe('App mode usage', () => {
|
||||
await expect.poll(() => fileComboWidget.getValue()).toBe(targetImage)
|
||||
})
|
||||
|
||||
test('Shares the graph side toolbar, filtered to assets + apps', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { sideToolbar, nodeLibraryTab, assetsTab, appsTab } = comfyPage.menu
|
||||
|
||||
await test.step('Graph mode shows the full toolbar', async () => {
|
||||
await expect(sideToolbar).toBeVisible()
|
||||
await expect(nodeLibraryTab.tabButton).toBeVisible()
|
||||
})
|
||||
|
||||
await test.step('App mode reuses it with only assets + apps', async () => {
|
||||
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
|
||||
await expect(comfyPage.appMode.centerPanel).toBeVisible()
|
||||
|
||||
await expect(sideToolbar).toBeVisible()
|
||||
await expect(assetsTab.tabButton).toBeVisible()
|
||||
await expect(appsTab.tabButton).toBeVisible()
|
||||
await expect(nodeLibraryTab.tabButton).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test('Workflow actions menu keeps the same position across graph/app mode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Toggling graph<->app mode happens from this control, so it must not move
|
||||
// out from under the cursor as the mode flips.
|
||||
const graphActions = comfyPage.page
|
||||
.getByTestId(TestIds.breadcrumb.subgraph)
|
||||
.getByRole('button', { name: 'Workflow actions' })
|
||||
await expect(graphActions).toBeVisible()
|
||||
const graphBox = await graphActions.boundingBox()
|
||||
|
||||
expect(graphBox).not.toBeNull()
|
||||
|
||||
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
|
||||
await expect(comfyPage.appMode.centerPanel).toBeVisible()
|
||||
|
||||
const appActions = comfyPage.page
|
||||
.getByTestId(TestIds.linear.centerPanel)
|
||||
.getByRole('button', { name: 'Workflow actions' })
|
||||
await expect(appActions).toBeVisible()
|
||||
|
||||
// The toggle segments reorder (morph) as the mode flips, so poll until the
|
||||
// active control settles at the same x it occupied in graph mode.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const box = await appActions.boundingBox()
|
||||
return box ? Math.abs(box.x - graphBox!.x) : Infinity
|
||||
})
|
||||
.toBeLessThanOrEqual(1)
|
||||
})
|
||||
|
||||
test('Toggle segment flips mode without opening the menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const toggle = comfyPage.page.getByTestId(
|
||||
TestIds.workflowActions.viewModeToggle
|
||||
)
|
||||
await expect(toggle).toBeVisible()
|
||||
|
||||
await comfyPage.page.getByRole('button', { name: 'Enter app mode' }).click()
|
||||
|
||||
await expect(comfyPage.appMode.centerPanel).toBeVisible()
|
||||
// The inactive segment switches mode; it must not also open the actions menu.
|
||||
await expect(comfyPage.page.getByRole('menu')).toBeHidden()
|
||||
await expect(toggle).toBeVisible()
|
||||
})
|
||||
|
||||
test('Toggle segment flips mode via keyboard without opening the menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const appSegment = comfyPage.page.getByRole('button', {
|
||||
name: 'Enter app mode'
|
||||
})
|
||||
await appSegment.focus()
|
||||
await appSegment.press('Enter')
|
||||
|
||||
await expect(comfyPage.appMode.centerPanel).toBeVisible()
|
||||
// Keyboard activation of the inactive segment must switch mode without the
|
||||
// keydown bubbling to the trigger and opening the actions menu.
|
||||
await expect(comfyPage.page.getByRole('menu')).toBeHidden()
|
||||
})
|
||||
|
||||
test('Mode toggle returns to app mode after exiting the builder', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const toggle = comfyPage.page.getByTestId(
|
||||
TestIds.workflowActions.viewModeToggle
|
||||
)
|
||||
await comfyPage.appMode.enableLinearMode()
|
||||
await expect(toggle).toBeVisible()
|
||||
|
||||
await comfyPage.appMode.enterBuilder()
|
||||
await expect(toggle).toBeHidden()
|
||||
await expect(comfyPage.appMode.centerPanel).toBeHidden()
|
||||
|
||||
await comfyPage.appMode.footer.exitButton.click()
|
||||
// The center panel only renders in app mode, so its return proves the exit
|
||||
// landed back in app mode rather than graph mode (where the toggle also shows).
|
||||
await expect(comfyPage.appMode.centerPanel).toBeVisible()
|
||||
await expect(toggle).toBeVisible()
|
||||
})
|
||||
|
||||
test('Mode toggle survives a sidebar tab remounting the app panel', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const toggle = comfyPage.page.getByTestId(
|
||||
TestIds.workflowActions.viewModeToggle
|
||||
)
|
||||
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
|
||||
await expect(comfyPage.appMode.centerPanel).toBeVisible()
|
||||
await expect(toggle).toBeVisible()
|
||||
|
||||
// Opening a sidebar tab remounts the app panel; the toggle re-renders with it.
|
||||
await comfyPage.menu.assetsTab.tabButton.click()
|
||||
await expect(toggle).toBeVisible()
|
||||
})
|
||||
|
||||
test.describe('Mobile', { tag: ['@mobile'] }, () => {
|
||||
test('panel navigation', async ({ comfyPage }) => {
|
||||
const { mobile } = comfyPage.appMode
|
||||
|
||||
@@ -19,7 +19,10 @@
|
||||
"size:collect": "node scripts/size-collect.js",
|
||||
"size:report": "node scripts/size-report.js",
|
||||
"collect-i18n": "pnpm exec playwright test --config=playwright.i18n.config.ts",
|
||||
"dev:cloud": "cross-env DEV_SERVER_COMFYUI_URL='https://testcloud.comfy.org/' vite --config vite.config.mts",
|
||||
"dev:cloud": "pnpm dev:cloud:test",
|
||||
"dev:cloud:test": "cross-env DEV_SERVER_COMFYUI_URL=https://testcloud.comfy.org/ vite --config vite.config.mts",
|
||||
"dev:cloud:staging": "cross-env DEV_SERVER_COMFYUI_URL=https://stagingcloud.comfy.org/ vite --config vite.config.mts",
|
||||
"dev:cloud:prod": "cross-env DEV_SERVER_COMFYUI_URL=https://cloud.comfy.org/ vite --config vite.config.mts",
|
||||
"dev:desktop": "pnpm --filter @comfyorg/desktop-ui run dev",
|
||||
"dev:electron": "cross-env DISTRIBUTION=desktop vite --config vite.electron.config.mts",
|
||||
"dev:no-vue": "cross-env DISABLE_VUE_PLUGINS=true vite --config vite.config.mts",
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import AppModeToolbar from './AppModeToolbar.vue'
|
||||
|
||||
const appModeState = vi.hoisted(() => ({ enableAppBuilder: true }))
|
||||
const enterBuilder = vi.hoisted(() => vi.fn())
|
||||
const nodes = vi.hoisted(() => ({ set: (_value: boolean) => {} }))
|
||||
|
||||
vi.mock('@/composables/useAppMode', () => ({
|
||||
useAppMode: () => ({ enableAppBuilder: appModeState.enableAppBuilder })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/appModeStore', async () => {
|
||||
const { ref } = await import('vue')
|
||||
const hasNodes = ref(true)
|
||||
nodes.set = (value: boolean) => {
|
||||
hasNodes.value = value
|
||||
}
|
||||
return { useAppModeStore: () => ({ enterBuilder, hasNodes }) }
|
||||
})
|
||||
|
||||
const BUILD_AN_APP = 'Build an app'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
linearMode: { appModeToolbar: { buildAnApp: BUILD_AN_APP } }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function setHasNodes(hasNodes: boolean) {
|
||||
nodes.set(hasNodes)
|
||||
}
|
||||
|
||||
function renderToolbar() {
|
||||
const user = userEvent.setup()
|
||||
const result = render(AppModeToolbar, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
WorkflowActionsDropdown: true,
|
||||
Button: {
|
||||
inheritAttrs: false,
|
||||
template:
|
||||
'<button v-bind="$attrs" @click="$emit(\'click\', $event)"><slot /></button>'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
return { ...result, user }
|
||||
}
|
||||
|
||||
describe('AppModeToolbar', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
appModeState.enableAppBuilder = true
|
||||
setHasNodes(true)
|
||||
})
|
||||
|
||||
it('shows an enabled build button and enters the builder on click', async () => {
|
||||
setHasNodes(true)
|
||||
const { user } = renderToolbar()
|
||||
|
||||
const button = screen.getByRole('button', { name: BUILD_AN_APP })
|
||||
expect(button).toBeEnabled()
|
||||
|
||||
await user.click(button)
|
||||
|
||||
expect(enterBuilder).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('disables the build button when there are no nodes', () => {
|
||||
setHasNodes(false)
|
||||
renderToolbar()
|
||||
|
||||
expect(screen.getByRole('button', { name: BUILD_AN_APP })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('hides the build button when app building is disabled', () => {
|
||||
setHasNodes(true)
|
||||
appModeState.enableAppBuilder = false
|
||||
renderToolbar()
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: BUILD_AN_APP })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,33 +1,119 @@
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import WorkflowActionsDropdown from '@/components/common/WorkflowActionsDropdown.vue'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import {
|
||||
openShareDialog,
|
||||
prefetchShareDialog
|
||||
} from '@/platform/workflow/sharing/composables/lazyShareDialog'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
const { t } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const { enableAppBuilder } = useAppMode()
|
||||
const appModeStore = useAppModeStore()
|
||||
const { enterBuilder } = appModeStore
|
||||
const { toastErrorHandler } = useErrorHandling()
|
||||
const { flags } = useFeatureFlags()
|
||||
const { hasNodes } = storeToRefs(appModeStore)
|
||||
const tooltipOptions = { showDelay: 300, hideDelay: 300 }
|
||||
|
||||
const isAssetsActive = computed(
|
||||
() => workspaceStore.sidebarTab.activeSidebarTab?.id === 'assets'
|
||||
)
|
||||
const isAppsActive = computed(
|
||||
() => workspaceStore.sidebarTab.activeSidebarTab?.id === 'apps'
|
||||
)
|
||||
|
||||
function openAssets() {
|
||||
void commandStore.execute('Workspace.ToggleSidebarTab.assets')
|
||||
}
|
||||
|
||||
function showApps() {
|
||||
void commandStore.execute('Workspace.ToggleSidebarTab.apps')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pointer-events-auto flex flex-row items-start gap-2">
|
||||
<div class="pointer-events-auto flex flex-col gap-2">
|
||||
<Button
|
||||
v-if="enableAppBuilder"
|
||||
v-tooltip.right="{
|
||||
value: t('linearMode.appModeToolbar.appBuilder'),
|
||||
...tooltipOptions
|
||||
}"
|
||||
variant="secondary"
|
||||
size="unset"
|
||||
:disabled="!hasNodes"
|
||||
:aria-label="t('linearMode.appModeToolbar.appBuilder')"
|
||||
class="size-10 rounded-lg"
|
||||
@click="enterBuilder"
|
||||
>
|
||||
<i class="icon-[lucide--hammer] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="isCloud && flags.workflowSharingEnabled"
|
||||
v-tooltip.right="{
|
||||
value: t('actionbar.shareTooltip'),
|
||||
...tooltipOptions
|
||||
}"
|
||||
variant="secondary"
|
||||
size="unset"
|
||||
:aria-label="t('actionbar.shareTooltip')"
|
||||
class="size-10 rounded-lg"
|
||||
@click="() => openShareDialog().catch(toastErrorHandler)"
|
||||
@pointerenter="prefetchShareDialog"
|
||||
>
|
||||
<i class="icon-[lucide--send] size-4" />
|
||||
</Button>
|
||||
|
||||
<div
|
||||
class="flex w-10 flex-col overflow-hidden rounded-lg bg-secondary-background"
|
||||
>
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
value: t('sideToolbar.mediaAssets.title'),
|
||||
...tooltipOptions
|
||||
}"
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
:aria-label="t('sideToolbar.mediaAssets.title')"
|
||||
:class="
|
||||
cn('size-10', isAssetsActive && 'bg-secondary-background-hover')
|
||||
"
|
||||
@click="openAssets"
|
||||
>
|
||||
<i class="icon-[comfy--image-ai-edit] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
value: t('linearMode.appModeToolbar.apps'),
|
||||
...tooltipOptions
|
||||
}"
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
:aria-label="t('linearMode.appModeToolbar.apps')"
|
||||
:class="
|
||||
cn('size-10', isAppsActive && 'bg-secondary-background-hover')
|
||||
"
|
||||
@click="showApps"
|
||||
>
|
||||
<i class="icon-[lucide--panels-top-left] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<WorkflowActionsDropdown source="app_mode_toolbar" />
|
||||
<Button
|
||||
v-if="enableAppBuilder"
|
||||
variant="base"
|
||||
size="unset"
|
||||
:disabled="!hasNodes"
|
||||
:aria-label="t('linearMode.appModeToolbar.buildAnApp')"
|
||||
class="h-10 gap-1.5 rounded-lg px-3 font-normal"
|
||||
@click="enterBuilder"
|
||||
>
|
||||
<i class="icon-[lucide--hammer] size-4" />
|
||||
<span>{{ t('linearMode.appModeToolbar.buildAnApp') }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import SubgraphBreadcrumb from './SubgraphBreadcrumb.vue'
|
||||
|
||||
const canvasState = vi.hoisted(() => ({ linearMode: false }))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => ({ activeWorkflow: { filename: 'workflow.json' } })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/subgraphNavigationStore', () => ({
|
||||
useSubgraphNavigationStore: () => ({ navigationStack: [] })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/subgraphStore', () => ({
|
||||
useSubgraphStore: () => ({ isSubgraphBlueprint: () => false })
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({ linearMode: canvasState.linearMode })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/element/useOverflowObserver', () => ({
|
||||
useOverflowObserver: () => ({
|
||||
dispose: vi.fn(),
|
||||
checkOverflow: vi.fn(),
|
||||
disposed: { value: false }
|
||||
})
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: { g: { graphNavigation: 'Graph navigation' } }
|
||||
}
|
||||
})
|
||||
|
||||
function renderBreadcrumb() {
|
||||
return render(SubgraphBreadcrumb, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
directives: { tooltip: {} },
|
||||
stubs: {
|
||||
WorkflowActionsDropdown: { template: '<div data-testid="wad" />' },
|
||||
Breadcrumb: true,
|
||||
Button: true,
|
||||
SubgraphBreadcrumbItem: true
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('SubgraphBreadcrumb', () => {
|
||||
beforeEach(() => {
|
||||
canvasState.linearMode = false
|
||||
})
|
||||
|
||||
it('renders the workflow actions dropdown when not in linear mode', () => {
|
||||
renderBreadcrumb()
|
||||
expect(screen.getByTestId('wad')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the workflow actions dropdown in linear mode', () => {
|
||||
canvasState.linearMode = true
|
||||
renderBreadcrumb()
|
||||
expect(screen.queryByTestId('wad')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -14,10 +14,7 @@
|
||||
'--p-breadcrumb-icon-width': `${ICON_WIDTH}px`
|
||||
}"
|
||||
>
|
||||
<WorkflowActionsDropdown
|
||||
v-if="!canvasStore.linearMode"
|
||||
source="breadcrumb_subgraph_menu_selected"
|
||||
/>
|
||||
<WorkflowActionsDropdown source="breadcrumb_subgraph_menu_selected" />
|
||||
<Button
|
||||
v-if="isInSubgraph"
|
||||
class="back-button pointer-events-auto ml-1.5 size-8 shrink-0 border border-transparent bg-transparent p-0 transition-all hover:rounded-lg hover:border-interface-stroke hover:bg-comfy-menu-bg"
|
||||
@@ -74,7 +71,6 @@ const ICON_WIDTH = 20
|
||||
|
||||
const workflowStore = useWorkflowStore()
|
||||
const navigationStore = useSubgraphNavigationStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const breadcrumbRef = ref<InstanceType<typeof Breadcrumb>>()
|
||||
const workflowName = computed(() => workflowStore.activeWorkflow?.filename)
|
||||
const isBlueprint = computed(() =>
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import WorkflowActionsDropdown from './WorkflowActionsDropdown.vue'
|
||||
|
||||
const spies = vi.hoisted(() => ({
|
||||
execute: vi.fn(),
|
||||
trackUiButtonClicked: vi.fn(),
|
||||
markAsSeen: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({ displayLinearMode: false })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: () => ({ execute: spies.execute, commands: [] })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/keybindings/keybindingStore', () => ({
|
||||
useKeybindingStore: () => ({
|
||||
getKeybindingByCommandId: () => ({ combo: { toString: () => 'Ctrl+L' } })
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({ trackUiButtonClicked: spies.trackUiButtonClicked })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useWorkflowActionsMenu', async () => {
|
||||
const { ref } = await import('vue')
|
||||
return { useWorkflowActionsMenu: () => ({ menuItems: ref([]) }) }
|
||||
})
|
||||
|
||||
vi.mock('@/composables/useNewMenuItemIndicator', async () => {
|
||||
const { ref } = await import('vue')
|
||||
return {
|
||||
useNewMenuItemIndicator: () => ({
|
||||
hasUnseenItems: ref(true),
|
||||
markAsSeen: spies.markAsSeen
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: { shortcutSuffix: ' ({shortcut})' },
|
||||
breadcrumbsMenu: {
|
||||
graph: 'Graph',
|
||||
app: 'App',
|
||||
enterNodeGraph: 'Enter node graph',
|
||||
enterAppMode: 'Enter app mode',
|
||||
workflowActions: 'Workflow actions'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function renderDropdown() {
|
||||
const user = userEvent.setup()
|
||||
const result = render(WorkflowActionsDropdown, {
|
||||
props: { source: 'test' },
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
directives: { tooltip: {} },
|
||||
stubs: {
|
||||
// Emits update:open on mount so handleOpen's telemetry path is exercised.
|
||||
DropdownMenuRoot: {
|
||||
emits: ['update:open'],
|
||||
mounted() {
|
||||
this.$emit('update:open', true)
|
||||
},
|
||||
template: '<div><slot /></div>'
|
||||
},
|
||||
DropdownMenuTrigger: { template: '<div><slot /></div>' },
|
||||
DropdownMenuPortal: { template: '<div><slot /></div>' },
|
||||
DropdownMenuContent: { template: '<div><slot /></div>' },
|
||||
WorkflowActionsList: true,
|
||||
Button: {
|
||||
inheritAttrs: false,
|
||||
template:
|
||||
'<button v-bind="$attrs" @click="$emit(\'click\', $event)"><slot /></button>'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
return { ...result, user }
|
||||
}
|
||||
|
||||
describe('WorkflowActionsDropdown', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('keeps the active segment label in its accessible name alongside the actions label', () => {
|
||||
renderDropdown()
|
||||
|
||||
// Graph is the active segment, so its name must contain the visible "Graph"
|
||||
// label (label-in-name) while still matching the "Workflow actions" trigger.
|
||||
const active = screen.getByRole('button', { name: /Workflow actions/ })
|
||||
expect(active).toHaveAttribute('aria-label', 'Graph Workflow actions')
|
||||
})
|
||||
|
||||
it('labels the inactive segment with its switch action only', () => {
|
||||
renderDropdown()
|
||||
|
||||
const inactive = screen.getByRole('button', { name: 'Enter app mode' })
|
||||
expect(inactive).toHaveAttribute('aria-label', 'Enter app mode')
|
||||
})
|
||||
|
||||
it('toggles the view mode when the inactive segment is clicked', async () => {
|
||||
const { user } = renderDropdown()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Enter app mode' }))
|
||||
|
||||
expect(spies.execute).toHaveBeenCalledWith('Comfy.ToggleLinear', {
|
||||
metadata: { source: 'test' }
|
||||
})
|
||||
})
|
||||
|
||||
it('does not toggle the view mode when the active segment is clicked', async () => {
|
||||
const { user } = renderDropdown()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Workflow actions/ }))
|
||||
|
||||
expect(spies.execute).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('switches mode when the inactive segment is activated by keyboard', async () => {
|
||||
const { user } = renderDropdown()
|
||||
const inactive = screen.getByRole('button', { name: 'Enter app mode' })
|
||||
|
||||
inactive.focus()
|
||||
await user.keyboard('{Enter}')
|
||||
|
||||
// The keydown guard stops the event bubbling to the trigger, but native
|
||||
// button activation still switches mode.
|
||||
expect(spies.execute).toHaveBeenCalledWith('Comfy.ToggleLinear', {
|
||||
metadata: { source: 'test' }
|
||||
})
|
||||
})
|
||||
|
||||
it('does not switch mode when the active segment is activated by keyboard', async () => {
|
||||
const { user } = renderDropdown()
|
||||
const active = screen.getByRole('button', { name: /Workflow actions/ })
|
||||
|
||||
active.focus()
|
||||
await user.keyboard('{Enter}')
|
||||
|
||||
expect(spies.execute).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('marks new items as seen and reports telemetry when the menu opens', () => {
|
||||
renderDropdown()
|
||||
|
||||
expect(spies.markAsSeen).toHaveBeenCalled()
|
||||
expect(spies.trackUiButtonClicked).toHaveBeenCalledWith({
|
||||
button_id: 'test',
|
||||
element_group: 'workflow_actions'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,12 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRoot,
|
||||
DropdownMenuTrigger
|
||||
} from 'reka-ui'
|
||||
import { computed, ref } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import WorkflowActionsList from '@/components/common/WorkflowActionsList.vue'
|
||||
@@ -18,67 +17,25 @@ import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
type ViewMode = 'graph' | 'app'
|
||||
|
||||
interface ViewModeSegment {
|
||||
mode: ViewMode
|
||||
icon: string
|
||||
label: string
|
||||
switchLabel: string
|
||||
switchTooltip: string
|
||||
active: boolean
|
||||
}
|
||||
|
||||
const { source, align = 'start' } = defineProps<{
|
||||
source: string
|
||||
align?: 'start' | 'center' | 'end'
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const canvasStore = useCanvasStore()
|
||||
const keybindingStore = useKeybindingStore()
|
||||
const dropdownOpen = ref(false)
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
const { menuItems } = useWorkflowActionsMenu(
|
||||
() => useCommandStore().execute('Comfy.RenameWorkflow'),
|
||||
{ isRoot: true }
|
||||
)
|
||||
|
||||
const { hasUnseenItems, markAsSeen } = useNewMenuItemIndicator(
|
||||
() => menuItems.value
|
||||
)
|
||||
|
||||
const toggleShortcut = computed(() => {
|
||||
const shortcut = keybindingStore
|
||||
.getKeybindingByCommandId('Comfy.ToggleLinear')
|
||||
?.combo.toString()
|
||||
return shortcut ? t('g.shortcutSuffix', { shortcut }) : ''
|
||||
})
|
||||
|
||||
const segments = computed<ViewModeSegment[]>(() => [
|
||||
{
|
||||
mode: 'graph',
|
||||
icon: 'icon-[comfy--workflow]',
|
||||
label: t('breadcrumbsMenu.graph'),
|
||||
switchLabel: t('breadcrumbsMenu.enterNodeGraph'),
|
||||
switchTooltip: t('breadcrumbsMenu.enterNodeGraph') + toggleShortcut.value,
|
||||
active: !canvasStore.displayLinearMode
|
||||
},
|
||||
{
|
||||
mode: 'app',
|
||||
icon: 'icon-[lucide--panels-top-left]',
|
||||
label: t('breadcrumbsMenu.app'),
|
||||
switchLabel: t('breadcrumbsMenu.enterAppMode'),
|
||||
switchTooltip: t('breadcrumbsMenu.enterAppMode') + toggleShortcut.value,
|
||||
active: canvasStore.displayLinearMode
|
||||
}
|
||||
])
|
||||
|
||||
// Inactive segment first (left), active last (right). On mode switch the array
|
||||
// reorders and TransitionGroup FLIP-animates the keyed nodes to their new spots.
|
||||
const orderedSegments = computed(() =>
|
||||
[...segments.value].sort((a, b) => Number(a.active) - Number(b.active))
|
||||
)
|
||||
|
||||
function handleOpen(open: boolean) {
|
||||
if (open) {
|
||||
markAsSeen()
|
||||
@@ -89,32 +46,23 @@ function handleOpen(open: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
function switchMode() {
|
||||
function toggleModeTooltip() {
|
||||
const label = canvasStore.linearMode
|
||||
? t('breadcrumbsMenu.enterNodeGraph')
|
||||
: t('breadcrumbsMenu.enterAppMode')
|
||||
const shortcut = keybindingStore
|
||||
.getKeybindingByCommandId('Comfy.ToggleLinear')
|
||||
?.combo.toString()
|
||||
return label + (shortcut ? t('g.shortcutSuffix', { shortcut }) : '')
|
||||
}
|
||||
|
||||
function toggleLinearMode() {
|
||||
dropdownOpen.value = false
|
||||
void useCommandStore().execute('Comfy.ToggleLinear', {
|
||||
metadata: { source }
|
||||
})
|
||||
}
|
||||
|
||||
// The container is the dropdown trigger, so an inactive segment must stop its
|
||||
// pointer event from bubbling up and opening the menu instead of switching.
|
||||
function onSegmentPointerDown(seg: ViewModeSegment, e: PointerEvent) {
|
||||
if (!seg.active) e.stopPropagation()
|
||||
}
|
||||
|
||||
// Keyboard mirror of the pointer guard: stop Enter/Space on an inactive segment
|
||||
// from bubbling to the trigger. The button's native activation still fires
|
||||
// onSegmentClick to switch mode, so the menu stays closed.
|
||||
function onSegmentKeydown(seg: ViewModeSegment, e: KeyboardEvent) {
|
||||
if (!seg.active && (e.key === 'Enter' || e.key === ' ')) e.stopPropagation()
|
||||
}
|
||||
|
||||
function onSegmentClick(seg: ViewModeSegment, e: MouseEvent) {
|
||||
if (seg.active) return
|
||||
e.stopPropagation()
|
||||
switchMode()
|
||||
}
|
||||
|
||||
const tooltipPt = {
|
||||
root: {
|
||||
style: {
|
||||
@@ -127,7 +75,7 @@ const tooltipPt = {
|
||||
style: { whiteSpace: 'nowrap' }
|
||||
},
|
||||
arrow: {
|
||||
style: { left: '16px' }
|
||||
class: '!left-[16px]'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -138,81 +86,69 @@ const tooltipPt = {
|
||||
:modal="false"
|
||||
@update:open="handleOpen"
|
||||
>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<slot name="button" :has-unseen-items="hasUnseenItems">
|
||||
<div
|
||||
data-testid="view-mode-toggle"
|
||||
class="group pointer-events-auto relative inline-block rounded-lg bg-base-background p-1"
|
||||
class="pointer-events-auto inline-flex items-center rounded-lg bg-secondary-background"
|
||||
>
|
||||
<TransitionGroup
|
||||
tag="div"
|
||||
move-class="transition-[background-color,color,transform] duration-200"
|
||||
class="flex items-center gap-1"
|
||||
<Button
|
||||
v-tooltip.bottom="{
|
||||
value: toggleModeTooltip(),
|
||||
showDelay: 300,
|
||||
hideDelay: 300,
|
||||
pt: tooltipPt
|
||||
}"
|
||||
:aria-label="
|
||||
canvasStore.linearMode
|
||||
? t('breadcrumbsMenu.enterNodeGraph')
|
||||
: t('breadcrumbsMenu.enterAppMode')
|
||||
"
|
||||
variant="base"
|
||||
class="m-1"
|
||||
@pointerdown.stop
|
||||
@click="toggleLinearMode"
|
||||
>
|
||||
<Button
|
||||
v-for="seg in orderedSegments"
|
||||
:key="seg.mode"
|
||||
v-tooltip.bottom="{
|
||||
value: seg.active
|
||||
? t('breadcrumbsMenu.workflowActions')
|
||||
: seg.switchTooltip,
|
||||
showDelay: 300,
|
||||
hideDelay: 300,
|
||||
pt: seg.active ? undefined : tooltipPt
|
||||
}"
|
||||
type="button"
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
:aria-label="
|
||||
seg.active
|
||||
? `${seg.label} ${t('breadcrumbsMenu.workflowActions')}`
|
||||
: seg.switchLabel
|
||||
"
|
||||
<i
|
||||
class="size-4"
|
||||
:class="
|
||||
cn(
|
||||
'relative flex h-8 items-center gap-0 rounded-md font-normal transition-[background-color,color,transform] duration-200',
|
||||
seg.active
|
||||
? 'bg-secondary-background pr-2 pl-2.5 text-base-foreground group-data-[state=open]:bg-secondary-background-hover group-data-[state=open]:shadow-interface hover:bg-secondary-background'
|
||||
: 'w-8 justify-center bg-transparent text-muted-foreground hover:bg-secondary-background hover:text-base-foreground'
|
||||
)
|
||||
canvasStore.linearMode
|
||||
? 'icon-[lucide--panels-top-left]'
|
||||
: 'icon-[comfy--workflow]'
|
||||
"
|
||||
@pointerdown="onSegmentPointerDown(seg, $event)"
|
||||
@keydown="onSegmentKeydown(seg, $event)"
|
||||
@click="onSegmentClick(seg, $event)"
|
||||
/>
|
||||
</Button>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button
|
||||
v-tooltip="{
|
||||
value: t('breadcrumbsMenu.workflowActions'),
|
||||
showDelay: 300,
|
||||
hideDelay: 300
|
||||
}"
|
||||
variant="secondary"
|
||||
size="unset"
|
||||
:aria-label="t('breadcrumbsMenu.workflowActions')"
|
||||
class="relative h-10 gap-1 rounded-lg pr-2 pl-2.5 text-center data-[state=open]:bg-secondary-background-hover data-[state=open]:shadow-interface"
|
||||
>
|
||||
<i :class="cn('size-4 shrink-0', seg.icon)" aria-hidden="true" />
|
||||
<span>{{
|
||||
canvasStore.linearMode
|
||||
? t('breadcrumbsMenu.app')
|
||||
: t('breadcrumbsMenu.graph')
|
||||
}}</span>
|
||||
<i
|
||||
class="icon-[lucide--chevron-down] size-4 text-muted-foreground"
|
||||
/>
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'grid transition-[grid-template-columns,opacity] duration-200',
|
||||
seg.active
|
||||
? 'ml-1.5 grid-cols-[1fr] opacity-100'
|
||||
: 'grid-cols-[0fr] opacity-0'
|
||||
)
|
||||
"
|
||||
>
|
||||
<span
|
||||
class="flex min-w-0 items-center overflow-hidden text-sm leading-none whitespace-nowrap"
|
||||
>
|
||||
{{ seg.label }}
|
||||
<i
|
||||
class="ml-1 icon-[lucide--chevron-down] size-4 shrink-0 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="seg.active && hasUnseenItems"
|
||||
v-if="hasUnseenItems"
|
||||
aria-hidden="true"
|
||||
class="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-primary-background"
|
||||
/>
|
||||
</Button>
|
||||
</TransitionGroup>
|
||||
</DropdownMenuTrigger>
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
</slot>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent
|
||||
:align
|
||||
:side-offset="8"
|
||||
:side-offset="5"
|
||||
:collision-padding="10"
|
||||
class="z-1000 min-w-56 rounded-lg border border-border-subtle bg-base-background px-2 py-3 shadow-interface"
|
||||
>
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #side-toolbar>
|
||||
<SideToolbar v-if="showUI && !isBuilderMode && !linearMode" />
|
||||
<template v-if="showUI && !isBuilderMode" #side-toolbar>
|
||||
<SideToolbar />
|
||||
</template>
|
||||
<template v-if="showUI" #side-bar-panel>
|
||||
<div
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import Tooltip from 'primevue/tooltip'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import SideToolbar from './SideToolbar.vue'
|
||||
|
||||
interface TestTab {
|
||||
id: string
|
||||
icon: string
|
||||
tooltip: string
|
||||
label: string
|
||||
title: string
|
||||
}
|
||||
|
||||
const spies = vi.hoisted(() => ({
|
||||
trackUiButtonClicked: vi.fn(),
|
||||
toggleAssets: vi.fn()
|
||||
}))
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
linearMode: false,
|
||||
isMultiUserServer: false,
|
||||
sidebarTabs: [] as TestTab[],
|
||||
activeSidebarTab: null as { id: string } | null
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: false,
|
||||
isDesktop: false,
|
||||
isNightly: false
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workspaceStore', () => ({
|
||||
useWorkspaceStore: () => ({
|
||||
getSidebarTabs: () => state.sidebarTabs,
|
||||
sidebarTab: { activeSidebarTab: state.activeSidebarTab }
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: (key: string) => {
|
||||
if (key === 'Comfy.Sidebar.Size') return 'large'
|
||||
if (key === 'Comfy.Sidebar.Location') return 'left'
|
||||
return 'floating'
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/userStore', () => ({
|
||||
useUserStore: () => ({ isMultiUserServer: state.isMultiUserServer })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: () => ({
|
||||
commands: [
|
||||
{ id: 'Workspace.ToggleSidebarTab.assets', function: spies.toggleAssets }
|
||||
]
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({ linearMode: state.linearMode, canvas: null })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/keybindings/keybindingStore', () => ({
|
||||
useKeybindingStore: () => ({ getKeybindingByCommandId: () => undefined })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({ trackUiButtonClicked: spies.trackUiButtonClicked })
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: {} }
|
||||
})
|
||||
|
||||
type SideToolbarProps = ComponentProps<typeof SideToolbar>
|
||||
|
||||
function renderToolbar(props: SideToolbarProps = {}) {
|
||||
return render(SideToolbar, {
|
||||
props,
|
||||
global: {
|
||||
plugins: [PrimeVue, i18n],
|
||||
directives: { tooltip: Tooltip },
|
||||
stubs: {
|
||||
ComfyMenuButton: { template: '<div />' },
|
||||
SidebarTemplatesButton: { template: '<div />' },
|
||||
SidebarLogoutIcon: { template: '<div data-testid="logout" />' },
|
||||
SidebarHelpCenterIcon: { template: '<div />' },
|
||||
SidebarSettingsButton: { template: '<div />' },
|
||||
HelpCenterPopups: { template: '<div />' },
|
||||
SidebarBottomPanelToggleButton: {
|
||||
template: '<div data-testid="bottom-panel-toggle" />'
|
||||
},
|
||||
SidebarShortcutsToggleButton: {
|
||||
template: '<div data-testid="shortcuts-toggle" />'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const assetsTab: TestTab = {
|
||||
id: 'assets',
|
||||
icon: 'pi pi-image',
|
||||
tooltip: 'Assets',
|
||||
label: 'Assets',
|
||||
title: 'Assets'
|
||||
}
|
||||
|
||||
const workflowsTab: TestTab = {
|
||||
id: 'workflows',
|
||||
icon: 'pi pi-folder',
|
||||
tooltip: 'Workflows',
|
||||
label: 'Workflows',
|
||||
title: 'Workflows'
|
||||
}
|
||||
|
||||
describe('SideToolbar', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
state.linearMode = false
|
||||
state.isMultiUserServer = false
|
||||
state.sidebarTabs = [assetsTab, workflowsTab]
|
||||
state.activeSidebarTab = null
|
||||
})
|
||||
|
||||
it('renders only the tabs listed in visibleTabIds', () => {
|
||||
renderToolbar({ visibleTabIds: ['assets'] })
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Assets' })).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Workflows' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders all sidebar tabs when visibleTabIds is omitted', () => {
|
||||
renderToolbar()
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Assets' })).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Workflows' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('marks the toolbar as connected when forceConnected is true', () => {
|
||||
renderToolbar({ forceConnected: true })
|
||||
|
||||
expect(screen.getByTestId('side-toolbar')).toHaveClass('connected-sidebar')
|
||||
})
|
||||
|
||||
it('does not mark the toolbar as connected by default', () => {
|
||||
renderToolbar()
|
||||
|
||||
expect(screen.getByTestId('side-toolbar')).not.toHaveClass(
|
||||
'connected-sidebar'
|
||||
)
|
||||
})
|
||||
|
||||
it('shows the shortcuts and bottom panel toggles when not in linear mode', () => {
|
||||
state.linearMode = false
|
||||
renderToolbar()
|
||||
|
||||
expect(screen.getByTestId('shortcuts-toggle')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('bottom-panel-toggle')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the shortcuts and bottom panel toggles in linear mode', () => {
|
||||
state.linearMode = true
|
||||
renderToolbar()
|
||||
|
||||
expect(screen.queryByTestId('shortcuts-toggle')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('bottom-panel-toggle')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reports telemetry and runs the toggle command when a tab is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderToolbar({ visibleTabIds: ['assets'] })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Assets' }))
|
||||
|
||||
expect(spies.trackUiButtonClicked).toHaveBeenCalledWith({
|
||||
button_id: 'sidebar_tab_assets_media_selected',
|
||||
element_group: 'sidebar'
|
||||
})
|
||||
expect(spies.toggleAssets).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders the logout icon only on a multi-user server', () => {
|
||||
state.isMultiUserServer = true
|
||||
renderToolbar()
|
||||
|
||||
expect(screen.getByTestId('logout')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -42,14 +42,8 @@
|
||||
:is-small="isSmall"
|
||||
/>
|
||||
<SidebarHelpCenterIcon :is-small="isSmall" />
|
||||
<SidebarBottomPanelToggleButton
|
||||
v-if="!isCloud && !canvasStore.linearMode"
|
||||
:is-small="isSmall"
|
||||
/>
|
||||
<SidebarShortcutsToggleButton
|
||||
v-if="!canvasStore.linearMode"
|
||||
:is-small="isSmall"
|
||||
/>
|
||||
<SidebarBottomPanelToggleButton v-if="!isCloud" :is-small="isSmall" />
|
||||
<SidebarShortcutsToggleButton :is-small="isSmall" />
|
||||
<SidebarSettingsButton :is-small="isSmall" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -95,11 +89,6 @@ import SidebarIcon from './SidebarIcon.vue'
|
||||
import SidebarLogoutIcon from './SidebarLogoutIcon.vue'
|
||||
import SidebarTemplatesButton from './SidebarTemplatesButton.vue'
|
||||
|
||||
const { visibleTabIds, forceConnected = false } = defineProps<{
|
||||
visibleTabIds?: string[]
|
||||
forceConnected?: boolean
|
||||
}>()
|
||||
|
||||
const NightlySurveyController =
|
||||
isNightly && !isCloud && !isDesktop
|
||||
? defineAsyncComponent(
|
||||
@@ -126,18 +115,12 @@ const sidebarLocation = computed<'left' | 'right'>(() =>
|
||||
const sidebarStyle = computed(() => settingStore.get('Comfy.Sidebar.Style'))
|
||||
const isConnected = computed(
|
||||
() =>
|
||||
forceConnected ||
|
||||
selectedTab.value ||
|
||||
isOverflowing.value ||
|
||||
sidebarStyle.value === 'connected'
|
||||
)
|
||||
|
||||
const tabs = computed(() => {
|
||||
const all = workspaceStore.getSidebarTabs()
|
||||
return visibleTabIds
|
||||
? all.filter((tab) => visibleTabIds.includes(tab.id))
|
||||
: all
|
||||
})
|
||||
const tabs = computed(() => workspaceStore.getSidebarTabs())
|
||||
const selectedTab = computed(() => workspaceStore.sidebarTab.activeSidebarTab)
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import SidebarHelpCenterIcon from './SidebarHelpCenterIcon.vue'
|
||||
|
||||
const typeformState = vi.hoisted(() => ({
|
||||
typeformError: false,
|
||||
isValidTypeformId: true,
|
||||
typeformId: 'jmmzmlKw'
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/surveys/useTypeformEmbed', async () => {
|
||||
const { computed } = await import('vue')
|
||||
return {
|
||||
useTypeformEmbed: () => ({
|
||||
typeformError: computed(() => typeformState.typeformError),
|
||||
isValidTypeformId: computed(() => typeformState.isValidTypeformId),
|
||||
typeformId: computed(() => typeformState.typeformId)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/composables/useHelpCenter', async () => {
|
||||
const { ref } = await import('vue')
|
||||
return {
|
||||
useHelpCenter: () => ({
|
||||
shouldShowRedDot: ref(false),
|
||||
toggleHelpCenter: vi.fn()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({ get: () => 'left' })
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', async () => {
|
||||
const { computed } = await import('vue')
|
||||
return {
|
||||
useCanvasStore: () => ({ linearMode: computed(() => true) })
|
||||
}
|
||||
})
|
||||
|
||||
const FEEDBACK_LOAD_ERROR =
|
||||
'Failed to load feedback form. Please try again later.'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
menu: { help: 'Help' },
|
||||
sideToolbar: { helpCenter: 'Help Center' },
|
||||
linearMode: {
|
||||
giveFeedback: 'Give feedback',
|
||||
feedbackLoadError: FEEDBACK_LOAD_ERROR
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function renderIcon() {
|
||||
return render(SidebarHelpCenterIcon, {
|
||||
props: { isSmall: false },
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
Popover: {
|
||||
template: '<div><slot name="button" /><slot /></div>'
|
||||
},
|
||||
SidebarIcon: true
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('SidebarHelpCenterIcon', () => {
|
||||
beforeEach(() => {
|
||||
typeformState.typeformError = false
|
||||
typeformState.isValidTypeformId = true
|
||||
})
|
||||
|
||||
it('mounts the Typeform embed container when the id is valid and loads', () => {
|
||||
const { container } = renderIcon()
|
||||
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- attribute hook: the embed target has no ARIA role
|
||||
expect(container.querySelector('[data-tf-widget]')).not.toBeNull()
|
||||
expect(screen.queryByText(FEEDBACK_LOAD_ERROR)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the localized fallback instead of the embed when loading fails', () => {
|
||||
typeformState.typeformError = true
|
||||
const { container } = renderIcon()
|
||||
|
||||
expect(screen.getByText(FEEDBACK_LOAD_ERROR)).toBeInTheDocument()
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- attribute hook: the embed target has no ARIA role
|
||||
expect(container.querySelector('[data-tf-widget]')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the localized fallback when the form id is invalid', () => {
|
||||
typeformState.isValidTypeformId = false
|
||||
const { container } = renderIcon()
|
||||
|
||||
expect(screen.getByText(FEEDBACK_LOAD_ERROR)).toBeInTheDocument()
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- attribute hook: the embed target has no ARIA role
|
||||
expect(container.querySelector('[data-tf-widget]')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,34 +1,5 @@
|
||||
<template>
|
||||
<Popover
|
||||
v-if="linearMode"
|
||||
:side="sidebarOnLeft ? 'right' : 'left'"
|
||||
:side-offset="8"
|
||||
>
|
||||
<template #button>
|
||||
<SidebarIcon
|
||||
icon="pi pi-question-circle"
|
||||
class="comfy-help-center-btn"
|
||||
data-testid="help-center-button"
|
||||
:label="$t('menu.help')"
|
||||
:tooltip="$t('linearMode.giveFeedback')"
|
||||
:is-small="isSmall"
|
||||
/>
|
||||
</template>
|
||||
<div
|
||||
v-if="typeformError || !isValidTypeformId"
|
||||
class="text-danger p-4 text-sm"
|
||||
>
|
||||
{{ $t('linearMode.feedbackLoadError') }}
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
ref="feedbackRef"
|
||||
data-tf-auto-resize
|
||||
:data-tf-widget="typeformId"
|
||||
/>
|
||||
</Popover>
|
||||
<SidebarIcon
|
||||
v-else
|
||||
icon="pi pi-question-circle"
|
||||
class="comfy-help-center-btn"
|
||||
data-testid="help-center-button"
|
||||
@@ -42,34 +13,13 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, useTemplateRef } from 'vue'
|
||||
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import { useHelpCenter } from '@/composables/useHelpCenter'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTypeformEmbed } from '@/platform/surveys/useTypeformEmbed'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
|
||||
import SidebarIcon from './SidebarIcon.vue'
|
||||
|
||||
const APP_MODE_FEEDBACK_TYPEFORM_ID = 'jmmzmlKw'
|
||||
|
||||
defineProps<{
|
||||
isSmall: boolean
|
||||
}>()
|
||||
|
||||
const { shouldShowRedDot, toggleHelpCenter } = useHelpCenter()
|
||||
const { linearMode } = storeToRefs(useCanvasStore())
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const sidebarOnLeft = computed(
|
||||
() => settingStore.get('Comfy.Sidebar.Location') === 'left'
|
||||
)
|
||||
|
||||
const feedbackRef = useTemplateRef<HTMLDivElement>('feedbackRef')
|
||||
const { typeformError, isValidTypeformId, typeformId } = useTypeformEmbed(
|
||||
feedbackRef,
|
||||
APP_MODE_FEEDBACK_TYPEFORM_ID
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import AppsSidebarTab from './AppsSidebarTab.vue'
|
||||
|
||||
const execute = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: () => ({ execute })
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: { beta: 'Beta' },
|
||||
linearMode: {
|
||||
appModeToolbar: {
|
||||
apps: 'Apps',
|
||||
create: 'Create',
|
||||
createApp: 'Create app',
|
||||
appsEmptyMessage: 'No apps yet',
|
||||
appsEmptyMessageAction: 'Create one to get started'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function renderTab() {
|
||||
const user = userEvent.setup()
|
||||
const result = render(AppsSidebarTab, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
BaseWorkflowsSidebarTab: {
|
||||
template:
|
||||
'<div><slot name="header-actions" :has-results="true" /><slot name="empty-state" /></div>'
|
||||
},
|
||||
Button: {
|
||||
inheritAttrs: false,
|
||||
template:
|
||||
'<button v-bind="$attrs" @click="$emit(\'click\', $event)"><slot /></button>'
|
||||
},
|
||||
NoResultsPlaceholder: {
|
||||
emits: ['action'],
|
||||
template: '<button @click="$emit(\'action\')">empty</button>'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
return { ...result, user }
|
||||
}
|
||||
|
||||
describe('AppsSidebarTab', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('runs the new-workflow command when the create action is clicked', async () => {
|
||||
const { user } = renderTab()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Create' }))
|
||||
|
||||
expect(execute).toHaveBeenCalledWith('Comfy.NewBlankWorkflow')
|
||||
})
|
||||
|
||||
it('runs the new-workflow command from the empty-state action', async () => {
|
||||
const { user } = renderTab()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'empty' }))
|
||||
|
||||
expect(execute).toHaveBeenCalledWith('Comfy.NewBlankWorkflow')
|
||||
})
|
||||
})
|
||||
@@ -13,26 +13,18 @@
|
||||
{{ $t('g.beta') }}
|
||||
</span>
|
||||
</template>
|
||||
<template #header-actions="{ hasResults }">
|
||||
<Button
|
||||
v-if="hasResults"
|
||||
variant="secondary"
|
||||
size="md"
|
||||
:aria-label="$t('linearMode.appModeToolbar.create')"
|
||||
@click="createApp"
|
||||
>
|
||||
<i class="icon-[lucide--plus] size-4" aria-hidden="true" />
|
||||
{{ $t('linearMode.appModeToolbar.create') }}
|
||||
</Button>
|
||||
</template>
|
||||
<template #empty-state>
|
||||
<NoResultsPlaceholder
|
||||
button-variant="secondary"
|
||||
text-class="text-muted-foreground text-sm"
|
||||
:message="`${$t('linearMode.appModeToolbar.appsEmptyMessage')}\n${$t('linearMode.appModeToolbar.appsEmptyMessageAction')}`"
|
||||
button-icon="icon-[lucide--plus]"
|
||||
:button-label="$t('linearMode.appModeToolbar.createApp')"
|
||||
@action="createApp"
|
||||
:message="
|
||||
isAppMode
|
||||
? $t('linearMode.appModeToolbar.appsEmptyMessage')
|
||||
: `${$t('linearMode.appModeToolbar.appsEmptyMessage')}\n${$t('linearMode.appModeToolbar.appsEmptyMessageAction')}`
|
||||
"
|
||||
button-icon="icon-[lucide--hammer]"
|
||||
:button-label="isAppMode ? undefined : $t('linearMode.buildAnApp')"
|
||||
@action="enterAppMode"
|
||||
/>
|
||||
</template>
|
||||
</BaseWorkflowsSidebarTab>
|
||||
@@ -41,17 +33,16 @@
|
||||
<script setup lang="ts">
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
import BaseWorkflowsSidebarTab from '@/components/sidebar/tabs/BaseWorkflowsSidebarTab.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
const commandStore = useCommandStore()
|
||||
const { isAppMode, setMode } = useAppMode()
|
||||
|
||||
function isAppWorkflow(workflow: ComfyWorkflow): boolean {
|
||||
return workflow.suffix === 'app.json'
|
||||
}
|
||||
|
||||
function createApp() {
|
||||
void commandStore.execute('Comfy.NewBlankWorkflow')
|
||||
function enterAppMode() {
|
||||
setMode('app')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -30,10 +30,6 @@
|
||||
"
|
||||
/>
|
||||
</Button>
|
||||
<slot
|
||||
name="header-actions"
|
||||
:has-results="filteredPersistedWorkflows.length > 0"
|
||||
/>
|
||||
</template>
|
||||
<template #header>
|
||||
<SidebarTopArea>
|
||||
|
||||
46
src/components/ui/TypeformPopoverButton.vue
Normal file
46
src/components/ui/TypeformPopoverButton.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import { breakpointsTailwind, useBreakpoints, whenever } from '@vueuse/core'
|
||||
import { useTemplateRef } from 'vue'
|
||||
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
const { active = true } = defineProps<{
|
||||
dataTfWidget: string
|
||||
active?: boolean
|
||||
}>()
|
||||
|
||||
const feedbackRef = useTemplateRef('feedbackRef')
|
||||
const isMobile = useBreakpoints(breakpointsTailwind).smaller('md')
|
||||
|
||||
whenever(feedbackRef, () => {
|
||||
const scriptEl = document.createElement('script')
|
||||
scriptEl.src = '//embed.typeform.com/next/embed.js'
|
||||
feedbackRef.value?.appendChild(scriptEl)
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<Button
|
||||
v-if="isMobile"
|
||||
as="a"
|
||||
:href="`https://form.typeform.com/to/${dataTfWidget}`"
|
||||
target="_blank"
|
||||
variant="inverted"
|
||||
class="flex h-10 items-center justify-center gap-2.5 px-3 py-2"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<i class="icon-[lucide--circle-help] size-4" />
|
||||
</Button>
|
||||
<Popover v-else>
|
||||
<template #button>
|
||||
<Button
|
||||
variant="inverted"
|
||||
class="flex h-10 items-center justify-center gap-2.5 px-3 py-2"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<i class="icon-[lucide--circle-help] size-4" />
|
||||
</Button>
|
||||
</template>
|
||||
<div v-if="active" ref="feedbackRef" data-tf-auto-resize :data-tf-widget />
|
||||
</Popover>
|
||||
</template>
|
||||
@@ -8,12 +8,12 @@ export function useWorkflowStatusDismissal() {
|
||||
const executionStore = useExecutionStore()
|
||||
|
||||
watch(
|
||||
() => workflowStore.activeWorkflow,
|
||||
(workflow) => {
|
||||
if (
|
||||
workflow &&
|
||||
executionStore.getWorkflowStatus(workflow) !== 'running'
|
||||
) {
|
||||
() => {
|
||||
const workflow = workflowStore.activeWorkflow
|
||||
return [workflow, executionStore.getWorkflowStatus(workflow)] as const
|
||||
},
|
||||
([workflow, status]) => {
|
||||
if (workflow && status !== undefined && status !== 'running') {
|
||||
executionStore.clearWorkflowStatus(workflow)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -26,6 +26,7 @@ vi.mock('@/scripts/app', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/extensions/core/load3d', () => ({}))
|
||||
vi.mock('@/extensions/core/load3dAdvanced', () => ({}))
|
||||
vi.mock('@/extensions/core/load3dPreviewExtensions', () => ({}))
|
||||
vi.mock('@/extensions/core/saveMesh', () => ({}))
|
||||
|
||||
|
||||
@@ -246,3 +246,37 @@ describe('Comfy.UploadAudio AUDIOUPLOAD widget', () => {
|
||||
expect(mockFetchApi).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
type AudioUIWidget = (node: LGraphNode, inputName: string) => unknown
|
||||
|
||||
async function loadAudioUIWidget() {
|
||||
vi.resetModules()
|
||||
mockRegisterExtension.mockClear()
|
||||
await import('./uploadAudio')
|
||||
const extension = mockRegisterExtension.mock.calls
|
||||
.map(([extension]) => extension as ComfyExtension)
|
||||
.find((extension) => extension.name === 'Comfy.AudioWidget')
|
||||
if (!extension)
|
||||
throw new Error('Comfy.AudioWidget extension was not registered')
|
||||
const widgets = await extension.getCustomWidgets!(fromAny({}))
|
||||
return (widgets as Record<string, AudioUIWidget>).AUDIO_UI
|
||||
}
|
||||
|
||||
describe('Comfy.AudioWidget AUDIO_UI widget', () => {
|
||||
it('excludes the audio player from workflow and prompt serialization', async () => {
|
||||
const AUDIO_UI = await loadAudioUIWidget()
|
||||
const domWidget = {
|
||||
serialize: true,
|
||||
options: {} as Record<string, unknown>
|
||||
}
|
||||
const node = fromAny<LGraphNode, unknown>({
|
||||
addDOMWidget: vi.fn(() => domWidget),
|
||||
constructor: { nodeData: { output_node: false } }
|
||||
})
|
||||
|
||||
AUDIO_UI(node, 'audioUI')
|
||||
|
||||
expect(domWidget.serialize).toBe(false)
|
||||
expect(domWidget.options.serialize).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -128,6 +128,7 @@ app.registerExtension({
|
||||
const audioUIWidget: DOMWidget<HTMLAudioElement, string> =
|
||||
node.addDOMWidget(inputName, /* name=*/ 'audioUI', audio)
|
||||
audioUIWidget.serialize = false
|
||||
audioUIWidget.options.serialize = false
|
||||
const { nodeData } = node.constructor
|
||||
if (nodeData == null) throw new TypeError('nodeData is null')
|
||||
|
||||
|
||||
@@ -3594,6 +3594,8 @@
|
||||
},
|
||||
"linearMode": {
|
||||
"linearMode": "App Mode",
|
||||
"beta": "App mode in beta",
|
||||
"buildAnApp": "Build an app",
|
||||
"giveFeedback": "Give feedback",
|
||||
"graphMode": "Graph Mode",
|
||||
"dragAndDropImage": "Click to browse or drag an image",
|
||||
@@ -3626,10 +3628,7 @@
|
||||
"appBuilder": "App builder",
|
||||
"apps": "Apps",
|
||||
"appsEmptyMessage": "Saved apps will show up here.",
|
||||
"appsEmptyMessageAction": "Click below to build your first app.",
|
||||
"buildAnApp": "Build an app",
|
||||
"create": "Create",
|
||||
"createApp": "Create app"
|
||||
"appsEmptyMessageAction": "Click below to build your first app."
|
||||
},
|
||||
"arrange": {
|
||||
"noOutputs": "No outputs added yet",
|
||||
@@ -3672,7 +3671,6 @@
|
||||
"support": "contact our support",
|
||||
"promptShow": "Show error report"
|
||||
},
|
||||
"feedbackLoadError": "Failed to load feedback form. Please try again later.",
|
||||
"queue": {
|
||||
"clickToClear": "Click to clear queue",
|
||||
"clear": "Clear queue"
|
||||
|
||||
@@ -129,6 +129,21 @@ describe('useSubscriptionDialog', () => {
|
||||
expect(props).not.toHaveProperty('onChooseTeam')
|
||||
})
|
||||
|
||||
it('sizes the unified pricing dialog via the Reka contentClass, not the ignored PrimeVue style', () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsInPersonalWorkspace.value = true
|
||||
const { showPricingTable } = useSubscriptionDialog()
|
||||
|
||||
showPricingTable()
|
||||
|
||||
const { dialogComponentProps } = mockShowLayoutDialog.mock.calls[0][0]
|
||||
// Reka (the default renderer) sizes via size/contentClass; a PrimeVue
|
||||
// `style` width is silently ignored and collapses the wide table to the
|
||||
// default md (576px) frame.
|
||||
expect(dialogComponentProps).toHaveProperty('contentClass')
|
||||
expect(dialogComponentProps).not.toHaveProperty('style')
|
||||
})
|
||||
|
||||
it('defaults to the personal tab in a personal workspace', () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsInPersonalWorkspace.value = true
|
||||
|
||||
@@ -129,18 +129,15 @@ export const useSubscriptionDialog = () => {
|
||||
(workspaceStore.isInPersonalWorkspace ? 'personal' : 'team')
|
||||
},
|
||||
dialogComponentProps: {
|
||||
// The dialog hugs its content so each step sizes itself: the pricing
|
||||
// table stays wide/fixed (cards fill it, DES QA 2026-06-13) while the
|
||||
// compact confirm/success steps shrink instead of floating in the big
|
||||
// pricing modal. Sizes are set on the content root per checkoutStep.
|
||||
style: 'max-width: 95vw; max-height: 90vh;',
|
||||
pt: {
|
||||
root: { class: 'rounded-2xl bg-transparent' },
|
||||
content: {
|
||||
class:
|
||||
'!p-0 rounded-2xl border border-border-default bg-secondary-background shadow-[0_25px_80px_rgba(5,6,12,0.45)]'
|
||||
}
|
||||
}
|
||||
// Reka (the default renderer) sizes via size/contentClass; a PrimeVue
|
||||
// `style` width is ignored here and collapses the table to the default
|
||||
// `md` frame. `w-fit` lets each step hug its content — the pricing
|
||||
// table fills its 1280px content while the compact confirm/success
|
||||
// steps shrink (the content root sets its own width per checkoutStep).
|
||||
renderer: 'reka',
|
||||
size: 'full',
|
||||
contentClass:
|
||||
'w-fit max-w-[min(1280px,95vw)] sm:max-w-[min(1280px,95vw)] max-h-[90vh] rounded-2xl border border-border-default bg-secondary-background shadow-[0_25px_80px_rgba(5,6,12,0.45)]'
|
||||
}
|
||||
})
|
||||
return
|
||||
|
||||
@@ -1,21 +1,16 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { nextTick, ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphCanvas, Positionable } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
|
||||
const { appModeState } = vi.hoisted(() => ({
|
||||
appModeState: {} as { isAppMode: Ref<boolean> }
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useAppMode', () => ({
|
||||
useAppMode: () => ({
|
||||
isAppMode: appModeState.isAppMode,
|
||||
isAppMode: { value: false },
|
||||
setMode: vi.fn()
|
||||
})
|
||||
}))
|
||||
@@ -48,7 +43,6 @@ describe('useCanvasStore', () => {
|
||||
let store: ReturnType<typeof useCanvasStore>
|
||||
|
||||
beforeEach(() => {
|
||||
appModeState.isAppMode = ref(false)
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
store = useCanvasStore()
|
||||
vi.clearAllMocks()
|
||||
@@ -135,69 +129,4 @@ describe('useCanvasStore', () => {
|
||||
|
||||
expect(store.selectedNodeIds).toHaveLength(0)
|
||||
})
|
||||
|
||||
describe('displayLinearMode', () => {
|
||||
const rafQueue = new Map<number, FrameRequestCallback>()
|
||||
let nextHandle: number
|
||||
|
||||
const advanceFrame = () => {
|
||||
const callbacks = [...rafQueue.values()]
|
||||
rafQueue.clear()
|
||||
for (const cb of callbacks) cb(0)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
rafQueue.clear()
|
||||
nextHandle = 0
|
||||
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
|
||||
const handle = ++nextHandle
|
||||
rafQueue.set(handle, cb)
|
||||
return handle
|
||||
})
|
||||
vi.stubGlobal('cancelAnimationFrame', (handle: number) => {
|
||||
rafQueue.delete(handle)
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('lags the view mode by two frames so the toggle can animate the switch', async () => {
|
||||
expect(store.displayLinearMode).toBe(false)
|
||||
|
||||
appModeState.isAppMode.value = true
|
||||
await nextTick()
|
||||
|
||||
// The real mode has flipped, but the displayed mode still lags so a toggle
|
||||
// that mounts now renders the old order before animating to the new one.
|
||||
expect(store.linearMode).toBe(true)
|
||||
expect(store.displayLinearMode).toBe(false)
|
||||
|
||||
// First frame only schedules the second; the displayed mode must not move.
|
||||
advanceFrame()
|
||||
expect(store.displayLinearMode).toBe(false)
|
||||
|
||||
// The second frame is the one that flips the displayed mode.
|
||||
advanceFrame()
|
||||
expect(store.displayLinearMode).toBe(true)
|
||||
})
|
||||
|
||||
it('cancels a stale frame chain so a rapid toggle has no transient flash', async () => {
|
||||
appModeState.isAppMode.value = true
|
||||
await nextTick()
|
||||
advanceFrame()
|
||||
|
||||
appModeState.isAppMode.value = false
|
||||
await nextTick()
|
||||
|
||||
// The pending second frame from the first toggle is cancelled, so it can
|
||||
// no longer flip the displayed mode to true before settling on false.
|
||||
advanceFrame()
|
||||
expect(store.displayLinearMode).toBe(false)
|
||||
|
||||
advanceFrame()
|
||||
expect(store.displayLinearMode).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEventListener, whenever } from '@vueuse/core'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, markRaw, ref, shallowRef, watch } from 'vue'
|
||||
import { computed, markRaw, ref, shallowRef } from 'vue'
|
||||
import type { Raw } from 'vue'
|
||||
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
@@ -57,26 +57,6 @@ export const useCanvasStore = defineStore('canvas', () => {
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Frame-lagged mirror of {@link linearMode} that drives the view-mode toggle's
|
||||
* segment morph. Lagging by two frames lets a toggle that mounts mid-switch
|
||||
* render the previous mode first, then animate into the new one. It lives in
|
||||
* the store so the value outlives the graph-mode toggle unmounting and the
|
||||
* app-mode toggle mounting in its place during a switch.
|
||||
*/
|
||||
const displayLinearMode = ref(linearMode.value)
|
||||
let outerFrame: number | undefined
|
||||
let innerFrame: number | undefined
|
||||
watch(linearMode, (next) => {
|
||||
if (outerFrame !== undefined) cancelAnimationFrame(outerFrame)
|
||||
if (innerFrame !== undefined) cancelAnimationFrame(innerFrame)
|
||||
outerFrame = requestAnimationFrame(() => {
|
||||
innerFrame = requestAnimationFrame(() => {
|
||||
displayLinearMode.value = next
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Set up scale synchronization when canvas is available
|
||||
let originalOnChanged: ((scale: number, offset: Point) => void) | undefined =
|
||||
undefined
|
||||
@@ -208,7 +188,6 @@ export const useCanvasStore = defineStore('canvas', () => {
|
||||
rerouteSelected,
|
||||
appScalePercentage,
|
||||
linearMode,
|
||||
displayLinearMode,
|
||||
updateSelectedItems,
|
||||
getCanvas,
|
||||
setAppZoomFromPercentage,
|
||||
|
||||
42
src/renderer/extensions/linearMode/LinearFeedback.vue
Normal file
42
src/renderer/extensions/linearMode/LinearFeedback.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import TypeformPopoverButton from '@/components/ui/TypeformPopoverButton.vue'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { side, widgetId } = defineProps<{
|
||||
side: 'left' | 'right'
|
||||
widgetId: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const settingStore = useSettingStore()
|
||||
const sidebarOnLeft = computed(
|
||||
() => settingStore.get('Comfy.Sidebar.Location') === 'left'
|
||||
)
|
||||
const visible = computed(() => sidebarOnLeft.value === (side === 'left'))
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex items-center gap-2 self-end px-4 pb-4 text-nowrap text-base-foreground',
|
||||
side === 'right' && 'flex-row-reverse',
|
||||
!visible && 'invisible'
|
||||
)
|
||||
"
|
||||
:aria-hidden="!visible || undefined"
|
||||
>
|
||||
<TypeformPopoverButton
|
||||
:active="visible"
|
||||
:data-tf-widget="widgetId"
|
||||
:align="side === 'left' ? 'start' : 'end'"
|
||||
/>
|
||||
<div class="flex flex-col text-sm text-muted-foreground">
|
||||
<span>{{ t('linearMode.beta') }}</span>
|
||||
<span>{{ t('linearMode.giveFeedback') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,186 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { defineComponent } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
import LinearPreview from './LinearPreview.vue'
|
||||
import type { OutputSelection } from './linearModeTypes'
|
||||
|
||||
const appModeState = vi.hoisted(() => ({
|
||||
isBuilderMode: false,
|
||||
isArrangeMode: false
|
||||
}))
|
||||
|
||||
const outputHistoryState = vi.hoisted(() => ({
|
||||
isWorkflowActive: false
|
||||
}))
|
||||
|
||||
const spies = vi.hoisted(() => ({
|
||||
cancelActiveWorkflowJobs: vi.fn(),
|
||||
deleteAssets: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useAppMode', async () => {
|
||||
const { computed } = await import('vue')
|
||||
return {
|
||||
useAppMode: () => ({
|
||||
isBuilderMode: computed(() => appModeState.isBuilderMode),
|
||||
isArrangeMode: computed(() => appModeState.isArrangeMode)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/renderer/extensions/linearMode/useOutputHistory', async () => {
|
||||
const { computed } = await import('vue')
|
||||
return {
|
||||
useOutputHistory: () => ({
|
||||
allOutputs: () => [],
|
||||
isWorkflowActive: computed(() => outputHistoryState.isWorkflowActive),
|
||||
cancelActiveWorkflowJobs: spies.cancelActiveWorkflowJobs
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/assets/composables/useMediaAssetActions', () => ({
|
||||
useMediaAssetActions: () => ({ deleteAssets: spies.deleteAssets })
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: { rootGraph: { id: 'root' }, loadGraphData: vi.fn() }
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: { download: 'Download' },
|
||||
linearMode: {
|
||||
rerun: 'Rerun',
|
||||
reuseParameters: 'Reuse Parameters',
|
||||
cancelThisRun: 'Cancel this run',
|
||||
deleteAllAssets: 'Delete all',
|
||||
downloadAll: 'Download all'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function renderPreview(
|
||||
props: { mobile?: boolean } = {},
|
||||
emitSelection?: OutputSelection
|
||||
) {
|
||||
const user = userEvent.setup()
|
||||
const outputHistoryStub = emitSelection
|
||||
? defineComponent({
|
||||
emits: ['update-selection'],
|
||||
mounted() {
|
||||
this.$emit('update-selection', emitSelection)
|
||||
},
|
||||
template: '<div data-testid="output-history" :class="$attrs.class" />'
|
||||
})
|
||||
: {
|
||||
template: '<div data-testid="output-history" :class="$attrs.class" />'
|
||||
}
|
||||
const result = render(LinearPreview, {
|
||||
props,
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
directives: { tooltip: {} },
|
||||
stubs: {
|
||||
ImagePreview: { template: '<div data-testid="image-preview" />' },
|
||||
LatentPreview: { template: '<div data-testid="latent-preview" />' },
|
||||
LinearWelcome: { template: '<div data-testid="linear-welcome" />' },
|
||||
LinearArrange: { template: '<div data-testid="linear-arrange" />' },
|
||||
MediaOutputPreview: true,
|
||||
Popover: { template: '<div data-testid="output-popover" />' },
|
||||
Button: {
|
||||
inheritAttrs: false,
|
||||
template:
|
||||
'<button v-bind="$attrs" @click="$emit(\'click\', $event)"><slot /></button>'
|
||||
},
|
||||
OutputHistory: outputHistoryStub
|
||||
}
|
||||
}
|
||||
})
|
||||
return { ...result, user }
|
||||
}
|
||||
|
||||
describe('LinearPreview', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
appModeState.isBuilderMode = false
|
||||
appModeState.isArrangeMode = false
|
||||
outputHistoryState.isWorkflowActive = false
|
||||
})
|
||||
|
||||
it('renders the welcome screen and output history when idle', () => {
|
||||
renderPreview()
|
||||
|
||||
expect(screen.getByTestId('linear-welcome')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('output-history')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('adds the desktop layout classes to the output history when not mobile', () => {
|
||||
renderPreview({ mobile: false })
|
||||
|
||||
expect(screen.getByTestId('output-history')).toHaveClass('z-10', 'min-w-0')
|
||||
})
|
||||
|
||||
it('omits the desktop layout classes from the output history on mobile', () => {
|
||||
renderPreview({ mobile: true })
|
||||
|
||||
const history = screen.getByTestId('output-history')
|
||||
expect(history).not.toHaveClass('z-10')
|
||||
expect(history).not.toHaveClass('min-w-0')
|
||||
})
|
||||
|
||||
it('hides the output history in builder mode', () => {
|
||||
appModeState.isBuilderMode = true
|
||||
|
||||
renderPreview()
|
||||
|
||||
expect(screen.queryByTestId('output-history')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the arrange view in arrange mode', () => {
|
||||
appModeState.isArrangeMode = true
|
||||
|
||||
renderPreview()
|
||||
|
||||
expect(screen.getByTestId('linear-arrange')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('linear-welcome')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the latent preview and cancel control while a workflow is active', async () => {
|
||||
outputHistoryState.isWorkflowActive = true
|
||||
|
||||
const { user } = renderPreview()
|
||||
|
||||
expect(screen.getByTestId('latent-preview')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByTestId('linear-cancel-run'))
|
||||
|
||||
expect(spies.cancelActiveWorkflowJobs).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows the selected asset actions and latent image when a selection is made', async () => {
|
||||
const asset: AssetItem = { id: 'a1', name: 'out.png', tags: [] }
|
||||
const selection: OutputSelection = {
|
||||
asset,
|
||||
canShowPreview: true,
|
||||
latentPreviewUrl: 'blob:preview'
|
||||
}
|
||||
|
||||
renderPreview({}, selection)
|
||||
|
||||
expect(await screen.findByTestId('linear-output-info')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('image-preview')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('output-popover')).toBeInTheDocument()
|
||||
expect(screen.getByText('Rerun')).toBeInTheDocument()
|
||||
expect(screen.getByText('Reuse Parameters')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -14,22 +14,23 @@ import ImagePreview from '@/renderer/extensions/linearMode/ImagePreview.vue'
|
||||
import LatentPreview from '@/renderer/extensions/linearMode/LatentPreview.vue'
|
||||
import LinearWelcome from '@/renderer/extensions/linearMode/LinearWelcome.vue'
|
||||
import LinearArrange from '@/renderer/extensions/linearMode/LinearArrange.vue'
|
||||
import LinearFeedback from '@/renderer/extensions/linearMode/LinearFeedback.vue'
|
||||
import MediaOutputPreview from '@/renderer/extensions/linearMode/MediaOutputPreview.vue'
|
||||
import OutputHistory from '@/renderer/extensions/linearMode/OutputHistory.vue'
|
||||
import { useOutputHistory } from '@/renderer/extensions/linearMode/useOutputHistory'
|
||||
import type { OutputSelection } from '@/renderer/extensions/linearMode/linearModeTypes'
|
||||
import { app } from '@/scripts/app'
|
||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { t } = useI18n()
|
||||
const mediaActions = useMediaAssetActions()
|
||||
const { isBuilderMode, isArrangeMode } = useAppMode()
|
||||
const { allOutputs, isWorkflowActive, cancelActiveWorkflowJobs } =
|
||||
useOutputHistory()
|
||||
const { runButtonClick, mobile } = defineProps<{
|
||||
const { runButtonClick, mobile, typeformWidgetId } = defineProps<{
|
||||
runButtonClick?: (e: Event) => void
|
||||
mobile?: boolean
|
||||
typeformWidgetId?: string
|
||||
}>()
|
||||
|
||||
const selectedItem = ref<AssetItem>()
|
||||
@@ -146,9 +147,28 @@ async function rerun(e: Event) {
|
||||
<LatentPreview v-else-if="showSkeleton || isWorkflowActive" />
|
||||
<LinearArrange v-else-if="isArrangeMode" />
|
||||
<LinearWelcome v-else />
|
||||
<div
|
||||
v-if="!mobile"
|
||||
class="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center"
|
||||
>
|
||||
<LinearFeedback
|
||||
v-if="typeformWidgetId"
|
||||
side="left"
|
||||
:widget-id="typeformWidgetId"
|
||||
/>
|
||||
<OutputHistory
|
||||
v-if="!isBuilderMode"
|
||||
class="z-10 min-w-0"
|
||||
@update-selection="handleSelection"
|
||||
/>
|
||||
<LinearFeedback
|
||||
v-if="typeformWidgetId"
|
||||
side="right"
|
||||
:widget-id="typeformWidgetId"
|
||||
/>
|
||||
</div>
|
||||
<OutputHistory
|
||||
v-if="!isBuilderMode"
|
||||
:class="cn(!mobile && 'z-10 min-w-0')"
|
||||
v-else-if="!isBuilderMode"
|
||||
@update-selection="handleSelection"
|
||||
/>
|
||||
</template>
|
||||
|
||||
227
src/utils/queueDisplay.test.ts
Normal file
227
src/utils/queueDisplay.test.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import type { JobState } from '@/types/queue'
|
||||
import type { BuildJobDisplayCtx } from '@/utils/queueDisplay'
|
||||
import { buildJobDisplay, iconForJobState } from '@/utils/queueDisplay'
|
||||
|
||||
type QueueDisplayTask = Parameters<typeof buildJobDisplay>[0]
|
||||
type PreviewOutput = NonNullable<QueueDisplayTask['previewOutput']>
|
||||
|
||||
function createJob(
|
||||
status: JobListItem['status'],
|
||||
overrides: Partial<JobListItem> = {}
|
||||
): JobListItem {
|
||||
return {
|
||||
id: 'job-123456',
|
||||
status,
|
||||
create_time: 1_710_000_000_000,
|
||||
priority: 12,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function createTask({
|
||||
job,
|
||||
jobId = 'job-123456',
|
||||
createTime = 1_710_000_000_000,
|
||||
executionTime,
|
||||
executionTimeInSeconds,
|
||||
previewOutput
|
||||
}: {
|
||||
job?: Partial<JobListItem>
|
||||
jobId?: string
|
||||
createTime?: number
|
||||
executionTime?: number
|
||||
executionTimeInSeconds?: number
|
||||
previewOutput?: PreviewOutput
|
||||
} = {}): QueueDisplayTask {
|
||||
return {
|
||||
job: createJob(job?.status ?? 'pending', job),
|
||||
jobId,
|
||||
createTime,
|
||||
executionTime,
|
||||
executionTimeInSeconds,
|
||||
previewOutput
|
||||
} as QueueDisplayTask
|
||||
}
|
||||
|
||||
function createCtx(
|
||||
overrides: Partial<BuildJobDisplayCtx> = {}
|
||||
): BuildJobDisplayCtx {
|
||||
return {
|
||||
t: (key, values) => {
|
||||
const entries = Object.entries(values ?? {})
|
||||
if (!entries.length) return key
|
||||
|
||||
return `${key}(${entries
|
||||
.map(([name, value]) => `${name}=${String(value)}`)
|
||||
.join(',')})`
|
||||
},
|
||||
locale: 'en-US',
|
||||
formatClockTimeFn: (ts, locale) => `${locale}:${ts}`,
|
||||
isActive: false,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('iconForJobState', () => {
|
||||
it.for<[JobState, string]>([
|
||||
['pending', 'icon-[lucide--loader-circle]'],
|
||||
['initialization', 'icon-[lucide--server-crash]'],
|
||||
['running', 'icon-[lucide--zap]'],
|
||||
['completed', 'icon-[lucide--check-check]'],
|
||||
['failed', 'icon-[lucide--alert-circle]']
|
||||
])('maps %s to its icon', ([state, icon]) => {
|
||||
expect(iconForJobState(state)).toBe(icon)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildJobDisplay', () => {
|
||||
it('shows the added hint for pending jobs when requested', () => {
|
||||
expect(
|
||||
buildJobDisplay(
|
||||
createTask(),
|
||||
'pending',
|
||||
createCtx({ showAddedHint: true })
|
||||
)
|
||||
).toEqual({
|
||||
iconName: 'icon-[lucide--check]',
|
||||
primary: 'queue.jobAddedToQueue',
|
||||
secondary: 'en-US:1710000000000',
|
||||
showClear: true
|
||||
})
|
||||
})
|
||||
|
||||
it('shows queued time for pending and initializing jobs', () => {
|
||||
expect(buildJobDisplay(createTask(), 'pending', createCtx())).toMatchObject(
|
||||
{
|
||||
iconName: 'icon-[lucide--loader-circle]',
|
||||
primary: 'queue.inQueue',
|
||||
secondary: 'en-US:1710000000000',
|
||||
showClear: true
|
||||
}
|
||||
)
|
||||
|
||||
expect(
|
||||
buildJobDisplay(createTask(), 'initialization', createCtx())
|
||||
).toMatchObject({
|
||||
iconName: 'icon-[lucide--server-crash]',
|
||||
primary: 'queue.initializingAlmostReady',
|
||||
secondary: 'en-US:1710000000000',
|
||||
showClear: true
|
||||
})
|
||||
})
|
||||
|
||||
it('formats active running progress from the injected context', () => {
|
||||
expect(
|
||||
buildJobDisplay(
|
||||
createTask({ job: { status: 'in_progress' } }),
|
||||
'running',
|
||||
createCtx({
|
||||
isActive: true,
|
||||
totalPercent: 42.7,
|
||||
currentNodePercent: -10,
|
||||
currentNodeName: 'KSampler'
|
||||
})
|
||||
)
|
||||
).toEqual({
|
||||
iconName: 'icon-[lucide--zap]',
|
||||
primary: 'sideToolbar.queueProgressOverlay.total(percent=43%)',
|
||||
secondary:
|
||||
'KSampler sideToolbar.queueProgressOverlay.colonPercent(percent=0%)',
|
||||
showClear: true
|
||||
})
|
||||
})
|
||||
|
||||
it('uses a compact running label when the job is not active', () => {
|
||||
expect(
|
||||
buildJobDisplay(
|
||||
createTask({ job: { status: 'in_progress' } }),
|
||||
'running',
|
||||
createCtx()
|
||||
)
|
||||
).toEqual({
|
||||
iconName: 'icon-[lucide--zap]',
|
||||
primary: 'g.running',
|
||||
secondary: '',
|
||||
showClear: true
|
||||
})
|
||||
})
|
||||
|
||||
it('shows local completed jobs as the preview filename', () => {
|
||||
expect(
|
||||
buildJobDisplay(
|
||||
createTask({
|
||||
job: {
|
||||
status: 'completed'
|
||||
},
|
||||
executionTimeInSeconds: 3.51,
|
||||
previewOutput: {
|
||||
filename: 'preview.png',
|
||||
isImage: true,
|
||||
url: '/api/view?filename=preview.png&type=output&subfolder='
|
||||
} as PreviewOutput
|
||||
}),
|
||||
'completed',
|
||||
createCtx()
|
||||
)
|
||||
).toEqual({
|
||||
iconName: 'icon-[lucide--check-check]',
|
||||
iconImageUrl: '/api/view?filename=preview.png&type=output&subfolder=',
|
||||
primary: 'preview.png',
|
||||
secondary: '3.51s',
|
||||
showClear: false
|
||||
})
|
||||
})
|
||||
|
||||
it('shows cloud completed jobs as elapsed time', () => {
|
||||
expect(
|
||||
buildJobDisplay(
|
||||
createTask({
|
||||
job: {
|
||||
status: 'completed'
|
||||
},
|
||||
executionTime: 64_000,
|
||||
executionTimeInSeconds: 64
|
||||
}),
|
||||
'completed',
|
||||
createCtx({ isCloud: true })
|
||||
)
|
||||
).toMatchObject({
|
||||
iconName: 'icon-[lucide--check-check]',
|
||||
primary: 'queue.completedIn(duration=1m 4s)',
|
||||
secondary: '64.00s',
|
||||
showClear: false
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to job title for completed jobs without a preview filename', () => {
|
||||
expect(
|
||||
buildJobDisplay(
|
||||
createTask({
|
||||
job: {
|
||||
status: 'completed',
|
||||
priority: 42
|
||||
}
|
||||
}),
|
||||
'completed',
|
||||
createCtx()
|
||||
)
|
||||
).toMatchObject({
|
||||
iconName: 'icon-[lucide--check-check]',
|
||||
primary: 'g.job #42',
|
||||
secondary: '',
|
||||
showClear: false
|
||||
})
|
||||
})
|
||||
|
||||
it('shows failed jobs as clearable failures', () => {
|
||||
expect(buildJobDisplay(createTask(), 'failed', createCtx())).toEqual({
|
||||
iconName: 'icon-[lucide--alert-circle]',
|
||||
primary: 'g.failed',
|
||||
secondary: 'g.failed',
|
||||
showClear: true
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,228 +0,0 @@
|
||||
import type * as VueUseCore from '@vueuse/core'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { defineComponent } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { SidebarTabExtension } from '@/types/extensionTypes'
|
||||
|
||||
import LinearView from './LinearView.vue'
|
||||
|
||||
interface ViewState {
|
||||
mobileDisplay: boolean
|
||||
sidebarLocation: 'left' | 'right'
|
||||
isBuilderMode: boolean
|
||||
isArrangeMode: boolean
|
||||
activeTab: SidebarTabExtension | null
|
||||
hasOutputs: boolean
|
||||
}
|
||||
|
||||
const state = vi.hoisted<ViewState>(() => ({
|
||||
mobileDisplay: false,
|
||||
sidebarLocation: 'left',
|
||||
isBuilderMode: false,
|
||||
isArrangeMode: false,
|
||||
activeTab: null,
|
||||
hasOutputs: false
|
||||
}))
|
||||
|
||||
const onResizeEnd = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@vueuse/core', async () => {
|
||||
const actual = await vi.importActual<typeof VueUseCore>('@vueuse/core')
|
||||
const { computed } = await import('vue')
|
||||
return {
|
||||
...actual,
|
||||
useBreakpoints: () => ({
|
||||
smaller: () => computed(() => state.mobileDisplay)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: (key: string) =>
|
||||
key === 'Comfy.Sidebar.Location' ? state.sidebarLocation : undefined
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workspaceStore', () => ({
|
||||
useWorkspaceStore: () => ({
|
||||
sidebarTab: {
|
||||
get activeSidebarTab() {
|
||||
return state.activeTab
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useAppMode', async () => {
|
||||
const { computed } = await import('vue')
|
||||
return {
|
||||
useAppMode: () => ({
|
||||
isBuilderMode: computed(() => state.isBuilderMode),
|
||||
isArrangeMode: computed(() => state.isArrangeMode)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/stores/appModeStore', async () => {
|
||||
const { reactive, computed } = await import('vue')
|
||||
return {
|
||||
useAppModeStore: () =>
|
||||
reactive({ hasOutputs: computed(() => state.hasOutputs) })
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/composables/useStablePrimeVueSplitterSizer', () => ({
|
||||
useStablePrimeVueSplitterSizer: () => ({ onResizeEnd })
|
||||
}))
|
||||
|
||||
const passthroughStub = { template: '<div><slot /></div>' }
|
||||
|
||||
function leafStub(testId: string) {
|
||||
return { template: `<div data-testid="${testId}" />` }
|
||||
}
|
||||
|
||||
const baseStubs = {
|
||||
Splitter: passthroughStub,
|
||||
SplitterPanel: passthroughStub,
|
||||
MobileDisplay: leafStub('mobile-display'),
|
||||
AppBuilder: leafStub('app-builder'),
|
||||
AppModeToolbar: leafStub('app-mode-toolbar'),
|
||||
ExtensionSlot: leafStub('extension-slot'),
|
||||
ErrorOverlay: leafStub('error-overlay'),
|
||||
SideToolbar: leafStub('side-toolbar'),
|
||||
TopbarBadges: leafStub('topbar-badges'),
|
||||
TopbarSubscribeButton: leafStub('topbar-subscribe-button'),
|
||||
WorkflowTabs: leafStub('workflow-tabs'),
|
||||
LinearControls: leafStub('linear-controls'),
|
||||
LinearPreview: leafStub('linear-preview'),
|
||||
LinearProgressBar: leafStub('linear-progress-bar')
|
||||
}
|
||||
|
||||
function renderView(overrides: Partial<ViewState> = {}) {
|
||||
Object.assign(state, overrides)
|
||||
return render(LinearView, {
|
||||
global: { stubs: baseStubs }
|
||||
})
|
||||
}
|
||||
|
||||
const sampleTab = { id: 'assets' } as SidebarTabExtension
|
||||
|
||||
function getFlexContainer(container: Element): HTMLElement {
|
||||
// eslint-disable-next-line testing-library/no-node-access -- the layout wrapper that carries the flex-direction class has no ARIA role
|
||||
const el = container.querySelector<HTMLElement>('.flex-1')
|
||||
if (!el) throw new Error('flex container not found')
|
||||
return el
|
||||
}
|
||||
|
||||
describe('LinearView', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
Object.assign(state, {
|
||||
mobileDisplay: false,
|
||||
sidebarLocation: 'left',
|
||||
isBuilderMode: false,
|
||||
isArrangeMode: false,
|
||||
activeTab: null,
|
||||
hasOutputs: false
|
||||
} satisfies ViewState)
|
||||
})
|
||||
|
||||
it('renders only the mobile display on small screens', () => {
|
||||
renderView({ mobileDisplay: true })
|
||||
|
||||
expect(screen.getByTestId('mobile-display')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('workflow-tabs')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('linear-preview')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the desktop layout with the center panel on larger screens', () => {
|
||||
renderView()
|
||||
|
||||
expect(screen.queryByTestId('mobile-display')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('workflow-tabs')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('linear-header-progress-bar')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('linear-preview')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('error-overlay')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('lays out left-to-right and shows the toolbar in app mode', () => {
|
||||
const { container } = renderView({
|
||||
sidebarLocation: 'left',
|
||||
activeTab: sampleTab,
|
||||
hasOutputs: true
|
||||
})
|
||||
|
||||
expect(getFlexContainer(container)).toHaveClass('flex-row')
|
||||
expect(screen.getByTestId('side-toolbar')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('app-mode-toolbar')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('extension-slot')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('linear-controls')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reverses the layout when the sidebar is on the right', () => {
|
||||
const { container } = renderView({
|
||||
sidebarLocation: 'right',
|
||||
activeTab: sampleTab,
|
||||
hasOutputs: true
|
||||
})
|
||||
|
||||
expect(getFlexContainer(container)).toHaveClass('flex-row-reverse')
|
||||
expect(screen.getByTestId('extension-slot')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('linear-controls')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('omits both side panels when there is no active tab or output', () => {
|
||||
renderView({ activeTab: null, hasOutputs: false })
|
||||
|
||||
expect(screen.queryByTestId('extension-slot')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('linear-controls')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('linear-preview')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the app builder in the right panel for left sidebar arrange mode', () => {
|
||||
renderView({
|
||||
sidebarLocation: 'left',
|
||||
isBuilderMode: true,
|
||||
isArrangeMode: true
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('app-builder')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('side-toolbar')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('app-mode-toolbar')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the app builder in the left panel for right sidebar arrange mode', () => {
|
||||
renderView({
|
||||
sidebarLocation: 'right',
|
||||
isBuilderMode: true,
|
||||
isArrangeMode: true
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('app-builder')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('side-toolbar')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('blocks the native splitter resize start and runs the resize-end handler', () => {
|
||||
const preventDefault = vi.fn()
|
||||
render(LinearView, {
|
||||
global: {
|
||||
stubs: {
|
||||
...baseStubs,
|
||||
Splitter: defineComponent({
|
||||
emits: ['resizestart', 'resizeend'],
|
||||
mounted() {
|
||||
this.$emit('resizestart', { originalEvent: { preventDefault } })
|
||||
this.$emit('resizeend')
|
||||
},
|
||||
template: '<div><slot /></div>'
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(preventDefault).toHaveBeenCalled()
|
||||
expect(onResizeEnd).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -10,11 +10,11 @@ import AppBuilder from '@/components/builder/AppBuilder.vue'
|
||||
import AppModeToolbar from '@/components/appMode/AppModeToolbar.vue'
|
||||
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
|
||||
import ErrorOverlay from '@/components/error/ErrorOverlay.vue'
|
||||
import SideToolbar from '@/components/sidebar/SideToolbar.vue'
|
||||
import TopbarBadges from '@/components/topbar/TopbarBadges.vue'
|
||||
import TopbarSubscribeButton from '@/components/topbar/TopbarSubscribeButton.vue'
|
||||
import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import LinearControls from '@/renderer/extensions/linearMode/LinearControls.vue'
|
||||
import LinearPreview from '@/renderer/extensions/linearMode/LinearPreview.vue'
|
||||
import LinearProgressBar from '@/renderer/extensions/linearMode/LinearProgressBar.vue'
|
||||
@@ -86,6 +86,8 @@ const { onResizeEnd } = useStablePrimeVueSplitterSizer(
|
||||
[activeTab, splitterKey]
|
||||
)
|
||||
|
||||
const TYPEFORM_WIDGET_ID = 'jmmzmlKw'
|
||||
|
||||
const bottomLeftRef = useTemplateRef('bottomLeftRef')
|
||||
const bottomRightRef = useTemplateRef('bottomRightRef')
|
||||
const linearWorkflowRef = useTemplateRef('linearWorkflowRef')
|
||||
@@ -97,7 +99,7 @@ function dragDrop(e: DragEvent) {
|
||||
</script>
|
||||
<template>
|
||||
<MobileDisplay v-if="mobileDisplay" />
|
||||
<div v-else class="absolute flex size-full flex-col" @dragover.prevent>
|
||||
<div v-else class="absolute size-full" @dragover.prevent>
|
||||
<div
|
||||
class="workflow-tabs-container pointer-events-auto h-(--workflow-tabs-height) w-full border-b border-interface-stroke shadow-interface"
|
||||
>
|
||||
@@ -107,96 +109,93 @@ function dragDrop(e: DragEvent) {
|
||||
<TopbarSubscribeButton />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-1 overflow-hidden bg-secondary-background"
|
||||
:class="sidebarOnLeft ? 'flex-row' : 'flex-row-reverse'"
|
||||
<Splitter
|
||||
:key="splitterKey"
|
||||
class="bg-comfy-menu-secondary-bg h-[calc(100%-var(--workflow-tabs-height))] w-full border-none"
|
||||
@resizestart="$event.originalEvent.preventDefault()"
|
||||
@resizeend="onResizeEnd"
|
||||
>
|
||||
<SideToolbar
|
||||
v-if="!isBuilderMode"
|
||||
:visible-tab-ids="['assets', 'apps']"
|
||||
force-connected
|
||||
/>
|
||||
<Splitter
|
||||
:key="splitterKey"
|
||||
class="h-full flex-1 border-none bg-secondary-background"
|
||||
@resizestart="$event.originalEvent.preventDefault()"
|
||||
@resizeend="onResizeEnd"
|
||||
<SplitterPanel
|
||||
v-if="hasLeftPanel"
|
||||
ref="leftPanel"
|
||||
:size="SIDE_PANEL_SIZE"
|
||||
:min-size="
|
||||
sidePanelMinSize(showLeftBuilder, showRightBuilder && !activeTab)
|
||||
"
|
||||
:style="
|
||||
showRightBuilder && !activeTab ? { display: 'none' } : undefined
|
||||
"
|
||||
:class="
|
||||
cn(
|
||||
'arrange-panel overflow-hidden outline-none',
|
||||
showLeftBuilder ? 'min-w-78 bg-comfy-menu-bg' : 'min-w-78'
|
||||
)
|
||||
"
|
||||
>
|
||||
<SplitterPanel
|
||||
v-if="hasLeftPanel"
|
||||
ref="leftPanel"
|
||||
:size="SIDE_PANEL_SIZE"
|
||||
:min-size="
|
||||
sidePanelMinSize(showLeftBuilder, showRightBuilder && !activeTab)
|
||||
"
|
||||
:style="
|
||||
showRightBuilder && !activeTab ? { display: 'none' } : undefined
|
||||
"
|
||||
class="arrange-panel min-w-78 overflow-hidden bg-comfy-menu-bg outline-none"
|
||||
<AppBuilder v-if="showLeftBuilder" />
|
||||
<div
|
||||
v-else-if="sidebarOnLeft && activeTab"
|
||||
class="size-full overflow-x-hidden border-r border-border-subtle"
|
||||
>
|
||||
<AppBuilder v-if="showLeftBuilder" />
|
||||
<div
|
||||
v-else-if="sidebarOnLeft && activeTab"
|
||||
class="size-full overflow-x-hidden border-r border-border-subtle"
|
||||
>
|
||||
<ExtensionSlot :extension="activeTab" />
|
||||
</div>
|
||||
<LinearControls
|
||||
v-else-if="!isArrangeMode"
|
||||
ref="linearWorkflowRef"
|
||||
:toast-to="unrefElement(bottomLeftRef) ?? undefined"
|
||||
/>
|
||||
</SplitterPanel>
|
||||
<SplitterPanel
|
||||
id="linearCenterPanel"
|
||||
data-testid="linear-center-panel"
|
||||
:size="CENTER_PANEL_SIZE"
|
||||
class="relative flex min-w-[20vw] flex-col gap-4 text-muted-foreground outline-none"
|
||||
@drop="dragDrop"
|
||||
<ExtensionSlot :extension="activeTab" />
|
||||
</div>
|
||||
<LinearControls
|
||||
v-else-if="!isArrangeMode"
|
||||
ref="linearWorkflowRef"
|
||||
:toast-to="unrefElement(bottomLeftRef) ?? undefined"
|
||||
/>
|
||||
</SplitterPanel>
|
||||
<SplitterPanel
|
||||
id="linearCenterPanel"
|
||||
data-testid="linear-center-panel"
|
||||
:size="CENTER_PANEL_SIZE"
|
||||
class="relative flex min-w-[20vw] flex-col gap-4 text-muted-foreground outline-none"
|
||||
@drop="dragDrop"
|
||||
>
|
||||
<LinearProgressBar
|
||||
data-testid="linear-header-progress-bar"
|
||||
class="absolute top-0 left-0 z-21 h-1 w-[calc(100%+16px)]"
|
||||
/>
|
||||
<LinearPreview
|
||||
:run-button-click="linearWorkflowRef?.runButtonClick"
|
||||
:typeform-widget-id="TYPEFORM_WIDGET_ID"
|
||||
/>
|
||||
<div class="absolute top-2 left-4.5 z-21">
|
||||
<AppModeToolbar v-if="!isBuilderMode" />
|
||||
</div>
|
||||
<div ref="bottomLeftRef" class="absolute bottom-7 left-4 z-20" />
|
||||
<div ref="bottomRightRef" class="absolute right-4 bottom-7 z-20" />
|
||||
<div class="absolute top-4 right-4 z-20"><ErrorOverlay app-mode /></div>
|
||||
</SplitterPanel>
|
||||
<SplitterPanel
|
||||
v-if="hasRightPanel"
|
||||
ref="rightPanel"
|
||||
:size="SIDE_PANEL_SIZE"
|
||||
:min-size="
|
||||
sidePanelMinSize(showRightBuilder, showLeftBuilder && !activeTab)
|
||||
"
|
||||
:style="showLeftBuilder && !activeTab ? { display: 'none' } : undefined"
|
||||
:class="
|
||||
cn(
|
||||
'arrange-panel overflow-hidden outline-none',
|
||||
showRightBuilder ? 'min-w-78 bg-comfy-menu-bg' : 'min-w-78'
|
||||
)
|
||||
"
|
||||
>
|
||||
<AppBuilder v-if="showRightBuilder" />
|
||||
<LinearControls
|
||||
v-else-if="sidebarOnLeft && !isArrangeMode"
|
||||
ref="linearWorkflowRef"
|
||||
:toast-to="unrefElement(bottomRightRef) ?? undefined"
|
||||
/>
|
||||
<div
|
||||
v-else-if="activeTab"
|
||||
class="h-full overflow-x-hidden border-l border-border-subtle"
|
||||
>
|
||||
<LinearProgressBar
|
||||
data-testid="linear-header-progress-bar"
|
||||
class="absolute top-0 left-0 z-21 h-1 w-[calc(100%+16px)]"
|
||||
/>
|
||||
<LinearPreview
|
||||
:run-button-click="linearWorkflowRef?.runButtonClick"
|
||||
/>
|
||||
<div class="absolute top-2 left-2 z-21">
|
||||
<AppModeToolbar v-if="!isBuilderMode" />
|
||||
</div>
|
||||
<div ref="bottomLeftRef" class="absolute bottom-7 left-4 z-20" />
|
||||
<div ref="bottomRightRef" class="absolute right-4 bottom-7 z-20" />
|
||||
<div class="absolute top-4 right-4 z-20">
|
||||
<ErrorOverlay app-mode />
|
||||
</div>
|
||||
</SplitterPanel>
|
||||
<SplitterPanel
|
||||
v-if="hasRightPanel"
|
||||
ref="rightPanel"
|
||||
:size="SIDE_PANEL_SIZE"
|
||||
:min-size="
|
||||
sidePanelMinSize(showRightBuilder, showLeftBuilder && !activeTab)
|
||||
"
|
||||
:style="
|
||||
showLeftBuilder && !activeTab ? { display: 'none' } : undefined
|
||||
"
|
||||
class="arrange-panel min-w-78 overflow-hidden bg-comfy-menu-bg outline-none"
|
||||
>
|
||||
<AppBuilder v-if="showRightBuilder" />
|
||||
<LinearControls
|
||||
v-else-if="sidebarOnLeft && !isArrangeMode"
|
||||
ref="linearWorkflowRef"
|
||||
:toast-to="unrefElement(bottomRightRef) ?? undefined"
|
||||
/>
|
||||
<div
|
||||
v-else-if="activeTab"
|
||||
class="h-full overflow-x-hidden border-l border-border-subtle"
|
||||
>
|
||||
<ExtensionSlot :extension="activeTab" />
|
||||
</div>
|
||||
</SplitterPanel>
|
||||
</Splitter>
|
||||
</div>
|
||||
<ExtensionSlot :extension="activeTab" />
|
||||
</div>
|
||||
</SplitterPanel>
|
||||
</Splitter>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user