Files
MajoorWaldi 6b34e87098 feat: opt-in folder browsing default + browse-mode index kickoff
- Default 'Show folders in Input / Output panels' (browser.showFolders) to
  disabled: fresh installs keep the classic flat grid until the user opts in
  (backend settings service, frontend defaults, settings UI toggle).
- Gate the root-level 'Create folder here' context menu on the setting.
- Kick off a throttled incremental background scan when browse mode serves
  the first page, so unindexed files get indexed/enriched and asset status
  dots resolve instead of staying pending (blue).
- Document the feature and setting in README, CHANGELOG, and
  docs/SETTINGS_CONFIGURATION.md.
2026-08-06 18:07:26 +02:00

1945 lines
71 KiB
TypeScript

/**
* GridContextMenu.js
*
* Vue now renders the menu DOM. This module only:
* - detects right-click context
* - computes the menu model
* - executes menu actions
*/
import {
browserFolderOp,
collectFiles,
deleteAsset,
deleteWorkflow,
duplicateWorkflow,
getAssetMetadata,
getWorkflowContent,
listWorkflowThumbnailCandidates,
markWorkflowLoaded,
moveWorkflow,
openInFolder,
post,
removeFilepathsFromCollection,
renameAsset,
setWorkflowThumbnail,
updateAssetRating,
} from "../../api/client.js";
import { ENDPOINTS, buildBatchZipDownloadURL, buildDownloadURL } from "../../api/endpoints.js";
import { ASSET_RATING_CHANGED_EVENT } from "../../app/events.js";
import { t } from "../../app/i18n.js";
import { loadMajoorSettings } from "../../app/settings/settingsCore.js";
import { comfyConfirm, comfyPrompt } from "../../app/dialogs.js";
import { comfyToast } from "../../app/toast.js";
import { APP_CONFIG } from "../../app/config.js";
import { createWorkflowDot } from "../../components/Badges.js";
import { normalizeRenameFilename, validateFilename } from "../../utils/filenames.js";
import { reportError } from "../../utils/logging.js";
import { safeClosest, safeEscapeSelector } from "../../utils/dom.js";
import { safeDispatchCustomEvent } from "../../utils/events.js";
import { confirmDeletion } from "../../utils/deleteGuard.js";
import { showAddToCollectionMenu } from "../collections/contextmenu/addToCollectionMenu.js";
import { removeAssetsFromGrid } from "../grid/gridApi.js";
import {
getRawHostApp,
importWorkflowPreferHostTab,
isHostWorkflowDirty,
} from "../../app/hostAdapter.js";
import { createSecureToken, pickRootId } from "../../utils/ids.js";
import { getShortcutDisplay } from "../grid/GridKeyboard.js";
import {
closeAllGridContextMenus,
openGridContextMenu,
openGridTagsPopover,
} from "./gridContextMenuState.js";
import { cancelAllRatingUpdates, scheduleRatingUpdate } from "./ratingUpdater.js";
import { createCanvasLoaderNodes } from "../dnd/canvasLoaderNode.js";
import { stageToInputDetailed } from "../dnd/staging/stageToInput.js";
import { resolveAssetWorkflow } from "../viewer/workflowGraphMap/workflowGraphMapData.js";
import { openWorkflowAssetPicker, openWorkflowPicker } from "../workflows/workflowPickerState.js";
import { openWorkflowInfoDialog } from "../workflows/workflowInfoState.js";
let _nextMenuItemId = 1;
let viewerOpenRequestModulePromise: Promise<typeof import("../viewer/viewerOpenRequest.js")> | null = null;
let floatingViewerManagerModulePromise: Promise<typeof import("../viewer/floatingViewerManager.js")> | null = null;
function loadViewerOpenRequestModule() {
if (!viewerOpenRequestModulePromise) {
viewerOpenRequestModulePromise = import("../viewer/viewerOpenRequest.js");
}
return viewerOpenRequestModulePromise;
}
function loadFloatingViewerManagerModule() {
if (!floatingViewerManagerModulePromise) {
floatingViewerManagerModulePromise = import("../viewer/floatingViewerManager.js");
}
return floatingViewerManagerModulePromise;
}
function inferItemTone(label: any) {
const normalized = String(label || "").trim().toLowerCase();
if (!normalized) return "";
if (normalized.includes("open graph map")) return "graph-map";
if (normalized.includes("open in floating viewer") || normalized.includes("open floating viewer")) {
return "floating-viewer";
}
if (normalized.includes("rename")) return "rename";
if (normalized.includes("delete")) return "delete";
return "";
}
function createItem(
label: any,
iconClass: any,
rightHint: any,
action: any,
{ disabled = false, closeOnSelect = true, submenu = null }: { disabled?: boolean; closeOnSelect?: boolean; submenu?: any[] | null } = {},
) {
return {
id: `mjr-grid-menu-item-${_nextMenuItemId++}`,
type: "item",
label: String(label || ""),
iconClass: iconClass ? String(iconClass) : "",
rightHint: rightHint ? String(rightHint) : "",
tone: inferItemTone(label),
disabled: !!disabled,
closeOnSelect,
submenu: Array.isArray(submenu) && submenu.length ? submenu : null,
action: typeof action === "function" ? action : null,
};
}
function createSeparator(): any {
return {
id: `mjr-grid-menu-separator-${_nextMenuItemId++}`,
type: "separator",
};
}
const getSelectedAssetIds = (gridContainer: any) => {
const selected = new Set();
if (!gridContainer) return selected;
try {
const raw = gridContainer.dataset?.mjrSelectedAssetIds;
if (raw) {
const list = JSON.parse(raw);
if (Array.isArray(list)) {
list.forEach((id: any) => selected.add(String(id)));
return selected;
}
}
} catch (e: any) {
console.debug?.(e);
}
try {
const cards = gridContainer.querySelectorAll(".mjr-asset-card.is-selected");
for (const card of cards) {
const id = card.dataset?.mjrAssetId;
if (id) selected.add(String(id));
}
} catch (e: any) {
console.debug?.(e);
}
return selected;
};
const getSelectedAssets = (gridContainer: any) => {
const assets: any[] = [];
if (!gridContainer) return assets;
try {
const selected =
typeof gridContainer?._mjrGetSelectedAssets === "function"
? gridContainer._mjrGetSelectedAssets()
: [];
if (Array.isArray(selected) && selected.length) {
return selected.filter((asset: any) => asset && typeof asset === "object");
}
} catch (e: any) {
console.debug?.(e);
}
try {
const raw = gridContainer.dataset?.mjrSelectedAssetIds;
if (raw) {
const ids = JSON.parse(raw);
if (Array.isArray(ids) && ids.length) {
const all =
typeof gridContainer?._mjrGetAssets === "function"
? gridContainer._mjrGetAssets()
: [];
const idSet = new Set(ids.map(String));
for (const asset of all || []) {
const id = String(asset?.id || "");
if (id && idSet.has(id)) assets.push(asset);
}
if (assets.length) return assets;
}
}
} catch (e: any) {
console.debug?.(e);
}
try {
const cards = gridContainer.querySelectorAll(".mjr-asset-card.is-selected");
for (const card of cards) {
const asset = card?._mjrAsset;
if (asset) assets.push(asset);
}
} catch (e: any) {
console.debug?.(e);
}
return assets;
};
async function enrichWorkflowGraphAsset(asset: any) {
const filepath = String(asset?.filepath || asset?.path || asset?.full_path || "").trim();
if (!filepath) return asset;
try {
const result = await getWorkflowContent(filepath, { timeoutMs: 25_000 });
const workflow = result?.data?.workflow || result?.workflow || null;
const prompt = result?.data?.prompt || result?.prompt || null;
if (!workflow && !prompt) return asset;
return {
...asset,
workflow: workflow || asset?.workflow,
prompt: prompt || asset?.prompt,
metadata_raw: {
...(asset?.metadata_raw && typeof asset.metadata_raw === "object"
? asset.metadata_raw
: {}),
...(workflow ? { workflow } : {}),
...(prompt ? { prompt } : {}),
},
};
} catch (e: any) {
console.debug?.(e);
return asset;
}
}
const getAssetFilepath = (asset: any) => {
try {
const filepath = asset?.filepath || asset?.path || asset?.file_info?.filepath || "";
return filepath ? String(filepath) : "";
} catch {
return "";
}
};
const buildDndPayloadFromAsset = (asset: any) => {
if (!asset || typeof asset !== "object") return null;
const filename = String(asset.filename || "").trim();
if (!filename) return null;
return {
filename,
subfolder: asset.subfolder || "",
type: String(asset.type || asset.source || "output").toLowerCase(),
root_id: pickRootId(asset) || undefined,
kind: String(asset.kind || "").toLowerCase(),
};
};
const buildBatchZipItemFromAsset = (asset: any) => {
if (!asset || typeof asset !== "object") return null;
const filename = String(asset.filename || asset.name || "").trim();
if (!filename) return null;
return {
filename,
subfolder: asset.subfolder || "",
type: String(asset.type || asset.source || "output").toLowerCase(),
root_id: pickRootId(asset) || undefined,
};
};
function triggerDownload(url: any, filename = "") {
if (!url) return;
const link = document.createElement("a");
link.href = url;
if (filename) link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
async function downloadSelectedAsZip(assets: any) {
const list = Array.isArray(assets) ? assets.filter((entry: any) => !_isFolderAsset(entry)) : [];
const items = list.map(buildBatchZipItemFromAsset).filter(Boolean);
if (items.length < 2) {
comfyToast("Select at least two files to download a ZIP.", "warning", 2600);
return;
}
let token = "";
try {
token = createSecureToken("mjr_", 24);
} catch (error: any) {
reportError(error, "[GridContextMenu] Batch ZIP token", {
showToast: APP_CONFIG.DEBUG_VERBOSE_ERRORS,
});
comfyToast("Could not create a secure ZIP token.", "error");
return;
}
comfyToast(`Preparing ZIP for ${items.length} files...`, "info", 1800);
const res = await post(ENDPOINTS.BATCH_ZIP_CREATE, {
token,
items,
strip_metadata: false,
});
if (!res?.ok) {
comfyToast(res?.error || "Failed to create ZIP.", "error");
return;
}
const filename = res?.data?.filename || `Majoor_Batch_${items.length}.zip`;
triggerDownload(buildBatchZipDownloadURL(token), filename);
comfyToast(`Downloading ${filename}...`, "info", 3000);
}
async function loadAssetsToCanvas(assets: any) {
const list = Array.isArray(assets) ? assets.filter(Boolean) : [];
const stagedItems = await Promise.all(
list.map(async (asset) => {
const payload = buildDndPayloadFromAsset(asset);
if (!payload) return null;
const staged = await stageToInputDetailed({
post,
endpoint: ENDPOINTS.STAGE_TO_INPUT,
payload,
index: false,
});
const relativePath = staged?.relativePath;
if (!relativePath) return null;
return {
payload,
relativePath,
droppedExt:
String(payload.filename || "")
.split(".")
.pop() || "",
};
}),
);
return createCanvasLoaderNodes({
app: getRawHostApp(),
items: stagedItems.filter(Boolean),
event: null,
});
}
const clearSelection = (gridContainer: any) => {
if (!gridContainer) return;
try {
if (typeof gridContainer?._mjrSetSelection === "function") {
gridContainer._mjrSetSelection([], "");
} else {
const cards = gridContainer.querySelectorAll(".mjr-asset-card.is-selected");
for (const card of cards) {
card.classList.remove("is-selected");
card.setAttribute("aria-selected", "false");
}
delete gridContainer.dataset.mjrSelectedAssetIds;
delete gridContainer.dataset.mjrSelectedAssetId;
}
} catch (e: any) {
console.debug?.(e);
}
safeDispatchCustomEvent(
"mjr:selection-changed",
{ selectedIds: [] },
{ target: gridContainer, warnPrefix: "[GridContextMenu]" },
);
};
const selectCardForDetails = (gridContainer: any, card: any, asset: any, state: any) => {
if (!gridContainer) return;
try {
clearSelection(gridContainer);
} catch (e: any) {
console.debug?.(e);
}
try {
card?.classList?.add?.("is-selected");
card?.setAttribute?.("aria-selected", "true");
} catch (e: any) {
console.debug?.(e);
}
const id = asset?.id != null ? String(asset.id) : "";
if (id) {
try {
if (typeof gridContainer?._mjrSetSelection === "function") {
gridContainer._mjrSetSelection([id], id);
} else {
gridContainer.dataset.mjrSelectedAssetIds = JSON.stringify([id]);
gridContainer.dataset.mjrSelectedAssetId = id;
}
} catch (e: any) {
console.debug?.(e);
}
}
if (state && typeof state === "object") {
try {
state.selectedAssetIds = id ? [id] : [];
state.activeAssetId = id;
} catch (e: any) {
console.debug?.(e);
}
}
};
const getAllAssetsInGrid = (gridContainer: any) => {
const assets: any[] = [];
if (!gridContainer) return assets;
try {
const getter = gridContainer._mjrGetAssets;
if (typeof getter === "function") {
const list = getter();
if (Array.isArray(list) && list.length) return list;
}
} catch (e: any) {
console.debug?.(e);
}
try {
const cards = gridContainer.querySelectorAll(".mjr-asset-card");
for (const card of cards) {
const asset = card?._mjrAsset;
if (asset) assets.push(asset);
}
} catch (e: any) {
console.debug?.(e);
}
return assets;
};
const findIndexById = (assets: any, assetId: any) => {
try {
const id = String(assetId ?? "");
return (assets || []).findIndex((asset: any) => String(asset?.id ?? "") === id);
} catch {
return -1;
}
};
function findRenderedCard(assetId: any) {
if (assetId == null) return null;
try {
return document.querySelector(`[data-mjr-asset-id="${safeEscapeSelector(assetId)}"]`);
} catch (e: any) {
console.debug?.(e);
return null;
}
}
function syncRenderedCardAsset(asset: any, patch: any = null) {
const card = findRenderedCard(asset?.id);
if (!card || !(card as any)._mjrAsset || typeof (card as any)._mjrAsset !== "object") return card;
try {
if (patch && typeof patch === "object") Object.assign((card as any)._mjrAsset, patch);
else if (asset && typeof asset === "object") Object.assign((card as any)._mjrAsset, asset);
} catch (e: any) {
console.debug?.(e);
}
return card;
}
const triggerBrowserGridReload = (gridContainer: any) => {
try {
const reason = gridContainer ? "grid-contextmenu" : "grid-contextmenu-global";
window?.dispatchEvent?.(new CustomEvent("mjr:reload-grid", { detail: { reason } }));
} catch (e: any) {
console.debug?.(e);
}
};
async function resetIndexForSingleAsset(asset: any, gridContainer: any) {
if (!asset) return { ok: false, error: "Missing asset" };
const fileEntry = {
filename: String(asset.filename || "").trim(),
subfolder: String(asset.subfolder || "").trim(),
type:
String(asset.type || asset.source || "output")
.trim()
.toLowerCase() || "output",
root_id:
String(asset.root_id || asset.rootId || asset?.file_info?.root_id || "").trim() ||
undefined,
};
if (!fileEntry.filename) return { ok: false, error: "Missing filename" };
const indexRes = await post(ENDPOINTS.INDEX_FILES, { files: [fileEntry], incremental: false });
if (!indexRes?.ok) return indexRes || { ok: false, error: "Index refresh failed" };
if (asset?.id != null) {
try {
const fresh = await getAssetMetadata(asset.id);
if (fresh?.ok && fresh?.data && typeof fresh.data === "object") {
Object.assign(asset, fresh.data);
const card = syncRenderedCardAsset(asset, fresh.data);
const oldDot = card?.querySelector?.(".mjr-workflow-dot");
if (oldDot) {
const nextDot = createWorkflowDot((card as any)._mjrAsset);
if (nextDot) oldDot.replaceWith(nextDot);
}
}
} catch (e: any) {
console.debug?.(e);
}
}
triggerBrowserGridReload(gridContainer);
return { ok: true, data: { refreshed: true } };
}
function setRating(asset: any, rating: any, onChanged: any) {
const assetId = asset?.id;
try {
asset.rating = rating;
} catch (e: any) {
console.debug?.(e);
}
try {
onChanged?.();
} catch (e: any) {
console.debug?.(e);
}
if (assetId) {
scheduleRatingUpdate(String(assetId), rating, {
successMessage: rating > 0 ? `Rating set to ${rating} stars` : "Rating cleared",
errorMessage: "Failed to update rating",
warnPrefix: "[GridContextMenu]",
onSuccess: () => {
safeDispatchCustomEvent(
ASSET_RATING_CHANGED_EVENT,
{ assetId: String(assetId), rating },
{ warnPrefix: "[GridContextMenu]" },
);
},
onFailure: (error) => {
reportError(error, "[GridContextMenu] Rating update", {
showToast: APP_CONFIG.DEBUG_VERBOSE_ERRORS,
});
},
});
return;
}
updateAssetRating(asset, rating).catch((error: any) => {
reportError(error, "[GridContextMenu] Rating update", {
showToast: APP_CONFIG.DEBUG_VERBOSE_ERRORS,
});
});
}
function _isBrowserScope(panelState: any) {
const scope = String(panelState?.scope || "").toLowerCase();
return scope === "custom" || scope === "input" || scope === "output";
}
function _browserFoldersEnabled() {
try {
const settings = loadMajoorSettings();
return !!(settings?.browser?.showFolders ?? false);
} catch {
return false;
}
}
function _isWorkflowScope(panelState: any) {
return String(panelState?.scope || "").toLowerCase() === "workflow";
}
function _isFolderAsset(asset: any) {
return String(asset?.kind || "").toLowerCase() === "folder";
}
function _isWorkflowAsset(asset: any) {
return String(asset?.kind || "").toLowerCase() === "workflow";
}
function _isWorkflowThumbnailSourceAsset(asset: any) {
if (!asset || _isFolderAsset(asset) || _isWorkflowAsset(asset)) return false;
const filepath = getAssetFilepath(asset);
if (!filepath) return false;
const kind = String(asset?.kind || "").toLowerCase();
if (kind === "image" || kind === "video") return true;
const ext = String(filepath.split(".").pop() || "").toLowerCase();
return ["png", "jpg", "jpeg", "webp", "gif", "mp4", "webm", "mov", "mkv", "avi", "m4v"].includes(ext);
}
async function _resolveHydratedAssetWorkflow(asset: any) {
if (!asset || _isFolderAsset(asset)) return null;
const directWorkflow = resolveAssetWorkflow(asset);
if (directWorkflow) return directWorkflow;
const assetId = asset?.id;
if (assetId == null || assetId === "") return null;
try {
const result = await getAssetMetadata(assetId, { timeoutMs: 30_000 });
const freshAsset = result?.data;
if (result?.ok && freshAsset && typeof freshAsset === "object") {
Object.assign(asset, freshAsset);
return resolveAssetWorkflow(asset);
}
} catch (e: any) {
console.debug?.(e);
}
return null;
}
async function _openAssetWorkflowAsNewTab(asset: any) {
const workflow = await _resolveHydratedAssetWorkflow(asset);
if (!workflow || typeof workflow !== "object") {
comfyToast(
t(
"toast.assetWorkflowMissing",
"No embedded ComfyUI workflow was found for this asset.",
),
"warn",
2600,
);
return;
}
const hostApp = getRawHostApp();
const importResult = importWorkflowPreferHostTab(workflow, hostApp);
if (!importResult.ok) {
comfyToast(
t(
"toast.workflowImportUnavailable",
"ComfyUI workflow import is unavailable in this frontend.",
),
"error",
);
return;
}
const message =
importResult.mode === "new-tab"
? t("toast.workflowLoadedNewTab", "Workflow loaded in a new ComfyUI tab.")
: t("toast.workflowLoaded", "Workflow loaded");
comfyToast(message, "success", 1800);
}
async function _loadWorkflowAsset(asset: any) {
const filepath = String(asset?.filepath || asset?.path || asset?.full_path || "").trim();
if (!filepath) {
comfyToast(t("toast.workflowMissingPath", "Workflow file path is missing."), "error");
return;
}
const hostApp = getRawHostApp();
const dirtyState = isHostWorkflowDirty(hostApp);
if (dirtyState === true) {
const ok = await comfyConfirm(
t(
"dialog.workflowLoadReplaceDirty",
"Current canvas has unsaved changes. Replace it with this workflow?",
),
t("tab.workflow", "Workflow"),
);
if (!ok) return;
}
const result = await getWorkflowContent(filepath, { timeoutMs: 30_000 });
const workflow = result?.data?.workflow || result?.workflow || null;
if (!result?.ok || !workflow || typeof workflow !== "object") {
comfyToast(result?.error || t("toast.workflowLoadFailed", "Failed to load workflow."), "error");
return;
}
const importResult = importWorkflowPreferHostTab(workflow, hostApp);
if (!importResult.ok) {
// Host API unavailable: fallback to opening/downloading JSON so user can still import manually.
const downloadUrl = buildDownloadURL(filepath, { inline: true });
try {
if (downloadUrl && typeof window !== "undefined") {
window.open(downloadUrl, "_blank", "noopener,noreferrer");
}
} catch (e: any) {
console.debug?.(e);
}
comfyToast(
t(
"toast.workflowImportFallback",
"ComfyUI workflow import is unavailable in this frontend. Opened workflow JSON for manual import.",
),
"warn",
);
if (importResult.mode === "new-tab") {
comfyToast(t("toast.workflowLoadedNewTab", "Workflow loaded in a new ComfyUI tab."), "success");
}
return;
}
try {
await markWorkflowLoaded({ filepath }, { timeoutMs: 10_000 });
} catch (e: any) {
console.debug?.(e);
}
comfyToast(t("toast.workflowLoaded", "Workflow loaded"), "success", 1800);
}
function _requestWorkflowGridReload(reason: string) {
try {
window.dispatchEvent(new CustomEvent("mjr:reload-grid", { detail: { reason } }));
} catch (e: any) {
console.debug?.(e);
}
}
async function _duplicateWorkflowAsset(asset: any) {
const filepath = String(asset?.filepath || "").trim();
const result = await duplicateWorkflow({ filepath }, { timeoutMs: 30_000 });
if (!result?.ok) {
comfyToast(result?.error || t("toast.workflowSaveFailed", "Failed to save workflow."), "error");
return;
}
comfyToast(t("toast.workflowSaved", "Workflow saved"), "success", 1800);
_requestWorkflowGridReload("workflow-duplicate");
}
async function _renameWorkflowAsset(asset: any) {
const filepath = String(asset?.filepath || "").trim();
const current = String(asset?.display_name || asset?.filename || "").replace(/\.json$/i, "");
const name = await comfyPrompt(t("ctx.renameWorkflow", "Rename workflow"), current, t("tab.workflow", "Workflow"));
const safeName = String(name || "").trim();
if (!safeName) return;
const result = await moveWorkflow({ filepath, name: safeName }, { timeoutMs: 30_000 });
if (!result?.ok) {
comfyToast(result?.error || t("toast.workflowSaveFailed", "Failed to save workflow."), "error");
return;
}
comfyToast(t("toast.workflowUpdated", "Workflow updated"), "success", 1800);
_requestWorkflowGridReload("workflow-rename");
}
async function _categorizeWorkflowAsset(asset: any) {
const filepath = String(asset?.filepath || "").trim();
const current = String(asset?.subfolder || "").trim();
const category = await comfyPrompt(
t("dialog.workflowCategory", "Workflow category"),
current,
t("tab.workflow", "Workflow"),
);
if (category === null || category === undefined) return;
const result = await moveWorkflow({ filepath, category: String(category || "") }, { timeoutMs: 30_000 });
if (!result?.ok) {
comfyToast(result?.error || t("toast.workflowSaveFailed", "Failed to save workflow."), "error");
return;
}
comfyToast(t("toast.workflowUpdated", "Workflow updated"), "success", 1800);
_requestWorkflowGridReload("workflow-categorize");
}
async function _setWorkflowThumbnailFromLinkedAsset(asset: any) {
const filepath = String(asset?.filepath || "").trim();
if (!filepath) {
comfyToast(t("toast.noFilePath"), "error");
return;
}
const candidatesRes = await listWorkflowThumbnailCandidates({ filepath, limit: 12 }, { timeoutMs: 15_000 });
if (!candidatesRes?.ok) {
comfyToast(candidatesRes?.error || t("toast.workflowLoadFailed", "Failed to load workflow."), "error");
return;
}
const candidates = Array.isArray(candidatesRes.data) ? candidatesRes.data : [];
if (!candidates.length) {
comfyToast(
t(
"toast.workflowThumbnailNoCandidates",
"No linked outputs are available for this workflow yet.",
),
"warning",
2600,
);
return;
}
const selectedCandidate = await openWorkflowAssetPicker({
title: t("ctx.setWorkflowThumbnail", "Set workflow thumbnail"),
workflow: asset,
items: candidates,
});
if (!selectedCandidate?.filepath) return;
const result = await setWorkflowThumbnail(
{ filepath, source_filepath: selectedCandidate.filepath },
{ timeoutMs: 30_000 },
);
if (!result?.ok) {
comfyToast(result?.error || t("toast.workflowSaveFailed", "Failed to save workflow."), "error");
return;
}
comfyToast(t("toast.workflowUpdated", "Workflow updated"), "success", 1800);
_requestWorkflowGridReload("workflow-thumbnail");
}
async function _exportWorkflowAsset(asset: any) {
const filepath = String(asset?.filepath || "").trim();
if (!filepath) {
comfyToast(t("toast.workflowMissingPath", "Workflow file path is missing."), "error");
return;
}
const result = await getWorkflowContent(filepath, { timeoutMs: 30_000 });
if (!result?.ok) {
comfyToast(result?.error || t("toast.workflowLoadFailed", "Failed to load workflow."), "error");
return;
}
const workflow = result?.data?.workflow || result?.workflow || null;
if (!workflow || typeof workflow !== "object") {
comfyToast(t("toast.workflowLoadFailed", "Failed to load workflow."), "error");
return;
}
const filename = String(asset?.filename || asset?.display_name || "workflow.json")
.replace(/[\\/:*?"<>|]+/g, "_")
.replace(/\.json$/i, "") || "workflow";
const blob = new Blob([`${JSON.stringify(workflow, null, 2)}\n`], { type: "application/json" });
const url = URL.createObjectURL(blob);
try {
const link = document.createElement("a");
link.href = url;
link.download = `${filename}.json`;
link.rel = "noopener";
document.body.appendChild(link);
link.click();
link.remove();
comfyToast(t("toast.workflowExported", "Workflow exported"), "success", 1800);
} finally {
setTimeout(() => URL.revokeObjectURL(url), 5000);
}
}
async function _showWorkflowInExplorer(asset: any) {
const filepath = String(asset?.filepath || "").trim();
if (!filepath) {
comfyToast(t("toast.workflowMissingPath", "Workflow file path is missing."), "error");
return;
}
const result = await openInFolder({ filepath });
if (!result?.ok) {
comfyToast(result?.error || t("toast.openFolderFailed", "Failed to open folder."), "error");
return;
}
comfyToast(t("toast.openedInFolder", "Opened in folder"), "success", 1600);
}
async function _assignAssetAsWorkflowThumbnail(asset: any) {
const sourceFilepath = getAssetFilepath(asset);
if (!sourceFilepath) {
comfyToast(t("toast.noFilePath"), "error");
return;
}
const selectedWorkflow = await openWorkflowPicker({
title: t("ctx.assignAsWorkflowThumbnail", "Use as workflow thumbnail"),
sourceAsset: asset,
});
const selectedWorkflowPath = String(selectedWorkflow?.filepath || "").trim();
if (!selectedWorkflowPath) return;
const sourceKind = String(asset?.kind || "").toLowerCase();
const isVideo = sourceKind === "video" || /\.(mp4|webm|mov|mkv|avi|m4v)$/i.test(sourceFilepath);
comfyToast(
isVideo
? t("toast.workflowThumbnailConverting", "Preparing a 5 second animated workflow thumbnail...")
: t("toast.workflowThumbnailApplying", "Applying workflow thumbnail..."),
"info",
2200,
);
const result = await setWorkflowThumbnail(
{ filepath: selectedWorkflowPath, source_filepath: sourceFilepath },
{ timeoutMs: isVideo ? 75_000 : 30_000 },
);
if (!result?.ok) {
comfyToast(result?.error || t("toast.workflowSaveFailed", "Failed to save workflow."), "error");
return;
}
comfyToast(t("toast.workflowUpdated", "Workflow updated"), "success", 1800);
_requestWorkflowGridReload("workflow-thumbnail-assign");
}
async function _deleteWorkflowAsset(asset: any) {
const filepath = String(asset?.filepath || "").trim();
const ok = await comfyConfirm(
t("dialog.deleteWorkflowConfirm", "Delete this workflow JSON and its adjacent thumbnail files?"),
t("ctx.deleteWorkflow", "Delete workflow"),
);
if (!ok) return;
const result = await deleteWorkflow({ filepath }, { timeoutMs: 30_000 });
if (!result?.ok) {
comfyToast(result?.error || t("toast.fileDeleteFailed", "Failed to delete file."), "error");
return;
}
comfyToast(t("toast.workflowDeleted", "Workflow deleted"), "success", 1800);
_requestWorkflowGridReload("workflow-delete");
}
function _getSelectionSnapshot(gridContainer: any) {
const selectedIds = getSelectedAssetIds(gridContainer);
const selectedAssetsNow = getSelectedAssets(gridContainer);
const effectiveSelectionCount = selectedAssetsNow.length || selectedIds.size;
return {
selectedIds,
selectedAssetsNow,
effectiveSelectionCount,
isMultiSelected: effectiveSelectionCount > 1,
hasSelection: effectiveSelectionCount > 0,
};
}
function _ensureRightClickSelection({ asset, card, gridContainer }: { asset?: any; card?: any; gridContainer?: any } = {}) {
let selectedIds = getSelectedAssetIds(gridContainer);
const cardSelected = !!card?.classList?.contains?.("is-selected");
if (!asset || cardSelected) return selectedIds;
const nextId = asset?.id != null ? String(asset.id) : "";
const newSelection = nextId ? [nextId] : [];
try {
if (nextId && typeof gridContainer?._mjrSetSelection === "function") {
gridContainer._mjrSetSelection(newSelection, newSelection[0]);
} else if (nextId) {
gridContainer.dataset.mjrSelectedAssetIds = JSON.stringify(newSelection);
gridContainer.dataset.mjrSelectedAssetId = newSelection[0];
} else {
delete gridContainer.dataset.mjrSelectedAssetIds;
delete gridContainer.dataset.mjrSelectedAssetId;
}
} catch (e: any) {
console.debug?.(e);
}
safeDispatchCustomEvent(
"mjr:selection-changed",
{
selectedIds: newSelection,
selectedAssets: [asset],
activeId: nextId,
},
{ target: gridContainer, warnPrefix: "[GridContextMenu]" },
);
selectedIds = new Set(newSelection);
try {
gridContainer.querySelectorAll(".mjr-asset-card.is-selected").forEach((el: any) => {
el.classList.remove("is-selected");
el.setAttribute?.("aria-selected", "false");
});
card?.classList?.add?.("is-selected");
card?.setAttribute?.("aria-selected", "true");
} catch (e: any) {
console.debug?.(e);
}
return selectedIds;
}
function _buildRatingSubmenu(asset: any, canRate: any) {
const stars = (n: any) => "*".repeat(n) + "o".repeat(Math.max(0, 5 - n));
const updateRenderedRating = (value: any) => {
syncRenderedCardAsset(asset, { rating: value });
};
return [
createItem(
stars(5),
"pi pi-star",
null,
async () => {
setRating(asset, 5, () => updateRenderedRating(5));
},
{ disabled: !canRate },
),
createItem(
stars(4),
"pi pi-star",
null,
async () => {
setRating(asset, 4, () => updateRenderedRating(4));
},
{ disabled: !canRate },
),
createItem(
stars(3),
"pi pi-star",
null,
async () => {
setRating(asset, 3, () => updateRenderedRating(3));
},
{ disabled: !canRate },
),
createItem(
stars(2),
"pi pi-star",
null,
async () => {
setRating(asset, 2, () => updateRenderedRating(2));
},
{ disabled: !canRate },
),
createItem(
stars(1),
"pi pi-star",
null,
async () => {
setRating(asset, 1, () => updateRenderedRating(1));
},
{ disabled: !canRate },
),
createSeparator(),
createItem(
t("ctx.resetRating", "Reset rating"),
"pi pi-star",
"0",
async () => {
setRating(asset, 0, () => updateRenderedRating(0));
},
{ disabled: !canRate },
),
];
}
function _resolveCreateFolderPath(currentPath: string, gridContainer: any): string {
const scope = String(gridContainer?.dataset?.mjrScope || "").toLowerCase();
if (scope !== "input" && scope !== "output") return currentPath;
const rel = String(currentPath || "").trim().replaceAll("\\", "/");
// Encode the scope + optional relative subfolder, e.g. "mjr://input/sub/dir"
return rel ? `mjr://${scope}/${rel}` : `mjr://${scope}`;
}
function _buildEmptyGridItems({ currentPath, gridContainer }: { currentPath?: any; gridContainer?: any } = {}) {
return [
createItem(
t("ctx.createFolderHere", "Create folder here..."),
"pi pi-plus",
null,
async () => {
try {
const name = await comfyPrompt(
t("dialog.newFolderName", "New folder name"),
"",
);
const next = String(name || "").trim();
if (!next) return;
const res = await browserFolderOp({
op: "create",
path: _resolveCreateFolderPath(currentPath, gridContainer),
name: next,
});
if (!res?.ok) {
comfyToast(res?.error || "Failed to create folder", "error");
return;
}
triggerBrowserGridReload(gridContainer);
comfyToast(
t("toast.folderCreated", "Folder created: {name}", { name: next }),
"success",
);
} catch (error: any) {
comfyToast(
t("toast.createFolderFailedDetail", "Create folder failed: {error}", {
error: error?.message || String(error || ""),
}),
"error",
);
}
},
),
];
}
function _buildFolderItems({ asset, gridContainer, panelState }: { asset?: any; gridContainer?: any; panelState?: any } = {}) {
const folderPath = String(asset?.filepath || "").trim();
const items = [
createItem(t("ctx.openFolder", "Open folder"), "pi pi-folder-open", null, () => {
try {
gridContainer.dispatchEvent(
new CustomEvent("mjr:open-folder-asset", {
bubbles: true,
detail: { asset },
}),
);
} catch (e: any) {
console.debug?.(e);
}
}),
createItem(t("ctx.pinAsBrowserRoot"), "pi pi-bookmark", null, async () => {
if (!folderPath) {
comfyToast(t("toast.unableResolveFolderPath"), "error");
return;
}
const label = await comfyPrompt(
t("dialog.browserRootLabelOptional"),
String(asset?.filename || ""),
);
const res = await post(ENDPOINTS.CUSTOM_ROOTS, {
path: folderPath,
label: String(label || "").trim() || undefined,
});
if (!res?.ok) {
comfyToast(res?.error || t("toast.pinFolderFailed"), "error");
return;
}
comfyToast(t("toast.folderPinnedAsBrowserRoot"), "success");
try {
window.dispatchEvent(
new CustomEvent("mjr:custom-roots-changed", {
detail: { preferredId: String(res?.data?.id || "") },
}),
);
} catch (e: any) {
console.debug?.(e);
}
}),
];
if (_isBrowserScope(panelState) && folderPath) {
items.push(
createSeparator(),
createItem(t("ctx.createFolderHere"), "pi pi-plus", null, async () => {
try {
const name = await comfyPrompt(t("dialog.newFolderName"), "");
const next = String(name || "").trim();
if (!next) return;
const res = await browserFolderOp({
op: "create",
path: folderPath,
name: next,
});
if (!res?.ok) {
comfyToast(res?.error || t("toast.createFolderFailed"), "error");
return;
}
triggerBrowserGridReload(gridContainer);
comfyToast(t("toast.folderCreated", { name: next }), "success");
} catch (error: any) {
comfyToast(
t("toast.createFolderFailedDetail", {
error: error?.message || String(error || ""),
}),
"error",
);
}
}),
createItem(t("ctx.renameFolder"), "pi pi-pencil", null, async () => {
try {
const current = String(asset?.filename || "").trim();
const name = await comfyPrompt(t("dialog.renameFolder"), current);
const next = String(name || "").trim();
if (!next || next === current) return;
const res = await browserFolderOp({
op: "rename",
path: folderPath,
name: next,
});
if (!res?.ok) {
comfyToast(res?.error || t("toast.renameFolderFailed"), "error");
return;
}
triggerBrowserGridReload(gridContainer);
comfyToast(t("toast.folderRenamed"), "success");
} catch (error: any) {
comfyToast(
t("toast.renameFolderFailedDetail", {
error: error?.message || String(error || ""),
}),
"error",
);
}
}),
createItem(t("ctx.moveFolder"), "pi pi-arrow-right", null, async () => {
try {
const destination = await comfyPrompt(t("dialog.destinationDirectoryPath"), "");
const dest = String(destination || "").trim();
if (!dest) return;
const res = await browserFolderOp({
op: "move",
path: folderPath,
destination: dest,
});
if (!res?.ok) {
comfyToast(res?.error || t("toast.moveFolderFailed"), "error");
return;
}
triggerBrowserGridReload(gridContainer);
comfyToast(t("toast.folderMoved"), "success");
} catch (error: any) {
comfyToast(
t("toast.moveFolderFailedDetail", {
error: error?.message || String(error || ""),
}),
"error",
);
}
}),
createItem(t("ctx.deleteFolder"), "pi pi-trash", null, async () => {
try {
const folderName = String(
asset?.filename || t("label.thisFolder", "this folder"),
);
const ok = await comfyConfirm(
t("dialog.deleteFolderRecursive", { name: folderName }),
);
if (!ok) return;
const res = await browserFolderOp({
op: "delete",
path: folderPath,
recursive: true,
});
if (!res?.ok) {
comfyToast(res?.error || t("toast.deleteFolderFailed"), "error");
return;
}
triggerBrowserGridReload(gridContainer);
comfyToast(t("toast.folderDeleted"), "success");
} catch (error: any) {
comfyToast(
t("toast.deleteFolderFailedDetail", {
error: error?.message || String(error || ""),
}),
"error",
);
}
}),
);
}
return items;
}
function _buildAssetItems({
asset,
card,
gridContainer,
panelState,
pointerX,
pointerY,
selection,
}: { asset?: any; card?: any; gridContainer?: any; panelState?: any; pointerX?: any; pointerY?: any; selection?: any } = {}) {
const { selectedAssetsNow, effectiveSelectionCount, isMultiSelected, hasSelection } = selection;
const items: any[] = [];
const inWorkflowScope = _isWorkflowScope(panelState);
const openInViewer = ({ assets = [] as any[], index = 0, mode = "" } = {}) =>
loadViewerOpenRequestModule()
.then((mod) => mod.requestViewerOpen({ assets, index, mode }))
.catch((e) => console.debug?.(e));
if (_isWorkflowAsset(asset)) {
items.push(
createItem(
t("ctx.loadWorkflow", "Load workflow"),
"pi pi-sitemap",
null,
() => _loadWorkflowAsset(asset),
),
createItem(
t("ctx.duplicateWorkflow", "Duplicate workflow"),
"pi pi-copy",
null,
() => _duplicateWorkflowAsset(asset),
),
createItem(
t("ctx.renameWorkflow", "Rename workflow"),
"pi pi-pencil",
null,
() => _renameWorkflowAsset(asset),
),
createItem(
t("ctx.categorizeWorkflow", "Set workflow category"),
"pi pi-folder",
null,
() => _categorizeWorkflowAsset(asset),
),
createItem(
t("ctx.setWorkflowThumbnail", "Set workflow thumbnail"),
"pi pi-image",
null,
() => _setWorkflowThumbnailFromLinkedAsset(asset),
),
createItem(
t("ctx.editWorkflowInfo", "Edit infos"),
"pi pi-info-circle",
null,
() => openWorkflowInfoDialog(asset),
),
createItem(
t("ctx.exportWorkflow", "Export workflow"),
"pi pi-download",
null,
() => _exportWorkflowAsset(asset),
),
createItem(
t("ctx.showInExplorer", "Show in Explorer"),
"pi pi-folder-open",
null,
() => _showWorkflowInExplorer(asset),
),
createItem(
t("ctx.deleteWorkflow", "Delete workflow"),
"pi pi-trash",
null,
() => _deleteWorkflowAsset(asset),
),
createSeparator(),
);
if (inWorkflowScope) {
items.push(
createItem("Open Graph Map", "pi pi-sitemap", null, async () => {
try {
const graphAsset = await enrichWorkflowGraphAsset(asset);
const { floatingViewerManager } = await loadFloatingViewerManagerModule();
await floatingViewerManager.openAssets({
assets: [graphAsset],
index: 0,
mode: "graph",
});
} catch (e: any) {
console.debug?.(e);
}
}),
);
return items;
}
} else if (inWorkflowScope) {
return items;
}
items.push(
createItem("Open As New Tab", "pi pi-external-link", null, () =>
_openAssetWorkflowAsNewTab(asset),
),
createItem("Open Viewer", "pi pi-image", getShortcutDisplay("OPEN_VIEWER"), () => {
try {
const selectedAssets = hasSelection ? getSelectedAssets(gridContainer) : [];
const mediaSelectedAssets = selectedAssets.filter(
(entry) => !_isFolderAsset(entry),
);
const selectedCount = mediaSelectedAssets.length;
if (selectedCount >= 2 && selectedCount <= 4) {
openInViewer({ assets: mediaSelectedAssets, index: 0 });
return;
}
const allAssets = getAllAssetsInGrid(gridContainer).filter(
(entry) => !_isFolderAsset(entry),
);
const index = findIndexById(allAssets, asset?.id);
if (!allAssets.length || index < 0) return;
openInViewer({ assets: allAssets, index: Math.max(0, index) });
} catch (e: any) {
console.debug?.(e);
}
}),
createItem(
"Open in Floating Viewer",
"pi pi-window-maximize",
getShortcutDisplay("OPEN_FLOATING_VIEWER"),
async () => {
try {
const { floatingViewerManager } = await loadFloatingViewerManagerModule();
const selectedAssets = hasSelection ? getSelectedAssets(gridContainer) : [];
const mediaSelectedAssets = selectedAssets.filter(
(entry) => !_isFolderAsset(entry),
);
if (mediaSelectedAssets.length) {
await floatingViewerManager.openAssets({
assets: mediaSelectedAssets,
index: Math.max(
0,
mediaSelectedAssets.findIndex(
(entry) => String(entry?.id || "") === String(asset?.id || ""),
),
),
});
return;
}
await floatingViewerManager.openAssets({ assets: [asset], index: 0 });
} catch (e: any) {
console.debug?.(e);
}
},
),
createItem("Load Asset", "pi pi-upload", "L / L+Drop", async () => {
const selectedAssets = hasSelection ? getSelectedAssets(gridContainer) : [];
const selectedMediaAssets = selectedAssets.filter((entry: any) => !_isFolderAsset(entry));
const list = selectedMediaAssets.length ? selectedMediaAssets : [asset];
const created = await loadAssetsToCanvas(list);
if (!created) {
comfyToast("Failed to create a loader node for this asset.", "error");
}
}),
createItem("Open Graph Map", "pi pi-sitemap", null, async () => {
try {
const graphAsset = await enrichWorkflowGraphAsset(asset);
const { floatingViewerManager } = await loadFloatingViewerManagerModule();
await floatingViewerManager.openAssets({
assets: [graphAsset],
index: 0,
mode: "graph",
});
} catch (e: any) {
console.debug?.(e);
}
}),
createItem(
t("ctx.showMetadataPanel", "Show metadata panel"),
"pi pi-info-circle",
getShortcutDisplay("METADATA_PANEL"),
() => {
try {
selectCardForDetails(gridContainer, card, asset, panelState);
const openDetails = gridContainer?._mjrOpenDetails;
if (typeof openDetails === "function") openDetails();
} catch (e: any) {
console.debug?.(e);
}
},
),
);
if (_isWorkflowThumbnailSourceAsset(asset)) {
items.push(
createItem(
t("ctx.assignAsWorkflowThumbnail", "Use as workflow thumbnail"),
"pi pi-image",
null,
() => _assignAssetAsWorkflowThumbnail(asset),
),
);
}
const mediaSelectedAssets = (hasSelection ? selectedAssetsNow : [] as any[]).filter(
(entry: any) => !_isFolderAsset(entry),
);
const selectedCount = mediaSelectedAssets.length;
if (selectedCount === 2) {
items.push(
createItem(
"Compare A/B (2 selected)",
"pi pi-clone",
getShortcutDisplay("COMPARE_AB"),
() => {
try {
openInViewer({ assets: mediaSelectedAssets, index: 0, mode: "ab" });
} catch (e: any) {
console.debug?.(e);
}
},
),
);
}
if (selectedCount >= 2 && selectedCount <= 4) {
items.push(
createItem(
`Side-by-side (2x2) (${selectedCount} selected)`,
"pi pi-table",
getShortcutDisplay("SIDE_BY_SIDE"),
() => {
try {
openInViewer({
assets: mediaSelectedAssets,
index: 0,
mode: "sidebyside",
});
} catch (e: any) {
console.debug?.(e);
}
},
),
);
}
items.push(
createItem(
t("ctx.openInFolder", "Open in folder"),
"pi pi-folder-open",
getShortcutDisplay("OPEN_IN_FOLDER"),
async () => {
const res = await openInFolder(asset);
if (!res?.ok) {
comfyToast(res?.error || t("toast.openFolderFailed"), "error");
} else {
comfyToast(t("toast.openedInFolder"), "info", 2000);
}
},
{ disabled: !(asset?.id || getAssetFilepath(asset)) },
),
createItem(
t("ctx.collectFiles", "Collect files"),
"pi pi-box",
null,
async () => {
comfyToast(t("toast.collectingFiles", "Collecting files..."), "info", 2500);
const res = await collectFiles(asset);
if (!res?.ok) {
comfyToast(res?.error || t("toast.collectFilesFailed", "Collect files failed"), "error");
return;
}
const data = res?.data || {};
const missingCount = Array.isArray(data.missing) ? data.missing.length : 0;
const where = data.fallback_used
? t("toast.collectFallbackDir", "output folder (source folder not writable)")
: t("toast.collectSameDir", "asset folder");
let message = t("toast.collectedFiles", "Collected {name} in {where}", {
name: String(data.zip_name || "zip"),
where,
});
if (missingCount > 0) {
message += ` — ${missingCount} ${t("toast.collectMissingInputs", "input(s) missing")}`;
}
comfyToast(message, missingCount > 0 ? "warning" : "success", 6000);
},
{ disabled: !getAssetFilepath(asset) },
),
createItem(
t("ctx.copyPath", "Copy path"),
"pi pi-copy",
getShortcutDisplay("COPY_PATH"),
async () => {
const filepath = asset?.filepath ? String(asset.filepath) : "";
if (!filepath) {
comfyToast(t("toast.noFilePath"), "error");
return;
}
try {
await navigator.clipboard.writeText(filepath);
comfyToast(t("toast.pathCopied"), "success", 2000);
} catch (err: any) {
console.warn(t("log.clipboardCopyFailed", "Clipboard copy failed"), err);
comfyToast(t("toast.pathCopyFailed"), "error");
}
},
),
createItem(
t("ctx.download", "Download"),
"pi pi-download",
getShortcutDisplay("DOWNLOAD"),
() => {
if (!asset?.filepath) return;
const url = buildDownloadURL(asset.filepath);
const filename = asset.filename || "download";
triggerDownload(url, filename);
comfyToast(
t("toast.downloadingFile", "Downloading {filename}...", { filename }),
"info",
3000,
);
},
{ disabled: !asset?.filepath },
),
createItem(
`Download selected as ZIP (${selectedCount})`,
"pi pi-file-zip",
null,
async () => {
await downloadSelectedAsZip(mediaSelectedAssets);
},
{ disabled: selectedCount < 2 },
),
createItem(
"Add to collection...",
"pi pi-bookmark",
getShortcutDisplay("ADD_TO_COLLECTION"),
async () => {
const selectedAssets = getSelectedAssets(gridContainer);
const list = selectedAssets.length ? selectedAssets : [asset];
await showAddToCollectionMenu({ x: pointerX, y: pointerY, assets: list });
},
),
);
const collectionId = String(panelState?.collectionId || "").trim();
if (collectionId) {
const selectedAssets = getSelectedAssets(gridContainer);
const list = selectedAssets.length ? selectedAssets : [asset];
const filepaths = list.map(getAssetFilepath).filter(Boolean);
const label =
filepaths.length > 1
? `Remove from collection (${filepaths.length})`
: "Remove from collection";
items.push(
createItem(label, "pi pi-bookmark", null, async () => {
if (!filepaths.length) {
comfyToast(t("toast.noFilePath"), "error");
return;
}
const res = await removeFilepathsFromCollection(collectionId, filepaths);
if (!res?.ok) {
comfyToast(res?.error || t("toast.removeFromCollectionFailed"), "error");
return;
}
triggerBrowserGridReload(gridContainer);
}),
);
}
const canRate = !!(asset?.id || getAssetFilepath(asset));
items.push(
createSeparator(),
createItem(
t("ctx.editTags", "Edit tags"),
"pi pi-tags",
getShortcutDisplay("EDIT_TAGS"),
() => {
showTagsPopover(pointerX + 6, pointerY + 6, asset, () => {
syncRenderedCardAsset(asset, {
tags: Array.isArray(asset?.tags) ? [...asset.tags] : [],
});
});
},
{ closeOnSelect: false },
),
createSeparator(),
createItem(t("ctx.setRating", "Set rating"), "pi pi-star", "1-5 >", null, {
disabled: !canRate,
closeOnSelect: false,
submenu: _buildRatingSubmenu(asset, canRate),
}),
createSeparator(),
createItem(
t("ctx.resetIndexFile", "Reset index (this file)"),
"pi pi-refresh",
null,
async () => {
if (!(asset?.filename || asset?.id)) return;
comfyToast(t("toast.rescanningFile", "Rescanning file..."), "info", 1600);
try {
const res = await resetIndexForSingleAsset(asset, gridContainer);
if (res?.ok) {
comfyToast(
t("toast.metadataRefreshed", "Metadata refreshed{suffix}", {
suffix: "",
}),
"success",
1800,
);
} else {
comfyToast(
res?.error || t("toast.resetFailed", "Failed to reset index"),
"error",
);
}
} catch (error: any) {
comfyToast(
`${t("toast.resetFailed", "Failed to reset index")}: ${error?.message || String(error || "")}`,
"error",
);
}
},
{ disabled: !(asset?.filename || asset?.id) },
),
createItem(
"Rename...",
"pi pi-pencil",
getShortcutDisplay("RENAME"),
async () => {
if (!(asset?.id || getAssetFilepath(asset))) return;
const currentName = asset.filename || "";
const rawInput = await comfyPrompt(
t("dialog.rename.title", "Rename file"),
currentName,
);
const newName = normalizeRenameFilename(rawInput, currentName);
if (!newName || newName === currentName) return;
const validation = validateFilename(newName);
if (!validation.valid) {
comfyToast(validation.reason, "error");
return;
}
try {
const result = await renameAsset(asset, newName);
if (!result?.ok) {
comfyToast(result?.error || t("toast.fileRenameFailed"), "error");
return;
}
const fresh = result?.data?.asset;
if (fresh && typeof fresh === "object") {
Object.assign(asset, fresh);
syncRenderedCardAsset(asset, fresh);
} else {
asset.filename = newName;
if (asset.filepath) {
asset.filepath = String(asset.filepath).replace(/[^\\/]+$/, newName);
}
if (asset.path) {
asset.path = String(asset.path).replace(/[^\\/]+$/, newName);
}
if (asset.file_info && typeof asset.file_info === "object") {
asset.file_info.filename = newName;
if (asset.file_info.filepath) {
asset.file_info.filepath = String(asset.file_info.filepath).replace(
/[^\\/]+$/,
newName,
);
}
if (asset.file_info.path) {
asset.file_info.path = String(asset.file_info.path).replace(
/[^\\/]+$/,
newName,
);
}
}
syncRenderedCardAsset(asset, {
filename: asset.filename,
filepath: asset.filepath,
path: asset.path,
file_info: asset.file_info,
});
}
const rendered = findRenderedCard(asset.id);
const filenameEl = rendered?.querySelector?.(".mjr-filename");
if (filenameEl) filenameEl.textContent = asset.filename || newName;
comfyToast(t("toast.fileRenamedSuccess"), "success");
triggerBrowserGridReload(gridContainer);
} catch (error: any) {
comfyToast(
t("toast.errorRenaming", "Error renaming file: {error}", {
error: error?.message || String(error || ""),
}),
"error",
);
}
},
{ disabled: !(asset?.id || getAssetFilepath(asset)) },
),
);
if (isMultiSelected) {
items.push(
createItem(
`Delete ${effectiveSelectionCount} files...`,
"pi pi-trash",
getShortcutDisplay("DELETE"),
async () => {
const ok = await confirmDeletion(Number(effectiveSelectionCount) || 0);
if (!ok) return;
try {
let successCount = 0;
let errorCount = 0;
const deletedIds = [];
let deletedByFilepath = 0;
const selectionList = selectedAssetsNow.length
? selectedAssetsNow
: [asset];
for (const selectedAsset of selectionList) {
const result = await deleteAsset(selectedAsset);
if (result?.ok) {
successCount += 1;
if (selectedAsset?.id != null) {
deletedIds.push(String(selectedAsset.id));
} else {
deletedByFilepath += 1;
}
} else {
errorCount += 1;
}
}
if (deletedIds.length) {
const removal = removeAssetsFromGrid(gridContainer, deletedIds);
if (panelState && Array.isArray(removal?.selectedIds)) {
panelState.selectedAssetIds = removal.selectedIds;
panelState.activeAssetId = removal.selectedIds[0] || "";
}
}
if (deletedByFilepath > 0) {
triggerBrowserGridReload(gridContainer);
}
if (errorCount === 0) {
comfyToast(
t("toast.filesDeletedSuccessN", "{n} files deleted successfully!", {
n: successCount,
}),
"success",
);
} else {
comfyToast(
t(
"toast.filesDeletedPartial",
"{success} files deleted, {failed} failed.",
{ success: successCount, failed: errorCount },
),
"warning",
);
}
} catch (error: any) {
comfyToast(
t("toast.errorDeleting", "Error deleting file: {error}", {
error: error?.message || String(error || ""),
}),
"error",
);
}
},
),
);
} else {
items.push(
createItem(
t("ctx.delete", "Delete"),
"pi pi-trash",
getShortcutDisplay("DELETE"),
async () => {
const ok = await confirmDeletion(1, asset?.filename);
if (!ok) return;
try {
const result = await deleteAsset(asset);
if (!result?.ok) {
comfyToast(result?.error || t("toast.fileDeleteFailed"), "error");
return;
}
if (asset?.id) {
const removal = removeAssetsFromGrid(gridContainer, [String(asset.id)]);
if (panelState && Array.isArray(removal?.selectedIds)) {
panelState.selectedAssetIds = removal.selectedIds;
panelState.activeAssetId = removal.selectedIds[0] || "";
}
} else {
triggerBrowserGridReload(gridContainer);
}
comfyToast(t("toast.fileDeletedSuccess"), "success");
} catch (error: any) {
comfyToast(
t("toast.errorDeleting", "Error deleting file: {error}", {
error: error?.message || String(error || ""),
}),
"error",
);
}
},
{ disabled: !(asset?.id || getAssetFilepath(asset)) },
),
);
}
return items;
}
export function showTagsPopover(x: number, y: number, asset: Record<string, any> | null, onChanged: (() => void) | null): void {
if (!asset) return;
openGridTagsPopover({
x,
y,
asset,
onChanged,
});
}
export function bindGridContextMenu({ gridContainer, getState = () => ({}) }: { gridContainer?: any; getState?: () => Record<string, any> } = {}): (() => void) | undefined {
if (!gridContainer) return;
if (
gridContainer._mjrGridContextMenuBound &&
typeof gridContainer._mjrGridContextMenuUnbind === "function"
) {
return gridContainer._mjrGridContextMenuUnbind;
}
const handler = async (event: any) => {
const panelState = (() => {
try {
return getState?.() || {};
} catch {
return {};
}
})();
const card = safeClosest(event.target, ".mjr-asset-card");
if (!card) {
if (!_isBrowserScope(panelState)) return;
const scope = String(gridContainer?.dataset?.mjrScope || "").toLowerCase();
const subfolderPath = String(gridContainer?.dataset?.mjrSubfolder || "").trim();
// For input/output scopes at root level the subfolder is empty —
// still show the menu so users can create folders at the root, but
// only while folder browsing is enabled (browser.showFolders).
const allowRootFolderCreate =
(scope === "input" || scope === "output") && _browserFoldersEnabled();
if (!subfolderPath && !allowRootFolderCreate) return;
event.preventDefault();
event.stopPropagation();
openGridContextMenu({
x: event.clientX,
y: event.clientY,
items: _buildEmptyGridItems({ currentPath: subfolderPath, gridContainer }),
});
return;
}
event.preventDefault();
event.stopPropagation();
const asset = (card as any)._mjrAsset;
if (!asset) return;
const selection = _getSelectionSnapshot(gridContainer);
const items = _isFolderAsset(asset)
? _buildFolderItems({ asset, gridContainer, panelState })
: _buildAssetItems({
asset,
card,
gridContainer,
panelState,
pointerX: event.clientX,
pointerY: event.clientY,
selection,
});
openGridContextMenu({
x: event.clientX,
y: event.clientY,
items,
});
};
try {
gridContainer.addEventListener("contextmenu", handler);
} catch (err: any) {
console.error("[GridContextMenu] Failed to bind:", err);
}
gridContainer._mjrGridContextMenuBound = true;
const unbind = () => {
try {
gridContainer.removeEventListener("contextmenu", handler);
} catch (e: any) {
console.debug?.(e);
}
try {
gridContainer._mjrGridContextMenuBound = false;
} catch (e: any) {
console.debug?.(e);
}
try {
gridContainer._mjrGridContextMenuUnbind = null;
} catch (e: any) {
console.debug?.(e);
}
try {
cancelAllRatingUpdates();
} catch (e: any) {
console.debug?.(e);
}
try {
closeAllGridContextMenus();
} catch (e: any) {
console.debug?.(e);
}
};
gridContainer._mjrGridContextMenuUnbind = unbind;
return unbind;
}