Files
Will Miao 92de2dbc52 feat(workflows): add Create/Update to the tab context menu
ComfyUI exposes commands and menuCommands for the menu bar, but the
workflow tab context menu is built inside WorkflowTab.vue and has no
extension hook. Reuse the registered create/update actions and inject
the entries into the reka-ui portal, the same fallback the Graph menu
already uses.

The entries sit above the tab-close group. Clicking one activates the
right-clicked tab through app.extensionManager.workflow.openWorkflow
(the switch the native Save/Export entries use), so the action targets
that tab's graph instead of whichever tab was active. Update is enabled
from that tab's own lm_source marker and link-status check, and a menu
instance is only enhanced once.
2026-10-30 18:21:16 +08:00

1111 lines
36 KiB
JavaScript

/**
* Write-back of the current ComfyUI graph into a LoRA Manager managed
* workflow ("Update LM-managed workflow").
*
* Correspondence: when LoRA Manager pushes a library workflow into ComfyUI
* ("Open in ComfyUI"), workflow_registry.js stamps the provenance marker
* `extra.lm_source = { file_path, sha256 }` onto the loaded graph. The marker
* rides the graph serialization, so it survives tab switches and browser
* reloads together with the workflow draft. Entry points below are only
* usable while the active graph carries that marker.
*
* Dirty tracking: the first `graphChanged` event after a load/write-back
* flips `marker.dirty` (persisted inside the marker, so reloads keep it).
* `graphChanged` is dispatched by ComfyUI's ChangeTracker only when the
* serialized state actually changed, so selection/panning never dirties.
*
* Entry points:
* - A command (`LoraManager.UpdateManagedWorkflow`), reachable from the
* command palette and the top menu bar (File group).
* - An item injected into the workflow actions dropdown (the "Graph ▾"
* toggle menu). The frontend offers no extension API for that menu, so
* the item is appended via DOM observation of the menu's trigger; if the
* markup changes upstream the injection silently stops and the command
* remains as the fallback.
* - The LM web UI card context menu ("Update from ComfyUI"): the backend
* routes its request here over the `lm_collect_workflow` WS channel and
* awaits the outcome via `/api/lm/workflows/collect-result`.
*
* The active graph's link state is reported to the backend
* (`/api/lm/workflows/comfyui-state`) on load/tab-switch/dirty-flip/
* write-back so the LM web UI can enable the menu entry only when the
* workflow is open in ComfyUI and has edits.
*/
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
import { lmUrl } from "./base_path.js";
import { showToast } from "./utils.js";
const MARKER_KEY = "lm_source";
const COMMAND_ID = "LoraManager.UpdateManagedWorkflow";
const MENU_LABEL = "Update LM-managed workflow";
const MENU_ICON = "pi pi-upload";
const CREATE_COMMAND_ID = "LoraManager.CreateManagedWorkflow";
const CREATE_MENU_LABEL = "Create LM-managed workflow";
const CREATE_MENU_ICON = "pi pi-plus";
// data-testid of the workflow actions dropdown trigger in ComfyUI_frontend
// (WorkflowActionsDropdown.vue).
const DROPDOWN_TRIGGER_TESTID = "view-mode-toggle";
// { enabled: boolean, path: string } once fetched; null while unknown (the
// menu entry stays optimistically enabled and the backend reports the error).
let workflowLibraryState = null;
let workflowLibraryFetch = null;
// Prompt ids this tab queued and that finished executing. The backend only
// attaches a preview image when the id is still the metadata registry's
// current prompt, so another tab's run can never be borrowed.
let lastExecutedPromptId = null;
const queuedPromptIds = new Set();
function getRootGraph() {
return app.rootGraph ?? app.graph ?? null;
}
/**
* The provenance marker of the active graph, or null when the open workflow
* was not loaded from the LoRA Manager library.
*/
export function getActiveLmSource() {
const marker = getRootGraph()?.extra?.[MARKER_KEY];
if (!marker || typeof marker.file_path !== "string" || !marker.file_path) {
return null;
}
return marker;
}
/**
* Fetch (once, then cached) whether the workflow library can accept a new
* entry: the opt-in feature must be on and a library folder configured.
* Never throws: an unreachable/older backend leaves the state unknown and the
* create entry optimistically enabled (the POST reports the real error).
*/
function refreshWorkflowLibraryState() {
if (workflowLibraryFetch) {
return workflowLibraryFetch;
}
workflowLibraryFetch = fetch(lmUrl("/api/lm/settings"))
.then((response) => (response.ok ? response.json() : null))
.then((payload) => {
const settings = payload?.settings;
if (!settings || typeof settings !== "object") {
return null;
}
workflowLibraryState = {
enabled: settings.enable_workflows !== false,
path:
typeof settings.workflow_path === "string"
? settings.workflow_path.trim()
: "",
};
return workflowLibraryState;
})
.catch(() => null)
.finally(() => {
workflowLibraryFetch = null;
});
return workflowLibraryFetch;
}
function canCreateWorkflow() {
if (workflowLibraryState == null) {
return true;
}
return workflowLibraryState.enabled && !!workflowLibraryState.path;
}
function createUnavailableTitle() {
if (workflowLibraryState?.enabled === false) {
return "Workflow management is disabled in LoRA Manager";
}
return "Configure the workflow library folder in LoRA Manager first";
}
/**
* Derive a library file name from the active ComfyUI workflow. Falls back to
* "Untitled" for temporary/unsaved workflows (the backend sanitizes it).
*/
function getActiveWorkflowName() {
const workflow = app.extensionManager?.workflow?.activeWorkflow;
const name = workflow?.filename || workflow?.name || "";
return typeof name === "string" && name.trim() ? name.trim() : "Untitled";
}
/**
* Stamp the provenance marker of a freshly created library workflow onto the
* live graph so "Update LM-managed workflow" becomes available immediately.
*/
function stampLmSource(filePath, sha256) {
const graph = getRootGraph();
if (!graph || typeof filePath !== "string" || !filePath) {
return;
}
graph.extra = {
...(graph.extra || {}),
[MARKER_KEY]: { file_path: filePath, sha256: sha256 || null },
};
}
function clearLmSource() {
const graph = getRootGraph();
if (graph && graph.extra && typeof graph.extra === "object") {
delete graph.extra[MARKER_KEY];
}
}
/**
* True when the linked library document still exists; true on any failure so
* a transient backend error never unlinks a perfectly valid workflow.
*/
async function isLinkedDocumentAlive(marker) {
if (!marker || typeof marker.file_path !== "string") {
return false;
}
try {
const response = await fetch(
lmUrl(
`/api/lm/workflows/link-status?file_path=${encodeURIComponent(
marker.file_path
)}`
)
);
if (!response.ok) {
return true;
}
const payload = await response.json();
if (payload?.success && payload.exists === false) {
return false;
}
return true;
} catch (_error) {
return true;
}
}
/**
* Drop a provenance marker whose library entry was deleted/renamed from the
* LM page, and report the resulting unlinked state.
*/
async function validateActiveLink() {
const marker = getActiveLmSource();
if (!marker) {
return false;
}
if (await isLinkedDocumentAlive(marker)) {
return true;
}
clearLmSource();
reportLinkState();
return false;
}
/**
* Track which prompt ids this tab queued and which of them finished. Used to
* ask the backend for a preview image without borrowing another tab's run.
*/
function installPromptTracking() {
if (typeof api.queuePrompt === "function" && !api.__lmQueuePromptHooked) {
api.__lmQueuePromptHooked = true;
const originalQueuePrompt = api.queuePrompt.bind(api);
api.queuePrompt = async function (...args) {
const response = await originalQueuePrompt(...args);
const promptId = response?.prompt_id;
if (typeof promptId === "string" && promptId) {
queuedPromptIds.add(promptId);
if (queuedPromptIds.size > 32) {
queuedPromptIds.delete(queuedPromptIds.values().next().value);
}
}
return response;
};
}
api.addEventListener("execution_success", (event) => {
const promptId = event?.detail?.prompt_id;
if (typeof promptId !== "string" || !promptId) {
return;
}
if (queuedPromptIds.has(promptId)) {
queuedPromptIds.delete(promptId);
lastExecutedPromptId = promptId;
}
});
}
/**
* Serialize the current graph without the LM provenance marker: the marker
* is client-side bookkeeping and must not be persisted into the library
* document (the backend strips it too, defense in depth).
*/
function serializeWorkflowForWriteback() {
const graph = getRootGraph();
const data = JSON.parse(JSON.stringify(graph.serialize()));
if (data.extra && typeof data.extra === "object") {
delete data.extra[MARKER_KEY];
}
return data;
}
async function requestWriteback(marker, { force = false } = {}) {
const response = await fetch(lmUrl("/api/lm/workflows/content"), {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
file_path: marker.file_path,
workflow: serializeWorkflowForWriteback(),
base_sha256: marker.sha256 || "",
force,
}),
});
let payload = null;
try {
payload = await response.json();
} catch (_error) {
payload = null;
}
return { status: response.status, payload };
}
/**
* Report the active graph's link state to the LM backend (null state when
* the active graph is not LM-linked). Best-effort: failures are silent so a
* missing/older backend never spams the console.
*/
export async function reportLinkState() {
const marker = getActiveLmSource();
try {
await fetch(lmUrl("/api/lm/workflows/comfyui-state"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
client_id: api.clientId ?? api.initialClientId ?? "",
state: marker
? {
file_path: marker.file_path,
sha256: marker.sha256 || null,
dirty: marker.dirty === true,
}
: null,
}),
});
} catch (_error) {
// Backend unreachable or older version without the endpoint.
}
}
/**
* Core write-back: PUT the current graph into the managed document.
* Returns { success, sha256? , error? }. On success the marker is refreshed
* (new hash, dirty cleared) and the new state is reported. On 409 the user
* is asked to confirm the overwrite when `askOnConflict` is set.
*/
async function performWriteback(marker, { askOnConflict = true } = {}) {
let result;
try {
result = await requestWriteback(marker);
} catch (error) {
return { success: false, error: error.message };
}
if (result.status === 409) {
if (!askOnConflict) {
return { success: false, error: "conflict" };
}
const overwrite = window.confirm(
"The library copy of this workflow has changed since it was opened.\n" +
"Overwrite the LoRA Manager copy with the current graph?"
);
if (!overwrite) {
return { success: false, error: "cancelled" };
}
try {
result = await requestWriteback(marker, { force: true });
} catch (error) {
return { success: false, error: error.message };
}
}
if (result.status === 404) {
// The library entry was deleted/renamed while this tab held the
// marker; unlink so "Update" reverts to "Create".
clearLmSource();
reportLinkState();
return { success: false, error: "not_found" };
}
if (result.payload?.success) {
if (result.payload.sha256) {
marker.sha256 = result.payload.sha256;
}
marker.dirty = false;
reportLinkState();
return { success: true, sha256: result.payload.sha256 ?? null };
}
return {
success: false,
error: result.payload?.error || `HTTP ${result.status}`,
};
}
/**
* Write the active graph back into its LoRA Manager managed workflow
* document. No-op (with a warning toast) when the graph carries no marker.
*/
export async function updateManagedWorkflow() {
const marker = getActiveLmSource();
if (!marker) {
showToast({
severity: "warn",
summary: "LoRA Manager",
detail: "This workflow is not linked to the LoRA Manager library. Open it via LoRA Manager first.",
life: 5000,
});
return;
}
if (!(await isLinkedDocumentAlive(marker))) {
clearLmSource();
reportLinkState();
showToast({
severity: "warn",
summary: "LoRA Manager",
detail: "The library copy was deleted or moved. Create it again to keep editing.",
life: 5000,
});
return;
}
const result = await performWriteback(marker);
if (result.success) {
showToast({
severity: "success",
summary: "LoRA Manager",
detail: "Workflow updated in the LoRA Manager library.",
life: 3000,
});
} else if (result.error === "not_found") {
showToast({
severity: "warn",
summary: "LoRA Manager",
detail: "The library copy was deleted or moved. Create it again to keep editing.",
life: 5000,
});
} else if (result.error !== "cancelled") {
showToast({
severity: "error",
summary: "LoRA Manager",
detail: `Failed to update the managed workflow: ${result.error}`,
life: 5000,
});
}
}
/**
* Create a new LoRA Manager library workflow from the active graph.
*
* The backend writes the document into the single configured
* ``workflow_path`` folder and returns its path/hash; the marker is then
* stamped onto the live graph so updates work without reopening it. When the
* graph is already linked, confirm first: the new entry is a separate copy
* and the existing library document is left untouched.
*/
export async function createManagedWorkflow() {
const graph = getRootGraph();
if (!graph || typeof graph.serialize !== "function") {
showToast({
severity: "warn",
summary: "LoRA Manager",
detail: "No workflow is open to create.",
life: 5000,
});
return;
}
const existingMarker = getActiveLmSource();
if (existingMarker) {
// The LM entry may have been deleted/renamed since this tab loaded;
// only ask about a copy when the link is still real.
if (await isLinkedDocumentAlive(existingMarker)) {
const createCopy = window.confirm(
"This graph is already linked to a LoRA Manager workflow.\n" +
"Create a new library workflow from the current graph?"
);
if (!createCopy) {
return;
}
} else {
clearLmSource();
reportLinkState();
}
}
let response;
try {
response = await fetch(lmUrl("/api/lm/workflows/create"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: getActiveWorkflowName(),
workflow: serializeWorkflowForWriteback(),
preview_prompt_id: lastExecutedPromptId || undefined,
}),
});
} catch (error) {
showToast({
severity: "error",
summary: "LoRA Manager",
detail: `Failed to create the library workflow: ${error.message}`,
life: 5000,
});
return;
}
let payload = null;
try {
payload = await response.json();
} catch (_error) {
payload = null;
}
if (response.ok && payload?.success) {
stampLmSource(payload.file_path, payload.sha256);
reportLinkState();
const displayName = payload.name || getActiveWorkflowName();
showToast({
severity: "success",
summary: "LoRA Manager",
detail: `Created "${displayName}" in the LoRA Manager library.`,
life: 3000,
});
return;
}
showToast({
severity: "error",
summary: "LoRA Manager",
detail: `Failed to create the library workflow: ${payload?.error || `HTTP ${response.status}`}`,
life: 5000,
});
}
/**
* Answer an LM-web-UI "Update from ComfyUI" request. Only the tab whose
* active graph marker matches the requested file_path responds; the outcome
* is posted back to the backend, which resolves the pending LM-page request.
*/
async function handleCollectRequest(detail) {
const requestId = detail?.request_id;
const filePath = detail?.file_path;
if (!requestId || !filePath) {
return;
}
const marker = getActiveLmSource();
if (!marker || marker.file_path !== filePath) {
return;
}
const result = marker.dirty
? await performWriteback(marker)
: { success: false, error: "no_changes" };
try {
await fetch(lmUrl("/api/lm/workflows/collect-result"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ request_id: requestId, ...result }),
});
} catch (error) {
console.error("LoRA Manager: failed to post collect result", error);
}
}
// ---------------------------------------------------------------------------
// Workflow actions dropdown injection
// ---------------------------------------------------------------------------
const ITEM_CLASSES =
"flex min-h-6 items-center gap-2 self-stretch rounded-sm p-2 outline-none";
const ITEM_ACTIVE_CLASSES = "cursor-pointer hover:bg-secondary-background-hover";
const ITEM_DISABLED_CLASSES = "cursor-default opacity-50";
const SEPARATOR_CLASSES = "my-1 w-full border-b border-border-subtle";
// classList.add/remove accept multiple tokens, while classList.toggle expects
// a single token — keep the class strings and split them at the call site.
const ITEM_ACTIVE_TOKENS = ITEM_ACTIVE_CLASSES.split(/\s+/);
const ITEM_DISABLED_TOKENS = ITEM_DISABLED_CLASSES.split(/\s+/);
function applyItemState(item, active) {
const add = active ? ITEM_ACTIVE_TOKENS : ITEM_DISABLED_TOKENS;
const remove = active ? ITEM_DISABLED_TOKENS : ITEM_ACTIVE_TOKENS;
item.classList.remove(...remove);
item.classList.add(...add);
}
function closeMenu(item) {
// Our node is not a reka-ui DropdownMenuItem, so the menu does not close
// on its own — close it the way Escape would.
const menu = item.closest('[role="menu"]');
(menu ?? document).dispatchEvent(
new KeyboardEvent("keydown", { key: "Escape", bubbles: true })
);
}
function buildCreateItem() {
const canCreate = canCreateWorkflow();
const item = document.createElement("div");
item.setAttribute("role", "menuitem");
item.dataset.lmAction = "create-workflow";
item.className = `${ITEM_CLASSES} ${canCreate ? ITEM_ACTIVE_CLASSES : ITEM_DISABLED_CLASSES}`;
if (!canCreate) {
item.setAttribute("aria-disabled", "true");
item.title = createUnavailableTitle();
}
const icon = document.createElement("i");
icon.className = CREATE_MENU_ICON;
const label = document.createElement("span");
label.className = "flex-1";
label.textContent = CREATE_MENU_LABEL;
item.append(icon, label);
item.addEventListener("click", () => {
if (!canCreateWorkflow()) {
return;
}
closeMenu(item);
createManagedWorkflow();
});
return item;
}
/**
* Re-evaluate the create entry after the library state fetch resolves while
* the menu is still open (the item is built before the fetch completes).
*/
function applyCreateAvailability(item) {
if (!item) {
return;
}
const canCreate = canCreateWorkflow();
applyItemState(item, canCreate);
if (canCreate) {
item.removeAttribute("aria-disabled");
item.removeAttribute("title");
} else {
item.setAttribute("aria-disabled", "true");
item.title = createUnavailableTitle();
}
}
function applyUpdateLinkState(item) {
if (!item) {
return;
}
const linked = getActiveLmSource() !== null;
applyItemState(item, linked);
if (linked) {
item.removeAttribute("aria-disabled");
item.removeAttribute("title");
} else {
item.setAttribute("aria-disabled", "true");
item.title = "Open a workflow from LoRA Manager, or create one first";
}
}
function buildUpdateItem() {
const item = document.createElement("div");
item.setAttribute("role", "menuitem");
item.dataset.lmAction = "update-workflow";
item.className = ITEM_CLASSES;
const icon = document.createElement("i");
icon.className = MENU_ICON;
const label = document.createElement("span");
label.className = "flex-1";
label.textContent = MENU_LABEL;
item.append(icon, label);
item.addEventListener("click", () => {
if (getActiveLmSource() === null) {
return;
}
closeMenu(item);
updateManagedWorkflow();
});
applyUpdateLinkState(item);
return item;
}
function buildMenuItems() {
const separator = document.createElement("div");
separator.className = SEPARATOR_CLASSES;
separator.setAttribute("role", "separator");
return [separator, buildCreateItem(), buildUpdateItem()];
}
/**
* Append the LM entries to an open workflow actions menu, once per menu
* instance (the portal remounts on every open).
*/
function injectIntoMenu(menuElement) {
if (menuElement.dataset.lmWritebackInjected) {
return;
}
menuElement.dataset.lmWritebackInjected = "1";
for (const node of buildMenuItems()) {
menuElement.appendChild(node);
}
if (workflowLibraryState === null || !canCreateWorkflow()) {
const createItem = menuElement.querySelector(
'[data-lm-action="create-workflow"]'
);
refreshWorkflowLibraryState().then(() =>
applyCreateAvailability(createItem)
);
}
if (getActiveLmSource()) {
const updateItem = menuElement.querySelector(
'[data-lm-action="update-workflow"]'
);
validateActiveLink().then(() => applyUpdateLinkState(updateItem));
}
}
function findOpenWorkflowMenu() {
// The workflow actions menu is the reka-ui dropdown containing the
// core items (pencil = Rename, save = Save, download = Export); icon
// classes are locale-independent.
for (const menu of document.querySelectorAll('[role="menu"]')) {
// The workflow tab context menu has the same core icons; skip it so
// the two injection paths never write into each other's menu.
if (menu.querySelector(".pi-arrows-h")) {
continue;
}
if (
menu.querySelector(".pi-pencil") &&
menu.querySelector(".pi-save") &&
menu.querySelector(".pi-download")
) {
return menu;
}
}
return null;
}
// ---------------------------------------------------------------------------
// Workflow tab context menu injection
// ---------------------------------------------------------------------------
//
// ComfyUI exposes commands and the File menu to extensions, but the workflow
// tab context menu is built inside WorkflowTab.vue with no extension hook.
// Fall back to the same portal injection used for the Graph menu: the items
// still call the registered create/update actions, and they first activate the
// right-clicked tab so Save-style actions always target that workflow.
const TAB_UPDATE_UNAVAILABLE_TITLE =
"This workflow is not linked to the LoRA Manager library";
function getTabWorkflow(tab) {
// Scope to the tab strip so a hidden second view (graph vs linear) can
// never shift the index that maps a tab to openWorkflows.
const container = tab.closest(".workflow-tabs-container") ?? document;
const index = Array.from(container.querySelectorAll(".workflow-tab")).indexOf(
tab
);
if (index < 0) {
return null;
}
const workflow = app.extensionManager?.workflow?.openWorkflows?.[index];
return workflow && typeof workflow === "object" ? workflow : null;
}
function getTabLmSource(tab) {
const workflow = getTabWorkflow(tab);
const store = app.extensionManager?.workflow;
if (!workflow || !store) {
return null;
}
if (store.activeWorkflow === workflow) {
return getActiveLmSource();
}
const marker = workflow.activeState?.extra?.[MARKER_KEY];
return marker &&
typeof marker === "object" &&
typeof marker.file_path === "string"
? marker
: null;
}
/**
* Make the right-clicked tab active, the way the native Save/Export entries
* do, so the LM action operates on that tab instead of whichever graph was
* previously active.
*/
async function activateTabWorkflow(tab) {
const workflow = getTabWorkflow(tab);
const store = app.extensionManager?.workflow;
if (!workflow || !store) {
return false;
}
if (store.activeWorkflow !== workflow) {
if (typeof store.openWorkflow !== "function") {
return false;
}
await store.openWorkflow(workflow);
}
return true;
}
function applyTabUpdateState(item, tab) {
if (!item) {
return;
}
const linked = getTabLmSource(tab) !== null;
applyItemState(item, linked);
if (linked) {
item.removeAttribute("aria-disabled");
item.removeAttribute("title");
} else {
item.setAttribute("aria-disabled", "true");
item.title = TAB_UPDATE_UNAVAILABLE_TITLE;
}
}
function buildTabActionItem(action, label, icon, tab) {
const item = document.createElement("div");
item.setAttribute("role", "menuitem");
item.dataset.lmTabAction =
action === "create" ? "create-workflow" : "update-workflow";
item.className = ITEM_CLASSES;
const iconElement = document.createElement("i");
iconElement.className = icon;
const labelElement = document.createElement("span");
labelElement.className = "flex-1";
labelElement.textContent = label;
item.append(iconElement, labelElement);
item.addEventListener("click", async (event) => {
event.preventDefault();
event.stopPropagation();
if (item.getAttribute("aria-disabled") === "true") {
return;
}
closeMenu(item);
try {
if (!(await activateTabWorkflow(tab))) {
return;
}
} catch (error) {
console.warn("LoRA Manager: could not activate workflow tab", error);
showToast({
severity: "warn",
summary: "LoRA Manager",
detail: "Could not switch to that workflow tab.",
life: 5000,
});
return;
}
if (action === "create") {
await createManagedWorkflow();
} else {
await updateManagedWorkflow();
}
});
return item;
}
function findTabMenuCloseSeparator(menu) {
// The last separator divides the workflow actions from the tab-close
// group; put the LM group just above it.
let separators = Array.from(menu.querySelectorAll('[role="separator"]'));
if (separators.length === 0) {
separators = Array.from(
menu.querySelectorAll(".my-1.w-full.border-b")
);
}
return separators.at(-1) ?? null;
}
function findTabContextMenu(tab) {
// reka-ui links the open trigger to its portalled content. Use that when
// available; otherwise pick the visible menu that carries the distinctive
// tab-close overlay icon (pi-arrows-h).
const contentId = tab.getAttribute("aria-controls");
if (contentId) {
const menu = document.getElementById(contentId);
if (
menu &&
menu.getAttribute("role") === "menu" &&
menu.dataset.state !== "closed"
) {
return menu;
}
}
const menus = Array.from(document.querySelectorAll('[role="menu"]')).filter(
(menu) => menu.getClientRects().length > 0 && menu.querySelector(".pi-arrows-h")
);
return menus.at(-1) ?? null;
}
/**
* Append the LM entries to the workflow tab context menu, once per menu
* instance. Unlike the Graph menu, Update reflects the marker of the
* right-clicked tab, not only the active graph.
*/
function injectIntoTabMenu(tab) {
const menu = findTabContextMenu(tab);
if (!menu || menu.dataset.lmTabMenuInjected) {
return;
}
// Wait until the tab-close group has rendered (pi-arrows-h belongs to
// Close Other Tabs) so the LM group is inserted above it, not after a
// half-built menu.
if (!menu.querySelector(".pi-arrows-h")) {
return;
}
menu.dataset.lmTabMenuInjected = "1";
const createItem = buildTabActionItem(
"create",
CREATE_MENU_LABEL,
CREATE_MENU_ICON,
tab
);
const updateItem = buildTabActionItem(
"update",
MENU_LABEL,
MENU_ICON,
tab
);
applyCreateAvailability(createItem);
applyTabUpdateState(updateItem, tab);
const separator = document.createElement("div");
separator.setAttribute("role", "separator");
separator.className = SEPARATOR_CLASSES;
const anchor = findTabMenuCloseSeparator(menu);
if (anchor) {
anchor.before(separator, createItem, updateItem);
} else {
menu.append(separator, createItem, updateItem);
}
const marker = getTabLmSource(tab);
if (marker) {
// An inactive tab can carry a marker whose library entry was already
// deleted. Keep Update disabled for that case; opening the tab later
// clears the marker through afterConfigureGraph.
isLinkedDocumentAlive(marker).then((alive) => {
if (!alive && updateItem.isConnected) {
applyItemState(updateItem, false);
updateItem.setAttribute("aria-disabled", "true");
updateItem.title = "The library copy was deleted or moved";
}
});
}
if (workflowLibraryState === null) {
refreshWorkflowLibraryState().then(() => {
if (createItem.isConnected) {
applyCreateAvailability(createItem);
}
});
}
}
function safeInjectIntoTabMenu(tab) {
try {
injectIntoTabMenu(tab);
} catch (error) {
console.warn("LoRA Manager: workflow tab menu injection failed", error);
}
}
function installTabContextMenuInjection() {
let lastContextTab = null;
const scheduleInjection = (tab) => {
// The portal mounts after the contextmenu event; poll briefly.
for (const delay of [0, 40, 120]) {
setTimeout(() => {
if (tab.isConnected) {
safeInjectIntoTabMenu(tab);
}
}, delay);
}
};
document.addEventListener(
"contextmenu",
(event) => {
const target = event.target;
if (!(target instanceof Element)) {
return;
}
const tab = target.closest(".workflow-tab");
if (!tab) {
return;
}
lastContextTab = tab;
scheduleInjection(tab);
},
true
);
// Keyboard/long-press openings do not fire a contextmenu event on the
// tab; catch the menu as it mounts while a tab is in the open state.
// Ignore unrelated DOM churn so graph edits do not trigger menu scans.
const observer = new MutationObserver((mutations) => {
let menuAdded = false;
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (
node instanceof Element &&
(node.matches('[role="menu"]') ||
node.querySelector('[role="menu"]'))
) {
menuAdded = true;
break;
}
}
if (menuAdded) {
break;
}
}
if (!menuAdded) {
return;
}
const tab =
document.querySelector('.workflow-tab[data-state="open"]') ??
lastContextTab;
if (!tab || !tab.isConnected) {
return;
}
const menu = findTabContextMenu(tab);
if (menu && !menu.dataset.lmTabMenuInjected) {
safeInjectIntoTabMenu(tab);
}
});
observer.observe(document.body, { childList: true, subtree: true });
}
function installDropdownInjection() {
let pollTimer = null;
const scheduleInjection = () => {
if (pollTimer != null) {
clearTimeout(pollTimer);
}
// The portal mounts asynchronously after the trigger click; poll
// briefly instead of assuming a fixed delay.
let attempts = 0;
const poll = () => {
pollTimer = null;
const menu = findOpenWorkflowMenu();
if (menu) {
injectIntoMenu(menu);
return;
}
attempts += 1;
if (attempts < 10) {
pollTimer = setTimeout(poll, 50);
}
};
pollTimer = setTimeout(poll, 0);
};
// The trigger itself mounts late (frontend boot) and can remount on mode
// switches, so observe its appearance and hook clicks on it.
const hookTrigger = () => {
const trigger = document.querySelector(`[data-testid="${DROPDOWN_TRIGGER_TESTID}"]`);
if (trigger && !trigger.dataset.lmWritebackHooked) {
trigger.dataset.lmWritebackHooked = "1";
trigger.addEventListener("click", scheduleInjection);
}
};
const observer = new MutationObserver(() => hookTrigger());
observer.observe(document.body, { childList: true, subtree: true });
hookTrigger();
}
app.registerExtension({
name: "LoraManager.WorkflowWriteback",
commands: [
{
id: CREATE_COMMAND_ID,
label: CREATE_MENU_LABEL,
menubarLabel: CREATE_MENU_LABEL,
icon: CREATE_MENU_ICON,
function: () => createManagedWorkflow(),
},
{
id: COMMAND_ID,
label: MENU_LABEL,
menubarLabel: MENU_LABEL,
icon: MENU_ICON,
function: () => updateManagedWorkflow(),
},
],
menuCommands: [
{ path: ["File"], commands: [CREATE_COMMAND_ID, COMMAND_ID] },
],
setup() {
try {
installDropdownInjection();
} catch (error) {
console.warn("LoRA Manager: workflow dropdown injection unavailable", error);
}
try {
installTabContextMenuInjection();
} catch (error) {
console.warn("LoRA Manager: workflow tab menu injection unavailable", error);
}
// Resolve whether the library can accept a new entry before the user
// opens the menu; failures leave the entry optimistically enabled and
// the create request itself reports the real error.
refreshWorkflowLibraryState();
// Track this tab's executed prompt so Create can attach its decode
// image as the workflow preview.
installPromptTracking();
// graphChanged is dispatched by ComfyUI's ChangeTracker only when the
// serialized state actually changed: the first one after a load or
// write-back is a genuine user edit.
api.addEventListener("graphChanged", () => {
const marker = getActiveLmSource();
if (marker && marker.dirty !== true) {
marker.dirty = true;
reportLinkState();
}
});
api.addEventListener("lm_collect_workflow", (event) =>
handleCollectRequest(event?.detail ?? {})
);
},
// Fires on initial load, workflow open, and tab switch: drop a stale
// marker (library entry deleted/renamed) before reporting which managed
// workflow is active now.
async afterConfigureGraph() {
// A different workflow is active now: never borrow its predecessor's
// executed prompt for the next Create preview.
lastExecutedPromptId = null;
await validateActiveLink();
await reportLinkState();
},
});