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

@@ -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 }),