Add Align Up/Down list reordering and naming-scheme Presets

Align Up/Down now swaps the selected file with its neighbor in the shared
item list (Original Files and New Names stay positionally paired, matching
the original FileBot's "align" buttons for manually lining up mismatched
lists).

Naming-scheme Presets: up to 9 saved expressions, one per digit slot,
persisted as presets.json (src-tauri/src/presets.rs, same load/save
pattern as settings.rs/history.rs). A new Presets panel lists all 9 slots
with Save Current / Apply / Clear, and a global 1-9 keydown handler applies
a slot instantly from anywhere in the app (ignored while a text field is
focused or a modal is open).
This commit is contained in:
Renamr Scaffold
2026-08-29 09:39:52 +00:00
parent 94eb26f778
commit 21d78b8f3a
5 changed files with 376 additions and 13 deletions
+15 -8
View File
@@ -23,7 +23,10 @@ video/audio codec, release group — so naming schemes can reference
`{resolution}`/`{source}`/`{video codec}`/`{audio codec}`/`{release group}` (or
FileBot's own `{vf}`/`{source}`/`{vc}`/`{ac}`/`{group}`) without a media prober; the
rest of FileBot's full media-metadata bindings (real probed video/audio stream info)
are not yet ported. See "Porting roadmap" below.
are not yet ported. The Align Up/Down buttons reorder the (shared) file list — the two
panes always stay positionally paired — and up to 9 naming schemes can be saved as
presets, recalled with a click or a `1`-`9` keypress (persisted locally, same pattern as
Settings/History). See "Porting roadmap" below.
The naming scheme is fully user-editable from the app (the "Naming Scheme" button next
to the New Names list) — the default is `{show name} - {season number}x{episode
@@ -136,15 +139,18 @@ src-tauri/ Tauri app shell: exposes renamr-core as commands to the f
movies, TMDB metadata fetch — `metadata.rs` for TV episodes,
plus a movie-fetch command alongside it — executing a rename
plan, native file/folder dialogs, drag-and-drop expansion), plus
`settings.rs` (TMDB token) and `history.rs` (rename history/undo
log) — both stored as JSON in the OS app-config directory — app
`settings.rs` (TMDB token), `history.rs` (rename history/undo
log), and `presets.rs` (up to 9 saved naming schemes) — all three
stored as JSON in the OS app-config directory — app
metadata/icons/window config (tauri.conf.json).
src/ React + TypeScript UI (Vite). Faithful recreation of the original
two-pane rename screen (Aurora theme) wired to real file loading,
drag-and-drop, live naming-scheme preview, local TV/movie
matching, TMDB fetch for both (behind a Settings modal for the
API token), a confirm-before-touching-disk modal for the actual
rename, and a History panel that can undo a past rename.
rename, a History panel that can undo a past rename, Align Up/Down
list reordering, and a Presets panel (plus `1`-`9` hotkeys) for
saved naming schemes.
scripts/gen_icons.py Regenerates src-tauri/icons/ (placeholder branding — swap the
colors/glyph in the script, or replace the icons directly, to
change the look).
@@ -250,10 +256,11 @@ ported from, for anyone picking this up:
Not yet done: the `--conflict` variants beyond skip (auto-number, override), and the
CLI flag surface itself.
7. **The rest of the UI** — the Rename panel (file loading, drag-drop, live preview,
local TV/movie matching, confirmed rename with per-item exclude, rename history/undo)
is built; the other five sidebar panels (Episodes/Subtitles/SFV/Filter/List) are
still stub screens, plus Align up/down reordering and Presets on the Rename panel
itself are still no-ops.
local TV/movie matching, confirmed rename with per-item exclude, rename history/undo,
Align Up/Down reordering, naming-scheme Presets with `1`-`9` hotkeys) is built; the
other five sidebar panels (Episodes/Subtitles/SFV/Filter/List) are still stub
screens. Align Up/Down is button-based (swaps the selected file with its neighbor),
not the original's drag-and-drop-within-a-list interaction.
8. **Undo/history for renames** — done, as a flat JSON-backed undo stack rather than a
full port of `net.filebot.HistoryDialog`'s session/element log
(`crates/renamr-core/src/history`, `src-tauri/src/history.rs`). Every successful
+4
View File
@@ -1,6 +1,7 @@
mod files;
mod history;
mod metadata;
mod presets;
mod settings;
use renamr_core::format::ExpressionFormat;
@@ -87,6 +88,9 @@ pub fn run() {
settings::save_tmdb_token,
history::list_rename_history,
history::undo_rename_batch,
presets::list_presets,
presets::save_preset,
presets::delete_preset,
files::pick_files,
files::pick_folder,
files::expand_dropped_paths,
+105
View File
@@ -0,0 +1,105 @@
//! Naming-scheme presets: up to 9 saved expressions, one per digit slot ("1".."9"),
//! recalled with a single keypress in the UI (mirrors the original FileBot's
//! `PresetEditor`/1-9 hotkey behavior in `RenamePanel.java`). Stored as plain JSON in
//! Tauri's per-OS app config directory, same load/save pattern as `settings.rs` and
//! `history.rs` - `presets.json` alongside `settings.json`/`history.json`.
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use tauri::{AppHandle, Manager};
#[derive(Debug, Default, Serialize, Deserialize)]
struct StoredPresets {
#[serde(default)]
slots: HashMap<String, String>,
}
/// A slot key is exactly one ASCII digit `1`-`9` ("0" and multi-character keys are
/// rejected - there are only 9 digit keys to bind hotkeys to, matching the original's
/// 1-9 preset shortcuts).
fn is_valid_slot(slot: &str) -> bool {
matches!(slot.as_bytes(), [b] if (b'1'..=b'9').contains(b))
}
fn presets_path(app: &AppHandle) -> Result<PathBuf, String> {
let dir = app
.path()
.app_config_dir()
.map_err(|e| format!("couldn't resolve the app config directory: {e}"))?;
fs::create_dir_all(&dir).map_err(|e| format!("couldn't create {}: {e}", dir.display()))?;
Ok(dir.join("presets.json"))
}
fn load(app: &AppHandle) -> StoredPresets {
let Ok(path) = presets_path(app) else {
return StoredPresets::default();
};
fs::read_to_string(path)
.ok()
.and_then(|contents| serde_json::from_str(&contents).ok())
.unwrap_or_default()
}
fn save(app: &AppHandle, presets: &StoredPresets) -> Result<(), String> {
let path = presets_path(app)?;
let json = serde_json::to_string_pretty(presets).map_err(|e| e.to_string())?;
fs::write(&path, json).map_err(|e| format!("couldn't write {}: {e}", path.display()))
}
/// Lists every saved preset slot -> naming-scheme expression. Slots with nothing saved
/// simply aren't present in the map - the frontend renders those as empty.
#[tauri::command]
pub fn list_presets(app: AppHandle) -> HashMap<String, String> {
load(&app).slots
}
/// Saves (or overwrites) the naming-scheme expression at one digit slot.
#[tauri::command]
pub fn save_preset(app: AppHandle, slot: String, expression: String) -> Result<(), String> {
if !is_valid_slot(&slot) {
return Err("Preset slots are the digits 1-9.".to_string());
}
let trimmed = expression.trim();
if trimmed.is_empty() {
return Err("Can't save an empty naming scheme as a preset.".to_string());
}
let mut presets = load(&app);
presets.slots.insert(slot, trimmed.to_string());
save(&app, &presets)
}
/// Clears one digit slot, if anything was saved there.
#[tauri::command]
pub fn delete_preset(app: AppHandle, slot: String) -> Result<(), String> {
if !is_valid_slot(&slot) {
return Err("Preset slots are the digits 1-9.".to_string());
}
let mut presets = load(&app);
presets.slots.remove(&slot);
save(&app, &presets)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accepts_every_digit_one_through_nine() {
for d in '1'..='9' {
assert!(is_valid_slot(&d.to_string()), "slot {d} should be valid");
}
}
#[test]
fn rejects_zero_multi_digit_and_non_digit_slots() {
assert!(!is_valid_slot("0"));
assert!(!is_valid_slot("10"));
assert!(!is_valid_slot(""));
assert!(!is_valid_slot("a"));
assert!(!is_valid_slot("99"));
}
}
+77
View File
@@ -568,6 +568,83 @@
gap: 2px;
}
.preset-row {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
padding: 6px 8px;
border-radius: 8px;
}
.preset-row:hover {
background: rgba(255, 255, 255, 0.03);
}
.preset-slot-badge {
flex: 0 0 auto;
width: 22px;
height: 22px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
background: rgba(255, 255, 255, 0.06);
color: var(--aurora-green);
font-size: 11.5px;
font-weight: 700;
}
.preset-expression {
all: unset;
box-sizing: border-box;
flex: 1 1 0;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 6px 8px;
border-radius: 6px;
font-family: ui-monospace, "SF Mono", monospace;
font-size: 12px;
color: var(--text);
cursor: pointer;
}
.preset-expression:hover {
background: rgba(255, 255, 255, 0.06);
}
.preset-expression:disabled {
cursor: default;
pointer-events: none;
}
.preset-expression-empty {
color: var(--muted-text);
font-style: italic;
cursor: default;
}
.preset-row-actions {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 6px;
}
.preset-save-button {
width: auto;
height: 26px;
padding: 0 10px;
font-size: 11px;
}
.preset-clear-button {
width: 26px;
height: 26px;
}
.status-bar {
position: absolute;
left: 18px;
+175 -5
View File
@@ -385,6 +385,23 @@ export default function RenamePanel() {
const [historyOpen, setHistoryOpen] = useState(false);
const [historyBatches, setHistoryBatches] = useState<HistoryBatch[] | null>(null);
const [undoingBatchId, setUndoingBatchId] = useState<string | null>(null);
// Naming-scheme presets: up to 9 saved expressions, one per digit slot, persisted on
// disk (see `src-tauri/src/presets.rs`) and recallable with a single 1-9 keypress
// (see the keydown listener below) as well as from the Presets panel. `null` = not
// loaded yet.
const [presets, setPresets] = useState<Record<string, string> | null>(null);
const [presetsOpen, setPresetsOpen] = useState(false);
const [presetsBusySlot, setPresetsBusySlot] = useState<string | null>(null);
const loadPresets = () => {
invoke<Record<string, string>>("list_presets")
.then(setPresets)
.catch(() => setPresets({}));
};
useEffect(() => {
loadPresets();
}, []);
useEffect(() => {
invoke<string | null>("get_tmdb_token")
@@ -1029,6 +1046,98 @@ export default function RenamePanel() {
setSelected(null);
};
// "Align Up"/"Align Down": swaps the selected file with its neighbor in `items`.
// Both list panes render the same underlying array in the same order (that's how a
// row in Original Files stays paired with its New Names preview), so this one
// handler reorders both panes at once - matching the original FileBot's "align"
// buttons, used to manually line files up when local matching gets them out of order.
const moveSelected = (direction: "up" | "down") => {
if (!selected) {
setStatus("Select a file first, then Align Up/Down moves it within the list.");
window.clearTimeout((moveSelected as any)._t);
(moveSelected as any)._t = window.setTimeout(() => setStatus(null), 3200);
return;
}
setItems((prev) => {
const idx = prev.findIndex((i) => i.id === selected);
if (idx === -1) return prev;
const swapWith = direction === "up" ? idx - 1 : idx + 1;
if (swapWith < 0 || swapWith >= prev.length) return prev; // already at that edge
const next = [...prev];
[next[idx], next[swapWith]] = [next[swapWith], next[idx]];
return next;
});
};
// Presets panel + 1-9 hotkeys. Applying a preset just swaps the naming-scheme
// expression (same as typing a new one into the format editor) - it doesn't touch
// `items` at all, so the live preview recomputes for whatever's already loaded.
const openPresets = () => {
setPresetsOpen(true);
loadPresets();
};
const closePresets = () => {
if (presetsBusySlot) return;
setPresetsOpen(false);
};
const applyPreset = (slot: string) => {
const value = presets?.[slot];
if (!value) return;
setExpression(value);
setStatus(`Applied preset ${slot}.`);
window.clearTimeout((applyPreset as any)._t);
(applyPreset as any)._t = window.setTimeout(() => setStatus(null), 2500);
};
const saveCurrentAsPreset = async (slot: string) => {
setPresetsBusySlot(slot);
try {
await invoke("save_preset", { slot, expression });
setPresets((prev) => ({ ...(prev ?? {}), [slot]: expression }));
} catch (err) {
setStatus(`Couldn't save preset ${slot}: ${err}`);
} finally {
setPresetsBusySlot(null);
}
};
const deletePreset = async (slot: string) => {
setPresetsBusySlot(slot);
try {
await invoke("delete_preset", { slot });
setPresets((prev) => {
if (!prev) return prev;
const { [slot]: _removed, ...rest } = prev;
return rest;
});
} catch (err) {
setStatus(`Couldn't clear preset ${slot}: ${err}`);
} finally {
setPresetsBusySlot(null);
}
};
// Global 1-9 hotkeys, matching the original's preset shortcuts. Ignored while any
// modal is open or while the user is typing in a text field (the naming-scheme
// input itself is the obvious case - typing "5" into it shouldn't jump presets).
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (!/^[1-9]$/.test(e.key)) return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
const target = e.target as HTMLElement | null;
if (target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable)) {
return;
}
if (renamePlan || settingsOpen || historyOpen || presetsOpen) return;
applyPreset(e.key);
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [presets, renamePlan, settingsOpen, historyOpen, presetsOpen]);
const matchedCount = useMemo(() => items.filter((i) => i.bindings).length, [items]);
const renderOriginalRow = (item: Item) => (
@@ -1067,14 +1176,14 @@ export default function RenamePanel() {
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={<DownIcon />} label="Align Down" onClick={() => moveSelected("down")} />
<ListButton icon={<UpIcon />} label="Align Up" onClick={() => moveSelected("up")} />
<LoadMenuButton onFiles={loadFiles} onFolder={loadFolder} />
<span className="list-button-gap" />
<ListButton icon={<RemoveIcon />} label="Exclude Selected" onClick={removeSelected} />
<ListButton icon={<ClearIcon />} label="Clear" onClick={clearAll} />
<ListButton icon={<HistoryIcon />} label="History" onClick={openHistory} />
<ListButton icon={<PresetsIcon />} label="Presets" onClick={() => notImplemented("Presets")} />
<ListButton icon={<PresetsIcon />} label="Presets" onClick={openPresets} />
</>
}
/>
@@ -1095,8 +1204,8 @@ export default function RenamePanel() {
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={<DownIcon />} label="Align Down" onClick={() => moveSelected("down")} />
<ListButton icon={<UpIcon />} label="Align Up" onClick={() => moveSelected("up")} />
<LoadMenuButton onFiles={loadFiles} onFolder={loadFolder} />
<span className="list-button-gap" />
<ListButton icon={<FetchIcon />} label="Fetch Data" onClick={runFetch} disabled={fetching} />
@@ -1374,6 +1483,67 @@ export default function RenamePanel() {
</div>
</div>
)}
{presetsOpen && (
<div className="modal-overlay" onClick={closePresets}>
<div className="modal-card" onClick={(e) => e.stopPropagation()}>
<h2 className="modal-title">Naming Scheme Presets</h2>
<p className="modal-subtitle">
Save up to 9 naming schemes and jump between them instantly — click a saved one below, or just press
its number key (<code>1</code>-<code>9</code>) anywhere in the app while no text field is focused.{" "}
<strong>Save Current</strong> stores whatever naming scheme is active right now into that slot,
overwriting anything already there.
</p>
<div className="modal-plan-list history-batch-list">
{Array.from({ length: 9 }, (_, i) => String(i + 1)).map((slot) => {
const saved = presets?.[slot];
const busy = presetsBusySlot === slot;
return (
<div className="preset-row" key={slot}>
<span className="preset-slot-badge">{slot}</span>
{saved ? (
<button
className="preset-expression"
title={`Apply: ${saved}`}
onClick={() => applyPreset(slot)}
disabled={busy}
>
{saved}
</button>
) : (
<span className="preset-expression preset-expression-empty">Unused</span>
)}
<div className="preset-row-actions">
<button
className="action-button preset-save-button"
onClick={() => saveCurrentAsPreset(slot)}
disabled={busy}
>
{busy ? "…" : "Save Current"}
</button>
{saved && (
<button
className="list-icon-button preset-clear-button"
title={`Clear preset ${slot}`}
onClick={() => deletePreset(slot)}
disabled={busy}
>
<RemoveIcon />
</button>
)}
</div>
</div>
);
})}
</div>
<div className="modal-actions">
<button className="action-button" onClick={closePresets} disabled={presetsBusySlot !== null}>
Close
</button>
</div>
</div>
</div>
)}
</div>
);
}