import React, {useContext, useState} from "react"; import { CardGroupCentered, CardPoster, encodeMediaPath, ErrorMessage, ExternalCardLink, FileIcon, findPosterPath, HandPointMessage, humanFileSize, isoDatetimeToAgoPopup, PageContainer, Paginator, PreviewLink, SearchInput, TagIcon, Toggle, textEllipsis, mimetypeColor, useTitle, } from "./Common"; import { usePages, useSearchDate, useSearchFiles, useSearchCensored, useSearchDeep, useSearchFilter, useSearchOrder, useSearchView, useStatusFlag, useWROLMode } from "../hooks/customHooks"; import {useFileWorkerStatus} from "../contexts/FileWorkerStatusContext"; import {IconArticle, IconLayoutList, IconSortAscending, IconSortDescending} from "@tabler/icons-react"; import {Link, Route, Routes, useSearchParams} from "react-router"; import {CardPlaceholder} from "./Placeholder"; import {ArchiveCard, ArchiveRowCells} from "./Archive"; import {QueryContext} from "../contexts/contexts"; import { Button, Card, Divider, Group, Header, Icon, IconButton, Label, Modal, Panel, Placeholder, Progress, Radio, Stack, Table, Tooltip, } from "./ui"; import {DateRangeForm, dateRangeIsEmpty, MonthsForm} from "./DatesSelector"; import {SelectableTable} from "./Tables"; import {VideoCard, VideoRowCells} from "./Videos"; import {FileBrowser} from "./FileBrowser"; import {refreshFiles} from "../api"; import {TagsSelector} from "../Tags"; import {Headlines} from "./Headline"; import {useSearch} from "./Search"; import {FILES_MEDIA_URI} from "./Vars"; // Split a ts_headline snippet on our sentinel markers and wrap each match in . // We use sentinels instead of ts_headline's default tags so the snippet can be // safely rendered as React children without dangerouslySetInnerHTML. function renderHighlightedSnippet(snippet) { const parts = snippet.split(/\[\[WROLPI_HL\]\]|\[\[\/WROLPI_HL\]\]/); // Even indices are plain text, odd indices are highlighted matches. return parts.map((part, i) => i % 2 === 1 ? {part} : {part}); } function EbookCard({file, sortField}) { const [searchParams] = useSearchParams(); const query = searchParams.get('q') || ''; const downloadUrl = `/download/${encodeMediaPath(file.primary_path)}`; const isEpub = file['mimetype'] && file['mimetype'].startsWith('application/epub'); const viewerUrl = isEpub ? `/epub/epub.html?url=${downloadUrl}` : null; // Link to doc detail page if this is a modeled doc, augmenting with // the section hint from search so the viewer opens at the right chapter/page. const hint = file.section_hint; let detailUrl = file.model === 'doc' ? `/docs/${file.id}` : null; if (detailUrl && hint) { const params = new URLSearchParams(); if (hint.kind === 'epub_spine') params.set('loc', String(hint.ordinal)); if (hint.kind === 'pdf_page') params.set('page', String(hint.ordinal)); if (query) params.set('q', query); detailUrl = `${detailUrl}?${params.toString()}`; } const title = file.title || file.stem || file.name; const header = detailUrl ? {title} : {title}; const meta = <> {file.author ?
{file.author}
: null} {sortField === 'published_datetime' ?
{file.published_datetime ? isoDatetimeToAgoPopup(file.published_datetime, false) : null}
: file.size &&
{humanFileSize(file.size)}
} {hint &&
{hint.label} {hint.snippet && {renderHighlightedSnippet(hint.snippet)} }
} ; return } title={{header}} meta={meta} color={mimetypeColor(file.mimetype, file.primary_path)} /> } function ImageCard({file}) { const url = `/media/${encodeMediaPath(file.primary_path)}`; const title = file.title || file.stem || file.primary_path; const header = {textEllipsis(title)} ; const dt = file.published_datetime || file.published_modified_datetime || file.modified; const meta = <>
{isoDatetimeToAgoPopup(dt, false)}
{humanFileSize(file.size)}
; return } title={{header}} meta={meta} color={mimetypeColor(file.mimetype, file.primary_path)} /> } function FileCard({file}) { const {sort} = useSearchOrder(); const sortField = sort ? sort.replace(/^-/, '') : null; const isDocType = file.model === 'doc' || (file.mimetype && ( file.mimetype.startsWith('application/epub') || file.mimetype.startsWith('application/x-mobipocket-ebook') )); // Default doc sort is published_datetime when no order query param is set. const docSortField = sortField || 'published_datetime'; if (file.model === 'video' && 'video' in file) { return ; } else if (file.model === 'archive') { return ; } else if (file.mimetype && file.mimetype.startsWith('image/')) { return ; } else if (isDocType) { return ; } const author = file.author; const size = file.size !== null && file.size !== undefined ? humanFileSize(file.size) : null; const title = file.title || file.name || file.primary_path; const dt = file.published_datetime || file.published_modified_datetime || file.modified; const header = {title} ; const meta = <> {author &&
{author}
} {sortField === 'published_datetime' ?
{file.published_datetime ? isoDatetimeToAgoPopup(file.published_datetime, false) : null}
: <>
{isoDatetimeToAgoPopup(dt, false)}
{size}
} ; return } title={header} meta={meta} color={mimetypeColor(file.mimetype, file.primary_path)}/> } export function FileCards({files}) { if (files && files.length >= 1) { return {files.map(i => )} } else if (files && files.length === 0) { return No results! } else if (files === undefined) { return Could not search! } // Response is pending. return } function ImageRowCells({file}) { const url = `/media/${encodeMediaPath(file.primary_path)}`; let poster = ; if (file.size && file.size < 50000000) { // Image is less than 5mb, use it. poster = ; } // Fragment for SelectableRow return
{poster}

{textEllipsis(file.title || file.stem || file.primary_path)}

{humanFileSize(file.size)}
} export function EbookRowCells({file}) { let cover = ; const posterPath = findPosterPath(file); if (posterPath) { const coverSrc = `/media/${encodeMediaPath(posterPath)}`; cover = ; } // Fragment for SelectableRow return
{cover}
{textEllipsis(file.title || file.stem)} {humanFileSize(file.size)}
} function FileRow({file}) { const isDocType = file.model === 'doc' || (file.mimetype && ( file.mimetype.startsWith('application/epub') || file.mimetype.startsWith('application/x-mobipocket-ebook') )); if (file.model === 'video' && 'video' in file) { return ; } else if (file.model === 'archive') { return ; } else if (file.mimetype && file.mimetype.startsWith('image/')) { return ; } else if (isDocType) { return ; } // Fragment for SelectableRow return
{textEllipsis(file.title || file.name || file.stem)}
} export function FileTable({files, selectOn, onSelect, footer, selectedKeys}) { if (files && files.length > 0) { const headerContents = ['Poster', 'Title', 'Data']; const rows = files.map(i => ); return ; } else if (files) { return No results! } else if (files === undefined) { return Could not search! } return } export function FileRowTagIcon({file}) { if (file.tags && file.tags.length) { return ; } } export function SearchViewButton({headlines}) { const {view, setView} = useSearchView(); if (headlines) { // Cycle: cards -> headline -> list if (view === 'headline') { return setView('list')}/>; } else if (view === 'list') { return setView('cards')}/>; } return setView('headline')}/>; } // Cycle: cards -> list if (view === 'list') { return setView('cards')}/>; } return setView('list')}/>; } export function FilesView( { files, activePage, totalPages, selectElem, selectedKeys, onSelect, setPage, headlines = false, }, ) { const {view} = useSearchView(); /* * No margin of its own. Videos, Archives, Docs and Files all render this as a top-level block * of the page, and the page spaces its blocks (see `wrolpi-stack`) -- so a `marginTop: 2em` * here added to that and put the pager twice as far from the results as anything else on the * page sits from its neighbour. Zim keeps its own paginator, inside an accordion panel that * does not stack, and is untouched. */ const paginator =
; let body; if (view === 'list') { const footer = selectElem ? <> {selectElem} : null; body = ; } else if (view === 'headline') { body = ; } else { body = ; } const viewButton = return { body, paginator, viewButton, } } export function SearchFilter({filters = [], modalHeader, size = 'medium'}) { const {filter, setFilter} = useSearchFilter(); const [open, setOpen] = useState(false); const handleOpen = (e) => { if (e) { e.preventDefault(); } setOpen(true); } const handleClear = (e) => { if (e) { e.preventDefault(); } setFilter(null); setOpen(false); } const filterFields = filters.map( i => setFilter(i['value'])} /> ); // Use violet color when filter has been applied. const buttonColor = filter ? 'violet' : 'grey'; if (filters && filters.length > 0) { return <> {/* Audited: unchanged at `small` (440px), recorded so the default cannot move it. */} setOpen(false)} closeIcon> {modalHeader || Filter} {filterFields} } return <> } export function TagsQuerySelector({onChange, showAny = true}) { // Creates modal that the User can use to manipulate the tag query params. const {activeTags, anyTag, setTags, setAnyTag} = useSearch(); const localOnChange = (tagNames, newAnyTag) => { if (newAnyTag) { setAnyTag(true); } else { setTags(tagNames); } if (onChange) { onChange(tagNames, newAnyTag); } } return 0} selectedTagNames={activeTags} anyTag={anyTag} onChange={localOnChange} filterByOverlap={true} style={{marginLeft: '0.3em', marginTop: '0.3em'}} /> } // see `filterToMimetypes` export const fileMimetypeFilterOptions = [ {key: 'video', text: 'Video', value: 'video'}, {key: 'archive', text: 'Archive', value: 'archive'}, {key: 'pdf', text: 'PDF', value: 'pdf'}, {key: 'ebook', text: 'eBook', value: 'ebook'}, {key: 'doc', text: 'Document', value: 'doc'}, {key: 'audio', text: 'Audio', value: 'audio'}, {key: 'image', text: 'Image', value: 'image'}, {key: 'zip', text: 'ZIP', value: 'zip'}, {key: 'model', text: '3D Model', value: 'model'}, {key: 'software', text: 'Software', value: 'software'}, ]; export const docMimetypeFilterOptions = [ {key: 'pdf', text: 'PDF', value: 'pdf'}, {key: 'epub', text: 'EPUB', value: 'epub'}, {key: 'comic', text: 'Comic', value: 'comic'}, {key: 'mobi', text: 'MOBI', value: 'mobi'}, ]; export function FileSearchFilterButton({size = 'medium'}) { return } export function DocSearchFilterButton({size = 'medium'}) { return } // The app-wide default page size (matches usePages' fallback); used to reset Results Per Page on Clear All. const DEFAULT_SEARCH_LIMIT = 24; // A single section within the comprehensive SearchFilterModal. Vertical spacing between sections is // provided by the surrounding Stack. function SearchFilterSection({header, children}) { return <>
{header}
{children} } // A comprehensive modal which combines every search option (sort, tags, file-type filter, dates, limit) // into a single dialog. Which sections appear is controlled by the props, so each page can show only what // is relevant. All state is URL-query driven (same hooks the individual controls use), so no extra wiring // is required on the page. export function SearchFilterModal( { open, onClose, sorts = null, fileFilterOptions = null, showDates = false, showTags = true, showLimit = true, showCensored = false, showDeep = false, limitOptions = [12, 24, 48, 96], }, ) { // Committed (URL) state — read only; drafts below hold the user's in-progress edits. const {filter} = useSearchFilter(); const {sort} = useSearchOrder(); const {dateRange, months} = useSearchDate(); const {activeTags, anyTag} = useSearch(); const {censored} = useSearchCensored(); const {deep} = useSearchDeep(); const {limit} = usePages(DEFAULT_SEARCH_LIMIT); const {updateQuery} = useContext(QueryContext); const emptyRange = [null, null]; const seededRange = dateRange && (dateRange[0] || dateRange[1]) ? dateRange : emptyRange; const seededMonths = (months || []).map(i => parseInt(i)); // Every selection is drafted locally and only applied to the URL (which performs the search) when the // modal is closed — via Done, the close icon, or clicking away. const [draftSort, setDraftSort] = useState(null); const [draftFilter, setDraftFilter] = useState(null); const [draftTags, setDraftTags] = useState([]); const [draftAnyTag, setDraftAnyTag] = useState(false); const [draftLimit, setDraftLimit] = useState(null); const [draftMonths, setDraftMonths] = useState([]); const [draftRange, setDraftRange] = useState(emptyRange); const [draftCensored, setDraftCensored] = useState(false); const [draftDeep, setDraftDeep] = useState(false); // Seed the drafts from the URL each time the modal is opened. React.useEffect(() => { if (open) { setDraftSort(sort || null); setDraftFilter(filter || null); setDraftTags(activeTags || []); setDraftAnyTag(anyTag || false); setDraftLimit(limit); setDraftMonths(seededMonths); setDraftRange(seededRange); setDraftCensored(censored || false); setDraftDeep(deep || false); } }, [open]); const handleClose = () => { // Apply every changed draft in a SINGLE updateQuery — sequential setters would each merge against // the same stale searchParams and clobber one another. Only reset pagination if something changed. const params = {}; if (sorts && (draftSort || null) !== (sort || null)) { params.order = draftSort; } if (fileFilterOptions && (draftFilter || null) !== (filter || null)) { params.filter = draftFilter; } if (showTags && (draftAnyTag !== anyTag || JSON.stringify(draftTags) !== JSON.stringify(activeTags))) { if (draftAnyTag) { params.tag = []; params.anyTag = 'true'; } else { params.tag = draftTags; params.anyTag = null; } } if (showLimit && draftLimit !== limit) { params.l = draftLimit; } if (showDates && (JSON.stringify(draftMonths) !== JSON.stringify(seededMonths) || JSON.stringify(draftRange) !== JSON.stringify(seededRange))) { let newFromDate = null; let newToDate = null; if (draftRange[0] <= draftRange[1]) { newFromDate = draftRange[0]; newToDate = draftRange[1]; } params.fromDate = newFromDate; params.toDate = newToDate; params.month = draftMonths; } if (showCensored && draftCensored !== censored) { params.censored = draftCensored ? 'true' : null; } if (showDeep && draftDeep !== deep) { params.deep = draftDeep ? 'true' : null; } if (Object.keys(params).length > 0) { params.o = 0; updateQuery(params); } if (onClose) { onClose(); } } const handleClearAll = () => { // Reset the form only; nothing is applied until the modal is closed. setDraftSort(null); setDraftFilter(null); setDraftTags([]); setDraftAnyTag(false); setDraftLimit(DEFAULT_SEARCH_LIMIT); setDraftMonths([]); setDraftRange(emptyRange); setDraftCensored(false); setDraftDeep(false); } // Sort section (operates on the draft). const sortKey = draftSort ? draftSort.replace(/^-/, '') : (sorts ? sorts[0]['value'] : null); const desc = draftSort ? draftSort.startsWith('-') : true; const sortButtons = sorts && sorts.map(o => ); const directionToggle = ; // File-type filter section (single-select radios; clicking the active one clears it). Wraps so more // options fit on each line. const filterFields = fileFilterOptions && fileFilterOptions.map(i => setDraftFilter(draftFilter === i['value'] ? null : i['value'])} /> ); // Results-per-page as buttons (matches the Sort By buttons and avoids a dropdown menu being clipped by // the scrolling modal content). const limitButtons = limitOptions.map(i => ); return Search Filters {sorts &&
{directionToggle}
{sortButtons}
} {fileFilterOptions && {filterFields} } {(showTags || showLimit) && {showTags &&
0)} selectedTagNames={draftTags} anyTag={draftAnyTag} filterByOverlap={true} onChange={(tagNames, newAnyTag) => { if (newAnyTag) { setDraftAnyTag(true); setDraftTags([]); } else { setDraftAnyTag(false); setDraftTags(tagNames); } }} style={{marginLeft: '0.3em', marginTop: '0.3em'}} />
} {showLimit &&
{limitButtons}
}
} {showCensored && } {showDeep && } {showDates && }
} // The trigger button which opens the comprehensive SearchFilterModal. Turns violet and displays a count // badge when any of its filters are active. export function SearchFilterButton( { sorts = null, fileFilterOptions = null, showDates = false, showTags = true, showLimit = true, showCensored = false, showDeep = false, limitOptions = [12, 24, 48, 96], size = 'medium', content = 'Filter', }, ) { const [open, setOpen] = useState(false); const {filter} = useSearchFilter(); const {sort} = useSearchOrder(); const {dateRange, months} = useSearchDate(); const {activeTags, anyTag} = useSearch(); const {censored} = useSearchCensored(); const {deep} = useSearchDeep(); let count = 0; if (fileFilterOptions && filter) { count += 1; } if (sorts && sort) { count += 1; } if (showTags && ((activeTags && activeTags.length > 0) || anyTag)) { count += 1; } if (showDates && ((months && months.length > 0) || !dateRangeIsEmpty(dateRange))) { count += 1; } if (showCensored && censored) { count += 1; } if (showDeep && deep) { count += 1; } const active = count > 0; return <> setOpen(false)} sorts={sorts} fileFilterOptions={fileFilterOptions} showDates={showDates} showTags={showTags} showLimit={showLimit} showCensored={showCensored} showDeep={showDeep} limitOptions={limitOptions} /> } // The single-line search control row shared by the Videos/Archives/Docs result pages: // [ view toggle ] [ search input (grows) ] [ Filter button ] // The text input is managed locally and only submits its value on enter/clear. Filter options are // forwarded to the SearchFilterButton so each page shows only the sections relevant to it. export function SearchControlBar( { searchStr, setSearchStr, placeholder = 'Search...', inputRef, viewButton, sorts = null, fileFilterOptions = null, showDates = false, showCensored = false, showDeep = false, }, ) { const [localSearchStr, setLocalSearchStr] = useState(searchStr || ''); const searchInput = setLocalSearchStr(null)} onSubmit={setSearchStr} placeholder={placeholder} inputRef={inputRef} />; // No `marginBottom`: this is a top-level block on Videos, Archives and Docs, and the page // spaces its own blocks. Carrying one here added to that gap. return
{viewButton}
{searchInput}
} // Reminds the user that the fast search only matches titles/authors/descriptions. Used by the module // pages (Videos/Archives/Docs), which have no deep estimate, so it shows whenever a fast search is // active; FilesSearchView has a count-aware variant. export function DeepSearchHint({searchStr, files}) { const {deep} = useSearchDeep(); const {updateQuery} = useContext(QueryContext); if (!searchStr || deep || !Array.isArray(files)) { return null; } return {files.length === 0 ? 'Not finding what you need?' : 'More results may be available in deep search.'} } export function FilesSearchView({ showView = true, emptySearch = false, model, deepEstimate = null, }) { const {searchFiles, total, searchStr, pages} = useSearchFiles(24, emptySearch, model); const {deep} = useSearchDeep(); const {updateQuery} = useContext(QueryContext); const {body, paginator, viewButton} = FilesView( { files: searchFiles, activePage: pages.activePage, totalPages: pages.totalPages, selectElem: null, selectedKeys: null, onSelect: null, setPage: pages.setPage, headlines: true, }, ); // The default search only matches titles/authors/descriptions; offer deep search when it would find // more. `deepEstimate` comes from the search estimates; without it, fall back to a generic hint on // zero results. let deepHint = null; if (Boolean(searchStr) && !deep && searchFiles) { const moreCount = deepEstimate !== null && deepEstimate !== undefined && total !== null ? deepEstimate - total : null; // Enable deep search and reset pagination in a single update (like the filter modal does), so the // user starts at page 1 of the deep results. const deepButton = ; if (searchFiles.length === 0 && (moreCount === null || moreCount > 0)) { deepHint = {moreCount > 0 ? `No results in fast search. ${moreCount} result${moreCount === 1 ? '' : 's'} available in deep search.` : 'Not finding what you need?'} {deepButton} ; } else if (searchFiles.length > 0 && moreCount > 0) { deepHint = {`${moreCount} more result${moreCount === 1 ? '' : 's'} available in deep search.`} {deepButton} ; } } return <> {/* No margin: another top-level block, spaced by the page. */} {showView &&
{viewButton}
} {body} {deepHint} {paginator} } function getPhaseLabel(status) { switch (status) { case 'counting': return 'Counting'; case 'comparing': case 'upserting': case 'deleting': return 'Inserting'; case 'modeling': return 'Modeling'; case 'indexing': return 'Indexing'; case 'cleanup': return 'Cleanup'; case 'planning': return 'Move: Planning'; case 'moving': return 'Move: Moving'; case 'reverting': return 'Reverting'; case 'reorganizing': return 'Reorganizing'; case 'batch_reorganizing': return 'Batch Reorganizing'; case 'error': return 'Error'; default: return null; } } // Map backend status to refresh phase number (1-5) function getRefreshPhase(status) { switch (status) { case 'counting': return 1; case 'comparing': case 'upserting': case 'deleting': return 2; case 'modeling': return 3; case 'indexing': return 4; case 'cleanup': return 5; default: return 0; } } // Check if this is a global refresh (full media directory) function isGlobalRefresh(progress) { if (!progress || progress.status === 'idle') return false; if (progress.task_type !== 'refresh') return false; // Global refresh has a single path ending with the media directory name const paths = progress.paths || []; return paths.length === 1 && (paths[0].endsWith('/wrolpi') || paths[0] === '/media/wrolpi'); } const REFRESH_PHASES = [ {key: 1, icon: 'search', title: 'Counting', description: 'Finding files'}, {key: 2, icon: 'disk', title: 'Inserting', description: 'Updating database'}, {key: 3, icon: 'settings', title: 'Modeling', description: 'Extracting metadata'}, {key: 4, icon: 'book', title: 'Indexing', description: 'Building search index'}, {key: 5, icon: 'check circle', title: 'Cleanup', description: 'Finalizing'}, ]; // A row of phase markers, brightness/weight (via the active // border) carrying the current stage rather than a bespoke component from the shared library. function RefreshStepItem({phase, isActive, isCompleted, isDisabled, description}) { return
{/* The same three roles Status maps complete/active/pending onto. Not the Status component itself: these rows carry a per-phase glyph, which is the point of them. */}
{phase.title}
{isActive || isCompleted ? description : ''}
} export function RefreshSteps({progress}) { const currentPhase = getRefreshPhase(progress?.status); const {operation_processed, operation_total, status} = progress || {}; if (currentPhase === 0) { return null; } return
{REFRESH_PHASES.map(phase => { const isCompleted = phase.key < currentPhase; const isActive = phase.key === currentPhase; const isDisabled = phase.key > currentPhase; // Determine description for active phase let description = phase.description; if (isActive) { if (operation_total > 0) { description = `${operation_processed?.toLocaleString()} / ${operation_total?.toLocaleString()}`; } else if (status === 'comparing') { description = 'Comparing files...'; } else if (status === 'upserting') { description = 'Updating files...'; } else if (status === 'deleting') { description = 'Removing deleted...'; } } return })}
} function RefreshProgressBar({status, operation_total, operation_processed, operation_percent, error}) { const phaseLabel = getPhaseLabel(status); if (!phaseLabel) { return null; } // Show error state if (status === 'error' && error) { return ; } let label = `${phaseLabel}`; if (operation_total > 0) { label = `${label} (${operation_processed.toLocaleString()} / ${operation_total.toLocaleString()})`; } return ; } export function FilesRefreshProgress() { const {status: progress} = useFileWorkerStatus(); if (!progress || progress.status === 'idle') { return null; } const {status, operation_total, operation_processed, operation_percent, error} = progress; // Show Steps component AND progress bar for global refresh operations if (isGlobalRefresh(progress)) { return <> ; } // Show just progress bar for other operations (moves, reorganize, targeted refresh) return ; } function FilesPage() { useTitle('Files'); return <>

You can also view your media directory at {FILES_MEDIA_URI}

; } export function FilesRoute() { return }/> ; } const useRefresh = () => { const wrolModeEnabled = useWROLMode(); const globalRefreshActive = useStatusFlag('global_refresh_active'); const [loading, setLoading] = React.useState(false); const [globalRefreshLoading, setGlobalRefreshLoading] = React.useState(false); const prevGlobalRefreshActive = React.useRef(false); const localRefreshFiles = async (paths) => { setLoading(true); try { await refreshFiles(paths); // Check if this was a global refresh (no paths = refresh all) // If so, set globalRefreshLoading and schedule a fallback timeout if (!paths || paths.length === 0) { setGlobalRefreshLoading(true); setTimeout(() => { setGlobalRefreshLoading(false); }, 10000); } } finally { setLoading(false); } } // Clear globalRefreshLoading when flag transitions from true to false React.useEffect(() => { if (prevGlobalRefreshActive.current && !globalRefreshActive) { setGlobalRefreshLoading(false); } prevGlobalRefreshActive.current = globalRefreshActive; }, [globalRefreshActive]); const isGlobalRefreshing = globalRefreshLoading || globalRefreshActive; return { globalRefreshing: isGlobalRefreshing, loading, wrolModeEnabled, refreshFiles: localRefreshFiles, } } export function FilesRefreshButton({paths, showLabel}) { const {globalRefreshing, loading, wrolModeEnabled, refreshFiles} = useRefresh(); const disabled = wrolModeEnabled || globalRefreshing; const busy = loading || globalRefreshing; if (showLabel) { return ; } return refreshFiles(paths)}/>; }