Files
MajoorWaldi-ComfyUI-Majoor-…/js/features/panel/AssetsManagerPanel.js
T
MajoorWaldi dc72be0139 feat: enhance JS testing and linting setup
- Added coverage reporting for Vitest with configuration in vitest.config.mjs
- Updated package.json to include new scripts for JS coverage, linting, and formatting
- Modified run_quality_gate.py to allow skipping ESLint and Prettier checks
- Introduced .prettierrc for consistent code formatting
- Created .prettierignore to exclude specific files and directories from formatting
2026-03-28 14:14:28 +01:00

2154 lines
74 KiB
JavaScript

import { comfyConfirm, comfyPrompt } from "../../app/dialogs.js";
import { comfyToast } from "../../app/toast.js";
import { EVENTS } from "../../app/events.js";
import { t } from "../../app/i18n.js";
import { startTimer, endTimer, trackGridRender, mark } from "../../app/metrics.js";
import {
createStatusIndicator,
setupStatusPolling,
triggerScan,
updateStatus,
} from "../status/StatusDot.js";
import {
createGridContainer,
loadAssets,
loadAssetsFromList,
prepareGridForScopeSwitch,
disposeGrid,
refreshGrid,
captureAnchor,
restoreAnchor,
bindGridScanListeners,
disposeGridScanListeners,
} from "../grid/GridView.js";
function _safeCssEscapeAttr(value) {
const str = String(value ?? "");
try {
if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
return CSS.escape(str);
}
} catch (e) {
console.debug?.(e);
}
return str.replace(/([!"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, "\\$1");
}
import {
get,
post,
getCollectionAssets,
vectorFindSimilar,
toggleWatcher,
setWatcherScope,
getDuplicateAlerts,
startDuplicatesAnalysis,
mergeDuplicateTags,
deleteAssets,
} from "../../api/client.js";
import { ENDPOINTS } from "../../api/endpoints.js";
import { createSidebar, showAssetInSidebar, closeSidebar } from "../../components/SidebarView.js";
import { createRatingBadge, createTagsBadge } from "../../components/Badges.js";
import { loadMajoorSettings } from "../../app/settings.js";
import { createPopoverManager } from "./views/popoverManager.js";
import { createCollectionsPopoverView } from "../collections/views/collectionsPopoverView.js";
import { createCollectionsController } from "../collections/controllers/collectionsController.js";
import { createPanelState } from "./state/panelState.js";
import { createHeaderView } from "./views/headerView.js";
import { createCustomPopoverView } from "./views/customPopoverView.js";
import { createFilterPopoverView } from "./views/filterPopoverView.js";
import { createSortPopoverView } from "./views/sortPopoverView.js";
import { createSearchView } from "./views/searchView.js";
import { createPinnedFoldersPopoverView } from "./views/pinnedFoldersPopoverView.js";
import { createMessagePopoverView } from "./views/messagePopoverView.js";
import { createSummaryBarView } from "./views/summaryBarView.js";
import { createAgendaCalendar } from "../filters/calendar/AgendaCalendar.js";
import { bindMessagePopoverController } from "./messages/messagePopoverController.js";
import { normalizeQuery } from "./controllers/query.js";
import { createGridController } from "./controllers/gridController.js";
import { createBrowserNavigationController } from "./controllers/browserNavigationController.js";
import { createScopeController } from "./controllers/scopeController.js";
import { createContextController } from "./controllers/contextController.js";
import { createCustomRootsController } from "./controllers/customRootsController.js";
import { bindFilters } from "./controllers/filtersController.js";
import { createSortController } from "./controllers/sortController.js";
import { bindSidebarOpen } from "./controllers/sidebarController.js";
import { createPanelHotkeysController } from "./controllers/panelHotkeysController.js";
import { createRatingHotkeysController } from "./controllers/ratingHotkeysController.js";
import { getHotkeysState, setHotkeysScope } from "./controllers/hotkeysState.js";
import { bindGridContextMenu } from "../contextmenu/GridContextMenu.js";
import { getViewerInstance } from "../../components/Viewer.js";
let _activeGridContainer = null;
export function getActiveGridContainer() {
// First try the cached reference
if (_activeGridContainer && _activeGridContainer.isConnected) {
return _activeGridContainer;
}
// Fallback: try to find it in the DOM by ID or class
try {
const grid =
document.getElementById("mjr-assets-grid") || document.querySelector(".mjr-grid");
if (grid && grid.isConnected) {
return grid;
}
} catch (e) {
console.debug?.(e);
}
return null;
}
export async function renderAssetsManager(container, { useComfyThemeUI = true } = {}) {
// Start performance timing
startTimer("panelRender");
mark("panelRender");
const panelLifecycleAC = typeof AbortController !== "undefined" ? new AbortController() : null;
if (useComfyThemeUI) {
container.classList.add("mjr-assets-manager");
} else {
container.classList.remove("mjr-assets-manager");
}
// Invalidate cached grid reference before cleanup to prevent stale access
// during async teardown of the old panel instance.
_activeGridContainer = null;
// Cleanup previous instance before re-render to prevent listener leaks
try {
container._eventCleanup?.();
} catch (e) {
console.debug?.(e);
}
try {
container._mjrPanelState?._mjrDispose?.();
} catch (e) {
console.debug?.(e);
}
try {
container._mjrPopoverManager?.dispose?.();
} catch (e) {
console.debug?.(e);
}
try {
container._mjrHotkeys?.dispose?.();
} catch (e) {
console.debug?.(e);
}
try {
container._mjrRatingHotkeys?.dispose?.();
} catch (e) {
console.debug?.(e);
}
try {
container._mjrSidebarController?.dispose?.();
} catch (e) {
console.debug?.(e);
}
try {
container._mjrGridContextMenuUnbind?.();
} catch (e) {
console.debug?.(e);
}
container.replaceChildren();
container.classList.add("mjr-am-container");
const popovers = createPopoverManager(container);
try {
container._mjrPopoverManager = popovers;
} catch (e) {
console.debug?.(e);
}
const state = createPanelState();
try {
container._mjrPanelState = state;
} catch (e) {
console.debug?.(e);
}
state.viewScope =
String(state.viewScope || "")
.trim()
.toLowerCase() === "similar"
? "similar"
: "";
state.similarResults = Array.isArray(state.similarResults) ? state.similarResults : [];
state.similarTitle = String(state.similarTitle || "");
state.similarSourceAssetId = String(state.similarSourceAssetId || "");
const headerView = createHeaderView();
const {
header,
headerActions,
tabButtons,
customMenuBtn,
filterBtn,
sortBtn,
collectionsBtn,
pinnedFoldersBtn,
messageBtn,
} = headerView;
const { customPopover, customSelect, customAddBtn, customRemoveBtn } =
createCustomPopoverView();
const {
filterPopover,
kindSelect,
wfCheckbox,
workflowTypeSelect,
ratingSelect,
minSizeInput,
maxSizeInput,
resolutionPresetSelect,
minWidthInput,
minHeightInput,
maxWidthInput,
maxHeightInput,
dateRangeSelect,
dateExactInput,
agendaContainer,
} = createFilterPopoverView();
const { sortPopover, sortMenu } = createSortPopoverView();
const { collectionsPopover, collectionsMenu } = createCollectionsPopoverView();
const { pinnedFoldersPopover, pinnedFoldersMenu } = createPinnedFoldersPopoverView();
const {
title: messagePopoverTitle,
messagePopover,
messageTabBtn,
historyTabBtn,
historyTabBadge,
historyPanel,
shortcutsTabBtn,
messageList,
shortcutsPanel,
markReadBtn,
} = createMessagePopoverView();
headerActions.appendChild(customPopover);
headerActions.appendChild(messagePopover);
const { searchSection, searchInputEl, similarBtn, setSemanticEnabled } = createSearchView({
filterBtn,
sortBtn,
collectionsBtn,
pinnedFoldersBtn,
filterPopover,
sortPopover,
collectionsPopover,
pinnedFoldersPopover,
});
// Restore persistent search query
if (state.searchQuery) {
searchInputEl.value = state.searchQuery;
}
searchInputEl.addEventListener(
"input",
(e) => {
state.searchQuery = e.target.value;
},
{ signal: panelLifecycleAC?.signal },
);
const { bar: summaryBar, update: updateSummaryBar } = createSummaryBarView();
const folderBreadcrumb = document.createElement("div");
folderBreadcrumb.className = "mjr-folder-breadcrumb";
folderBreadcrumb.style.cssText =
"display:none; align-items:center; gap:4px; padding:3px 6px; margin:1px 0 3px 0; font-size:11px; opacity:0.78; overflow:auto; white-space:nowrap; border-radius:8px; background:color-mix(in srgb, var(--mjr-surface-2, rgba(255,255,255,0.05)) 42%, transparent);";
const content = document.createElement("div");
content.classList.add("mjr-am-content");
let gridContainer = null;
let sidebarController = null;
container.tabIndex = -1;
const statusSection = createStatusIndicator({
getScanContext: () => ({
scope: state.scope,
customRootId: state.customRootId,
}),
});
const statusDot = statusSection.querySelector("#mjr-status-dot");
const statusText = statusSection.querySelector("#mjr-status-text");
const capabilitiesSection = statusSection.querySelector("#mjr-status-capabilities");
const browseSection = document.createElement("div");
browseSection.classList.add("mjr-am-browse");
browseSection.style.cssText = `
flex: 1;
display: flex;
flex-direction: row;
overflow: hidden;
`;
const gridWrapper = document.createElement("div");
gridWrapper.classList.add("mjr-am-grid-scroll");
gridWrapper.style.cssText = `
flex: 1;
overflow: auto;
position: relative;
`;
// NOTE: Scroll restoration is done AFTER grid loads (see initialLoadPromise below).
// Restoring scroll on an empty grid would be useless.
let _scrollTimer = null;
let _lastUserInteractionAt = 0;
const markUserInteraction = () => {
try {
_lastUserInteractionAt = Date.now();
} catch {
_lastUserInteractionAt = 0;
}
};
gridWrapper.addEventListener(
"scroll",
() => {
markUserInteraction();
if (_scrollTimer) clearTimeout(_scrollTimer);
_scrollTimer = setTimeout(() => {
try {
state.scrollTop = gridWrapper.scrollTop;
} catch (e) {
console.debug?.(e);
}
}, 100);
},
{ passive: true, signal: panelLifecycleAC?.signal },
);
gridContainer = createGridContainer();
gridWrapper.appendChild(gridContainer);
_activeGridContainer = gridContainer;
try {
gridContainer.dataset.mjrScope = state.scope;
} catch (e) {
console.debug?.(e);
}
const summaryBarDisposers = [];
const registerSummaryDispose = (fn) => {
if (typeof fn === "function") summaryBarDisposers.push(fn);
};
gridContainer._mjrSummaryBarDispose = () => {
for (const fn of summaryBarDisposers.splice(0)) {
try {
fn();
} catch (e) {
console.debug?.(e);
}
}
};
try {
const handler = () => {
try {
updateSummaryBar?.({ state, gridContainer });
} catch (e) {
console.debug?.(e);
}
};
gridContainer.addEventListener("mjr:grid-stats", handler, {
signal: panelLifecycleAC?.signal,
});
registerSummaryDispose(() => {
try {
gridContainer.removeEventListener("mjr:grid-stats", handler);
} catch (e) {
console.debug?.(e);
}
});
} catch (e) {
console.debug?.(e);
}
try {
bindGridScanListeners();
} catch (e) {
console.debug?.(e);
}
// Bind grid context menu
container._mjrGridContextMenuUnbind = bindGridContextMenu({
gridContainer,
getState: () => state,
onRequestOpenViewer: (asset) => {
gridContainer?.dispatchEvent?.(
new CustomEvent("mjr:open-viewer", { detail: { asset } }),
);
},
getViewer: getViewerInstance,
});
const settings = loadMajoorSettings();
const sidebar = createSidebar(settings.sidebar?.position || "right");
const similarEnabledTitle = t("search.findSimilar", "Find Similar");
const similarDisabledTitle = t(
"search.similarDisabled",
"AI features are disabled in settings",
);
const isAiEnabled = () => {
try {
const s = loadMajoorSettings();
return !!(s?.ai?.vectorSearchEnabled ?? true);
} catch {
return true;
}
};
const applyAiUiState = (enabled) => {
const aiEnabled = !!enabled;
try {
container.classList.toggle("mjr-ai-disabled", !aiEnabled);
} catch (e) {
console.debug?.(e);
}
try {
similarBtn.disabled = !aiEnabled;
similarBtn.title = aiEnabled ? similarEnabledTitle : similarDisabledTitle;
} catch (e) {
console.debug?.(e);
}
try {
setSemanticEnabled?.(aiEnabled);
} catch (e) {
console.debug?.(e);
}
};
let _sidebarPosition = "right";
const applySidebarPosition = (nextPosition) => {
const pos = String(nextPosition || "right").toLowerCase() === "left" ? "left" : "right";
if (_sidebarPosition === pos) return;
_sidebarPosition = pos;
// Preserve grid scroll where possible.
let scrollTop = 0;
let scrollLeft = 0;
try {
scrollTop = Number(gridWrapper?.scrollTop || 0) || 0;
scrollLeft = Number(gridWrapper?.scrollLeft || 0) || 0;
} catch (e) {
console.debug?.(e);
}
try {
sidebar.dataset.position = pos;
const isOpen = sidebar.classList?.contains?.("is-open");
const borderColor = "var(--mjr-border, rgba(255,255,255,0.12))";
sidebar.style.borderLeft =
pos === "left" ? "none" : isOpen ? `1px solid ${borderColor}` : "none";
sidebar.style.borderRight =
pos === "left" ? (isOpen ? `1px solid ${borderColor}` : "none") : "none";
} catch (e) {
console.debug?.(e);
}
try {
if (pos === "left") {
browseSection.appendChild(sidebar);
browseSection.appendChild(gridWrapper);
browseSection.insertBefore(sidebar, browseSection.firstChild);
} else {
browseSection.appendChild(gridWrapper);
browseSection.appendChild(sidebar);
}
} catch (e) {
console.debug?.(e);
}
try {
requestAnimationFrame(() => {
try {
gridWrapper.scrollTop = scrollTop;
gridWrapper.scrollLeft = scrollLeft;
} catch (e) {
console.debug?.(e);
}
});
} catch {
try {
gridWrapper.scrollTop = scrollTop;
gridWrapper.scrollLeft = scrollLeft;
} catch (e) {
console.debug?.(e);
}
}
};
const applyWatcherForScope = async (nextScope) => {
const scope = String(nextScope || state.scope || "output").toLowerCase();
const settings = loadMajoorSettings();
const wantsEnabled = !!settings?.watcher?.enabled;
// Custom scope is a live filesystem browser; watcher must stay disabled there.
if (scope === "custom") {
try {
await toggleWatcher(false);
} catch (e) {
console.debug?.(e);
}
return;
}
const shouldEnable = wantsEnabled;
try {
await toggleWatcher(shouldEnable);
} catch (e) {
console.debug?.(e);
}
if (!shouldEnable) return;
try {
await setWatcherScope({
scope,
customRootId: "",
});
} catch (e) {
console.debug?.(e);
}
};
// Initial position (no reload needed).
try {
_sidebarPosition = ""; // force apply
applySidebarPosition(settings.sidebar?.position || "right");
} catch (e) {
console.debug?.(e);
}
// Live updates when ComfyUI settings change.
const onSettingsChanged = (_e) => {
const changedKey = String(_e?.detail?.key || "").trim();
const isInitialSync = changedKey === "__init__";
const shouldRefreshGrid =
isInitialSync ||
changedKey === "" ||
changedKey.startsWith("grid.") ||
changedKey.startsWith("infiniteScroll.") ||
changedKey.startsWith("siblings.") ||
changedKey.startsWith("search.") ||
changedKey.startsWith("rtHydrate.") ||
changedKey.startsWith("workflowMinimap.");
const shouldRefreshSidebar =
isInitialSync ||
changedKey === "" ||
changedKey.startsWith("sidebar.") ||
changedKey.startsWith("ai.");
const shouldApplyWatcher = changedKey === "" || changedKey.startsWith("watcher.");
try {
const s = loadMajoorSettings();
const aiEnabled = !!(s?.ai?.vectorSearchEnabled ?? true);
const hover = String(s?.ui?.cardHoverColor || "").trim();
const selected = String(s?.ui?.cardSelectionColor || "").trim();
const ratingColor = String(s?.ui?.ratingColor || "").trim();
const tagColor = String(s?.ui?.tagColor || "").trim();
if (/^#[0-9a-fA-F]{3,8}$/.test(hover)) {
document.documentElement.style.setProperty("--mjr-card-hover-color", hover);
}
if (/^#[0-9a-fA-F]{3,8}$/.test(selected)) {
document.documentElement.style.setProperty("--mjr-card-selection-color", selected);
}
if (/^#[0-9a-fA-F]{3,8}$/.test(ratingColor)) {
document.documentElement.style.setProperty("--mjr-rating-color", ratingColor);
}
if (/^#[0-9a-fA-F]{3,8}$/.test(tagColor)) {
document.documentElement.style.setProperty("--mjr-tag-color", tagColor);
}
// 1. Sidebar position
applySidebarPosition(s.sidebar?.position || "right");
applyAiUiState(aiEnabled);
// 2. Grid visuals (badges, details, sizes)
// Refresh only when a relevant setting changed to avoid reload storms.
if (shouldRefreshGrid) {
// Use getActiveGridContainer() to avoid stale closure reference
const currentGrid = getActiveGridContainer() || gridContainer;
// Siblings toggle requires a full data reload: PNG siblings are filtered
// during appendAssets (state.assets), so refreshGrid alone can't add them
// back or remove them — a re-fetch is needed.
if (!isInitialSync && changedKey.startsWith("siblings.")) {
try {
currentGrid.dispatchEvent(new CustomEvent("mjr:reload-grid"));
} catch {
refreshGrid(currentGrid);
}
} else {
refreshGrid(currentGrid);
}
}
if (shouldRefreshSidebar && state.sidebarOpen && state.activeAssetId) {
sidebarController?.refreshActiveAsset?.();
}
} catch (e) {
console.debug?.(e);
}
try {
if (!shouldApplyWatcher) return;
applyWatcherForScope(state.scope);
} catch (e) {
console.debug?.(e);
}
};
try {
onSettingsChanged({ detail: { key: "__init__" } });
} catch (e) {
console.debug?.(e);
}
try {
window.addEventListener?.("mjr-settings-changed", onSettingsChanged, {
signal: panelLifecycleAC?.signal,
});
} catch (e) {
console.debug?.(e);
}
// Enrichment-complete reload removed: handleCountersUpdate (path B) already
// detects new assets and triggers queuedReload with proper throttle + anchor
// preservation. A second unconditional reload here caused systematic double
// reloads after every watcher flush that triggers metadata enrichment.
content.appendChild(statusSection);
content.appendChild(searchSection);
content.appendChild(folderBreadcrumb);
content.appendChild(summaryBar);
content.appendChild(browseSection);
container.appendChild(header);
try {
container._mjrVersionUpdateCleanup?.();
} catch (e) {
console.debug?.(e);
}
container._mjrVersionUpdateCleanup = headerView.dispose || header._mjrVersionUpdateCleanup;
container.appendChild(content);
const getQuery = () => normalizeQuery(searchInputEl);
const gridController = createGridController({
gridContainer,
loadAssets,
loadAssetsFromList,
getCollectionAssets,
disposeGrid,
getQuery,
searchInputEl,
state,
});
let scopeController = null;
let contextController = null;
let _duplicatesAlert = null;
let _dupPollTimer = null;
let _autoLoadTimer = null;
let browserNav = null;
let _unbindBrowserFolderNav = null;
const notifyContextChanged = () => {
try {
contextController?.update?.();
} catch (e) {
console.debug?.(e);
}
try {
browserNav?.renderBreadcrumb?.();
} catch (e) {
console.debug?.(e);
}
};
browserNav = createBrowserNavigationController({
state,
gridContainer,
folderBreadcrumb,
customSelect,
reloadGrid: () => gridController.reloadGrid(),
onContextChanged: () => {
try {
contextController?.update?.();
} catch (e) {
console.debug?.(e);
}
},
lifecycleSignal: panelLifecycleAC?.signal || null,
});
try {
_unbindBrowserFolderNav = browserNav.bindGridFolderNavigation();
} catch (e) {
console.debug?.(e);
}
const refreshDuplicateAlerts = async () => {
const scope = String(state.scope || "output").toLowerCase();
const customRootId = String(state.customRootId || "").trim();
if (scope === "custom" && !customRootId) {
_duplicatesAlert = null;
notifyContextChanged();
return;
}
try {
const result = await getDuplicateAlerts(
{
scope,
customRootId,
maxGroups: 4,
maxPairs: 4,
},
{ signal: panelLifecycleAC?.signal },
);
if (!result?.ok) return;
const data = result.data || {};
_duplicatesAlert = {
exactCount: Array.isArray(data.exact_groups) ? data.exact_groups.length : 0,
similarCount: Array.isArray(data.similar_pairs) ? data.similar_pairs.length : 0,
firstGroup: Array.isArray(data.exact_groups) ? data.exact_groups[0] : null,
firstPair: Array.isArray(data.similar_pairs) ? data.similar_pairs[0] : null,
};
notifyContextChanged();
} catch (e) {
console.debug?.(e);
}
};
// Allow context menus to request a refresh (e.g. after "Remove from collection").
let _reloadGridHandler = null;
let _globalReloadGridHandler;
let _lastReloadRequestAt = 0;
const requestQueuedReload = () => {
try {
const now = Date.now();
if (now - Number(_lastReloadRequestAt || 0) < 500) return;
_lastReloadRequestAt = now;
} catch (e) {
console.debug?.(e);
}
queuedReload().catch((err) => {
try {
console.warn("[Majoor] queuedReload failed", err);
} catch (e) {
console.debug?.(e);
}
});
};
try {
_reloadGridHandler = () => {
requestQueuedReload();
};
gridContainer.addEventListener("mjr:reload-grid", _reloadGridHandler, {
signal: panelLifecycleAC?.signal,
});
const onDuplicateBadgeFocus = (e) => {
try {
const detail = e?.detail || {};
const filenameKey = String(detail?.filenameKey || "")
.trim()
.toLowerCase();
if (!filenameKey || !gridContainer) return;
const cards = Array.from(gridContainer.querySelectorAll(".mjr-asset-card")).filter(
(card) =>
String(card?.dataset?.mjrFilenameKey || "")
.trim()
.toLowerCase() === filenameKey,
);
if (!cards.length) return;
const ids = cards
.map((card) => String(card?.dataset?.mjrAssetId || "").trim())
.filter(Boolean);
const activeId = String(cards[0]?.dataset?.mjrAssetId || ids[0] || "").trim();
if (ids.length && typeof gridContainer?._mjrSetSelection === "function") {
gridContainer._mjrSetSelection(ids, activeId);
}
cards[0]?.scrollIntoView?.({ block: "nearest", behavior: "smooth" });
markUserInteraction();
notifyContextChanged();
const n = Number(detail?.count || ids.length || cards.length || 0);
try {
comfyToast(
t(
"toast.nameCollisionInView",
"Name collision in view: {n} item(s) selected",
{ n },
),
"info",
1800,
);
} catch (e) {
console.debug?.(e);
}
} catch (e) {
console.debug?.(e);
}
};
gridContainer.addEventListener("mjr:badge-duplicates-focus", onDuplicateBadgeFocus, {
signal: panelLifecycleAC?.signal,
});
registerSummaryDispose(() => {
try {
if (_reloadGridHandler)
gridContainer.removeEventListener("mjr:reload-grid", _reloadGridHandler);
} catch (e) {
console.debug?.(e);
}
try {
gridContainer.removeEventListener(
"mjr:badge-duplicates-focus",
onDuplicateBadgeFocus,
);
} catch (e) {
console.debug?.(e);
}
_reloadGridHandler = null;
});
} catch (e) {
console.debug?.(e);
}
try {
_globalReloadGridHandler = () => {
try {
if (panelLifecycleAC?.signal?.aborted) return;
} catch (e) {
console.debug?.(e);
}
try {
if (globalThis?._mjrMaintenanceActive) return;
} catch (e) {
console.debug?.(e);
}
requestQueuedReload();
};
window.addEventListener("mjr:reload-grid", _globalReloadGridHandler, {
signal: panelLifecycleAC?.signal,
});
} catch (e) {
console.debug?.(e);
}
const customRootsController = createCustomRootsController({
state,
customSelect,
customRemoveBtn,
comfyConfirm,
comfyPrompt,
comfyToast,
get,
post,
ENDPOINTS,
reloadGrid: gridController.reloadGrid,
onRootChanged: async () => {
try {
browserNav?.resetHistory?.();
} catch (e) {
console.debug?.(e);
}
try {
await applyWatcherForScope(state.scope);
} catch (e) {
console.debug?.(e);
}
try {
await refreshDuplicateAlerts();
} catch (e) {
console.debug?.(e);
}
},
});
const disposeCustomRootsBindings = customRootsController.bind({
customAddBtn,
customRemoveBtn,
});
try {
window.addEventListener(
"mjr:custom-roots-changed",
async (e) => {
const preferredId = String(e?.detail?.preferredId || "").trim() || null;
await customRootsController.refreshCustomRoots(preferredId);
},
{ signal: panelLifecycleAC?.signal },
);
} catch (e) {
console.debug?.(e);
}
scopeController = createScopeController({
state,
tabButtons,
customMenuBtn,
customPopover,
popovers,
refreshCustomRoots: customRootsController.refreshCustomRoots,
reloadGrid: gridController.reloadGrid,
onChanged: () => {
try {
if (gridContainer) gridContainer.dataset.mjrScope = state.scope;
} catch (e) {
console.debug?.(e);
}
notifyContextChanged();
},
onScopeChanged: async () => {
try {
await applyWatcherForScope(state.scope);
} catch (e) {
console.debug?.(e);
}
try {
await refreshDuplicateAlerts();
} catch (e) {
console.debug?.(e);
}
},
onBeforeReload: async () => {
// Prevent stale cards overlap during fast scope switches.
try {
prepareGridForScopeSwitch(gridContainer);
} catch (e) {
console.debug?.(e);
}
// Force an immediate counters refresh for this scope instead of waiting polling tick.
try {
await updateStatus(
statusDot,
statusText,
capabilitiesSection,
{ scope: state.scope, customRootId: state.customRootId },
null,
{ signal: panelLifecycleAC?.signal || null },
);
} catch (e) {
console.debug?.(e);
}
},
});
const exitSimilarViewIfActive = async ({ reload = false } = {}) => {
try {
if (
String(state.viewScope || "")
.trim()
.toLowerCase() !== "similar"
)
return;
await scopeController?.clearSimilarScope?.({ reload });
} catch (e) {
console.debug?.(e);
}
};
if (!header._mjrTabListenersBound) {
Object.values(tabButtons).forEach((btn) => {
btn.addEventListener("click", () => scopeController.setScope(btn.dataset.scope), {
signal: panelLifecycleAC?.signal,
});
});
header._mjrTabListenersBound = true;
}
bindMessagePopoverController({
messageBtn,
messagePopover,
title: messagePopoverTitle,
messageList,
historyTabBtn,
historyTabBadge,
historyPanel,
shortcutsPanel,
messageTabBtn,
shortcutsTabBtn,
markReadBtn,
popovers,
signal: panelLifecycleAC?.signal,
onBeforeToggle: () => {
popovers.close(customPopover);
popovers.close(filterPopover);
popovers.close(sortPopover);
popovers.close(collectionsPopover);
popovers.close(pinnedFoldersPopover);
},
});
popovers.setDismissWhitelist([
customPopover,
filterPopover,
sortPopover,
collectionsPopover,
pinnedFoldersPopover,
messagePopover,
customMenuBtn,
filterBtn,
sortBtn,
collectionsBtn,
pinnedFoldersBtn,
messageBtn,
]);
customMenuBtn.addEventListener(
"click",
(e) => {
e.stopPropagation();
popovers.close(filterPopover);
popovers.close(sortPopover);
popovers.close(collectionsPopover);
popovers.close(messagePopover);
popovers.toggle(customPopover, customMenuBtn);
},
{ signal: panelLifecycleAC?.signal },
);
filterBtn.addEventListener(
"click",
(e) => {
e.stopPropagation();
popovers.close(customPopover);
popovers.close(sortPopover);
popovers.close(collectionsPopover);
popovers.close(messagePopover);
popovers.toggle(filterPopover, filterBtn);
},
{ signal: panelLifecycleAC?.signal },
);
similarBtn?.addEventListener(
"click",
async (e) => {
e.stopPropagation();
if (!isAiEnabled()) {
comfyToast(similarDisabledTitle, "info", 2200);
return;
}
try {
popovers.close(customPopover);
popovers.close(filterPopover);
popovers.close(sortPopover);
popovers.close(collectionsPopover);
popovers.close(messagePopover);
} catch (err) {
console.debug?.(err);
}
const selectedIdRaw = String(
state.activeAssetId ||
(Array.isArray(state.selectedAssetIds) ? state.selectedAssetIds[0] : "") ||
"",
).trim();
const selectedId = Number(selectedIdRaw);
if (!Number.isFinite(selectedId) || selectedId <= 0) {
comfyToast(
t(
"search.selectAssetForSimilar",
"Select an asset first to find similar images/videos.",
),
"info",
2500,
);
return;
}
const prevTitle = similarBtn.title;
similarBtn.disabled = true;
similarBtn.title = t("search.findingSimilar", "Finding similar assets...");
try {
const res = await vectorFindSimilar(selectedId, {
topK: 100,
scope: state.scope || "output",
customRootId: state.customRootId || "",
});
if (!res?.ok) {
comfyToast(
String(
res?.error ||
t("search.findSimilarFailed", "Failed to find similar assets"),
),
"error",
3000,
);
return;
}
const list = Array.isArray(res?.data) ? res.data : [];
state.similarResults = list;
state.similarSourceAssetId = String(selectedId);
state.similarTitle = t(
"search.similarResults",
"Similar to asset #{id} ({n} results)",
{
id: selectedId,
n: list.length,
},
);
await scopeController?.setScope?.("similar");
try {
state.lastGridCount = Number(list.length || 0) || 0;
state.lastGridTotal = Number(list.length || 0) || 0;
gridContainer.dispatchEvent?.(
new CustomEvent("mjr:grid-stats", {
detail: { count: list.length, total: list.length },
}),
);
} catch (err) {
console.debug?.(err);
}
} catch (err) {
console.debug?.(err);
comfyToast(
t("search.findSimilarFailed", "Failed to find similar assets"),
"error",
3000,
);
} finally {
similarBtn.disabled = false;
similarBtn.title = prevTitle;
}
},
{ signal: panelLifecycleAC?.signal },
);
const sortController = createSortController({
state,
sortBtn,
sortMenu,
sortPopover,
popovers,
reloadGrid: gridController.reloadGrid,
onChanged: () => {
notifyContextChanged();
},
});
sortController.bind({
onBeforeToggle: () => {
popovers.close(customPopover);
popovers.close(filterPopover);
popovers.close(collectionsPopover);
popovers.close(messagePopover);
},
});
const collectionsController = createCollectionsController({
state,
collectionsBtn,
collectionsMenu,
collectionsPopover,
popovers,
isAiEnabled,
reloadGrid: gridController.reloadGrid,
onChanged: () => {
notifyContextChanged();
},
});
collectionsController.bind({
onBeforeToggle: () => {
popovers.close(customPopover);
popovers.close(filterPopover);
popovers.close(sortPopover);
popovers.close(messagePopover);
},
});
const createPinnedMenuItem = (label, { right = null, danger = false } = {}) => {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "mjr-menu-item";
if (danger) btn.style.color = "var(--error-text, #f44336)";
btn.style.cssText = `
display:flex;
align-items:center;
justify-content:space-between;
width:100%;
gap:10px;
padding:9px 10px;
border-radius:9px;
border:1px solid rgba(120,190,255,0.18);
background:linear-gradient(135deg, rgba(255,255,255,0.03), rgba(255,255,255,0.01));
transition: border-color 120ms ease, background 120ms ease;
`;
const left = document.createElement("span");
left.textContent = String(label || "");
left.style.cssText = "font-weight:600; color:var(--fg-color, #e6edf7);";
const rightWrap = document.createElement("span");
rightWrap.style.cssText = "display:flex; align-items:center; gap:8px;";
if (right) {
const hint = document.createElement("span");
hint.textContent = String(right);
hint.style.cssText = "opacity:0.68; font-size:11px;";
rightWrap.appendChild(hint);
}
btn.appendChild(left);
btn.appendChild(rightWrap);
btn.addEventListener(
"mouseenter",
() => {
btn.style.borderColor = "rgba(145,205,255,0.4)";
btn.style.background =
"linear-gradient(135deg, rgba(80,140,255,0.18), rgba(32,100,200,0.14))";
},
{ signal: panelLifecycleAC?.signal },
);
btn.addEventListener(
"mouseleave",
() => {
btn.style.borderColor = "rgba(120,190,255,0.18)";
btn.style.background =
"linear-gradient(135deg, rgba(255,255,255,0.03), rgba(255,255,255,0.01))";
},
{ signal: panelLifecycleAC?.signal },
);
return btn;
};
const renderPinnedFoldersMenu = async () => {
try {
pinnedFoldersMenu.replaceChildren();
} catch (e) {
console.debug?.(e);
}
let roots = [];
try {
const res = await get(
ENDPOINTS.CUSTOM_ROOTS,
panelLifecycleAC?.signal ? { signal: panelLifecycleAC.signal } : undefined,
);
roots = res?.ok && Array.isArray(res.data) ? res.data : [];
} catch (e) {
console.debug?.(e);
}
if (!roots.length) {
const empty = document.createElement("div");
empty.className = "mjr-muted";
empty.style.cssText = "padding:10px 12px; opacity:0.75;";
empty.textContent = t("msg.noPinnedFolders", "No pinned folders");
pinnedFoldersMenu.appendChild(empty);
return;
}
for (const r of roots) {
const id = String(r?.id || "").trim();
if (!id) continue;
const label = String(r?.name || r?.path || id).trim();
const path = String(r?.path || "").trim();
const row = document.createElement("div");
row.style.cssText = "display:flex; align-items:stretch; width:100%;";
const openBtn = createPinnedMenuItem(label, { right: path || null });
openBtn.style.flex = "1";
openBtn.addEventListener(
"click",
async () => {
state.scope = "custom";
try {
if (gridContainer) gridContainer.dataset.mjrScope = "custom";
} catch (e) {
console.debug?.(e);
}
state.customRootId = id;
state.customRootLabel = label;
state.currentFolderRelativePath = "";
try {
if (customSelect) customSelect.value = id;
} catch (e) {
console.debug?.(e);
}
try {
scopeController?.setActiveTabStyles?.();
} catch (e) {
console.debug?.(e);
}
try {
await applyWatcherForScope(state.scope);
} catch (e) {
console.debug?.(e);
}
try {
await refreshDuplicateAlerts();
} catch (e) {
console.debug?.(e);
}
try {
notifyContextChanged();
} catch (e) {
console.debug?.(e);
}
popovers.close(pinnedFoldersPopover);
await gridController.reloadGrid();
},
{ signal: panelLifecycleAC?.signal },
);
const unpinBtn = document.createElement("button");
unpinBtn.type = "button";
unpinBtn.className = "mjr-menu-item";
unpinBtn.title = t("ctx.unpinFolder", "Unpin folder");
unpinBtn.style.cssText =
"width:42px; justify-content:center; padding:0; border:1px solid rgba(255,95,95,0.35); border-radius:9px; margin-left:6px; background: linear-gradient(135deg, rgba(255,70,70,0.16), rgba(160,20,20,0.12));";
const trash = document.createElement("i");
trash.className = "pi pi-times";
trash.style.cssText = "opacity:0.88; color: rgba(255,130,130,0.96);";
unpinBtn.appendChild(trash);
unpinBtn.addEventListener(
"click",
async (e) => {
e.preventDefault();
e.stopPropagation();
const ok = await comfyConfirm(
t("dialog.unpinFolder", 'Unpin folder "{name}"?', { name: label }),
);
if (!ok) return;
const res = await post(ENDPOINTS.CUSTOM_ROOTS_REMOVE, { id });
if (!res?.ok) {
comfyToast(
res?.error || t("toast.unpinFolderFailed", "Failed to unpin folder"),
"error",
);
return;
}
if (String(state.customRootId || "") === id) {
state.customRootId = "";
state.customRootLabel = "";
state.currentFolderRelativePath = "";
}
try {
await customRootsController.refreshCustomRoots();
} catch (e) {
console.debug?.(e);
}
await renderPinnedFoldersMenu();
await gridController.reloadGrid();
},
{ signal: panelLifecycleAC?.signal },
);
row.appendChild(openBtn);
row.appendChild(unpinBtn);
pinnedFoldersMenu.appendChild(row);
}
};
pinnedFoldersBtn?.addEventListener(
"click",
async (e) => {
e.stopPropagation();
try {
await renderPinnedFoldersMenu();
} catch (e) {
console.debug?.(e);
}
popovers.close(customPopover);
popovers.close(filterPopover);
popovers.close(sortPopover);
popovers.close(collectionsPopover);
popovers.close(messagePopover);
popovers.toggle(pinnedFoldersPopover, pinnedFoldersBtn);
},
{ signal: panelLifecycleAC?.signal },
);
// Calendar UI (separate module) for day indicators.
let agendaCalendar = null;
try {
try {
kindSelect.value = state.kindFilter || "";
wfCheckbox.checked = !!state.workflowOnly;
workflowTypeSelect.value = String(state.workflowType || "")
.trim()
.toUpperCase();
ratingSelect.value = String(Number(state.minRating || 0) || 0);
minSizeInput.value = Number(state.minSizeMB || 0) > 0 ? String(state.minSizeMB) : "";
maxSizeInput.value = Number(state.maxSizeMB || 0) > 0 ? String(state.maxSizeMB) : "";
minWidthInput.value = Number(state.minWidth || 0) > 0 ? String(state.minWidth) : "";
minHeightInput.value = Number(state.minHeight || 0) > 0 ? String(state.minHeight) : "";
maxWidthInput.value = Number(state.maxWidth || 0) > 0 ? String(state.maxWidth) : "";
maxHeightInput.value = Number(state.maxHeight || 0) > 0 ? String(state.maxHeight) : "";
const minW = Number(state.minWidth || 0) || 0;
const minH = Number(state.minHeight || 0) || 0;
if (minW >= 3840 && minH >= 2160) resolutionPresetSelect.value = "uhd";
else if (minW >= 2560 && minH >= 1440) resolutionPresetSelect.value = "qhd";
else if (minW >= 1920 && minH >= 1080) resolutionPresetSelect.value = "fhd";
else if (minW >= 1280 && minH >= 720) resolutionPresetSelect.value = "hd";
else resolutionPresetSelect.value = "";
dateRangeSelect.value = state.dateRangeFilter || "";
dateExactInput.value = state.dateExactFilter || "";
} catch (e) {
console.debug?.(e);
}
agendaCalendar = createAgendaCalendar({
container: agendaContainer,
hiddenInput: dateExactInput,
state,
onRequestReloadGrid: () => gridController.reloadGrid(),
});
} catch (e) {
console.debug?.(e);
}
const disposeFilters = bindFilters({
state,
kindSelect,
wfCheckbox,
workflowTypeSelect,
ratingSelect,
minSizeInput,
maxSizeInput,
resolutionPresetSelect,
minWidthInput,
minHeightInput,
maxWidthInput,
maxHeightInput,
dateRangeSelect,
dateExactInput,
reloadGrid: gridController.reloadGrid,
lifecycleSignal: panelLifecycleAC?.signal || null,
onFiltersChanged: () => {
void exitSimilarViewIfActive({ reload: false });
try {
agendaCalendar?.refresh?.();
} catch (e) {
console.debug?.(e);
}
notifyContextChanged();
},
});
// Context controller: pills + button highlighting.
try {
contextController = createContextController({
state,
gridContainer,
filterBtn,
sortBtn,
collectionsBtn,
searchInputEl,
kindSelect,
wfCheckbox,
workflowTypeSelect,
ratingSelect,
minSizeInput,
maxSizeInput,
minWidthInput,
minHeightInput,
maxWidthInput,
maxHeightInput,
resolutionPresetSelect,
dateRangeSelect,
dateExactInput,
scopeController,
sortController,
updateSummaryBar,
reloadGrid: () => gridController.reloadGrid(),
getExtraContext: () => ({ duplicatesAlert: _duplicatesAlert }),
extraActions: {
clearSimilarScope: async () => {
try {
await scopeController?.clearSimilarScope?.();
} catch (e) {
console.debug?.(e);
}
},
clearTransientContext: async () => {
try {
state.viewScope = "";
state.similarResults = [];
state.similarTitle = "";
state.similarSourceAssetId = "";
} catch (e) {
console.debug?.(e);
}
},
resetBrowserHistory: () => {
try {
browserNav?.resetHistory?.();
} catch (e) {
console.debug?.(e);
}
},
onDuplicateAlertClick: async (alert) => {
const current = alert || _duplicatesAlert || {};
const grp = current.firstGroup;
if (grp && Array.isArray(grp.assets) && grp.assets.length >= 2) {
const keep = grp.assets[0] || {};
const dupIds = grp.assets
.slice(1)
.map((x) => Number(x?.id || 0))
.filter((x) => x > 0);
if (dupIds.length) {
const mergeOk = await comfyConfirm(
t(
"dialog.mergeDuplicateTags",
'Exact duplicates detected ({count}). Merge tags into "{target}"?',
{
count: grp.assets.length,
target: keep?.filename || keep?.id,
},
),
);
if (mergeOk) {
const mergeRes = await mergeDuplicateTags(
Number(keep?.id || 0),
dupIds,
);
if (mergeRes?.ok)
comfyToast(
t("toast.tagsMerged", "Tags merged"),
"success",
2200,
);
else
comfyToast(
t("toast.tagMergeFailed", "Tag merge failed: {error}", {
error: mergeRes?.error || "error",
}),
"warning",
3500,
);
}
const delOk = await comfyConfirm(
t(
"dialog.deleteExactDuplicates",
"Delete {count} exact duplicate(s)?",
{ count: dupIds.length },
),
);
if (delOk) {
const delRes = await deleteAssets(dupIds);
if (delRes?.ok) {
comfyToast(
t("toast.duplicatesDeleted", "Duplicates deleted"),
"success",
2200,
);
await gridController.reloadGrid();
} else {
comfyToast(
t("toast.deleteFailed", "Delete failed: {error}", {
error: delRes?.error || "error",
}),
"warning",
3500,
);
}
}
await refreshDuplicateAlerts();
return;
}
}
const startOk = await comfyConfirm(
t(
"dialog.startDuplicateAnalysis",
"Start duplicate analysis in background?",
),
);
if (!startOk) return;
const runRes = await startDuplicatesAnalysis(500);
if (runRes?.ok)
comfyToast(
t("toast.dupAnalysisStarted", "Duplicate analysis started"),
"info",
2200,
);
else
comfyToast(
t("toast.analysisNotStarted", "Analysis not started: {error}", {
error: runRes?.error || "error",
}),
"warning",
3500,
);
await refreshDuplicateAlerts();
},
},
});
} catch (e) {
console.debug?.(e);
}
// Summary bar updates: selection changes, load changes, scope/collection changes.
try {
notifyContextChanged();
const onStats = () => notifyContextChanged();
const onSelectionChanged = (e) => {
markUserInteraction();
try {
const detail = e?.detail || {};
const ids = Array.isArray(detail.selectedIds)
? detail.selectedIds.map(String).filter(Boolean)
: [];
state.selectedAssetIds = ids;
state.activeAssetId = String(detail.activeId || ids[0] || "");
} catch (e) {
console.debug?.(e);
}
notifyContextChanged();
};
gridContainer.addEventListener("mjr:grid-stats", onStats, {
signal: panelLifecycleAC?.signal,
});
gridContainer.addEventListener("mjr:selection-changed", onSelectionChanged, {
signal: panelLifecycleAC?.signal,
});
document.addEventListener?.("mjr-settings-changed", onStats, {
signal: panelLifecycleAC?.signal,
});
// Observe DOM changes for card count / selection class changes.
let raf = null;
const schedule = () => {
if (raf) cancelAnimationFrame(raf);
raf = requestAnimationFrame(() => {
raf = null;
notifyContextChanged();
});
};
const mo = new MutationObserver(() => schedule());
mo.observe(gridContainer, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["class"],
});
gridContainer._mjrSummaryBarObserver = mo;
registerSummaryDispose(() => {
try {
gridContainer.removeEventListener("mjr:grid-stats", onStats);
} catch (e) {
console.debug?.(e);
}
try {
gridContainer.removeEventListener("mjr:selection-changed", onSelectionChanged);
} catch (e) {
console.debug?.(e);
}
try {
document.removeEventListener?.("mjr-settings-changed", onStats);
} catch (e) {
console.debug?.(e);
}
try {
mo.disconnect();
} catch (e) {
console.debug?.(e);
}
});
} catch (e) {
console.debug?.(e);
}
sidebarController = bindSidebarOpen({
gridContainer,
sidebar,
createRatingBadge,
createTagsBadge,
showAssetInSidebar,
closeSidebar,
state,
});
const hotkeys = createPanelHotkeysController({
onTriggerScan: (ctx) => {
triggerScan(statusDot, statusText, capabilitiesSection, ctx || {});
},
onToggleDetails: () => {
sidebarController?.toggleDetails?.();
},
onToggleFloatingViewer: () => {
try {
window.dispatchEvent(new Event(EVENTS.MFV_TOGGLE));
} catch (e) {
console.debug?.(e);
}
},
onFocusSearch: () => {
try {
searchInputEl?.focus?.();
// Move cursor to end for immediate typing.
const len = searchInputEl?.value?.length || 0;
searchInputEl?.setSelectionRange?.(len, len);
} catch (e) {
console.debug?.(e);
}
},
onClearSearch: () => {
try {
if (!searchInputEl) return;
searchInputEl.value = "";
searchInputEl.dispatchEvent?.(new Event("input", { bubbles: true }));
} catch (e) {
console.debug?.(e);
}
},
getScanContext: () => ({
scope: state.scope,
customRootId: state.customRootId,
}),
});
const ratingHotkeys = createRatingHotkeysController({
gridContainer,
createRatingBadge,
});
let searchTimeout = null;
let lastSearchValue = "";
if (container._eventCleanup) {
try {
container._eventCleanup();
} catch (e) {
console.debug?.(e);
}
}
hotkeys.bind(content);
ratingHotkeys.bind();
setHotkeysScope("grid");
// Attach controllers to container for cleanup on re-render
try {
container._mjrHotkeys = hotkeys;
container._mjrRatingHotkeys = ratingHotkeys;
container._mjrSidebarController = sidebarController;
} catch (e) {
console.debug?.(e);
}
container._eventCleanup = () => {
try {
panelLifecycleAC?.abort?.();
} catch (e) {
console.debug?.(e);
}
try {
statusSection?._mjrStatusPollDispose?.();
} catch (e) {
console.debug?.(e);
}
try {
container._mjrVersionUpdateCleanup?.();
} catch (e) {
console.debug?.(e);
}
container._mjrVersionUpdateCleanup = null;
try {
container._mjrPanelState?._mjrDispose?.();
} catch (e) {
console.debug?.(e);
}
try {
container._mjrPanelState = null;
} catch (e) {
console.debug?.(e);
}
try {
if (_scrollTimer) clearTimeout(_scrollTimer);
_scrollTimer = null;
} catch (e) {
console.debug?.(e);
}
try {
hotkeys.dispose();
} catch (e) {
console.debug?.(e);
}
try {
ratingHotkeys.dispose();
} catch (e) {
console.debug?.(e);
}
if (getHotkeysState().scope === "grid") {
setHotkeysScope(null);
}
try {
agendaCalendar?.dispose?.();
} catch (e) {
console.debug?.(e);
}
try {
disposeFilters?.();
} catch (e) {
console.debug?.(e);
}
try {
if (searchTimeout) clearTimeout(searchTimeout);
searchTimeout = null;
} catch (e) {
console.debug?.(e);
}
try {
sidebar?.dispose?.();
} catch (e) {
console.debug?.(e);
}
try {
sidebarController?.dispose?.();
} catch (e) {
console.debug?.(e);
}
try {
if (_dupPollTimer) clearInterval(_dupPollTimer);
_dupPollTimer = null;
} catch (e) {
console.debug?.(e);
}
try {
if (_autoLoadTimer) clearTimeout(_autoLoadTimer);
_autoLoadTimer = null;
} catch (e) {
console.debug?.(e);
}
try {
gridContainer?._mjrSummaryBarDispose?.();
} catch (e) {
console.debug?.(e);
}
try {
gridContainer?._mjrSummaryBarObserver?.disconnect?.();
} catch (e) {
console.debug?.(e);
}
try {
gridContainer?._mjrGridContextMenuUnbind?.();
} catch (e) {
console.debug?.(e);
}
try {
disposeCustomRootsBindings?.();
} catch (e) {
console.debug?.(e);
}
try {
_unbindBrowserFolderNav?.();
} catch (e) {
console.debug?.(e);
}
try {
disposeGridScanListeners();
} catch (e) {
console.debug?.(e);
}
try {
if (gridContainer) disposeGrid(gridContainer);
} catch (e) {
console.debug?.(e);
}
if (_activeGridContainer === gridContainer) {
_activeGridContainer = null;
}
gridContainer = null;
try {
popovers.dispose();
} catch (e) {
console.debug?.(e);
}
try {
if (container._mjrPopoverManager === popovers) {
container._mjrPopoverManager = null;
}
} catch (e) {
console.debug?.(e);
}
};
let lastKnownScan = null;
let lastKnownIndexEnd = null;
let lastKnownTotalAssets = null;
let hasSeenFirstCounters = false;
let pendingReloadCount = 0;
let isReloading = false;
let _lastAutoReloadAt = 0;
const queuedReload = async () => {
if (!gridContainer) return;
pendingReloadCount++;
if (isReloading) return;
isReloading = true;
try {
while (pendingReloadCount > 0) {
pendingReloadCount = 0;
const anchor = captureAnchor(gridContainer);
await gridController.reloadGrid();
try {
await restoreAnchor(gridContainer, anchor);
} catch (e) {
console.debug?.(e);
}
}
} finally {
isReloading = false;
}
};
const handleCountersUpdate = async (counters) => {
if (!hasSeenFirstCounters) {
hasSeenFirstCounters = true;
lastKnownScan = counters.last_scan_end;
lastKnownIndexEnd = counters.last_index_end;
lastKnownTotalAssets = Number(counters.total_assets ?? null);
return;
}
const hasNewScan = counters.last_scan_end && counters.last_scan_end !== lastKnownScan;
// last_index_end is updated by index_paths (individual file indexing, e.g. after a workflow run).
// last_scan_end is only updated by full directory scans. Both must be checked to catch generation events.
const hasNewIndexEnd =
counters.last_index_end && counters.last_index_end !== lastKnownIndexEnd;
const totalAssets = Number(counters.total_assets ?? null);
// Fallback for "new files appeared" without a full scan end update (e.g. incremental indexing).
// Only auto-refresh when browsing output with default filters to avoid disrupting searches.
const isDefaultBrowse =
state.scope === "output" &&
!state.collectionId &&
String(getQuery?.() ?? "*").trim() === "*" &&
!state.kindFilter &&
!state.workflowOnly &&
!(Number(state.minRating || 0) > 0) &&
!(Number(state.minSizeMB || 0) > 0) &&
!(Number(state.maxSizeMB || 0) > 0) &&
!(Number(state.minWidth || 0) > 0) &&
!(Number(state.minHeight || 0) > 0) &&
!(Number(state.maxWidth || 0) > 0) &&
!(Number(state.maxHeight || 0) > 0) &&
!String(state.workflowType || "").trim() &&
!state.dateRangeFilter &&
!state.dateExactFilter;
const totalDelta =
Number.isFinite(totalAssets) && Number.isFinite(lastKnownTotalAssets)
? totalAssets - lastKnownTotalAssets
: 0;
// Only trigger reload for large bulk additions (e.g. full scan, external copy).
// Small increments from generation are handled by upsert (no reload needed).
const hasNewTotal = totalDelta >= 20;
// Keep baseline current even if we skip auto-reload; avoids repeated trigger loops.
if (Number.isFinite(totalAssets)) {
lastKnownTotalAssets = totalAssets;
}
// Auto-reload should only happen in default output browsing mode.
// In Browser/Custom or filtered contexts it feels like random refresh storms.
if (!isDefaultBrowse) {
lastKnownScan = counters.last_scan_end;
lastKnownIndexEnd = counters.last_index_end;
return;
}
// If upsert handled assets recently, skip reload entirely — assets are already visible.
const recentUpsertMs = Date.now() - Number(window.__mjrLastAssetUpsert || 0);
const upsertHandledRecently = recentUpsertMs < 15000;
// hasNewIndexEnd: files were indexed (e.g. after a generation run).
// Primary handler: mjr-asset-added WS push → upsertAsset (instant, no flash).
// Fallback: reload when the upsert didn't run recently (panel was hidden, wrong
// scope, or WS event was missed). Skip the reload when upsert ran within the
// last 15 s to avoid a flash over assets that are already visible.
if (hasNewIndexEnd) lastKnownIndexEnd = counters.last_index_end;
const needsFallbackReload =
hasNewIndexEnd && !hasNewScan && !hasNewTotal && !upsertHandledRecently;
// If upsert handled things recently, also skip hasNewTotal reloads — only
// full scans (hasNewScan) should force a reload in that window.
if (upsertHandledRecently && !hasNewScan) return;
if (!hasNewScan && !hasNewTotal && !needsFallbackReload) return;
// Global throttle against reload storms from frequent watcher/enrichment updates.
try {
const now = Date.now();
if (now - Number(_lastAutoReloadAt || 0) < 15000) return;
} catch (e) {
console.debug?.(e);
}
// Avoid disruptive auto-reload while user is actively interacting.
try {
const recentInteraction = Date.now() - Number(_lastUserInteractionAt || 0) < 2000;
if (recentInteraction) return;
} catch (e) {
console.debug?.(e);
}
lastKnownScan = counters.last_scan_end;
lastKnownIndexEnd = counters.last_index_end;
try {
await queuedReload();
} catch (e) {
console.debug?.(e);
}
try {
_lastAutoReloadAt = Date.now();
} catch {
_lastAutoReloadAt = 0;
}
};
setupStatusPolling(
statusDot,
statusText,
statusSection,
capabilitiesSection,
handleCountersUpdate,
() => ({ scope: state.scope, customRootId: state.customRootId }),
);
scopeController.setActiveTabStyles();
browserNav?.renderBreadcrumb?.();
// Initial loading of custom roots (restores selection from state if applicable)
customRootsController.refreshCustomRoots().catch(() => {});
searchInputEl.addEventListener(
"input",
(e) => {
const value = e?.target?.value ?? "";
if (value === lastSearchValue) return;
lastSearchValue = value;
void exitSimilarViewIfActive({ reload: false });
notifyContextChanged();
if (searchTimeout) clearTimeout(searchTimeout);
// Immediate search for empty / browse-all queries.
if (value.length === 0 || value === "*") {
startTimer("searchQuery");
gridController.reloadGrid();
return;
}
// Debounce for normal searches.
searchTimeout = setTimeout(() => {
searchTimeout = null;
startTimer("searchQuery");
gridController.reloadGrid();
}, 200);
},
{ signal: panelLifecycleAC?.signal },
);
searchInputEl.addEventListener(
"keypress",
(e) => {
if (e.key === "Enter") {
void exitSimilarViewIfActive({ reload: false });
startTimer("searchQuery");
gridController.reloadGrid();
notifyContextChanged();
}
},
{ signal: panelLifecycleAC?.signal },
);
const initialLoadPromise = gridController.reloadGrid();
try {
await refreshDuplicateAlerts();
} catch (e) {
console.debug?.(e);
}
try {
_dupPollTimer = setInterval(() => {
try {
if (panelLifecycleAC?.signal?.aborted) return;
} catch (e) {
console.debug?.(e);
}
refreshDuplicateAlerts().catch(() => {});
}, 15000);
} catch (e) {
console.debug?.(e);
}
// Restore scroll, selection visuals, and sidebar AFTER grid loads.
const restoreUIState = async () => {
try {
await initialLoadPromise;
} catch (e) {
console.debug?.(e);
}
// Wait one frame for DOM to settle before restoring.
requestAnimationFrame(() => {
// 1. Restore scroll position
if (state.scrollTop > 0) {
try {
gridWrapper.scrollTop = state.scrollTop;
} catch (e) {
console.debug?.(e);
}
}
// 2. Restore selection visuals on cards
if (state.selectedAssetIds?.length || state.activeAssetId) {
try {
const selected = (state.selectedAssetIds || []).map(String);
const activeId = String(state.activeAssetId || selected[0] || "");
selected.forEach((id) => {
const card = gridContainer.querySelector(
`.mjr-asset-card[data-mjr-asset-id="${_safeCssEscapeAttr(id)}"]`,
);
if (card) {
card.classList.add("is-selected");
card.setAttribute?.("aria-selected", "true");
}
});
if (activeId) {
const activeCard = gridContainer.querySelector(
`.mjr-asset-card[data-mjr-asset-id="${_safeCssEscapeAttr(activeId)}"]`,
);
if (activeCard) {
activeCard.classList.add("is-active");
activeCard.setAttribute?.("aria-selected", "true");
// Scroll selection into view if not visible after scroll restore
if (state.scrollTop === 0) {
activeCard.scrollIntoView?.({
block: "nearest",
behavior: "instant",
});
}
}
}
} catch (e) {
console.debug?.(e);
}
}
// 3. Restore sidebar if it was open
if (state.sidebarOpen && state.activeAssetId) {
try {
sidebarController?.toggleDetails?.();
} catch (e) {
console.debug?.(e);
}
}
});
};
restoreUIState();
const checkAndAutoLoad = async () => {
try {
await initialLoadPromise;
} catch (e) {
console.debug?.(e);
}
const hasCards = gridContainer.querySelector(".mjr-card") != null;
if (hasCards) return;
if (state.scope !== "output") return;
if (getQuery() !== "*") return;
try {
const statusData = await get(`${ENDPOINTS.HEALTH_COUNTERS}?scope=output`, {
signal: panelLifecycleAC?.signal,
});
if (statusData?.ok && (statusData.data?.total_assets || 0) > 0) {
await loadAssets(gridContainer);
}
} catch (e) {
console.debug?.(e);
}
};
_autoLoadTimer = setTimeout(checkAndAutoLoad, 2000);
// End performance timing and record metrics
const renderDuration = endTimer("panelRender", "gridRender");
trackGridRender(renderDuration);
return { gridContainer };
}