384 lines
9.7 KiB
TypeScript
384 lines
9.7 KiB
TypeScript
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'
|
||
}
|
||
|
||
/**
|
||
* 管理在 IDE 中显示文件差异的 Hook。
|
||
* 打开 IDE diff 视图,让用户审查并接受或拒绝编辑。
|
||
*/
|
||
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,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 从旧内容和新内容重新计算编辑。这是必要的,
|
||
* 以便应用用户可能对新内容所做的任何编辑。
|
||
*/
|
||
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)
|
||
}
|
||
|
||
/**
|
||
* 完成条件:
|
||
*
|
||
* 1. Tab 在 IDE 中关闭
|
||
* 2. Tab 在 IDE 中保存(我们然后关闭 tab)
|
||
* 3. 用户在 IDE 中选择了选项
|
||
* 4. 用户在终端中选择了选项(或按 esc)
|
||
*
|
||
* 用新文件内容解决。
|
||
*
|
||
* TODO: 5 分钟不活动后超时?
|
||
* TODO: IDE 退出时更新自动批准 UI
|
||
* TODO: 批准提示卸载时关闭 IDE tab
|
||
*/
|
||
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'
|
||
)
|
||
}
|