feat: add file duplicate to file explorer context menu (#351)

* feat: add file duplicate option to file explorer context menu

* fix: address review findings
This commit is contained in:
Jinjing 2026-04-06 17:39:31 -07:00 committed by GitHub
parent 132e033292
commit 5d6fb3923f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 106 additions and 0 deletions

1
.gitignore vendored
View file

@ -53,3 +53,4 @@ design-docs/
.stably/*
!.stably/docs/
.playwright-cli
.validate-ui-screenshots/

View file

@ -17,6 +17,7 @@ import { useFileExplorerReveal } from './useFileExplorerReveal'
import { useFileExplorerInlineInput } from './useFileExplorerInlineInput'
import { useFileExplorerKeys } from './useFileExplorerKeys'
import { useActiveWorktreePath } from './useActiveWorktreePath'
import { useFileDuplicate } from './useFileDuplicate'
import { useFileExplorerDragDrop } from './useFileExplorerDragDrop'
import { useFileExplorerTree } from './useFileExplorerTree'
@ -236,6 +237,8 @@ export default function FileExplorer(): React.JSX.Element {
[activeWorktreeId, pinFile]
)
const handleDuplicate = useFileDuplicate({ worktreePath, refreshDir })
const handleWheelCapture = useCallback((e: React.WheelEvent<HTMLDivElement>) => {
const container = scrollRef.current
if (!container || Math.abs(e.deltaY) <= Math.abs(e.deltaX)) {
@ -385,6 +388,7 @@ export default function FileExplorer(): React.JSX.Element {
onSelect={() => setSelectedPath(n.path)}
onStartNew={startNew}
onStartRename={startRename}
onDuplicate={handleDuplicate}
onRequestDelete={() => requestDelete(n)}
onMoveDrop={handleMoveDrop}
onDragTargetChange={setDropTargetDir}

View file

@ -4,6 +4,7 @@ import {
Copy,
File,
FilePlus,
Files,
Folder,
FolderOpen,
FolderPlus,
@ -190,6 +191,7 @@ type FileExplorerRowProps = {
onSelect: () => void
onStartNew: (type: 'file' | 'folder', dir: string, depth: number) => void
onStartRename: (node: TreeNode) => void
onDuplicate: (node: TreeNode) => void
onRequestDelete: () => void
onMoveDrop: (sourcePath: string, destDir: string) => void
onDragTargetChange: (dir: string | null) => void
@ -215,6 +217,7 @@ export function FileExplorerRow({
onSelect,
onStartNew,
onStartRename,
onDuplicate,
onRequestDelete,
onMoveDrop,
onDragTargetChange,
@ -384,6 +387,12 @@ export function FileExplorerRow({
Copy Relative Path
<ContextMenuShortcut>{isMac ? '⌥⇧⌘C' : 'Ctrl+Shift+Alt+C'}</ContextMenuShortcut>
</ContextMenuItem>
{!node.isDirectory && (
<ContextMenuItem onSelect={() => onDuplicate(node)}>
<Files />
Duplicate
</ContextMenuItem>
)}
<ContextMenuSeparator />
<ContextMenuItem onSelect={() => onStartRename(node)}>
<Pencil />

View file

@ -0,0 +1,92 @@
import { useCallback } from 'react'
import { toast } from 'sonner'
import { basename, dirname, joinPath } from '@/lib/path'
import type { TreeNode } from './file-explorer-types'
/**
* Electron's ipcRenderer.invoke wraps errors as:
* "Error invoking remote method 'channel': Error: actual message"
* Strip the wrapper so users see only the meaningful part.
*/
function extractIpcErrorMessage(err: unknown, fallback: string): string {
if (!(err instanceof Error)) {
return fallback
}
const match = err.message.match(/Error invoking remote method '[^']*': (?:Error: )?(.+)/)
return match ? match[1] : err.message
}
type UseFileDuplicateParams = {
worktreePath: string | null
refreshDir: (dirPath: string) => Promise<void>
}
export function useFileDuplicate({
worktreePath,
refreshDir
}: UseFileDuplicateParams): (node: TreeNode) => void {
return useCallback(
(node: TreeNode) => {
if (node.isDirectory || !worktreePath) {
return
}
const dir = dirname(node.path)
const name = basename(node.path)
const dotIndex = name.lastIndexOf('.')
const stem = dotIndex > 0 ? name.slice(0, dotIndex) : name
const ext = dotIndex > 0 ? name.slice(dotIndex) : ''
const run = async (): Promise<void> => {
// Why: generate a unique "stem copy.ext", "stem copy 2.ext", … name
// so we never collide with an existing file. pathExists checks are
// sequential to avoid TOCTOU races with COPYFILE_EXCL on the backend.
let candidate = joinPath(dir, `${stem} copy${ext}`)
let n = 2
while (await window.api.shell.pathExists(candidate)) {
candidate = joinPath(dir, `${stem} copy ${n}${ext}`)
n += 1
}
// Why: Between the final pathExists returning false and the copyFile
// call, another process could create a file at that path (TOCTOU race).
// The backend uses COPYFILE_EXCL which will fail with EEXIST in that
// case. Instead of surfacing a generic error toast, we retry with the
// next candidate name. A max-retry limit of 10 prevents infinite loops
// in degenerate scenarios.
const MAX_RETRIES = 10
let retries = 0
// eslint-disable-next-line no-constant-condition
while (true) {
try {
await window.api.shell.copyFile({ srcPath: node.path, destPath: candidate })
break
} catch (err) {
const isEexist =
err instanceof Error &&
(err.message.includes('EEXIST') || err.message.includes('already exists'))
if (isEexist && retries < MAX_RETRIES) {
// The candidate was taken between our check and the copy attempt;
// advance to the next name and retry.
candidate = joinPath(dir, `${stem} copy ${n}${ext}`)
n += 1
retries += 1
continue
}
toast.error(extractIpcErrorMessage(err, `Failed to duplicate '${name}'.`))
return
}
}
// Best-effort refresh; the file was already copied successfully,
// so a refresh failure should not surface an error to the user.
try {
await refreshDir(dir)
} catch {
// noop the copy succeeded; stale tree is a minor inconvenience.
}
}
void run()
},
[worktreePath, refreshDir]
)
}