(null)
+
+ const { wrapper } = mountComponent(
+ { item: createMockItem('folder') },
+ {
+ provide: {
+ [InjectKeyContextMenuNode as symbol]: contextMenuNode
+ }
+ }
+ )
+
+ const folderDiv = wrapper.find('div.group\\/tree-node')
+ await folderDiv.trigger('contextmenu')
+
+ expect(contextMenuNode.value).toBeNull()
+ })
})
describe('rendering', () => {
diff --git a/src/components/common/TreeExplorerV2Node.vue b/src/components/common/TreeExplorerV2Node.vue
index 6291a4b2f..316bdfb81 100644
--- a/src/components/common/TreeExplorerV2Node.vue
+++ b/src/components/common/TreeExplorerV2Node.vue
@@ -5,27 +5,26 @@
:level="item.level"
as-child
>
-
-
-
-
-
-
- {{ item.value.label }}
-
-
-
-
+
+
+
+
+
+ {{ item.value.label }}
+
+
+
import type { FlattenedItem } from 'reka-ui'
-import { ContextMenuTrigger, TreeItem } from 'reka-ui'
+import { TreeItem } from 'reka-ui'
import { computed, inject } from 'vue'
import NodePreviewCard from '@/components/node/NodePreviewCard.vue'
diff --git a/src/components/sidebar/tabs/NodeLibrarySidebarTabV2.test.ts b/src/components/sidebar/tabs/NodeLibrarySidebarTabV2.test.ts
index 1bf30a126..9b7c4fc37 100644
--- a/src/components/sidebar/tabs/NodeLibrarySidebarTabV2.test.ts
+++ b/src/components/sidebar/tabs/NodeLibrarySidebarTabV2.test.ts
@@ -113,7 +113,7 @@ describe('NodeLibrarySidebarTabV2', () => {
const wrapper = mountComponent()
const triggers = wrapper.findAllComponents(TabsTrigger)
- expect(triggers.length).toBe(3)
+ expect(triggers).toHaveLength(3)
})
it('should render search box', () => {
diff --git a/src/components/sidebar/tabs/NodeLibrarySidebarTabV2.vue b/src/components/sidebar/tabs/NodeLibrarySidebarTabV2.vue
index c405b68c5..ec521103e 100644
--- a/src/components/sidebar/tabs/NodeLibrarySidebarTabV2.vue
+++ b/src/components/sidebar/tabs/NodeLibrarySidebarTabV2.vue
@@ -94,7 +94,7 @@
diff --git a/src/composables/usePerTabState.test.ts b/src/composables/usePerTabState.test.ts
new file mode 100644
index 000000000..947124adc
--- /dev/null
+++ b/src/composables/usePerTabState.test.ts
@@ -0,0 +1,73 @@
+import { ref } from 'vue'
+import { describe, expect, it } from 'vitest'
+
+import { usePerTabState } from './usePerTabState'
+
+type TabId = 'a' | 'b' | 'c'
+
+describe('usePerTabState', () => {
+ function setup(initialTab: TabId = 'a') {
+ const selectedTab = ref(initialTab)
+ const stateByTab = ref>({
+ a: [],
+ b: [],
+ c: []
+ })
+ const state = usePerTabState(selectedTab, stateByTab)
+ return { selectedTab, stateByTab, state }
+ }
+
+ it('should return state for the current tab', () => {
+ const { selectedTab, stateByTab, state } = setup()
+
+ stateByTab.value.a = ['key1', 'key2']
+ stateByTab.value.b = ['key3']
+
+ expect(state.value).toEqual(['key1', 'key2'])
+
+ selectedTab.value = 'b'
+ expect(state.value).toEqual(['key3'])
+ })
+
+ it('should set state only for the current tab', () => {
+ const { stateByTab, state } = setup()
+
+ state.value = ['new-key1', 'new-key2']
+
+ expect(stateByTab.value.a).toEqual(['new-key1', 'new-key2'])
+ expect(stateByTab.value.b).toEqual([])
+ expect(stateByTab.value.c).toEqual([])
+ })
+
+ it('should preserve state when switching tabs', () => {
+ const { selectedTab, stateByTab, state } = setup()
+
+ state.value = ['a-key']
+ selectedTab.value = 'b'
+ state.value = ['b-key']
+ selectedTab.value = 'c'
+ state.value = ['c-key']
+
+ expect(stateByTab.value.a).toEqual(['a-key'])
+ expect(stateByTab.value.b).toEqual(['b-key'])
+ expect(stateByTab.value.c).toEqual(['c-key'])
+
+ selectedTab.value = 'a'
+ expect(state.value).toEqual(['a-key'])
+ })
+
+ it('should not share state between tabs', () => {
+ const { selectedTab, state } = setup()
+
+ state.value = ['only-a']
+
+ selectedTab.value = 'b'
+ expect(state.value).toEqual([])
+
+ selectedTab.value = 'c'
+ expect(state.value).toEqual([])
+
+ selectedTab.value = 'a'
+ expect(state.value).toEqual(['only-a'])
+ })
+})
diff --git a/src/composables/usePerTabState.ts b/src/composables/usePerTabState.ts
new file mode 100644
index 000000000..3019dba66
--- /dev/null
+++ b/src/composables/usePerTabState.ts
@@ -0,0 +1,14 @@
+import type { Ref } from 'vue'
+import { computed } from 'vue'
+
+export function usePerTabState(
+ selectedTab: Ref,
+ stateByTab: Ref>
+) {
+ return computed({
+ get: () => stateByTab.value[selectedTab.value],
+ set: (value) => {
+ stateByTab.value[selectedTab.value] = value
+ }
+ })
+}
diff --git a/src/locales/en/main.json b/src/locales/en/main.json
index 47aaa1336..3f780931f 100644
--- a/src/locales/en/main.json
+++ b/src/locales/en/main.json
@@ -804,7 +804,9 @@
"alphabeticalDesc": "Sort alphabetically within groups"
},
"sections": {
- "favorites": "Favorites"
+ "favorites": "Favorites",
+ "favoriteNode": "Favorite Node",
+ "unfavoriteNode": "Unfavorite Node"
}
},
"modelLibrary": "Model Library",
diff --git a/src/platform/settings/constants/coreSettings.ts b/src/platform/settings/constants/coreSettings.ts
index 55159eb70..8749ae600 100644
--- a/src/platform/settings/constants/coreSettings.ts
+++ b/src/platform/settings/constants/coreSettings.ts
@@ -314,9 +314,12 @@ export const CORE_SETTINGS: SettingParams[] = [
// Bookmarks are stored in the settings store.
{
id: 'Comfy.NodeLibrary.NewDesign',
- name: 'Use new node library design',
- type: 'hidden',
- defaultValue: false,
+ category: ['Comfy', 'Node Library', 'NewDesign'],
+ name: 'New Node Library Design',
+ type: 'boolean',
+ tooltip:
+ 'Enable the redesigned node library sidebar with tabs (Essential, All, Custom), improved search, and hover previews.',
+ defaultValue: true,
experimental: true
},
// Bookmarks are in format of category/display_name. e.g. "conditioning/CLIPTextEncode"
diff --git a/src/utils/textTickerUtils.test.ts b/src/utils/textTickerUtils.test.ts
new file mode 100644
index 000000000..3fa2592f9
--- /dev/null
+++ b/src/utils/textTickerUtils.test.ts
@@ -0,0 +1,58 @@
+import { describe, expect, it } from 'vitest'
+
+import { splitTextAtWordBoundary } from '@/utils/textTickerUtils'
+
+describe('splitTextAtWordBoundary', () => {
+ it('returns full text when ratio >= 1 (fits in one line)', () => {
+ expect(splitTextAtWordBoundary('Load Checkpoint', 1)).toEqual([
+ 'Load Checkpoint',
+ ''
+ ])
+ expect(splitTextAtWordBoundary('Load Checkpoint', 1.5)).toEqual([
+ 'Load Checkpoint',
+ ''
+ ])
+ })
+
+ it('splits at last word boundary before estimated break', () => {
+ // "Load Checkpoint Loader" = 22 chars, ratio 0.5 → estimate at char 11
+ // lastIndexOf(' ', 11) → 15? No: "Load Checkpoint Loader"
+ // 0123456789...
+ // ' ' at index 4 and 15
+ // lastIndexOf(' ', 11) → 4
+ expect(splitTextAtWordBoundary('Load Checkpoint Loader', 0.5)).toEqual([
+ 'Load',
+ 'Checkpoint Loader'
+ ])
+ })
+
+ it('splits longer text proportionally', () => {
+ // ratio 0.7 → estimate at char 15
+ // lastIndexOf(' ', 15) → 15 (the space between "Checkpoint" and "Loader")
+ expect(splitTextAtWordBoundary('Load Checkpoint Loader', 0.7)).toEqual([
+ 'Load Checkpoint',
+ 'Loader'
+ ])
+ })
+
+ it('returns full text when no word boundary found', () => {
+ expect(splitTextAtWordBoundary('Superlongwordwithoutspaces', 0.5)).toEqual([
+ 'Superlongwordwithoutspaces',
+ ''
+ ])
+ })
+
+ it('handles single word text', () => {
+ expect(splitTextAtWordBoundary('Checkpoint', 0.5)).toEqual([
+ 'Checkpoint',
+ ''
+ ])
+ })
+
+ it('handles ratio near zero', () => {
+ expect(splitTextAtWordBoundary('Load Checkpoint Loader', 0.1)).toEqual([
+ 'Load Checkpoint Loader',
+ ''
+ ])
+ })
+})
diff --git a/src/utils/textTickerUtils.ts b/src/utils/textTickerUtils.ts
new file mode 100644
index 000000000..484650766
--- /dev/null
+++ b/src/utils/textTickerUtils.ts
@@ -0,0 +1,10 @@
+export function splitTextAtWordBoundary(
+ text: string,
+ ratio: number
+): [string, string] {
+ if (ratio >= 1) return [text, '']
+ const estimate = Math.floor(text.length * ratio)
+ const breakIndex = text.lastIndexOf(' ', estimate)
+ if (breakIndex <= 0) return [text, '']
+ return [text.substring(0, breakIndex), text.substring(breakIndex + 1)]
+}