From 048bfbefaad85845bbd4810a18758e83cf5e68ca Mon Sep 17 00:00:00 2001
From: xxvw
Date: Mon, 18 May 2026 18:15:19 +0900
Subject: [PATCH] feat(ui): in-flight state and richer result panel for library
export/import
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The Library screen now reflects the actual lifecycle of an export or
import dispatch:
- A `libraryBusy` flag is raised between the moment the user accepts
the file-picker dialog and the IPC reply, so the Export Library… /
Import Library… buttons disable themselves and show "Exporting…" /
"Importing…" labels (preventing accidental re-trigger on a slow
exporter).
- The previous one-line "Exported N tracks" hint is replaced by a
dedicated `LibraryResultPanel` showing the destination path
(mono-formatted), track count, byte size for export, and
imported/updated/skipped counts for import. Warnings collapse into
a `` block so the success line stays uncluttered when the
warning list is non-empty.
- Errors get a separate red-bordered variant with the full message
in a preformatted block so multi-line errors from the plugins
(e.g. "no exporter registered for serato") stay readable.
CSS lives next to the component (LibraryResultPanel.css) following
the same colocated pattern FormatPickerModal already uses, so App.css
is not touched.
---
.../components/library/LibraryResultPanel.css | 67 ++++++
ui/src/screens/LibraryScreen.tsx | 214 ++++++++++++------
2 files changed, 218 insertions(+), 63 deletions(-)
create mode 100644 ui/src/components/library/LibraryResultPanel.css
diff --git a/ui/src/components/library/LibraryResultPanel.css b/ui/src/components/library/LibraryResultPanel.css
new file mode 100644
index 0000000..2641f7b
--- /dev/null
+++ b/ui/src/components/library/LibraryResultPanel.css
@@ -0,0 +1,67 @@
+.library-result {
+ background: var(--c-ink-2);
+ border: 1px solid var(--c-ink-5);
+ border-radius: var(--r-3);
+ padding: var(--s-4);
+ margin-bottom: var(--s-3);
+ display: flex;
+ flex-direction: column;
+ gap: var(--s-3);
+}
+
+.library-result.error {
+ border-color: var(--c-danger);
+}
+
+.library-result header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.library-result dl {
+ margin: 0;
+ display: grid;
+ grid-template-columns: max-content 1fr;
+ gap: var(--s-2) var(--s-4);
+ font-size: var(--fs-small);
+}
+
+.library-result dl > div {
+ display: contents;
+}
+
+.library-result dt {
+ color: var(--c-ink-9);
+}
+
+.library-result dd {
+ margin: 0;
+}
+
+.library-result dd.mono {
+ font-family: var(--font-mono);
+ word-break: break-all;
+}
+
+.library-result details {
+ font-size: var(--fs-small);
+}
+
+.library-result details summary {
+ cursor: pointer;
+ color: var(--c-warn);
+}
+
+.library-result details ul {
+ margin: var(--s-2) 0 0;
+ padding-left: var(--s-5);
+}
+
+.library-result-error {
+ margin: 0;
+ font-family: var(--font-mono);
+ font-size: var(--fs-small);
+ white-space: pre-wrap;
+ color: var(--c-danger);
+}
diff --git a/ui/src/screens/LibraryScreen.tsx b/ui/src/screens/LibraryScreen.tsx
index 04d3731..ae7da7d 100644
--- a/ui/src/screens/LibraryScreen.tsx
+++ b/ui/src/screens/LibraryScreen.tsx
@@ -1,6 +1,7 @@
import { open, save } from "@tauri-apps/plugin-dialog";
import { useCallback, useMemo, useState } from "react";
+import "@/components/library/LibraryResultPanel.css";
import { FormatPickerModal } from "@/components/library/FormatPickerModal";
import {
ipc,
@@ -87,10 +88,23 @@ export function LibraryScreen({
const [pickerMode, setPickerMode] = useState<"export" | "import" | null>(
null,
);
+ const [libraryBusy, setLibraryBusy] = useState<"export" | "import" | null>(
+ null,
+ );
const [libraryResult, setLibraryResult] = useState<
- | { kind: "export"; report: LibraryExportReport }
- | { kind: "import"; report: LibraryImportReport }
- | { kind: "error"; message: string }
+ | {
+ kind: "export";
+ format: FormatInfo;
+ destination: string;
+ report: LibraryExportReport;
+ }
+ | {
+ kind: "import";
+ format: FormatInfo;
+ source: string;
+ report: LibraryImportReport;
+ }
+ | { kind: "error"; mode: "export" | "import"; message: string }
| null
>(null);
@@ -102,20 +116,24 @@ export function LibraryScreen({
if (mode === "export") {
const destination = await pickExportDestination(f);
if (!destination) return;
+ setLibraryBusy("export");
const report = await ipc.libraryExport({
format: f.format,
destination,
});
- setLibraryResult({ kind: "export", report });
+ setLibraryResult({ kind: "export", format: f, destination, report });
} else {
const source = await pickImportSource(f);
if (!source) return;
+ setLibraryBusy("import");
const report = await ipc.libraryImport({ format: f.format, source });
- setLibraryResult({ kind: "import", report });
+ setLibraryResult({ kind: "import", format: f, source, report });
await refresh();
}
} catch (e) {
- setLibraryResult({ kind: "error", message: String(e) });
+ setLibraryResult({ kind: "error", mode, message: String(e) });
+ } finally {
+ setLibraryBusy(null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pickerMode]);
@@ -199,17 +217,18 @@ export function LibraryScreen({
{filtered.length}
@@ -264,61 +283,16 @@ export function LibraryScreen({
)}
+ {libraryBusy && (
+
+ {libraryBusy === "export" ? "Exporting library…" : "Importing library…"}
+
+ )}
{libraryResult && (
-
- {libraryResult.kind === "export" && (
- <>
- Exported {libraryResult.report.tracks_written} track(s) as{" "}
- {libraryResult.report.format}
- {libraryResult.report.warnings.length > 0 && (
- <>. {libraryResult.report.warnings.length} warning(s)>
- )}
- .{" "}
-
- >
- )}
- {libraryResult.kind === "import" && (
- <>
- Imported {libraryResult.report.tracks_imported} new,{" "}
- {libraryResult.report.tracks_updated} updated,{" "}
- {libraryResult.report.tracks_skipped} skipped (
- {libraryResult.report.format}).{" "}
-
- >
- )}
- {libraryResult.kind === "error" && (
- <>
- Library operation failed: {libraryResult.message}{" "}
-
- >
- )}
-
+ setLibraryResult(null)}
+ />
)}
void;
+}) {
+ if (result.kind === "error") {
+ return (
+
+
+
+ {result.mode === "export" ? "Export failed" : "Import failed"}
+
+
+
+
{result.message}
+
+ );
+ }
+ if (result.kind === "export") {
+ return (
+
+
+ Export complete · {result.format.label}
+
+
+
+
+
- Destination
+ - {result.destination}
+
+
+
- Tracks
+ - {result.report.tracks_written}
+
+
+
- Bytes
+ - {formatBytes(result.report.bytes_written)}
+
+
+ {result.report.warnings.length > 0 && (
+
+
+ {result.report.warnings.length} warning
+ {result.report.warnings.length === 1 ? "" : "s"}
+
+
+ {result.report.warnings.map((w, i) => (
+ - {w}
+ ))}
+
+
+ )}
+
+ );
+ }
+ // import
+ return (
+
+
+ Import complete · {result.format.label}
+
+
+
+
+
- Source
+ - {result.source}
+
+
+
- Imported
+ - {result.report.tracks_imported}
+
+
+
- Updated
+ - {result.report.tracks_updated}
+
+
+
- Skipped
+ - {result.report.tracks_skipped}
+
+
+ {result.report.warnings.length > 0 && (
+
+
+ {result.report.warnings.length} warning
+ {result.report.warnings.length === 1 ? "" : "s"}
+
+
+ {result.report.warnings.map((w, i) => (
+ - {w}
+ ))}
+
+
+ )}
+
+ );
+}
+
function formatBytes(n: number): string {
if (!Number.isFinite(n) || n <= 0) return "0 B";
const units = ["B", "KiB", "MiB", "GiB", "TiB"];