Skip to content

Commit a0b214f

Browse files
committed
feat(diff): 添加文件差异预览和原始内容恢复功能
- 在 DiffPreview 组件中新增打开文件及查看差异编辑器按钮 - 通过 wrpc 实现打开文件及展示差异编辑器的远程调用 - 在扩展中新增 openDiffEditor 方法,支持反向应用 diff 预览重建原始文件内容 - 通过临时文件机制启动 VS Code 原生差异比较编辑器 - 新增 diff-utils 模块,实现统一 diff 解析与反向应用算法 - 更新路由类型与实现,支持差异编辑器和文件内容拉取接口 - 工具气泡组件显示名称首字母大写,优化显示效果 - 新增字符串首字母大写辅助函数 capitalize,提高代码复用性
1 parent c544cef commit a0b214f

6 files changed

Lines changed: 329 additions & 2 deletions

File tree

packages/vscode-ide-companion/src/extension.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { appRouter, type RouterContext } from "./router.js";
2020
import { attachRouterToPanel } from "@webview-rpc/host";
2121
import { createLogger } from "./utils/logger.js";
2222
import { checkForUpdates } from "./utils/checkForUpdates.js";
23+
import { reverseApplyDiff } from "./utils/diff-utils.js";
2324

2425
const DEFAULT_MODEL = "deepseek-v4-pro";
2526
const DEFAULT_BASE_URL = "https://api.deepseek.com";
@@ -174,6 +175,8 @@ export class DeepCodeViewProvider implements vscode.WebviewViewProvider {
174175
// Switch to this session
175176
this.sessionManager.setActiveSessionId(sessionId);
176177
},
178+
getFileContent: (filePath) => this.getFileContent(filePath),
179+
showDiffEditor: (filePath, diffPreview) => this.openDiffEditor(filePath, diffPreview),
177180
};
178181

179182
// Attach RPC router
@@ -324,6 +327,61 @@ export class DeepCodeViewProvider implements vscode.WebviewViewProvider {
324327
editor.selection = new vscode.Selection(position, position);
325328
editor.revealRange(new vscode.Range(position, position), vscode.TextEditorRevealType.InCenter);
326329
}
330+
331+
/**
332+
* Get the raw file content from disk.
333+
*/
334+
private getFileContent(filePath: string): string {
335+
return fs.readFileSync(filePath, "utf8");
336+
}
337+
338+
/**
339+
* Open a VS Code native diff editor showing the before/after of a file edit.
340+
*
341+
* The current file on disk contains the modified version (after the edit was
342+
* applied). The diff_preview from the tool result metadata is used to
343+
* reconstruct the original content by reverse-applying the unified diff.
344+
*/
345+
private async openDiffEditor(filePath: string, diffPreview: string): Promise<void> {
346+
try {
347+
// Read the current (modified) file content from disk
348+
const modifiedContent = this.getFileContent(filePath);
349+
350+
// Reconstruct the original content by reverse-applying the diff
351+
const originalContent = reverseApplyDiff(modifiedContent, diffPreview);
352+
353+
if (originalContent === null) {
354+
void vscode.window.showWarningMessage(
355+
`Unable to reconstruct original content for diff. The file may have been modified since the edit.`
356+
);
357+
return;
358+
}
359+
360+
// Create temp files for the diff editor
361+
const tempDir = path.join(os.tmpdir(), "deepcode-diff");
362+
if (!fs.existsSync(tempDir)) {
363+
fs.mkdirSync(tempDir, { recursive: true });
364+
}
365+
366+
const baseName = path.basename(filePath);
367+
const timestamp = Date.now();
368+
const originalPath = path.join(tempDir, `${baseName}.${timestamp}.original`);
369+
const modifiedPath = path.join(tempDir, `${baseName}.${timestamp}.modified`);
370+
371+
fs.writeFileSync(originalPath, originalContent, "utf8");
372+
fs.writeFileSync(modifiedPath, modifiedContent, "utf8");
373+
374+
const originalUri = vscode.Uri.file(originalPath);
375+
const modifiedUri = vscode.Uri.file(modifiedPath);
376+
const title = `${baseName} (Original ↔ Modified)`;
377+
378+
// Open VS Code's native diff editor
379+
await vscode.commands.executeCommand("vscode.diff", originalUri, modifiedUri, title);
380+
} catch (error) {
381+
const message = error instanceof Error ? error.message : String(error);
382+
void vscode.window.showErrorMessage(`Diff editor error: ${message}`);
383+
}
384+
}
327385
}
328386

329387
export async function activate(context: vscode.ExtensionContext): Promise<void> {

packages/vscode-ide-companion/src/router.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ export interface RouterContext {
1515
openSettings: () => Promise<void>;
1616
getActiveEditor: () => { fileName: string; languageId: string; lineCount: number } | null;
1717
openChatPanel: (sessionId: string, viewColumn: number) => void;
18+
showDiffEditor: (filePath: string, diffPreview: string) => Promise<void>;
19+
getFileContent: (filePath: string) => string;
1820
}
1921

2022
export const { router, procedure } = initWRPC.context<RouterContext>().create();
@@ -339,6 +341,23 @@ export const appRouter = router({
339341
await vscodeApi.env.openExternal(vscodeApi.Uri.parse(input.url));
340342
return { ok: true };
341343
}),
344+
345+
showDiffEditor: procedure
346+
.input(
347+
z.object({
348+
filePath: z.string(),
349+
diffPreview: z.string(),
350+
})
351+
)
352+
.resolve(async ({ ctx, input }) => {
353+
await ctx.showDiffEditor(input.filePath, input.diffPreview);
354+
return { ok: true };
355+
}),
356+
357+
getFileContent: procedure.input(z.object({ filePath: z.string() })).resolve(({ ctx, input }) => {
358+
const content = ctx.getFileContent(input.filePath);
359+
return { content };
360+
}),
342361
});
343362

344363
export type AppRouter = typeof appRouter;
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
/**
2+
* Utilities for working with unified diff previews generated by buildDiffPreview.
3+
*
4+
* The diff format is a simplified single-hunk unified diff:
5+
* ```
6+
* --- a/filePath (or /dev/null for new files)
7+
* +++ b/filePath
8+
* @@ -oldStart,oldLen +newStart,newLen @@
9+
* context_before (optional, one line with " " prefix)
10+
* -removed_line_1
11+
* -removed_line_2
12+
* +added_line_1
13+
* +added_line_2
14+
* context_after (optional, one line with " " prefix)
15+
* ```
16+
*/
17+
18+
interface ParsedDiff {
19+
isNewFile: boolean;
20+
removedLines: string[];
21+
addedLines: string[];
22+
contextBefore: string[];
23+
contextAfter: string[];
24+
}
25+
26+
/**
27+
* Parse the simplified unified diff from buildDiffPreview into structured data.
28+
*/
29+
export function parseDiff(diffText: string): ParsedDiff {
30+
const diffLines = diffText.split("\n");
31+
32+
const isNewFile = diffLines[0]?.includes("/dev/null") ?? false;
33+
34+
// Find hunk header index
35+
let i = 0;
36+
while (i < diffLines.length && !diffLines[i].startsWith("@@")) {
37+
i++;
38+
}
39+
40+
const contextBefore: string[] = [];
41+
const removedLines: string[] = [];
42+
const addedLines: string[] = [];
43+
const contextAfter: string[] = [];
44+
45+
// State machine: before → removed → added → after
46+
enum State {
47+
ContextBefore,
48+
Removed,
49+
Added,
50+
ContextAfter,
51+
Done,
52+
}
53+
54+
let state: State = State.ContextBefore;
55+
56+
for (i = i + 1; i < diffLines.length; i++) {
57+
const line = diffLines[i];
58+
59+
// Handle truncation marker
60+
if (line === "...") break;
61+
62+
if (line.length === 0) continue;
63+
64+
const prefix = line[0];
65+
const content = line.slice(1);
66+
67+
switch (state) {
68+
case State.ContextBefore:
69+
if (prefix === " ") {
70+
contextBefore.push(content);
71+
state = State.Removed;
72+
} else if (prefix === "-") {
73+
removedLines.push(content);
74+
state = State.Removed;
75+
}
76+
break;
77+
case State.Removed:
78+
if (prefix === "-") {
79+
removedLines.push(content);
80+
} else if (prefix === "+") {
81+
addedLines.push(content);
82+
state = State.Added;
83+
} else if (prefix === " ") {
84+
contextAfter.push(content);
85+
state = State.ContextAfter;
86+
}
87+
break;
88+
case State.Added:
89+
if (prefix === "+") {
90+
addedLines.push(content);
91+
} else if (prefix === " ") {
92+
contextAfter.push(content);
93+
state = State.ContextAfter;
94+
}
95+
break;
96+
case State.ContextAfter:
97+
if (prefix === " ") {
98+
contextAfter.push(content);
99+
}
100+
break;
101+
}
102+
}
103+
104+
return { isNewFile, removedLines, addedLines, contextBefore, contextAfter };
105+
}
106+
107+
/**
108+
* Given the current file content (after the edit was applied) and the diff
109+
* preview, compute the original content (before the edit).
110+
*
111+
* Returns null if the reconstruction fails (e.g., content no longer matches).
112+
*/
113+
export function reverseApplyDiff(modifiedContent: string, diffText: string): string | null {
114+
const diff = parseDiff(diffText);
115+
116+
// New file: original is empty
117+
if (diff.isNewFile) {
118+
return "";
119+
}
120+
121+
const modifiedLines = modifiedContent.split("\n");
122+
123+
// Build the anchor pattern: contextBefore + addedLines
124+
const anchor = [...diff.contextBefore, ...diff.addedLines];
125+
126+
if (anchor.length === 0 && diff.contextAfter.length === 0) {
127+
// No identifiable anchor, probably an empty diff
128+
return modifiedContent;
129+
}
130+
131+
// Find the anchor in modified content
132+
let anchorStart = -1;
133+
for (let j = 0; j <= modifiedLines.length - anchor.length; j++) {
134+
let match = true;
135+
for (let k = 0; k < anchor.length; k++) {
136+
if (modifiedLines[j + k] !== anchor[k]) {
137+
match = false;
138+
break;
139+
}
140+
}
141+
if (match) {
142+
// Verify contextAfter follows after the anchor
143+
const afterStart = j + anchor.length;
144+
if (diff.contextAfter.length === 0) {
145+
anchorStart = j;
146+
break;
147+
}
148+
if (afterStart + diff.contextAfter.length <= modifiedLines.length) {
149+
let afterMatch = true;
150+
for (let k = 0; k < diff.contextAfter.length; k++) {
151+
if (modifiedLines[afterStart + k] !== diff.contextAfter[k]) {
152+
afterMatch = false;
153+
break;
154+
}
155+
}
156+
if (afterMatch) {
157+
anchorStart = j;
158+
break;
159+
}
160+
}
161+
}
162+
}
163+
164+
if (anchorStart === -1) {
165+
// Try a looser match: find just the contextBefore
166+
if (diff.contextBefore.length > 0) {
167+
for (let j = 0; j <= modifiedLines.length - 1; j++) {
168+
if (modifiedLines[j] === diff.contextBefore[0]) {
169+
anchorStart = j;
170+
break;
171+
}
172+
}
173+
}
174+
175+
if (anchorStart === -1) {
176+
return null; // Could not reconstruct
177+
}
178+
}
179+
180+
// Reconstruct original:
181+
// Lines before anchor + contextBefore + removedLines + contextAfter + lines after changed region
182+
const beforeAnchor = modifiedLines.slice(0, anchorStart);
183+
const changedEnd = anchorStart + diff.contextBefore.length + diff.addedLines.length + diff.contextAfter.length;
184+
const afterChanged = modifiedLines.slice(changedEnd);
185+
186+
const original: string[] = [...beforeAnchor, ...diff.contextBefore, ...diff.removedLines, ...diff.contextAfter];
187+
188+
// Remove trailing empty line that may have been added by split()
189+
if (afterChanged.length === 1 && afterChanged[0] === "") {
190+
// Modified has trailing newline, will be rejoined naturally
191+
} else if (afterChanged.length > 0) {
192+
original.push(...afterChanged);
193+
}
194+
195+
// Handle trailing newline: if modified content ends with \n, ensure original does too
196+
const result = original.join("\n");
197+
if (modifiedContent.endsWith("\n") && !result.endsWith("\n")) {
198+
return result + "\n";
199+
}
200+
if (!modifiedContent.endsWith("\n") && result.endsWith("\n")) {
201+
return result.slice(0, -1);
202+
}
203+
204+
return result;
205+
}

packages/vscode-ide-companion/src/webview/components/DiffPreview.tsx

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Button } from "@/webview/components/ui/button";
22
import type { FileToolMetadata } from "@/webview/components/bubbles/ToolBubble";
3+
import { wrpc } from "@/webview/wrpc";
34

45
interface DiffPreviewProps {
56
output: string;
@@ -15,6 +16,16 @@ export default function DiffPreview({ metadata, output }: DiffPreviewProps) {
1516
const meta = metadata;
1617
if (!meta?.file_path) return null;
1718

19+
const handleOpenFile = () => {
20+
void wrpc.openFile.mutate({ filePath: meta.file_path!, line: 1 });
21+
};
22+
23+
const handleViewDiff = () => {
24+
if (meta.diff_preview) {
25+
void wrpc.showDiffEditor.mutate({ filePath: meta.file_path!, diffPreview: meta.diff_preview });
26+
}
27+
};
28+
1829
const diffLines = (meta.diff_preview || "")
1930
.split("\n")
2031
.filter((l) => !l.startsWith("--- ") && !l.startsWith("+++ ") && !l.startsWith("@@ "));
@@ -24,14 +35,38 @@ export default function DiffPreview({ metadata, output }: DiffPreviewProps) {
2435
{output && <div className="text-xs text-muted-foreground pl-3">{output.trim()}</div>}
2536

2637
<div className="flex items-center gap-2 text-xs pl-3">
27-
<span className="text-muted-foreground">File</span>
38+
<span className="text-muted-foreground shrink-0">File</span>
2839
<Button
2940
variant="link"
3041
className="text-(--vscode-textLink-foreground) hover:underline cursor-pointer border-none bg-transparent p-0 text-xs truncate"
3142
title={meta.file_path}
43+
onClick={handleOpenFile}
3244
>
3345
{formatDisplayPath(meta.file_path)}
3446
</Button>
47+
48+
<div className="flex items-center gap-1 ml-1 shrink-0">
49+
<Button
50+
variant="secondary"
51+
size="xs"
52+
className="h-5 px-1.5 text-[10px] cursor-pointer"
53+
title="Open file in editor"
54+
onClick={handleOpenFile}
55+
>
56+
Open
57+
</Button>
58+
{meta.diff_preview && (
59+
<Button
60+
variant="secondary"
61+
size="xs"
62+
className="h-5 px-1.5 text-[10px] cursor-pointer"
63+
title="View changes in diff editor"
64+
onClick={handleViewDiff}
65+
>
66+
View Diff
67+
</Button>
68+
)}
69+
</div>
3570
</div>
3671

3772
{diffLines.length > 0 && (

packages/vscode-ide-companion/src/webview/components/bubbles/ToolBubble.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { ChevronDown } from "lucide-react";
77
import { Button } from "@/webview/components/ui/button";
88
import { Tooltip, TooltipContent, TooltipTrigger } from "@/webview/components/ui/tooltip";
99
import ProgressShimmer from "@/webview/components/ProgressShimmer";
10+
import { capitalize } from "@/webview/utils";
1011
export interface ToolBubbleProps {
1112
content: string;
1213
meta?: {
@@ -270,7 +271,7 @@ export default function ToolBubble({
270271
variant="ghost"
271272
className="group w-full flex data-[state=open]:rounded-b-none data-[state=open]:border-muted"
272273
>
273-
<span className="font-medium">{name}</span>
274+
<span className="font-medium">{capitalize(name)}</span>
274275
{paramsMd && (
275276
<Tooltip>
276277
<TooltipTrigger asChild>

0 commit comments

Comments
 (0)