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:
Your Name
2026-02-15 23:44:26 +01:00
parent 7d306e201d
commit 0debbd53a2
20 changed files with 669 additions and 223 deletions
+40
View File
@@ -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" };
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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"),
+4 -7
View File
@@ -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";
+36 -11
View File
@@ -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;
}
+21 -6
View File
@@ -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" };
+217 -10
View File
@@ -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 = "";
}
+16 -26
View File
@@ -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?.()
})
);
+5 -11
View File
@@ -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 };
}
+7 -1
View File
@@ -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 };
+39
View File
@@ -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)";
}
+29 -7
View File
@@ -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))