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:
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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..."}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
Reference in New Issue
Block a user