mirror of
https://github.com/stablyai/orca.git
synced 2026-10-03 16:02:11 +00:00
#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.
251 lines
8.8 KiB
TypeScript
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
|
|
}
|
|
}
|