feat: Add apps sidebar tab (#9342)
## Summary <!-- One sentence describing what changed and why. --> ## Changes - **What**: <!-- Core functionality added/modified --> - **Breaking**: <!-- Any breaking changes (if none, remove this line) --> - **Dependencies**: <!-- New dependencies (if none, remove this line) --> ## Review Focus <!-- Critical design decisions or edge cases that need attention --> <!-- If this PR fixes an issue, uncomment and update the line below --> <!-- Fixes #ISSUE_NUMBER --> ## Screenshots (if applicable) <img width="383" height="359" alt="image" src="https://github.com/user-attachments/assets/47905196-9db6-4a57-8cf7-384d4d37d000" /> <img width="335" height="281" alt="image" src="https://github.com/user-attachments/assets/843068f3-e895-4781-bf5f-e0eb86d3387c" /> ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-9342-feat-Add-apps-sidebar-tab-3176d73d3650812b822fc9cc3f17322e) by [Unito](https://www.unito.io) --------- Co-authored-by: github-actions <github-actions@github.com>
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 106 KiB |
@@ -21,8 +21,8 @@ const tooltipOptions = { showDelay: 300, hideDelay: 300 }
|
||||
const isAssetsActive = computed(
|
||||
() => workspaceStore.sidebarTab.activeSidebarTab?.id === 'assets'
|
||||
)
|
||||
const isWorkflowsActive = computed(
|
||||
() => workspaceStore.sidebarTab.activeSidebarTab?.id === 'workflows'
|
||||
const isAppsActive = computed(
|
||||
() => workspaceStore.sidebarTab.activeSidebarTab?.id === 'apps'
|
||||
)
|
||||
|
||||
function openAssets() {
|
||||
@@ -30,7 +30,7 @@ function openAssets() {
|
||||
}
|
||||
|
||||
function showApps() {
|
||||
void commandStore.execute('Workspace.ToggleSidebarTab.workflows')
|
||||
void commandStore.execute('Workspace.ToggleSidebarTab.apps')
|
||||
}
|
||||
|
||||
function openTemplates() {
|
||||
@@ -104,9 +104,7 @@ function openTemplates() {
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
:aria-label="t('linearMode.appModeToolbar.apps')"
|
||||
:class="
|
||||
cn('size-10', isWorkflowsActive && 'bg-secondary-background-hover')
|
||||
"
|
||||
:class="cn('size-10', isAppsActive && 'bg-secondary-background-hover')"
|
||||
@click="showApps"
|
||||
>
|
||||
<i class="icon-[lucide--panels-top-left] size-4" />
|
||||
|
||||
@@ -3,14 +3,18 @@
|
||||
<Card>
|
||||
<template #content>
|
||||
<div class="flex flex-col items-center">
|
||||
<i :class="icon" style="font-size: 3rem; margin-bottom: 1rem" />
|
||||
<h3>{{ title }}</h3>
|
||||
<i
|
||||
v-if="icon"
|
||||
:class="icon"
|
||||
style="font-size: 3rem; margin-bottom: 1rem"
|
||||
/>
|
||||
<h3 v-if="title">{{ title }}</h3>
|
||||
<p :class="textClass" class="text-center whitespace-pre-line">
|
||||
{{ message }}
|
||||
</p>
|
||||
<Button
|
||||
v-if="buttonLabel"
|
||||
variant="textonly"
|
||||
:variant="buttonVariant ?? 'textonly'"
|
||||
@click="$emit('action')"
|
||||
>
|
||||
{{ buttonLabel }}
|
||||
@@ -25,14 +29,16 @@
|
||||
import Card from 'primevue/card'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { ButtonVariants } from '../ui/button/button.variants'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: string
|
||||
icon?: string
|
||||
title: string
|
||||
title?: string
|
||||
message: string
|
||||
textClass?: string
|
||||
buttonLabel?: string
|
||||
buttonVariant?: ButtonVariants['variant']
|
||||
}>()
|
||||
|
||||
defineEmits(['action'])
|
||||
@@ -51,7 +57,6 @@ defineEmits(['action'])
|
||||
}
|
||||
|
||||
.no-results-placeholder p {
|
||||
color: var(--text-color-secondary);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
48
src/components/sidebar/tabs/AppsSidebarTab.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<BaseWorkflowsSidebarTab
|
||||
:title="$t('linearMode.appModeToolbar.apps')"
|
||||
:filter="isAppWorkflow"
|
||||
:label-transform="stripAppJsonSuffix"
|
||||
hide-leaf-icon
|
||||
:search-subject="$t('linearMode.appModeToolbar.apps')"
|
||||
data-testid="apps-sidebar"
|
||||
>
|
||||
<template #alt-title>
|
||||
<span
|
||||
class="ml-2 flex items-center rounded-full bg-primary-background px-1.5 py-0.5 text-xxs uppercase text-base-foreground"
|
||||
>
|
||||
{{ $t('g.beta') }}
|
||||
</span>
|
||||
</template>
|
||||
<template #empty-state>
|
||||
<NoResultsPlaceholder
|
||||
button-variant="secondary"
|
||||
text-class="text-muted-foreground text-sm"
|
||||
:message="$t('linearMode.appModeToolbar.appsEmptyMessage')"
|
||||
:button-label="$t('linearMode.appModeToolbar.enterAppMode')"
|
||||
@action="enterAppMode"
|
||||
/>
|
||||
</template>
|
||||
</BaseWorkflowsSidebarTab>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
import BaseWorkflowsSidebarTab from '@/components/sidebar/tabs/BaseWorkflowsSidebarTab.vue'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
|
||||
const { setMode } = useAppMode()
|
||||
|
||||
function isAppWorkflow(workflow: ComfyWorkflow): boolean {
|
||||
return workflow.suffix === 'app.json'
|
||||
}
|
||||
|
||||
function stripAppJsonSuffix(label: string): string {
|
||||
return label.replace(/\.app\.json$/i, '')
|
||||
}
|
||||
|
||||
function enterAppMode() {
|
||||
setMode('app')
|
||||
}
|
||||
</script>
|
||||
352
src/components/sidebar/tabs/BaseWorkflowsSidebarTab.vue
Normal file
@@ -0,0 +1,352 @@
|
||||
<template>
|
||||
<SidebarTabTemplate
|
||||
:title="title"
|
||||
v-bind="$attrs"
|
||||
:data-testid="dataTestid"
|
||||
class="workflows-sidebar-tab"
|
||||
>
|
||||
<template #alt-title>
|
||||
<slot name="alt-title" />
|
||||
</template>
|
||||
<template #tool-buttons>
|
||||
<Button
|
||||
v-tooltip.bottom="$t('g.refresh')"
|
||||
variant="muted-textonly"
|
||||
size="icon"
|
||||
:aria-label="$t('g.refresh')"
|
||||
@click="workflowStore.syncWorkflows()"
|
||||
>
|
||||
<i class="icon-[lucide--refresh-cw] size-4" />
|
||||
</Button>
|
||||
</template>
|
||||
<template #header>
|
||||
<div class="px-2 2xl:px-4">
|
||||
<SearchBox
|
||||
ref="searchBoxRef"
|
||||
v-model:model-value="searchQuery"
|
||||
class="workflows-search-box"
|
||||
:placeholder="$t('g.searchPlaceholder', { subject: searchSubject })"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<div v-if="!isSearching" class="comfyui-workflows-panel">
|
||||
<div
|
||||
v-if="workflowTabsPosition === 'Sidebar'"
|
||||
class="comfyui-workflows-open"
|
||||
>
|
||||
<TextDivider
|
||||
:text="t('sideToolbar.workflowTab.workflowTreeType.open')"
|
||||
type="dashed"
|
||||
class="ml-2"
|
||||
/>
|
||||
<TreeExplorer
|
||||
v-model:expanded-keys="dummyExpandedKeys"
|
||||
:root="renderTreeNode(openWorkflowsTree, WorkflowTreeType.Open)"
|
||||
:selection-keys="selectionKeys"
|
||||
>
|
||||
<template #node="{ node }">
|
||||
<TreeExplorerTreeNode :node="node">
|
||||
<template #before-label="{ node: treeNode }">
|
||||
<span
|
||||
v-if="
|
||||
(treeNode.data as ComfyWorkflow)?.isModified ||
|
||||
!(treeNode.data as ComfyWorkflow)?.isPersisted
|
||||
"
|
||||
>*</span
|
||||
>
|
||||
</template>
|
||||
<template #actions="{ node: treeNode }">
|
||||
<Button
|
||||
class="close-workflow-button"
|
||||
:variant="
|
||||
workspaceStore.shiftDown ? 'destructive' : 'textonly'
|
||||
"
|
||||
size="icon-sm"
|
||||
:aria-label="$t('g.close')"
|
||||
@click.stop="
|
||||
handleCloseWorkflow(treeNode.data as ComfyWorkflow)
|
||||
"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-3" />
|
||||
</Button>
|
||||
</template>
|
||||
</TreeExplorerTreeNode>
|
||||
</template>
|
||||
</TreeExplorer>
|
||||
</div>
|
||||
<div
|
||||
v-show="filteredBookmarkedWorkflows.length > 0"
|
||||
class="comfyui-workflows-bookmarks"
|
||||
>
|
||||
<TextDivider
|
||||
:text="t('sideToolbar.workflowTab.workflowTreeType.bookmarks')"
|
||||
type="dashed"
|
||||
class="ml-2"
|
||||
/>
|
||||
<TreeExplorer
|
||||
v-model:expanded-keys="dummyExpandedKeys"
|
||||
:root="
|
||||
renderTreeNode(
|
||||
bookmarkedWorkflowsTree,
|
||||
WorkflowTreeType.Bookmarks
|
||||
)
|
||||
"
|
||||
:selection-keys="selectionKeys"
|
||||
>
|
||||
<template #node="{ node }">
|
||||
<WorkflowTreeLeaf :node="node" />
|
||||
</template>
|
||||
</TreeExplorer>
|
||||
</div>
|
||||
<div class="comfyui-workflows-browse">
|
||||
<TextDivider
|
||||
:text="t('sideToolbar.workflowTab.workflowTreeType.browse')"
|
||||
type="dashed"
|
||||
class="ml-2"
|
||||
/>
|
||||
<TreeExplorer
|
||||
v-if="filteredPersistedWorkflows.length > 0"
|
||||
v-model:expanded-keys="expandedKeys"
|
||||
:root="renderTreeNode(workflowsTree, WorkflowTreeType.Browse)"
|
||||
:selection-keys="selectionKeys"
|
||||
>
|
||||
<template #node="{ node }">
|
||||
<WorkflowTreeLeaf :node="node" />
|
||||
</template>
|
||||
</TreeExplorer>
|
||||
<slot v-else name="empty-state">
|
||||
<NoResultsPlaceholder
|
||||
icon="pi pi-folder"
|
||||
:title="$t('g.empty')"
|
||||
:message="$t('g.noWorkflowsFound')"
|
||||
/>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="comfyui-workflows-search-panel">
|
||||
<TreeExplorer
|
||||
v-model:expanded-keys="expandedKeys"
|
||||
:root="renderTreeNode(filteredRoot, WorkflowTreeType.Browse)"
|
||||
>
|
||||
<template #node="{ node }">
|
||||
<WorkflowTreeLeaf :node="node" />
|
||||
</template>
|
||||
</TreeExplorer>
|
||||
</div>
|
||||
</template>
|
||||
</SidebarTabTemplate>
|
||||
<ConfirmDialog />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ConfirmDialog from 'primevue/confirmdialog'
|
||||
import { computed, nextTick, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import TextDivider from '@/components/common/TextDivider.vue'
|
||||
import TreeExplorer from '@/components/common/TreeExplorer.vue'
|
||||
import TreeExplorerTreeNode from '@/components/common/TreeExplorerTreeNode.vue'
|
||||
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
|
||||
import WorkflowTreeLeaf from '@/components/sidebar/tabs/workflows/WorkflowTreeLeaf.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useTreeExpansion } from '@/composables/useTreeExpansion'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import {
|
||||
ComfyWorkflow,
|
||||
useWorkflowBookmarkStore,
|
||||
useWorkflowStore
|
||||
} from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import type { TreeExplorerNode, TreeNode } from '@/types/treeExplorerTypes'
|
||||
import { ensureWorkflowSuffix, getWorkflowSuffix } from '@/utils/formatUtil'
|
||||
import { buildTree, sortedTree } from '@/utils/treeUtil'
|
||||
|
||||
const {
|
||||
title,
|
||||
filter,
|
||||
searchSubject,
|
||||
dataTestid,
|
||||
labelTransform,
|
||||
hideLeafIcon
|
||||
} = defineProps<{
|
||||
title: string
|
||||
filter?: (workflow: ComfyWorkflow) => boolean
|
||||
searchSubject: string
|
||||
dataTestid: string
|
||||
labelTransform?: (label: string) => string
|
||||
hideLeafIcon?: boolean
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const applyFilter = (workflows: ComfyWorkflow[]) =>
|
||||
filter ? workflows.filter(filter) : workflows
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const workflowTabsPosition = computed(() =>
|
||||
settingStore.get('Comfy.Workflow.WorkflowTabsPosition')
|
||||
)
|
||||
|
||||
const searchBoxRef = ref()
|
||||
|
||||
const searchQuery = ref('')
|
||||
const isSearching = computed(() => searchQuery.value.length > 0)
|
||||
const filteredWorkflows = ref<ComfyWorkflow[]>([])
|
||||
const filteredRoot = computed<TreeNode>(() => {
|
||||
return buildWorkflowTree(filteredWorkflows.value as ComfyWorkflow[])
|
||||
})
|
||||
const handleSearch = async (query: string) => {
|
||||
if (query.length === 0) {
|
||||
filteredWorkflows.value = []
|
||||
expandedKeys.value = {}
|
||||
return
|
||||
}
|
||||
const lowerQuery = query.toLocaleLowerCase()
|
||||
filteredWorkflows.value = applyFilter(workflowStore.workflows).filter(
|
||||
(workflow) => {
|
||||
return workflow.path.toLocaleLowerCase().includes(lowerQuery)
|
||||
}
|
||||
)
|
||||
await nextTick()
|
||||
expandNode(filteredRoot.value)
|
||||
}
|
||||
|
||||
const workflowStore = useWorkflowStore()
|
||||
const workflowService = useWorkflowService()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const expandedKeys = ref<Record<string, boolean>>({})
|
||||
const { expandNode, toggleNodeOnEvent } = useTreeExpansion(expandedKeys)
|
||||
const dummyExpandedKeys = ref<Record<string, boolean>>({})
|
||||
|
||||
const handleCloseWorkflow = async (workflow?: ComfyWorkflow) => {
|
||||
if (workflow) {
|
||||
await workflowService.closeWorkflow(workflow, {
|
||||
warnIfUnsaved: !workspaceStore.shiftDown
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
enum WorkflowTreeType {
|
||||
Open = 'Open',
|
||||
Bookmarks = 'Bookmarks',
|
||||
Browse = 'Browse'
|
||||
}
|
||||
|
||||
const buildWorkflowTree = (workflows: ComfyWorkflow[]) => {
|
||||
return buildTree(workflows, (workflow: ComfyWorkflow) =>
|
||||
workflow.key.split('/')
|
||||
)
|
||||
}
|
||||
|
||||
const filteredPersistedWorkflows = computed(() =>
|
||||
applyFilter(workflowStore.persistedWorkflows)
|
||||
)
|
||||
const filteredBookmarkedWorkflows = computed(() =>
|
||||
applyFilter(workflowStore.bookmarkedWorkflows)
|
||||
)
|
||||
|
||||
const workflowsTree = computed(() =>
|
||||
sortedTree(buildWorkflowTree(filteredPersistedWorkflows.value), {
|
||||
groupLeaf: true
|
||||
})
|
||||
)
|
||||
// Bookmarked workflows tree is flat.
|
||||
const bookmarkedWorkflowsTree = computed(() =>
|
||||
buildTree(filteredBookmarkedWorkflows.value, (workflow) => [workflow.key])
|
||||
)
|
||||
// Open workflows tree is flat.
|
||||
const openWorkflowsTree = computed(() =>
|
||||
buildTree(applyFilter(workflowStore.openWorkflows), (workflow) => [
|
||||
workflow.key
|
||||
])
|
||||
)
|
||||
|
||||
const renderTreeNode = (
|
||||
node: TreeNode,
|
||||
type: WorkflowTreeType
|
||||
): TreeExplorerNode<ComfyWorkflow> => {
|
||||
const children = node.children?.map((child) => renderTreeNode(child, type))
|
||||
|
||||
const workflow: ComfyWorkflow = node.data
|
||||
|
||||
async function handleClick(
|
||||
this: TreeExplorerNode<ComfyWorkflow>,
|
||||
e: MouseEvent
|
||||
) {
|
||||
if (this.leaf) {
|
||||
await workflowService.openWorkflow(workflow)
|
||||
} else {
|
||||
toggleNodeOnEvent(e, this)
|
||||
}
|
||||
}
|
||||
|
||||
const actions = node.leaf
|
||||
? {
|
||||
handleClick,
|
||||
async handleRename(newName: string) {
|
||||
const suffix = getWorkflowSuffix(workflow.suffix)
|
||||
const newPath =
|
||||
type === WorkflowTreeType.Browse
|
||||
? workflow.directory + '/' + ensureWorkflowSuffix(newName, suffix)
|
||||
: ComfyWorkflow.basePath + ensureWorkflowSuffix(newName, suffix)
|
||||
|
||||
await workflowService.renameWorkflow(workflow, newPath)
|
||||
},
|
||||
handleDelete: workflow.isTemporary
|
||||
? undefined
|
||||
: async function () {
|
||||
await workflowService.deleteWorkflow(workflow)
|
||||
},
|
||||
contextMenuItems() {
|
||||
return [
|
||||
{
|
||||
label: t('g.insert'),
|
||||
icon: 'pi pi-file-export',
|
||||
command: async () => {
|
||||
const workflow = node.data
|
||||
await workflowService.insertWorkflow(workflow)
|
||||
}
|
||||
},
|
||||
{
|
||||
label: t('g.duplicate'),
|
||||
icon: 'pi pi-file-export',
|
||||
command: async () => {
|
||||
const workflow = node.data
|
||||
await workflowService.duplicateWorkflow(workflow)
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
draggable: true
|
||||
}
|
||||
: { handleClick }
|
||||
|
||||
const label =
|
||||
node.leaf && labelTransform ? labelTransform(node.label) : node.label
|
||||
|
||||
return {
|
||||
key: node.key,
|
||||
label,
|
||||
leaf: node.leaf,
|
||||
icon: node.leaf && hideLeafIcon ? 'hidden' : undefined,
|
||||
data: node.data,
|
||||
children,
|
||||
...actions
|
||||
}
|
||||
}
|
||||
|
||||
const selectionKeys = computed(() => ({
|
||||
[`root/${workflowStore.activeWorkflow?.key}`]: true
|
||||
}))
|
||||
|
||||
const workflowBookmarkStore = useWorkflowBookmarkStore()
|
||||
onMounted(async () => {
|
||||
searchBoxRef.value?.focus()
|
||||
await workflowBookmarkStore.loadBookmarks()
|
||||
})
|
||||
</script>
|
||||
@@ -1,315 +1,11 @@
|
||||
<template>
|
||||
<SidebarTabTemplate
|
||||
<BaseWorkflowsSidebarTab
|
||||
:title="$t('sideToolbar.workflows')"
|
||||
v-bind="$attrs"
|
||||
:search-subject="$t('g.workflow')"
|
||||
data-testid="workflows-sidebar"
|
||||
class="workflows-sidebar-tab"
|
||||
>
|
||||
<template #tool-buttons>
|
||||
<Button
|
||||
v-tooltip.bottom="$t('g.refresh')"
|
||||
variant="muted-textonly"
|
||||
size="icon"
|
||||
:aria-label="$t('g.refresh')"
|
||||
@click="workflowStore.syncWorkflows()"
|
||||
>
|
||||
<i class="icon-[lucide--refresh-cw] size-4" />
|
||||
</Button>
|
||||
</template>
|
||||
<template #header>
|
||||
<div class="px-2 2xl:px-4">
|
||||
<SearchBox
|
||||
ref="searchBoxRef"
|
||||
v-model:model-value="searchQuery"
|
||||
class="workflows-search-box"
|
||||
:placeholder="
|
||||
$t('g.searchPlaceholder', { subject: $t('g.workflow') })
|
||||
"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<div v-if="!isSearching" class="comfyui-workflows-panel">
|
||||
<div
|
||||
v-if="workflowTabsPosition === 'Sidebar'"
|
||||
class="comfyui-workflows-open"
|
||||
>
|
||||
<TextDivider
|
||||
:text="t('sideToolbar.workflowTab.workflowTreeType.open')"
|
||||
type="dashed"
|
||||
class="ml-2"
|
||||
/>
|
||||
<TreeExplorer
|
||||
v-model:expanded-keys="dummyExpandedKeys"
|
||||
:root="renderTreeNode(openWorkflowsTree, WorkflowTreeType.Open)"
|
||||
:selection-keys="selectionKeys"
|
||||
>
|
||||
<template #node="{ node }">
|
||||
<TreeExplorerTreeNode :node="node">
|
||||
<template #before-label="{ node: treeNode }">
|
||||
<span
|
||||
v-if="
|
||||
(treeNode.data as ComfyWorkflow)?.isModified ||
|
||||
!(treeNode.data as ComfyWorkflow)?.isPersisted
|
||||
"
|
||||
>*</span
|
||||
>
|
||||
</template>
|
||||
<template #actions="{ node: treeNode }">
|
||||
<Button
|
||||
class="close-workflow-button"
|
||||
:variant="
|
||||
workspaceStore.shiftDown ? 'destructive' : 'textonly'
|
||||
"
|
||||
size="icon-sm"
|
||||
:aria-label="$t('g.close')"
|
||||
@click.stop="
|
||||
handleCloseWorkflow(treeNode.data as ComfyWorkflow)
|
||||
"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-3" />
|
||||
</Button>
|
||||
</template>
|
||||
</TreeExplorerTreeNode>
|
||||
</template>
|
||||
</TreeExplorer>
|
||||
</div>
|
||||
<div
|
||||
v-show="workflowStore.bookmarkedWorkflows.length > 0"
|
||||
class="comfyui-workflows-bookmarks"
|
||||
>
|
||||
<TextDivider
|
||||
:text="t('sideToolbar.workflowTab.workflowTreeType.bookmarks')"
|
||||
type="dashed"
|
||||
class="ml-2"
|
||||
/>
|
||||
<TreeExplorer
|
||||
v-model:expanded-keys="dummyExpandedKeys"
|
||||
:root="
|
||||
renderTreeNode(
|
||||
bookmarkedWorkflowsTree,
|
||||
WorkflowTreeType.Bookmarks
|
||||
)
|
||||
"
|
||||
:selection-keys="selectionKeys"
|
||||
>
|
||||
<template #node="{ node }">
|
||||
<WorkflowTreeLeaf :node="node" />
|
||||
</template>
|
||||
</TreeExplorer>
|
||||
</div>
|
||||
<div class="comfyui-workflows-browse">
|
||||
<TextDivider
|
||||
:text="t('sideToolbar.workflowTab.workflowTreeType.browse')"
|
||||
type="dashed"
|
||||
class="ml-2"
|
||||
/>
|
||||
<TreeExplorer
|
||||
v-if="workflowStore.persistedWorkflows.length > 0"
|
||||
v-model:expanded-keys="expandedKeys"
|
||||
:root="renderTreeNode(workflowsTree, WorkflowTreeType.Browse)"
|
||||
:selection-keys="selectionKeys"
|
||||
>
|
||||
<template #node="{ node }">
|
||||
<WorkflowTreeLeaf :node="node" />
|
||||
</template>
|
||||
</TreeExplorer>
|
||||
<NoResultsPlaceholder
|
||||
v-else
|
||||
icon="pi pi-folder"
|
||||
:title="$t('g.empty')"
|
||||
:message="$t('g.noWorkflowsFound')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="comfyui-workflows-search-panel">
|
||||
<TreeExplorer
|
||||
v-model:expanded-keys="expandedKeys"
|
||||
:root="renderTreeNode(filteredRoot, WorkflowTreeType.Browse)"
|
||||
>
|
||||
<template #node="{ node }">
|
||||
<WorkflowTreeLeaf :node="node" />
|
||||
</template>
|
||||
</TreeExplorer>
|
||||
</div>
|
||||
</template>
|
||||
</SidebarTabTemplate>
|
||||
<ConfirmDialog />
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ConfirmDialog from 'primevue/confirmdialog'
|
||||
import { computed, nextTick, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import TextDivider from '@/components/common/TextDivider.vue'
|
||||
import TreeExplorer from '@/components/common/TreeExplorer.vue'
|
||||
import TreeExplorerTreeNode from '@/components/common/TreeExplorerTreeNode.vue'
|
||||
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
|
||||
import WorkflowTreeLeaf from '@/components/sidebar/tabs/workflows/WorkflowTreeLeaf.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useTreeExpansion } from '@/composables/useTreeExpansion'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import {
|
||||
ComfyWorkflow,
|
||||
useWorkflowBookmarkStore,
|
||||
useWorkflowStore
|
||||
} from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import type { TreeExplorerNode, TreeNode } from '@/types/treeExplorerTypes'
|
||||
import { ensureWorkflowSuffix, getWorkflowSuffix } from '@/utils/formatUtil'
|
||||
import { buildTree, sortedTree } from '@/utils/treeUtil'
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const workflowTabsPosition = computed(() =>
|
||||
settingStore.get('Comfy.Workflow.WorkflowTabsPosition')
|
||||
)
|
||||
|
||||
const searchBoxRef = ref()
|
||||
|
||||
const searchQuery = ref('')
|
||||
const isSearching = computed(() => searchQuery.value.length > 0)
|
||||
const filteredWorkflows = ref<ComfyWorkflow[]>([])
|
||||
const filteredRoot = computed<TreeNode>(() => {
|
||||
return buildWorkflowTree(filteredWorkflows.value as ComfyWorkflow[])
|
||||
})
|
||||
const handleSearch = async (query: string) => {
|
||||
if (query.length === 0) {
|
||||
filteredWorkflows.value = []
|
||||
expandedKeys.value = {}
|
||||
return
|
||||
}
|
||||
const lowerQuery = query.toLocaleLowerCase()
|
||||
filteredWorkflows.value = workflowStore.workflows.filter((workflow) => {
|
||||
return workflow.path.toLocaleLowerCase().includes(lowerQuery)
|
||||
})
|
||||
await nextTick()
|
||||
expandNode(filteredRoot.value)
|
||||
}
|
||||
|
||||
const workflowStore = useWorkflowStore()
|
||||
const workflowService = useWorkflowService()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const { t } = useI18n()
|
||||
const expandedKeys = ref<Record<string, boolean>>({})
|
||||
const { expandNode, toggleNodeOnEvent } = useTreeExpansion(expandedKeys)
|
||||
const dummyExpandedKeys = ref<Record<string, boolean>>({})
|
||||
|
||||
const handleCloseWorkflow = async (workflow?: ComfyWorkflow) => {
|
||||
if (workflow) {
|
||||
await workflowService.closeWorkflow(workflow, {
|
||||
warnIfUnsaved: !workspaceStore.shiftDown
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
enum WorkflowTreeType {
|
||||
Open = 'Open',
|
||||
Bookmarks = 'Bookmarks',
|
||||
Browse = 'Browse'
|
||||
}
|
||||
|
||||
const buildWorkflowTree = (workflows: ComfyWorkflow[]) => {
|
||||
return buildTree(workflows, (workflow: ComfyWorkflow) =>
|
||||
workflow.key.split('/')
|
||||
)
|
||||
}
|
||||
|
||||
const workflowsTree = computed(() =>
|
||||
sortedTree(buildWorkflowTree(workflowStore.persistedWorkflows), {
|
||||
groupLeaf: true
|
||||
})
|
||||
)
|
||||
// Bookmarked workflows tree is flat.
|
||||
const bookmarkedWorkflowsTree = computed(() =>
|
||||
buildTree(workflowStore.bookmarkedWorkflows, (workflow) => [workflow.key])
|
||||
)
|
||||
// Open workflows tree is flat.
|
||||
const openWorkflowsTree = computed(() =>
|
||||
buildTree(workflowStore.openWorkflows, (workflow) => [workflow.key])
|
||||
)
|
||||
|
||||
const renderTreeNode = (
|
||||
node: TreeNode,
|
||||
type: WorkflowTreeType
|
||||
): TreeExplorerNode<ComfyWorkflow> => {
|
||||
const children = node.children?.map((child) => renderTreeNode(child, type))
|
||||
|
||||
const workflow: ComfyWorkflow = node.data
|
||||
|
||||
async function handleClick(
|
||||
this: TreeExplorerNode<ComfyWorkflow>,
|
||||
e: MouseEvent
|
||||
) {
|
||||
if (this.leaf) {
|
||||
await workflowService.openWorkflow(workflow)
|
||||
} else {
|
||||
toggleNodeOnEvent(e, this)
|
||||
}
|
||||
}
|
||||
|
||||
const actions = node.leaf
|
||||
? {
|
||||
handleClick,
|
||||
async handleRename(newName: string) {
|
||||
const suffix = getWorkflowSuffix(workflow.suffix)
|
||||
const newPath =
|
||||
type === WorkflowTreeType.Browse
|
||||
? workflow.directory + '/' + ensureWorkflowSuffix(newName, suffix)
|
||||
: ComfyWorkflow.basePath + ensureWorkflowSuffix(newName, suffix)
|
||||
|
||||
await workflowService.renameWorkflow(workflow, newPath)
|
||||
},
|
||||
handleDelete: workflow.isTemporary
|
||||
? undefined
|
||||
: async function () {
|
||||
await workflowService.deleteWorkflow(workflow)
|
||||
},
|
||||
contextMenuItems() {
|
||||
return [
|
||||
{
|
||||
label: t('g.insert'),
|
||||
icon: 'pi pi-file-export',
|
||||
command: async () => {
|
||||
const workflow = node.data
|
||||
await workflowService.insertWorkflow(workflow)
|
||||
}
|
||||
},
|
||||
{
|
||||
label: t('g.duplicate'),
|
||||
icon: 'pi pi-file-export',
|
||||
command: async () => {
|
||||
const workflow = node.data
|
||||
await workflowService.duplicateWorkflow(workflow)
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
draggable: true
|
||||
}
|
||||
: { handleClick }
|
||||
|
||||
return {
|
||||
key: node.key,
|
||||
label: node.label,
|
||||
leaf: node.leaf,
|
||||
data: node.data,
|
||||
children,
|
||||
...actions
|
||||
}
|
||||
}
|
||||
|
||||
const selectionKeys = computed(() => ({
|
||||
[`root/${workflowStore.activeWorkflow?.key}`]: true
|
||||
}))
|
||||
|
||||
const workflowBookmarkStore = useWorkflowBookmarkStore()
|
||||
onMounted(async () => {
|
||||
searchBoxRef.value?.focus()
|
||||
await workflowBookmarkStore.loadBookmarks()
|
||||
})
|
||||
import BaseWorkflowsSidebarTab from '@/components/sidebar/tabs/BaseWorkflowsSidebarTab.vue'
|
||||
</script>
|
||||
|
||||
@@ -3043,7 +3043,9 @@
|
||||
},
|
||||
"appModeToolbar": {
|
||||
"appBuilder": "App builder",
|
||||
"apps": "Apps"
|
||||
"apps": "Apps",
|
||||
"appsEmptyMessage": "Saved apps will show up here.\nClick below to build your first app.",
|
||||
"enterAppMode": "Enter app mode"
|
||||
},
|
||||
"arrange": {
|
||||
"noOutputs": "No outputs added yet",
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { markRaw } from 'vue'
|
||||
|
||||
import AppsSidebarTab from '@/components/sidebar/tabs/AppsSidebarTab.vue'
|
||||
import type { SidebarTabExtension } from '@/types/extensionTypes'
|
||||
|
||||
export const useAppsSidebarTab = (): SidebarTabExtension => {
|
||||
return {
|
||||
id: 'apps',
|
||||
icon: 'icon-[lucide--panels-top-left]',
|
||||
title: 'linearMode.appModeToolbar.apps',
|
||||
tooltip: 'linearMode.appModeToolbar.apps',
|
||||
component: markRaw(AppsSidebarTab),
|
||||
type: 'vue'
|
||||
}
|
||||
}
|
||||
@@ -84,6 +84,15 @@ vi.mock(
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/platform/workflow/management/composables/useAppsSidebarTab', () => ({
|
||||
useAppsSidebarTab: () => ({
|
||||
id: 'apps',
|
||||
title: 'apps',
|
||||
type: 'vue',
|
||||
component: {}
|
||||
})
|
||||
}))
|
||||
|
||||
describe('useSidebarTabStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
@@ -105,9 +114,10 @@ describe('useSidebarTabStore', () => {
|
||||
'assets',
|
||||
'node-library',
|
||||
'model-library',
|
||||
'workflows'
|
||||
'workflows',
|
||||
'apps'
|
||||
])
|
||||
expect(mockRegisterCommand).toHaveBeenCalledTimes(5)
|
||||
expect(mockRegisterCommand).toHaveBeenCalledTimes(6)
|
||||
})
|
||||
|
||||
it('does not register the job history tab when QPO V2 is disabled', () => {
|
||||
@@ -122,9 +132,10 @@ describe('useSidebarTabStore', () => {
|
||||
'assets',
|
||||
'node-library',
|
||||
'model-library',
|
||||
'workflows'
|
||||
'workflows',
|
||||
'apps'
|
||||
])
|
||||
expect(mockRegisterCommand).toHaveBeenCalledTimes(4)
|
||||
expect(mockRegisterCommand).toHaveBeenCalledTimes(5)
|
||||
})
|
||||
|
||||
it('prepends the job history tab when QPO V2 is toggled on', async () => {
|
||||
@@ -144,8 +155,9 @@ describe('useSidebarTabStore', () => {
|
||||
'assets',
|
||||
'node-library',
|
||||
'model-library',
|
||||
'workflows'
|
||||
'workflows',
|
||||
'apps'
|
||||
])
|
||||
expect(mockRegisterCommand).toHaveBeenCalledTimes(5)
|
||||
expect(mockRegisterCommand).toHaveBeenCalledTimes(6)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useModelLibrarySidebarTab } from '@/composables/sidebarTabs/useModelLib
|
||||
import { useNodeLibrarySidebarTab } from '@/composables/sidebarTabs/useNodeLibrarySidebarTab'
|
||||
import { t, te } from '@/i18n'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useAppsSidebarTab } from '@/platform/workflow/management/composables/useAppsSidebarTab'
|
||||
import { useWorkflowsSidebarTab } from '@/platform/workflow/management/composables/useWorkflowsSidebarTab'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useMenuItemStore } from '@/stores/menuItemStore'
|
||||
@@ -135,6 +136,7 @@ export const useSidebarTabStore = defineStore('sidebarTab', () => {
|
||||
registerSidebarTab(useNodeLibrarySidebarTab())
|
||||
registerSidebarTab(useModelLibrarySidebarTab())
|
||||
registerSidebarTab(useWorkflowsSidebarTab())
|
||||
registerSidebarTab(useAppsSidebarTab())
|
||||
|
||||
const menuStore = useMenuItemStore()
|
||||
|
||||
|
||||
@@ -113,6 +113,12 @@ const graphCanvasContainerRef = ref<HTMLDivElement | null>(null)
|
||||
const { isBuilderMode } = useAppMode()
|
||||
const { linearMode } = storeToRefs(useCanvasStore())
|
||||
|
||||
watch(linearMode, (isLinear) => {
|
||||
if (isLinear) {
|
||||
useSidebarTabStore().activeSidebarTabId = null
|
||||
}
|
||||
})
|
||||
|
||||
const telemetry = useTelemetry()
|
||||
const firebaseAuthStore = useFirebaseAuthStore()
|
||||
let hasTrackedLogin = false
|
||||
|
||||