feat: Enhance context and custom roots management
- Added resetBrowserHistory functionality to contextController for better state management. - Improved handling of custom root states in customRootsController, including resetting labels and paths. - Removed unused refreshCustomRoots from scopeController. - Updated sidebarController to provide more context in asset status updates. - Introduced new folder details section in the sidebar to display folder metadata. - Created pinned folders popover view for better folder management. - Implemented custom status dot themes for improved visual feedback in asset status. - Added folder info endpoint in the backend to retrieve folder statistics and metadata.
This commit is contained in:
@@ -459,6 +459,46 @@ export async function getFileMetadataScoped(
|
||||
return get(url, options);
|
||||
}
|
||||
|
||||
export async function getFolderInfo({ filepath = "", root_id = "", subfolder = "" } = {}, options = {}) {
|
||||
try {
|
||||
if (globalThis.__mjrFolderInfoSupported === false) {
|
||||
return { ok: false, data: null, error: "Folder info endpoint unavailable", code: "UNAVAILABLE" };
|
||||
}
|
||||
if (globalThis.__mjrFolderInfoSupported == null) {
|
||||
const rr = await get("/mjr/am/routes");
|
||||
if (rr?.ok && Array.isArray(rr.data)) {
|
||||
const hasRoute = rr.data.some((r) => String(r?.path || "").trim() === "/mjr/am/folder-info");
|
||||
globalThis.__mjrFolderInfoSupported = !!hasRoute;
|
||||
if (!hasRoute) {
|
||||
return { ok: false, data: null, error: "Folder info endpoint unavailable", code: "UNAVAILABLE" };
|
||||
}
|
||||
} else {
|
||||
// Soft-fail: keep null so future calls can retry route discovery.
|
||||
globalThis.__mjrFolderInfoSupported = null;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const fp = String(filepath || "").trim();
|
||||
const rid = String(root_id || "").trim();
|
||||
const sub = String(subfolder || "").trim();
|
||||
let url = ENDPOINTS.FOLDER_INFO;
|
||||
const params = [];
|
||||
if (fp) params.push(`filepath=${encodeURIComponent(fp)}`);
|
||||
else {
|
||||
if (rid) params.push(`root_id=${encodeURIComponent(rid)}`);
|
||||
if (sub) params.push(`subfolder=${encodeURIComponent(sub)}`);
|
||||
}
|
||||
if (params.length) url += `?${params.join("&")}`;
|
||||
const res = await get(url, options);
|
||||
try {
|
||||
if (!res?.ok && Number(res?.status || 0) === 404) {
|
||||
globalThis.__mjrFolderInfoSupported = false;
|
||||
}
|
||||
} catch {}
|
||||
return res;
|
||||
}
|
||||
|
||||
export async function setProbeBackendMode(mode) {
|
||||
if (!mode || typeof mode !== "string") {
|
||||
return { ok: false, error: "Missing mode", code: "INVALID_INPUT" };
|
||||
|
||||
@@ -26,6 +26,7 @@ export const ENDPOINTS = {
|
||||
ROOTS: "/mjr/am/roots",
|
||||
CUSTOM_ROOTS: "/mjr/am/custom-roots",
|
||||
CUSTOM_ROOTS_REMOVE: "/mjr/am/custom-roots/remove",
|
||||
FOLDER_INFO: "/mjr/am/folder-info",
|
||||
|
||||
// Metadata
|
||||
METADATA: "/mjr/am/metadata",
|
||||
|
||||
+4
-2
@@ -313,7 +313,6 @@ const DICTIONARY = {
|
||||
"toast.failedRemoveFolder": "Failed to remove custom folder",
|
||||
"toast.folderLinked": "Folder linked successfully",
|
||||
"toast.folderRemoved": "Folder removed",
|
||||
"toast.nativeBrowserUnavailable": "Native folder browser unavailable. Please enter path manually.",
|
||||
"toast.errorAddingFolder": "An error occurred while adding the custom folder",
|
||||
"toast.errorRemovingFolder": "An error occurred while removing the custom folder",
|
||||
"toast.failedCreateCollection": "Failed to create collection",
|
||||
@@ -394,6 +393,8 @@ const DICTIONARY = {
|
||||
"status.toast.success": "Index status: ready",
|
||||
"status.toast.warning": "Index status: attention needed",
|
||||
"status.toast.error": "Index status: error",
|
||||
"status.toast.browser": "Index status: browser scope",
|
||||
"status.browserMetricsHidden": "Browser mode: global DB/index metrics hidden",
|
||||
"toast.dbRestoreStopping": "Stopping running workers",
|
||||
"toast.dbRestoreResetting": "Unlocking and resetting database",
|
||||
"toast.dbRestoreReplacing": "Replacing database files",
|
||||
@@ -705,7 +706,6 @@ const DICTIONARY = {
|
||||
"toast.failedRemoveFolder": "Échec de la suppression du dossier personnalisé",
|
||||
"toast.folderLinked": "Dossier lié avec succès",
|
||||
"toast.folderRemoved": "Dossier supprimé",
|
||||
"toast.nativeBrowserUnavailable": "Explorateur natif indisponible. Veuillez entrer le chemin manuellement.",
|
||||
"toast.errorAddingFolder": "Erreur lors de l'ajout du dossier personnalisé",
|
||||
"toast.errorRemovingFolder": "Erreur lors de la suppression du dossier personnalisé",
|
||||
"toast.failedCreateCollection": "Échec de la création de la collection",
|
||||
@@ -780,6 +780,8 @@ const DICTIONARY = {
|
||||
"status.toast.success": "État index : prêt",
|
||||
"status.toast.warning": "État index : attention requise",
|
||||
"status.toast.error": "État index : erreur",
|
||||
"status.toast.browser": "État index : scope Navigateur",
|
||||
"status.browserMetricsHidden": "Mode navigateur : les métriques globales DB/index sont masquées",
|
||||
"hotkey.scan": "Scanner (S)",
|
||||
"hotkey.search": "Rechercher (Ctrl+F)",
|
||||
"hotkey.details": "Afficher détails (D)",
|
||||
|
||||
+4
-4
@@ -408,11 +408,11 @@ const applySettingsToConfig = (settings) => {
|
||||
let mode = settings.grid?.videoAutoplayMode ?? APP_DEFAULTS.GRID_VIDEO_AUTOPLAY_MODE;
|
||||
// Backward compat: old boolean "videoHoverAutoplay" → "hover"
|
||||
if (mode === undefined || mode === null) {
|
||||
mode = settings.grid?.videoHoverAutoplay ? "hover" : "off";
|
||||
mode = settings.grid?.videoHoverAutoplay === false ? "off" : "hover";
|
||||
}
|
||||
if (mode === true) mode = "hover";
|
||||
if (mode === false) mode = "off";
|
||||
if (mode !== "hover" && mode !== "always") mode = "off";
|
||||
if (mode !== "hover" && mode !== "always" && mode !== "off") mode = "hover";
|
||||
APP_CONFIG.GRID_VIDEO_AUTOPLAY_MODE = mode;
|
||||
}
|
||||
|
||||
@@ -1053,11 +1053,11 @@ export const registerMajoorSettings = (app, onApplied) => {
|
||||
let mode = settings.grid?.videoAutoplayMode;
|
||||
// Backward compat from old boolean
|
||||
if (mode === undefined || mode === null) {
|
||||
mode = settings.grid?.videoHoverAutoplay ? "hover" : "off";
|
||||
mode = settings.grid?.videoHoverAutoplay === false ? "off" : "hover";
|
||||
}
|
||||
if (mode === true) mode = "hover";
|
||||
if (mode === false) mode = "off";
|
||||
if (mode !== "hover" && mode !== "always") mode = "off";
|
||||
if (mode !== "hover" && mode !== "always" && mode !== "off") mode = "hover";
|
||||
const labels = {
|
||||
off: t("setting.grid.videoAutoplayMode.off", "Off"),
|
||||
hover: t("setting.grid.videoAutoplayMode.hover", "Hover"),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Badges Component - File type, rating, tags badges
|
||||
*/
|
||||
import { resolveAssetStatusDotColor } from "../features/status/AssetStatusDotTheme.js";
|
||||
|
||||
/**
|
||||
* Create file type badge (overlaid on thumbnail)
|
||||
@@ -160,21 +161,17 @@ export function createWorkflowDot(asset) {
|
||||
status = "pending";
|
||||
title = "Pending: database metadata enrichment in progress";
|
||||
}
|
||||
applyAssetStatusDotState(dot, status, title);
|
||||
applyAssetStatusDotState(dot, status, title, { asset });
|
||||
dot.textContent = "\u25CF";
|
||||
dot.title = `${title}\nClick to rescan this file`;
|
||||
|
||||
return dot;
|
||||
}
|
||||
|
||||
export function applyAssetStatusDotState(dot, state, title = "") {
|
||||
export function applyAssetStatusDotState(dot, state, title = "", context = {}) {
|
||||
if (!dot) return;
|
||||
const s = String(state || "").toLowerCase();
|
||||
let color = "var(--mjr-status-neutral, #666)";
|
||||
if (s === "pending" || s === "info") color = "var(--mjr-status-info, #64B5F6)";
|
||||
else if (s === "success") color = "var(--mjr-status-success, #4CAF50)";
|
||||
else if (s === "warning") color = "var(--mjr-status-warning, #FFA726)";
|
||||
else if (s === "error") color = "var(--mjr-status-error, #f44336)";
|
||||
const color = resolveAssetStatusDotColor(s, { dot, ...(context || {}) });
|
||||
|
||||
try {
|
||||
dot.dataset.mjrStatus = s || "neutral";
|
||||
|
||||
@@ -5,13 +5,14 @@
|
||||
* while delegating UI rendering/parsing to `js/components/sidebar/sections/*` and `parsers/*`.
|
||||
*/
|
||||
|
||||
import { getAssetMetadata, getFileMetadataScoped } from "../../api/client.js";
|
||||
import { getAssetMetadata, getFileMetadataScoped, getFolderInfo } from "../../api/client.js";
|
||||
import { createSidebarHeader } from "./sections/HeaderSection.js";
|
||||
import { createPreviewSection } from "./sections/PreviewSection.js";
|
||||
import { createRatingTagsSection } from "./sections/RatingTagsSection.js";
|
||||
import { createFileInfoSection } from "./sections/FileInfoSection.js";
|
||||
import { createGenerationSection } from "./sections/GenerationSection.js";
|
||||
import { createWorkflowMinimapSection } from "./sections/WorkflowMinimapSection.js";
|
||||
import { createFolderDetailsSection } from "./sections/FolderDetailsSection.js";
|
||||
import { ASSET_RATING_CHANGED_EVENT, ASSET_TAGS_CHANGED_EVENT } from "../../app/events.js";
|
||||
import { loadMajoorSettings } from "../../app/settings.js";
|
||||
|
||||
@@ -124,6 +125,7 @@ const cleanupMinimapSections = (root) => {
|
||||
*/
|
||||
export async function showAssetInSidebar(sidebar, asset, onUpdate) {
|
||||
if (!sidebar || !asset) return;
|
||||
const isFolderAsset = String(asset?.kind || "").toLowerCase() === "folder";
|
||||
|
||||
const hasMeaningfulMetadataRaw = (value) => {
|
||||
if (value == null) return false;
|
||||
@@ -201,16 +203,20 @@ export async function showAssetInSidebar(sidebar, asset, onUpdate) {
|
||||
content.innerHTML = "";
|
||||
const settings = loadMajoorSettings();
|
||||
const showPreviewThumb = !!(settings?.sidebar?.showPreviewThumb ?? true);
|
||||
content.appendChild(createPreviewSection(data, { showPreviewThumb }));
|
||||
const ratingTagsSection = createRatingTagsSection(data, onUpdate);
|
||||
sidebar._ratingTagsSection = ratingTagsSection;
|
||||
content.appendChild(ratingTagsSection);
|
||||
const fileInfoSection = createFileInfoSection(data);
|
||||
if (fileInfoSection) content.appendChild(fileInfoSection);
|
||||
const genMetadata = createGenerationSection(data);
|
||||
if (genMetadata) content.appendChild(genMetadata);
|
||||
const workflow = createWorkflowMinimapSection(data);
|
||||
if (workflow) content.appendChild(workflow);
|
||||
if (isFolderAsset) {
|
||||
content.appendChild(createFolderDetailsSection(asset, data?.folder_info || data?.folderInfo || null));
|
||||
} else {
|
||||
content.appendChild(createPreviewSection(data, { showPreviewThumb }));
|
||||
const ratingTagsSection = createRatingTagsSection(data, onUpdate);
|
||||
sidebar._ratingTagsSection = ratingTagsSection;
|
||||
content.appendChild(ratingTagsSection);
|
||||
const fileInfoSection = createFileInfoSection(data);
|
||||
if (fileInfoSection) content.appendChild(fileInfoSection);
|
||||
const genMetadata = createGenerationSection(data);
|
||||
if (genMetadata) content.appendChild(genMetadata);
|
||||
const workflow = createWorkflowMinimapSection(data);
|
||||
if (workflow) content.appendChild(workflow);
|
||||
}
|
||||
sidebar._currentFullAsset = data;
|
||||
};
|
||||
|
||||
@@ -236,6 +242,25 @@ export async function showAssetInSidebar(sidebar, asset, onUpdate) {
|
||||
if (sidebar._requestSeq !== requestSeq || sidebar._currentAsset !== asset) return;
|
||||
const opts = buildFetchOptions();
|
||||
const signal = opts.signal;
|
||||
if (isFolderAsset) {
|
||||
try {
|
||||
const filepath = String(fullAsset?.filepath || fullAsset?.subfolder || "").trim();
|
||||
const root_id = String(fullAsset?.root_id || fullAsset?.rootId || "").trim();
|
||||
const subfolder = root_id ? String(fullAsset?.subfolder || "").trim() : "";
|
||||
const res = await getFolderInfo({ filepath, root_id, subfolder }, opts);
|
||||
if (signal?.aborted) return;
|
||||
if (res?.ok && res.data) {
|
||||
tryUpdateWith({ folder_info: res.data });
|
||||
}
|
||||
} catch (err) {
|
||||
if (!(signal?.aborted)) console.warn("Failed to load folder details:", err);
|
||||
} finally {
|
||||
if (!signal?.aborted) {
|
||||
sidebar._currentFetchAbortController = null;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (asset.id && (!hasGenerationLikeData(fullAsset) && !fullAsset.exif)) {
|
||||
const result = await getAssetMetadata(asset.id, opts);
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { createSection, createParametersBox } from "../utils/dom.js";
|
||||
import { formatDate, formatTime } from "../../../utils/format.js";
|
||||
|
||||
function formatBytes(bytes) {
|
||||
const n = Number(bytes || 0);
|
||||
if (!Number.isFinite(n) || n <= 0) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let size = n;
|
||||
let i = 0;
|
||||
while (size >= 1024 && i < units.length - 1) {
|
||||
size /= 1024;
|
||||
i += 1;
|
||||
}
|
||||
return `${size.toFixed(i > 0 ? 1 : 0)} ${units[i]}`;
|
||||
}
|
||||
|
||||
export function createFolderDetailsSection(asset, folderInfo = null) {
|
||||
const section = createSection("Folder Details");
|
||||
const data = folderInfo || {};
|
||||
|
||||
const path = String(
|
||||
data.path ||
|
||||
asset?.filepath ||
|
||||
asset?.subfolder ||
|
||||
""
|
||||
);
|
||||
const name = String(data.name || asset?.filename || "").trim();
|
||||
const files = Number(data.files ?? 0);
|
||||
const folders = Number(data.folders ?? 0);
|
||||
const size = Number(data.size ?? 0);
|
||||
const mtime = Number(data.mtime ?? asset?.mtime ?? 0);
|
||||
const ctime = Number(data.ctime ?? 0);
|
||||
const truncated = !!data.truncated;
|
||||
|
||||
const rows = [
|
||||
{ label: "Name", value: name || "-" },
|
||||
{ label: "Path", value: path || "-" },
|
||||
{ label: "Folders", value: Number.isFinite(folders) ? String(folders) : "-" },
|
||||
{ label: "Files", value: Number.isFinite(files) ? String(files) : "-" },
|
||||
{ label: "Size", value: formatBytes(size) },
|
||||
];
|
||||
if (ctime > 0) {
|
||||
rows.push({
|
||||
label: "Created",
|
||||
value: `${formatDate(ctime) || "-"} ${formatTime(ctime) || ""}`.trim(),
|
||||
});
|
||||
}
|
||||
if (mtime > 0) {
|
||||
rows.push({
|
||||
label: "Modified",
|
||||
value: `${formatDate(mtime) || "-"} ${formatTime(mtime) || ""}`.trim(),
|
||||
});
|
||||
}
|
||||
if (truncated) {
|
||||
rows.push({
|
||||
label: "Note",
|
||||
value: "Scan was truncated for performance",
|
||||
valueStyle: "color:#FFB74D;font-weight:600;",
|
||||
});
|
||||
}
|
||||
|
||||
section.appendChild(createParametersBox("Folder", rows, "#26A69A", { emphasis: true }));
|
||||
return section;
|
||||
}
|
||||
|
||||
@@ -562,19 +562,17 @@ export function createGridContainer() {
|
||||
if (!asset) return;
|
||||
|
||||
// Get all assets in current grid for navigation
|
||||
let allAssets, currentIndex;
|
||||
let allAssets;
|
||||
|
||||
// Use state.assets if available (VirtualGrid support)
|
||||
const state = GRID_STATE.get(container);
|
||||
if (state && state.assets && state.assets.length > 0) {
|
||||
allAssets = state.assets;
|
||||
currentIndex = allAssets.findIndex(a => a.id === asset.id);
|
||||
} else {
|
||||
// Fallback to DOM (legacy)
|
||||
const allCards = _getRenderedCards(container);
|
||||
// @ts-ignore
|
||||
allAssets = allCards.map(c => c._mjrAsset).filter(Boolean);
|
||||
currentIndex = allAssets.findIndex(a => a.id === asset.id);
|
||||
}
|
||||
|
||||
const isFolder = String(asset?.kind || "").toLowerCase() === "folder";
|
||||
@@ -588,7 +586,10 @@ export function createGridContainer() {
|
||||
detail: { subfolder: nextSubfolder },
|
||||
})
|
||||
);
|
||||
loadAssets(container, state?.query || "*", { reset: true }).catch(() => {});
|
||||
// Fallback: if no panel-level handler is attached, reload from grid directly.
|
||||
if (!container?._mjrHasCustomSubfolderHandler) {
|
||||
loadAssets(container, state?.query || "*", { reset: true }).catch(() => {});
|
||||
}
|
||||
} catch {}
|
||||
return;
|
||||
}
|
||||
@@ -1277,6 +1278,20 @@ function appendAssets(gridContainer, assets, state) {
|
||||
}
|
||||
|
||||
for (const asset of assets || []) {
|
||||
// Browser/custom entries can arrive without numeric IDs; selection relies on a stable ID.
|
||||
// Assign a deterministic synthetic ID for any item missing one.
|
||||
try {
|
||||
if (asset?.id == null || String(asset.id).trim() === "") {
|
||||
const kindLower = String(asset?.kind || "").toLowerCase();
|
||||
const fp = String(asset?.filepath || "").trim();
|
||||
const sub = String(asset?.subfolder || "").trim();
|
||||
const name = String(asset?.filename || "").trim();
|
||||
const type = String(asset?.type || "").trim().toLowerCase();
|
||||
const base = `${type}|${kindLower}|${fp}|${sub}|${name}`;
|
||||
asset.id = `asset:${base || "unknown"}`;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const filename = String(asset?.filename || "");
|
||||
const extUpper = _getExtUpper(filename);
|
||||
const stemLower = _getStemLower(filename);
|
||||
@@ -1513,10 +1528,10 @@ async function fetchPage(gridContainer, query, limit, offset, { requestId = 0, s
|
||||
|
||||
if (result.ok) {
|
||||
let assets = (result.data?.assets) || [];
|
||||
const serverCount = Array.isArray(assets) ? assets.length : 0;
|
||||
const rawTotal = result.data?.total;
|
||||
const total = rawTotal == null ? null : (Number(rawTotal || 0) || 0);
|
||||
|
||||
return { ok: true, assets, total, count: assets.length, sortKey, safeQuery };
|
||||
return { ok: true, assets, total, count: serverCount, sortKey, safeQuery };
|
||||
} else {
|
||||
try {
|
||||
if (String(result?.code || "") === "ABORTED") return { ok: false, aborted: true, error: "Aborted" };
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
|
||||
import { comfyConfirm, comfyPrompt } from "../../app/dialogs.js";
|
||||
import { comfyConfirm } from "../../app/dialogs.js";
|
||||
import { comfyToast } from "../../app/toast.js";
|
||||
import { createStatusIndicator, setupStatusPolling, triggerScan, updateStatus } from "../status/StatusDot.js";
|
||||
import {
|
||||
@@ -39,6 +39,7 @@ 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 { createSummaryBarView } from "./views/summaryBarView.js";
|
||||
import { createAgendaCalendar } from "../filters/calendar/AgendaCalendar.js";
|
||||
|
||||
@@ -115,7 +116,7 @@ export async function renderAssetsManager(container, { useComfyThemeUI = true }
|
||||
} catch {}
|
||||
|
||||
const headerView = createHeaderView();
|
||||
const { header, headerActions, tabButtons, customMenuBtn, filterBtn, sortBtn, collectionsBtn } = headerView;
|
||||
const { header, headerActions, tabButtons, customMenuBtn, filterBtn, sortBtn, collectionsBtn, pinnedFoldersBtn } = headerView;
|
||||
|
||||
const { customPopover, customSelect, customAddBtn, customRemoveBtn } = createCustomPopoverView();
|
||||
const {
|
||||
@@ -135,6 +136,7 @@ export async function renderAssetsManager(container, { useComfyThemeUI = true }
|
||||
} = createFilterPopoverView();
|
||||
const { sortPopover, sortMenu } = createSortPopoverView();
|
||||
const { collectionsPopover, collectionsMenu } = createCollectionsPopoverView();
|
||||
const { pinnedFoldersPopover, pinnedFoldersMenu } = createPinnedFoldersPopoverView();
|
||||
|
||||
headerActions.appendChild(customPopover);
|
||||
|
||||
@@ -142,9 +144,11 @@ export async function renderAssetsManager(container, { useComfyThemeUI = true }
|
||||
filterBtn,
|
||||
sortBtn,
|
||||
collectionsBtn,
|
||||
pinnedFoldersBtn,
|
||||
filterPopover,
|
||||
sortPopover,
|
||||
collectionsPopover
|
||||
collectionsPopover,
|
||||
pinnedFoldersPopover
|
||||
});
|
||||
|
||||
// Restore persistent search query
|
||||
@@ -443,6 +447,13 @@ export async function renderAssetsManager(container, { useComfyThemeUI = true }
|
||||
if (gridContainer?.dataset) gridContainer.dataset.mjrSubfolder = v;
|
||||
} catch {}
|
||||
};
|
||||
const resetToBrowserRoot = async () => {
|
||||
state.customRootId = "";
|
||||
setFolderPath("");
|
||||
folderForwardStack.length = 0;
|
||||
notifyContextChanged();
|
||||
await gridController.reloadGrid();
|
||||
};
|
||||
const navigateFolder = async (target, { pushHistory = true, clearForward = true } = {}) => {
|
||||
const prev = currentFolderPath();
|
||||
const next = normPath(target);
|
||||
@@ -468,6 +479,16 @@ export async function renderAssetsManager(container, { useComfyThemeUI = true }
|
||||
const renderFolderBreadcrumb = () => {
|
||||
const isCustom = String(state.scope || "") === "custom";
|
||||
const rel = String(state.currentFolderRelativePath || state.subfolder || "").trim().replaceAll("\\", "/");
|
||||
const selectedRootId = String(state.customRootId || "").trim();
|
||||
const selectedRootLabel = (() => {
|
||||
if (!selectedRootId) return "";
|
||||
try {
|
||||
const opt = customSelect?.selectedOptions?.[0] || null;
|
||||
return String(opt?.textContent || "").trim();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
})();
|
||||
if (!isCustom) {
|
||||
folderBackStack.length = 0;
|
||||
folderForwardStack.length = 0;
|
||||
@@ -482,13 +503,19 @@ export async function renderAssetsManager(container, { useComfyThemeUI = true }
|
||||
backBtn.type = "button";
|
||||
backBtn.textContent = "Back";
|
||||
backBtn.className = "mjr-btn-link";
|
||||
backBtn.disabled = folderBackStack.length === 0;
|
||||
const canBackToBrowserRoot = !!String(state.customRootId || "").trim() && !rel;
|
||||
backBtn.disabled = folderBackStack.length === 0 && !canBackToBrowserRoot;
|
||||
backBtn.style.cssText = "background:none;border:none;padding:0 4px 0 0;font:inherit;color:var(--mjr-accent, #7aa2ff);cursor:pointer;";
|
||||
backBtn.addEventListener("click", async () => {
|
||||
const prev = folderBackStack.pop();
|
||||
if (prev == null) return;
|
||||
folderForwardStack.push(currentFolderPath());
|
||||
await navigateFolder(prev, { pushHistory: false, clearForward: false });
|
||||
if (prev != null) {
|
||||
folderForwardStack.push(currentFolderPath());
|
||||
await navigateFolder(prev, { pushHistory: false, clearForward: false });
|
||||
return;
|
||||
}
|
||||
if (canBackToBrowserRoot) {
|
||||
await resetToBrowserRoot();
|
||||
}
|
||||
}, { signal: panelLifecycleAC?.signal });
|
||||
folderBreadcrumb.appendChild(backBtn);
|
||||
|
||||
@@ -517,6 +544,10 @@ export async function renderAssetsManager(container, { useComfyThemeUI = true }
|
||||
el.style.cssText = `background:none;border:none;padding:0;color:${isCurrent ? "var(--mjr-text, inherit)" : "var(--mjr-accent, #7aa2ff)"};cursor:${isCurrent ? "default" : "pointer"};font:inherit;`;
|
||||
if (!isCurrent) {
|
||||
el.addEventListener("click", async () => {
|
||||
if (label === "Computer" && String(state.customRootId || "").trim()) {
|
||||
await resetToBrowserRoot();
|
||||
return;
|
||||
}
|
||||
await navigateFolder(target);
|
||||
}, { signal: panelLifecycleAC?.signal });
|
||||
} else {
|
||||
@@ -526,6 +557,14 @@ export async function renderAssetsManager(container, { useComfyThemeUI = true }
|
||||
};
|
||||
|
||||
folderBreadcrumb.appendChild(mk("Computer", "", !rel));
|
||||
if (selectedRootId) {
|
||||
const sepRoot = document.createElement("span");
|
||||
sepRoot.textContent = "/";
|
||||
sepRoot.style.opacity = "0.6";
|
||||
folderBreadcrumb.appendChild(sepRoot);
|
||||
// Root node: click returns to root of selected custom folder.
|
||||
folderBreadcrumb.appendChild(mk(selectedRootLabel || selectedRootId, "", !rel));
|
||||
}
|
||||
if (!rel) return;
|
||||
const parts = rel.split("/").filter(Boolean);
|
||||
let acc = "";
|
||||
@@ -590,7 +629,7 @@ export async function renderAssetsManager(container, { useComfyThemeUI = true }
|
||||
} catch {}
|
||||
};
|
||||
gridContainer.addEventListener("mjr:reload-grid", _reloadGridHandler);
|
||||
const onCustomSubfolderChanged = (e) => {
|
||||
const onCustomSubfolderChanged = async (e) => {
|
||||
const next = String(e?.detail?.subfolder || "").trim().replaceAll("\\", "/");
|
||||
const prev = currentFolderPath();
|
||||
if (next !== prev) {
|
||||
@@ -598,9 +637,15 @@ export async function renderAssetsManager(container, { useComfyThemeUI = true }
|
||||
folderForwardStack.length = 0;
|
||||
setFolderPath(next);
|
||||
notifyContextChanged();
|
||||
try {
|
||||
await gridController.reloadGrid();
|
||||
} catch {}
|
||||
}
|
||||
};
|
||||
gridContainer.addEventListener("mjr:custom-subfolder-changed", onCustomSubfolderChanged);
|
||||
try {
|
||||
gridContainer._mjrHasCustomSubfolderHandler = true;
|
||||
} catch {}
|
||||
const onDuplicateBadgeFocus = (e) => {
|
||||
try {
|
||||
const detail = e?.detail || {};
|
||||
@@ -633,6 +678,9 @@ export async function renderAssetsManager(container, { useComfyThemeUI = true }
|
||||
try {
|
||||
gridContainer.removeEventListener("mjr:custom-subfolder-changed", onCustomSubfolderChanged);
|
||||
} catch {}
|
||||
try {
|
||||
gridContainer._mjrHasCustomSubfolderHandler = false;
|
||||
} catch {}
|
||||
try {
|
||||
gridContainer.removeEventListener("mjr:badge-duplicates-focus", onDuplicateBadgeFocus);
|
||||
} catch {}
|
||||
@@ -657,13 +705,16 @@ export async function renderAssetsManager(container, { useComfyThemeUI = true }
|
||||
customSelect,
|
||||
customRemoveBtn,
|
||||
comfyConfirm,
|
||||
comfyPrompt,
|
||||
comfyToast,
|
||||
get,
|
||||
post,
|
||||
ENDPOINTS,
|
||||
reloadGrid: gridController.reloadGrid,
|
||||
onRootChanged: async () => {
|
||||
try {
|
||||
folderBackStack.length = 0;
|
||||
folderForwardStack.length = 0;
|
||||
} catch {}
|
||||
try {
|
||||
await applyWatcherForScope(state.scope);
|
||||
} catch {}
|
||||
@@ -731,10 +782,12 @@ export async function renderAssetsManager(container, { useComfyThemeUI = true }
|
||||
filterPopover,
|
||||
sortPopover,
|
||||
collectionsPopover,
|
||||
pinnedFoldersPopover,
|
||||
customMenuBtn,
|
||||
filterBtn,
|
||||
sortBtn,
|
||||
collectionsBtn
|
||||
collectionsBtn,
|
||||
pinnedFoldersBtn
|
||||
]);
|
||||
|
||||
customMenuBtn.addEventListener("click", (e) => {
|
||||
@@ -782,6 +835,154 @@ export async function renderAssetsManager(container, { useComfyThemeUI = true }
|
||||
});
|
||||
collectionsController.bind();
|
||||
|
||||
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))";
|
||||
});
|
||||
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))";
|
||||
});
|
||||
return btn;
|
||||
};
|
||||
|
||||
const renderPinnedFoldersMenu = async () => {
|
||||
try {
|
||||
pinnedFoldersMenu.replaceChildren();
|
||||
} catch {}
|
||||
|
||||
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 {}
|
||||
|
||||
if (!roots.length) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "mjr-muted";
|
||||
empty.style.cssText = "padding:10px 12px; opacity:0.75;";
|
||||
empty.textContent = "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";
|
||||
state.customRootId = id;
|
||||
state.customRootLabel = label;
|
||||
state.subfolder = "";
|
||||
state.currentFolderRelativePath = "";
|
||||
try {
|
||||
if (customSelect) customSelect.value = id;
|
||||
} catch {}
|
||||
try {
|
||||
scopeController?.setActiveTabStyles?.();
|
||||
} catch {}
|
||||
try {
|
||||
await applyWatcherForScope(state.scope);
|
||||
} catch {}
|
||||
try {
|
||||
await refreshDuplicateAlerts();
|
||||
} catch {}
|
||||
try {
|
||||
notifyContextChanged();
|
||||
} catch {}
|
||||
popovers.close(pinnedFoldersPopover);
|
||||
await gridController.reloadGrid();
|
||||
});
|
||||
|
||||
const unpinBtn = document.createElement("button");
|
||||
unpinBtn.type = "button";
|
||||
unpinBtn.className = "mjr-menu-item";
|
||||
unpinBtn.title = "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(`Unpin folder "${label}"?`);
|
||||
if (!ok) return;
|
||||
const res = await post(ENDPOINTS.CUSTOM_ROOTS_REMOVE, { id });
|
||||
if (!res?.ok) {
|
||||
comfyToast(res?.error || "Failed to unpin folder", "error");
|
||||
return;
|
||||
}
|
||||
if (String(state.customRootId || "") === id) {
|
||||
state.customRootId = "";
|
||||
state.customRootLabel = "";
|
||||
state.subfolder = "";
|
||||
state.currentFolderRelativePath = "";
|
||||
}
|
||||
try {
|
||||
await customRootsController.refreshCustomRoots();
|
||||
} catch {}
|
||||
await renderPinnedFoldersMenu();
|
||||
await gridController.reloadGrid();
|
||||
});
|
||||
|
||||
row.appendChild(openBtn);
|
||||
row.appendChild(unpinBtn);
|
||||
pinnedFoldersMenu.appendChild(row);
|
||||
}
|
||||
};
|
||||
|
||||
pinnedFoldersBtn?.addEventListener("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await renderPinnedFoldersMenu();
|
||||
} catch {}
|
||||
popovers.close(customPopover);
|
||||
popovers.close(filterPopover);
|
||||
popovers.close(sortPopover);
|
||||
popovers.close(collectionsPopover);
|
||||
popovers.toggle(pinnedFoldersPopover, pinnedFoldersBtn);
|
||||
}, { signal: panelLifecycleAC?.signal });
|
||||
|
||||
// Calendar UI (separate module) for day indicators.
|
||||
let agendaCalendar = null;
|
||||
try {
|
||||
@@ -859,6 +1060,12 @@ export async function renderAssetsManager(container, { useComfyThemeUI = true }
|
||||
reloadGrid: () => gridController.reloadGrid(),
|
||||
getExtraContext: () => ({ duplicatesAlert: _duplicatesAlert }),
|
||||
extraActions: {
|
||||
resetBrowserHistory: () => {
|
||||
try {
|
||||
folderBackStack.length = 0;
|
||||
folderForwardStack.length = 0;
|
||||
} catch {}
|
||||
},
|
||||
onDuplicateAlertClick: async (alert) => {
|
||||
const current = alert || _duplicatesAlert || {};
|
||||
const grp = current.firstGroup;
|
||||
|
||||
@@ -40,6 +40,12 @@ export function createContextController({
|
||||
extraActions = null,
|
||||
getExtraContext = null
|
||||
} = {}) {
|
||||
const resetBrowserHistory = () => {
|
||||
try {
|
||||
extraActions?.resetBrowserHistory?.();
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const applyBadgeCssVars = () => {
|
||||
try {
|
||||
const root =
|
||||
@@ -79,9 +85,14 @@ export function createContextController({
|
||||
let didSetScope = false;
|
||||
try {
|
||||
state.customRootId = "";
|
||||
state.customRootLabel = "";
|
||||
state.subfolder = "";
|
||||
state.currentFolderRelativePath = "";
|
||||
delete gridContainer?.dataset?.mjrSubfolder;
|
||||
if (typeof scopeController?.setScope === "function") {
|
||||
const isCustom = String(state.scope || "").toLowerCase() === "custom";
|
||||
if (isCustom) {
|
||||
resetBrowserHistory();
|
||||
} else if (typeof scopeController?.setScope === "function") {
|
||||
await scopeController.setScope("output");
|
||||
didSetScope = true;
|
||||
} else {
|
||||
@@ -94,28 +105,25 @@ export function createContextController({
|
||||
} catch {}
|
||||
},
|
||||
clearCustomRoot: async () => {
|
||||
let didSetScope = false;
|
||||
try {
|
||||
state.customRootId = "";
|
||||
if (String(state.scope || "").toLowerCase() === "custom") {
|
||||
if (typeof scopeController?.setScope === "function") {
|
||||
await scopeController.setScope("output");
|
||||
didSetScope = true;
|
||||
} else {
|
||||
state.scope = "output";
|
||||
scopeController?.setActiveTabStyles?.();
|
||||
}
|
||||
}
|
||||
state.customRootLabel = "";
|
||||
state.subfolder = "";
|
||||
state.currentFolderRelativePath = "";
|
||||
delete gridContainer?.dataset?.mjrSubfolder;
|
||||
resetBrowserHistory();
|
||||
} catch {}
|
||||
try {
|
||||
if (!didSetScope) await reloadGrid?.();
|
||||
await reloadGrid?.();
|
||||
} catch {}
|
||||
},
|
||||
clearFolder: async () => {
|
||||
try {
|
||||
// Ensure state is cleared so it doesn't get restored
|
||||
state.subfolder = "";
|
||||
state.subfolder = "";
|
||||
state.currentFolderRelativePath = "";
|
||||
delete gridContainer?.dataset?.mjrSubfolder;
|
||||
resetBrowserHistory();
|
||||
} catch {}
|
||||
try {
|
||||
await reloadGrid?.();
|
||||
@@ -228,9 +236,14 @@ export function createContextController({
|
||||
state.dateExactFilter = "";
|
||||
state.sort = "mtime_desc";
|
||||
state.customRootId = "";
|
||||
state.customRootLabel = "";
|
||||
state.subfolder = "";
|
||||
state.currentFolderRelativePath = "";
|
||||
delete gridContainer?.dataset?.mjrSubfolder;
|
||||
if (typeof scopeController?.setScope === "function") {
|
||||
const isCustom = String(state.scope || "").toLowerCase() === "custom";
|
||||
if (isCustom) {
|
||||
resetBrowserHistory();
|
||||
} else if (typeof scopeController?.setScope === "function") {
|
||||
await scopeController.setScope("output");
|
||||
didSetScope = true;
|
||||
} else {
|
||||
|
||||
@@ -5,7 +5,6 @@ export function createCustomRootsController({
|
||||
customSelect,
|
||||
customRemoveBtn,
|
||||
comfyConfirm,
|
||||
comfyPrompt,
|
||||
comfyToast,
|
||||
get,
|
||||
post,
|
||||
@@ -13,34 +12,6 @@ export function createCustomRootsController({
|
||||
reloadGrid,
|
||||
onRootChanged = null
|
||||
}) {
|
||||
const isSensitivePath = (input) => {
|
||||
try {
|
||||
const raw = String(input || "").trim();
|
||||
if (!raw) return false;
|
||||
const p = raw.replaceAll("\\", "/").toLowerCase();
|
||||
|
||||
// Very broad roots (drive root / filesystem root).
|
||||
if (p === "/" || /^[a-z]:\/?$/.test(p)) return true;
|
||||
|
||||
// Common sensitive/system folders (best-effort heuristics).
|
||||
const needles = [
|
||||
"/windows",
|
||||
"/program files",
|
||||
"/program files (x86)",
|
||||
"/users",
|
||||
"/appdata",
|
||||
"/system32",
|
||||
"/etc",
|
||||
"/usr",
|
||||
"/bin",
|
||||
"/var",
|
||||
];
|
||||
return needles.some((n) => p === n || p.startsWith(n + "/") || p.includes(n + "/"));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const refreshCustomRoots = async (preferId = null) => {
|
||||
// Show loading state
|
||||
const originalPlaceholder = customSelect.querySelector('option[value=""]')?.textContent || t("label.selectFolder", "Select folder…");
|
||||
@@ -74,9 +45,16 @@ export function createCustomRootsController({
|
||||
if (desired && roots.some((r) => r.id === desired)) {
|
||||
customSelect.value = desired;
|
||||
state.customRootId = desired;
|
||||
try {
|
||||
const sel = customSelect.options[customSelect.selectedIndex];
|
||||
state.customRootLabel = String(sel?.text || "").trim();
|
||||
} catch {
|
||||
state.customRootLabel = "";
|
||||
}
|
||||
} else {
|
||||
customSelect.value = "";
|
||||
state.customRootId = "";
|
||||
state.customRootLabel = "";
|
||||
}
|
||||
customRemoveBtn.disabled = !state.customRootId;
|
||||
} catch (err) {
|
||||
@@ -98,6 +76,12 @@ export function createCustomRootsController({
|
||||
// Only update state if not disabled (not in loading/error state)
|
||||
if (!customSelect.disabled) {
|
||||
state.customRootId = customSelect.value || "";
|
||||
try {
|
||||
const sel = customSelect.options[customSelect.selectedIndex];
|
||||
state.customRootLabel = String(sel?.text || "").trim();
|
||||
} catch {
|
||||
state.customRootLabel = "";
|
||||
}
|
||||
state.subfolder = "";
|
||||
state.currentFolderRelativePath = "";
|
||||
customRemoveBtn.disabled = !state.customRootId;
|
||||
@@ -108,97 +92,9 @@ export function createCustomRootsController({
|
||||
}
|
||||
});
|
||||
|
||||
customAddBtn.addEventListener("click", async () => {
|
||||
// Disable button during operation to prevent multiple clicks
|
||||
customAddBtn.disabled = true;
|
||||
customAddBtn.textContent = t("btn.adding");
|
||||
|
||||
try {
|
||||
// Attempt to open the native folder selector via the backend
|
||||
const response = await post("/mjr/sys/browse-folder", {});
|
||||
|
||||
if (response && response.ok && response.data && response.data.path) {
|
||||
// If a path is returned, use it directly
|
||||
const path = response.data.path;
|
||||
|
||||
// (Optional) Security check as in the original code
|
||||
if (isSensitivePath(path)) {
|
||||
const ok = await comfyConfirm(
|
||||
t("dialog.securityWarning", "This looks like a system or very broad directory.\n\nAdding it can expose sensitive files via the viewer/custom roots feature.\n\nContinue?"),
|
||||
t("dialog.securityWarningTitle", "Majoor: Security Warning")
|
||||
);
|
||||
if (!ok) return;
|
||||
}
|
||||
|
||||
// Send to backend for actual addition
|
||||
const json = await post(ENDPOINTS.CUSTOM_ROOTS, { path });
|
||||
if (!json?.ok) {
|
||||
comfyToast(json?.error || t("toast.failedAddFolder", "Failed to add custom folder"), "error");
|
||||
return;
|
||||
}
|
||||
// Refresh
|
||||
comfyToast(t("toast.folderLinked", "Folder linked successfully"), "success");
|
||||
state.subfolder = "";
|
||||
state.currentFolderRelativePath = "";
|
||||
await refreshCustomRoots(json.data?.id);
|
||||
await reloadGrid();
|
||||
return;
|
||||
} else if (response && !response.ok && (response.code === "TKINTER_ERROR" || response.code === "HEADLESS_ENV" || response.code === "TKINTER_UNAVAILABLE")) {
|
||||
// Handle specific tkinter errors
|
||||
console.warn("Tkinter error:", response.error);
|
||||
comfyToast(t("toast.nativeBrowserUnavailable", "Native folder browser unavailable. Please enter path manually."), "warning");
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("Native selector failed or was cancelled, falling back to manual input...", e);
|
||||
} finally {
|
||||
// Re-enable button regardless of outcome
|
||||
customAddBtn.disabled = false;
|
||||
customAddBtn.textContent = t("btn.add");
|
||||
}
|
||||
|
||||
// --- FALLBACK TO ORIGINAL METHOD (Text box) ---
|
||||
const path = await comfyPrompt(
|
||||
t("dialog.enterFolderPath", "Enter a folder path to add as a Custom root:"),
|
||||
"",
|
||||
t("dialog.customFoldersTitle", "Majoor: Custom Folders")
|
||||
);
|
||||
if (!path) return;
|
||||
|
||||
try {
|
||||
if (isSensitivePath(path)) {
|
||||
const ok = await comfyConfirm(
|
||||
t("dialog.securityWarning", "This looks like a system or very broad directory.\n\nAdding it can expose sensitive files via the viewer/custom roots feature.\n\nContinue?"),
|
||||
t("dialog.securityWarningTitle", "Majoor: Security Warning")
|
||||
);
|
||||
if (!ok) return;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
// Show loading state
|
||||
customAddBtn.disabled = true;
|
||||
customAddBtn.textContent = t("btn.adding");
|
||||
|
||||
const json = await post(ENDPOINTS.CUSTOM_ROOTS, { path });
|
||||
if (!json?.ok) {
|
||||
comfyToast(json?.error || t("toast.failedAddFolder", "Failed to add custom folder"), "error");
|
||||
return;
|
||||
}
|
||||
const newId = json.data?.id;
|
||||
comfyToast(t("toast.folderLinked", "Folder linked successfully"), "success");
|
||||
state.subfolder = "";
|
||||
state.currentFolderRelativePath = "";
|
||||
await refreshCustomRoots(newId);
|
||||
await reloadGrid();
|
||||
} catch (err) {
|
||||
console.warn("Majoor: add custom root failed", err);
|
||||
comfyToast(t("toast.errorAddingFolder", "An error occurred while adding the custom folder"), "error");
|
||||
} finally {
|
||||
// Re-enable button regardless of outcome
|
||||
customAddBtn.disabled = false;
|
||||
customAddBtn.textContent = t("btn.add");
|
||||
}
|
||||
});
|
||||
// Temporary: custom-root add flow disabled.
|
||||
customAddBtn.disabled = true;
|
||||
customAddBtn.title = t("status.pending", "Pending");
|
||||
|
||||
customRemoveBtn.addEventListener("click", async () => {
|
||||
if (!state.customRootId) return;
|
||||
@@ -221,6 +117,7 @@ export function createCustomRootsController({
|
||||
}
|
||||
comfyToast(t("toast.folderRemoved", "Folder removed"), "success");
|
||||
state.customRootId = "";
|
||||
state.customRootLabel = "";
|
||||
state.subfolder = "";
|
||||
state.currentFolderRelativePath = "";
|
||||
await refreshCustomRoots();
|
||||
|
||||
@@ -4,7 +4,6 @@ export function createScopeController({
|
||||
customMenuBtn,
|
||||
customPopover,
|
||||
popovers,
|
||||
refreshCustomRoots,
|
||||
reloadGrid,
|
||||
onChanged = null,
|
||||
onScopeChanged = null,
|
||||
|
||||
@@ -22,7 +22,7 @@ async function rescanSingleAsset({ card, asset, sidebar, onAssetUpdated }) {
|
||||
const dot = card.querySelector(".mjr-workflow-dot");
|
||||
try {
|
||||
if (dot) {
|
||||
applyAssetStatusDotState(dot, "pending", "Pending: metadata refresh in progress");
|
||||
applyAssetStatusDotState(dot, "pending", "Pending: metadata refresh in progress", { asset });
|
||||
dot.classList.add("mjr-pulse-animation");
|
||||
dot.style.cursor = "progress";
|
||||
dot.title = "Pending: metadata refresh in progress";
|
||||
@@ -44,7 +44,7 @@ async function rescanSingleAsset({ card, asset, sidebar, onAssetUpdated }) {
|
||||
const indexRes = await post(ENDPOINTS.INDEX_FILES, { files: [fileEntry], incremental: false });
|
||||
indexOk = !!indexRes?.ok;
|
||||
if (!indexOk && dot) {
|
||||
applyAssetStatusDotState(dot, "error", "Error: metadata refresh failed");
|
||||
applyAssetStatusDotState(dot, "error", "Error: metadata refresh failed", { asset });
|
||||
dot.classList.remove("mjr-pulse-animation");
|
||||
dot.style.cursor = "";
|
||||
}
|
||||
@@ -58,7 +58,7 @@ async function rescanSingleAsset({ card, asset, sidebar, onAssetUpdated }) {
|
||||
} catch {
|
||||
try {
|
||||
if (dot) {
|
||||
applyAssetStatusDotState(dot, "error", "Error: metadata refresh failed");
|
||||
applyAssetStatusDotState(dot, "error", "Error: metadata refresh failed", { asset });
|
||||
dot.classList.remove("mjr-pulse-animation");
|
||||
dot.style.cursor = "";
|
||||
}
|
||||
|
||||
@@ -14,6 +14,14 @@ const _labelSort = (key) => {
|
||||
return "Newest first";
|
||||
};
|
||||
|
||||
const _labelScope = (scope) => {
|
||||
const s = String(scope || "").toLowerCase();
|
||||
if (s === "custom") return "Browser";
|
||||
if (s === "all") return "All";
|
||||
if (s === "input" || s === "inputs") return "Inputs";
|
||||
return "Outputs";
|
||||
};
|
||||
|
||||
function _createPill({ label, value, onClear } = {}) {
|
||||
const pill = document.createElement("div");
|
||||
pill.className = "mjr-context-pill";
|
||||
@@ -49,13 +57,12 @@ export function createContextPillsView() {
|
||||
const root = document.createElement("div");
|
||||
root.className = "mjr-am-context-pills";
|
||||
|
||||
const update = ({ state, gridContainer, rawQuery = "", actions = null } = {}) => {
|
||||
const update = ({ state, rawQuery = "", actions = null } = {}) => {
|
||||
const safeActions = actions && typeof actions === "object" ? actions : {};
|
||||
|
||||
const query = _safeText(rawQuery || "").trim();
|
||||
const isQueryActive = query.length > 0 && query !== "*";
|
||||
|
||||
const subfolder = _safeText(gridContainer?.dataset?.mjrSubfolder || "").trim();
|
||||
const isFilterActive = !!(
|
||||
_safeText(state?.kindFilter || "").trim()
|
||||
|| !!state?.workflowOnly
|
||||
@@ -70,19 +77,19 @@ export function createContextPillsView() {
|
||||
);
|
||||
const isSortActive = _safeText(state?.sort || "mtime_desc").trim() !== "mtime_desc";
|
||||
const isCollectionActive = _safeText(state?.collectionId || "").trim().length > 0;
|
||||
const isScopeActive = String(state?.scope || "output") !== "output";
|
||||
const isCustomActive = String(state?.scope || "") === "custom" && _safeText(state?.customRootId || "").trim().length > 0;
|
||||
const isFolderActive = subfolder.length > 0 && !isCollectionActive && !isCustomActive;
|
||||
const scopeValue = String(state?.scope || "output").toLowerCase();
|
||||
const isBrowserScope = scopeValue === "custom";
|
||||
const isScopeActive = scopeValue !== "output";
|
||||
|
||||
const anyContext =
|
||||
isQueryActive || isFilterActive || isSortActive || isCollectionActive || isScopeActive || isCustomActive || isFolderActive;
|
||||
isQueryActive || isFilterActive || isSortActive || isCollectionActive || isScopeActive;
|
||||
void anyContext;
|
||||
|
||||
try {
|
||||
root.replaceChildren();
|
||||
} catch {}
|
||||
|
||||
if (isCollectionActive) {
|
||||
if (!isBrowserScope && isCollectionActive) {
|
||||
root.appendChild(
|
||||
_createPill({
|
||||
label: "Collection",
|
||||
@@ -91,28 +98,11 @@ export function createContextPillsView() {
|
||||
})
|
||||
);
|
||||
}
|
||||
if (isCustomActive) {
|
||||
root.appendChild(
|
||||
_createPill({
|
||||
label: "Custom",
|
||||
value: _safeText(state?.customRootId || "").trim(),
|
||||
onClear: () => safeActions?.clearCustomRoot?.()
|
||||
})
|
||||
);
|
||||
} else if (isFolderActive) {
|
||||
root.appendChild(
|
||||
_createPill({
|
||||
label: "Folder",
|
||||
value: subfolder,
|
||||
onClear: () => safeActions?.clearFolder?.()
|
||||
})
|
||||
);
|
||||
}
|
||||
if (isScopeActive) {
|
||||
if (!isBrowserScope && isScopeActive) {
|
||||
root.appendChild(
|
||||
_createPill({
|
||||
label: "Scope",
|
||||
value: _safeText(state?.scope || "output"),
|
||||
value: _labelScope(state?.scope || "output"),
|
||||
onClear: () => safeActions?.clearScope?.()
|
||||
})
|
||||
);
|
||||
|
||||
@@ -7,20 +7,12 @@ import {
|
||||
getStoredVersionUpdateState,
|
||||
} from "../../../app/versionCheck.js";
|
||||
|
||||
const METADATA_URL = new URL("../../../comfyui_extension.json", import.meta.url);
|
||||
let _extensionMetadataPromise = null;
|
||||
|
||||
function getExtensionMetadata() {
|
||||
if (!_extensionMetadataPromise) {
|
||||
_extensionMetadataPromise = (async () => {
|
||||
try {
|
||||
const response = await fetch(METADATA_URL.toString(), { credentials: "same-origin" });
|
||||
if (!response.ok) return {};
|
||||
return await response.json();
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
})();
|
||||
// Keep API shape but avoid requesting a manifest file that may not exist.
|
||||
_extensionMetadataPromise = Promise.resolve({});
|
||||
}
|
||||
return _extensionMetadataPromise;
|
||||
}
|
||||
@@ -159,6 +151,7 @@ export function createHeaderView() {
|
||||
const filterBtn = createIconButton("pi-filter", "Filters");
|
||||
const sortBtn = createIconButton("pi-sort", "Sort");
|
||||
const collectionsBtn = createIconButton("pi-bookmark", "Collections");
|
||||
const pinnedFoldersBtn = createIconButton("pi-folder", "Pinned folders");
|
||||
|
||||
customMenuBtn.style.display = "none";
|
||||
headerTools.appendChild(customMenuBtn);
|
||||
@@ -202,7 +195,8 @@ export function createHeaderView() {
|
||||
customMenuBtn,
|
||||
filterBtn,
|
||||
sortBtn,
|
||||
collectionsBtn
|
||||
collectionsBtn,
|
||||
pinnedFoldersBtn
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
export function createPinnedFoldersPopoverView() {
|
||||
const pinnedFoldersPopover = document.createElement("div");
|
||||
pinnedFoldersPopover.className = "mjr-popover mjr-pinned-folders-popover";
|
||||
pinnedFoldersPopover.style.display = "none";
|
||||
pinnedFoldersPopover.style.cssText = "display:none;";
|
||||
|
||||
const pinnedFoldersMenu = document.createElement("div");
|
||||
pinnedFoldersMenu.className = "mjr-menu";
|
||||
pinnedFoldersMenu.style.cssText = "display:grid; gap:6px;";
|
||||
pinnedFoldersPopover.appendChild(pinnedFoldersMenu);
|
||||
|
||||
return { pinnedFoldersPopover, pinnedFoldersMenu };
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { get } from "../../../api/client.js";
|
||||
import { debounce } from "../../../utils/debounce.js";
|
||||
|
||||
export function createSearchView({ filterBtn, sortBtn, collectionsBtn, filterPopover, sortPopover, collectionsPopover }) {
|
||||
export function createSearchView({ filterBtn, sortBtn, collectionsBtn, pinnedFoldersBtn, filterPopover, sortPopover, collectionsPopover, pinnedFoldersPopover }) {
|
||||
const searchSection = document.createElement("div");
|
||||
searchSection.classList.add("mjr-am-search");
|
||||
|
||||
@@ -75,9 +75,15 @@ export function createSearchView({ filterBtn, sortBtn, collectionsBtn, filterPop
|
||||
if (collectionsBtn) collectionsAnchor.appendChild(collectionsBtn);
|
||||
if (collectionsPopover) collectionsAnchor.appendChild(collectionsPopover);
|
||||
|
||||
const pinnedFoldersAnchor = document.createElement("div");
|
||||
pinnedFoldersAnchor.className = "mjr-popover-anchor";
|
||||
if (pinnedFoldersBtn) pinnedFoldersAnchor.appendChild(pinnedFoldersBtn);
|
||||
if (pinnedFoldersPopover) pinnedFoldersAnchor.appendChild(pinnedFoldersPopover);
|
||||
|
||||
searchTools.appendChild(filterAnchor);
|
||||
searchTools.appendChild(sortAnchor);
|
||||
searchTools.appendChild(collectionsAnchor);
|
||||
searchTools.appendChild(pinnedFoldersAnchor);
|
||||
searchSection.appendChild(searchTools);
|
||||
|
||||
return { searchSection, searchInputEl };
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
function _norm(value) {
|
||||
return String(value || "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function isCustomBrowserScope({ dot = null, asset = null, scope = "" } = {}) {
|
||||
const explicit = _norm(scope);
|
||||
if (explicit) return explicit === "custom";
|
||||
|
||||
const fromAsset = _norm(asset?.type || asset?.scope);
|
||||
if (fromAsset) return fromAsset === "custom";
|
||||
|
||||
try {
|
||||
const fromGrid = _norm(dot?.closest?.(".mjr-grid")?.dataset?.mjrScope);
|
||||
if (fromGrid) return fromGrid === "custom";
|
||||
} catch {}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function resolveAssetStatusDotColor(state, context = {}) {
|
||||
const s = _norm(state);
|
||||
const isCustom = isCustomBrowserScope(context);
|
||||
|
||||
if (isCustom) {
|
||||
// Custom/Browser palette: explicitly no blue tones.
|
||||
if (s === "pending" || s === "info") return "var(--mjr-browser-status-info, #4DB6AC)";
|
||||
if (s === "success") return "var(--mjr-browser-status-success, #2E7D32)";
|
||||
if (s === "warning") return "var(--mjr-browser-status-warning, #FFB74D)";
|
||||
if (s === "error") return "var(--mjr-browser-status-error, #EF5350)";
|
||||
return "var(--mjr-browser-status-neutral, #90A4AE)";
|
||||
}
|
||||
|
||||
if (s === "pending" || s === "info") return "var(--mjr-status-info, #64B5F6)";
|
||||
if (s === "success") return "var(--mjr-status-success, #4CAF50)";
|
||||
if (s === "warning") return "var(--mjr-status-warning, #FFA726)";
|
||||
if (s === "error") return "var(--mjr-status-error, #f44336)";
|
||||
return "var(--mjr-status-neutral, #666)";
|
||||
}
|
||||
|
||||
@@ -96,6 +96,11 @@ function applyStatusHighlight(section, tone = "neutral", options = {}) {
|
||||
border: "rgba(244,67,54,0.62)",
|
||||
glow: "rgba(244,67,54,0.2)",
|
||||
},
|
||||
browser: {
|
||||
bg: "linear-gradient(135deg, rgba(0,150,136,0.18) 0%, rgba(38,166,154,0.08) 100%)",
|
||||
border: "rgba(0,150,136,0.58)",
|
||||
glow: "rgba(0,150,136,0.18)",
|
||||
},
|
||||
};
|
||||
const style = map[tone] || map.neutral;
|
||||
const prevTone = String(section.dataset?.mjrStatusTone || "neutral");
|
||||
@@ -115,6 +120,7 @@ function applyStatusHighlight(section, tone = "neutral", options = {}) {
|
||||
success: t("status.toast.success", "Index status: ready"),
|
||||
warning: t("status.toast.warning", "Index status: attention needed"),
|
||||
error: t("status.toast.error", "Index status: error"),
|
||||
browser: t("status.toast.browser", "Index status: browser scope"),
|
||||
};
|
||||
const level = tone === "error" ? "error" : tone === "warning" ? "warning" : tone === "success" ? "success" : "info";
|
||||
try {
|
||||
@@ -431,7 +437,7 @@ export function createStatusIndicator(options = {}) {
|
||||
statusDot.style.background = "var(--mjr-status-error, #f44336)";
|
||||
applyStatusHighlight(section, "error");
|
||||
}
|
||||
} catch (_error) {
|
||||
} catch {
|
||||
statusDot.style.background = "var(--mjr-status-error, #f44336)";
|
||||
applyStatusHighlight(section, "error");
|
||||
} finally {
|
||||
@@ -495,7 +501,7 @@ export function createStatusIndicator(options = {}) {
|
||||
statusDot.style.background = "var(--mjr-status-error, #f44336)";
|
||||
applyStatusHighlight(section, "error");
|
||||
}
|
||||
} catch (_error) {
|
||||
} catch {
|
||||
statusDot.style.background = "var(--mjr-status-error, #f44336)";
|
||||
applyStatusHighlight(section, "error");
|
||||
} finally {
|
||||
@@ -1011,6 +1017,7 @@ export async function updateStatus(statusDot, statusText, capabilitiesSection =
|
||||
} else if (dbLocked || !indexHealthy) {
|
||||
healthTone = "warning";
|
||||
}
|
||||
const displayTone = isCustomBrowserMode && healthTone !== "error" ? "browser" : healthTone;
|
||||
let watcherInfo = counters.watcher;
|
||||
try {
|
||||
if (!watcherInfo || typeof watcherInfo.enabled !== "boolean") {
|
||||
@@ -1025,7 +1032,7 @@ export async function updateStatus(statusDot, statusText, capabilitiesSection =
|
||||
}
|
||||
} catch {}
|
||||
const watcherLine = isCustomBrowserMode
|
||||
? t("status.watcher.disabledScoped", "Watcher: disabled ({scope})", { scope: t("scope.custom", "Custom") })
|
||||
? t("status.watcher.disabledScoped", "Watcher: disabled ({scope})", { scope: t("scope.customBrowser", "Browser") })
|
||||
: formatWatcherLine(watcherInfo, desiredScope);
|
||||
|
||||
renderCapabilities(capabilitiesSection, toolAvailability, toolPaths);
|
||||
@@ -1051,14 +1058,27 @@ export async function updateStatus(statusDot, statusText, capabilitiesSection =
|
||||
return counters;
|
||||
}
|
||||
|
||||
if (isCustomBrowserMode) {
|
||||
statusDot.style.background = "var(--mjr-status-browser, #26A69A)";
|
||||
applyStatusHighlight(section, "browser");
|
||||
setStatusWithHint(
|
||||
statusText,
|
||||
t("status.ready", "Ready"),
|
||||
[t("status.browserMetricsHidden", "Browser mode: global DB/index metrics hidden"), watcherLine].filter(Boolean).join(" | ")
|
||||
);
|
||||
return counters;
|
||||
}
|
||||
|
||||
if (totalAssets === 0) {
|
||||
statusDot.style.background =
|
||||
healthTone === "info"
|
||||
displayTone === "browser"
|
||||
? "var(--mjr-status-browser, #26A69A)"
|
||||
: healthTone === "info"
|
||||
? "var(--mjr-status-info, #64B5F6)"
|
||||
: healthTone === "warning"
|
||||
? "var(--mjr-status-warning, #FFA726)"
|
||||
: "var(--mjr-status-success, #4CAF50)";
|
||||
applyStatusHighlight(section, healthTone);
|
||||
applyStatusHighlight(section, displayTone);
|
||||
setStatusWithHint(
|
||||
statusText,
|
||||
t("status.noAssets", `No assets indexed yet (${scopeLabel})`, { scope: scopeLabel }),
|
||||
@@ -1066,12 +1086,14 @@ export async function updateStatus(statusDot, statusText, capabilitiesSection =
|
||||
);
|
||||
} else {
|
||||
statusDot.style.background =
|
||||
healthTone === "info"
|
||||
displayTone === "browser"
|
||||
? "var(--mjr-status-browser, #26A69A)"
|
||||
: healthTone === "info"
|
||||
? "var(--mjr-status-info, #64B5F6)"
|
||||
: healthTone === "warning"
|
||||
? "var(--mjr-status-warning, #FFA726)"
|
||||
: "var(--mjr-status-success, #4CAF50)";
|
||||
applyStatusHighlight(section, healthTone);
|
||||
applyStatusHighlight(section, displayTone);
|
||||
setStatusLines(
|
||||
statusText,
|
||||
[
|
||||
|
||||
@@ -3,6 +3,7 @@ Custom roots management endpoints.
|
||||
"""
|
||||
import os
|
||||
import errno
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from aiohttp import web
|
||||
from mjr_am_backend.custom_roots import (
|
||||
@@ -39,6 +40,70 @@ except ImportError:
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def _compute_folder_stats(folder_path: Path, *, max_entries: int = 200000) -> dict:
|
||||
files = 0
|
||||
folders = 0
|
||||
total_size = 0
|
||||
scanned = 0
|
||||
truncated = False
|
||||
|
||||
stack = [folder_path]
|
||||
while stack:
|
||||
cur = stack.pop()
|
||||
try:
|
||||
with os.scandir(cur) as it:
|
||||
for entry in it:
|
||||
scanned += 1
|
||||
if scanned > max_entries:
|
||||
truncated = True
|
||||
break
|
||||
try:
|
||||
if entry.is_symlink():
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
try:
|
||||
if entry.is_dir(follow_symlinks=False):
|
||||
folders += 1
|
||||
try:
|
||||
stack.append(Path(entry.path))
|
||||
except Exception:
|
||||
continue
|
||||
continue
|
||||
if entry.is_file(follow_symlinks=False):
|
||||
files += 1
|
||||
try:
|
||||
total_size += int(entry.stat(follow_symlinks=False).st_size or 0)
|
||||
except Exception:
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
if truncated:
|
||||
break
|
||||
|
||||
try:
|
||||
st = folder_path.stat()
|
||||
mtime = int(st.st_mtime)
|
||||
ctime = int(st.st_ctime)
|
||||
except Exception:
|
||||
mtime = 0
|
||||
ctime = 0
|
||||
|
||||
return {
|
||||
"path": str(folder_path),
|
||||
"name": folder_path.name or str(folder_path),
|
||||
"files": int(files),
|
||||
"folders": int(folders),
|
||||
"size": int(total_size),
|
||||
"mtime": int(mtime),
|
||||
"ctime": int(ctime),
|
||||
"scanned_entries": int(scanned),
|
||||
"truncated": bool(truncated),
|
||||
}
|
||||
|
||||
|
||||
def register_custom_roots_routes(routes: web.RouteTableDef) -> None:
|
||||
"""Register custom root directory routes."""
|
||||
|
||||
@@ -258,3 +323,58 @@ def register_custom_roots_routes(routes: web.RouteTableDef) -> None:
|
||||
Result.Err("VIEW_FAILED", sanitize_error_message(exc, "Failed to serve file"))
|
||||
)
|
||||
|
||||
@routes.get("/mjr/am/folder-info")
|
||||
async def folder_info(request):
|
||||
"""
|
||||
Return folder details for sidebar metadata panel.
|
||||
|
||||
Query params:
|
||||
- filepath: absolute folder path (browser mode)
|
||||
- root_id + subfolder: folder under a configured custom root
|
||||
"""
|
||||
filepath = str(request.query.get("filepath", "") or "").strip()
|
||||
root_id = str(request.query.get("root_id", "") or "").strip()
|
||||
subfolder = str(request.query.get("subfolder", "") or "").strip()
|
||||
|
||||
target = None
|
||||
base_root = None
|
||||
|
||||
if filepath:
|
||||
try:
|
||||
target = Path(filepath).resolve(strict=True)
|
||||
except Exception:
|
||||
return _json_response(Result.Err("DIR_NOT_FOUND", "Directory not found"))
|
||||
else:
|
||||
if not root_id:
|
||||
return _json_response(Result.Err("INVALID_INPUT", "Missing filepath or root_id"))
|
||||
root_result = resolve_custom_root(root_id)
|
||||
if not root_result.ok:
|
||||
return _json_response(root_result)
|
||||
base_root = Path(root_result.data).resolve(strict=False)
|
||||
rel = _safe_rel_path(subfolder or "")
|
||||
if rel is None:
|
||||
return _json_response(Result.Err("INVALID_INPUT", "Invalid subfolder"))
|
||||
target = (base_root / rel)
|
||||
try:
|
||||
target = target.resolve(strict=True)
|
||||
except Exception:
|
||||
return _json_response(Result.Err("DIR_NOT_FOUND", "Directory not found"))
|
||||
if not _is_within_root(target, base_root):
|
||||
return _json_response(Result.Err("FORBIDDEN", "Path outside root"))
|
||||
|
||||
if not target or not target.exists() or not target.is_dir():
|
||||
return _json_response(Result.Err("DIR_NOT_FOUND", "Directory not found"))
|
||||
|
||||
try:
|
||||
stats = await asyncio.to_thread(_compute_folder_stats, target)
|
||||
except Exception:
|
||||
stats = _compute_folder_stats(target)
|
||||
|
||||
if base_root is not None:
|
||||
try:
|
||||
stats["root_id"] = root_id
|
||||
stats["relative_path"] = str(target.relative_to(base_root)).replace("\\", "/")
|
||||
except Exception:
|
||||
pass
|
||||
return _json_response(Result.Ok(stats))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user