Files
orca/src/renderer/src/components/right-sidebar/useFileExplorerInlineInput.ts
T
Brennan Benson b2eb055a9c refactor(ipc): make the canonical envelope stripper the only one
#17230 added stripIpcInvokeEnvelope as a canonical home for Electron's IPC
wrapper. It was the sixth implementation, not the first: nine other files
carried ten hand-rolled copies, and they disagreed.

Measured against a shared corpus, the copies split four ways. An envelope whose
tail has no "Error: " prefix — Electron builds that tail from the main side's
error.toString(), so a rejected non-Error produces one — was left fully visible
by quick-open. A message-less handler failure rendered the bare word "Error" in
the Linux recovery card, the voice download toast and the AI Vault scan row; an
empty tail rendered an empty string in all three. Every copy was anchored at ^,
so an envelope a caller had prefixed with its own context stayed on screen. The
two AccountsPane copies were scoped to their own channel, so a rejection from
any other channel kept its wrapper.

All ten now route through one stripper, moved to src/shared because
ai-vault-scan-error-message is imported by main and cannot reach a renderer-only
module. The canonical regex additionally covers "Error occurred in handler for",
which AccountsPane stripped and the canonical one did not, and a separate
stripErrorClassPrefix keeps the bare "Error: " trim that three sites had — that
prefix is Error.prototype.toString(), not the envelope, and an existing Linux
card test caught its loss.

Nothing is swallowed. The three sites that gained a null fallback now
console.warn the original rejection, which none of them logged before, and
Electron still logs the handler's original error with its stack in main.

extractIpcErrorMessage keeps its own fail-open regex and its 18 call sites are
untouched: it returns the tail verbatim and never returns null.

A census test pins the envelope to the two files that own it, so a seventh copy
fails CI. It lists nine offenders against the pre-fix tree.
2026-08-29 20:12:30 -07:00

251 lines
8.8 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type React from 'react'
import { toast } from 'sonner'
import { useAppStore } from '@/store'
import { detectLanguage } from '@/lib/language-detect'
import { dirname, joinPath } from '@/lib/path'
import { renameFileOnDisk } from '@/lib/rename-file'
import { extractIpcErrorMessage } from '@/lib/ipc-error'
import type { InlineInput } from './file-explorer-inline-input-row'
import type { TreeNode } from './file-explorer-types'
import type { FileExplorerRowProjection } from './file-explorer-row-projection'
import { commitFileExplorerOp } from './fileExplorerUndoRedo'
import { createRuntimePath, deleteRuntimePath } from '@/runtime/runtime-file-client'
import {
captureFileExplorerOperationGuard,
getFileExplorerOperationOwner
} from './file-explorer-operation-owner'
type UseFileExplorerInlineInputParams = {
activeWorktreeId: string | null
worktreePath: string | null
expanded: Set<string>
rowProjection: FileExplorerRowProjection
scrollRef: React.RefObject<HTMLDivElement | null>
refreshDir: (dirPath: string) => Promise<void>
}
type UseFileExplorerInlineInputResult = {
inlineInput: InlineInput | null
inlineInputIndex: number
startNew: (type: 'file' | 'folder', parentPath: string, depth: number) => void
startRename: (node: TreeNode) => void
dismissInlineInput: () => void
handleInlineSubmit: (value: string) => void
}
export function useFileExplorerInlineInput({
activeWorktreeId,
worktreePath,
expanded,
rowProjection,
scrollRef,
refreshDir
}: UseFileExplorerInlineInputParams): UseFileExplorerInlineInputResult {
const toggleDir = useAppStore((s) => s.toggleDir)
const openFile = useAppStore((s) => s.openFile)
const [inlineInput, setInlineInput] = useState<InlineInput | null>(null)
const scrollFocusFrameRef = useRef<number | null>(null)
const cancelScrollFocusFrame = useCallback((): void => {
if (scrollFocusFrameRef.current === null) {
return
}
cancelAnimationFrame(scrollFocusFrameRef.current)
scrollFocusFrameRef.current = null
}, [])
useEffect(() => cancelScrollFocusFrame, [cancelScrollFocusFrame])
const scheduleScrollFocus = useCallback((): void => {
cancelScrollFocusFrame()
scrollFocusFrameRef.current = requestAnimationFrame(() => {
scrollFocusFrameRef.current = null
scrollRef.current?.focus()
})
}, [cancelScrollFocusFrame, scrollRef])
const inlineInputIndex = useMemo(() => {
if (!inlineInput || inlineInput.type === 'rename') {
return -1
}
return rowProjection.getInsertIndexAfterSubtree(inlineInput.parentPath, worktreePath)
}, [inlineInput, rowProjection, worktreePath])
const startNew = useCallback(
(type: 'file' | 'folder', parentPath: string, depth: number) => {
if (activeWorktreeId && parentPath !== worktreePath && !expanded.has(parentPath)) {
toggleDir(activeWorktreeId, parentPath)
}
setInlineInput({
parentPath,
type,
depth,
operationOwner: getFileExplorerOperationOwner(activeWorktreeId)
})
},
[activeWorktreeId, worktreePath, expanded, toggleDir]
)
const startRename = useCallback(
(node: TreeNode) =>
setInlineInput({
parentPath: dirname(node.path),
type: 'rename',
depth: node.depth,
existingName: node.name,
existingPath: node.path,
operationOwner: node.operationOwner
}),
[]
)
const dismissInlineInput = useCallback(() => {
setInlineInput(null)
scheduleScrollFocus()
}, [scheduleScrollFocus])
const handleInlineSubmit = useCallback(
(value: string) => {
if (!inlineInput || !value.trim() || !activeWorktreeId || !worktreePath) {
setInlineInput(null)
return
}
const name = value.trim()
// No-op if the user submitted the same name (e.g. blur without editing)
if (inlineInput.type === 'rename' && name === inlineInput.existingName) {
setInlineInput(null)
return
}
const run = async (): Promise<void> => {
if (inlineInput.type === 'rename' && inlineInput.existingPath) {
await renameFileOnDisk({
oldPath: inlineInput.existingPath,
newName: name,
worktreeId: activeWorktreeId,
worktreePath,
operationOwner: inlineInput.operationOwner,
refreshDir
})
} else {
const fullPath = joinPath(inlineInput.parentPath, name)
try {
const operationGuard = captureFileExplorerOperationGuard(
activeWorktreeId,
inlineInput.operationOwner
)
const operationRoute = operationGuard.route
const fileContext = {
settings: operationRoute.settings,
worktreeId: activeWorktreeId,
worktreePath,
connectionId: operationRoute.connectionId,
expectedExecutionHostId: operationRoute.expectedExecutionHostId,
expectedSshTargetId: operationRoute.expectedSshTargetId,
expectedSshConnectionGeneration: operationRoute.expectedSshConnectionGeneration
}
operationGuard.assertCurrent()
await createRuntimePath(
fileContext,
fullPath,
inlineInput.type === 'folder' ? 'directory' : 'file'
)
const parentForRefresh = inlineInput.parentPath
if (inlineInput.type === 'folder') {
commitFileExplorerOp({
undo: async () => {
const currentRoute = operationGuard.assertCurrent()
await deleteRuntimePath(
{
...fileContext,
settings: currentRoute.settings,
connectionId: currentRoute.connectionId
},
fullPath,
true
)
await refreshDir(parentForRefresh)
},
redo: async () => {
const currentRoute = operationGuard.assertCurrent()
await createRuntimePath(
{
...fileContext,
settings: currentRoute.settings,
connectionId: currentRoute.connectionId
},
fullPath,
'directory'
)
await refreshDir(parentForRefresh)
}
})
} else {
commitFileExplorerOp({
undo: async () => {
const currentRoute = operationGuard.assertCurrent()
await deleteRuntimePath(
{
...fileContext,
settings: currentRoute.settings,
connectionId: currentRoute.connectionId
},
fullPath
)
await refreshDir(parentForRefresh)
},
redo: async () => {
const currentRoute = operationGuard.assertCurrent()
await createRuntimePath(
{
...fileContext,
settings: currentRoute.settings,
connectionId: currentRoute.connectionId
},
fullPath,
'file'
)
await refreshDir(parentForRefresh)
}
})
}
await refreshDir(inlineInput.parentPath)
if (inlineInput.type === 'file') {
const runtimeEnvironmentId =
fileContext.settings.activeRuntimeEnvironmentId?.trim() || null
openFile(
{
filePath: fullPath,
relativePath: worktreePath ? fullPath.slice(worktreePath.length + 1) : name,
worktreeId: activeWorktreeId,
runtimeEnvironmentId: runtimeEnvironmentId ?? undefined,
language: detectLanguage(name),
mode: 'edit'
},
{ suppressActiveRuntimeFallback: runtimeEnvironmentId === null }
)
}
} catch (err) {
// Refresh the directory even on failure so the tree stays consistent
await refreshDir(inlineInput.parentPath)
toast.error(extractIpcErrorMessage(err, `Failed to create '${name}'.`))
}
}
}
void run()
setInlineInput(null)
scheduleScrollFocus()
},
[inlineInput, activeWorktreeId, worktreePath, refreshDir, openFile, scheduleScrollFocus]
)
return {
inlineInput,
inlineInputIndex,
startNew,
startRename,
dismissInlineInput,
handleInlineSubmit
}
}