| 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' && |
| |
| |
| !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, |
| ) |
| |
| if (isUnmounted.current) { |
| return |
| } |
|
|
| logEvent('tengu_ext_diff_accepted', {}) |
|
|
| const newEdits = computeEditsFromContents( |
| filePath, |
| oldContent, |
| newContent, |
| editMode, |
| ) |
|
|
| if (newEdits.length === 0) { |
| |
| logEvent('tengu_ext_diff_rejected', {}) |
| |
| const ideClient = getConnectedIdeClient( |
| toolUseContext.options.mcpClients, |
| ) |
| if (ideClient) { |
| |
| await closeTabInIDE(tabName, ideClient) |
| } |
| onChange( |
| { type: 'reject' }, |
| { |
| file_path: filePath, |
| edits: edits, |
| }, |
| ) |
| return |
| } |
|
|
| |
| onChange( |
| { type: 'accept-once' }, |
| { |
| file_path: filePath, |
| edits: newEdits, |
| }, |
| ) |
| } catch (error) { |
| logError(error as Error) |
| setHasError(true) |
| } |
| } |
|
|
| useEffect(() => { |
| void showDiff() |
|
|
| |
| return () => { |
| isUnmounted.current = true |
| } |
| |
| }, []) |
|
|
| return { |
| closeTabInIDE() { |
| const ideClient = getConnectedIdeClient(toolUseContext.options.mcpClients) |
|
|
| if (!ideClient) { |
| return Promise.resolve() |
| } |
|
|
| return closeTabInIDE(tabName, ideClient) |
| }, |
| showingDiffInIDE: shouldShowDiffInIDE && !hasError, |
| ideName: ideName, |
| hasError, |
| } |
| } |
|
|
| |
| |
| |
| |
| export function computeEditsFromContents( |
| filePath: string, |
| oldContent: string, |
| newContent: string, |
| editMode: 'single' | 'multiple', |
| ): FileEdit[] { |
| |
| const singleHunk = editMode === 'single' |
| const patch = getPatchFromContents({ |
| filePath, |
| oldContent, |
| newContent, |
| singleHunk, |
| }) |
|
|
| if (patch.length === 0) { |
| return [] |
| } |
|
|
| |
| if (singleHunk && patch.length > 1) { |
| logError( |
| new Error( |
| `Unexpected number of hunks: ${patch.length}. Expected 1 hunk.`, |
| ), |
| ) |
| } |
|
|
| |
| return getEditsForPatch(patch) |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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() { |
| |
| |
| if (isCleanedUp) { |
| return |
| } |
| isCleanedUp = true |
|
|
| |
| try { |
| await closeTabInIDE(tabName, ideClient) |
| } catch (e) { |
| logError(e as Error) |
| } |
|
|
| process.off('beforeExit', cleanup) |
| toolUseContext.abortController.signal.removeEventListener('abort', cleanup) |
| } |
|
|
| |
| toolUseContext.abortController.signal.addEventListener('abort', cleanup) |
| process.on('beforeExit', cleanup) |
|
|
| |
| 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 |
|
|
| |
| 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, |
| ) |
|
|
| |
| const data = Array.isArray(rpcResult) ? rpcResult : [rpcResult] |
|
|
| |
| 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, |
| } |
| } |
|
|
| |
| |
| 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') |
| } |
|
|
| |
| await callIdeRpc('close_tab', { tab_name: tabName }, ideClient) |
| } catch (error) { |
| logError(error as Error) |
| |
| } |
| } |
|
|
| 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' |
| ) |
| } |
|
|