fix numbering, text editor, and selection handling

Separate file/folder numbering by default, with shared numbering
option in the numbering rule popup. Text editor now loads names
from previous pipeline steps instead of originals, and uses
absolute position so it works correctly with any numbering mode.
Folders sort before files for consistent ordering. Spacebar
works in the text editor textarea without triggering drag.
This commit is contained in:
2026-03-14 21:41:11 +02:00
parent d84274db2e
commit fe491ec427
9 changed files with 116 additions and 39 deletions

View File

@@ -49,6 +49,7 @@ pub type Result<T> = std::result::Result<T, NominaError>;
pub struct RenameContext { pub struct RenameContext {
pub index: usize, pub index: usize,
pub total: usize, pub total: usize,
pub absolute_index: usize,
pub original_name: String, pub original_name: String,
pub extension: String, pub extension: String,
pub path: PathBuf, pub path: PathBuf,
@@ -65,6 +66,7 @@ impl RenameContext {
Self { Self {
index, index,
total: 1, total: 1,
absolute_index: index,
original_name: String::new(), original_name: String::new(),
extension: String::new(), extension: String::new(),
path: PathBuf::new(), path: PathBuf::new(),

View File

@@ -33,14 +33,33 @@ impl Pipeline {
self.steps.push(PipelineStep { rule, mode }); self.steps.push(PipelineStep { rule, mode });
} }
pub fn preview(&self, files: &[crate::FileEntry]) -> Vec<PreviewResult> { pub fn preview(&self, files: &[crate::FileEntry], shared_numbering: bool) -> Vec<PreviewResult> {
// precompute per-type counts and indices when using separate numbering
let dir_count = if shared_numbering { 0 } else { files.iter().filter(|f| f.is_dir).count() };
let file_count = if shared_numbering { 0 } else { files.iter().filter(|f| !f.is_dir).count() };
let mut dir_idx: usize = 0;
let mut file_idx: usize = 0;
let results: Vec<PreviewResult> = files let results: Vec<PreviewResult> = files
.iter() .iter()
.enumerate() .enumerate()
.map(|(i, file)| { .map(|(i, file)| {
let (index, total) = if shared_numbering {
(i, files.len())
} else if file.is_dir {
let idx = dir_idx;
dir_idx += 1;
(idx, dir_count)
} else {
let idx = file_idx;
file_idx += 1;
(idx, file_count)
};
let ctx = RenameContext { let ctx = RenameContext {
index: i, index,
total: files.len(), total,
absolute_index: i,
original_name: file.name.clone(), original_name: file.name.clone(),
extension: file.extension.clone(), extension: file.extension.clone(),
path: file.path.clone(), path: file.path.clone(),
@@ -183,7 +202,7 @@ mod tests {
); );
let files = vec![make_file("IMG_001.jpg"), make_file("IMG_002.jpg")]; let files = vec![make_file("IMG_001.jpg"), make_file("IMG_002.jpg")];
let results = pipeline.preview(&files); let results = pipeline.preview(&files, true);
assert_eq!(results[0].new_name, "photo-001.jpg"); assert_eq!(results[0].new_name, "photo-001.jpg");
assert_eq!(results[1].new_name, "photo-002.jpg"); assert_eq!(results[1].new_name, "photo-002.jpg");
} }
@@ -215,7 +234,7 @@ mod tests {
); );
let files = vec![make_file("IMG_001.jpg")]; let files = vec![make_file("IMG_001.jpg")];
let results = pipeline.preview(&files); let results = pipeline.preview(&files, true);
assert_eq!(results[0].new_name, "PHOTO-001.jpg"); assert_eq!(results[0].new_name, "PHOTO-001.jpg");
} }
@@ -238,7 +257,7 @@ mod tests {
); );
let files = vec![make_file("same.txt"), make_file("same.txt")]; let files = vec![make_file("same.txt"), make_file("same.txt")];
let results = pipeline.preview(&files); let results = pipeline.preview(&files, true);
assert!(results[0].has_conflict); assert!(results[0].has_conflict);
assert!(results[1].has_conflict); assert!(results[1].has_conflict);
} }

View File

@@ -20,7 +20,7 @@ impl TextEditorRule {
impl RenameRule for TextEditorRule { impl RenameRule for TextEditorRule {
fn apply(&self, filename: &str, context: &RenameContext) -> String { fn apply(&self, filename: &str, context: &RenameContext) -> String {
if let Some(name) = self.names.get(context.index) { if let Some(name) = self.names.get(context.absolute_index) {
if !name.is_empty() { if !name.is_empty() {
return name.clone(); return name.clone();
} }

View File

@@ -97,6 +97,7 @@ pub async fn preview_rename(
directory: String, directory: String,
filters: Option<FilterConfig>, filters: Option<FilterConfig>,
selected_paths: Option<Vec<String>>, selected_paths: Option<Vec<String>>,
shared_numbering: Option<bool>,
) -> Result<Vec<PreviewResult>, String> { ) -> Result<Vec<PreviewResult>, String> {
let scanner = FileScanner::new( let scanner = FileScanner::new(
PathBuf::from(&directory), PathBuf::from(&directory),
@@ -104,13 +105,19 @@ pub async fn preview_rename(
); );
let all_files = scanner.scan(); let all_files = scanner.scan();
let files = if let Some(ref paths) = selected_paths { let mut files = if let Some(ref paths) = selected_paths {
let file_map: std::collections::HashMap<PathBuf, _> = all_files.into_iter().map(|f| (f.path.clone(), f)).collect(); let file_map: std::collections::HashMap<PathBuf, _> = all_files.into_iter().map(|f| (f.path.clone(), f)).collect();
paths.iter().filter_map(|p| file_map.get(&PathBuf::from(p)).cloned()).collect() paths.iter().filter_map(|p| file_map.get(&PathBuf::from(p)).cloned()).collect()
} else { } else {
all_files all_files
}; };
// sort: folders before files, then case-insensitive alphabetical
files.sort_by(|a, b| {
b.is_dir.cmp(&a.is_dir)
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
});
let mut pipeline = Pipeline::new(); let mut pipeline = Pipeline::new();
for cfg in &rules { for cfg in &rules {
if let Some(rule) = build_rule(cfg) { if let Some(rule) = build_rule(cfg) {
@@ -127,7 +134,7 @@ pub async fn preview_rename(
} }
} }
Ok(pipeline.preview(&files)) Ok(pipeline.preview(&files, shared_numbering.unwrap_or(false)))
} }
#[tauri::command] #[tauri::command]

View File

@@ -410,7 +410,7 @@ function PipelineCard({ rule, index, onScrollToCenter }: { rule: PipelineRule; i
e.preventDefault(); e.preventDefault();
requestAnimationFrame(() => { requestAnimationFrame(() => {
const el = cardRef.current?.closest("[data-slot='popover']")?.querySelector<HTMLElement>( const el = cardRef.current?.closest("[data-slot='popover']")?.querySelector<HTMLElement>(
".p-4 input, .p-4 select, .p-4 button, .p-4 [tabindex='0']" ".p-4 textarea, .p-4 input, .p-4 select, .p-4 button, .p-4 [tabindex='0']"
); );
el?.focus(); el?.focus();
}); });

View File

@@ -1,4 +1,5 @@
import { useRuleStore } from "@/stores/ruleStore"; import { useRuleStore } from "@/stores/ruleStore";
import { useSettingsStore } from "@/stores/settingsStore";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { NumberInput } from "@/components/ui/number-input"; import { NumberInput } from "@/components/ui/number-input";
import { SegmentedControl } from "@/components/ui/segmented-control"; import { SegmentedControl } from "@/components/ui/segmented-control";
@@ -25,6 +26,8 @@ const bases = [
export function NumberingConfig({ ruleId }: { ruleId: string }) { export function NumberingConfig({ ruleId }: { ruleId: string }) {
const rule = useRuleStore((s) => s.pipeline.find((r) => r.id === ruleId))?.config as NumberingConfigType | undefined; const rule = useRuleStore((s) => s.pipeline.find((r) => r.id === ruleId))?.config as NumberingConfigType | undefined;
const updateRule = useRuleStore((s) => s.updateRule); const updateRule = useRuleStore((s) => s.updateRule);
const sharedNumbering = useSettingsStore((s) => s.sharedNumbering);
const setSharedNumbering = useSettingsStore((s) => s.setSharedNumbering);
if (!rule) return null; if (!rule) return null;
const update = (changes: Partial<NumberingConfigType>) => updateRule(ruleId, changes); const update = (changes: Partial<NumberingConfigType>) => updateRule(ruleId, changes);
@@ -74,7 +77,14 @@ export function NumberingConfig({ ruleId }: { ruleId: string }) {
<Checkbox checked={rule.reverse} onCheckedChange={(c) => update({ reverse: !!c })} /> <Checkbox checked={rule.reverse} onCheckedChange={(c) => update({ reverse: !!c })} />
Reverse order Reverse order
</label> </label>
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
<Checkbox checked={sharedNumbering} onCheckedChange={(c) => setSharedNumbering(!!c)} />
Shared numbering
</label>
</div> </div>
<p className="text-[11px] text-muted-foreground -mt-1">
Shared numbering counts files and folders together. When off, they get separate sequences.
</p>
</div> </div>
); );
} }

View File

@@ -1,47 +1,77 @@
import { useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { useRuleStore } from "@/stores/ruleStore"; import { useRuleStore } from "@/stores/ruleStore";
import { useFileStore } from "@/stores/fileStore"; import { useFileStore } from "@/stores/fileStore";
import { useSettingsStore } from "@/stores/settingsStore";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import type { TextEditorConfig as TextEditorConfigType } from "@/types/rules"; import type { TextEditorConfig as TextEditorConfigType } from "@/types/rules";
import type { PreviewResult } from "@/types/files";
export function TextEditorConfig({ ruleId }: { ruleId: string }) { export function TextEditorConfig({ ruleId }: { ruleId: string }) {
const rule = useRuleStore((s) => s.pipeline.find((r) => r.id === ruleId))?.config as TextEditorConfigType | undefined; const pipeline = useRuleStore((s) => s.pipeline);
const rule = pipeline.find((r) => r.id === ruleId)?.config as TextEditorConfigType | undefined;
const updateRule = useRuleStore((s) => s.updateRule); const updateRule = useRuleStore((s) => s.updateRule);
const sortedFilePaths = useFileStore((s) => s.sortedFilePaths); const currentPath = useFileStore((s) => s.currentPath);
const filters = useFileStore((s) => s.filters);
const selectedFiles = useFileStore((s) => s.selectedFiles); const selectedFiles = useFileStore((s) => s.selectedFiles);
const files = useFileStore((s) => s.files); const sharedNumbering = useSettingsStore((s) => s.sharedNumbering);
const [text, setText] = useState(""); const [text, setText] = useState("");
const [initialized, setInitialized] = useState(false); const [initialized, setInitialized] = useState(false);
const [intermediateStems, setIntermediateStems] = useState<string[]>([]);
// get selected file stems in display order // get rules before this one in the pipeline
const fileMap = new Map(files.map((f) => [f.path, f])); const ruleIndex = pipeline.findIndex((r) => r.id === ruleId);
const selectedStems = sortedFilePaths const rulesBefore = ruleIndex > 0
.filter((p) => selectedFiles.has(p)) ? pipeline.slice(0, ruleIndex).filter((r) => r.config.enabled).map((r) => r.config)
.map((p) => fileMap.get(p)?.stem ?? ""); : [];
// compute intermediate names (after previous steps, before this one)
const fetchIntermediateNames = useCallback(async () => {
if (!currentPath) return [];
const selected = selectedFiles.size > 0 ? Array.from(selectedFiles) : null;
try {
const results = await invoke<PreviewResult[]>("preview_rename", {
rules: rulesBefore,
directory: currentPath,
filters,
selectedPaths: selected,
sharedNumbering,
});
// extract stems from the intermediate results
const stems = results.map((r) => {
const name = r.new_name;
const lastDot = name.lastIndexOf(".");
return lastDot > 0 ? name.substring(0, lastDot) : name;
});
setIntermediateStems(stems);
return stems;
} catch {
return [];
}
}, [currentPath, filters, selectedFiles, sharedNumbering, rulesBefore.length]);
// fetch intermediate names on mount
useEffect(() => {
fetchIntermediateNames();
}, [fetchIntermediateNames]);
const fileCount = intermediateStems.length;
const lineCount = text ? text.split("\n").length : 0; const lineCount = text ? text.split("\n").length : 0;
const fileCount = selectedStems.length;
const mismatch = lineCount !== fileCount && text.length > 0; const mismatch = lineCount !== fileCount && text.length > 0;
// populate textarea with current stems when first opened or when selection changes and user hasn't edited // populate textarea with intermediate stems when first opened
useEffect(() => { useEffect(() => {
if (!rule) return; if (!rule || initialized) return;
if (rule.names.length === 0 && !initialized) { if (rule.names.length > 0) {
const joined = selectedStems.join("\n"); setText(rule.names.join("\n"));
setInitialized(true);
} else if (intermediateStems.length > 0) {
const joined = intermediateStems.join("\n");
setText(joined); setText(joined);
setInitialized(true); setInitialized(true);
} }
}, [rule, selectedStems.length, initialized]); }, [rule, intermediateStems.length, initialized]);
// sync rule names when first loaded with existing data
useEffect(() => {
if (!rule) return;
if (rule.names.length > 0 && !initialized) {
setText(rule.names.join("\n"));
setInitialized(true);
}
}, [rule, initialized]);
if (!rule) return null; if (!rule) return null;
@@ -51,10 +81,11 @@ export function TextEditorConfig({ ruleId }: { ruleId: string }) {
updateRule(ruleId, { names }); updateRule(ruleId, { names });
}; };
const handleLoad = () => { const handleLoad = async () => {
const joined = selectedStems.join("\n"); const stems = await fetchIntermediateNames();
const joined = stems.join("\n");
setText(joined); setText(joined);
updateRule(ruleId, { names: selectedStems }); updateRule(ruleId, { names: stems });
}; };
return ( return (
@@ -76,6 +107,7 @@ export function TextEditorConfig({ ruleId }: { ruleId: string }) {
<textarea <textarea
value={text} value={text}
onChange={(e) => handleChange(e.target.value)} onChange={(e) => handleChange(e.target.value)}
onKeyDown={(e) => e.stopPropagation()}
spellCheck={false} spellCheck={false}
className="w-full min-h-[360px] max-h-[600px] rounded-lg border border-input bg-transparent p-3 text-xs font-mono leading-relaxed resize-y outline-none focus:border-ring focus:ring-3 focus:ring-ring/50 dark:bg-input/30" className="w-full min-h-[360px] max-h-[600px] rounded-lg border border-input bg-transparent p-3 text-xs font-mono leading-relaxed resize-y outline-none focus:border-ring focus:ring-3 focus:ring-ring/50 dark:bg-input/30"
placeholder={"One filename per line...\nLine 1 = first selected file\nLine 2 = second selected file\n..."} placeholder={"One filename per line...\nLine 1 = first selected file\nLine 2 = second selected file\n..."}

View File

@@ -182,12 +182,15 @@ export const useRuleStore = create<RuleState>((set, get) => ({
} }
try { try {
const { filters } = useFileStore.getState(); const { filters, selectedFiles } = useFileStore.getState();
const selected = selectedFiles.size > 0 ? Array.from(selectedFiles) : null;
const sharedNumbering = useSettingsStore.getState().sharedNumbering;
const results = await invoke<PreviewResult[]>("preview_rename", { const results = await invoke<PreviewResult[]>("preview_rename", {
rules: activeRules, rules: activeRules,
directory, directory,
filters, filters,
selectedPaths: null, selectedPaths: selected,
sharedNumbering,
}); });
useFileStore.getState().setPreviewResults(results); useFileStore.getState().setPreviewResults(results);
} catch (e) { } catch (e) {

View File

@@ -39,6 +39,7 @@ interface SettingsState {
skipReadOnly: boolean; skipReadOnly: boolean;
conflictStrategy: ConflictStrategy; conflictStrategy: ConflictStrategy;
caseSensitiveMatch: boolean; caseSensitiveMatch: boolean;
sharedNumbering: boolean;
// pipeline // pipeline
autoPreviewDelay: number; autoPreviewDelay: number;
@@ -96,6 +97,7 @@ interface SettingsState {
setSkipReadOnly: (v: boolean) => void; setSkipReadOnly: (v: boolean) => void;
setConflictStrategy: (v: ConflictStrategy) => void; setConflictStrategy: (v: ConflictStrategy) => void;
setCaseSensitiveMatch: (v: boolean) => void; setCaseSensitiveMatch: (v: boolean) => void;
setSharedNumbering: (v: boolean) => void;
setAutoPreviewDelay: (v: number) => void; setAutoPreviewDelay: (v: number) => void;
setShowDisabledRules: (v: DisabledRuleDisplay) => void; setShowDisabledRules: (v: DisabledRuleDisplay) => void;
setCreateBackups: (v: boolean) => void; setCreateBackups: (v: boolean) => void;
@@ -167,6 +169,7 @@ const defaults = {
skipReadOnly: true, skipReadOnly: true,
conflictStrategy: "suffix" as ConflictStrategy, conflictStrategy: "suffix" as ConflictStrategy,
caseSensitiveMatch: false, caseSensitiveMatch: false,
sharedNumbering: false,
autoPreviewDelay: 150, autoPreviewDelay: 150,
showDisabledRules: "dimmed" as DisabledRuleDisplay, showDisabledRules: "dimmed" as DisabledRuleDisplay,
createBackups: false, createBackups: false,
@@ -218,6 +221,7 @@ export const useSettingsStore = create<SettingsState>()(
setSkipReadOnly: (v) => set({ skipReadOnly: v }), setSkipReadOnly: (v) => set({ skipReadOnly: v }),
setConflictStrategy: (v) => set({ conflictStrategy: v }), setConflictStrategy: (v) => set({ conflictStrategy: v }),
setCaseSensitiveMatch: (v) => set({ caseSensitiveMatch: v }), setCaseSensitiveMatch: (v) => set({ caseSensitiveMatch: v }),
setSharedNumbering: (v) => set({ sharedNumbering: v }),
setAutoPreviewDelay: (v) => set({ autoPreviewDelay: v }), setAutoPreviewDelay: (v) => set({ autoPreviewDelay: v }),
setShowDisabledRules: (v) => set({ showDisabledRules: v }), setShowDisabledRules: (v) => set({ showDisabledRules: v }),
setCreateBackups: (v) => set({ createBackups: v }), setCreateBackups: (v) => set({ createBackups: v }),