Show scan warnings as structured cards instead of long sentences

Warnings are now stored and served as structured data (title, detail,
slave/master items, total) and rendered as cards with a Slave -> Master
table: the first five rows, with "Show all" for the rest.

- Renamed folders highlight the characters that differ; non-ASCII
  characters get their code point in a tooltip, so look-alikes such as
  "-" (U+002D) and "‐" (U+2010) can be told apart.
- Songs in a different folder are grouped by folder pair with a song
  count, most affected first, instead of two long examples.
- Folders that only differ by name are no longer also reported as
  songs in a different folder.
- Warnings saved as plain text by earlier scans still display.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cdnutterandClaude Opus 5.5 committed 2026-09-23 21:49:22 -07:00
1 parent 1d31999793
commit 9e848cd765
13 files changed
+305 -92

No files matched your search

+8 -1
View File
@@ -75,7 +75,7 @@ export type Pair = {
approx_threshold: number;
last_scan_at: number | null;
last_scan_error: string;
last_scan_warnings: string[];
last_scan_warnings: ScanWarning[];
summary: Summary;
job: Job | null;
};
@@ -199,3 +199,10 @@ export type BulkPreview = {
bytes_freed: number;
examples: Record<string, string[] | undefined>;
};
export type ScanWarning = {
title: string;
detail?: string;
items?: { slave: string; master: string; count?: number }[];
total?: number;
};
+1 -1
View File
@@ -105,7 +105,7 @@ function PairCard({ pair }: { pair: Pair }) {
)}
{pair.last_scan_warnings.length > 0 && (
<div className="mt-3 flex gap-2 text-xs text-amber-300">
<AlertTriangle className="size-3.5 shrink-0" /> {pair.last_scan_warnings.length} layout warning{pair.last_scan_warnings.length > 1 && "s"}
<AlertTriangle className="size-3.5 shrink-0" /> {pair.last_scan_warnings.length === 1 ? pair.last_scan_warnings[0].title : `${pair.last_scan_warnings.length} warnings`}
</div>
)}
<a href={`#/pair/${pair.id}`} className="mt-4 flex items-center gap-1 self-end text-sm text-accent hover:underline">
+6 -9
View File
@@ -1,12 +1,13 @@
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, ChevronLeft, Folder, FolderOpen } from "lucide-react";
import { api, type Pair, type PairInput } from "../api";
import { ChevronLeft, Folder, FolderOpen } from "lucide-react";
import { api, type Pair, type PairInput, type ScanWarning } from "../api";
import { WarningList } from "./Warnings";
import { useInfo } from "../App";
import { Alert, Button, Field, Modal, Toggle, inputCls } from "../ui";
import { useToast } from "../toast";
type Validation = { ok: boolean; error?: string; warnings: string[] };
type Validation = { ok: boolean; error?: string; warnings: ScanWarning[] };
export function PairForm({ pair, open, onClose }: { pair?: Pair; open: boolean; onClose: () => void }) {
const info = useInfo();
@@ -63,7 +64,7 @@ export function PairForm({ pair, open, onClose }: { pair?: Pair; open: boolean;
onSuccess: setValidation,
});
const save = useMutation({
mutationFn: () => (pair ? api.put<{ warnings: string[] }>(`/pairs/${pair.id}`, body()) : api.post<{ warnings: string[] }>("/pairs", body())),
mutationFn: () => (pair ? api.put<{ warnings: ScanWarning[] }>(`/pairs/${pair.id}`, body()) : api.post<{ warnings: ScanWarning[] }>("/pairs", body())),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["pairs"] });
qc.invalidateQueries({ queryKey: ["pair"] });
@@ -188,11 +189,7 @@ export function PairForm({ pair, open, onClose }: { pair?: Pair; open: boolean;
{validation && !validation.ok && <Alert tone="red">{validation.error}</Alert>}
{validation?.ok && validation.warnings.length === 0 && <Alert tone="green">Looks good. Both folders are valid and on the same filesystem.</Alert>}
{validation?.warnings.map((w) => (
<Alert key={w} tone="amber" icon={<AlertTriangle className="size-4 shrink-0 mt-0.5" />}>
{w}
</Alert>
))}
{validation?.ok && <WarningList warnings={validation.warnings} />}
{save.error && <Alert tone="red">{save.error.message}</Alert>}
<div className="flex justify-end gap-2 pt-2">
+3 -6
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, ChevronLeft, Pencil, RefreshCw, Square, Trash2 } from "lucide-react";
import { ChevronLeft, Pencil, RefreshCw, Square, Trash2 } from "lucide-react";
import { api, type Job, type Pair } from "../api";
import { Alert, Badge, Button, Card, Modal, Spinner, cx, formatAgo } from "../ui";
import { useToast } from "../toast";
@@ -9,6 +9,7 @@ import { PairForm } from "./PairForm";
import { Review } from "./Review";
import { Build } from "./Build";
import { OpsTable } from "./Activity";
import { WarningList } from "./Warnings";
export function usePair(id: number) {
return useQuery({
@@ -174,11 +175,7 @@ export function PairPage({ id, tab }: { id: number; tab: string }) {
</Card>
)}
{pair.last_scan_error && <Alert tone="red">Last scan failed: {pair.last_scan_error}</Alert>}
{pair.last_scan_warnings.map((w) => (
<Alert key={w} tone="amber" icon={<AlertTriangle className="mt-0.5 size-4 shrink-0" />}>
{w}
</Alert>
))}
<WarningList warnings={pair.last_scan_warnings} />
<div className="flex gap-1 border-b border-line">
{tabs.map((t) => (
+108
View File
@@ -0,0 +1,108 @@
import { useState } from "react";
import { AlertTriangle, ArrowRight, ChevronDown, ChevronRight } from "lucide-react";
import type { ScanWarning } from "../api";
import { cx } from "../ui";
const PREVIEW_ROWS = 5;
/** Scan and validation warnings: a heading, an explanation and a folder table. */
export function WarningList({ warnings }: { warnings: ScanWarning[] }) {
if (warnings.length === 0) return null;
return (
<div className="space-y-3">
{warnings.map((w) => (
<WarningCard key={w.title} w={w} />
))}
</div>
);
}
function WarningCard({ w }: { w: ScanWarning }) {
const [expanded, setExpanded] = useState(false);
const items = w.items ?? [];
const grouped = items.some((it) => it.count);
const shown = expanded ? items : items.slice(0, PREVIEW_ROWS);
const total = w.total ?? items.length;
const hidden = total - shown.length;
return (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/[0.07] text-sm">
<div className="flex gap-2.5 px-4 pt-3 pb-2">
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-300" />
<div className="min-w-0">
<div className="font-medium text-amber-100">{w.title}</div>
{w.detail && <div className="mt-0.5 text-xs text-amber-200/70">{w.detail}</div>}
</div>
</div>
{items.length > 0 && (
<div className="px-4 pb-3 pl-10.5">
<div className="overflow-x-auto rounded-md border border-amber-500/15 bg-bg/40">
<table className="w-full text-xs">
<thead>
<tr className="text-left text-[11px] uppercase tracking-wide text-amber-200/50">
<th className="px-3 py-1.5 font-medium">Slave</th>
<th className="w-4" aria-hidden />
<th className="px-3 py-1.5 font-medium">Master</th>
{grouped && <th className="px-3 py-1.5 text-right font-medium">Songs</th>}
</tr>
</thead>
<tbody>
{shown.map((it) => (
<tr key={it.slave + "\u0000" + it.master} className="border-t border-amber-500/10 align-top">
<td className="px-3 py-1.5 mono break-all text-gray-200">{grouped ? it.slave || "(library root)" : <Diff a={it.slave} b={it.master} />}</td>
<td className="py-1.5 text-amber-200/40">
<ArrowRight className="size-3 mt-0.5" />
</td>
<td className="px-3 py-1.5 mono break-all text-gray-200">{grouped ? it.master || "(library root)" : <Diff a={it.master} b={it.slave} />}</td>
{grouped && <td className="px-3 py-1.5 text-right tabular-nums text-amber-100">{it.count}</td>}
</tr>
))}
</tbody>
</table>
</div>
{(items.length > PREVIEW_ROWS || hidden > 0) && (
<div className="mt-1.5 flex items-center gap-3 text-xs">
{items.length > PREVIEW_ROWS && (
<button className="flex items-center gap-1 text-amber-200 hover:text-white cursor-pointer" onClick={() => setExpanded(!expanded)}>
{expanded ? <ChevronDown className="size-3.5" /> : <ChevronRight className="size-3.5" />}
{expanded ? "Show fewer" : `Show all ${items.length}`}
</button>
)}
{expanded && total > items.length && <span className="text-amber-200/60">and {total - items.length} more not listed</span>}
{!expanded && items.length <= PREVIEW_ROWS && hidden > 0 && <span className="text-amber-200/60">and {hidden} more</span>}
</div>
)}
</div>
)}
</div>
);
}
/**
* Shows `a`, highlighting where it differs from `b` (common prefix and suffix
* are left plain). Non-ASCII characters in the highlight get their code point
* in a tooltip, so look-alikes such as "-" (U+002D) and "‐" (U+2010) can be
* told apart.
*/
function Diff({ a, b }: { a: string; b: string }) {
const ca = Array.from(a);
const cb = Array.from(b);
let pre = 0;
while (pre < ca.length && pre < cb.length && ca[pre] === cb[pre]) pre++;
let suf = 0;
while (suf < ca.length - pre && suf < cb.length - pre && ca[ca.length - 1 - suf] === cb[cb.length - 1 - suf]) suf++;
const mid = ca.slice(pre, ca.length - suf);
if (mid.length === 0) return <>{a}</>;
const exotic = mid.filter((c) => c.codePointAt(0)! > 0x7e);
const tip = exotic.length ? exotic.map((c) => `"${c}" U+${c.codePointAt(0)!.toString(16).toUpperCase().padStart(4, "0")}`).join(", ") : undefined;
return (
<>
{ca.slice(0, pre).join("")}
<mark title={tip} className={cx("rounded-sm bg-amber-400/25 px-px text-amber-50", tip && "underline decoration-dotted")}>
{mid.join("")}
</mark>
{ca.slice(ca.length - suf).join("")}
</>
);
}