commander rename
This commit is contained in:
@@ -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<CaseMode, (value: string) => 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<string, number>()
|
||||
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<PlanRow, 'error'>) => {
|
||||
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<RenameForm>(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<RenameForm>) => 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 (
|
||||
<Drawer
|
||||
isOpen={isOpen}
|
||||
placement="bottom"
|
||||
size="full"
|
||||
onClose={onClose}
|
||||
hideCloseButton={true}
|
||||
>
|
||||
<DrawerContent className="bg-content1/80 backdrop-blur-md dark:bg-content1/90">
|
||||
{() => (
|
||||
<>
|
||||
<DrawerHeader className="flex items-center justify-between border-b border-divider">
|
||||
<span className="text-base font-semibold">
|
||||
Batch Rename
|
||||
<span className="ml-2 font-normal text-default-400">
|
||||
{items.length} items
|
||||
</span>
|
||||
</span>
|
||||
<Button isIconOnly={true} size="sm" variant="light" onPress={onClose}>
|
||||
<XIcon className="size-4" />
|
||||
</Button>
|
||||
</DrawerHeader>
|
||||
|
||||
<DrawerBody className="flex flex-col gap-5 overflow-hidden">
|
||||
<div className="flex flex-col gap-5">
|
||||
<section className="flex flex-col gap-2">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input
|
||||
label="Find"
|
||||
labelPlacement="outside"
|
||||
placeholder="Text to find"
|
||||
value={form.find}
|
||||
onValueChange={(v) => patch({ find: v })}
|
||||
{...PLAIN_TEXT}
|
||||
/>
|
||||
<Input
|
||||
label="Replace with"
|
||||
labelPlacement="outside"
|
||||
placeholder="Replacement"
|
||||
value={form.replace}
|
||||
onValueChange={(v) => patch({ replace: v })}
|
||||
{...PLAIN_TEXT}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-6">
|
||||
<Switch
|
||||
size="sm"
|
||||
isSelected={form.regex}
|
||||
onValueChange={(v) => patch({ regex: v })}
|
||||
>
|
||||
Regex
|
||||
</Switch>
|
||||
<Switch
|
||||
size="sm"
|
||||
isSelected={form.caseInsensitive}
|
||||
onValueChange={(v) => patch({ caseInsensitive: v })}
|
||||
>
|
||||
Case-insensitive
|
||||
</Switch>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid grid-cols-2 gap-3">
|
||||
<Input
|
||||
label="Prefix"
|
||||
labelPlacement="outside"
|
||||
placeholder="Added to the start"
|
||||
value={form.prefix}
|
||||
onValueChange={(v) => patch({ prefix: v })}
|
||||
{...PLAIN_TEXT}
|
||||
/>
|
||||
<Input
|
||||
label="Suffix"
|
||||
labelPlacement="outside"
|
||||
placeholder="Added before the extension"
|
||||
value={form.suffix}
|
||||
onValueChange={(v) => patch({ suffix: v })}
|
||||
{...PLAIN_TEXT}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-2">
|
||||
<Switch
|
||||
size="sm"
|
||||
isSelected={form.numberEnabled}
|
||||
onValueChange={(v) => patch({ numberEnabled: v })}
|
||||
>
|
||||
Add sequential number
|
||||
</Switch>
|
||||
{form.numberEnabled && (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<Input
|
||||
type="number"
|
||||
label="Start at"
|
||||
labelPlacement="outside"
|
||||
value={String(form.numberStart)}
|
||||
onValueChange={(v) =>
|
||||
patch({
|
||||
numberStart: Number.parseInt(v, 10) || 0,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
label="Min digits"
|
||||
labelPlacement="outside"
|
||||
value={String(form.numberPadding)}
|
||||
onValueChange={(v) =>
|
||||
patch({
|
||||
numberPadding: Math.max(
|
||||
0,
|
||||
Number.parseInt(v, 10) || 0
|
||||
),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
label="Separator"
|
||||
labelPlacement="outside"
|
||||
placeholder="e.g. - or _"
|
||||
value={form.numberSeparator}
|
||||
onValueChange={(v) => patch({ numberSeparator: v })}
|
||||
{...PLAIN_TEXT}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="flex flex-wrap items-center justify-between gap-4">
|
||||
<RadioGroup
|
||||
label="Case"
|
||||
orientation="horizontal"
|
||||
value={form.caseMode}
|
||||
onValueChange={(v) => patch({ caseMode: v as CaseMode })}
|
||||
size="sm"
|
||||
>
|
||||
<Radio value="none">Original</Radio>
|
||||
<Radio value="lower">lower</Radio>
|
||||
<Radio value="upper">UPPER</Radio>
|
||||
<Radio value="title">Title</Radio>
|
||||
</RadioGroup>
|
||||
<Switch
|
||||
size="sm"
|
||||
isSelected={form.includeExtension}
|
||||
onValueChange={(v) => patch({ includeExtension: v })}
|
||||
>
|
||||
Include extension
|
||||
</Switch>
|
||||
</section>
|
||||
|
||||
{regexError && (
|
||||
<div className="flex items-center gap-2 text-sm text-danger">
|
||||
<AlertCircleIcon className="size-4" />
|
||||
{regexError}
|
||||
</div>
|
||||
)}
|
||||
{failures.length > 0 && (
|
||||
<div className="flex flex-col gap-1 text-sm text-danger">
|
||||
<span className="font-medium">
|
||||
{failures.length} rename(s) failed:
|
||||
</span>
|
||||
{failures.map((f) => (
|
||||
<span key={f.name} className="text-xs">
|
||||
{f.name}: {f.error}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<span className="mb-1 text-xs font-medium text-default-400">
|
||||
Preview
|
||||
</span>
|
||||
<ScrollShadow className="flex-1 border rounded-lg border-divider">
|
||||
{rows.map((row) => (
|
||||
<div
|
||||
key={row.item.path}
|
||||
className="grid grid-cols-[1fr_auto_1fr] items-center gap-3 px-3 py-2 border-b border-divider last:border-b-0"
|
||||
>
|
||||
<div className="flex items-center min-w-0 gap-2">
|
||||
<FileIcon entry={row.entry} size="sm" />
|
||||
<span
|
||||
className="text-sm truncate text-default-500"
|
||||
title={row.oldName}
|
||||
>
|
||||
{row.oldName}
|
||||
</span>
|
||||
</div>
|
||||
<ArrowRightIcon className="size-4 text-default-300 shrink-0" />
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span
|
||||
className={cn(
|
||||
'truncate text-sm',
|
||||
row.error
|
||||
? 'text-danger'
|
||||
: row.newName !== row.oldName
|
||||
? 'font-medium text-foreground'
|
||||
: 'text-default-400'
|
||||
)}
|
||||
title={row.newName}
|
||||
>
|
||||
{row.newName || '—'}
|
||||
</span>
|
||||
{row.error && (
|
||||
<span className="text-xs text-danger">
|
||||
{row.error}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</ScrollShadow>
|
||||
</div>
|
||||
</DrawerBody>
|
||||
|
||||
<DrawerFooter className="border-t border-divider">
|
||||
<Button variant="light" onPress={onClose} data-focus-visible="false">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
isDisabled={applyDisabled}
|
||||
isLoading={applyMutation.isPending}
|
||||
onPress={() => applyMutation.mutate()}
|
||||
data-focus-visible="false"
|
||||
>
|
||||
{pending.length > 0 ? `Rename ${pending.length}` : 'Rename'}
|
||||
</Button>
|
||||
</DrawerFooter>
|
||||
</>
|
||||
)}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user