initial project scaffold

Rust workspace with nomina-core (rename engine) and nomina-app (Tauri v2 shell).
React/TypeScript frontend with tabbed rule panels, virtual-scrolled file list,
and Zustand state management. All 9 rule types implemented with 25 passing tests.
This commit is contained in:
2026-03-13 23:49:29 +02:00
commit 9dca2bedfa
69 changed files with 17462 additions and 0 deletions

7
ui/src/App.tsx Normal file
View File

@@ -0,0 +1,7 @@
import { AppShell } from "./components/layout/AppShell";
import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts";
export default function App() {
useKeyboardShortcuts();
return <AppShell />;
}

View File

@@ -0,0 +1,127 @@
import { useRef } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { useFileStore } from "../../stores/fileStore";
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}
export function FileList() {
const files = useFileStore((s) => s.files);
const previewResults = useFileStore((s) => s.previewResults);
const selectedFiles = useFileStore((s) => s.selectedFiles);
const toggleFileSelection = useFileStore((s) => s.toggleFileSelection);
const parentRef = useRef<HTMLDivElement>(null);
const previewMap = new Map(previewResults.map((r) => [r.original_path, r]));
const rowVirtualizer = useVirtualizer({
count: files.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 28,
overscan: 20,
});
if (files.length === 0) {
return (
<div
className="flex items-center justify-center h-full text-sm"
style={{ color: "var(--text-secondary)" }}
>
Navigate to a folder to see files
</div>
);
}
return (
<div className="flex flex-col h-full">
{/* header */}
<div
className="flex text-xs font-medium border-b px-2 py-1 shrink-0"
style={{
background: "var(--bg-tertiary)",
borderColor: "var(--border)",
color: "var(--text-secondary)",
}}
>
<div className="w-8" />
<div className="flex-1 min-w-0 px-2">Original Name</div>
<div className="flex-1 min-w-0 px-2">New Name</div>
<div className="w-20 px-2 text-right">Size</div>
<div className="w-16 px-2 text-center">Status</div>
</div>
{/* virtual rows */}
<div ref={parentRef} className="flex-1 overflow-auto">
<div style={{ height: `${rowVirtualizer.getTotalSize()}px`, position: "relative" }}>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const file = files[virtualRow.index];
const preview = previewMap.get(file.path);
const isSelected = selectedFiles.has(file.path);
const changed = preview && preview.new_name !== preview.original_name;
const hasError = preview?.has_error;
const hasConflict = preview?.has_conflict;
let rowBg = virtualRow.index % 2 === 0 ? "var(--row-even)" : "var(--row-odd)";
if (hasConflict) rowBg = "rgba(220, 38, 38, 0.1)";
else if (hasError) rowBg = "rgba(217, 119, 6, 0.1)";
else if (changed) rowBg = "rgba(22, 163, 74, 0.06)";
return (
<div
key={file.path}
className="flex items-center text-xs px-2 absolute w-full"
style={{
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
background: rowBg,
borderBottom: "1px solid var(--border)",
}}
>
<div className="w-8 flex items-center justify-center">
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleFileSelection(file.path)}
className="accent-[var(--accent)]"
/>
</div>
<div
className="flex-1 min-w-0 px-2 truncate"
style={{ color: "var(--text-primary)" }}
>
{file.name}
</div>
<div
className="flex-1 min-w-0 px-2 truncate"
style={{
color: changed ? "var(--success)" : "var(--text-secondary)",
fontWeight: changed ? 500 : 400,
}}
>
{preview?.new_name || file.name}
</div>
<div
className="w-20 px-2 text-right"
style={{ color: "var(--text-secondary)" }}
>
{file.is_dir ? "-" : formatSize(file.size)}
</div>
<div className="w-16 px-2 text-center">
{hasConflict && <span style={{ color: "var(--error)" }}>Conflict</span>}
{hasError && !hasConflict && (
<span style={{ color: "var(--warning)" }}>Error</span>
)}
{changed && !hasError && <span style={{ color: "var(--success)" }}>OK</span>}
</div>
</div>
);
})}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,26 @@
import { useState } from "react";
import { Sidebar } from "./Sidebar";
import { StatusBar } from "./StatusBar";
import { Toolbar } from "./Toolbar";
import { FileList } from "../browser/FileList";
import { RulePanel } from "../rules/RulePanel";
export function AppShell() {
const [sidebarWidth, setSidebarWidth] = useState(240);
return (
<div className="flex flex-col h-screen">
<Toolbar />
<div className="flex flex-1 min-h-0">
<Sidebar width={sidebarWidth} onResize={setSidebarWidth} />
<div className="flex flex-col flex-1 min-w-0">
<div className="flex-1 min-h-0 overflow-auto">
<FileList />
</div>
<RulePanel />
</div>
</div>
<StatusBar />
</div>
);
}

View File

@@ -0,0 +1,151 @@
import { useState, useEffect } from "react";
import { invoke } from "@tauri-apps/api/core";
import { useFileStore } from "../../stores/fileStore";
interface SidebarProps {
width: number;
onResize: (width: number) => void;
}
export function Sidebar({ width }: SidebarProps) {
const [pathInput, setPathInput] = useState("");
const [drives, setDrives] = useState<string[]>([]);
const [folders, setFolders] = useState<string[]>([]);
const scanDirectory = useFileStore((s) => s.scanDirectory);
const currentPath = useFileStore((s) => s.currentPath);
useEffect(() => {
// detect windows drives
const detected: string[] = [];
for (const letter of "CDEFGHIJKLMNOPQRSTUVWXYZ") {
detected.push(`${letter}:\\`);
}
setDrives(detected);
}, []);
useEffect(() => {
if (currentPath) {
setPathInput(currentPath);
loadFolders(currentPath);
}
}, [currentPath]);
async function loadFolders(path: string) {
try {
const entries = await invoke<Array<{ path: string; name: string; is_dir: boolean }>>(
"scan_directory",
{
path,
filters: {
mask: "*",
regex_filter: null,
min_size: null,
max_size: null,
include_files: false,
include_folders: true,
include_hidden: false,
subfolder_depth: 0,
},
},
);
setFolders(entries.filter((e) => e.is_dir).map((e) => e.path));
} catch {
setFolders([]);
}
}
function handlePathSubmit(e: React.FormEvent) {
e.preventDefault();
if (pathInput.trim()) {
scanDirectory(pathInput.trim());
}
}
function handleDriveClick(drive: string) {
scanDirectory(drive);
}
function handleFolderClick(path: string) {
scanDirectory(path);
}
function folderName(path: string): string {
const parts = path.replace(/\\/g, "/").split("/").filter(Boolean);
return parts[parts.length - 1] || path;
}
return (
<div
className="flex flex-col border-r overflow-hidden select-none"
style={{
width: `${width}px`,
minWidth: "180px",
maxWidth: "400px",
background: "var(--bg-secondary)",
borderColor: "var(--border)",
}}
>
<form onSubmit={handlePathSubmit} className="p-2 border-b" style={{ borderColor: "var(--border)" }}>
<input
type="text"
value={pathInput}
onChange={(e) => setPathInput(e.target.value)}
placeholder="Enter path..."
className="w-full px-2 py-1 text-xs rounded border"
style={{
background: "var(--bg-primary)",
borderColor: "var(--border)",
color: "var(--text-primary)",
}}
/>
</form>
<div className="flex-1 overflow-y-auto text-xs">
{!currentPath && (
<div className="p-2">
<div className="text-[10px] uppercase tracking-wide mb-1" style={{ color: "var(--text-secondary)" }}>
Drives
</div>
{drives.map((d) => (
<button
key={d}
onClick={() => handleDriveClick(d)}
className="block w-full text-left px-2 py-1 rounded hover:opacity-80"
style={{ color: "var(--text-primary)" }}
>
{d}
</button>
))}
</div>
)}
{currentPath && (
<div className="p-2">
{currentPath.includes("\\") || currentPath.includes("/") ? (
<button
onClick={() => {
const parent = currentPath.replace(/\\/g, "/").replace(/\/[^/]+\/?$/, "");
if (parent) scanDirectory(parent.replace(/\//g, "\\") || currentPath.slice(0, 3));
}}
className="block w-full text-left px-2 py-1 rounded mb-1 hover:opacity-80"
style={{ color: "var(--accent)" }}
>
..
</button>
) : null}
{folders.map((f) => (
<button
key={f}
onClick={() => handleFolderClick(f)}
className="block w-full text-left px-2 py-1 rounded truncate hover:opacity-80"
style={{ color: "var(--text-primary)" }}
>
{folderName(f)}
</button>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,35 @@
import { useFileStore } from "../../stores/fileStore";
export function StatusBar() {
const files = useFileStore((s) => s.files);
const selectedFiles = useFileStore((s) => s.selectedFiles);
const previewResults = useFileStore((s) => s.previewResults);
const loading = useFileStore((s) => s.loading);
const conflicts = previewResults.filter((r) => r.has_conflict).length;
const changes = previewResults.filter(
(r) => r.original_name !== r.new_name && !r.has_error,
).length;
const status = loading ? "Scanning..." : changes > 0 ? "Preview ready" : "Ready";
return (
<div
className="flex items-center gap-4 px-4 py-1 text-xs border-t select-none"
style={{
background: "var(--bg-secondary)",
borderColor: "var(--border)",
color: "var(--text-secondary)",
}}
>
<span>{files.length} files</span>
<span>{selectedFiles.size} selected</span>
{changes > 0 && <span>{changes} to rename</span>}
{conflicts > 0 && (
<span style={{ color: "var(--error)" }}>{conflicts} conflicts</span>
)}
<div className="flex-1" />
<span>{status}</span>
</div>
);
}

View File

@@ -0,0 +1,98 @@
import { invoke } from "@tauri-apps/api/core";
import { useFileStore } from "../../stores/fileStore";
import { useRuleStore } from "../../stores/ruleStore";
export function Toolbar() {
const previewResults = useFileStore((s) => s.previewResults);
const currentPath = useFileStore((s) => s.currentPath);
const scanDirectory = useFileStore((s) => s.scanDirectory);
const requestPreview = useRuleStore((s) => s.requestPreview);
const resetAllRules = useRuleStore((s) => s.resetAllRules);
async function handleRename() {
const ops = previewResults.filter(
(r) => !r.has_conflict && !r.has_error && r.original_name !== r.new_name,
);
if (ops.length === 0) return;
try {
const report = await invoke<{ succeeded: number; failed: string[] }>(
"execute_rename",
{ operations: ops },
);
if (report.failed.length > 0) {
console.error("Some renames failed:", report.failed);
}
// refresh
if (currentPath) {
await scanDirectory(currentPath);
}
} catch (e) {
console.error("Rename failed:", e);
}
}
async function handleUndo() {
try {
await invoke("undo_last");
if (currentPath) {
await scanDirectory(currentPath);
}
} catch (e) {
console.error("Undo failed:", e);
}
}
const hasChanges = previewResults.some(
(r) => r.original_name !== r.new_name && !r.has_conflict && !r.has_error,
);
return (
<div
className="flex items-center gap-2 px-4 py-2 border-b select-none"
style={{
background: "var(--bg-secondary)",
borderColor: "var(--border)",
}}
>
<span className="font-semibold text-sm mr-4" style={{ color: "var(--accent)" }}>
Nomina
</span>
<button
onClick={handleRename}
disabled={!hasChanges}
className="px-4 py-1.5 rounded text-sm font-medium text-white disabled:opacity-40"
style={{ background: hasChanges ? "var(--accent)" : "var(--border)" }}
>
Rename
</button>
<button
onClick={() => requestPreview(currentPath)}
className="px-3 py-1.5 rounded text-sm border"
style={{ borderColor: "var(--border)", color: "var(--text-primary)" }}
>
Preview
</button>
<button
onClick={handleUndo}
className="px-3 py-1.5 rounded text-sm border"
style={{ borderColor: "var(--border)", color: "var(--text-primary)" }}
>
Undo
</button>
<div className="flex-1" />
<button
onClick={resetAllRules}
className="px-3 py-1.5 rounded text-sm border"
style={{ borderColor: "var(--border)", color: "var(--text-secondary)" }}
>
Clear Rules
</button>
</div>
);
}

View File

@@ -0,0 +1,102 @@
import { useRuleStore } from "../../stores/ruleStore";
import type { AddConfig, StepMode } from "../../types/rules";
export function AddTab() {
const rule = useRuleStore((s) => s.rules.add) as AddConfig;
const updateRule = useRuleStore((s) => s.updateRule);
const update = (changes: Partial<AddConfig>) => updateRule("add", changes);
return (
<div className="flex flex-col gap-3 text-xs">
<div className="flex gap-3">
<label className="flex flex-col flex-1 gap-1">
<span style={{ color: "var(--text-secondary)" }}>Prefix</span>
<input
type="text"
value={rule.prefix}
onChange={(e) => update({ prefix: e.target.value })}
className="px-2 py-1.5 rounded border"
style={{
background: "var(--bg-primary)",
borderColor: "var(--border)",
color: "var(--text-primary)",
}}
/>
</label>
<label className="flex flex-col flex-1 gap-1">
<span style={{ color: "var(--text-secondary)" }}>Suffix</span>
<input
type="text"
value={rule.suffix}
onChange={(e) => update({ suffix: e.target.value })}
className="px-2 py-1.5 rounded border"
style={{
background: "var(--bg-primary)",
borderColor: "var(--border)",
color: "var(--text-primary)",
}}
/>
</label>
</div>
<div className="flex gap-3">
<label className="flex flex-col flex-1 gap-1">
<span style={{ color: "var(--text-secondary)" }}>Insert</span>
<input
type="text"
value={rule.insert}
onChange={(e) => update({ insert: e.target.value })}
className="px-2 py-1.5 rounded border"
style={{
background: "var(--bg-primary)",
borderColor: "var(--border)",
color: "var(--text-primary)",
}}
/>
</label>
<label className="flex flex-col gap-1 w-24">
<span style={{ color: "var(--text-secondary)" }}>At position</span>
<input
type="number"
value={rule.insert_at}
onChange={(e) => update({ insert_at: parseInt(e.target.value) || 0 })}
min={0}
className="px-2 py-1.5 rounded border"
style={{
background: "var(--bg-primary)",
borderColor: "var(--border)",
color: "var(--text-primary)",
}}
/>
</label>
</div>
<div className="flex items-center gap-4">
<label className="flex items-center gap-1.5 cursor-pointer">
<input
type="checkbox"
checked={rule.word_space}
onChange={(e) => update({ word_space: e.target.checked })}
/>
<span>Word space</span>
</label>
<div className="flex-1" />
<span style={{ color: "var(--text-secondary)" }}>Mode:</span>
{(["Simultaneous", "Sequential"] as StepMode[]).map((mode) => (
<label key={mode} className="flex items-center gap-1 cursor-pointer">
<input
type="radio"
name="add-mode"
checked={rule.step_mode === mode}
onChange={() => update({ step_mode: mode })}
/>
<span>{mode}</span>
</label>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,69 @@
import { useRuleStore } from "../../stores/ruleStore";
import type { CaseConfig, StepMode } from "../../types/rules";
const caseModes = ["Same", "Upper", "Lower", "Title", "Sentence", "Invert", "Random"] as const;
export function CaseTab() {
const rule = useRuleStore((s) => s.rules.case) as CaseConfig;
const updateRule = useRuleStore((s) => s.updateRule);
const update = (changes: Partial<CaseConfig>) => updateRule("case", changes);
return (
<div className="flex flex-col gap-3 text-xs">
<div className="flex gap-3 items-end">
<label className="flex flex-col gap-1 w-40">
<span style={{ color: "var(--text-secondary)" }}>Case mode</span>
<select
value={rule.mode}
onChange={(e) => update({ mode: e.target.value as CaseConfig["mode"] })}
className="px-2 py-1.5 rounded border"
style={{
background: "var(--bg-primary)",
borderColor: "var(--border)",
color: "var(--text-primary)",
}}
>
{caseModes.map((m) => (
<option key={m} value={m}>{m}</option>
))}
</select>
</label>
{rule.mode === "Title" && (
<label className="flex flex-col flex-1 gap-1">
<span style={{ color: "var(--text-secondary)" }}>Exceptions (comma-separated)</span>
<input
type="text"
value={rule.exceptions}
onChange={(e) => update({ exceptions: e.target.value })}
className="px-2 py-1.5 rounded border"
style={{
background: "var(--bg-primary)",
borderColor: "var(--border)",
color: "var(--text-primary)",
}}
placeholder="the, a, an, of..."
/>
</label>
)}
</div>
<div className="flex items-center gap-2">
<div className="flex-1" />
<span style={{ color: "var(--text-secondary)" }}>Mode:</span>
{(["Simultaneous", "Sequential"] as StepMode[]).map((mode) => (
<label key={mode} className="flex items-center gap-1 cursor-pointer">
<input
type="radio"
name="case-mode"
checked={rule.step_mode === mode}
onChange={() => update({ step_mode: mode })}
/>
<span>{mode}</span>
</label>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,71 @@
import { useRuleStore } from "../../stores/ruleStore";
import type { ExtensionConfig, StepMode } from "../../types/rules";
const extModes = ["Same", "Lower", "Upper", "Title", "Extra", "Remove", "Fixed"] as const;
export function ExtensionTab() {
const rule = useRuleStore((s) => s.rules.extension) as ExtensionConfig;
const updateRule = useRuleStore((s) => s.updateRule);
const update = (changes: Partial<ExtensionConfig>) => updateRule("extension", changes);
return (
<div className="flex flex-col gap-3 text-xs">
<div className="flex gap-3 items-end">
<label className="flex flex-col gap-1 w-40">
<span style={{ color: "var(--text-secondary)" }}>Extension mode</span>
<select
value={rule.mode}
onChange={(e) => update({ mode: e.target.value as ExtensionConfig["mode"] })}
className="px-2 py-1.5 rounded border"
style={{
background: "var(--bg-primary)",
borderColor: "var(--border)",
color: "var(--text-primary)",
}}
>
{extModes.map((m) => (
<option key={m} value={m}>{m}</option>
))}
</select>
</label>
{(rule.mode === "Fixed" || rule.mode === "Extra") && (
<label className="flex flex-col flex-1 gap-1">
<span style={{ color: "var(--text-secondary)" }}>
{rule.mode === "Extra" ? "Extra extension" : "New extension"}
</span>
<input
type="text"
value={rule.fixed_value}
onChange={(e) => update({ fixed_value: e.target.value })}
className="px-2 py-1.5 rounded border"
style={{
background: "var(--bg-primary)",
borderColor: "var(--border)",
color: "var(--text-primary)",
}}
placeholder="e.g. bak, txt..."
/>
</label>
)}
</div>
<div className="flex items-center gap-2">
<div className="flex-1" />
<span style={{ color: "var(--text-secondary)" }}>Mode:</span>
{(["Simultaneous", "Sequential"] as StepMode[]).map((mode) => (
<label key={mode} className="flex items-center gap-1 cursor-pointer">
<input
type="radio"
name="ext-mode"
checked={rule.step_mode === mode}
onChange={() => update({ step_mode: mode })}
/>
<span>{mode}</span>
</label>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,136 @@
import { useRuleStore } from "../../stores/ruleStore";
import type { NumberingConfig, StepMode } from "../../types/rules";
const numberModes = ["None", "Prefix", "Suffix", "Both", "Insert"] as const;
const bases = ["Decimal", "Hex", "Octal", "Binary", "Alpha"] as const;
export function NumberingTab() {
const rule = useRuleStore((s) => s.rules.numbering) as NumberingConfig;
const updateRule = useRuleStore((s) => s.updateRule);
const update = (changes: Partial<NumberingConfig>) => updateRule("numbering", changes);
return (
<div className="flex flex-col gap-3 text-xs">
<div className="flex gap-3">
<label className="flex flex-col gap-1 w-28">
<span style={{ color: "var(--text-secondary)" }}>Position</span>
<select
value={rule.mode}
onChange={(e) => update({ mode: e.target.value as NumberingConfig["mode"] })}
className="px-2 py-1.5 rounded border"
style={{
background: "var(--bg-primary)",
borderColor: "var(--border)",
color: "var(--text-primary)",
}}
>
{numberModes.map((m) => (
<option key={m} value={m}>{m}</option>
))}
</select>
</label>
<label className="flex flex-col gap-1 w-20">
<span style={{ color: "var(--text-secondary)" }}>Start</span>
<input
type="number"
value={rule.start}
onChange={(e) => update({ start: parseInt(e.target.value) || 0 })}
className="px-2 py-1.5 rounded border"
style={{
background: "var(--bg-primary)",
borderColor: "var(--border)",
color: "var(--text-primary)",
}}
/>
</label>
<label className="flex flex-col gap-1 w-20">
<span style={{ color: "var(--text-secondary)" }}>Step</span>
<input
type="number"
value={rule.increment}
onChange={(e) => update({ increment: parseInt(e.target.value) || 1 })}
className="px-2 py-1.5 rounded border"
style={{
background: "var(--bg-primary)",
borderColor: "var(--border)",
color: "var(--text-primary)",
}}
/>
</label>
<label className="flex flex-col gap-1 w-20">
<span style={{ color: "var(--text-secondary)" }}>Padding</span>
<input
type="number"
value={rule.padding}
onChange={(e) => update({ padding: parseInt(e.target.value) || 1 })}
min={1}
className="px-2 py-1.5 rounded border"
style={{
background: "var(--bg-primary)",
borderColor: "var(--border)",
color: "var(--text-primary)",
}}
/>
</label>
<label className="flex flex-col gap-1 w-20">
<span style={{ color: "var(--text-secondary)" }}>Separator</span>
<input
type="text"
value={rule.separator}
onChange={(e) => update({ separator: e.target.value })}
className="px-2 py-1.5 rounded border"
style={{
background: "var(--bg-primary)",
borderColor: "var(--border)",
color: "var(--text-primary)",
}}
/>
</label>
<label className="flex flex-col gap-1 w-24">
<span style={{ color: "var(--text-secondary)" }}>Base</span>
<select
value={rule.base}
onChange={(e) => update({ base: e.target.value as NumberingConfig["base"] })}
className="px-2 py-1.5 rounded border"
style={{
background: "var(--bg-primary)",
borderColor: "var(--border)",
color: "var(--text-primary)",
}}
>
{bases.map((b) => (
<option key={b} value={b}>{b}</option>
))}
</select>
</label>
</div>
<div className="flex items-center gap-4">
<label className="flex items-center gap-1.5 cursor-pointer">
<input
type="checkbox"
checked={rule.per_folder}
onChange={(e) => update({ per_folder: e.target.checked })}
/>
<span>Reset per folder</span>
</label>
<div className="flex-1" />
<span style={{ color: "var(--text-secondary)" }}>Mode:</span>
{(["Simultaneous", "Sequential"] as StepMode[]).map((mode) => (
<label key={mode} className="flex items-center gap-1 cursor-pointer">
<input
type="radio"
name="numbering-mode"
checked={rule.step_mode === mode}
onChange={() => update({ step_mode: mode })}
/>
<span>{mode}</span>
</label>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,74 @@
import { useRuleStore } from "../../stores/ruleStore";
import type { RegexConfig, StepMode } from "../../types/rules";
export function RegexTab() {
const rule = useRuleStore((s) => s.rules.regex) as RegexConfig;
const updateRule = useRuleStore((s) => s.updateRule);
const update = (changes: Partial<RegexConfig>) => updateRule("regex", changes);
return (
<div className="flex flex-col gap-3 text-xs">
<div className="flex gap-3">
<label className="flex flex-col flex-1 gap-1">
<span style={{ color: "var(--text-secondary)" }}>Pattern</span>
<input
type="text"
value={rule.pattern}
onChange={(e) => update({ pattern: e.target.value })}
className="px-2 py-1.5 rounded border font-mono"
style={{
background: "var(--bg-primary)",
borderColor: "var(--border)",
color: "var(--text-primary)",
}}
placeholder="Regex pattern..."
/>
</label>
<label className="flex flex-col flex-1 gap-1">
<span style={{ color: "var(--text-secondary)" }}>Replace with</span>
<input
type="text"
value={rule.replace_with}
onChange={(e) => update({ replace_with: e.target.value })}
className="px-2 py-1.5 rounded border font-mono"
style={{
background: "var(--bg-primary)",
borderColor: "var(--border)",
color: "var(--text-primary)",
}}
placeholder="$1, $2 for capture groups..."
/>
</label>
</div>
<div className="flex items-center gap-4">
<label className="flex items-center gap-1.5 cursor-pointer">
<input
type="checkbox"
checked={rule.case_insensitive}
onChange={(e) => update({ case_insensitive: e.target.checked })}
/>
<span>Case insensitive</span>
</label>
<div className="flex-1" />
<div className="flex items-center gap-2">
<span style={{ color: "var(--text-secondary)" }}>Mode:</span>
{(["Simultaneous", "Sequential"] as StepMode[]).map((mode) => (
<label key={mode} className="flex items-center gap-1 cursor-pointer">
<input
type="radio"
name="regex-mode"
checked={rule.step_mode === mode}
onChange={() => update({ step_mode: mode })}
/>
<span>{mode}</span>
</label>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,123 @@
import { useRuleStore } from "../../stores/ruleStore";
import type { RemoveConfig, StepMode } from "../../types/rules";
export function RemoveTab() {
const rule = useRuleStore((s) => s.rules.remove) as RemoveConfig;
const updateRule = useRuleStore((s) => s.updateRule);
const update = (changes: Partial<RemoveConfig>) => updateRule("remove", changes);
return (
<div className="flex flex-col gap-3 text-xs">
<div className="flex gap-3">
<label className="flex flex-col gap-1 w-24">
<span style={{ color: "var(--text-secondary)" }}>First N</span>
<input
type="number"
value={rule.first_n}
onChange={(e) => update({ first_n: parseInt(e.target.value) || 0 })}
min={0}
className="px-2 py-1.5 rounded border"
style={{
background: "var(--bg-primary)",
borderColor: "var(--border)",
color: "var(--text-primary)",
}}
/>
</label>
<label className="flex flex-col gap-1 w-24">
<span style={{ color: "var(--text-secondary)" }}>Last N</span>
<input
type="number"
value={rule.last_n}
onChange={(e) => update({ last_n: parseInt(e.target.value) || 0 })}
min={0}
className="px-2 py-1.5 rounded border"
style={{
background: "var(--bg-primary)",
borderColor: "var(--border)",
color: "var(--text-primary)",
}}
/>
</label>
<label className="flex flex-col gap-1 w-24">
<span style={{ color: "var(--text-secondary)" }}>From</span>
<input
type="number"
value={rule.from}
onChange={(e) => update({ from: parseInt(e.target.value) || 0 })}
min={0}
className="px-2 py-1.5 rounded border"
style={{
background: "var(--bg-primary)",
borderColor: "var(--border)",
color: "var(--text-primary)",
}}
/>
</label>
<label className="flex flex-col gap-1 w-24">
<span style={{ color: "var(--text-secondary)" }}>To</span>
<input
type="number"
value={rule.to}
onChange={(e) => update({ to: parseInt(e.target.value) || 0 })}
min={0}
className="px-2 py-1.5 rounded border"
style={{
background: "var(--bg-primary)",
borderColor: "var(--border)",
color: "var(--text-primary)",
}}
/>
</label>
</div>
<div className="flex gap-3">
<label className="flex flex-col flex-1 gap-1">
<span style={{ color: "var(--text-secondary)" }}>Crop before</span>
<input
type="text"
value={rule.crop_before || ""}
onChange={(e) => update({ crop_before: e.target.value || null })}
className="px-2 py-1.5 rounded border"
style={{
background: "var(--bg-primary)",
borderColor: "var(--border)",
color: "var(--text-primary)",
}}
/>
</label>
<label className="flex flex-col flex-1 gap-1">
<span style={{ color: "var(--text-secondary)" }}>Crop after</span>
<input
type="text"
value={rule.crop_after || ""}
onChange={(e) => update({ crop_after: e.target.value || null })}
className="px-2 py-1.5 rounded border"
style={{
background: "var(--bg-primary)",
borderColor: "var(--border)",
color: "var(--text-primary)",
}}
/>
</label>
</div>
<div className="flex items-center gap-2">
<div className="flex-1" />
<span style={{ color: "var(--text-secondary)" }}>Mode:</span>
{(["Simultaneous", "Sequential"] as StepMode[]).map((mode) => (
<label key={mode} className="flex items-center gap-1 cursor-pointer">
<input
type="radio"
name="remove-mode"
checked={rule.step_mode === mode}
onChange={() => update({ step_mode: mode })}
/>
<span>{mode}</span>
</label>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,82 @@
import { useRuleStore } from "../../stores/ruleStore";
import type { ReplaceConfig, StepMode } from "../../types/rules";
export function ReplaceTab() {
const rule = useRuleStore((s) => s.rules.replace) as ReplaceConfig;
const updateRule = useRuleStore((s) => s.updateRule);
const update = (changes: Partial<ReplaceConfig>) => updateRule("replace", changes);
return (
<div className="flex flex-col gap-3 text-xs">
<div className="flex gap-3">
<label className="flex flex-col flex-1 gap-1">
<span style={{ color: "var(--text-secondary)" }}>Find</span>
<input
type="text"
value={rule.search}
onChange={(e) => update({ search: e.target.value })}
className="px-2 py-1.5 rounded border"
style={{
background: "var(--bg-primary)",
borderColor: "var(--border)",
color: "var(--text-primary)",
}}
placeholder="Text to find..."
/>
</label>
<label className="flex flex-col flex-1 gap-1">
<span style={{ color: "var(--text-secondary)" }}>Replace with</span>
<input
type="text"
value={rule.replace_with}
onChange={(e) => update({ replace_with: e.target.value })}
className="px-2 py-1.5 rounded border"
style={{
background: "var(--bg-primary)",
borderColor: "var(--border)",
color: "var(--text-primary)",
}}
placeholder="Replacement text..."
/>
</label>
</div>
<div className="flex items-center gap-4">
<label className="flex items-center gap-1.5 cursor-pointer">
<input
type="checkbox"
checked={rule.match_case}
onChange={(e) => update({ match_case: e.target.checked })}
/>
<span>Match case</span>
</label>
<label className="flex items-center gap-1.5 cursor-pointer">
<input
type="checkbox"
checked={rule.first_only}
onChange={(e) => update({ first_only: e.target.checked })}
/>
<span>First only</span>
</label>
<div className="flex-1" />
<div className="flex items-center gap-2">
<span style={{ color: "var(--text-secondary)" }}>Mode:</span>
{(["Simultaneous", "Sequential"] as StepMode[]).map((mode) => (
<label key={mode} className="flex items-center gap-1 cursor-pointer">
<input
type="radio"
name="replace-mode"
checked={rule.step_mode === mode}
onChange={() => update({ step_mode: mode })}
/>
<span>{mode}</span>
</label>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,113 @@
import { useEffect } from "react";
import { useRuleStore } from "../../stores/ruleStore";
import { useFileStore } from "../../stores/fileStore";
import { ReplaceTab } from "./ReplaceTab";
import { RegexTab } from "./RegexTab";
import { RemoveTab } from "./RemoveTab";
import { AddTab } from "./AddTab";
import { CaseTab } from "./CaseTab";
import { NumberingTab } from "./NumberingTab";
import { ExtensionTab } from "./ExtensionTab";
const tabs = [
{ id: "replace", label: "Replace" },
{ id: "regex", label: "Regex" },
{ id: "remove", label: "Remove" },
{ id: "add", label: "Add" },
{ id: "case", label: "Case" },
{ id: "numbering", label: "Number" },
{ id: "extension", label: "Extension" },
];
export function RulePanel() {
const activeTab = useRuleStore((s) => s.activeTab);
const setActiveTab = useRuleStore((s) => s.setActiveTab);
const rules = useRuleStore((s) => s.rules);
const requestPreview = useRuleStore((s) => s.requestPreview);
const currentPath = useFileStore((s) => s.currentPath);
// auto-preview when rules change
useEffect(() => {
if (currentPath) {
requestPreview(currentPath);
}
}, [rules, currentPath, requestPreview]);
function isTabActive(id: string): boolean {
const rule = rules[id];
if (!rule || !rule.enabled) return false;
// check if rule has any non-default values set
switch (id) {
case "replace":
return !!(rule as any).search;
case "regex":
return !!(rule as any).pattern;
case "remove":
return (rule as any).first_n > 0 || (rule as any).last_n > 0 || (rule as any).from !== (rule as any).to;
case "add":
return !!(rule as any).prefix || !!(rule as any).suffix || !!(rule as any).insert;
case "case":
return (rule as any).mode !== "Same";
case "numbering":
return (rule as any).mode !== "None";
case "extension":
return (rule as any).mode !== "Same";
default:
return false;
}
}
return (
<div
className="border-t flex flex-col"
style={{
borderColor: "var(--border)",
background: "var(--bg-secondary)",
height: "240px",
minHeight: "160px",
}}
>
{/* tab bar */}
<div
className="flex border-b shrink-0"
style={{ borderColor: "var(--border)" }}
>
{tabs.map((tab) => {
const active = activeTab === tab.id;
const hasContent = isTabActive(tab.id);
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className="px-4 py-2 text-xs font-medium relative"
style={{
color: active ? "var(--accent)" : "var(--text-secondary)",
background: active ? "var(--bg-primary)" : "transparent",
borderBottom: active ? "2px solid var(--accent)" : "2px solid transparent",
}}
>
{tab.label}
{hasContent && (
<span
className="absolute top-1 right-1 w-1.5 h-1.5 rounded-full"
style={{ background: "var(--accent)" }}
/>
)}
</button>
);
})}
</div>
{/* tab content */}
<div className="flex-1 overflow-auto p-3">
{activeTab === "replace" && <ReplaceTab />}
{activeTab === "regex" && <RegexTab />}
{activeTab === "remove" && <RemoveTab />}
{activeTab === "add" && <AddTab />}
{activeTab === "case" && <CaseTab />}
{activeTab === "numbering" && <NumberingTab />}
{activeTab === "extension" && <ExtensionTab />}
</div>
</div>
);
}

View File

@@ -0,0 +1,12 @@
import { useEffect, useState } from "react";
export function useDebounce<T>(value: T, delay: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}

View File

@@ -0,0 +1,36 @@
import { useEffect } from "react";
import { useFileStore } from "../stores/fileStore";
import { useRuleStore } from "../stores/ruleStore";
export function useKeyboardShortcuts() {
const selectAll = useFileStore((s) => s.selectAll);
const deselectAll = useFileStore((s) => s.deselectAll);
const resetAllRules = useRuleStore((s) => s.resetAllRules);
const setActiveTab = useRuleStore((s) => s.setActiveTab);
useEffect(() => {
const tabs = ["replace", "regex", "remove", "add", "case", "numbering", "extension"];
function handler(e: KeyboardEvent) {
if (e.ctrlKey && e.key === "a" && !e.shiftKey) {
e.preventDefault();
selectAll();
}
if (e.ctrlKey && e.shiftKey && e.key === "A") {
e.preventDefault();
deselectAll();
}
if (e.key === "Escape") {
resetAllRules();
}
if (e.ctrlKey && e.key >= "1" && e.key <= "7") {
e.preventDefault();
const idx = parseInt(e.key) - 1;
if (idx < tabs.length) setActiveTab(tabs[idx]);
}
}
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [selectAll, deselectAll, resetAllRules, setActiveTab]);
}

78
ui/src/index.css Normal file
View File

@@ -0,0 +1,78 @@
@import "tailwindcss";
:root {
--bg-primary: #ffffff;
--bg-secondary: #f8f9fa;
--bg-tertiary: #e9ecef;
--text-primary: #212529;
--text-secondary: #6c757d;
--border: #dee2e6;
--accent: #4f46e5;
--accent-hover: #4338ca;
--success: #16a34a;
--warning: #d97706;
--error: #dc2626;
--row-even: #ffffff;
--row-odd: #f8f9fb;
}
@media (prefers-color-scheme: dark) {
:root {
--bg-primary: #1a1a2e;
--bg-secondary: #16213e;
--bg-tertiary: #0f3460;
--text-primary: #e2e8f0;
--text-secondary: #94a3b8;
--border: #334155;
--accent: #6366f1;
--accent-hover: #818cf8;
--success: #22c55e;
--warning: #f59e0b;
--error: #ef4444;
--row-even: #1a1a2e;
--row-odd: #1e2240;
}
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
overflow: hidden;
height: 100vh;
}
#root {
height: 100vh;
display: flex;
flex-direction: column;
}
input, select, button {
font-family: inherit;
font-size: inherit;
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-secondary);
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-secondary);
}

10
ui/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

View File

@@ -0,0 +1,85 @@
import { create } from "zustand";
import { invoke } from "@tauri-apps/api/core";
import type { FileEntry, FilterConfig, PreviewResult } from "../types/files";
interface FileState {
currentPath: string;
files: FileEntry[];
previewResults: PreviewResult[];
selectedFiles: Set<string>;
loading: boolean;
error: string | null;
filters: FilterConfig;
setCurrentPath: (path: string) => void;
scanDirectory: (path: string) => Promise<void>;
setPreviewResults: (results: PreviewResult[]) => void;
toggleFileSelection: (path: string) => void;
selectAll: () => void;
deselectAll: () => void;
setFilters: (filters: Partial<FilterConfig>) => void;
}
export const useFileStore = create<FileState>((set, get) => ({
currentPath: "",
files: [],
previewResults: [],
selectedFiles: new Set(),
loading: false,
error: null,
filters: {
mask: "*",
regex_filter: null,
min_size: null,
max_size: null,
include_files: true,
include_folders: false,
include_hidden: false,
subfolder_depth: 0,
},
setCurrentPath: (path) => set({ currentPath: path }),
scanDirectory: async (path) => {
set({ loading: true, error: null });
try {
const files = await invoke<FileEntry[]>("scan_directory", {
path,
filters: get().filters,
});
set({
files,
currentPath: path,
loading: false,
selectedFiles: new Set(files.map((f) => f.path)),
previewResults: [],
});
} catch (e) {
set({ error: String(e), loading: false });
}
},
setPreviewResults: (results) => set({ previewResults: results }),
toggleFileSelection: (path) => {
const selected = new Set(get().selectedFiles);
if (selected.has(path)) {
selected.delete(path);
} else {
selected.add(path);
}
set({ selectedFiles: selected });
},
selectAll: () => {
set({ selectedFiles: new Set(get().files.map((f) => f.path)) });
},
deselectAll: () => {
set({ selectedFiles: new Set() });
},
setFilters: (filters) => {
set({ filters: { ...get().filters, ...filters } });
},
}));

View File

@@ -0,0 +1,88 @@
import { create } from "zustand";
import { invoke } from "@tauri-apps/api/core";
import type { PreviewResult } from "../types/files";
import type { RuleConfig } from "../types/rules";
import { useFileStore } from "./fileStore";
import {
defaultReplace,
defaultRegex,
defaultRemove,
defaultAdd,
defaultCase,
defaultNumbering,
defaultExtension,
} from "../types/rules";
interface RuleState {
rules: Record<string, RuleConfig>;
activeTab: string;
previewDebounceTimer: ReturnType<typeof setTimeout> | null;
setActiveTab: (tab: string) => void;
updateRule: (type: string, updates: Partial<RuleConfig>) => void;
resetRule: (type: string) => void;
resetAllRules: () => void;
requestPreview: (directory: string) => void;
}
function getDefaults(): Record<string, RuleConfig> {
return {
replace: defaultReplace(),
regex: defaultRegex(),
remove: defaultRemove(),
add: defaultAdd(),
case: defaultCase(),
numbering: defaultNumbering(),
extension: defaultExtension(),
};
}
export const useRuleStore = create<RuleState>((set, get) => ({
rules: getDefaults(),
activeTab: "replace",
previewDebounceTimer: null,
setActiveTab: (tab) => set({ activeTab: tab }),
updateRule: (type, updates) => {
const rules = { ...get().rules };
rules[type] = { ...rules[type], ...updates };
set({ rules });
},
resetRule: (type) => {
const defaults = getDefaults();
const rules = { ...get().rules };
rules[type] = defaults[type];
set({ rules });
},
resetAllRules: () => {
set({ rules: getDefaults() });
},
requestPreview: (directory) => {
const timer = get().previewDebounceTimer;
if (timer) clearTimeout(timer);
const newTimer = setTimeout(async () => {
const { rules } = get();
const activeRules = Object.values(rules).filter((r) => r.enabled);
if (activeRules.length === 0 || !directory) return;
try {
const results = await invoke<PreviewResult[]>("preview_rename", {
rules: activeRules,
directory,
});
useFileStore.getState().setPreviewResults(results);
} catch (e) {
console.error("Preview failed:", e);
}
}, 150);
set({ previewDebounceTimer: newTimer });
},
}));

View File

@@ -0,0 +1,13 @@
import { create } from "zustand";
type Theme = "light" | "dark" | "system";
interface SettingsState {
theme: Theme;
setTheme: (theme: Theme) => void;
}
export const useSettingsStore = create<SettingsState>((set) => ({
theme: "system",
setTheme: (theme) => set({ theme }),
}));

32
ui/src/types/files.ts Normal file
View File

@@ -0,0 +1,32 @@
export interface FileEntry {
path: string;
name: string;
stem: string;
extension: string;
size: number;
is_dir: boolean;
is_hidden: boolean;
created: string | null;
modified: string | null;
accessed: string | null;
}
export interface PreviewResult {
original_path: string;
original_name: string;
new_name: string;
has_conflict: boolean;
has_error: boolean;
error_message: string | null;
}
export interface FilterConfig {
mask: string;
regex_filter: string | null;
min_size: number | null;
max_size: number | null;
include_files: boolean;
include_folders: boolean;
include_hidden: boolean;
subfolder_depth: number | null;
}

17
ui/src/types/presets.ts Normal file
View File

@@ -0,0 +1,17 @@
import type { FilterConfig } from "./files";
import type { RuleConfig } from "./rules";
export interface NominaPreset {
version: number;
name: string;
description: string;
created: string;
rules: RuleConfig[];
filters: FilterConfig | null;
}
export interface PresetInfo {
name: string;
description: string;
path: string;
}

154
ui/src/types/rules.ts Normal file
View File

@@ -0,0 +1,154 @@
export type StepMode = "Simultaneous" | "Sequential";
export interface RuleConfig {
type: string;
step_mode: StepMode;
enabled: boolean;
[key: string]: unknown;
}
export interface ReplaceConfig extends RuleConfig {
type: "replace";
search: string;
replace_with: string;
match_case: boolean;
first_only: boolean;
}
export interface RegexConfig extends RuleConfig {
type: "regex";
pattern: string;
replace_with: string;
case_insensitive: boolean;
}
export interface RemoveConfig extends RuleConfig {
type: "remove";
first_n: number;
last_n: number;
from: number;
to: number;
crop_before: string | null;
crop_after: string | null;
}
export interface AddConfig extends RuleConfig {
type: "add";
prefix: string;
suffix: string;
insert: string;
insert_at: number;
word_space: boolean;
}
export interface CaseConfig extends RuleConfig {
type: "case";
mode: "Same" | "Upper" | "Lower" | "Title" | "Sentence" | "Invert" | "Random";
exceptions: string;
}
export interface NumberingConfig extends RuleConfig {
type: "numbering";
mode: "None" | "Prefix" | "Suffix" | "Both" | "Insert";
start: number;
increment: number;
padding: number;
separator: string;
break_at: number;
base: "Decimal" | "Hex" | "Octal" | "Binary" | "Alpha";
per_folder: boolean;
insert_at: number;
}
export interface ExtensionConfig extends RuleConfig {
type: "extension";
mode: "Same" | "Lower" | "Upper" | "Title" | "Extra" | "Remove" | "Fixed";
fixed_value: string;
}
export function defaultReplace(): ReplaceConfig {
return {
type: "replace",
step_mode: "Simultaneous",
enabled: true,
search: "",
replace_with: "",
match_case: true,
first_only: false,
};
}
export function defaultRegex(): RegexConfig {
return {
type: "regex",
step_mode: "Simultaneous",
enabled: true,
pattern: "",
replace_with: "",
case_insensitive: false,
};
}
export function defaultRemove(): RemoveConfig {
return {
type: "remove",
step_mode: "Simultaneous",
enabled: true,
first_n: 0,
last_n: 0,
from: 0,
to: 0,
crop_before: null,
crop_after: null,
};
}
export function defaultAdd(): AddConfig {
return {
type: "add",
step_mode: "Simultaneous",
enabled: true,
prefix: "",
suffix: "",
insert: "",
insert_at: 0,
word_space: false,
};
}
export function defaultCase(): CaseConfig {
return {
type: "case",
step_mode: "Simultaneous",
enabled: true,
mode: "Same",
exceptions: "",
};
}
export function defaultNumbering(): NumberingConfig {
return {
type: "numbering",
step_mode: "Sequential",
enabled: true,
mode: "None",
start: 1,
increment: 1,
padding: 1,
separator: "_",
break_at: 0,
base: "Decimal",
per_folder: false,
insert_at: 0,
};
}
export function defaultExtension(): ExtensionConfig {
return {
type: "extension",
step_mode: "Simultaneous",
enabled: true,
mode: "Same",
fixed_value: "",
};
}