2026-08-29 02:06:14 +00:00
|
|
|
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
2026-08-29 01:55:54 +00:00
|
|
|
import { invoke } from "@tauri-apps/api/core";
|
|
|
|
|
import {
|
|
|
|
|
ClearIcon,
|
|
|
|
|
DownIcon,
|
|
|
|
|
FetchIcon,
|
|
|
|
|
GearIcon,
|
|
|
|
|
HistoryIcon,
|
|
|
|
|
LoadIcon,
|
|
|
|
|
PresetsIcon,
|
|
|
|
|
RemoveIcon,
|
|
|
|
|
UpIcon,
|
|
|
|
|
} from "./Icons";
|
|
|
|
|
|
2026-08-29 02:06:14 +00:00
|
|
|
interface Item {
|
2026-08-29 01:55:54 +00:00
|
|
|
id: string;
|
|
|
|
|
originalName: string;
|
|
|
|
|
ext: string;
|
2026-08-29 02:06:14 +00:00
|
|
|
bindings: Record<string, string> | null; // null = no data to preview with (rendered muted)
|
|
|
|
|
isSample: boolean;
|
2026-08-29 01:55:54 +00:00
|
|
|
}
|
|
|
|
|
|
2026-08-29 02:06:14 +00:00
|
|
|
interface FileEntry {
|
|
|
|
|
path: string;
|
|
|
|
|
name: string; // filename without extension
|
|
|
|
|
ext: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Shown until the user loads real files, so the two-pane layout and live naming-scheme
|
|
|
|
|
// preview are visible immediately. Item 4 demonstrates the "no data" muted row style.
|
|
|
|
|
// This is what full metadata-matched renaming will look like once the matching engine
|
|
|
|
|
// (see the porting roadmap) is ported — real loaded files use their own filename fields
|
|
|
|
|
// instead (see realFileBindings below) since there's no online identification yet.
|
|
|
|
|
const SAMPLE_ITEMS: Item[] = [
|
2026-08-29 01:55:54 +00:00
|
|
|
{
|
2026-08-29 02:06:14 +00:00
|
|
|
id: "sample-1",
|
2026-08-29 01:55:54 +00:00
|
|
|
originalName: "Breaking.Bad.S05E14.720p.HDTV.x264-KILLERS.mkv",
|
|
|
|
|
ext: "mkv",
|
|
|
|
|
bindings: { n: "Breaking Bad", s00e00: "S05E14", t: "Ozymandias" },
|
2026-08-29 02:06:14 +00:00
|
|
|
isSample: true,
|
2026-08-29 01:55:54 +00:00
|
|
|
},
|
|
|
|
|
{
|
2026-08-29 02:06:14 +00:00
|
|
|
id: "sample-2",
|
2026-08-29 01:55:54 +00:00
|
|
|
originalName: "Breaking.Bad.S05E15.720p.HDTV.x264-KILLERS.mkv",
|
|
|
|
|
ext: "mkv",
|
|
|
|
|
bindings: { n: "Breaking Bad", s00e00: "S05E15", t: "Granite State" },
|
2026-08-29 02:06:14 +00:00
|
|
|
isSample: true,
|
2026-08-29 01:55:54 +00:00
|
|
|
},
|
|
|
|
|
{
|
2026-08-29 02:06:14 +00:00
|
|
|
id: "sample-3",
|
2026-08-29 01:55:54 +00:00
|
|
|
originalName: "The.Office.US.S03E01.DVDRip.XviD-SAiNTS.avi",
|
|
|
|
|
ext: "avi",
|
|
|
|
|
bindings: { n: "The Office (US)", s00e00: "S03E01", t: "Gay Witch Hunt" },
|
2026-08-29 02:06:14 +00:00
|
|
|
isSample: true,
|
2026-08-29 01:55:54 +00:00
|
|
|
},
|
|
|
|
|
{
|
2026-08-29 02:06:14 +00:00
|
|
|
id: "sample-4",
|
2026-08-29 01:55:54 +00:00
|
|
|
originalName: "random_video_file.mp4",
|
|
|
|
|
ext: "mp4",
|
|
|
|
|
bindings: null,
|
2026-08-29 02:06:14 +00:00
|
|
|
isSample: true,
|
2026-08-29 01:55:54 +00:00
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
|
2026-08-29 02:06:14 +00:00
|
|
|
const SAMPLE_EXPRESSION = "{n} - {s00e00} - {t}";
|
|
|
|
|
// The matching engine isn't ported yet, so real loaded files only have their own
|
|
|
|
|
// filename-derived fields to preview with (mirrors the original's plain FileNameFormatter,
|
|
|
|
|
// used whenever a file has no online match).
|
|
|
|
|
const REAL_FILE_EXPRESSION = "{name}";
|
|
|
|
|
|
|
|
|
|
function realFileToItem(entry: FileEntry): Item {
|
|
|
|
|
return {
|
|
|
|
|
id: entry.path,
|
|
|
|
|
originalName: entry.ext ? `${entry.name}.${entry.ext}` : entry.name,
|
|
|
|
|
ext: entry.ext,
|
|
|
|
|
bindings: { name: entry.name, ext: entry.ext },
|
|
|
|
|
isSample: false,
|
|
|
|
|
};
|
|
|
|
|
}
|
2026-08-29 01:55:54 +00:00
|
|
|
|
|
|
|
|
function ListButton({
|
|
|
|
|
icon,
|
|
|
|
|
label,
|
|
|
|
|
onClick,
|
|
|
|
|
}: {
|
|
|
|
|
icon: JSX.Element;
|
|
|
|
|
label: string;
|
|
|
|
|
onClick: () => void;
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
|
|
|
|
<button className="list-icon-button" title={label} onClick={onClick}>
|
|
|
|
|
{icon}
|
|
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-29 02:06:14 +00:00
|
|
|
function LoadMenuButton({ onFiles, onFolder }: { onFiles: () => void; onFolder: () => void }) {
|
|
|
|
|
const [open, setOpen] = useState(false);
|
|
|
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!open) return;
|
|
|
|
|
const onOutside = (e: MouseEvent) => {
|
|
|
|
|
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
|
|
|
|
};
|
|
|
|
|
document.addEventListener("mousedown", onOutside);
|
|
|
|
|
return () => document.removeEventListener("mousedown", onOutside);
|
|
|
|
|
}, [open]);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="load-menu" ref={ref}>
|
|
|
|
|
<ListButton icon={<LoadIcon />} label="Load Files" onClick={() => setOpen((v) => !v)} />
|
|
|
|
|
{open && (
|
|
|
|
|
<div className="load-menu-popup">
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setOpen(false);
|
|
|
|
|
onFiles();
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
Select Files…
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setOpen(false);
|
|
|
|
|
onFolder();
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
Select Folder…
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-29 01:55:54 +00:00
|
|
|
export default function RenamePanel() {
|
2026-08-29 02:06:14 +00:00
|
|
|
const [expression, setExpression] = useState(SAMPLE_EXPRESSION);
|
2026-08-29 01:55:54 +00:00
|
|
|
const [editingFormat, setEditingFormat] = useState(false);
|
2026-08-29 02:06:14 +00:00
|
|
|
const [items, setItems] = useState<Item[]>(SAMPLE_ITEMS);
|
2026-08-29 01:55:54 +00:00
|
|
|
const [selected, setSelected] = useState<string | null>(SAMPLE_ITEMS[0].id);
|
|
|
|
|
const [computedNames, setComputedNames] = useState<Record<string, string>>({});
|
|
|
|
|
const [computeError, setComputeError] = useState<string | null>(null);
|
|
|
|
|
const [status, setStatus] = useState<string | null>(null);
|
|
|
|
|
|
2026-08-29 02:06:14 +00:00
|
|
|
// Refs so the drag-and-drop listener (registered once on mount) always sees the
|
|
|
|
|
// latest expression/items instead of the stale values from its first render.
|
|
|
|
|
const expressionRef = useRef(expression);
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
expressionRef.current = expression;
|
|
|
|
|
}, [expression]);
|
|
|
|
|
|
2026-08-29 01:55:54 +00:00
|
|
|
useEffect(() => {
|
|
|
|
|
let cancelled = false;
|
|
|
|
|
|
|
|
|
|
Promise.all(
|
2026-08-29 02:06:14 +00:00
|
|
|
items.map(async (item) => {
|
2026-08-29 01:55:54 +00:00
|
|
|
if (!item.bindings) return [item.id, null] as const;
|
2026-08-29 02:06:14 +00:00
|
|
|
const rendered = await invoke<string>("render_expression", {
|
|
|
|
|
expression,
|
|
|
|
|
bindings: item.bindings,
|
|
|
|
|
});
|
|
|
|
|
return [item.id, `${rendered}${item.ext ? `.${item.ext}` : ""}`] as const;
|
2026-08-29 01:55:54 +00:00
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.then((entries) => {
|
|
|
|
|
if (cancelled) return;
|
|
|
|
|
const next: Record<string, string> = {};
|
|
|
|
|
for (const [id, name] of entries) {
|
|
|
|
|
if (name) next[id] = name;
|
|
|
|
|
}
|
|
|
|
|
setComputedNames(next);
|
|
|
|
|
setComputeError(null);
|
|
|
|
|
})
|
|
|
|
|
.catch((err) => {
|
|
|
|
|
if (cancelled) return;
|
|
|
|
|
setComputeError(String(err));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
cancelled = true;
|
|
|
|
|
};
|
2026-08-29 02:06:14 +00:00
|
|
|
}, [expression, items]);
|
2026-08-29 01:55:54 +00:00
|
|
|
|
|
|
|
|
const notImplemented = (feature: string) => {
|
|
|
|
|
setStatus(`${feature} isn't implemented yet — see the porting roadmap in the README.`);
|
|
|
|
|
window.clearTimeout((notImplemented as any)._t);
|
|
|
|
|
(notImplemented as any)._t = window.setTimeout(() => setStatus(null), 3200);
|
|
|
|
|
};
|
|
|
|
|
|
2026-08-29 02:06:14 +00:00
|
|
|
const addRealFiles = (entries: FileEntry[]) => {
|
|
|
|
|
if (entries.length === 0) return;
|
|
|
|
|
|
|
|
|
|
// Only auto-switch the naming scheme the first time real files replace the demo,
|
|
|
|
|
// and only if the user hasn't already customized it.
|
|
|
|
|
if (expressionRef.current === SAMPLE_EXPRESSION) {
|
|
|
|
|
setExpression(REAL_FILE_EXPRESSION);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setItems((prev) => {
|
|
|
|
|
const real = prev.filter((i) => !i.isSample);
|
|
|
|
|
const existing = new Set(real.map((i) => i.id));
|
|
|
|
|
const added = entries.map(realFileToItem).filter((i) => !existing.has(i.id));
|
|
|
|
|
const next = [...real, ...added];
|
|
|
|
|
if (added.length > 0) setSelected(added[0].id);
|
|
|
|
|
return next;
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const loadFiles = () => {
|
|
|
|
|
invoke<FileEntry[]>("pick_files")
|
|
|
|
|
.then(addRealFiles)
|
|
|
|
|
.catch((err) => setStatus(`Couldn't open the file picker: ${err}`));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const loadFolder = () => {
|
|
|
|
|
invoke<FileEntry[]>("pick_folder")
|
|
|
|
|
.then(addRealFiles)
|
|
|
|
|
.catch((err) => setStatus(`Couldn't open the folder picker: ${err}`));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// OS drag-and-drop of files/folders onto the window.
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
let unlisten: (() => void) | undefined;
|
|
|
|
|
let cancelled = false;
|
|
|
|
|
|
|
|
|
|
import("@tauri-apps/api/webview")
|
|
|
|
|
.then(({ getCurrentWebview }) =>
|
|
|
|
|
getCurrentWebview().onDragDropEvent((event) => {
|
|
|
|
|
if (event.payload.type === "drop") {
|
|
|
|
|
invoke<FileEntry[]>("expand_dropped_paths", { paths: event.payload.paths })
|
|
|
|
|
.then(addRealFiles)
|
|
|
|
|
.catch((err) => setStatus(`Couldn't read dropped files: ${err}`));
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.then((fn) => {
|
|
|
|
|
if (cancelled) fn();
|
|
|
|
|
else unlisten = fn;
|
|
|
|
|
})
|
|
|
|
|
.catch(() => {
|
|
|
|
|
// Not running inside Tauri (e.g. a plain browser preview) - drag-and-drop
|
|
|
|
|
// just won't be available there; the Load button still works via invoke.
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
cancelled = true;
|
|
|
|
|
unlisten?.();
|
|
|
|
|
};
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const removeSelected = () => {
|
|
|
|
|
if (!selected) return notImplemented("Excluding items");
|
|
|
|
|
setItems((prev) => {
|
|
|
|
|
const idx = prev.findIndex((i) => i.id === selected);
|
|
|
|
|
const next = prev.filter((i) => i.id !== selected);
|
|
|
|
|
const fallback = next[Math.min(idx, next.length - 1)];
|
|
|
|
|
setSelected(fallback ? fallback.id : null);
|
|
|
|
|
return next;
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const clearAll = () => {
|
|
|
|
|
setItems([]);
|
|
|
|
|
setSelected(null);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const matchedCount = useMemo(() => items.filter((i) => i.bindings).length, [items]);
|
|
|
|
|
|
|
|
|
|
const renderOriginalRow = (item: Item) => (
|
|
|
|
|
<Row
|
|
|
|
|
key={item.id}
|
|
|
|
|
selected={selected === item.id}
|
|
|
|
|
muted={!item.bindings}
|
|
|
|
|
onClick={() => setSelected(item.id)}
|
|
|
|
|
text={item.originalName}
|
|
|
|
|
badge={item.ext}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const renderNameRow = (item: Item) => {
|
|
|
|
|
const name = computedNames[item.id];
|
|
|
|
|
const [base, ext] = name ? splitExt(name) : [null, item.ext];
|
|
|
|
|
return (
|
|
|
|
|
<Row
|
|
|
|
|
key={item.id}
|
|
|
|
|
selected={selected === item.id}
|
|
|
|
|
muted={!item.bindings}
|
|
|
|
|
onClick={() => setSelected(item.id)}
|
|
|
|
|
text={item.bindings ? base ?? "…" : "No data"}
|
|
|
|
|
badge={ext}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
};
|
2026-08-29 01:55:54 +00:00
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="rename-panel">
|
|
|
|
|
<div className="rename-lists">
|
|
|
|
|
<ListCard
|
|
|
|
|
title="Original Files"
|
2026-08-29 02:06:14 +00:00
|
|
|
items={items}
|
|
|
|
|
renderRow={renderOriginalRow}
|
|
|
|
|
emptyHint="Drop files or folders here, or click Load"
|
2026-08-29 01:55:54 +00:00
|
|
|
buttonRow={
|
|
|
|
|
<>
|
|
|
|
|
<ListButton icon={<DownIcon />} label="Align Down" onClick={() => notImplemented("Reordering")} />
|
|
|
|
|
<ListButton icon={<UpIcon />} label="Align Up" onClick={() => notImplemented("Reordering")} />
|
2026-08-29 02:06:14 +00:00
|
|
|
<LoadMenuButton onFiles={loadFiles} onFolder={loadFolder} />
|
2026-08-29 01:55:54 +00:00
|
|
|
<span className="list-button-gap" />
|
2026-08-29 02:06:14 +00:00
|
|
|
<ListButton icon={<RemoveIcon />} label="Exclude Selected" onClick={removeSelected} />
|
|
|
|
|
<ListButton icon={<ClearIcon />} label="Clear" onClick={clearAll} />
|
2026-08-29 01:55:54 +00:00
|
|
|
<ListButton icon={<HistoryIcon />} label="History" onClick={() => notImplemented("Rename history")} />
|
|
|
|
|
<ListButton icon={<PresetsIcon />} label="Presets" onClick={() => notImplemented("Presets")} />
|
|
|
|
|
</>
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
<div className="rename-actions">
|
|
|
|
|
<button className="action-button" onClick={() => notImplemented("Matching")}>
|
|
|
|
|
Match
|
|
|
|
|
</button>
|
|
|
|
|
<button className="action-button primary" onClick={() => notImplemented("Renaming")}>
|
|
|
|
|
Rename
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<ListCard
|
|
|
|
|
title="New Names"
|
2026-08-29 02:06:14 +00:00
|
|
|
items={items}
|
|
|
|
|
renderRow={renderNameRow}
|
|
|
|
|
emptyHint="Naming-scheme preview appears here"
|
2026-08-29 01:55:54 +00:00
|
|
|
buttonRow={
|
|
|
|
|
<>
|
|
|
|
|
<ListButton icon={<DownIcon />} label="Align Down" onClick={() => notImplemented("Reordering")} />
|
|
|
|
|
<ListButton icon={<UpIcon />} label="Align Up" onClick={() => notImplemented("Reordering")} />
|
2026-08-29 02:06:14 +00:00
|
|
|
<LoadMenuButton onFiles={loadFiles} onFolder={loadFolder} />
|
2026-08-29 01:55:54 +00:00
|
|
|
<span className="list-button-gap" />
|
|
|
|
|
<ListButton icon={<FetchIcon />} label="Fetch Data" onClick={() => notImplemented("Online matching")} />
|
|
|
|
|
<ListButton icon={<GearIcon />} label="Naming Scheme" onClick={() => setEditingFormat((v) => !v)} />
|
|
|
|
|
</>
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{editingFormat && (
|
|
|
|
|
<div className="format-editor">
|
|
|
|
|
<label htmlFor="expression">Naming scheme</label>
|
|
|
|
|
<input
|
|
|
|
|
id="expression"
|
|
|
|
|
value={expression}
|
|
|
|
|
spellCheck={false}
|
|
|
|
|
onChange={(e) => setExpression(e.target.value)}
|
|
|
|
|
/>
|
|
|
|
|
{computeError && <p className="format-error">{computeError}</p>}
|
|
|
|
|
<p className="format-hint">
|
2026-08-29 02:06:14 +00:00
|
|
|
Live preview against {matchedCount} of {items.length} file{items.length === 1 ? "" : "s"}. Runs the real
|
|
|
|
|
Rust naming-expression engine (Rhai-backed) through Tauri. Loaded files only expose{" "}
|
|
|
|
|
<code>{"{name}"}</code>/<code>{"{ext}"}</code> (their own filename) since the matching engine that would
|
|
|
|
|
populate <code>{"{n}"}</code>/<code>{"{s00e00}"}</code>/etc. from online metadata isn't built yet.
|
2026-08-29 01:55:54 +00:00
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{status && <div className="status-bar">{status}</div>}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function ListCard({
|
|
|
|
|
title,
|
2026-08-29 02:06:14 +00:00
|
|
|
items,
|
|
|
|
|
renderRow,
|
|
|
|
|
emptyHint,
|
2026-08-29 01:55:54 +00:00
|
|
|
buttonRow,
|
|
|
|
|
}: {
|
|
|
|
|
title: string;
|
2026-08-29 02:06:14 +00:00
|
|
|
items: Item[];
|
|
|
|
|
renderRow: (item: Item) => ReactNode;
|
|
|
|
|
emptyHint: string;
|
2026-08-29 01:55:54 +00:00
|
|
|
buttonRow: ReactNode;
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="list-card">
|
|
|
|
|
<span className="list-card-title">{title}</span>
|
2026-08-29 02:06:14 +00:00
|
|
|
<div className="list-card-body">
|
|
|
|
|
{items.length === 0 ? <div className="list-empty-hint">{emptyHint}</div> : items.map(renderRow)}
|
|
|
|
|
</div>
|
2026-08-29 01:55:54 +00:00
|
|
|
<div className="list-button-row">{buttonRow}</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function Row({
|
|
|
|
|
text,
|
|
|
|
|
badge,
|
|
|
|
|
selected,
|
|
|
|
|
muted,
|
|
|
|
|
onClick,
|
|
|
|
|
}: {
|
|
|
|
|
text: string;
|
|
|
|
|
badge: string | null;
|
|
|
|
|
selected: boolean;
|
|
|
|
|
muted: boolean;
|
|
|
|
|
onClick: () => void;
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
2026-08-29 02:06:14 +00:00
|
|
|
<div className={`list-row${selected ? " selected" : ""}${muted ? " muted" : ""}`} onClick={onClick}>
|
2026-08-29 01:55:54 +00:00
|
|
|
<span className="list-row-text">{text}</span>
|
|
|
|
|
{badge && <span className="list-row-badge">{badge}</span>}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function splitExt(name: string): [string, string] {
|
|
|
|
|
const i = name.lastIndexOf(".");
|
|
|
|
|
if (i <= 0) return [name, ""];
|
|
|
|
|
return [name.slice(0, i), name.slice(i + 1)];
|
|
|
|
|
}
|