From 0debbd53a2f4fb60dc9cb3843de4bd2fce6b8baf Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 15 Feb 2026 23:44:26 +0100 Subject: [PATCH] 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. --- js/api/client.js | 40 +++ js/api/endpoints.js | 1 + js/app/i18n.js | 6 +- js/app/settings.js | 8 +- js/components/Badges.js | 11 +- js/components/sidebar/SidebarView.js | 47 +++- .../sidebar/sections/FolderDetailsSection.js | 65 +++++ js/features/grid/GridView.js | 27 ++- js/features/panel/AssetsManagerPanel.js | 227 +++++++++++++++++- .../panel/controllers/contextController.js | 41 ++-- .../controllers/customRootsController.js | 137 ++--------- .../panel/controllers/scopeController.js | 1 - .../panel/controllers/sidebarController.js | 6 +- js/features/panel/views/contextPillsView.js | 42 ++-- js/features/panel/views/headerView.js | 16 +- .../panel/views/pinnedFoldersPopoverView.js | 14 ++ js/features/panel/views/searchView.js | 8 +- js/features/status/AssetStatusDotTheme.js | 39 +++ js/features/status/StatusDot.js | 36 ++- .../routes/handlers/custom_roots.py | 120 +++++++++ 20 files changed, 669 insertions(+), 223 deletions(-) create mode 100644 js/components/sidebar/sections/FolderDetailsSection.js create mode 100644 js/features/panel/views/pinnedFoldersPopoverView.js create mode 100644 js/features/status/AssetStatusDotTheme.js diff --git a/js/api/client.js b/js/api/client.js index 645d45b3..555ce6ba 100644 --- a/js/api/client.js +++ b/js/api/client.js @@ -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" }; diff --git a/js/api/endpoints.js b/js/api/endpoints.js index 1fec8f54..82202cf3 100644 --- a/js/api/endpoints.js +++ b/js/api/endpoints.js @@ -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", diff --git a/js/app/i18n.js b/js/app/i18n.js index 786fd920..915e288c 100644 --- a/js/app/i18n.js +++ b/js/app/i18n.js @@ -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)", diff --git a/js/app/settings.js b/js/app/settings.js index fdf8b9ab..b2173494 100644 --- a/js/app/settings.js +++ b/js/app/settings.js @@ -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"), diff --git a/js/components/Badges.js b/js/components/Badges.js index e708d32b..057f0f6e 100644 --- a/js/components/Badges.js +++ b/js/components/Badges.js @@ -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"; diff --git a/js/components/sidebar/SidebarView.js b/js/components/sidebar/SidebarView.js index af3f353e..1fdcd5f8 100644 --- a/js/components/sidebar/SidebarView.js +++ b/js/components/sidebar/SidebarView.js @@ -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); diff --git a/js/components/sidebar/sections/FolderDetailsSection.js b/js/components/sidebar/sections/FolderDetailsSection.js new file mode 100644 index 00000000..465e893e --- /dev/null +++ b/js/components/sidebar/sections/FolderDetailsSection.js @@ -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; +} + diff --git a/js/features/grid/GridView.js b/js/features/grid/GridView.js index 7f2caace..d7df542b 100644 --- a/js/features/grid/GridView.js +++ b/js/features/grid/GridView.js @@ -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" }; diff --git a/js/features/panel/AssetsManagerPanel.js b/js/features/panel/AssetsManagerPanel.js index 0643b0d5..98e9c222 100644 --- a/js/features/panel/AssetsManagerPanel.js +++ b/js/features/panel/AssetsManagerPanel.js @@ -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; diff --git a/js/features/panel/controllers/contextController.js b/js/features/panel/controllers/contextController.js index 211c7737..7a611113 100644 --- a/js/features/panel/controllers/contextController.js +++ b/js/features/panel/controllers/contextController.js @@ -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 { diff --git a/js/features/panel/controllers/customRootsController.js b/js/features/panel/controllers/customRootsController.js index 21d92a78..4b46df1f 100644 --- a/js/features/panel/controllers/customRootsController.js +++ b/js/features/panel/controllers/customRootsController.js @@ -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(); diff --git a/js/features/panel/controllers/scopeController.js b/js/features/panel/controllers/scopeController.js index cc0510b8..e0c24841 100644 --- a/js/features/panel/controllers/scopeController.js +++ b/js/features/panel/controllers/scopeController.js @@ -4,7 +4,6 @@ export function createScopeController({ customMenuBtn, customPopover, popovers, - refreshCustomRoots, reloadGrid, onChanged = null, onScopeChanged = null, diff --git a/js/features/panel/controllers/sidebarController.js b/js/features/panel/controllers/sidebarController.js index 178e355a..e7034a1f 100644 --- a/js/features/panel/controllers/sidebarController.js +++ b/js/features/panel/controllers/sidebarController.js @@ -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 = ""; } diff --git a/js/features/panel/views/contextPillsView.js b/js/features/panel/views/contextPillsView.js index 81ac57ec..6b8270da 100644 --- a/js/features/panel/views/contextPillsView.js +++ b/js/features/panel/views/contextPillsView.js @@ -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?.() }) ); diff --git a/js/features/panel/views/headerView.js b/js/features/panel/views/headerView.js index 2b1dba15..833f6495 100644 --- a/js/features/panel/views/headerView.js +++ b/js/features/panel/views/headerView.js @@ -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 }; } diff --git a/js/features/panel/views/pinnedFoldersPopoverView.js b/js/features/panel/views/pinnedFoldersPopoverView.js new file mode 100644 index 00000000..e83d25c3 --- /dev/null +++ b/js/features/panel/views/pinnedFoldersPopoverView.js @@ -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 }; +} + diff --git a/js/features/panel/views/searchView.js b/js/features/panel/views/searchView.js index 85253882..2f00d2df 100644 --- a/js/features/panel/views/searchView.js +++ b/js/features/panel/views/searchView.js @@ -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 }; diff --git a/js/features/status/AssetStatusDotTheme.js b/js/features/status/AssetStatusDotTheme.js new file mode 100644 index 00000000..3c550cd7 --- /dev/null +++ b/js/features/status/AssetStatusDotTheme.js @@ -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)"; +} + diff --git a/js/features/status/StatusDot.js b/js/features/status/StatusDot.js index 36c91846..d16e43eb 100644 --- a/js/features/status/StatusDot.js +++ b/js/features/status/StatusDot.js @@ -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, [ diff --git a/mjr_am_backend/routes/handlers/custom_roots.py b/mjr_am_backend/routes/handlers/custom_roots.py index 33da679d..72983086 100644 --- a/mjr_am_backend/routes/handlers/custom_roots.py +++ b/mjr_am_backend/routes/handlers/custom_roots.py @@ -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)) +