-
Notifications
You must be signed in to change notification settings - Fork 377
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: support ai merge conflict (#3531)
* feat: support ai merge contlict * fix: metadata resultcontent * fix: remove message * chore: improve design color * fix: icon * fix: placeholder * chore: improve color token
- Loading branch information
Showing
13 changed files
with
1,509 additions
and
47 deletions.
There are no files selected for viewing
34 changes: 0 additions & 34 deletions
34
packages/ai-native/src/browser/ai-terminal/terminal.feature.registry.ts
This file was deleted.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
310 changes: 310 additions & 0 deletions
310
packages/ai-native/src/browser/merge-conflict/cache-conflicts.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,310 @@ | ||
/* --------------------------------------------------------------------------------------------- | ||
* Copyright (c) Microsoft Corporation. All rights reserved. | ||
* Licensed under the MIT License. See License.txt in the project root for license information. | ||
*--------------------------------------------------------------------------------------------*/ | ||
// Some code copied and modified from https://github.com/microsoft/vscode/blob/main/extensions/merge-conflict/src/mergeConflictParser.ts | ||
|
||
import { Injectable } from '@opensumi/di'; | ||
import { Disposable, uuid } from '@opensumi/ide-core-common'; | ||
import * as monaco from '@opensumi/ide-monaco'; | ||
|
||
import { ICacheDocumentMergeConflict, IDocumentMergeConflictDescriptor, IMergeRegion } from './types'; | ||
|
||
const startHeaderMarker = '<<<<<<<'; | ||
const commonAncestorsMarker = '|||||||'; | ||
const splitterMarker = '======='; | ||
const endFooterMarker = '>>>>>>>'; | ||
|
||
interface IScanMergedConflict { | ||
startHeader: TextLine; | ||
commonAncestors: TextLine[]; | ||
splitter?: TextLine; | ||
endFooter?: TextLine; | ||
} | ||
|
||
export interface IConflictCache { | ||
id: string; | ||
range: monaco.Range; | ||
text: string; | ||
isResolved: boolean; | ||
} | ||
|
||
export class TextLine { | ||
lineNumber: number; | ||
text: string; | ||
range: monaco.Range; | ||
rangeIncludingLineBreak: monaco.Range; | ||
firstNonWhitespaceCharacterIndex: number; | ||
isEmptyOrWhitespace: boolean; | ||
constructor(document: monaco.editor.ITextModel, line: number) { | ||
if (typeof line !== 'number' || line <= 0 || line > document.getLineCount()) { | ||
throw new Error('Illegal value for `line`'); | ||
} | ||
this.text = document.getLineContent(line); | ||
this.firstNonWhitespaceCharacterIndex = /^(\s*)/.exec(this.text)![1].length; | ||
this.range = new monaco.Range(line, 1, line, this.text.length + 1); | ||
this.rangeIncludingLineBreak = | ||
line <= document.getLineCount() ? new monaco.Range(line, 1, line + 1, 1) : this.range; | ||
this.lineNumber = line; | ||
this.isEmptyOrWhitespace = this.firstNonWhitespaceCharacterIndex === this.text.length; | ||
} | ||
} | ||
|
||
// 内置 MergeConflict 插件 以支持AI交互 | ||
@Injectable() | ||
export class CacheConflict extends Disposable { | ||
private _conflictTextCaches = new Map<string, string>(); | ||
|
||
private _conflictRangeCaches = new Map<string, IConflictCache[]>(); | ||
|
||
scanDocument(document: monaco.editor.ITextModel) { | ||
// Scan each line in the document, we already know there is at least a <<<<<<< and | ||
// >>>>>> marker within the document, we need to group these into conflict ranges. | ||
// We initially build a scan match, that references the lines of the header, splitter | ||
// and footer. This is then converted into a full descriptor containing all required | ||
// ranges. | ||
|
||
let currentConflict: IScanMergedConflict | null = null; | ||
const conflictDescriptors: IDocumentMergeConflictDescriptor[] = []; | ||
const cacheConflictDescriptors = this._conflictTextCaches.get(document.uri.toString()); | ||
|
||
for (let i = 0; i < document.getLineCount(); i++) { | ||
const line = new TextLine(document, i + 1); | ||
// Ignore empty lines | ||
if (!line || line.isEmptyOrWhitespace) { | ||
continue; | ||
} | ||
|
||
// Is this a start line? <<<<<<< | ||
if (line.text.startsWith(startHeaderMarker)) { | ||
if (currentConflict !== null) { | ||
// Error, we should not see a startMarker before we've seen an endMarker | ||
currentConflict = null; | ||
|
||
// Give up parsing, anything matched up this to this point will be decorated | ||
// anything after will not | ||
break; | ||
} | ||
|
||
// Create a new conflict starting at this line | ||
currentConflict = { startHeader: line, commonAncestors: [] }; | ||
} | ||
// Are we within a conflict block and is this a common ancestors marker? ||||||| | ||
else if (currentConflict && !currentConflict.splitter && line.text.startsWith(commonAncestorsMarker)) { | ||
currentConflict.commonAncestors.push(line); | ||
} | ||
// Are we within a conflict block and is this a splitter? ======= | ||
else if (currentConflict && !currentConflict.splitter && line.text === splitterMarker) { | ||
currentConflict.splitter = line; | ||
} | ||
// Are we within a conflict block and is this a footer? >>>>>>> | ||
else if (currentConflict && line.text.startsWith(endFooterMarker)) { | ||
currentConflict.endFooter = line; | ||
|
||
// Create a full descriptor from the lines that we matched. This can return | ||
// null if the descriptor could not be completed. | ||
const completeDescriptor = scanItemTolMergeConflictDescriptor(document, currentConflict); | ||
|
||
if (completeDescriptor !== null) { | ||
conflictDescriptors.push(completeDescriptor); | ||
} | ||
|
||
// Reset the current conflict to be empty, so we can match the next | ||
// starting header marker. | ||
currentConflict = null; | ||
} | ||
} | ||
if (!cacheConflictDescriptors && conflictDescriptors.length) { | ||
this._conflictTextCaches.set(document.uri.toString(), document.getValue()); | ||
const conflictRanges: IConflictCache[] = []; | ||
conflictDescriptors.filter(Boolean).forEach((descriptor) => { | ||
const range = descriptor.range; | ||
conflictRanges.push({ | ||
id: uuid(), | ||
range, | ||
text: document.getValueInRange(range), | ||
isResolved: false, | ||
}); | ||
}); | ||
this._conflictRangeCaches.set(document.uri.toString(), conflictRanges); | ||
} | ||
|
||
return conflictDescriptors?.filter(Boolean).map((descriptor) => new DocumentMergeConflict(descriptor)); | ||
} | ||
getConflictText(uri: string) { | ||
return this._conflictTextCaches.get(uri); | ||
} | ||
getAllConflictsByUri(uri: string) { | ||
return this._conflictRangeCaches.get(uri); | ||
} | ||
|
||
getAllConflicts() { | ||
return this._conflictRangeCaches; | ||
} | ||
|
||
setConflictResolved(uri: string, id: string) { | ||
const conflictRanges = this._conflictRangeCaches.get(uri); | ||
if (conflictRanges) { | ||
const conflictRange = conflictRanges.find((item) => item.id === id); | ||
if (conflictRange) { | ||
conflictRange.isResolved = true; | ||
} | ||
} | ||
} | ||
|
||
deleteConflictText(uri: string) { | ||
this._conflictTextCaches.delete(uri); | ||
} | ||
dispose() { | ||
this._conflictTextCaches.clear(); | ||
this._conflictRangeCaches.clear(); | ||
} | ||
} | ||
|
||
function scanItemTolMergeConflictDescriptor( | ||
document: monaco.editor.ITextModel, | ||
scanned: IScanMergedConflict, | ||
): IDocumentMergeConflictDescriptor | null { | ||
// Validate we have all the required lines within the scan item. | ||
if (!scanned.startHeader || !scanned.splitter || !scanned.endFooter) { | ||
return null; | ||
} | ||
|
||
const tokenAfterCurrentBlock: TextLine = scanned.commonAncestors[0] || scanned.splitter; | ||
|
||
// Assume that descriptor.current.header, descriptor.incoming.header and descriptor.splitter | ||
// have valid ranges, fill in content and total ranges from these parts. | ||
// NOTE: We need to shift the decorator range back one character so the splitter does not end up with | ||
// two decoration colors (current and splitter), if we take the new line from the content into account | ||
// the decorator will wrap to the next line. | ||
return { | ||
current: { | ||
header: scanned.startHeader.range, | ||
decoratorContent: new monaco.Range( | ||
scanned.startHeader.rangeIncludingLineBreak.endLineNumber, | ||
scanned.startHeader.rangeIncludingLineBreak.endColumn, | ||
shiftBackOneCharacter( | ||
document, | ||
tokenAfterCurrentBlock.range.getStartPosition(), | ||
scanned.startHeader.rangeIncludingLineBreak.getEndPosition(), | ||
).lineNumber, | ||
shiftBackOneCharacter( | ||
document, | ||
tokenAfterCurrentBlock.range.getStartPosition(), | ||
scanned.startHeader.rangeIncludingLineBreak.getEndPosition(), | ||
).column, | ||
), | ||
// Current content is range between header (shifted for linebreak) and splitter or common ancestors mark start | ||
content: new monaco.Range( | ||
scanned.startHeader.rangeIncludingLineBreak.endLineNumber, | ||
scanned.startHeader.rangeIncludingLineBreak.endColumn, | ||
tokenAfterCurrentBlock.range.startLineNumber, | ||
tokenAfterCurrentBlock.range.startColumn, | ||
), | ||
name: scanned.startHeader.text.substring(startHeaderMarker.length + 1), | ||
}, | ||
commonAncestors: scanned.commonAncestors.map((currentTokenLine, index, commonAncestors) => { | ||
const nextTokenLine = commonAncestors[index + 1] || scanned.splitter; | ||
return { | ||
header: currentTokenLine.range, | ||
decoratorContent: new monaco.Range( | ||
currentTokenLine.rangeIncludingLineBreak.endLineNumber, | ||
currentTokenLine.rangeIncludingLineBreak.endColumn, | ||
|
||
shiftBackOneCharacter( | ||
document, | ||
nextTokenLine.range.getStartPosition(), | ||
currentTokenLine.rangeIncludingLineBreak.getEndPosition(), | ||
).lineNumber, | ||
shiftBackOneCharacter( | ||
document, | ||
nextTokenLine.range.getStartPosition(), | ||
currentTokenLine.rangeIncludingLineBreak.getEndPosition(), | ||
).lineNumber, | ||
), | ||
// Each common ancestors block is range between one common ancestors token | ||
// (shifted for linebreak) and start of next common ancestors token or splitter | ||
content: new monaco.Range( | ||
currentTokenLine.rangeIncludingLineBreak.endLineNumber, | ||
currentTokenLine.rangeIncludingLineBreak.endColumn, | ||
nextTokenLine.range.startLineNumber, | ||
nextTokenLine.range.startColumn, | ||
), | ||
name: currentTokenLine.text.substring(commonAncestorsMarker.length + 1), | ||
}; | ||
}), | ||
splitter: scanned.splitter.range, | ||
incoming: { | ||
header: scanned.endFooter.range, | ||
decoratorContent: new monaco.Range( | ||
scanned.splitter.rangeIncludingLineBreak.endLineNumber, | ||
scanned.splitter.rangeIncludingLineBreak.endColumn, | ||
shiftBackOneCharacter( | ||
document, | ||
scanned.endFooter.range.getStartPosition(), | ||
scanned.splitter.rangeIncludingLineBreak.getEndPosition(), | ||
).lineNumber, | ||
shiftBackOneCharacter( | ||
document, | ||
scanned.endFooter.range.getStartPosition(), | ||
scanned.splitter.rangeIncludingLineBreak.getEndPosition(), | ||
).column, | ||
), | ||
// Incoming content is range between splitter (shifted for linebreak) and footer start | ||
content: new monaco.Range( | ||
scanned.splitter.rangeIncludingLineBreak.endLineNumber, | ||
scanned.splitter.rangeIncludingLineBreak.endColumn, | ||
scanned.endFooter.range.startLineNumber, | ||
scanned.endFooter.range.startColumn, | ||
), | ||
name: scanned.endFooter.text.substring(endFooterMarker.length + 1), | ||
}, | ||
// Entire range is between current header start and incoming header end (including line break) | ||
range: new monaco.Range( | ||
scanned.startHeader.range.startLineNumber, | ||
scanned.startHeader.range.startColumn, | ||
scanned.endFooter.range.endLineNumber, | ||
scanned.endFooter.range.endColumn, | ||
), | ||
}; | ||
} | ||
|
||
function shiftBackOneCharacter( | ||
document: monaco.editor.ITextModel, | ||
range: monaco.Position, | ||
unlessEqual: monaco.Position, | ||
): monaco.Position { | ||
if (range.equals(unlessEqual)) { | ||
return range; | ||
} | ||
|
||
let line = range.lineNumber; | ||
let character = range.column - 1; | ||
|
||
if (character < 0) { | ||
line--; | ||
character = new TextLine(document, line).range.endColumn; | ||
} | ||
return new monaco.Position(line, character); | ||
} | ||
|
||
export class DocumentMergeConflict implements ICacheDocumentMergeConflict { | ||
public range: monaco.Range; | ||
public current: IMergeRegion; | ||
public incoming: IMergeRegion; | ||
public commonAncestors: IMergeRegion[]; | ||
public splitter: monaco.Range; | ||
public incomingContent: string; | ||
public currentContent: string; | ||
public bothContent: string; | ||
public aiContent?: string; | ||
public defaultContent: string; | ||
private applied = false; | ||
constructor(descriptor: IDocumentMergeConflictDescriptor) { | ||
this.range = descriptor.range; | ||
this.current = descriptor.current; | ||
this.incoming = descriptor.incoming; | ||
this.commonAncestors = descriptor.commonAncestors; | ||
this.splitter = descriptor.splitter; | ||
} | ||
} |
Oops, something went wrong.
9ff7278
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Release Candidate Summary:
Released 🚀 2.27.3-rc-1713171335.0
user input ref: main