Download src/hooks/useDiffInIDE.ts from chenbhao/codev: direct link, hf CLI and curl.
- Browser
- Download file 9.87 kB
-
https://huggingface.co/chenbhao/codev/resolve/main/src/hooks/useDiffInIDE.ts
- Command line
-
hf download hf://chenbhao/codev/src/hooks/useDiffInIDE.ts
-
curl -L -o useDiffInIDE.ts https://huggingface.co/chenbhao/codev/resolve/main/src/hooks/useDiffInIDE.ts
9.87 kB
| import { randomUUID } from 'crypto' | |
| import { basename } from 'path' | |
| import { useEffect, useMemo, useRef, useState } from 'react' | |
| import { logEvent } from 'src/services/analytics/index.js' | |
| import { readFileSync } from 'src/utils/fileRead.js' | |
| import { expandPath } from 'src/utils/path.js' | |
| import type { PermissionOption } from '../components/permissions/FilePermissionDialog/permissionOptions.js' | |
| import type { | |
| MCPServerConnection, | |
| McpSSEIDEServerConfig, | |
| McpWebSocketIDEServerConfig, | |
| } from '../services/mcp/types.js' | |
| import type { ToolUseContext } from '../Tool.js' | |
| import type { FileEdit } from '../tools/FileEditTool/types.js' | |
| import { | |
| getEditsForPatch, | |
| getPatchForEdits, | |
| } from '../tools/FileEditTool/utils.js' | |
| import { getGlobalConfig } from '../utils/config.js' | |
| import { getPatchFromContents } from '../utils/diff.js' | |
| import { isENOENT } from '../utils/errors.js' | |
| import { | |
| callIdeRpc, | |
| getConnectedIdeClient, | |
| getConnectedIdeName, | |
| hasAccessToIDEExtensionDiffFeature, | |
| } from '../utils/ide.js' | |
| import { WindowsToWSLConverter } from '../utils/idePathConversion.js' | |
| import { logError } from '../utils/log.js' | |
| import { getPlatform } from '../utils/platform.js' | |
| type Props = { | |
| onChange( | |
| option: PermissionOption, | |
| input: { | |
| file_path: string | |
| edits: FileEdit[] | |
| }, | |
| ): void | |
| toolUseContext: ToolUseContext | |
| filePath: string | |
| edits: FileEdit[] | |
| editMode: 'single' | 'multiple' | |
| } | |
| export function useDiffInIDE({ | |
| onChange, | |
| toolUseContext, | |
| filePath, | |
| edits, | |
| editMode, | |
| }: Props): { | |
| closeTabInIDE: () => void | |
| showingDiffInIDE: boolean | |
| ideName: string | |
| hasError: boolean | |
| } { | |
| const isUnmounted = useRef(false) | |
| const [hasError, setHasError] = useState(false) | |
| const sha = useMemo(() => randomUUID().slice(0, 6), []) | |
| const tabName = useMemo( | |
| () => `✻ [Claude Code] ${basename(filePath)} (${sha}) ⧉`, | |
| [filePath, sha], | |
| ) | |
| const shouldShowDiffInIDE = | |
| hasAccessToIDEExtensionDiffFeature(toolUseContext.options.mcpClients) && | |
| getGlobalConfig().diffTool === 'auto' && | |
| // Diffs should only be for file edits. | |
| // File writes may come through here but are not supported for diffs. | |
| !filePath.endsWith('.ipynb') | |
| const ideName = | |
| getConnectedIdeName(toolUseContext.options.mcpClients) ?? 'IDE' | |
| async function showDiff(): Promise<void> { | |
| if (!shouldShowDiffInIDE) { | |
| return | |
| } | |
| try { | |
| logEvent('tengu_ext_will_show_diff', {}) | |
| const { oldContent, newContent } = await showDiffInIDE( | |
| filePath, | |
| edits, | |
| toolUseContext, | |
| tabName, | |
| ) | |
| // Skip if component has been unmounted | |
| if (isUnmounted.current) { | |
| return | |
| } | |
| logEvent('tengu_ext_diff_accepted', {}) | |
| const newEdits = computeEditsFromContents( | |
| filePath, | |
| oldContent, | |
| newContent, | |
| editMode, | |
| ) | |
| if (newEdits.length === 0) { | |
| // No changes -- edit was rejected (eg. reverted) | |
| logEvent('tengu_ext_diff_rejected', {}) | |
| // We close the tab here because 'no' no longer auto-closes | |
| const ideClient = getConnectedIdeClient( | |
| toolUseContext.options.mcpClients, | |
| ) | |
| if (ideClient) { | |
| // Close the tab in the IDE | |
| await closeTabInIDE(tabName, ideClient) | |
| } | |
| onChange( | |
| { type: 'reject' }, | |
| { | |
| file_path: filePath, | |
| edits: edits, | |
| }, | |
| ) | |
| return | |
| } | |
| // File was modified - edit was accepted | |
| onChange( | |
| { type: 'accept-once' }, | |
| { | |
| file_path: filePath, | |
| edits: newEdits, | |
| }, | |
| ) | |
| } catch (error) { | |
| logError(error as Error) | |
| setHasError(true) | |
| } | |
| } | |
| useEffect(() => { | |
| void showDiff() | |
| // Set flag on unmount | |
| return () => { | |
| isUnmounted.current = true | |
| } | |
| // eslint-disable-next-line react-hooks/exhaustive-deps | |
| }, []) | |
| return { | |
| closeTabInIDE() { | |
| const ideClient = getConnectedIdeClient(toolUseContext.options.mcpClients) | |
| if (!ideClient) { | |
| return Promise.resolve() | |
| } | |
| return closeTabInIDE(tabName, ideClient) | |
| }, | |
| showingDiffInIDE: shouldShowDiffInIDE && !hasError, | |
| ideName: ideName, | |
| hasError, | |
| } | |
| } | |
| /** | |
| * Re-computes the edits from the old and new contents. This is necessary | |
| * to apply any edits the user may have made to the new contents. | |
| */ | |
| export function computeEditsFromContents( | |
| filePath: string, | |
| oldContent: string, | |
| newContent: string, | |
| editMode: 'single' | 'multiple', | |
| ): FileEdit[] { | |
| // Use unformatted patches, otherwise the edits will be formatted. | |
| const singleHunk = editMode === 'single' | |
| const patch = getPatchFromContents({ | |
| filePath, | |
| oldContent, | |
| newContent, | |
| singleHunk, | |
| }) | |
| if (patch.length === 0) { | |
| return [] | |
| } | |
| // For single edit mode, verify we only got one hunk | |
| if (singleHunk && patch.length > 1) { | |
| logError( | |
| new Error( | |
| `Unexpected number of hunks: ${patch.length}. Expected 1 hunk.`, | |
| ), | |
| ) | |
| } | |
| // Re-compute the edits to match the patch | |
| return getEditsForPatch(patch) | |
| } | |
| /** | |
| * Done if: | |
| * | |
| * 1. Tab is closed in IDE | |
| * 2. Tab is saved in IDE (we then close the tab) | |
| * 3. User selected an option in IDE | |
| * 4. User selected an option in terminal (or hit esc) | |
| * | |
| * Resolves with the new file content. | |
| * | |
| * TODO: Time out after 5 mins of inactivity? | |
| * TODO: Update auto-approval UI when IDE exits | |
| * TODO: Close the IDE tab when the approval prompt is unmounted | |
| */ | |
| async function showDiffInIDE( | |
| file_path: string, | |
| edits: FileEdit[], | |
| toolUseContext: ToolUseContext, | |
| tabName: string, | |
| ): Promise<{ oldContent: string; newContent: string }> { | |
| let isCleanedUp = false | |
| const oldFilePath = expandPath(file_path) | |
| let oldContent = '' | |
| try { | |
| oldContent = readFileSync(oldFilePath) | |
| } catch (e: unknown) { | |
| if (!isENOENT(e)) { | |
| throw e | |
| } | |
| } | |
| async function cleanup() { | |
| // Careful to avoid race conditions, since this | |
| // function can be called from multiple places. | |
| if (isCleanedUp) { | |
| return | |
| } | |
| isCleanedUp = true | |
| // Don't fail if this fails | |
| try { | |
| await closeTabInIDE(tabName, ideClient) | |
| } catch (e) { | |
| logError(e as Error) | |
| } | |
| process.off('beforeExit', cleanup) | |
| toolUseContext.abortController.signal.removeEventListener('abort', cleanup) | |
| } | |
| // Cleanup if the user hits esc to cancel the tool call - or on exit | |
| toolUseContext.abortController.signal.addEventListener('abort', cleanup) | |
| process.on('beforeExit', cleanup) | |
| // Open the diff in the IDE | |
| const ideClient = getConnectedIdeClient(toolUseContext.options.mcpClients) | |
| try { | |
| const { updatedFile } = getPatchForEdits({ | |
| filePath: oldFilePath, | |
| fileContents: oldContent, | |
| edits, | |
| }) | |
| if (!ideClient || ideClient.type !== 'connected') { | |
| throw new Error('IDE client not available') | |
| } | |
| let ideOldPath = oldFilePath | |
| // Only convert paths if we're in WSL and IDE is on Windows | |
| const ideRunningInWindows = | |
| (ideClient.config as McpSSEIDEServerConfig | McpWebSocketIDEServerConfig) | |
| .ideRunningInWindows === true | |
| if ( | |
| getPlatform() === 'wsl' && | |
| ideRunningInWindows && | |
| process.env.WSL_DISTRO_NAME | |
| ) { | |
| const converter = new WindowsToWSLConverter(process.env.WSL_DISTRO_NAME) | |
| ideOldPath = converter.toIDEPath(oldFilePath) | |
| } | |
| const rpcResult = await callIdeRpc( | |
| 'openDiff', | |
| { | |
| old_file_path: ideOldPath, | |
| new_file_path: ideOldPath, | |
| new_file_contents: updatedFile, | |
| tab_name: tabName, | |
| }, | |
| ideClient, | |
| ) | |
| // Convert the raw RPC result to a ToolCallResponse format | |
| const data = Array.isArray(rpcResult) ? rpcResult : [rpcResult] | |
| // If the user saved the file then take the new contents and resolve with that. | |
| if (isSaveMessage(data)) { | |
| void cleanup() | |
| return { | |
| oldContent: oldContent, | |
| newContent: data[1].text, | |
| } | |
| } else if (isClosedMessage(data)) { | |
| void cleanup() | |
| return { | |
| oldContent: oldContent, | |
| newContent: updatedFile, | |
| } | |
| } else if (isRejectedMessage(data)) { | |
| void cleanup() | |
| return { | |
| oldContent: oldContent, | |
| newContent: oldContent, | |
| } | |
| } | |
| // Indicates that the tool call completed with none of the expected | |
| // results. Did the user close the IDE? | |
| throw new Error('Not accepted') | |
| } catch (error) { | |
| logError(error as Error) | |
| void cleanup() | |
| throw error | |
| } | |
| } | |
| async function closeTabInIDE( | |
| tabName: string, | |
| ideClient?: MCPServerConnection | undefined, | |
| ): Promise<void> { | |
| try { | |
| if (!ideClient || ideClient.type !== 'connected') { | |
| throw new Error('IDE client not available') | |
| } | |
| // Use direct RPC to close the tab | |
| await callIdeRpc('close_tab', { tab_name: tabName }, ideClient) | |
| } catch (error) { | |
| logError(error as Error) | |
| // Don't throw - this is a cleanup operation | |
| } | |
| } | |
| function isClosedMessage(data: unknown): data is { text: 'TAB_CLOSED' } { | |
| return ( | |
| Array.isArray(data) && | |
| typeof data[0] === 'object' && | |
| data[0] !== null && | |
| 'type' in data[0] && | |
| data[0].type === 'text' && | |
| 'text' in data[0] && | |
| data[0].text === 'TAB_CLOSED' | |
| ) | |
| } | |
| function isRejectedMessage(data: unknown): data is { text: 'DIFF_REJECTED' } { | |
| return ( | |
| Array.isArray(data) && | |
| typeof data[0] === 'object' && | |
| data[0] !== null && | |
| 'type' in data[0] && | |
| data[0].type === 'text' && | |
| 'text' in data[0] && | |
| data[0].text === 'DIFF_REJECTED' | |
| ) | |
| } | |
| function isSaveMessage( | |
| data: unknown, | |
| ): data is [{ text: 'FILE_SAVED' }, { text: string }] { | |
| return ( | |
| Array.isArray(data) && | |
| data[0]?.type === 'text' && | |
| data[0].text === 'FILE_SAVED' && | |
| typeof data[1].text === 'string' | |
| ) | |
| } | |