mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-06 22:10:14 -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:
@@ -102,6 +102,7 @@ npm run test:coverage # Generate coverage report
|
|||||||
- ComfyUI: `app.registerExtension()`, `node.addDOMWidget(name, type, element, options)`
|
- ComfyUI: `app.registerExtension()`, `node.addDOMWidget(name, type, element, options)`
|
||||||
- Event handlers via `addEventListener` or widget callbacks
|
- Event handlers via `addEventListener` or widget callbacks
|
||||||
- Shared utilities: `web/comfyui/utils.js`
|
- Shared utilities: `web/comfyui/utils.js`
|
||||||
|
- Dual-mode rendering patterns (canvas vs Vue): see `docs/comfyui-dual-mode-widgets.md`
|
||||||
|
|
||||||
### Vue Composables Pattern
|
### Vue Composables Pattern
|
||||||
|
|
||||||
|
|||||||
65
docs/comfyui-dual-mode-widgets.md
Normal file
65
docs/comfyui-dual-mode-widgets.md
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# ComfyUI Dual-Mode Widget Rendering
|
||||||
|
|
||||||
|
ComfyUI custom node widgets render in one of two modes. Patterns that work in one often fail silently in the other. Test both.
|
||||||
|
|
||||||
|
## Mode Detection
|
||||||
|
|
||||||
|
```js
|
||||||
|
typeof LiteGraph !== 'undefined' && LiteGraph.vueNodesMode
|
||||||
|
```
|
||||||
|
|
||||||
|
In Vue SFCs, `window.LiteGraph` is unavailable — pass as a prop from `main.ts`.
|
||||||
|
|
||||||
|
## Canvas Mode Layout
|
||||||
|
|
||||||
|
Uses `computeLayoutSize()` + `distributeSpace()` to allocate widget height within the node. Widgets with `computeLayoutSize` participate in space distribution; those with `computeSize` have fixed height.
|
||||||
|
|
||||||
|
- `getMinHeight()` in `addDOMWidget` options → minimum widget height
|
||||||
|
- `widget.computeLayoutSize()` → `{ minHeight, minWidth, maxHeight? }`
|
||||||
|
- Avoid `getMaxHeight()` unless the widget genuinely needs a fixed cap (prevents user resize)
|
||||||
|
|
||||||
|
## Vue Mode Layout
|
||||||
|
|
||||||
|
Uses CSS Grid (`grid-template-rows`) + `ResizeObserver`. The ResizeObserver watches the widget's DOM and feeds back into grid row sizing. This creates a feedback loop: content grows → row resizes → more space for content → content reflows/grows → row resizes again.
|
||||||
|
|
||||||
|
### Height Containment
|
||||||
|
|
||||||
|
The fix: `contain: layout size` on the widget root. This tells the browser the element's intrinsic size is CSS-determined, not driven by descendant content. The ResizeObserver sees a stable size and the loop is broken.
|
||||||
|
|
||||||
|
```css
|
||||||
|
.widget-root.lm-vue-node {
|
||||||
|
height: 100%;
|
||||||
|
min-height: var(--comfy-widget-min-height, 200px);
|
||||||
|
contain: layout size;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Existing examples: `.lm-loras-container.lm-vue-node` and `.comfy-tags-container.lm-vue-node` in `web/comfyui/lm_styles.css`.
|
||||||
|
|
||||||
|
**Do NOT** fix height issues with `maxHeight`, `getMaxHeight()`, or inline `max-height` — these prevent the user from resizing the node.
|
||||||
|
|
||||||
|
## Scroll Wheel Isolation
|
||||||
|
|
||||||
|
Both modes need to distinguish "user wants to scroll widget content" from "user wants to zoom canvas".
|
||||||
|
|
||||||
|
**Canvas mode:** Add `@wheel` on widget root. Check `event.target.closest(selector)` for scrollable sub-areas. If scrollable → `event.stopPropagation()`. Otherwise → `app.canvas.processMouseWheel(event)`.
|
||||||
|
|
||||||
|
**Vue mode:** Add CSS class `lm-wheel-scrollable` to scrollable elements. The global capture-phase hook in `web/comfyui/utils.js` (`enableListWheelScroll`) detects wheel events on marked elements and manually scrolls them via `element.scrollTop`, consuming the event before canvas zoom sees it.
|
||||||
|
|
||||||
|
## DOM Structure
|
||||||
|
|
||||||
|
`main.ts` creates an outer `<div>` container, then `vueApp.mount(container)`. The Vue app renders its own root element inside.
|
||||||
|
|
||||||
|
- `container.id` / `container.style.*` → outer element
|
||||||
|
- Vue scoped `<style>` → `[data-v-hash]` applies only to Vue root
|
||||||
|
|
||||||
|
Classes needed by scoped Vue CSS must go on the Vue root element. Pass data as props and bind with `:class` rather than manipulating the DOM from `main.ts`.
|
||||||
|
|
||||||
|
## Serialization
|
||||||
|
|
||||||
|
For stateful widgets that need workflow persistence:
|
||||||
|
|
||||||
|
- `serialize: true` in `addDOMWidget` options
|
||||||
|
- `serializeValue()` → state snapshot (called on workflow save)
|
||||||
|
- `onSetValue(v)` → restore state (called on workflow load)
|
||||||
|
- Always handle missing keys in restored value for backward compatibility with old workflows
|
||||||
@@ -1,40 +1,111 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="lora-info-widget">
|
<div class="lora-info-widget" :class="{ 'lm-vue-node': isVueMode }" @wheel="onWheel">
|
||||||
<template v-if="loraName">
|
<template v-if="loraName">
|
||||||
<div class="info-field">
|
<!-- Tab bar -->
|
||||||
<label class="info-label">Filename</label>
|
<div class="lora-info-tabs">
|
||||||
<div class="lora-filename">{{ loraName }}</div>
|
<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>
|
||||||
<div class="info-field notes-field">
|
|
||||||
<label class="info-label">Notes</label>
|
<!-- Notes tab content -->
|
||||||
<textarea
|
<div v-show="activeTab === 'notes'" class="tab-content notes-tab">
|
||||||
v-model="notes"
|
<div class="info-field">
|
||||||
class="lora-notes"
|
<label class="info-label">Filename</label>
|
||||||
placeholder="Add notes about this LoRA..."
|
<div class="lora-filename">{{ loraName }}</div>
|
||||||
:disabled="saving"
|
</div>
|
||||||
></textarea>
|
<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>
|
</div>
|
||||||
<button
|
|
||||||
class="save-btn"
|
|
||||||
:disabled="notes === originalNotes || saving"
|
|
||||||
@click="saveNotes"
|
|
||||||
>
|
|
||||||
{{ saving ? 'Saving...' : 'Save' }}
|
|
||||||
</button>
|
|
||||||
</template>
|
</template>
|
||||||
<div v-else class="placeholder">No LoRA selected</div>
|
<div v-else class="placeholder">No LoRA selected</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue'
|
import { onMounted, ref, computed, watch } from 'vue'
|
||||||
|
|
||||||
interface LoraInfoWidget {
|
interface LoraInfoWidget {
|
||||||
serializeValue?: () => Promise<unknown>
|
serializeValue?: () => Promise<unknown>
|
||||||
value?: unknown
|
value?: unknown
|
||||||
onSetValue?: (v: unknown) => void
|
onSetValue?: (v: unknown) => void
|
||||||
callback?: unknown
|
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<{
|
const props = defineProps<{
|
||||||
@@ -42,6 +113,7 @@ const props = defineProps<{
|
|||||||
node: { id: number }
|
node: { id: number }
|
||||||
api: { fetchApi: (url: string, options?: RequestInit) => Promise<Response> }
|
api: { fetchApi: (url: string, options?: RequestInit) => Promise<Response> }
|
||||||
app: { extensionManager: { toast: { add: (opts: Record<string, unknown>) => void } } }
|
app: { extensionManager: { toast: { add: (opts: Record<string, unknown>) => void } } }
|
||||||
|
isVueMode?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const loraName = ref<string>('')
|
const loraName = ref<string>('')
|
||||||
@@ -49,6 +121,69 @@ const notes = ref<string>('')
|
|||||||
const originalNotes = ref<string>('')
|
const originalNotes = ref<string>('')
|
||||||
const filePath = ref<string>('')
|
const filePath = ref<string>('')
|
||||||
const saving = ref<boolean>(false)
|
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() {
|
async function saveNotes() {
|
||||||
if (notes.value === originalNotes.value || saving.value) return
|
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(() => {
|
onMounted(() => {
|
||||||
// Display-only widget - return null on serialization to avoid saving to workflow
|
// Persist tab state along with other widget data
|
||||||
props.widget.serializeValue = async () => null
|
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)
|
// Handle external value updates (e.g., loading workflow, paste)
|
||||||
props.widget.onSetValue = (v: unknown) => {
|
props.widget.onSetValue = (v: unknown) => {
|
||||||
if (v && typeof v === 'object') {
|
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.name !== undefined) loraName.value = data.name
|
||||||
if (data.notes !== undefined) {
|
if (data.notes !== undefined) {
|
||||||
notes.value = data.notes
|
notes.value = data.notes
|
||||||
originalNotes.value = data.notes
|
originalNotes.value = data.notes
|
||||||
}
|
}
|
||||||
if (data.filePath !== undefined) filePath.value = data.filePath
|
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)
|
// Restore from saved value if exists (for workflow loading)
|
||||||
if (props.widget.value && typeof props.widget.value === 'object') {
|
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.name !== undefined) loraName.value = data.name
|
||||||
if (data.notes !== undefined) {
|
if (data.notes !== undefined) {
|
||||||
notes.value = data.notes
|
notes.value = data.notes
|
||||||
originalNotes.value = data.notes
|
originalNotes.value = data.notes
|
||||||
}
|
}
|
||||||
if (data.filePath !== undefined) filePath.value = data.filePath
|
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).
|
// Expose setLoraInfo on the widget object for external callers (e.g., lora_info.js).
|
||||||
// Accepts null to clear the display (when selection is deselected).
|
// 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) {
|
if (data) {
|
||||||
loraName.value = data.name
|
loraName.value = data.name
|
||||||
notes.value = data.notes
|
notes.value = data.notes
|
||||||
originalNotes.value = data.notes
|
originalNotes.value = data.notes
|
||||||
filePath.value = data.filePath
|
filePath.value = data.filePath
|
||||||
|
// Preserve existing activeTab unless explicitly provided
|
||||||
|
if (data.activeTab !== undefined) {
|
||||||
|
activeTab.value = data.activeTab
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
loraName.value = ''
|
loraName.value = ''
|
||||||
notes.value = ''
|
notes.value = ''
|
||||||
originalNotes.value = ''
|
originalNotes.value = ''
|
||||||
filePath.value = ''
|
filePath.value = ''
|
||||||
|
// Do NOT reset activeTab on deselection — user's tab preference persists
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,6 +342,86 @@ onMounted(() => {
|
|||||||
overflow: hidden;
|
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 {
|
.info-field {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -235,6 +502,87 @@ onMounted(() => {
|
|||||||
border-color: rgba(226, 232, 240, 0.1);
|
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 {
|
.placeholder {
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
color: rgba(226, 232, 240, 0.5);
|
color: rgba(226, 232, 240, 0.5);
|
||||||
@@ -242,4 +590,14 @@ onMounted(() => {
|
|||||||
padding: 16px 0;
|
padding: 16px 0;
|
||||||
font-size: 12px;
|
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>
|
</style>
|
||||||
@@ -656,7 +656,7 @@ function createLoraInfoWidget(node: any) {
|
|||||||
|
|
||||||
forwardMiddleMouseToCanvas(container)
|
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(
|
const widget = node.addDOMWidget(
|
||||||
'lora_info_display',
|
'lora_info_display',
|
||||||
@@ -666,13 +666,13 @@ function createLoraInfoWidget(node: any) {
|
|||||||
getValue() {
|
getValue() {
|
||||||
return internalValue
|
return internalValue
|
||||||
},
|
},
|
||||||
setValue(v: { name?: string; notes?: string; filePath?: string }) {
|
setValue(v: { name?: string; notes?: string; filePath?: string; activeTab?: string }) {
|
||||||
internalValue = v
|
internalValue = v
|
||||||
if (typeof widget.onSetValue === 'function') {
|
if (typeof widget.onSetValue === 'function') {
|
||||||
widget.onSetValue(v)
|
widget.onSetValue(v)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
serialize: false, // Display-only widget
|
serialize: true,
|
||||||
getMinHeight() {
|
getMinHeight() {
|
||||||
return LORA_INFO_WIDGET_MIN_HEIGHT
|
return LORA_INFO_WIDGET_MIN_HEIGHT
|
||||||
}
|
}
|
||||||
@@ -684,6 +684,7 @@ function createLoraInfoWidget(node: any) {
|
|||||||
node,
|
node,
|
||||||
api,
|
api,
|
||||||
app,
|
app,
|
||||||
|
isVueMode: typeof LiteGraph !== 'undefined' && LiteGraph.vueNodesMode,
|
||||||
})
|
})
|
||||||
|
|
||||||
vueApp.use(PrimeVue, {
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user