Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions apps/desktop/src/__tests__/LocationStatusBadge.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<LocationStatusBadge status={s} />);
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(
<LocationStatusBadge status="active" />,
);
const activeCls = activeC.querySelector("span")?.className ?? "";
const { container: missingC } = render(
<LocationStatusBadge status="missing" />,
);
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(<LocationStatusBadge status="missing" />);
// 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(<LocationStatusBadge status="quarantined" />);
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);
}
});
});
59 changes: 59 additions & 0 deletions apps/desktop/src/__tests__/verify-message.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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");
});
});
39 changes: 39 additions & 0 deletions apps/desktop/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,14 @@ import type {
FileWithMetadataPayload,
FileWithTagsPayload,
ListProvidersPayload,
PruneReport,
ScanReport,
SearchHit,
Tag,
TranscribeStartedPayload,
TranscriptionConfig,
VolumeRecord,
VerifyReport,
} from "./bindings";

// ── Error parsing ─────────────────────────────────────────────────────
Expand Down Expand Up @@ -466,3 +468,40 @@ export function updateTranscriptionConfig(
export function getTranscriptionConfig(): ResultAsync<TranscriptionConfig, CoreError> {
return fromInvoke<TranscriptionConfig>("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<VerifyReport, CoreError> {
return fromInvoke<VerifyReport>("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<number, CoreError> {
return fromInvoke<number>("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<PruneReport, CoreError> {
return fromInvoke<PruneReport>("prune_missing_locations", { dryRun });
}
26 changes: 26 additions & 0 deletions apps/desktop/src/bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
13 changes: 12 additions & 1 deletion apps/desktop/src/components/FileTable.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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"
Expand All @@ -231,7 +240,9 @@ export default function FileTable({ files, loading }: FileTableProps) {
<td className="px-4 py-2 mono-metadata text-muted-foreground max-w-xs truncate">
{f.relative_path}
</td>
<td className="px-4 py-2">{f.status}</td>
<td className="px-4 py-2">
<LocationStatusBadge status={f.status} />
</td>
<td className="px-4 py-2">
<RowTagsCell
fileUuid={f.file_uuid}
Expand Down
95 changes: 95 additions & 0 deletions apps/desktop/src/components/LibraryHealthPill.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/**
* Library-health control: run a verify sweep, and remove entries for
* files that are gone.
*
* WHY the two actions are separate buttons rather than one "clean up":
* verify is safe and repeatable; prune deletes catalogue rows. Fusing
* them would mean a single click both decides what is missing and acts
* on that decision, with no moment for the user to look at the number
* first. Keeping them apart is what makes the count reviewable.
*
* WHY prune is hidden entirely at zero rather than shown disabled: a
* greyed "Remove 0 missing" invites clicking to find out what it does.
* The control appears when there is something to remove.
*/

import { useState } from "react";

import {
useMissingCount,
usePruneMissing,
useVerifyLocations,
} from "../queries/verify";

const BTN =
"rounded-full px-3 py-1 text-sm font-medium transition-colors duration-micro " +
"ease-perima focus-visible:outline-none focus-visible:ring-2 " +
"focus-visible:ring-ring focus-visible:ring-offset-2 " +
"focus-visible:ring-offset-background disabled:opacity-40 " +
"disabled:pointer-events-none";

export default function LibraryHealthPill() {
const { data: missing = 0 } = useMissingCount();
const verify = useVerifyLocations();
const prune = usePruneMissing();
const [confirming, setConfirming] = useState(false);

return (
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => {
verify.mutate(false);
}}
disabled={verify.isPending}
title="Check whether every indexed file is still on disk"
className={`${BTN} bg-secondary text-secondary-foreground hover:bg-popover`}
>
{verify.isPending ? "Checking…" : "Check files"}
</button>

{missing > 0 &&
(confirming ? (
<span className="flex items-center gap-2">
{/* WHY the count is repeated in the confirm: the user may have
run verify some time ago, and the number is the whole
basis for the decision. */}
<span className="text-xs text-muted-foreground">
Remove {missing} missing file(s) from the library?
</span>
<button
type="button"
onClick={() => {
prune.mutate(false);
setConfirming(false);
}}
disabled={prune.isPending}
className={`${BTN} bg-destructive text-destructive-foreground hover:opacity-90`}
>
{prune.isPending ? "Removing…" : "Remove"}
</button>
<button
type="button"
onClick={() => {
setConfirming(false);
}}
className={`${BTN} bg-secondary text-secondary-foreground hover:bg-popover`}
>
Cancel
</button>
</span>
) : (
<button
type="button"
onClick={() => {
setConfirming(true);
}}
title="Remove catalogue entries for files that are no longer on disk. The files themselves are not touched."
className={`${BTN} bg-destructive/15 text-destructive-foreground ring-1 ring-destructive/40 hover:bg-destructive/25`}
>
{missing} missing
</button>
))}
</div>
);
}
Loading
Loading