Improve collector metadata progress and alignment

Stream completed metadata checks into the collector while preserving generation-based stale-result protection. Derive row statuses from verified availability, move analysis feedback into the sidebar, center table values under their headers, and keep the header synchronized during horizontal scrolling.
This commit is contained in:
Sucukdeluxe
2026-08-26 16:05:09 +02:00
parent 0ba87c37ec
commit eed8d1e970
15 changed files with 309 additions and 57 deletions
+5 -2
View File
@@ -1001,8 +1001,11 @@ export class AppController {
return prepareCollectorContainers(filePaths, addedAt); return prepareCollectorContainers(filePaths, addedAt);
} }
public enrichCollectorPackages(request: CollectorEnrichmentRequest): Promise<CollectorInspectionResult> { public enrichCollectorPackages(
return enrichCollectorPackages(request, this.settings); request: CollectorEnrichmentRequest,
onProgress?: (result: CollectorInspectionResult) => void
): Promise<CollectorInspectionResult> {
return enrichCollectorPackages(request, this.settings, {}, onProgress);
} }
public async addContainers(filePaths: string[]): Promise<{ addedPackages: number; addedLinks: number }> { public async addContainers(filePaths: string[]): Promise<{ addedPackages: number; addedLinks: number }> {
+61 -13
View File
@@ -27,10 +27,42 @@ export interface CollectorInspectionDependencies {
checkDdownload?: typeof checkDdownloadOnline; checkDdownload?: typeof checkDdownloadOnline;
checkOneFichier?: (links: string[]) => Promise<Map<string, OneFichierCheckResult>>; checkOneFichier?: (links: string[]) => Promise<Map<string, OneFichierCheckResult>>;
checkRapidgator?: typeof checkRapidgatorOnline; checkRapidgator?: typeof checkRapidgatorOnline;
resolveFilenames?: (links: string[]) => Promise<Map<string, string>>; resolveFilenames?: (links: string[], onResolved?: (link: string, fileName: string) => void) => Promise<Map<string, string>>;
importContainers?: typeof importDlcContainers; importContainers?: typeof importDlcContainers;
} }
function collectorProgressResult(packages: CollectorPackage[], urls: ReadonlySet<string>): CollectorInspectionResult {
const fragments = packages.flatMap((pkg) => {
const links = pkg.links.filter((link) => urls.has(link.url)).map((link) => ({ ...link }));
return links.length > 0 ? [{ ...pkg, links }] : [];
});
return { packages: regroupEnrichedPackages(fragments), invalidCount: 0, duplicateCount: 0 };
}
function createCollectorProgressEmitter(
packages: CollectorPackage[],
onProgress?: (result: CollectorInspectionResult) => void
): { queue: (url: string) => void; flush: () => void } {
const pending = new Set<string>();
let timer: ReturnType<typeof setTimeout> | null = null;
const flush = (): void => {
if (timer) clearTimeout(timer);
timer = null;
if (!onProgress || pending.size === 0) return;
const urls = new Set(pending);
pending.clear();
onProgress(collectorProgressResult(packages, urls));
};
return {
queue: (url) => {
if (!onProgress) return;
pending.add(url);
if (!timer) timer = setTimeout(flush, 100);
},
flush
};
}
interface PreparedSourceLink { interface PreparedSourceLink {
url: string; url: string;
fileName: string; fileName: string;
@@ -177,7 +209,8 @@ function regroupEnrichedPackages(packages: CollectorPackage[]): CollectorPackage
export async function enrichCollectorPackages( export async function enrichCollectorPackages(
request: CollectorEnrichmentRequest, request: CollectorEnrichmentRequest,
settings: AppSettings, settings: AppSettings,
dependencies: CollectorInspectionDependencies = {} dependencies: CollectorInspectionDependencies = {},
onProgress?: (result: CollectorInspectionResult) => void
): Promise<CollectorInspectionResult> { ): Promise<CollectorInspectionResult> {
const packages = request.packages.map((pkg) => ({ ...pkg, links: pkg.links.map((link) => ({ ...link })) })); const packages = request.packages.map((pkg) => ({ ...pkg, links: pkg.links.map((link) => ({ ...link })) }));
const linksByUrl = new Map(packages.flatMap((pkg) => pkg.links).map((link) => [link.url, link])); const linksByUrl = new Map(packages.flatMap((pkg) => pkg.links).map((link) => [link.url, link]));
@@ -195,9 +228,21 @@ export async function enrichCollectorPackages(
const checkOneFichier = dependencies.checkOneFichier ?? checkOneFichierLinks; const checkOneFichier = dependencies.checkOneFichier ?? checkOneFichierLinks;
const checkRapidgator = dependencies.checkRapidgator ?? checkRapidgatorOnline; const checkRapidgator = dependencies.checkRapidgator ?? checkRapidgatorOnline;
const checkDdownload = dependencies.checkDdownload ?? checkDdownloadOnline; const checkDdownload = dependencies.checkDdownload ?? checkDdownloadOnline;
const resolveFilenames = dependencies.resolveFilenames ?? ((links) => new DebridService(settings).resolveFilenames(links)); const resolveFilenames = dependencies.resolveFilenames ?? ((links, onResolved) => new DebridService(settings).resolveFilenames(links, onResolved));
const progress = createCollectorProgressEmitter(packages, onProgress);
const oneFichierPromise = oneFichierLinks.length > 0 const oneFichierPromise = oneFichierLinks.length > 0
? checkOneFichier(oneFichierLinks).catch(() => new Map<string, OneFichierCheckResult>()) ? checkOneFichier(oneFichierLinks).then((results) => {
for (const [url, result] of results) {
const link = linksByUrl.get(url);
if (!link) continue;
link.availability = result.online ? "online" : "offline";
link.status = result.online ? (result.fileName ? "ready" : "unknown") : "offline";
if (result.fileName) link.fileName = sanitizeFilename(result.fileName);
if (result.fileSizeBytes !== null && result.fileSizeBytes >= 0) link.fileSizeBytes = result.fileSizeBytes;
progress.queue(url);
}
return results;
}).catch(() => new Map<string, OneFichierCheckResult>())
: Promise.resolve(new Map<string, OneFichierCheckResult>()); : Promise.resolve(new Map<string, OneFichierCheckResult>());
const rapidgatorPromise = runWithConcurrency(rapidgatorLinks, 8, async (url) => { const rapidgatorPromise = runWithConcurrency(rapidgatorLinks, 8, async (url) => {
const result = await checkRapidgator(url).catch(() => null); const result = await checkRapidgator(url).catch(() => null);
@@ -207,6 +252,7 @@ export async function enrichCollectorPackages(
link.status = result.online ? (result.fileName ? "ready" : "unknown") : "offline"; link.status = result.online ? (result.fileName ? "ready" : "unknown") : "offline";
if (result.fileName) link.fileName = sanitizeFilename(result.fileName); if (result.fileName) link.fileName = sanitizeFilename(result.fileName);
if (result.fileSizeBytes !== null && result.fileSizeBytes >= 0) link.fileSizeBytes = result.fileSizeBytes; if (result.fileSizeBytes !== null && result.fileSizeBytes >= 0) link.fileSizeBytes = result.fileSizeBytes;
progress.queue(url);
}); });
const ddownloadPromise = runWithConcurrency(ddownloadLinks, 4, async (url) => { const ddownloadPromise = runWithConcurrency(ddownloadLinks, 4, async (url) => {
const result = await checkDdownload(url).catch(() => null); const result = await checkDdownload(url).catch(() => null);
@@ -216,9 +262,16 @@ export async function enrichCollectorPackages(
link.status = result.online ? (result.fileName ? "ready" : "unknown") : "offline"; link.status = result.online ? (result.fileName ? "ready" : "unknown") : "offline";
if (result.fileName) link.fileName = sanitizeFilename(result.fileName); if (result.fileName) link.fileName = sanitizeFilename(result.fileName);
if (result.fileSizeBytes !== null && result.fileSizeBytes >= 0) link.fileSizeBytes = result.fileSizeBytes; if (result.fileSizeBytes !== null && result.fileSizeBytes >= 0) link.fileSizeBytes = result.fileSizeBytes;
progress.queue(url);
}); });
const genericPromise = genericLinks.length > 0 const genericPromise = genericLinks.length > 0
? resolveFilenames(genericLinks).catch(() => new Map<string, string>()) ? resolveFilenames(genericLinks, (url, fileName) => {
const link = linksByUrl.get(url);
if (!link || !fileName) return;
link.fileName = sanitizeFilename(fileName);
link.status = "ready";
progress.queue(url);
}).catch(() => new Map<string, string>())
: Promise.resolve(new Map<string, string>()); : Promise.resolve(new Map<string, string>());
const [oneFichierResults, genericResults] = await Promise.all([ const [oneFichierResults, genericResults] = await Promise.all([
oneFichierPromise, oneFichierPromise,
@@ -226,20 +279,15 @@ export async function enrichCollectorPackages(
rapidgatorPromise, rapidgatorPromise,
ddownloadPromise ddownloadPromise
]).then(([oneFichier, generic]) => [oneFichier, generic] as const); ]).then(([oneFichier, generic]) => [oneFichier, generic] as const);
for (const [url, result] of oneFichierResults) { void oneFichierResults;
const link = linksByUrl.get(url);
if (!link) continue;
link.availability = result.online ? "online" : "offline";
link.status = result.online ? (result.fileName ? "ready" : "unknown") : "offline";
if (result.fileName) link.fileName = sanitizeFilename(result.fileName);
if (result.fileSizeBytes !== null && result.fileSizeBytes >= 0) link.fileSizeBytes = result.fileSizeBytes;
}
for (const [url, fileName] of genericResults) { for (const [url, fileName] of genericResults) {
const link = linksByUrl.get(url); const link = linksByUrl.get(url);
if (!link || !fileName) continue; if (!link || !fileName) continue;
link.fileName = sanitizeFilename(fileName); link.fileName = sanitizeFilename(fileName);
link.status = "ready"; link.status = "ready";
progress.queue(url);
} }
progress.flush();
return { packages: regroupEnrichedPackages(packages), invalidCount: 0, duplicateCount: 0 }; return { packages: regroupEnrichedPackages(packages), invalidCount: 0, duplicateCount: 0 };
} }
+7 -2
View File
@@ -569,8 +569,13 @@ function registerIpcHandlers(): void {
const request = validateCollectorContainerPreparationRequest({ filePaths, addedAt }); const request = validateCollectorContainerPreparationRequest({ filePaths, addedAt });
return controller.prepareCollectorContainers(request.filePaths, request.addedAt); return controller.prepareCollectorContainers(request.filePaths, request.addedAt);
}); });
handleTrusted(IPC_CHANNELS.ENRICH_COLLECTOR_PACKAGES, (_event: IpcMainInvokeEvent, value: unknown) => { handleTrusted(IPC_CHANNELS.ENRICH_COLLECTOR_PACKAGES, (event: IpcMainInvokeEvent, value: unknown) => {
return controller.enrichCollectorPackages(validateCollectorEnrichmentRequest(value)); const request = validateCollectorEnrichmentRequest(value);
return controller.enrichCollectorPackages(request, (result) => {
if (!event.sender.isDestroyed()) {
event.sender.send(IPC_CHANNELS.COLLECTOR_ENRICHMENT_PROGRESS, { requestId: request.requestId, result });
}
});
}); });
handleTrusted(IPC_CHANNELS.GET_START_CONFLICTS, () => controller.getStartConflicts()); handleTrusted(IPC_CHANNELS.GET_START_CONFLICTS, () => controller.getStartConflicts());
handleTrusted(IPC_CHANNELS.RESOLVE_START_CONFLICT, (_event: IpcMainInvokeEvent, packageId: string, policy: "keep" | "skip" | "overwrite") => { handleTrusted(IPC_CHANNELS.RESOLVE_START_CONFLICT, (_event: IpcMainInvokeEvent, packageId: string, policy: "keep" | "skip" | "overwrite") => {
+6
View File
@@ -33,6 +33,7 @@ import {
import type { RealDebridLoginRequest } from "../shared/preload-api"; import type { RealDebridLoginRequest } from "../shared/preload-api";
import type { import type {
CollectorEnrichmentRequest, CollectorEnrichmentRequest,
CollectorEnrichmentProgress,
CollectorInspectionResult, CollectorInspectionResult,
CollectorTextPreparationRequest CollectorTextPreparationRequest
} from "../shared/collector"; } from "../shared/collector";
@@ -62,6 +63,11 @@ const api: ElectronApi = {
ipcRenderer.invoke(IPC_CHANNELS.PREPARE_COLLECTOR_CONTAINERS, filePaths, addedAt), ipcRenderer.invoke(IPC_CHANNELS.PREPARE_COLLECTOR_CONTAINERS, filePaths, addedAt),
enrichCollectorPackages: (request: CollectorEnrichmentRequest): Promise<CollectorInspectionResult> => enrichCollectorPackages: (request: CollectorEnrichmentRequest): Promise<CollectorInspectionResult> =>
ipcRenderer.invoke(IPC_CHANNELS.ENRICH_COLLECTOR_PACKAGES, request), ipcRenderer.invoke(IPC_CHANNELS.ENRICH_COLLECTOR_PACKAGES, request),
onCollectorEnrichmentProgress: (callback: (progress: CollectorEnrichmentProgress) => void): (() => void) => {
const listener = (_event: unknown, progress: CollectorEnrichmentProgress): void => callback(progress);
ipcRenderer.on(IPC_CHANNELS.COLLECTOR_ENRICHMENT_PROGRESS, listener);
return () => ipcRenderer.removeListener(IPC_CHANNELS.COLLECTOR_ENRICHMENT_PROGRESS, listener);
},
getPathForDroppedFile: (file: File): string => webUtils.getPathForFile(file), getPathForDroppedFile: (file: File): string => webUtils.getPathForFile(file),
getStartConflicts: (): Promise<StartConflictEntry[]> => ipcRenderer.invoke(IPC_CHANNELS.GET_START_CONFLICTS), getStartConflicts: (): Promise<StartConflictEntry[]> => ipcRenderer.invoke(IPC_CHANNELS.GET_START_CONFLICTS),
resolveStartConflict: (packageId: string, policy: DuplicatePolicy): Promise<StartConflictResolutionResult> => resolveStartConflict: (packageId: string, policy: DuplicatePolicy): Promise<StartConflictResolutionResult> =>
+21 -7
View File
@@ -61,7 +61,7 @@ import { Dialog } from "./ui/Dialog";
import { Icon } from "./ui/Icon"; import { Icon } from "./ui/Icon";
import { Toast } from "./ui/Toast"; import { Toast } from "./ui/Toast";
import { LinkAddressesDialog } from "./ui/LinkAddressesDialog"; import { LinkAddressesDialog } from "./ui/LinkAddressesDialog";
import { serializeCollectorPackages, type CollectorInspectionResult, type CollectorPackage } from "../shared/collector"; import { serializeCollectorPackages, type CollectorEnrichmentProgress, type CollectorInspectionResult, type CollectorPackage } from "../shared/collector";
import { routeDroppedDlcFiles } from "./collector-drop"; import { routeDroppedDlcFiles } from "./collector-drop";
import { beginCollectorEnrichment, filterCurrentCollectorEnrichment } from "./collector-enrichment"; import { beginCollectorEnrichment, filterCurrentCollectorEnrichment } from "./collector-enrichment";
import { import {
@@ -77,6 +77,7 @@ import {
CollectorInputDialog, CollectorInputDialog,
MemoizedCollectorContent, MemoizedCollectorContent,
CollectorSidebar, CollectorSidebar,
CollectorSidebarStatus,
CollectorToolbar, CollectorToolbar,
type CollectorViewActions type CollectorViewActions
} from "./views/collector/CollectorView"; } from "./views/collector/CollectorView";
@@ -1720,6 +1721,7 @@ export function App(): ReactElement {
const [collectorInput, setCollectorInput] = useState<CollectorInputState | null>(null); const [collectorInput, setCollectorInput] = useState<CollectorInputState | null>(null);
const collectorPackagesRef = useRef<CollectorPackage[]>(collectorPackages); const collectorPackagesRef = useRef<CollectorPackage[]>(collectorPackages);
const collectorEnrichmentGenerationsRef = useRef(new Map<string, number>()); const collectorEnrichmentGenerationsRef = useRef(new Map<string, number>());
const collectorEnrichmentRequestsRef = useRef(new Map<string, ReturnType<typeof beginCollectorEnrichment>>());
const importCollectorTextRef = useRef<(rawText: string) => Promise<void>>(() => Promise.resolve()); const importCollectorTextRef = useRef<(rawText: string) => Promise<void>>(() => Promise.resolve());
const activeTabRef = useRef<Tab>(tab); const activeTabRef = useRef<Tab>(tab);
const packageOrderRef = useRef<string[]>([]); const packageOrderRef = useRef<string[]>([]);
@@ -3608,8 +3610,10 @@ export function App(): ReactElement {
return; return;
} }
const generations = beginCollectorEnrichment(packages, collectorEnrichmentGenerationsRef.current); const generations = beginCollectorEnrichment(packages, collectorEnrichmentGenerationsRef.current);
const requestId = `collector-${Date.now().toString(36)}-${crypto.randomUUID()}`;
collectorEnrichmentRequestsRef.current.set(requestId, generations);
setCollectorAnalyzingCount((current) => current + 1); setCollectorAnalyzingCount((current) => current + 1);
void window.rd.enrichCollectorPackages({ packages }).then((result) => { void window.rd.enrichCollectorPackages({ requestId, packages }).then((result) => {
mergeCollectorResult({ mergeCollectorResult({
...result, ...result,
packages: filterCurrentCollectorEnrichment( packages: filterCurrentCollectorEnrichment(
@@ -3623,10 +3627,24 @@ export function App(): ReactElement {
setCollectorError(`Metadatenprüfung fehlgeschlagen: ${String(error)}`); setCollectorError(`Metadatenprüfung fehlgeschlagen: ${String(error)}`);
} }
}).finally(() => { }).finally(() => {
collectorEnrichmentRequestsRef.current.delete(requestId);
setCollectorAnalyzingCount((current) => Math.max(0, current - 1)); setCollectorAnalyzingCount((current) => Math.max(0, current - 1));
}); });
}; };
useEffect(() => window.rd.onCollectorEnrichmentProgress((progress: CollectorEnrichmentProgress) => {
const generations = collectorEnrichmentRequestsRef.current.get(progress.requestId);
if (!generations) return;
mergeCollectorResult({
...progress.result,
packages: filterCurrentCollectorEnrichment(
progress.result.packages,
generations,
collectorEnrichmentGenerationsRef.current
)
}, true);
}), []);
const importCollectorText = async (rawText: string): Promise<void> => { const importCollectorText = async (rawText: string): Promise<void> => {
if (!rawText.trim()) { if (!rawText.trim()) {
showToast("Keine Links eingegeben", 2200); showToast("Keine Links eingegeben", 2200);
@@ -6245,11 +6263,7 @@ export function App(): ReactElement {
sidebarStatus={tab === "downloads" ? ( sidebarStatus={tab === "downloads" ? (
<DownloadsSidebarStatus model={downloadsViewModel} /> <DownloadsSidebarStatus model={downloadsViewModel} />
) : tab === "collector" ? ( ) : tab === "collector" ? (
<> <CollectorSidebarStatus model={collectorViewModel} />
<span>Pakete: {collectorPackages.length}</span>
<span>Links: {collectorViewModel.totalCount}</span>
<span>Ausgewählt: {collectorViewModel.selectedCount}</span>
</>
) : tab === "history" ? ( ) : tab === "history" ? (
<> <>
<span>Einträge: {historyViewModel.totalCount}</span> <span>Einträge: {historyViewModel.totalCount}</span>
+32 -13
View File
@@ -48,10 +48,9 @@ export interface CollectorInputDialogProps {
} }
function packageStatus(row: CollectorWorkspacePackageRow): string { function packageStatus(row: CollectorWorkspacePackageRow): string {
const ready = row.allLinks.reduce((count, link) => count + (link.status === "ready" ? 1 : 0), 0);
if (row.offlineCount === row.totalCount) return "Offline"; if (row.offlineCount === row.totalCount) return "Offline";
if (ready === row.totalCount) return "Bereit"; if (row.onlineCount === row.totalCount) return "Online";
if (ready > 0 || row.onlineCount > 0) return `${ready}/${row.totalCount} geprüft`; if (row.onlineCount > 0) return "Teilweise online";
return "Ungeprüft"; return "Ungeprüft";
} }
@@ -73,10 +72,11 @@ function availabilityClass(row: CollectorWorkspacePackageRow): string {
return "unknown"; return "unknown";
} }
function linkStatus(status: "ready" | "offline" | "unknown"): string { function availabilityTone(online: number, offline: number, total: number): "online" | "offline" | "partial" | "unknown" {
if (status === "ready") return "Bereit"; if (online === total) return "online";
if (status === "offline") return "Offline"; if (offline === total) return "offline";
return "Ungeprüft"; if (online > 0) return "partial";
return "unknown";
} }
function linkAvailability(availability: "online" | "offline" | "unknown"): string { function linkAvailability(availability: "online" | "offline" | "unknown"): string {
@@ -101,6 +101,7 @@ export function collectorFileInteractionAttributes(
} }
interface CollectorViewportState { interface CollectorViewportState {
scrollLeft: number;
scrollTop: number; scrollTop: number;
viewportHeight: number; viewportHeight: number;
} }
@@ -116,6 +117,7 @@ interface CollectorVirtualPackage {
function useCollectorViewport(bodyRef: React.RefObject<HTMLDivElement>): CollectorViewportState { function useCollectorViewport(bodyRef: React.RefObject<HTMLDivElement>): CollectorViewportState {
const [viewport, setViewport] = useState<CollectorViewportState>({ const [viewport, setViewport] = useState<CollectorViewportState>({
scrollLeft: 0,
scrollTop: 0, scrollTop: 0,
viewportHeight: DOWNLOAD_VIRTUAL_DEFAULT_VIEWPORT_HEIGHT viewportHeight: DOWNLOAD_VIRTUAL_DEFAULT_VIEWPORT_HEIGHT
}); });
@@ -126,10 +128,11 @@ function useCollectorViewport(bodyRef: React.RefObject<HTMLDivElement>): Collect
const measure = (): void => { const measure = (): void => {
frame = 0; frame = 0;
const next = { const next = {
scrollLeft: body.scrollLeft,
scrollTop: body.scrollTop, scrollTop: body.scrollTop,
viewportHeight: body.clientHeight || DOWNLOAD_VIRTUAL_DEFAULT_VIEWPORT_HEIGHT viewportHeight: body.clientHeight || DOWNLOAD_VIRTUAL_DEFAULT_VIEWPORT_HEIGHT
}; };
setViewport((current) => current.scrollTop === next.scrollTop && current.viewportHeight === next.viewportHeight ? current : next); setViewport((current) => current.scrollLeft === next.scrollLeft && current.scrollTop === next.scrollTop && current.viewportHeight === next.viewportHeight ? current : next);
}; };
const schedule = (): void => { const schedule = (): void => {
if (frame !== 0) return; if (frame !== 0) return;
@@ -147,6 +150,10 @@ function useCollectorViewport(bodyRef: React.RefObject<HTMLDivElement>): Collect
return viewport; return viewport;
} }
export function collectorHeaderScrollStyle(scrollLeft: number): CSSProperties {
return { transform: `translateX(${-Math.max(0, scrollLeft)}px)` };
}
function collectorVirtualPackageStyle(top: number, height: number): CSSProperties { function collectorVirtualPackageStyle(top: number, height: number): CSSProperties {
return { return {
"--collector-virtual-package-top": `${top}px`, "--collector-virtual-package-top": `${top}px`,
@@ -208,6 +215,19 @@ export function CollectorSidebar({ model, actions }: CollectorViewProps): ReactE
); );
} }
export function CollectorSidebarStatus({ model }: { model: CollectorWorkspaceViewModel }): ReactElement {
return (
<>
<span>Pakete: {model.packageCount}</span>
<span>Links: {model.totalCount}</span>
<span>Ausgewählt: {model.selectedCount}</span>
{model.analyzing ? (
<span aria-live="polite" className="collector-sidebar-analysis" role="status"><span aria-hidden="true" />Analyse läuft im Hintergrund</span>
) : null}
</>
);
}
export function CollectorToolbar({ model, actions }: CollectorViewProps): ReactElement { export function CollectorToolbar({ model, actions }: CollectorViewProps): ReactElement {
return ( return (
<Toolbar className="collector-toolbar" data-visual-region="collector-toolbar" label="Linksammler-Aktionen"> <Toolbar className="collector-toolbar" data-visual-region="collector-toolbar" label="Linksammler-Aktionen">
@@ -300,7 +320,7 @@ function CollectorPackageGroup({ row, model, actions, selected, focusIndexStart,
<span className="collector-hoster-cell" role="cell"> <span className="collector-hoster-cell" role="cell">
{row.hosters.map(formatHosterLabel).map((hoster) => <CollectorHosterLabel hoster={hoster} key={hoster.title} />)} {row.hosters.map(formatHosterLabel).map((hoster) => <CollectorHosterLabel hoster={hoster} key={hoster.title} />)}
</span> </span>
<span className="collector-status-cell" role="cell">{packageStatus(row)}</span> <span className={`collector-status-cell is-${availabilityTone(row.onlineCount, row.offlineCount, row.totalCount)}`} role="cell">{packageStatus(row)}</span>
<span className={`collector-availability-cell is-${availabilityClass(row)}`} role="cell">{packageAvailability(row)}</span> <span className={`collector-availability-cell is-${availabilityClass(row)}`} role="cell">{packageAvailability(row)}</span>
<span className="collector-added-cell" role="cell">{formatDateTime(row.addedAt)}</span> <span className="collector-added-cell" role="cell">{formatDateTime(row.addedAt)}</span>
</div> </div>
@@ -318,7 +338,7 @@ function CollectorPackageGroup({ row, model, actions, selected, focusIndexStart,
<span className="collector-name-cell is-file" role="cell" title={link.url}><span className={`collector-link-state is-${link.availability}`} />{link.fileName}</span> <span className="collector-name-cell is-file" role="cell" title={link.url}><span className={`collector-link-state is-${link.availability}`} />{link.fileName}</span>
<span className="collector-size-cell" role="cell">{link.fileSizeBytes === null ? "Unbekannt" : humanSize(link.fileSizeBytes)}</span> <span className="collector-size-cell" role="cell">{link.fileSizeBytes === null ? "Unbekannt" : humanSize(link.fileSizeBytes)}</span>
<span className="collector-hoster-cell" role="cell"><CollectorHosterLabel hoster={hoster} /></span> <span className="collector-hoster-cell" role="cell"><CollectorHosterLabel hoster={hoster} /></span>
<span className="collector-status-cell" role="cell">{linkStatus(link.status)}</span> <span className={`collector-status-cell is-${link.availability}`} role="cell">{linkAvailability(link.availability)}</span>
<span className={`collector-availability-cell is-${link.availability}`} role="cell">{linkAvailability(link.availability)}</span> <span className={`collector-availability-cell is-${link.availability}`} role="cell">{linkAvailability(link.availability)}</span>
<span className="collector-added-cell" role="cell">{formatDateTime(link.addedAt)}</span> <span className="collector-added-cell" role="cell">{formatDateTime(link.addedAt)}</span>
</div> </div>
@@ -368,7 +388,7 @@ export function CollectorContent({ model, actions }: CollectorViewProps): ReactE
model.animationsEnabled model.animationsEnabled
); );
const effectivePinnedIds = mergeCollectorPinnedIds(resolveCollectorTransitionPins(transitionPinnedIds, freshPinnedIds), focusedPackageId); const effectivePinnedIds = mergeCollectorPinnedIds(resolveCollectorTransitionPins(transitionPinnedIds, freshPinnedIds), focusedPackageId);
const stateOffset = (model.analyzing ? 38 : 0) + (model.error ? 38 : 0); const stateOffset = model.error ? 38 : 0;
const virtualWindow = useMemo(() => calculateDownloadVirtualWindow(virtualPackages, { const virtualWindow = useMemo(() => calculateDownloadVirtualWindow(virtualPackages, {
scrollTop: Math.max(0, viewport.scrollTop - stateOffset), scrollTop: Math.max(0, viewport.scrollTop - stateOffset),
viewportHeight: viewport.viewportHeight, viewportHeight: viewport.viewportHeight,
@@ -406,7 +426,7 @@ export function CollectorContent({ model, actions }: CollectorViewProps): ReactE
<section className="collector-content" aria-label="Gesammelte Downloadpakete"> <section className="collector-content" aria-label="Gesammelte Downloadpakete">
<DataTable aria-rowcount={logicalRowCount} className="collector-table" label="Gesammelte Downloadpakete"> <DataTable aria-rowcount={logicalRowCount} className="collector-table" label="Gesammelte Downloadpakete">
<DataTableHeader className="collector-table-header"> <DataTableHeader className="collector-table-header">
<div aria-rowindex={1} className="collector-table-header-row" role="row"> <div aria-rowindex={1} className="collector-table-header-row" role="row" style={collectorHeaderScrollStyle(viewport.scrollLeft)}>
<span aria-label="Auswahl" className="collector-column-select" role="columnheader" /> <span aria-label="Auswahl" className="collector-column-select" role="columnheader" />
<span role="columnheader">Name</span> <span role="columnheader">Name</span>
<span role="columnheader">Größe</span> <span role="columnheader">Größe</span>
@@ -433,7 +453,6 @@ export function CollectorContent({ model, actions }: CollectorViewProps): ReactE
}} }}
ref={bodyRef} ref={bodyRef}
> >
{model.analyzing ? <div aria-live="polite" className="collector-background-state" role="status"><span />Analyse läuft im Hintergrund</div> : null}
{model.error ? <div aria-live="polite" className="collector-background-error" role="status">{model.error}</div> : null} {model.error ? <div aria-live="polite" className="collector-background-error" role="status">{model.error}</div> : null}
{model.empty ? ( {model.empty ? (
<DataTableEmpty <DataTableEmpty
@@ -28,6 +28,7 @@ export interface CollectorWorkspacePackageRow {
export interface CollectorWorkspaceViewModel { export interface CollectorWorkspaceViewModel {
packages: CollectorWorkspacePackageRow[]; packages: CollectorWorkspacePackageRow[];
packageCount: number;
filters: CollectorWorkspaceFilterEntry[]; filters: CollectorWorkspaceFilterEntry[];
filter: CollectorWorkspaceFilter; filter: CollectorWorkspaceFilter;
query: string; query: string;
@@ -276,6 +277,7 @@ export function buildCollectorWorkspaceViewModel(
return { return {
packages: rows, packages: rows,
packageCount: packages.length,
filters: [ filters: [
{ id: "all", label: "Alle", count: allLinks.length }, { id: "all", label: "Alle", count: allLinks.length },
{ id: "online", label: "Online", count: availabilityCounts.online }, { id: "online", label: "Online", count: availabilityCounts.online },
+45 -8
View File
@@ -141,7 +141,6 @@
height: 100%; height: 100%;
} }
.collector-background-state,
.collector-background-error { .collector-background-error {
align-items: center; align-items: center;
backdrop-filter: blur(8px); backdrop-filter: blur(8px);
@@ -156,14 +155,15 @@
padding: 0 11px; padding: 0 11px;
} }
.collector-background-state { .collector-sidebar-analysis {
background: color-mix(in srgb, var(--ui-surface) 88%, transparent); align-items: center;
color: var(--ui-text-secondary); display: flex;
gap: 6px;
} }
.collector-background-state span { .collector-sidebar-analysis > span {
animation: collector-analysis-pulse 1s ease-in-out infinite alternate; animation: collector-analysis-pulse 1s ease-in-out infinite alternate;
background: var(--ui-primary); background: var(--ui-success);
border-radius: 50%; border-radius: 50%;
height: 7px; height: 7px;
width: 7px; width: 7px;
@@ -184,6 +184,12 @@
.collector-table-header { .collector-table-header {
height: 41px; height: 41px;
overflow: hidden;
}
.collector-table-header,
.collector-table-body {
scrollbar-gutter: stable;
} }
.collector-table-header-row, .collector-table-header-row,
@@ -211,6 +217,25 @@
padding: 0 12px; padding: 0 12px;
} }
.collector-table-header-row > span:nth-child(2) {
padding-left: 48px;
}
.collector-table-header-row > span:nth-child(n+3) {
display: flex;
justify-content: center;
text-align: center;
}
.collector-size-cell,
.collector-hoster-cell,
.collector-status-cell,
.collector-availability-cell,
.collector-added-cell {
justify-content: center;
text-align: center;
}
.collector-table-body { .collector-table-body {
overflow: auto; overflow: auto;
} }
@@ -326,7 +351,7 @@
.collector-name-cell.is-file { .collector-name-cell.is-file {
color: var(--ui-text); color: var(--ui-text);
padding-left: 36px; padding-left: 32px;
} }
.collector-collapse-button { .collector-collapse-button {
@@ -404,6 +429,18 @@
color: var(--ui-text-muted); color: var(--ui-text-muted);
} }
.collector-status-cell.is-online {
color: var(--ui-success);
}
.collector-status-cell.is-offline {
color: var(--ui-danger);
}
.collector-status-cell.is-partial {
color: var(--ui-warning-text);
}
.collector-availability-cell { .collector-availability-cell {
font-weight: 600; font-weight: 600;
white-space: nowrap; white-space: nowrap;
@@ -467,7 +504,7 @@
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.collector-background-state span { .collector-sidebar-analysis > span {
animation: none; animation: none;
} }
+11 -2
View File
@@ -32,9 +32,15 @@ export interface CollectorContainerPreparationRequest {
} }
export interface CollectorEnrichmentRequest { export interface CollectorEnrichmentRequest {
requestId: string;
packages: CollectorPackage[]; packages: CollectorPackage[];
} }
export interface CollectorEnrichmentProgress {
requestId: string;
result: CollectorInspectionResult;
}
export interface CollectorInspectionResult { export interface CollectorInspectionResult {
packages: CollectorPackage[]; packages: CollectorPackage[];
invalidCount: number; invalidCount: number;
@@ -127,7 +133,10 @@ export function validateCollectorEnrichmentRequest(value: unknown): CollectorEnr
throw new Error("Linksammler-Anreicherung ist ungültig"); throw new Error("Linksammler-Anreicherung ist ungültig");
} }
const raw = value as Record<string, unknown>; const raw = value as Record<string, unknown>;
if (Object.keys(raw).some((key) => key !== "packages") if (Object.keys(raw).some((key) => key !== "requestId" && key !== "packages")
|| typeof raw.requestId !== "string"
|| raw.requestId.length === 0
|| raw.requestId.length > 160
|| !Array.isArray(raw.packages) || !Array.isArray(raw.packages)
|| raw.packages.length === 0 || raw.packages.length === 0
|| raw.packages.length > 2_000 || raw.packages.length > 2_000
@@ -135,7 +144,7 @@ export function validateCollectorEnrichmentRequest(value: unknown): CollectorEnr
|| raw.packages.reduce((sum, entry) => sum + (entry as CollectorPackage).links.length, 0) > 20_000) { || raw.packages.reduce((sum, entry) => sum + (entry as CollectorPackage).links.length, 0) > 20_000) {
throw new Error("Linksammler-Anreicherung ist ungültig"); throw new Error("Linksammler-Anreicherung ist ungültig");
} }
return { packages: structuredClone(raw.packages) as CollectorPackage[] }; return { requestId: raw.requestId, packages: structuredClone(raw.packages) as CollectorPackage[] };
} }
function collectorMarkerValue(value: string): string { function collectorMarkerValue(value: string): string {
+1
View File
@@ -17,6 +17,7 @@ export const IPC_CHANNELS = {
PREPARE_COLLECTOR_TEXT: "collector:prepare-text", PREPARE_COLLECTOR_TEXT: "collector:prepare-text",
PREPARE_COLLECTOR_CONTAINERS: "collector:prepare-containers", PREPARE_COLLECTOR_CONTAINERS: "collector:prepare-containers",
ENRICH_COLLECTOR_PACKAGES: "collector:enrich-packages", ENRICH_COLLECTOR_PACKAGES: "collector:enrich-packages",
COLLECTOR_ENRICHMENT_PROGRESS: "collector:enrichment-progress",
GET_START_CONFLICTS: "queue:get-start-conflicts", GET_START_CONFLICTS: "queue:get-start-conflicts",
RESOLVE_START_CONFLICT: "queue:resolve-start-conflict", RESOLVE_START_CONFLICT: "queue:resolve-start-conflict",
CLEAR_ALL: "queue:clear-all", CLEAR_ALL: "queue:clear-all",
+2
View File
@@ -36,6 +36,7 @@ import type {
import { isRealDebridWebAccountId } from "./real-debrid-accounts"; import { isRealDebridWebAccountId } from "./real-debrid-accounts";
import type { import type {
CollectorEnrichmentRequest, CollectorEnrichmentRequest,
CollectorEnrichmentProgress,
CollectorInspectionResult, CollectorInspectionResult,
CollectorTextPreparationRequest CollectorTextPreparationRequest
} from "./collector"; } from "./collector";
@@ -87,6 +88,7 @@ export interface ElectronApi {
prepareCollectorText: (request: CollectorTextPreparationRequest) => Promise<CollectorInspectionResult>; prepareCollectorText: (request: CollectorTextPreparationRequest) => Promise<CollectorInspectionResult>;
prepareCollectorContainers: (filePaths: string[], addedAt: number) => Promise<CollectorInspectionResult>; prepareCollectorContainers: (filePaths: string[], addedAt: number) => Promise<CollectorInspectionResult>;
enrichCollectorPackages: (request: CollectorEnrichmentRequest) => Promise<CollectorInspectionResult>; enrichCollectorPackages: (request: CollectorEnrichmentRequest) => Promise<CollectorInspectionResult>;
onCollectorEnrichmentProgress: (callback: (progress: CollectorEnrichmentProgress) => void) => () => void;
getPathForDroppedFile: (file: File) => string; getPathForDroppedFile: (file: File) => string;
getStartConflicts: () => Promise<StartConflictEntry[]>; getStartConflicts: () => Promise<StartConflictEntry[]>;
resolveStartConflict: (packageId: string, policy: DuplicatePolicy) => Promise<StartConflictResolutionResult>; resolveStartConflict: (packageId: string, policy: DuplicatePolicy) => Promise<StartConflictResolutionResult>;
+20 -5
View File
@@ -5,7 +5,9 @@ import type { ElectronApi } from "../src/shared/preload-api";
const electron = vi.hoisted(() => ({ const electron = vi.hoisted(() => ({
api: undefined as ElectronApi | undefined, api: undefined as ElectronApi | undefined,
invoke: vi.fn<(...args: unknown[]) => Promise<unknown>>(async () => undefined), invoke: vi.fn<(...args: unknown[]) => Promise<unknown>>(async () => undefined),
getPathForFile: vi.fn(() => "C:\\Imports\\dropped.dlc") getPathForFile: vi.fn(() => "C:\\Imports\\dropped.dlc"),
on: vi.fn(),
removeListener: vi.fn()
})); }));
vi.mock("electron", () => ({ vi.mock("electron", () => ({
@@ -16,8 +18,8 @@ vi.mock("electron", () => ({
}, },
ipcRenderer: { ipcRenderer: {
invoke: electron.invoke, invoke: electron.invoke,
on: vi.fn(), on: electron.on,
removeListener: vi.fn(), removeListener: electron.removeListener,
send: vi.fn() send: vi.fn()
}, },
webUtils: { getPathForFile: electron.getPathForFile } webUtils: { getPathForFile: electron.getPathForFile }
@@ -118,12 +120,12 @@ describe("account preload contract", () => {
await electron.api?.prepareCollectorText(textRequest); await electron.api?.prepareCollectorText(textRequest);
await electron.api?.prepareCollectorContainers(["C:\\Imports\\sample.dlc"], 2345); await electron.api?.prepareCollectorContainers(["C:\\Imports\\sample.dlc"], 2345);
await electron.api?.enrichCollectorPackages({ packages }); await electron.api?.enrichCollectorPackages({ requestId: "request-preload", packages });
expect(electron.invoke.mock.calls).toEqual([ expect(electron.invoke.mock.calls).toEqual([
[IPC_CHANNELS.PREPARE_COLLECTOR_TEXT, textRequest], [IPC_CHANNELS.PREPARE_COLLECTOR_TEXT, textRequest],
[IPC_CHANNELS.PREPARE_COLLECTOR_CONTAINERS, ["C:\\Imports\\sample.dlc"], 2345], [IPC_CHANNELS.PREPARE_COLLECTOR_CONTAINERS, ["C:\\Imports\\sample.dlc"], 2345],
[IPC_CHANNELS.ENRICH_COLLECTOR_PACKAGES, { packages }] [IPC_CHANNELS.ENRICH_COLLECTOR_PACKAGES, { requestId: "request-preload", packages }]
]); ]);
}); });
@@ -134,4 +136,17 @@ describe("account preload contract", () => {
expect(electron.getPathForFile).toHaveBeenCalledWith(file); expect(electron.getPathForFile).toHaveBeenCalledWith(file);
expect(electron.invoke).not.toHaveBeenCalled(); expect(electron.invoke).not.toHaveBeenCalled();
}); });
it("subscribes and unsubscribes collector enrichment progress", () => {
const callback = vi.fn();
const unsubscribe = electron.api?.onCollectorEnrichmentProgress(callback);
const listener = electron.on.mock.calls.find((call) => call[0] === IPC_CHANNELS.COLLECTOR_ENRICHMENT_PROGRESS)?.[1] as ((event: unknown, value: unknown) => void) | undefined;
const progress = { requestId: "request-progress", result: { packages: [], invalidCount: 0, duplicateCount: 0 } };
listener?.({}, progress);
unsubscribe?.();
expect(callback).toHaveBeenCalledWith(progress);
expect(electron.removeListener).toHaveBeenCalledWith(IPC_CHANNELS.COLLECTOR_ENRICHMENT_PROGRESS, listener);
});
}); });
+50 -4
View File
@@ -10,6 +10,7 @@ import {
validateCollectorEnrichmentRequest, validateCollectorEnrichmentRequest,
validateCollectorTextPreparationRequest validateCollectorTextPreparationRequest
} from "../src/shared/collector"; } from "../src/shared/collector";
import type { CollectorInspectionResult } from "../src/shared/collector";
describe("collector preparation", () => { describe("collector preparation", () => {
it("returns a stable package skeleton without requesting metadata", () => { it("returns a stable package skeleton without requesting metadata", () => {
@@ -89,7 +90,7 @@ describe("collector enrichment", () => {
const linkBefore = prepared.packages[0].links[0]; const linkBefore = prepared.packages[0].links[0];
const result = await enrichCollectorPackages( const result = await enrichCollectorPackages(
{ packages: prepared.packages }, { requestId: "request-one", packages: prepared.packages },
defaultSettings(), defaultSettings(),
{ {
checkOneFichier: async () => new Map([[linkBefore.url, { checkOneFichier: async () => new Map([[linkBefore.url, {
@@ -124,8 +125,8 @@ describe("collector enrichment", () => {
return new Map(); return new Map();
}; };
const firstRun = enrichCollectorPackages({ packages: first.packages }, defaultSettings(), { checkOneFichier }); const firstRun = enrichCollectorPackages({ requestId: "request-first", packages: first.packages }, defaultSettings(), { checkOneFichier });
const secondRun = enrichCollectorPackages({ packages: second.packages }, defaultSettings(), { checkOneFichier }); const secondRun = enrichCollectorPackages({ requestId: "request-second", packages: second.packages }, defaultSettings(), { checkOneFichier });
await vi.waitFor(() => expect(started).toHaveLength(2)); await vi.waitFor(() => expect(started).toHaveLength(2));
resolvers.forEach((resolve) => resolve()); resolvers.forEach((resolve) => resolve());
await Promise.all([firstRun, secondRun]); await Promise.all([firstRun, secondRun]);
@@ -136,9 +137,54 @@ describe("collector enrichment", () => {
]); ]);
}); });
it("reports finished RapidGator links before the complete enrichment resolves", async () => {
const prepared = prepareCollectorText({
rawText: [
"https://rapidgator.net/file/aaaaaaaa/one.bin.html",
"https://rapidgator.net/file/bbbbbbbb/two.bin.html"
].join("\n"),
addedAt: 7_000
});
const resolvers = new Map<string, (value: { online: boolean; fileName: string; fileSizeBytes: number }) => void>();
const progress: CollectorInspectionResult[] = [];
let completed = false;
const run = enrichCollectorPackages(
{ requestId: "request-progress", packages: prepared.packages },
defaultSettings(),
{
checkRapidgator: (url) => new Promise((resolve) => resolvers.set(url, resolve))
},
(result) => progress.push(result)
).then((result) => {
completed = true;
return result;
});
await vi.waitFor(() => expect(resolvers.size).toBe(2));
resolvers.get("https://rapidgator.net/file/aaaaaaaa/one.bin.html")?.({
online: true,
fileName: "one.part01.rar",
fileSizeBytes: 100
});
await vi.waitFor(() => expect(progress.length).toBeGreaterThan(0));
expect(completed).toBe(false);
expect(progress.flatMap((entry) => entry.packages).flatMap((pkg) => pkg.links)).toEqual([
expect.objectContaining({ fileName: "one.part01.rar", fileSizeBytes: 100, availability: "online" })
]);
resolvers.get("https://rapidgator.net/file/bbbbbbbb/two.bin.html")?.({
online: false,
fileName: "two.bin",
fileSizeBytes: 200
});
await run;
});
it("rejects enrichment payloads that do not contain prepared absolute links", () => { it("rejects enrichment payloads that do not contain prepared absolute links", () => {
expect(() => validateCollectorEnrichmentRequest({ packages: [] })).toThrow(/ungültig/i); expect(() => validateCollectorEnrichmentRequest({ requestId: "request-empty", packages: [] })).toThrow(/ungültig/i);
expect(() => validateCollectorEnrichmentRequest({ expect(() => validateCollectorEnrichmentRequest({
requestId: "request-invalid",
packages: [{ packages: [{
id: "package", id: "package",
name: "Paket", name: "Paket",
+45 -1
View File
@@ -16,9 +16,11 @@ import {
CollectorInputDialog, CollectorInputDialog,
MemoizedCollectorContent, MemoizedCollectorContent,
CollectorSidebar, CollectorSidebar,
CollectorSidebarStatus,
CollectorToolbar, CollectorToolbar,
CollectorView, CollectorView,
collectorFileInteractionAttributes, collectorFileInteractionAttributes,
collectorHeaderScrollStyle,
collectorPackageIntrinsicBlockSize, collectorPackageIntrinsicBlockSize,
toggleAllCollectorPackageIds, toggleAllCollectorPackageIds,
type CollectorViewActions type CollectorViewActions
@@ -391,6 +393,7 @@ describe("CollectorView", () => {
expect(html).toContain("SBS14HD.part01.rar"); expect(html).toContain("SBS14HD.part01.rar");
expect(html).toContain("SBS14HD.part02.rar"); expect(html).toContain("SBS14HD.part02.rar");
expect(html).toContain("2/2 online"); expect(html).toContain("2/2 online");
expect(html).toContain(">Online<");
expect(html).toContain("aria-label=\"SBS14HD einklappen\""); expect(html).toContain("aria-label=\"SBS14HD einklappen\"");
expect(html).not.toContain("URL oder Rohzeile"); expect(html).not.toContain("URL oder Rohzeile");
expect(html).not.toContain(">Zeile<"); expect(html).not.toContain(">Zeile<");
@@ -399,15 +402,50 @@ describe("CollectorView", () => {
it("keeps rows and actions available during background analysis", () => { it("keeps rows and actions available during background analysis", () => {
const model = buildCollectorWorkspaceViewModel(packages, "all", "", true, ["link-1"], [], "", true); const model = buildCollectorWorkspaceViewModel(packages, "all", "", true, ["link-1"], [], "", true);
const html = renderToStaticMarkup(<CollectorContent actions={createActions()} model={model} />); const html = renderToStaticMarkup(<CollectorContent actions={createActions()} model={model} />);
const sidebarStatus = renderToStaticMarkup(<CollectorSidebarStatus model={model} />);
const toolbar = CollectorToolbar({ actions: createActions(), model }); const toolbar = CollectorToolbar({ actions: createActions(), model });
expect(html).toContain("Analyse läuft im Hintergrund"); expect(html).not.toContain("Analyse läuft im Hintergrund");
expect(sidebarStatus).toContain("Analyse läuft im Hintergrund");
expect(sidebarStatus).toContain('role="status"');
expect(html).toContain("SBS14HD.part01.rar"); expect(html).toContain("SBS14HD.part01.rar");
expect(findButton(toolbar, "Auswahl übergeben (1)").props.disabled).toBe(false); expect(findButton(toolbar, "Auswahl übergeben (1)").props.disabled).toBe(false);
expect(findButton(toolbar, "Alle übergeben (4)").props.disabled).toBe(false); expect(findButton(toolbar, "Alle übergeben (4)").props.disabled).toBe(false);
expect(findButton(toolbar, "Auswahl entfernen").props.disabled).toBe(false); expect(findButton(toolbar, "Auswahl entfernen").props.disabled).toBe(false);
}); });
it("derives status exclusively from availability instead of filename readiness", () => {
const unknownReady = [{
...packages[0],
links: [{ ...packages[0].links[0], availability: "unknown" as const, status: "ready" as const }]
}];
const partial = [{
...packages[0],
links: [
{ ...packages[0].links[0], availability: "online" as const },
{ ...packages[0].links[1], availability: "unknown" as const }
]
}];
const offlineUnknown = [{
...packages[0],
links: [
{ ...packages[0].links[0], availability: "offline" as const },
{ ...packages[0].links[1], availability: "unknown" as const }
]
}];
const unknownHtml = renderToStaticMarkup(<CollectorContent actions={createActions()} model={buildCollectorWorkspaceViewModel(unknownReady, "all", "", false, [], [], "", true)} />);
const partialHtml = renderToStaticMarkup(<CollectorContent actions={createActions()} model={buildCollectorWorkspaceViewModel(partial, "all", "", false, [], [], "", true)} />);
const offlineUnknownHtml = renderToStaticMarkup(<CollectorContent actions={createActions()} model={buildCollectorWorkspaceViewModel(offlineUnknown, "all", "", false, [], [], "", true)} />);
expect(unknownHtml).not.toContain(">Bereit<");
expect(unknownHtml).toContain(">Ungeprüft<");
expect(partialHtml).toContain(">Teilweise online<");
expect(partialHtml).toContain("1/2 online");
expect(offlineUnknownHtml).not.toContain(">Teilweise online<");
expect(offlineUnknownHtml).toContain(">Ungeprüft<");
});
it("renders known hosters as icons with their full name as tooltip", () => { it("renders known hosters as icons with their full name as tooltip", () => {
const html = renderToStaticMarkup(<CollectorContent actions={createActions()} model={buildCollectorWorkspaceViewModel(packages, "all", "", false, [], [], "", true)} />); const html = renderToStaticMarkup(<CollectorContent actions={createActions()} model={buildCollectorWorkspaceViewModel(packages, "all", "", false, [], [], "", true)} />);
@@ -497,6 +535,12 @@ describe("CollectorView", () => {
expect(css).toMatch(/@media \(prefers-reduced-motion: reduce\)[\s\S]*\.collector-virtual-spacer\.is-motion-enabled \.collector-virtual-package\s*\{[^}]*transition-duration:\s*300ms, 300ms !important;/s); expect(css).toMatch(/@media \(prefers-reduced-motion: reduce\)[\s\S]*\.collector-virtual-spacer\.is-motion-enabled \.collector-virtual-package\s*\{[^}]*transition-duration:\s*300ms, 300ms !important;/s);
expect(css).toMatch(/\.collector-package-items-frame\.is-expanding\s*\{[^}]*animation:\s*collector-items-expand/s); expect(css).toMatch(/\.collector-package-items-frame\.is-expanding\s*\{[^}]*animation:\s*collector-items-expand/s);
expect(css).not.toMatch(/\.collector-package-items-frame\.is-animated\s*\{[^}]*animation:\s*collector-items-expand/s); expect(css).not.toMatch(/\.collector-package-items-frame\.is-animated\s*\{[^}]*animation:\s*collector-items-expand/s);
expect(css).toMatch(/\.collector-table-header-row > span:nth-child\(2\)\s*\{[^}]*padding-left:\s*48px;/s);
expect(css).toMatch(/\.collector-name-cell\.is-file\s*\{[^}]*padding-left:\s*32px;/s);
expect(css).toMatch(/\.collector-table-header,\s*\.collector-table-body\s*\{[^}]*scrollbar-gutter:\s*stable;/s);
expect(css).toMatch(/\.collector-size-cell,\s*\.collector-hoster-cell,\s*\.collector-status-cell,\s*\.collector-availability-cell,\s*\.collector-added-cell\s*\{[^}]*text-align:\s*center;/s);
expect(css).toMatch(/\.collector-table-header-row > span:nth-child\(n\+3\)\s*\{[^}]*justify-content:\s*center;[^}]*text-align:\s*center;/s);
expect(collectorHeaderScrollStyle(37)).toEqual({ transform: "translateX(-37px)" });
}); });
it("uses one consistent gap across collector toolbar groups", () => { it("uses one consistent gap across collector toolbar groups", () => {
+1
View File
@@ -62,6 +62,7 @@ export function createVisualElectronApi(
prepareCollectorText: async () => ({ packages: [], invalidCount: 0, duplicateCount: 0 }), prepareCollectorText: async () => ({ packages: [], invalidCount: 0, duplicateCount: 0 }),
prepareCollectorContainers: async () => ({ packages: [], invalidCount: 0, duplicateCount: 0 }), prepareCollectorContainers: async () => ({ packages: [], invalidCount: 0, duplicateCount: 0 }),
enrichCollectorPackages: async (request) => ({ packages: clone(request.packages), invalidCount: 0, duplicateCount: 0 }), enrichCollectorPackages: async (request) => ({ packages: clone(request.packages), invalidCount: 0, duplicateCount: 0 }),
onCollectorEnrichmentProgress: () => () => {},
getPathForDroppedFile: () => "", getPathForDroppedFile: () => "",
getStartConflicts: async () => [], getStartConflicts: async () => [],
resolveStartConflict: async (_packageId, policy) => ({ resolveStartConflict: async (_packageId, policy) => ({