From fe491ec427ab0f70808e9e5829a57d38d1de3b1d Mon Sep 17 00:00:00 2001 From: lashman Date: Sat, 14 Mar 2026 21:41:11 +0200 Subject: [PATCH] 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. --- crates/nomina-core/src/lib.rs | 2 + crates/nomina-core/src/pipeline.rs | 31 +++++-- crates/nomina-core/src/rules/text_editor.rs | 2 +- crates/nomina/src/commands/rename.rs | 11 ++- ui/src/components/pipeline/PipelineStrip.tsx | 2 +- .../pipeline/configs/NumberingConfig.tsx | 10 +++ .../pipeline/configs/TextEditorConfig.tsx | 86 +++++++++++++------ ui/src/stores/ruleStore.ts | 7 +- ui/src/stores/settingsStore.ts | 4 + 9 files changed, 116 insertions(+), 39 deletions(-) diff --git a/crates/nomina-core/src/lib.rs b/crates/nomina-core/src/lib.rs index 0a38d7a..d837d37 100644 --- a/crates/nomina-core/src/lib.rs +++ b/crates/nomina-core/src/lib.rs @@ -49,6 +49,7 @@ pub type Result = std::result::Result; pub struct RenameContext { pub index: usize, pub total: usize, + pub absolute_index: usize, pub original_name: String, pub extension: String, pub path: PathBuf, @@ -65,6 +66,7 @@ impl RenameContext { Self { index, total: 1, + absolute_index: index, original_name: String::new(), extension: String::new(), path: PathBuf::new(), diff --git a/crates/nomina-core/src/pipeline.rs b/crates/nomina-core/src/pipeline.rs index c00101d..3546129 100644 --- a/crates/nomina-core/src/pipeline.rs +++ b/crates/nomina-core/src/pipeline.rs @@ -33,14 +33,33 @@ impl Pipeline { self.steps.push(PipelineStep { rule, mode }); } - pub fn preview(&self, files: &[crate::FileEntry]) -> Vec { + pub fn preview(&self, files: &[crate::FileEntry], shared_numbering: bool) -> Vec { + // 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 = files .iter() .enumerate() .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 { - index: i, - total: files.len(), + index, + total, + absolute_index: i, original_name: file.name.clone(), extension: file.extension.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 results = pipeline.preview(&files); + let results = pipeline.preview(&files, true); assert_eq!(results[0].new_name, "photo-001.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 results = pipeline.preview(&files); + let results = pipeline.preview(&files, true); 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 results = pipeline.preview(&files); + let results = pipeline.preview(&files, true); assert!(results[0].has_conflict); assert!(results[1].has_conflict); } diff --git a/crates/nomina-core/src/rules/text_editor.rs b/crates/nomina-core/src/rules/text_editor.rs index 07750f8..dca9050 100644 --- a/crates/nomina-core/src/rules/text_editor.rs +++ b/crates/nomina-core/src/rules/text_editor.rs @@ -20,7 +20,7 @@ impl TextEditorRule { impl RenameRule for TextEditorRule { 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() { return name.clone(); } diff --git a/crates/nomina/src/commands/rename.rs b/crates/nomina/src/commands/rename.rs index 99f0ace..57273b9 100644 --- a/crates/nomina/src/commands/rename.rs +++ b/crates/nomina/src/commands/rename.rs @@ -97,6 +97,7 @@ pub async fn preview_rename( directory: String, filters: Option, selected_paths: Option>, + shared_numbering: Option, ) -> Result, String> { let scanner = FileScanner::new( PathBuf::from(&directory), @@ -104,13 +105,19 @@ pub async fn preview_rename( ); 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 = all_files.into_iter().map(|f| (f.path.clone(), f)).collect(); paths.iter().filter_map(|p| file_map.get(&PathBuf::from(p)).cloned()).collect() } else { 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(); for cfg in &rules { 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] diff --git a/ui/src/components/pipeline/PipelineStrip.tsx b/ui/src/components/pipeline/PipelineStrip.tsx index b77a6d3..b3df708 100644 --- a/ui/src/components/pipeline/PipelineStrip.tsx +++ b/ui/src/components/pipeline/PipelineStrip.tsx @@ -410,7 +410,7 @@ function PipelineCard({ rule, index, onScrollToCenter }: { rule: PipelineRule; i e.preventDefault(); requestAnimationFrame(() => { const el = cardRef.current?.closest("[data-slot='popover']")?.querySelector( - ".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(); }); diff --git a/ui/src/components/pipeline/configs/NumberingConfig.tsx b/ui/src/components/pipeline/configs/NumberingConfig.tsx index 9aaf892..9bf7b8e 100644 --- a/ui/src/components/pipeline/configs/NumberingConfig.tsx +++ b/ui/src/components/pipeline/configs/NumberingConfig.tsx @@ -1,4 +1,5 @@ import { useRuleStore } from "@/stores/ruleStore"; +import { useSettingsStore } from "@/stores/settingsStore"; import { Input } from "@/components/ui/input"; import { NumberInput } from "@/components/ui/number-input"; import { SegmentedControl } from "@/components/ui/segmented-control"; @@ -25,6 +26,8 @@ const bases = [ export function NumberingConfig({ ruleId }: { ruleId: string }) { const rule = useRuleStore((s) => s.pipeline.find((r) => r.id === ruleId))?.config as NumberingConfigType | undefined; const updateRule = useRuleStore((s) => s.updateRule); + const sharedNumbering = useSettingsStore((s) => s.sharedNumbering); + const setSharedNumbering = useSettingsStore((s) => s.setSharedNumbering); if (!rule) return null; const update = (changes: Partial) => updateRule(ruleId, changes); @@ -74,7 +77,14 @@ export function NumberingConfig({ ruleId }: { ruleId: string }) { update({ reverse: !!c })} /> Reverse order + +

+ Shared numbering counts files and folders together. When off, they get separate sequences. +

); } diff --git a/ui/src/components/pipeline/configs/TextEditorConfig.tsx b/ui/src/components/pipeline/configs/TextEditorConfig.tsx index 41d39c3..ebfe2d9 100644 --- a/ui/src/components/pipeline/configs/TextEditorConfig.tsx +++ b/ui/src/components/pipeline/configs/TextEditorConfig.tsx @@ -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 { useFileStore } from "@/stores/fileStore"; +import { useSettingsStore } from "@/stores/settingsStore"; import { Button } from "@/components/ui/button"; import type { TextEditorConfig as TextEditorConfigType } from "@/types/rules"; +import type { PreviewResult } from "@/types/files"; 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 sortedFilePaths = useFileStore((s) => s.sortedFilePaths); + const currentPath = useFileStore((s) => s.currentPath); + const filters = useFileStore((s) => s.filters); const selectedFiles = useFileStore((s) => s.selectedFiles); - const files = useFileStore((s) => s.files); + const sharedNumbering = useSettingsStore((s) => s.sharedNumbering); const [text, setText] = useState(""); const [initialized, setInitialized] = useState(false); + const [intermediateStems, setIntermediateStems] = useState([]); - // get selected file stems in display order - const fileMap = new Map(files.map((f) => [f.path, f])); - const selectedStems = sortedFilePaths - .filter((p) => selectedFiles.has(p)) - .map((p) => fileMap.get(p)?.stem ?? ""); + // get rules before this one in the pipeline + const ruleIndex = pipeline.findIndex((r) => r.id === ruleId); + const rulesBefore = ruleIndex > 0 + ? pipeline.slice(0, ruleIndex).filter((r) => r.config.enabled).map((r) => r.config) + : []; + // 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("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 fileCount = selectedStems.length; 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(() => { - if (!rule) return; - if (rule.names.length === 0 && !initialized) { - const joined = selectedStems.join("\n"); + if (!rule || initialized) return; + if (rule.names.length > 0) { + setText(rule.names.join("\n")); + setInitialized(true); + } else if (intermediateStems.length > 0) { + const joined = intermediateStems.join("\n"); setText(joined); setInitialized(true); } - }, [rule, selectedStems.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]); + }, [rule, intermediateStems.length, initialized]); if (!rule) return null; @@ -51,10 +81,11 @@ export function TextEditorConfig({ ruleId }: { ruleId: string }) { updateRule(ruleId, { names }); }; - const handleLoad = () => { - const joined = selectedStems.join("\n"); + const handleLoad = async () => { + const stems = await fetchIntermediateNames(); + const joined = stems.join("\n"); setText(joined); - updateRule(ruleId, { names: selectedStems }); + updateRule(ruleId, { names: stems }); }; return ( @@ -76,6 +107,7 @@ export function TextEditorConfig({ ruleId }: { ruleId: string }) {