Naming scheme editor: clickable field tokens + persist across restarts
Two usability fixes to the naming-scheme editor, both reported/requested
directly: the field-reference list ({show name}, {episode number}, etc.)
was plain unclickable text, so building a scheme meant typing every field
name out by hand; and the editor's text always reset to the built-in
sample expression on every launch, since nothing ever wrote it to disk -
only the explicit 1-9 preset slots (presets.rs) were persisted.
Field list -> clickable buttons: each field reference is now a real
<button> (.format-field-chip) wired to insertField(), which reads the
input's real DOM selectionStart/selectionEnd and inserts the token at
the caret, then restores focus and moves the caret to just after the
inserted text so clicking several fields in a row builds up
left-to-right. Purely a frontend change - no new Tauri commands needed.
Persistence: added last_naming_scheme to settings.rs's StoredSettings
(same settings.json already used for tv/movie source, credentials,
etc.), plus get_last_naming_scheme/save_last_naming_scheme commands.
RenamePanel.tsx loads it once on mount (restoring the saved scheme
before the user can interact with anything) and saves it back debounced
(600ms after typing settles) as the user edits - gated behind a
schemeLoaded flag so the save effect can never fire before the load
completes and clobber a saved scheme with the not-yet-replaced sample
default. An empty/blank saved value (fresh install) leaves the built-in
sample expression in place, same fallback behavior as every other
optional setting in this file.
Verified via cargo test --workspace (191 -> 193, two new settings.rs
tests: schema-evolution for the new field, and the blank-clears-to-None
behavior) + npm run build (clean) + a 7-check Playwright script against
npm run dev (mocking window.__TAURI_INTERNALS__.invoke): confirmed the
editor restores a mocked saved scheme on launch, clicking a field chip
inserts its token at a caret placed via setSelectionRange (not just
appended), a second click lands immediately after the first rather than
at the old position, and save_last_naming_scheme fires once (debounced)
with the exact edited text rather than on every keystroke.
Updated README's naming-scheme paragraph to describe both.
This commit is contained in:
@@ -306,13 +306,26 @@
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.format-fields-group code {
|
||||
.format-field-chip {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 2px 6px;
|
||||
font-family: ui-monospace, "SF Mono", monospace;
|
||||
font-size: 10.5px;
|
||||
color: var(--aurora-green);
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease, color 0.12s ease, transform 0.06s ease;
|
||||
}
|
||||
|
||||
.format-field-chip:hover {
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
}
|
||||
|
||||
.format-field-chip:active {
|
||||
background: var(--aurora-green);
|
||||
color: var(--surface);
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
|
||||
@@ -452,6 +452,10 @@ function LoadMenuButton({ onFiles, onFolder }: { onFiles: () => void; onFolder:
|
||||
|
||||
export default function RenamePanel() {
|
||||
const [expression, setExpression] = useState(SAMPLE_EXPRESSION);
|
||||
// Whether the one-time "restore the last-saved naming scheme" load (see the effect
|
||||
// below) has finished - gates the debounced save effect so it can never fire before
|
||||
// the load does and stomp a saved scheme with the SAMPLE_EXPRESSION default.
|
||||
const [schemeLoaded, setSchemeLoaded] = useState(false);
|
||||
const [editingFormat, setEditingFormat] = useState(false);
|
||||
const [items, setItems] = useState<Item[]>(SAMPLE_ITEMS);
|
||||
const [selected, setSelected] = useState<string | null>(SAMPLE_ITEMS[0].id);
|
||||
@@ -543,6 +547,34 @@ export default function RenamePanel() {
|
||||
expressionRef.current = expression;
|
||||
}, [expression]);
|
||||
|
||||
// Restores the naming-scheme editor's text from the last time the app was open (see
|
||||
// `src-tauri/src/settings.rs`'s `last_naming_scheme`) - without this, the editor
|
||||
// always reset to SAMPLE_EXPRESSION on every launch, silently discarding whatever
|
||||
// custom scheme the user had built. A missing/blank saved value (first run, or
|
||||
// nothing ever customized) leaves the built-in sample expression in place.
|
||||
// `schemeLoaded` only flips to true once this settles, so the save effect below can
|
||||
// never fire first and overwrite a saved scheme with the not-yet-replaced default.
|
||||
useEffect(() => {
|
||||
invoke<string | null>("get_last_naming_scheme")
|
||||
.then((saved) => {
|
||||
if (saved && saved.trim()) setExpression(saved);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setSchemeLoaded(true));
|
||||
}, []);
|
||||
|
||||
// Persists the naming-scheme editor's text as the user edits it, debounced so a
|
||||
// fast typist doesn't hit disk on every keystroke. Deliberately separate from the
|
||||
// explicit 1-9 preset slots (`presets.rs`) - this tracks whatever's live in the
|
||||
// editor right now, not a named save.
|
||||
useEffect(() => {
|
||||
if (!schemeLoaded) return;
|
||||
const timer = window.setTimeout(() => {
|
||||
invoke("save_last_naming_scheme", { expression }).catch(() => {});
|
||||
}, 600);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [schemeLoaded, expression]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
@@ -1405,6 +1437,47 @@ export default function RenamePanel() {
|
||||
);
|
||||
};
|
||||
|
||||
// So clicking a field reference below (e.g. "{show name}") can insert it into the
|
||||
// naming-scheme input at the caret instead of the user typing the exact text out by
|
||||
// hand every time.
|
||||
const expressionInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const insertField = (token: string) => {
|
||||
const el = expressionInputRef.current;
|
||||
if (!el) {
|
||||
// No live input to read a caret position from (shouldn't normally happen, since
|
||||
// this is only reachable while the editor is open) - fall back to appending.
|
||||
setExpression((prev) => prev + token);
|
||||
return;
|
||||
}
|
||||
const start = el.selectionStart ?? el.value.length;
|
||||
const end = el.selectionEnd ?? el.value.length;
|
||||
const next = el.value.slice(0, start) + token + el.value.slice(end);
|
||||
setExpression(next);
|
||||
// Restore focus and put the caret right after what was just inserted, so clicking
|
||||
// several fields in a row inserts them left-to-right rather than all piling up at
|
||||
// the same spot. Deferred a tick so this runs after React re-renders the input
|
||||
// with the new value.
|
||||
const caret = start + token.length;
|
||||
requestAnimationFrame(() => {
|
||||
el.focus();
|
||||
el.setSelectionRange(caret, caret);
|
||||
});
|
||||
};
|
||||
|
||||
// One clickable field-reference button, styled like the plain `<code>` tags this
|
||||
// replaced (see RenamePanel.css's `.format-field-chip`) but wired to `insertField`.
|
||||
const FieldChip = ({ token }: { token: string }) => (
|
||||
<button
|
||||
type="button"
|
||||
className="format-field-chip"
|
||||
title={`Insert ${token} at the cursor`}
|
||||
onClick={() => insertField(token)}
|
||||
>
|
||||
{token}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="rename-panel">
|
||||
<div className="rename-lists">
|
||||
@@ -1460,6 +1533,7 @@ export default function RenamePanel() {
|
||||
<label htmlFor="expression">Naming scheme</label>
|
||||
<input
|
||||
id="expression"
|
||||
ref={expressionInputRef}
|
||||
value={expression}
|
||||
spellCheck={false}
|
||||
onChange={(e) => setExpression(e.target.value)}
|
||||
@@ -1474,56 +1548,57 @@ export default function RenamePanel() {
|
||||
<a href="https://rhai.rs/" target="_blank" rel="noreferrer">
|
||||
Rhai
|
||||
</a>{" "}
|
||||
script for anyone who wants it (e.g. <code>{"{show name.to_upper()}"}</code>).
|
||||
script for anyone who wants it (e.g. <code>{"{show name.to_upper()}"}</code>). Click any field below to
|
||||
insert it at the cursor instead of typing it out.
|
||||
</p>
|
||||
<div className="format-fields">
|
||||
<div className="format-fields-group">
|
||||
<span className="format-fields-group-title">Always available</span>
|
||||
<code>{"{name}"}</code>
|
||||
<code>{"{ext}"}</code>
|
||||
<FieldChip token="{name}" />
|
||||
<FieldChip token="{ext}" />
|
||||
</div>
|
||||
<div className="format-fields-group">
|
||||
<span className="format-fields-group-title">
|
||||
TV, after <strong>Match</strong> (local, offline)
|
||||
</span>
|
||||
<code>{"{show name}"}</code>
|
||||
<code>{"{season number}"}</code>
|
||||
<code>{"{episode number}"}</code>
|
||||
<FieldChip token="{show name}" />
|
||||
<FieldChip token="{season number}" />
|
||||
<FieldChip token="{episode number}" />
|
||||
</div>
|
||||
<div className="format-fields-group">
|
||||
<span className="format-fields-group-title">
|
||||
TV, after <strong>Fetch Data</strong> (real TMDB metadata)
|
||||
</span>
|
||||
<code>{"{episode name}"}</code>
|
||||
<code>{"{airdate}"}</code>
|
||||
<FieldChip token="{episode name}" />
|
||||
<FieldChip token="{airdate}" />
|
||||
</div>
|
||||
<div className="format-fields-group">
|
||||
<span className="format-fields-group-title">
|
||||
Movies, after <strong>Match</strong> or <strong>Fetch Data</strong>
|
||||
</span>
|
||||
<code>{"{movie name}"}</code>
|
||||
<code>{"{year}"}</code>
|
||||
<code>{"{airdate}"}</code>
|
||||
<FieldChip token="{movie name}" />
|
||||
<FieldChip token="{year}" />
|
||||
<FieldChip token="{airdate}" />
|
||||
</div>
|
||||
<div className="format-fields-group">
|
||||
<span className="format-fields-group-title">
|
||||
Release tags, after <strong>Match</strong> (TV or movies, local, offline)
|
||||
</span>
|
||||
<code>{"{resolution}"}</code>
|
||||
<code>{"{source}"}</code>
|
||||
<code>{"{video codec}"}</code>
|
||||
<code>{"{audio codec}"}</code>
|
||||
<code>{"{release group}"}</code>
|
||||
<FieldChip token="{resolution}" />
|
||||
<FieldChip token="{source}" />
|
||||
<FieldChip token="{video codec}" />
|
||||
<FieldChip token="{audio codec}" />
|
||||
<FieldChip token="{release group}" />
|
||||
</div>
|
||||
<div className="format-fields-group">
|
||||
<span className="format-fields-group-title">
|
||||
Real file probing, after <strong>Match</strong> (reads the actual file, local, offline)
|
||||
</span>
|
||||
<code>{"{width}"}</code>
|
||||
<code>{"{height}"}</code>
|
||||
<code>{"{fps}"}</code>
|
||||
<code>{"{duration}"}</code>
|
||||
<code>{"{file size}"}</code>
|
||||
<FieldChip token="{width}" />
|
||||
<FieldChip token="{height}" />
|
||||
<FieldChip token="{fps}" />
|
||||
<FieldChip token="{duration}" />
|
||||
<FieldChip token="{file size}" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="format-hint">
|
||||
|
||||
Reference in New Issue
Block a user