Add real file loading: native file/folder picker + OS drag-and-drop, backed by real Rust commands (pick_files/pick_folder/expand_dropped_paths)

This commit is contained in:
Renamr Scaffold
2026-08-29 02:06:14 +00:00
parent 87e150f31b
commit 07ce595f77
7 changed files with 550 additions and 65 deletions
+46
View File
@@ -120,6 +120,52 @@
background: #28515a;
}
.load-menu {
position: relative;
display: inline-flex;
}
.load-menu-popup {
position: absolute;
bottom: calc(100% + 6px);
left: 0;
z-index: 10;
display: flex;
flex-direction: column;
min-width: 150px;
padding: 4px;
background: #16262d;
border: 1px solid var(--surface-deep);
border-radius: 10px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
}
.load-menu-popup button {
all: unset;
box-sizing: border-box;
padding: 8px 10px;
border-radius: 6px;
font-size: 12px;
color: var(--text);
cursor: pointer;
white-space: nowrap;
}
.load-menu-popup button:hover {
background: var(--surface-deep);
}
.list-empty-hint {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: 24px;
color: var(--muted-text);
font-size: 12px;
}
.rename-actions {
display: flex;
flex-direction: column;
+224 -63
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { invoke } from "@tauri-apps/api/core";
import {
ClearIcon,
@@ -12,44 +12,71 @@ import {
UpIcon,
} from "./Icons";
interface SampleItem {
interface Item {
id: string;
originalName: string;
ext: string;
bindings: Record<string, string> | null; // null = no match (rendered muted, like the original)
bindings: Record<string, string> | null; // null = no data to preview with (rendered muted)
isSample: boolean;
}
// Placeholder sample batch standing in for a real folder scan + online match, which
// aren't ported yet (see the porting roadmap). This is enough to show the two-list
// layout, selection sync, and live naming-scheme preview working end-to-end.
const SAMPLE_ITEMS: SampleItem[] = [
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[] = [
{
id: "1",
id: "sample-1",
originalName: "Breaking.Bad.S05E14.720p.HDTV.x264-KILLERS.mkv",
ext: "mkv",
bindings: { n: "Breaking Bad", s00e00: "S05E14", t: "Ozymandias" },
isSample: true,
},
{
id: "2",
id: "sample-2",
originalName: "Breaking.Bad.S05E15.720p.HDTV.x264-KILLERS.mkv",
ext: "mkv",
bindings: { n: "Breaking Bad", s00e00: "S05E15", t: "Granite State" },
isSample: true,
},
{
id: "3",
id: "sample-3",
originalName: "The.Office.US.S03E01.DVDRip.XviD-SAiNTS.avi",
ext: "avi",
bindings: { n: "The Office (US)", s00e00: "S03E01", t: "Gay Witch Hunt" },
isSample: true,
},
{
id: "4",
id: "sample-4",
originalName: "random_video_file.mp4",
ext: "mp4",
bindings: null,
isSample: true,
},
];
const DEFAULT_EXPRESSION = "{n} - {s00e00} - {t}";
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,
};
}
function ListButton({
icon,
@@ -67,29 +94,73 @@ function ListButton({
);
}
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>
);
}
export default function RenamePanel() {
const [expression, setExpression] = useState(DEFAULT_EXPRESSION);
const [expression, setExpression] = useState(SAMPLE_EXPRESSION);
const [editingFormat, setEditingFormat] = useState(false);
const [items, setItems] = useState<Item[]>(SAMPLE_ITEMS);
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);
// 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]);
useEffect(() => {
let cancelled = false;
Promise.all(
SAMPLE_ITEMS.map(async (item) => {
items.map(async (item) => {
if (!item.bindings) return [item.id, null] as const;
try {
const rendered = await invoke<string>("render_expression", {
expression,
bindings: item.bindings,
});
return [item.id, `${rendered}.${item.ext}`] as const;
} catch (err) {
throw err;
}
const rendered = await invoke<string>("render_expression", {
expression,
bindings: item.bindings,
});
return [item.id, `${rendered}${item.ext ? `.${item.ext}` : ""}`] as const;
})
)
.then((entries) => {
@@ -109,7 +180,7 @@ export default function RenamePanel() {
return () => {
cancelled = true;
};
}, [expression]);
}, [expression, items]);
const notImplemented = (feature: string) => {
setStatus(`${feature} isn't implemented yet — see the porting roadmap in the README.`);
@@ -117,31 +188,128 @@ export default function RenamePanel() {
(notImplemented as any)._t = window.setTimeout(() => setStatus(null), 3200);
};
const matchedCount = useMemo(() => SAMPLE_ITEMS.filter((i) => i.bindings).length, []);
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}
/>
);
};
return (
<div className="rename-panel">
<div className="rename-lists">
<ListCard
title="Original Files"
rows={SAMPLE_ITEMS.map((item) => (
<Row
key={item.id}
selected={selected === item.id}
muted={!item.bindings}
onClick={() => setSelected(item.id)}
text={item.originalName}
badge={item.ext}
/>
))}
items={items}
renderRow={renderOriginalRow}
emptyHint="Drop files or folders here, or click Load"
buttonRow={
<>
<ListButton icon={<DownIcon />} label="Align Down" onClick={() => notImplemented("Reordering")} />
<ListButton icon={<UpIcon />} label="Align Up" onClick={() => notImplemented("Reordering")} />
<ListButton icon={<LoadIcon />} label="Load Files" onClick={() => notImplemented("Loading files")} />
<LoadMenuButton onFiles={loadFiles} onFolder={loadFolder} />
<span className="list-button-gap" />
<ListButton icon={<RemoveIcon />} label="Exclude Selected" onClick={() => notImplemented("Excluding items")} />
<ListButton icon={<ClearIcon />} label="Clear" onClick={() => notImplemented("Clearing the list")} />
<ListButton icon={<RemoveIcon />} label="Exclude Selected" onClick={removeSelected} />
<ListButton icon={<ClearIcon />} label="Clear" onClick={clearAll} />
<ListButton icon={<HistoryIcon />} label="History" onClick={() => notImplemented("Rename history")} />
<ListButton icon={<PresetsIcon />} label="Presets" onClick={() => notImplemented("Presets")} />
</>
@@ -159,25 +327,14 @@ export default function RenamePanel() {
<ListCard
title="New Names"
rows={SAMPLE_ITEMS.map((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 match"}
badge={ext}
/>
);
})}
items={items}
renderRow={renderNameRow}
emptyHint="Naming-scheme preview appears here"
buttonRow={
<>
<ListButton icon={<DownIcon />} label="Align Down" onClick={() => notImplemented("Reordering")} />
<ListButton icon={<UpIcon />} label="Align Up" onClick={() => notImplemented("Reordering")} />
<ListButton icon={<LoadIcon />} label="Load Files" onClick={() => notImplemented("Loading files")} />
<LoadMenuButton onFiles={loadFiles} onFolder={loadFolder} />
<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)} />
@@ -197,9 +354,10 @@ export default function RenamePanel() {
/>
{computeError && <p className="format-error">{computeError}</p>}
<p className="format-hint">
Live preview against {matchedCount} matched sample file{matchedCount === 1 ? "" : "s"}. This runs the
real Rust naming-expression engine (Rhai-backed) through Tauri — the only piece of the renamer that's
fully implemented so far.
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.
</p>
</div>
)}
@@ -211,17 +369,23 @@ export default function RenamePanel() {
function ListCard({
title,
rows,
items,
renderRow,
emptyHint,
buttonRow,
}: {
title: string;
rows: ReactNode;
items: Item[];
renderRow: (item: Item) => ReactNode;
emptyHint: string;
buttonRow: ReactNode;
}) {
return (
<div className="list-card">
<span className="list-card-title">{title}</span>
<div className="list-card-body">{rows}</div>
<div className="list-card-body">
{items.length === 0 ? <div className="list-empty-hint">{emptyHint}</div> : items.map(renderRow)}
</div>
<div className="list-button-row">{buttonRow}</div>
</div>
);
@@ -241,10 +405,7 @@ function Row({
onClick: () => void;
}) {
return (
<div
className={`list-row${selected ? " selected" : ""}${muted ? " muted" : ""}`}
onClick={onClick}
>
<div className={`list-row${selected ? " selected" : ""}${muted ? " muted" : ""}`} onClick={onClick}>
<span className="list-row-text">{text}</span>
{badge && <span className="list-row-badge">{badge}</span>}
</div>