mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-06 14:10:13 -03:00
feat(widgets): add Description tab to LoraInfoWidget with dual-mode rendering support
- Add Notes/Description tab switching with tab state persistence in widget value - Lazy-load model description and version description from /lm/loras/metadata - Render CivitAI HTML descriptions inline via v-html - Auto-fetch description when LoRA selection changes while on Description tab - Fix Vue mode height containment via contain:layout size (lm-vue-node class) - Fix scroll wheel isolation: widget scroll vs canvas zoom in both render modes - Add docs/comfyui-dual-mode-widgets.md with widget rendering patterns
This commit is contained in:
@@ -1,40 +1,111 @@
|
||||
<template>
|
||||
<div class="lora-info-widget">
|
||||
<div class="lora-info-widget" :class="{ 'lm-vue-node': isVueMode }" @wheel="onWheel">
|
||||
<template v-if="loraName">
|
||||
<div class="info-field">
|
||||
<label class="info-label">Filename</label>
|
||||
<div class="lora-filename">{{ loraName }}</div>
|
||||
<!-- Tab bar -->
|
||||
<div class="lora-info-tabs">
|
||||
<label
|
||||
class="lora-info-tab"
|
||||
:class="{ active: activeTab === 'notes' }"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
v-model="activeTab"
|
||||
value="notes"
|
||||
class="lora-info-tab-input"
|
||||
/>
|
||||
<span class="lora-info-tab-label">Notes</span>
|
||||
</label>
|
||||
<label
|
||||
class="lora-info-tab"
|
||||
:class="{ active: activeTab === 'description' }"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
v-model="activeTab"
|
||||
value="description"
|
||||
class="lora-info-tab-input"
|
||||
@change="onDescriptionTabActivated"
|
||||
/>
|
||||
<span class="lora-info-tab-label">Description</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="info-field notes-field">
|
||||
<label class="info-label">Notes</label>
|
||||
<textarea
|
||||
v-model="notes"
|
||||
class="lora-notes"
|
||||
placeholder="Add notes about this LoRA..."
|
||||
:disabled="saving"
|
||||
></textarea>
|
||||
|
||||
<!-- Notes tab content -->
|
||||
<div v-show="activeTab === 'notes'" class="tab-content notes-tab">
|
||||
<div class="info-field">
|
||||
<label class="info-label">Filename</label>
|
||||
<div class="lora-filename">{{ loraName }}</div>
|
||||
</div>
|
||||
<div class="info-field notes-field">
|
||||
<label class="info-label">Notes</label>
|
||||
<textarea
|
||||
v-model="notes"
|
||||
class="lora-notes lm-wheel-scrollable"
|
||||
placeholder="Add notes about this LoRA..."
|
||||
:disabled="saving"
|
||||
></textarea>
|
||||
</div>
|
||||
<button
|
||||
class="save-btn"
|
||||
:disabled="notes === originalNotes || saving"
|
||||
@click="saveNotes"
|
||||
>
|
||||
{{ saving ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Description tab content -->
|
||||
<div v-show="activeTab === 'description'" class="tab-content description-tab lm-wheel-scrollable">
|
||||
<!-- Loading state -->
|
||||
<div v-if="descriptionLoading" class="description-state">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
<span>Loading description...</span>
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<div v-else-if="descriptionError" class="description-state error">
|
||||
<span>Failed to load description</span>
|
||||
</div>
|
||||
|
||||
<!-- Empty state (loaded but no content) -->
|
||||
<div v-else-if="!hasDescription" class="description-state placeholder">
|
||||
<span>No description available</span>
|
||||
</div>
|
||||
|
||||
<!-- Description content -->
|
||||
<div v-else class="description-content">
|
||||
<div v-if="versionDescription" class="description-section">
|
||||
<label class="info-label">About this version</label>
|
||||
<div class="description-text" v-html="versionDescription"></div>
|
||||
</div>
|
||||
<div v-if="modelDescription" class="description-section">
|
||||
<label class="info-label">Model Description</label>
|
||||
<div class="description-text" v-html="modelDescription"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="save-btn"
|
||||
:disabled="notes === originalNotes || saving"
|
||||
@click="saveNotes"
|
||||
>
|
||||
{{ saving ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
</template>
|
||||
<div v-else class="placeholder">No LoRA selected</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { onMounted, ref, computed, watch } from 'vue'
|
||||
|
||||
interface LoraInfoWidget {
|
||||
serializeValue?: () => Promise<unknown>
|
||||
value?: unknown
|
||||
onSetValue?: (v: unknown) => void
|
||||
callback?: unknown
|
||||
_setLoraInfo?: (data: { name: string; notes: string; filePath: string }) => void
|
||||
_setLoraInfo?: (data: { name: string; notes: string; filePath: string; activeTab?: string } | null) => void
|
||||
__pendingLoraInfo?: { name: string; notes: string; filePath: string; activeTab?: string } | null
|
||||
}
|
||||
|
||||
interface LoraInfoWidgetValue {
|
||||
name?: string
|
||||
notes?: string
|
||||
filePath?: string
|
||||
activeTab?: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -42,6 +113,7 @@ const props = defineProps<{
|
||||
node: { id: number }
|
||||
api: { fetchApi: (url: string, options?: RequestInit) => Promise<Response> }
|
||||
app: { extensionManager: { toast: { add: (opts: Record<string, unknown>) => void } } }
|
||||
isVueMode?: boolean
|
||||
}>()
|
||||
|
||||
const loraName = ref<string>('')
|
||||
@@ -49,6 +121,69 @@ const notes = ref<string>('')
|
||||
const originalNotes = ref<string>('')
|
||||
const filePath = ref<string>('')
|
||||
const saving = ref<boolean>(false)
|
||||
const activeTab = ref<string>('notes')
|
||||
|
||||
// Description tab state
|
||||
const versionDescription = ref<string>('')
|
||||
const modelDescription = ref<string>('')
|
||||
const descriptionLoading = ref<boolean>(false)
|
||||
const descriptionError = ref<boolean>(false)
|
||||
const descriptionLoaded = ref<boolean>(false)
|
||||
|
||||
const hasDescription = computed(() =>
|
||||
!!(versionDescription.value || modelDescription.value)
|
||||
)
|
||||
|
||||
// Reset and auto-fetch description state when the LoRA selection changes
|
||||
watch(filePath, (newPath) => {
|
||||
descriptionLoaded.value = false
|
||||
descriptionError.value = false
|
||||
versionDescription.value = ''
|
||||
modelDescription.value = ''
|
||||
if (newPath && activeTab.value === 'description') {
|
||||
fetchDescription()
|
||||
}
|
||||
})
|
||||
|
||||
function onDescriptionTabActivated() {
|
||||
if (!descriptionLoaded.value && filePath.value) {
|
||||
fetchDescription()
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchDescription() {
|
||||
if (descriptionLoading.value || !filePath.value) return
|
||||
|
||||
descriptionLoading.value = true
|
||||
descriptionError.value = false
|
||||
|
||||
try {
|
||||
const response = await props.api.fetchApi(
|
||||
`/lm/loras/metadata?file_path=${encodeURIComponent(filePath.value)}`,
|
||||
{ method: 'GET' }
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch metadata: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
if (data.success && data.metadata) {
|
||||
versionDescription.value = data.metadata.description || ''
|
||||
modelDescription.value = data.metadata.model?.description || ''
|
||||
descriptionLoaded.value = true
|
||||
} else {
|
||||
// Successful response but no metadata — treat as empty, not error
|
||||
descriptionLoaded.value = true
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[LoraInfoWidget] Failed to fetch description:', e)
|
||||
descriptionError.value = true
|
||||
// Don't set descriptionLoaded — allow retry on next tab switch
|
||||
} finally {
|
||||
descriptionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveNotes() {
|
||||
if (notes.value === originalNotes.value || saving.value) return
|
||||
@@ -91,47 +226,99 @@ async function saveNotes() {
|
||||
}
|
||||
}
|
||||
|
||||
function onWheel(event: WheelEvent) {
|
||||
const target = event.target as HTMLElement | null
|
||||
if (!target) return
|
||||
|
||||
const comfyApp = (window as unknown as { app?: { canvas?: { processMouseWheel?: (e: WheelEvent) => void } } }).app
|
||||
if (!comfyApp?.canvas?.processMouseWheel) return
|
||||
|
||||
// Always pass pinch-to-zoom to canvas
|
||||
if (event.ctrlKey) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
comfyApp.canvas.processMouseWheel(event)
|
||||
return
|
||||
}
|
||||
|
||||
// Horizontal scroll: pass to canvas
|
||||
if (Math.abs(event.deltaX) > Math.abs(event.deltaY)) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
comfyApp.canvas.processMouseWheel(event)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the target is inside a scrollable area (notes textarea or description tab)
|
||||
const scrollableEl = target.closest('.lora-notes, .description-tab') as HTMLElement | null
|
||||
if (scrollableEl) {
|
||||
const canScrollY = scrollableEl.scrollHeight > scrollableEl.clientHeight
|
||||
if (canScrollY) {
|
||||
// Let native scroll handle it, but stop propagation to prevent canvas zoom
|
||||
event.stopPropagation()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Forward to canvas for zoom
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
comfyApp.canvas.processMouseWheel(event)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Display-only widget - return null on serialization to avoid saving to workflow
|
||||
props.widget.serializeValue = async () => null
|
||||
// Persist tab state along with other widget data
|
||||
props.widget.serializeValue = async (): Promise<LoraInfoWidgetValue> => ({
|
||||
name: loraName.value,
|
||||
notes: notes.value,
|
||||
filePath: filePath.value,
|
||||
activeTab: activeTab.value,
|
||||
})
|
||||
|
||||
// Handle external value updates (e.g., loading workflow, paste)
|
||||
props.widget.onSetValue = (v: unknown) => {
|
||||
if (v && typeof v === 'object') {
|
||||
const data = v as { name?: string; notes?: string; filePath?: string }
|
||||
const data = v as LoraInfoWidgetValue
|
||||
if (data.name !== undefined) loraName.value = data.name
|
||||
if (data.notes !== undefined) {
|
||||
notes.value = data.notes
|
||||
originalNotes.value = data.notes
|
||||
}
|
||||
if (data.filePath !== undefined) filePath.value = data.filePath
|
||||
if (data.activeTab !== undefined) activeTab.value = data.activeTab
|
||||
}
|
||||
}
|
||||
|
||||
// Restore from saved value if exists (for workflow loading)
|
||||
if (props.widget.value && typeof props.widget.value === 'object') {
|
||||
const data = props.widget.value as { name?: string; notes?: string; filePath?: string }
|
||||
const data = props.widget.value as LoraInfoWidgetValue
|
||||
if (data.name !== undefined) loraName.value = data.name
|
||||
if (data.notes !== undefined) {
|
||||
notes.value = data.notes
|
||||
originalNotes.value = data.notes
|
||||
}
|
||||
if (data.filePath !== undefined) filePath.value = data.filePath
|
||||
if (data.activeTab !== undefined) activeTab.value = data.activeTab
|
||||
}
|
||||
|
||||
// Expose setLoraInfo on the widget object for external callers (e.g., lora_info.js).
|
||||
// Accepts null to clear the display (when selection is deselected).
|
||||
props.widget._setLoraInfo = (data: { name: string; notes: string; filePath: string } | null) => {
|
||||
props.widget._setLoraInfo = (data: { name: string; notes: string; filePath: string; activeTab?: string } | null) => {
|
||||
if (data) {
|
||||
loraName.value = data.name
|
||||
notes.value = data.notes
|
||||
originalNotes.value = data.notes
|
||||
filePath.value = data.filePath
|
||||
// Preserve existing activeTab unless explicitly provided
|
||||
if (data.activeTab !== undefined) {
|
||||
activeTab.value = data.activeTab
|
||||
}
|
||||
} else {
|
||||
loraName.value = ''
|
||||
notes.value = ''
|
||||
originalNotes.value = ''
|
||||
filePath.value = ''
|
||||
// Do NOT reset activeTab on deselection — user's tab preference persists
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,6 +342,86 @@ onMounted(() => {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Vue node mode: prevent content from pushing node size via ResizeObserver.
|
||||
contain:layout size tells the browser the element's intrinsic size is
|
||||
determined solely by CSS — not by descendant content. This breaks the
|
||||
feedback loop where content grows → ResizeObserver resizes → content
|
||||
reflows → repeat. Same technique used by tags_widget.js + lm_styles.css. */
|
||||
.lora-info-widget.lm-vue-node {
|
||||
contain: layout size;
|
||||
}
|
||||
|
||||
/* ── Tab bar ── */
|
||||
.lora-info-tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
margin-bottom: 10px;
|
||||
border-bottom: 1px solid var(--border-color, #444);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.lora-info-tab {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
padding: 6px 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.lora-info-tab-input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.lora-info-tab-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--fg-color, #fff);
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.lora-info-tab:hover .lora-info-tab-label {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.lora-info-tab.active .lora-info-tab-label {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.lora-info-tab.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -1px;
|
||||
left: 25%;
|
||||
right: 25%;
|
||||
height: 2px;
|
||||
background: rgba(66, 153, 225, 0.8);
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
/* ── Tab content ── */
|
||||
.tab-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.notes-tab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.description-tab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ── Info fields (shared) ── */
|
||||
.info-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -235,6 +502,87 @@ onMounted(() => {
|
||||
border-color: rgba(226, 232, 240, 0.1);
|
||||
}
|
||||
|
||||
/* ── Description states ── */
|
||||
.description-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 24px 16px;
|
||||
color: var(--fg-color, #fff);
|
||||
opacity: 0.5;
|
||||
font-size: 12px;
|
||||
min-height: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.description-state.error {
|
||||
opacity: 0.7;
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
/* ── Description content ── */
|
||||
.description-content {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.description-section {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.description-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.description-text {
|
||||
padding: 8px 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--fg-color, #fff);
|
||||
opacity: 0.85;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.description-text :deep(p) {
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.description-text :deep(p:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.description-text :deep(a) {
|
||||
color: rgba(66, 153, 225, 0.9);
|
||||
}
|
||||
|
||||
.description-text :deep(ul),
|
||||
.description-text :deep(ol) {
|
||||
padding-left: 20px;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.description-text :deep(h1),
|
||||
.description-text :deep(h2),
|
||||
.description-text :deep(h3) {
|
||||
font-size: 13px;
|
||||
margin: 10px 0 4px 0;
|
||||
font-weight: 600;
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.description-text :deep(code) {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.description-text :deep(img) {
|
||||
max-width: 100%;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* ── Placeholder (shared) ── */
|
||||
.placeholder {
|
||||
font-style: italic;
|
||||
color: rgba(226, 232, 240, 0.5);
|
||||
@@ -242,4 +590,14 @@ onMounted(() => {
|
||||
padding: 16px 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ── Spinner (Font Awesome) ── */
|
||||
.fa-spinner {
|
||||
animation: fa-spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes fa-spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
@@ -656,7 +656,7 @@ function createLoraInfoWidget(node: any) {
|
||||
|
||||
forwardMiddleMouseToCanvas(container)
|
||||
|
||||
let internalValue: { name?: string; notes?: string; filePath?: string } | undefined
|
||||
let internalValue: { name?: string; notes?: string; filePath?: string; activeTab?: string } | undefined
|
||||
|
||||
const widget = node.addDOMWidget(
|
||||
'lora_info_display',
|
||||
@@ -666,13 +666,13 @@ function createLoraInfoWidget(node: any) {
|
||||
getValue() {
|
||||
return internalValue
|
||||
},
|
||||
setValue(v: { name?: string; notes?: string; filePath?: string }) {
|
||||
setValue(v: { name?: string; notes?: string; filePath?: string; activeTab?: string }) {
|
||||
internalValue = v
|
||||
if (typeof widget.onSetValue === 'function') {
|
||||
widget.onSetValue(v)
|
||||
}
|
||||
},
|
||||
serialize: false, // Display-only widget
|
||||
serialize: true,
|
||||
getMinHeight() {
|
||||
return LORA_INFO_WIDGET_MIN_HEIGHT
|
||||
}
|
||||
@@ -684,6 +684,7 @@ function createLoraInfoWidget(node: any) {
|
||||
node,
|
||||
api,
|
||||
app,
|
||||
isVueMode: typeof LiteGraph !== 'undefined' && LiteGraph.vueNodesMode,
|
||||
})
|
||||
|
||||
vueApp.use(PrimeVue, {
|
||||
|
||||
417
vue-widgets/tests/components/LoraInfoWidget.test.ts
Normal file
417
vue-widgets/tests/components/LoraInfoWidget.test.ts
Normal file
@@ -0,0 +1,417 @@
|
||||
/**
|
||||
* Tests for LoraInfoWidget — tab switching, lazy description loading,
|
||||
* state serialization roundtrip, and activeTab persistence.
|
||||
*/
|
||||
|
||||
import { nextTick } from 'vue'
|
||||
import { shallowMount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
||||
import LoraInfoWidget from '@/components/LoraInfoWidget.vue'
|
||||
import { setupFetchMock, resetFetchMock } from '../setup'
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
function createMockFetchApi(overrides: {
|
||||
response?: unknown
|
||||
ok?: boolean
|
||||
error?: string
|
||||
} = {}) {
|
||||
const { response = { success: true, metadata: {} }, ok = true } = overrides
|
||||
return vi.fn().mockResolvedValue({
|
||||
ok,
|
||||
json: () => Promise.resolve(response),
|
||||
})
|
||||
}
|
||||
|
||||
function createMockToast() {
|
||||
return { add: vi.fn() }
|
||||
}
|
||||
|
||||
function createMockWidget(value?: unknown) {
|
||||
type PendingInfo = { name: string; notes: string; filePath: string; activeTab?: string } | null
|
||||
const widget = {
|
||||
serializeValue: (async () => null) as () => Promise<unknown>,
|
||||
value: (value ?? undefined) as unknown,
|
||||
onSetValue: undefined as unknown as ((v: unknown) => void),
|
||||
_setLoraInfo: undefined as unknown as (data: Record<string, unknown> | null) => void,
|
||||
__pendingLoraInfo: undefined as unknown as PendingInfo | undefined,
|
||||
}
|
||||
return widget
|
||||
}
|
||||
|
||||
interface MountOptions {
|
||||
initialValue?: Record<string, unknown>
|
||||
}
|
||||
|
||||
type TestWidget = ReturnType<typeof createMockWidget>
|
||||
|
||||
function mountWidget(options: MountOptions = {}) {
|
||||
const fetchApi = createMockFetchApi()
|
||||
const widget = createMockWidget(options.initialValue)
|
||||
const node = { id: 1 }
|
||||
const app = { extensionManager: { toast: createMockToast() } }
|
||||
|
||||
const wrapper = shallowMount(LoraInfoWidget, {
|
||||
props: { widget, node, api: { fetchApi }, app },
|
||||
})
|
||||
|
||||
return { wrapper, widget: widget as TestWidget, fetchApi, app }
|
||||
}
|
||||
|
||||
// ── Tests ──
|
||||
|
||||
describe('LoraInfoWidget', () => {
|
||||
beforeEach(() => {
|
||||
setupFetchMock()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetFetchMock()
|
||||
})
|
||||
|
||||
describe('initial state', () => {
|
||||
it('shows placeholder when no LoRA is selected', () => {
|
||||
const { wrapper } = mountWidget()
|
||||
expect(wrapper.text()).toContain('No LoRA selected')
|
||||
})
|
||||
|
||||
it('shows Notes tab by default when LoRA is set', async () => {
|
||||
const { wrapper, widget } = mountWidget()
|
||||
widget._setLoraInfo!({ name: 'test.safetensors', notes: '', filePath: '/path/test.safetensors' })
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('test.safetensors')
|
||||
expect(wrapper.find('.notes-tab').isVisible()).toBe(true)
|
||||
expect(wrapper.find('.description-tab').isVisible()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('tab switching', () => {
|
||||
it('switches to Description tab and back to Notes', async () => {
|
||||
const { wrapper, widget } = mountWidget()
|
||||
widget._setLoraInfo!({ name: 'test.safetensors', notes: '', filePath: '/path/test.safetensors' })
|
||||
await nextTick()
|
||||
|
||||
const tabs = wrapper.findAll('.lora-info-tab')
|
||||
|
||||
// Click Description tab
|
||||
const descriptionTab = wrapper.findAll('.lora-info-tab-input')[1]
|
||||
await descriptionTab.setValue('description')
|
||||
await nextTick()
|
||||
|
||||
expect(tabs[1].classes()).toContain('active')
|
||||
expect(wrapper.text()).toContain('No description available')
|
||||
|
||||
// Switch back to Notes
|
||||
const notesTab = wrapper.findAll('.lora-info-tab-input')[0]
|
||||
await notesTab.setValue('notes')
|
||||
await nextTick()
|
||||
|
||||
expect(tabs[0].classes()).toContain('active')
|
||||
expect(wrapper.text()).toContain('test.safetensors')
|
||||
})
|
||||
})
|
||||
|
||||
describe('description lazy loading', () => {
|
||||
it('fetches metadata when Description tab is activated', async () => {
|
||||
const fetchApi = createMockFetchApi({
|
||||
response: {
|
||||
success: true,
|
||||
metadata: {
|
||||
description: '<p>Version desc</p>',
|
||||
model: { description: '<p>Model desc</p>' },
|
||||
},
|
||||
},
|
||||
})
|
||||
const widget = createMockWidget()
|
||||
const wrapper = shallowMount(LoraInfoWidget, {
|
||||
props: {
|
||||
widget,
|
||||
node: { id: 1 },
|
||||
api: { fetchApi },
|
||||
app: { extensionManager: { toast: createMockToast() } },
|
||||
},
|
||||
})
|
||||
|
||||
widget._setLoraInfo!({ name: 'test.safetensors', notes: '', filePath: '/path/test.safetensors' })
|
||||
await nextTick()
|
||||
|
||||
// Switch to Description tab
|
||||
const descriptionTab = wrapper.findAll('.lora-info-tab-input')[1]
|
||||
await descriptionTab.setValue('description')
|
||||
await nextTick()
|
||||
await nextTick() // flush async fetch
|
||||
|
||||
expect(fetchApi).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/lm/loras/metadata'),
|
||||
expect.objectContaining({ method: 'GET' })
|
||||
)
|
||||
expect(wrapper.html()).toContain('Version desc')
|
||||
expect(wrapper.html()).toContain('Model desc')
|
||||
})
|
||||
|
||||
it('shows loading state while fetching', async () => {
|
||||
// Use a never-resolving promise to simulate loading
|
||||
const fetchApi = vi.fn().mockReturnValue(new Promise(() => {}))
|
||||
const widget = createMockWidget()
|
||||
const wrapper = shallowMount(LoraInfoWidget, {
|
||||
props: {
|
||||
widget,
|
||||
node: { id: 1 },
|
||||
api: { fetchApi },
|
||||
app: { extensionManager: { toast: createMockToast() } },
|
||||
},
|
||||
})
|
||||
|
||||
widget._setLoraInfo!({ name: 'test.safetensors', notes: '', filePath: '/path/test.safetensors' })
|
||||
await nextTick()
|
||||
|
||||
const descriptionTab = wrapper.findAll('.lora-info-tab-input')[1]
|
||||
await descriptionTab.setValue('description')
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('Loading description')
|
||||
})
|
||||
|
||||
it('shows error state when fetch fails', async () => {
|
||||
const fetchApi = vi.fn().mockRejectedValue(new Error('Network error'))
|
||||
const widget = createMockWidget()
|
||||
const wrapper = shallowMount(LoraInfoWidget, {
|
||||
props: {
|
||||
widget,
|
||||
node: { id: 1 },
|
||||
api: { fetchApi },
|
||||
app: { extensionManager: { toast: createMockToast() } },
|
||||
},
|
||||
})
|
||||
|
||||
widget._setLoraInfo!({ name: 'test.safetensors', notes: '', filePath: '/path/test.safetensors' })
|
||||
await nextTick()
|
||||
|
||||
const descriptionTab = wrapper.findAll('.lora-info-tab-input')[1]
|
||||
await descriptionTab.setValue('description')
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('Failed to load description')
|
||||
})
|
||||
|
||||
it('shows empty state when metadata has no descriptions', async () => {
|
||||
const fetchApi = createMockFetchApi({
|
||||
response: {
|
||||
success: true,
|
||||
metadata: {
|
||||
description: '',
|
||||
model: {},
|
||||
},
|
||||
},
|
||||
})
|
||||
const widget = createMockWidget()
|
||||
const wrapper = shallowMount(LoraInfoWidget, {
|
||||
props: {
|
||||
widget,
|
||||
node: { id: 1 },
|
||||
api: { fetchApi },
|
||||
app: { extensionManager: { toast: createMockToast() } },
|
||||
},
|
||||
})
|
||||
|
||||
widget._setLoraInfo!({ name: 'test.safetensors', notes: '', filePath: '/path/test.safetensors' })
|
||||
await nextTick()
|
||||
|
||||
const descriptionTab = wrapper.findAll('.lora-info-tab-input')[1]
|
||||
await descriptionTab.setValue('description')
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('No description available')
|
||||
})
|
||||
|
||||
it('caches description and does not re-fetch on second activation', async () => {
|
||||
const fetchApi = createMockFetchApi({
|
||||
response: {
|
||||
success: true,
|
||||
metadata: {
|
||||
description: '<p>Version desc</p>',
|
||||
model: { description: '<p>Model desc</p>' },
|
||||
},
|
||||
},
|
||||
})
|
||||
const widget = createMockWidget()
|
||||
const wrapper = shallowMount(LoraInfoWidget, {
|
||||
props: {
|
||||
widget,
|
||||
node: { id: 1 },
|
||||
api: { fetchApi },
|
||||
app: { extensionManager: { toast: createMockToast() } },
|
||||
},
|
||||
})
|
||||
|
||||
widget._setLoraInfo!({ name: 'test.safetensors', notes: '', filePath: '/path/test.safetensors' })
|
||||
await nextTick()
|
||||
|
||||
// First activation
|
||||
const descriptionTab = wrapper.findAll('.lora-info-tab-input')[1]
|
||||
await descriptionTab.setValue('description')
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
|
||||
expect(fetchApi).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Switch away and back
|
||||
const notesTab = wrapper.findAll('.lora-info-tab-input')[0]
|
||||
await notesTab.setValue('notes')
|
||||
await nextTick()
|
||||
await descriptionTab.setValue('description')
|
||||
await nextTick()
|
||||
|
||||
// Should NOT have called fetch again
|
||||
expect(fetchApi).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('re-fetches when LoRA selection changes', async () => {
|
||||
const fetchApi = createMockFetchApi({
|
||||
response: {
|
||||
success: true,
|
||||
metadata: {
|
||||
description: '<p>Version desc</p>',
|
||||
model: { description: '<p>Model desc</p>' },
|
||||
},
|
||||
},
|
||||
})
|
||||
const widget = createMockWidget()
|
||||
const wrapper = shallowMount(LoraInfoWidget, {
|
||||
props: {
|
||||
widget,
|
||||
node: { id: 1 },
|
||||
api: { fetchApi },
|
||||
app: { extensionManager: { toast: createMockToast() } },
|
||||
},
|
||||
})
|
||||
|
||||
widget._setLoraInfo!({ name: 'first.safetensors', notes: '', filePath: '/path/first.safetensors' })
|
||||
await nextTick()
|
||||
|
||||
const descriptionTab = wrapper.findAll('.lora-info-tab-input')[1]
|
||||
await descriptionTab.setValue('description')
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
|
||||
expect(fetchApi).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Select a different LoRA — resets description state
|
||||
widget._setLoraInfo!({ name: 'second.safetensors', notes: '', filePath: '/path/second.safetensors' })
|
||||
await nextTick()
|
||||
|
||||
// Should show loading again (not cached)
|
||||
await descriptionTab.setValue('description')
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
|
||||
expect(fetchApi).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('serialization roundtrip', () => {
|
||||
it('serializeValue includes activeTab', async () => {
|
||||
const { wrapper, widget } = mountWidget()
|
||||
widget._setLoraInfo!({ name: 'test.safetensors', notes: 'my notes', filePath: '/path/test.safetensors' })
|
||||
await nextTick()
|
||||
|
||||
// Switch to Description tab
|
||||
const descriptionTab = wrapper.findAll('.lora-info-tab-input')[1]
|
||||
await descriptionTab.setValue('description')
|
||||
await nextTick()
|
||||
|
||||
const serialized = await widget.serializeValue!()
|
||||
expect(serialized).toMatchObject({
|
||||
name: 'test.safetensors',
|
||||
notes: 'my notes',
|
||||
filePath: '/path/test.safetensors',
|
||||
activeTab: 'description',
|
||||
})
|
||||
})
|
||||
|
||||
it('onSetValue restores activeTab from workflow value', async () => {
|
||||
const { wrapper } = mountWidget({
|
||||
initialValue: {
|
||||
name: 'saved.safetensors',
|
||||
notes: 'saved notes',
|
||||
filePath: '/path/saved.safetensors',
|
||||
activeTab: 'description',
|
||||
},
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
// Description tab should be visible (activeTab restored to 'description')
|
||||
expect(wrapper.find('.description-tab').isVisible()).toBe(true)
|
||||
expect(wrapper.text()).toContain('saved.safetensors')
|
||||
})
|
||||
|
||||
it('defaults to notes tab when activeTab is missing in saved value', async () => {
|
||||
const { wrapper } = mountWidget({
|
||||
initialValue: {
|
||||
name: 'legacy.safetensors',
|
||||
notes: 'legacy notes',
|
||||
filePath: '/path/legacy.safetensors',
|
||||
// No activeTab — legacy workflow
|
||||
},
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('.notes-tab').isVisible()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('_setLoraInfo race condition guard', () => {
|
||||
it('consumes __pendingLoraInfo pushed before mount', async () => {
|
||||
const widget = createMockWidget()
|
||||
widget.__pendingLoraInfo = {
|
||||
name: 'pending.safetensors',
|
||||
notes: 'pending notes',
|
||||
filePath: '/path/pending.safetensors',
|
||||
}
|
||||
|
||||
const wrapper = shallowMount(LoraInfoWidget, {
|
||||
props: {
|
||||
widget,
|
||||
node: { id: 1 },
|
||||
api: { fetchApi: createMockFetchApi() },
|
||||
app: { extensionManager: { toast: createMockToast() } },
|
||||
},
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(widget.__pendingLoraInfo).toBeUndefined()
|
||||
expect(wrapper.text()).toContain('pending.safetensors')
|
||||
})
|
||||
|
||||
it('preserves activeTab when _setLoraInfo called with null (deselection)', async () => {
|
||||
const { wrapper, widget } = mountWidget()
|
||||
widget._setLoraInfo!({ name: 'test.safetensors', notes: '', filePath: '/path/test.safetensors' })
|
||||
await nextTick()
|
||||
|
||||
// Switch to Description tab
|
||||
const descriptionTab = wrapper.findAll('.lora-info-tab-input')[1]
|
||||
await descriptionTab.setValue('description')
|
||||
await nextTick()
|
||||
|
||||
// Deselect — template shows placeholder (no tab bar rendered)
|
||||
widget._setLoraInfo!(null)
|
||||
await nextTick()
|
||||
|
||||
// Placeholder shown
|
||||
expect(wrapper.text()).toContain('No LoRA selected')
|
||||
|
||||
// Re-select — activeTab should still be 'description'
|
||||
widget._setLoraInfo!({ name: 'second.safetensors', notes: '', filePath: '/path/second.safetensors' })
|
||||
await nextTick()
|
||||
|
||||
const tabs = wrapper.findAll('.lora-info-tab')
|
||||
expect(tabs[1].classes()).toContain('active')
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user