diff --git a/Cargo.lock b/Cargo.lock index 2d66b2f..97a1565 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2077,6 +2077,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.13.1", "block2", + "libc", "objc2", "objc2-core-foundation", ] @@ -2534,6 +2535,8 @@ dependencies = [ "serde_json", "tauri", "tauri-build", + "tauri-plugin-dialog", + "walkdir", ] [[package]] @@ -2580,6 +2583,30 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + [[package]] name = "rhai" version = "1.26.0" @@ -3288,6 +3315,64 @@ dependencies = [ "tauri-utils", ] +[[package]] +name = "tauri-plugin" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "walkdir", +] + +[[package]] +name = "tauri-plugin-dialog" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2d3c1dbe38037e7f590cdf2492594d5ceebe031e7bc7e827509b22a999d2940" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.20", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371" +dependencies = [ + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.20", + "toml 1.1.4+spec-1.1.0", + "url", +] + [[package]] name = "tauri-runtime" version = "2.11.3" @@ -4303,6 +4388,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -4336,13 +4430,30 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", + "windows_i686_gnullvm 0.52.6", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + [[package]] name = "windows-threading" version = "0.1.0" @@ -4373,6 +4484,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.42.2" @@ -4385,6 +4502,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.42.2" @@ -4397,12 +4520,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.42.2" @@ -4415,6 +4550,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.42.2" @@ -4427,6 +4568,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" @@ -4439,6 +4586,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.42.2" @@ -4451,6 +4604,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" version = "0.5.40" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d3c2627..cf1c6cf 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -14,5 +14,7 @@ tauri-build = { version = "2", features = [] } [dependencies] renamr-core = { path = "../crates/renamr-core" } tauri = { version = "2", features = [] } +tauri-plugin-dialog = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" +walkdir = "2" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json new file mode 100644 index 0000000..a373edd --- /dev/null +++ b/src-tauri/capabilities/default.json @@ -0,0 +1,7 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "Default permissions for Renamr's main window.", + "windows": ["main"], + "permissions": ["core:default", "dialog:default"] +} diff --git a/src-tauri/src/files.rs b/src-tauri/src/files.rs new file mode 100644 index 0000000..981f37c --- /dev/null +++ b/src-tauri/src/files.rs @@ -0,0 +1,102 @@ +use serde::Serialize; +use std::path::Path; +use tauri::AppHandle; +use tauri_plugin_dialog::DialogExt; +use walkdir::WalkDir; + +#[derive(Debug, Clone, Serialize)] +pub struct FileEntry { + pub path: String, + pub name: String, + pub ext: String, +} + +fn is_hidden(path: &Path) -> bool { + path.file_name() + .and_then(|n| n.to_str()) + .map(|n| n.starts_with('.')) + .unwrap_or(false) +} + +fn to_file_entry(path: &Path) -> FileEntry { + let name = path + .file_stem() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| path.to_string_lossy().to_string()); + let ext = path + .extension() + .map(|s| s.to_string_lossy().to_lowercase()) + .unwrap_or_default(); + + FileEntry { + path: path.to_string_lossy().to_string(), + name, + ext, + } +} + +/// Expands a list of dropped/selected paths into the individual files they contain. +/// Directories are walked recursively (this is how a user dropping a whole show/season +/// folder ends up with every episode file listed); hidden files/dirs are skipped. +fn expand(paths: &[String]) -> Vec { + let mut out = Vec::new(); + let mut seen = std::collections::HashSet::new(); + + for raw in paths { + let path = Path::new(raw); + + if path.is_dir() { + for entry in WalkDir::new(path) + .into_iter() + .filter_entry(|e| !is_hidden(e.path()) || e.path() == path) + .filter_map(|e| e.ok()) + { + if entry.file_type().is_file() { + let full = entry.path().to_string_lossy().to_string(); + if seen.insert(full) { + out.push(to_file_entry(entry.path())); + } + } + } + } else if path.is_file() { + let full = path.to_string_lossy().to_string(); + if seen.insert(full) { + out.push(to_file_entry(path)); + } + } + } + + out.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + out +} + +/// Native "Select Files" dialog, mirroring the original's Load Files button. +#[tauri::command] +pub fn pick_files(app: AppHandle) -> Vec { + let selection = app.dialog().file().blocking_pick_files(); + let paths: Vec = selection + .into_iter() + .flatten() + .filter_map(|f| f.into_path().ok()) + .map(|p| p.to_string_lossy().to_string()) + .collect(); + expand(&paths) +} + +/// Native "Select Folders" dialog; the folder's contents are expanded recursively. +#[tauri::command] +pub fn pick_folder(app: AppHandle) -> Vec { + let selection = app.dialog().file().blocking_pick_folder(); + let paths: Vec = selection + .into_iter() + .filter_map(|f| f.into_path().ok()) + .map(|p| p.to_string_lossy().to_string()) + .collect(); + expand(&paths) +} + +/// Expands paths dropped onto the window (files and/or folders) into their files. +#[tauri::command] +pub fn expand_dropped_paths(paths: Vec) -> Vec { + expand(&paths) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a49b4a3..84e9d49 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,3 +1,5 @@ +mod files; + use renamr_core::format::ExpressionFormat; use renamr_core::string_bindings; use std::collections::HashMap; @@ -16,7 +18,13 @@ fn render_expression(expression: String, bindings: HashMap) -> R #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() - .invoke_handler(tauri::generate_handler![render_expression]) + .plugin(tauri_plugin_dialog::init()) + .invoke_handler(tauri::generate_handler![ + render_expression, + files::pick_files, + files::pick_folder, + files::expand_dropped_paths, + ]) .run(tauri::generate_context!()) .expect("error while running Renamr"); } diff --git a/src/components/RenamePanel.css b/src/components/RenamePanel.css index 69be025..ea4e22f 100644 --- a/src/components/RenamePanel.css +++ b/src/components/RenamePanel.css @@ -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; diff --git a/src/components/RenamePanel.tsx b/src/components/RenamePanel.tsx index 20629f9..0c32f0c 100644 --- a/src/components/RenamePanel.tsx +++ b/src/components/RenamePanel.tsx @@ -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 | null; // null = no match (rendered muted, like the original) + bindings: Record | 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(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 ( +
+ } label="Load Files" onClick={() => setOpen((v) => !v)} /> + {open && ( +
+ + +
+ )} +
+ ); +} + 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(SAMPLE_ITEMS); const [selected, setSelected] = useState(SAMPLE_ITEMS[0].id); const [computedNames, setComputedNames] = useState>({}); const [computeError, setComputeError] = useState(null); const [status, setStatus] = useState(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("render_expression", { - expression, - bindings: item.bindings, - }); - return [item.id, `${rendered}.${item.ext}`] as const; - } catch (err) { - throw err; - } + const rendered = await invoke("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("pick_files") + .then(addRealFiles) + .catch((err) => setStatus(`Couldn't open the file picker: ${err}`)); + }; + + const loadFolder = () => { + invoke("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("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) => ( + 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 ( + setSelected(item.id)} + text={item.bindings ? base ?? "…" : "No data"} + badge={ext} + /> + ); + }; return (
( - setSelected(item.id)} - text={item.originalName} - badge={item.ext} - /> - ))} + items={items} + renderRow={renderOriginalRow} + emptyHint="Drop files or folders here, or click Load" buttonRow={ <> } label="Align Down" onClick={() => notImplemented("Reordering")} /> } label="Align Up" onClick={() => notImplemented("Reordering")} /> - } label="Load Files" onClick={() => notImplemented("Loading files")} /> + - } label="Exclude Selected" onClick={() => notImplemented("Excluding items")} /> - } label="Clear" onClick={() => notImplemented("Clearing the list")} /> + } label="Exclude Selected" onClick={removeSelected} /> + } label="Clear" onClick={clearAll} /> } label="History" onClick={() => notImplemented("Rename history")} /> } label="Presets" onClick={() => notImplemented("Presets")} /> @@ -159,25 +327,14 @@ export default function RenamePanel() { { - const name = computedNames[item.id]; - const [base, ext] = name ? splitExt(name) : [null, item.ext]; - return ( - setSelected(item.id)} - text={item.bindings ? base ?? "…" : "No match"} - badge={ext} - /> - ); - })} + items={items} + renderRow={renderNameRow} + emptyHint="Naming-scheme preview appears here" buttonRow={ <> } label="Align Down" onClick={() => notImplemented("Reordering")} /> } label="Align Up" onClick={() => notImplemented("Reordering")} /> - } label="Load Files" onClick={() => notImplemented("Loading files")} /> + } label="Fetch Data" onClick={() => notImplemented("Online matching")} /> } label="Naming Scheme" onClick={() => setEditingFormat((v) => !v)} /> @@ -197,9 +354,10 @@ export default function RenamePanel() { /> {computeError &&

{computeError}

}

- 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{" "} + {"{name}"}/{"{ext}"} (their own filename) since the matching engine that would + populate {"{n}"}/{"{s00e00}"}/etc. from online metadata isn't built yet.

)} @@ -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 (
{title} -
{rows}
+
+ {items.length === 0 ?
{emptyHint}
: items.map(renderRow)} +
{buttonRow}
); @@ -241,10 +405,7 @@ function Row({ onClick: () => void; }) { return ( -
+
{text} {badge && {badge}}