diff --git a/apps/desktop/src/__tests__/LocationStatusBadge.test.tsx b/apps/desktop/src/__tests__/LocationStatusBadge.test.tsx new file mode 100644 index 0000000..b979346 --- /dev/null +++ b/apps/desktop/src/__tests__/LocationStatusBadge.test.tsx @@ -0,0 +1,70 @@ +/** + * Tests for {@link LocationStatusBadge} + {@link isUnavailable}. + * + * The point of the component is that an abnormal status is *visibly* + * different from a normal one, so these assert on the distinction rather + * than on exact class strings — pinning Tailwind classes would make the + * test a change-detector for restyling. + */ + +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { + LocationStatusBadge, + isUnavailable, +} from "../components/LocationStatusBadge"; + +describe("LocationStatusBadge", () => { + it("renders the status text for every known state", () => { + for (const s of ["active", "missing", "moved", "stale"]) { + // WHY getAllByText: the badge nests an `sr-only` span inside the + // visible one, so the outer element's text also contains the + // label. Two matches is the correct shape, not a duplicate render + // — assert the label is present rather than unique. + const { container, unmount } = render(); + expect(screen.getAllByText(s, { exact: false }).length).toBeGreaterThan(0); + expect(container.textContent).toContain(s); + unmount(); + } + }); + + it("gives missing a visual treatment that active does not have", () => { + const { container: activeC } = render( + , + ); + const activeCls = activeC.querySelector("span")?.className ?? ""; + const { container: missingC } = render( + , + ); + const missingCls = missingC.querySelector("span")?.className ?? ""; + + expect(missingCls).not.toEqual(activeCls); + expect(missingCls).toMatch(/destructive/); + expect(activeCls).not.toMatch(/destructive/); + }); + + it("carries the meaning in text, not only in colour", () => { + render(); + // Screen-reader text must state the condition; a colour-only signal + // is invisible to assistive tech and to colour-blind users. + expect(screen.getByText(/missing from disk/i)).toBeInTheDocument(); + }); + + it("renders an unknown status neutrally instead of blanking the cell", () => { + const { container } = render(); + expect(container.textContent).toContain("quarantined"); + expect(container.querySelector("span")?.className ?? "").not.toMatch( + /destructive/, + ); + }); +}); + +describe("isUnavailable", () => { + it("is true only for missing", () => { + expect(isUnavailable("missing")).toBe(true); + for (const s of ["active", "moved", "stale", "anything-else"]) { + expect(isUnavailable(s)).toBe(false); + } + }); +}); diff --git a/apps/desktop/src/__tests__/verify-message.test.ts b/apps/desktop/src/__tests__/verify-message.test.ts new file mode 100644 index 0000000..9c88695 --- /dev/null +++ b/apps/desktop/src/__tests__/verify-message.test.ts @@ -0,0 +1,59 @@ +/** + * Tests for {@link verifyMessage}. + * + * The load-bearing case is the partial sweep: when a volume is not + * mounted, its rows are neither checked nor marked, and a message that + * reports only "no changes" would read as a clean bill of health over + * files the sweep never looked at. + */ + +import { describe, expect, it } from "vitest"; + +import type { VerifyReport } from "../bindings"; +import { verifyMessage } from "../queries/verify"; + +const report = (over: Partial = {}): VerifyReport => ({ + checked: 0, + newly_missing: 0, + recovered: 0, + skipped_unmounted: 0, + rows_written: 0, + completed: true, + ...over, +}); + +describe("verifyMessage", () => { + it("reports a clean sweep as no changes", () => { + const m = verifyMessage(report({ checked: 42 })); + expect(m).toContain("Checked 42"); + expect(m).toContain("no changes"); + expect(m).not.toContain("skipped"); + }); + + it("always surfaces skipped unmounted locations", () => { + const m = verifyMessage(report({ checked: 78, skipped_unmounted: 417 })); + expect(m).toContain("417"); + expect(m).toContain("not checked"); + }); + + it("never claims 'no changes' alone when rows were skipped", () => { + // A sweep that saw nothing because everything was on an unplugged + // drive must not read as a healthy library. + const m = verifyMessage(report({ checked: 0, skipped_unmounted: 500 })); + expect(m).toContain("500"); + expect(m).toMatch(/not checked/); + }); + + it("reports missing and recovered counts", () => { + const m = verifyMessage(report({ checked: 10, newly_missing: 3, recovered: 2 })); + expect(m).toContain("3 now missing"); + expect(m).toContain("2 recovered"); + expect(m).not.toContain("no changes"); + }); + + it("flags a cancelled sweep as having written nothing", () => { + const m = verifyMessage(report({ checked: 5, completed: false })); + expect(m).toContain("cancelled"); + expect(m).toContain("nothing written"); + }); +}); diff --git a/apps/desktop/src/api.ts b/apps/desktop/src/api.ts index f943bcc..9cbd949 100644 --- a/apps/desktop/src/api.ts +++ b/apps/desktop/src/api.ts @@ -23,12 +23,14 @@ import type { FileWithMetadataPayload, FileWithTagsPayload, ListProvidersPayload, + PruneReport, ScanReport, SearchHit, Tag, TranscribeStartedPayload, TranscriptionConfig, VolumeRecord, + VerifyReport, } from "./bindings"; // ── Error parsing ───────────────────────────────────────────────────── @@ -466,3 +468,40 @@ export function updateTranscriptionConfig( export function getTranscriptionConfig(): ResultAsync { return fromInvoke("get_transcription_config", {}); } + +/** + * Reconcile catalogued locations against the filesystem. + * + * Marks vanished files `missing` and restores ones that came back. + * + * IMPORTANT for callers: `skipped_unmounted > 0` means the sweep could + * not see part of the library (a volume is not mounted). Those rows were + * left untouched and are NOT missing. Do not present the result as a + * complete picture, and do not offer `pruneMissingLocations` off the + * back of it without saying so — pruning after a partial sweep is still + * safe (skipped rows are never marked missing) but the count shown to + * the user would be misleading. + */ +export function verifyLocations(dryRun: boolean): ResultAsync { + return fromInvoke("verify_locations", { dryRun }); +} + +/** + * Count locations currently marked `missing` — i.e. what a prune would + * remove. Use it to label a confirmation with the real number before the + * user commits. + */ +export function countMissingLocations(): ResultAsync { + return fromInvoke("count_missing_locations", {}); +} + +/** + * Remove catalogue entries for locations marked `missing`. + * + * Destructive: soft-deletes the rows. Trusts `status = missing` and does + * no filesystem check of its own, so run {@link verifyLocations} first or + * the set may be stale. Pass `dryRun` to get the count without writing. + */ +export function pruneMissingLocations(dryRun: boolean): ResultAsync { + return fromInvoke("prune_missing_locations", { dryRun }); +} diff --git a/apps/desktop/src/bindings.ts b/apps/desktop/src/bindings.ts index 4bceb18..803c1de 100644 --- a/apps/desktop/src/bindings.ts +++ b/apps/desktop/src/bindings.ts @@ -315,6 +315,32 @@ export type ScanReport = { volume_label: string | null; }; +/** + * Outcome of a location verify sweep. + * Rust: `crates/app/src/verify.rs::VerifyReport`. + * + * `skipped_unmounted` counts locations on volumes not mounted on this + * device. They were NOT checked and their status was NOT changed — a + * non-zero value means this report describes only part of the library. + */ +export type VerifyReport = { + checked: number; + newly_missing: number; + recovered: number; + skipped_unmounted: number; + rows_written: number; + completed: boolean; +}; + +/** + * Outcome of a prune. + * Rust: `crates/app/src/verify.rs::PruneReport`. + */ +export type PruneReport = { + missing_found: number; + rows_pruned: number; +}; + /** * File location joined with extracted media metadata. * Rust: `crates/desktop/src/payloads.rs::FileWithMetadataPayload`. diff --git a/apps/desktop/src/components/FileTable.tsx b/apps/desktop/src/components/FileTable.tsx index 819b0d7..848699a 100644 --- a/apps/desktop/src/components/FileTable.tsx +++ b/apps/desktop/src/components/FileTable.tsx @@ -1,6 +1,7 @@ import { useState } from "react"; import type { FileWithTagsPayload, Tag } from "../bindings"; import TagChip from "./TagChip"; +import { LocationStatusBadge, isUnavailable } from "./LocationStatusBadge"; import { useAttachTag, useAttachTagByUuid, @@ -208,7 +209,15 @@ export default function FileTable({ files, loading }: FileTableProps) { selectedFileUuid === f.file_uuid ? null : f.file_uuid, ); }} + // WHY dim the whole row rather than only badge the cell: + // the status column is one of six and easy to miss when + // scanning by filename. Desaturating the entire row makes + // "this file is not on disk" legible peripherally, while the + // badge carries the precise verdict. Opacity only — the row + // stays selectable so the user can inspect or prune it. className={`border-b border-border cursor-pointer transition-colors duration-micro ${ + isUnavailable(f.status) ? "opacity-50" : "" + } ${ selectedFileUuid === f.file_uuid ? "bg-accent text-accent-foreground" : "hover:bg-muted" @@ -231,7 +240,9 @@ export default function FileTable({ files, loading }: FileTableProps) { {f.relative_path} - {f.status} + + +