diff --git a/tests/frontend/core/workflowWriteback.test.js b/tests/frontend/core/workflowWriteback.test.js index 809a00ee..7a7b66c8 100644 --- a/tests/frontend/core/workflowWriteback.test.js +++ b/tests/frontend/core/workflowWriteback.test.js @@ -152,6 +152,133 @@ describe("LoraManager.WorkflowWriteback", () => { }); }); + describe("workflow tab context menu injection", () => { + function mountTabs({ marker = null } = {}) { + document.body.innerHTML = ` +
+ Other Flow +
+
+ My Flow +
+ + `; + const activeWorkflow = { filename: "Other Flow" }; + const targetWorkflow = { + filename: "My Flow", + activeState: marker ? { extra: { lm_source: marker } } : {}, + }; + appMock.extensionManager.workflow = { + activeWorkflow, + openWorkflows: [activeWorkflow, targetWorkflow], + openWorkflow: vi.fn(), + }; + return { activeWorkflow, targetWorkflow }; + } + + function openTabMenu() { + const tabs = document.querySelectorAll(".workflow-tab"); + tabs[1].dispatchEvent( + new MouseEvent("contextmenu", { bubbles: true, cancelable: true }) + ); + } + + it("adds create/update before the close group using the tab's own marker", async () => { + const marker = { file_path: "/lib/target.json", sha256: "abc" }; + mountTabs({ marker }); + appMock.rootGraph = createRootGraph(); + extension.setup(); + + openTabMenu(); + await vi.waitFor(() => { + expect( + document.querySelector('[data-lm-tab-action="create-workflow"]') + ).not.toBeNull(); + }); + + const menu = document.getElementById("tab-menu-target"); + const createItem = menu.querySelector( + '[data-lm-tab-action="create-workflow"]' + ); + const updateItem = menu.querySelector( + '[data-lm-tab-action="update-workflow"]' + ); + expect(createItem.textContent).toContain("Create LM-managed workflow"); + expect(updateItem.textContent).toContain("Update LM-managed workflow"); + // Active graph has no marker, but the right-clicked tab does. + expect(updateItem.className).toContain("cursor-pointer"); + // Both keep the native menu-item layout and sit before Close Tab. + for (const item of [createItem, updateItem]) { + expect(item.className).toContain("flex"); + expect(item.className).toContain("p-2"); + } + const closeTab = Array.from(menu.querySelectorAll('[role="menuitem"]')).find( + (item) => item.textContent.includes("Close Tab") + ); + expect( + createItem.compareDocumentPosition(closeTab) & + Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); + // Re-opening the same menu instance must not duplicate the entries. + openTabMenu(); + await new Promise((resolve) => setTimeout(resolve, 150)); + expect( + menu.querySelectorAll('[data-lm-tab-action="create-workflow"]').length + ).toBe(1); + }); + + it("activates the right-clicked tab before updating it", async () => { + const marker = { file_path: "/lib/target.json", sha256: "abc" }; + const { targetWorkflow } = mountTabs({ marker }); + appMock.rootGraph = createRootGraph(); + const openWorkflow = appMock.extensionManager.workflow.openWorkflow; + openWorkflow.mockImplementation(async (workflow) => { + appMock.extensionManager.workflow.activeWorkflow = workflow; + appMock.rootGraph = createRootGraph({ marker }); + return workflow; + }); + fetchMock.mockImplementation(async (url) => { + if ( + typeof url === "string" && + url.startsWith("/api/lm/workflows/link-status") + ) { + return okResponse({ success: true, exists: true }); + } + return okResponse(); + }); + extension.setup(); + + openTabMenu(); + await vi.waitFor(() => { + expect( + document.querySelector('[data-lm-tab-action="update-workflow"]') + ).not.toBeNull(); + }); + document + .querySelector('[data-lm-tab-action="update-workflow"]') + .dispatchEvent(new MouseEvent("click", { bubbles: true })); + + await vi.waitFor(() => { + expect(fetchCalls(fetchMock, "/api/lm/workflows/content")).toHaveLength(1); + }); + expect(openWorkflow).toHaveBeenCalledWith(targetWorkflow); + const body = JSON.parse( + fetchCalls(fetchMock, "/api/lm/workflows/content")[0][1].body + ); + expect(body.file_path).toBe(marker.file_path); + }); + }); + describe("getActiveLmSource", () => { it("returns null when the graph carries no marker", () => { appMock.rootGraph = createRootGraph(); diff --git a/web/comfyui/workflow_writeback.js b/web/comfyui/workflow_writeback.js index 5a26083b..82ac4554 100644 --- a/web/comfyui/workflow_writeback.js +++ b/web/comfyui/workflow_writeback.js @@ -681,6 +681,11 @@ function findOpenWorkflowMenu() { // 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") && @@ -692,6 +697,305 @@ function findOpenWorkflowMenu() { 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 = () => { @@ -762,6 +1066,12 @@ app.registerExtension({ 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.