diff --git a/src/__tests__/Home.e2e.spec.ts b/src/__tests__/Home.e2e.spec.ts index 42280188..bffb8824 100644 --- a/src/__tests__/Home.e2e.spec.ts +++ b/src/__tests__/Home.e2e.spec.ts @@ -112,7 +112,17 @@ describe('Home', () => { expect(image).toMatchImageSnapshot() }) - it('9. should scroll to the first file in diff', async () => { + it('9. should show the AI prompt copy button after diff loads', async () => { + await page.waitForSelector( + `[data-testid="${upgradeButtonTestIDs.aiPromptButton}"]` + ) + + const image = await page.screenshot() + + expect(image).toMatchImageSnapshot() + }) + + it('10. should scroll to the first file in diff', async () => { await page.evaluate((testID) => { document .querySelector(`[data-testid="${testID}"]`) @@ -125,7 +135,7 @@ describe('Home', () => { expect(image).toMatchImageSnapshot() }) - it('10. should collapse first file in diff', async () => { + it('11. should collapse first file in diff', async () => { await page.click( `[data-testid="${diffHeaderTestIDs.collapseClickableArea}"]` ) diff --git a/src/__tests__/components/common/UpgradeButton.spec.tsx b/src/__tests__/components/common/UpgradeButton.spec.tsx new file mode 100644 index 00000000..ca06dd72 --- /dev/null +++ b/src/__tests__/components/common/UpgradeButton.spec.tsx @@ -0,0 +1,30 @@ +import React from 'react' +import { fireEvent, render, waitFor } from '@testing-library/react' +import UpgradeButton, { + testIDs, +} from '../../../components/common/UpgradeButton' + +describe('UpgradeButton', () => { + it('does not render the AI prompt button until the prompt is ready', () => { + const { queryByTestId } = render() + + expect(queryByTestId(testIDs.aiPromptButton)).toBeNull() + }) + + it('shows the AI prompt button and triggers copy when clicked', async () => { + const onAiPromptClick = jest.fn().mockResolvedValue(undefined) + const { getByTestId } = render( + + ) + + fireEvent.click(getByTestId(testIDs.aiPromptButton)) + + await waitFor(() => { + expect(onAiPromptClick).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/src/__tests__/utils.spec.ts b/src/__tests__/utils.spec.ts index d01996b3..c40333b3 100644 --- a/src/__tests__/utils.spec.ts +++ b/src/__tests__/utils.spec.ts @@ -1,9 +1,11 @@ import { PACKAGE_NAMES } from '../constants' +import { parseDiff } from 'react-diff-view' import '../releases/__mocks__/index' import { getVersionsContentInDiff, replaceAppDetails, getChangelogURL, + buildAiUpgradePrompt, } from '../utils' describe('getVersionsContentInDiff', () => { @@ -132,3 +134,117 @@ describe('replaceAppDetails ', () => { } ) }) + +describe('buildAiUpgradePrompt', () => { + const createFiles = (rawDiffText: string) => parseDiff(rawDiffText) + const createPrompt = ( + overrides: Partial[0]> + ) => + buildAiUpgradePrompt({ + files: [], + packageName: PACKAGE_NAMES.RN, + language: 'cpp', + fromVersion: '0.63.2', + toVersion: '0.64.2', + ...overrides, + }) + + it('includes overview, warnings, and structured file changes', () => { + const prompt = createPrompt({ + files: createFiles( + [ + 'diff --git a/RnDiffApp/App.js b/RnDiffApp/App.js', + '--- a/RnDiffApp/App.js', + '+++ b/RnDiffApp/App.js', + '@@ -1 +1 @@', + '-package com.rndiffapp;', + '+package com.rndiffapp;', + ].join('\n') + ), + appName: 'MyApp', + appPackage: 'com.example.myapp', + }) + + expect(prompt).toContain( + 'You are helping upgrade a React Native app using the provided template diff.' + ) + expect(prompt).toContain('# React Native upgrade guidance request') + expect(prompt).toContain('## Task overview') + expect(prompt).toContain('- From version: 0.63.2') + expect(prompt).toContain('- To version: 0.64.2') + expect(prompt).toContain('- App name input or fallback: MyApp') + expect(prompt).toContain( + '- App package input or fallback: com.example.myapp' + ) + expect(prompt).toContain( + 'The app name and app package values may be inaccurate because the user may have left them unchanged, blank, or approximate. Verify them against the real project before applying changes.' + ) + expect(prompt).toContain( + 'This diff only represents the React Native bootstrap/template project between versions. First understand the current project structure and apply only the changes that are relevant to this codebase.' + ) + expect(prompt).toContain('\n\n## Task overview') + expect(prompt).toContain('## File changes') + expect(prompt).toContain('\n\n### `App.js`') + expect(prompt).toContain('### `App.js`') + expect(prompt).toContain('- Change type: Modified') + expect(prompt).toContain('```diff') + expect(prompt).toContain('```') + expect(prompt).toContain('-package com.example.myapp;') + }) + + it('omits binary patch contents and adds download guidance', () => { + const prompt = createPrompt({ + files: createFiles( + [ + 'diff --git a/RnDiffApp/android/gradle/wrapper/gradle-wrapper.jar b/RnDiffApp/android/gradle/wrapper/gradle-wrapper.jar', + 'index abc..def 100644', + 'Binary files a/RnDiffApp/android/gradle/wrapper/gradle-wrapper.jar and b/RnDiffApp/android/gradle/wrapper/gradle-wrapper.jar differ', + 'diff --git a/RnDiffApp/App.js b/RnDiffApp/App.js', + '--- a/RnDiffApp/App.js', + '+++ b/RnDiffApp/App.js', + '@@ -1 +1 @@', + '-console.log("old")', + '+console.log("new")', + ].join('\n') + ), + }) + + expect(prompt).toContain('## Binary file handling') + expect(prompt).toContain( + '\n\n### `android/gradle/wrapper/gradle-wrapper.jar`' + ) + expect(prompt).toContain('### `android/gradle/wrapper/gradle-wrapper.jar`') + expect(prompt).toContain('```bash') + expect(prompt).toContain( + 'curl -L "https://raw.githubusercontent.com/react-native-community/rn-diff-purge/release/0.64.2/RnDiffApp/android/gradle/wrapper/gradle-wrapper.jar" -o "android/gradle/wrapper/gradle-wrapper.jar"' + ) + expect(prompt).not.toContain( + 'Binary files a/RnDiffApp/android/gradle/wrapper/gradle-wrapper.jar' + ) + expect(prompt).toContain('### `App.js`') + }) + + it('tells the agent to remove deleted binary files instead of downloading them', () => { + const prompt = createPrompt({ + files: createFiles( + [ + 'diff --git a/RnDiffApp/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/RnDiffApp/android/app/src/main/res/mipmap-hdpi/ic_launcher.png', + 'deleted file mode 100644', + 'index abcdef..000000', + 'Binary files a/RnDiffApp/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and /dev/null differ', + ].join('\n') + ), + }) + + expect(prompt).toContain( + '\n\n### `android/app/src/main/res/mipmap-hdpi/ic_launcher.png`' + ) + expect(prompt).toContain( + '### `android/app/src/main/res/mipmap-hdpi/ic_launcher.png`' + ) + expect(prompt).toContain( + '- Remove this file from the target project if it still exists.' + ) + expect(prompt).not.toContain('raw.githubusercontent.com') + }) +}) diff --git a/src/components/common/DiffViewer.tsx b/src/components/common/DiffViewer.tsx index cb3bbec6..071defeb 100644 --- a/src/components/common/DiffViewer.tsx +++ b/src/components/common/DiffViewer.tsx @@ -72,6 +72,7 @@ interface DiffViewerProps { onToggleChangeSelection: (args: ChangeEventArgs) => void appName: string appPackage: string + onDiffResolved?: (diff: File[]) => void } const DiffViewer = ({ packageName, @@ -83,6 +84,7 @@ const DiffViewer = ({ onToggleChangeSelection, appName, appPackage, + onDiffResolved, }: DiffViewerProps) => { const { isLoading, isDone, diff } = useFetchDiff({ shouldShowDiff, @@ -178,6 +180,12 @@ const DiffViewer = ({ } }, [isDone]) + useEffect(() => { + if (isDone) { + onDiffResolved?.(diff) + } + }, [isDone, diff, onDiffResolved]) + if (!shouldShowDiff) { return null } diff --git a/src/components/common/UpgradeButton.tsx b/src/components/common/UpgradeButton.tsx index f8358f1c..b432dee5 100644 --- a/src/components/common/UpgradeButton.tsx +++ b/src/components/common/UpgradeButton.tsx @@ -1,43 +1,84 @@ import React from 'react' import styled from '@emotion/styled' import { Button as AntdButton, ButtonProps } from 'antd' +import { CopyOutlined } from '@ant-design/icons' +import { deviceSizes } from '../../utils/device-sizes' export const testIDs = { upgradeButton: 'upgradeButton', + aiPromptButton: 'aiPromptButton', } const Container = styled.div` display: flex; + align-items: center; + gap: 12px; justify-content: center; height: auto; overflow: hidden; margin-top: 28px; + + @media ${deviceSizes.mobile} { + flex-direction: column; + } ` const Button = styled(AntdButton)` border-radius: 5px; ` +const AiPromptButton = styled(Button)` + && { + background: linear-gradient(135deg, #ff6fb5 0%, #59c4ff 100%) !important; + border: 0; + box-shadow: 0 2px 0 rgba(89, 196, 255, 0.24); + color: #fff; + } + + &&:hover { + opacity: 0.8; + } +` + interface UpgradeButtonProps extends React.PropsWithRef { onShowDiff: () => void + showAiPromptButton?: boolean + onAiPromptClick?: () => Promise } const UpgradeButton = React.forwardRef< HTMLElement, UpgradeButtonProps & React.RefAttributes ->(({ onShowDiff, ...props }, ref) => ( - - - -)) +>( + ( + { onShowDiff, showAiPromptButton = false, onAiPromptClick, ...props }, + ref + ) => ( + + + + {showAiPromptButton && ( + + Copy for AI + + + )} + + ) +) export default UpgradeButton diff --git a/src/components/common/VersionSelector.tsx b/src/components/common/VersionSelector.tsx index c138a712..d7cae587 100644 --- a/src/components/common/VersionSelector.tsx +++ b/src/components/common/VersionSelector.tsx @@ -225,6 +225,10 @@ const VersionSelector = ({ showReleaseCandidates, appPackage, appName, + isAiPromptReady, + onCopyAiPrompt, + resolvedFromVersion, + resolvedToVersion, }: { packageName: string language: string @@ -233,6 +237,10 @@ const VersionSelector = ({ showReleaseCandidates: boolean appPackage: string appName?: string + isAiPromptReady: boolean + onCopyAiPrompt: () => Promise + resolvedFromVersion: string + resolvedToVersion: string }) => { const { isLoading, isDone, releaseVersions } = useFetchReleaseVersions({ packageName, @@ -365,6 +373,10 @@ const VersionSelector = ({ appName, }) } + const showAiPromptButton = + isAiPromptReady && + localFromVersion === resolvedFromVersion && + localToVersion === resolvedToVersion return ( @@ -403,7 +415,12 @@ const VersionSelector = ({ /> - + ) } diff --git a/src/components/pages/Home.tsx b/src/components/pages/Home.tsx index e863b624..adc3273d 100644 --- a/src/components/pages/Home.tsx +++ b/src/components/pages/Home.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useDeferredValue } from 'react' +import React, { useState, useEffect, useDeferredValue, useMemo } from 'react' import styled from '@emotion/styled' import { ThemeProvider } from '@emotion/react' import { Card, Input, Typography, ConfigProvider, theme } from 'antd' @@ -11,7 +11,7 @@ import DiffViewer from '../common/DiffViewer' import Settings from '../common/Settings' // @ts-ignore-next-line import logo from '../../assets/logo.svg' -import { SHOW_LATEST_RCS } from '../../utils' +import { SHOW_LATEST_RCS, buildAiUpgradePrompt } from '../../utils' import { useGetLanguageFromURL } from '../../hooks/get-language-from-url' import { useGetPackageNameFromURL } from '../../hooks/get-package-name-from-url' import { @@ -25,6 +25,7 @@ import { updateURL } from '../../utils/update-url' import { deviceSizes } from '../../utils/device-sizes' import { lightTheme, darkTheme, type Theme } from '../../theme' import { CheckboxValueType } from 'antd/es/checkbox/Group' +import type { File } from 'gitdiff-parser' const Page = styled.div<{ theme?: Theme }>` background-color: ${({ theme }) => theme.background}; @@ -136,6 +137,7 @@ const Home = () => { const [fromVersion, setFromVersion] = useState('') const [toVersion, setToVersion] = useState('') const [shouldShowDiff, setShouldShowDiff] = useState(false) + const [resolvedDiff, setResolvedDiff] = useState([]) const [settings, setSettings] = useState>({ [`${SHOW_LATEST_RCS}`]: false, }) @@ -147,6 +149,37 @@ const Home = () => { // Avoid UI lag when typing. const deferredAppName = useDeferredValue(appName || DEFAULT_APP_NAME) const deferredAppPackage = useDeferredValue(appPackage) + const normalizedAppPackage = + deferredAppPackage !== DEFAULT_APP_PACKAGE ? deferredAppPackage : undefined + const aiPrompt = useMemo(() => { + if ( + !shouldShowDiff || + !fromVersion || + !toVersion || + resolvedDiff.length === 0 + ) { + return '' + } + + return buildAiUpgradePrompt({ + files: resolvedDiff, + packageName, + language, + fromVersion, + toVersion, + appName: deferredAppName, + appPackage: normalizedAppPackage, + }) + }, [ + shouldShowDiff, + fromVersion, + toVersion, + resolvedDiff, + packageName, + language, + deferredAppName, + normalizedAppPackage, + ]) const homepageUrl = process.env.PUBLIC_URL @@ -158,18 +191,27 @@ const Home = () => { }, []) const handleShowDiff = ({ - fromVersion, - toVersion, + fromVersion: nextFromVersion, + toVersion: nextToVersion, }: { fromVersion: string toVersion: string }) => { - if (fromVersion === toVersion) { + if (nextFromVersion === nextToVersion) { + return + } + + if ( + shouldShowDiff && + fromVersion === nextFromVersion && + toVersion === nextToVersion + ) { return } - setFromVersion(fromVersion) - setToVersion(toVersion) + setFromVersion(nextFromVersion) + setToVersion(nextToVersion) + setResolvedDiff([]) setShouldShowDiff(true) } @@ -196,6 +238,7 @@ const Home = () => { setLanguage(localLanguage) setFromVersion('') setToVersion('') + setResolvedDiff([]) setShouldShowDiff(false) } @@ -310,6 +353,10 @@ const Home = () => { isPackageNameDefinedInURL={isPackageNameDefinedInURL} appPackage={appPackage} appName={appName} + isAiPromptReady={!!aiPrompt} + onCopyAiPrompt={() => navigator.clipboard.writeText(aiPrompt)} + resolvedFromVersion={fromVersion} + resolvedToVersion={toVersion} /> {/* @@ -330,6 +377,7 @@ const Home = () => { } packageName={packageName} language={language} + onDiffResolved={setResolvedDiff} /> diff --git a/src/utils.ts b/src/utils.ts index 08030885..ad25707d 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,4 +1,5 @@ import semver from 'semver/preload' +import type { Change, File, Hunk } from 'gitdiff-parser' import { RN_DIFF_REPOSITORIES, DEFAULT_APP_NAME, @@ -178,3 +179,188 @@ export const getFilePathsToShow = ({ newPath: removeAppPathPrefix(newPathSanitized, appName), } } + +const isBinaryFile = (file: File) => + !!file.isBinary || + (file.hunks.length === 0 && !['rename', 'copy'].includes(file.type)) + +const isDeletedBinaryFile = (file: File) => { + return ( + isBinaryFile(file) && + (file.type === 'delete' || file.newRevision === '000000') + ) +} + +const NO_LINE_LEVEL_PATCH_MESSAGE = + '- No line-level patch was included for this file.' + +const serializeChange = ({ + change, + appName, + appPackage, +}: { + change: Change + appName?: string + appPackage?: string +}) => { + const prefix = + change.type === 'insert' ? '+' : change.type === 'delete' ? '-' : ' ' + + return `${prefix}${replaceAppDetails(change.content, appName, appPackage)}` +} + +const serializeHunk = ({ + hunk, + appName, + appPackage, +}: { + hunk: Hunk + appName?: string + appPackage?: string +}) => + [hunk.content] + .concat( + hunk.changes.map((change) => + serializeChange({ change, appName, appPackage }) + ) + ) + .join('\n') + +const FILE_CHANGE_LABELS: Record = { + add: 'Added', + copy: 'Copied', + delete: 'Deleted', + modify: 'Modified', + rename: 'Renamed', +} + +export const buildAiUpgradePrompt = ({ + files, + packageName, + language, + fromVersion, + toVersion, + appName, + appPackage, +}: { + files: File[] + packageName: string + language: string + fromVersion: string + toVersion: string + appName?: string + appPackage?: string +}) => { + const effectiveAppName = appName || DEFAULT_APP_NAME + const effectiveAppPackage = + appPackage || `com.${effectiveAppName.toLowerCase()}` + const binaryFiles = files.filter(isBinaryFile) + const textFiles = files.filter((file) => !isBinaryFile(file)) + const binaryInstructions = + binaryFiles.length > 0 + ? [ + '', + '## Binary file handling', + '- Binary files are listed separately because inline patch payloads are not useful here.', + ...binaryFiles.flatMap((file) => { + const localPaths = getFilePathsToShow({ + oldPath: file.oldPath, + newPath: file.newPath, + appName, + appPackage, + }) + const localPath = isDeletedBinaryFile(file) + ? localPaths.oldPath + : localPaths.newPath + + if (isDeletedBinaryFile(file)) { + return [ + '', + `### \`${localPath}\``, + '- Remove this file from the target project if it still exists.', + ] + } + + const downloadURL = getBinaryFileURL({ + packageName, + language, + version: toVersion, + path: file.newPath, + }) + + return [ + '', + `### \`${localPath}\``, + '```bash', + `curl -L "${downloadURL}" -o "${localPath}"`, + '```', + ] + }), + ] + : [] + const structuredFileChanges = + textFiles.length > 0 + ? [ + '', + '## File changes', + ...textFiles.flatMap((file) => { + const localPaths = getFilePathsToShow({ + oldPath: file.oldPath, + newPath: file.newPath, + appName, + appPackage, + }) + const localPath = + file.type === 'delete' ? localPaths.oldPath : localPaths.newPath + const fileChanges = [ + `### \`${localPath}\``, + `- Change type: ${FILE_CHANGE_LABELS[file.type]}`, + ] + + if ( + ['rename', 'copy'].includes(file.type) && + localPaths.oldPath !== localPaths.newPath + ) { + fileChanges.push(`- Previous path: \`${localPaths.oldPath}\``) + } + + if (file.hunks.length > 0) { + fileChanges.push( + '```diff', + file.hunks + .map((hunk) => serializeHunk({ hunk, appName, appPackage })) + .join('\n'), + '```' + ) + } else { + fileChanges.push(NO_LINE_LEVEL_PATCH_MESSAGE) + } + + return ['', ...fileChanges] + }), + ] + : [] + + return [ + '# React Native upgrade guidance request', + '', + 'You are helping upgrade a React Native app using the provided template diff.', + '', + '## Task overview', + `- From version: ${fromVersion}`, + `- To version: ${toVersion}`, + `- App name input or fallback: ${effectiveAppName}`, + `- App package input or fallback: ${effectiveAppPackage}`, + '', + '## Important notes', + '- Use the structured file changes below as the source of truth for parsed template changes.', + '- This diff only represents the React Native bootstrap/template project between versions. First understand the current project structure and apply only the changes that are relevant to this codebase.', + '- Preserve project-specific code and merge carefully instead of blindly overwriting files.', + '- Review binary file changes separately if any are present.', + '- The app name and app package values may be inaccurate because the user may have left them unchanged, blank, or approximate. Verify them against the real project before applying changes.', + ...binaryInstructions, + ...structuredFileChanges, + ] + .filter((line) => line !== undefined) + .join('\n') +} diff --git a/src/utils/device-sizes.ts b/src/utils/device-sizes.ts index 80780b3e..eb2045b4 100644 --- a/src/utils/device-sizes.ts +++ b/src/utils/device-sizes.ts @@ -1,3 +1,4 @@ export const deviceSizes = { + mobile: '(max-width: 640px)', tablet: '(min-width: 768px)', }