Files
DumiFlex-ComfyUI-Wildcard-P…/src/components/context/PushBundleToLibraryModal.vue
T
DumiFlex 8000e1b75c fix(ui): render embed/canvas checkboxes via a scoped WpCheck component
The inline `<span class="wp-check">` controls relied on the global `.wp-check`
rule from manager/styles/tokens.css, which is loaded in the SPA but NOT on the
ComfyUI canvas (settings.ts pulls a hand-picked CSS set; tokens.css is not in
it). So in canvas-mounted instance modals the converted checkboxes rendered as
unstyled, zero-size spans — invisible. (`.opt__check` survived because it is
component-scoped CSS, which ships into every bundle the component renders in.)

Fix: extract the wildcard-option box into `WpCheck.vue` — a shared component
whose `<style scoped>` mirrors `.opt__check` verbatim. Scoped CSS is compiled
into the component chunk, so it travels into the SPA *and* the canvas bundle
with no dependency on any global stylesheet. Route PoolSection, DisabledRules-
Section, SubcategoryFilterPicker, Push/PushBundleToLibraryModal, and
DisplayPlaygroundModal through it.

The SPA keeps its global `.wp-check` (ui/Checkbox.vue consumers) untouched.
Reverts the 405 -> 407 KB budget bump: one shared component is leaner than the
13 inline copies, so the total lands back under 405 KB.
2026-06-10 01:59:33 +03:00

668 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
/**
* PushBundleToLibraryModal — bundle-scoped sibling of PushToLibraryModal.
*
* Same two-action grammar:
* - **Update existing** (PUT /wp/api/bundles/{id}) — disabled when the
* library entry has been deleted upstream (would 404).
* - **Save as new entry** (POST /wp/api/bundles) — fork. When the
* source was missing this becomes a "reattach": skips the "(copy)"
* name suffix (the original name slot is free) and caller rebinds
* the workflow BundleInstance to the new uuid.
*
* Caller pre-builds the `childrenForLibrary` payload via
* `buildLibraryChildrenWithIntegrity` so the modal stays free of
* bundle-walk logic.
*/
import { computed, onBeforeUnmount, ref, watch } from "vue";
import { bundleHashes } from "./drift-store";
import { api } from "../../manager/api/client";
import type { BundleInstance } from "../../widgets/_shared";
import WpCheck from "@/components/shared/WpCheck.vue";
interface ChildPreview {
name: string;
kind: string;
}
/** Cascade restore pre-scan handed in by the caller — drives the
* "Restore N missing references" UI. When both counts are 0 the
* section stays hidden and the cascade checkbox is absent. */
interface CascadeScan {
missingModuleCount: number;
missingBundleCount: number;
missingModuleNames: string[];
missingBundleNames: string[];
}
/** Caller-supplied async hook. Runs BEFORE the modal's own POST/PUT.
* Returns the rewritten children array (already includes the new
* uuids from the restored library entries) and the count breakdown.
* Throws on any failure — modal aborts the save and surfaces the
* error. */
type CascadeRestoreFn = () => Promise<{
rewrittenChildren: Record<string, unknown>[];
restoredModuleCount: number;
restoredBundleCount: number;
}>;
interface SaveResult {
/** `update` — PUT to existing entry. `fork` — POST new entry with
* "(copy)" suffix; workflow row keeps its old library_id. `reattach`
* — POST when source entry was missing; no copy suffix, caller
* should rebind the workflow row's library_id to the new uuid so
* MISSING clears. */
mode: "update" | "fork" | "reattach";
/** New library uuid when forking/reattaching; same as bundle.library_id
* when updating. */
id: string;
/** payload_hash returned by the server. */
payload_hash: string;
/** Display name as persisted. */
name: string;
/** Original bundle.library_id at modal-open time. Lets the caller
* rebind the workflow BundleInstance on reattach. */
origId: string;
/** Cascade restore breakdown, when the user opted in. Lets the
* caller surface a richer success toast and (already committed by
* the cascade fn) skip its own rebinding for restored items. */
cascade?: {
restoredModuleCount: number;
restoredBundleCount: number;
};
}
interface Props {
open: boolean;
bundle: BundleInstance | null;
childrenForLibrary: Array<Record<string, unknown>>;
childrenPreview: ChildPreview[];
/** Optional — when set, the modal surfaces a "Restore N missing
* references" section AND wires up the cascade checkbox. The
* caller must also pass `cascadeRestore` for the actual work. */
cascadeScan?: CascadeScan | null;
cascadeRestore?: CascadeRestoreFn;
}
const props = defineProps<Props>();
const emit = defineEmits<{
close: [];
saved: [SaveResult];
}>();
const name = ref<string>("");
const description = ref<string>("");
const tagsText = ref<string>("");
const busy = ref<boolean>(false);
const errorMsg = ref<string>("");
/** User-controlled — defaults to ON when a cascade scan reports any
* missing children, OFF otherwise. The checkbox lets the user save
* WITHOUT restoring if they're intentionally pushing a partial state. */
const cascadeEnabled = ref<boolean>(false);
/** True when the modal should show the cascade section. Driven by the
* scan: any missing module or inner-bundle triggers it. */
const cascadeAvailable = computed<boolean>(() => {
const scan = props.cascadeScan;
if (!scan) return false;
return scan.missingModuleCount > 0 || scan.missingBundleCount > 0;
});
/** True when the bundle was inserted from a library entry (has a real
* `library_id` — bundle instances always do, but inline-only paths
* could in theory pass null). Without this the Update path can't
* target an existing row. */
const isLibraryTracked = computed(() => Boolean(props.bundle?.library_id));
/** True when the bundle's library_id is no longer in the polled
* hashes map — entry deleted upstream so PUT would 404. */
const isLibraryMissing = computed(() => {
if (!isLibraryTracked.value || !props.bundle) return false;
if (bundleHashes.value === null) return false;
return !(props.bundle.library_id in bundleHashes.value);
});
const canUpdateExisting = computed(
() => isLibraryTracked.value && !isLibraryMissing.value,
);
const tagsParsed = computed<string[]>(() =>
tagsText.value
.split(",")
.map((t) => t.trim())
.filter((t) => t.length > 0),
);
function resetForm(): void {
name.value = (props.bundle?.name ?? "").trim();
description.value = "";
tagsText.value = "";
busy.value = false;
errorMsg.value = "";
// Cascade is opt-in but defaults ON when there's anything to heal —
// that's the case where saving WITHOUT cascade ships broken refs
// into library, so we make the safer choice the default and let the
// user opt out.
cascadeEnabled.value = cascadeAvailable.value;
}
/** Seed description + tags from the live library entry when the
* bundle is tracked. Skips on missing — the entry is gone. Only
* fills empty form fields so a mid-edit doesn't get clobbered. */
async function seedFromLibrary(): Promise<void> {
if (!props.bundle || !isLibraryTracked.value || isLibraryMissing.value) return;
try {
const row = await api.bundles.get(props.bundle.library_id);
if (!description.value && typeof row.description === "string") {
description.value = row.description;
}
if (!tagsText.value && Array.isArray(row.tags)) {
tagsText.value = row.tags.join(", ");
}
} catch {
/* network — leave form as-is */
}
}
watch(
() => props.open,
(isOpen) => {
if (!isOpen) return;
resetForm();
void seedFromLibrary();
},
);
function close(): void {
if (busy.value) return;
emit("close");
}
/** Cascade pre-pass — runs once per save attempt when cascade is
* enabled. Returns the (rewritten) children to use in the actual
* POST/PUT plus the count breakdown for the success toast. Throws
* on any failure; the caller surfaces the error via `errorMsg`. */
async function runCascadeIfEnabled(): Promise<{
children: Record<string, unknown>[];
cascadeMeta?: { restoredModuleCount: number; restoredBundleCount: number };
}> {
if (!cascadeEnabled.value || !cascadeAvailable.value || !props.cascadeRestore) {
return { children: props.childrenForLibrary };
}
const result = await props.cascadeRestore();
return {
children: result.rewrittenChildren,
cascadeMeta: {
restoredModuleCount: result.restoredModuleCount,
restoredBundleCount: result.restoredBundleCount,
},
};
}
async function doUpdate(): Promise<void> {
if (!props.bundle || !canUpdateExisting.value) return;
busy.value = true;
errorMsg.value = "";
try {
const cascade = await runCascadeIfEnabled();
const finalName = name.value.trim() || props.bundle.name || "bundle";
const updated = await api.bundles.update(props.bundle.library_id, {
name: finalName,
description: description.value,
tags: tagsParsed.value,
color: props.bundle.color ?? null,
children: cascade.children,
});
emit("saved", {
mode: "update",
id: props.bundle.library_id,
payload_hash: updated.payload_hash,
name: updated.name,
origId: props.bundle.library_id,
cascade: cascade.cascadeMeta,
});
} catch (err) {
errorMsg.value = err instanceof Error ? err.message : String(err);
} finally {
busy.value = false;
}
}
async function doSaveAsNew(): Promise<void> {
if (!props.bundle) return;
busy.value = true;
errorMsg.value = "";
try {
const cascade = await runCascadeIfEnabled();
let existing = new Set<string>();
try {
const list = await api.bundles.list({});
existing = new Set(list.items.map((b) => b.name).filter(Boolean));
} catch {
/* fall through with empty set */
}
const baseName = stripCopySuffix(name.value.trim() || props.bundle.name || "bundle");
// Re-attach path: source entry was deleted. Always use the bare
// base name — library entries are keyed by uuid, not name, so a
// same-name collision with a separate entry is tolerable and beats
// forcing "(copy)" when the user's intent is "restore my deleted
// entry". Default fork path keeps the collision-avoiding suffix.
const reattach = isLibraryMissing.value;
const finalName = reattach ? baseName : pickCopyName(baseName, existing);
const created = await api.bundles.create({
name: finalName,
description: description.value,
tags: tagsParsed.value,
color: props.bundle.color ?? null,
children: cascade.children,
});
emit("saved", {
mode: reattach ? "reattach" : "fork",
id: created.id,
payload_hash: created.payload_hash,
name: created.name,
origId: props.bundle.library_id,
cascade: cascade.cascadeMeta,
});
} catch (err) {
errorMsg.value = err instanceof Error ? err.message : String(err);
} finally {
busy.value = false;
}
}
function stripCopySuffix(s: string): string {
return s.replace(/\s*\(copy(?:\s+\d+)?\)\s*$/, "");
}
function pickCopyName(base: string, taken: ReadonlySet<string>): string {
const first = `${base} (copy)`;
if (!taken.has(first)) return first;
for (let i = 2; i < 1000; i++) {
const c = `${base} (copy ${i})`;
if (!taken.has(c)) return c;
}
return `${base} (copy ${Date.now()})`;
}
function onKey(ev: KeyboardEvent): void {
if (!props.open) return;
if (ev.key === "Escape") {
ev.preventDefault();
close();
}
}
watch(
() => props.open,
(v) => {
if (v) window.addEventListener("keydown", onKey);
else window.removeEventListener("keydown", onKey);
},
{ immediate: true },
);
onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
</script>
<template>
<Teleport to="body">
<Transition name="wp-modal" appear>
<div
v-if="open && bundle"
class="wp-ptl-overlay"
data-test="pbtl-overlay"
@click="close"
>
<div
class="wp-ptl"
role="dialog"
aria-modal="true"
aria-labelledby="wp-pbtl-title"
data-test="pbtl"
@click.stop
>
<header class="wp-ptl__head">
<h3 id="wp-pbtl-title" class="wp-ptl__title">Push bundle to library</h3>
<button
type="button"
class="wp-ptl__close"
aria-label="Close"
data-test="pbtl-close"
@click="close"
>×</button>
</header>
<div class="wp-ptl__body">
<div class="wp-ptl-field">
<label class="wp-ptl-field__label" for="wp-pbtl-name">Name</label>
<input
id="wp-pbtl-name"
v-model="name"
type="text"
class="wp-ptl-input"
data-test="pbtl-name"
placeholder="(unnamed)"
/>
</div>
<div class="wp-ptl-field">
<label class="wp-ptl-field__label" for="wp-pbtl-description">Description</label>
<textarea
id="wp-pbtl-description"
v-model="description"
class="wp-ptl-input wp-ptl-input--multi"
data-test="pbtl-description"
rows="2"
/>
</div>
<div class="wp-ptl-field">
<label class="wp-ptl-field__label" for="wp-pbtl-tags">Tags (comma-separated)</label>
<input
id="wp-pbtl-tags"
v-model="tagsText"
type="text"
class="wp-ptl-input"
data-test="pbtl-tags"
placeholder="e.g. style, qa"
/>
</div>
<div class="wp-ptl-field">
<label class="wp-ptl-field__label">
Children ({{ childrenPreview.length }})
</label>
<ul class="wp-ptl-children" data-test="pbtl-children">
<li
v-for="(c, i) in childrenPreview"
:key="`${c.kind}-${i}`"
class="wp-ptl-children__item"
>
<span class="wp-ptl-children__kind">{{ c.kind }}</span>
<span class="wp-ptl-children__name">{{ c.name }}</span>
</li>
<li v-if="childrenPreview.length === 0" class="wp-ptl-children__empty">
(empty bundle)
</li>
</ul>
</div>
<!-- Cascade restore — surfaces when the scan detected any
missing children. The default is ON because saving
without cascade preserves broken refs / dead-uuid
module snapshots in the library. -->
<div
v-if="cascadeAvailable"
class="wp-ptl-cascade"
data-test="pbtl-cascade"
>
<span class="wp-ptl-cascade__toggle">
<WpCheck
v-model="cascadeEnabled"
data-test="pbtl-cascade-toggle"
aria-label="Restore missing references first"
/>
<span>
Restore
<strong>{{ (cascadeScan?.missingModuleCount ?? 0) + (cascadeScan?.missingBundleCount ?? 0) }}</strong>
missing reference{{
((cascadeScan?.missingModuleCount ?? 0) + (cascadeScan?.missingBundleCount ?? 0)) === 1
? ""
: "s"
}} first
</span>
</span>
<div class="wp-ptl-cascade__hint">
Cascade-creates fresh library entries for every missing
child (bottom-up — modules first, then inner bundles)
before saving this bundle. Without this, broken refs
ship into the library.
</div>
<ul class="wp-ptl-cascade__list">
<li
v-for="n in (cascadeScan?.missingBundleNames ?? [])"
:key="`mb-${n}`"
class="wp-ptl-cascade__item"
data-test="pbtl-cascade-bundle"
>
<span class="wp-ptl-cascade__kind">BUNDLE</span>
<span class="wp-ptl-cascade__name">{{ n }}</span>
</li>
<li
v-for="n in (cascadeScan?.missingModuleNames ?? [])"
:key="`mm-${n}`"
class="wp-ptl-cascade__item"
data-test="pbtl-cascade-module"
>
<span class="wp-ptl-cascade__kind">MODULE</span>
<span class="wp-ptl-cascade__name">{{ n }}</span>
</li>
</ul>
</div>
<div v-if="isLibraryMissing" class="wp-ptl-note wp-ptl-note--danger" data-test="pbtl-missing">
The library entry for this bundle has been deleted upstream.
"Update existing" is unavailable — use "Save as new entry" to
re-add it to the library.
</div>
<div v-if="errorMsg" class="wp-ptl-error" data-test="pbtl-error">
{{ errorMsg }}
</div>
</div>
<footer class="wp-ptl__foot">
<button
type="button"
class="wp-ptl-btn"
data-test="pbtl-cancel"
:disabled="busy"
@click="close"
>Cancel</button>
<span class="wp-ptl-spacer" />
<button
type="button"
class="wp-ptl-btn"
data-test="pbtl-save-new"
:disabled="busy"
@click="doSaveAsNew"
>Save as new entry</button>
<button
type="button"
class="wp-ptl-btn wp-ptl-btn--primary"
data-test="pbtl-update"
:disabled="busy || !canUpdateExisting"
:title="!isLibraryTracked
? 'No existing library entry to update'
: isLibraryMissing
? 'Library entry has been deleted — save as new entry instead'
: undefined"
@click="doUpdate"
>Update existing</button>
</footer>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
@import "../shared/theme.css";
@import "../shared/_modal-motion.css";
.wp-ptl-overlay {
position: fixed; inset: 0; z-index: 10000;
display: flex; align-items: center; justify-content: center;
padding: 16px;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(2px);
}
.wp-ptl {
width: min(620px, 100%);
max-height: calc(100vh - 32px);
background: var(--wp-bg-1, #0b0b12);
border: 1px solid var(--wp-border-strong, var(--wp-border, #2a2a3a));
border-radius: var(--wp-radius-lg, 10px);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.55);
display: flex; flex-direction: column;
overflow: hidden;
color: var(--wp-text, #e7e7ee);
}
.wp-ptl__head {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 14px;
border-bottom: 1px solid var(--wp-border, #2a2a3a);
}
.wp-ptl__title { margin: 0; font-size: 14px; font-weight: 600; }
.wp-ptl__close {
background: transparent; border: none; color: var(--wp-text-muted);
font-size: 18px; cursor: pointer; line-height: 1; padding: 0 6px;
}
.wp-ptl__close:hover { color: var(--wp-text); }
.wp-ptl__body {
flex: 1; min-height: 0; overflow-y: auto;
padding: 14px;
display: flex; flex-direction: column; gap: 12px;
}
.wp-ptl-field { display: flex; flex-direction: column; gap: 4px; }
.wp-ptl-field__label {
font-size: 11px; font-weight: 600; letter-spacing: 0.04em;
text-transform: uppercase; color: var(--wp-text-dim);
}
.wp-ptl-input {
width: 100%;
background: var(--wp-bg-2, #15151f);
color: var(--wp-text, #e7e7ee);
border: 1px solid var(--wp-border, #2a2a3a);
border-radius: 6px;
padding: 6px 8px;
font-size: 13px;
font-family: inherit;
}
.wp-ptl-input:focus { outline: 1px solid var(--wp-accent-500, #8b5cf6); }
.wp-ptl-input--multi { resize: vertical; min-height: 48px; }
.wp-ptl-children {
margin: 0;
padding: 6px 8px;
list-style: none;
background: var(--wp-bg-2, #15151f);
border: 1px solid var(--wp-border, #2a2a3a);
border-radius: 6px;
font-size: 12px;
max-height: 160px;
overflow: auto;
display: flex; flex-direction: column; gap: 2px;
}
.wp-ptl-children__item {
display: flex; align-items: center; gap: 8px;
padding: 2px 4px;
}
.wp-ptl-children__kind {
font: 600 9px/1 var(--wp-font-sans);
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--wp-text-dim);
min-width: 56px;
}
.wp-ptl-children__name {
color: var(--wp-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wp-ptl-children__empty {
color: var(--wp-text-dim);
font-style: italic;
padding: 2px 4px;
}
.wp-ptl-cascade {
display: flex; flex-direction: column; gap: 6px;
padding: 10px 12px;
background: color-mix(in oklab, var(--wp-danger, #ef4444) 10%, transparent);
border: 1px solid color-mix(in oklab, var(--wp-danger, #ef4444) 30%, transparent);
border-radius: 6px;
}
.wp-ptl-cascade__toggle {
display: flex; align-items: center; gap: 8px;
font-size: 13px;
font-weight: 600;
color: var(--wp-text, #e7e7ee);
cursor: pointer;
}
.wp-ptl-cascade__hint {
font-size: 11.5px;
color: var(--wp-text-muted, #a1a1ad);
line-height: 1.4;
}
.wp-ptl-cascade__list {
list-style: none;
margin: 0;
padding: 4px 6px;
background: var(--wp-bg-2, #15151f);
border-radius: 4px;
font-size: 11.5px;
max-height: 110px;
overflow: auto;
display: flex; flex-direction: column; gap: 1px;
}
.wp-ptl-cascade__item {
display: flex; align-items: center; gap: 8px;
padding: 2px 4px;
}
.wp-ptl-cascade__kind {
font: 600 9px/1 var(--wp-font-sans);
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--wp-danger, #ef4444);
min-width: 56px;
}
.wp-ptl-cascade__name {
color: var(--wp-text, #e7e7ee);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wp-ptl-note {
padding: 8px 10px;
border-radius: 6px;
font-size: 12px;
}
.wp-ptl-note--danger {
background: color-mix(in oklab, var(--wp-danger, #ef4444) 12%, transparent);
border: 1px solid color-mix(in oklab, var(--wp-danger, #ef4444) 32%, transparent);
color: var(--wp-danger, #ef4444);
}
.wp-ptl-error {
padding: 8px 10px;
border-radius: 6px;
background: color-mix(in oklab, var(--wp-danger, #ef4444) 14%, transparent);
border: 1px solid color-mix(in oklab, var(--wp-danger, #ef4444) 32%, transparent);
color: var(--wp-danger, #ef4444);
font-size: 12px;
}
.wp-ptl__foot {
display: flex; align-items: center; gap: 8px;
padding: 10px 14px;
border-top: 1px solid var(--wp-border, #2a2a3a);
background: var(--wp-bg-1, #0b0b12);
}
.wp-ptl-spacer { flex: 1; }
.wp-ptl-btn {
background: var(--wp-bg-3, #1c1c28);
color: var(--wp-text, #e7e7ee);
border: 1px solid var(--wp-border-strong, var(--wp-border, #2a2a3a));
border-radius: 6px;
padding: 6px 12px;
font-size: 12.5px;
cursor: pointer;
}
.wp-ptl-btn:hover:not(:disabled) { background: color-mix(in oklab, var(--wp-bg-3, #1c1c28) 80%, var(--wp-text, #fff)); }
.wp-ptl-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.wp-ptl-btn--primary {
background: var(--wp-accent-500, #8b5cf6);
border-color: var(--wp-accent-500, #8b5cf6);
color: #fff;
}
.wp-ptl-btn--primary:hover:not(:disabled) {
background: var(--wp-accent-600, #7c3aed);
}
</style>