diff --git a/src/components/navigator/BatchRenameDrawer.tsx b/src/components/navigator/BatchRenameDrawer.tsx new file mode 100644 index 0000000..d6e1fdd --- /dev/null +++ b/src/components/navigator/BatchRenameDrawer.tsx @@ -0,0 +1,445 @@ +import { + Button, + Drawer, + DrawerBody, + DrawerContent, + DrawerFooter, + DrawerHeader, + Input, + Radio, + RadioGroup, + ScrollShadow, + Switch, + cn, +} from '@heroui/react' +import { useMutation } from '@tanstack/react-query' +import { AlertCircleIcon, ArrowRightIcon, XIcon } from 'lucide-react' +import { useEffect, useMemo, useState } from 'react' +import { getFsInfo } from '../../../lib/format.ts' +import FileIcon from './FileIcon' +import type { Entry, SelectItem } from './types' +import { renamePath } from './utils' + +type CaseMode = 'none' | 'lower' | 'upper' | 'title' + +type RenameForm = { + find: string + replace: string + regex: boolean + caseInsensitive: boolean + caseMode: CaseMode + prefix: string + suffix: string + numberEnabled: boolean + numberStart: number + numberPadding: number + numberSeparator: string + includeExtension: boolean +} + +const INITIAL_FORM: RenameForm = { + find: '', + replace: '', + regex: false, + caseInsensitive: false, + caseMode: 'none', + prefix: '', + suffix: '', + numberEnabled: false, + numberStart: 1, + numberPadding: 0, + numberSeparator: '', + includeExtension: false, +} + +const RE_REGEXP_META = /[.*+?^${}()|[\]\\]/g +const RE_WORD = /\w\S*/g + +const APPLY_CASE: Record string> = { + none: (value) => value, + lower: (value) => value.toLowerCase(), + upper: (value) => value.toUpperCase(), + title: (value) => + value.replace( + RE_WORD, + (word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase() + ), +} + +// The find/replace pattern, compiled once per plan; `error` when the user's regex doesn't parse. +function compileFind(form: RenameForm): { find?: RegExp; error?: string } { + if (!form.find) return {} + try { + const source = form.regex ? form.find : form.find.replace(RE_REGEXP_META, '\\$&') + return { find: new RegExp(source, form.caseInsensitive ? 'gi' : 'g') } + } catch { + return { error: 'Invalid regular expression' } + } +} + +// prefix + (find/replace → case) + suffix + number, with the extension re-attached last. +function computeNewName( + oldName: string, + isDir: boolean, + index: number, + form: RenameForm, + find?: RegExp +): string { + // Folders never have an extension; for files, split on the last dot unless the user opted to + // include it. `dot > 0` keeps dotfiles (".env") whole. + const dot = isDir || form.includeExtension ? -1 : oldName.lastIndexOf('.') + const ext = dot > 0 ? oldName.slice(dot) : '' + let base = dot > 0 ? oldName.slice(0, dot) : oldName + + if (find) base = base.replace(find, form.replace) + base = APPLY_CASE[form.caseMode](base) + + const number = form.numberEnabled + ? `${form.numberSeparator}${String(form.numberStart + index).padStart(form.numberPadding, '0')}` + : '' + return `${form.prefix}${base}${form.suffix}${number}${ext}` +} + +type PlanRow = { + item: SelectItem + entry: Entry // for FileIcon + oldName: string + newName: string + target: string // full path after the rename; collisions are checked per folder + error?: string +} + +// Maps the selection to old→new rows and flags anything that would make a sequential rename unsafe: +// empty names, two items renamed to the same target, or a target that is another selected item's +// current name. With an invalid regex every name is left unchanged and the banner blocks Apply. +function buildPlan( + items: SelectItem[], + form: RenameForm +): { rows: PlanRow[]; regexError?: string } { + const { find, error: regexError } = compileFind(form) + + const rows = items.map((item, index) => { + const oldName = getFsInfo(item.path).name + const isDir = item.type === 'folder' + const dir = item.path.slice(0, item.path.lastIndexOf(oldName)) + const newName = regexError ? oldName : computeNewName(oldName, isDir, index, form, find) + return { + item, + entry: { key: item.path, name: oldName, isDir, fullPath: item.path }, + oldName, + newName, + target: dir + newName, + } + }) + + const targetCounts = new Map() + for (const row of rows) targetCounts.set(row.target, (targetCounts.get(row.target) ?? 0) + 1) + const currentPaths = new Set(rows.map((row) => row.item.path)) + + const errorFor = (row: Omit) => { + if (!row.newName) return 'Name cannot be empty' + if ((targetCounts.get(row.target) ?? 0) > 1) return 'Duplicate name' + if (row.newName !== row.oldName && currentPaths.has(row.target)) { + return 'Would collide with another selected item' + } + return undefined + } + + return { regexError, rows: rows.map((row) => ({ ...row, error: errorFor(row) })) } +} + +// Free-text fields (names, patterns) must not be autocorrected by the webview. +const PLAIN_TEXT = { + autoCapitalize: 'off', + autoComplete: 'off', + autoCorrect: 'off', + spellCheck: 'false', +} as const + +export default function BatchRenameDrawer({ + isOpen, + onClose, + items, + onDone, +}: { + isOpen: boolean + onClose: () => void + items: SelectItem[] + onDone: () => void +}) { + const [form, setForm] = useState(INITIAL_FORM) + const [failures, setFailures] = useState<{ name: string; error: string }[]>([]) + + // Fresh form each time the drawer opens. + useEffect(() => { + if (isOpen) { + setForm(INITIAL_FORM) + setFailures([]) + } + }, [isOpen]) + + const patch = (partial: Partial) => setForm((prev) => ({ ...prev, ...partial })) + + const { rows, regexError } = useMemo(() => buildPlan(items, form), [items, form]) + const pending = rows.filter((row) => !row.error && row.newName !== row.oldName) + const applyDisabled = !!regexError || rows.some((row) => row.error) || pending.length === 0 + + const applyMutation = useMutation({ + // Sequential on purpose: the plan's collision checks assume renames happen one at a time. + mutationFn: async () => { + const failed: { name: string; error: string }[] = [] + for (const row of pending) { + try { + await renamePath(row.item.path, row.item.type === 'folder', row.newName) + } catch (error) { + failed.push({ + name: row.oldName, + error: error instanceof Error ? error.message : 'Rename failed', + }) + } + } + return failed + }, + onSuccess: (failed) => { + onDone() + if (failed.length === 0) onClose() + else setFailures(failed) + }, + }) + + return ( + + + {() => ( + <> + + + Batch Rename + + {items.length} items + + + + + + +
+
+
+ patch({ find: v })} + {...PLAIN_TEXT} + /> + patch({ replace: v })} + {...PLAIN_TEXT} + /> +
+
+ patch({ regex: v })} + > + Regex + + patch({ caseInsensitive: v })} + > + Case-insensitive + +
+
+ +
+ patch({ prefix: v })} + {...PLAIN_TEXT} + /> + patch({ suffix: v })} + {...PLAIN_TEXT} + /> +
+ +
+ patch({ numberEnabled: v })} + > + Add sequential number + + {form.numberEnabled && ( +
+ + patch({ + numberStart: Number.parseInt(v, 10) || 0, + }) + } + /> + + patch({ + numberPadding: Math.max( + 0, + Number.parseInt(v, 10) || 0 + ), + }) + } + /> + patch({ numberSeparator: v })} + {...PLAIN_TEXT} + /> +
+ )} +
+ +
+ patch({ caseMode: v as CaseMode })} + size="sm" + > + Original + lower + UPPER + Title + + patch({ includeExtension: v })} + > + Include extension + +
+ + {regexError && ( +
+ + {regexError} +
+ )} + {failures.length > 0 && ( +
+ + {failures.length} rename(s) failed: + + {failures.map((f) => ( + + {f.name}: {f.error} + + ))} +
+ )} +
+ +
+ + Preview + + + {rows.map((row) => ( +
+
+ + + {row.oldName} + +
+ +
+ + {row.newName || '—'} + + {row.error && ( + + {row.error} + + )} +
+
+ ))} +
+
+
+ + + + + + + )} +
+
+ ) +}