Something went wrong. Try again.
source dump of claude code forked from oppi.li/claude-code
Something went wrong. Try again.
9.9 kB · 379 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380import { 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' )}