Compare commits
90 Commits
v1.39.14
...
feat/missi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c3bb91f1e | ||
|
|
864b14b302 | ||
|
|
7060133ff9 | ||
|
|
cc2c10745b | ||
|
|
8ab9a7b887 | ||
|
|
2dbd7e86c3 | ||
|
|
faede75bb4 | ||
|
|
8099cce232 | ||
|
|
27d4a34435 | ||
|
|
e1e560403e | ||
|
|
aff0ebad50 | ||
|
|
44dc208339 | ||
|
|
388c21a88d | ||
|
|
b28f46d237 | ||
|
|
2900e5e52e | ||
|
|
e02d58da0c | ||
|
|
f48ae4619d | ||
|
|
07e64a7f44 | ||
|
|
cc151d3c22 | ||
|
|
c54b470ca8 | ||
|
|
33f136b38b | ||
|
|
4fbf89ae4c | ||
|
|
aa1c25f98e | ||
|
|
34e21f3267 | ||
|
|
1349fffbce | ||
|
|
cde872fcf7 | ||
|
|
596df0f0c6 | ||
|
|
d3c0e331eb | ||
|
|
b47414a52f | ||
|
|
631d484901 | ||
|
|
e83e396c09 | ||
|
|
821c1e74ff | ||
|
|
d06cc0819a | ||
|
|
f5f5a77435 | ||
|
|
efe78b799f | ||
|
|
e70484d596 | ||
|
|
3dba245dd3 | ||
|
|
2ca0c30cf7 | ||
|
|
c8ba5f7300 | ||
|
|
39cc8ab97a | ||
|
|
2ee0a1337c | ||
|
|
980f280b3c | ||
|
|
4856fb0802 | ||
|
|
82ace36982 | ||
|
|
3d88d0a6ab | ||
|
|
21cfd44a2d | ||
|
|
d8d0dcbf71 | ||
|
|
066a1f1f11 | ||
|
|
2b896a722b | ||
|
|
96b9e886ea | ||
|
|
58182ddda7 | ||
|
|
0f0029ca29 | ||
|
|
ba7f622fbd | ||
|
|
fcb4341c98 | ||
|
|
27da781029 | ||
|
|
36d59f26cd | ||
|
|
5f7a6e7aba | ||
|
|
2c07bedbb1 | ||
|
|
78635294ce | ||
|
|
2f09c6321e | ||
|
|
38edba7024 | ||
|
|
f851c3189f | ||
|
|
71d26eb4d9 | ||
|
|
d04dd32235 | ||
|
|
c52f48af45 | ||
|
|
01cf3244b8 | ||
|
|
0f33444eef | ||
|
|
44ce9379eb | ||
|
|
138fa6a2ce | ||
|
|
ce9d0ca670 | ||
|
|
6cf0357b3e | ||
|
|
c0c81dba49 | ||
|
|
553ea63357 | ||
|
|
995ebc4ba4 | ||
|
|
d282353370 | ||
|
|
85ae0a57c3 | ||
|
|
0d64d503ec | ||
|
|
30ef6f2b8c | ||
|
|
6012341fd1 | ||
|
|
a80f6d7922 | ||
|
|
0f5aca6726 | ||
|
|
4fc1d2ef5b | ||
|
|
92b7437d86 | ||
|
|
dd1fefe843 | ||
|
|
adcb663b3e | ||
|
|
28b171168a | ||
|
|
69062c6da1 | ||
|
|
a7c2115166 | ||
|
|
d044bed9b2 | ||
|
|
d873c8048f |
2
.github/workflows/ci-lint-format.yaml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ !github.event.pull_request.head.repo.fork && github.head_ref || github.ref }}
|
||||
token: ${{ secrets.PR_GH_TOKEN }}
|
||||
token: ${{ !github.event.pull_request.head.repo.fork && secrets.PR_GH_TOKEN || github.token }}
|
||||
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
|
||||
@@ -96,6 +96,7 @@
|
||||
"typescript/restrict-template-expressions": "off",
|
||||
"typescript/unbound-method": "off",
|
||||
"typescript/no-floating-promises": "error",
|
||||
"typescript/no-explicit-any": "error",
|
||||
"vue/no-import-compiler-macros": "error",
|
||||
"vue/no-dupe-keys": "error"
|
||||
},
|
||||
|
||||
@@ -98,12 +98,10 @@ const config: StorybookConfig = {
|
||||
},
|
||||
build: {
|
||||
rolldownOptions: {
|
||||
experimental: {
|
||||
strictExecutionOrder: true
|
||||
},
|
||||
treeshake: false,
|
||||
output: {
|
||||
keepNames: true
|
||||
keepNames: true,
|
||||
strictExecutionOrder: true
|
||||
},
|
||||
onwarn: (warning, warn) => {
|
||||
// Suppress specific warnings
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"id": "save-image-and-webm-test",
|
||||
"revision": 0,
|
||||
"last_node_id": 12,
|
||||
"last_link_id": 2,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 10,
|
||||
"type": "LoadImage",
|
||||
"pos": [50, 100],
|
||||
"size": [315, 314],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [1, 2]
|
||||
},
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LoadImage"
|
||||
},
|
||||
"widgets_values": ["example.png", "image"]
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"type": "SaveImage",
|
||||
"pos": [450, 100],
|
||||
"size": [210, 270],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["ComfyUI"]
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"type": "SaveWEBM",
|
||||
"pos": [450, 450],
|
||||
"size": [210, 368],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 2
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["ComfyUI", "vp9", 6, 32]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[1, 10, 0, 11, 0, "IMAGE"],
|
||||
[2, 10, 0, 12, 0, "IMAGE"]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"frontendVersion": "1.17.0",
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -23,9 +23,7 @@ export class SettingDialog extends BaseDialog {
|
||||
* @param value - The value to set
|
||||
*/
|
||||
async setStringSetting(id: string, value: string) {
|
||||
const settingInputDiv = this.page.locator(
|
||||
`div.settings-container div[id="${id}"]`
|
||||
)
|
||||
const settingInputDiv = this.root.locator(`div[id="${id}"]`)
|
||||
await settingInputDiv.locator('input').fill(value)
|
||||
}
|
||||
|
||||
@@ -34,16 +32,31 @@ export class SettingDialog extends BaseDialog {
|
||||
* @param id - The id of the setting
|
||||
*/
|
||||
async toggleBooleanSetting(id: string) {
|
||||
const settingInputDiv = this.page.locator(
|
||||
`div.settings-container div[id="${id}"]`
|
||||
)
|
||||
const settingInputDiv = this.root.locator(`div[id="${id}"]`)
|
||||
await settingInputDiv.locator('input').click()
|
||||
}
|
||||
|
||||
get searchBox() {
|
||||
return this.root.getByPlaceholder(/Search/)
|
||||
}
|
||||
|
||||
get categories() {
|
||||
return this.root.locator('nav').getByRole('button')
|
||||
}
|
||||
|
||||
category(name: string) {
|
||||
return this.root.locator('nav').getByRole('button', { name })
|
||||
}
|
||||
|
||||
get contentArea() {
|
||||
return this.root.getByRole('main')
|
||||
}
|
||||
|
||||
async goToAboutPanel() {
|
||||
await this.page.getByTestId(TestIds.dialogs.settingsTabAbout).click()
|
||||
await this.page
|
||||
.getByTestId(TestIds.dialogs.about)
|
||||
.waitFor({ state: 'visible' })
|
||||
const aboutButton = this.root.locator('nav').getByRole('button', {
|
||||
name: 'About'
|
||||
})
|
||||
await aboutButton.click()
|
||||
await this.page.waitForSelector('.about-container')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,9 +226,7 @@ test.describe('Bottom Panel Shortcuts', { tag: '@ui' }, () => {
|
||||
await expect(bottomPanel.shortcuts.manageButton).toBeVisible()
|
||||
await bottomPanel.shortcuts.manageButton.click()
|
||||
|
||||
await expect(comfyPage.page.getByRole('dialog')).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByRole('option', { name: 'Keybinding' })
|
||||
).toBeVisible()
|
||||
await expect(comfyPage.settingDialog.root).toBeVisible()
|
||||
await expect(comfyPage.settingDialog.category('Keybinding')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -244,9 +244,13 @@ test.describe('Missing models warning', () => {
|
||||
test.describe('Settings', () => {
|
||||
test('@mobile Should be visible on mobile', async ({ comfyPage }) => {
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
const settingsContent = comfyPage.page.locator('.settings-content')
|
||||
await expect(settingsContent).toBeVisible()
|
||||
const isUsableHeight = await settingsContent.evaluate(
|
||||
const settingsDialog = comfyPage.page.locator(
|
||||
'[data-testid="settings-dialog"]'
|
||||
)
|
||||
await expect(settingsDialog).toBeVisible()
|
||||
const contentArea = settingsDialog.locator('main')
|
||||
await expect(contentArea).toBeVisible()
|
||||
const isUsableHeight = await contentArea.evaluate(
|
||||
(el) => el.clientHeight > 30
|
||||
)
|
||||
expect(isUsableHeight).toBeTruthy()
|
||||
@@ -256,7 +260,9 @@ test.describe('Settings', () => {
|
||||
await comfyPage.page.keyboard.down('ControlOrMeta')
|
||||
await comfyPage.page.keyboard.press(',')
|
||||
await comfyPage.page.keyboard.up('ControlOrMeta')
|
||||
const settingsLocator = comfyPage.page.locator('.settings-container')
|
||||
const settingsLocator = comfyPage.page.locator(
|
||||
'[data-testid="settings-dialog"]'
|
||||
)
|
||||
await expect(settingsLocator).toBeVisible()
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await expect(settingsLocator).not.toBeVisible()
|
||||
@@ -275,10 +281,15 @@ test.describe('Settings', () => {
|
||||
test('Should persist keybinding setting', async ({ comfyPage }) => {
|
||||
// Open the settings dialog
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
await comfyPage.page.waitForSelector('.settings-container')
|
||||
await comfyPage.page.waitForSelector('[data-testid="settings-dialog"]')
|
||||
|
||||
// Open the keybinding tab
|
||||
await comfyPage.page.getByLabel('Keybinding').click()
|
||||
const settingsDialog = comfyPage.page.locator(
|
||||
'[data-testid="settings-dialog"]'
|
||||
)
|
||||
await settingsDialog
|
||||
.locator('nav [role="button"]', { hasText: 'Keybinding' })
|
||||
.click()
|
||||
await comfyPage.page.waitForSelector(
|
||||
'[placeholder="Search Keybindings..."]'
|
||||
)
|
||||
|
||||
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 29 KiB |
@@ -215,6 +215,14 @@ test.describe('Node search box', { tag: '@node' }, () => {
|
||||
await expectFilterChips(comfyPage, ['MODEL', 'CLIP'])
|
||||
})
|
||||
|
||||
test('Does not add duplicate filter with same type and value', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.searchBox.addFilter('MODEL', 'Input Type')
|
||||
await comfyPage.searchBox.addFilter('MODEL', 'Input Type')
|
||||
await expectFilterChips(comfyPage, ['MODEL'])
|
||||
})
|
||||
|
||||
test('Can remove filter', async ({ comfyPage }) => {
|
||||
await comfyPage.searchBox.addFilter('MODEL', 'Input Type')
|
||||
await comfyPage.searchBox.removeFilter(0)
|
||||
|
||||
42
browser_tests/tests/saveImageAndWebp.spec.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe(
|
||||
'Save Image and WEBM preview',
|
||||
{ tag: ['@screenshot', '@widget'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
test('Can preview both SaveImage and SaveWEBM outputs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'widgets/save_image_and_animated_webp'
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
await comfyPage.runButton.click()
|
||||
|
||||
const saveImageNode = comfyPage.vueNodes.getNodeByTitle('Save Image')
|
||||
const saveWebmNode = comfyPage.vueNodes.getNodeByTitle('SaveWEBM')
|
||||
|
||||
// Wait for SaveImage to render an img inside .image-preview
|
||||
await expect(saveImageNode.locator('.image-preview img')).toBeVisible({
|
||||
timeout: 30000
|
||||
})
|
||||
|
||||
// Wait for SaveWEBM to render a video inside .video-preview
|
||||
await expect(saveWebmNode.locator('.video-preview video')).toBeVisible({
|
||||
timeout: 30000
|
||||
})
|
||||
|
||||
await expect(comfyPage.page).toHaveScreenshot(
|
||||
'save-image-and-webm-preview.png'
|
||||
)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 89 KiB |
@@ -5,13 +5,6 @@ import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
test.describe('Subgraph duplicate ID remapping', { tag: ['@subgraph'] }, () => {
|
||||
const WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Graph.DeduplicateSubgraphNodeIds',
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test('All node IDs are globally unique after loading', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
|
||||
@@ -61,7 +61,10 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialCount = await getSubgraphSlotCount(comfyPage, 'inputs')
|
||||
const vaeEncodeNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
const [vaeEncodeNode] = await comfyPage.nodeOps.getNodeRefsByType(
|
||||
'VAEEncode',
|
||||
true
|
||||
)
|
||||
|
||||
await comfyPage.subgraph.connectFromInput(vaeEncodeNode, 0)
|
||||
await comfyPage.nextFrame()
|
||||
@@ -77,7 +80,10 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialCount = await getSubgraphSlotCount(comfyPage, 'outputs')
|
||||
const vaeEncodeNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
const [vaeEncodeNode] = await comfyPage.nodeOps.getNodeRefsByType(
|
||||
'VAEEncode',
|
||||
true
|
||||
)
|
||||
|
||||
await comfyPage.subgraph.connectToOutput(vaeEncodeNode, 0)
|
||||
await comfyPage.nextFrame()
|
||||
@@ -820,7 +826,7 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
|
||||
// Open settings dialog using hotkey
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
await comfyPage.page.waitForSelector('.settings-container', {
|
||||
await comfyPage.page.waitForSelector('[data-testid="settings-dialog"]', {
|
||||
state: 'visible'
|
||||
})
|
||||
|
||||
@@ -830,7 +836,7 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
|
||||
// Dialog should be closed
|
||||
await expect(
|
||||
comfyPage.page.locator('.settings-container')
|
||||
comfyPage.page.locator('[data-testid="settings-dialog"]')
|
||||
).not.toBeVisible()
|
||||
|
||||
// Should still be in subgraph
|
||||
|
||||
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 115 KiB |
@@ -22,7 +22,6 @@ test.describe('Settings Search functionality', { tag: '@settings' }, () => {
|
||||
name: 'TestSettingsExtension',
|
||||
settings: [
|
||||
{
|
||||
// Extensions can register arbitrary setting IDs
|
||||
id: 'TestHiddenSetting' as TestSettingId,
|
||||
name: 'Test Hidden Setting',
|
||||
type: 'hidden',
|
||||
@@ -30,7 +29,6 @@ test.describe('Settings Search functionality', { tag: '@settings' }, () => {
|
||||
category: ['Test', 'Hidden']
|
||||
},
|
||||
{
|
||||
// Extensions can register arbitrary setting IDs
|
||||
id: 'TestDeprecatedSetting' as TestSettingId,
|
||||
name: 'Test Deprecated Setting',
|
||||
type: 'text',
|
||||
@@ -39,7 +37,6 @@ test.describe('Settings Search functionality', { tag: '@settings' }, () => {
|
||||
category: ['Test', 'Deprecated']
|
||||
},
|
||||
{
|
||||
// Extensions can register arbitrary setting IDs
|
||||
id: 'TestVisibleSetting' as TestSettingId,
|
||||
name: 'Test Visible Setting',
|
||||
type: 'text',
|
||||
@@ -52,238 +49,143 @@ test.describe('Settings Search functionality', { tag: '@settings' }, () => {
|
||||
})
|
||||
|
||||
test('can open settings dialog and use search box', async ({ comfyPage }) => {
|
||||
// Open settings dialog
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
||||
await expect(settingsDialog).toBeVisible()
|
||||
const dialog = comfyPage.settingDialog
|
||||
await dialog.open()
|
||||
|
||||
// Find the search box
|
||||
const searchBox = comfyPage.page.locator('.settings-search-box input')
|
||||
await expect(searchBox).toBeVisible()
|
||||
|
||||
// Verify search box has the correct placeholder
|
||||
await expect(searchBox).toHaveAttribute(
|
||||
await expect(dialog.searchBox).toHaveAttribute(
|
||||
'placeholder',
|
||||
expect.stringContaining('Search')
|
||||
)
|
||||
})
|
||||
|
||||
test('search box is functional and accepts input', async ({ comfyPage }) => {
|
||||
// Open settings dialog
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
||||
await expect(settingsDialog).toBeVisible()
|
||||
const dialog = comfyPage.settingDialog
|
||||
await dialog.open()
|
||||
|
||||
// Find and interact with the search box
|
||||
const searchBox = comfyPage.page.locator('.settings-search-box input')
|
||||
await searchBox.fill('Comfy')
|
||||
|
||||
// Verify the input was accepted
|
||||
await expect(searchBox).toHaveValue('Comfy')
|
||||
await dialog.searchBox.fill('Comfy')
|
||||
await expect(dialog.searchBox).toHaveValue('Comfy')
|
||||
})
|
||||
|
||||
test('search box clears properly', async ({ comfyPage }) => {
|
||||
// Open settings dialog
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
||||
await expect(settingsDialog).toBeVisible()
|
||||
const dialog = comfyPage.settingDialog
|
||||
await dialog.open()
|
||||
|
||||
// Find and interact with the search box
|
||||
const searchBox = comfyPage.page.locator('.settings-search-box input')
|
||||
await searchBox.fill('test')
|
||||
await expect(searchBox).toHaveValue('test')
|
||||
await dialog.searchBox.fill('test')
|
||||
await expect(dialog.searchBox).toHaveValue('test')
|
||||
|
||||
// Clear the search box
|
||||
await searchBox.clear()
|
||||
await expect(searchBox).toHaveValue('')
|
||||
await dialog.searchBox.clear()
|
||||
await expect(dialog.searchBox).toHaveValue('')
|
||||
})
|
||||
|
||||
test('settings categories are visible in sidebar', async ({ comfyPage }) => {
|
||||
// Open settings dialog
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
||||
await expect(settingsDialog).toBeVisible()
|
||||
const dialog = comfyPage.settingDialog
|
||||
await dialog.open()
|
||||
|
||||
// Check that the sidebar has categories
|
||||
const categories = comfyPage.page.locator(
|
||||
'.settings-sidebar .p-listbox-option'
|
||||
)
|
||||
expect(await categories.count()).toBeGreaterThan(0)
|
||||
|
||||
// Check that at least one category is visible
|
||||
await expect(categories.first()).toBeVisible()
|
||||
expect(await dialog.categories.count()).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('can select different categories in sidebar', async ({ comfyPage }) => {
|
||||
// Open settings dialog
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
||||
await expect(settingsDialog).toBeVisible()
|
||||
const dialog = comfyPage.settingDialog
|
||||
await dialog.open()
|
||||
|
||||
// Click on a specific category (Appearance) to verify category switching
|
||||
const appearanceCategory = comfyPage.page.getByRole('option', {
|
||||
name: 'Appearance'
|
||||
})
|
||||
await appearanceCategory.click()
|
||||
const categoryCount = await dialog.categories.count()
|
||||
|
||||
// Verify the category is selected
|
||||
await expect(appearanceCategory).toHaveClass(/p-listbox-option-selected/)
|
||||
})
|
||||
if (categoryCount > 1) {
|
||||
await dialog.categories.nth(1).click()
|
||||
|
||||
test('settings content area is visible', async ({ comfyPage }) => {
|
||||
// Open settings dialog
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
||||
await expect(settingsDialog).toBeVisible()
|
||||
|
||||
// Check that the content area is visible
|
||||
const contentArea = comfyPage.page.locator('.settings-content')
|
||||
await expect(contentArea).toBeVisible()
|
||||
|
||||
// Check that tab panels are visible
|
||||
const tabPanels = comfyPage.page.locator('.settings-tab-panels')
|
||||
await expect(tabPanels).toBeVisible()
|
||||
await expect(dialog.categories.nth(1)).toHaveClass(
|
||||
/bg-interface-menu-component-surface-selected/
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('search functionality affects UI state', async ({ comfyPage }) => {
|
||||
// Open settings dialog
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
||||
await expect(settingsDialog).toBeVisible()
|
||||
const dialog = comfyPage.settingDialog
|
||||
await dialog.open()
|
||||
|
||||
// Find the search box
|
||||
const searchBox = comfyPage.page.locator('.settings-search-box input')
|
||||
|
||||
// Type in search box
|
||||
await searchBox.fill('graph')
|
||||
|
||||
// Verify that the search input is handled
|
||||
await expect(searchBox).toHaveValue('graph')
|
||||
await dialog.searchBox.fill('graph')
|
||||
await expect(dialog.searchBox).toHaveValue('graph')
|
||||
})
|
||||
|
||||
test('settings dialog can be closed', async ({ comfyPage }) => {
|
||||
// Open settings dialog
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
||||
await expect(settingsDialog).toBeVisible()
|
||||
const dialog = comfyPage.settingDialog
|
||||
await dialog.open()
|
||||
|
||||
// Close with escape key
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
|
||||
// Verify dialog is closed
|
||||
await expect(settingsDialog).not.toBeVisible()
|
||||
await expect(dialog.root).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('search box has proper debouncing behavior', async ({ comfyPage }) => {
|
||||
// Open settings dialog
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
||||
await expect(settingsDialog).toBeVisible()
|
||||
const dialog = comfyPage.settingDialog
|
||||
await dialog.open()
|
||||
|
||||
// Type rapidly in search box
|
||||
const searchBox = comfyPage.page.locator('.settings-search-box input')
|
||||
await searchBox.fill('a')
|
||||
await searchBox.fill('ab')
|
||||
await searchBox.fill('abc')
|
||||
await searchBox.fill('abcd')
|
||||
await dialog.searchBox.fill('a')
|
||||
await dialog.searchBox.fill('ab')
|
||||
await dialog.searchBox.fill('abc')
|
||||
await dialog.searchBox.fill('abcd')
|
||||
|
||||
// Verify final value
|
||||
await expect(searchBox).toHaveValue('abcd')
|
||||
await expect(dialog.searchBox).toHaveValue('abcd')
|
||||
})
|
||||
|
||||
test('search excludes hidden settings from results', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Open settings dialog
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
||||
await expect(settingsDialog).toBeVisible()
|
||||
const dialog = comfyPage.settingDialog
|
||||
await dialog.open()
|
||||
|
||||
// Search for our test settings
|
||||
const searchBox = comfyPage.page.locator('.settings-search-box input')
|
||||
await searchBox.fill('Test')
|
||||
await dialog.searchBox.fill('Test')
|
||||
|
||||
// Get all settings content
|
||||
const settingsContent = comfyPage.page.locator('.settings-tab-panels')
|
||||
|
||||
// Should show visible setting but not hidden setting
|
||||
await expect(settingsContent).toContainText('Test Visible Setting')
|
||||
await expect(settingsContent).not.toContainText('Test Hidden Setting')
|
||||
await expect(dialog.contentArea).toContainText('Test Visible Setting')
|
||||
await expect(dialog.contentArea).not.toContainText('Test Hidden Setting')
|
||||
})
|
||||
|
||||
test('search excludes deprecated settings from results', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Open settings dialog
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
||||
await expect(settingsDialog).toBeVisible()
|
||||
const dialog = comfyPage.settingDialog
|
||||
await dialog.open()
|
||||
|
||||
// Search for our test settings
|
||||
const searchBox = comfyPage.page.locator('.settings-search-box input')
|
||||
await searchBox.fill('Test')
|
||||
await dialog.searchBox.fill('Test')
|
||||
|
||||
// Get all settings content
|
||||
const settingsContent = comfyPage.page.locator('.settings-tab-panels')
|
||||
|
||||
// Should show visible setting but not deprecated setting
|
||||
await expect(settingsContent).toContainText('Test Visible Setting')
|
||||
await expect(settingsContent).not.toContainText('Test Deprecated Setting')
|
||||
await expect(dialog.contentArea).toContainText('Test Visible Setting')
|
||||
await expect(dialog.contentArea).not.toContainText(
|
||||
'Test Deprecated Setting'
|
||||
)
|
||||
})
|
||||
|
||||
test('search shows visible settings but excludes hidden and deprecated', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Open settings dialog
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
||||
await expect(settingsDialog).toBeVisible()
|
||||
const dialog = comfyPage.settingDialog
|
||||
await dialog.open()
|
||||
|
||||
// Search for our test settings
|
||||
const searchBox = comfyPage.page.locator('.settings-search-box input')
|
||||
await searchBox.fill('Test')
|
||||
await dialog.searchBox.fill('Test')
|
||||
|
||||
// Get all settings content
|
||||
const settingsContent = comfyPage.page.locator('.settings-tab-panels')
|
||||
|
||||
// Should only show the visible setting
|
||||
await expect(settingsContent).toContainText('Test Visible Setting')
|
||||
|
||||
// Should not show hidden or deprecated settings
|
||||
await expect(settingsContent).not.toContainText('Test Hidden Setting')
|
||||
await expect(settingsContent).not.toContainText('Test Deprecated Setting')
|
||||
await expect(dialog.contentArea).toContainText('Test Visible Setting')
|
||||
await expect(dialog.contentArea).not.toContainText('Test Hidden Setting')
|
||||
await expect(dialog.contentArea).not.toContainText(
|
||||
'Test Deprecated Setting'
|
||||
)
|
||||
})
|
||||
|
||||
test('search by setting name excludes hidden and deprecated', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Open settings dialog
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
||||
await expect(settingsDialog).toBeVisible()
|
||||
const dialog = comfyPage.settingDialog
|
||||
await dialog.open()
|
||||
|
||||
const searchBox = comfyPage.page.locator('.settings-search-box input')
|
||||
const settingsContent = comfyPage.page.locator('.settings-tab-panels')
|
||||
await dialog.searchBox.clear()
|
||||
await dialog.searchBox.fill('Hidden')
|
||||
await expect(dialog.contentArea).not.toContainText('Test Hidden Setting')
|
||||
|
||||
// Search specifically for hidden setting by name
|
||||
await searchBox.clear()
|
||||
await searchBox.fill('Hidden')
|
||||
await dialog.searchBox.clear()
|
||||
await dialog.searchBox.fill('Deprecated')
|
||||
await expect(dialog.contentArea).not.toContainText(
|
||||
'Test Deprecated Setting'
|
||||
)
|
||||
|
||||
// Should not show the hidden setting even when searching by name
|
||||
await expect(settingsContent).not.toContainText('Test Hidden Setting')
|
||||
|
||||
// Search specifically for deprecated setting by name
|
||||
await searchBox.clear()
|
||||
await searchBox.fill('Deprecated')
|
||||
|
||||
// Should not show the deprecated setting even when searching by name
|
||||
await expect(settingsContent).not.toContainText('Test Deprecated Setting')
|
||||
|
||||
// Search for visible setting by name - should work
|
||||
await searchBox.clear()
|
||||
await searchBox.fill('Visible')
|
||||
|
||||
// Should show the visible setting
|
||||
await expect(settingsContent).toContainText('Test Visible Setting')
|
||||
await dialog.searchBox.clear()
|
||||
await dialog.searchBox.fill('Visible')
|
||||
await expect(dialog.contentArea).toContainText('Test Visible Setting')
|
||||
})
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 64 KiB |
@@ -0,0 +1,57 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../../../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Vue Nodes Image Preview', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.loadWorkflow('widgets/load_image_widget')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
async function loadImageOnNode(
|
||||
comfyPage: Awaited<
|
||||
ReturnType<(typeof test)['info']>
|
||||
>['fixtures']['comfyPage']
|
||||
) {
|
||||
const loadImageNode = (await comfyPage.getNodeRefsByType('LoadImage'))[0]
|
||||
const { x, y } = await loadImageNode.getPosition()
|
||||
|
||||
await comfyPage.dragAndDropFile('image64x64.webp', {
|
||||
dropPosition: { x, y }
|
||||
})
|
||||
|
||||
const imagePreview = comfyPage.page.locator('.image-preview')
|
||||
await expect(imagePreview).toBeVisible()
|
||||
await expect(imagePreview.locator('img')).toBeVisible()
|
||||
await expect(imagePreview).toContainText('x')
|
||||
|
||||
return imagePreview
|
||||
}
|
||||
|
||||
test.fixme('opens mask editor from image preview button', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const imagePreview = await loadImageOnNode(comfyPage)
|
||||
|
||||
await imagePreview.locator('[role="img"]').hover()
|
||||
await comfyPage.page.getByLabel('Edit or mask image').click()
|
||||
|
||||
await expect(comfyPage.page.locator('.mask-editor-dialog')).toBeVisible()
|
||||
})
|
||||
|
||||
test.fixme('shows image context menu options', async ({ comfyPage }) => {
|
||||
await loadImageOnNode(comfyPage)
|
||||
|
||||
const nodeHeader = comfyPage.vueNodes.getNodeByTitle('Load Image')
|
||||
await nodeHeader.click()
|
||||
await nodeHeader.click({ button: 'right' })
|
||||
|
||||
const contextMenu = comfyPage.page.locator('.p-contextmenu')
|
||||
await expect(contextMenu).toBeVisible()
|
||||
await expect(contextMenu.getByText('Open Image')).toBeVisible()
|
||||
await expect(contextMenu.getByText('Copy Image')).toBeVisible()
|
||||
await expect(contextMenu.getByText('Save Image')).toBeVisible()
|
||||
await expect(contextMenu.getByText('Open in Mask Editor')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 80 KiB |
@@ -320,5 +320,15 @@ export default defineConfig([
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
// Storybook-only mock components (__stories__/**/*.vue)
|
||||
// These are not shipped to production and do not require i18n or strict Vue patterns.
|
||||
{
|
||||
files: ['**/__stories__/**/*.vue'],
|
||||
rules: {
|
||||
'@intlify/vue-i18n/no-raw-text': 'off',
|
||||
'vue/no-unused-properties': 'off'
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
25
global.d.ts
vendored
@@ -10,9 +10,28 @@ interface ImpactQueueFunction {
|
||||
a?: unknown[][]
|
||||
}
|
||||
|
||||
type GtagGetFieldName = 'client_id' | 'session_id' | 'session_number'
|
||||
|
||||
interface GtagGetFieldValueMap {
|
||||
client_id: string | number | undefined
|
||||
session_id: string | number | undefined
|
||||
session_number: string | number | undefined
|
||||
}
|
||||
|
||||
interface GtagFunction {
|
||||
<TField extends GtagGetFieldName>(
|
||||
command: 'get',
|
||||
targetId: string,
|
||||
fieldName: TField,
|
||||
callback: (value: GtagGetFieldValueMap[TField]) => void
|
||||
): void
|
||||
(...args: unknown[]): void
|
||||
}
|
||||
|
||||
interface Window {
|
||||
__CONFIG__: {
|
||||
gtm_container_id?: string
|
||||
ga_measurement_id?: string
|
||||
mixpanel_token?: string
|
||||
require_whitelist?: boolean
|
||||
subscription_required?: boolean
|
||||
@@ -36,12 +55,8 @@ interface Window {
|
||||
badge?: string
|
||||
}
|
||||
}
|
||||
__ga_identity__?: {
|
||||
client_id?: string
|
||||
session_id?: string
|
||||
session_number?: string
|
||||
}
|
||||
dataLayer?: Array<Record<string, unknown>>
|
||||
gtag?: GtagFunction
|
||||
ire_o?: string
|
||||
ire?: ImpactQueueFunction
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.39.16",
|
||||
"version": "1.40.8",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -193,7 +193,7 @@
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"vite": "^8.0.0-beta.8"
|
||||
"vite": "catalog:"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
4
packages/design-system/src/icons/comfy-c.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 9">
|
||||
<path d="M1.82148 8.68376C1.61587 8.68376 1.44996 8.60733 1.34177 8.46284C1.23057 8.31438 1.20157 8.10711 1.26219 7.89434L1.50561 7.03961C1.52502 6.97155 1.51151 6.89831 1.46918 6.8417C1.42684 6.7852 1.3606 6.75194 1.29025 6.75194H0.590376C0.384656 6.75194 0.21875 6.67562 0.110614 6.53113C-0.000591531 6.38256 -0.0295831 6.17529 0.0310774 5.96252L0.867308 3.03952L0.959638 2.71838C1.08375 2.28258 1.53638 1.9284 1.96878 1.9284H2.80622C2.90615 1.9284 2.99406 1.86177 3.02157 1.76508L3.29852 0.79284C3.4225 0.357484 3.87514 0.0033043 4.30753 0.0033043L6.09854 0.000112775L7.40967 0C7.61533 0 7.78124 0.0763259 7.88937 0.220813C8.00058 0.369269 8.02957 0.576538 7.96895 0.78931L7.59405 2.10572C7.4701 2.54096 7.01746 2.89503 6.58507 2.89503L4.79008 2.89844H3.95292C3.8531 2.89844 3.7653 2.96496 3.73762 3.06155L3.03961 5.49964C3.02008 5.56781 3.03359 5.64127 3.07604 5.69787C3.11837 5.75437 3.18461 5.78763 3.2549 5.78763C3.25507 5.78763 4.44105 5.78532 4.44105 5.78532H5.7483C5.95396 5.78532 6.11986 5.86164 6.228 6.00613C6.33921 6.1547 6.3682 6.36197 6.30754 6.57474L5.93263 7.89092C5.80869 8.32628 5.35605 8.68034 4.92366 8.68034L3.12872 8.68376H1.82148Z" fill="#8A8A8A"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
556
pnpm-lock.yaml
generated
@@ -92,7 +92,7 @@ catalog:
|
||||
unplugin-icons: ^22.5.0
|
||||
unplugin-typegpu: 0.8.0
|
||||
unplugin-vue-components: ^30.0.0
|
||||
vite: ^8.0.0-beta.8
|
||||
vite: 8.0.0-beta.13
|
||||
vite-plugin-dts: ^4.5.4
|
||||
vite-plugin-html: ^3.2.2
|
||||
vite-plugin-vue-devtools: ^8.0.0
|
||||
|
||||
@@ -2,11 +2,12 @@ import { createTestingPinia } from '@pinia/testing'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed, defineComponent, h, nextTick, onMounted } from 'vue'
|
||||
import { computed, defineComponent, h, nextTick, onMounted, ref } from 'vue'
|
||||
import type { Component } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import TopMenuSection from '@/components/TopMenuSection.vue'
|
||||
import QueueNotificationBannerHost from '@/components/queue/QueueNotificationBannerHost.vue'
|
||||
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
|
||||
import LoginButton from '@/components/topbar/LoginButton.vue'
|
||||
import type {
|
||||
@@ -19,7 +20,11 @@ import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
|
||||
const mockData = vi.hoisted(() => ({ isLoggedIn: false, isDesktop: false }))
|
||||
const mockData = vi.hoisted(() => ({
|
||||
isLoggedIn: false,
|
||||
isDesktop: false,
|
||||
setShowConflictRedDot: (_value: boolean) => {}
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
useCurrentUser: () => {
|
||||
@@ -36,6 +41,36 @@ vi.mock('@/platform/distribution/types', () => ({
|
||||
return mockData.isDesktop
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/updates/common/releaseStore', () => ({
|
||||
useReleaseStore: () => ({
|
||||
shouldShowRedDot: computed(() => true)
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/workbench/extensions/manager/composables/useConflictAcknowledgment',
|
||||
() => {
|
||||
const shouldShowConflictRedDot = ref(false)
|
||||
mockData.setShowConflictRedDot = (value: boolean) => {
|
||||
shouldShowConflictRedDot.value = value
|
||||
}
|
||||
|
||||
return {
|
||||
useConflictAcknowledgment: () => ({
|
||||
shouldShowRedDot: shouldShowConflictRedDot
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
vi.mock('@/workbench/extensions/manager/composables/useManagerState', () => ({
|
||||
useManagerState: () => ({
|
||||
shouldShowManagerButtons: computed(() => true),
|
||||
openManager: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/firebaseAuthStore', () => ({
|
||||
useFirebaseAuthStore: vi.fn(() => ({
|
||||
currentUser: null,
|
||||
@@ -79,6 +114,7 @@ function createWrapper({
|
||||
SubgraphBreadcrumb: true,
|
||||
QueueProgressOverlay: true,
|
||||
QueueInlineProgressSummary: true,
|
||||
QueueNotificationBannerHost: true,
|
||||
CurrentUserButton: true,
|
||||
LoginButton: true,
|
||||
ContextMenu: {
|
||||
@@ -108,12 +144,25 @@ function createTask(id: string, status: JobStatus): TaskItemImpl {
|
||||
return new TaskItemImpl(createJob(id, status))
|
||||
}
|
||||
|
||||
function createComfyActionbarStub(actionbarTarget: HTMLElement) {
|
||||
return defineComponent({
|
||||
name: 'ComfyActionbar',
|
||||
setup(_, { emit }) {
|
||||
onMounted(() => {
|
||||
emit('update:progressTarget', actionbarTarget)
|
||||
})
|
||||
return () => h('div')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('TopMenuSection', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
localStorage.clear()
|
||||
mockData.isDesktop = false
|
||||
mockData.isLoggedIn = false
|
||||
mockData.setShowConflictRedDot(false)
|
||||
})
|
||||
|
||||
describe('authentication state', () => {
|
||||
@@ -166,6 +215,17 @@ describe('TopMenuSection', () => {
|
||||
|
||||
const queueButton = wrapper.find('[data-testid="queue-overlay-toggle"]')
|
||||
expect(queueButton.text()).toContain('3 active')
|
||||
expect(wrapper.find('[data-testid="active-jobs-indicator"]').exists()).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('hides the active jobs indicator when no jobs are active', () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
expect(wrapper.find('[data-testid="active-jobs-indicator"]').exists()).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('hides queue progress overlay when QPO V2 is enabled', async () => {
|
||||
@@ -281,15 +341,7 @@ describe('TopMenuSection', () => {
|
||||
const executionStore = useExecutionStore(pinia)
|
||||
executionStore.activePromptId = 'prompt-1'
|
||||
|
||||
const ComfyActionbarStub = defineComponent({
|
||||
name: 'ComfyActionbar',
|
||||
setup(_, { emit }) {
|
||||
onMounted(() => {
|
||||
emit('update:progressTarget', actionbarTarget)
|
||||
})
|
||||
return () => h('div')
|
||||
}
|
||||
})
|
||||
const ComfyActionbarStub = createComfyActionbarStub(actionbarTarget)
|
||||
|
||||
const wrapper = createWrapper({
|
||||
pinia,
|
||||
@@ -311,6 +363,103 @@ describe('TopMenuSection', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe(QueueNotificationBannerHost, () => {
|
||||
const configureSettings = (
|
||||
pinia: ReturnType<typeof createTestingPinia>,
|
||||
qpoV2Enabled: boolean
|
||||
) => {
|
||||
const settingStore = useSettingStore(pinia)
|
||||
vi.mocked(settingStore.get).mockImplementation((key) => {
|
||||
if (key === 'Comfy.Queue.QPOV2') return qpoV2Enabled
|
||||
if (key === 'Comfy.UseNewMenu') return 'Top'
|
||||
return undefined
|
||||
})
|
||||
}
|
||||
|
||||
it('renders queue notification banners when QPO V2 is enabled', async () => {
|
||||
const pinia = createTestingPinia({ createSpy: vi.fn })
|
||||
configureSettings(pinia, true)
|
||||
|
||||
const wrapper = createWrapper({ pinia })
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(
|
||||
wrapper.findComponent({ name: 'QueueNotificationBannerHost' }).exists()
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('renders queue notification banners when QPO V2 is disabled', async () => {
|
||||
const pinia = createTestingPinia({ createSpy: vi.fn })
|
||||
configureSettings(pinia, false)
|
||||
|
||||
const wrapper = createWrapper({ pinia })
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(
|
||||
wrapper.findComponent({ name: 'QueueNotificationBannerHost' }).exists()
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('renders inline summary above banners when both are visible', async () => {
|
||||
const pinia = createTestingPinia({ createSpy: vi.fn })
|
||||
configureSettings(pinia, true)
|
||||
const wrapper = createWrapper({ pinia })
|
||||
|
||||
await nextTick()
|
||||
|
||||
const html = wrapper.html()
|
||||
const inlineSummaryIndex = html.indexOf(
|
||||
'queue-inline-progress-summary-stub'
|
||||
)
|
||||
const queueBannerIndex = html.indexOf(
|
||||
'queue-notification-banner-host-stub'
|
||||
)
|
||||
|
||||
expect(inlineSummaryIndex).toBeGreaterThan(-1)
|
||||
expect(queueBannerIndex).toBeGreaterThan(-1)
|
||||
expect(inlineSummaryIndex).toBeLessThan(queueBannerIndex)
|
||||
})
|
||||
|
||||
it('does not teleport queue notification banners when actionbar is floating', async () => {
|
||||
localStorage.setItem('Comfy.MenuPosition.Docked', 'false')
|
||||
const actionbarTarget = document.createElement('div')
|
||||
document.body.appendChild(actionbarTarget)
|
||||
const pinia = createTestingPinia({ createSpy: vi.fn })
|
||||
configureSettings(pinia, true)
|
||||
const executionStore = useExecutionStore(pinia)
|
||||
executionStore.activePromptId = 'prompt-1'
|
||||
|
||||
const ComfyActionbarStub = createComfyActionbarStub(actionbarTarget)
|
||||
|
||||
const wrapper = createWrapper({
|
||||
pinia,
|
||||
attachTo: document.body,
|
||||
stubs: {
|
||||
ComfyActionbar: ComfyActionbarStub,
|
||||
QueueNotificationBannerHost: true
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
await nextTick()
|
||||
|
||||
expect(
|
||||
actionbarTarget.querySelector('queue-notification-banner-host-stub')
|
||||
).toBeNull()
|
||||
expect(
|
||||
wrapper
|
||||
.findComponent({ name: 'QueueNotificationBannerHost' })
|
||||
.exists()
|
||||
).toBe(true)
|
||||
} finally {
|
||||
wrapper.unmount()
|
||||
actionbarTarget.remove()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('disables the clear queue context menu item when no queued jobs exist', () => {
|
||||
const wrapper = createWrapper()
|
||||
const menu = wrapper.findComponent({ name: 'ContextMenu' })
|
||||
@@ -330,4 +479,16 @@ describe('TopMenuSection', () => {
|
||||
const model = menu.props('model') as MenuItem[]
|
||||
expect(model[0]?.disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('shows manager red dot only for manager conflicts', async () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
// Release red dot is mocked as true globally for this test file.
|
||||
expect(wrapper.find('span.bg-red-500').exists()).toBe(false)
|
||||
|
||||
mockData.setShowConflictRedDot(true)
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('span.bg-red-500').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -36,7 +36,14 @@
|
||||
|
||||
<div
|
||||
ref="actionbarContainerRef"
|
||||
class="actionbar-container relative pointer-events-auto flex gap-2 h-12 items-center rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 shadow-interface"
|
||||
:class="
|
||||
cn(
|
||||
'actionbar-container relative pointer-events-auto flex gap-2 h-12 items-center rounded-lg border bg-comfy-menu-bg px-2 shadow-interface',
|
||||
hasAnyError
|
||||
? 'border-destructive-background-hover'
|
||||
: 'border-interface-stroke'
|
||||
)
|
||||
"
|
||||
>
|
||||
<ActionBarButtons />
|
||||
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
|
||||
@@ -60,7 +67,7 @@
|
||||
? isQueueOverlayExpanded
|
||||
: undefined
|
||||
"
|
||||
class="px-3"
|
||||
class="relative px-3"
|
||||
data-testid="queue-overlay-toggle"
|
||||
@click="toggleQueueOverlay"
|
||||
@contextmenu.stop.prevent="showQueueContextMenu"
|
||||
@@ -68,6 +75,12 @@
|
||||
<span class="text-sm font-normal tabular-nums">
|
||||
{{ activeJobsLabel }}
|
||||
</span>
|
||||
<StatusBadge
|
||||
v-if="activeJobsCount > 0"
|
||||
data-testid="active-jobs-indicator"
|
||||
variant="dot"
|
||||
class="pointer-events-none absolute -top-0.5 -right-0.5 animate-pulse"
|
||||
/>
|
||||
<span class="sr-only">
|
||||
{{
|
||||
isQueuePanelV2Enabled
|
||||
@@ -97,6 +110,7 @@
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<ErrorOverlay />
|
||||
<QueueProgressOverlay
|
||||
v-if="isQueueProgressOverlayEnabled"
|
||||
v-model:expanded="isQueueOverlayExpanded"
|
||||
@@ -105,7 +119,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex flex-col items-end gap-1">
|
||||
<Teleport
|
||||
v-if="inlineProgressSummaryTarget"
|
||||
:to="inlineProgressSummaryTarget"
|
||||
@@ -121,6 +135,10 @@
|
||||
class="pr-1"
|
||||
:hidden="isQueueOverlayExpanded"
|
||||
/>
|
||||
<QueueNotificationBannerHost
|
||||
v-if="shouldShowQueueNotificationBanners"
|
||||
class="pr-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -135,8 +153,11 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ComfyActionbar from '@/components/actionbar/ComfyActionbar.vue'
|
||||
import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.vue'
|
||||
import StatusBadge from '@/components/common/StatusBadge.vue'
|
||||
import QueueInlineProgressSummary from '@/components/queue/QueueInlineProgressSummary.vue'
|
||||
import QueueNotificationBannerHost from '@/components/queue/QueueNotificationBannerHost.vue'
|
||||
import QueueProgressOverlay from '@/components/queue/QueueProgressOverlay.vue'
|
||||
import ErrorOverlay from '@/components/error/ErrorOverlay.vue'
|
||||
import ActionBarButtons from '@/components/topbar/ActionBarButtons.vue'
|
||||
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
|
||||
import LoginButton from '@/components/topbar/LoginButton.vue'
|
||||
@@ -145,7 +166,6 @@ import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
@@ -157,6 +177,7 @@ import { isDesktop } from '@/platform/distribution/types'
|
||||
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
|
||||
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
|
||||
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
@@ -173,8 +194,6 @@ const sidebarTabStore = useSidebarTabStore()
|
||||
const { activeJobsCount } = storeToRefs(queueStore)
|
||||
const { isOverlayExpanded: isQueueOverlayExpanded } = storeToRefs(queueUIStore)
|
||||
const { activeSidebarTabId } = storeToRefs(sidebarTabStore)
|
||||
const releaseStore = useReleaseStore()
|
||||
const { shouldShowRedDot: showReleaseRedDot } = storeToRefs(releaseStore)
|
||||
const { shouldShowRedDot: shouldShowConflictRedDot } =
|
||||
useConflictAcknowledgment()
|
||||
const isTopMenuHovered = ref(false)
|
||||
@@ -207,6 +226,9 @@ const isQueueProgressOverlayEnabled = computed(
|
||||
const shouldShowInlineProgressSummary = computed(
|
||||
() => isQueuePanelV2Enabled.value && isActionbarEnabled.value
|
||||
)
|
||||
const shouldShowQueueNotificationBanners = computed(
|
||||
() => isActionbarEnabled.value
|
||||
)
|
||||
const progressTarget = ref<HTMLElement | null>(null)
|
||||
function updateProgressTarget(target: HTMLElement | null) {
|
||||
progressTarget.value = target
|
||||
@@ -236,12 +258,12 @@ const queueContextMenuItems = computed<MenuItem[]>(() => [
|
||||
}
|
||||
])
|
||||
|
||||
// Use either release red dot or conflict red dot
|
||||
const shouldShowRedDot = computed((): boolean => {
|
||||
const releaseRedDot = showReleaseRedDot.value
|
||||
return releaseRedDot || shouldShowConflictRedDot.value
|
||||
return shouldShowConflictRedDot.value
|
||||
})
|
||||
|
||||
const { hasAnyError } = storeToRefs(executionStore)
|
||||
|
||||
// Right side panel toggle
|
||||
const { isOpen: isRightSidePanelOpen } = storeToRefs(rightSidePanelStore)
|
||||
const rightSidePanelTooltipConfig = computed(() =>
|
||||
|
||||
@@ -89,12 +89,12 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
|
||||
import type { BottomPanelExtension } from '@/types/extensionTypes'
|
||||
|
||||
const bottomPanelStore = useBottomPanelStore()
|
||||
const dialogService = useDialogService()
|
||||
const settingsDialog = useSettingsDialog()
|
||||
const { t } = useI18n()
|
||||
|
||||
const isShortcutsTabActive = computed(() => {
|
||||
@@ -115,7 +115,7 @@ const getTabDisplayTitle = (tab: BottomPanelExtension): string => {
|
||||
}
|
||||
|
||||
const openKeybindingSettings = async () => {
|
||||
dialogService.showSettingsDialog('keybinding')
|
||||
settingsDialog.show('keybinding')
|
||||
}
|
||||
|
||||
const closeBottomPanel = () => {
|
||||
|
||||
@@ -3,49 +3,26 @@
|
||||
<label class="content-center text-xs text-node-component-slot-text">
|
||||
{{ $t('boundingBox.x') }}
|
||||
</label>
|
||||
<input
|
||||
v-model.number="x"
|
||||
type="number"
|
||||
:min="0"
|
||||
step="1"
|
||||
class="h-7 rounded-lg border-none bg-component-node-widget-background px-2 text-xs text-component-node-foreground focus:outline-0"
|
||||
/>
|
||||
<ScrubableNumberInput v-model="x" :min="0" :step="1" />
|
||||
<label class="content-center text-xs text-node-component-slot-text">
|
||||
{{ $t('boundingBox.y') }}
|
||||
</label>
|
||||
<input
|
||||
v-model.number="y"
|
||||
type="number"
|
||||
:min="0"
|
||||
step="1"
|
||||
class="h-7 rounded-lg border-none bg-component-node-widget-background px-2 text-xs text-component-node-foreground focus:outline-0"
|
||||
/>
|
||||
<ScrubableNumberInput v-model="y" :min="0" :step="1" />
|
||||
<label class="content-center text-xs text-node-component-slot-text">
|
||||
{{ $t('boundingBox.width') }}
|
||||
</label>
|
||||
<input
|
||||
v-model.number="width"
|
||||
type="number"
|
||||
:min="1"
|
||||
step="1"
|
||||
class="h-7 rounded-lg border-none bg-component-node-widget-background px-2 text-xs text-component-node-foreground focus:outline-0"
|
||||
/>
|
||||
<ScrubableNumberInput v-model="width" :min="1" :step="1" />
|
||||
<label class="content-center text-xs text-node-component-slot-text">
|
||||
{{ $t('boundingBox.height') }}
|
||||
</label>
|
||||
<input
|
||||
v-model.number="height"
|
||||
type="number"
|
||||
:min="1"
|
||||
step="1"
|
||||
class="h-7 rounded-lg border-none bg-component-node-widget-background px-2 text-xs text-component-node-foreground focus:outline-0"
|
||||
/>
|
||||
<ScrubableNumberInput v-model="height" :min="1" :step="1" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import ScrubableNumberInput from '@/components/common/ScrubableNumberInput.vue'
|
||||
import type { Bounds } from '@/renderer/core/layout/types'
|
||||
|
||||
const modelValue = defineModel<Bounds>({
|
||||
|
||||
175
src/components/common/ScrubableNumberInput.vue
Normal file
@@ -0,0 +1,175 @@
|
||||
<template>
|
||||
<div
|
||||
ref="container"
|
||||
class="flex h-7 rounded-lg bg-component-node-widget-background text-xs text-component-node-foreground"
|
||||
>
|
||||
<slot name="background" />
|
||||
<Button
|
||||
v-if="!hideButtons"
|
||||
:aria-label="t('g.ariaLabel.decrement')"
|
||||
data-testid="decrement"
|
||||
class="h-full w-8 rounded-r-none hover:bg-base-foreground/20 disabled:opacity-30"
|
||||
variant="muted-textonly"
|
||||
:disabled="!canDecrement"
|
||||
tabindex="-1"
|
||||
@click="modelValue = clamp(modelValue - step)"
|
||||
>
|
||||
<i class="pi pi-minus" />
|
||||
</Button>
|
||||
<div class="relative min-w-[4ch] flex-1 py-1.5 my-0.25">
|
||||
<input
|
||||
ref="inputField"
|
||||
v-bind="inputAttrs"
|
||||
:value="displayValue ?? modelValue"
|
||||
:disabled
|
||||
:class="
|
||||
cn(
|
||||
'bg-transparent border-0 focus:outline-0 p-1 truncate text-sm absolute inset-0'
|
||||
)
|
||||
"
|
||||
inputmode="decimal"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
@blur="handleBlur"
|
||||
@keyup.enter="handleBlur"
|
||||
@dragstart.prevent
|
||||
/>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'absolute inset-0 z-10 cursor-ew-resize',
|
||||
textEdit && 'pointer-events-none hidden'
|
||||
)
|
||||
"
|
||||
@pointerdown="handlePointerDown"
|
||||
@pointermove="handlePointerMove"
|
||||
@pointerup="handlePointerUp"
|
||||
@pointercancel="resetDrag"
|
||||
/>
|
||||
</div>
|
||||
<slot />
|
||||
<Button
|
||||
v-if="!hideButtons"
|
||||
:aria-label="t('g.ariaLabel.increment')"
|
||||
data-testid="increment"
|
||||
class="h-full w-8 rounded-l-none hover:bg-base-foreground/20 disabled:opacity-30"
|
||||
variant="muted-textonly"
|
||||
:disabled="!canIncrement"
|
||||
tabindex="-1"
|
||||
@click="modelValue = clamp(modelValue + step)"
|
||||
>
|
||||
<i class="pi pi-plus" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onClickOutside } from '@vueuse/core'
|
||||
import { computed, ref, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const {
|
||||
min,
|
||||
max,
|
||||
step = 1,
|
||||
disabled = false,
|
||||
hideButtons = false,
|
||||
displayValue,
|
||||
parseValue
|
||||
} = defineProps<{
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
disabled?: boolean
|
||||
hideButtons?: boolean
|
||||
displayValue?: string
|
||||
parseValue?: (raw: string) => number | undefined
|
||||
inputAttrs?: Record<string, unknown>
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const modelValue = defineModel<number>({ default: 0 })
|
||||
|
||||
const container = useTemplateRef<HTMLDivElement>('container')
|
||||
const inputField = useTemplateRef<HTMLInputElement>('inputField')
|
||||
const textEdit = ref(false)
|
||||
|
||||
onClickOutside(container, () => {
|
||||
if (textEdit.value) textEdit.value = false
|
||||
})
|
||||
|
||||
function clamp(value: number): number {
|
||||
const lo = min ?? -Infinity
|
||||
const hi = max ?? Infinity
|
||||
return Math.min(hi, Math.max(lo, value))
|
||||
}
|
||||
|
||||
const canDecrement = computed(
|
||||
() => modelValue.value > (min ?? -Infinity) && !disabled
|
||||
)
|
||||
const canIncrement = computed(
|
||||
() => modelValue.value < (max ?? Infinity) && !disabled
|
||||
)
|
||||
|
||||
const dragging = ref(false)
|
||||
const dragDelta = ref(0)
|
||||
const hasDragged = ref(false)
|
||||
|
||||
function handleBlur(e: Event) {
|
||||
const target = e.target as HTMLInputElement
|
||||
const raw = target.value.trim()
|
||||
const parsed = parseValue
|
||||
? parseValue(raw)
|
||||
: raw === ''
|
||||
? undefined
|
||||
: Number(raw)
|
||||
if (parsed != null && !isNaN(parsed)) {
|
||||
modelValue.value = clamp(parsed)
|
||||
} else {
|
||||
target.value = displayValue ?? String(modelValue.value)
|
||||
}
|
||||
textEdit.value = false
|
||||
}
|
||||
|
||||
function handlePointerDown(e: PointerEvent) {
|
||||
if (e.button !== 0) return
|
||||
if (disabled) return
|
||||
const target = e.target as HTMLElement
|
||||
target.setPointerCapture(e.pointerId)
|
||||
dragging.value = true
|
||||
dragDelta.value = 0
|
||||
hasDragged.value = false
|
||||
}
|
||||
|
||||
function handlePointerMove(e: PointerEvent) {
|
||||
if (!dragging.value) return
|
||||
dragDelta.value += e.movementX
|
||||
const steps = (dragDelta.value / 10) | 0
|
||||
if (steps === 0) return
|
||||
hasDragged.value = true
|
||||
const unclipped = modelValue.value + steps * step
|
||||
dragDelta.value %= 10
|
||||
modelValue.value = clamp(unclipped)
|
||||
}
|
||||
|
||||
function handlePointerUp() {
|
||||
if (!dragging.value) return
|
||||
|
||||
if (!hasDragged.value) {
|
||||
textEdit.value = true
|
||||
inputField.value?.focus()
|
||||
inputField.value?.select()
|
||||
}
|
||||
|
||||
resetDrag()
|
||||
}
|
||||
|
||||
function resetDrag() {
|
||||
dragging.value = false
|
||||
dragDelta.value = 0
|
||||
}
|
||||
</script>
|
||||
@@ -12,9 +12,9 @@
|
||||
nodeContent: ({ context }) => ({
|
||||
class: 'group/tree-node',
|
||||
onClick: (e: MouseEvent) =>
|
||||
onNodeContentClick(e, context.node as RenderedTreeExplorerNode),
|
||||
onNodeContentClick(e, context.node as RenderedTreeExplorerNode<T>),
|
||||
onContextmenu: (e: MouseEvent) =>
|
||||
handleContextMenu(e, context.node as RenderedTreeExplorerNode)
|
||||
handleContextMenu(e, context.node as RenderedTreeExplorerNode<T>)
|
||||
}),
|
||||
nodeToggleButton: () => ({
|
||||
onClick: (e: MouseEvent) => {
|
||||
@@ -36,15 +36,11 @@
|
||||
</Tree>
|
||||
<ContextMenu ref="menu" :model="menuItems" />
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
<script setup lang="ts" generic="T">
|
||||
import ContextMenu from 'primevue/contextmenu'
|
||||
import type { MenuItem, MenuItemCommandEvent } from 'primevue/menuitem'
|
||||
import Tree from 'primevue/tree'
|
||||
import { computed, provide, ref } from 'vue'
|
||||
import { computed, provide, ref, shallowRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import TreeExplorerTreeNode from '@/components/common/TreeExplorerTreeNode.vue'
|
||||
@@ -60,6 +56,10 @@ import type {
|
||||
} from '@/types/treeExplorerTypes'
|
||||
import { combineTrees, findNodeByKey } from '@/utils/treeUtil'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
const expandedKeys = defineModel<Record<string, boolean>>('expandedKeys', {
|
||||
required: true
|
||||
})
|
||||
@@ -69,13 +69,13 @@ const selectionKeys = defineModel<Record<string, boolean>>('selectionKeys')
|
||||
const storeSelectionKeys = selectionKeys.value !== undefined
|
||||
|
||||
const props = defineProps<{
|
||||
root: TreeExplorerNode
|
||||
root: TreeExplorerNode<T>
|
||||
class?: string
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'nodeClick', node: RenderedTreeExplorerNode, event: MouseEvent): void
|
||||
(e: 'nodeDelete', node: RenderedTreeExplorerNode): void
|
||||
(e: 'contextMenu', node: RenderedTreeExplorerNode, event: MouseEvent): void
|
||||
(e: 'nodeClick', node: RenderedTreeExplorerNode<T>, event: MouseEvent): void
|
||||
(e: 'nodeDelete', node: RenderedTreeExplorerNode<T>): void
|
||||
(e: 'contextMenu', node: RenderedTreeExplorerNode<T>, event: MouseEvent): void
|
||||
}>()
|
||||
|
||||
const {
|
||||
@@ -83,19 +83,19 @@ const {
|
||||
getAddFolderMenuItem,
|
||||
handleFolderCreation,
|
||||
addFolderCommand
|
||||
} = useTreeFolderOperations(
|
||||
/* expandNode */ (node: TreeExplorerNode) => {
|
||||
} = useTreeFolderOperations<T>(
|
||||
/* expandNode */ (node: TreeExplorerNode<T>) => {
|
||||
expandedKeys.value[node.key] = true
|
||||
}
|
||||
)
|
||||
|
||||
const renderedRoot = computed<RenderedTreeExplorerNode>(() => {
|
||||
const renderedRoot = computed<RenderedTreeExplorerNode<T>>(() => {
|
||||
const renderedRoot = fillNodeInfo(props.root)
|
||||
return newFolderNode.value
|
||||
? combineTrees(renderedRoot, newFolderNode.value)
|
||||
: renderedRoot
|
||||
})
|
||||
const getTreeNodeIcon = (node: TreeExplorerNode) => {
|
||||
const getTreeNodeIcon = (node: TreeExplorerNode<T>) => {
|
||||
if (node.getIcon) {
|
||||
const icon = node.getIcon()
|
||||
if (icon) {
|
||||
@@ -111,7 +111,9 @@ const getTreeNodeIcon = (node: TreeExplorerNode) => {
|
||||
const isExpanded = expandedKeys.value?.[node.key] ?? false
|
||||
return isExpanded ? 'pi pi-folder-open' : 'pi pi-folder'
|
||||
}
|
||||
const fillNodeInfo = (node: TreeExplorerNode): RenderedTreeExplorerNode => {
|
||||
const fillNodeInfo = (
|
||||
node: TreeExplorerNode<T>
|
||||
): RenderedTreeExplorerNode<T> => {
|
||||
const children = node.children?.map(fillNodeInfo) ?? []
|
||||
const totalLeaves = node.leaf
|
||||
? 1
|
||||
@@ -128,7 +130,7 @@ const fillNodeInfo = (node: TreeExplorerNode): RenderedTreeExplorerNode => {
|
||||
}
|
||||
const onNodeContentClick = async (
|
||||
e: MouseEvent,
|
||||
node: RenderedTreeExplorerNode
|
||||
node: RenderedTreeExplorerNode<T>
|
||||
) => {
|
||||
if (!storeSelectionKeys) {
|
||||
selectionKeys.value = {}
|
||||
@@ -139,20 +141,22 @@ const onNodeContentClick = async (
|
||||
emit('nodeClick', node, e)
|
||||
}
|
||||
const menu = ref<InstanceType<typeof ContextMenu> | null>(null)
|
||||
const menuTargetNode = ref<RenderedTreeExplorerNode | null>(null)
|
||||
const menuTargetNode = shallowRef<RenderedTreeExplorerNode<T> | null>(null)
|
||||
const extraMenuItems = computed(() => {
|
||||
return menuTargetNode.value?.contextMenuItems
|
||||
? typeof menuTargetNode.value.contextMenuItems === 'function'
|
||||
? menuTargetNode.value.contextMenuItems(menuTargetNode.value)
|
||||
: menuTargetNode.value.contextMenuItems
|
||||
const node = menuTargetNode.value
|
||||
return node?.contextMenuItems
|
||||
? typeof node.contextMenuItems === 'function'
|
||||
? node.contextMenuItems(node)
|
||||
: node.contextMenuItems
|
||||
: []
|
||||
})
|
||||
const renameEditingNode = ref<RenderedTreeExplorerNode | null>(null)
|
||||
const renameEditingNode = shallowRef<RenderedTreeExplorerNode<T> | null>(null)
|
||||
const errorHandling = useErrorHandling()
|
||||
const handleNodeLabelEdit = async (
|
||||
node: RenderedTreeExplorerNode,
|
||||
n: RenderedTreeExplorerNode,
|
||||
newName: string
|
||||
) => {
|
||||
const node = n as RenderedTreeExplorerNode<T>
|
||||
await errorHandling.wrapWithErrorHandlingAsync(
|
||||
async () => {
|
||||
if (node.key === newFolderNode.value?.key) {
|
||||
@@ -170,35 +174,36 @@ const handleNodeLabelEdit = async (
|
||||
provide(InjectKeyHandleEditLabelFunction, handleNodeLabelEdit)
|
||||
|
||||
const { t } = useI18n()
|
||||
const renameCommand = (node: RenderedTreeExplorerNode) => {
|
||||
const renameCommand = (node: RenderedTreeExplorerNode<T>) => {
|
||||
renameEditingNode.value = node
|
||||
}
|
||||
const deleteCommand = async (node: RenderedTreeExplorerNode) => {
|
||||
const deleteCommand = async (node: RenderedTreeExplorerNode<T>) => {
|
||||
await node.handleDelete?.()
|
||||
emit('nodeDelete', node)
|
||||
}
|
||||
const menuItems = computed<MenuItem[]>(() =>
|
||||
[
|
||||
getAddFolderMenuItem(menuTargetNode.value),
|
||||
const menuItems = computed<MenuItem[]>(() => {
|
||||
const node = menuTargetNode.value
|
||||
return [
|
||||
getAddFolderMenuItem(node),
|
||||
{
|
||||
label: t('g.rename'),
|
||||
icon: 'pi pi-file-edit',
|
||||
command: () => {
|
||||
if (menuTargetNode.value) {
|
||||
renameCommand(menuTargetNode.value)
|
||||
if (node) {
|
||||
renameCommand(node)
|
||||
}
|
||||
},
|
||||
visible: menuTargetNode.value?.handleRename !== undefined
|
||||
visible: node?.handleRename !== undefined
|
||||
},
|
||||
{
|
||||
label: t('g.delete'),
|
||||
icon: 'pi pi-trash',
|
||||
command: async () => {
|
||||
if (menuTargetNode.value) {
|
||||
await deleteCommand(menuTargetNode.value)
|
||||
if (node) {
|
||||
await deleteCommand(node)
|
||||
}
|
||||
},
|
||||
visible: menuTargetNode.value?.handleDelete !== undefined,
|
||||
visible: node?.handleDelete !== undefined,
|
||||
isAsync: true // The delete command can be async
|
||||
},
|
||||
...extraMenuItems.value
|
||||
@@ -210,9 +215,12 @@ const menuItems = computed<MenuItem[]>(() =>
|
||||
})
|
||||
: undefined
|
||||
}))
|
||||
)
|
||||
})
|
||||
|
||||
const handleContextMenu = (e: MouseEvent, node: RenderedTreeExplorerNode) => {
|
||||
const handleContextMenu = (
|
||||
e: MouseEvent,
|
||||
node: RenderedTreeExplorerNode<T>
|
||||
) => {
|
||||
menuTargetNode.value = node
|
||||
emit('contextMenu', node, e)
|
||||
if (menuItems.value.filter((item) => item.visible).length > 0) {
|
||||
@@ -224,15 +232,13 @@ const wrapCommandWithErrorHandler = (
|
||||
command: (event: MenuItemCommandEvent) => void,
|
||||
{ isAsync = false }: { isAsync: boolean }
|
||||
) => {
|
||||
const node = menuTargetNode.value
|
||||
return isAsync
|
||||
? errorHandling.wrapWithErrorHandlingAsync(
|
||||
command as (event: MenuItemCommandEvent) => Promise<void>,
|
||||
menuTargetNode.value?.handleError
|
||||
)
|
||||
: errorHandling.wrapWithErrorHandling(
|
||||
command,
|
||||
menuTargetNode.value?.handleError
|
||||
node?.handleError
|
||||
)
|
||||
: errorHandling.wrapWithErrorHandling(command, node?.handleError)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
<script setup lang="ts" generic="T">
|
||||
import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview'
|
||||
import Badge from 'primevue/badge'
|
||||
import { computed, inject, ref } from 'vue'
|
||||
@@ -53,17 +53,17 @@ import type {
|
||||
} from '@/types/treeExplorerTypes'
|
||||
|
||||
const props = defineProps<{
|
||||
node: RenderedTreeExplorerNode
|
||||
node: RenderedTreeExplorerNode<T>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(
|
||||
e: 'itemDropped',
|
||||
node: RenderedTreeExplorerNode,
|
||||
data: RenderedTreeExplorerNode
|
||||
node: RenderedTreeExplorerNode<T>,
|
||||
data: RenderedTreeExplorerNode<T>
|
||||
): void
|
||||
(e: 'dragStart', node: RenderedTreeExplorerNode): void
|
||||
(e: 'dragEnd', node: RenderedTreeExplorerNode): void
|
||||
(e: 'dragStart', node: RenderedTreeExplorerNode<T>): void
|
||||
(e: 'dragEnd', node: RenderedTreeExplorerNode<T>): void
|
||||
}>()
|
||||
|
||||
const nodeBadgeText = computed<string>(() => {
|
||||
@@ -80,7 +80,7 @@ const showNodeBadgeText = computed<boolean>(() => nodeBadgeText.value !== '')
|
||||
const isEditing = computed<boolean>(() => props.node.isEditingLabel ?? false)
|
||||
const handleEditLabel = inject(InjectKeyHandleEditLabelFunction)
|
||||
const handleRename = (newName: string) => {
|
||||
handleEditLabel?.(props.node, newName)
|
||||
handleEditLabel?.(props.node as RenderedTreeExplorerNode, newName)
|
||||
}
|
||||
|
||||
const container = ref<HTMLElement | null>(null)
|
||||
@@ -117,9 +117,13 @@ if (props.node.droppable) {
|
||||
onDrop: async (event) => {
|
||||
const dndData = event.source.data as TreeExplorerDragAndDropData
|
||||
if (dndData.type === 'tree-explorer-node') {
|
||||
await props.node.handleDrop?.(dndData)
|
||||
await props.node.handleDrop?.(dndData as TreeExplorerDragAndDropData<T>)
|
||||
canDrop.value = false
|
||||
emit('itemDropped', props.node, dndData.data)
|
||||
emit(
|
||||
'itemDropped',
|
||||
props.node,
|
||||
dndData.data as RenderedTreeExplorerNode<T>
|
||||
)
|
||||
}
|
||||
},
|
||||
onDragEnter: (event) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<BaseModalLayout
|
||||
:content-title="$t('templateWorkflows.title', 'Workflow Templates')"
|
||||
class="workflow-template-selector-dialog"
|
||||
size="md"
|
||||
>
|
||||
<template #leftPanelHeaderTitle>
|
||||
<i class="icon-[comfy--template]" />
|
||||
@@ -854,19 +854,3 @@ onBeforeUnmount(() => {
|
||||
cardRefs.value = [] // Release DOM refs
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Ensure the workflow template selector dialog fits within provided dialog */
|
||||
.workflow-template-selector-dialog.base-widget-layout {
|
||||
width: 100% !important;
|
||||
max-width: 1400px;
|
||||
height: 100% !important;
|
||||
aspect-ratio: auto !important;
|
||||
}
|
||||
|
||||
@media (min-width: 1600px) {
|
||||
.workflow-template-selector-dialog.base-widget-layout {
|
||||
max-width: 1600px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,12 +4,7 @@
|
||||
v-for="item in dialogStore.dialogStack"
|
||||
:key="item.key"
|
||||
v-model:visible="item.visible"
|
||||
:class="[
|
||||
'global-dialog',
|
||||
item.key === 'global-settings' && teamWorkspacesEnabled
|
||||
? 'settings-dialog-workspace'
|
||||
: ''
|
||||
]"
|
||||
class="global-dialog"
|
||||
v-bind="item.dialogComponentProps"
|
||||
:pt="getDialogPt(item)"
|
||||
:aria-labelledby="item.key"
|
||||
|
||||
@@ -116,7 +116,7 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||
import type { ConfirmationDialogType } from '@/services/dialogService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
@@ -134,10 +134,7 @@ const onCancel = () => useDialogStore().closeDialog()
|
||||
|
||||
function openBlueprintOverwriteSetting() {
|
||||
useDialogStore().closeDialog()
|
||||
void useDialogService().showSettingsDialog(
|
||||
undefined,
|
||||
'Comfy.Workflow.WarnBlueprintOverwrite'
|
||||
)
|
||||
useSettingsDialog().show(undefined, 'Comfy.Workflow.WarnBlueprintOverwrite')
|
||||
}
|
||||
|
||||
const doNotAskAgain = ref(false)
|
||||
|
||||
@@ -64,7 +64,7 @@ import FileDownload from '@/components/common/FileDownload.vue'
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
import { isDesktop } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
// TODO: Read this from server internal API rather than hardcoding here
|
||||
@@ -105,10 +105,7 @@ const doNotAskAgain = ref(false)
|
||||
|
||||
function openShowMissingModelsSetting() {
|
||||
useDialogStore().closeDialog({ key: 'global-missing-models-warning' })
|
||||
void useDialogService().showSettingsDialog(
|
||||
undefined,
|
||||
'Comfy.Workflow.ShowMissingModelsWarning'
|
||||
)
|
||||
useSettingsDialog().show(undefined, 'Comfy.Workflow.ShowMissingModelsWarning')
|
||||
}
|
||||
|
||||
const modelDownloads = ref<Record<string, ModelInfo>>({})
|
||||
|
||||
@@ -90,7 +90,7 @@ import Button from '@/components/ui/button/Button.vue'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
import PackInstallButton from '@/workbench/extensions/manager/components/manager/button/PackInstallButton.vue'
|
||||
@@ -118,10 +118,7 @@ const handleGotItClick = () => {
|
||||
|
||||
function openShowMissingNodesSetting() {
|
||||
dialogStore.closeDialog({ key: 'global-missing-nodes' })
|
||||
void useDialogService().showSettingsDialog(
|
||||
undefined,
|
||||
'Comfy.Workflow.ShowMissingNodesWarning'
|
||||
)
|
||||
useSettingsDialog().show(undefined, 'Comfy.Workflow.ShowMissingNodesWarning')
|
||||
}
|
||||
|
||||
const { missingNodePacks, isLoading, error } = useMissingNodes()
|
||||
|
||||
@@ -162,7 +162,7 @@ import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { clearTopupTracking } from '@/platform/telemetry/topupTracker'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
@@ -173,7 +173,7 @@ const { isInsufficientCredits = false } = defineProps<{
|
||||
const { t } = useI18n()
|
||||
const authActions = useFirebaseAuthActions()
|
||||
const dialogStore = useDialogStore()
|
||||
const dialogService = useDialogService()
|
||||
const settingsDialog = useSettingsDialog()
|
||||
const telemetry = useTelemetry()
|
||||
const toast = useToast()
|
||||
const { buildDocsUrl, docsPaths } = useExternalLink()
|
||||
@@ -266,7 +266,7 @@ async function handleBuy() {
|
||||
: isSubscriptionEnabled()
|
||||
? 'subscription'
|
||||
: 'credits'
|
||||
dialogService.showSettingsDialog(settingsPanel)
|
||||
settingsDialog.show(settingsPanel)
|
||||
} catch (error) {
|
||||
console.error('Purchase failed:', error)
|
||||
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
<template>
|
||||
<PanelTemplate
|
||||
value="About"
|
||||
class="about-container"
|
||||
data-testid="about-panel"
|
||||
>
|
||||
<div class="about-container flex flex-col gap-2" data-testid="about-panel">
|
||||
<h2 class="mb-2 text-2xl font-bold">
|
||||
{{ $t('g.about') }}
|
||||
</h2>
|
||||
@@ -32,7 +28,7 @@
|
||||
v-if="systemStatsStore.systemStats"
|
||||
:stats="systemStatsStore.systemStats"
|
||||
/>
|
||||
</PanelTemplate>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -43,8 +39,6 @@ import SystemStatsPanel from '@/components/common/SystemStatsPanel.vue'
|
||||
import { useAboutPanelStore } from '@/stores/aboutPanelStore'
|
||||
import { useSystemStatsStore } from '@/stores/systemStatsStore'
|
||||
|
||||
import PanelTemplate from './PanelTemplate.vue'
|
||||
|
||||
const systemStatsStore = useSystemStatsStore()
|
||||
const aboutPanelStore = useAboutPanelStore()
|
||||
</script>
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
<template>
|
||||
<PanelTemplate value="Keybinding" class="keybinding-panel">
|
||||
<template #header>
|
||||
<SearchBox
|
||||
v-model="filters['global'].value"
|
||||
:placeholder="
|
||||
$t('g.searchPlaceholder', { subject: $t('g.keybindings') })
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
<div class="keybinding-panel flex flex-col gap-2">
|
||||
<SearchBox
|
||||
v-model="filters['global'].value"
|
||||
:placeholder="$t('g.searchPlaceholder', { subject: $t('g.keybindings') })"
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
v-model:selection="selectedCommandData"
|
||||
@@ -135,7 +131,7 @@
|
||||
<i class="pi pi-replay" />
|
||||
{{ $t('g.resetAll') }}
|
||||
</Button>
|
||||
</PanelTemplate>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -159,7 +155,6 @@ import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
|
||||
import PanelTemplate from './PanelTemplate.vue'
|
||||
import KeyComboDisplay from './keybinding/KeyComboDisplay.vue'
|
||||
|
||||
const filters = ref({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<TabPanel value="Credits" class="credits-container h-full">
|
||||
<div class="credits-container h-full">
|
||||
<!-- Legacy Design -->
|
||||
<div class="flex h-full flex-col">
|
||||
<h2 class="mb-2 text-2xl font-bold">
|
||||
@@ -102,7 +102,7 @@
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TabPanel>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -110,7 +110,6 @@ import Column from 'primevue/column'
|
||||
import DataTable from 'primevue/datatable'
|
||||
import Divider from 'primevue/divider'
|
||||
import Skeleton from 'primevue/skeleton'
|
||||
import TabPanel from 'primevue/tabpanel'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import UserCredit from '@/components/common/UserCredit.vue'
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
<template>
|
||||
<TabPanel :value="props.value" class="h-full w-full" :class="props.class">
|
||||
<div class="flex h-full w-full flex-col gap-2">
|
||||
<slot name="header" />
|
||||
<ScrollPanel class="h-0 grow pr-2">
|
||||
<slot />
|
||||
</ScrollPanel>
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</TabPanel>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ScrollPanel from 'primevue/scrollpanel'
|
||||
import TabPanel from 'primevue/tabpanel'
|
||||
|
||||
const props = defineProps<{
|
||||
value: string
|
||||
class?: string
|
||||
}>()
|
||||
</script>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<TabPanel value="User" class="user-settings-container h-full">
|
||||
<div class="user-settings-container h-full">
|
||||
<div class="flex h-full flex-col">
|
||||
<h2 class="mb-2 text-2xl font-bold">{{ $t('userSettings.title') }}</h2>
|
||||
<Divider class="mb-3" />
|
||||
@@ -95,13 +95,12 @@
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TabPanel>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Divider from 'primevue/divider'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import TabPanel from 'primevue/tabpanel'
|
||||
|
||||
import UserAvatar from '@/components/common/UserAvatar.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
<template>
|
||||
<TabPanel value="Workspace" class="h-full">
|
||||
<WorkspacePanelContent />
|
||||
</TabPanel>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import TabPanel from 'primevue/tabpanel'
|
||||
|
||||
import WorkspacePanelContent from '@/components/dialog/content/setting/WorkspacePanelContent.vue'
|
||||
</script>
|
||||
@@ -1,19 +0,0 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-2">
|
||||
<WorkspaceProfilePic
|
||||
class="size-6 text-xs"
|
||||
:workspace-name="workspaceName"
|
||||
/>
|
||||
|
||||
<span>{{ workspaceName }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
|
||||
const { workspaceName } = storeToRefs(useTeamWorkspaceStore())
|
||||
</script>
|
||||
@@ -1,32 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<h2 :class="cn(flags.teamWorkspacesEnabled ? 'px-6' : 'px-4')">
|
||||
<i class="pi pi-cog" />
|
||||
<span>{{ $t('g.settings') }}</span>
|
||||
<Tag
|
||||
v-if="isStaging"
|
||||
value="staging"
|
||||
severity="warn"
|
||||
class="ml-2 text-xs"
|
||||
/>
|
||||
</h2>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import Tag from 'primevue/tag'
|
||||
|
||||
import { isStaging } from '@/config/staging'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
const { flags } = useFeatureFlags()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.pi-cog {
|
||||
font-size: 1.25rem;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
.version-tag {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
100
src/components/error/ErrorOverlay.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
enter-from-class="-translate-y-3 opacity-0"
|
||||
enter-to-class="translate-y-0 opacity-100"
|
||||
>
|
||||
<div v-if="isVisible" class="flex justify-end w-full pointer-events-none">
|
||||
<div
|
||||
class="pointer-events-auto flex w-80 min-w-72 flex-col overflow-hidden rounded-lg border border-interface-stroke bg-comfy-menu-bg shadow-interface transition-colors duration-200 ease-in-out"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex h-12 items-center gap-2 px-4">
|
||||
<span class="flex-1 text-sm font-bold text-destructive-background">
|
||||
{{ errorCountLabel }}
|
||||
</span>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="t('g.close')"
|
||||
@click="dismiss"
|
||||
>
|
||||
<i class="icon-[lucide--x] block size-5 leading-none" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="px-4 pb-3">
|
||||
<ul class="m-0 flex list-none flex-col gap-1.5 p-0">
|
||||
<li
|
||||
v-for="(message, idx) in groupedErrorMessages"
|
||||
:key="idx"
|
||||
class="flex items-baseline gap-2 text-sm leading-snug text-muted-foreground"
|
||||
>
|
||||
<span
|
||||
class="mt-1.5 size-1 shrink-0 rounded-full bg-muted-foreground"
|
||||
/>
|
||||
<span>{{ message }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-end gap-4 px-4 py-3">
|
||||
<Button variant="muted-textonly" size="unset" @click="dismiss">
|
||||
{{ t('g.dismiss') }}
|
||||
</Button>
|
||||
<Button variant="secondary" size="lg" @click="seeErrors">
|
||||
{{ t('errorOverlay.seeErrors') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useErrorGroups } from '@/components/rightSidePanel/errors/useErrorGroups'
|
||||
|
||||
const { t } = useI18n()
|
||||
const executionStore = useExecutionStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
const { totalErrorCount, isErrorOverlayOpen } = storeToRefs(executionStore)
|
||||
const { groupedErrorMessages } = useErrorGroups(ref(''), t)
|
||||
|
||||
const errorCountLabel = computed(() =>
|
||||
t(
|
||||
'errorOverlay.errorCount',
|
||||
{ count: totalErrorCount.value },
|
||||
totalErrorCount.value
|
||||
)
|
||||
)
|
||||
|
||||
const isVisible = computed(
|
||||
() => isErrorOverlayOpen.value && totalErrorCount.value > 0
|
||||
)
|
||||
|
||||
function dismiss() {
|
||||
executionStore.dismissErrorOverlay()
|
||||
}
|
||||
|
||||
function seeErrors() {
|
||||
if (canvasStore.canvas) {
|
||||
canvasStore.canvas.deselectAll()
|
||||
canvasStore.updateSelectedItems()
|
||||
}
|
||||
|
||||
rightSidePanelStore.openPanel('errors')
|
||||
executionStore.dismissErrorOverlay()
|
||||
}
|
||||
</script>
|
||||
@@ -60,6 +60,9 @@
|
||||
v-if="shouldRenderVueNodes && comfyApp.canvas && comfyAppReady"
|
||||
:canvas="comfyApp.canvas"
|
||||
@wheel.capture="canvasInteractions.forwardEventToCanvas"
|
||||
@pointerdown.capture="forwardPanEvent"
|
||||
@pointerup.capture="forwardPanEvent"
|
||||
@pointermove.capture="forwardPanEvent"
|
||||
>
|
||||
<!-- Vue nodes rendered based on graph nodes -->
|
||||
<LGraphNode
|
||||
@@ -114,6 +117,7 @@ import {
|
||||
} from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { isMiddlePointerInput } from '@/base/pointerUtils'
|
||||
import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue'
|
||||
import TopMenuSection from '@/components/TopMenuSection.vue'
|
||||
import BottomPanel from '@/components/bottomPanel/BottomPanel.vue'
|
||||
@@ -160,6 +164,7 @@ import { ChangeTracker } from '@/scripts/changeTracker'
|
||||
import { IS_CONTROL_WIDGET, updateControlWidgetLabel } from '@/scripts/widgets'
|
||||
import { useColorPaletteService } from '@/services/colorPaletteService'
|
||||
import { useNewUserService } from '@/services/useNewUserService'
|
||||
import { shouldIgnoreCopyPaste } from '@/workbench/eventHelpers'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
import { useBootstrapStore } from '@/stores/bootstrapStore'
|
||||
@@ -540,4 +545,13 @@ onMounted(async () => {
|
||||
onUnmounted(() => {
|
||||
vueNodeLifecycle.cleanup()
|
||||
})
|
||||
function forwardPanEvent(e: PointerEvent) {
|
||||
if (
|
||||
(shouldIgnoreCopyPaste(e.target) && document.activeElement === e.target) ||
|
||||
!isMiddlePointerInput(e)
|
||||
)
|
||||
return
|
||||
|
||||
canvasInteractions.forwardEventToCanvas(e)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -67,18 +67,6 @@ describe('HoneyToast', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('applies collapsed max-height class when collapsed', async () => {
|
||||
const wrapper = mountComponent({ visible: true, expanded: false })
|
||||
await nextTick()
|
||||
|
||||
const expandableArea = document.body.querySelector(
|
||||
'[role="status"] > div:first-child'
|
||||
)
|
||||
expect(expandableArea?.classList.contains('max-h-0')).toBe(true)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('has aria-live="polite" for accessibility', async () => {
|
||||
const wrapper = mountComponent({ visible: true })
|
||||
await nextTick()
|
||||
@@ -127,11 +115,6 @@ describe('HoneyToast', () => {
|
||||
expect(content?.textContent).toBe('expanded')
|
||||
expect(toggleBtn?.textContent?.trim()).toBe('Collapse')
|
||||
|
||||
const expandableArea = document.body.querySelector(
|
||||
'[role="status"] > div:first-child'
|
||||
)
|
||||
expect(expandableArea?.classList.contains('max-h-[400px]')).toBe(true)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -26,13 +26,13 @@ function toggle() {
|
||||
v-if="visible"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
class="fixed inset-x-0 bottom-6 z-9999 mx-auto w-4/5 max-w-3xl overflow-hidden rounded-lg border border-border-default bg-base-background shadow-lg"
|
||||
class="fixed inset-x-0 bottom-6 z-9999 mx-auto max-w-3xl overflow-hidden rounded-lg border border-border-default bg-base-background shadow-lg min-w-0 w-min transition-all duration-300"
|
||||
>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'overflow-hidden transition-all duration-300',
|
||||
isExpanded ? 'max-h-[400px]' : 'max-h-0'
|
||||
'overflow-hidden transition-all duration-300 min-w-0 max-w-full',
|
||||
isExpanded ? 'w-[max(400px,40vw)] max-h-100' : 'w-0 max-h-0'
|
||||
)
|
||||
"
|
||||
>
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
:src="imageUrl"
|
||||
:alt="$t('imageCrop.cropPreviewAlt')"
|
||||
draggable="false"
|
||||
class="block size-full object-contain select-none brightness-50"
|
||||
class="block size-full object-contain select-none"
|
||||
@load="handleImageLoad"
|
||||
@error="handleImageError"
|
||||
@dragstart.prevent
|
||||
@@ -36,14 +36,12 @@
|
||||
|
||||
<div
|
||||
v-if="imageUrl && !isLoading"
|
||||
class="absolute box-content cursor-move overflow-hidden border-2 border-white"
|
||||
class="absolute box-content cursor-move border-2 border-white shadow-[0_0_0_9999px_rgba(0,0,0,0.5)]"
|
||||
:style="cropBoxStyle"
|
||||
@pointerdown="handleDragStart"
|
||||
@pointermove="handleDragMove"
|
||||
@pointerup="handleDragEnd"
|
||||
>
|
||||
<div class="pointer-events-none size-full" :style="cropImageStyle" />
|
||||
</div>
|
||||
/>
|
||||
|
||||
<div
|
||||
v-for="handle in resizeHandles"
|
||||
@@ -131,7 +129,6 @@ const {
|
||||
isLockEnabled,
|
||||
|
||||
cropBoxStyle,
|
||||
cropImageStyle,
|
||||
resizeHandles,
|
||||
|
||||
handleImageLoad,
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import CompletionSummaryBanner from './CompletionSummaryBanner.vue'
|
||||
|
||||
const meta: Meta<typeof CompletionSummaryBanner> = {
|
||||
title: 'Queue/CompletionSummaryBanner',
|
||||
component: CompletionSummaryBanner,
|
||||
parameters: {
|
||||
layout: 'padded'
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const thumb = (hex: string) =>
|
||||
`data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24'><rect width='24' height='24' fill='%23${hex}'/></svg>`
|
||||
|
||||
const thumbs = [thumb('ff6b6b'), thumb('4dabf7'), thumb('51cf66')]
|
||||
|
||||
export const AllSuccessSingle: Story = {
|
||||
args: {
|
||||
mode: 'allSuccess',
|
||||
completedCount: 1,
|
||||
failedCount: 0,
|
||||
thumbnailUrls: [thumbs[0]]
|
||||
}
|
||||
}
|
||||
|
||||
export const AllSuccessPlural: Story = {
|
||||
args: {
|
||||
mode: 'allSuccess',
|
||||
completedCount: 3,
|
||||
failedCount: 0,
|
||||
thumbnailUrls: thumbs
|
||||
}
|
||||
}
|
||||
|
||||
export const MixedSingleSingle: Story = {
|
||||
args: {
|
||||
mode: 'mixed',
|
||||
completedCount: 1,
|
||||
failedCount: 1,
|
||||
thumbnailUrls: thumbs.slice(0, 2)
|
||||
}
|
||||
}
|
||||
|
||||
export const MixedPluralPlural: Story = {
|
||||
args: {
|
||||
mode: 'mixed',
|
||||
completedCount: 2,
|
||||
failedCount: 3,
|
||||
thumbnailUrls: thumbs
|
||||
}
|
||||
}
|
||||
|
||||
export const AllFailedSingle: Story = {
|
||||
args: {
|
||||
mode: 'allFailed',
|
||||
completedCount: 0,
|
||||
failedCount: 1,
|
||||
thumbnailUrls: []
|
||||
}
|
||||
}
|
||||
|
||||
export const AllFailedPlural: Story = {
|
||||
args: {
|
||||
mode: 'allFailed',
|
||||
completedCount: 0,
|
||||
failedCount: 4,
|
||||
thumbnailUrls: []
|
||||
}
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import CompletionSummaryBanner from './CompletionSummaryBanner.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
sideToolbar: {
|
||||
queueProgressOverlay: {
|
||||
jobsCompleted: '{count} job completed | {count} jobs completed',
|
||||
jobsFailed: '{count} job failed | {count} jobs failed'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const mountComponent = (props: Record<string, unknown>) =>
|
||||
mount(CompletionSummaryBanner, {
|
||||
props: {
|
||||
mode: 'allSuccess',
|
||||
completedCount: 0,
|
||||
failedCount: 0,
|
||||
...props
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n]
|
||||
}
|
||||
})
|
||||
|
||||
describe('CompletionSummaryBanner', () => {
|
||||
it('renders success mode text, thumbnails, and aria label', () => {
|
||||
const wrapper = mountComponent({
|
||||
mode: 'allSuccess',
|
||||
completedCount: 3,
|
||||
failedCount: 0,
|
||||
thumbnailUrls: [
|
||||
'https://example.com/thumb-a.png',
|
||||
'https://example.com/thumb-b.png'
|
||||
],
|
||||
ariaLabel: 'Open queue summary'
|
||||
})
|
||||
|
||||
const button = wrapper.get('button')
|
||||
expect(button.attributes('aria-label')).toBe('Open queue summary')
|
||||
expect(wrapper.text()).toContain('3 jobs completed')
|
||||
|
||||
const thumbnailImages = wrapper.findAll('img')
|
||||
expect(thumbnailImages).toHaveLength(2)
|
||||
expect(thumbnailImages[0].attributes('src')).toBe(
|
||||
'https://example.com/thumb-a.png'
|
||||
)
|
||||
expect(thumbnailImages[1].attributes('src')).toBe(
|
||||
'https://example.com/thumb-b.png'
|
||||
)
|
||||
|
||||
const thumbnailContainers = wrapper.findAll('.inline-block.h-6.w-6')
|
||||
expect(thumbnailContainers[1].attributes('style')).toContain(
|
||||
'margin-left: -12px'
|
||||
)
|
||||
|
||||
expect(wrapper.html()).not.toContain('icon-[lucide--circle-alert]')
|
||||
})
|
||||
|
||||
it('renders mixed mode with success and failure counts', () => {
|
||||
const wrapper = mountComponent({
|
||||
mode: 'mixed',
|
||||
completedCount: 2,
|
||||
failedCount: 1
|
||||
})
|
||||
|
||||
const summaryText = wrapper.text().replace(/\s+/g, ' ').trim()
|
||||
expect(summaryText).toContain('2 jobs completed, 1 job failed')
|
||||
})
|
||||
|
||||
it('renders failure mode icon without thumbnails', () => {
|
||||
const wrapper = mountComponent({
|
||||
mode: 'allFailed',
|
||||
completedCount: 0,
|
||||
failedCount: 4
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('4 jobs failed')
|
||||
expect(wrapper.html()).toContain('icon-[lucide--circle-alert]')
|
||||
expect(wrapper.findAll('img')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -1,109 +0,0 @@
|
||||
<template>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
class="group w-full justify-between gap-3 p-1 text-left font-normal hover:cursor-pointer focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-background"
|
||||
:aria-label="props.ariaLabel"
|
||||
@click="emit('click', $event)"
|
||||
>
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<span v-if="props.mode === 'allFailed'" class="inline-flex items-center">
|
||||
<i
|
||||
class="ml-1 icon-[lucide--circle-alert] block size-4 leading-none text-destructive-background"
|
||||
/>
|
||||
</span>
|
||||
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<span
|
||||
v-if="props.mode !== 'allFailed'"
|
||||
class="relative inline-flex h-6 items-center"
|
||||
>
|
||||
<span
|
||||
v-for="(url, idx) in props.thumbnailUrls"
|
||||
:key="url + idx"
|
||||
class="inline-block h-6 w-6 overflow-hidden rounded-[6px] border-0 bg-secondary-background"
|
||||
:style="{ marginLeft: idx === 0 ? '0' : '-12px' }"
|
||||
>
|
||||
<img
|
||||
:src="url"
|
||||
:alt="$t('sideToolbar.queueProgressOverlay.preview')"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span class="text-[14px] font-normal text-text-primary">
|
||||
<template v-if="props.mode === 'allSuccess'">
|
||||
<i18n-t
|
||||
keypath="sideToolbar.queueProgressOverlay.jobsCompleted"
|
||||
:plural="props.completedCount"
|
||||
>
|
||||
<template #count>
|
||||
<span class="font-bold">{{ props.completedCount }}</span>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</template>
|
||||
<template v-else-if="props.mode === 'mixed'">
|
||||
<i18n-t
|
||||
keypath="sideToolbar.queueProgressOverlay.jobsCompleted"
|
||||
:plural="props.completedCount"
|
||||
>
|
||||
<template #count>
|
||||
<span class="font-bold">{{ props.completedCount }}</span>
|
||||
</template>
|
||||
</i18n-t>
|
||||
<span>, </span>
|
||||
<i18n-t
|
||||
keypath="sideToolbar.queueProgressOverlay.jobsFailed"
|
||||
:plural="props.failedCount"
|
||||
>
|
||||
<template #count>
|
||||
<span class="font-bold">{{ props.failedCount }}</span>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</template>
|
||||
<template v-else>
|
||||
<i18n-t
|
||||
keypath="sideToolbar.queueProgressOverlay.jobsFailed"
|
||||
:plural="props.failedCount"
|
||||
>
|
||||
<template #count>
|
||||
<span class="font-bold">{{ props.failedCount }}</span>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</template>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="flex items-center justify-center rounded p-1 text-text-secondary transition-colors duration-200 ease-in-out"
|
||||
>
|
||||
<i class="icon-[lucide--chevron-down] block size-4 leading-none" />
|
||||
</span>
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type {
|
||||
CompletionSummary,
|
||||
CompletionSummaryMode
|
||||
} from '@/composables/queue/useCompletionSummary'
|
||||
|
||||
type Props = {
|
||||
mode: CompletionSummaryMode
|
||||
completedCount: CompletionSummary['completedCount']
|
||||
failedCount: CompletionSummary['failedCount']
|
||||
thumbnailUrls?: CompletionSummary['thumbnailUrls']
|
||||
ariaLabel?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
thumbnailUrls: () => []
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'click', event: MouseEvent): void
|
||||
}>()
|
||||
</script>
|
||||
140
src/components/queue/QueueNotificationBanner.stories.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import type { QueueNotificationBanner as QueueNotificationBannerItem } from '@/composables/queue/useQueueNotificationBanners'
|
||||
|
||||
import QueueNotificationBanner from './QueueNotificationBanner.vue'
|
||||
|
||||
const meta: Meta<typeof QueueNotificationBanner> = {
|
||||
title: 'Queue/QueueNotificationBanner',
|
||||
component: QueueNotificationBanner,
|
||||
parameters: {
|
||||
layout: 'padded'
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const thumbnail = (hex: string) =>
|
||||
`data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='256' height='256'><rect width='256' height='256' fill='%23${hex}'/></svg>`
|
||||
|
||||
const args = (notification: QueueNotificationBannerItem) => ({ notification })
|
||||
|
||||
export const Queueing: Story = {
|
||||
args: args({
|
||||
type: 'queuedPending',
|
||||
count: 1
|
||||
})
|
||||
}
|
||||
|
||||
export const QueueingMultiple: Story = {
|
||||
args: args({
|
||||
type: 'queuedPending',
|
||||
count: 3
|
||||
})
|
||||
}
|
||||
|
||||
export const Queued: Story = {
|
||||
args: args({
|
||||
type: 'queued',
|
||||
count: 1
|
||||
})
|
||||
}
|
||||
|
||||
export const QueuedMultiple: Story = {
|
||||
args: args({
|
||||
type: 'queued',
|
||||
count: 4
|
||||
})
|
||||
}
|
||||
|
||||
export const Completed: Story = {
|
||||
args: args({
|
||||
type: 'completed',
|
||||
count: 1,
|
||||
thumbnailUrls: [thumbnail('4dabf7')]
|
||||
})
|
||||
}
|
||||
|
||||
export const CompletedMultiple: Story = {
|
||||
args: args({
|
||||
type: 'completed',
|
||||
count: 4
|
||||
})
|
||||
}
|
||||
|
||||
export const CompletedMultipleWithThumbnail: Story = {
|
||||
args: args({
|
||||
type: 'completed',
|
||||
count: 4,
|
||||
thumbnailUrls: [
|
||||
thumbnail('ff6b6b'),
|
||||
thumbnail('4dabf7'),
|
||||
thumbnail('51cf66')
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
export const Failed: Story = {
|
||||
args: args({
|
||||
type: 'failed',
|
||||
count: 1
|
||||
})
|
||||
}
|
||||
|
||||
export const Gallery: Story = {
|
||||
render: () => ({
|
||||
components: { QueueNotificationBanner },
|
||||
setup() {
|
||||
const queueing = args({
|
||||
type: 'queuedPending',
|
||||
count: 1
|
||||
})
|
||||
const queued = args({
|
||||
type: 'queued',
|
||||
count: 2
|
||||
})
|
||||
const completed = args({
|
||||
type: 'completed',
|
||||
count: 1,
|
||||
thumbnailUrls: [thumbnail('ff6b6b')]
|
||||
})
|
||||
const completedMultiple = args({
|
||||
type: 'completed',
|
||||
count: 4
|
||||
})
|
||||
const completedMultipleWithThumbnail = args({
|
||||
type: 'completed',
|
||||
count: 4,
|
||||
thumbnailUrls: [
|
||||
thumbnail('51cf66'),
|
||||
thumbnail('ffd43b'),
|
||||
thumbnail('ff922b')
|
||||
]
|
||||
})
|
||||
const failed = args({
|
||||
type: 'failed',
|
||||
count: 2
|
||||
})
|
||||
|
||||
return {
|
||||
queueing,
|
||||
queued,
|
||||
completed,
|
||||
completedMultiple,
|
||||
completedMultipleWithThumbnail,
|
||||
failed
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div class="flex flex-col gap-2">
|
||||
<QueueNotificationBanner v-bind="queueing" />
|
||||
<QueueNotificationBanner v-bind="queued" />
|
||||
<QueueNotificationBanner v-bind="completed" />
|
||||
<QueueNotificationBanner v-bind="completedMultiple" />
|
||||
<QueueNotificationBanner v-bind="completedMultipleWithThumbnail" />
|
||||
<QueueNotificationBanner v-bind="failed" />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
136
src/components/queue/QueueNotificationBanner.test.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import QueueNotificationBanner from '@/components/queue/QueueNotificationBanner.vue'
|
||||
import type { QueueNotificationBanner as QueueNotificationBannerItem } from '@/composables/queue/useQueueNotificationBanners'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
queue: {
|
||||
jobAddedToQueue: 'Job added to queue',
|
||||
jobQueueing: 'Job queueing'
|
||||
},
|
||||
sideToolbar: {
|
||||
queueProgressOverlay: {
|
||||
preview: 'Preview',
|
||||
jobCompleted: 'Job completed',
|
||||
jobFailed: 'Job failed',
|
||||
jobsAddedToQueue:
|
||||
'{count} job added to queue | {count} jobs added to queue',
|
||||
jobsCompleted: '{count} job completed | {count} jobs completed',
|
||||
jobsFailed: '{count} job failed | {count} jobs failed'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const mountComponent = (notification: QueueNotificationBannerItem) =>
|
||||
mount(QueueNotificationBanner, {
|
||||
props: { notification },
|
||||
global: {
|
||||
plugins: [i18n]
|
||||
}
|
||||
})
|
||||
|
||||
describe(QueueNotificationBanner, () => {
|
||||
it('renders singular queued message without count prefix', () => {
|
||||
const wrapper = mountComponent({
|
||||
type: 'queued',
|
||||
count: 1
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('Job added to queue')
|
||||
expect(wrapper.text()).not.toContain('1 job')
|
||||
})
|
||||
|
||||
it('renders queued message with pluralization', () => {
|
||||
const wrapper = mountComponent({
|
||||
type: 'queued',
|
||||
count: 2
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('2 jobs added to queue')
|
||||
expect(wrapper.html()).toContain('icon-[lucide--check]')
|
||||
})
|
||||
|
||||
it('renders queued pending message with spinner icon', () => {
|
||||
const wrapper = mountComponent({
|
||||
type: 'queuedPending',
|
||||
count: 1
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('Job queueing')
|
||||
expect(wrapper.html()).toContain('icon-[lucide--loader-circle]')
|
||||
expect(wrapper.html()).toContain('animate-spin')
|
||||
})
|
||||
|
||||
it('renders failed message and alert icon', () => {
|
||||
const wrapper = mountComponent({
|
||||
type: 'failed',
|
||||
count: 1
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('Job failed')
|
||||
expect(wrapper.html()).toContain('icon-[lucide--circle-alert]')
|
||||
})
|
||||
|
||||
it('renders completed message with thumbnail preview when provided', () => {
|
||||
const wrapper = mountComponent({
|
||||
type: 'completed',
|
||||
count: 3,
|
||||
thumbnailUrls: ['https://example.com/preview.png']
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('3 jobs completed')
|
||||
const image = wrapper.get('img')
|
||||
expect(image.attributes('src')).toBe('https://example.com/preview.png')
|
||||
expect(image.attributes('alt')).toBe('Preview')
|
||||
})
|
||||
|
||||
it('renders two completion thumbnail previews', () => {
|
||||
const wrapper = mountComponent({
|
||||
type: 'completed',
|
||||
count: 4,
|
||||
thumbnailUrls: [
|
||||
'https://example.com/preview-1.png',
|
||||
'https://example.com/preview-2.png'
|
||||
]
|
||||
})
|
||||
|
||||
const images = wrapper.findAll('img')
|
||||
expect(images.length).toBe(2)
|
||||
expect(images[0].attributes('src')).toBe(
|
||||
'https://example.com/preview-1.png'
|
||||
)
|
||||
expect(images[1].attributes('src')).toBe(
|
||||
'https://example.com/preview-2.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('caps completion thumbnail previews at two', () => {
|
||||
const wrapper = mountComponent({
|
||||
type: 'completed',
|
||||
count: 4,
|
||||
thumbnailUrls: [
|
||||
'https://example.com/preview-1.png',
|
||||
'https://example.com/preview-2.png',
|
||||
'https://example.com/preview-3.png',
|
||||
'https://example.com/preview-4.png'
|
||||
]
|
||||
})
|
||||
|
||||
const images = wrapper.findAll('img')
|
||||
expect(images.length).toBe(2)
|
||||
expect(images[0].attributes('src')).toBe(
|
||||
'https://example.com/preview-1.png'
|
||||
)
|
||||
expect(images[1].attributes('src')).toBe(
|
||||
'https://example.com/preview-2.png'
|
||||
)
|
||||
})
|
||||
})
|
||||
149
src/components/queue/QueueNotificationBanner.vue
Normal file
@@ -0,0 +1,149 @@
|
||||
<template>
|
||||
<div class="inline-flex overflow-hidden rounded-lg bg-secondary-background">
|
||||
<div class="flex items-center gap-2 p-1 pr-3">
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'relative shrink-0 items-center rounded-[4px]',
|
||||
showsCompletionPreview && showThumbnails
|
||||
? 'flex h-8 overflow-visible p-0'
|
||||
: showsCompletionPreview
|
||||
? 'flex size-8 justify-center overflow-hidden p-0'
|
||||
: 'flex size-8 justify-center p-1'
|
||||
)
|
||||
"
|
||||
>
|
||||
<template v-if="showThumbnails">
|
||||
<div class="flex h-8 shrink-0 items-center">
|
||||
<div
|
||||
v-for="(thumbnailUrl, index) in thumbnailUrls"
|
||||
:key="`completion-preview-${index}`"
|
||||
:class="
|
||||
cn(
|
||||
'relative size-8 shrink-0 overflow-hidden rounded-[4px]',
|
||||
index > 0 && '-ml-3 ring-2 ring-secondary-background'
|
||||
)
|
||||
"
|
||||
>
|
||||
<img
|
||||
:src="thumbnailUrl"
|
||||
:alt="t('sideToolbar.queueProgressOverlay.preview')"
|
||||
class="size-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
v-else-if="showCompletionGradientFallback"
|
||||
class="size-full bg-linear-to-br from-coral-500 via-coral-500 to-azure-600"
|
||||
/>
|
||||
<i
|
||||
v-else
|
||||
:class="cn(iconClass, 'size-4', iconColorClass)"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex h-full items-center">
|
||||
<span
|
||||
class="overflow-hidden text-ellipsis text-center font-inter text-[12px] leading-normal font-normal text-base-foreground"
|
||||
>
|
||||
{{ bannerText }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { QueueNotificationBanner } from '@/composables/queue/useQueueNotificationBanners'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { notification } = defineProps<{
|
||||
notification: QueueNotificationBanner
|
||||
}>()
|
||||
|
||||
const { t, n } = useI18n()
|
||||
|
||||
const thumbnailUrls = computed(() => {
|
||||
if (notification.type !== 'completed') {
|
||||
return []
|
||||
}
|
||||
return notification.thumbnailUrls?.slice(0, 2) ?? []
|
||||
})
|
||||
|
||||
const showThumbnails = computed(() => {
|
||||
if (notification.type !== 'completed') {
|
||||
return false
|
||||
}
|
||||
return thumbnailUrls.value.length > 0
|
||||
})
|
||||
|
||||
const showCompletionGradientFallback = computed(
|
||||
() => notification.type === 'completed' && !showThumbnails.value
|
||||
)
|
||||
|
||||
const showsCompletionPreview = computed(
|
||||
() => showThumbnails.value || showCompletionGradientFallback.value
|
||||
)
|
||||
|
||||
const bannerText = computed(() => {
|
||||
const count = notification.count
|
||||
if (notification.type === 'queuedPending') {
|
||||
return t('queue.jobQueueing')
|
||||
}
|
||||
if (notification.type === 'queued') {
|
||||
if (count === 1) {
|
||||
return t('queue.jobAddedToQueue')
|
||||
}
|
||||
return t(
|
||||
'sideToolbar.queueProgressOverlay.jobsAddedToQueue',
|
||||
{ count: n(count) },
|
||||
count
|
||||
)
|
||||
}
|
||||
if (notification.type === 'failed') {
|
||||
if (count === 1) {
|
||||
return t('sideToolbar.queueProgressOverlay.jobFailed')
|
||||
}
|
||||
return t(
|
||||
'sideToolbar.queueProgressOverlay.jobsFailed',
|
||||
{ count: n(count) },
|
||||
count
|
||||
)
|
||||
}
|
||||
if (count === 1) {
|
||||
return t('sideToolbar.queueProgressOverlay.jobCompleted')
|
||||
}
|
||||
return t(
|
||||
'sideToolbar.queueProgressOverlay.jobsCompleted',
|
||||
{ count: n(count) },
|
||||
count
|
||||
)
|
||||
})
|
||||
|
||||
const iconClass = computed(() => {
|
||||
if (notification.type === 'queuedPending') {
|
||||
return 'icon-[lucide--loader-circle]'
|
||||
}
|
||||
if (notification.type === 'queued') {
|
||||
return 'icon-[lucide--check]'
|
||||
}
|
||||
if (notification.type === 'failed') {
|
||||
return 'icon-[lucide--circle-alert]'
|
||||
}
|
||||
return 'icon-[lucide--image]'
|
||||
})
|
||||
|
||||
const iconColorClass = computed(() => {
|
||||
if (notification.type === 'queuedPending') {
|
||||
return 'animate-spin text-slate-100'
|
||||
}
|
||||
if (notification.type === 'failed') {
|
||||
return 'text-danger-200'
|
||||
}
|
||||
return 'text-slate-100'
|
||||
})
|
||||
</script>
|
||||
18
src/components/queue/QueueNotificationBannerHost.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="currentNotification"
|
||||
class="flex justify-end"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
>
|
||||
<QueueNotificationBanner :notification="currentNotification" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import QueueNotificationBanner from '@/components/queue/QueueNotificationBanner.vue'
|
||||
import { useQueueNotificationBanners } from '@/composables/queue/useQueueNotificationBanners'
|
||||
|
||||
const { currentNotification } = useQueueNotificationBanners()
|
||||
</script>
|
||||
@@ -1,69 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import QueueOverlayEmpty from './QueueOverlayEmpty.vue'
|
||||
import type { CompletionSummary } from '@/composables/queue/useCompletionSummary'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
sideToolbar: {
|
||||
queueProgressOverlay: {
|
||||
expandCollapsedQueue: 'Expand job queue',
|
||||
noActiveJobs: 'No active jobs'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const CompletionSummaryBannerStub = {
|
||||
name: 'CompletionSummaryBanner',
|
||||
props: [
|
||||
'mode',
|
||||
'completedCount',
|
||||
'failedCount',
|
||||
'thumbnailUrls',
|
||||
'ariaLabel'
|
||||
],
|
||||
emits: ['click'],
|
||||
template: '<button class="summary-banner" @click="$emit(\'click\')"></button>'
|
||||
}
|
||||
|
||||
const mountComponent = (summary: CompletionSummary) =>
|
||||
mount(QueueOverlayEmpty, {
|
||||
props: { summary },
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
components: { CompletionSummaryBanner: CompletionSummaryBannerStub }
|
||||
}
|
||||
})
|
||||
|
||||
describe('QueueOverlayEmpty', () => {
|
||||
it('renders completion summary banner and proxies click', async () => {
|
||||
const summary: CompletionSummary = {
|
||||
mode: 'mixed',
|
||||
completedCount: 2,
|
||||
failedCount: 1,
|
||||
thumbnailUrls: ['thumb-a']
|
||||
}
|
||||
|
||||
const wrapper = mountComponent(summary)
|
||||
const summaryBanner = wrapper.findComponent(CompletionSummaryBannerStub)
|
||||
|
||||
expect(summaryBanner.exists()).toBe(true)
|
||||
expect(summaryBanner.props()).toMatchObject({
|
||||
mode: 'mixed',
|
||||
completedCount: 2,
|
||||
failedCount: 1,
|
||||
thumbnailUrls: ['thumb-a'],
|
||||
ariaLabel: 'Expand job queue'
|
||||
})
|
||||
|
||||
await summaryBanner.trigger('click')
|
||||
expect(wrapper.emitted('summaryClick')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -1,27 +0,0 @@
|
||||
<template>
|
||||
<div class="pointer-events-auto">
|
||||
<CompletionSummaryBanner
|
||||
:mode="summary.mode"
|
||||
:completed-count="summary.completedCount"
|
||||
:failed-count="summary.failedCount"
|
||||
:thumbnail-urls="summary.thumbnailUrls"
|
||||
:aria-label="t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')"
|
||||
@click="$emit('summaryClick')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import CompletionSummaryBanner from '@/components/queue/CompletionSummaryBanner.vue'
|
||||
import type { CompletionSummary } from '@/composables/queue/useCompletionSummary'
|
||||
|
||||
defineProps<{ summary: CompletionSummary }>()
|
||||
|
||||
defineEmits<{
|
||||
(e: 'summaryClick'): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
@@ -4,46 +4,17 @@
|
||||
:header-title="headerTitle"
|
||||
:show-concurrent-indicator="showConcurrentIndicator"
|
||||
:concurrent-workflow-count="concurrentWorkflowCount"
|
||||
:queued-count="queuedCount"
|
||||
@clear-history="$emit('clearHistory')"
|
||||
@clear-queued="$emit('clearQueued')"
|
||||
/>
|
||||
|
||||
<div class="flex items-center justify-between px-3">
|
||||
<Button
|
||||
class="grow gap-1 justify-center"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
@click="$emit('showAssets')"
|
||||
>
|
||||
<i class="icon-[comfy--image-ai-edit] size-4" />
|
||||
<span>{{ t('sideToolbar.queueProgressOverlay.showAssets') }}</span>
|
||||
</Button>
|
||||
<div class="ml-4 inline-flex items-center">
|
||||
<div
|
||||
class="inline-flex h-6 items-center text-[12px] leading-none text-text-primary opacity-90"
|
||||
>
|
||||
<span class="font-bold">{{ queuedCount }}</span>
|
||||
<span class="ml-1">{{
|
||||
t('sideToolbar.queueProgressOverlay.queuedSuffix')
|
||||
}}</span>
|
||||
</div>
|
||||
<Button
|
||||
v-if="queuedCount > 0"
|
||||
class="ml-2"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
:aria-label="t('sideToolbar.queueProgressOverlay.clearQueued')"
|
||||
@click="$emit('clearQueued')"
|
||||
>
|
||||
<i class="icon-[lucide--list-x] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<JobFiltersBar
|
||||
:selected-job-tab="selectedJobTab"
|
||||
:selected-workflow-filter="selectedWorkflowFilter"
|
||||
:selected-sort-mode="selectedSortMode"
|
||||
:has-failed-jobs="hasFailedJobs"
|
||||
@show-assets="$emit('showAssets')"
|
||||
@update:selected-job-tab="$emit('update:selectedJobTab', $event)"
|
||||
@update:selected-workflow-filter="
|
||||
$emit('update:selectedWorkflowFilter', $event)
|
||||
@@ -71,9 +42,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type {
|
||||
JobGroup,
|
||||
JobListItem,
|
||||
@@ -112,8 +81,6 @@ const emit = defineEmits<{
|
||||
(e: 'viewItem', item: JobListItem): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const currentMenuItem = ref<JobListItem | null>(null)
|
||||
const jobContextMenuRef = ref<InstanceType<typeof JobContextMenu> | null>(null)
|
||||
|
||||
|
||||
@@ -40,6 +40,8 @@ const i18n = createI18n({
|
||||
sideToolbar: {
|
||||
queueProgressOverlay: {
|
||||
running: 'running',
|
||||
queuedSuffix: 'queued',
|
||||
clearQueued: 'Clear queued',
|
||||
moreOptions: 'More options',
|
||||
clearHistory: 'Clear history'
|
||||
}
|
||||
@@ -54,6 +56,7 @@ const mountHeader = (props = {}) =>
|
||||
headerTitle: 'Job queue',
|
||||
showConcurrentIndicator: true,
|
||||
concurrentWorkflowCount: 2,
|
||||
queuedCount: 3,
|
||||
...props
|
||||
},
|
||||
global: {
|
||||
@@ -80,6 +83,25 @@ describe('QueueOverlayHeader', () => {
|
||||
expect(wrapper.find('.inline-flex.items-center.gap-1').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows queued summary and emits clear queued', async () => {
|
||||
const wrapper = mountHeader({ queuedCount: 4 })
|
||||
|
||||
expect(wrapper.text()).toContain('4')
|
||||
expect(wrapper.text()).toContain('queued')
|
||||
|
||||
const clearQueuedButton = wrapper.get('button[aria-label="Clear queued"]')
|
||||
await clearQueuedButton.trigger('click')
|
||||
expect(wrapper.emitted('clearQueued')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('hides clear queued button when queued count is zero', () => {
|
||||
const wrapper = mountHeader({ queuedCount: 0 })
|
||||
|
||||
expect(wrapper.find('button[aria-label="Clear queued"]').exists()).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('toggles popover and emits clear history', async () => {
|
||||
const spy = vi.spyOn(tooltipConfig, 'buildTooltipConfig')
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex h-12 items-center justify-between gap-2 border-b border-interface-stroke px-2"
|
||||
class="flex h-12 items-center gap-2 border-b border-interface-stroke px-2"
|
||||
>
|
||||
<div class="px-2 text-[14px] font-normal text-text-primary">
|
||||
<div class="min-w-0 flex-1 px-2 text-[14px] font-normal text-text-primary">
|
||||
<span>{{ headerTitle }}</span>
|
||||
<span
|
||||
v-if="showConcurrentIndicator"
|
||||
@@ -17,6 +17,25 @@
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="inline-flex h-6 items-center gap-2 text-[12px] leading-none text-text-primary"
|
||||
>
|
||||
<span class="opacity-90">
|
||||
<span class="font-bold">{{ queuedCount }}</span>
|
||||
<span class="ml-1">{{
|
||||
t('sideToolbar.queueProgressOverlay.queuedSuffix')
|
||||
}}</span>
|
||||
</span>
|
||||
<Button
|
||||
v-if="queuedCount > 0"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
:aria-label="t('sideToolbar.queueProgressOverlay.clearQueued')"
|
||||
@click="$emit('clearQueued')"
|
||||
>
|
||||
<i class="icon-[lucide--list-x] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div v-if="!isCloud" class="flex items-center gap-1">
|
||||
<Button
|
||||
v-tooltip.top="moreTooltipConfig"
|
||||
@@ -78,10 +97,12 @@ defineProps<{
|
||||
headerTitle: string
|
||||
showConcurrentIndicator: boolean
|
||||
concurrentWorkflowCount: number
|
||||
queuedCount: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'clearHistory'): void
|
||||
(e: 'clearQueued'): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
99
src/components/queue/QueueProgressOverlay.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
import QueueProgressOverlay from '@/components/queue/QueueProgressOverlay.vue'
|
||||
import { i18n } from '@/i18n'
|
||||
import type { JobStatus } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: false
|
||||
}))
|
||||
|
||||
const QueueOverlayExpandedStub = defineComponent({
|
||||
name: 'QueueOverlayExpanded',
|
||||
props: {
|
||||
headerTitle: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
template: '<div data-testid="expanded-title">{{ headerTitle }}</div>'
|
||||
})
|
||||
|
||||
function createTask(id: string, status: JobStatus): TaskItemImpl {
|
||||
return new TaskItemImpl({
|
||||
id,
|
||||
status,
|
||||
create_time: 0,
|
||||
priority: 0
|
||||
})
|
||||
}
|
||||
|
||||
const mountComponent = (
|
||||
runningTasks: TaskItemImpl[],
|
||||
pendingTasks: TaskItemImpl[]
|
||||
) => {
|
||||
const pinia = createTestingPinia({
|
||||
createSpy: vi.fn,
|
||||
stubActions: false
|
||||
})
|
||||
const queueStore = useQueueStore(pinia)
|
||||
queueStore.runningTasks = runningTasks
|
||||
queueStore.pendingTasks = pendingTasks
|
||||
|
||||
return mount(QueueProgressOverlay, {
|
||||
props: {
|
||||
expanded: true
|
||||
},
|
||||
global: {
|
||||
plugins: [pinia, i18n],
|
||||
stubs: {
|
||||
QueueOverlayExpanded: QueueOverlayExpandedStub,
|
||||
QueueOverlayActive: true,
|
||||
ResultGallery: true
|
||||
},
|
||||
directives: {
|
||||
tooltip: () => {}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('QueueProgressOverlay', () => {
|
||||
beforeEach(() => {
|
||||
i18n.global.locale.value = 'en'
|
||||
})
|
||||
|
||||
it('shows expanded header with running and queued labels', () => {
|
||||
const wrapper = mountComponent(
|
||||
[
|
||||
createTask('running-1', 'in_progress'),
|
||||
createTask('running-2', 'in_progress')
|
||||
],
|
||||
[createTask('pending-1', 'pending')]
|
||||
)
|
||||
|
||||
expect(wrapper.get('[data-testid="expanded-title"]').text()).toBe(
|
||||
'2 running, 1 queued'
|
||||
)
|
||||
})
|
||||
|
||||
it('shows only running label when queued count is zero', () => {
|
||||
const wrapper = mountComponent([createTask('running-1', 'in_progress')], [])
|
||||
|
||||
expect(wrapper.get('[data-testid="expanded-title"]').text()).toBe(
|
||||
'1 running'
|
||||
)
|
||||
})
|
||||
|
||||
it('shows job queue title when there are no active jobs', () => {
|
||||
const wrapper = mountComponent([], [])
|
||||
|
||||
expect(wrapper.get('[data-testid="expanded-title"]').text()).toBe(
|
||||
'Job Queue'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -44,12 +44,6 @@
|
||||
@clear-queued="cancelQueuedWorkflows"
|
||||
@view-all-jobs="viewAllJobs"
|
||||
/>
|
||||
|
||||
<QueueOverlayEmpty
|
||||
v-else-if="completionSummary"
|
||||
:summary="completionSummary"
|
||||
@summary-click="onSummaryClick"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -64,11 +58,9 @@ import { computed, nextTick, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import QueueOverlayActive from '@/components/queue/QueueOverlayActive.vue'
|
||||
import QueueOverlayEmpty from '@/components/queue/QueueOverlayEmpty.vue'
|
||||
import QueueOverlayExpanded from '@/components/queue/QueueOverlayExpanded.vue'
|
||||
import QueueClearHistoryDialog from '@/components/queue/dialogs/QueueClearHistoryDialog.vue'
|
||||
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
|
||||
import { useCompletionSummary } from '@/composables/queue/useCompletionSummary'
|
||||
import { useJobList } from '@/composables/queue/useJobList'
|
||||
import type { JobListItem } from '@/composables/queue/useJobList'
|
||||
import { useQueueProgress } from '@/composables/queue/useQueueProgress'
|
||||
@@ -84,7 +76,7 @@ import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
|
||||
type OverlayState = 'hidden' | 'empty' | 'active' | 'expanded'
|
||||
type OverlayState = 'hidden' | 'active' | 'expanded'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -100,7 +92,7 @@ const emit = defineEmits<{
|
||||
(e: 'update:expanded', value: boolean): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { t, n } = useI18n()
|
||||
const queueStore = useQueueStore()
|
||||
const commandStore = useCommandStore()
|
||||
const executionStore = useExecutionStore()
|
||||
@@ -130,26 +122,20 @@ const isExpanded = computed({
|
||||
}
|
||||
})
|
||||
|
||||
const { summary: completionSummary, clearSummary } = useCompletionSummary()
|
||||
const hasCompletionSummary = computed(() => completionSummary.value !== null)
|
||||
|
||||
const runningCount = computed(() => queueStore.runningTasks.length)
|
||||
const queuedCount = computed(() => queueStore.pendingTasks.length)
|
||||
const isExecuting = computed(() => !executionStore.isIdle)
|
||||
const hasActiveJob = computed(() => runningCount.value > 0 || isExecuting.value)
|
||||
const activeJobsCount = computed(() => runningCount.value + queuedCount.value)
|
||||
|
||||
const overlayState = computed<OverlayState>(() => {
|
||||
if (isExpanded.value) return 'expanded'
|
||||
if (hasActiveJob.value) return 'active'
|
||||
if (hasCompletionSummary.value) return 'empty'
|
||||
return 'hidden'
|
||||
})
|
||||
|
||||
const showBackground = computed(
|
||||
() =>
|
||||
overlayState.value === 'expanded' ||
|
||||
overlayState.value === 'empty' ||
|
||||
(overlayState.value === 'active' && isOverlayHovered.value)
|
||||
)
|
||||
|
||||
@@ -169,11 +155,34 @@ const bottomRowClass = computed(
|
||||
: 'opacity-0 pointer-events-none'
|
||||
}`
|
||||
)
|
||||
const headerTitle = computed(() =>
|
||||
hasActiveJob.value
|
||||
? `${activeJobsCount.value} ${t('sideToolbar.queueProgressOverlay.activeJobsSuffix')}`
|
||||
: t('sideToolbar.queueProgressOverlay.jobQueue')
|
||||
const runningJobsLabel = computed(() =>
|
||||
t('sideToolbar.queueProgressOverlay.runningJobsLabel', {
|
||||
count: n(runningCount.value)
|
||||
})
|
||||
)
|
||||
const queuedJobsLabel = computed(() =>
|
||||
t('sideToolbar.queueProgressOverlay.queuedJobsLabel', {
|
||||
count: n(queuedCount.value)
|
||||
})
|
||||
)
|
||||
const headerTitle = computed(() => {
|
||||
if (!hasActiveJob.value) {
|
||||
return t('sideToolbar.queueProgressOverlay.jobQueue')
|
||||
}
|
||||
|
||||
if (queuedCount.value === 0) {
|
||||
return runningJobsLabel.value
|
||||
}
|
||||
|
||||
if (runningCount.value === 0) {
|
||||
return queuedJobsLabel.value
|
||||
}
|
||||
|
||||
return t('sideToolbar.queueProgressOverlay.runningQueuedSummary', {
|
||||
running: runningJobsLabel.value,
|
||||
queued: queuedJobsLabel.value
|
||||
})
|
||||
})
|
||||
|
||||
const concurrentWorkflowCount = computed(
|
||||
() => executionStore.runningWorkflowCount
|
||||
@@ -230,19 +239,10 @@ const setExpanded = (expanded: boolean) => {
|
||||
isExpanded.value = expanded
|
||||
}
|
||||
|
||||
const openExpandedFromEmpty = () => {
|
||||
setExpanded(true)
|
||||
}
|
||||
|
||||
const viewAllJobs = () => {
|
||||
setExpanded(true)
|
||||
}
|
||||
|
||||
const onSummaryClick = () => {
|
||||
openExpandedFromEmpty()
|
||||
clearSummary()
|
||||
}
|
||||
|
||||
const openAssetsSidebar = () => {
|
||||
sidebarTabStore.activeSidebarTabId = 'assets'
|
||||
}
|
||||
|
||||
79
src/components/queue/job/JobFiltersBar.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
vi.mock('primevue/popover', () => {
|
||||
const PopoverStub = defineComponent({
|
||||
name: 'Popover',
|
||||
setup(_, { slots, expose }) {
|
||||
expose({
|
||||
hide: () => undefined,
|
||||
toggle: (_event: Event) => undefined
|
||||
})
|
||||
return () => slots.default?.()
|
||||
}
|
||||
})
|
||||
return { default: PopoverStub }
|
||||
})
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: false
|
||||
}))
|
||||
|
||||
import JobFiltersBar from '@/components/queue/job/JobFiltersBar.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: {
|
||||
all: 'All',
|
||||
completed: 'Completed'
|
||||
},
|
||||
queue: {
|
||||
jobList: {
|
||||
sortMostRecent: 'Most recent',
|
||||
sortTotalGenerationTime: 'Total generation time'
|
||||
}
|
||||
},
|
||||
sideToolbar: {
|
||||
queueProgressOverlay: {
|
||||
filterJobs: 'Filter jobs',
|
||||
filterBy: 'Filter by',
|
||||
sortJobs: 'Sort jobs',
|
||||
sortBy: 'Sort by',
|
||||
showAssets: 'Show assets',
|
||||
showAssetsPanel: 'Show assets panel',
|
||||
filterAllWorkflows: 'All workflows',
|
||||
filterCurrentWorkflow: 'Current workflow'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe('JobFiltersBar', () => {
|
||||
it('emits showAssets when the assets icon button is clicked', async () => {
|
||||
const wrapper = mount(JobFiltersBar, {
|
||||
props: {
|
||||
selectedJobTab: 'All',
|
||||
selectedWorkflowFilter: 'all',
|
||||
selectedSortMode: 'mostRecent',
|
||||
hasFailedJobs: false
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
directives: { tooltip: () => undefined }
|
||||
}
|
||||
})
|
||||
|
||||
const showAssetsButton = wrapper.get(
|
||||
'button[aria-label="Show assets panel"]'
|
||||
)
|
||||
await showAssetsButton.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('showAssets')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -127,6 +127,15 @@
|
||||
</template>
|
||||
</div>
|
||||
</Popover>
|
||||
<Button
|
||||
v-tooltip.top="showAssetsTooltipConfig"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
:aria-label="t('sideToolbar.queueProgressOverlay.showAssetsPanel')"
|
||||
@click="$emit('showAssets')"
|
||||
>
|
||||
<i class="icon-[comfy--image-ai-edit] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -150,6 +159,7 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'showAssets'): void
|
||||
(e: 'update:selectedJobTab', value: JobTab): void
|
||||
(e: 'update:selectedWorkflowFilter', value: 'all' | 'current'): void
|
||||
(e: 'update:selectedSortMode', value: JobSortMode): void
|
||||
@@ -165,6 +175,9 @@ const filterTooltipConfig = computed(() =>
|
||||
const sortTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.sortBy'))
|
||||
)
|
||||
const showAssetsTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.showAssets'))
|
||||
)
|
||||
|
||||
// This can be removed when cloud implements /jobs and we switch to it.
|
||||
const showWorkflowFilter = !isCloud
|
||||
|
||||
@@ -14,10 +14,12 @@ import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import type { RightSidePanelTab } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { isGroupNode } from '@/utils/executableGroupNodeDto'
|
||||
|
||||
import TabInfo from './info/TabInfo.vue'
|
||||
import TabGlobalParameters from './parameters/TabGlobalParameters.vue'
|
||||
@@ -31,12 +33,16 @@ import {
|
||||
useFlatAndCategorizeSelectedItems
|
||||
} from './shared'
|
||||
import SubgraphEditor from './subgraph/SubgraphEditor.vue'
|
||||
import TabErrors from './errors/TabErrors.vue'
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const settingStore = useSettingStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const { hasAnyError, allErrorExecutionIds } = storeToRefs(executionStore)
|
||||
|
||||
const { findParentGroup } = useGraphHierarchy()
|
||||
|
||||
const { selectedItems: directlySelectedItems } = storeToRefs(canvasStore)
|
||||
@@ -87,11 +93,42 @@ function closePanel() {
|
||||
type RightSidePanelTabList = Array<{
|
||||
label: () => string
|
||||
value: RightSidePanelTab
|
||||
icon?: string
|
||||
}>
|
||||
|
||||
const hasDirectNodeError = computed(() =>
|
||||
selectedNodes.value.some((node) =>
|
||||
executionStore.activeGraphErrorNodeIds.has(String(node.id))
|
||||
)
|
||||
)
|
||||
|
||||
const hasContainerInternalError = computed(() => {
|
||||
if (allErrorExecutionIds.value.length === 0) return false
|
||||
return selectedNodes.value.some((node) => {
|
||||
if (!(node instanceof SubgraphNode || isGroupNode(node))) return false
|
||||
return executionStore.hasInternalErrorForNode(node.id)
|
||||
})
|
||||
})
|
||||
|
||||
const hasRelevantErrors = computed(() => {
|
||||
if (!hasSelection.value) return hasAnyError.value
|
||||
return hasDirectNodeError.value || hasContainerInternalError.value
|
||||
})
|
||||
|
||||
const tabs = computed<RightSidePanelTabList>(() => {
|
||||
const list: RightSidePanelTabList = []
|
||||
|
||||
if (
|
||||
settingStore.get('Comfy.RightSidePanel.ShowErrorsTab') &&
|
||||
hasRelevantErrors.value
|
||||
) {
|
||||
list.push({
|
||||
label: () => t('rightSidePanel.errors'),
|
||||
value: 'errors',
|
||||
icon: 'icon-[lucide--octagon-alert] bg-node-stroke-error ml-1'
|
||||
})
|
||||
}
|
||||
|
||||
list.push({
|
||||
label: () =>
|
||||
flattedItems.value.length > 1
|
||||
@@ -271,6 +308,7 @@ function handleProxyWidgetsUpdate(value: ProxyWidgetsProperty) {
|
||||
:value="tab.value"
|
||||
>
|
||||
{{ tab.label() }}
|
||||
<i v-if="tab.icon" :class="cn(tab.icon, 'size-4')" />
|
||||
</Tab>
|
||||
</TabList>
|
||||
</nav>
|
||||
@@ -278,7 +316,8 @@ function handleProxyWidgetsUpdate(value: ProxyWidgetsProperty) {
|
||||
|
||||
<!-- Panel Content -->
|
||||
<div class="scrollbar-thin flex-1 overflow-y-auto">
|
||||
<template v-if="!hasSelection">
|
||||
<TabErrors v-if="activeTab === 'errors'" />
|
||||
<template v-else-if="!hasSelection">
|
||||
<TabGlobalParameters v-if="activeTab === 'parameters'" />
|
||||
<TabNodes v-else-if="activeTab === 'nodes'" />
|
||||
<TabGlobalSettings v-else-if="activeTab === 'settings'" />
|
||||
|
||||
162
src/components/rightSidePanel/errors/ErrorNodeCard.stories.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import ErrorNodeCard from './ErrorNodeCard.vue'
|
||||
import type { ErrorCardData } from './types'
|
||||
|
||||
/**
|
||||
* ErrorNodeCard displays a single error card inside the error tab.
|
||||
* It shows the node header (ID badge, title, action buttons)
|
||||
* and the list of error items (message, traceback, copy button).
|
||||
*/
|
||||
const meta: Meta<typeof ErrorNodeCard> = {
|
||||
title: 'RightSidePanel/Errors/ErrorNodeCard',
|
||||
component: ErrorNodeCard,
|
||||
parameters: {
|
||||
layout: 'centered'
|
||||
},
|
||||
argTypes: {
|
||||
showNodeIdBadge: { control: 'boolean' }
|
||||
},
|
||||
decorators: [
|
||||
(story) => ({
|
||||
components: { story },
|
||||
template:
|
||||
'<div class="w-[330px] bg-base-surface border border-interface-stroke rounded-lg p-4"><story /></div>'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const singleErrorCard: ErrorCardData = {
|
||||
id: 'node-10',
|
||||
title: 'CLIPTextEncode',
|
||||
nodeId: '10',
|
||||
nodeTitle: 'CLIP Text Encode (Prompt)',
|
||||
isSubgraphNode: false,
|
||||
errors: [
|
||||
{
|
||||
message: 'Required input "text" is missing.',
|
||||
details: 'Input: text\nExpected: STRING'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const multipleErrorsCard: ErrorCardData = {
|
||||
id: 'node-24',
|
||||
title: 'VAEDecode',
|
||||
nodeId: '24',
|
||||
nodeTitle: 'VAE Decode',
|
||||
isSubgraphNode: false,
|
||||
errors: [
|
||||
{
|
||||
message: 'Required input "samples" is missing.',
|
||||
details: ''
|
||||
},
|
||||
{
|
||||
message: 'Value "NaN" is not a valid number for "strength".',
|
||||
details: 'Expected: FLOAT [0.0 .. 1.0]'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const runtimeErrorCard: ErrorCardData = {
|
||||
id: 'exec-45',
|
||||
title: 'KSampler',
|
||||
nodeId: '45',
|
||||
nodeTitle: 'KSampler',
|
||||
isSubgraphNode: false,
|
||||
errors: [
|
||||
{
|
||||
message: 'OutOfMemoryError: CUDA out of memory. Tried to allocate 1.2GB.',
|
||||
details: [
|
||||
'Traceback (most recent call last):',
|
||||
' File "ksampler.py", line 142, in sample',
|
||||
' samples = model.apply(latent)',
|
||||
'RuntimeError: CUDA out of memory.'
|
||||
].join('\n'),
|
||||
isRuntimeError: true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const subgraphErrorCard: ErrorCardData = {
|
||||
id: 'node-3:15',
|
||||
title: 'KSampler',
|
||||
nodeId: '3:15',
|
||||
nodeTitle: 'Nested KSampler',
|
||||
isSubgraphNode: true,
|
||||
errors: [
|
||||
{
|
||||
message: 'Latent input is required.',
|
||||
details: ''
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const promptOnlyCard: ErrorCardData = {
|
||||
id: '__prompt__',
|
||||
title: 'Prompt has no outputs.',
|
||||
errors: [
|
||||
{
|
||||
message:
|
||||
'The workflow does not contain any output nodes (e.g. Save Image, Preview Image) to produce a result.'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
/** Single validation error with node ID badge visible */
|
||||
export const WithNodeIdBadge: Story = {
|
||||
args: {
|
||||
card: singleErrorCard,
|
||||
showNodeIdBadge: true
|
||||
}
|
||||
}
|
||||
|
||||
/** Single validation error without node ID badge */
|
||||
export const WithoutNodeIdBadge: Story = {
|
||||
args: {
|
||||
card: singleErrorCard,
|
||||
showNodeIdBadge: false
|
||||
}
|
||||
}
|
||||
|
||||
/** Subgraph node error — shows "Enter subgraph" button */
|
||||
export const WithEnterSubgraphButton: Story = {
|
||||
args: {
|
||||
card: subgraphErrorCard,
|
||||
showNodeIdBadge: true
|
||||
}
|
||||
}
|
||||
|
||||
/** Regular node error — no "Enter subgraph" button */
|
||||
export const WithoutEnterSubgraphButton: Story = {
|
||||
args: {
|
||||
card: singleErrorCard,
|
||||
showNodeIdBadge: true
|
||||
}
|
||||
}
|
||||
|
||||
/** Multiple validation errors on one node */
|
||||
export const MultipleErrors: Story = {
|
||||
args: {
|
||||
card: multipleErrorsCard,
|
||||
showNodeIdBadge: true
|
||||
}
|
||||
}
|
||||
|
||||
/** Runtime execution error with full traceback */
|
||||
export const RuntimeError: Story = {
|
||||
args: {
|
||||
card: runtimeErrorCard,
|
||||
showNodeIdBadge: true
|
||||
}
|
||||
}
|
||||
|
||||
/** Prompt-level error (no node header) */
|
||||
export const PromptError: Story = {
|
||||
args: {
|
||||
card: promptOnlyCard,
|
||||
showNodeIdBadge: false
|
||||
}
|
||||
}
|
||||
132
src/components/rightSidePanel/errors/ErrorNodeCard.vue
Normal file
@@ -0,0 +1,132 @@
|
||||
<template>
|
||||
<div class="overflow-hidden">
|
||||
<!-- Card Header -->
|
||||
<div
|
||||
v-if="card.nodeId && !compact"
|
||||
class="flex flex-wrap items-center gap-2 py-2"
|
||||
>
|
||||
<span
|
||||
v-if="showNodeIdBadge"
|
||||
class="shrink-0 rounded-md bg-secondary-background-selected px-2 py-0.5 text-xs font-mono text-muted-foreground font-bold"
|
||||
>
|
||||
#{{ card.nodeId }}
|
||||
</span>
|
||||
<span
|
||||
v-if="card.nodeTitle"
|
||||
class="flex-1 text-sm text-muted-foreground truncate font-medium"
|
||||
>
|
||||
{{ card.nodeTitle }}
|
||||
</span>
|
||||
<Button
|
||||
v-if="card.isSubgraphNode"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="rounded-lg text-sm shrink-0"
|
||||
@click.stop="handleEnterSubgraph"
|
||||
>
|
||||
{{ t('rightSidePanel.enterSubgraph') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-7 text-muted-foreground hover:text-base-foreground shrink-0"
|
||||
:aria-label="t('rightSidePanel.locateNode')"
|
||||
@click.stop="handleLocateNode"
|
||||
>
|
||||
<i class="icon-[lucide--locate] size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Multiple Errors within one Card -->
|
||||
<div class="divide-y divide-interface-stroke/20 space-y-4">
|
||||
<!-- Card Content -->
|
||||
<div
|
||||
v-for="(error, idx) in card.errors"
|
||||
:key="idx"
|
||||
class="flex flex-col gap-3"
|
||||
>
|
||||
<!-- Error Message -->
|
||||
<p
|
||||
v-if="error.message && !compact"
|
||||
class="m-0 text-sm break-words whitespace-pre-wrap leading-relaxed px-0.5"
|
||||
>
|
||||
{{ error.message }}
|
||||
</p>
|
||||
|
||||
<!-- Traceback / Details -->
|
||||
<div
|
||||
v-if="error.details"
|
||||
:class="
|
||||
cn(
|
||||
'rounded-lg bg-secondary-background-hover p-2.5 overflow-y-auto border border-interface-stroke/30',
|
||||
error.isRuntimeError ? 'max-h-[10lh]' : 'max-h-[6lh]'
|
||||
)
|
||||
"
|
||||
>
|
||||
<p
|
||||
class="m-0 text-xs text-muted-foreground break-words whitespace-pre-wrap font-mono leading-relaxed"
|
||||
>
|
||||
{{ error.details }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="w-full justify-center gap-2 h-8 text-xs"
|
||||
@click="handleCopyError(error)"
|
||||
>
|
||||
<i class="icon-[lucide--copy] size-3.5" />
|
||||
{{ t('g.copy') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import type { ErrorCardData, ErrorItem } from './types'
|
||||
|
||||
const {
|
||||
card,
|
||||
showNodeIdBadge = false,
|
||||
compact = false
|
||||
} = defineProps<{
|
||||
card: ErrorCardData
|
||||
showNodeIdBadge?: boolean
|
||||
/** Hide card header and error message (used in single-node selection mode) */
|
||||
compact?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
locateNode: [nodeId: string]
|
||||
enterSubgraph: [nodeId: string]
|
||||
copyToClipboard: [text: string]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
function handleLocateNode() {
|
||||
if (card.nodeId) {
|
||||
emit('locateNode', card.nodeId)
|
||||
}
|
||||
}
|
||||
|
||||
function handleEnterSubgraph() {
|
||||
if (card.nodeId) {
|
||||
emit('enterSubgraph', card.nodeId)
|
||||
}
|
||||
}
|
||||
|
||||
function handleCopyError(error: ErrorItem) {
|
||||
emit(
|
||||
'copyToClipboard',
|
||||
[error.message, error.details].filter(Boolean).join('\n\n')
|
||||
)
|
||||
}
|
||||
</script>
|
||||
137
src/components/rightSidePanel/errors/TabErrors.stories.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* @file TabErrors.stories.ts
|
||||
*
|
||||
* Error Tab – Missing Node Packs UX Flow Stories (OSS environment)
|
||||
*/
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import StoryOSSMissingNodePackFlow from './__stories__/StoryOSSMissingNodePackFlow.vue'
|
||||
import MockOSSMissingNodePack from './__stories__/MockOSSMissingNodePack.vue'
|
||||
import MockCloudMissingNodePack from './__stories__/MockCloudMissingNodePack.vue'
|
||||
import MockCloudMissingModel from './__stories__/MockCloudMissingModel.vue'
|
||||
import MockCloudMissingModelBasic from './__stories__/MockCloudMissingModelBasic.vue'
|
||||
import MockOSSMissingModel from './__stories__/MockOSSMissingModel.vue'
|
||||
|
||||
// Storybook Meta
|
||||
|
||||
const meta = {
|
||||
title: 'RightSidePanel/Errors/TabErrors',
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
docs: {
|
||||
description: {
|
||||
component: `
|
||||
## Error Tab – Missing Node Packs UX Flow (OSS environment)
|
||||
|
||||
### Right Panel Structure
|
||||
- **Nav Item**: "Workflow Overview" + panel-right button
|
||||
- **Tab bar**: Error (octagon-alert icon) | Inputs | Nodes | Global settings
|
||||
- **Search bar**: 12px, #8a8a8a placeholder
|
||||
- **Missing Node Packs section**: octagon-alert (red) + label + Install All + chevron
|
||||
- **Each widget row** (72px): name (truncate) + info + locate | Install node pack ↓
|
||||
|
||||
> In Cloud environments, the Install button is not displayed.
|
||||
`
|
||||
}
|
||||
}
|
||||
}
|
||||
} satisfies Meta
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Stories
|
||||
|
||||
/**
|
||||
* **[Local OSS] Missing Node Packs**
|
||||
*
|
||||
* A standalone story for the Right Side Panel's Error Tab mockup.
|
||||
* This allows testing the tab's interactions (install, locate, etc.) in isolation.
|
||||
*/
|
||||
export const OSS_ErrorTabOnly: Story = {
|
||||
name: '[Local OSS] Missing Node Packs',
|
||||
render: () => ({
|
||||
components: { MockOSSMissingNodePack },
|
||||
template: `
|
||||
<div class="h-[800px] border border-[#494a50] rounded-lg overflow-hidden">
|
||||
<MockOSSMissingNodePack @log="msg => console.log('Log:', msg)" />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* **[Local OSS] UX Flow - Missing Node Pack**
|
||||
*
|
||||
* Full ComfyUI layout simulation:
|
||||
*/
|
||||
export const OSS_MissingNodePacksFullFlow: Story = {
|
||||
name: '[Local OSS] UX Flow - Missing Node Pack',
|
||||
render: () => ({
|
||||
components: { StoryOSSMissingNodePackFlow },
|
||||
template: `<div style="width:100vw;height:100vh;"><StoryOSSMissingNodePackFlow /></div>`
|
||||
}),
|
||||
parameters: {
|
||||
layout: 'fullscreen'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* **[Cloud] Missing Node Pack**
|
||||
*/
|
||||
export const Cloud_MissingNodePacks: Story = {
|
||||
name: '[Cloud] Missing Node Pack',
|
||||
render: () => ({
|
||||
components: { MockCloudMissingNodePack },
|
||||
template: `
|
||||
<div class="h-[800px] border border-[#494a50] rounded-lg overflow-hidden">
|
||||
<MockCloudMissingNodePack @log="msg => console.log('Log:', msg)" />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* **[Local OSS] Missing Model**
|
||||
*/
|
||||
export const OSS_MissingModels: Story = {
|
||||
name: '[Local OSS] Missing Model',
|
||||
render: () => ({
|
||||
components: { MockOSSMissingModel },
|
||||
template: `
|
||||
<div class="h-[800px] border border-[#494a50] rounded-lg overflow-hidden">
|
||||
<MockOSSMissingModel @locate="name => console.log('Locate:', name)" />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* **[Cloud] Missing Model**
|
||||
*/
|
||||
export const Cloud_MissingModels: Story = {
|
||||
name: '[Cloud] Missing Model',
|
||||
render: () => ({
|
||||
components: { MockCloudMissingModelBasic },
|
||||
template: `
|
||||
<div class="h-[800px] border border-[#494a50] rounded-lg overflow-hidden">
|
||||
<MockCloudMissingModelBasic @locate="name => console.log('Locate:', name)" />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* **[Cloud] Missing Model - with model type selector**
|
||||
*/
|
||||
export const Cloud_MissingModelsWithSelector: Story = {
|
||||
name: '[Cloud] Missing Model - with model type selector',
|
||||
render: () => ({
|
||||
components: { MockCloudMissingModel },
|
||||
template: `
|
||||
<div class="h-[800px] border border-[#494a50] rounded-lg overflow-hidden">
|
||||
<MockCloudMissingModel @locate="name => console.log('Locate:', name)" />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
218
src/components/rightSidePanel/errors/TabErrors.test.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import TabErrors from './TabErrors.vue'
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
rootGraph: {
|
||||
serialize: vi.fn(() => ({})),
|
||||
getNodeById: vi.fn()
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/graphTraversalUtil', () => ({
|
||||
getNodeByExecutionId: vi.fn(),
|
||||
forEachNode: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useCopyToClipboard', () => ({
|
||||
useCopyToClipboard: vi.fn(() => ({
|
||||
copyToClipboard: vi.fn()
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: vi.fn(() => ({
|
||||
fitView: vi.fn()
|
||||
}))
|
||||
}))
|
||||
|
||||
describe('TabErrors.vue', () => {
|
||||
let i18n: ReturnType<typeof createI18n>
|
||||
|
||||
beforeEach(() => {
|
||||
i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: {
|
||||
workflow: 'Workflow',
|
||||
copy: 'Copy'
|
||||
},
|
||||
rightSidePanel: {
|
||||
noErrors: 'No errors',
|
||||
noneSearchDesc: 'No results found',
|
||||
promptErrors: {
|
||||
prompt_no_outputs: {
|
||||
desc: 'Prompt has no outputs'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function mountComponent(initialState = {}) {
|
||||
return mount(TabErrors, {
|
||||
global: {
|
||||
plugins: [
|
||||
PrimeVue,
|
||||
i18n,
|
||||
createTestingPinia({
|
||||
createSpy: vi.fn,
|
||||
initialState
|
||||
})
|
||||
],
|
||||
stubs: {
|
||||
FormSearchInput: {
|
||||
template:
|
||||
'<input @input="$emit(\'update:modelValue\', $event.target.value)" />'
|
||||
},
|
||||
PropertiesAccordionItem: {
|
||||
template: '<div><slot name="label" /><slot /></div>'
|
||||
},
|
||||
Button: {
|
||||
template: '<button><slot /></button>'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it('renders "no errors" state when store is empty', () => {
|
||||
const wrapper = mountComponent()
|
||||
expect(wrapper.text()).toContain('No errors')
|
||||
})
|
||||
|
||||
it('renders prompt-level errors (Group title = error message)', async () => {
|
||||
const wrapper = mountComponent({
|
||||
execution: {
|
||||
lastPromptError: {
|
||||
type: 'prompt_no_outputs',
|
||||
message: 'Server Error: No outputs',
|
||||
details: 'Error details'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Group title should be the raw message from store
|
||||
expect(wrapper.text()).toContain('Server Error: No outputs')
|
||||
// Item message should be localized desc
|
||||
expect(wrapper.text()).toContain('Prompt has no outputs')
|
||||
// Details should not be rendered for prompt errors
|
||||
expect(wrapper.text()).not.toContain('Error details')
|
||||
})
|
||||
|
||||
it('renders node validation errors grouped by class_type', async () => {
|
||||
const { getNodeByExecutionId } = await import('@/utils/graphTraversalUtil')
|
||||
vi.mocked(getNodeByExecutionId).mockReturnValue({
|
||||
title: 'CLIP Text Encode'
|
||||
} as ReturnType<typeof getNodeByExecutionId>)
|
||||
|
||||
const wrapper = mountComponent({
|
||||
execution: {
|
||||
lastNodeErrors: {
|
||||
'6': {
|
||||
class_type: 'CLIPTextEncode',
|
||||
errors: [
|
||||
{ message: 'Required input is missing', details: 'Input: text' }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('CLIPTextEncode')
|
||||
expect(wrapper.text()).toContain('#6')
|
||||
expect(wrapper.text()).toContain('CLIP Text Encode')
|
||||
expect(wrapper.text()).toContain('Required input is missing')
|
||||
})
|
||||
|
||||
it('renders runtime execution errors from WebSocket', async () => {
|
||||
const { getNodeByExecutionId } = await import('@/utils/graphTraversalUtil')
|
||||
vi.mocked(getNodeByExecutionId).mockReturnValue({
|
||||
title: 'KSampler'
|
||||
} as ReturnType<typeof getNodeByExecutionId>)
|
||||
|
||||
const wrapper = mountComponent({
|
||||
execution: {
|
||||
lastExecutionError: {
|
||||
prompt_id: 'abc',
|
||||
node_id: '10',
|
||||
node_type: 'KSampler',
|
||||
exception_message: 'Out of memory',
|
||||
exception_type: 'RuntimeError',
|
||||
traceback: ['Line 1', 'Line 2'],
|
||||
timestamp: Date.now()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('KSampler')
|
||||
expect(wrapper.text()).toContain('#10')
|
||||
expect(wrapper.text()).toContain('RuntimeError: Out of memory')
|
||||
expect(wrapper.text()).toContain('Line 1')
|
||||
})
|
||||
|
||||
it('filters errors based on search query', async () => {
|
||||
const { getNodeByExecutionId } = await import('@/utils/graphTraversalUtil')
|
||||
vi.mocked(getNodeByExecutionId).mockReturnValue(null)
|
||||
|
||||
const wrapper = mountComponent({
|
||||
execution: {
|
||||
lastNodeErrors: {
|
||||
'1': {
|
||||
class_type: 'CLIPTextEncode',
|
||||
errors: [{ message: 'Missing text input' }]
|
||||
},
|
||||
'2': {
|
||||
class_type: 'KSampler',
|
||||
errors: [{ message: 'Out of memory' }]
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('CLIPTextEncode')
|
||||
expect(wrapper.text()).toContain('KSampler')
|
||||
|
||||
const searchInput = wrapper.find('input')
|
||||
await searchInput.setValue('Missing text input')
|
||||
|
||||
expect(wrapper.text()).toContain('CLIPTextEncode')
|
||||
expect(wrapper.text()).not.toContain('KSampler')
|
||||
})
|
||||
|
||||
it('calls copyToClipboard when copy button is clicked', async () => {
|
||||
const { useCopyToClipboard } =
|
||||
await import('@/composables/useCopyToClipboard')
|
||||
const mockCopy = vi.fn()
|
||||
vi.mocked(useCopyToClipboard).mockReturnValue({ copyToClipboard: mockCopy })
|
||||
|
||||
const wrapper = mountComponent({
|
||||
execution: {
|
||||
lastNodeErrors: {
|
||||
'1': {
|
||||
class_type: 'TestNode',
|
||||
errors: [{ message: 'Test message', details: 'Test details' }]
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Find the copy button (rendered inside ErrorNodeCard)
|
||||
const copyButtons = wrapper.findAll('button')
|
||||
const copyButton = copyButtons.find((btn) => btn.text().includes('Copy'))
|
||||
expect(copyButton).toBeTruthy()
|
||||
await copyButton!.trigger('click')
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledWith('Test message\n\nTest details')
|
||||
})
|
||||
})
|
||||
164
src/components/rightSidePanel/errors/TabErrors.vue
Normal file
@@ -0,0 +1,164 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-full min-w-0">
|
||||
<!-- Search bar -->
|
||||
<div
|
||||
class="px-4 pt-1 pb-4 flex gap-2 border-b border-interface-stroke shrink-0 min-w-0"
|
||||
>
|
||||
<FormSearchInput v-model="searchQuery" />
|
||||
</div>
|
||||
|
||||
<!-- Scrollable content -->
|
||||
<div class="flex-1 overflow-y-auto min-w-0">
|
||||
<div
|
||||
v-if="filteredGroups.length === 0"
|
||||
class="text-sm text-muted-foreground px-4 text-center pt-5 pb-15"
|
||||
>
|
||||
{{
|
||||
searchQuery.trim()
|
||||
? t('rightSidePanel.noneSearchDesc')
|
||||
: t('rightSidePanel.noErrors')
|
||||
}}
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<!-- Group by Class Type -->
|
||||
<PropertiesAccordionItem
|
||||
v-for="group in filteredGroups"
|
||||
:key="group.title"
|
||||
:collapse="collapseState[group.title] ?? false"
|
||||
class="border-b border-interface-stroke"
|
||||
@update:collapse="collapseState[group.title] = $event"
|
||||
>
|
||||
<template #label>
|
||||
<div class="flex items-center gap-2 flex-1 min-w-0">
|
||||
<span class="flex-1 flex items-center gap-2 min-w-0">
|
||||
<i
|
||||
class="icon-[lucide--octagon-alert] size-4 text-destructive-background-hover shrink-0"
|
||||
/>
|
||||
<span class="text-destructive-background-hover truncate">
|
||||
{{ group.title }}
|
||||
</span>
|
||||
<span
|
||||
v-if="group.cards.length > 1"
|
||||
class="text-destructive-background-hover"
|
||||
>
|
||||
({{ group.cards.length }})
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Cards in Group (default slot) -->
|
||||
<div class="px-4 space-y-3">
|
||||
<ErrorNodeCard
|
||||
v-for="card in group.cards"
|
||||
:key="card.id"
|
||||
:card="card"
|
||||
:show-node-id-badge="showNodeIdBadge"
|
||||
:compact="isSingleNodeSelected"
|
||||
@locate-node="handleLocateNode"
|
||||
@enter-subgraph="handleEnterSubgraph"
|
||||
@copy-to-clipboard="copyToClipboard"
|
||||
/>
|
||||
</div>
|
||||
</PropertiesAccordionItem>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fixed Footer: Help Links -->
|
||||
<div class="shrink-0 border-t border-interface-stroke p-4 min-w-0">
|
||||
<i18n-t
|
||||
keypath="rightSidePanel.errorHelp"
|
||||
tag="p"
|
||||
class="m-0 text-sm text-muted-foreground leading-tight break-words"
|
||||
>
|
||||
<template #github>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
class="inline underline text-inherit text-sm whitespace-nowrap"
|
||||
@click="openGitHubIssues"
|
||||
>
|
||||
{{ t('rightSidePanel.errorHelpGithub') }}
|
||||
</Button>
|
||||
</template>
|
||||
<template #support>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
class="inline underline text-inherit text-sm whitespace-nowrap"
|
||||
@click="contactSupport"
|
||||
>
|
||||
{{ t('rightSidePanel.errorHelpSupport') }}
|
||||
</Button>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { useFocusNode } from '@/composables/canvas/useFocusNode'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { NodeBadgeMode } from '@/types/nodeSource'
|
||||
|
||||
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
|
||||
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
|
||||
import ErrorNodeCard from './ErrorNodeCard.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useErrorGroups } from './useErrorGroups'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
const { focusNode, enterSubgraph } = useFocusNode()
|
||||
const { staticUrls } = useExternalLink()
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
const searchQuery = ref('')
|
||||
|
||||
const showNodeIdBadge = computed(
|
||||
() =>
|
||||
(settingStore.get('Comfy.NodeBadge.NodeIdBadgeMode') as NodeBadgeMode) !==
|
||||
NodeBadgeMode.None
|
||||
)
|
||||
|
||||
const { filteredGroups, collapseState, isSingleNodeSelected, errorNodeCache } =
|
||||
useErrorGroups(searchQuery, t)
|
||||
|
||||
function handleLocateNode(nodeId: string) {
|
||||
focusNode(nodeId, errorNodeCache.value)
|
||||
}
|
||||
|
||||
function handleEnterSubgraph(nodeId: string) {
|
||||
enterSubgraph(nodeId, errorNodeCache.value)
|
||||
}
|
||||
|
||||
function openGitHubIssues() {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'error_tab_github_issues_clicked'
|
||||
})
|
||||
window.open(staticUrls.githubIssues, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
async function contactSupport() {
|
||||
useTelemetry()?.trackHelpResourceClicked({
|
||||
resource_type: 'help_feedback',
|
||||
is_external: true,
|
||||
source: 'error_dialog'
|
||||
})
|
||||
try {
|
||||
await useCommandStore().execute('Comfy.ContactSupport')
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
useToastStore().addAlert(t('rightSidePanel.contactSupportFailed'))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,521 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
// Props / Emits
|
||||
|
||||
const emit = defineEmits<{
|
||||
'locate': [name: string],
|
||||
}>()
|
||||
|
||||
// Mock Data
|
||||
|
||||
interface MissingModel {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
}
|
||||
|
||||
const INITIAL_MISSING_MODELS: Record<string, MissingModel[]> = {
|
||||
'Lora': [
|
||||
{ id: 'm1', name: 'Flat_color_anime.safetensors', type: 'Lora' },
|
||||
{ id: 'm2', name: 'Bokeh_blur_xl.safetensors', type: 'Lora' },
|
||||
{ id: 'm3', name: 'Skin_texture_realism.safetensors', type: 'Lora' }
|
||||
],
|
||||
'VAE': [
|
||||
{ id: 'v1', name: 'vae-ft-mse-840000-ema-pruned.safetensors', type: 'VAE' },
|
||||
{ id: 'v2', name: 'clear-vae-v1.safetensors', type: 'VAE' }
|
||||
]
|
||||
}
|
||||
|
||||
const MODEL_TYPES = [
|
||||
'AnimateDiff Model',
|
||||
'AnimateDiff Motion LoRA',
|
||||
'Audio Encoders',
|
||||
'Chatterbox/chatterbox',
|
||||
'Chatterbox/chatterbox Multilingual',
|
||||
'Chatterbox/chatterbox Turbo',
|
||||
'Chatterbox/chatterbox Vc',
|
||||
'Checkpoints',
|
||||
'CLIP Vision'
|
||||
]
|
||||
|
||||
const LIBRARY_MODELS = [
|
||||
'v1-5-pruned-emaonly.safetensors',
|
||||
'sd_xl_base_1.0.safetensors',
|
||||
'dreamshaper_8.safetensors',
|
||||
'realisticVisionV51_v51VAE.safetensors'
|
||||
]
|
||||
|
||||
// State
|
||||
|
||||
const collapsedCategories = ref<Record<string, boolean>>({
|
||||
'VAE': true
|
||||
})
|
||||
|
||||
// Stores the URL input for each model
|
||||
const modelInputs = ref<Record<string, string>>({})
|
||||
|
||||
// Tracks which models have finished their "revealing" delay
|
||||
const revealingModels = ref<Record<string, boolean>>({})
|
||||
const inputTimeouts = ref<Record<string, ReturnType<typeof setTimeout>>>({})
|
||||
|
||||
// Model Status: 'idle' | 'downloading' | 'downloaded' | 'using_library'
|
||||
const importStatus = ref<Record<string, 'idle' | 'downloading' | 'downloaded' | 'using_library'>>({})
|
||||
const downloadProgress = ref<Record<string, number>>({})
|
||||
const downloadTimers = ref<Record<string, ReturnType<typeof setInterval>>>({})
|
||||
const selectedLibraryModel = ref<Record<string, string>>({})
|
||||
|
||||
// Track hidden models (removed after clicking check button)
|
||||
const removedModels = ref<Record<string, boolean>>({})
|
||||
|
||||
// Watch for input changes to trigger the 1s delay
|
||||
watch(modelInputs, (newVal) => {
|
||||
for (const id in newVal) {
|
||||
const value = newVal[id]
|
||||
if (value && !revealingModels.value[id] && !inputTimeouts.value[id]) {
|
||||
inputTimeouts.value[id] = setTimeout(() => {
|
||||
revealingModels.value[id] = true
|
||||
delete inputTimeouts.value[id]
|
||||
}, 1000)
|
||||
} else if (!value) {
|
||||
revealingModels.value[id] = false
|
||||
if (inputTimeouts.value[id]) {
|
||||
clearTimeout(inputTimeouts.value[id])
|
||||
delete inputTimeouts.value[id]
|
||||
}
|
||||
}
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
// Compute which categories have at least one visible model
|
||||
const activeCategories = computed(() => {
|
||||
const result: Record<string, boolean> = {}
|
||||
for (const cat in INITIAL_MISSING_MODELS) {
|
||||
result[cat] = INITIAL_MISSING_MODELS[cat].some(m => !removedModels.value[m.id])
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
// Stores the selected type for each model
|
||||
const selectedModelTypes = ref<Record<string, string>>({})
|
||||
|
||||
// Tracks which model's type dropdown is currently open
|
||||
const activeDropdown = ref<string | null>(null)
|
||||
// Tracks which model's library dropdown is currently open
|
||||
const activeLibraryDropdown = ref<string | null>(null)
|
||||
|
||||
// Actions
|
||||
|
||||
function toggleDropdown(modelId: string) {
|
||||
activeLibraryDropdown.value = null
|
||||
if (activeDropdown.value === modelId) {
|
||||
activeDropdown.value = null
|
||||
} else {
|
||||
activeDropdown.value = modelId
|
||||
}
|
||||
}
|
||||
|
||||
function toggleLibraryDropdown(modelId: string) {
|
||||
activeDropdown.value = null
|
||||
if (activeLibraryDropdown.value === modelId) {
|
||||
activeLibraryDropdown.value = null
|
||||
} else {
|
||||
activeLibraryDropdown.value = modelId
|
||||
}
|
||||
}
|
||||
|
||||
function selectType(modelId: string, type: string) {
|
||||
selectedModelTypes.value[modelId] = type
|
||||
activeDropdown.value = null
|
||||
}
|
||||
|
||||
function selectFromLibrary(modelId: string, fileName: string) {
|
||||
selectedLibraryModel.value[modelId] = fileName
|
||||
importStatus.value[modelId] = 'using_library'
|
||||
activeLibraryDropdown.value = null
|
||||
}
|
||||
|
||||
function startImport(modelId: string) {
|
||||
if (downloadTimers.value[modelId]) {
|
||||
clearInterval(downloadTimers.value[modelId])
|
||||
}
|
||||
|
||||
importStatus.value[modelId] = 'downloading'
|
||||
downloadProgress.value[modelId] = 0
|
||||
|
||||
const startTime = Date.now()
|
||||
const duration = 5000
|
||||
|
||||
downloadTimers.value[modelId] = setInterval(() => {
|
||||
const elapsed = Date.now() - startTime
|
||||
const progress = Math.min((elapsed / duration) * 100, 100)
|
||||
downloadProgress.value[modelId] = progress
|
||||
|
||||
if (progress >= 100) {
|
||||
clearInterval(downloadTimers.value[modelId])
|
||||
delete downloadTimers.value[modelId]
|
||||
importStatus.value[modelId] = 'downloaded'
|
||||
}
|
||||
}, 50)
|
||||
}
|
||||
|
||||
function handleCheckClick(modelId: string) {
|
||||
if (importStatus.value[modelId] === 'downloaded' || importStatus.value[modelId] === 'using_library') {
|
||||
// Update object with spread to guarantee reactivity trigger
|
||||
removedModels.value = { ...removedModels.value, [modelId]: true }
|
||||
}
|
||||
}
|
||||
|
||||
function cancelImport(modelId: string) {
|
||||
if (downloadTimers.value[modelId]) {
|
||||
clearInterval(downloadTimers.value[modelId])
|
||||
delete downloadTimers.value[modelId]
|
||||
}
|
||||
importStatus.value[modelId] = 'idle'
|
||||
downloadProgress.value[modelId] = 0
|
||||
modelInputs.value[modelId] = ''
|
||||
selectedLibraryModel.value[modelId] = ''
|
||||
}
|
||||
|
||||
function resetAll() {
|
||||
// Clear all status records
|
||||
modelInputs.value = {}
|
||||
revealingModels.value = {}
|
||||
|
||||
// Clear any running timers
|
||||
for (const id in downloadTimers.value) {
|
||||
clearInterval(downloadTimers.value[id])
|
||||
}
|
||||
downloadTimers.value = {}
|
||||
|
||||
for (const id in inputTimeouts.value) {
|
||||
clearTimeout(inputTimeouts.value[id])
|
||||
}
|
||||
inputTimeouts.value = {}
|
||||
|
||||
importStatus.value = {}
|
||||
downloadProgress.value = {}
|
||||
selectedLibraryModel.value = {}
|
||||
removedModels.value = {}
|
||||
activeDropdown.value = null
|
||||
activeLibraryDropdown.value = null
|
||||
}
|
||||
|
||||
// Helpers
|
||||
|
||||
function getElementStyle(el: HTMLElement) {
|
||||
return {
|
||||
height: el.style.height,
|
||||
overflow: el.style.overflow,
|
||||
paddingTop: el.style.paddingTop,
|
||||
paddingBottom: el.style.paddingBottom,
|
||||
marginTop: el.style.marginTop,
|
||||
marginBottom: el.style.marginBottom
|
||||
}
|
||||
}
|
||||
|
||||
// Transitions
|
||||
|
||||
const DURATION = 150
|
||||
|
||||
function enterTransition(element: Element, done: () => void) {
|
||||
const el = element as HTMLElement
|
||||
const init = getElementStyle(el)
|
||||
const { width } = getComputedStyle(el)
|
||||
el.style.width = width
|
||||
el.style.position = 'absolute'
|
||||
el.style.visibility = 'hidden'
|
||||
el.style.height = ''
|
||||
const { height } = getComputedStyle(el)
|
||||
el.style.position = ''
|
||||
el.style.visibility = ''
|
||||
el.style.height = '0px'
|
||||
el.style.overflow = 'hidden'
|
||||
const anim = el.animate(
|
||||
[{ height: '0px', opacity: 0 }, { height, opacity: 1 }],
|
||||
{ duration: DURATION, easing: 'ease-in-out' }
|
||||
)
|
||||
el.style.height = init.height
|
||||
anim.onfinish = () => { el.style.overflow = init.overflow; done() }
|
||||
}
|
||||
|
||||
function leaveTransition(element: Element, done: () => void) {
|
||||
const el = element as HTMLElement
|
||||
const init = getElementStyle(el)
|
||||
const { height } = getComputedStyle(el)
|
||||
el.style.height = height
|
||||
el.style.overflow = 'hidden'
|
||||
const anim = el.animate(
|
||||
[{ height, opacity: 1 }, { height: '0px', opacity: 0 }],
|
||||
{ duration: DURATION, easing: 'ease-in-out' }
|
||||
)
|
||||
el.style.height = init.height
|
||||
anim.onfinish = () => { el.style.overflow = init.overflow; done() }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="w-[320px] h-full shrink-0 flex flex-col gap-4 py-1 bg-[#171718] border-l border-[#494a50] shadow-[1px_1px_8px_0px_rgba(0,0,0,0.4)]"
|
||||
>
|
||||
<!-- ① Nav Item -->
|
||||
<div class="flex h-12 items-center overflow-hidden py-2 border-b border-[#55565e] shrink-0">
|
||||
<div class="flex flex-1 gap-2 items-center min-w-0 pl-4 pr-3">
|
||||
<p class="flex-1 min-w-0 font-bold text-sm text-white whitespace-pre-wrap">
|
||||
Workflow Overview
|
||||
</p>
|
||||
<div class="flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg bg-[#262729] shrink-0 cursor-pointer hover:bg-[#303133]">
|
||||
<i class="icon-[lucide--panel-right] size-4 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ② Node Header -->
|
||||
<div class="flex flex-col gap-3 items-start px-4 shrink-0">
|
||||
<div class="flex gap-2 items-center w-full overflow-x-auto no-scrollbar">
|
||||
<div class="flex gap-1 h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0 bg-[#262729]">
|
||||
<span class="text-sm text-white">Error</span>
|
||||
<div class="flex items-center justify-center size-6 shrink-0">
|
||||
<i class="icon-[lucide--octagon-alert] size-4 text-[#e04e48]" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0">
|
||||
<span class="text-sm text-[#8a8a8a]">Inputs</span>
|
||||
</div>
|
||||
<div class="flex h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0">
|
||||
<span class="text-sm text-[#8a8a8a]">Nodes</span>
|
||||
</div>
|
||||
<div class="flex h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0">
|
||||
<span class="text-sm whitespace-nowrap text-[#8a8a8a]">Global settings</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 h-8 items-center min-h-[32px] px-2 py-1.5 rounded-lg bg-[#262729] w-full">
|
||||
<i class="icon-[lucide--search] size-4 text-[#8a8a8a] shrink-0" />
|
||||
<p class="flex-1 text-xs text-[#8a8a8a] truncate leading-normal">
|
||||
Search for nodes or inputs
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="h-px bg-[#55565e] shrink-0 w-full" />
|
||||
|
||||
<!-- ③ Content: Missing Models -->
|
||||
<div class="flex-1 overflow-y-auto min-w-0 no-scrollbar">
|
||||
<template v-for="(models, category) in INITIAL_MISSING_MODELS" :key="category">
|
||||
<div
|
||||
v-if="activeCategories[category]"
|
||||
class="px-4 mb-4"
|
||||
>
|
||||
<!-- Category Header -->
|
||||
<div
|
||||
class="flex h-8 items-center justify-center w-full group"
|
||||
:class="category === 'VAE' ? 'cursor-default' : 'cursor-pointer'"
|
||||
@click="category !== 'VAE' && (collapsedCategories[category] = !collapsedCategories[category])"
|
||||
>
|
||||
<div class="flex items-center justify-center size-6 shrink-0">
|
||||
<i class="icon-[lucide--octagon-alert] size-4 text-[#e04e48]" />
|
||||
</div>
|
||||
<p class="flex-1 min-w-0 text-sm text-[#e04e48] whitespace-pre-wrap font-medium">
|
||||
{{ category }} ({{ models.filter(m => !removedModels[m.id]).length }})
|
||||
</p>
|
||||
<div class="flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg shrink-0">
|
||||
<i
|
||||
class="icon-[lucide--chevron-up] size-4 text-[#8a8a8a] transition-all"
|
||||
:class="[
|
||||
category !== 'VAE' ? 'group-hover:text-white' : '',
|
||||
collapsedCategories[category] ? '-rotate-180' : ''
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Model List -->
|
||||
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
|
||||
<div v-if="!collapsedCategories[category]" class="pt-2">
|
||||
<TransitionGroup :css="false" @enter="enterTransition" @leave="leaveTransition">
|
||||
<div v-for="model in models" v-show="!removedModels[model.id]" :key="model.id" class="flex flex-col w-full mb-6 last:mb-4">
|
||||
|
||||
<!-- Model Header (Always visible) -->
|
||||
<div class="flex h-8 items-center w-full gap-2 mb-1">
|
||||
<i class="icon-[lucide--file-check] size-4 text-white shrink-0" />
|
||||
<p class="flex-1 min-w-0 text-sm font-medium text-white overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{{ model.name }}
|
||||
</p>
|
||||
|
||||
<!-- Check Button (Highlights blue when downloaded or using from library) -->
|
||||
<div
|
||||
class="flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg shrink-0 transition-colors"
|
||||
:class="[
|
||||
(importStatus[model.id] === 'downloaded' || importStatus[model.id] === 'using_library')
|
||||
? 'cursor-pointer hover:bg-[#1e2d3d] bg-[#1e2d3d]'
|
||||
: 'opacity-20 cursor-default'
|
||||
]"
|
||||
@click="handleCheckClick(model.id)"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--check] size-4"
|
||||
:class="(importStatus[model.id] === 'downloaded' || importStatus[model.id] === 'using_library') ? 'text-[#3b82f6]' : 'text-white'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Locate Button -->
|
||||
<div
|
||||
class="flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg shrink-0 cursor-pointer hover:bg-[#262729]"
|
||||
@click="emit('locate', model.name)"
|
||||
>
|
||||
<i class="icon-[lucide--locate] size-4 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Input or Progress Area -->
|
||||
<div class="relative mt-1">
|
||||
<!-- CARD (Download or Library Substitute) -->
|
||||
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
|
||||
<div
|
||||
v-if="importStatus[model.id] && importStatus[model.id] !== 'idle'"
|
||||
class="relative bg-white/5 border border-[#55565e] rounded-lg overflow-hidden flex items-center p-2 gap-2"
|
||||
>
|
||||
<!-- Progress Filling (Only while downloading) -->
|
||||
<div
|
||||
v-if="importStatus[model.id] === 'downloading'"
|
||||
class="absolute inset-y-0 left-0 bg-[#3b82f6]/10 transition-all duration-100 ease-linear pointer-events-none"
|
||||
:style="{ width: downloadProgress[model.id] + '%' }"
|
||||
/>
|
||||
|
||||
<!-- Left Icon -->
|
||||
<div class="relative z-10 size-[32px] flex items-center justify-center shrink-0">
|
||||
<i class="icon-[lucide--file-check] size-5 text-[#8a8a8a]" />
|
||||
</div>
|
||||
|
||||
<!-- Center Text -->
|
||||
<div class="relative z-10 flex-1 min-w-0 flex flex-col justify-center">
|
||||
<span class="text-[12px] font-medium text-white truncate leading-tight">
|
||||
{{ importStatus[model.id] === 'using_library' ? selectedLibraryModel[model.id] : model.name }}
|
||||
</span>
|
||||
<span class="text-[12px] text-[#8a8a8a] leading-tight mt-0.5">
|
||||
<template v-if="importStatus[model.id] === 'downloading'">Importing ...</template>
|
||||
<template v-else-if="importStatus[model.id] === 'downloaded'">Imported</template>
|
||||
<template v-else-if="importStatus[model.id] === 'using_library'">Using from Library</template>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Cancel (X) Button (Always visible in this card) -->
|
||||
<div
|
||||
class="relative z-10 size-6 flex items-center justify-center text-[#55565e] hover:text-white cursor-pointer transition-colors shrink-0"
|
||||
@click="cancelImport(model.id)"
|
||||
>
|
||||
<i class="icon-[lucide--circle-x] size-4" />
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- IDLE / INPUT AREA -->
|
||||
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
|
||||
<div v-if="!importStatus[model.id] || importStatus[model.id] === 'idle'" class="flex flex-col gap-2">
|
||||
<!-- URL Input Area -->
|
||||
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
|
||||
<div v-if="!selectedLibraryModel[model.id]" class="flex flex-col gap-2">
|
||||
<div class="h-8 bg-[#262729] rounded-lg flex items-center px-3 border border-transparent focus-within:border-[#494a50] transition-colors whitespace-nowrap overflow-hidden">
|
||||
<input
|
||||
v-model="modelInputs[model.id]"
|
||||
type="text"
|
||||
placeholder="Paste Model URL (Civitai or Hugging Face)"
|
||||
class="bg-transparent border-none outline-none text-xs text-white w-full placeholder:text-[#8a8a8a]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
|
||||
<div v-if="revealingModels[model.id]" class="flex flex-col gap-2">
|
||||
<div class="px-0.5 pt-0.5 whitespace-nowrap overflow-hidden text-ellipsis">
|
||||
<span class="text-xs font-bold text-white">something_model.safetensors</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 px-0.5">
|
||||
<span class="text-[11px] text-[#8a8a8a]">What type of model is this?</span>
|
||||
<i class="icon-[lucide--help-circle] size-3 text-[#55565e]" />
|
||||
<span class="text-[11px] text-[#55565e]">Not sure? Just leave this as is</span>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<div class="h-9 bg-[#262729] rounded-lg flex items-center px-3 border border-transparent cursor-pointer hover:bg-[#303133]" @click="toggleDropdown(model.id)">
|
||||
<span class="flex-1 text-xs text-[#8a8a8a]">{{ selectedModelTypes[model.id] || 'Select model type' }}</span>
|
||||
<i class="icon-[lucide--chevron-down] size-4 text-[#8a8a8a]" />
|
||||
</div>
|
||||
<div v-if="activeDropdown === model.id" class="absolute top-full left-0 w-full mt-1 bg-[#26272b] border border-[#3f4045] rounded-lg shadow-xl z-50 overflow-hidden py-1">
|
||||
<div v-for="type in MODEL_TYPES" :key="type" class="px-3 py-2 text-xs text-[#e2e2e4] hover:bg-[#323338] cursor-pointer" @click="selectType(model.id, type)">
|
||||
{{ type }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pt-0.5">
|
||||
<Button variant="primary" class="w-full h-9 justify-center gap-2 text-sm font-semibold" @click="startImport(model.id)">
|
||||
<i class="icon-[lucide--download] size-4" /> Import
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- OR / Library Dropdown -->
|
||||
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
|
||||
<div v-if="!modelInputs[model.id]" class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-center py-0.5 font-bold text-[10px] text-[#8a8a8a]">OR</div>
|
||||
<div class="relative">
|
||||
<div
|
||||
class="h-8 bg-[#262729] rounded-lg flex items-center px-3 cursor-pointer group/lib hover:border-[#494a50] border border-transparent"
|
||||
@click="toggleLibraryDropdown(model.id)"
|
||||
>
|
||||
<span class="flex-1 text-xs text-white truncate">Use from Library</span>
|
||||
<i class="icon-[lucide--chevron-down] size-3.5 text-[#8a8a8a] group-hover/lib:text-white" />
|
||||
</div>
|
||||
<!-- Library Dropdown Menu -->
|
||||
<div v-if="activeLibraryDropdown === model.id" class="absolute top-full left-0 w-full mt-1 bg-[#26272b] border border-[#3f4045] rounded-lg shadow-xl z-50 overflow-hidden py-1">
|
||||
<div
|
||||
v-for="libModel in LIBRARY_MODELS"
|
||||
:key="libModel"
|
||||
class="px-3 py-2 text-xs text-[#e2e2e4] hover:bg-[#323338] cursor-pointer flex items-center gap-2"
|
||||
@click="selectFromLibrary(model.id, libModel)"
|
||||
>
|
||||
<i class="icon-[lucide--file-code] size-3.5 text-[#8a8a8a]" />
|
||||
{{ libModel }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Bottom Divider -->
|
||||
<div class="-mx-4 mt-6 h-px bg-[#55565e]" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Reset Button (Convenience for Storybook testing) -->
|
||||
<div v-if="Object.keys(removedModels).length > 0" class="flex justify-center py-8">
|
||||
<Button variant="muted-textonly" class="text-xs gap-2 hover:text-white" @click="resetAll">
|
||||
<i class="icon-[lucide--rotate-ccw] size-3.5" />
|
||||
Reset Storybook Flow
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="h-px bg-[#55565e] shrink-0 w-full" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,472 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
// Props / Emits
|
||||
|
||||
const emit = defineEmits<{
|
||||
'locate': [name: string],
|
||||
}>()
|
||||
|
||||
// Mock Data
|
||||
|
||||
interface MissingModel {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
}
|
||||
|
||||
const INITIAL_MISSING_MODELS: Record<string, MissingModel[]> = {
|
||||
'Lora': [
|
||||
{ id: 'm1', name: 'Flat_color_anime.safetensors', type: 'Lora' },
|
||||
{ id: 'm2', name: 'Bokeh_blur_xl.safetensors', type: 'Lora' },
|
||||
{ id: 'm3', name: 'Skin_texture_realism.safetensors', type: 'Lora' }
|
||||
],
|
||||
'VAE': [
|
||||
{ id: 'v1', name: 'vae-ft-mse-840000-ema-pruned.safetensors', type: 'VAE' },
|
||||
{ id: 'v2', name: 'clear-vae-v1.safetensors', type: 'VAE' }
|
||||
]
|
||||
}
|
||||
|
||||
const LIBRARY_MODELS = [
|
||||
'v1-5-pruned-emaonly.safetensors',
|
||||
'sd_xl_base_1.0.safetensors',
|
||||
'dreamshaper_8.safetensors',
|
||||
'realisticVisionV51_v51VAE.safetensors'
|
||||
]
|
||||
|
||||
// State
|
||||
|
||||
const collapsedCategories = ref<Record<string, boolean>>({
|
||||
'VAE': true
|
||||
})
|
||||
|
||||
// Stores the URL input for each model
|
||||
const modelInputs = ref<Record<string, string>>({})
|
||||
|
||||
// Tracks which models have finished their "revealing" delay
|
||||
const revealingModels = ref<Record<string, boolean>>({})
|
||||
const inputTimeouts = ref<Record<string, ReturnType<typeof setTimeout>>>({})
|
||||
|
||||
// Model Status: 'idle' | 'downloading' | 'downloaded' | 'using_library'
|
||||
const importStatus = ref<Record<string, 'idle' | 'downloading' | 'downloaded' | 'using_library'>>({})
|
||||
const downloadProgress = ref<Record<string, number>>({})
|
||||
const downloadTimers = ref<Record<string, ReturnType<typeof setInterval>>>({})
|
||||
const selectedLibraryModel = ref<Record<string, string>>({})
|
||||
|
||||
// Track hidden models (removed after clicking check button)
|
||||
const removedModels = ref<Record<string, boolean>>({})
|
||||
|
||||
// Watch for input changes to trigger the 1s delay
|
||||
watch(modelInputs, (newVal) => {
|
||||
for (const id in newVal) {
|
||||
const value = newVal[id]
|
||||
if (value && !revealingModels.value[id] && !inputTimeouts.value[id]) {
|
||||
inputTimeouts.value[id] = setTimeout(() => {
|
||||
revealingModels.value[id] = true
|
||||
delete inputTimeouts.value[id]
|
||||
}, 1000)
|
||||
} else if (!value) {
|
||||
revealingModels.value[id] = false
|
||||
if (inputTimeouts.value[id]) {
|
||||
clearTimeout(inputTimeouts.value[id])
|
||||
delete inputTimeouts.value[id]
|
||||
}
|
||||
}
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
// Compute which categories have at least one visible model
|
||||
const activeCategories = computed(() => {
|
||||
const result: Record<string, boolean> = {}
|
||||
for (const cat in INITIAL_MISSING_MODELS) {
|
||||
result[cat] = INITIAL_MISSING_MODELS[cat].some(m => !removedModels.value[m.id])
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
// Tracks which model's library dropdown is currently open
|
||||
const activeLibraryDropdown = ref<string | null>(null)
|
||||
|
||||
// Actions
|
||||
|
||||
function toggleLibraryDropdown(modelId: string) {
|
||||
if (activeLibraryDropdown.value === modelId) {
|
||||
activeLibraryDropdown.value = null
|
||||
} else {
|
||||
activeLibraryDropdown.value = modelId
|
||||
}
|
||||
}
|
||||
|
||||
function selectFromLibrary(modelId: string, fileName: string) {
|
||||
selectedLibraryModel.value[modelId] = fileName
|
||||
importStatus.value[modelId] = 'using_library'
|
||||
activeLibraryDropdown.value = null
|
||||
}
|
||||
|
||||
function startImport(modelId: string) {
|
||||
if (downloadTimers.value[modelId]) {
|
||||
clearInterval(downloadTimers.value[modelId])
|
||||
}
|
||||
|
||||
importStatus.value[modelId] = 'downloading'
|
||||
downloadProgress.value[modelId] = 0
|
||||
|
||||
const startTime = Date.now()
|
||||
const duration = 5000
|
||||
|
||||
downloadTimers.value[modelId] = setInterval(() => {
|
||||
const elapsed = Date.now() - startTime
|
||||
const progress = Math.min((elapsed / duration) * 100, 100)
|
||||
downloadProgress.value[modelId] = progress
|
||||
|
||||
if (progress >= 100) {
|
||||
clearInterval(downloadTimers.value[modelId])
|
||||
delete downloadTimers.value[modelId]
|
||||
importStatus.value[modelId] = 'downloaded'
|
||||
}
|
||||
}, 50)
|
||||
}
|
||||
|
||||
function handleCheckClick(modelId: string) {
|
||||
if (importStatus.value[modelId] === 'downloaded' || importStatus.value[modelId] === 'using_library') {
|
||||
// Update object with spread to guarantee reactivity trigger
|
||||
removedModels.value = { ...removedModels.value, [modelId]: true }
|
||||
}
|
||||
}
|
||||
|
||||
function cancelImport(modelId: string) {
|
||||
if (downloadTimers.value[modelId]) {
|
||||
clearInterval(downloadTimers.value[modelId])
|
||||
delete downloadTimers.value[modelId]
|
||||
}
|
||||
importStatus.value[modelId] = 'idle'
|
||||
downloadProgress.value[modelId] = 0
|
||||
modelInputs.value[modelId] = ''
|
||||
selectedLibraryModel.value[modelId] = ''
|
||||
}
|
||||
|
||||
function resetAll() {
|
||||
// Clear all status records
|
||||
modelInputs.value = {}
|
||||
revealingModels.value = {}
|
||||
|
||||
// Clear any running timers
|
||||
for (const id in downloadTimers.value) {
|
||||
clearInterval(downloadTimers.value[id])
|
||||
}
|
||||
downloadTimers.value = {}
|
||||
|
||||
for (const id in inputTimeouts.value) {
|
||||
clearTimeout(inputTimeouts.value[id])
|
||||
}
|
||||
inputTimeouts.value = {}
|
||||
|
||||
importStatus.value = {}
|
||||
downloadProgress.value = {}
|
||||
selectedLibraryModel.value = {}
|
||||
removedModels.value = {}
|
||||
activeLibraryDropdown.value = null
|
||||
}
|
||||
|
||||
// Helpers
|
||||
|
||||
function getElementStyle(el: HTMLElement) {
|
||||
return {
|
||||
height: el.style.height,
|
||||
overflow: el.style.overflow,
|
||||
paddingTop: el.style.paddingTop,
|
||||
paddingBottom: el.style.paddingBottom,
|
||||
marginTop: el.style.marginTop,
|
||||
marginBottom: el.style.marginBottom
|
||||
}
|
||||
}
|
||||
|
||||
// Transitions
|
||||
|
||||
const DURATION = 150
|
||||
|
||||
function enterTransition(element: Element, done: () => void) {
|
||||
const el = element as HTMLElement
|
||||
const init = getElementStyle(el)
|
||||
const { width } = getComputedStyle(el)
|
||||
el.style.width = width
|
||||
el.style.position = 'absolute'
|
||||
el.style.visibility = 'hidden'
|
||||
el.style.height = ''
|
||||
const { height } = getComputedStyle(el)
|
||||
el.style.position = ''
|
||||
el.style.visibility = ''
|
||||
el.style.height = '0px'
|
||||
el.style.overflow = 'hidden'
|
||||
const anim = el.animate(
|
||||
[{ height: '0px', opacity: 0 }, { height, opacity: 1 }],
|
||||
{ duration: DURATION, easing: 'ease-in-out' }
|
||||
)
|
||||
el.style.height = init.height
|
||||
anim.onfinish = () => { el.style.overflow = init.overflow; done() }
|
||||
}
|
||||
|
||||
function leaveTransition(element: Element, done: () => void) {
|
||||
const el = element as HTMLElement
|
||||
const init = getElementStyle(el)
|
||||
const { height } = getComputedStyle(el)
|
||||
el.style.height = height
|
||||
el.style.overflow = 'hidden'
|
||||
const anim = el.animate(
|
||||
[{ height, opacity: 1 }, { height: '0px', opacity: 0 }],
|
||||
{ duration: DURATION, easing: 'ease-in-out' }
|
||||
)
|
||||
el.style.height = init.height
|
||||
anim.onfinish = () => { el.style.overflow = init.overflow; done() }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="w-[320px] h-full shrink-0 flex flex-col gap-4 py-1 bg-[#171718] border-l border-[#494a50] shadow-[1px_1px_8px_0px_rgba(0,0,0,0.4)]"
|
||||
>
|
||||
<!-- ① Nav Item -->
|
||||
<div class="flex h-12 items-center overflow-hidden py-2 border-b border-[#55565e] shrink-0">
|
||||
<div class="flex flex-1 gap-2 items-center min-w-0 pl-4 pr-3">
|
||||
<p class="flex-1 min-w-0 font-bold text-sm text-white whitespace-pre-wrap">
|
||||
Workflow Overview
|
||||
</p>
|
||||
<div class="flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg bg-[#262729] shrink-0 cursor-pointer hover:bg-[#303133]">
|
||||
<i class="icon-[lucide--panel-right] size-4 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ② Node Header -->
|
||||
<div class="flex flex-col gap-3 items-start px-4 shrink-0">
|
||||
<div class="flex gap-2 items-center w-full overflow-x-auto no-scrollbar">
|
||||
<div class="flex gap-1 h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0 bg-[#262729]">
|
||||
<span class="text-sm text-white">Error</span>
|
||||
<div class="flex items-center justify-center size-6 shrink-0">
|
||||
<i class="icon-[lucide--octagon-alert] size-4 text-[#e04e48]" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0">
|
||||
<span class="text-sm text-[#8a8a8a]">Inputs</span>
|
||||
</div>
|
||||
<div class="flex h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0">
|
||||
<span class="text-sm text-[#8a8a8a]">Nodes</span>
|
||||
</div>
|
||||
<div class="flex h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0">
|
||||
<span class="text-sm whitespace-nowrap text-[#8a8a8a]">Global settings</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 h-8 items-center min-h-[32px] px-2 py-1.5 rounded-lg bg-[#262729] w-full">
|
||||
<i class="icon-[lucide--search] size-4 text-[#8a8a8a] shrink-0" />
|
||||
<p class="flex-1 text-xs text-[#8a8a8a] truncate leading-normal">
|
||||
Search for nodes or inputs
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="h-px bg-[#55565e] shrink-0 w-full" />
|
||||
|
||||
<!-- ③ Content: Missing Models -->
|
||||
<div class="flex-1 overflow-y-auto min-w-0 no-scrollbar">
|
||||
<template v-for="(models, category) in INITIAL_MISSING_MODELS" :key="category">
|
||||
<div
|
||||
v-if="activeCategories[category]"
|
||||
class="px-4 mb-4"
|
||||
>
|
||||
<!-- Category Header -->
|
||||
<div
|
||||
class="flex h-8 items-center justify-center w-full group"
|
||||
:class="category === 'VAE' ? 'cursor-default' : 'cursor-pointer'"
|
||||
@click="category !== 'VAE' && (collapsedCategories[category] = !collapsedCategories[category])"
|
||||
>
|
||||
<div class="flex items-center justify-center size-6 shrink-0">
|
||||
<i class="icon-[lucide--octagon-alert] size-4 text-[#e04e48]" />
|
||||
</div>
|
||||
<p class="flex-1 min-w-0 text-sm text-[#e04e48] whitespace-pre-wrap font-medium">
|
||||
{{ category }} ({{ models.filter(m => !removedModels[m.id]).length }})
|
||||
</p>
|
||||
<div class="flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg shrink-0">
|
||||
<i
|
||||
class="icon-[lucide--chevron-up] size-4 text-[#8a8a8a] transition-all"
|
||||
:class="[
|
||||
category !== 'VAE' ? 'group-hover:text-white' : '',
|
||||
collapsedCategories[category] ? '-rotate-180' : ''
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Model List -->
|
||||
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
|
||||
<div v-if="!collapsedCategories[category]" class="pt-2">
|
||||
<TransitionGroup :css="false" @enter="enterTransition" @leave="leaveTransition">
|
||||
<div v-for="model in models" v-show="!removedModels[model.id]" :key="model.id" class="flex flex-col w-full mb-6 last:mb-4">
|
||||
|
||||
<!-- Model Header (Always visible) -->
|
||||
<div class="flex h-8 items-center w-full gap-2 mb-1">
|
||||
<i class="icon-[lucide--file-check] size-4 text-white shrink-0" />
|
||||
<p class="flex-1 min-w-0 text-sm font-medium text-white overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{{ model.name }}
|
||||
</p>
|
||||
|
||||
<!-- Check Button (Highlights blue when downloaded or using from library) -->
|
||||
<div
|
||||
class="flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg shrink-0 transition-colors"
|
||||
:class="[
|
||||
(importStatus[model.id] === 'downloaded' || importStatus[model.id] === 'using_library')
|
||||
? 'cursor-pointer hover:bg-[#1e2d3d] bg-[#1e2d3d]'
|
||||
: 'opacity-20 cursor-default'
|
||||
]"
|
||||
@click="handleCheckClick(model.id)"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--check] size-4"
|
||||
:class="(importStatus[model.id] === 'downloaded' || importStatus[model.id] === 'using_library') ? 'text-[#3b82f6]' : 'text-white'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Locate Button -->
|
||||
<div
|
||||
class="flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg shrink-0 cursor-pointer hover:bg-[#262729]"
|
||||
@click="emit('locate', model.name)"
|
||||
>
|
||||
<i class="icon-[lucide--locate] size-4 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Input or Progress Area -->
|
||||
<div class="relative mt-1">
|
||||
<!-- CARD (Download or Library Substitute) -->
|
||||
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
|
||||
<div
|
||||
v-if="importStatus[model.id] && importStatus[model.id] !== 'idle'"
|
||||
class="relative bg-white/5 border border-[#55565e] rounded-lg overflow-hidden flex items-center p-2 gap-2"
|
||||
>
|
||||
<!-- Progress Filling (Only while downloading) -->
|
||||
<div
|
||||
v-if="importStatus[model.id] === 'downloading'"
|
||||
class="absolute inset-y-0 left-0 bg-[#3b82f6]/10 transition-all duration-100 ease-linear pointer-events-none"
|
||||
:style="{ width: downloadProgress[model.id] + '%' }"
|
||||
/>
|
||||
|
||||
<!-- Left Icon -->
|
||||
<div class="relative z-10 size-[32px] flex items-center justify-center shrink-0">
|
||||
<i class="icon-[lucide--file-check] size-5 text-[#8a8a8a]" />
|
||||
</div>
|
||||
|
||||
<!-- Center Text -->
|
||||
<div class="relative z-10 flex-1 min-w-0 flex flex-col justify-center">
|
||||
<span class="text-[12px] font-medium text-white truncate leading-tight">
|
||||
{{ importStatus[model.id] === 'using_library' ? selectedLibraryModel[model.id] : model.name }}
|
||||
</span>
|
||||
<span class="text-[12px] text-[#8a8a8a] leading-tight mt-0.5">
|
||||
<template v-if="importStatus[model.id] === 'downloading'">Importing ...</template>
|
||||
<template v-else-if="importStatus[model.id] === 'downloaded'">Imported</template>
|
||||
<template v-else-if="importStatus[model.id] === 'using_library'">Using from Library</template>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Cancel (X) Button (Always visible in this card) -->
|
||||
<div
|
||||
class="relative z-10 size-6 flex items-center justify-center text-[#55565e] hover:text-white cursor-pointer transition-colors shrink-0"
|
||||
@click="cancelImport(model.id)"
|
||||
>
|
||||
<i class="icon-[lucide--circle-x] size-4" />
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- IDLE / INPUT AREA -->
|
||||
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
|
||||
<div v-if="!importStatus[model.id] || importStatus[model.id] === 'idle'" class="flex flex-col gap-2">
|
||||
<!-- URL Input Area -->
|
||||
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
|
||||
<div v-if="!selectedLibraryModel[model.id]" class="flex flex-col gap-2">
|
||||
<div class="h-8 bg-[#262729] rounded-lg flex items-center px-3 border border-transparent focus-within:border-[#494a50] transition-colors whitespace-nowrap overflow-hidden">
|
||||
<input
|
||||
v-model="modelInputs[model.id]"
|
||||
type="text"
|
||||
placeholder="Paste Model URL (Civitai or Hugging Face)"
|
||||
class="bg-transparent border-none outline-none text-xs text-white w-full placeholder:text-[#8a8a8a]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
|
||||
<div v-if="revealingModels[model.id]" class="flex flex-col gap-2">
|
||||
<div class="px-0.5 pt-0.5 whitespace-nowrap overflow-hidden text-ellipsis">
|
||||
<span class="text-xs font-bold text-white">something_model.safetensors</span>
|
||||
</div>
|
||||
<div class="pt-0.5">
|
||||
<Button variant="primary" class="w-full h-9 justify-center gap-2 text-sm font-semibold" @click="startImport(model.id)">
|
||||
<i class="icon-[lucide--download] size-4" /> Import
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- OR / Library Dropdown -->
|
||||
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
|
||||
<div v-if="!modelInputs[model.id]" class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-center py-0.5 font-bold text-[10px] text-[#8a8a8a]">OR</div>
|
||||
<div class="relative">
|
||||
<div
|
||||
class="h-8 bg-[#262729] rounded-lg flex items-center px-3 cursor-pointer group/lib hover:border-[#494a50] border border-transparent"
|
||||
@click="toggleLibraryDropdown(model.id)"
|
||||
>
|
||||
<span class="flex-1 text-xs text-white truncate">Use from Library</span>
|
||||
<i class="icon-[lucide--chevron-down] size-3.5 text-[#8a8a8a] group-hover/lib:text-white" />
|
||||
</div>
|
||||
<!-- Library Dropdown Menu -->
|
||||
<div v-if="activeLibraryDropdown === model.id" class="absolute top-full left-0 w-full mt-1 bg-[#26272b] border border-[#3f4045] rounded-lg shadow-xl z-50 overflow-hidden py-1">
|
||||
<div
|
||||
v-for="libModel in LIBRARY_MODELS"
|
||||
:key="libModel"
|
||||
class="px-3 py-2 text-xs text-[#e2e2e4] hover:bg-[#323338] cursor-pointer flex items-center gap-2"
|
||||
@click="selectFromLibrary(model.id, libModel)"
|
||||
>
|
||||
<i class="icon-[lucide--file-code] size-3.5 text-[#8a8a8a]" />
|
||||
{{ libModel }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Bottom Divider -->
|
||||
<div class="-mx-4 mt-6 h-px bg-[#55565e]" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Reset Button (Convenience for Storybook testing) -->
|
||||
<div v-if="Object.keys(removedModels).length > 0" class="flex justify-center py-8">
|
||||
<Button variant="muted-textonly" class="text-xs gap-2 hover:text-white" @click="resetAll">
|
||||
<i class="icon-[lucide--rotate-ccw] size-3.5" />
|
||||
Reset Storybook Flow
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="h-px bg-[#55565e] shrink-0 w-full" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,206 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import type { MissingNodePack } from './MockManagerDialog.vue'
|
||||
|
||||
// Props / Emits
|
||||
|
||||
const emit = defineEmits<{
|
||||
'locate': [pack: MissingNodePack]
|
||||
}>()
|
||||
|
||||
// Mock Data
|
||||
|
||||
const MOCK_MISSING_PACKS: MissingNodePack[] = [
|
||||
{
|
||||
id: 'pack-1',
|
||||
displayName: 'MeshGraphormerDepthMapPreprocessor_for_SEGS //Inspire',
|
||||
packId: 'comfyui-inspire-pack',
|
||||
description: 'Inspire Pack provides various creative and utility nodes for ComfyUI workflows.'
|
||||
},
|
||||
{
|
||||
id: 'pack-2',
|
||||
displayName: 'TilePreprocessor_Provider_for_SEGS',
|
||||
packId: 'comfyui-controlnet-aux',
|
||||
description: 'Auxiliary preprocessors for ControlNet including tile, depth, and pose processors.'
|
||||
},
|
||||
{
|
||||
id: 'pack-3',
|
||||
displayName: 'WD14Tagger | pysssss',
|
||||
packId: 'comfyui-wdv14-tagger',
|
||||
description: 'Automatic image tagging using WD14 model from pysssss.'
|
||||
},
|
||||
{
|
||||
id: 'pack-4',
|
||||
displayName: 'CR Simple Image Compare',
|
||||
packId: 'comfyui-crystools',
|
||||
description: 'Crystal Tools suite including image comparison and utility nodes.'
|
||||
},
|
||||
{
|
||||
id: 'pack-5',
|
||||
displayName: 'FaceDetailer | impact',
|
||||
packId: 'comfyui-impact-pack',
|
||||
description: 'Impact Pack provides face detailing, masking, and segmentation utilities.'
|
||||
}
|
||||
]
|
||||
|
||||
// State
|
||||
|
||||
const isSectionCollapsed = ref(false)
|
||||
|
||||
// Helpers
|
||||
|
||||
function getElementStyle(el: HTMLElement) {
|
||||
return {
|
||||
height: el.style.height,
|
||||
overflow: el.style.overflow,
|
||||
paddingTop: el.style.paddingTop,
|
||||
paddingBottom: el.style.paddingBottom,
|
||||
marginTop: el.style.marginTop,
|
||||
marginBottom: el.style.marginBottom
|
||||
}
|
||||
}
|
||||
|
||||
// Transitions
|
||||
|
||||
const DURATION = 150
|
||||
|
||||
function enterTransition(element: Element, done: () => void) {
|
||||
const el = element as HTMLElement
|
||||
const init = getElementStyle(el)
|
||||
const { width } = getComputedStyle(el)
|
||||
el.style.width = width
|
||||
el.style.position = 'absolute'
|
||||
el.style.visibility = 'hidden'
|
||||
el.style.height = ''
|
||||
const { height } = getComputedStyle(el)
|
||||
el.style.position = ''
|
||||
el.style.visibility = ''
|
||||
el.style.height = '0px'
|
||||
el.style.overflow = 'hidden'
|
||||
const anim = el.animate(
|
||||
[{ height: '0px', opacity: 0 }, { height, opacity: 1 }],
|
||||
{ duration: DURATION, easing: 'ease-in-out' }
|
||||
)
|
||||
el.style.height = init.height
|
||||
anim.onfinish = () => { el.style.overflow = init.overflow; done() }
|
||||
}
|
||||
|
||||
function leaveTransition(element: Element, done: () => void) {
|
||||
const el = element as HTMLElement
|
||||
const init = getElementStyle(el)
|
||||
const { height } = getComputedStyle(el)
|
||||
el.style.height = height
|
||||
el.style.overflow = 'hidden'
|
||||
const anim = el.animate(
|
||||
[{ height, opacity: 1 }, { height: '0px', opacity: 0 }],
|
||||
{ duration: DURATION, easing: 'ease-in-out' }
|
||||
)
|
||||
el.style.height = init.height
|
||||
anim.onfinish = () => { el.style.overflow = init.overflow; done() }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="w-[320px] h-full shrink-0 flex flex-col gap-4 py-1 bg-[#171718] border-l border-[#494a50] shadow-[1px_1px_8px_0px_rgba(0,0,0,0.4)]"
|
||||
>
|
||||
<!-- ① Nav Item: "Workflow Overview" + panel-right button -->
|
||||
<div class="flex h-12 items-center overflow-hidden py-2 border-b border-[#55565e] shrink-0">
|
||||
<div class="flex flex-1 gap-2 items-center min-w-0 pl-4 pr-3">
|
||||
<p class="flex-1 min-w-0 font-bold text-sm text-white whitespace-pre-wrap">
|
||||
Workflow Overview
|
||||
</p>
|
||||
<div class="flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg bg-[#262729] shrink-0 cursor-pointer hover:bg-[#303133]">
|
||||
<i class="icon-[lucide--panel-right] size-4 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ② Node Header: tab bar + search -->
|
||||
<div class="flex flex-col gap-3 items-start px-4 shrink-0">
|
||||
<!-- Tab bar -->
|
||||
<div class="flex gap-2 items-center w-full overflow-x-auto no-scrollbar">
|
||||
<!-- "Error" tab (active) -->
|
||||
<div class="flex gap-1 h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0 bg-[#262729]">
|
||||
<span class="text-sm text-white">Error</span>
|
||||
<div class="flex items-center justify-center size-6 shrink-0">
|
||||
<i class="icon-[lucide--octagon-alert] size-4 text-[#e04e48]" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- Other tabs -->
|
||||
<div class="flex h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0">
|
||||
<span class="text-sm text-[#8a8a8a]">Inputs</span>
|
||||
</div>
|
||||
<div class="flex h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0">
|
||||
<span class="text-sm text-[#8a8a8a]">Nodes</span>
|
||||
</div>
|
||||
<div class="flex h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0">
|
||||
<span class="text-sm whitespace-nowrap text-[#8a8a8a]">Global settings</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search bar -->
|
||||
<div class="flex gap-2 h-8 items-center min-h-[32px] px-2 py-1.5 rounded-lg bg-[#262729] w-full">
|
||||
<i class="icon-[lucide--search] size-4 text-[#8a8a8a] shrink-0" />
|
||||
<p class="flex-1 text-xs text-[#8a8a8a] truncate leading-normal">
|
||||
Search for nodes or inputs
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="h-px bg-[#55565e] shrink-0 w-full" />
|
||||
|
||||
<!-- ③ Content: Nodes (Cloud Version) -->
|
||||
<div class="flex-1 overflow-y-auto min-w-0">
|
||||
<div class="px-4">
|
||||
<!-- Section Header: Unsupported Node Packs -->
|
||||
<div class="flex h-8 items-center justify-center w-full">
|
||||
<div class="flex items-center justify-center size-6 shrink-0">
|
||||
<i class="icon-[lucide--octagon-alert] size-4 text-[#e04e48]" />
|
||||
</div>
|
||||
<p class="flex-1 min-w-0 text-sm text-[#e04e48] whitespace-pre-wrap font-medium">Unsupported Node Packs</p>
|
||||
<div
|
||||
class="group flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg shrink-0 cursor-pointer"
|
||||
@click="isSectionCollapsed = !isSectionCollapsed"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--chevron-up] size-4 text-[#8a8a8a] group-hover:text-white transition-all"
|
||||
:class="isSectionCollapsed ? '-rotate-180' : ''"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cloud Warning Text -->
|
||||
<div v-if="!isSectionCollapsed" class="mt-3 mb-5">
|
||||
<p class="m-0 text-sm text-[#8a8a8a] leading-relaxed">
|
||||
This workflow requires custom nodes not yet available on Comfy Cloud.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="-mx-4 border-b border-[#55565e]">
|
||||
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
|
||||
<div v-if="!isSectionCollapsed" class="px-4 pb-2">
|
||||
<div v-for="pack in MOCK_MISSING_PACKS" :key="pack.id" class="flex flex-col w-full group/card mb-1">
|
||||
<!-- Widget Header -->
|
||||
<div class="flex h-8 items-center w-full">
|
||||
<p class="flex-1 min-w-0 text-sm text-white overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{{ pack.displayName }}
|
||||
</p>
|
||||
<div
|
||||
class="flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg shrink-0 cursor-pointer hover:bg-[#262729]"
|
||||
@click="emit('locate', pack)"
|
||||
>
|
||||
<i class="icon-[lucide--locate] size-4 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- No Install button in Cloud version -->
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="h-px bg-[#55565e] shrink-0 w-full" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,288 @@
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
// Story-only type (Separated from actual production types)
|
||||
export interface MissingNodePack {
|
||||
id: string
|
||||
displayName: string
|
||||
packId: string
|
||||
description: string
|
||||
}
|
||||
|
||||
// Props / Emits
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
selectedPack?: MissingNodePack | null
|
||||
}>(),
|
||||
{ selectedPack: null }
|
||||
)
|
||||
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!--
|
||||
BaseModalLayout structure reproduction:
|
||||
- Outer: rounded-2xl overflow-hidden
|
||||
- Grid: 14rem(left) 1fr(content) 18rem(right)
|
||||
- Left/Right panel bg: modal-panel-background = charcoal-600 = #262729
|
||||
- Main bg: base-background = charcoal-800 = #171718
|
||||
- Header height: h-18 (4.5rem / 72px)
|
||||
- Border: charcoal-200 = #494a50
|
||||
- NavItem selected: charcoal-300 = #3c3d42
|
||||
- NavItem hovered: charcoal-400 = #313235
|
||||
-->
|
||||
<div
|
||||
class="w-full h-full rounded-2xl overflow-hidden shadow-[1px_1px_8px_0px_rgba(0,0,0,0.4)]"
|
||||
style="display:grid; grid-template-columns: 14rem 1fr 18rem;"
|
||||
>
|
||||
|
||||
<!-- ① Left Panel: bg = modal-panel-background = #262729 -->
|
||||
<nav class="h-full overflow-hidden flex flex-col" style="background:#262729;">
|
||||
<!-- Header: h-18 = 72px -->
|
||||
<header class="flex w-full shrink-0 gap-2 pl-6 pr-3 items-center" style="height:4.5rem;">
|
||||
<i class="icon-[comfy--extensions-blocks] text-white" />
|
||||
<h2 class="text-white text-base font-semibold m-0">Nodes Manager</h2>
|
||||
</header>
|
||||
|
||||
<!-- NavItems: px-3 gap-1 flex-col -->
|
||||
<div class="flex flex-col gap-1 px-3 pb-3 overflow-y-auto">
|
||||
<!-- All Extensions -->
|
||||
<div class="flex cursor-pointer select-none items-center gap-2 rounded-md px-4 py-3 text-sm text-white transition-colors hover:bg-[#313235]">
|
||||
<i class="icon-[lucide--list] size-4 text-white/70 shrink-0" />
|
||||
<span class="min-w-0 truncate">All Extensions</span>
|
||||
</div>
|
||||
<!-- Not Installed -->
|
||||
<div class="flex cursor-pointer select-none items-center gap-2 rounded-md px-4 py-3 text-sm text-white transition-colors hover:bg-[#313235]">
|
||||
<i class="icon-[lucide--globe] size-4 text-white/70 shrink-0" />
|
||||
<span class="min-w-0 truncate">Not Installed</span>
|
||||
</div>
|
||||
|
||||
<!-- Installed Group -->
|
||||
<div class="flex flex-col gap-1 mt-2">
|
||||
<p class="px-4 py-1 text-[10px] text-white/40 uppercase tracking-wider font-medium m-0">Installed</p>
|
||||
<div class="flex cursor-pointer select-none items-center gap-2 rounded-md px-4 py-3 text-sm text-white transition-colors hover:bg-[#313235]">
|
||||
<i class="icon-[lucide--download] size-4 text-white/70 shrink-0" />
|
||||
<span class="min-w-0 truncate">All Installed</span>
|
||||
</div>
|
||||
<div class="flex cursor-pointer select-none items-center gap-2 rounded-md px-4 py-3 text-sm text-white transition-colors hover:bg-[#313235]">
|
||||
<i class="icon-[lucide--refresh-cw] size-4 text-white/70 shrink-0" />
|
||||
<span class="min-w-0 truncate">Updates Available</span>
|
||||
</div>
|
||||
<div class="flex cursor-pointer select-none items-center gap-2 rounded-md px-4 py-3 text-sm text-white transition-colors hover:bg-[#313235]">
|
||||
<i class="icon-[lucide--triangle-alert] size-4 text-white/70 shrink-0" />
|
||||
<span class="min-w-0 truncate">Conflicting</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- In Workflow Group -->
|
||||
<div class="flex flex-col gap-1 mt-2">
|
||||
<p class="px-4 py-1 text-[10px] text-white/40 uppercase tracking-wider font-medium m-0">In Workflow</p>
|
||||
<div class="flex cursor-pointer select-none items-center gap-2 rounded-md px-4 py-3 text-sm text-white transition-colors hover:bg-[#313235]">
|
||||
<i class="icon-[lucide--share-2] size-4 text-white/70 shrink-0" />
|
||||
<span class="min-w-0 truncate">In Workflow</span>
|
||||
</div>
|
||||
<!-- Missing Nodes: active selection = charcoal-300 = #3c3d42 -->
|
||||
<div class="flex cursor-pointer select-none items-center gap-2 rounded-md px-4 py-3 text-sm text-white transition-colors bg-[#3c3d42]">
|
||||
<i class="icon-[lucide--triangle-alert] size-4 text-[#fd9903] shrink-0" />
|
||||
<span class="min-w-0 truncate">Missing Nodes</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- ② Main Content: bg = base-background = #171718 -->
|
||||
<div class="flex flex-col overflow-hidden" style="background:#171718;">
|
||||
<!-- Header row 1: Node Pack dropdown | Search | Install All -->
|
||||
<header class="w-full px-6 flex items-center gap-3 shrink-0" style="height:4.5rem;">
|
||||
<!-- Node Pack Dropdown -->
|
||||
<div class="flex items-center gap-2 h-10 px-3 rounded-lg shrink-0 cursor-pointer text-sm text-white border border-[#494a50] hover:border-[#55565e]" style="background:#262729;">
|
||||
<span>Node Pack</span>
|
||||
<i class="icon-[lucide--chevron-down] size-4 text-[#8a8a8a]" />
|
||||
</div>
|
||||
<!-- Search bar (flex-1) -->
|
||||
<div class="flex items-center h-10 rounded-lg px-4 gap-2 flex-1" style="background:#262729;">
|
||||
<i class="pi pi-search text-xs text-[#8a8a8a] shrink-0" />
|
||||
<span class="text-sm text-[#8a8a8a]">Search</span>
|
||||
</div>
|
||||
<!-- Install All Button (blue, right side) -->
|
||||
<Button variant="primary" size="sm" class="shrink-0 gap-2 px-4 text-sm h-10 font-semibold rounded-xl">
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
Install All
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<!-- Header row 2: Downloads Sort Dropdown (right aligned) -->
|
||||
<div class="flex justify-end px-6 py-3 shrink-0">
|
||||
<div class="flex items-center h-10 gap-2 px-4 rounded-xl cursor-pointer text-sm text-white border border-[#494a50] hover:border-[#55565e]" style="background:#262729;">
|
||||
<i class="icon-[lucide--arrow-up-down] size-4 text-[#8a8a8a]" />
|
||||
<span>Downloads</span>
|
||||
<i class="icon-[lucide--chevron-down] size-4 text-[#8a8a8a]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pack Grid Content -->
|
||||
<div class="flex-1 min-h-0 overflow-y-auto px-6 py-4">
|
||||
<div class="grid gap-4" style="grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr));">
|
||||
<div
|
||||
v-for="(_, i) in 9"
|
||||
:key="i"
|
||||
:class="[
|
||||
'rounded-xl border p-4 flex flex-col gap-3 cursor-pointer transition-colors',
|
||||
i === 0 && selectedPack
|
||||
? 'border-[#0b8ce9]/70 ring-1 ring-[#0b8ce9]/40'
|
||||
: 'border-[#494a50] hover:border-[#55565e]'
|
||||
]"
|
||||
:style="i === 0 && selectedPack ? 'background:#172d3a;' : 'background:#262729;'"
|
||||
>
|
||||
<!-- Card Image Area -->
|
||||
<div class="w-full h-20 rounded-lg flex items-center justify-center" style="background:#313235;">
|
||||
<i class="icon-[lucide--package] size-6 text-[#8a8a8a]" />
|
||||
</div>
|
||||
<!-- Card Text Content -->
|
||||
<div>
|
||||
<p class="m-0 text-xs font-semibold text-white truncate">
|
||||
{{ i === 0 && selectedPack ? selectedPack.packId : 'node-pack-' + (i + 1) }}
|
||||
</p>
|
||||
<p class="m-0 text-[11px] text-[#8a8a8a] truncate mt-0.5">by publisher</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ③ Right Info Panel -->
|
||||
<aside class="h-full flex flex-col overflow-hidden" style="background:#1c1d1f; border-left: 1px solid #494a50;">
|
||||
<!-- Header: h-18 - Title + Panel icons + X close -->
|
||||
<header class="flex h-[4.5rem] shrink-0 items-center px-5 gap-3 border-b border-[#494a50]">
|
||||
<h2 class="flex-1 select-none text-base font-bold text-white m-0">Node Pack Info</h2>
|
||||
<!-- Panel Collapse Icon -->
|
||||
<button
|
||||
class="flex items-center justify-center text-[#8a8a8a] hover:text-white p-1"
|
||||
style="background:none;border:none;outline:none;cursor:pointer;"
|
||||
>
|
||||
<i class="icon-[lucide--panel-right] size-4" />
|
||||
</button>
|
||||
<!-- Close X Icon -->
|
||||
<button
|
||||
class="flex items-center justify-center text-[#8a8a8a] hover:text-white p-1"
|
||||
style="background:none;border:none;outline:none;cursor:pointer;"
|
||||
@click="emit('close')"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<!-- Panel Content Area -->
|
||||
<div class="flex-1 min-h-0 overflow-y-auto">
|
||||
<div v-if="props.selectedPack" class="flex flex-col divide-y divide-[#2e2f31]">
|
||||
|
||||
<!-- ACTIONS SECTION -->
|
||||
<div class="flex flex-col gap-3 px-5 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-[11px] font-bold text-[#8a8a8a] uppercase tracking-widest">Actions</span>
|
||||
<i class="icon-[lucide--chevron-up] size-4 text-[#8a8a8a]" />
|
||||
</div>
|
||||
<Button variant="primary" class="w-full justify-center gap-2 h-10 font-semibold rounded-xl">
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
Install
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- BASIC INFO SECTION -->
|
||||
<div class="flex flex-col gap-4 px-5 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-[11px] font-bold text-[#8a8a8a] uppercase tracking-widest">Basic Info</span>
|
||||
<i class="icon-[lucide--chevron-up] size-4 text-[#8a8a8a]" />
|
||||
</div>
|
||||
<!-- Name -->
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="text-sm font-medium text-white">Name</span>
|
||||
<span class="text-sm text-[#8a8a8a]">{{ props.selectedPack.packId }}</span>
|
||||
</div>
|
||||
<!-- Created By -->
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="text-sm font-medium text-white">Created By</span>
|
||||
<span class="text-sm text-[#8a8a8a]">publisher</span>
|
||||
</div>
|
||||
<!-- Downloads -->
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="text-sm font-medium text-white">Downloads</span>
|
||||
<span class="text-sm text-[#8a8a8a]">539,373</span>
|
||||
</div>
|
||||
<!-- Last Updated -->
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="text-sm font-medium text-white">Last Updated</span>
|
||||
<span class="text-sm text-[#8a8a8a]">Jan 21, 2026</span>
|
||||
</div>
|
||||
<!-- Status -->
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-sm font-medium text-white">Status</span>
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 w-fit px-2.5 py-1 rounded-full text-xs text-white border border-[#494a50]"
|
||||
style="background:#262729;"
|
||||
>
|
||||
<span class="size-2 rounded-full bg-[#8a8a8a] shrink-0" />
|
||||
Unknown
|
||||
</span>
|
||||
</div>
|
||||
<!-- Version -->
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-sm font-medium text-white">Version</span>
|
||||
<span
|
||||
class="inline-flex items-center gap-1 w-fit px-2.5 py-1 rounded-full text-xs text-white border border-[#494a50]"
|
||||
style="background:#262729;"
|
||||
>
|
||||
1.8.0
|
||||
<i class="icon-[lucide--chevron-right] size-3 text-[#8a8a8a]" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DESCRIPTION SECTION -->
|
||||
<div class="flex flex-col gap-4 px-5 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-[11px] font-bold text-[#8a8a8a] uppercase tracking-widest">Description</span>
|
||||
<i class="icon-[lucide--chevron-up] size-4 text-[#8a8a8a]" />
|
||||
</div>
|
||||
<!-- Description -->
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="text-sm font-medium text-white">Description</span>
|
||||
<p class="m-0 text-sm text-[#8a8a8a] leading-relaxed">{{ props.selectedPack.description }}</p>
|
||||
</div>
|
||||
<!-- Repository -->
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="text-sm font-medium text-white">Repository</span>
|
||||
<div class="flex items-start gap-2">
|
||||
<i class="icon-[lucide--github] size-4 text-[#8a8a8a] shrink-0 mt-0.5" />
|
||||
<span class="text-sm text-[#8a8a8a] break-all flex-1">https://github.com/aria1th/{{ props.selectedPack.packId }}</span>
|
||||
<i class="icon-[lucide--external-link] size-4 text-[#8a8a8a] shrink-0 mt-0.5" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- License -->
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="text-sm font-medium text-white">License</span>
|
||||
<span class="text-sm text-[#8a8a8a]">MIT</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- NODES SECTION -->
|
||||
<div class="flex flex-col gap-3 px-5 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-[11px] font-bold text-[#8a8a8a] uppercase tracking-widest">Nodes</span>
|
||||
<i class="icon-[lucide--chevron-up] size-4 text-[#8a8a8a]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- No Selection State -->
|
||||
<div v-else class="flex flex-col items-center justify-center h-full gap-3 px-6 opacity-40">
|
||||
<i class="icon-[lucide--package] size-8 text-white" />
|
||||
<p class="m-0 text-sm text-white text-center">Select a pack to view details</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,405 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
// Props / Emits
|
||||
|
||||
const emit = defineEmits<{
|
||||
'locate': [name: string],
|
||||
}>()
|
||||
|
||||
// Mock Data
|
||||
|
||||
interface MissingModel {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
}
|
||||
|
||||
const INITIAL_MISSING_MODELS: Record<string, MissingModel[]> = {
|
||||
'Lora': [
|
||||
{ id: 'm1', name: 'Flat_color_anime.safetensors', type: 'Lora' },
|
||||
{ id: 'm2', name: 'Bokeh_blur_xl.safetensors', type: 'Lora' },
|
||||
{ id: 'm3', name: 'Skin_texture_realism.safetensors', type: 'Lora' }
|
||||
],
|
||||
'VAE': [
|
||||
{ id: 'v1', name: 'vae-ft-mse-840000-ema-pruned.safetensors', type: 'VAE' },
|
||||
{ id: 'v2', name: 'clear-vae-v1.safetensors', type: 'VAE' }
|
||||
]
|
||||
}
|
||||
|
||||
const LIBRARY_MODELS = [
|
||||
'v1-5-pruned-emaonly.safetensors',
|
||||
'sd_xl_base_1.0.safetensors',
|
||||
'dreamshaper_8.safetensors',
|
||||
'realisticVisionV51_v51VAE.safetensors'
|
||||
]
|
||||
|
||||
// State
|
||||
|
||||
const collapsedCategories = ref<Record<string, boolean>>({
|
||||
'VAE': true
|
||||
})
|
||||
|
||||
// Model Status: 'idle' | 'downloading' | 'downloaded' | 'using_library'
|
||||
const importStatus = ref<Record<string, 'idle' | 'downloading' | 'downloaded' | 'using_library'>>({})
|
||||
const downloadProgress = ref<Record<string, number>>({})
|
||||
const downloadTimers = ref<Record<string, ReturnType<typeof setInterval>>>({})
|
||||
const selectedLibraryModel = ref<Record<string, string>>({})
|
||||
|
||||
// Track hidden models (removed after clicking check button)
|
||||
const removedModels = ref<Record<string, boolean>>({})
|
||||
|
||||
// Compute which categories have at least one visible model
|
||||
const activeCategories = computed(() => {
|
||||
const result: Record<string, boolean> = {}
|
||||
for (const cat in INITIAL_MISSING_MODELS) {
|
||||
result[cat] = INITIAL_MISSING_MODELS[cat].some(m => !removedModels.value[m.id])
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
// Tracks which model's library dropdown is currently open
|
||||
const activeLibraryDropdown = ref<string | null>(null)
|
||||
|
||||
// Actions
|
||||
|
||||
function toggleLibraryDropdown(modelId: string) {
|
||||
if (activeLibraryDropdown.value === modelId) {
|
||||
activeLibraryDropdown.value = null
|
||||
} else {
|
||||
activeLibraryDropdown.value = modelId
|
||||
}
|
||||
}
|
||||
|
||||
function selectFromLibrary(modelId: string, fileName: string) {
|
||||
selectedLibraryModel.value[modelId] = fileName
|
||||
importStatus.value[modelId] = 'using_library'
|
||||
activeLibraryDropdown.value = null
|
||||
}
|
||||
|
||||
function startUpload(modelId: string) {
|
||||
if (downloadTimers.value[modelId]) {
|
||||
clearInterval(downloadTimers.value[modelId])
|
||||
}
|
||||
|
||||
importStatus.value[modelId] = 'downloading'
|
||||
downloadProgress.value[modelId] = 0
|
||||
|
||||
const startTime = Date.now()
|
||||
const duration = 3000 // Speed up for OSS simulation
|
||||
|
||||
downloadTimers.value[modelId] = setInterval(() => {
|
||||
const elapsed = Date.now() - startTime
|
||||
const progress = Math.min((elapsed / duration) * 100, 100)
|
||||
downloadProgress.value[modelId] = progress
|
||||
|
||||
if (progress >= 100) {
|
||||
clearInterval(downloadTimers.value[modelId])
|
||||
delete downloadTimers.value[modelId]
|
||||
importStatus.value[modelId] = 'downloaded'
|
||||
}
|
||||
}, 50)
|
||||
}
|
||||
|
||||
function handleCheckClick(modelId: string) {
|
||||
if (importStatus.value[modelId] === 'downloaded' || importStatus.value[modelId] === 'using_library') {
|
||||
removedModels.value = { ...removedModels.value, [modelId]: true }
|
||||
}
|
||||
}
|
||||
|
||||
function cancelImport(modelId: string) {
|
||||
if (downloadTimers.value[modelId]) {
|
||||
clearInterval(downloadTimers.value[modelId])
|
||||
delete downloadTimers.value[modelId]
|
||||
}
|
||||
importStatus.value[modelId] = 'idle'
|
||||
downloadProgress.value[modelId] = 0
|
||||
selectedLibraryModel.value[modelId] = ''
|
||||
}
|
||||
|
||||
function resetAll() {
|
||||
for (const id in downloadTimers.value) {
|
||||
clearInterval(downloadTimers.value[id])
|
||||
}
|
||||
downloadTimers.value = {}
|
||||
importStatus.value = {}
|
||||
downloadProgress.value = {}
|
||||
selectedLibraryModel.value = {}
|
||||
removedModels.value = {}
|
||||
activeLibraryDropdown.value = null
|
||||
}
|
||||
|
||||
// Helpers
|
||||
|
||||
function getElementStyle(el: HTMLElement) {
|
||||
return {
|
||||
height: el.style.height,
|
||||
overflow: el.style.overflow,
|
||||
paddingTop: el.style.paddingTop,
|
||||
paddingBottom: el.style.paddingBottom,
|
||||
marginTop: el.style.marginTop,
|
||||
marginBottom: el.style.marginBottom
|
||||
}
|
||||
}
|
||||
|
||||
// Transitions
|
||||
|
||||
const DURATION = 150
|
||||
|
||||
function enterTransition(element: Element, done: () => void) {
|
||||
const el = element as HTMLElement
|
||||
const init = getElementStyle(el)
|
||||
const { width } = getComputedStyle(el)
|
||||
el.style.width = width
|
||||
el.style.position = 'absolute'
|
||||
el.style.visibility = 'hidden'
|
||||
el.style.height = ''
|
||||
const { height } = getComputedStyle(el)
|
||||
el.style.position = ''
|
||||
el.style.visibility = ''
|
||||
el.style.height = '0px'
|
||||
el.style.overflow = 'hidden'
|
||||
const anim = el.animate(
|
||||
[{ height: '0px', opacity: 0 }, { height, opacity: 1 }],
|
||||
{ duration: DURATION, easing: 'ease-in-out' }
|
||||
)
|
||||
el.style.height = init.height
|
||||
anim.onfinish = () => { el.style.overflow = init.overflow; done() }
|
||||
}
|
||||
|
||||
function leaveTransition(element: Element, done: () => void) {
|
||||
const el = element as HTMLElement
|
||||
const init = getElementStyle(el)
|
||||
const { height } = getComputedStyle(el)
|
||||
el.style.height = height
|
||||
el.style.overflow = 'hidden'
|
||||
const anim = el.animate(
|
||||
[{ height, opacity: 1 }, { height: '0px', opacity: 0 }],
|
||||
{ duration: DURATION, easing: 'ease-in-out' }
|
||||
)
|
||||
el.style.height = init.height
|
||||
anim.onfinish = () => { el.style.overflow = init.overflow; done() }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="w-[320px] h-full shrink-0 flex flex-col gap-4 py-1 bg-[#171718] border-l border-[#494a50] shadow-[1px_1px_8px_0px_rgba(0,0,0,0.4)]"
|
||||
>
|
||||
<!-- ① Nav Item -->
|
||||
<div class="flex h-12 items-center overflow-hidden py-2 border-b border-[#55565e] shrink-0">
|
||||
<div class="flex flex-1 gap-2 items-center min-w-0 pl-4 pr-3">
|
||||
<p class="flex-1 min-w-0 font-bold text-sm text-white whitespace-pre-wrap">
|
||||
Workflow Overview
|
||||
</p>
|
||||
<div class="flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg bg-[#262729] shrink-0 cursor-pointer hover:bg-[#303133]">
|
||||
<i class="icon-[lucide--panel-right] size-4 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ② Node Header -->
|
||||
<div class="flex flex-col gap-3 items-start px-4 shrink-0">
|
||||
<div class="flex gap-2 items-center w-full overflow-x-auto no-scrollbar">
|
||||
<div class="flex gap-1 h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0 bg-[#262729]">
|
||||
<span class="text-sm text-white">Error</span>
|
||||
<div class="flex items-center justify-center size-6 shrink-0">
|
||||
<i class="icon-[lucide--octagon-alert] size-4 text-[#e04e48]" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0">
|
||||
<span class="text-sm text-[#8a8a8a]">Inputs</span>
|
||||
</div>
|
||||
<div class="flex h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0">
|
||||
<span class="text-sm text-[#8a8a8a]">Nodes</span>
|
||||
</div>
|
||||
<div class="flex h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0">
|
||||
<span class="text-sm whitespace-nowrap text-[#8a8a8a]">Global settings</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 h-8 items-center min-h-[32px] px-2 py-1.5 rounded-lg bg-[#262729] w-full">
|
||||
<i class="icon-[lucide--search] size-4 text-[#8a8a8a] shrink-0" />
|
||||
<p class="flex-1 text-xs text-[#8a8a8a] truncate leading-normal">
|
||||
Search for nodes or inputs
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="h-px bg-[#55565e] shrink-0 w-full" />
|
||||
|
||||
<!-- ③ Content: Missing Models -->
|
||||
<div class="flex-1 overflow-y-auto min-w-0 no-scrollbar">
|
||||
<template v-for="(models, category) in INITIAL_MISSING_MODELS" :key="category">
|
||||
<div
|
||||
v-if="activeCategories[category]"
|
||||
class="px-4 mb-4"
|
||||
>
|
||||
<!-- Category Header -->
|
||||
<div
|
||||
class="flex h-8 items-center justify-center w-full group"
|
||||
:class="category === 'VAE' ? 'cursor-default' : 'cursor-pointer'"
|
||||
@click="category !== 'VAE' && (collapsedCategories[category] = !collapsedCategories[category])"
|
||||
>
|
||||
<div class="flex items-center justify-center size-6 shrink-0">
|
||||
<i class="icon-[lucide--octagon-alert] size-4 text-[#e04e48]" />
|
||||
</div>
|
||||
<p class="flex-1 min-w-0 text-sm text-[#e04e48] whitespace-pre-wrap font-medium">
|
||||
{{ category }} ({{ models.filter(m => !removedModels[m.id]).length }})
|
||||
</p>
|
||||
<div class="flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg shrink-0">
|
||||
<i
|
||||
class="icon-[lucide--chevron-up] size-4 text-[#8a8a8a] transition-all"
|
||||
:class="[
|
||||
category !== 'VAE' ? 'group-hover:text-white' : '',
|
||||
collapsedCategories[category] ? '-rotate-180' : ''
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Model List -->
|
||||
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
|
||||
<div v-if="!collapsedCategories[category]" class="pt-2">
|
||||
<TransitionGroup :css="false" @enter="enterTransition" @leave="leaveTransition">
|
||||
<div v-for="model in models" v-show="!removedModels[model.id]" :key="model.id" class="flex flex-col w-full mb-6 last:mb-4">
|
||||
|
||||
<!-- Model Header (Always visible) -->
|
||||
<div class="flex h-8 items-center w-full gap-2 mb-1">
|
||||
<i class="icon-[lucide--file-check] size-4 text-white shrink-0" />
|
||||
<p class="flex-1 min-w-0 text-sm font-medium text-white overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{{ model.name }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
class="flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg shrink-0 transition-colors"
|
||||
:class="[
|
||||
(importStatus[model.id] === 'downloaded' || importStatus[model.id] === 'using_library')
|
||||
? 'cursor-pointer hover:bg-[#1e2d3d] bg-[#1e2d3d]'
|
||||
: 'opacity-20 cursor-default'
|
||||
]"
|
||||
@click="handleCheckClick(model.id)"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--check] size-4"
|
||||
:class="(importStatus[model.id] === 'downloaded' || importStatus[model.id] === 'using_library') ? 'text-[#3b82f6]' : 'text-white'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg shrink-0 cursor-pointer hover:bg-[#262729]"
|
||||
@click="emit('locate', model.name)"
|
||||
>
|
||||
<i class="icon-[lucide--locate] size-4 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Input or Progress Area -->
|
||||
<div class="relative mt-1">
|
||||
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
|
||||
<div
|
||||
v-if="importStatus[model.id] && importStatus[model.id] !== 'idle'"
|
||||
class="relative bg-white/5 border border-[#55565e] rounded-lg overflow-hidden flex items-center p-2 gap-2"
|
||||
>
|
||||
<div
|
||||
v-if="importStatus[model.id] === 'downloading'"
|
||||
class="absolute inset-y-0 left-0 bg-[#3b82f6]/10 transition-all duration-100 ease-linear pointer-events-none"
|
||||
:style="{ width: downloadProgress[model.id] + '%' }"
|
||||
/>
|
||||
|
||||
<div class="relative z-10 size-[32px] flex items-center justify-center shrink-0">
|
||||
<i class="icon-[lucide--file-check] size-5 text-[#8a8a8a]" />
|
||||
</div>
|
||||
|
||||
<div class="relative z-10 flex-1 min-w-0 flex flex-col justify-center">
|
||||
<span class="text-[12px] font-medium text-white truncate leading-tight">
|
||||
{{ importStatus[model.id] === 'using_library' ? selectedLibraryModel[model.id] : model.name }}
|
||||
</span>
|
||||
<span class="text-[12px] text-[#8a8a8a] leading-tight mt-0.5">
|
||||
<template v-if="importStatus[model.id] === 'downloading'">Uploading ...</template>
|
||||
<template v-else-if="importStatus[model.id] === 'downloaded'">Uploaded</template>
|
||||
<template v-else-if="importStatus[model.id] === 'using_library'">Using from Library</template>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="relative z-10 size-6 flex items-center justify-center text-[#55565e] hover:text-white cursor-pointer transition-colors shrink-0"
|
||||
@click="cancelImport(model.id)"
|
||||
>
|
||||
<i class="icon-[lucide--circle-x] size-4" />
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- IDLE / UPLOAD AREA -->
|
||||
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
|
||||
<div v-if="!importStatus[model.id] || importStatus[model.id] === 'idle'" class="flex flex-col gap-2">
|
||||
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
|
||||
<div v-if="!selectedLibraryModel[model.id]" class="flex flex-col gap-2">
|
||||
<!-- Direct Upload Section -->
|
||||
<div
|
||||
class="h-8 rounded-lg flex items-center justify-center border border-dashed border-[#55565e] hover:border-white transition-colors cursor-pointer group"
|
||||
@click="startUpload(model.id)"
|
||||
>
|
||||
<span class="text-xs text-[#8a8a8a] group-hover:text-white">Upload .safetensors or .ckpt</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-center py-0.5 font-bold text-[10px] text-[#8a8a8a]">OR</div>
|
||||
<div class="relative">
|
||||
<div
|
||||
class="h-8 bg-[#262729] rounded-lg flex items-center px-3 cursor-pointer group/lib hover:border-[#494a50] border border-transparent"
|
||||
@click="toggleLibraryDropdown(model.id)"
|
||||
>
|
||||
<span class="flex-1 text-xs text-white truncate">Use from Library</span>
|
||||
<i class="icon-[lucide--chevron-down] size-3.5 text-[#8a8a8a] group-hover/lib:text-white" />
|
||||
</div>
|
||||
<div v-if="activeLibraryDropdown === model.id" class="absolute top-full left-0 w-full mt-1 bg-[#26272b] border border-[#3f4045] rounded-lg shadow-xl z-50 overflow-hidden py-1">
|
||||
<div
|
||||
v-for="libModel in LIBRARY_MODELS"
|
||||
:key="libModel"
|
||||
class="px-3 py-2 text-xs text-[#e2e2e4] hover:bg-[#323338] cursor-pointer flex items-center gap-2"
|
||||
@click="selectFromLibrary(model.id, libModel)"
|
||||
>
|
||||
<i class="icon-[lucide--file-code] size-3.5 text-[#8a8a8a]" />
|
||||
{{ libModel }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<div class="-mx-4 mt-6 h-px bg-[#55565e]" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="Object.keys(removedModels).length > 0" class="flex justify-center py-8">
|
||||
<Button variant="muted-textonly" class="text-xs gap-2 hover:text-white" @click="resetAll">
|
||||
<i class="icon-[lucide--rotate-ccw] size-3.5" />
|
||||
Reset Storybook Flow
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="h-px bg-[#55565e] shrink-0 w-full" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,312 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { MissingNodePack } from './MockManagerDialog.vue'
|
||||
|
||||
// Props / Emits
|
||||
|
||||
const emit = defineEmits<{
|
||||
'open-manager': [pack: MissingNodePack],
|
||||
'locate': [pack: MissingNodePack],
|
||||
'log': [msg: string]
|
||||
}>()
|
||||
|
||||
// Mock Data
|
||||
|
||||
const MOCK_MISSING_PACKS: MissingNodePack[] = [
|
||||
{
|
||||
id: 'pack-1',
|
||||
displayName: 'MeshGraphormerDepthMapPreprocessor_for_SEGS //Inspire',
|
||||
packId: 'comfyui-inspire-pack',
|
||||
description: 'Inspire Pack provides various creative and utility nodes for ComfyUI workflows.'
|
||||
},
|
||||
{
|
||||
id: 'pack-2',
|
||||
displayName: 'TilePreprocessor_Provider_for_SEGS',
|
||||
packId: 'comfyui-controlnet-aux',
|
||||
description: 'Auxiliary preprocessors for ControlNet including tile, depth, and pose processors.'
|
||||
},
|
||||
{
|
||||
id: 'pack-3',
|
||||
displayName: 'WD14Tagger | pysssss',
|
||||
packId: 'comfyui-wdv14-tagger',
|
||||
description: 'Automatic image tagging using WD14 model from pysssss.'
|
||||
},
|
||||
{
|
||||
id: 'pack-4',
|
||||
displayName: 'CR Simple Image Compare',
|
||||
packId: 'comfyui-crystools',
|
||||
description: 'Crystal Tools suite including image comparison and utility nodes.'
|
||||
},
|
||||
{
|
||||
id: 'pack-5',
|
||||
displayName: 'FaceDetailer | impact',
|
||||
packId: 'comfyui-impact-pack',
|
||||
description: 'Impact Pack provides face detailing, masking, and segmentation utilities.'
|
||||
}
|
||||
]
|
||||
|
||||
// State
|
||||
|
||||
const isSectionCollapsed = ref(false)
|
||||
const installStates = ref<Record<string, 'idle' | 'installing' | 'error'>>({})
|
||||
const hasSuccessfulInstall = ref(false)
|
||||
|
||||
// Helpers
|
||||
|
||||
function getInstallState(packId: string) {
|
||||
return installStates.value[packId] ?? 'idle'
|
||||
}
|
||||
|
||||
function getElementStyle(el: HTMLElement) {
|
||||
return {
|
||||
height: el.style.height,
|
||||
overflow: el.style.overflow,
|
||||
paddingTop: el.style.paddingTop,
|
||||
paddingBottom: el.style.paddingBottom,
|
||||
marginTop: el.style.marginTop,
|
||||
marginBottom: el.style.marginBottom
|
||||
}
|
||||
}
|
||||
|
||||
// Actions
|
||||
|
||||
function onInstall(pack: MissingNodePack) {
|
||||
if (getInstallState(pack.id) !== 'idle') return
|
||||
installStates.value[pack.id] = 'installing'
|
||||
emit('log', `⤵ Installing: "${pack.packId}"`)
|
||||
|
||||
const isErrorPack = pack.id === 'pack-2'
|
||||
const delay = isErrorPack ? 2000 : 3000
|
||||
|
||||
setTimeout(() => {
|
||||
if (isErrorPack) {
|
||||
installStates.value[pack.id] = 'error'
|
||||
emit('log', `⚠️ Install failed: "${pack.packId}"`)
|
||||
} else {
|
||||
installStates.value[pack.id] = 'idle'
|
||||
hasSuccessfulInstall.value = true
|
||||
emit('log', `✓ Installed: "${pack.packId}"`)
|
||||
}
|
||||
}, delay)
|
||||
}
|
||||
|
||||
function onInstallAll() {
|
||||
const idlePacks = MOCK_MISSING_PACKS.filter(p => getInstallState(p.id) === 'idle')
|
||||
if (!idlePacks.length) {
|
||||
emit('log', 'No packs to install')
|
||||
return
|
||||
}
|
||||
emit('log', `Install All → Starting sequential install of ${idlePacks.length} pack(s)`)
|
||||
idlePacks.forEach((pack, i) => {
|
||||
setTimeout(() => onInstall(pack), i * 1000)
|
||||
})
|
||||
}
|
||||
|
||||
function resetAll() {
|
||||
installStates.value = {}
|
||||
hasSuccessfulInstall.value = false
|
||||
emit('log', '🔄 Reboot Server')
|
||||
}
|
||||
|
||||
// Transitions
|
||||
|
||||
const DURATION = 150
|
||||
|
||||
function enterTransition(element: Element, done: () => void) {
|
||||
const el = element as HTMLElement
|
||||
const init = getElementStyle(el)
|
||||
const { width } = getComputedStyle(el)
|
||||
el.style.width = width
|
||||
el.style.position = 'absolute'
|
||||
el.style.visibility = 'hidden'
|
||||
el.style.height = ''
|
||||
const { height } = getComputedStyle(el)
|
||||
el.style.position = ''
|
||||
el.style.visibility = ''
|
||||
el.style.height = '0px'
|
||||
el.style.overflow = 'hidden'
|
||||
const anim = el.animate(
|
||||
[{ height: '0px', opacity: 0 }, { height, opacity: 1 }],
|
||||
{ duration: DURATION, easing: 'ease-in-out' }
|
||||
)
|
||||
el.style.height = init.height
|
||||
anim.onfinish = () => { el.style.overflow = init.overflow; done() }
|
||||
}
|
||||
|
||||
function leaveTransition(element: Element, done: () => void) {
|
||||
const el = element as HTMLElement
|
||||
const init = getElementStyle(el)
|
||||
const { height } = getComputedStyle(el)
|
||||
el.style.height = height
|
||||
el.style.overflow = 'hidden'
|
||||
const anim = el.animate(
|
||||
[{ height, opacity: 1 }, { height: '0px', opacity: 0 }],
|
||||
{ duration: DURATION, easing: 'ease-in-out' }
|
||||
)
|
||||
el.style.height = init.height
|
||||
anim.onfinish = () => { el.style.overflow = init.overflow; done() }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="w-[320px] h-full shrink-0 flex flex-col gap-4 py-1 bg-[#171718] border-l border-[#494a50] shadow-[1px_1px_8px_0px_rgba(0,0,0,0.4)]"
|
||||
>
|
||||
<!-- ① Nav Item: "Workflow Overview" + panel-right button -->
|
||||
<div class="flex h-12 items-center overflow-hidden py-2 border-b border-[#55565e] shrink-0">
|
||||
<div class="flex flex-1 gap-2 items-center min-w-0 pl-4 pr-3">
|
||||
<p class="flex-1 min-w-0 font-bold text-sm text-white whitespace-pre-wrap">
|
||||
Workflow Overview
|
||||
</p>
|
||||
<div class="flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg bg-[#262729] shrink-0 cursor-pointer hover:bg-[#303133]">
|
||||
<i class="icon-[lucide--panel-right] size-4 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ② Node Header: tab bar + search -->
|
||||
<div class="flex flex-col gap-3 items-start px-4 shrink-0">
|
||||
<!-- Tab bar -->
|
||||
<div class="flex gap-2 items-center w-full overflow-x-auto no-scrollbar">
|
||||
<!-- "Error" tab (active) -->
|
||||
<div class="flex gap-1 h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0 bg-[#262729]">
|
||||
<span class="text-sm text-white">Error</span>
|
||||
<div class="flex items-center justify-center size-6 shrink-0">
|
||||
<i class="icon-[lucide--octagon-alert] size-4 text-[#e04e48]" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- Other tabs -->
|
||||
<div class="flex h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0">
|
||||
<span class="text-sm text-[#8a8a8a]">Inputs</span>
|
||||
</div>
|
||||
<div class="flex h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0">
|
||||
<span class="text-sm text-[#8a8a8a]">Nodes</span>
|
||||
</div>
|
||||
<div class="flex h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0">
|
||||
<span class="text-sm whitespace-nowrap text-[#8a8a8a]">Global settings</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search bar -->
|
||||
<div class="flex gap-2 h-8 items-center min-h-[32px] px-2 py-1.5 rounded-lg bg-[#262729] w-full">
|
||||
<i class="icon-[lucide--search] size-4 text-[#8a8a8a] shrink-0" />
|
||||
<p class="flex-1 text-xs text-[#8a8a8a] truncate leading-normal">
|
||||
Search for nodes or inputs
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="h-px bg-[#55565e] shrink-0 w-full" />
|
||||
|
||||
<!-- ③ Content: Nodes (Missing Node Packs) -->
|
||||
<div class="flex-1 overflow-y-auto min-w-0">
|
||||
<div class="px-4">
|
||||
<!-- Section Header -->
|
||||
<div class="flex h-8 items-center justify-center w-full">
|
||||
<div class="flex items-center justify-center size-6 shrink-0">
|
||||
<i class="icon-[lucide--octagon-alert] size-4 text-[#e04e48]" />
|
||||
</div>
|
||||
<p class="flex-1 min-w-0 text-sm text-[#e04e48] whitespace-pre-wrap">Missing Node Packs</p>
|
||||
<div
|
||||
class="flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg bg-[#262729] shrink-0 cursor-pointer hover:bg-[#303133]"
|
||||
@click="onInstallAll"
|
||||
>
|
||||
<span class="text-sm text-white">Install All</span>
|
||||
</div>
|
||||
<div
|
||||
class="group flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg shrink-0 cursor-pointer"
|
||||
@click="isSectionCollapsed = !isSectionCollapsed"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--chevron-up] size-4 text-[#8a8a8a] group-hover:text-white transition-all"
|
||||
:class="isSectionCollapsed ? '-rotate-180' : ''"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-2" />
|
||||
|
||||
<div class="-mx-4 border-b border-[#55565e]">
|
||||
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
|
||||
<div v-if="!isSectionCollapsed" class="px-4 pb-2">
|
||||
<div v-for="pack in MOCK_MISSING_PACKS" :key="pack.id" class="flex flex-col w-full group/card mb-1">
|
||||
<!-- Widget Header -->
|
||||
<div class="flex h-8 items-center w-full">
|
||||
<p class="flex-1 min-w-0 text-sm text-white overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{{ pack.displayName }}
|
||||
</p>
|
||||
<div
|
||||
class="flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg shrink-0 cursor-pointer hover:bg-[#262729]"
|
||||
@click="emit('open-manager', pack)"
|
||||
>
|
||||
<i class="icon-[lucide--info] size-4 text-white" />
|
||||
</div>
|
||||
<div
|
||||
class="flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg shrink-0 cursor-pointer hover:bg-[#262729]"
|
||||
@click="emit('locate', pack)"
|
||||
>
|
||||
<i class="icon-[lucide--locate] size-4 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Install button -->
|
||||
<div class="flex items-start w-full pt-1 pb-2">
|
||||
<div
|
||||
class="flex flex-1 h-8 items-center justify-center overflow-hidden p-2 rounded-lg min-w-0 transition-colors select-none"
|
||||
:class="[
|
||||
getInstallState(pack.id) === 'idle'
|
||||
? 'bg-[#262729] cursor-pointer hover:bg-[#303133]'
|
||||
: getInstallState(pack.id) === 'error'
|
||||
? 'bg-[#3a2020] cursor-pointer hover:bg-[#4a2a2a]'
|
||||
: 'bg-[#262729] opacity-60 cursor-default'
|
||||
]"
|
||||
@click="onInstall(pack)"
|
||||
>
|
||||
<svg
|
||||
v-if="getInstallState(pack.id) === 'installing'"
|
||||
class="animate-spin size-4 text-white shrink-0"
|
||||
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
||||
>
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
<i
|
||||
v-else-if="getInstallState(pack.id) === 'error'"
|
||||
class="icon-[lucide--triangle-alert] size-4 text-[#f59e0b] shrink-0"
|
||||
/>
|
||||
<i v-else class="icon-[lucide--download] size-4 text-white shrink-0" />
|
||||
<span class="text-sm text-white ml-1.5 shrink-0">
|
||||
{{ getInstallState(pack.id) === 'installing' ? 'Installing...' : 'Install node pack' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<Transition
|
||||
enter-active-class="transition-opacity duration-300 ease-out"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition-opacity duration-200 ease-in"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div v-if="hasSuccessfulInstall && !isSectionCollapsed" class="px-4 pb-4 pt-1">
|
||||
<Button
|
||||
variant="primary"
|
||||
class="w-full h-9 justify-center gap-2 text-sm font-semibold"
|
||||
@click="resetAll"
|
||||
>
|
||||
<i class="icon-[lucide--refresh-cw] size-4" />
|
||||
Apply Changes
|
||||
</Button>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="h-px bg-[#55565e] shrink-0 w-full" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,96 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import MockManagerDialog from './MockManagerDialog.vue'
|
||||
import MockOSSMissingNodePack from './MockOSSMissingNodePack.vue'
|
||||
import type { MissingNodePack } from './MockManagerDialog.vue'
|
||||
|
||||
// State
|
||||
|
||||
const isManagerOpen = ref(false)
|
||||
const selectedPack = ref<MissingNodePack | null>(null)
|
||||
const statusLog = ref<string>('')
|
||||
|
||||
// Actions
|
||||
|
||||
function log(msg: string) {
|
||||
statusLog.value = msg
|
||||
}
|
||||
|
||||
function openManager(pack: MissingNodePack) {
|
||||
selectedPack.value = pack
|
||||
isManagerOpen.value = true
|
||||
log(`ⓘ Opening Manager: "${pack.displayName.split('//')[0].trim()}"`)
|
||||
}
|
||||
|
||||
function closeManager() {
|
||||
isManagerOpen.value = false
|
||||
selectedPack.value = null
|
||||
log('Manager closed')
|
||||
}
|
||||
|
||||
function onLocate(pack: MissingNodePack) {
|
||||
log(`◎ Locating on canvas: "${pack.displayName.split('//')[0].trim()}"`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- ComfyUI layout simulation: canvas + right side panel + manager overlay -->
|
||||
<div class="relative w-full h-full flex overflow-hidden bg-[#0d0e10]">
|
||||
|
||||
<!-- Canvas area -->
|
||||
<div class="flex-1 min-w-0 relative flex flex-col items-center justify-center gap-4 overflow-hidden">
|
||||
<!-- Grid background -->
|
||||
<div
|
||||
class="absolute inset-0 opacity-15"
|
||||
style="background-image: repeating-linear-gradient(#444 0 1px, transparent 1px 100%), repeating-linear-gradient(90deg, #444 0 1px, transparent 1px 100%); background-size: 32px 32px;"
|
||||
/>
|
||||
<div class="relative z-10 flex flex-col items-center gap-4">
|
||||
<div class="text-[#8a8a8a]/30 text-sm select-none">ComfyUI Canvas</div>
|
||||
<div class="flex gap-5 flex-wrap justify-center px-8">
|
||||
<div v-for="i in 4" :key="i" class="w-[160px] h-[80px] rounded-lg border border-[#3a3b3d] bg-[#1a1b1d]/80 flex flex-col p-3 gap-2">
|
||||
<div class="h-3 w-24 rounded bg-[#2a2b2d]" />
|
||||
<div class="h-2 w-16 rounded bg-[#2a2b2d]" />
|
||||
<div class="h-2 w-20 rounded bg-[#2a2b2d]" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-center min-h-[36px]">
|
||||
<div
|
||||
v-if="statusLog"
|
||||
class="px-4 py-1.5 rounded-lg text-xs text-center bg-blue-950/70 border border-blue-500/40 text-blue-300"
|
||||
>{{ statusLog }}</div>
|
||||
<div v-else class="px-4 py-1.5 text-xs text-[#8a8a8a]/30 border border-dashed border-[#2a2b2d] rounded-lg">
|
||||
Click the buttons in the right-side error tab
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: MockOSSMissingNodePack (320px) -->
|
||||
<MockOSSMissingNodePack
|
||||
@open-manager="openManager"
|
||||
@locate="onLocate"
|
||||
@log="log"
|
||||
/>
|
||||
|
||||
<!-- Manager dialog overlay (full screen including right panel) -->
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-200 ease-out"
|
||||
enter-from-class="opacity-0 scale-95"
|
||||
enter-to-class="opacity-100 scale-100"
|
||||
leave-active-class="transition-all duration-150 ease-in"
|
||||
leave-from-class="opacity-100 scale-100"
|
||||
leave-to-class="opacity-0 scale-95"
|
||||
>
|
||||
<div
|
||||
v-if="isManagerOpen"
|
||||
class="absolute inset-0 z-20 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
@click.self="closeManager"
|
||||
>
|
||||
<div class="relative h-[80vh] w-[90vw] max-w-[1400px]">
|
||||
<MockManagerDialog :selected-pack="selectedPack" @close="closeManager" />
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
21
src/components/rightSidePanel/errors/types.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export interface ErrorItem {
|
||||
message: string
|
||||
details?: string
|
||||
isRuntimeError?: boolean
|
||||
}
|
||||
|
||||
export interface ErrorCardData {
|
||||
id: string
|
||||
title: string
|
||||
nodeId?: string
|
||||
nodeTitle?: string
|
||||
graphNodeId?: string
|
||||
isSubgraphNode?: boolean
|
||||
errors: ErrorItem[]
|
||||
}
|
||||
|
||||
export interface ErrorGroup {
|
||||
title: string
|
||||
cards: ErrorCardData[]
|
||||
priority: number
|
||||
}
|
||||
405
src/components/rightSidePanel/errors/useErrorGroups.ts
Normal file
@@ -0,0 +1,405 @@
|
||||
import { computed, reactive, watch } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import Fuse from 'fuse.js'
|
||||
import type { IFuseOptions } from 'fuse.js'
|
||||
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
|
||||
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
|
||||
import { isLGraphNode } from '@/utils/litegraphUtil'
|
||||
import { isGroupNode } from '@/utils/executableGroupNodeDto'
|
||||
import { st } from '@/i18n'
|
||||
import type { ErrorCardData, ErrorGroup, ErrorItem } from './types'
|
||||
import {
|
||||
isNodeExecutionId,
|
||||
parseNodeExecutionId
|
||||
} from '@/types/nodeIdentification'
|
||||
|
||||
const PROMPT_CARD_ID = '__prompt__'
|
||||
const SINGLE_GROUP_KEY = '__single__'
|
||||
const KNOWN_PROMPT_ERROR_TYPES = new Set(['prompt_no_outputs', 'no_prompt'])
|
||||
|
||||
interface GroupEntry {
|
||||
priority: number
|
||||
cards: Map<string, ErrorCardData>
|
||||
}
|
||||
|
||||
interface ErrorSearchItem {
|
||||
groupIndex: number
|
||||
cardIndex: number
|
||||
searchableNodeId: string
|
||||
searchableNodeTitle: string
|
||||
searchableMessage: string
|
||||
searchableDetails: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve display info for a node by its execution ID.
|
||||
* For group node internals, resolves the parent group node's title instead.
|
||||
*/
|
||||
function resolveNodeInfo(nodeId: string) {
|
||||
const graphNode = getNodeByExecutionId(app.rootGraph, nodeId)
|
||||
|
||||
const parts = parseNodeExecutionId(nodeId)
|
||||
const parentId = parts && parts.length > 1 ? String(parts[0]) : null
|
||||
const parentNode = parentId
|
||||
? app.rootGraph.getNodeById(Number(parentId))
|
||||
: null
|
||||
const isParentGroupNode = parentNode ? isGroupNode(parentNode) : false
|
||||
|
||||
return {
|
||||
title: isParentGroupNode
|
||||
? parentNode?.title || ''
|
||||
: resolveNodeDisplayName(graphNode, {
|
||||
emptyLabel: '',
|
||||
untitledLabel: '',
|
||||
st
|
||||
}),
|
||||
graphNodeId: graphNode ? String(graphNode.id) : undefined,
|
||||
isParentGroupNode
|
||||
}
|
||||
}
|
||||
|
||||
function getOrCreateGroup(
|
||||
groupsMap: Map<string, GroupEntry>,
|
||||
title: string,
|
||||
priority = 1
|
||||
): Map<string, ErrorCardData> {
|
||||
let entry = groupsMap.get(title)
|
||||
if (!entry) {
|
||||
entry = { priority, cards: new Map() }
|
||||
groupsMap.set(title, entry)
|
||||
}
|
||||
return entry.cards
|
||||
}
|
||||
|
||||
function createErrorCard(
|
||||
nodeId: string,
|
||||
classType: string,
|
||||
idPrefix: string
|
||||
): ErrorCardData {
|
||||
const nodeInfo = resolveNodeInfo(nodeId)
|
||||
return {
|
||||
id: `${idPrefix}-${nodeId}`,
|
||||
title: classType,
|
||||
nodeId,
|
||||
nodeTitle: nodeInfo.title,
|
||||
graphNodeId: nodeInfo.graphNodeId,
|
||||
isSubgraphNode: isNodeExecutionId(nodeId) && !nodeInfo.isParentGroupNode,
|
||||
errors: []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* In single-node mode, regroup cards by error message instead of class_type.
|
||||
* This lets the user see "what kinds of errors this node has" at a glance.
|
||||
*/
|
||||
function regroupByErrorMessage(
|
||||
groupsMap: Map<string, GroupEntry>
|
||||
): Map<string, GroupEntry> {
|
||||
const allCards = Array.from(groupsMap.values()).flatMap((g) =>
|
||||
Array.from(g.cards.values())
|
||||
)
|
||||
|
||||
const cardErrorPairs = allCards.flatMap((card) =>
|
||||
card.errors.map((error) => ({ card, error }))
|
||||
)
|
||||
|
||||
const messageMap = new Map<string, GroupEntry>()
|
||||
for (const { card, error } of cardErrorPairs) {
|
||||
addCardErrorToGroup(messageMap, card, error)
|
||||
}
|
||||
|
||||
return messageMap
|
||||
}
|
||||
|
||||
function addCardErrorToGroup(
|
||||
messageMap: Map<string, GroupEntry>,
|
||||
card: ErrorCardData,
|
||||
error: ErrorItem
|
||||
) {
|
||||
const group = getOrCreateGroup(messageMap, error.message, 1)
|
||||
if (!group.has(card.id)) {
|
||||
group.set(card.id, { ...card, errors: [] })
|
||||
}
|
||||
group.get(card.id)?.errors.push(error)
|
||||
}
|
||||
|
||||
function toSortedGroups(groupsMap: Map<string, GroupEntry>): ErrorGroup[] {
|
||||
return Array.from(groupsMap.entries())
|
||||
.map(([title, groupData]) => ({
|
||||
title,
|
||||
cards: Array.from(groupData.cards.values()),
|
||||
priority: groupData.priority
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if (a.priority !== b.priority) return a.priority - b.priority
|
||||
return a.title.localeCompare(b.title)
|
||||
})
|
||||
}
|
||||
|
||||
function searchErrorGroups(groups: ErrorGroup[], query: string) {
|
||||
if (!query) return groups
|
||||
|
||||
const searchableList: ErrorSearchItem[] = []
|
||||
for (let gi = 0; gi < groups.length; gi++) {
|
||||
const group = groups[gi]
|
||||
for (let ci = 0; ci < group.cards.length; ci++) {
|
||||
const card = group.cards[ci]
|
||||
searchableList.push({
|
||||
groupIndex: gi,
|
||||
cardIndex: ci,
|
||||
searchableNodeId: card.nodeId ?? '',
|
||||
searchableNodeTitle: card.nodeTitle ?? '',
|
||||
searchableMessage: card.errors.map((e) => e.message).join(' '),
|
||||
searchableDetails: card.errors.map((e) => e.details ?? '').join(' ')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const fuseOptions: IFuseOptions<ErrorSearchItem> = {
|
||||
keys: [
|
||||
{ name: 'searchableNodeId', weight: 0.3 },
|
||||
{ name: 'searchableNodeTitle', weight: 0.3 },
|
||||
{ name: 'searchableMessage', weight: 0.3 },
|
||||
{ name: 'searchableDetails', weight: 0.1 }
|
||||
],
|
||||
threshold: 0.3
|
||||
}
|
||||
|
||||
const fuse = new Fuse(searchableList, fuseOptions)
|
||||
const results = fuse.search(query)
|
||||
|
||||
const matchedCardKeys = new Set(
|
||||
results.map((r) => `${r.item.groupIndex}:${r.item.cardIndex}`)
|
||||
)
|
||||
|
||||
return groups
|
||||
.map((group, gi) => ({
|
||||
...group,
|
||||
cards: group.cards.filter((_, ci) => matchedCardKeys.has(`${gi}:${ci}`))
|
||||
}))
|
||||
.filter((group) => group.cards.length > 0)
|
||||
}
|
||||
|
||||
export function useErrorGroups(
|
||||
searchQuery: Ref<string>,
|
||||
t: (key: string) => string
|
||||
) {
|
||||
const executionStore = useExecutionStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const collapseState = reactive<Record<string, boolean>>({})
|
||||
|
||||
const selectedNodeInfo = computed(() => {
|
||||
const items = canvasStore.selectedItems
|
||||
const nodeIds = new Set<string>()
|
||||
const containerIds = new Set<string>()
|
||||
|
||||
for (const item of items) {
|
||||
if (!isLGraphNode(item)) continue
|
||||
nodeIds.add(String(item.id))
|
||||
if (item instanceof SubgraphNode || isGroupNode(item)) {
|
||||
containerIds.add(String(item.id))
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
nodeIds: nodeIds.size > 0 ? nodeIds : null,
|
||||
containerIds
|
||||
}
|
||||
})
|
||||
|
||||
const isSingleNodeSelected = computed(
|
||||
() =>
|
||||
selectedNodeInfo.value.nodeIds?.size === 1 &&
|
||||
selectedNodeInfo.value.containerIds.size === 0
|
||||
)
|
||||
|
||||
const errorNodeCache = computed(() => {
|
||||
const map = new Map<string, LGraphNode>()
|
||||
for (const execId of executionStore.allErrorExecutionIds) {
|
||||
const node = getNodeByExecutionId(app.rootGraph, execId)
|
||||
if (node) map.set(execId, node)
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
function isErrorInSelection(executionNodeId: string): boolean {
|
||||
const nodeIds = selectedNodeInfo.value.nodeIds
|
||||
if (!nodeIds) return true
|
||||
|
||||
const graphNode = errorNodeCache.value.get(executionNodeId)
|
||||
if (graphNode && nodeIds.has(String(graphNode.id))) return true
|
||||
|
||||
for (const containerId of selectedNodeInfo.value.containerIds) {
|
||||
if (executionNodeId.startsWith(`${containerId}:`)) return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function addNodeErrorToGroup(
|
||||
groupsMap: Map<string, GroupEntry>,
|
||||
nodeId: string,
|
||||
classType: string,
|
||||
idPrefix: string,
|
||||
errors: ErrorItem[],
|
||||
filterBySelection = false
|
||||
) {
|
||||
if (filterBySelection && !isErrorInSelection(nodeId)) return
|
||||
const groupKey = isSingleNodeSelected.value ? SINGLE_GROUP_KEY : classType
|
||||
const cards = getOrCreateGroup(groupsMap, groupKey, 1)
|
||||
if (!cards.has(nodeId)) {
|
||||
cards.set(nodeId, createErrorCard(nodeId, classType, idPrefix))
|
||||
}
|
||||
cards.get(nodeId)?.errors.push(...errors)
|
||||
}
|
||||
|
||||
function processPromptError(groupsMap: Map<string, GroupEntry>) {
|
||||
if (selectedNodeInfo.value.nodeIds || !executionStore.lastPromptError)
|
||||
return
|
||||
|
||||
const error = executionStore.lastPromptError
|
||||
const groupTitle = error.message
|
||||
const cards = getOrCreateGroup(groupsMap, groupTitle, 0)
|
||||
const isKnown = KNOWN_PROMPT_ERROR_TYPES.has(error.type)
|
||||
|
||||
// Prompt errors are not tied to a node, so they bypass addNodeErrorToGroup.
|
||||
cards.set(PROMPT_CARD_ID, {
|
||||
id: PROMPT_CARD_ID,
|
||||
title: groupTitle,
|
||||
errors: [
|
||||
{
|
||||
message: isKnown
|
||||
? t(`rightSidePanel.promptErrors.${error.type}.desc`)
|
||||
: error.message
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
function processNodeErrors(
|
||||
groupsMap: Map<string, GroupEntry>,
|
||||
filterBySelection = false
|
||||
) {
|
||||
if (!executionStore.lastNodeErrors) return
|
||||
|
||||
for (const [nodeId, nodeError] of Object.entries(
|
||||
executionStore.lastNodeErrors
|
||||
)) {
|
||||
addNodeErrorToGroup(
|
||||
groupsMap,
|
||||
nodeId,
|
||||
nodeError.class_type,
|
||||
'node',
|
||||
nodeError.errors.map((e) => ({
|
||||
message: e.message,
|
||||
details: e.details ?? undefined
|
||||
})),
|
||||
filterBySelection
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function processExecutionError(
|
||||
groupsMap: Map<string, GroupEntry>,
|
||||
filterBySelection = false
|
||||
) {
|
||||
if (!executionStore.lastExecutionError) return
|
||||
|
||||
const e = executionStore.lastExecutionError
|
||||
addNodeErrorToGroup(
|
||||
groupsMap,
|
||||
String(e.node_id),
|
||||
e.node_type,
|
||||
'exec',
|
||||
[
|
||||
{
|
||||
message: `${e.exception_type}: ${e.exception_message}`,
|
||||
details: e.traceback.join('\n'),
|
||||
isRuntimeError: true
|
||||
}
|
||||
],
|
||||
filterBySelection
|
||||
)
|
||||
}
|
||||
|
||||
const allErrorGroups = computed<ErrorGroup[]>(() => {
|
||||
const groupsMap = new Map<string, GroupEntry>()
|
||||
|
||||
processPromptError(groupsMap)
|
||||
processNodeErrors(groupsMap)
|
||||
processExecutionError(groupsMap)
|
||||
|
||||
return toSortedGroups(groupsMap)
|
||||
})
|
||||
|
||||
const tabErrorGroups = computed<ErrorGroup[]>(() => {
|
||||
const groupsMap = new Map<string, GroupEntry>()
|
||||
|
||||
processPromptError(groupsMap)
|
||||
processNodeErrors(groupsMap, true)
|
||||
processExecutionError(groupsMap, true)
|
||||
|
||||
return isSingleNodeSelected.value
|
||||
? toSortedGroups(regroupByErrorMessage(groupsMap))
|
||||
: toSortedGroups(groupsMap)
|
||||
})
|
||||
|
||||
const filteredGroups = computed<ErrorGroup[]>(() => {
|
||||
const query = searchQuery.value.trim()
|
||||
return searchErrorGroups(tabErrorGroups.value, query)
|
||||
})
|
||||
|
||||
const groupedErrorMessages = computed<string[]>(() => {
|
||||
const messages = new Set<string>()
|
||||
for (const group of allErrorGroups.value) {
|
||||
for (const card of group.cards) {
|
||||
for (const err of card.errors) {
|
||||
messages.add(err.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(messages)
|
||||
})
|
||||
|
||||
/**
|
||||
* When an external trigger (e.g. "See Error" button in SectionWidgets)
|
||||
* sets focusedErrorNodeId, expand only the group containing the target
|
||||
* node and collapse all others so the user sees the relevant errors
|
||||
* immediately.
|
||||
*/
|
||||
function expandFocusedErrorGroup(graphNodeId: string | null) {
|
||||
if (!graphNodeId) return
|
||||
const prefix = `${graphNodeId}:`
|
||||
for (const group of allErrorGroups.value) {
|
||||
const hasMatch = group.cards.some(
|
||||
(card) =>
|
||||
card.graphNodeId === graphNodeId ||
|
||||
(card.nodeId?.startsWith(prefix) ?? false)
|
||||
)
|
||||
collapseState[group.title] = !hasMatch
|
||||
}
|
||||
rightSidePanelStore.focusedErrorNodeId = null
|
||||
}
|
||||
|
||||
watch(() => rightSidePanelStore.focusedErrorNodeId, expandFocusedErrorGroup, {
|
||||
immediate: true
|
||||
})
|
||||
|
||||
return {
|
||||
allErrorGroups,
|
||||
tabErrorGroups,
|
||||
filteredGroups,
|
||||
collapseState,
|
||||
isSingleNodeSelected,
|
||||
errorNodeCache,
|
||||
groupedErrorMessages
|
||||
}
|
||||
}
|
||||
@@ -5,13 +5,18 @@ import { useI18n } from 'vue-i18n'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
|
||||
import { parseProxyWidgets } from '@/core/schemas/proxyWidget'
|
||||
import type {
|
||||
LGraphGroup,
|
||||
LGraphNode,
|
||||
SubgraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { isGroupNode } from '@/utils/executableGroupNodeDto'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { getWidgetDefaultValue } from '@/utils/widgetUtil'
|
||||
import type { WidgetValue } from '@/utils/widgetUtil'
|
||||
|
||||
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
|
||||
import { HideLayoutFieldKey } from '@/types/widgetTypes'
|
||||
@@ -57,6 +62,9 @@ watchEffect(() => (widgets.value = widgetsProp))
|
||||
provide(HideLayoutFieldKey, true)
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const getNodeParentGroup = inject(GetNodeParentGroupKey, null)
|
||||
@@ -100,6 +108,26 @@ const targetNode = computed<LGraphNode | null>(() => {
|
||||
return allSameNode ? widgets.value[0].node : null
|
||||
})
|
||||
|
||||
const hasDirectError = computed(() => {
|
||||
if (!targetNode.value) return false
|
||||
return executionStore.activeGraphErrorNodeIds.has(String(targetNode.value.id))
|
||||
})
|
||||
|
||||
const hasContainerInternalError = computed(() => {
|
||||
if (!targetNode.value) return false
|
||||
const isContainer =
|
||||
targetNode.value instanceof SubgraphNode || isGroupNode(targetNode.value)
|
||||
if (!isContainer) return false
|
||||
|
||||
return executionStore.hasInternalErrorForNode(targetNode.value.id)
|
||||
})
|
||||
|
||||
const nodeHasError = computed(() => {
|
||||
if (!targetNode.value) return false
|
||||
if (canvasStore.selectedItems.length === 1) return false
|
||||
return hasDirectError.value || hasContainerInternalError.value
|
||||
})
|
||||
|
||||
const parentGroup = computed<LGraphGroup | null>(() => {
|
||||
if (!targetNode.value || !getNodeParentGroup) return null
|
||||
return getNodeParentGroup(targetNode.value)
|
||||
@@ -118,15 +146,38 @@ function handleLocateNode() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleWidgetValueUpdate(
|
||||
widget: IBaseWidget,
|
||||
newValue: string | number | boolean | object
|
||||
) {
|
||||
widget.value = newValue
|
||||
widget.callback?.(newValue)
|
||||
function navigateToErrorTab() {
|
||||
if (!targetNode.value) return
|
||||
if (!useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab')) return
|
||||
rightSidePanelStore.focusedErrorNodeId = String(targetNode.value.id)
|
||||
rightSidePanelStore.openPanel('errors')
|
||||
}
|
||||
|
||||
function writeWidgetValue(widget: IBaseWidget, value: WidgetValue) {
|
||||
widget.value = value
|
||||
widget.callback?.(value)
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
}
|
||||
|
||||
function handleResetAllWidgets() {
|
||||
for (const { widget, node: widgetNode } of widgetsProp) {
|
||||
const spec = nodeDefStore.getInputSpecForWidget(widgetNode, widget.name)
|
||||
const defaultValue = getWidgetDefaultValue(spec)
|
||||
if (defaultValue !== undefined) {
|
||||
writeWidgetValue(widget, defaultValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleWidgetValueUpdate(widget: IBaseWidget, newValue: WidgetValue) {
|
||||
if (newValue === undefined) return
|
||||
writeWidgetValue(widget, newValue)
|
||||
}
|
||||
|
||||
function handleWidgetReset(widget: IBaseWidget, newValue: WidgetValue) {
|
||||
writeWidgetValue(widget, newValue)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
widgetsContainer,
|
||||
rootElement
|
||||
@@ -142,9 +193,20 @@ defineExpose({
|
||||
:tooltip
|
||||
>
|
||||
<template #label>
|
||||
<div class="flex items-center gap-2 flex-1 min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2 flex-1 min-w-0">
|
||||
<span class="flex-1 flex items-center gap-2 min-w-0">
|
||||
<span class="truncate">
|
||||
<i
|
||||
v-if="nodeHasError"
|
||||
class="icon-[lucide--octagon-alert] size-4 shrink-0 text-destructive-background-hover"
|
||||
/>
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'truncate',
|
||||
nodeHasError && 'text-destructive-background-hover'
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot name="label">
|
||||
{{ displayLabel }}
|
||||
</slot>
|
||||
@@ -157,6 +219,26 @@ defineExpose({
|
||||
{{ parentGroup.title }}
|
||||
</span>
|
||||
</span>
|
||||
<Button
|
||||
v-if="nodeHasError"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="shrink-0 rounded-lg text-sm"
|
||||
@click.stop="navigateToErrorTab"
|
||||
>
|
||||
{{ t('rightSidePanel.seeError') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!isEmpty"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="subbutton shrink-0 size-8 cursor-pointer text-muted-foreground hover:text-base-foreground"
|
||||
:title="t('rightSidePanel.resetAllParameters')"
|
||||
:aria-label="t('rightSidePanel.resetAllParameters')"
|
||||
@click.stop="handleResetAllWidgets"
|
||||
>
|
||||
<i class="icon-[lucide--rotate-ccw] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="canShowLocateButton"
|
||||
variant="textonly"
|
||||
@@ -189,6 +271,7 @@ defineExpose({
|
||||
:parents="parents"
|
||||
:is-shown-on-parents="isWidgetShownOnParents(node, widget)"
|
||||
@update:widget-value="handleWidgetValueUpdate(widget, $event)"
|
||||
@reset-to-default="handleWidgetReset(widget, $event)"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
|
||||
209
src/components/rightSidePanel/parameters/WidgetActions.test.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import type { Slots } from 'vue'
|
||||
import { h } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import WidgetActions from './WidgetActions.vue'
|
||||
|
||||
const { mockGetInputSpecForWidget } = vi.hoisted(() => ({
|
||||
mockGetInputSpecForWidget: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/nodeDefStore', () => ({
|
||||
useNodeDefStore: () => ({
|
||||
getInputSpecForWidget: mockGetInputSpecForWidget
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({
|
||||
canvas: { setDirty: vi.fn() }
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workspace/favoritedWidgetsStore', () => ({
|
||||
useFavoritedWidgetsStore: () => ({
|
||||
isFavorited: vi.fn().mockReturnValue(false),
|
||||
toggleFavorite: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: () => ({
|
||||
prompt: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/components/button/MoreButton.vue', () => ({
|
||||
default: (_: unknown, { slots }: { slots: Slots }) =>
|
||||
h('div', slots.default?.({ close: () => {} }))
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: {
|
||||
rename: 'Rename',
|
||||
enterNewName: 'Enter new name'
|
||||
},
|
||||
rightSidePanel: {
|
||||
hideInput: 'Hide input',
|
||||
showInput: 'Show input',
|
||||
addFavorite: 'Favorite',
|
||||
removeFavorite: 'Unfavorite',
|
||||
resetToDefault: 'Reset to default'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe('WidgetActions', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.resetAllMocks()
|
||||
mockGetInputSpecForWidget.mockReturnValue({
|
||||
type: 'INT',
|
||||
default: 42
|
||||
})
|
||||
})
|
||||
|
||||
function createMockWidget(
|
||||
value: number = 100,
|
||||
callback?: () => void
|
||||
): IBaseWidget {
|
||||
return {
|
||||
name: 'test_widget',
|
||||
type: 'number',
|
||||
value,
|
||||
label: 'Test Widget',
|
||||
options: {},
|
||||
y: 0,
|
||||
callback
|
||||
} as IBaseWidget
|
||||
}
|
||||
|
||||
function createMockNode(): LGraphNode {
|
||||
return {
|
||||
id: 1,
|
||||
type: 'TestNode'
|
||||
} as LGraphNode
|
||||
}
|
||||
|
||||
function mountWidgetActions(widget: IBaseWidget, node: LGraphNode) {
|
||||
return mount(WidgetActions, {
|
||||
props: {
|
||||
widget,
|
||||
node,
|
||||
label: 'Test Widget'
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it('shows reset button when widget has default value', () => {
|
||||
const widget = createMockWidget()
|
||||
const node = createMockNode()
|
||||
|
||||
const wrapper = mountWidgetActions(widget, node)
|
||||
|
||||
const resetButton = wrapper
|
||||
.findAll('button')
|
||||
.find((b) => b.text().includes('Reset'))
|
||||
expect(resetButton).toBeDefined()
|
||||
})
|
||||
|
||||
it('emits resetToDefault with default value when reset button clicked', async () => {
|
||||
const widget = createMockWidget(100)
|
||||
const node = createMockNode()
|
||||
|
||||
const wrapper = mountWidgetActions(widget, node)
|
||||
|
||||
const resetButton = wrapper
|
||||
.findAll('button')
|
||||
.find((b) => b.text().includes('Reset'))
|
||||
|
||||
await resetButton?.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('resetToDefault')).toHaveLength(1)
|
||||
expect(wrapper.emitted('resetToDefault')![0]).toEqual([42])
|
||||
})
|
||||
|
||||
it('disables reset button when value equals default', () => {
|
||||
const widget = createMockWidget(42)
|
||||
const node = createMockNode()
|
||||
|
||||
const wrapper = mountWidgetActions(widget, node)
|
||||
|
||||
const resetButton = wrapper
|
||||
.findAll('button')
|
||||
.find((b) => b.text().includes('Reset'))
|
||||
|
||||
expect(resetButton?.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('does not show reset button when no default value exists', () => {
|
||||
mockGetInputSpecForWidget.mockReturnValue({
|
||||
type: 'CUSTOM'
|
||||
})
|
||||
|
||||
const widget = createMockWidget(100)
|
||||
const node = createMockNode()
|
||||
|
||||
const wrapper = mountWidgetActions(widget, node)
|
||||
|
||||
const resetButton = wrapper
|
||||
.findAll('button')
|
||||
.find((b) => b.text().includes('Reset'))
|
||||
|
||||
expect(resetButton).toBeUndefined()
|
||||
})
|
||||
|
||||
it('uses fallback default for INT type without explicit default', async () => {
|
||||
mockGetInputSpecForWidget.mockReturnValue({
|
||||
type: 'INT'
|
||||
})
|
||||
|
||||
const widget = createMockWidget(100)
|
||||
const node = createMockNode()
|
||||
|
||||
const wrapper = mountWidgetActions(widget, node)
|
||||
|
||||
const resetButton = wrapper
|
||||
.findAll('button')
|
||||
.find((b) => b.text().includes('Reset'))
|
||||
|
||||
await resetButton?.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('resetToDefault')![0]).toEqual([0])
|
||||
})
|
||||
|
||||
it('uses first option as default for combo without explicit default', async () => {
|
||||
mockGetInputSpecForWidget.mockReturnValue({
|
||||
type: 'COMBO',
|
||||
options: ['option1', 'option2', 'option3']
|
||||
})
|
||||
|
||||
const widget = createMockWidget(100)
|
||||
const node = createMockNode()
|
||||
|
||||
const wrapper = mountWidgetActions(widget, node)
|
||||
|
||||
const resetButton = wrapper
|
||||
.findAll('button')
|
||||
.find((b) => b.text().includes('Reset'))
|
||||
|
||||
await resetButton?.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('resetToDefault')![0]).toEqual(['option1'])
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { isEqual } from 'es-toolkit'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -14,7 +15,10 @@ import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
|
||||
import { getWidgetDefaultValue } from '@/utils/widgetUtil'
|
||||
import type { WidgetValue } from '@/utils/widgetUtil'
|
||||
|
||||
const {
|
||||
widget,
|
||||
@@ -28,10 +32,15 @@ const {
|
||||
isShownOnParents?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
resetToDefault: [value: WidgetValue]
|
||||
}>()
|
||||
|
||||
const label = defineModel<string>('label', { required: true })
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const favoritedWidgetsStore = useFavoritedWidgetsStore()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const dialogService = useDialogService()
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -43,6 +52,19 @@ const isFavorited = computed(() =>
|
||||
favoritedWidgetsStore.isFavorited(favoriteNode.value, widget.name)
|
||||
)
|
||||
|
||||
const inputSpec = computed(() =>
|
||||
nodeDefStore.getInputSpecForWidget(node, widget.name)
|
||||
)
|
||||
|
||||
const defaultValue = computed(() => getWidgetDefaultValue(inputSpec.value))
|
||||
|
||||
const hasDefault = computed(() => defaultValue.value !== undefined)
|
||||
|
||||
const isCurrentValueDefault = computed(() => {
|
||||
if (!hasDefault.value) return true
|
||||
return isEqual(widget.value, defaultValue.value)
|
||||
})
|
||||
|
||||
async function handleRename() {
|
||||
const newLabel = await dialogService.prompt({
|
||||
title: t('g.rename'),
|
||||
@@ -97,6 +119,11 @@ function handleToggleFavorite() {
|
||||
favoritedWidgetsStore.toggleFavorite(favoriteNode.value, widget.name)
|
||||
}
|
||||
|
||||
function handleResetToDefault() {
|
||||
if (!hasDefault.value) return
|
||||
emit('resetToDefault', defaultValue.value)
|
||||
}
|
||||
|
||||
const buttonClasses = cn([
|
||||
'border-none bg-transparent',
|
||||
'w-full flex items-center gap-2 rounded px-3 py-2 text-sm',
|
||||
@@ -162,6 +189,21 @@ const buttonClasses = cn([
|
||||
<span>{{ t('rightSidePanel.addFavorite') }}</span>
|
||||
</template>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="hasDefault"
|
||||
:class="cn(buttonClasses, isCurrentValueDefault && 'opacity-50')"
|
||||
:disabled="isCurrentValueDefault"
|
||||
@click="
|
||||
() => {
|
||||
handleResetToDefault()
|
||||
close()
|
||||
}
|
||||
"
|
||||
>
|
||||
<i class="icon-[lucide--rotate-ccw] size-4" />
|
||||
<span>{{ t('rightSidePanel.resetToDefault') }}</span>
|
||||
</button>
|
||||
</template>
|
||||
</MoreButton>
|
||||
</template>
|
||||
|
||||
@@ -20,6 +20,7 @@ import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
|
||||
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { renameWidget } from '@/utils/widgetUtil'
|
||||
import type { WidgetValue } from '@/utils/widgetUtil'
|
||||
|
||||
import WidgetActions from './WidgetActions.vue'
|
||||
|
||||
@@ -42,7 +43,8 @@ const {
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:widgetValue': [value: string | number | boolean | object]
|
||||
'update:widgetValue': [value: WidgetValue]
|
||||
resetToDefault: [value: WidgetValue]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -83,11 +85,8 @@ const favoriteNode = computed(() =>
|
||||
)
|
||||
|
||||
const widgetValue = computed({
|
||||
get: () => {
|
||||
widget.vueTrack?.()
|
||||
return widget.value
|
||||
},
|
||||
set: (newValue: string | number | boolean | object) => {
|
||||
get: () => widget.value,
|
||||
set: (newValue: WidgetValue) => {
|
||||
emit('update:widgetValue', newValue)
|
||||
}
|
||||
})
|
||||
@@ -157,6 +156,7 @@ const displayLabel = customRef((track, trigger) => {
|
||||
:node="node"
|
||||
:parents="parents"
|
||||
:is-shown-on-parents="isShownOnParents"
|
||||
@reset-to-default="emit('resetToDefault', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||