Add List panel (session 9, fifteenth feature)

Renders a naming-scheme-style expression per loaded file via the same
render_expression engine the Rename panel's live preview uses, fed by
the same local guess_episode/guess_movie/guess_release_info matching
Filter already uses. Copy List and Export to File... (a new
export_text_list Tauri command - the only new backend surface this
feature needed) round it out. Closes out all six original sidebar
panels as real screens.
This commit is contained in:
Renamr Scaffold
2026-08-29 17:28:38 +00:00
parent bfcec9135a
commit 03768c398d
7 changed files with 526 additions and 6 deletions
+3
View File
@@ -6,6 +6,7 @@ import EpisodesPanel from "./components/EpisodesPanel";
import SubtitlesPanel from "./components/SubtitlesPanel";
import SfvPanel from "./components/SfvPanel";
import FilterPanel from "./components/FilterPanel";
import ListPanel from "./components/ListPanel";
import PanelStub from "./components/PanelStub";
export default function App() {
@@ -28,6 +29,8 @@ export default function App() {
<SfvPanel />
) : selected === "filter" ? (
<FilterPanel />
) : selected === "list" ? (
<ListPanel />
) : (
<PanelStub name={current.name} />
)}
+139
View File
@@ -0,0 +1,139 @@
.list-panel {
height: 100%;
min-width: 0;
display: flex;
flex-direction: column;
gap: 10px;
padding: 18px;
overflow-y: auto;
}
.list-toolbar {
display: flex;
align-items: center;
gap: 14px;
background: var(--card);
border: 1px solid var(--surface-deep);
border-radius: 12px;
padding: 14px;
}
.list-loaded-count {
font-size: 12px;
color: var(--muted-text);
}
.list-format {
display: flex;
align-items: center;
gap: 12px;
background: var(--card);
border: 1px solid var(--surface-deep);
border-radius: 12px;
padding: 12px 14px;
}
.list-format-label {
flex: 0 0 auto;
font-size: 11.5px;
color: var(--muted-text);
}
.list-format-input {
flex: 1;
min-width: 0;
font-family: "SF Mono", Menlo, monospace;
}
.list-format-hint {
font-size: 11.5px;
color: var(--muted-text);
padding: 0 2px;
line-height: 1.5;
}
.list-format-hint code {
background: var(--surface-deep);
border-radius: 4px;
padding: 1px 4px;
font-size: 11px;
}
.list-results {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.list-results-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 2px 2px 4px;
}
.list-results-actions {
display: flex;
gap: 8px;
}
.list-match-count {
font-size: 13px;
font-weight: 600;
}
.list-error {
color: #ff8a8a;
font-size: 12px;
padding: 4px 2px;
}
.list-row {
display: flex;
align-items: center;
gap: 12px;
background: var(--card);
border: 1px solid var(--surface-deep);
border-radius: 10px;
padding: 9px 12px;
}
.list-row-line {
flex: 1;
min-width: 0;
font-size: 12.5px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.list-row-empty {
color: var(--muted-text);
font-weight: 400;
font-style: italic;
}
.list-row-name {
flex: 0 0 auto;
max-width: 40%;
font-size: 11.5px;
color: var(--muted-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.list-empty-note {
color: var(--muted-text);
font-size: 12.5px;
padding: 8px 2px;
}
.list-status {
font-size: 12px;
color: var(--aurora-cyan);
}
+263
View File
@@ -0,0 +1,263 @@
import { useEffect, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
interface FileEntry {
path: string;
name: string; // filename without extension
ext: string;
}
// Same local, filename-only guessers "Match" (Rename) and the Filter panel's scan
// already use (aurora-curator-core's `matching` module) - no network calls, so a
// loaded batch can be scanned and formatted instantly.
interface EpisodeGuess {
seriesName: string;
season: number;
episode: number;
}
interface MovieGuess {
title: string;
year: number | null;
}
interface ReleaseInfo {
resolution: string | null;
source: string | null;
videoCodec: string | null;
audioCodec: string | null;
group: string | null;
}
// The naming-scheme-style expression List renders per file, reusing exactly the same
// `render_expression` command (and therefore the same `{ }` syntax) the Rename panel's
// live preview uses. `{name}`/`{ext}` always work; every other field only fills in once
// the local scan below recognizes it (no Fetch Data step here - List stays local-only,
// same choice Filter made, since neither panel touches the filesystem or the network).
const DEFAULT_EXPRESSION = "{name}";
function pad2(n: number): string {
return n.toString().padStart(2, "0");
}
function displayName(file: FileEntry): string {
return file.ext ? `${file.name}.${file.ext}` : file.name;
}
export default function ListPanel() {
const [files, setFiles] = useState<FileEntry[]>([]);
const [bindings, setBindings] = useState<Record<string, Record<string, string>>>({});
const [scanning, setScanning] = useState(false);
const [expression, setExpression] = useState(DEFAULT_EXPRESSION);
const [lines, setLines] = useState<Record<string, string>>({});
const [computeError, setComputeError] = useState<string | null>(null);
const [exporting, setExporting] = useState(false);
const [status, setStatus] = useState<string | null>(null);
async function loadFiles() {
const picked = await invoke<FileEntry[]>("pick_files");
if (picked.length === 0) return;
setFiles(picked);
const base: Record<string, Record<string, string>> = {};
for (const file of picked) base[file.path] = { name: file.name, ext: file.ext };
setBindings(base);
setStatus(null);
await scan(picked, base);
}
async function scan(targets: FileEntry[], base: Record<string, Record<string, string>>) {
setScanning(true);
try {
const results = await Promise.all(
targets.map(async (file): Promise<readonly [string, Record<string, string>]> => {
const releasePromise = invoke<ReleaseInfo | null>("guess_release_info", {
filename: displayName(file),
}).catch(() => null);
let extra: Record<string, string> = {};
let matched = false;
try {
const episode = await invoke<EpisodeGuess | null>("guess_episode", { filename: displayName(file) });
if (episode) {
matched = true;
extra = {
n: episode.seriesName,
s00e00: `S${pad2(episode.season)}E${pad2(episode.episode)}`,
season: String(episode.season),
episode: String(episode.episode),
"show name": episode.seriesName,
"season number": String(episode.season),
"episode number": pad2(episode.episode),
};
}
} catch {
// Fall through and try a movie guess instead.
}
if (!matched) {
try {
const movie = await invoke<MovieGuess | null>("guess_movie", { filename: displayName(file) });
if (movie) {
extra = {
n: movie.title,
...(movie.year ? { y: String(movie.year) } : {}),
"movie name": movie.title,
...(movie.year ? { year: String(movie.year) } : {}),
};
}
} catch {
// Neither guess worked - the file just keeps its base {name}/{ext} bindings.
}
}
const release = await releasePromise;
const releaseBindings: Record<string, string> = release
? {
...(release.resolution ? { vf: release.resolution, resolution: release.resolution } : {}),
...(release.source ? { source: release.source } : {}),
...(release.videoCodec ? { vc: release.videoCodec, "video codec": release.videoCodec } : {}),
...(release.audioCodec ? { ac: release.audioCodec, "audio codec": release.audioCodec } : {}),
...(release.group ? { group: release.group, "release group": release.group } : {}),
}
: {};
return [file.path, { ...base[file.path], ...extra, ...releaseBindings }] as const;
})
);
const next: Record<string, Record<string, string>> = {};
let matchedCount = 0;
for (const [path, b] of results) {
next[path] = b;
if (b.n) matchedCount += 1;
}
setBindings(next);
setStatus(
`Scanned ${targets.length} file${targets.length === 1 ? "" : "s"} locally (${matchedCount} matched; no network calls).`
);
} finally {
setScanning(false);
}
}
// Re-render every file's line whenever the expression or the underlying bindings
// change - same live-preview pattern the Rename panel's naming-scheme editor uses,
// via the same `render_expression` command. A single file whose rendered output
// ends up empty (e.g. an expression referencing a field this batch never matched)
// just shows a blank line rather than blocking the rest of the list.
useEffect(() => {
let cancelled = false;
let firstError: string | null = null;
Promise.all(
files.map(async (file): Promise<readonly [string, string]> => {
const b = bindings[file.path];
if (!b) return [file.path, ""] as const;
try {
const rendered = await invoke<string>("render_expression", { expression, bindings: b });
return [file.path, rendered] as const;
} catch (err) {
if (!firstError) firstError = String(err);
return [file.path, ""] as const;
}
})
).then((entries) => {
if (cancelled) return;
const next: Record<string, string> = {};
for (const [path, line] of entries) next[path] = line;
setLines(next);
setComputeError(firstError);
});
return () => {
cancelled = true;
};
}, [expression, files, bindings]);
const orderedLines = files.map((f) => lines[f.path] ?? "");
const nonEmptyCount = orderedLines.filter((l) => l.trim() !== "").length;
function copyList() {
if (files.length === 0) return;
navigator.clipboard
.writeText(orderedLines.join("\n"))
.then(() => setStatus(`Copied ${files.length} line${files.length === 1 ? "" : "s"} to the clipboard.`))
.catch(() => setStatus("Couldn't copy to the clipboard."));
}
async function exportList() {
if (files.length === 0) return;
setExporting(true);
try {
const savedPath = await invoke<string | null>("export_text_list", {
defaultName: "list",
content: orderedLines.join("\n"),
});
setStatus(savedPath ? `Saved to ${savedPath}` : "Export cancelled.");
} catch (err) {
setStatus(String(err));
} finally {
setExporting(false);
}
}
return (
<div className="list-panel">
<div className="list-toolbar">
<button className="action-button" onClick={loadFiles}>
Load Files…
</button>
<span className="list-loaded-count">
{files.length > 0 ? `${files.length} file${files.length === 1 ? "" : "s"} loaded` : "No files loaded"}
</span>
</div>
<div className="list-format">
<span className="list-format-label">Format</span>
<input
className="modal-text-input list-format-input"
type="text"
value={expression}
onChange={(e) => setExpression(e.target.value)}
spellCheck={false}
/>
</div>
<div className="list-format-hint">
Same <code>{"{ }"}</code> syntax as the naming scheme. <code>{"{name}"}</code>/<code>{"{ext}"}</code> always work;{" "}
<code>{"{show name}"}</code>/<code>{"{season number}"}</code>/<code>{"{episode number}"}</code>,{" "}
<code>{"{movie name}"}</code>/<code>{"{year}"}</code>, and release tags fill in once a file is recognized -
local matching only, no Fetch Data step.
</div>
<div className="list-results">
{files.length === 0 && <div className="list-empty-note">Load files to build a formatted list from them.</div>}
{files.length > 0 && (
<div className="list-results-header">
<span className="list-match-count">
{scanning ? "Scanning…" : `${nonEmptyCount} of ${files.length} lines rendered`}
</span>
<div className="list-results-actions">
<button className="action-button" onClick={copyList} disabled={files.length === 0}>
Copy List
</button>
<button className="action-button" onClick={exportList} disabled={exporting || files.length === 0}>
{exporting ? "Exporting…" : "Export to File…"}
</button>
</div>
</div>
)}
{computeError && <div className="list-error">{computeError}</div>}
{files.map((file) => (
<div className="list-row" key={file.path}>
<span className="list-row-line">{lines[file.path] || <span className="list-row-empty">(empty)</span>}</span>
<span className="list-row-name">{displayName(file)}</span>
</div>
))}
</div>
{status && <div className="list-status">{status}</div>}
</div>
);
}
+1
View File
@@ -10,6 +10,7 @@ import "./components/EpisodesPanel.css";
import "./components/SubtitlesPanel.css";
import "./components/SfvPanel.css";
import "./components/FilterPanel.css";
import "./components/ListPanel.css";
import "./components/PanelStub.css";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(