diff --git a/extensions/void/package-lock.json b/extensions/void/package-lock.json index 557fad42..6ebc6476 100644 --- a/extensions/void/package-lock.json +++ b/extensions/void/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.1", "dependencies": { "@anthropic-ai/sdk": "^0.27.1", + "diff-match-patch": "^1.0.5", "diff": "^7.0.0", "ollama": "^0.5.9", "openai": "^4.57.0" @@ -16,6 +17,7 @@ "devDependencies": { "@eslint/js": "^9.9.1", "@types/diff": "^5.2.2", + "@types/diff-match-patch": "^1.0.36", "@types/jest": "^29.5.12", "@types/mocha": "^10.0.8", "@types/node": "^22.5.1", @@ -610,6 +612,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/diff-match-patch": { + "version": "1.0.36", + "resolved": "https://registry.npmjs.org/@types/diff-match-patch/-/diff-match-patch-1.0.36.tgz", + "integrity": "sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg==", + "dev": true + }, "node_modules/@types/estree": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", @@ -2154,6 +2162,11 @@ "node": ">=0.3.1" } }, + "node_modules/diff-match-patch": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz", + "integrity": "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==" + }, "node_modules/diff-sequences": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", diff --git a/extensions/void/package.json b/extensions/void/package.json index ef86a1cb..17c44e1f 100644 --- a/extensions/void/package.json +++ b/extensions/void/package.json @@ -274,11 +274,11 @@ "title": "Show Selection Lens" }, { - "command": "void.approveDiff", + "command": "void.acceptDiff", "title": "Approve Diff" }, { - "command": "void.discardDiff", + "command": "void.rejectDiff", "title": "Discard Diff" }, { @@ -357,6 +357,7 @@ "devDependencies": { "@eslint/js": "^9.9.1", "@types/diff": "^5.2.2", + "@types/diff-match-patch": "^1.0.36", "@types/jest": "^29.5.12", "@types/mocha": "^10.0.8", "@types/node": "^22.5.1", @@ -385,6 +386,7 @@ }, "dependencies": { "@anthropic-ai/sdk": "^0.27.1", + "diff-match-patch": "^1.0.5", "ollama": "^0.5.9", "openai": "^4.57.0", "diff": "^7.0.0" diff --git a/extensions/void/src/ApprovalCodeLensProvider.ts b/extensions/void/src/ApprovalCodeLensProvider.ts deleted file mode 100644 index 80386fa1..00000000 --- a/extensions/void/src/ApprovalCodeLensProvider.ts +++ /dev/null @@ -1,186 +0,0 @@ -import * as vscode from 'vscode'; -import { SuggestedEdit } from './getDiffedLines'; - -// each diff on the user's screen right now -type DiffType = { - diffid: number, - lenses: vscode.CodeLens[], - greenRange: vscode.Range, - originalCode: string, // If a revert happens, we replace the greenRange with this content. -} - -// TODO in theory this should be disposed -const greenDecoration = vscode.window.createTextEditorDecorationType({ - backgroundColor: 'rgba(0 255 51 / 0.2)', - isWholeLine: false, // after: { contentText: ' [original]', color: 'rgba(0 255 60 / 0.5)' } // hoverMessage: originalText // this applies to hovering over after:... -}) - -export class ApprovalCodeLensProvider implements vscode.CodeLensProvider { - - private _diffsOfDocument: { [docUriStr: string]: DiffType[] } = {}; - private _computedLensesOfDocument: { [docUriStr: string]: vscode.CodeLens[] } = {} // computed from diffsOfDocument[docUriStr].lenses - private _diffidPool = 0 - - private _onDidChangeCodeLenses: vscode.EventEmitter = new vscode.EventEmitter(); // signals a UI refresh on .fire() events - - private _weAreEditing: boolean = false - - // used internally by vscode - public readonly onDidChangeCodeLenses: vscode.Event = this._onDidChangeCodeLenses.event; - - - // used internally by vscode - public provideCodeLenses(document: vscode.TextDocument, token: vscode.CancellationToken): vscode.ProviderResult { - const docUriStr = document.uri.toString() - return this._computedLensesOfDocument[docUriStr] - } - - // declared by us, registered with vscode.languages.registerCodeLensProvider() - constructor() { - // this acts as a useEffect. Every time text changes, clear the diffs in this editor - vscode.workspace.onDidChangeTextDocument((e) => { - const editor = vscode.window.activeTextEditor - if (!editor) - return - if (this._weAreEditing) - return - const docUri = editor.document.uri - const docUriStr = docUri.toString() - this._diffsOfDocument[docUriStr].splice(0) // clear diffs - editor.setDecorations(greenDecoration, []) // clear decorations - this._computedLensesOfDocument[docUriStr] = this._diffsOfDocument[docUriStr].flatMap(diff => diff.lenses) // recompute - this._onDidChangeCodeLenses.fire() // refresh - }) - } - - // used by us only - private refreshLenses = (editor: vscode.TextEditor, docUriStr: string) => { - editor.setDecorations(greenDecoration, this._diffsOfDocument[docUriStr].map(diff => diff.greenRange)) // refresh highlighting - this._computedLensesOfDocument[docUriStr] = this._diffsOfDocument[docUriStr].flatMap(diff => diff.lenses) // recompute _computedLensesOfDocument (can optimize this later) - this._onDidChangeCodeLenses.fire() // fire event for vscode to refresh lenses - } - - // used by us only - public async addNewApprovals(editor: vscode.TextEditor, suggestedEdits: SuggestedEdit[]) { - - const docUri = editor.document.uri - const docUriStr = docUri.toString() - - if (!this._diffsOfDocument[docUriStr]) - this._diffsOfDocument[docUriStr] = [] - if (!this._computedLensesOfDocument[docUriStr]) - this._computedLensesOfDocument[docUriStr] = [] - - - // 1. convert suggested edits (which are described using line numbers) into actual edits (described using vscode.Range, vscode.Uri) - // must do this before adding codelenses or highlighting so that codelens and highlights will apply to the fresh code and not the old code - // apply changes in reverse order so additions don't push down the line numbers of the next edit - let workspaceEdit = new vscode.WorkspaceEdit(); - for (let i = suggestedEdits.length - 1; i > -1; i -= 1) { - let suggestedEdit = suggestedEdits[i] - - let greenRange: vscode.Range - - // INSERTIONS (e.g. {originalStartLine: 0, originalEndLine: -1}) - if (suggestedEdit.originalStartLine > suggestedEdit.originalEndLine) { - const originalPosition = new vscode.Position(suggestedEdit.originalStartLine, 0) - workspaceEdit.insert(docUri, originalPosition, suggestedEdit.newContent + '\n') // add back in the line we deleted when we made the startline->endline range go negative - greenRange = new vscode.Range(suggestedEdit.startLine, 0, suggestedEdit.endLine + 1, 0) - } - // DELETIONS - else if (suggestedEdit.startLine > suggestedEdit.endLine) { - const deleteRange = new vscode.Range(suggestedEdit.originalStartLine, 0, suggestedEdit.originalEndLine + 1, 0) - workspaceEdit.delete(docUri, deleteRange) - greenRange = new vscode.Range(suggestedEdit.startLine, 0, suggestedEdit.startLine, 0) - suggestedEdit.originalContent += '\n' // add back in the line we deleted when we made the startline->endline range go negative - } - // REPLACEMENTS - else { - const originalRange = new vscode.Range(suggestedEdit.originalStartLine, 0, suggestedEdit.originalEndLine, Number.MAX_SAFE_INTEGER) - workspaceEdit.replace(docUri, originalRange, suggestedEdit.newContent) - greenRange = new vscode.Range(suggestedEdit.startLine, 0, suggestedEdit.endLine, Number.MAX_SAFE_INTEGER) - } - - this._diffsOfDocument[docUriStr].push({ - diffid: this._diffidPool, - greenRange: greenRange, - originalCode: suggestedEdit.originalContent, - lenses: [ - new vscode.CodeLens(greenRange, { title: 'Accept', command: 'void.approveDiff', arguments: [{ diffid: this._diffidPool }] }), - new vscode.CodeLens(greenRange, { title: 'Reject', command: 'void.discardDiff', arguments: [{ diffid: this._diffidPool }] }) - ] - }); - this._diffidPool += 1 - } - - this._weAreEditing = true - await vscode.workspace.applyEdit(workspaceEdit) - await vscode.workspace.save(docUri) - this._weAreEditing = false - - // refresh - this.refreshLenses(editor, docUriStr) - - console.log('diffs after added:', this._diffsOfDocument[docUriStr]) - } - - // called on void.approveDiff - public async approveDiff({ diffid }: { diffid: number }) { - const editor = vscode.window.activeTextEditor - if (!editor) - return - - const docUri = editor.document.uri - const docUriStr = docUri.toString() - - // get index of this diff in diffsOfDocument - const index = this._diffsOfDocument[docUriStr].findIndex(diff => diff.diffid === diffid); - if (index === -1) { - console.error('Error: DiffID could not be found: ', diffid, this._diffsOfDocument[docUriStr]) - return - } - - // remove this diff from the diffsOfDocument[docStr] (can change this behavior in future if add something like history) - this._diffsOfDocument[docUriStr].splice(index, 1) - - // refresh - this.refreshLenses(editor, docUriStr) - } - - - // called on void.discardDiff - public async discardDiff({ diffid }: { diffid: number }) { - const editor = vscode.window.activeTextEditor - if (!editor) - return - - const docUri = editor.document.uri - const docUriStr = docUri.toString() - - // get index of this diff in diffsOfDocument - const index = this._diffsOfDocument[docUriStr].findIndex(diff => diff.diffid === diffid); - if (index === -1) { - console.error('Void error: DiffID could not be found: ', diffid, this._diffsOfDocument[docUriStr]) - return - } - - const { greenRange: range, lenses, originalCode } = this._diffsOfDocument[docUriStr][index] // do this before we splice and mess up index - - // remove this diff from the diffsOfDocument[docStr] (can change this behavior in future if add something like history) - this._diffsOfDocument[docUriStr].splice(index, 1) - - // clear the decoration in this diffs range - editor.setDecorations(greenDecoration, this._diffsOfDocument[docUriStr].map(diff => diff.greenRange)) - - // REVERT THE CHANGE (this is the only part that's different from approveDiff) - let workspaceEdit = new vscode.WorkspaceEdit(); - workspaceEdit.replace(docUri, range, originalCode); - this._weAreEditing = true - await vscode.workspace.applyEdit(workspaceEdit) - await vscode.workspace.save(docUri) - this._weAreEditing = false - - // refresh - this.refreshLenses(editor, docUriStr) - } -} diff --git a/extensions/void/src/DisplayChangesProvider.ts b/extensions/void/src/DisplayChangesProvider.ts new file mode 100644 index 00000000..4166d5b4 --- /dev/null +++ b/extensions/void/src/DisplayChangesProvider.ts @@ -0,0 +1,315 @@ +import * as vscode from 'vscode'; +import { findDiffs } from './findDiffs'; +import { Diff, BaseDiffArea, BaseDiff, DiffArea } from './shared_types'; + + + +// TODO in theory this should be disposed +const greenDecoration = vscode.window.createTextEditorDecorationType({ + backgroundColor: 'rgba(0 255 51 / 0.2)', + isWholeLine: false, // after: { contentText: ' [original]', color: 'rgba(0 255 60 / 0.5)' } // hoverMessage: originalText // this applies to hovering over after:... +}) + + +// responsible for displaying diffs and showing accept/reject buttons +export class DisplayChangesProvider implements vscode.CodeLensProvider { + + private _diffAreasOfDocument: { [docUriStr: string]: DiffArea[] } = {} + private _diffsOfDocument: { [docUriStr: string]: Diff[] } = {} + + private _diffareaidPool = 0 + private _diffidPool = 0 + private _weAreEditing: boolean = false + + // used internally by vscode + private _onDidChangeCodeLenses: vscode.EventEmitter = new vscode.EventEmitter(); // signals a UI refresh on .fire() events + public readonly onDidChangeCodeLenses: vscode.Event = this._onDidChangeCodeLenses.event; + + // used internally by vscode + public provideCodeLenses(document: vscode.TextDocument, token: vscode.CancellationToken): vscode.ProviderResult { + const docUriStr = document.uri.toString() + return this._diffsOfDocument[docUriStr].flatMap(diff => diff.lenses) + } + + // declared by us, registered with vscode.languages.registerCodeLensProvider() + constructor() { + + console.log('Creating DisplayChangesProvider') + + // this acts as a useEffect. Every time text changes, clear the diffs in this editor + vscode.workspace.onDidChangeTextDocument((e) => { + + const editor = vscode.window.activeTextEditor + + if (!editor) + return + if (this._weAreEditing) + return + + const docUri = editor.document.uri + const docUriStr = docUri.toString() + const diffAreas = this._diffAreasOfDocument[docUriStr] || [] + + // loop through each change + for (const change of e.contentChanges) { + + // here, `change.range` is the range of the original file that gets replaced with `change.text` + + + // compute net number of newlines lines that were added/removed + const numNewLines = (change.text.match(/\n/g) || []).length + const numLineDeletions = change.range.end.line - change.range.start.line + const deltaNewlines = numNewLines - numLineDeletions + + // compute overlap with each diffArea and shrink/elongate the diffArea accordingly + for (const diffArea of diffAreas) { + + // if the change is fully within the diffArea, elongate it by the delta amount of newlines + if (change.range.start.line >= diffArea.startLine && change.range.end.line <= diffArea.endLine) { + diffArea.endLine += deltaNewlines + } + // check if the `diffArea` was fully deleted and remove it if so + if (diffArea.startLine > diffArea.endLine) { + //remove it + const index = diffAreas.findIndex(da => da === diffArea) + diffAreas.splice(index, 1) + } + + // TODO handle other cases where eg. the change overlaps many diffAreas + } + + + // if a diffArea is below the last character of the change, shift the diffArea up/down by the delta amount of newlines + for (const diffArea of diffAreas) { + if (diffArea.startLine > change.range.end.line) { + diffArea.startLine += deltaNewlines + diffArea.endLine += deltaNewlines + } + } + + // TODO merge any diffAreas if they overlap with each other as a result from the shift + + } + + // refresh the diffAreas + this.refreshDiffAreas(docUri) + + }) + } + + + // used by us only + public addDiffArea(uri: vscode.Uri, diffArea: BaseDiffArea) { + + const uriStr = uri.toString() + + // make sure array is defined + if (!this._diffAreasOfDocument[uriStr]) + this._diffAreasOfDocument[uriStr] = [] + + // remove all diffAreas that the new `diffArea` is overlapping with + this._diffAreasOfDocument[uriStr] = this._diffAreasOfDocument[uriStr].filter(da => { + + const noOverlap = da.startLine > diffArea.endLine || da.endLine < diffArea.startLine + + if (!noOverlap) return false + + return true + }) + + // add `diffArea` to storage + this._diffAreasOfDocument[uriStr].push({ + ...diffArea, + diffareaid: this._diffareaidPool + }) + this._diffareaidPool += 1 + } + + + // used by us only + public refreshDiffAreas(docUri: vscode.Uri) { + + const editor = vscode.window.activeTextEditor // TODO the editor should be that of `docUri` and not necessarily the current editor + if (!editor) { + console.log('Error: No active editor!') + return; + } + + const docUriStr = docUri.toString() + const diffAreas = this._diffAreasOfDocument[docUriStr] || [] + + // reset all diffs (we update them below) + this._diffsOfDocument[docUriStr] = [] + + // for each diffArea + for (const diffArea of diffAreas) { + + // get code inside of diffArea + const currentCode = editor.document.getText(new vscode.Range(diffArea.startLine, 0, diffArea.endLine, Number.MAX_SAFE_INTEGER)).replace(/\r\n/g, '\n') + + // compute the diffs + const diffs = findDiffs(diffArea.originalCode, currentCode) + + // print diffs + console.log('!CODEBefore:', JSON.stringify(diffArea.originalCode)) + console.log('!CODEAfter:', JSON.stringify(currentCode)) + + // add the diffs to `this._diffsOfDocument[docUriStr]` + this.addDiffs(editor.document.uri, diffs, diffArea) + + for (const diff of this._diffsOfDocument[docUriStr]) { + console.log('------------') + console.log('deletedCode:', JSON.stringify(diff.deletedCode)) + console.log('insertedCode:', JSON.stringify(diff.insertedCode)) + console.log('deletedRange:', diff.deletedRange.start.line, diff.deletedRange.end.line,) + console.log('insertedRange:', diff.insertedRange.start.line, diff.insertedRange.end.line,) + } + + + } + + // update green highlighting + editor.setDecorations( + greenDecoration, + (this._diffsOfDocument[docUriStr] + .filter(diff => diff.insertedRange !== undefined) + .map(diff => diff.insertedRange) + ) + ); + + // TODO update red highlighting + // this._diffsOfDocument[docUriStr].map(diff => diff.deletedCode) + + // update code lenses + this._onDidChangeCodeLenses.fire() + + } + + // used by us only + public addDiffs(docUri: vscode.Uri, diffs: BaseDiff[], diffArea: DiffArea) { + + const docUriStr = docUri.toString() + + // if no diffs, set diffs to [] + if (!this._diffsOfDocument[docUriStr]) + this._diffsOfDocument[docUriStr] = [] + + // add each diff and its codelens to the document + for (let i = diffs.length - 1; i > -1; i -= 1) { + let suggestedDiff = diffs[i] + + this._diffsOfDocument[docUriStr].push({ + ...suggestedDiff, + diffid: this._diffidPool, + // originalCode: suggestedDiff.deletedText, + lenses: [ + new vscode.CodeLens(suggestedDiff.insertedRange, { title: 'Accept', command: 'void.acceptDiff', arguments: [{ diffid: this._diffidPool, diffareaid: diffArea.diffareaid }] }), + new vscode.CodeLens(suggestedDiff.insertedRange, { title: 'Reject', command: 'void.rejectDiff', arguments: [{ diffid: this._diffidPool, diffareaid: diffArea.diffareaid }] }) + ] + }); + this._diffidPool += 1 + } + + } + + // called on void.acceptDiff + public async acceptDiff({ diffid, diffareaid }: { diffid: number, diffareaid: number }) { + const editor = vscode.window.activeTextEditor + if (!editor) + return + + // get document uri + const docUri = editor.document.uri + const docUriStr = docUri.toString() + + // get relevant diff + // TODO speed up with hashmap + const diffIdx = this._diffsOfDocument[docUriStr].findIndex(diff => diff.diffid === diffid); + if (diffIdx === -1) { + console.error('Error: DiffID could not be found: ', diffid, diffareaid, this._diffsOfDocument[docUriStr], this._diffAreasOfDocument[docUriStr]); return; + } + + // get relevant diffArea + const diffareaIdx = this._diffAreasOfDocument[docUriStr].findIndex(diff => diff.diffareaid === diffareaid); + if (diffareaIdx === -1) { + console.error('Error: DiffAreaID could not be found: ', diffid, diffareaid, this._diffsOfDocument[docUriStr], this._diffAreasOfDocument[docUriStr]); return; + } + + const diff = this._diffsOfDocument[docUriStr][diffIdx] + const diffArea = this._diffAreasOfDocument[docUriStr][diffareaIdx] + + // replace `originalCode[diff.deletedRange]` with diff.insertedCode + // TODO add a history event to undo this change + const originalLines = diffArea.originalCode.split('\n'); + const relativeStart = diff.deletedRange.start.line - diffArea.originalStartLine + const relativeEnd = diff.deletedRange.end.line - diffArea.originalStartLine + diffArea.originalCode = [ + ...originalLines.slice(0, relativeStart), // lines before the deleted range + ...diff.insertedCode.split('\n'), // inserted lines + ...originalLines.slice(relativeEnd + 1) // lines after the deleted range + ].join('\n') + + // if the diffArea has no changes, remove it + const currentDiffAreaCode = editor.document.getText() + .replace(/\r\n/g, '\n') + .split('\n') + .slice(diffArea.startLine, diffArea.endLine + 1) + .join('\n') + if (diffArea.originalCode === currentDiffAreaCode) { // if the currentDiffAreaCode === diffArea.originalCode, remove the diffArea + const index = this._diffAreasOfDocument[docUriStr].findIndex(da => da.diffareaid === diffArea.diffareaid) + this._diffAreasOfDocument[docUriStr].splice(index, 1) + } + + // refresh the diff area + this.refreshDiffAreas(docUri) + } + + + // called on void.rejectDiff + public async rejectDiff({ diffid, diffareaid }: { diffid: number, diffareaid: number }) { + const editor = vscode.window.activeTextEditor + if (!editor) + return + + // get document uri + const docUri = editor.document.uri + const docUriStr = docUri.toString() + + // get relevant diff + // TODO speed up with hashmap + const diffIdx = this._diffsOfDocument[docUriStr].findIndex(diff => diff.diffid === diffid); + if (diffIdx === -1) { + console.error('Error: DiffID could not be found: ', diffid, diffareaid, this._diffsOfDocument[docUriStr], this._diffAreasOfDocument[docUriStr]); return; + } + + // get relevant diffArea + const diffareaIdx = this._diffAreasOfDocument[docUriStr].findIndex(diff => diff.diffareaid === diffareaid); + if (diffareaIdx === -1) { + console.error('Error: DiffAreaID could not be found: ', diffid, diffareaid, this._diffsOfDocument[docUriStr], this._diffAreasOfDocument[docUriStr]); return; + } + + const diff = this._diffsOfDocument[docUriStr][diffIdx] + const diffArea = this._diffAreasOfDocument[docUriStr][diffareaIdx] + + // replace `editorCode[diff.insertedRange]` with diff.deletedCode + const workspaceEdit = new vscode.WorkspaceEdit(); + workspaceEdit.replace(docUri, diff.insertedRange, diff.deletedCode) + this._weAreEditing = true + await vscode.workspace.applyEdit(workspaceEdit) + this._weAreEditing = false + + // if the diffArea has no changes, remove it + const currentDiffAreaCode = editor.document.getText() + .replace(/\r\n/g, '\n') + .split('\n') + .slice(diffArea.startLine, diffArea.endLine + 1) + .join('\n') + if (diffArea.originalCode === currentDiffAreaCode) { // if the currentDiffAreaCode === diffArea.originalCode, remove the diffArea + const index = this._diffAreasOfDocument[docUriStr].findIndex(da => da.diffareaid === diffArea.diffareaid) + this._diffAreasOfDocument[docUriStr].splice(index, 1) + } + + // refresh the diff area + this.refreshDiffAreas(docUri) + } +} diff --git a/extensions/void/src/extension.ts b/extensions/void/src/extension.ts index 895e2f97..0c0541be 100644 --- a/extensions/void/src/extension.ts +++ b/extensions/void/src/extension.ts @@ -1,12 +1,12 @@ import * as vscode from 'vscode'; -import { ChatThreads, WebviewMessage } from './shared_types'; -import { getDiffedLines } from './getDiffedLines'; -import { ApprovalCodeLensProvider } from './ApprovalCodeLensProvider'; +import { DisplayChangesProvider } from './DisplayChangesProvider'; +import { BaseDiffArea, ChatThreads, WebviewMessage } from './shared_types'; import { SidebarWebviewProvider } from './SidebarWebviewProvider'; import { ApiConfig } from './common/sendLLMMessage'; const readFileContentOfUri = async (uri: vscode.Uri) => { - return Buffer.from(await vscode.workspace.fs.readFile(uri)).toString('utf8').replace(/\r\n/g, '\n'); // must remove windows \r or every line will appear different because of it + return Buffer.from(await vscode.workspace.fs.readFile(uri)).toString('utf8') + .replace(/\r\n/g, '\n') // replace windows \r\n with \n } @@ -84,22 +84,22 @@ export function activate(context: vscode.ExtensionContext) { ); // 3. Show an approve/reject codelens above each change - const approvalCodeLensProvider = new ApprovalCodeLensProvider(); - context.subscriptions.push(vscode.languages.registerCodeLensProvider('*', approvalCodeLensProvider)); + const displayChangesProvider = new DisplayChangesProvider(); + context.subscriptions.push(vscode.languages.registerCodeLensProvider('*', displayChangesProvider)); // 4. Add approve/reject commands - context.subscriptions.push(vscode.commands.registerCommand('void.approveDiff', async (params) => { - approvalCodeLensProvider.approveDiff(params) + context.subscriptions.push(vscode.commands.registerCommand('void.acceptDiff', async (params) => { + displayChangesProvider.acceptDiff(params) })); - context.subscriptions.push(vscode.commands.registerCommand('void.discardDiff', async (params) => { - approvalCodeLensProvider.discardDiff(params) + context.subscriptions.push(vscode.commands.registerCommand('void.rejectDiff', async (params) => { + displayChangesProvider.rejectDiff(params) })); context.subscriptions.push(vscode.commands.registerCommand('void.openSettings', async () => { vscode.commands.executeCommand('workbench.action.openSettings', '@ext:void.void'); })); - // 5. + // 5. Receive messages from sidebar webviewProvider.webview.then( webview => { @@ -133,17 +133,38 @@ export function activate(context: vscode.ExtensionContext) { // send contents to webview webview.postMessage({ type: 'files', files, } satisfies WebviewMessage) - } - else if (m.type === 'applyCode') { + } else if (m.type === 'applyChanges') { const editor = vscode.window.activeTextEditor if (!editor) { vscode.window.showInformationMessage('No active editor!') return } - const oldContents = await readFileContentOfUri(editor.document.uri) - const suggestedEdits = getDiffedLines(oldContents, m.code) - await approvalCodeLensProvider.addNewApprovals(editor, suggestedEdits) + + // create an area to show diffs + const diffArea: BaseDiffArea = { + startLine: 0, // in ctrl+L the start and end lines are the full document + endLine: editor.document.lineCount, + originalStartLine: 0, + originalEndLine: editor.document.lineCount, + originalCode: await readFileContentOfUri(editor.document.uri), + } + displayChangesProvider.addDiffArea(editor.document.uri, diffArea) + + + // write new code `m.code` to the document + // TODO update like this: + // this._weAreEditing = true + // await vscode.workspace.applyEdit(workspaceEdit) + // await vscode.workspace.save(docUri) + // this._weAreEditing = false + await editor.edit(editBuilder => { + editBuilder.replace(new vscode.Range(diffArea.startLine, 0, diffArea.endLine, Number.MAX_SAFE_INTEGER), m.code); + }); + + // rediff the changes based on the diffAreas + displayChangesProvider.refreshDiffAreas(editor.document.uri) + } else if (m.type === 'getApiConfig') { const apiConfig = getApiConfig() diff --git a/extensions/void/src/findDiffs.ts b/extensions/void/src/findDiffs.ts new file mode 100644 index 00000000..ee432200 --- /dev/null +++ b/extensions/void/src/findDiffs.ts @@ -0,0 +1,277 @@ + +import * as vscode from 'vscode'; +// import { diffLines, Change } from 'diff'; +import { BaseDiff } from './shared_types'; + +import { diff_match_patch } from 'diff-match-patch'; + + +const diffLines = (text1: string, text2: string) => { + var dmp = new diff_match_patch(); + var a = dmp.diff_linesToChars_(text1, text2); + var lineText1 = a.chars1; + var lineText2 = a.chars2; + var lineArray = a.lineArray; + var diffs = dmp.diff_main(lineText1, lineText2, false); + dmp.diff_charsToLines_(diffs, lineArray); + // dmp.diff_cleanupSemantic(diffs); + return diffs; +} + + +// TODO use a better diff algorithm +export const findDiffs = (oldText: string, newText: string): BaseDiff[] => { + + const diffs = diffLines(oldText, newText); + + const blocks: BaseDiff[] = []; + let reprBlock: string[] = []; + let deletedBlock: string[] = []; + let insertedBlock: string[] = []; + let insertedLine = 0; + let deletedLine = 0; + let insertedStart = 0; + let deletedStart = 0; + + diffs.forEach(([operation, text]) => { + + const lines = text.split('\n'); + + switch (operation) { + + // insertion + case 1: + if (reprBlock.length === 0) { reprBlock.push('@@@@'); } + if (insertedBlock.length === 0) insertedStart = insertedLine; + insertedLine += lines.length - 1; // Update only the line count for new text + insertedBlock.push(text); + reprBlock.push(lines.map(line => `+ ${line}`).join('\n')); + break; + + // deletion + case -1: + if (reprBlock.length === 0) { reprBlock.push('@@@@'); } + if (deletedBlock.length === 0) deletedStart = deletedLine; + deletedLine += lines.length - 1; // Update only the line count for old text + deletedBlock.push(text); + reprBlock.push(lines.map(line => `- ${line}`).join('\n')); + break; + + // no change + case 0: + // If we have a pending block, add it to the blocks array + if (insertedBlock.length > 0 || deletedBlock.length > 0) { + blocks.push({ + code: reprBlock.join(''), + deletedCode: deletedBlock.join(''), + insertedCode: insertedBlock.join(''), + deletedRange: new vscode.Range(deletedStart, 0, deletedLine, Number.MAX_SAFE_INTEGER), + insertedRange: new vscode.Range(insertedStart, 0, insertedLine, Number.MAX_SAFE_INTEGER), + }); + } + + // Reset the block variables + reprBlock = []; + deletedBlock = []; + insertedBlock = []; + + // Update line counts for unchanged text + insertedLine += lines.length - 1; + deletedLine += lines.length - 1; + + break; + } + }); + + // Add any remaining blocks after the loop ends + if (insertedBlock.length > 0 || deletedBlock.length > 0) { + blocks.push({ + code: reprBlock.join(''), + deletedCode: deletedBlock.join(''), + insertedCode: insertedBlock.join(''), + deletedRange: new vscode.Range(deletedStart, 0, deletedLine, Number.MAX_SAFE_INTEGER), + insertedRange: new vscode.Range(insertedStart, 0, insertedLine, Number.MAX_SAFE_INTEGER), + }); + } + + return blocks; +}; + + + +// export const findDiffs = (oldText: string, newText: string): DiffBlock[] => { + +// const diffs = diffLines(oldText, newText); + +// const blocks: DiffBlock[] = []; + +// let reprBlock: string[] = []; +// let deletedBlock: string[] = []; +// let insertedBlock: string[] = []; + +// let insertedEnd = 0; +// let deletedEnd = 0; +// let insertedStart = 0; +// let deletedStart = 0; + +// diffs.forEach(part => { + +// part.count = part.count ?? 0 + +// // if the part is an addition or deletion, add it to the current block +// if (part.added || part.removed) { +// if (reprBlock.length === 0) { reprBlock.push('@@@@'); } +// if (part.added) { +// if (insertedBlock.length === 0) insertedStart = insertedEnd; +// insertedEnd += part.count +// insertedBlock.push(part.value); +// reprBlock.push(part.value.split('\n').map(line => `+ ${line}`).join('\n')); +// } +// if (part.removed) { +// if (deletedBlock.length === 0) deletedStart = deletedEnd; +// deletedEnd += part.count +// deletedBlock.push(part.value); +// reprBlock.push(part.value.split('\n').map(line => `- ${line}`).join('\n')); +// } +// } + +// // if the part is unchanged, finalize the block and add it to the array +// else { +// // if the block is not null, add it to the array +// if (insertedBlock.length > 0 || deletedBlock.length > 0) { +// blocks.push({ +// code: reprBlock.join('\n'), +// deletedCode: deletedBlock.join(''), +// insertedCode: insertedBlock.join(''), +// deletedRange: new vscode.Range(deletedStart, 0, deletedEnd, Number.MAX_SAFE_INTEGER), +// insertedRange: new vscode.Range(insertedStart, 0, insertedEnd, Number.MAX_SAFE_INTEGER), +// }); +// } + +// // update block variables +// reprBlock = []; +// deletedBlock = []; +// insertedBlock = []; +// insertedEnd += part.count; +// deletedEnd += part.count; + +// } + +// }) + +// // finally, add the last block to the array +// if (insertedBlock.length > 0 || deletedBlock.length > 0) { +// blocks.push({ +// code: reprBlock.join('\n'), +// deletedCode: deletedBlock.join(''), +// insertedCode: insertedBlock.join(''), +// deletedRange: new vscode.Range(deletedStart, 0, deletedEnd, Number.MAX_SAFE_INTEGER), +// insertedRange: new vscode.Range(insertedStart, 0, insertedEnd, Number.MAX_SAFE_INTEGER), +// }); +// } + +// return blocks; + +// } + + + + + + + + + + + +// import { diffLines, Change } from 'diff'; + +// export type SuggestedEdit = { +// // start/end of current file +// startLine: number; +// endLine: number; + +// // start/end of original file +// originalStartLine: number, +// originalEndLine: number, + +// // original content (originalfile[originalStart...originalEnd]) +// originalContent: string; +// newContent: string; +// } + +// export function getDiffedLines(oldStr: string, newStr: string) { +// // an ordered list of every original line, line added to the new file, and line removed from the old file (order is unambiguous, think about it) +// const lineByLineChanges: Change[] = diffLines(oldStr, newStr); +// console.debug('Line by line changes', lineByLineChanges) + +// lineByLineChanges.push({ value: '' }) // add a dummy so we flush any streaks we haven't yet at the very end (!line.added && !line.removed) + +// let oldFileLineNum: number = 0; +// let newFileLineNum: number = 0; + +// let streakStartInNewFile: number | undefined = undefined +// let streakStartInOldFile: number | undefined = undefined + +// let oldStrLines = oldStr.split('\n') +// let newStrLines = newStr.split('\n') + +// const replacements: SuggestedEdit[] = [] + +// for (let line of lineByLineChanges) { +// // no change on this line +// if (!line.added && !line.removed) { +// // if we were on a streak, add it +// if (streakStartInNewFile !== undefined) { + +// const startLine = streakStartInNewFile +// const endLine = newFileLineNum - 1 // don't include current line, the edit was up to this line but not including it +// const newContent = newStrLines.slice(startLine, endLine + 1).join('\n') + +// const originalStartLine = streakStartInOldFile! +// const originalEndLine = oldFileLineNum - 1 // don't include current line, the edit was up to this line but not including it +// const originalContent = oldStrLines.slice(originalStartLine, originalEndLine + 1).join('\n') + +// const replacement: SuggestedEdit = { startLine, endLine, newContent, originalStartLine, originalEndLine, originalContent } + +// replacements.push(replacement) +// streakStartInNewFile = undefined +// streakStartInOldFile = undefined +// } + +// oldFileLineNum += line.count ?? 0; +// newFileLineNum += line.count ?? 0; +// } + + +// // line was removed from old file +// else if (line.removed) { + +// // if we weren't on a streak, start one on this current line num +// if (streakStartInNewFile === undefined) { +// streakStartInNewFile = newFileLineNum +// streakStartInOldFile = oldFileLineNum +// } + +// oldFileLineNum += line.count ?? 0 // we processed the line so add 1 +// } + +// // line was added to new file +// else if (line.added) { + +// // if we weren't on a streak, start one on this current line num +// if (streakStartInNewFile === undefined) { +// streakStartInNewFile = newFileLineNum +// streakStartInOldFile = oldFileLineNum +// } + +// newFileLineNum += line.count ?? 0; // we processed the line so add 1 +// } + +// } // end for + +// console.debug('Replacements', replacements) + +// return replacements + +// } \ No newline at end of file diff --git a/extensions/void/src/getDiffedLines.ts b/extensions/void/src/getDiffedLines.ts deleted file mode 100644 index 56fa119e..00000000 --- a/extensions/void/src/getDiffedLines.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { diffLines, Change } from 'diff'; - -export type SuggestedEdit = { - // start/end of current file - startLine: number; - endLine: number; - - // start/end of original file - originalStartLine: number, - originalEndLine: number, - - // original content (originalfile[originalStart...originalEnd]) - originalContent: string; - newContent: string; -} - -export function getDiffedLines(oldStr: string, newStr: string) { - // an ordered list of every original line, line added to the new file, and line removed from the old file (order is unambiguous, think about it) - const lineByLineChanges: Change[] = diffLines(oldStr, newStr); - console.debug('Line by line changes', lineByLineChanges) - - lineByLineChanges.push({ value: '' }) // add a dummy so we flush any streaks we haven't yet at the very end (!line.added && !line.removed) - - let oldFileLineNum: number = 0; - let newFileLineNum: number = 0; - - let streakStartInNewFile: number | undefined = undefined - let streakStartInOldFile: number | undefined = undefined - - let oldStrLines = oldStr.split('\n') - let newStrLines = newStr.split('\n') - - const replacements: SuggestedEdit[] = [] - - for (let line of lineByLineChanges) { - // no change on this line - if (!line.added && !line.removed) { - // if we were on a streak, add it - if (streakStartInNewFile !== undefined) { - - const startLine = streakStartInNewFile - const endLine = newFileLineNum - 1 // don't include current line, the edit was up to this line but not including it - const newContent = newStrLines.slice(startLine, endLine + 1).join('\n') - - const originalStartLine = streakStartInOldFile! - const originalEndLine = oldFileLineNum - 1 // don't include current line, the edit was up to this line but not including it - const originalContent = oldStrLines.slice(originalStartLine, originalEndLine + 1).join('\n') - - const replacement: SuggestedEdit = { startLine, endLine, newContent, originalStartLine, originalEndLine, originalContent } - - replacements.push(replacement) - streakStartInNewFile = undefined - streakStartInOldFile = undefined - } - - oldFileLineNum += line.count ?? 0; - newFileLineNum += line.count ?? 0; - } - - - // line was removed from old file - else if (line.removed) { - - // if we weren't on a streak, start one on this current line num - if (streakStartInNewFile === undefined) { - streakStartInNewFile = newFileLineNum - streakStartInOldFile = oldFileLineNum - } - - oldFileLineNum += line.count ?? 0 // we processed the line so add 1 - } - - // line was added to new file - else if (line.added) { - - // if we weren't on a streak, start one on this current line num - if (streakStartInNewFile === undefined) { - streakStartInNewFile = newFileLineNum - streakStartInOldFile = oldFileLineNum - } - - newFileLineNum += line.count ?? 0; // we processed the line so add 1 - } - - } // end for - - console.debug('Replacements', replacements) - - return replacements - -} diff --git a/extensions/void/src/shared_types.ts b/extensions/void/src/shared_types.ts index bc56ca47..ad2025f1 100644 --- a/extensions/void/src/shared_types.ts +++ b/extensions/void/src/shared_types.ts @@ -2,18 +2,49 @@ import * as vscode from 'vscode'; import { ApiConfig } from './common/sendLLMMessage'; + + // a selection is a frozen snapshot -type Selection = { selectionStr: string, selectionRange: vscode.Range, filePath: vscode.Uri } +type CodeSelection = { selectionStr: string, selectionRange: vscode.Range, filePath: vscode.Uri } type File = { filepath: vscode.Uri, content: string } +// an area that is currently being diffed +type BaseDiffArea = { + // use `startLine` and `endLine` instead of `range` for mutibility + // bounds are relative to the file, inclusive + startLine: number; + endLine: number; + originalStartLine: number, + originalEndLine: number, + originalCode: string, // the original chunk of code (not necessarily the whole file) + // `newCode: string,` is not included because it is the code in the actual file, `document.text()[startline: endLine + 1]` +} + +type DiffArea = BaseDiffArea & { diffareaid: number } + +// the return type of diff creator +type BaseDiff = { + code: string; // representation of the diff in text + deletedRange: vscode.Range; // relative to the file, inclusive + insertedRange: vscode.Range; + deletedCode: string; + insertedCode: string; +} + +// each diff on the user's screen +type Diff = { + diffid: number, + lenses: vscode.CodeLens[], +} & BaseDiff + type WebviewMessage = ( // editor -> sidebar - | { type: 'ctrl+l', selection: Selection } // user presses ctrl+l in the editor + | { type: 'ctrl+l', selection: CodeSelection } // user presses ctrl+l in the editor // sidebar -> editor - | { type: 'applyCode', code: string } // user clicks "apply" in the sidebar + | { type: 'applyChanges', code: string } // user clicks "apply" in the sidebar // sidebar -> editor | { type: 'requestFiles', filepaths: vscode.Uri[] } @@ -44,6 +75,7 @@ type WebviewMessage = ( ) + type Command = WebviewMessage['type'] type ChatThreads = { @@ -59,7 +91,7 @@ type ChatMessage = role: "user"; content: string; // content sent to the llm displayContent: string; // content displayed to user - selection: Selection | null; // the user's selection + selection: CodeSelection | null; // the user's selection files: vscode.Uri[]; // the files sent in the message } | { @@ -69,7 +101,9 @@ type ChatMessage = } export { - Selection, + BaseDiff, BaseDiffArea, + Diff, DiffArea, + CodeSelection, File, WebviewMessage, Command, diff --git a/extensions/void/src/sidebar/Sidebar.tsx b/extensions/void/src/sidebar/Sidebar.tsx index 4092749f..8521d83d 100644 --- a/extensions/void/src/sidebar/Sidebar.tsx +++ b/extensions/void/src/sidebar/Sidebar.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect, useRef, useCallback, FormEvent } from "react" import { ApiConfig, sendLLMMessage } from "../common/sendLLMMessage" -import { ChatMessage, File, Selection, WebviewMessage } from "../shared_types" +import { File, CodeSelection, WebviewMessage, ChatMessage } from "../shared_types" import { awaitVSCodeResponse, getVSCodeAPI, resolveAwaitingVSCodeResponse } from "./getVscodeApi" import { marked } from 'marked'; @@ -21,7 +21,7 @@ ${content} \`\`\``).join('\n') } -const userInstructionsStr = (instructions: string, files: File[], selection: Selection | null) => { +const userInstructionsStr = (instructions: string, files: File[], selection: CodeSelection | null) => { return ` ${filesStr(files)} @@ -112,7 +112,7 @@ const Sidebar = () => { const { allThreads, currentThread, addMessageToHistory, startNewThread, } = useThreads() // state of current message - const [selection, setSelection] = useState(null) // the code the user is selecting + const [selection, setSelection] = useState(null) // the code the user is selecting const [files, setFiles] = useState([]) // the names of the files in the chat const [instructions, setInstructions] = useState('') // the user's instructions @@ -194,7 +194,7 @@ const Sidebar = () => { const newHistoryElt: ChatMessage = { role: 'user', content, displayContent: instructions, selection, files } addMessageToHistory(newHistoryElt) - // send message to claude + // send message to LLM let { abort } = sendLLMMessage({ messages: [...(currentThread?.messages ?? []).map(m => ({ role: m.role, content: m.content })), { role: 'user', content }], onText: (newText, fullText) => setMessageStream(fullText), diff --git a/extensions/void/src/sidebar/getVscodeApi.ts b/extensions/void/src/sidebar/getVscodeApi.ts index 03de615e..9e5172d7 100644 --- a/extensions/void/src/sidebar/getVscodeApi.ts +++ b/extensions/void/src/sidebar/getVscodeApi.ts @@ -5,7 +5,7 @@ import { Command, WebviewMessage } from "../shared_types"; // message -> res[] const awaiting: { [c in Command]: ((res: any) => void)[] } = { "ctrl+l": [], - "applyCode": [], + "applyChanges": [], "requestFiles": [], "files": [], "apiConfig": [], diff --git a/extensions/void/src/sidebar/markdown/BlockCode.tsx b/extensions/void/src/sidebar/markdown/BlockCode.tsx index c2f8aa05..f397c580 100644 --- a/extensions/void/src/sidebar/markdown/BlockCode.tsx +++ b/extensions/void/src/sidebar/markdown/BlockCode.tsx @@ -53,7 +53,7 @@ const BlockCode = ({