fix: Ensure filename is available for diff rendering in write-file

This commit resolves a bug where the `write-file` operation could fail to render content due to a missing filename.

The fix involves:
- Ensuring `fileName` is consistently passed to `DiffRenderer.tsx` through `ToolConfirmationMessage.tsx`, `ToolMessage.tsx`, and `useGeminiStream.ts`.
- Modifying `edit.ts` and `write-file.ts` to include `fileName` in the `FileDiff` object.
- Expanding the `FileDiff` interface in `tools.ts` to include `fileName`.

Additionally, this commit enhances the diff rendering by:
- Adding syntax highlighting based on file extension in `DiffRenderer.tsx`.
- Adding more language mappings to `getLanguageFromExtension` in `DiffRenderer.tsx`.
- Added lots of tests for all the above.

Fixes https://b.corp.google.com/issues/418125982
This commit is contained in:
Taylor Mullen
2025-05-15 23:51:53 -07:00
committed by N. Taylor Mullen
parent dce7d2c4f7
commit 968e09f0b5
17 changed files with 504 additions and 28 deletions

View File

@@ -0,0 +1,117 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import { DiffRenderer } from './DiffRenderer.js';
import * as CodeColorizer from '../../utils/CodeColorizer.js';
import { vi } from 'vitest';
describe('<DiffRenderer />', () => {
const mockColorizeCode = vi.spyOn(CodeColorizer, 'colorizeCode');
beforeEach(() => {
mockColorizeCode.mockClear();
});
it('should call colorizeCode with correct language for new file with known extension', () => {
const newFileDiffContent = `
diff --git a/test.py b/test.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/test.py
@@ -0,0 +1 @@
+print("hello world")
`;
render(
<DiffRenderer diffContent={newFileDiffContent} filename="test.py" />,
);
expect(mockColorizeCode).toHaveBeenCalledWith(
'print("hello world")',
'python',
);
});
it('should call colorizeCode with null language for new file with unknown extension', () => {
const newFileDiffContent = `
diff --git a/test.unknown b/test.unknown
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/test.unknown
@@ -0,0 +1 @@
+some content
`;
render(
<DiffRenderer diffContent={newFileDiffContent} filename="test.unknown" />,
);
expect(mockColorizeCode).toHaveBeenCalledWith('some content', null);
});
it('should call colorizeCode with null language for new file if no filename is provided', () => {
const newFileDiffContent = `
diff --git a/test.txt b/test.txt
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/test.txt
@@ -0,0 +1 @@
+some text content
`;
render(<DiffRenderer diffContent={newFileDiffContent} />);
expect(mockColorizeCode).toHaveBeenCalledWith('some text content', null);
});
it('should render diff content for existing file (not calling colorizeCode directly for the whole block)', () => {
const existingFileDiffContent = `
diff --git a/test.txt b/test.txt
index 0000001..0000002 100644
--- a/test.txt
+++ b/test.txt
@@ -1 +1 @@
-old line
+new line
`;
const { lastFrame } = render(
<DiffRenderer
diffContent={existingFileDiffContent}
filename="test.txt"
/>,
);
// colorizeCode is used internally by the line-by-line rendering, not for the whole block
expect(mockColorizeCode).not.toHaveBeenCalledWith(
expect.stringContaining('old line'),
expect.anything(),
);
expect(mockColorizeCode).not.toHaveBeenCalledWith(
expect.stringContaining('new line'),
expect.anything(),
);
const output = lastFrame();
const lines = output!.split('\n');
expect(lines[0]).toBe('1 - old line');
expect(lines[1]).toBe('1 + new line');
});
it('should handle diff with only header and no changes', () => {
const noChangeDiff = `diff --git a/file.txt b/file.txt
index 1234567..1234567 100644
--- a/file.txt
+++ b/file.txt
`;
const { lastFrame } = render(
<DiffRenderer diffContent={noChangeDiff} filename="file.txt" />,
);
expect(lastFrame()).toContain('No changes detected');
expect(mockColorizeCode).not.toHaveBeenCalled();
});
it('should handle empty diff content', () => {
const { lastFrame } = render(<DiffRenderer diffContent="" />);
expect(lastFrame()).toContain('No diff content');
expect(mockColorizeCode).not.toHaveBeenCalled();
});
});

View File

@@ -105,6 +105,14 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
const parsedLines = parseDiffWithLineNumbers(diffContent);
if (parsedLines.length === 0) {
return (
<Box borderStyle="round" borderColor={Colors.SubtleComment} padding={1}>
<Text dimColor>No changes detected.</Text>
</Box>
);
}
// Check if the diff represents a new file (only additions and header lines)
const isNewFile = parsedLines.every(
(line) =>
@@ -233,16 +241,21 @@ const renderDiffContent = (
const getLanguageFromExtension = (extension: string): string | null => {
const languageMap: { [key: string]: string } = {
'.js': 'javascript',
'.ts': 'typescript',
'.py': 'python',
'.json': 'json',
'.css': 'css',
'.html': 'html',
'.sh': 'bash',
'.md': 'markdown',
'.yaml': 'yaml',
'.yml': 'yaml',
js: 'javascript',
ts: 'typescript',
py: 'python',
json: 'json',
css: 'css',
html: 'html',
sh: 'bash',
md: 'markdown',
yaml: 'yaml',
yml: 'yaml',
txt: 'plaintext',
java: 'java',
c: 'c',
cpp: 'cpp',
rb: 'ruby',
};
return languageMap[extension] || null; // Return null if extension not found
};

View File

@@ -52,7 +52,12 @@ export const ToolConfirmationMessage: React.FC<
if (isEditDetails(confirmationDetails)) {
// Body content is now the DiffRenderer, passing filename to it
// The bordered box is removed from here and handled within DiffRenderer
bodyContent = <DiffRenderer diffContent={confirmationDetails.fileDiff} />;
bodyContent = (
<DiffRenderer
diffContent={confirmationDetails.fileDiff}
filename={confirmationDetails.fileName}
/>
);
question = `Apply this change?`;
options.push(

View File

@@ -90,7 +90,10 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
</Box>
)}
{typeof displayableResult === 'object' && (
<DiffRenderer diffContent={displayableResult.fileDiff} />
<DiffRenderer
diffContent={displayableResult.fileDiff}
filename={displayableResult.fileName}
/>
)}
{hiddenLines > 0 && (
<Box>

View File

@@ -154,7 +154,9 @@ export async function handleAtCommand({
if (isNodeError(error) && error.code === 'ENOENT') {
onDebugMessage(`Path not found, proceeding with original: ${pathSpec}`);
} else {
console.error(`Error stating path ${pathPart}:`, error);
console.error(
`Error stating path ${pathPart}: ${getErrorMessage(error)}`,
);
onDebugMessage(
`Error stating path, proceeding with original: ${pathSpec}`,
);
@@ -200,7 +202,7 @@ export async function handleAtCommand({
);
return { processedQuery, shouldProceed: true };
} catch (error) {
} catch (error: unknown) {
// Handle errors during tool execution
toolCallDisplay = {
callId: `client-read-${userMessageTimestamp}`,

View File

@@ -7,7 +7,12 @@
import { useState, useEffect, useCallback } from 'react';
import * as fs from 'fs/promises';
import * as path from 'path';
import { isNodeError, escapePath, unescapePath } from '@gemini-code/server';
import {
isNodeError,
escapePath,
unescapePath,
getErrorMessage,
} from '@gemini-code/server';
import {
MAX_SUGGESTIONS_TO_SHOW,
Suggestion,
@@ -202,7 +207,7 @@ export function useCompletion(
setActiveSuggestionIndex(filteredSuggestions.length > 0 ? 0 : -1);
setVisibleStartIndex(0);
}
} catch (error) {
} catch (error: unknown) {
if (isNodeError(error) && error.code === 'ENOENT') {
// Directory doesn't exist, likely mid-typing, clear suggestions
if (isMounted) {
@@ -211,8 +216,7 @@ export function useCompletion(
}
} else {
console.error(
`Error fetching completion suggestions for ${baseDirAbsolute}:`,
error,
`Error fetching completion suggestions for ${baseDirAbsolute}: ${getErrorMessage(error)}`,
);
if (isMounted) {
resetCompletionState();

View File

@@ -326,6 +326,7 @@ export const useGeminiStream = (
if ('fileDiff' in originalDetails) {
resultDisplay = {
fileDiff: (originalDetails as ToolEditConfirmationDetails).fileDiff,
fileName: (originalDetails as ToolEditConfirmationDetails).fileName,
};
} else {
resultDisplay = `~~${(originalDetails as ToolExecuteConfirmationDetails).command}~~`;
@@ -590,7 +591,7 @@ export const useGeminiStream = (
addItem(
{
type: MessageType.ERROR,
text: `[Stream Error: ${getErrorMessage(error)}]`,
text: `[Stream Error: ${getErrorMessage(error) || 'Unknown error'}]`,
},
userMessageTimestamp,
);