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
+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>
);
}