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.
This commit is contained in:
@@ -152,6 +152,133 @@ describe("LoraManager.WorkflowWriteback", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("workflow tab context menu injection", () => {
|
||||
function mountTabs({ marker = null } = {}) {
|
||||
document.body.innerHTML = `
|
||||
<div class="workflow-tab" data-state="closed" aria-controls="tab-menu-other">
|
||||
<span class="workflow-label">Other Flow</span>
|
||||
</div>
|
||||
<div class="workflow-tab" data-state="closed" aria-controls="tab-menu-target">
|
||||
<span class="workflow-label">My Flow</span>
|
||||
</div>
|
||||
<div id="tab-menu-target" role="menu">
|
||||
<div role="menuitem"><i class="pi pi-pencil"></i><span>Rename</span></div>
|
||||
<div role="separator"></div>
|
||||
<div role="menuitem"><i class="pi pi-save"></i><span>Save</span></div>
|
||||
<div role="separator"></div>
|
||||
<div role="menuitem"><i class="pi pi-times"></i><span>Close Tab</span></div>
|
||||
<div role="menuitem">
|
||||
<span><i class="pi pi-times"></i><i class="pi pi-arrows-h"></i></span>
|
||||
<span>Close Other Tabs</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user