mirror of
https://github.com/stablyai/orca
synced 2026-04-21 14:17:16 +00:00
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:
parent
132e033292
commit
5d6fb3923f
4 changed files with 106 additions and 0 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -53,3 +53,4 @@ design-docs/
|
|||
.stably/*
|
||||
!.stably/docs/
|
||||
.playwright-cli
|
||||
.validate-ui-screenshots/
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
)
|
||||
}
|
||||
Loading…
Reference in a new issue