Compare commits

1 Commits
v0.1.0 ... main

Author SHA1 Message Date
fe491ec427 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.
2026-03-14 21:41:11 +02:00
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 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(),

View File

@@ -33,14 +33,33 @@ impl Pipeline {
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
.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);
}

View File

@@ -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();
}

View File

@@ -97,6 +97,7 @@ pub async fn preview_rename(
directory: String,
filters: Option<FilterConfig>,
selected_paths: Option<Vec<String>>,
shared_numbering: Option<bool>,
) -> Result<Vec<PreviewResult>, 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<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()
} 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]

View File

@@ -410,7 +410,7 @@ function PipelineCard({ rule, index, onScrollToCenter }: { rule: PipelineRule; i
e.preventDefault();
requestAnimationFrame(() => {
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();
});

View File

@@ -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<NumberingConfigType>) => updateRule(ruleId, changes);
@@ -74,7 +77,14 @@ export function NumberingConfig({ ruleId }: { ruleId: string }) {
<Checkbox checked={rule.reverse} onCheckedChange={(c) => update({ reverse: !!c })} />
Reverse order
</label>
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
<Checkbox checked={sharedNumbering} onCheckedChange={(c) => setSharedNumbering(!!c)} />
Shared numbering
</label>
</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>
);
}

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 { 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<string[]>([]);
// 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<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 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 }) {
<textarea
value={text}
onChange={(e) => handleChange(e.target.value)}
onKeyDown={(e) => e.stopPropagation()}
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"
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 {
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", {
rules: activeRules,
directory,
filters,
selectedPaths: null,
selectedPaths: selected,
sharedNumbering,
});
useFileStore.getState().setPreviewResults(results);
} catch (e) {

View File

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