/** * The Market settings section: Discover / Favorites / Themes / Installed tabs over the * /dsh-market/* host routes, with install/update/uninstall flows and the * pending-restart bookkeeping in sessionStorage. */ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, type CSSProperties, type ReactNode } from 'react' import { createPortal } from 'react-dom' import { Button, DisclosureRow, Input, Menu, Modal, Pill, StateDot, Toast, Tooltip, type MenuEntry, } from '@deepseek-ai/dsh-client-ui-primitives' import { IconChevronDownOutline14, IconChevronLeftOutline14, IconChevronRightOutline14, IconChevronUpOutline14, IconCheckOutline16, IconCodeOutline16, IconCordisPluginOutline14, IconDownloadOutline16, IconFolderOpen16, IconFullscreenOutline16, IconLinkOutline14, IconLoadingOutline16, IconQuestionOutline14, IconRefreshOutline14, IconSearchOutline16, IconSparkle16, IconWarningOutline16, } from './icons.ts' import { HostCheckbox, HostSwitch, HostTag } from './optional-primitives.ts' import css from './Market.module.css' import { MARK_BLOCK_RADIUS, MARK_BLOCK_SIZE, MARK_GRID_BLOCKS, MARK_PLUG_BLOCK, MARK_VIEW_BOX } from './market-mark.ts' import { CommentsModal } from './CommentsModal.tsx' import { SearchInput } from './SearchInput.tsx' import { downloadStatsText } from './download-stats.ts' import { OperationsPanel } from './OperationsPanel.tsx' import { applyRecovery, fetchRecovery, initialKeep, RecoveryPanel, watchRestart, type RecoveryView } from './RecoveryPanel.tsx' import { clearSettled, drop, enqueue, patch as patchRecord, recordForUrl } from './operations.ts' import type { OperationRecord } from './operations.ts' import { Diagnostics } from './Diagnostics.tsx' import { exportMarketLog } from './self-check.ts' import { api, applyGithubRouting, avatarColor, catalogEntryForInstalled, entryForDep, githubRouteCandidates, groupSwitchState, humanOutput, installedForCatalog, isGenerationSpec, isInstalled, localizeBilingual, localizeBilingualList, looksTerminal, matchInstalledName, orderedCategories, pluginCategories, formatCount, pageItems, pluginName, pluginScreenshotCandidates, pluginScreenshots, pluginsForFavorites, queuedRowApplies, rankThemeScreenshots, readSession, releaseNotesHttpsImage, rememberGithubRoute, resetScreenshotsCache, resolveCatalogRestore, safeScreenshots, sanitizeReleaseNotesBody, staleFavoriteUrls, themePlugins as themePluginsOf, themeSwatch, TIME_RANGE_DAYS, visiblePlugins, } from './market-data.ts' import type { ActivationInfo, ActivationState, GistExportResult, InstalledMap, InstalledRepoHints, InstalledRepoIdentities, MarketStatus, Registry, RegistryPlugin, HostCompatibility, HostCompatibilityMap, ScreenshotCandidate, ScreenshotMeasurement, SharedHostPackageDependencyFinding, SortDir, SortField, ThemeSnapshot, TimeRange, Translate, UpdateStatus, } from './market-data.ts' function isHostDependencyFinding(value: unknown): value is SharedHostPackageDependencyFinding { if (value === null || typeof value !== 'object') return false const finding = value as Partial return finding.code === 'shared-host-package-dependency' && finding.severity === 'warning' && finding.subject?.kind === 'package' && typeof finding.subject.name === 'string' && finding.evidence?.basis === 'manifest-declaration' && typeof finding.evidence?.dependency === 'string' && typeof finding.evidence.declaredRange === 'string' && finding.evidence.declaredIn === 'dependencies' } const HOST_DEPENDENCY_PREVIEW_LIMIT = 5 const IGNORED_UPDATES_SESSION_KEY = 'dshm-updates-ignored' const UNAVAILABLE_HOST_COMPATIBILITY: HostCompatibility = { status: 'unknown', basis: 'unavailable', requirement: null, declarations: [], } /** * Read the update reminders dismissed for this host process. The boot id is * part of the value rather than the key so sessionStorage never accumulates * one orphaned entry per process. Invalid and stale records fail open: an * update reminder is safer than silently hiding one we cannot account for. */ function ignoredUpdatesForBoot(boot: string): string[] { const saved = readSession(IGNORED_UPDATES_SESSION_KEY) const valid = saved !== null && typeof saved === 'object' && !Array.isArray(saved) && saved.boot === boot && Array.isArray(saved.names) && saved.names.every((name: unknown) => typeof name === 'string' && name !== '') if (!valid) { try { sessionStorage.removeItem(IGNORED_UPDATES_SESSION_KEY) } catch { /* storage unavailable */ } return [] } return [...new Set(saved.names as string[])] } function HostDependencyDiagnostics({ findings, t, }: { findings: SharedHostPackageDependencyFinding[] t: Translate }) { if (findings.length === 0) return null const preview = findings.slice(0, HOST_DEPENDENCY_PREVIEW_LIMIT) const remaining = findings.length - preview.length return (
{t('hostDependencyWarning')}
{preview.map(finding => (
{finding.subject.name} → {finding.evidence.dependency}@{finding.evidence.declaredRange}
))} {remaining > 0 && (
{t('hostDependencyMore').replace('{0}', String(remaining))}
)}
) } /** The state label + dot for one activation result (P0-2). */ function activationMeta( state: ActivationState, t: Translate, dependencyOf?: string, ): { label: string; dot: 'done' | 'warning' | 'error' } { // A library another plugin pulled in is not a plugin that failed to start, // so it gets its own label and no warning dot (#634). if (state === 'inert' && dependencyOf !== undefined) { return { label: t('stateDependencyLibrary').replace('{0}', dependencyOf), dot: 'done' } } if (state === 'live') return { label: t('stateLive'), dot: 'done' } if (state === 'restart') return { label: t('stateRestart'), dot: 'warning' } if (state === 'inert') return { label: t('stateInert'), dot: 'warning' } if (state === 'broken') return { label: t('stateBroken'), dot: 'error' } if (state === 'disabled') return { label: t('stateDisabled'), dot: 'warning' } return { label: '—', dot: 'warning' } } function phaseLabel(phase: NonNullable, t: Translate): string { if (phase === 'resolving') return t('phaseResolving') if (phase === 'downloading') return t('phaseDownloading') if (phase === 'linking') return t('phaseLinking') return t('phaseBuilding') } /** * Page/page-size state shared by every paged list in this file (Discover, * Themes) — each caller owns its OWN instance (their filters are * independent, a search in one tab has no business resetting the other's * page), but the mechanics (clamp against a shrinking list, reset to page 1 * when the filters that produced `count` change, scroll back to the top of * the shared body on any page move) are one implementation, not two. */ function usePagination(count: number, resetDeps: readonly unknown[], scrollToTop: () => void): { currentPage: number totalPages: number pageSize: number goToPage: (next: number) => void changePageSize: (size: number) => void } { const [page, setPage] = useState(1) const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE) // eslint-disable-next-line react-hooks/exhaustive-deps -- resetDeps IS the intended dependency list, supplied by the caller. useEffect(() => { setPage(1) }, resetDeps) const totalPages = Math.max(1, Math.ceil(count / pageSize)) // Clamp in case the list shrank while the user was on a later page. const currentPage = Math.min(page, totalPages) const goToPage = (next: number) => { setPage(Math.max(1, Math.min(next, totalPages))) scrollToTop() } const changePageSize = (size: number) => { setPageSize(size) setPage(1) scrollToTop() } return { currentPage, totalPages, pageSize, goToPage, changePageSize } } /** * The sort/time-range dropdown (primitives Menu): three independent option * groups, ids namespaced so one onSelect routes by prefix. Owns its own * open state — a caller wires only the sort VALUES, not the dropdown's UI * state, so Discover and Themes can each mount one without threading an * extra `filterOpen`/`setFilterOpen` pair through their own state. */ function FilterMenu({ sortField, sortDir, timeRange, hostVersion, compatibleWithHost, onSortField, onSortDir, onTimeRange, onCompatibleWithHost, t, }: { sortField: SortField sortDir: SortDir timeRange: TimeRange hostVersion?: string | null compatibleWithHost?: boolean onSortField: (field: SortField) => void onSortDir: (dir: SortDir) => void onTimeRange: (range: TimeRange) => void onCompatibleWithHost?: (enabled: boolean) => void t: Translate }) { const [open, setOpen] = useState(false) // Direction labels adapt to the field: stars → asc/desc, added → oldest/newest. const sortDirLabel = (dir: SortDir): string => sortField === 'added' ? dir === 'desc' ? 'sortNewest' : 'sortOldest' : dir === 'desc' ? 'sortDesc' : 'sortAsc' const items = useMemo(() => [ { type: 'label', id: 'f-sort', text: t('filterSort') }, ...SORT_FIELD_OPTIONS.map(opt => ({ id: 'field:' + opt.key, label: t(opt.label) })), { type: 'separator', id: 'f-sep1' }, { type: 'label', id: 'f-dir', text: t('filterDir') }, ...SORT_DIR_OPTIONS.map(dir => ({ id: 'dir:' + dir, label: t(sortDirLabel(dir)) })), { type: 'separator', id: 'f-sep2' }, { type: 'label', id: 'f-time', text: t('filterTime') }, ...TIME_OPTIONS.map(opt => ({ id: 'time:' + opt.key, label: t(opt.label) })), ...(onCompatibleWithHost === undefined ? [] : [ { type: 'separator' as const, id: 'f-sep3' }, { type: 'label' as const, id: 'f-host', text: t('filterHost') }, { id: 'host:all', label: t('hostAll') }, { id: 'host:compatible', label: hostVersion === undefined ? t('hostDetecting') : hostVersion === null ? t('hostUnknown') : t('hostCompatible').replace('{0}', hostVersion), }, ]), // eslint-disable-next-line react-hooks/exhaustive-deps -- sortDirLabel closes only over sortField, already a dep. ], [t, sortField, hostVersion, onCompatibleWithHost]) const selectedIds = useMemo( () => [ 'field:' + sortField, 'dir:' + sortDir, 'time:' + timeRange, ...(onCompatibleWithHost === undefined ? [] : [compatibleWithHost === true ? 'host:compatible' : 'host:all']), ], [sortField, sortDir, timeRange, compatibleWithHost, onCompatibleWithHost]) const onSelect = (id: string) => { if (id.startsWith('field:')) onSortField(id.slice(6) as SortField) else if (id.startsWith('dir:')) onSortDir(id.slice(4) as SortDir) else if (id.startsWith('time:')) onTimeRange(id.slice(5) as TimeRange) else if (id === 'host:all') onCompatibleWithHost?.(false) else if (id === 'host:compatible' && typeof hostVersion === 'string') onCompatibleWithHost?.(true) } return ( setOpen(false)} onSelect={onSelect} selectedIds={selectedIds} align="end" portal anchor={( )} items={items} /> ) } /** Prev/numbered/next controls plus a per-page-size menu — one * implementation for every paged list, driven entirely by `usePagination`'s * return value. Owns its own page-size dropdown open state for the same * reason `FilterMenu` owns its own. * * Layout: the numbered cluster (`.pagerPages`) centers in the row and the * page-info + page-size menu (`.pagerMeta`) sit on the right, all on ONE * line. The host settings dialog gives this row ~556px (800px panel − 188px * section nav − 48px options padding − 8px body padding), which fits only a * compact pager: `pageItems` caps the numbered window (its 1 and N are * always present, so skipping to either end stays one click away) and * `.pager`/`.pagerPages` are nowrap with tight paddings — a wrapping pager * reads as broken here. */ function Pager({ currentPage, totalPages, pageSize, onGoToPage, onChangePageSize, t }: { currentPage: number totalPages: number pageSize: number onGoToPage: (page: number) => void onChangePageSize: (size: number) => void t: Translate }) { const [sizeOpen, setSizeOpen] = useState(false) return (
{totalPages > 1 && ( <> {pageItems(currentPage, totalPages).map((item, i) => ( item === '…' ? … : ( ) ))} )}
{totalPages > 1 && {t('pageInfo').replace('{0}', String(currentPage)).replace('{1}', String(totalPages))}} setSizeOpen(false)} onSelect={id => { onChangePageSize(Number(id)) // primitives Menu does not close itself on select (FilterMenu // stays open on purpose for multi-pick). Page size is one shot; // leaving it open after scrollToTop parks the panel mid-list. setSizeOpen(false) }} selectedId={String(pageSize)} align="end" portal anchor={( )} items={PAGE_SIZES.map(size => ({ id: String(size), label: String(size) }))} />
) } /** * Card avatar: the plugin owner's GitHub avatar (no API, browser-cached), * falling back to the initial-letter tile when it can't load. */ /** Inline pass: links, `code`, **bold**; everything else plain text. */ function mdInline(text: string): Array { return text.split(/(\[[^\]]+\]\(\s*https:\/\/[^)\s]+\s*\)|\*\*[^*]+\*\*|`[^`]+`)/g).map((part, i) => { const link = /^\[([^\]]+)\]\(\s*(https:\/\/[^)\s]+)\s*\)$/u.exec(part) if (link !== null) { return ( {link[1]} ) } if (part.startsWith('**') && part.endsWith('**') && part.length > 4) { return {part.slice(2, -2)} } if (part.startsWith('`') && part.endsWith('`') && part.length > 2) { return {part.slice(1, -1)} } return part }) } /** * Release-body markdown, reduced to what a reading dialog needs: headings, * bullets, quotes, fenced code, paragraphs, bold, inline code, https links, * and allowlisted https images. HTML from the repo is stripped first (never * interpreted as markup); remaining text arrives as React children or * controlled nodes only. */ function renderMarkdown(md: string): Array { const out: Array = [] let bullets: string[] | null = null let fence: string[] | null = null const flushList = (): void => { if (bullets === null) return const items = bullets out.push(
    {items.map((item, i) =>
  • {mdInline(item)}
  • )}
) bullets = null } const flushFence = (): void => { if (fence === null) return const body = fence.join('\n') out.push(
{body}
) fence = null } for (const line of sanitizeReleaseNotesBody(md).split('\n')) { const trimmed = line.trim() if (fence !== null) { if (/^```/.test(trimmed)) { flushFence() } else { fence.push(line.replace(/\s+$/u, '')) } continue } if (/^```/.test(trimmed)) { flushList() fence = [] continue } if (trimmed === '') { flushList(); continue } const image = releaseNotesHttpsImage(trimmed) if (image !== null) { flushList() out.push( {image.alt}, ) continue } const heading = /^#{1,6}\s+(.*)$/.exec(trimmed) if (heading !== null) { flushList() out.push(
{mdInline(heading[1]!)}
) continue } const quote = /^>\s?(.*)$/u.exec(trimmed) if (quote !== null) { flushList() out.push(
{mdInline(quote[1]!)}
) continue } const bullet = /^[-*]\s+(.*)$/.exec(trimmed) if (bullet !== null) { ;(bullets ??= []).push(bullet[1]!) continue } flushList() out.push(
{mdInline(line)}
) } flushList() flushFence() return out } export function OwnerAvatar({ name, owner }: { name: string; owner: string }) { const [failed, setFailed] = useState(false) const [routeIndex, setRouteIndex] = useState(0) if (failed || owner === '') { return (
{name.replace(/^dsh[-_]/i, '').charAt(0).toUpperCase() || 'P'}
) } const candidates = githubRouteCandidates( 'avatar', `https://avatars.githubusercontent.com/${encodeURIComponent(owner)}?size=96`, ) const candidate = candidates[Math.min(routeIndex, candidates.length - 1)]! return ( rememberGithubRoute('avatar', candidate.proxy)} onError={() => { if (routeIndex + 1 < candidates.length) setRouteIndex(routeIndex + 1) else setFailed(true) }} /> ) } /** * AppStore-style screenshot strip in the install detail dialog (#61). * Curated registry screenshots win; otherwise images are extracted from the * repo README. Requests start only once the dialog opens; failures — no * README, no images, broken links — degrade to rendering nothing at all. */ function ScreenshotStrip({ plugin, onOpen }: { plugin: RegistryPlugin; onOpen: (shots: string[], index: number) => void }) { const [shots, setShots] = useState([]) const [broken, setBroken] = useState([]) useEffect(() => { let live = true setShots([]) setBroken([]) pluginScreenshots(plugin).then((list) => { if (live) setShots(list) }) return () => { live = false } }, [plugin]) const visible = shots.filter(src => !broken.includes(src)) if (visible.length === 0) return null return (
{visible.map((src, i) => ( onOpen(visible, i)} onError={() => setBroken(prev => prev.includes(src) ? prev : prev.concat(src))} /> ))}
) } /** * Advances an index every `intervalMs` while `count > 1` — the shared clock * behind both a card's auto-cycling thumbnail and the lightbox. A manual * jump (clicking a dot, an arrow, opening on a specific shot) restarts the * clock instead of letting it fire again moments later: without that, a * deliberate "go back one" reads as broken when it auto-advances right past * where the user just navigated to. * * `intervalMs <= 0` disables the timer entirely (no auto-advance at all); * manual jumps still work. The lightbox uses this: a full-bleed image needs * to stay put until the viewer moves on, so it must never page itself. */ function useAutoCarousel(count: number, initial: number, intervalMs = 3500): [number, (i: number) => void] { const [index, setIndexState] = useState(initial) const [resetTick, setResetTick] = useState(0) useEffect(() => { if (count <= 1 || intervalMs <= 0) return const timer = setInterval(() => { setIndexState(i => (i + 1) % count) }, intervalMs) return () => clearInterval(timer) }, [count, intervalMs, resetTick]) const setIndex = (i: number): void => { if (count <= 0) return setIndexState(((i % count) + count) % count) setResetTick(t => t + 1) } return [index, setIndex] } /** * A card thumbnail (or dialog strip image) renders at well under 150px on * screen; the curated screenshot behind it can be a full-resolution PNG * several hundred KB to a few MB — GitHub's own hosts offer no resized * variant, so rendering the original meant downloading full-size images for * a strip nobody asked to see full-size. images.weserv.nl resizes * server-side (by decoded HEIGHT, `fit=inside` so it never crops, `we=1` so * it never upscales something already smaller) before the bytes reach the * browser. The lightbox — an explicit "show me this big" — still requests * the ORIGINAL directly: proxying that one too would add a hop with nothing * left to save, and once the thumbnail is genuinely smaller it can no longer * share a cache entry with the full-size open anyway. */ function thumbUrl(src: string, height: number): string { // The resizer stays in every region, including China. // // It was briefly bypassed there on the assumption that a service in the // Netherlands would be one more far-away host in the way. Measured from an // unproxied mainland connection, that was wrong twice over: weserv answers // in 1.39s, and it answers with 23KB where the original is 41KB. Routing // around it would have traded a working request for a bigger one, on a // page that makes dozens of them. return `https://images.weserv.nl/?url=${encodeURIComponent(src.replace(/^https?:\/\//, ''))}&h=${String(height)}&fit=inside&we=1` } /** * True once the wrapped element has scrolled within `rootMargin` of the * viewport. Falls back to true immediately where IntersectionObserver is * unavailable (old browsers, jsdom without a stub) — a missing observer * should degrade to eager loading, not a permanently empty thumbnail. * Native `img loading="lazy"` already defers the network fetch on its own, * but its trigger distance isn't ours to tune, and scrolling a 400+ entry * catalog queues every off-screen card's request the instant the browser * decides to start prefetching — this hook is what lets CardShot not even * SET `src` until a card is actually close. */ function useNearViewport(rootMargin = '200px'): [(node: T | null) => void, boolean] { const [near, setNear] = useState(typeof IntersectionObserver === 'undefined') const [node, setNode] = useState(null) useEffect(() => { if (near || node === null) return const obs = new IntersectionObserver((entries) => { if (entries.some(entry => entry.isIntersecting)) setNear(true) }, { rootMargin }) obs.observe(node) return () => obs.disconnect() }, [near, node, rootMargin]) return [setNode, near] } /** * A card's own thumbnail strip — curated screenshots only (#61 supplement): * this data already rode along with the catalog fetch that drew the grid, * so showing it costs nothing extra. README-scraped fallback images stay * dialog-only, where fetching one repo's README on click is a single * request instead of one per visible card. * * Horizontal scroll at each image's own aspect ratio, not an auto-cycling * single crop: cropping every shot into one fixed box hid most of a tall * screenshot, and cycling on a timer meant the card you were looking at * kept changing under you. Scrolling is a gesture the user drives. */ /** Thumbnails per card. The dialog shows every screenshot; a grid of cards * pulling six full-size PNGs each is what makes the first paint crawl. */ const CARD_SHOT_LIMIT = 3 function CardShot({ plugin, onOpen }: { plugin: RegistryPlugin; onOpen: (shots: string[], index: number) => void }) { const shots = safeScreenshots(plugin.screenshots) const [broken, setBroken] = useState([]) const visible = shots.filter(src => !broken.includes(src)).slice(0, CARD_SHOT_LIMIT) const [setStripRef, near] = useNearViewport() if (visible.length === 0) return null return (
{visible.map((src, i) => ( { e.stopPropagation(); onOpen(visible, i) }} onError={() => setBroken(prev => prev.includes(src) ? prev : prev.concat(src))} /> ))}
) } /** * Read dimensions through the same low-resolution, no-upscale route used by * card thumbnails. Large originals therefore stay off the wire, while a * genuinely tiny image remains tiny and can be rejected by the scorer. */ function measureThemeCandidates(candidates: ScreenshotCandidate[]): Promise { if (typeof Image === 'undefined') return Promise.resolve([]) return Promise.all(candidates.map(candidate => new Promise((resolve) => { const probe = new Image() let settled = false const finish = (measurement: ScreenshotMeasurement | null) => { if (settled) return settled = true window.clearTimeout(timer) probe.onload = null probe.onerror = null resolve(measurement) } const timer = window.setTimeout(() => finish(null), 6_000) probe.onload = () => finish({ src: candidate.src, width: probe.naturalWidth, height: probe.naturalHeight }) probe.onerror = () => finish(null) probe.referrerPolicy = 'no-referrer' probe.decoding = 'async' probe.src = thumbUrl(candidate.src, 240) }))).then(results => results.filter((result): result is ScreenshotMeasurement => result !== null)) } const measuredThemePreviewTasks = new Map>() const measuredThemePreviewResults = new Map() /** Clear measured theme media at the boundary of an accepted catalog generation. */ export function resetThemePreviewCache(): void { measuredThemePreviewTasks.clear() measuredThemePreviewResults.clear() } /** README fetch + geometry probes, shared across search/page remounts. */ function measuredThemePreview(plugin: RegistryPlugin): Promise { const cached = measuredThemePreviewTasks.get(plugin.url) if (cached !== undefined) return cached const task = pluginScreenshotCandidates(plugin).then(async (candidates) => { const measurements = await measureThemeCandidates(candidates) const ranked = rankThemeScreenshots(candidates, measurements) measuredThemePreviewResults.set(plugin.url, ranked) return ranked }).catch(() => { measuredThemePreviewResults.set(plugin.url, []) return [] }) measuredThemePreviewTasks.set(plugin.url, task) return task } /** * Themes are chosen visually, so their catalog card gets one stable, large * preview instead of the generic plugin card's horizontal thumbnail strip. * Curated screenshots keep their declared order. A missing curated set is * filled lazily from README only when the card nears the viewport, then * ranked by both README semantics and measured image geometry. */ function ThemeCover({ plugin, onOpen, t }: { plugin: RegistryPlugin onOpen: (shots: string[], index: number) => void t: Translate }) { const curated = safeScreenshots(plugin.screenshots) const curatedKey = curated.join('\n') const cachedFallback = curated.length === 0 ? measuredThemePreviewResults.get(plugin.url) : undefined const [fallback, setFallback] = useState<{ loading: boolean; shots: string[] }>({ loading: curated.length === 0 && cachedFallback === undefined, shots: cachedFallback ?? [], }) const [broken, setBroken] = useState([]) const [setCoverRef, near] = useNearViewport() useEffect(() => { setBroken([]) if (curated.length > 0) { setFallback({ loading: false, shots: [] }) return } const cached = measuredThemePreviewResults.get(plugin.url) if (cached !== undefined) { setFallback({ loading: false, shots: cached }) return } setFallback({ loading: true, shots: [] }) if (!near) return let live = true void measuredThemePreview(plugin).then(shots => { if (live) setFallback({ loading: false, shots }) }) return () => { live = false } // `plugin.url` identifies a card; registry objects are deliberately not a // dependency because polling may recreate one without changing its media. // eslint-disable-next-line react-hooks/exhaustive-deps }, [curatedKey, near, plugin.url]) const shots = curated.length > 0 ? curated : fallback.shots const visible = shots.filter(src => !broken.includes(src)) const name = pluginName(plugin.name) if (visible.length === 0) { return ( ) } const src = visible[0]! return ( ) } /** * Masonry columns holding items in their input order. * * Items are dealt alternately (0,2,4… left; 1,3,5… right) rather than split * down the middle, so the sort order still reads left-to-right then down — * the ranking is the whole point of the sort menu above it. Each column is * its own flex stack, so a tall item only pushes down the items beneath IT * instead of leaving a hole beside its shorter neighbour. * * Below the two-up breakpoint the CSS collapses to one column, and dealing * alternately would then interleave the list wrongly — so at one column the * items stay in a single stack in their original order. */ function Masonry({ items, render, columns = 2 }: { items: T[] render: (item: T) => ReactNode columns?: number }) { const wide = useMediaWide() if (!wide || columns < 2) { return
{items.map(render)}
} const buckets: T[][] = Array.from({ length: columns }, () => []) items.forEach((item, index) => { buckets[index % columns]!.push(item) }) return (
{buckets.map((bucket, index) => (
{bucket.map(render)}
))}
) } /** * Whether the layout is at its two-up width. Matches the CSS breakpoint * exactly: the column split is decided in JS but rendered by CSS, and the * two disagreeing would deal cards into columns the stylesheet has already * stacked. */ function useMediaWide(): boolean { const query = '(min-width: 681px)' const subscribe = useCallback((notify: () => void) => { if (typeof matchMedia !== 'function') return () => {} const list = matchMedia(query) list.addEventListener('change', notify) return () => list.removeEventListener('change', notify) }, []) return useSyncExternalStore( subscribe, () => (typeof matchMedia === 'function' ? matchMedia(query).matches : true), // Server/jsdom without matchMedia: assume the two-up layout, which is // what the stylesheet defaults to before any media query applies. () => true, ) } /** * A card's description, clamped to 5 lines so one wordy entry doesn't blow * the two-up grid's row height out for whatever sits beside it — the grid * already tolerates SOME height variance by design (`.card`'s `align-self: * start`), just not an unbounded one. The toggle only renders when the text * actually overflows the clamp: a two-line description has nothing to * "expand", so no button beats a button that does nothing. */ function CardDesc({ text, t }: { text: string; t: Translate }) { const ref = useRef(null) const [expanded, setExpanded] = useState(false) const [canExpand, setCanExpand] = useState(false) useLayoutEffect(() => { const el = ref.current if (el === null) return setCanExpand(el.scrollHeight > el.clientHeight + 1) }, [text]) return (
{text}
{canExpand && ( )}
) } /** * Full-bleed image preview, opened from a card thumbnail or a dialog's * screenshot strip. Not the shared Modal primitive: Modal is chrome for a * decision (title, description, footer actions); this is just the same * already-downloaded image shown bigger — there is no separate "thumbnail" * vs "full size" asset to fetch. */ function ScreenshotLightbox({ shots, startIndex, onClose, t }: { shots: string[]; startIndex: number; onClose: () => void; t: Translate }) { // Full-bleed previews must not auto-advance: a chart or a screenshot needs // to stay readable until the viewer moves on, so the carousel timer is // disabled with intervalMs = 0. Arrows, dots, and the keyboard still // navigate manually. const [index, setIndex] = useAutoCarousel(shots.length, startIndex, 0) const host = useMarketPortalHost() useEffect(() => { // Capture phase + stopPropagation: the Settings dialog underneath is a // Modal with its own Escape-to-close handling, also on window/document. // Without this, one Escape press closed both layers at once — verified // on a real host — because the modal's bubble-phase listener still fired // after this one. Capture runs first and this stops it from reaching // bubble phase at all, so only the top layer responds to one press. const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.stopPropagation(); onClose() } else if (e.key === 'ArrowLeft') { e.stopPropagation(); setIndex(index - 1) } else if (e.key === 'ArrowRight') { e.stopPropagation(); setIndex(index + 1) } } window.addEventListener('keydown', onKey, true) return () => window.removeEventListener('keydown', onKey, true) // eslint-disable-next-line react-hooks/exhaustive-deps }, [index]) // Into a container this package owns, never into document.body itself. // // In-tree rendering is not an option: the primitives' own Modal (the // settings dialog underneath) portals itself to document.body, so the // lightbox rendered in place sat BEHIND it whatever the z-index — a portal // only wins a stacking tie against another portal by mounting later. // Reported on a real host: "大的预览图层级不对,现在在弹窗的后面". // // But sharing document.body with the host was the other half of a trap. // The host's settings dialog and this package are separate React roots, // and two roots appending and removing children of the SAME container // interleave in an order neither one models. The host's root then calls // removeChild for a node this one had already moved, React throws // `NotFoundError: The node to be removed is not a child of this node`, the // `settings.section` slot catches it, and the whole market panel goes // blank (#293 by @Tianhao-1017, #286, #241 — the reporter of #293 traced // this to the line, with the stack and a clean-reinstall check). // // Owning one container fixes that structurally: the host's root sees a // single opaque child it never touches, and everything this package // mounts or unmounts happens inside it. return createPortal(
{/* A literal "×" rather than IconCloseOutline16: the primitives package's own Modal uses that icon at runtime, but this package version's public type surface doesn't resolve it — `tsc` reports "no exported member" even though icons/index.d.ts declares it. Not worth a type-check suppression for one close glyph. */} e.stopPropagation()} /> {shots.length > 1 && ( <>
e.stopPropagation()}> {shots.map((src, i) => ( setIndex(i)} /> ))}
)}
, host, ) } /** * The one DOM node this package portals into, created on first use and kept * for the life of the page. * * Created imperatively rather than rendered, and never removed: the point is * that `document.body`'s child list stops being shared state between two * React roots. A container that came and went would put the same churn back * into body, just less often — and "less often" is what made this bug * intermittent and hard to believe in the first place. * * Re-appended on every open so it stays last among body's children. That is * what keeps the lightbox above the host's own portalled dialog, which is * why the portal exists at all; moving a node we own is not something the * host's root tracks, so it cannot disturb it. */ let portalHost: HTMLElement | null = null function marketPortalHost(): HTMLElement { if (portalHost === null) { portalHost = document.createElement('div') // Named so anyone inspecting the DOM, or a future host wanting to give // plugins a real portal slot, can see who owns it. portalHost.setAttribute('data-dsh-market-portal', '') // Dialogs, the lightbox and every other layer render THROUGH this host, // outside the root above — so it needs the same exemption, or a // translated dialog crashes React exactly like a translated page (#293). portalHost.setAttribute('translate', 'no') portalHost.classList.add('notranslate') } return portalHost } /** * Move the container to the end of `document.body`, which is what keeps this * package's layers above the host's own portalled dialog. * * In a layout effect, NOT during render. `createPortal` needs the element * while rendering, but appending it does not belong there: React may start a * render, abandon it and start again, so a mutation in the render body runs * for passes that never commit — and this particular mutation reorders * `document.body`, the one container this package shares with the host's * separate React root. That is the same shared-child-list hazard #293 was * about, just arrived at from the other side. Committing it in an effect * means it happens once, after React is done, in the order React expects. */ function useMarketPortalHost(): HTMLElement { const host = marketPortalHost() useLayoutEffect(() => { // appendChild on an existing child MOVES it to the end — the stacking // guarantee, refreshed on open without ever creating a second container. document.body.appendChild(host) }, [host]) return host } /** Test hook: the container is module state and outlives a component unmount. */ export function resetMarketPortalHost(): void { portalHost?.remove() portalHost = null } /** * Official-style market glyph: the shared block-grid brand mark converted to * the official monochrome icon form (16×16, fill="currentColor") so it * follows the active theme. Mirrors the settings-nav glyph used for the * "market" section id — both now draw the geometry in market-mark.ts, so the * nav entry and the section it opens cannot drift apart. */ function MarketLogo({ size = 16, style, animated = false }: { size?: number; style?: CSSProperties; animated?: boolean }) { return ( ) } /** * GitHub mark beside catalog card titles (#256, #365). Catalog intake in * awesome-dsh-plugin rejects any entry whose `url` is not * `https://github.com/owner/repo` (scripts/lib/entries.mjs), so this renders * unconditionally — not a bet that today's snapshot happens to be all * GitHub. The generic outbound arrow did not say so until hover. This rides * the title's own line — no second link, no extra row. */ function GithubRepoMark({ size = 12, className }: { size?: number; className?: string }) { return ( ) } /** Bookmark toggle on catalog cards (#414). Outline when off, filled when on — * deliberately not a star, which the byline already uses for GitHub popularity. */ function BookmarkMark({ size = 14, filled = false, className }: { size?: number; filled?: boolean; className?: string }) { return ( ) } /** A compact rolling-period label, with source metadata on hover or focus. */ function DownloadCount({ plugin, t }: { plugin: RegistryPlugin; t: Translate }) { const tip = downloadStatsText(plugin, t) if (tip === null) return null return ( {'· ↓ ' + formatCount(plugin.downloads!) + ' / ' + t('downloadsPeriod')} ) } /** Catalog npm latest, omitted for github-only and not-yet-backfilled rows. */ function CatalogVersionMark({ version, tip }: { version: string | null | undefined; tip: string }) { if (typeof version !== 'string' || version.length === 0) return null const label = /^v/i.test(version) ? version : `v${version}` return ( {`· ${label}`} ) } /** * Module-scope caches so re-entering the section renders instantly instead * of refetching and rebuilding from a spinner (#30 by @StarsTom). Module * state survives section switches; a background refetch keeps it current. */ let cachedRegistry: Registry | null = null let cachedInstalled: InstalledMap | null = null let cachedRepoIdentities: InstalledRepoIdentities | null = null let cachedRepoHints: InstalledRepoHints | null = null /** Discover grid page-size choices — the catalog grows daily, so cap each page. */ const PAGE_SIZES = [24, 48, 96] const DEFAULT_PAGE_SIZE = 24 const WEBDAV_STORAGE_KEY = 'dshm-webdav' function savedWebdav(): { url: string; username: string; password: string; auto: boolean } { try { const value = JSON.parse(localStorage.getItem(WEBDAV_STORAGE_KEY) ?? '{}') as Record return { url: typeof value.url === 'string' ? value.url : '', username: typeof value.username === 'string' ? value.username : '', // The password never persists in the browser: plugins run same-origin // with dshmarket, so a stored password would be readable by any plugin // client on this host and become the weakest credential in the profile // (review #63). It lives in server config / memory only. password: '', auto: value.auto === true, } } catch { return { url: '', username: '', password: '', auto: false } } } function backupDependencies(value: unknown): InstalledMap { if (value === null || typeof value !== 'object') throw new Error('invalid backup') const backup = value as { format?: unknown; version?: unknown; files?: unknown } if (backup.format !== 'dsh-profile-backup' || backup.version !== 0.2) throw new Error('unsupported backup format') const files = backup.files if (!Array.isArray(files)) throw new Error('unsupported backup format') const manifest = files.find(file => file !== null && typeof file === 'object' && (file as { path?: unknown }).path === 'package.json') as { json?: unknown } | undefined if (manifest?.json === null || typeof manifest?.json !== 'object' || Array.isArray(manifest.json)) throw new Error('backup package.json is invalid') const dependencies = (manifest.json as { dependencies?: unknown }).dependencies if (dependencies === null || typeof dependencies !== 'object' || Array.isArray(dependencies)) return {} if (!Object.values(dependencies).every(spec => typeof spec === 'string')) throw new Error('backup dependencies are invalid') return dependencies as InstalledMap } function installedRepoIdentities(value: unknown): InstalledRepoIdentities { if (value === null || typeof value !== 'object' || Array.isArray(value)) return {} const identities: InstalledRepoIdentities = {} for (const [name, ids] of Object.entries(value)) { if (!Array.isArray(ids)) continue const strings = ids.filter((id): id is string => typeof id === 'string') if (strings.length > 0) identities[name] = strings } return identities } function installedRepoHints(value: unknown): InstalledRepoHints { return installedRepoIdentities(value) } function installedMap(value: unknown): InstalledMap { if (value === null || typeof value !== 'object' || Array.isArray(value)) return {} const installed: InstalledMap = {} for (const [name, spec] of Object.entries(value)) { if (typeof spec === 'string') installed[name] = spec } return installed } /** A `{name: {...}}` map, or anything else — for untyped server fields. */ function isRecordOfRecords(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value) } function sameInstalledMap(left: InstalledMap, right: InstalledMap): boolean { const names = Object.keys(left) return names.length === Object.keys(right).length && names.every(name => left[name] === right[name]) } /** * Whether one installed plugin has a pending update (either an ordinary * upgrade via npm/git/restore, or a host-managed generation release), and has * not already been updated in the current session. * * THE answer to that question. It used to have three copies that disagreed — * the reminder count, the card's pill, and the installed list's ordering — * which is the shape this repository already paid for once * (`src/entry-identity.ts`: one assumption, several copies, each fixed at a * different time). Callers now pass the two things that are genuinely their * own policy: * * - `ignored`: a session-level "ignore this update" (#657). The NOTICE * surfaces pass it — the badge and the ordering, because a row the user * dismissed should not keep jumping to the top or counting toward * attention. The row's own pill does NOT, because the pill answers "is * there an update" (which dismissing does not change) and the row shows the * dismissal right beside it. * - whether a disabled plugin counts, which the reminder count says no to and * the list says yes to: a disabled row is still a row someone may want to * update from the list, but it is not asking for attention. */ function isPluginUpdatable( name: string, spec: string, status: UpdateStatus | undefined, updatedNames: readonly string[], ignored: ReadonlySet = new Set(), ): boolean { if (updatedNames.includes(name) || ignored.has(name) || status === undefined) return false if (status.updateAvailable === true) return true const generation = status.kind === 'generation' || isGenerationSpec(spec) return generation && status.latest != null } /** Sort field choices in the filter panel. */ const SORT_FIELD_OPTIONS: ReadonlyArray<{ key: SortField; label: string }> = [ { key: 'downloads', label: 'sortDownloads' }, { key: 'stars', label: 'sortStars' }, { key: 'added', label: 'sortAdded' }, ] /** Sort direction choices in the filter panel (labels depend on the field). */ const SORT_DIR_OPTIONS: ReadonlyArray = ['desc', 'asc'] /** Published-within choices in the filter panel. */ const TIME_OPTIONS: ReadonlyArray<{ key: TimeRange; label: string }> = [ { key: 'all', label: 'timeAll' }, { key: 'day', label: 'timeDay' }, { key: 'week', label: 'timeWeek' }, { key: 'month', label: 'timeMonth' }, { key: 'quarter', label: 'timeQuarter' }, { key: 'year', label: 'timeYear' }, ] export interface MarketSectionProps { t: Translate locale: { subscribe(callback: () => void): () => void getSnapshot(): { active: string } } theme: { setTheme(id: string): void } themeStore: { subscribe(callback: () => void): () => void getSnapshot(): ThemeSnapshot | null } /** Optional host-provided destination: `discover:` or `installed:`. */ preferredSubsectionId?: string } interface SourceMigrationConfirm { name: string source: string target: string } export function MarketSection(props: MarketSectionProps) { const t = props.t const initialWebdav = useMemo(savedWebdav, []) const localeSnap = useSyncExternalStore( cb => props.locale.subscribe(cb), () => props.locale.getSnapshot(), ) const lang = String(localeSnap.active).toLowerCase().startsWith('zh') ? 'zh' : 'en' // null when the composition has no theme service — the Themes tab hides. const themeSnap = useSyncExternalStore( props.themeStore.subscribe, props.themeStore.getSnapshot, ) const [data, setData] = useState(cachedRegistry) const [loadError, setLoadError] = useState(null) const [installed, setInstalledState] = useState(cachedInstalled ?? {}) const setInstalled = useCallback((value: InstalledMap) => { cachedInstalled = value; setInstalledState(value) }, []) const [repoIdentities, setRepoIdentitiesState] = useState(cachedRepoIdentities ?? {}) const setRepoIdentities = useCallback((value: InstalledRepoIdentities) => { cachedRepoIdentities = value setRepoIdentitiesState(value) }, []) const [repoHints, setRepoHintsState] = useState(cachedRepoHints ?? {}) const setRepoHints = useCallback((value: InstalledRepoHints) => { cachedRepoHints = value setRepoHintsState(value) }, []) const [installedFiles, setInstalledFiles] = useState([]) const [skins, setSkins] = useState([]) const [tab, setTab] = useState(() => { const saved = sessionStorage.getItem('dshm-tab') if (saved !== null) sessionStorage.removeItem('dshm-tab') return saved || 'discover' }) const [q, setQ] = useState('') const [discoverSearchReset, resetDiscoverSearch] = useState(0) const [installedSearchReset, resetInstalledSearch] = useState(0) /** Per-tab searches stay independent: discover / themes / installed. */ const [qThemes, setQThemes] = useState('') const [qFavorites, setQFavorites] = useState('') const [qInstalled, setQInstalled] = useState('') const [cat, setCat] = useState('all') // FLAQ Desktop supplies this for onboarding/feature navigation; upstream dsh web omits it, so ordinary web opens intentionally leave this effect idle. useEffect(() => { const target = props.preferredSubsectionId if (target === undefined) return const separator = target.indexOf(':') const kind = separator === -1 ? target : target.slice(0, separator) const value = separator === -1 ? '' : target.slice(separator + 1) if (kind === 'installed') { setTab('installed') setQInstalled(value) resetInstalledSearch(n => n + 1) } else if (kind === 'discover') { setTab('discover') setCat('all') setQ(value) resetDiscoverSearch(n => n + 1) } }, [props.preferredSubsectionId]) const [confirming, setConfirming] = useState(null) /** The plugin whose comment thread is open, or null. */ const [commentsFor, setCommentsFor] = useState(null) /** A rejected install and the installed plugins it clashed with, one entry * per owner as grouped by the host. */ interface ConflictNotice { plugin: RegistryPlugin groups: Array<{ owner: string; ids: string[] }> } /** * Every mutating operation the user started. Records outlive the card that * started them, so paginating or searching cannot take a pending decision * off screen. */ const [records, setRecords] = useState([]) const recordSeq = useRef(0) /** The synthetic install task rebuilt from dshm-pending after a remount. */ const recoveredInstall = useRef<{ id: string; url: string; name?: string } | null>(null) /** The synthetic task rebuilt from dshm-updating after this section remounts. */ const recoveredUpdateRecordId = useRef(null) /** * The install queue: agents-busy 409s become `queued` records instead of * failures, and drain automatically when agents go idle (see drainQueue). * Persisted in localStorage so a refresh keeps the queue; only `queued` * records persist — `running` recovery stays on the dshm-pending paths. */ const queueRestoredRef = useRef(false) useEffect(() => { if (queueRestoredRef.current) return let saved: unknown = null try { saved = JSON.parse(localStorage.getItem('dshm-queue-v1') ?? 'null') } catch { saved = null } if (!Array.isArray(saved) || saved.length === 0) { queueRestoredRef.current = true return } if (data === null) return queueRestoredRef.current = true // Consumed only once the rows can actually be restored. Removing it // earlier lost the queue outright: this effect runs on the first render, // where `data === null` (the catalog has not arrived), and the run that // follows found the storage empty — so a REFRESH with a pending queue, the // case this feature exists for, threw it away. try { localStorage.removeItem('dshm-queue-v1') } catch { /* storage unavailable */ } /** * A queued row may only run while the world it was queued in still holds. * * It drains with no confirmation — that is what queueing is — so a row * that has gone stale is a destructive operation launched from an old * decision: queue an uninstall at 10:00, uninstall it by hand (or change * your mind), open the market at 15:00 and it runs. Each kind therefore * has to be true RIGHT NOW, and a row that no longer is gets REPORTED * rather than executed or silently dropped — the user queued it, so the * user is told what became of it. */ const judged = (saved as unknown[]).flatMap((entry): Array< { ok: true; row: { kind: OperationRecord['kind']; name: string; url?: string } } | { ok: false; kind: OperationRecord['kind']; name: string; url?: string; reason: string } > => { if (entry === null || typeof entry !== 'object') return [] const row = entry as Record const kindRaw = row.kind if (kindRaw !== 'install' && kindRaw !== 'update' && kindRaw !== 'uninstall') return [] if (typeof row.name !== 'string' || row.name === '') return [] if (row.url !== undefined && typeof row.url !== 'string') return [] const kind: OperationRecord['kind'] = kindRaw const name = row.name const url = typeof row.url === 'string' ? row.url : undefined const stale = (reason: string) => [{ ok: false as const, kind, name, url, reason }] const verdict = queuedRowApplies({ kind, name, ...(url === undefined ? {} : { url }) }, { installed, updates, plugins: data.plugins }) if (verdict !== null) return stale(verdict === 'gone' ? t('agentQueueStaleGone') : t('agentQueueStaleNoUpdate')) if (kind === 'install' && url === undefined) return [] return [{ ok: true as const, row: { kind, name, ...(url === undefined ? {} : { url }) } }] }) const valid = judged.flatMap(verdict => verdict.ok ? [verdict.row] : []) const stale = judged.flatMap(verdict => verdict.ok ? [] : [verdict]) if (valid.length === 0 && stale.length === 0) { return } setRecords(prev => { const kept = [...prev] for (const entry of valid) { const dup = kept.some(record => record.state === 'queued' && record.kind === entry.kind && record.name === entry.name && (entry.kind !== 'install' || record.url === entry.url)) if (dup) continue recordSeq.current += 1 kept.push({ id: `op-${String(recordSeq.current)}`, kind: entry.kind, name: entry.name, ...(entry.url === undefined ? {} : { url: entry.url }), state: 'queued', reason: t('agentBusyQueued'), }) } for (const row of stale) { recordSeq.current += 1 kept.push({ id: `op-${String(recordSeq.current)}`, kind: row.kind, name: row.name, ...(row.url === undefined ? {} : { url: row.url }), // Reported, not executed and not hidden: the row is the user's, and // "we did not do this, here is why" is the only honest end for it. state: 'failed', reason: row.reason, }) } return kept }) setOperationsOpen(true) }, [data, t]) useEffect(() => { // Never write before the restore has read. On the first render `records` // is empty, and this effect would then delete the stored queue that the // restore — which waits for the catalog — is about to load: every refresh // with a cold catalog silently lost the user's queued work. The restore is // this queue's only reader, so waiting for it is also what makes the // consume safe. if (!queueRestoredRef.current) return try { const queued = records .filter(record => record.state === 'queued') .map(record => ({ kind: record.kind, name: record.name, ...(record.url === undefined ? {} : { url: record.url }) })) if (queued.length === 0) localStorage.removeItem('dshm-queue-v1') else localStorage.setItem('dshm-queue-v1', JSON.stringify(queued)) } catch { /* storage unavailable */ } }, [records]) /** Raised by the card marker, so "查看详情" lands on the record itself. */ const [operationsOpen, setOperationsOpen] = useState(false) const openOperations = useCallback(() => setOperationsOpen(true), []) /** * Two plugins can ship under one name from different authors, so a roster * row that shows only the package name cannot tell the user which of their * plugins a swap would uninstall. Resolve through the catalog for the * author and avatar a card would show, and fall back to the bare name for * anything installed outside it. */ const describePlugin = useCallback((name: string) => { const entry = data?.plugins.find(plugin => plugin.npm === name || plugin.name === name) if (entry === undefined) return { title: name } return { title: pluginName(entry.name), author: entry.owner === '' ? undefined : entry.owner, avatar: , } }, [data]) /** Ids are sequential rather than random so a replayed session is stable. */ const nextRecordId = useCallback(() => { recordSeq.current += 1 return `op-${String(recordSeq.current)}` }, []) const [replacing, setReplacing] = useState(false) /** Shared by every screenshot source (card thumbnail, dialog strip). */ const [lightbox, setLightbox] = useState<{ shots: string[]; index: number } | null>(null) const openLightbox = (shots: string[], index: number): void => setLightbox({ shots, index }) const [themesFullscreen, setThemesFullscreen] = useState(false) const [busyUrl, setBusyUrl] = useState(null) /** Consecutive idle polls with a pending install that never landed (#32). */ const idleStrikes = useRef(0) /** Same idle-strike bookkeeping for an update whose response was lost. */ const updateIdleStrikes = useRef(0) const [doneUrls, setDoneUrls] = useState([]) const [installError, setInstallError] = useState(null) /** * The recovery surface, when a restart this page asked for did not come * back. Non-null means the origin answering /dsh-market/* is the recovery * server, not the host — see RecoveryPanel.tsx. */ const [recovery, setRecovery] = useState(null) const [recoveryOpen, setRecoveryOpen] = useState(false) const [recoveryKeep, setRecoveryKeep] = useState>({}) const [recoveryBusy, setRecoveryBusy] = useState(false) const [favoriteError, setFavoriteError] = useState(null) /** Ignores out-of-order /dsh-market/favorite responses after a newer toggle. */ const favoriteOpGen = useRef(0) const [clearingStale, setClearingStale] = useState(false) /** The notes payload the server answers with, verbatim (see /changelog). */ type NoteRelease = { tag: string | null; name: string | null; publishedAt: string | null; url: string | null; body: string } type NoteCommit = { sha: string; message: string; date: string | null } interface UpdateNotes { kind: 'release' | 'commits' | 'npm' | 'none' release?: NoteRelease commits?: { items: NoteCommit[]; found: boolean } npmTimes?: Array<{ version: string; date: string }> } type ResolvedNotes = | { kind: 'release'; release: NoteRelease } | { kind: 'commits'; commits: { items: NoteCommit[]; found: boolean } } | { kind: 'npm'; npmTimes: Array<{ version: string; date: string }> } | { kind: 'none' } interface CompatibilityNotice { code: 'soft-incompatible' risks: Array<{ plugin: string; peer: string; range: string; resolved: string; direction: string }> /** Cross-layer loader-name collisions this operation introduced (#230). */ shadowedNames?: Array<{ name: string; layers: string[]; count: number }> /** Client bundles that no longer parse after the operation (#222). */ brokenBundles?: Array<{ name: string; reason: string }> rollbackId?: string rollbackUnavailable?: string } const [compatibilityNotice, setCompatibilityNotice] = useState(null) const [rollingBack, setRollingBack] = useState(false) /** Log export lifecycle for visible feedback (#84): idle → busy → done/fail. */ const [exportState, setExportState] = useState<'idle' | 'busy' | 'done' | 'fail'>('idle') /** * Programmatic log download with explicit feedback (#84) — the plain * `` gave no sign anything happened, and the error banner's * "export the log" wording pointed at text that was not clickable at all. * Success/failure surface as a primitives Toast (body portal, no layout * impact) instead of inline text. */ const doExportLog = useCallback(() => { setExportState('busy') // The composing lives in self-check.ts: the error boundary offers this // same download, and a crashed market is exactly when the log matters. exportMarketLog().then(() => setExportState('done'), () => setExportState('fail')) }, []) /** Stable onDone for the export Toast — a fresh closure per render would * reset the Toast's auto-dismiss timer on every parent re-render. */ const exportToastDone = useCallback(() => setExportState('idle'), []) const favoriteErrorDone = useCallback(() => setFavoriteError(null), []) const [updates, setUpdates] = useState>({}) /** Update reminders dismissed for this host boot. The Installed tab still * shows these plugins and their update actions; only proactive prompts use * this set. */ const [ignoredUpdateNames, setIgnoredUpdateNames] = useState([]) const [updatingName, setUpdatingName] = useState(null) /** Update-notes dialog (#294): which row opened it, and what it resolved to. */ const [notesFor, setNotesFor] = useState<{ name: string; current: string | null; latest: string | null; repoUrl: string | null } | null>(null) const [updateNotes, setUpdateNotes] = useState(null) const [notesState, setNotesState] = useState<'loading' | 'ready' | 'fail'>('loading') // Plugin blocked by pnpm's fresh-release safety wait; arms the update-now button. const [staleName, setStaleName] = useState(null) // Local link:/file: restore — a modal asks before swapping to the catalog. const [restoreConfirm, setRestoreConfirm] = useState<{ name: string; entry: RegistryPlugin; verified: boolean } | null>(null) /** A release whose declared host requirement this host does not meet (#404). kind distinguishes the update dialog from the fresh-install dialog. */ const [hostIncompatible, setHostIncompatible] = useState< { kind: 'update' | 'install' name: string version: string requirement: string | null hostVersion: string | null /** The npm package the search runs on; null when the entry has no npm name. */ npmName: string | null /** Kept for the install path: the card this refusal came from. */ plugin: RegistryPlugin | null } | null >(null) /** * The answer to "the newest release is too new for this host — what CAN I * install?" (#581). The dialog asks as soon as it opens, because that * question is the only way out it can offer: the alternative the route * gives (install anyway) is a decision about breaking the host, and most * users reaching this dialog have no way to weigh it. */ const [findingCompat, setFindingCompat] = useState< { status: 'idle' | 'loading' | 'not-found' | 'error' } | { status: 'found'; version: string } >({ status: 'idle' }) const [compatRetry, setCompatRetry] = useState(0) // Ask the moment the dialog opens: "which version still works" is the one // answer that turns a refusal into a choice, and the route can only give it // for a plugin the catalog carries by npm name. useEffect(() => { if (hostIncompatible === null || hostIncompatible.npmName === null) { setFindingCompat({ status: 'idle' }) return } const controller = new AbortController() setFindingCompat({ status: 'loading' }) fetch(api('/dsh-market/find-compatible'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ npmName: hostIncompatible.npmName, upgradeOnly: hostIncompatible.kind === 'update' }), signal: controller.signal, }) .then(async response => { if (!response.ok) throw new Error(`HTTP ${response.status}`) return await response.json() as { compatibleVersion?: string | null } }) .then(data => { if (controller.signal.aborted) return setFindingCompat(typeof data.compatibleVersion === 'string' && data.compatibleVersion !== '' ? { status: 'found', version: data.compatibleVersion } : { status: 'not-found' }) }) .catch(() => { if (!controller.signal.aborted) setFindingCompat({ status: 'error' }) }) return () => controller.abort() }, [hostIncompatible, compatRetry]) const [restoreBlocked, setRestoreBlocked] = useState<{ name: string; reason: 'no-catalog' | 'repo-mismatch' } | null>(null) // Snapshot the source switch the user agreed to review; later renders must not change it under the dialog. const [migrationConfirm, setMigrationConfirm] = useState(null) /** Determinate percent parsed from pnpm's Progress line, when available. */ const [progressPct, setProgressPct] = useState(null) /** * Blocked build scripts from the last install or update: enables * approve-and-retry (#6; updates in #69). Exactly one of `plugin` * (retry installs it) / `updateName` (retry re-runs the update) is set. */ const [buildsSkipped, setBuildsSkipped] = useState<{ plugin?: RegistryPlugin; updateName?: string; names: string[]; restore?: boolean } | null>(null) const [updatingAll, setUpdatingAll] = useState(false) const [updatedNames, setUpdatedNames] = useState([]) // #558: session-tracked updates the host actually parked behind a restart. // Kept separate from `updatedNames`, which doubles as the row-level // "updated" marker and therefore also records client-only updates that go // live without a restart. const [restartNames, setRestartNames] = useState([]) const [hotUrls, setHotUrls] = useState([]) const [hotNames, setHotNames] = useState([]) const [progressLine, setProgressLine] = useState(null) /** Per-package activation states from /dsh-market/installed + operations. */ const [activations, setActivations] = useState>({}) /** #60: persisted disable list + custom groups, straight from /installed. */ const [disabledNames, setDisabledNames] = useState([]) /** The user's own note per plugin (#347): package name → text. */ const [notes, setNotes] = useState>({}) /** Catalog URLs bookmarked for later install (#414). */ const [favoriteUrls, setFavoriteUrls] = useState([]) /** Rows the user asked to show the AUTHOR's description on, despite a note. */ const [showTheirs, setShowTheirs] = useState([]) /** The row whose note is being edited, and the text in the box. */ const [notingName, setNotingName] = useState(null) const [noteDraft, setNoteDraft] = useState('') /** The disable set as of the first load; null until it arrives. */ const loadedDisabled = useRef | null>(null) /** * Patch-layer flags (port of dsh-plugin-hub): packages whose bundle rows * the user patch layer disables / force-enables. The UI treats them as the * real switch state so hand-edited cordis.patch.yml toggles are visible. */ const [patchDisabledNames, setPatchDisabledNames] = useState([]) /** Bundle packages DSH's own plugin page turned off by leaving them out of dsh.profile.bundles (#696). */ const [unbundledNames, setUnbundledNames] = useState([]) const [groups, setGroups] = useState>({}) const [groupOrder, setGroupOrder] = useState([]) /** Installed-tab sub-view: flat list or groups (All-plugins was removed — * it duplicated the Discover tab). */ const [installedView, setInstalledView] = useState<'list' | 'groups'>('list') const [togglingName, setTogglingName] = useState(null) // Group editor state (create / rename / delete / assign). const [creatingGroup, setCreatingGroup] = useState(false) const [newGroupName, setNewGroupName] = useState('') const [renamingGroup, setRenamingGroup] = useState(null) const [renamingValue, setRenamingValue] = useState('') const [deletingGroup, setDeletingGroup] = useState(null) /** Open add-members picker for this group name. Plugins only. */ const [addPanel, setAddPanel] = useState(null) const [addQuery, setAddQuery] = useState('') const [addSelected, setAddSelected] = useState([]) /** Group whose single theme slot is being chosen. */ const [themePanel, setThemePanel] = useState(null) const [themePick, setThemePick] = useState(null) const [groupMenuFor, setGroupMenuFor] = useState(null) const [collapsedGroups, setCollapsedGroups] = useState>(() => new Set()) const [assignFor, setAssignFor] = useState(null) /** Structured progress from pnpm ndjson (P1-6). */ const [progressPhase, setProgressPhase] = useState(null) const [progressCurrent, setProgressCurrent] = useState(null) const [progressDone, setProgressDone] = useState(0) const [cancelling, setCancelling] = useState(false) /** Server-side operation lock from /dsh-market/status (#91). */ const [hostBusy, setHostBusy] = useState(false) /** * The market's own version, shown beside the heading. Most bug reports * arrive as a photo of the screen, and without a version in frame the * first reply always has to ask which one it was. */ const [version, setVersion] = useState(null) /** * The catalog's own build date (#712). The version on a card is the * catalog's copy, not a live npm lookup, so a plugin published after the * last refresh shows the older number — the tooltip has to say so, or * "npm latest" beside a number that is not npm's latest is a bug report * waiting to be filed. Null until the catalog answers. */ const [catalogUpdated, setCatalogUpdated] = useState(null) const catalogVersionTip = useMemo( () => (catalogUpdated === null ? t('catalogVersionUndated') : t('catalogVersionDated').replace('{0}', catalogUpdated)), [catalogUpdated, t], ) /** Non-live activation results from the last operation, shown as a banner. */ const [activationWarnings, setActivationWarnings] = useState<{ name: string; info: ActivationInfo }[]>([]) const [hostDependencyFindings, setHostDependencyFindings] = useState([]) /** Plugin name awaiting uninstall confirmation (Modal). */ const [removeConfirm, setRemoveConfirm] = useState(null) const [removingName, setRemovingName] = useState(null) const [removedCount, setRemovedCount] = useState(0) /** Toggles whose live fiber did not follow the switch — restart to apply. */ const [toggleRestart, setToggleRestart] = useState(0) /** Last completed toggle, shown as a toast (#299). The switch and the row * tag already say the new state, but both live in a row the user may have * scrolled past — a mis-click there goes unnoticed. The toast is fixed on * screen, so it is the part that actually catches an accident. */ const [toggled, setToggled] = useState<{ name: string; enabled: boolean } | null>(null) const toggledDone = useCallback(() => setToggled(null), []) /** * Dismissal of the host-reported restart notice, keyed to the current boot * so it reappears after a restart that did not happen and after any new * change. sessionStorage, not local: closing the tab is a fresh start. */ const [restartNoticeDismissed, setRestartNoticeDismissed] = useState(false) /** Client-part plugins toggled this session — their UI needs a refresh. */ const [refreshNames, setRefreshNames] = useState([]) const [envReady, setEnvReady] = useState(true) /** * Packages the market stopped declaring because their build could no longer * compose (#663). They are gone from the installed list — this is the only * thing that can say why, so it is read from `/status` on every poll rather * than folded into a one-shot reply. */ const [brokenPlugins, setBrokenPlugins] = useState>({}) const brokenPluginNames = useMemo(() => Object.keys(brokenPlugins), [brokenPlugins]) const [envFixing, setEnvFixing] = useState(false) const [envFailed, setEnvFailed] = useState(false) const [bootId, setBootId] = useState(null) /** One-click restart (#14 by @ysyyhhh): server capability + in-flight state. */ const [restartEnabled, setRestartEnabled] = useState(false) /** Supervisor the host detected around itself, when it named one (#229). */ const [supervisor, setSupervisor] = useState(null) /** Debugger latch when one-click restart must not kill the host (#447). */ const [debuggerLatch, setDebuggerLatch] = useState(null) const [restarting, setRestarting] = useState(false) const [showTop, setShowTop] = useState(false) const [backupBusy, setBackupBusy] = useState(false) const [backupMessage, setBackupMessage] = useState(null) const [backupRestored, setBackupRestored] = useState(false) const [pendingBackup, setPendingBackup] = useState(null) const [pendingDependencies, setPendingDependencies] = useState({}) const [webdavUrl, setWebdavUrl] = useState(initialWebdav.url) const [webdavUser, setWebdavUser] = useState(initialWebdav.username) const [webdavPassword, setWebdavPassword] = useState(initialWebdav.password) const [autoBackup, setAutoBackup] = useState(initialWebdav.auto) /** GitHub token — session memory only, never written to any storage. */ const [gistToken, setGistToken] = useState('') /** Gist id — persisted across reloads (non-sensitive: the Gist itself is private). */ const [gistId, setGistId] = useState(() => { try { return localStorage.getItem('dshm-gist-id') ?? '' } catch { return '' } }) /** Export mode: 'update' PATCHes the Gist in the field, 'create' makes a new one. */ const [gistMode, setGistMode] = useState<'update' | 'create'>(() => { try { return localStorage.getItem('dshm-gist-id') ? 'update' : 'create' } catch { return 'create' } }) const [gistBusy, setGistBusy] = useState(false) const [gistMessage, setGistMessage] = useState(null) const [gistOk, setGistOk] = useState(false) const [gistResult, setGistResult] = useState(null) /** Export picker: open state, selected plugin names, include-config flag. */ const [exportOpen, setExportOpen] = useState(false) const [exportSelection, setExportSelection] = useState>(new Set()) const [exportIncludeConfig, setExportIncludeConfig] = useState(false) /** Export failure shown INSIDE the picker so it is never hidden behind it. */ const [exportError, setExportError] = useState(null) /** Bundle-only plugin names from /dsh-market/installed (picker list). */ const [installedBundles, setInstalledBundles] = useState([]) const bodyRef = useRef(null) /** Hidden file input behind the Import button (a Button can't host an ). */ const fileInputRef = useRef(null) const [sortField, setSortField] = useState('downloads') const [sortDir, setSortDir] = useState('desc') const [timeRange, setTimeRange] = useState('all') /** Undefined while the first catalog response is pending; null means the host cannot be located. */ const [hostVersion, setHostVersion] = useState(undefined) /** v1 is deliberately opt-in: undeclared/unknown entries stay visible even when enabled. */ const [compatibleWithHost, setCompatibleWithHost] = useState(false) const [hostCompatibility, setHostCompatibility] = useState({}) const [hostCompatibilityPending, setHostCompatibilityPending] = useState(0) /** Names already resolved or in flight; failed/unavailable names are released for an explicit retry. */ const requestedHostCompatibility = useRef(new Set()) const [catsOpen, setCatsOpen] = useState(false) /** Themes tab: independent from Discover's sort/time state above — a * search or sort choice in one tab has no business resetting the other. */ const [themeSortField, setThemeSortField] = useState('downloads') const [themeSortDir, setThemeSortDir] = useState('desc') const [themeTimeRange, setThemeTimeRange] = useState('all') const [favSortField, setFavSortField] = useState('downloads') const [favSortDir, setFavSortDir] = useState('desc') const [favTimeRange, setFavTimeRange] = useState('all') /** WebDAV provider-preset dropdown (primitives Menu). */ const [presetOpen, setPresetOpen] = useState(false) /** Install-command disclosure inside the confirm dialog. */ const [cmdOpen, setCmdOpen] = useState(false) const [capsOpen, setCapsOpen] = useState(false) /** Per-row "why is it not live" disclosure (installed tab). */ const [whyOpen, setWhyOpen] = useState(null) /** Restore-confirm dialog (replaces window.confirm). */ const [restoreConfirmOpen, setRestoreConfirmOpen] = useState(false) /** Plugins that failed to install during a restore (replaces window.alert). */ const [restoreErrors, setRestoreErrors] = useState([]) // How many category pills fit in the two collapsed rows (measured once — // the settings panel width is fixed); null = measuring render with all // pills clamped, then slice so the chevron flows inline after the last one. const [visibleCats, setVisibleCats] = useState(null) /** Same idea as `visibleCats`, but how many fit in a single row — used to * shrink an expanded (2+ row) category list while the sticky header is * pinned during scroll, distinct from the two-row collapsed default. */ const [visibleCatsOneRow, setVisibleCatsOneRow] = useState(null) const catsWrapRef = useRef(null) // Whether the sticky header is currently pinned to the top of the scroll // area. Tracked via a sentinel just above it rather than a scrollTop // threshold: the threshold would have to hard-code the header's offset // (padding, sticky `top`), which drifts silently whenever that CSS // changes. The sentinel just reports what's actually true on screen. const [catsStuck, setCatsStuck] = useState(false) /** While the sticky header is pinned, expansion is this flag — not * `catsOpen`. Becoming stuck collapses on the SAME render (stuckExpanded * starts false) instead of a follow-up `useLayoutEffect` that flipped * `catsOpen` and forced a second commit; that delayed height change is * what lined up with the host Settings dialog hitching after tab 收放. * An explicit chevron click while stuck sets this true and keeps * `catsOpen` in sync so unstuck restores the user's choice. */ const [stuckExpanded, setStuckExpanded] = useState(false) const [catsSentinel, setCatsSentinel] = useState(null) /** * Latest /status sample for the queue drain: `busy` gates pnpm execution, * `runningAgents` gates the agent-file guard. A ref (not state) because the * drain interval is mount-once and must never read a stale closure. */ const statusRef = useRef<{ busy: boolean; runningAgents: string[] }>({ busy: false, runningAgents: [] }) /** True while the drain is executing one queued request (no parallel starts). */ const drainingRef = useRef(false) const refreshInstalled = useCallback((force?: boolean) => { fetch(api('/dsh-market/installed'), { cache: 'no-store' }) .then(res => res.json()) .then(body => { setInstalled(body.installed || {}) setRepoIdentities(installedRepoIdentities(body.repoIdentities)) setRepoHints(installedRepoHints(body.repoHints)) setInstalledFiles(Array.isArray(body.present) ? body.present : Object.keys(body.installed || {})) setSkins(body.live || []) if (Array.isArray(body.disabled)) { setDisabledNames(body.disabled) // The switch positions this page was BUILT with. A toggle away from // them needs a refresh; a toggle back to them does not, and the // banner has to be able to say so (#340). if (loadedDisabled.current === null) loadedDisabled.current = new Set(body.disabled as string[]) } if (body.notes !== null && typeof body.notes === 'object' && !Array.isArray(body.notes)) { setNotes(body.notes as Record) } if (Array.isArray(body.patchDisabled)) setPatchDisabledNames(body.patchDisabled) if (Array.isArray(body.unbundled)) setUnbundledNames(body.unbundled) if (body.groups && typeof body.groups === 'object') setGroups(body.groups) if (Array.isArray(body.groupOrder)) setGroupOrder(body.groupOrder) if (Array.isArray(body.favorites)) setFavoriteUrls(body.favorites.filter((url: unknown): url is string => typeof url === 'string')) setInstalledBundles(Array.isArray(body.bundles) ? body.bundles.filter((name: unknown): name is string => typeof name === 'string') : []) if (body.activation && typeof body.activation === 'object') setActivations(body.activation) const findings = body.diagnostics?.schema === 'dsh-market/diagnostics/v1' && Array.isArray(body.diagnostics.findings) ? body.diagnostics.findings.filter(isHostDependencyFinding) : [] setBrokenPlugins(isRecordOfRecords(body.brokenPlugins) ? body.brokenPlugins : {}) setHostDependencyFindings(findings) }) .catch(() => {}) fetch(api('/dsh-market/updates') + (force === true ? '?force=1' : ''), { cache: 'no-store' }) .then(res => res.json()) .then(body => setUpdates(body.updates || {})) .catch(() => {}) }, []) /** Active Bundles count as installed in Discover without becoming package-manager targets. */ const catalogInstalled = useMemo( () => installedForCatalog(installed, installedBundles), [installed, installedBundles], ) /** Lookup set for the persisted disable list (#60). */ const disabledSet = useMemo(() => new Set(disabledNames), [disabledNames]) const favoriteUrlSet = useMemo(() => new Set(favoriteUrls), [favoriteUrls]) /** Effective switch state: market disable list ∪ user-patch-layer disables. */ const effectiveDisabledSet = useMemo( () => new Set([...disabledNames, ...patchDisabledNames, ...unbundledNames]), [disabledNames, patchDisabledNames, unbundledNames], ) useEffect(() => { if (tab !== 'themes' && themesFullscreen) setThemesFullscreen(false) }, [tab, themesFullscreen]) useEffect(() => { if (!themesFullscreen || lightbox !== null) return const onKey = (event: KeyboardEvent) => { if (event.key !== 'Escape') return event.stopPropagation() setThemesFullscreen(false) } window.addEventListener('keydown', onKey, true) return () => window.removeEventListener('keydown', onKey, true) }, [lightbox, themesFullscreen]) const loadCatalog = useCallback(() => { setLoadError(null) return fetch(api('/dsh-market/registry'), { cache: 'no-store' }) .then(async (res) => { const body = (await res.json().catch(() => ({}))) as { registry?: Registry hostVersion?: string | null error?: string } if (!res.ok) throw new Error(typeof body.error === 'string' ? body.error : `HTTP ${String(res.status)}`) return body }) .then((body) => { if (body.registry === undefined) throw new Error('the catalog response carried no data') if (body.registry.updated !== cachedRegistry?.updated) { resetScreenshotsCache() resetThemePreviewCache() } cachedRegistry = body.registry setData(body.registry) const catalogDate = body.registry.updated setCatalogUpdated(typeof catalogDate === 'string' && catalogDate.trim() !== '' ? catalogDate : null) setHostVersion(typeof body.hostVersion === 'string' ? body.hostVersion : null) setLoadError(null) }) // Report WHY. An unreachable catalog used to be answered with a // bundled copy, so "cannot reach the registry" and "the catalog is // smaller today" looked identical on screen — and the second reading // is the one users reached. .catch((error: unknown) => { setLoadError(error instanceof Error ? error.message : String(error)) }) }, []) const loadHostCompatibility = useCallback(async (names: readonly string[]): Promise => { const unique = [...new Set(names)].filter(name => { if (name === '' || requestedHostCompatibility.current.has(name)) return false requestedHostCompatibility.current.add(name) return true }) if (unique.length === 0) return setHostCompatibilityPending(count => count + unique.length) for (let offset = 0; offset < unique.length; offset += 64) { const chunk = unique.slice(offset, offset + 64) try { const response = await fetch(api('/dsh-market/discovery-compatibility'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ packages: chunk }), }) const body = await response.json() as { hostVersion?: string | null plugins?: Record } if (!response.ok || body.plugins === null || typeof body.plugins !== 'object') { throw new Error(`HTTP ${String(response.status)}`) } if (typeof body.hostVersion === 'string' || body.hostVersion === null) { setHostVersion(body.hostVersion) } const accepted: HostCompatibilityMap = {} for (const name of chunk) { const item = body.plugins[name] as HostCompatibility | null | undefined if (item === null || item === undefined || !['compatible', 'incompatible', 'unknown'].includes(item.status) || !['manifest', 'undeclared', 'unavailable'].includes(item.basis) || (item.requirement !== null && typeof item.requirement !== 'string') || !Array.isArray(item.declarations)) { requestedHostCompatibility.current.delete(name) accepted[name] = UNAVAILABLE_HOST_COMPATIBILITY continue } accepted[name] = item // A transient registry failure is shown as unknown, but remains // retryable when navigation or a filter toggle asks again later. if (item.basis === 'unavailable') requestedHostCompatibility.current.delete(name) } setHostCompatibility(current => ({ ...current, ...accepted })) } catch { for (const name of chunk) requestedHostCompatibility.current.delete(name) setHostCompatibility(current => ({ ...current, ...Object.fromEntries(chunk.map(name => [name, UNAVAILABLE_HOST_COMPATIBILITY])), })) } finally { setHostCompatibilityPending(count => Math.max(0, count - chunk.length)) } } }, []) useEffect(() => { void loadCatalog() fetch(api('/dsh-market/status'), { cache: 'no-store' }) .then(res => res.json()) .then(status => { statusRef.current = { busy: status.busy === true, runningAgents: Array.isArray(status.runningAgents) ? status.runningAgents.map(String) : [], } setEnvReady(status.pnpm !== false) // Applied before anything renders a github.com URL. The catalog this // page draws from is a larger request through the same server, so it // lands later; and if it ever did not, the status poll re-renders // within seconds and the images correct themselves. applyGithubRouting(status) if (typeof status.boot === 'string') { setBootId(status.boot) setIgnoredUpdateNames(ignoredUpdatesForBoot(status.boot)) // A dismissal only silences the notice for the boot it was made // in: if the user dismissed instead of restarting, the next boot // (or a stale dismissal from a previous one) shows it again. try { setRestartNoticeDismissed(sessionStorage.getItem('dshm-restart-dismissed') === status.boot) } catch { /* storage unavailable */ } } setRestartEnabled(status.restart === true) setSupervisor(typeof status.supervisor === 'string' ? status.supervisor : null) setDebuggerLatch(typeof status.debugger === 'string' ? status.debugger : null) if (typeof status.version === 'string' && status.version !== '') setVersion(status.version) }) .catch(() => {}) refreshInstalled() }, [refreshInstalled, loadCatalog]) // Pending-restart flags survive tab switches and page reloads, scoped to // one host process: a different boot id means the restart happened and the // stale banner must not resurrect. useEffect(() => { if (bootId === null) return const saved = readSession('dshm-restart') if (saved === null) return if (saved.boot !== bootId) { sessionStorage.removeItem('dshm-restart') return } if (Array.isArray(saved.doneUrls) && saved.doneUrls.length > 0) setDoneUrls(saved.doneUrls) if (Array.isArray(saved.updated) && saved.updated.length > 0) setUpdatedNames(saved.updated) if (Array.isArray(saved.restartNames) && saved.restartNames.length > 0) setRestartNames(saved.restartNames) if (typeof saved.removed === 'number' && saved.removed > 0) setRemovedCount(saved.removed) if (typeof saved.toggled === 'number' && saved.toggled > 0) setToggleRestart(saved.toggled) }, [bootId]) useEffect(() => { if (bootId === null) return if (doneUrls.length === 0 && updatedNames.length === 0 && restartNames.length === 0 && removedCount === 0 && toggleRestart === 0) { // Nothing pending: drop any stale entry (e.g. a hot mount cleared the // only doneUrl) so a same-boot remount cannot resurrect the banner (#73). sessionStorage.removeItem('dshm-restart') return } sessionStorage.setItem('dshm-restart', JSON.stringify({ boot: bootId, doneUrls, updated: updatedNames, restartNames, removed: removedCount, toggled: toggleRestart, })) }, [bootId, doneUrls, updatedNames, restartNames, removedCount, toggleRestart]) const fixEnv = useCallback(() => { setEnvFixing(true) setEnvFailed(false) fetch(api('/dsh-market/setup-pnpm'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }) .then(res => res.json()) .then(body => { if (body.ok) { setEnvReady(true) } else { setEnvFailed(true) if (typeof body.error === 'string') setInstallError(body.error) } }) .catch(() => setEnvFailed(true)) .finally(() => setEnvFixing(false)) }, []) // Recover an install whose HTTP response was lost (page navigated away or // the connection dropped): the pending marker survives in sessionStorage and // the poll below converges the button state from the host's ground truth. useEffect(() => { const pending = readSession('dshm-pending') if (pending !== null && typeof pending.url === 'string') { setBusyUrl(pending.url) recoveredInstall.current = { id: `recovered-install:${pending.url}`, url: pending.url, ...(typeof pending.name === 'string' && pending.name !== '' ? { name: pending.name } : {}), } } // Same recovery for an update in flight: closing the config page unmounts // this section and drops `updatingName` with it, so the running row's // progress vanished on reopen. The marker restores the row and the poll // below converges it from the host's ground truth. const updating = readSession('dshm-updating') if (updating !== null && typeof updating.name === 'string' && updating.name !== '') { setUpdatingName(updating.name) const id = `recovered-update:${updating.name}` recoveredUpdateRecordId.current = id setRecords(list => list.some(record => record.kind === 'update' && record.name === updating.name && record.state === 'running') ? list : enqueue(list, { id, kind: 'update', name: updating.name, state: 'running' })) } }, []) // New markers carry the name and recover immediately. Older markers only // carried the URL, so wait for the catalog and resolve the same task from it. useEffect(() => { const recovered = recoveredInstall.current if (recovered === null) return const name = recovered.name ?? data?.plugins.find(plugin => plugin.url === recovered.url)?.name if (name === undefined) return recovered.name = name setRecords(list => list.some(record => record.id === recovered.id) ? list : enqueue(list, { id: recovered.id, kind: 'install', name, url: recovered.url, state: 'running', })) }, [data]) useEffect(() => { if (busyUrl === null && updatingName === null) { // `hostBusy` is sampled by the progress poll. A normal update response // can settle the local operation before the next poll observes the // route lock released, leaving the restart button disabled until this // section remounts (#440). With no tracked install/update left, discard // that stale sample; the guarded restart route still handles the small // post-response lock-release window with its existing 409 retry. setHostBusy(false) setProgressLine(null) setProgressPhase(null) setProgressCurrent(null) setProgressDone(0) setCancelling(false) return } const timer = setInterval(() => { fetch(api('/dsh-market/status'), { cache: 'no-store' }) .then(res => res.json()) .then(status => { statusRef.current = { busy: status.busy === true, runningAgents: Array.isArray(status.runningAgents) ? status.runningAgents.map(String) : [], } setHostBusy(status.busy === true) setDebuggerLatch(typeof status.debugger === 'string' ? status.debugger : null) if (status.active) { setCancelling(status.cancelling === true) if (status.phase !== null && status.phase !== undefined) { // Structured pnpm progress: stage + current package + count. setProgressPhase(status.phase) setProgressCurrent(status.currentPackage ?? null) setProgressDone(status.done ?? 0) setProgressLine(null) if (typeof status.size === 'number' && status.size > 0 && typeof status.downloaded === 'number') { setProgressPct(Math.max(4, Math.min(96, Math.round(status.downloaded / status.size * 100)))) } } else { setProgressLine((status.lastLine || '…') + ' (' + status.seconds + 's)') setProgressPhase(null) setProgressCurrent(null) setProgressDone(0) const m = /resolved (\d+), reused (\d+), downloaded (\d+), added (\d+)/.exec(status.lastLine || '') if (m !== null && Number(m[1]) > 0) { const done = Number(m[2]) + Number(m[3]) + Number(m[4]) setProgressPct(Math.max(4, Math.min(96, Math.round(done / Number(m[1]) * 100)))) } } } else { setProgressLine(null) setProgressPct(null) setProgressPhase(null) setProgressCurrent(null) setProgressDone(0) setCancelling(false) const statusInstalled = installedMap(status.installed) if (!sameInstalledMap(installed, statusInstalled)) refreshInstalled() const pending = readSession('dshm-pending') // status.busy (#91): pnpm exited but the install route still // holds the operation lock (validation, hot-mount). Neither // declare the install done nor count an idle strike yet — a // premature banner here invited a restart click into a 409. if (pending !== null && busyUrl !== null && status.busy !== true) { const nowInstalled = data !== null && data.plugins.some(p => p.url === busyUrl && isInstalled(p, statusInstalled, repoIdentities, data.plugins, repoHints)) if (nowInstalled) { idleStrikes.current = 0 sessionStorage.removeItem('dshm-pending') const recovered = recoveredInstall.current if (recovered !== null) { setRecords(list => drop(list, recovered.id)) recoveredInstall.current = null } setDoneUrls(urls => urls.includes(busyUrl) ? urls : urls.concat(busyUrl)) setBusyUrl(null) } else if (++idleStrikes.current >= 2) { // Host is idle and the plugin never landed: the install died // (e.g. exit 127) with its response lost. Without this the // button says "installing" forever — across reloads (#32). idleStrikes.current = 0 sessionStorage.removeItem('dshm-pending') const recovered = recoveredInstall.current if (recovered !== null) { setRecords(list => drop(list, recovered.id)) recoveredInstall.current = null } setBusyUrl(null) setInstallError(t('installFail') + ' — ' + t('exportLog')) } } // An update whose response was lost — the page was closed mid-run // and reopened via the dshm-updating marker — converges the same // way. Once the host reports the operation fully settled (pnpm // exited AND the mutation lock released), hand the running row // back to the refreshed listing instead of showing "updating" // forever. Two idle polls guard the brief window before the host // has actually started the command. if (updatingName !== null && status.busy !== true) { if (++updateIdleStrikes.current >= 2) { updateIdleStrikes.current = 0 sessionStorage.removeItem('dshm-updating') const recoveredId = recoveredUpdateRecordId.current if (recoveredId !== null) { setRecords(list => drop(list, recoveredId)) recoveredUpdateRecordId.current = null } setUpdatingName(null) refreshInstalled() } } else { updateIdleStrikes.current = 0 } } }) .catch(() => {}) }, 2000) return () => clearInterval(timer) }, [busyUrl, updatingName, data, installed, repoIdentities, repoHints, refreshInstalled]) const scrollToTop = () => { const el = bodyRef.current if (el) { // jsdom (tests) lacks Element.scrollTo — fall back to the assignment. if (typeof el.scrollTo === 'function') el.scrollTo({ top: 0, behavior: 'smooth' }) else el.scrollTop = 0 } } // The .body scroller is shared across top tabs AND in-tab list replacements // (Discover/Themes category, search, sort; Installed search and list/groups). // Leaving scrollTop in place opens the next list mid-page — or, when it is // shorter, at its clamped bottom. Instant (not the smooth scrollToTop used // for pagination) so the jump happens before paint. useLayoutEffect(() => { const el = bodyRef.current if (el !== null) el.scrollTop = 0 setShowTop(false) }, [tab, q, cat, sortField, sortDir, timeRange, compatibleWithHost, qThemes, themeSortField, themeSortDir, themeTimeRange, qFavorites, favSortField, favSortDir, favTimeRange, qInstalled, installedView]) const plugins = useMemo( () => (data === null ? [] : visiblePlugins(data.plugins, { category: cat, query: q, lang, categories: data.categories, sort: `${sortField}-${sortDir}`, sinceDays: timeRange === 'all' ? undefined : TIME_RANGE_DAYS[timeRange], hostCompatibility, compatibleWithHost, })), [data, q, cat, lang, sortField, sortDir, timeRange, hostCompatibility, compatibleWithHost]) const { currentPage, totalPages, pageSize, goToPage, changePageSize } = usePagination(plugins.length, [q, cat, sortField, sortDir, timeRange, compatibleWithHost], scrollToTop) const pagePlugins = plugins.slice((currentPage - 1) * pageSize, currentPage * pageSize) const pageHostPackages = [...new Set(pagePlugins.flatMap(plugin => typeof plugin.npm === 'string' && plugin.npm !== '' ? [plugin.npm] : []))] const allHostPackages = useMemo( () => [...new Set((data?.plugins ?? []).flatMap(plugin => typeof plugin.npm === 'string' && plugin.npm !== '' ? [plugin.npm] : []))], [data], ) const pageHostPackagesKey = pageHostPackages.join('\u0000') const allHostPackagesKey = allHostPackages.join('\u0000') useEffect(() => { if (tab === 'discover') void loadHostCompatibility(pageHostPackages) // The key is the stable identity of this page's npm package set; the // array itself is recreated as compatibility results arrive. // eslint-disable-next-line react-hooks/exhaustive-deps }, [tab, pageHostPackagesKey, loadHostCompatibility]) useEffect(() => { if (compatibleWithHost) void loadHostCompatibility(allHostPackages) // eslint-disable-next-line react-hooks/exhaustive-deps }, [compatibleWithHost, allHostPackagesKey, loadHostCompatibility]) const loadedHostPackages = allHostPackages.reduce( (count, name) => count + (hostCompatibility[name] === undefined ? 0 : 1), 0, ) const themePlugins = useMemo( () => (data === null ? [] : visiblePlugins(data.plugins, { category: 'theme', query: qThemes, lang, categories: data.categories, sort: `${themeSortField}-${themeSortDir}`, sinceDays: themeTimeRange === 'all' ? undefined : TIME_RANGE_DAYS[themeTimeRange], })), [data, qThemes, lang, themeSortField, themeSortDir, themeTimeRange]) const themePagination = usePagination( themePlugins.length, [qThemes, themeSortField, themeSortDir, themeTimeRange], scrollToTop) const themePagePlugins = themePlugins.slice( (themePagination.currentPage - 1) * themePagination.pageSize, themePagination.currentPage * themePagination.pageSize) const favoriteListed = useMemo( () => (data === null ? [] : pluginsForFavorites(data.plugins, favoriteUrlSet, { query: qFavorites, lang, categories: data.categories, sort: `${favSortField}-${favSortDir}`, sinceDays: favTimeRange === 'all' ? undefined : TIME_RANGE_DAYS[favTimeRange], })), [data, qFavorites, lang, favoriteUrlSet, favSortField, favSortDir, favTimeRange]) const favoritePlugins = useMemo( () => favoriteListed.filter(p => !pluginCategories(p).includes('theme')), [favoriteListed]) const favoriteThemes = useMemo( () => favoriteListed.filter(p => pluginCategories(p).includes('theme')), [favoriteListed]) const favResetDeps = [qFavorites, favSortField, favSortDir, favTimeRange] as const const favoritePluginPagination = usePagination( favoritePlugins.length, favResetDeps, scrollToTop) const favoriteThemePagination = usePagination( favoriteThemes.length, favResetDeps, scrollToTop) const favoritePagePlugins = favoritePlugins.slice( (favoritePluginPagination.currentPage - 1) * favoritePluginPagination.pageSize, favoritePluginPagination.currentPage * favoritePluginPagination.pageSize) const favoritePageThemes = favoriteThemes.slice( (favoriteThemePagination.currentPage - 1) * favoriteThemePagination.pageSize, favoriteThemePagination.currentPage * favoriteThemePagination.pageSize) // Favorites reuse pluginCard (and its host-requirement badge). Discover // already loads compatibility for the current page; without the same // fetch here the badge stays on "Reading host requirement…" forever. const favoritePageHostPackages = [...new Set(favoritePagePlugins.flatMap(plugin => typeof plugin.npm === 'string' && plugin.npm !== '' ? [plugin.npm] : []))] const favoritePageHostPackagesKey = favoritePageHostPackages.join('\u0000') useEffect(() => { if (tab === 'favorites') void loadHostCompatibility(favoritePageHostPackages) // eslint-disable-next-line react-hooks/exhaustive-deps }, [tab, favoritePageHostPackagesKey, loadHostCompatibility]) const favoriteStale = useMemo( () => (data === null ? [] : staleFavoriteUrls(favoriteUrls, data.plugins)), [data, favoriteUrls]) const favoritesAllStale = favoriteUrls.length > 0 && favoriteStale.length === favoriteUrls.length && qFavorites.trim() === '' /** Tab badge counts catalog-visible bookmarks once the registry is loaded. */ const favoriteTabCount = data === null ? favoriteUrls.length : favoriteListed.length /** Download a host endpoint as a file — primitives Button can't be an . * Prefers the server's Content-Disposition filename (e.g. the timestamped * backup export) and falls back to the caller's name. */ const downloadFile = useCallback((url: string, filename: string) => { fetch(url) .then(res => { if (!res.ok) throw new Error('HTTP ' + res.status) const disposition = res.headers.get('content-disposition') if (disposition !== null) { const match = /filename="?([^";]+)"?/.exec(disposition) if (match !== null && match[1] !== undefined && match[1] !== '') filename = match[1] } return res.blob() }) .then(blob => { const a = document.createElement('a') a.href = URL.createObjectURL(blob) a.download = filename a.click() setTimeout(() => URL.revokeObjectURL(a.href), 2000) }) .catch(error => setInstallError(String(error))) }, []) const doRollback = useCallback((rollbackId: string) => { setRollingBack(true) setInstallError(null) fetch(api('/dsh-market/rollback'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ rollbackId }), }) .then(res => res.json().then(body => ({ status: res.status, body }))) .then(({ status, body }) => { if (status === 200 && body.ok) { setCompatibilityNotice(null) refreshInstalled() } else { setInstallError(String(body.error || body.detail || 'rollback failed')) } }) .catch(error => setInstallError(String(error))) .finally(() => setRollingBack(false)) }, [refreshInstalled]) const compatibilitySummary = (risks: CompatibilityNotice['risks']): string => { if (risks.length === 0) return '' const first = risks[0] return `${first.plugin}: ${first.peer} ${first.range} vs ${first.resolved}` } /** Which name now resolves from two layers, and which layers those are. */ const shadowSummary = (entries: NonNullable): string => { if (entries.length === 0) return '' const first = entries[0] const rest = entries.length > 1 ? ` (+${entries.length - 1})` : '' return `${first.name} — ${first.layers.join(' / ')}${rest}` } const doInstall = useCallback((plugin: RegistryPlugin, force = false, version?: string) => { setBuildsSkipped(null) setConfirming(null) setInstallError(null) setActivationWarnings([]) setBusyUrl(plugin.url) // One record per attempt. A retry appends rather than reusing the old // one, so the card resolves to the newest and its Install button returns. const recordId = nextRecordId() setRecords(list => enqueue(list, { id: recordId, kind: 'install', name: plugin.name, url: plugin.url, state: 'running', })) sessionStorage.setItem('dshm-pending', JSON.stringify({ url: plugin.url, name: plugin.name })) fetch(api('/dsh-market/install'), { method: 'POST', headers: { 'content-type': 'application/json' }, // `version` when the refusal dialog found a release this host supports: // the route pins THAT release instead of resolving the newest one — // which is the release that was just refused (#581). body: JSON.stringify({ url: plugin.url, ...(force ? { force: true } : {}), ...(version !== undefined ? { version } : {}) }), }) .then(res => res.json().then(body => ({ status: res.status, body }))) .then(({ status, body }) => { setBusyUrl(null) sessionStorage.removeItem('dshm-pending') if (status === 200 && body.ok && body.hot && pluginCategories(plugin).includes('theme')) { // Themes auto-activate on install; reload straight into the Themes // tab so the new look is on screen immediately. sessionStorage.setItem('dshm-toast', JSON.stringify([plugin.name])) sessionStorage.setItem('dshm-tab', 'themes') location.reload() return } if (body.cancelled === true) { // User-cancelled: quiet reset, nothing to report. setRecords(list => drop(list, recordId)) refreshInstalled() if (body.partial === true) setInstallError(t('partialNote')) return } if (status === 200 && body.ok) { sessionStorage.setItem('dshm-tab', 'installed') if (body.activation && typeof body.activation === 'object') { setActivations(prev => ({ ...prev, ...body.activation })) const warns = Object.entries(body.activation as Record) .filter(([, info]) => info.state !== 'live' && info.state !== 'missing') .map(([name, info]) => ({ name, info })) setActivationWarnings(warns) } if (body.hot) { // The status-poll recovery path may have already counted this URL // as pending-restart before the install response confirmed a hot // mount; a hot plugin must not stay in doneUrls (#73). setDoneUrls(urls => urls.filter(url => url !== plugin.url)) setHotUrls(urls => urls.includes(plugin.url) ? urls : urls.concat(plugin.url)) setHotNames(names => names.includes(plugin.name) ? names : names.concat(plugin.name)) } else { setDoneUrls(urls => urls.includes(plugin.url) ? urls : urls.concat(plugin.url)) } if (body.compatibility?.code === 'soft-incompatible') { setCompatibilityNotice(body.compatibility as CompatibilityNotice) } // `warned` keeps the ✓: the plugin IS installed, so calling a // compatibility risk a failure would misreport what happened. setRecords(list => patchRecord(list, recordId, body.heldRelease !== undefined // The plugin is installed and works; the profile's own // minimumReleaseAge is why it is not the newest one. A `warned` // keeps the ✓ that the install earned, and the record carries the // version the hold refused so the row can offer it (#635). ? { state: 'warned', reason: t('heldReleaseNotice') .replace('{0}', String(body.heldRelease.latest)) .replace('{1}', String(body.heldRelease.installed ?? '')), heldRelease: body.heldRelease, } : body.compatibility?.code === 'soft-incompatible' ? { state: 'warned', reason: t('compatRiskBanner') } : { state: 'done', needsRefresh: body.hot !== true })) refreshInstalled() } else { if (status === 409) { if (body.agentsBusy === true) { // Agents-busy is a queue, not a failure: keep the record as // `queued` so the drain below runs it when agents go idle. // The host changed nothing (it refuses before touching pnpm), // so there is nothing to roll back and nothing to decide. setRecords(list => patchRecord(list, recordId, { state: 'queued', reason: t('agentBusyQueued') })) setOperationsOpen(true) return } setRecords(list => patchRecord(list, recordId, { state: 'failed', reason: t('busyWait') })) setOperationsOpen(true) return } // A clash is not a failure to report and forget: the host already // reverted it, so what remains is a decision. `input` keeps the // record in the panel until the user answers it. if (Array.isArray(body.conflictGroups) && body.conflictGroups.length > 0) { setRecords(list => patchRecord(list, recordId, { state: 'input', conflicts: body.conflictGroups as ConflictNotice['groups'], })) // Raise the panel for anything that needs an answer. A red dot on // a closed panel is not a report; out of sight is out of mind. setOperationsOpen(true) return } // A host-compatibility refusal is not a failure to report and // forget: the host already stopped it, so what remains is a // decision with two facts on the table and a way past (#404, // extended to fresh installs). `input` keeps the record in the // panel until the user answers it. if (body.hostIncompatible && typeof body.hostIncompatible === 'object') { const notice = body.hostIncompatible as { name?: unknown; version?: unknown; requirement?: unknown; hostVersion?: unknown; npmName?: unknown } setRecords(list => drop(list, recordId)) setHostIncompatible({ kind: 'install', name: String(notice.name ?? plugin.name), version: String(notice.version ?? ''), requirement: typeof notice.requirement === 'string' ? notice.requirement : null, hostVersion: typeof notice.hostVersion === 'string' ? notice.hostVersion : null, npmName: typeof notice.npmName === 'string' ? notice.npmName : (typeof plugin.npm === 'string' ? plugin.npm : null), plugin, }) return } const blocked = Array.isArray(body.ignoredBuilds) ? body.ignoredBuilds.map(String) : [] if (blocked.length > 0) setBuildsSkipped({ plugin, names: blocked }) const text = (v: unknown) => typeof v === 'string' ? v : (v && typeof (v as any).text === 'string') ? (v as any).text : v == null ? '' : JSON.stringify(v) const orphans = Array.isArray(body.orphanBundles) ? body.orphanBundles.map(String) : [] const failure = text(body.error) || humanOutput([text(body.stderr), text(body.stdout)].filter(Boolean).join('\n')) || ('exit ' + body.exitCode) // The profile will not boot as it stands (#339). Said FIRST, because // it outranks whatever else went wrong: a plugin that failed to // install is recoverable, a profile that cannot start is not — and // the user would otherwise meet it as a Node stack trace after the // next restart, with nothing linking it to this operation. // A stale catalog entry (#346) is said before pnpm's own wording, // which for that failure reads like the user broke something. const staleEntry = typeof body.staleEntry === 'string' ? body.staleEntry : null const detail = [ orphans.length > 0 ? `${t('orphanBundle')} ${orphans.join(', ')}` : null, staleEntry, failure, ].filter(Boolean).join('\n') // Carry the blocked names onto the record too: the panel is where // this failure is read, so it is where the one-click way out has to // be (#314). The reason stays bilingual; the panel localizes it. setRecords(list => patchRecord(list, recordId, { state: 'failed', reason: detail.trim().slice(-600), ...(blocked.length > 0 ? { blockedBuilds: blocked } : {}), })) setOperationsOpen(true) } }) .catch(() => { // #100: a long install can outlive its HTTP response (loopback // stacks and proxies reset idle connections) while pnpm keeps // working server-side — declaring failure here produced a false // "install failed, export the log" with an EMPTY log (the route // only logs when it finishes), followed by the plugin quietly // appearing minutes later. Keep dshm-pending and the busy button // instead, and let the status poll decide: its recovery path marks // success once the plugin lands (busy-aware since #91) and strikes // out genuinely dead installs (#32). }) }, [nextRecordId, refreshInstalled, t]) /** * Resolve a loader-id clash the only way one profile allows: uninstall the * plugins holding the ids, then retry the install. Sequential because each * route takes the host's mutation lock, so a parallel burst would 409. * * A failure part-way leaves plugins already gone. Nothing reinstalls them * automatically (a rollback would itself be an install that can fail), so * the message names them — reporting only "failed" would leave the user * guessing which of their plugins survived. */ const doReplace = useCallback(async (record: OperationRecord, plugin: RegistryPlugin) => { setInstallError(null) setReplacing(true) const removed: string[] = [] try { for (const group of record.conflicts ?? []) { const response = await fetch(api('/dsh-market/uninstall'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: group.owner }), }) const body = await response.json() as { ok?: boolean; error?: unknown; stderr?: unknown } if (response.status !== 200 || body.ok !== true) { const text = (v: unknown) => typeof v === 'string' ? v : v == null ? '' : JSON.stringify(v) const detail = (text(body.error) || humanOutput(text(body.stderr)) || 'error').trim().slice(-400) const reason = removed.length === 0 ? `${t('installFail')}: ${group.owner} — ${detail}` : `${t('conflictReplaceFailed')} ${removed.join(', ')} — ${detail}` setRecords(list => patchRecord(list, record.id, { state: 'failed', conflicts: undefined, reason })) setOperationsOpen(true) refreshInstalled() return } removed.push(group.owner) } } finally { setReplacing(false) } // The clash record is done with; the retry opens its own, so the card // resolves to the new attempt rather than the answered decision. setRecords(list => drop(list, record.id)) refreshInstalled() doInstall(plugin) }, [doInstall, refreshInstalled, t]) /** * Answer a clash. `keep` is not a no-op to skip: it is the user declining * the install, so the record retires rather than lingering as unanswered. */ const resolveConflict = useCallback((record: OperationRecord, choice: 'keep' | 'swap') => { if (choice === 'keep') { setRecords(list => patchRecord(list, record.id, { state: 'failed', conflicts: undefined, reason: t('conflictDeclined'), })) return } const plugin = data?.plugins.find(candidate => candidate.url === record.url) if (plugin === undefined) return void doReplace(record, plugin) }, [data, doReplace, t]) /** * Turn the recovery surface into the failure prompt. * * The banner keeps the host's own words for what happened (the parsed boot * failure), and the new option sits beside the other actions — the point of * the exercise is that a dead end now has a way out, not that the failure * is explained differently. */ const enterRecovery = useCallback((view: RecoveryView) => { setRecovery(view) // The payload decides where the switches start (off for a blamed plugin); // this copies it rather than re-deriving it — see initialKeep. setRecoveryKeep(initialKeep(view)) setRecoveryBusy(false) setRestarting(false) setInstallError(t('recoveryBanner') + (view.failure.summary || t('recoveryNoSummary'))) }, [t]) /** * Restart the host and reload once the boot id changes (#14 by @ysyyhhh). * The 202 races the process's SIGTERM, so network errors on the initial * request are expected and treated as "restart under way". * * A boot that never happens is now a first-class outcome rather than only a * timeout: the market's restart helper starts the recovery surface on this * same origin, so the poll below is how the tab that asked for the restart * finds out WHICH plugin stopped the boot and gets to switch it off. */ const doRestart = useCallback(() => { // While the recovery surface is the thing answering this origin there is // no host to restart: the failure banner's "adjust plugins" is the action. if (bootId === null || restarting || recovery !== null) return const previousBoot = bootId setRestarting(true) setInstallError(null) setRecovery(null) const awaitNewBoot = () => { const deadline = Date.now() + 60000 const poll = () => { fetch(api('/dsh-market/status'), { cache: 'no-store' }) .then(res => res.json()) .then((next) => { if (next.recovery === true) { void fetchRecovery().then((view) => { if (view !== null) enterRecovery(view) }) return } if (typeof next.boot === 'string' && next.boot !== previousBoot) { location.reload() return } retry() }) .catch(retry) } const retry = () => { if (Date.now() > deadline) { // Last look before giving up on the clock: a failure that took the // helper's whole window to become a verdict lands right here. void fetchRecovery().then((view) => { if (view !== null) { enterRecovery(view) return } setRestarting(false) setInstallError(t('recoveryTimeout')) }) return } setTimeout(poll, 1500) } poll() } const requestRestart = (attemptsLeft: number) => { fetch(api('/dsh-market/restart'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }) .then(res => res.json().then(body => ({ status: res.status, body }))) .then(({ status, body }) => { if (status === 202 && body.ok === true) { awaitNewBoot() return } // 409 = the install route still holds the operation lock for its // post-processing (#91) — a short quiet retry beats surfacing // "cannot restart while a plugin operation is running" to a user // who just followed our own banner. if (status === 409 && attemptsLeft > 0) { setTimeout(() => requestRestart(attemptsLeft - 1), 1500) return } setRestarting(false) setInstallError(t('restartFail') + ': ' + localizeBilingual(String(body.error || ('HTTP ' + String(status))), lang)) }) .catch(awaitNewBoot) // the host may die mid-response; keep polling } requestRestart(10) }, [bootId, restarting, recovery, t, lang, enterRecovery]) /** * Write the chosen enable set through the recovery surface and wait for the * next boot. The write goes to the profile's patch layer — the same durable * mechanism the live toggles use — so the choice is what the loader applies * on every later start, not just this one. */ const applyRecoveryChoice = useCallback(() => { if (recovery === null || recoveryBusy) return setRecoveryBusy(true) setInstallError(null) const enabled = Object.entries(recoveryKeep).filter(([, on]) => on).map(([name]) => name) void applyRecovery(enabled).then((result) => { if (!result.ok) { setRecoveryBusy(false) setInstallError(t('recoveryApplyFailed') + (result.error ?? '')) return } // The surface releases the port for the boot attempt, so a failure to // reach it from here on is expected — watchRestart treats that as // "still starting" rather than as an error. setRecoveryOpen(false) setRestarting(true) void watchRestart(recovery.bootId, { onBoot: () => { location.reload() }, onRecovery: (view) => { enterRecovery(view); setRecoveryOpen(true) }, onTimeout: () => { setRecoveryBusy(false) setRestarting(false) setInstallError(t('recoveryTimeout')) }, }) }) }, [recovery, recoveryBusy, recoveryKeep, t, enterRecovery]) /** Cancel the running plugin command (#6 by @qichuang321). */ const doCancel = useCallback(() => { fetch(api('/dsh-market/cancel'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }) .catch(() => {}) }, []) const doUpdate = useCallback((name: string, force = false, restore = false, compatVersion?: string) => { setInstallError(null) setActivationWarnings([]) // Only THIS row's stale marker is cleared. "Update all" walks the list // calling straight into here, so an unconditional reset meant every // earlier release-age failure lost its retry button and only the last // one kept it — the rest failed silently with no way forward (#255). setStaleName(prev => (prev === name ? null : prev)) setRestoreConfirm(prev => (prev?.name === name ? null : prev)) setMigrationConfirm(null) setUpdatingName(name) updateIdleStrikes.current = 0 // Mirror the install flow's dshm-pending marker: closing the config page // unmounts this section and drops `updatingName`, so the running row's // progress was lost on reopen. The marker survives the unmount and lets a // reopen restore the row while the status poll converges the outcome. sessionStorage.setItem('dshm-updating', JSON.stringify({ name })) // The Tasks panel exists to answer "what is running right now", and an // update is one of the things that runs. `OperationKind` has carried // 'update' since the panel was written; only the enqueue was missing, so // "update all" left the panel empty while several plugins were mid-flight // (#295 by @sanyecao88). One record per attempt, like the install flow. const updateRecordId = nextRecordId() setRecords(list => enqueue(list, { id: updateRecordId, kind: 'update', name, state: 'running' })) return fetch(api('/dsh-market/update'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name, ...(force ? { force: true } : {}), ...(restore ? { restore: true } : {}), ...(compatVersion !== undefined ? { compatVersion } : {}) }), }) .then(res => res.json().then(body => ({ status: res.status, body }))) .then(({ status, body }) => { // A response means the host settled the request (even a 4xx/5xx), so // the running row can hand back now. Only a lost response keeps the // marker + row for the poll to converge. sessionStorage.removeItem('dshm-updating') setUpdatingName(null) if (body.cancelled === true) { setRecords(list => drop(list, updateRecordId)) refreshInstalled() if (body.partial === true) setInstallError(t('partialNote')) return } // The plugin is already on the version this update would install // (#495 by @Ztyss) — the list this row came from was taken before an // earlier round of the same batch moved it. Not a failure and not a // change: the row goes away, the list is re-read so the rest of it is // trustworthy too, and no restart is claimed, because nothing on disk // moved. if (status === 200 && body.ok && body.skipped === 'current') { setRecords(list => drop(list, updateRecordId)) refreshInstalled(true) return } if (status === 200 && body.ok) { setRecords(list => patchRecord(list, updateRecordId, { state: 'done' })) setUpdatedNames(names => names.concat(name)) // #558: the restart banner counts plugins the host parked behind a // restart, not every completed change. A client-only plugin comes // back 'inert'/'live' and goes live on refresh; when the host // reports no activation at all, count it to stay on the safe side. const activation = body.activation && typeof body.activation === 'object' ? body.activation[name] : undefined if (!activation || activation.state === 'restart') { setRestartNames(names => names.includes(name) ? names : names.concat(name)) } if (body.activation && typeof body.activation === 'object') { setActivations(prev => ({ ...prev, ...body.activation })) } if (body.compatibility?.code === 'soft-incompatible') { setCompatibilityNotice(body.compatibility as CompatibilityNotice) } refreshInstalled() } else { if (status === 409) { if (body.agentsBusy === true) { // Same queue treatment as installs: the host refused before // touching pnpm, so this becomes a `queued` record the drain // runs when agents go idle. setRecords(list => patchRecord(list, updateRecordId, { state: 'queued', reason: t('agentBusyUpdateQueued') })) setOperationsOpen(true) return } setRecords(list => patchRecord(list, updateRecordId, { state: 'failed', reason: t('busyWait') })) setInstallError(t('busyWait')) return } // The target says it needs a newer host than this one (#404). Not // a failure to report and move on from — a decision, so it gets a // dialog with the two facts and a way past. The row is dropped // rather than marked failed: nothing was attempted. if (body.hostIncompatible && typeof body.hostIncompatible === 'object') { const notice = body.hostIncompatible as { name?: unknown; version?: unknown; requirement?: unknown; hostVersion?: unknown; npmName?: unknown } setRecords(list => drop(list, updateRecordId)) setHostIncompatible({ kind: 'update', name: String(notice.name ?? name), version: String(notice.version ?? ''), requirement: typeof notice.requirement === 'string' ? notice.requirement : null, hostVersion: typeof notice.hostVersion === 'string' ? notice.hostVersion : null, npmName: typeof notice.npmName === 'string' ? notice.npmName : null, plugin: null, }) return } if (body.stale === true) setStaleName(name) // Blocked build scripts during an update (#69): same // approve-and-retry banner as the install flow, retrying the update. if (Array.isArray(body.ignoredBuilds) && body.ignoredBuilds.length > 0) { setBuildsSkipped({ updateName: name, names: body.ignoredBuilds.map(String), restore }) } const text = (v: unknown) => typeof v === 'string' ? v : (v && typeof (v as any).text === 'string') ? (v as any).text : v == null ? '' : JSON.stringify(v) const orphans = Array.isArray(body.orphanBundles) ? body.orphanBundles.map(String) : [] const failure = text(body.error) || humanOutput([text(body.stderr), text(body.stdout)].filter(Boolean).join('\n')) || ('exit ' + body.exitCode) // The profile will not boot as it stands (#339). Said FIRST, because // it outranks whatever else went wrong: a plugin that failed to // install is recoverable, a profile that cannot start is not — and // the user would otherwise meet it as a Node stack trace after the // next restart, with nothing linking it to this operation. // A stale catalog entry (#346) is said before pnpm's own wording, // which for that failure reads like the user broke something. const staleEntry = typeof body.staleEntry === 'string' ? body.staleEntry : null const detail = [ orphans.length > 0 ? `${t('orphanBundle')} ${orphans.join(', ')}` : null, staleEntry, failure, ].filter(Boolean).join('\n') const clipped = detail.trim().slice(-600) setRecords(list => patchRecord(list, updateRecordId, { state: 'failed', reason: clipped })) // Localize the server half before prepending t() chrome. setInstallError((restore ? t('restoreFail') : t('updateFail')) + ': ' + name + ' — ' + localizeBilingual(clipped, lang)) } }) .catch(() => { // A lost response does not mean the update stopped (the route holds // its reply until pnpm finishes, #100): keep the marker AND the // running row, and let the status poll converge the outcome instead // of declaring a false failure — mirroring the install flow's catch. }) }, [refreshInstalled, t, lang]) const doSourceMigration = useCallback((name: string) => { setInstallError(null) setActivationWarnings([]) setMigrationConfirm(null) setUpdatingName(name) return fetch(api('/dsh-market/migrate-source'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name }), }) .then(res => res.json().then(body => ({ status: res.status, body }))) .then(({ status, body }) => { setUpdatingName(null) if (status === 200 && body.ok === true) { const targetName = typeof body.to?.name === 'string' ? body.to.name : name setUpdatedNames(names => names.includes(targetName) ? names : names.concat(targetName)) if (body.activation && typeof body.activation === 'object') { setActivations(prev => ({ ...prev, ...body.activation })) } refreshInstalled(true) const warnings = Array.isArray(body.warnings) ? body.warnings.map(String).filter(Boolean) : [] if (warnings.length > 0) setInstallError(warnings.join('\n')) return } if (status === 409 && body.agentsBusy === true) { const running = Array.isArray(body.runningAgents) && body.runningAgents.length > 0 ? ` (${body.runningAgents.join(', ')})` : '' setInstallError(t('agentBusyUpdate') + running) return } setInstallError(t('migrateFail') + ': ' + localizeBilingual(String(body.error || ('HTTP ' + String(status))), lang)) }) .catch(error => { setUpdatingName(null) setInstallError(t('migrateFail') + ': ' + String(error)) }) }, [refreshInstalled, t, lang]) const askSourceMigration = useCallback((name: string) => { const migration = updates[name]?.sourceMigration const source = installed[name] setStaleName(null) setRestoreConfirm(null) setRestoreBlocked(null) setInstallError(null) if (migration === undefined || source === undefined) { setMigrationConfirm(null) return } setMigrationConfirm({ name, source: String(source), target: migration.target, }) }, [installed, updates]) const askRestore = useCallback((name: string) => { if (data === null) return const spec = installed[name] if (spec === undefined) return const specText = String(spec) setStaleName(null) setMigrationConfirm(null) setRestoreBlocked(null) if (/^(?:link|file):/i.test(specText)) { const resolved = resolveCatalogRestore( data.plugins, name, repoIdentities[name] ?? [], repoHints[name] ?? [], ) if (!resolved.ok) { setRestoreConfirm(null) setRestoreBlocked({ name, reason: resolved.reason }) return } setRestoreConfirm({ name, entry: resolved.entry, verified: resolved.verified }) return } const entry = entryForDep(data.plugins, name, specText, repoIdentities[name], repoHints[name]) if (entry === undefined) { setRestoreConfirm(null) setRestoreBlocked({ name, reason: 'no-catalog' }) return } // Not a local checkout: the entry was resolved from the install spec // itself, which names the source. Nothing was guessed from the name. setRestoreConfirm({ name, entry, verified: true }) }, [data, installed, repoHints, repoIdentities]) /** Open the update-notes dialog and start its fetch. Lazy: the request only exists while a user is actually looking at one plugin's notes, and closing the dialog abandons the render — the server side caches the payload, so reopening is cheap. */ const openNotes = useCallback((name: string, current: string | null, latest: string | null, repoUrl: string | null) => { setNotesFor({ name, current, latest, repoUrl }) setUpdateNotes(null) setNotesState('loading') fetch(`${api('/dsh-market/changelog')}?name=${encodeURIComponent(name)}`) .then(res => res.json()) .then(body => { setUpdateNotes(body as ResolvedNotes); setNotesState('ready') }) .catch(() => setNotesState('fail')) }, []) const doUseSkin = useCallback((name: string) => { setInstallError(null) fetch(api('/dsh-market/use-skin'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name }), }) .then(res => res.json().then(body => ({ status: res.status, body }))) .then(({ status, body }) => { if (status === 200 && body.ok) { sessionStorage.setItem('dshm-toast', JSON.stringify([name])) sessionStorage.setItem('dshm-toast-mode', 'theme') sessionStorage.setItem('dshm-tab', 'themes') location.reload() } else { setInstallError(String(body.error || 'failed')) } }) .catch(error => setInstallError(String(error))) }, []) /** * Forget a pending page-refresh for a plugin that is no longer here. * * The banner counts what the page has not caught up with. Install then * uninstall and the page is level again — there is nothing left to load — * but both sets were append-only, so it kept asking for a refresh that * would show nothing (#340). It conflated "something needs doing" with * "something happened in this session". */ /** Write (or clear, when empty) this plugin's note. */ const saveNote = useCallback((name: string, text: string) => { setNotingName(null) fetch(api('/dsh-market/note'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name, text }), }) .then(res => res.json()) .then((body) => { if (body.ok && body.notes !== null && typeof body.notes === 'object') { setNotes(body.notes as Record) } else setInstallError(String(body.error || 'note failed')) }) .catch(error => setInstallError(String(error))) }, []) const toggleFavorite = useCallback((url: string) => { const gen = ++favoriteOpGen.current const favorited = !favoriteUrlSet.has(url) const previous = favoriteUrls setFavoriteError(null) setFavoriteUrls((list) => { if (favorited) return list.includes(url) ? list : [...list, url] return list.filter(entry => entry !== url) }) fetch(api('/dsh-market/favorite'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ url, favorited }), }) .then(async (res) => { const text = await res.text() let body: { ok?: unknown; favorites?: unknown; error?: unknown } | null = null if (text !== '') { try { body = JSON.parse(text) as { ok?: unknown; favorites?: unknown; error?: unknown } } catch { /* non-JSON */ } } return { status: res.status, body } }) .then(({ status, body }) => { if (gen !== favoriteOpGen.current) return if (status === 200 && body?.ok === true && Array.isArray(body.favorites)) { setFavoriteUrls(body.favorites.filter((entry: unknown): entry is string => typeof entry === 'string')) return } setFavoriteUrls(previous) if (body === null && (status === 404 || status === 405)) { setFavoriteError(t('favoriteUnavailable')) return } if (status === 409) { setFavoriteError(t('favoriteBusy')) return } setFavoriteError(typeof body?.error === 'string' ? body.error : t('favoriteFailed')) }) .catch((error: unknown) => { if (gen !== favoriteOpGen.current) return setFavoriteUrls(previous) setFavoriteError(String(error)) }) }, [favoriteUrlSet, favoriteUrls, t]) const clearStaleFavorites = useCallback(() => { if (favoriteStale.length === 0) return const gen = ++favoriteOpGen.current const stale = favoriteStale const previous = favoriteUrls setFavoriteError(null) setClearingStale(true) setFavoriteUrls(list => list.filter(url => !stale.includes(url))) void (async () => { try { let latest: string[] | null = null for (const url of stale) { const res = await fetch(api('/dsh-market/favorite'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ url, favorited: false }), }) const text = await res.text() let body: { ok?: unknown; favorites?: unknown; error?: unknown } | null = null if (text !== '') { try { body = JSON.parse(text) as { ok?: unknown; favorites?: unknown; error?: unknown } } catch { /* non-JSON */ } } if (res.status === 200 && body?.ok === true && Array.isArray(body.favorites)) { latest = body.favorites.filter((entry: unknown): entry is string => typeof entry === 'string') continue } if (gen !== favoriteOpGen.current) return setFavoriteUrls(previous) if (res.status === 409) setFavoriteError(t('favoriteBusy')) else setFavoriteError(typeof body?.error === 'string' ? body.error : t('favoriteFailed')) return } if (gen !== favoriteOpGen.current) return if (latest !== null) setFavoriteUrls(latest) } catch (error: unknown) { if (gen !== favoriteOpGen.current) return setFavoriteUrls(previous) setFavoriteError(String(error)) } finally { if (gen === favoriteOpGen.current) setClearingStale(false) } })() }, [favoriteStale, favoriteUrls, t]) const clearPendingRefresh = useCallback((name: string) => { setHotNames(names => names.filter(entry => entry !== name)) setRefreshNames(names => names.filter(entry => entry !== name)) }, []) const doUninstall = useCallback((name: string) => { setRemoveConfirm(null) setInstallError(null) setActivationWarnings([]) setRemovingName(name) // Uninstalls join the same operation records as installs/updates, so an // agents-busy refusal can queue them and the drain can run them later. const uninstallRecordId = nextRecordId() setRecords(list => enqueue(list, { id: uninstallRecordId, kind: 'uninstall', name, state: 'running' })) return fetch(api('/dsh-market/uninstall'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name }), }) .then(res => res.json().then(body => ({ status: res.status, body }))) .then(({ status, body }) => { if (status === 409 && body.agentsBusy === true) { setRecords(list => patchRecord(list, uninstallRecordId, { state: 'queued', reason: t('agentBusyUninstallQueued') })) setOperationsOpen(true) return } setRecords(list => drop(list, uninstallRecordId)) if (status === 200 && body.ok) { if (!body.hot) setRemovedCount(n => n + 1) // A client-part plugin stays injected until a page reload — the same // pending-refresh banner as enable/disable tells the user to reload, // instead of silently leaving the uninstalled plugin's UI running. // // But only when it was live in THIS page. A plugin installed during // this session was never injected: the banner was asking the user to // reload in order to GET it, so undoing the install nets to zero and // the banner must go (#340 — "it was reporting session history, not // pending work"). Being already pending is exactly what distinguishes // the two, and the server cannot see it: `refresh` says the package // HAD a client part, not that this page ever loaded it. const neverLoadedHere = hotNames.includes(name) || refreshNames.includes(name) if (body.refresh === true && !neverLoadedHere) { setRefreshNames(names => names.includes(name) ? names : names.concat(name)) } else clearPendingRefresh(name) refreshInstalled() } else { if (body.cancelled === true) { refreshInstalled() if (body.partial === true) setInstallError(t('partialNote')) return } // Half-uninstall reconcile: the package is gone and the server has // already converged the manifest to disk truth. Refresh so the card // leaves the list instead of luring the user into a retry that // would 400 on "not installed"; the note separates the outcome // (removed, profile synced) from the process (pnpm errored). if (body.reconciled === true) { if (!body.hot) setRemovedCount(n => n + 1) clearPendingRefresh(name) refreshInstalled() setInstallError(t('reconciledNote')) return } const text = (v: unknown) => typeof v === 'string' ? v : (v && typeof (v as any).text === 'string') ? (v as any).text : v == null ? '' : JSON.stringify(v) setInstallError((text(body.error) || humanOutput(text(body.stderr)) || 'error').trim().slice(-600)) } }) .catch(error => setInstallError(String(error))) .finally(() => setRemovingName(null)) // hotNames/refreshNames are read above to tell a plugin this page loaded // from one installed inside it, so they belong in the closure. }, [refreshInstalled, hotNames, refreshNames, nextRecordId, t]) // Drain-loop refs, assigned after doInstall/doUpdate/doUninstall exist // (the mount-once drain effect below only reads refs, never closures). const busyUrlRef = useRef(null) busyUrlRef.current = busyUrl const updatingNameRef = useRef(null) updatingNameRef.current = updatingName const removingNameRef = useRef(null) removingNameRef.current = removingName const dataRef = useRef(null) dataRef.current = data const doInstallRef = useRef<((plugin: RegistryPlugin) => void) | null>(null) doInstallRef.current = doInstall const doUpdateRef = useRef<((name: string, force?: boolean, restore?: boolean) => Promise) | null>(null) doUpdateRef.current = doUpdate const doUninstallRef = useRef<((name: string) => Promise) | null>(null) doUninstallRef.current = doUninstall /** * The install queue drain: agents-busy 409s no longer ask the user to come * back later — the queued record runs itself once agents go idle and the * operation lock is free. Self-sufficient by design: when every operation * is queued, nothing else polls /status, so this loop fetches it itself * (only while a queued record exists, so an idle page makes no requests). * One mutation at a time — the host still serializes via its lock, and a * re-refused drain simply re-queues instead of failing. */ useEffect(() => { let disposed = false const timer = setInterval(() => { if (drainingRef.current) return if (busyUrlRef.current !== null || updatingNameRef.current !== null || removingNameRef.current !== null) return void fetch(api('/dsh-market/status'), { cache: 'no-store' }) .then(res => res.json()) .then(status => { if (disposed) return const runningAgents: string[] = Array.isArray(status.runningAgents) ? status.runningAgents.map(String) : [] const busy = status.busy === true statusRef.current = { busy, runningAgents } if (busy || runningAgents.length > 0) return if (busyUrlRef.current !== null || updatingNameRef.current !== null || removingNameRef.current !== null) return let next: OperationRecord | null = null let hasRunning = false setRecords(prev => { hasRunning = prev.some(record => record.state === 'running') const found = prev.find(record => record.state === 'queued') next = found ?? null return found === undefined ? prev : prev.filter(record => record.id !== found.id) }) if (next === null || hasRunning) return const task: OperationRecord = next drainingRef.current = true try { if (task.kind === 'install') { const plugin = dataRef.current?.plugins.find(candidate => candidate.url === task.url) if (plugin !== undefined) doInstallRef.current?.(plugin) } else if (task.kind === 'update') { void doUpdateRef.current?.(task.name) } else { void doUninstallRef.current?.(task.name) } } finally { drainingRef.current = false } }) .catch(() => { /* retry on the next tick */ }) }, 2000) return () => { disposed = true clearInterval(timer) } }, []) /** A queued record's "run now": retry immediately instead of waiting for idle. */ const runQueuedNow = useCallback((record: OperationRecord) => { if (record.kind === 'install') { const plugin = data?.plugins.find(candidate => candidate.url === record.url) if (plugin === undefined) { setRecords(prev => drop(prev, record.id)) return } setRecords(prev => drop(prev, record.id)) doInstall(plugin) } else if (record.kind === 'update') { setRecords(prev => drop(prev, record.id)) void doUpdate(record.name) } else { setRecords(prev => drop(prev, record.id)) void doUninstall(record.name) } }, [data, doInstall, doUpdate, doUninstall]) /** Live enable/disable of one installed plugin (#60). `reload` opts the * card-level theme flow into a page refresh so the visual result lands * immediately (mirrors the use-skin reload on activate). */ const doToggle = useCallback((name: string, enabled: boolean, reload = false) => { setTogglingName(name) setInstallError(null) return fetch(api('/dsh-market/toggle'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name, enabled }), }) .then(res => res.json().then(body => ({ status: res.status, body }))) .then(({ status, body }) => { if (status === 200 && body.ok) { if (Array.isArray(body.disabled)) setDisabledNames(body.disabled) if (Array.isArray(body.live)) setSkins(body.live) if (body.activation && typeof body.activation === 'object') { setActivations(prev => ({ ...prev, ...body.activation })) } // A toggle whose fiber did not follow the switch joins the // pending-restart banner (same path as installs/updates/removals). if (body.restart === true) setToggleRestart(n => n + 1) // A client-part plugin's UI is already in the page — refresh to // show the change (mirrors the install hot banner). // Back to the position the page was rendered with means there is // nothing left for a refresh to show, so the banner drops it // instead of counting the round trip as a pending change (#340). if (body.refresh === true) { const wasDisabled = loadedDisabled.current?.has(name) ?? false if (wasDisabled === !enabled) clearPendingRefresh(name) else setRefreshNames(names => names.includes(name) ? names : names.concat(name)) } // Not on the reload path: the page is about to go away, and the // theme flow lands its own toast on the other side. if (!reload) setToggled({ name, enabled }) refreshInstalled() if (reload) { // Land back in the Themes tab with the stock look on screen. // Drop a stale install/switch toast so it cannot resurrect. sessionStorage.removeItem('dshm-toast') sessionStorage.removeItem('dshm-toast-mode') sessionStorage.setItem('dshm-tab', 'themes') location.reload() } } else { const text = (v: unknown) => typeof v === 'string' ? v : v == null ? '' : JSON.stringify(v) // The server's bilingual reason (e.g. host cannot hot-mount — // restart required) beats the generic failure line. setInstallError(text(body.reason) || text(body.error) || t('toggleFail')) // The durable state (state.json + patch layer) was still written, // so a restart applies it even though the live drive failed. if (body.restart === true) setToggleRestart(n => n + 1) if (body.refresh === true) setRefreshNames(names => names.includes(name) ? names : names.concat(name)) } }) .catch(error => setInstallError(String(error))) .finally(() => setTogglingName(null)) }, [clearPendingRefresh, refreshInstalled, t]) /** Adopt the groups payload returned by POST /dsh-market/groups. */ const setGroupPayload = useCallback((body: { groups?: Record groupOrder?: string[] disabled?: string[] }) => { if (body.groups && typeof body.groups === 'object') setGroups(body.groups) if (Array.isArray(body.groupOrder)) setGroupOrder(body.groupOrder) if (Array.isArray(body.disabled)) setDisabledNames(body.disabled) }, []) /** One POST /dsh-market/groups round trip (create/rename/delete/members/toggle). */ const doGroupAction = useCallback((payload: Record): Promise => { setInstallError(null) return fetch(api('/dsh-market/groups'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload), }) .then(res => res.json().then(body => ({ status: res.status, body }))) .then(({ status, body }) => { if (status === 200 && body.ok) { setGroupPayload(body) // Batch toggles whose members did not follow the switch join the // pending-restart banner too. if (Array.isArray(body.restartMembers) && body.restartMembers.length > 0) { setToggleRestart(n => n + body.restartMembers.length) } if (Array.isArray(body.refreshMembers) && body.refreshMembers.length > 0) { setRefreshNames(names => [...new Set([...names, ...body.refreshMembers])]) } refreshInstalled() return true } const text = (v: unknown) => typeof v === 'string' ? v : v == null ? '' : JSON.stringify(v) setInstallError(text(body.error) || t('toggleFail')) if (Array.isArray(body.restartMembers) && body.restartMembers.length > 0) { setToggleRestart(n => n + body.restartMembers.length) } if (Array.isArray(body.refreshMembers) && body.refreshMembers.length > 0) { setRefreshNames(names => [...new Set([...names, ...body.refreshMembers])]) } return false }) .catch(error => { setInstallError(String(error)); return false }) }, [refreshInstalled, setGroupPayload, t]) /** Approve the build scripts pnpm refused, then rerun what was blocked. */ const approveAndRetry = useCallback(( names: string[], resume: () => void, ) => { fetch(api('/dsh-market/approve-builds'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ packages: names }), }) .then(res => res.json()) .then((body) => { if (!body.ok) setInstallError(String(body.error || 'approve failed')) else resume() }) .catch(error => setInstallError(String(error))) }, []) const doGroupToggle = useCallback((name: string, enabled: boolean) => { return doGroupAction({ action: 'toggle', name, enabled }) }, [doGroupAction]) const cancelCreateGroup = useCallback(() => { setCreatingGroup(false) setNewGroupName('') }, []) const doCreateGroup = useCallback(() => { const name = newGroupName.trim() if (name === '') return void doGroupAction({ action: 'create', name }).then(ok => { if (ok) cancelCreateGroup() }) }, [cancelCreateGroup, doGroupAction, newGroupName]) const doRenameGroup = useCallback((name: string) => { const newName = renamingValue.trim() if (newName === '' || newName === name) { setRenamingGroup(null) return } void doGroupAction({ action: 'rename', name, newName }).then(ok => { if (ok) { setRenamingGroup(null) setRenamingValue('') } }) }, [doGroupAction, renamingValue]) const doDeleteGroup = useCallback((name: string) => { void doGroupAction({ action: 'delete', name }).then(ok => { if (ok) setDeletingGroup(null) }) }, [doGroupAction]) const doAssign = useCallback((name: string, group: string) => { if (group === '') return const members = groups[group] ?? [] void doGroupAction({ action: 'set-members', name: group, members: [...members, name] }).then(ok => { if (ok) setAssignFor(null) }) }, [doGroupAction, groups]) const doRemoveMember = useCallback((group: string, name: string) => { const members = (groups[group] ?? []).filter(member => member !== name) void doGroupAction({ action: 'set-members', name: group, members }) }, [doGroupAction, groups]) /** Open the multi-select add-members dialog for a group. Plugins only. */ const openAddPanel = useCallback((group: string) => { setRenamingGroup(null) setDeletingGroup(null) setGroupMenuFor(null) setThemePanel(null) setAddQuery('') setAddSelected([]) setAddPanel(group) }, []) /** Commit the current multi-select into the open group. */ const doAddSelectedMembers = useCallback(() => { if (addPanel === null || addSelected.length === 0) return const members = groups[addPanel] ?? [] const next = [...members] for (const name of addSelected) { if (!next.includes(name)) next.push(name) } void doGroupAction({ action: 'set-members', name: addPanel, members: next }).then(ok => { if (ok) setAddPanel(null) }) }, [addPanel, addSelected, doGroupAction, groups]) const toggleCollapsedGroup = useCallback((gid: string) => { setCollapsedGroups(prev => { const next = new Set(prev) if (next.has(gid)) next.delete(gid) else next.add(gid) return next }) }, []) // The market itself stays out of the batch: its update reloads this page // mid-run, which would strand the remaining items. const selfName = installed['dshmarket'] !== undefined ? 'dshmarket' : 'dsh-market' const updatableNames = Object.keys(installed).filter( name => name !== selfName && !effectiveDisabledSet.has(name) && isPluginUpdatable(name, String(installed[name]), updates[name], updatedNames), ) // Replacing a local source with its catalog source is deliberately not a // batch update: every such plugin has an existing, explicit confirmation // gate because the source switch cannot be rolled back. const batchUpdatableNames = updatableNames.filter(name => updates[name]?.restoreRequired !== true) const ignoredUpdateSet = useMemo(() => new Set(ignoredUpdateNames), [ignoredUpdateNames]) const reminderUpdatableNames = updatableNames.filter(name => !ignoredUpdateSet.has(name)) const reminderBatchUpdatableNames = batchUpdatableNames.filter(name => !ignoredUpdateSet.has(name)) const selfUpdateAvailable = updates[selfName]?.updateAvailable === true && !updatedNames.includes(selfName) const reminderUpdateNames = [ ...(selfUpdateAvailable ? [selfName] : []), ...updatableNames, ].filter(name => !ignoredUpdateSet.has(name)) // The market manages itself from its own settings card (Settings → Plugins // → Plugin configuration), not as a row here — listing it in both places // read as two different controls for the same thing. const installedOtherCount = Object.keys(installed).filter(name => name !== selfName).length const ignoreUpdateNotices = useCallback((names: string[]) => { if (bootId === null || names.length === 0) return setIgnoredUpdateNames(current => { const next = [...new Set([...current, ...names])] try { sessionStorage.setItem(IGNORED_UPDATES_SESSION_KEY, JSON.stringify({ boot: bootId, names: next })) } catch { /* storage unavailable: keep the dismissal for this mount */ } return next }) }, [bootId]) const doUpdateAll = useCallback(() => { const names = reminderBatchUpdatableNames.slice() setUpdatingAll(true) const next = () => { const name = names.shift() if (name === undefined) { setUpdatingAll(false) // The queue was a snapshot taken before any of it ran, and a batch is // where it drifts furthest from the profile (#495): re-read the list // once at the end so what is left on screen is what is actually still // updatable, forced past the 30-minute listing cache. refreshInstalled(true) return } doUpdate(name).then(next, next) } next() }, [reminderBatchUpdatableNames, doUpdate, refreshInstalled]) const finishRestore = useCallback((body: { errors?: unknown; unportable?: unknown; bootErrors?: unknown }) => { const errors = Array.isArray(body.errors) ? body.errors as { name?: unknown; error?: unknown }[] : [] // Machine-specific dependency paths (#205): a `link:/Users/…` spec from // the machine that wrote the backup names a directory that does not // exist here, so pnpm cannot satisfy it. Listed with the other restore // problems rather than in a banner of its own — from the operator's // side it is one question ("what went wrong with my restore?"). const unportable = Array.isArray(body.unportable) ? body.unportable as { name?: unknown; spec?: unknown }[] : [] // Partial failures surface inline in the Backup tab (previously a // window.alert); the restore itself still completes. // What the profile analysis says about the composition that just landed // (#205). The restore itself succeeded; these are the packages the // composition still needs, which otherwise surfaced only at the NEXT // boot, as a Loader error with nothing tying it back to the restore. const bootErrors = Array.isArray(body.bootErrors) ? body.bootErrors.map(String) : [] setRestoreErrors([ ...errors.map(item => `${String(item.name)}: ${String(item.error)}`), ...unportable.map(item => `${String(item.name)}: ${t('restoreUnportable')} (${String(item.spec)})`), ...bootErrors.map(line => `${t('restoreBootError')} ${line}`), ]) setBackupRestored(true) setBackupMessage(t('restoreDone')) if (errors.length === 0) { setPendingBackup(null) setPendingDependencies({}) } refreshInstalled(true) }, [refreshInstalled, t]) const previewBackup = useCallback((backup: unknown) => { const dependencies = backupDependencies(backup) setPendingBackup(backup) setPendingDependencies(dependencies) setBackupMessage(t('restorePreviewDone')) setRestoreErrors([]) setTab('installed') }, [t]) /** Actually run the restore; the confirm dialog gates this (previously window.confirm). */ const doRestore = useCallback(() => { if (pendingBackup === null) return Promise.resolve() setRestoreConfirmOpen(false) setBackupBusy(true) setBackupMessage(null) setRestoreErrors([]) return fetch(api('/dsh-market/restore'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ backup: pendingBackup }), }).then(async response => { const body = await response.json() if (!response.ok) throw new Error(String(body.error || 'restore failed')) finishRestore(body) }).catch(error => setBackupMessage(String(error))).finally(() => setBackupBusy(false)) }, [finishRestore, pendingBackup]) const runWebdav = useCallback((action: 'backup' | 'restore') => { if (webdavUrl.trim() === '') return setBackupBusy(true) setBackupMessage(null) setRestoreErrors([]) fetch(api('/dsh-market/webdav'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ action, url: webdavUrl.trim(), username: webdavUser, password: webdavPassword }), }).then(async response => { const body = await response.json() if (!response.ok) throw new Error(String(body.error || 'WebDAV failed')) if (action === 'restore') { previewBackup(body.backup) } if (action === 'backup') { try { localStorage.setItem('dshm-webdav-last', String(Date.now())) } catch { /* storage unavailable */ } setBackupMessage(t('backupDone')) } }).catch(error => setBackupMessage(String(error))).finally(() => setBackupBusy(false)) }, [previewBackup, t, webdavPassword, webdavUrl, webdavUser]) /** Map the server's token-source string to a localized label. */ const gistSourceLabel = (source: string): string => { if (source === 'token') return t('gistSrcToken') if (source === 'env') return t('gistSrcEnv') if (source === 'gh') return t('gistSrcGh') return source } /** Turn any failure (server error, network error, timeout) into a friendly message. */ const gistErrorMessage = (error: unknown): string => { const err = error as { name?: unknown; code?: unknown } const name = typeof err?.name === 'string' ? err.name : '' const code = typeof err?.code === 'string' ? err.code : '' if (code === 'timeout' || name === 'TimeoutError' || name === 'AbortError') return t('gistErrTimeout') if (code === 'network') return t('gistErrNetwork') if (code === 'auth') return t('gistErrAuth') if (code === 'notfound') return t('gistErrNotFound') if (code === 'rate-limit') return t('gistErrRateLimit') if (code === 'invalid') return t('gistErrInvalid') // Network-level fetch failures surface as TypeError("Failed to fetch"). if (error instanceof TypeError) return t('gistErrNetwork') return String(error) } const runGist = useCallback((action: 'export' | 'import' | 'verify') => { setGistBusy(true) setGistMessage(null) setGistOk(false) setGistResult(null) setRestoreErrors([]) setExportError(null) const body: Record = { action, token: gistToken.trim() } // Import always targets the field; export targets it only in update mode // (create mode deliberately ignores the field and makes a new Gist). if (action === 'import') body.gistId = gistId.trim() if (action === 'export' && gistMode === 'update') { if (gistId.trim() === '') { setGistBusy(false) setGistMessage(t('gistErrNoId')) setGistOk(false) return } body.gistId = gistId.trim() } if (action === 'export') { // All plugins selected → full backup (with config); partial → only // the checked plugins, config optional via the picker flag. const allNames = new Set([...Object.keys(installed), ...installedBundles]) const allSelected = exportSelection.size === allNames.size && exportSelection.size > 0 if (!allSelected) { body.includeDeps = [...exportSelection] if (exportIncludeConfig) body.includeConfig = true } } fetch(api('/dsh-market/gist'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), // Fallback ceiling only — the server answers structured errors (with a // code) within 25 s, so a wedged host cannot leave the user staring at // "working…" forever. signal: AbortSignal.timeout(30_000), }).then(async response => { let body: Record = {} try { body = await response.json() as Record } catch { /* non-JSON response */ } if (!response.ok) { const error = new Error(String(body.error || 'Gist failed')) if (typeof body.code === 'string') (error as { code?: string }).code = body.code throw error } if (action === 'export') { setGistResult(body as unknown as GistExportResult) // Backfill the id so the next export updates this Gist instead of // creating yet another one. const ref = body as unknown as GistExportResult if (typeof ref.gistId === 'string' && ref.gistId !== '') { setGistId(ref.gistId) // A fresh export flips the mode to update so the next export // PATCHes the same Gist instead of creating yet another one. setGistMode('update') try { localStorage.setItem('dshm-gist-id', ref.gistId) } catch { /* storage unavailable */ } } setGistMessage(t('gistExportDone')) setGistOk(true) setExportOpen(false) } else if (action === 'import') { previewBackup(body.backup) } else { // verify: tell the user which token source actually served the request. const source = typeof body.source === 'string' ? body.source : '' setGistMessage(t('gistVerifySource').replace('{0}', gistSourceLabel(source))) setGistOk(true) } }).catch(error => { const message = gistErrorMessage(error) setGistMessage(message) setGistOk(false) // Keep the picker open (selection preserved) and show the failure // inside it — never hidden behind the dialog. if (action === 'export') setExportError(message) }).finally(() => setGistBusy(false)) }, [exportIncludeConfig, exportSelection, gistId, gistToken, installed, installedBundles, previewBackup, t]) /** The picker list: dependency plugins + bundle-only plugins, deduplicated. */ const exportOptions = useMemo(() => { const names = new Set([...Object.keys(installed), ...installedBundles]) return [...names].sort() }, [installed, installedBundles]) /** Classify an install spec for the export picker badge. */ const specKind = (spec: string | undefined): 'npm' | 'git' | 'file' | 'bundle' => { if (spec === undefined) return 'bundle' if (/^file:/i.test(spec)) return 'file' if (/^(github:|git\+|git:)/i.test(spec)) return 'git' return 'npm' } const openExportPicker = useCallback(() => { const names = new Set([...Object.keys(installed), ...installedBundles]) setExportSelection(new Set(names)) setExportIncludeConfig(false) setExportOpen(true) }, [installed, installedBundles]) useEffect(() => { // Persist only the non-secret WebDAV settings; the password stays // server-side/in-memory (see savedWebdav). Storage itself may be // unavailable (e.g. the client test env), so never let it crash the UI. try { localStorage.setItem(WEBDAV_STORAGE_KEY, JSON.stringify({ url: webdavUrl, username: webdavUser, auto: autoBackup })) } catch { /* storage unavailable — config just won't survive reload */ } if (!autoBackup || webdavUrl.trim() === '') return let last = 0 try { last = Number(localStorage.getItem('dshm-webdav-last')) || 0 } catch { /* ignore */ } if (Date.now() - last >= 24 * 60 * 60 * 1000) runWebdav('backup') }, [autoBackup, runWebdav, webdavUrl, webdavUser]) // #558: count restart-pending session changes (restartNames), not every // completed `updatedNames` entry (which would double-count a batch update // that reports both a done URL and a name for the same plugin). const sessionPendingRestart = doneUrls.length + restartNames.length + removedCount + toggleRestart + (backupRestored ? 1 : 0) /** * Plugins the HOST reports as restart-pending, independent of what this * browser session happens to remember. Installing and then reloading the * page used to leave no restart affordance at all: the banner is built * from session state, while the Installed tab only says "activates on * restart" in passing — so the user was told a restart was needed and * given nothing to press. Dismissible, because a standing banner nobody * wants to act on right now is just noise (it returns next session, or * as soon as another change lands). */ const hostPendingNames = Object.keys(activations).filter(name => activations[name]?.state === 'restart') const showHostPending = hostPendingNames.length > 0 && !restartNoticeDismissed && sessionPendingRestart === 0 const pendingRestart = sessionPendingRestart > 0 ? sessionPendingRestart : (showHostPending ? hostPendingNames.length : 0) const displayedInstalled = pendingBackup === null ? installed : { ...pendingDependencies, ...installed } /** * Installed entries ordered for the list view. * * The order settles once and then holds, so rows never reshuffle under a * pointer that is already aiming at one (#631). The single moment that has * to reorder is when the update check lands: `/installed` is a local read * and `/updates` is a network probe over every package, so the list is * always rendered BEFORE the answer exists — freezing on the view alone * would leave it in manifest order forever. `updatesLoaded` is therefore * the one part of `updates` allowed in, as a boolean: it flips once when * the result arrives, and every later change (a newer check, a row the user * just updated) leaves the boolean and the order alone. */ const isInstalledListActive = tab === 'installed' && installedView === 'list' const updatesLoaded = Object.keys(updates).length > 0 const orderedInstalledEntries = useMemo(() => { return Object.entries(displayedInstalled) .filter(([name]) => name !== selfName) .sort(([nameA, specA], [nameB, specB]) => { const aUp = isPluginUpdatable(nameA, String(specA), updates[nameA], updatedNames, ignoredUpdateSet) ? 1 : 0 const bUp = isPluginUpdatable(nameB, String(specB), updates[nameB], updatedNames, ignoredUpdateSet) ? 1 : 0 return bUp - aUp }) // eslint-disable-next-line react-hooks/exhaustive-deps -- `updatesLoaded` stands in for `updates`/`updatedNames`: reorder when the check lands, then hold (#631) }, [isInstalledListActive, displayedInstalled, selfName, updatesLoaded]) const missingRestoreCount = Object.keys(pendingDependencies).filter(name => !installedFiles.includes(name)).length // Self-update lives in the header button and the settings card, not this // tab's row list (the market itself is filtered out below) — so a pending // self-update alone must not light up a dot pointing at an empty-looking tab. const hasUpdates = reminderUpdatableNames.length > 0 /** Live status line: structured phase, or the human-line fallback. */ const phasePart = progressPhase != null ? phaseLabel(progressPhase, t) + (progressCurrent !== null ? ' · ' + progressCurrent : '') + (progressDone > 0 ? ' · ' + t('packagesDone').replace('{0}', String(progressDone)) : '') : progressLine || t('progressHint') const progressText = cancelling ? t('cancelling') + ' · ' + phasePart : phasePart /** Whether the catalog carries ANY theme-category entry at all — distinct * from `themePlugins.length === 0` above (which also fires the instant a * search/sort/time filter matches nothing) so the two empty states read * differently: "there's nothing here yet" vs "nothing matches your filter". */ const anyThemePlugins = data === null ? [] : themePluginsOf(data.plugins) /** The catalog entry a deprecated plugin's `replacement` names, if any. */ const replacementOf = (p: RegistryPlugin): RegistryPlugin | undefined => p.deprecated === true && p.replacement !== undefined ? data?.plugins.find(r => r.name === p.replacement) : undefined const renderFavoriteControl = (url: string) => { const favorited = favoriteUrlSet.has(url) return ( ) } /** * Chip label for one capability name, or the scanner's own name. * * `network` has a label; a name this build has never seen (`dynamic-code` * arrived after the first integration) shows as itself rather than * disappearing — an unlabelled fact is still a fact, and a missing chip * would read as "does not do that". */ const capabilityLabel = (name: string): string => { const key = 'cap' + name.split('-').map(part => part.charAt(0).toUpperCase() + part.slice(1)).join('') const label = t(key) return label === key ? name : label } /** * The scanner's red-line sentences, in the reader's language. * * The scanner emits a fixed set of shapes — one per rule family — and every * one of them is translated here. A sentence from a family this build does * not know stays in the scanner's own words on purpose: a mistranslation of * a security fact is worse than a foreign word, and this is the one line on * the card a reader must not misread. The shapes are matched by their stable * prefix rather than in full, so the parenthesised detail the scanner * attaches can change without falling back to English. */ const redLineLabel = (line: string): string => { if (line === 'reads credentials/secrets AND has network access') return t('capRedCredentialsNetwork') const plaintext = /^uses plaintext http:\/\/ to (.+)$/.exec(line) if (plaintext !== null) return t('capRedPlaintextHttp').replace('{0}', plaintext[1]!) const literalIp = /^uses literal IP (.+) for network access$/.exec(line) if (literalIp !== null) return t('capRedLiteralIp').replace('{0}', literalIp[1]!) const installScript = /^runs code at install time(?: \((.+)\))?$/.exec(line) if (installScript !== null) { return installScript[1] === undefined ? t('capRedInstallScript') : t('capRedInstallScriptScripts').replace('{0}', installScript[1]) } // The parenthesised detail is not a name. The scanner emits exactly two // shapes — "overrides bundle " and "disables bundle " — and pasting // either through leaves the verb in English. That verb is the fact: the // install would replace a part of DSH, or switch one off. const coreVerb = /^tampers with a core bundle \((overrides|disables) bundle (.+)\)$/.exec(line) if (coreVerb !== null) { const key = coreVerb[1] === 'overrides' ? 'capRedCoreOverride' : 'capRedCoreDisable' return t(key).replace('{0}', coreVerb[2]!) } const coreTamper = /^tampers with a core bundle(?: \((.+)\))?$/.exec(line) if (coreTamper !== null) { return coreTamper[1] === undefined ? t('capRedCoreTamper') : t('capRedCoreTamperDetail').replace('{0}', coreTamper[1]) } return line } /** * Whether a red line is one a reader has to weigh BEFORE installing, rather * than a fact about what the plugin does once it runs. * * Two families qualify, for one reason: they happen at install time. There is * no "afterwards" to inspect, so this is the last moment the decision can be * made. The other families — credentials+network above all, 73% of every red * line the catalog holds — describe what plugins normally do, and calling * that urgent is how a warning gets trained away. */ const redLineIsUrgent = (line: string): boolean => line.startsWith('runs code at install time') || line.startsWith('tampers with a core bundle') /** * What the static scan found (#401), in the detail dialog and nowhere else. * * It used to sit on both cards. It does not any more, for three reasons that * all pointed the same way: a capability list does not help anyone choose a * plugin, most of it cannot be acted on, and — the one that decided it — a * line every card carries is a line nobody reads, including the install-time * script that IS worth stopping for. So the card says nothing about this, the * dialog leads with the one thing that needs a decision before you press * install, and the rest of the facts are one click away. * * Which lines count as that one thing is `redLineIsUrgent`, not a guess made * here: a rare rule that fires at install time, never a description of what * plugins normally do. */ const capabilityDetail = (p: RegistryPlugin) => { const redLines = p.capabilityRedLines ?? [] return ( <> {redLines.filter(redLineIsUrgent).map(line => (

{' ' + redLineLabel(line)}

))} } title={t('capabilityTitle')} open={capsOpen} expandable expandOnRowClick onToggle={() => setCapsOpen(o => !o)} >
{p.capabilities === undefined ? {t('capabilityUnchecked')} : p.capabilities.length === 0 ? {t('capabilityNone')} : p.capabilities.map(name => (HostTag !== null ? {capabilityLabel(name)} : {capabilityLabel(name)} ))} {redLines.filter(line => !redLineIsUrgent(line)).map(line => ( {redLineLabel(line)} ))}

{t('capabilityNote')}

{typeof p.capabilityCheckedAt === 'string' && p.capabilityCheckedAt.length > 0 && (

{t('capabilityScannedAt').replace('{0}', p.capabilityCheckedAt.slice(0, 10))}

)}
) } /** * The enable/disable control, wherever it appears — the installed row, a * group member row, the plugin detail view. One helper because they were * three copies of the same markup, and because the host has this component: * its own plugin list uses `Switch`, so the market's rows should look like * the list they sit in. Before 0.1.7-rc.2 the market's own switch renders, * with the same `role="switch"` contract either way. */ const onOffSwitch = (opts: { label: string; on: boolean; disabled: boolean; toggle: () => void }) => (HostSwitch !== null ? opts.toggle()} label={opts.label} disabled={opts.disabled} /> : ( )) /** * A labelled checkbox: the host's when it has one, the market's label+input * otherwise. Only the simple ones go through here — see `HostCheckbox` for * why the export rows and the recovery panel keep their own markup. */ const labelledCheckbox = (opts: { label: string; checked: boolean; disabled?: boolean; className?: string; onChange: (next: boolean) => void }) => (HostCheckbox !== null ? : ( )) const pluginCard = (p: RegistryPlugin) => { const desc = (p.description && (p.description[lang] || p.description.en)) || '' const done = doneUrls.includes(p.url) || hotUrls.includes(p.url) const already = isInstalled(p, catalogInstalled, repoIdentities, data?.plugins, repoHints) const busy = busyUrl === p.url const replacement = replacementOf(p) // The card reflects its own latest operation. Without this a rejected // install leaves the card looking untouched, and pressing Install again // is the obvious next move — which is how the same clash gets hit twice. const record = recordForUrl(records, p.url) const blocked = record !== null && (record.state === 'input' || record.state === 'failed') const queued = record !== null && record.state === 'queued' const compatibility = typeof p.npm === 'string' ? hostCompatibility[p.npm] : undefined const hostRequirementLabel = compatibility?.requirement !== null && compatibility?.requirement !== undefined ? t('hostRequirement').replace('{0}', compatibility.requirement) : typeof p.npm !== 'string' ? t('hostRequirementUnavailable') : compatibility === undefined ? t('hostRequirementLoading') : compatibility.basis === 'undeclared' ? t('hostRequirementUndeclared') : t('hostRequirementUnavailable') const hostRequirementTitle = compatibility?.declarations.map(declaration => declaration.kind === 'engine' ? `engines.dsh: ${declaration.range}` : `${declaration.package ?? 'peer'}: ${declaration.range}`, ).join('\n') || hostRequirementLabel return (
{/* The avatar belongs to the AUTHOR, not to the title. Beside the name it reads as one signature, which is what frees the title to be just the plugin — and lets two authors ship a plugin of the same name without either card needing a qualifier. */}
{pluginName(p.name)} {p.deprecated === true && {t('deprecatedBadge')}}
{p.owner} {typeof p.stars === 'number' && ( {'· ★ ' + formatCount(p.stars)} )}
{/* Top right, at its natural size: in the footer it needed a row of its own once the cards went two-up, which cost every card that height whether or not it had anything else to say. */}
{done ? {t('installedBadge')} : already ? {t('alreadyInstalled')} : busy ? : queued ? ( ) : blocked ? ( ) : ( )}
{p.deprecated === true && (
⚠️ {t('deprecatedWarn')} {replacement !== undefined && ( {t('replacementHint') + ' ' + replacement.name} )}
)}
{hostRequirementLabel} {pluginCategories(p).map(category => ( {(data!.categories[category] && (data!.categories[category]![lang] || data!.categories[category]!.en)) || category} ))}
{/* Published date and a source link used to live here too — both redundant now that the title itself opens the repo, and the date/tag pair alone was long enough in English to wrap onto its own line, splitting one card's footer into two visual rows. */} {renderFavoriteControl(p.url)}
{busy && (
{progressText} {progressPct !== null && {progressPct}%}
)}
) } const installedNameOf = (p: RegistryPlugin) => matchInstalledName(p, installed, repoIdentities, data?.plugins, repoHints) // Plugins loaded at boot (bundle-layer skins) aren't in the shim list but // are just as live; the boot manifest is the page's own record of them. const bootEntries = (typeof window !== 'undefined' && window.__DSH_BOOT__ && Array.isArray(window.__DSH_BOOT__.entries)) ? window.__DSH_BOOT__.entries : [] // Theme-native card: visual preview first, then identity and lifecycle. // This deliberately does not reuse pluginCard; repository metadata is // useful context here, but it must not outrank the theme itself. const themePluginCard = (p: RegistryPlugin) => { const instName = installedNameOf(p) const desc = (p.description && (p.description[lang] || p.description.en)) || '' const replacement = replacementOf(p) const done = doneUrls.includes(p.url) || hotUrls.includes(p.url) const busy = busyUrl === p.url const record = recordForUrl(records, p.url) const blocked = record !== null && (record.state === 'input' || record.state === 'failed') const themeQueued = record !== null && record.state === 'queued' // A theme switched off via the Installed-tab toggle (or a group switch) // stays in the boot manifest, so the disabled set must veto the badge. const mounted = instName !== null && (skins.includes(instName) || bootEntries.some(e => e.id === instName)) && !effectiveDisabledSet.has(instName) return (
{pluginName(p.name)}
{p.owner} {typeof p.stars === 'number' && ( {'· ★ ' + formatCount(p.stars)} )}
{p.deprecated === true && {t('deprecatedBadge')}} {mounted && {t('themeActive')}} {instName !== null && !mounted && ( {effectiveDisabledSet.has(instName) ? t('disabledState') : t('alreadyInstalled')} )}

{desc}

{p.deprecated === true && (
⚠️ {t('deprecatedWarn')} {replacement !== undefined && ( {t('replacementHint') + ' ' + replacement.name} )}
)}
{renderFavoriteControl(p.url)} {instName === null && ( {done ? t('installedBadge') : t('notInstalled')} )}
{instName === null ? done ? {t('installedBadge')} : busy ? : themeQueued ? ( ) : blocked ? ( ) : ( ) : ( <> {removingName === instName ? : } {mounted ? ( ) : } )}
{busy && (
{progressText} {progressPct !== null && {progressPct}%}
)}
) } const themeCard = (id: string, label: string, swatch: string[]) => { const active = themeSnap !== null && themeSnap.preference === id return (
{swatch.map((c, i) => )}
{label} {active ? {t('themeActive')} : ( )}
) } const categories = data === null ? [] : Object.keys(data.categories) useLayoutEffect(() => { setVisibleCats(null); setVisibleCatsOneRow(null) }, [lang, categories.length]) useLayoutEffect(() => { if (catsOpen || visibleCats !== null) return const el = catsWrapRef.current if (el === null) return const chips = [...el.children].filter((c): c is HTMLElement => (c as HTMLElement).dataset?.chip === '1') if (chips.length === 0) return const first = chips[0]! const rowThreeTop = first.offsetTop + (first.offsetHeight + 6) * 2 - 3 let fits = 0 for (const chip of chips) { if (chip.offsetTop < rowThreeTop) fits += 1 } // Reserve the tail slot of row two for the chevron itself. setVisibleCats(fits >= chips.length ? fits : Math.max(1, fits - 1)) // Same measuring pass, one row's worth instead of two — used only while // the sticky header is pinned during scroll (#188-adjacent request), to // shrink an OPEN multi-row category list down to its first row without // touching the user's actual open/closed choice. const rowTwoTop = first.offsetTop + first.offsetHeight + 6 - 3 let fitsOneRow = 0 for (const chip of chips) { if (chip.offsetTop < rowTwoTop) fitsOneRow += 1 } setVisibleCatsOneRow(fitsOneRow >= chips.length ? fitsOneRow : Math.max(1, fitsOneRow - 1)) }, [catsOpen, visibleCats, data]) useEffect(() => { if (catsSentinel === null || typeof IntersectionObserver === 'undefined') return const observer = new IntersectionObserver( ([entry]) => { const leftView = entry !== undefined && !entry.isIntersecting // Collapsing shrinks the sticky header, which shrinks the scrollable // content. When there is barely more content than viewport, that // makes scrollHeight drop below the current scroll position, the // browser CLAMPS scrollTop, the sentinel slides back into view, and // the row expands again — which grows the content, restores the // scroll, and starts over. Reported as the category bar flapping and // the list refusing to scroll (#266 by @hidge123), and reproduced // here: a filtered list went scrollTop 78 → 0 and snapped straight // back from one row to four. // // The guard is not a tuning constant, it is the feature's own // precondition: collapsing exists to reclaim vertical space while // scrolling a LONG list. If the scroller has less overflow than the // category row could give back, collapsing buys nothing and can only // start the loop, so it does not happen. Long lists — the case this // was built for — are unaffected. const root = bodyRef.current const wrap = catsWrapRef.current if (leftView && root !== null && wrap !== null) { const overflow = root.scrollHeight - root.clientHeight if (overflow <= wrap.offsetHeight) return } setCatsStuck(prev => (prev === leftView ? prev : leftView)) }, { root: bodyRef.current, threshold: 0 }, ) observer.observe(catsSentinel) return () => observer.disconnect() }, [catsSentinel]) // Drop any in-pin expand once the header unpins, so the next pin starts // collapsed without a rising-edge setState in the observer. useEffect(() => { if (!catsStuck) setStuckExpanded(false) }, [catsStuck]) /** Expanded chips + chevron share one value. Stuck uses `stuckExpanded` * so pinning collapses without rewriting `catsOpen` in a layout effect * (see stuckExpanded state). Leaving stuck falls back to `catsOpen`, * which still holds the pre-pin / in-pin user choice. */ const catsExpanded = catsStuck ? stuckExpanded : catsOpen /** * A fresh install (hotUrls/hotNames) and a toggle/group action * (refreshNames) both end in the same place — "reload the page" — and * used to render as two near-identical banners stacked on top of each * other when both happened in one session (reported as "为啥有三个状态横幅 * 啊,太奇怪了"). They're merged into one count and one banner; only the * restart banner (a full host restart, a different action entirely) stays * separate. */ const pendingRefreshNames = useMemo( () => [...new Set([...hotNames, ...refreshNames])], [hotNames, refreshNames], ) /** Installed plugins the market itself cannot group (#60). */ const groupableNames = Object.keys(installed).filter(name => name !== 'dsh-market' && name !== 'dshmarket') /** Names already inside some group; everything else shows under "ungrouped". */ const groupedNames = useMemo(() => new Set(Object.values(groups).flat()), [groups]) const ungroupedNames = groupableNames.filter(name => !groupedNames.has(name)) const groupQuery = qInstalled.trim().toLowerCase() const matchesInstalledQuery = useCallback((name: string) => { if (groupQuery === '') return true if (name.toLowerCase().includes(groupQuery)) return true const note = notes[name] if (note !== undefined && note.toLowerCase().includes(groupQuery)) return true const spec = installed[name] if (spec !== undefined && String(spec).toLowerCase().includes(groupQuery)) return true if (data !== null && spec !== undefined) { const entry = catalogEntryForInstalled(data.plugins, name, String(spec), repoIdentities[name], repoHints[name]) const desc = (entry?.description && (entry.description[lang] || entry.description.en)) || '' if (desc.toLowerCase().includes(groupQuery)) return true } return false }, [data, groupQuery, installed, lang, notes, repoHints, repoIdentities]) const visibleUngrouped = ungroupedNames.filter(matchesInstalledQuery) const visibleGroupIds = groupOrder.filter(gid => { if (groupQuery === '') return true if (gid.toLowerCase().includes(groupQuery)) return true return (groups[gid] ?? []).some(matchesInstalledQuery) }) /** Installed package names the catalog classifies as themes (client-side * mirror of the server's classification; themes are exclusive per group). */ const installedThemeNames = useMemo(() => { const names = new Set() if (data === null) return names for (const [name, spec] of Object.entries(installed)) { const entry = catalogEntryForInstalled(data.plugins, name, String(spec), repoIdentities[name], repoHints[name]) if (entry !== undefined && pluginCategories(entry).includes('theme')) names.add(name) } return names }, [data, installed, repoIdentities, repoHints]) const openThemePanel = useCallback((group: string) => { const current = (groups[group] ?? []).find(name => installedThemeNames.has(name)) ?? null setAddPanel(null) setRenamingGroup(null) setDeletingGroup(null) setGroupMenuFor(null) setThemePick(current) setThemePanel(group) }, [groups, installedThemeNames]) /** Set or clear the single theme slot. The previous theme leaves this group. */ const applyGroupTheme = useCallback((group: string, themeName: string | null) => { const members = groups[group] ?? [] const without = members.filter(name => !installedThemeNames.has(name)) const next = themeName === null ? without : [...without, themeName] void doGroupAction({ action: 'set-members', name: group, members: next }).then(ok => { if (ok) setThemePanel(null) }) }, [doGroupAction, groups, installedThemeNames]) return (

{t('nav')}

{/* A quiet pointer back to the project — most visitors reach the market through a client that embeds it, with no other way to find the repo it came from. */} dsh-market {version !== null && v{version}} {(() => { const self = installed['dshmarket'] !== undefined ? 'dshmarket' : 'dsh-market' const status = updates[self] return status && status.updateAvailable && !updatedNames.includes(self) && !ignoredUpdateSet.has(self) && ( ) })()} {reminderBatchUpdatableNames.length >= 1 && ( )} {bootId !== null && reminderUpdateNames.length > 0 && ( )}
{t('subtitle')} {t('submitPlugin')}
{themeSnap !== null && } {/* In the tab row, not above the grid: paginating, searching and switching tab all leave it — and any pending decision — in place. */} setRecords(list => clearSettled(list))} onCancel={() => doCancel()} onDismiss={record => setRecords(list => drop(list, record.id))} onRefresh={() => location.reload()} onResolveConflict={resolveConflict} onForceInstall={(record) => { // Same road as any other install, with the one flag that says // the user has seen the hold and wants the release anyway. The // record is dropped first so the retry replaces it rather than // sitting beside a row that already said "installed". const plugin = record.url === undefined ? undefined : data?.plugins.find(p => p.url === record.url) if (plugin === undefined) return setRecords(list => drop(list, record.id)) doInstall(plugin, true) }} onRunNow={runQueuedNow} onApproveBuilds={(record) => { const names = record.blockedBuilds ?? [] if (names.length === 0) return setRecords(list => drop(list, record.id)) const plugin = record.url === undefined ? undefined : data?.plugins.find(p => p.url === record.url) approveAndRetry(names, () => { if (plugin !== undefined) doInstall(plugin) else doUpdate(record.name, false, false) }) }} />
{/* Backup & Restore and Diagnostics sit under Advanced rather than as their own top-level tabs — most users never need either, and having five peers up top buried the ones people actually reach for. */} {(tab === 'backup' || tab === 'diagnostics') && (
)} {!envReady && (
{envFailed ? t('envFixFail') : t('envMissing')} {!envFailed && ( )}
)} {backupMessage !== null &&
{backupMessage}
} {restoreErrors.length > 0 && (
{t('restorePartial')}
{restoreErrors.map(error =>
{error}
)}
)} {tab === 'installed' && pendingBackup !== null && (
{t('restoreMissing').replace('{0}', String(missingRestoreCount))}
)} {pendingRefreshNames.length > 0 && (
{pendingRefreshNames.length} {t('refreshBanner')}
)} {pendingRestart > 0 && (
{pendingRestart} {t('restartBanner')} {restartEnabled && debuggerLatch === null && recovery === null && ( )} {/* Only the standing host-reported notice is dismissible: a banner for something you just did in this session should not be swipeable away mid-flow. */} {showHostPending && ( )}
)} {activationWarnings.length > 0 && (
{activationWarnings.map(({ name, info }) => (
{name} — {activationMeta(info.state, t, info.dependencyOf).label} {info.reasons.length > 0 && ({localizeBilingualList(info.reasons, lang)})}
))}
)} {tab === 'installed' && } {tab === 'installed' && brokenPluginNames.length > 0 && (
{brokenPluginNames.map(name => (
{t('brokenPluginTitle').replace('{0}', name)} {t('brokenPluginBody')}
{/* Same road the replacement hint takes: search for it and land on the catalog, where Install does the right thing. */}
))}
)}
{buildsSkipped !== null && (
{t('buildsSkipped')} {buildsSkipped.names.join(', ')}
)} {compatibilityNotice !== null && (
{/* Two independent findings share one banner and one rollback, because they came from one operation. Each is named for what it actually is: a peer-version risk and a loader-name collision are not the same problem and must not read as one. */} {compatibilityNotice.risks.length > 0 && ( <>{t(compatibilityNotice.rollbackId === undefined ? 'compatRiskBannerNoRollback' : 'compatRiskBanner')} {compatibilitySummary(compatibilityNotice.risks)} )} {compatibilityNotice.shadowedNames !== undefined && compatibilityNotice.shadowedNames.length > 0 && ( <> {compatibilityNotice.risks.length > 0 && ' · '} {t('shadowNameBanner')} {shadowSummary(compatibilityNotice.shadowedNames)} )} {compatibilityNotice.brokenBundles !== undefined && compatibilityNotice.brokenBundles.length > 0 && ( <> {(compatibilityNotice.risks.length > 0 || (compatibilityNotice.shadowedNames?.length ?? 0) > 0) && ' · '} {t('brokenBundleBanner')}{' '} {compatibilityNotice.brokenBundles.map(entry => entry.name).join(', ')} )} {compatibilityNotice.rollbackId === undefined ? {compatibilityNotice.rollbackUnavailable ? localizeBilingual(compatibilityNotice.rollbackUnavailable, lang) : t('rollbackUnavailable')} : ( )}
)} {installError !== null && (
{localizeBilingual(installError, lang)}
{/* The new option beside the failure: when the restart this page asked for never came back, every other action here is beside the point — what the user needs is the list of plugins and the ones DSH blamed, which the recovery surface is holding. */} {recovery !== null && ( )} {/* Primary, because the banner's own words point at it ("点 「立即更新」不再等待") and it is the way out of the wait. With the default variant it inherited the banner's 12px red text and sat flush against the export button, which is why #410 was reported as "there is no such button" — @Dave-12138 spotted it in the reporter's own screenshot: it was there, it just did not look like one. */} {staleName !== null && ( )} {/* The banner text told users to export the log; now it IS the button (#84). */}
)}
{ const show = e.currentTarget.scrollTop > 400 setShowTop(prev => (prev === show ? prev : show)) }} > {tab === 'backup' ? (

{t('backupLocal')}

{t('backupHint')}

{t('credsWarning')}

{ const file = event.currentTarget.files?.[0] event.currentTarget.value = '' if (file !== undefined) file.text().then(text => previewBackup(JSON.parse(text))).catch(error => setBackupMessage(String(error))) }} />

{t('webdav')}

setPresetOpen(false)} onSelect={id => { const urls: Record = { jianguoyun: 'https://dav.jianguoyun.com/dav/dsh-profile-backup.json', koofr: 'https://app.koofr.net/dav/Koofr/dsh-profile-backup.json', nextcloud: 'https://nextcloud.example/remote.php/dav/files/USERNAME/dsh-profile-backup.json', } if (urls[id] !== undefined) setWebdavUrl(urls[id]!) }} align="start" anchor={( )} items={[ { id: 'custom', label: t('webdavPreset') }, { id: 'jianguoyun', label: '坚果云 / Nutstore' }, { id: 'koofr', label: 'Koofr' }, { id: 'nextcloud', label: 'Nextcloud' }, ]} /> } type="url" value={webdavUrl} placeholder={t('webdavUrl')} onChange={e => setWebdavUrl(e.target.value)} /> setWebdavUser(e.target.value)} /> setWebdavPassword(e.target.value)} />
{labelledCheckbox({ label: t('autoBackup'), checked: autoBackup, className: css.backupCheck, onChange: setAutoBackup, })}

{t('webdavNote')}

{t('credsWarning')}

{t('gist')}

setGistToken(e.target.value)} /> } value={gistId} placeholder={t('gistId')} onChange={e => setGistId(e.target.value)} />
{gistResult !== null && (

{t('gistCreated')}{' '} {gistResult.gistUrl}

)} {gistMessage !== null && (
{gistMessage}
)}

{t('gistNote')}

) : tab === 'discover' ? loadError !== null ?
{t('loadFail')}
{loadError}
: data === null ?
{t('loading')}
: ( <>
{/* The height cap belongs to the MEASURING pass only: that pass renders every chip so their offsets can be counted, and clipping hides the tall row from the user for the frame it exists. Applying it while OPEN clipped the very rows the user had just asked to see — with 20 categories, expanding revealed two rows out of six and read as "nothing happened". Collapsed after measuring needs no cap: the list is already sliced to what fits. */}
{(() => { // Collapsed, the selected category is pulled to the front so it never hides. // Whenever collapsed (default, or auto-collapsed by the sticky // header going stuck — see catsExpanded / stuckExpanded), a stuck // header uses the one-row budget instead of the two-row one so an // already-open list that just got pinned shrinks further. const budget = catsStuck ? visibleCatsOneRow : visibleCats const ordered = orderedCategories(categories, cat, catsExpanded, budget) const shown = catsExpanded || budget === null ? ordered : ordered.slice(0, Math.max(0, budget - 1)) return ( <> setCat('all')}>{t('all') + ' (' + formatCount(data!.count) + ')'} {shown.map(id => ( setCat(id)} >{(data.categories[id] && (data.categories[id]![lang] || data.categories[id]!.en)) || id} ))}
{compatibleWithHost && typeof hostVersion === 'string' && (
{hostCompatibilityPending > 0 || loadedHostPackages < allHostPackages.length ? t('hostFilterLoading') .replace('{0}', String(loadedHostPackages)) .replace('{1}', String(allHostPackages.length)) : t('hostFilterActive').replace('{0}', hostVersion)}
)}
{plugins.length === 0 ?
{t('empty')}
: ( <> )} ) : tab === 'favorites' ? data === null ?
{t('loading')}
: favoriteUrls.length === 0 ?
{t('favoritesEmpty')}
: ( <>
{favoriteListed.length === 0 ? favoritesAllStale ? (
{t('favoritesStaleEmpty')}
) :
{t('empty')}
: ( <> {favoriteStale.length > 0 && (
{t('favoritesStaleNote').replace('{0}', String(favoriteStale.length))}
)}
{t('favoritesResultCount').replace('{0}', String(favoriteListed.length))}
{favoritePlugins.length > 0 && ( <>

{t('favoritesPluginsSection').replace('{0}', String(favoritePlugins.length))}

)} {favoriteThemes.length > 0 && ( <>

{t('favoritesThemesSection').replace('{0}', String(favoriteThemes.length))}

{favoritePageThemes.map(themePluginCard)}
)} )} ) : tab === 'themes' && themeSnap !== null ? ( <>
{/* Light/dark/system live in the official Appearance setting; this tab only shows what that setting can't: registered third-party palettes (none in the wild yet) and installable theme plugins. */} {(() => { const extra = themeSnap.themes.filter(def => def.id !== 'light' && def.id !== 'dark') return extra.length > 0 && (
{extra.map(def => themeCard(def.id, def.id, themeSwatch(def)))}
) })()} {data === null ?
{t('loading')}
: anyThemePlugins.length === 0 ?
{t('themeEmpty')}
: themePlugins.length === 0 ?
{t('empty')}
: ( <>
{t('themeResultCount').replace('{0}', String(themePlugins.length))}
{themePagePlugins.map(themePluginCard)}
)} ) : tab === 'diagnostics' ? : ( <>
{installedView === 'groups' && ( creatingGroup ? (
{ const next = event.relatedTarget if (next instanceof Node && event.currentTarget.contains(next)) return cancelCreateGroup() }} > setNewGroupName(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') doCreateGroup(); if (e.key === 'Escape') cancelCreateGroup() }} autoFocus />
) : )}
{installedView === 'groups' ? ( <> {groupQuery === '' && groupOrder.length === 0 ?
{t('noGroups')}
: visibleGroupIds.map(gid => { const members = groups[gid] ?? [] const nameHit = groupQuery !== '' && gid.toLowerCase().includes(groupQuery) const visibleMembers = (groupQuery === '' || nameHit ? members : members.filter(matchesInstalledQuery) ).slice().sort((a, b) => Number(installedThemeNames.has(b)) - Number(installedThemeNames.has(a))) const themeSlot = visibleMembers.some(name => installedThemeNames.has(name)) && visibleMembers.some(name => !installedThemeNames.has(name)) if (groupQuery !== '' && !nameHit && visibleMembers.length === 0) return null const sw = groupSwitchState(members, effectiveDisabledSet) const enabledCount = members.filter(member => !effectiveDisabledSet.has(member)).length const collapsed = groupQuery === '' && collapsedGroups.has(gid) const meta = t('groupMembersMeta') .replace('{0}', String(members.length)) .replace('{1}', String(enabledCount)) return (
{/* Deliberately NOT onOffSwitch: a group's state is three-valued (all on / all off / mixed), and the host's Switch is a boolean control — routing this through it would flatten "mixed" into one of the two, which is the one thing this switch must not say. */}
{gid} {meta} {sw === 'mixed' && {t('groupMixed')}}
{deletingGroup === gid ? ( <> ) : ( setGroupMenuFor(null)} onSelect={id => { setGroupMenuFor(null) if (id === 'rename') { setAddPanel(null) setDeletingGroup(null) setRenamingGroup(gid) setRenamingValue(gid) } else if (id === 'delete') { setAddPanel(null) setRenamingGroup(null) setDeletingGroup(gid) } }} align="end" portal anchor={( )} items={[ { id: 'rename', label: t('groupRename') }, { id: 'delete', label: t('groupDelete') }, ]} /> )}
{!collapsed && (
{members.length === 0 &&
{t('groupEmpty')}
} {visibleMembers.map(member => (
{member} {installedThemeNames.has(member) && · {t('groupThemeBadge')}} {effectiveDisabledSet.has(member) && {t('disabledState')}} {onOffSwitch({ label: (effectiveDisabledSet.has(member) ? t('enable') : t('disable')) + ' ' + member, on: !effectiveDisabledSet.has(member), disabled: togglingName !== null, toggle: () => doToggle(member, effectiveDisabledSet.has(member)), })}
))}
)}
) })} {(groupQuery === '' || visibleUngrouped.length > 0) && (
{t('ungrouped')} {t('groupMembersMeta') .replace('{0}', String((groupQuery === '' ? ungroupedNames : visibleUngrouped).length)) .replace('{1}', String((groupQuery === '' ? ungroupedNames : visibleUngrouped).filter(name => !effectiveDisabledSet.has(name)).length))}
{(groupQuery === '' ? ungroupedNames : visibleUngrouped).length === 0 ?
{t('installedEmpty')}
: visibleUngrouped.map(name => { const entry = data === null ? undefined : catalogEntryForInstalled(data.plugins, name, String(installed[name]), repoIdentities[name], repoHints[name]) const off = effectiveDisabledSet.has(name) const act = activations[name] const meta = !off && act !== undefined ? activationMeta(act.state, t, act.dependencyOf) : null const note = notes[name] const authored = (entry?.description && (entry.description[lang] || entry.description.en)) || '' const shown = note ?? authored const stateLabel = off ? t('disabledState') : act?.state === 'inert' && act.dependencyOf === undefined ? t('groupStateInert') : act?.state === 'restart' ? t('groupStateRestart') : act?.state === 'broken' ? t('groupStateBroken') : meta?.label const stateDot = off ? 'warning' as const : meta?.dot === 'error' ? 'error' as const : meta?.dot === 'warning' ? 'warning' as const : 'done' as const const stateClass = stateDot === 'error' ? css.actBroken : stateDot === 'warning' ? css.actWarn : css.actLive return (
{name} {entry?.deprecated === true && {t('deprecatedBadge')}} {installedThemeNames.has(name) && · {t('groupThemeBadge')}} {stateLabel !== undefined && ( {stateLabel} )} {shown !== '' && ( {shown} )}
setAssignFor(null)} onSelect={id => { const blocked = installedThemeNames.has(name) && (groups[id] ?? []).some(member => installedThemeNames.has(member)) if (blocked) return setAssignFor(null) doAssign(name, id) }} align="end" portal anchor={( )} items={groupOrder.map(gid => { const blocked = installedThemeNames.has(name) && (groups[gid] ?? []).some(member => installedThemeNames.has(member)) return { id: gid, disabled: blocked, label: blocked ? gid + ' · ' + t('groupThemeTaken') : gid, } })} />
) })}
)} {groupQuery !== '' && visibleGroupIds.length === 0 && visibleUngrouped.length === 0 && (
{t('groupSearchEmpty')}
)}

{t('groupOrgHint')}

) : orderedInstalledEntries.length === 0 ?
{t('installedEmpty')}
: ( { const needle = qInstalled.trim().toLowerCase() if (needle === '') return true if (name.toLowerCase().includes(needle)) return true if (String(spec).toLowerCase().includes(needle)) return true const entry = data === null ? undefined : catalogEntryForInstalled(data.plugins, name, String(spec), repoIdentities[name], repoHints[name]) if (entry !== undefined) { const desc = (entry.description && (entry.description[lang] || entry.description.en)) || '' if (desc.toLowerCase().includes(needle)) return true if ((entry.owner || '').toLowerCase().includes(needle)) return true } return false })} render={([name, spec]) => { const missing = pendingBackup !== null && !installedFiles.includes(name) const entry = data === null ? undefined : catalogEntryForInstalled(data.plugins, name, String(spec), repoIdentities[name], repoHints[name]) const status = updates[name] // A generation is the desktop host's own install (#497): // the host updates it, the market only says a newer // release exists. Not a development checkout, so no // "local" tag and no restore — the host would put the // generation straight back. const generation = status?.kind === 'generation' || isGenerationSpec(String(spec)) const localDev = !generation && (/^(?:link|file):/i.test(String(spec)) || status?.kind === 'linked') const act = activations[name] const meta = act !== undefined ? activationMeta(act.state, t, act.dependencyOf) : null const version = status && status.version ? 'v' + status.version : '' const specText = String(spec) // A plain range beside the resolved version says the // same thing twice. Every other spec — github:, file:, // link:, a tag — is the only place the row says where // the plugin came from, so it stays. const specRedundant = version !== '' && /^[\^~]?\d/.test(specText) const ghSpec = /^github:([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+?)(?:#|$)/.exec(specText) const repoUrl = entry !== undefined ? entry.url : ghSpec !== null ? 'https://github.com/' + ghSpec[1] : null const off = effectiveDisabledSet.has(name) // Switches only where they make sense: everything in // the disable list (to re-enable), plus live/restart // states. inert/broken rows keep their diagnosis // without a misleading toggle (#60). const toggleable = off || (act !== undefined && (act.state === 'live' || act.state === 'restart')) return (
{/* Row-scoped, NOT `.nm` alone: `.nm` clips with overflow+ellipsis as one block, so with the name and the version as inline siblings the ellipsis landed at the end of the LINE and ate the version — a long scoped package name hid the one fact this row exists to state (#257 by @HualuozhE). Laying the row out as flex lets the name be the only thing that truncates. `.nm` is shared by six other places (discover titles, group rows, theme cards); changing it there would reflow all of them. */}
{/* The name is the link to the README. A separate button beside it pointed at the same page. */} {repoUrl !== null ? {name} : name} {entry?.deprecated === true && {t('deprecatedBadge')}} {version && {version}}
{localDev && ( {t('linkedDev')} )}
{specRedundant ? null : repoUrl !== null ? {specText} :
{specText}
} {/* The user's own note REPLACES the author's description (#347): a catalog blurb answers "what is this", written for strangers and often not in the reader's language, and cannot answer "why did I install this" — which is what someone with forty plugins is asking. The original stays one click away rather than being lost. */} {notingName === name ? (
setNoteDraft(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') saveNote(name, noteDraft) if (e.key === 'Escape') setNotingName(null) }} />
) : (() => { const note = notes[name] const authored = (entry?.description && (entry.description[lang] || entry.description.en)) || '' const theirs = showTheirs.includes(name) const shown = note !== undefined && !theirs ? note : authored return (
{shown !== '' && ( {shown} )} {note !== undefined && authored !== '' && ( )}
) })()} {/* Update-notes entry (#294). Only a row with an update pending renders it — a plugin that is up to date has nothing to preview — and it is one quiet line in the flow the row already reserves for conditional content, so rows without it are pixel-identical to before. */} {isPluginUpdatable(name, String(spec), status, []) && (
{bootId !== null && ( ignoredUpdateSet.has(name) ? {t('updateNoticeIgnored')} : ( ) )}
)} {!off && act !== undefined && meta !== null && (
{/* Only a state the switch does NOT already show earns a line here: "installed but not active" is news, "live" is what the switch is for. */} {meta.dot !== 'done' && ( {meta.label} )} {act.state !== 'live' && act.reasons.length > 0 && ( } title={t('actWhy')} open={whyOpen === name} expandable expandOnRowClick onToggle={() => setWhyOpen(whyOpen === name ? null : name)} className={css.actWhy} >
{localizeBilingualList(act.reasons, lang)}
)}
)} {entry !== undefined && entry.deprecated === true && (
⚠️ {t('deprecatedWarn')} {entry.replacement !== undefined && ( {t('replacementHint') + ' ' + entry.replacement} )}
)} {updatingName === name && (
{progressText} {progressPct !== null && {progressPct}%}
)}
{/* At half width the identity and the controls cannot share a line, so the row is two stacked bands. Left as one wrapping line, neighbouring cards broke at different points and stopped lining up. The market itself never reaches this row (filtered out above — it manages itself from its own settings card), so no self-toggle special case is needed. */}
{/* Dot + tag, the pairing the host's own plugin inventory uses for exactly this state. */} {!missing && ( {off ? t('disabledState') : t('switchOnLabel')} )} {toggleable && ( onOffSwitch({ label: (off ? t('enable') : t('disable')) + ' ' + name, on: !off, disabled: togglingName !== null || busyUrl !== null || updatingName !== null || removingName !== null, toggle: () => doToggle(name, off), }) )} {/* State and switch pack left, the operations pack right: with everything in one flow the switch's x depended on whether the update slot rendered a button or a tag. */} {entry !== undefined && entry.deprecated === true && entry.replacement !== undefined && (() => { const replacement = data?.plugins.find(r => r.name === entry.replacement) if (replacement === undefined) return null return ( <> {!isInstalled(replacement, catalogInstalled, repoIdentities, data?.plugins, repoHints) && ( )} ) })()} {/* Status slot and Uninstall wrap as ONE unit. As sibling children of a wrapping flex row they broke apart independently, leaving the tag on one line and the button on the next (#242 by @Ztyss). Nested, the pair either fits or moves together, and the tag — already ellipsizing since #234 — is what gives up width first. */} {!missing && status?.sourceMigration !== undefined && ( )} {missing ? {t('notInstalled')} : updatedNames.includes(name) ? {act?.state === 'live' ? t('updatedLive') : t('updated')} : updatingName === name ? : status !== undefined && generation && status.latest != null ? {t('hostUpdateReady').replace('{0}', status.latest)} : status && status.updateAvailable ? ( ) : localDev ? ( ) : {t('upToDate')}} {!missing && name !== 'dsh-market' && name !== 'dshmarket' && ( removingName === name ? : ( ) )}
) }} /> )} )}
{showTop && ( )} {renamingGroup !== null && ( { setRenamingGroup(null); setRenamingValue('') }} title={t('groupRenameTitle')} description={t('groupRenameHint')} footer={( <> )} >
setRenamingValue(e.target.value)} onFocus={e => e.currentTarget.select()} onKeyDown={e => { if (e.key === 'Enter') doRenameGroup(renamingGroup) }} autoFocus />
)} {addPanel !== null && (() => { const members = groups[addPanel] ?? [] const pluginCandidates = ungroupedNames.filter(name => !installedThemeNames.has(name) && !members.includes(name)) const needle = addQuery.trim().toLowerCase() const candidates = needle === '' ? pluginCandidates : pluginCandidates.filter(name => name.toLowerCase().includes(needle)) return ( setAddPanel(null)} title={t('groupAddTitle').replace('{0}', addPanel)} footer={( <> {t('groupAddSelected').replace('{0}', String(addSelected.length))} )} >
{candidates.length === 0 ?

{t('groupAddEmpty')}

: (
{candidates.map(name => ( ))}
)}

{t('groupAddHint')}

) })()} {themePanel !== null && (() => { const members = groups[themePanel] ?? [] const current = members.find(name => installedThemeNames.has(name)) ?? null const choices = [ ...(current !== null ? [current] : []), ...ungroupedNames.filter(name => installedThemeNames.has(name)), ] return ( setThemePanel(null)} title={t('groupThemeTitle').replace('{0}', themePanel)} footer={( <> {current !== null && ( )} )} >

{t('groupThemeHint')}

{choices.length === 0 ?

{t('groupThemeEmpty')}

: (
{choices.map(name => ( ))}
)}
) })()} {confirming !== null && ( { setConfirming(null); setCmdOpen(false) }} title={t('confirmTitle') + ' ' + confirming.name + '?'} footer={( <> )} > {/* The detail dialog has to show at LEAST what the card already does — owner, version, downloads, stars, published date, category — a "detail" view that shows less than the summary it opened from is backwards. */}
{confirming.owner} {typeof confirming.stars === 'number' && ( {'· ★ ' + formatCount(confirming.stars)} )} {pluginCategories(confirming).map(category => ( {(data!.categories[category] && (data!.categories[category]![lang] || data!.categories[category]!.en)) || category} ))}
{confirming.added &&
{t('published') + ' ' + confirming.added}
} {downloadStatsText(confirming, t) !== null && (
{downloadStatsText(confirming, t)}
)} {/* The Modal primitive's own `description` prop is sized for a one-line subtitle under the title — a full plugin description rendered there read as an oversized heading, not body text (reported on a real host). Rendering it here, at the card's own size, also matches the card's own reading order: name, byline, description, then screenshots. */} {capabilityDetail(confirming)} } title={t('cmdDetails')} open={cmdOpen} expandable expandOnRowClick onToggle={() => setCmdOpen(o => !o)} >
{confirming.install}
{looksTerminal(confirming, lang) && (

{' ' + t('terminalWarn') + ' '} {t('readme')}

)} {confirming.deprecated === true && (() => { const replacement = replacementOf(confirming) return (
⚠️ {t('deprecatedWarn')} {replacement !== undefined && ( {t('replacementHint') + ' ' + replacement.name} )}
) })()}

{' ' + t('confirmWarn')}

)} {recovery !== null && ( setRecoveryKeep(current => ({ ...current, [name]: on }))} onApply={applyRecoveryChoice} onClose={() => setRecoveryOpen(false)} t={t} /> )} {commentsFor !== null && ( setCommentsFor(null)} t={t} /> )} {lightbox !== null && ( setLightbox(null)} t={t} /> )} {migrationConfirm !== null && ( setMigrationConfirm(null)} title={t('migrateTitle')} description={t('migrateDescription')} footer={( <> )} >
{t('migrateCurrentSource')} {migrationConfirm.name}: {migrationConfirm.source}
↓
{t('migrateTargetSource')} {migrationConfirm.target}

{t('migrateWarning')}

)} {removeConfirm !== null && ( setRemoveConfirm(null)} title={t('uninstall') + ' ' + removeConfirm + '?'} description={t('uninstallConfirmDesc')} footer={( <> )} /> )} {restoreConfirm !== null && ( setRestoreConfirm(null)} title={`${t('restoreOnline')} ${restoreConfirm.name}?`} // An unverified match is named for what it is (#485). The local // copy declares no repository, so the only thing that agrees with // the catalog entry below is the package name — and the owner on // that line may be a stranger, not the author of this checkout. description={`${restoreConfirm.verified ? t('restoreHint') : t('restoreNameOnlyHint')}\n\n${restoreConfirm.entry.owner} · ${restoreConfirm.entry.url}`} footer={( <> )} /> )} {hostIncompatible !== null && ( setHostIncompatible(null)} title={t('hostIncompatibleTitle')} description={[ t(hostIncompatible.kind === 'install' ? 'hostIncompatibleBodyInstall' : 'hostIncompatibleBody') .replace('{plugin}', `${hostIncompatible.name} ${hostIncompatible.version}`.trim()) .replace('{requirement}', hostIncompatible.requirement ?? t('hostIncompatibleUnknown')) .replace('{host}', hostIncompatible.hostVersion ?? t('hostIncompatibleUnknown')), // The way out, said in the same breath as the refusal: what the // user can install instead of a version this host cannot run. hostIncompatible.npmName === null ? null : findingCompat.status === 'loading' ? t('hostIncompatibleSearching') : findingCompat.status === 'found' ? t(hostIncompatible.kind === 'install' ? 'hostIncompatibleFoundInstall' : 'hostIncompatibleFoundUpdate') .replace('{version}', findingCompat.version) : findingCompat.status === 'not-found' ? t('hostIncompatibleNoCompat') : findingCompat.status === 'error' ? t('hostIncompatibleSearchFailed') : null, ].filter((line): line is string => line !== null).join('\n')} footer={( <> {/* Staying put is the recommended action, so it is the primary one — the opposite of the usual dialog, because here the safe choice is to do nothing. */} {/* A release this host can actually run is the best outcome available, so it takes the primary seat — and `stay put` moves to outline rather than disappearing. The "anyway" button keeps the ghost seat: it is the only option here that asks the user to accept a broken host, and it must not look like the recommended one. */} {findingCompat.status === 'found' && ( )} {findingCompat.status === 'error' && hostIncompatible.npmName !== null && ( )} )} /> )} {restoreBlocked !== null && ( setRestoreBlocked(null)} title={`${t('restoreNoCatalogTitle')} — ${restoreBlocked.name}`} description={t(restoreBlocked.reason === 'repo-mismatch' ? 'restoreNoMatch' : 'restoreNoCatalog')} footer={( )} /> )} {notesFor !== null && ( setNotesFor(null)} className={notesState === 'ready' && updateNotes?.kind === 'release' ? css.notesModalWide : undefined} /* The host's Modal renders its title node verbatim; the hand-written primitives.d.ts narrows the prop to string, so this cast documents intent rather than defeating a runtime check. */ title={(notesFor.repoUrl !== null ? {notesFor.name} : notesFor.name) as unknown as string} footer={( )} > {/* The version line reads as versions when both ends are semver and as short shas when the plugin updates from git — a 40-char sha pair wraps the dialog into nonsense. */} {(notesFor.current !== null || notesFor.latest !== null) && (
{notesFor.current !== null && notesFor.current.length === 40 ? notesFor.current.slice(0, 7) : notesFor.current} → {notesFor.latest !== null && notesFor.latest.length === 40 ? notesFor.latest.slice(0, 7) : notesFor.latest}
)} {notesState === 'loading' &&
{t('loading')}
} {notesState === 'fail' &&
{t('notesLoadFail')}
} {notesState === 'ready' && updateNotes !== null && ( updateNotes.kind === 'release' ? (
{t('notesRelease')} {updateNotes.release.tag !== null && {' ' + updateNotes.release.tag}} {updateNotes.release.publishedAt !== null && {' · ' + updateNotes.release.publishedAt.slice(0, 10)}}
{/* Author-written markdown through the tiny converter: HTML is stripped first; headings, quotes, fences, bullets, bold, inline code, https links and allowlisted images only. */}
{renderMarkdown(updateNotes.release.body || t('notesNone'))}
) : updateNotes.kind === 'commits' ? (
{t('notesCommits')}
{!updateNotes.commits.found &&
{t('notesCommitsRecent')}
}
    {updateNotes.commits.items.map(c => (
  • {c.date !== null ? c.date.slice(0, 10) : ''} {mdInline(c.message)} {notesFor.repoUrl !== null && ( /* The commit itself on GitHub — the escape hatch when two lines of clamp hide exactly the detail wanted. */ {c.sha.slice(0, 7)} )}
  • ))}
) : updateNotes.kind === 'npm' ? (
{t('notesNpm')}
    {updateNotes.npmTimes.map(v => (
  • {v.date.slice(0, 10)} {v.version}
  • ))}
) :
{t('notesNone')}
)}
)} {restoreConfirmOpen && pendingBackup !== null && ( setRestoreConfirmOpen(false)} title={t('restoreConfirm')} footer={( <> )} /> )} {exportOpen && ( setExportOpen(false)} title={t('gistExportSelect')} description={t('gistExportHint')} footer={( <> )} > {exportOptions.length === 0 &&

{t('gistNoPlugins')}

} {exportOptions.length > 0 && ( <>
{exportOptions.map(name => ( ))}
{labelledCheckbox({ label: t('gistIncludeConfig'), checked: exportIncludeConfig, className: css.backupCheck, onChange: setExportIncludeConfig, })} {exportIncludeConfig &&

{t('credsWarning')}

} {exportError !== null &&

{exportError}

} )}
)} {/* Log-export feedback via the Toast primitive — body portal, so it never squeezes the subtitle row or the error banner. */} {exportState === 'done' && ( } onDone={exportToastDone} /> )} {exportState === 'fail' && ( } onDone={exportToastDone} /> )} {favoriteError !== null && ( } onDone={favoriteErrorDone} /> )} {toggled !== null && ( : } onDone={toggledDone} /> )}
) }