vzcode 1.44.0 → 1.46.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{buildWorker-CRLuRnCr.js → buildWorker-C_tGhWXG.js} +135 -74
- package/dist/assets/index-CC0CUUDi.js +457 -0
- package/dist/assets/{index-Beqaj63x.css → index-CQyHWTWE.css} +1 -1
- package/dist/assets/worker-CekYspLa.js +453 -0
- package/dist/index.html +2 -2
- package/package.json +13 -7
- package/src/client/CodeEditor/{getOrCreateEditor.ts → getOrCreateEditor.tsx} +129 -1
- package/src/client/CodeEditor/index.tsx +11 -4
- package/src/client/Icons/SparklesSVG.tsx +9 -3
- package/src/client/RunCodeWidget/index.tsx +18 -15
- package/src/client/SplitPaneResizeContext.tsx +26 -108
- package/src/client/VZCodeContext.tsx +15 -1
- package/src/client/VZRight.tsx +32 -4
- package/src/client/VZSidebar/AIChat/ChatInput.tsx +80 -10
- package/src/client/VZSidebar/AIChat/DiffView.scss +154 -0
- package/src/client/VZSidebar/AIChat/DiffView.tsx +133 -0
- package/src/client/VZSidebar/AIChat/Message.tsx +24 -0
- package/src/client/VZSidebar/AIChat/MessageList.tsx +134 -12
- package/src/client/VZSidebar/AIChat/ThinkingScratchpad.tsx +37 -0
- package/src/client/VZSidebar/AIChat/index.tsx +155 -17
- package/src/client/VZSidebar/AIChat/styles.scss +223 -1
- package/src/client/VZSidebar/aiCopyPaste.ts +15 -2
- package/src/client/VZSidebar/index.tsx +10 -2
- package/src/client/bootstrap.ts +11 -1
- package/src/client/featureFlags.ts +2 -0
- package/src/client/useActions.ts +12 -0
- package/src/client/usePrettier/index.ts +1 -3
- package/src/client/vzReducer/aiChatReducer.ts +13 -0
- package/src/client/vzReducer/createInitialState.ts +1 -0
- package/src/client/vzReducer/index.ts +9 -0
- package/src/client/vzReducer/searchReducer.test.ts +44 -2
- package/src/runCode.ts +5 -17
- package/src/server/aiChatHandler/aiEditing.ts +56 -0
- package/src/server/aiChatHandler/chatOperations.ts +78 -1
- package/src/server/aiChatHandler/index.ts +33 -9
- package/src/server/aiChatHandler/llmStreaming.ts +101 -19
- package/src/server/computeInitialDocument.ts +25 -6
- package/src/server/generateAIResponse.ts +4 -0
- package/src/server/index.ts +83 -2
- package/src/utils/fileDiff.ts +114 -0
- package/dist/assets/index-C46Hp5ym.js +0 -340
- package/dist/assets/worker-DkYJN5WQ.js +0 -453
|
@@ -3,7 +3,9 @@ import {
|
|
|
3
3
|
StreamingMarkdownParser,
|
|
4
4
|
} from 'llm-code-format';
|
|
5
5
|
import { ChatOpenAI } from '@langchain/openai';
|
|
6
|
+
import OpenAI from 'openai';
|
|
6
7
|
import fs from 'fs';
|
|
8
|
+
import { generateRunId } from '@vizhub/viz-utils';
|
|
7
9
|
import {
|
|
8
10
|
updateAIStatus,
|
|
9
11
|
createAIMessage,
|
|
@@ -13,8 +15,12 @@ import {
|
|
|
13
15
|
clearFileContent,
|
|
14
16
|
appendLineToFile,
|
|
15
17
|
updateFiles,
|
|
18
|
+
updateAIScratchpad,
|
|
16
19
|
} from './chatOperations.js';
|
|
17
20
|
import { mergeFileChanges } from 'editcodewithai';
|
|
21
|
+
import { diff } from '../../ot.js';
|
|
22
|
+
import { VizChatId, VizContent } from '@vizhub/viz-types';
|
|
23
|
+
import { ShareDBDoc } from '../../types.js';
|
|
18
24
|
|
|
19
25
|
const DEBUG = false;
|
|
20
26
|
|
|
@@ -34,28 +40,30 @@ const DEBUG = false;
|
|
|
34
40
|
const enableStreamingEditing = false;
|
|
35
41
|
|
|
36
42
|
/**
|
|
37
|
-
* Creates and configures the LLM function for streaming
|
|
43
|
+
* Creates and configures the LLM function for streaming with reasoning tokens
|
|
38
44
|
*/
|
|
39
45
|
export const createLLMFunction = ({
|
|
40
46
|
shareDBDoc,
|
|
41
47
|
createVizBotLocalPresence,
|
|
42
48
|
chatId,
|
|
49
|
+
}: {
|
|
50
|
+
shareDBDoc: ShareDBDoc<VizContent>;
|
|
51
|
+
createVizBotLocalPresence: () => any;
|
|
52
|
+
chatId: VizChatId;
|
|
43
53
|
}) => {
|
|
44
54
|
return async (fullPrompt: string) => {
|
|
45
55
|
const localPresence = createVizBotLocalPresence();
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
},
|
|
56
|
+
|
|
57
|
+
// Create OpenRouter client for reasoning token support
|
|
58
|
+
const openRouterClient = new OpenAI({
|
|
59
|
+
apiKey: process.env.VIZHUB_EDIT_WITH_AI_API_KEY,
|
|
60
|
+
baseURL:
|
|
61
|
+
process.env.VIZHUB_EDIT_WITH_AI_BASE_URL ||
|
|
62
|
+
'https://openrouter.ai/api/v1',
|
|
63
|
+
defaultHeaders: {
|
|
64
|
+
'HTTP-Referer': 'https://vizhub.com',
|
|
65
|
+
'X-Title': 'VizHub',
|
|
57
66
|
},
|
|
58
|
-
streaming: true,
|
|
59
67
|
});
|
|
60
68
|
|
|
61
69
|
let fullContent = '';
|
|
@@ -177,12 +185,58 @@ export const createLLMFunction = ({
|
|
|
177
185
|
const parser = new StreamingMarkdownParser(callbacks);
|
|
178
186
|
|
|
179
187
|
const chunks = [];
|
|
188
|
+
let reasoningContent = '';
|
|
189
|
+
|
|
190
|
+
// Stream the response with reasoning tokens
|
|
191
|
+
const modelName =
|
|
192
|
+
process.env.VIZHUB_EDIT_WITH_AI_MODEL_NAME ||
|
|
193
|
+
'anthropic/claude-3.5-sonnet';
|
|
194
|
+
const stream = await (
|
|
195
|
+
openRouterClient.chat.completions.create as any
|
|
196
|
+
)({
|
|
197
|
+
model: modelName,
|
|
198
|
+
messages: [{ role: 'user', content: fullPrompt }],
|
|
199
|
+
max_tokens: 8192,
|
|
200
|
+
reasoning: {
|
|
201
|
+
effort: 'medium',
|
|
202
|
+
exclude: false,
|
|
203
|
+
},
|
|
204
|
+
usage: { include: true },
|
|
205
|
+
stream: true,
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
let reasoningStarted = false;
|
|
209
|
+
let contentStarted = false;
|
|
180
210
|
|
|
181
|
-
// Stream the response
|
|
182
|
-
const stream = await chatModel.stream(fullPrompt);
|
|
183
211
|
for await (const chunk of stream) {
|
|
184
|
-
|
|
185
|
-
|
|
212
|
+
const delta = chunk.choices[0]?.delta as any; // Type assertion for OpenRouter-specific reasoning fields
|
|
213
|
+
|
|
214
|
+
if (delta?.reasoning) {
|
|
215
|
+
// Handle reasoning tokens (thinking)
|
|
216
|
+
if (!reasoningStarted) {
|
|
217
|
+
reasoningStarted = true;
|
|
218
|
+
updateAIStatus(shareDBDoc, chatId, 'Thinking...');
|
|
219
|
+
}
|
|
220
|
+
reasoningContent += delta.reasoning;
|
|
221
|
+
updateAIScratchpad(
|
|
222
|
+
shareDBDoc,
|
|
223
|
+
chatId,
|
|
224
|
+
reasoningContent,
|
|
225
|
+
);
|
|
226
|
+
} else if (delta?.content) {
|
|
227
|
+
// Handle regular content tokens
|
|
228
|
+
if (reasoningStarted && !contentStarted) {
|
|
229
|
+
// Clear reasoning when content starts
|
|
230
|
+
contentStarted = true;
|
|
231
|
+
updateAIScratchpad(shareDBDoc, chatId, '');
|
|
232
|
+
updateAIStatus(
|
|
233
|
+
shareDBDoc,
|
|
234
|
+
chatId,
|
|
235
|
+
'Generating response...',
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const chunkContent = delta.content;
|
|
186
240
|
chunks.push(chunkContent);
|
|
187
241
|
|
|
188
242
|
if (enableStreamingEditing) {
|
|
@@ -196,14 +250,20 @@ export const createLLMFunction = ({
|
|
|
196
250
|
fullContent,
|
|
197
251
|
);
|
|
198
252
|
}
|
|
253
|
+
} else if (chunk.usage) {
|
|
254
|
+
// Handle usage information
|
|
255
|
+
DEBUG && console.log('Usage:', chunk.usage);
|
|
199
256
|
}
|
|
200
257
|
|
|
201
|
-
if (!generationId && chunk.
|
|
202
|
-
generationId = chunk.
|
|
258
|
+
if (!generationId && chunk.id) {
|
|
259
|
+
generationId = chunk.id;
|
|
203
260
|
}
|
|
204
261
|
}
|
|
205
262
|
await parser.flushRemaining();
|
|
206
263
|
reportFileEdited();
|
|
264
|
+
|
|
265
|
+
// Final cleanup - clear scratchpad and set final status
|
|
266
|
+
updateAIScratchpad(shareDBDoc, chatId, '');
|
|
207
267
|
updateAIStatus(shareDBDoc, chatId, 'Done editing.');
|
|
208
268
|
updateAIMessageContent(
|
|
209
269
|
shareDBDoc,
|
|
@@ -242,6 +302,28 @@ export const createLLMFunction = ({
|
|
|
242
302
|
);
|
|
243
303
|
}
|
|
244
304
|
|
|
305
|
+
// Generate a new runId to trigger a run when AI finishes editing
|
|
306
|
+
// This will trigger a re-run without hot reloading
|
|
307
|
+
const newRunId = generateRunId();
|
|
308
|
+
const runIdOp = diff(shareDBDoc.data, {
|
|
309
|
+
...shareDBDoc.data,
|
|
310
|
+
runId: newRunId,
|
|
311
|
+
});
|
|
312
|
+
shareDBDoc.submitOp(runIdOp, (error) => {
|
|
313
|
+
if (error) {
|
|
314
|
+
console.warn(
|
|
315
|
+
'Error setting runId after AI editing:',
|
|
316
|
+
error,
|
|
317
|
+
);
|
|
318
|
+
} else {
|
|
319
|
+
DEBUG &&
|
|
320
|
+
console.log(
|
|
321
|
+
'Set new runId after AI editing:',
|
|
322
|
+
newRunId,
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
|
|
245
327
|
// Write chunks file for debugging
|
|
246
328
|
if (DEBUG) {
|
|
247
329
|
const chunksFileJSONpath = `./ai-chunks-${chatId}.json`;
|
|
@@ -10,6 +10,15 @@ import createIgnore from 'ignore';
|
|
|
10
10
|
import { ignoreFilePattern, baseIgnore } from './config.js';
|
|
11
11
|
import { isDirectory } from './isDirectory.js';
|
|
12
12
|
|
|
13
|
+
// Import the image file utility
|
|
14
|
+
const isImageFile = (fileName) => {
|
|
15
|
+
return (
|
|
16
|
+
fileName.match(
|
|
17
|
+
/\.(png|jpg|jpeg|gif|bmp|svg|webp)$/i,
|
|
18
|
+
) !== null
|
|
19
|
+
);
|
|
20
|
+
};
|
|
21
|
+
|
|
13
22
|
/**
|
|
14
23
|
* @param {string} fullPath - absolut path of the workspace root
|
|
15
24
|
* @param {string} currentDirectoryPath - path where the ignore file is found, relative to fullPath
|
|
@@ -180,13 +189,23 @@ export const computeInitialDocument = ({ fullPath }) => {
|
|
|
180
189
|
|
|
181
190
|
files.forEach((file) => {
|
|
182
191
|
const id = randomId();
|
|
192
|
+
let text = null;
|
|
193
|
+
|
|
194
|
+
if (!isDirectory(file)) {
|
|
195
|
+
const filePath = path.join(fullPath, file);
|
|
196
|
+
|
|
197
|
+
if (isImageFile(file)) {
|
|
198
|
+
// Read image files as binary and convert to base64
|
|
199
|
+
const buffer = fs.readFileSync(filePath);
|
|
200
|
+
text = buffer.toString('base64');
|
|
201
|
+
} else {
|
|
202
|
+
// Read non-image files as UTF-8 text
|
|
203
|
+
text = fs.readFileSync(filePath, 'utf-8');
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
183
207
|
initialDocument.files[id] = {
|
|
184
|
-
text
|
|
185
|
-
? null
|
|
186
|
-
: fs.readFileSync(
|
|
187
|
-
path.join(fullPath, file),
|
|
188
|
-
'utf-8',
|
|
189
|
-
),
|
|
208
|
+
text,
|
|
190
209
|
name: file,
|
|
191
210
|
};
|
|
192
211
|
});
|
|
@@ -150,6 +150,10 @@ export const generateAIResponse = async ({
|
|
|
150
150
|
// model: 'gpt-3.5-turbo',
|
|
151
151
|
model: 'gpt-4o',
|
|
152
152
|
messages,
|
|
153
|
+
reasoning: {
|
|
154
|
+
effort: 'medium',
|
|
155
|
+
exclude: false,
|
|
156
|
+
} as any, // Type assertion for OpenRouter-specific reasoning parameter
|
|
153
157
|
stream: true,
|
|
154
158
|
});
|
|
155
159
|
|
package/src/server/index.ts
CHANGED
|
@@ -14,10 +14,20 @@ import { computeInitialDocument } from './computeInitialDocument.js';
|
|
|
14
14
|
import { handleAIAssist } from './handleAIAssist.js';
|
|
15
15
|
import { handleAICopilot } from './handleAICopilot.js';
|
|
16
16
|
import { handleAIChatMessage } from './handleAIChatMessage.js';
|
|
17
|
+
import { undoAIEdit } from './aiChatHandler/chatOperations.js';
|
|
17
18
|
import { isDirectory } from './isDirectory.js';
|
|
18
19
|
import { createToken } from './livekit.js';
|
|
19
20
|
import './setupEnv.js';
|
|
20
21
|
|
|
22
|
+
// Import the image file utility
|
|
23
|
+
const isImageFile = (fileName) => {
|
|
24
|
+
return (
|
|
25
|
+
fileName.match(
|
|
26
|
+
/\.(png|jpg|jpeg|gif|bmp|svg|webp)$/i,
|
|
27
|
+
) !== null
|
|
28
|
+
);
|
|
29
|
+
};
|
|
30
|
+
|
|
21
31
|
// The time in milliseconds by which auto-saving is debounced.
|
|
22
32
|
const autoSaveDebounceTimeMS = 800;
|
|
23
33
|
|
|
@@ -132,6 +142,57 @@ app.post(
|
|
|
132
142
|
}),
|
|
133
143
|
);
|
|
134
144
|
|
|
145
|
+
// Handle AI Chat Undo requests.
|
|
146
|
+
app.post(
|
|
147
|
+
'/ai-chat-undo',
|
|
148
|
+
bodyParser.json(),
|
|
149
|
+
async (req, res) => {
|
|
150
|
+
const { chatId, messageId } = req.body;
|
|
151
|
+
|
|
152
|
+
if (!chatId || !messageId) {
|
|
153
|
+
return res
|
|
154
|
+
.status(400)
|
|
155
|
+
.json({ error: 'Missing chatId or messageId' });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
const chat = shareDBDoc.data.chats?.[chatId];
|
|
160
|
+
if (!chat) {
|
|
161
|
+
return res
|
|
162
|
+
.status(404)
|
|
163
|
+
.json({ error: 'Chat not found' });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const message = chat.messages.find(
|
|
167
|
+
(msg) => msg.id === messageId,
|
|
168
|
+
);
|
|
169
|
+
if (
|
|
170
|
+
!message ||
|
|
171
|
+
message.role !== 'assistant' ||
|
|
172
|
+
!message.beforeFiles
|
|
173
|
+
) {
|
|
174
|
+
return res
|
|
175
|
+
.status(400)
|
|
176
|
+
.json({ error: 'Invalid message for undo' });
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
undoAIEdit(
|
|
180
|
+
shareDBDoc,
|
|
181
|
+
chatId,
|
|
182
|
+
messageId,
|
|
183
|
+
message.beforeFiles,
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
res.status(200).json({ success: true });
|
|
187
|
+
} catch (error) {
|
|
188
|
+
console.error('Error undoing AI edit:', error);
|
|
189
|
+
res
|
|
190
|
+
.status(500)
|
|
191
|
+
.json({ error: 'Failed to undo edit' });
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
);
|
|
195
|
+
|
|
135
196
|
// Livekit Token Generator
|
|
136
197
|
app.get('/livekit-token', async (req, res) => {
|
|
137
198
|
const { room, username } = req.query;
|
|
@@ -161,7 +222,17 @@ const save = () => {
|
|
|
161
222
|
if (previous && current) {
|
|
162
223
|
// Handle changing of text content.
|
|
163
224
|
if (previous.text !== current.text) {
|
|
164
|
-
|
|
225
|
+
if (isImageFile(current.name)) {
|
|
226
|
+
// Write image files as binary from base64
|
|
227
|
+
const buffer = Buffer.from(
|
|
228
|
+
current.text,
|
|
229
|
+
'base64',
|
|
230
|
+
);
|
|
231
|
+
fs.writeFileSync(current.name, buffer);
|
|
232
|
+
} else {
|
|
233
|
+
// Write non-image files as text
|
|
234
|
+
fs.writeFileSync(current.name, current.text);
|
|
235
|
+
}
|
|
165
236
|
}
|
|
166
237
|
|
|
167
238
|
// // Handle renaming files.
|
|
@@ -256,7 +327,17 @@ const save = () => {
|
|
|
256
327
|
if (!previous && current) {
|
|
257
328
|
//File Creation
|
|
258
329
|
if (!isDirectory(current.name)) {
|
|
259
|
-
|
|
330
|
+
if (isImageFile(current.name)) {
|
|
331
|
+
// Write image files as binary from base64
|
|
332
|
+
const buffer = Buffer.from(
|
|
333
|
+
current.text,
|
|
334
|
+
'base64',
|
|
335
|
+
);
|
|
336
|
+
fs.writeFileSync(current.name, buffer);
|
|
337
|
+
} else {
|
|
338
|
+
// Write non-image files as text
|
|
339
|
+
fs.writeFileSync(current.name, current.text);
|
|
340
|
+
}
|
|
260
341
|
} else {
|
|
261
342
|
fs.mkdirSync(current.name, { recursive: true });
|
|
262
343
|
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { createTwoFilesPatch } from 'diff';
|
|
2
|
+
import { VizFiles, VizFileId } from '@vizhub/viz-types';
|
|
3
|
+
|
|
4
|
+
export interface UnifiedFilesDiff {
|
|
5
|
+
[fileId: VizFileId]: string; // Unified diff string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Generate a unified diff for a single file using the diff library
|
|
10
|
+
*/
|
|
11
|
+
export function generateFileUnifiedDiff(
|
|
12
|
+
fileId: VizFileId,
|
|
13
|
+
fileName: string,
|
|
14
|
+
beforeContent: string,
|
|
15
|
+
afterContent: string,
|
|
16
|
+
): string {
|
|
17
|
+
// Only generate diff if there are actual changes
|
|
18
|
+
if (beforeContent === afterContent) {
|
|
19
|
+
return '';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Use the diff library's createTwoFilesPatch function to generate unified diff
|
|
23
|
+
const unifiedDiff = createTwoFilesPatch(
|
|
24
|
+
fileName,
|
|
25
|
+
fileName,
|
|
26
|
+
beforeContent,
|
|
27
|
+
afterContent,
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
return unifiedDiff;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Generate unified diffs for multiple files
|
|
35
|
+
*/
|
|
36
|
+
export function generateFilesUnifiedDiff(
|
|
37
|
+
beforeFiles: VizFiles,
|
|
38
|
+
afterFiles: VizFiles,
|
|
39
|
+
): UnifiedFilesDiff {
|
|
40
|
+
const result: UnifiedFilesDiff = {};
|
|
41
|
+
const allFileIds = new Set([
|
|
42
|
+
...Object.keys(beforeFiles),
|
|
43
|
+
...Object.keys(afterFiles),
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
for (const fileId of allFileIds) {
|
|
47
|
+
const beforeFile = beforeFiles[fileId];
|
|
48
|
+
const afterFile = afterFiles[fileId];
|
|
49
|
+
|
|
50
|
+
const beforeContent = beforeFile?.text || '';
|
|
51
|
+
const afterContent = afterFile?.text || '';
|
|
52
|
+
const fileName =
|
|
53
|
+
afterFile?.name || beforeFile?.name || fileId;
|
|
54
|
+
|
|
55
|
+
const unifiedDiff = generateFileUnifiedDiff(
|
|
56
|
+
fileId,
|
|
57
|
+
fileName,
|
|
58
|
+
beforeContent,
|
|
59
|
+
afterContent,
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
// Only include files that have changes
|
|
63
|
+
if (unifiedDiff) {
|
|
64
|
+
result[fileId] = unifiedDiff;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return result;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Create a snapshot of current files for diff comparison
|
|
73
|
+
*/
|
|
74
|
+
export function createFilesSnapshot(
|
|
75
|
+
files: VizFiles,
|
|
76
|
+
): VizFiles {
|
|
77
|
+
return JSON.parse(JSON.stringify(files));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Parse unified diff to extract basic statistics
|
|
82
|
+
*/
|
|
83
|
+
export function parseUnifiedDiffStats(
|
|
84
|
+
unifiedDiff: string,
|
|
85
|
+
): {
|
|
86
|
+
additions: number;
|
|
87
|
+
deletions: number;
|
|
88
|
+
} {
|
|
89
|
+
const lines = unifiedDiff.split('\n');
|
|
90
|
+
let additions = 0;
|
|
91
|
+
let deletions = 0;
|
|
92
|
+
|
|
93
|
+
for (const line of lines) {
|
|
94
|
+
if (line.startsWith('+') && !line.startsWith('+++')) {
|
|
95
|
+
additions++;
|
|
96
|
+
} else if (
|
|
97
|
+
line.startsWith('-') &&
|
|
98
|
+
!line.startsWith('---')
|
|
99
|
+
) {
|
|
100
|
+
deletions++;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return { additions, deletions };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Combine multiple unified diffs into a single diff string
|
|
109
|
+
*/
|
|
110
|
+
export function combineUnifiedDiffs(
|
|
111
|
+
unifiedDiffs: UnifiedFilesDiff,
|
|
112
|
+
): string {
|
|
113
|
+
return Object.values(unifiedDiffs).join('\n');
|
|
114
|
+
}
|