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>
This commit is contained in:
pythongosssss
2026-03-04 17:54:26 +00:00
committed by GitHub
parent 194218a9d6
commit f4ed79b133
34 changed files with 462 additions and 326 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 106 KiB

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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