import { useEffect, useRef, useState } from 'react'; import { api } from '@renderer/api'; import type { ToolApprovalFileContent } from '@shared/types'; // ============================================================================= // Types // ============================================================================= export interface ToolApprovalDiffData { /** Whether this tool type supports diff preview */ hasDiff: boolean; /** Loading state (file read in progress) */ loading: boolean; /** Error message */ error: string | null; /** File name for syntax highlighting */ fileName: string; /** Original file content */ oldString: string; /** New file content after tool execution */ newString: string; /** File doesn't exist yet */ isNewFile: boolean; /** File content was truncated at size limit */ truncated: boolean; /** File is binary */ isBinary: boolean; } const DIFF_TOOLS = new Set(['Write', 'Edit', 'NotebookEdit']); const INITIAL_STATE: ToolApprovalDiffData = { hasDiff: false, loading: false, error: null, fileName: '', oldString: '', newString: '', isNewFile: false, truncated: false, isBinary: false, }; // ============================================================================= // Helpers // ============================================================================= function getFilePath(toolInput: Record): string { const fp = toolInput.file_path ?? toolInput.notebook_path; return typeof fp === 'string' ? fp : ''; } function computeEditResult( currentContent: string, toolInput: Record ): { newString: string; error: string | null } { const oldStr = typeof toolInput.old_string === 'string' ? toolInput.old_string : ''; const newStr = typeof toolInput.new_string === 'string' ? toolInput.new_string : ''; const replaceAll = toolInput.replace_all === true; if (!oldStr) { return { newString: currentContent, error: 'Edit: old_string is empty' }; } if (!currentContent.includes(oldStr)) { return { newString: currentContent, error: 'Fragment not found in current file' }; } const result = replaceAll ? currentContent.replaceAll(oldStr, newStr) : currentContent.replace(oldStr, newStr); return { newString: result, error: null }; } // ============================================================================= // Hook // ============================================================================= /** * Lazy-loading hook that reads file content from disk and computes diff data * for Write/Edit/NotebookEdit tool approvals. * * @param toolName - The tool requesting approval * @param toolInput - The tool's input parameters * @param requestId - Unique approval request ID (used for cancellation) * @param enabled - Only fetch when true (lazy — user expanded the diff section) */ export function useToolApprovalDiff( toolName: string, toolInput: Record, requestId: string, enabled: boolean ): ToolApprovalDiffData { const [state, setState] = useState(INITIAL_STATE); const activeRef = useRef(null); const hasDiff = DIFF_TOOLS.has(toolName); const filePath = getFilePath(toolInput); useEffect(() => { // Reset when approval changes setState(INITIAL_STATE); activeRef.current = null; }, [requestId]); useEffect(() => { if (!hasDiff || !enabled || !filePath) return; // NotebookEdit: no file read needed, just show new_source if (toolName === 'NotebookEdit') { const newSource = typeof toolInput.new_source === 'string' ? toolInput.new_source : ''; setState({ hasDiff: true, loading: false, error: null, fileName: filePath, oldString: '', newString: newSource, isNewFile: false, truncated: false, isBinary: false, }); return; } // Write / Edit: need to read current file from disk const currentRequestId = requestId; activeRef.current = currentRequestId; setState((prev) => ({ ...prev, hasDiff: true, loading: true, error: null })); void (async () => { let result: ToolApprovalFileContent; try { result = await api.teams.readFileForToolApproval(filePath); } catch (err) { if (activeRef.current !== currentRequestId) return; setState((prev) => ({ ...prev, loading: false, error: err instanceof Error ? err.message : String(err), })); return; } if (activeRef.current !== currentRequestId) return; if (result.error) { setState((prev) => ({ ...prev, loading: false, error: result.error ?? 'Unknown read error', fileName: filePath, })); return; } if (result.isBinary) { setState((prev) => ({ ...prev, loading: false, isBinary: true, fileName: filePath, })); return; } const isNewFile = !result.exists; const currentContent = result.content; if (toolName === 'Write') { const newContent = typeof toolInput.content === 'string' ? toolInput.content : ''; setState({ hasDiff: true, loading: false, error: null, fileName: filePath, oldString: currentContent, newString: newContent, isNewFile, truncated: result.truncated, isBinary: false, }); } else if (toolName === 'Edit') { const { newString, error } = computeEditResult(currentContent, toolInput); setState({ hasDiff: true, loading: false, error, fileName: filePath, oldString: currentContent, newString, isNewFile: false, truncated: result.truncated, isBinary: false, }); } })(); return () => { activeRef.current = null; }; // eslint-disable-next-line react-hooks/exhaustive-deps -- toolInput is a fresh object each render, use requestId for identity }, [hasDiff, enabled, filePath, requestId, toolName]); if (!hasDiff) return INITIAL_STATE; return { ...state, hasDiff: true }; }