Merge pull request #6589 from continuedev/jacob/enhancement/nextedit-polish

enhancement: Improve Next Edit DX
This commit is contained in:
Jacob Kim
2025-07-15 13:09:27 -07:00
committed by GitHub
8 changed files with 946 additions and 101 deletions

View File

@@ -9,9 +9,7 @@
* creating a render of it.
*/
import {
transformerMetaHighlight,
transformerNotationDiff,
transformerNotationFocus,
transformerNotationHighlight,
} from "@shikijs/transformers";
import { JSDOM } from "jsdom";
@@ -21,6 +19,7 @@ import {
getSingletonHighlighter,
Highlighter,
} from "shiki";
import { DiffLine } from "..";
import { escapeForSVG, kebabOfStr } from "../util/text";
interface CodeRendererOptions {
@@ -37,6 +36,10 @@ interface HTMLOptions {
interface ConversionOptions extends HTMLOptions {
transparent?: boolean;
imageType: "svg";
fontSize: number;
fontFamily: string;
dimensions: Dimensions;
lineHeight: number;
}
interface Dimensions {
@@ -166,83 +169,102 @@ export class CodeRenderer {
code: string,
language: string = "javascript",
currLineOffsetFromTop: number,
newDiffLines: DiffLine[],
): Promise<string> {
const annotatedCode = code
.split("\n")
.map((line, i) =>
i === currLineOffsetFromTop
? line + " \/\/ \[\!code highlight\]"
: line,
)
.join("\n");
const lines = code.split("\n");
const newDiffLineMap = new Set();
if (newDiffLines) {
newDiffLines.forEach((diffLine) => {
if (diffLine.type === "new") {
newDiffLineMap.add(diffLine.line);
}
});
}
const annotatedLines = [];
// NOTE: Shiki's preprocessor deletes transformer annotations when applied to an empty line.
// If you are transforming an empty line, make sure that
// the transformation is applied to a non-empty line first.
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Add highlight comment before target line.
if (i + 1 === currLineOffsetFromTop && currLineOffsetFromTop >= 0) {
annotatedLines.push("// [!code highlight:1]");
}
// Handle diff lines
if (newDiffLineMap.has(line)) {
if (line.trim() === "") {
// For empty lines, add the magic comment on a separate line before.
annotatedLines.push("// [!code ++]");
annotatedLines.push(line); // The empty line itself.
} else {
// For non-empty lines, append the magic comment.
annotatedLines.push(line + "// [!code ++]");
}
newDiffLineMap.delete(line);
} else {
annotatedLines.push(line);
}
}
const annotatedCode = annotatedLines.join("\n");
await this.highlighter!.loadLanguage(language as BundledLanguage);
return this.highlighter!.codeToHtml(annotatedCode, {
lang: language,
theme: this.currentTheme,
transformers: [
// transformerColorizedBrackets(),
transformerMetaHighlight(),
transformerNotationHighlight(),
transformerNotationDiff(),
transformerNotationFocus(),
],
transformers: [transformerNotationHighlight(), transformerNotationDiff()],
});
}
async convertToSVG(
code: string,
language: string = "javascript",
fontSize: number,
fontFamily: string,
dimensions: Dimensions,
lineHeight: number,
options: ConversionOptions,
currLineOffsetFromTop: number,
newDiffLines: DiffLine[],
): Promise<Buffer> {
const strokeWidth = 1;
const highlightedCodeHtml = await this.highlightCode(
code,
language,
currLineOffsetFromTop,
newDiffLines,
);
// console.log(highlightedCodeHtml);
const { guts, lineBackgrounds } = this.convertShikiHtmlToSvgGut(
highlightedCodeHtml,
fontSize,
fontFamily,
lineHeight,
dimensions,
options,
);
const backgroundColor = this.getBackgroundColor(highlightedCodeHtml);
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${dimensions.width}" height="${dimensions.height}" shape-rendering="crispEdges">
<style>
:root {
--purple: rgb(112, 114, 209);
--green: rgb(136, 194, 163);
--blue: rgb(107, 166, 205);
}
</style>
<g>
<rect x="0" y="0" rx="10" ry="10" width="${dimensions.width}" height="${dimensions.height}" fill="${this.editorBackground}" shape-rendering="crispEdges" />
${lineBackgrounds}
${guts}
</g>
</svg>`;
console.log(svg);
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${options.dimensions.width}" height="${options.dimensions.height}" shape-rendering="crispEdges">
<style>
:root {
--purple: rgb(112, 114, 209);
--green: rgb(136, 194, 163);
--blue: rgb(107, 166, 205);
}
</style>
<g>
<rect x="0" y="0" rx="10" ry="10" width="${options.dimensions.width}" height="${options.dimensions.height}" fill="${this.editorBackground}" shape-rendering="crispEdges" />
${lineBackgrounds}
${guts}
</g>
</svg>`;
// console.log(svg);
return Buffer.from(svg, "utf8");
}
convertShikiHtmlToSvgGut(
shikiHtml: string,
fontSize: number,
fontFamily: string,
lineHeight: number,
dimensions: Dimensions,
options: ConversionOptions,
): { guts: string; lineBackgrounds: string } {
const dom = new JSDOM(shikiHtml);
const document = dom.window.document;
@@ -263,6 +285,7 @@ export class CodeRenderer {
if (classes.includes("highlighted")) {
fill = ` fill="${this.editorLineHighlight}"`;
}
const content = el.textContent || "";
return `<tspan xml:space="preserve"${fill}>${escapeForSVG(content)}</tspan>`;
})
@@ -278,8 +301,8 @@ export class CodeRenderer {
// The first step is to add lineHeight / 2 to move the axis down.
// The second step is to add 'dominant-baseline="central"' to vertically center the text.
// Note that we choose "central" over "middle". "middle" will center the text too perfectly, which is actually undesirable!
const y = index * lineHeight + lineHeight / 2;
return `<text x="0" y="${y}" font-family="${fontFamily}" font-size="${fontSize.toString()}" xml:space="preserve" dominant-baseline="central" shape-rendering="crispEdges">${spans}</text>`;
const y = index * options.lineHeight + options.lineHeight / 2;
return `<text x="0" y="${y}" font-family="${options.fontFamily}" font-size="${options.fontSize.toString()}" xml:space="preserve" dominant-baseline="central" shape-rendering="crispEdges">${spans}</text>`;
});
const lineBackgrounds = lines
@@ -287,8 +310,11 @@ export class CodeRenderer {
const classes = line?.getAttribute("class") || "";
const bgColor = classes.includes("highlighted")
? this.editorLineHighlight
: this.editorBackground;
const y = index * lineHeight;
: classes.includes("diff add")
? "rgba(255, 255, 0, 0.2)"
: this.editorBackground;
const y = index * options.lineHeight;
const isFirst = index === 0;
const isLast = index === lines.length - 1;
const radius = 10;
@@ -297,24 +323,24 @@ export class CodeRenderer {
// This is undesirable in our case because pixel-perfect alignment of these rectangles will introduce thin gaps.
// Turning it off with 'shape-rendering="crispEdges"' solves the issue.
return isFirst
? `<path d="M ${0} ${y + lineHeight}
L ${0} ${y + radius}
Q ${0} ${y} ${radius} ${y}
L ${dimensions.width - radius} ${y}
Q ${dimensions.width} ${y} ${dimensions.width} ${y + radius}
L ${dimensions.width} ${y + lineHeight}
Z"
fill="${bgColor}" />`
? `<path d="M ${0} ${y + options.lineHeight}
L ${0} ${y + radius}
Q ${0} ${y} ${radius} ${y}
L ${options.dimensions.width - radius} ${y}
Q ${options.dimensions.width} ${y} ${options.dimensions.width} ${y + radius}
L ${options.dimensions.width} ${y + options.lineHeight}
Z"
fill="${bgColor}" />`
: isLast
? `<path d="M ${0} ${y}
L ${0} ${y + lineHeight - radius}
Q ${0} ${y + lineHeight} ${radius} ${y + lineHeight}
L ${dimensions.width - radius} ${y + lineHeight}
Q ${dimensions.width} ${y + lineHeight} ${dimensions.width} ${y + lineHeight - 10}
L ${dimensions.width} ${y}
Z"
fill="${bgColor}" />`
: `<rect x="0" y="${y}" rx="${radius}" ry="${radius}" width="100%" height="${lineHeight}" fill="${bgColor}" shape-rendering="crispEdges" />`;
? `<path d="M ${0} ${y}
L ${0} ${y + options.lineHeight - radius}
Q ${0} ${y + options.lineHeight} ${radius} ${y + options.lineHeight}
L ${options.dimensions.width - radius} ${y + options.lineHeight}
Q ${options.dimensions.width} ${y + options.lineHeight} ${options.dimensions.width} ${y + options.lineHeight - 10}
L ${options.dimensions.width} ${y}
Z"
fill="${bgColor}" />`
: `<rect x="0" y="${y}" width="100%" height="${options.lineHeight}" fill="${bgColor}" shape-rendering="crispEdges" />`;
})
.join("\n");
@@ -344,12 +370,9 @@ export class CodeRenderer {
async getDataUri(
code: string,
language: string = "javascript",
fontSize: number,
fontFamily: string,
dimensions: Dimensions,
lineHeight: number,
options: ConversionOptions,
currLineOffsetFromTop: number,
newDiffLines: DiffLine[],
): Promise<DataUri> {
switch (options.imageType) {
// case "png":
@@ -366,12 +389,9 @@ export class CodeRenderer {
const svgBuffer = await this.convertToSVG(
code,
language,
fontSize,
fontFamily,
dimensions,
lineHeight,
options,
currLineOffsetFromTop,
newDiffLines,
);
return `data:image/svg+xml;base64,${svgBuffer.toString("base64")}`;
}

View File

@@ -1,6 +1,6 @@
import { diffLines, type Change } from "diff";
import { diffChars, diffLines, type Change } from "diff";
import { DiffLine } from "..";
import { DiffChar, DiffLine } from "..";
export function convertMyersChangeToDiffLines(change: Change): DiffLine[] {
const type: DiffLine["type"] = change.added
@@ -55,3 +55,157 @@ export function myersDiff(oldContent: string, newContent: string): DiffLine[] {
return ourFormat;
}
export function myersCharDiff(
oldContent: string,
newContent: string,
): DiffChar[] {
// Process the content character by character.
// We will handle newlines separately,
// because diffChars does not have an option to ignore eol newlines.
const theirFormat = diffChars(oldContent, newContent);
// Track indices as we process the diff.
let oldIndex = 0;
let newIndex = 0;
let oldLineIndex = 0;
let newLineIndex = 0;
let oldCharIndexInLine = 0;
let newCharIndexInLine = 0;
const result: DiffChar[] = [];
for (const change of theirFormat) {
// Split the change value by newlines to handle them separately.
if (change.value.includes("\n")) {
const parts = change.value.split(/(\n)/g); // This keeps the newlines as separate entries.
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
if (part === "") continue;
if (part === "\n") {
// Handle newline.
if (change.added) {
result.push({
type: "new",
char: part,
newIndex: newIndex,
newLineIndex: newLineIndex,
newCharIndexInLine: newCharIndexInLine,
});
newIndex += part.length;
newLineIndex++;
newCharIndexInLine = 0; // Reset when moving to a new line.
} else if (change.removed) {
result.push({
type: "old",
char: part,
oldIndex: oldIndex,
oldLineIndex: oldLineIndex,
oldCharIndexInLine: oldCharIndexInLine,
});
oldIndex += part.length;
oldLineIndex++;
oldCharIndexInLine = 0; // Reset when moving to a new line.
} else {
result.push({
type: "same",
char: part,
oldIndex: oldIndex,
newIndex: newIndex,
oldLineIndex: oldLineIndex,
newLineIndex: newLineIndex,
oldCharIndexInLine: oldCharIndexInLine,
newCharIndexInLine: newCharIndexInLine,
});
oldIndex += part.length;
newIndex += part.length;
oldLineIndex++;
newLineIndex++;
oldCharIndexInLine = 0;
newCharIndexInLine = 0;
}
} else {
// Handle regular text.
if (change.added) {
result.push({
type: "new",
char: part,
newIndex: newIndex,
newLineIndex: newLineIndex,
newCharIndexInLine: newCharIndexInLine,
});
newIndex += part.length;
newCharIndexInLine += part.length;
} else if (change.removed) {
result.push({
type: "old",
char: part,
oldIndex: oldIndex,
oldLineIndex: oldLineIndex,
oldCharIndexInLine: oldCharIndexInLine,
});
oldIndex += part.length;
oldCharIndexInLine += part.length;
} else {
result.push({
type: "same",
char: part,
oldIndex: oldIndex,
newIndex: newIndex,
oldLineIndex: oldLineIndex,
newLineIndex: newLineIndex,
oldCharIndexInLine: oldCharIndexInLine,
newCharIndexInLine: newCharIndexInLine,
});
oldIndex += part.length;
newIndex += part.length;
oldCharIndexInLine += part.length;
newCharIndexInLine += part.length;
}
}
}
} else {
// No newlines, handle as a simple change.
if (change.added) {
result.push({
type: "new",
char: change.value,
newIndex: newIndex,
newLineIndex: newLineIndex,
newCharIndexInLine: newCharIndexInLine,
});
newIndex += change.value.length;
newCharIndexInLine += change.value.length;
} else if (change.removed) {
result.push({
type: "old",
char: change.value,
oldIndex: oldIndex,
oldLineIndex: oldLineIndex,
oldCharIndexInLine: oldCharIndexInLine,
});
oldIndex += change.value.length;
oldCharIndexInLine += change.value.length;
} else {
result.push({
type: "same",
char: change.value,
oldIndex: oldIndex,
newIndex: newIndex,
oldLineIndex: oldLineIndex,
newLineIndex: newLineIndex,
oldCharIndexInLine: oldCharIndexInLine,
newCharIndexInLine: newCharIndexInLine,
});
oldIndex += change.value.length;
newIndex += change.value.length;
oldCharIndexInLine += change.value.length;
newCharIndexInLine += change.value.length;
}
}
}
return result;
}

View File

@@ -2,7 +2,7 @@ import { describe, expect, test } from "vitest";
import { dedent } from "../util";
import { myersDiff } from "./myers";
import { myersCharDiff, myersDiff } from "./myers";
describe("Test myers diff function", () => {
test("should ...", () => {
@@ -56,3 +56,589 @@ describe("Test myers diff function", () => {
]);
});
});
describe("Test myersCharDiff function on the same line", () => {
test("should differentiate character changes", () => {
const oldContent = "hello world";
const newContent = "hello earth";
const diffChars = myersCharDiff(oldContent, newContent);
expect(diffChars).toEqual([
{
type: "same",
char: "hello ",
oldIndex: 0,
newIndex: 0,
oldCharIndexInLine: 0,
newCharIndexInLine: 0,
oldLineIndex: 0,
newLineIndex: 0,
},
{
type: "old",
char: "wo",
oldIndex: 6,
oldCharIndexInLine: 6,
oldLineIndex: 0,
},
{
type: "new",
char: "ea",
newIndex: 6,
newCharIndexInLine: 6,
newLineIndex: 0,
},
{
type: "same",
char: "r",
oldIndex: 8,
newIndex: 8,
oldCharIndexInLine: 8,
newCharIndexInLine: 8,
oldLineIndex: 0,
newLineIndex: 0,
},
{
type: "old",
char: "ld",
oldIndex: 9,
oldCharIndexInLine: 9,
oldLineIndex: 0,
},
{
type: "new",
char: "th",
newIndex: 9,
newCharIndexInLine: 9,
newLineIndex: 0,
},
]);
});
test("should handle insertions", () => {
const oldContent = "abc";
const newContent = "abxyzc";
const diffChars = myersCharDiff(oldContent, newContent);
expect(diffChars).toEqual([
{
type: "same",
char: "ab",
oldIndex: 0,
newIndex: 0,
oldCharIndexInLine: 0,
newCharIndexInLine: 0,
oldLineIndex: 0,
newLineIndex: 0,
},
{
type: "new",
char: "xyz",
newIndex: 2,
newCharIndexInLine: 2,
newLineIndex: 0,
},
{
type: "same",
char: "c",
oldIndex: 2,
newIndex: 5,
oldCharIndexInLine: 2,
newCharIndexInLine: 5,
oldLineIndex: 0,
newLineIndex: 0,
},
]);
});
test("should handle deletions", () => {
const oldContent = "abxyzc";
const newContent = "abc";
const diffChars = myersCharDiff(oldContent, newContent);
expect(diffChars).toEqual([
{
type: "same",
char: "ab",
oldIndex: 0,
newIndex: 0,
oldCharIndexInLine: 0,
newCharIndexInLine: 0,
oldLineIndex: 0,
newLineIndex: 0,
},
{
type: "old",
char: "xyz",
oldIndex: 2,
oldCharIndexInLine: 2,
oldLineIndex: 0,
},
{
type: "same",
char: "c",
oldIndex: 5,
newIndex: 2,
oldCharIndexInLine: 5,
newCharIndexInLine: 2,
oldLineIndex: 0,
newLineIndex: 0,
},
]);
});
test("should handle empty strings", () => {
const oldContent = "";
const newContent = "abc";
const diffChars = myersCharDiff(oldContent, newContent);
expect(diffChars).toEqual([
{
type: "new",
char: "abc",
newIndex: 0,
newCharIndexInLine: 0,
newLineIndex: 0,
},
]);
});
test("should handle identical strings", () => {
const content = "no changes here";
const diffChars = myersCharDiff(content, content);
expect(diffChars).toEqual([
{
type: "same",
char: "no changes here",
oldIndex: 0,
newIndex: 0,
oldCharIndexInLine: 0,
newCharIndexInLine: 0,
oldLineIndex: 0,
newLineIndex: 0,
},
]);
});
test("should handle whitespace changes", () => {
const oldContent = "hello world";
const newContent = "hello world";
const diffChars = myersCharDiff(oldContent, newContent);
expect(diffChars).toEqual([
{
type: "same",
char: "hello ",
oldIndex: 0,
newIndex: 0,
oldCharIndexInLine: 0,
newCharIndexInLine: 0,
oldLineIndex: 0,
newLineIndex: 0,
},
{
type: "new",
char: " ",
newIndex: 6,
newCharIndexInLine: 6,
newLineIndex: 0,
},
{
type: "same",
char: "world",
oldIndex: 6,
newIndex: 7,
oldCharIndexInLine: 6,
newCharIndexInLine: 7,
oldLineIndex: 0,
newLineIndex: 0,
},
]);
});
test("should handle complex changes", () => {
const oldContent = "The quick brown fox jumps over the lazy dog";
const newContent = "The fast brown fox leaps over the sleeping dog";
const diffChars = myersCharDiff(oldContent, newContent);
expect(diffChars).toEqual([
{
type: "same",
char: "The ",
oldIndex: 0,
newIndex: 0,
oldCharIndexInLine: 0,
newCharIndexInLine: 0,
oldLineIndex: 0,
newLineIndex: 0,
},
{
type: "old",
char: "quick",
oldIndex: 4,
oldCharIndexInLine: 4,
oldLineIndex: 0,
},
{
type: "new",
char: "fast",
newIndex: 4,
newCharIndexInLine: 4,
newLineIndex: 0,
},
{
type: "same",
char: " brown fox ",
oldIndex: 9,
newIndex: 8,
oldCharIndexInLine: 9,
newCharIndexInLine: 8,
oldLineIndex: 0,
newLineIndex: 0,
},
{
type: "old",
char: "jum",
oldIndex: 20,
oldCharIndexInLine: 20,
oldLineIndex: 0,
},
{
type: "new",
char: "lea",
newIndex: 19,
newCharIndexInLine: 19,
newLineIndex: 0,
},
{
type: "same",
char: "ps over the ",
oldIndex: 23,
newIndex: 22,
oldCharIndexInLine: 23,
newCharIndexInLine: 22,
oldLineIndex: 0,
newLineIndex: 0,
},
{
type: "new",
char: "s",
newIndex: 34,
newCharIndexInLine: 34,
newLineIndex: 0,
},
{
type: "same",
char: "l",
oldIndex: 35,
newIndex: 35,
oldCharIndexInLine: 35,
newCharIndexInLine: 35,
oldLineIndex: 0,
newLineIndex: 0,
},
{
type: "old",
char: "azy",
oldIndex: 36,
oldCharIndexInLine: 36,
oldLineIndex: 0,
},
{
type: "new",
char: "eeping",
newIndex: 36,
newCharIndexInLine: 36,
newLineIndex: 0,
},
{
type: "same",
char: " dog",
oldIndex: 39,
newIndex: 42,
oldCharIndexInLine: 39,
newCharIndexInLine: 42,
oldLineIndex: 0,
newLineIndex: 0,
},
]);
});
});
describe("Test myersCharDiff function on different lines", () => {
test("should track line indices for multi-line changes", () => {
const oldContent = ["Line one", "Line two", "Line three"].join("\n");
const newContent = ["Line one", "Modified line", "Line three"].join("\n");
const diffChars = myersCharDiff(oldContent, newContent);
expect(diffChars).toEqual([
{
type: "same",
char: "Line one",
oldIndex: 0,
newIndex: 0,
oldCharIndexInLine: 0,
newCharIndexInLine: 0,
oldLineIndex: 0,
newLineIndex: 0,
},
{
type: "same",
char: "\n",
oldIndex: 8,
newIndex: 8,
oldCharIndexInLine: 8,
newCharIndexInLine: 8,
oldLineIndex: 0,
newLineIndex: 0,
},
{
type: "old",
char: "L",
oldIndex: 9,
oldCharIndexInLine: 0,
oldLineIndex: 1,
},
{
type: "new",
char: "Mod",
newIndex: 9,
newCharIndexInLine: 0,
newLineIndex: 1,
},
{
char: "i",
newCharIndexInLine: 3,
newIndex: 12,
newLineIndex: 1,
oldCharIndexInLine: 1,
oldIndex: 10,
oldLineIndex: 1,
type: "same",
},
{
char: "n",
oldCharIndexInLine: 2,
oldIndex: 11,
oldLineIndex: 1,
type: "old",
},
{
char: "fi",
newCharIndexInLine: 4,
newIndex: 13,
newLineIndex: 1,
type: "new",
},
{
char: "e",
newCharIndexInLine: 6,
newIndex: 15,
newLineIndex: 1,
oldCharIndexInLine: 3,
oldIndex: 12,
oldLineIndex: 1,
type: "same",
},
{
char: "d",
newCharIndexInLine: 7,
newIndex: 16,
newLineIndex: 1,
type: "new",
},
{
char: " ",
newCharIndexInLine: 8,
newIndex: 17,
newLineIndex: 1,
oldCharIndexInLine: 4,
oldIndex: 13,
oldLineIndex: 1,
type: "same",
},
{
type: "old",
char: "two",
oldCharIndexInLine: 5,
oldIndex: 14,
oldLineIndex: 1,
},
{
type: "new",
char: "line",
newCharIndexInLine: 9,
newIndex: 18,
newLineIndex: 1,
},
{
type: "same",
char: "\n",
oldIndex: 17,
oldCharIndexInLine: 8,
oldLineIndex: 1,
newIndex: 22,
newCharIndexInLine: 13,
newLineIndex: 1,
},
{
type: "same",
char: "Line three",
oldIndex: 18,
newIndex: 23,
oldCharIndexInLine: 0,
newCharIndexInLine: 0,
oldLineIndex: 2,
newLineIndex: 2,
},
]);
});
test("should track line indices when adding new lines", () => {
const oldContent = ["First line", "Last line"].join("\n");
const newContent = [
"First line",
"Middle line",
"Another middle",
"Last line",
].join("\n");
const diffChars = myersCharDiff(oldContent, newContent);
expect(diffChars).toEqual([
{
type: "same",
char: "First line",
oldIndex: 0,
newIndex: 0,
oldCharIndexInLine: 0,
newCharIndexInLine: 0,
oldLineIndex: 0,
newLineIndex: 0,
},
{
type: "same",
char: "\n",
oldIndex: 10,
newIndex: 10,
oldCharIndexInLine: 10,
newCharIndexInLine: 10,
oldLineIndex: 0,
newLineIndex: 0,
},
{
type: "new",
char: "Middle line",
newIndex: 11,
newCharIndexInLine: 0,
newLineIndex: 1,
},
{
type: "new",
char: "\n",
newIndex: 22,
newCharIndexInLine: 11,
newLineIndex: 1,
},
{
type: "new",
char: "Another middle",
newCharIndexInLine: 0,
newIndex: 23,
newLineIndex: 2,
},
{
type: "new",
char: "\n",
newCharIndexInLine: 14,
newIndex: 37,
newLineIndex: 2,
},
{
type: "same",
char: "Last line",
oldIndex: 11,
oldCharIndexInLine: 0,
oldLineIndex: 1,
newIndex: 38,
newCharIndexInLine: 0,
newLineIndex: 3,
},
]);
});
test("should track line indices when removing lines", () => {
const oldContent = [
"Start",
"Line to remove",
"Another to remove",
"End",
].join("\n");
const newContent = ["Start", "End"].join("\n");
const diffChars = myersCharDiff(oldContent, newContent);
expect(diffChars).toEqual([
{
type: "same",
char: "Start",
oldIndex: 0,
newIndex: 0,
oldCharIndexInLine: 0,
newCharIndexInLine: 0,
oldLineIndex: 0,
newLineIndex: 0,
},
{
type: "same",
char: "\n",
oldIndex: 5,
newIndex: 5,
oldCharIndexInLine: 5,
newCharIndexInLine: 5,
oldLineIndex: 0,
newLineIndex: 0,
},
{
type: "old",
char: "Line to remove",
oldIndex: 6,
oldCharIndexInLine: 0,
oldLineIndex: 1,
},
{
type: "old",
char: "\n",
oldCharIndexInLine: 14,
oldIndex: 20,
oldLineIndex: 1,
},
{
type: "old",
char: "Another to remove",
oldCharIndexInLine: 0,
oldIndex: 21,
oldLineIndex: 2,
},
{
type: "old",
char: "\n",
oldCharIndexInLine: 17,
oldIndex: 38,
oldLineIndex: 2,
},
{
type: "same",
char: "End",
oldIndex: 39,
newIndex: 6,
oldCharIndexInLine: 0,
newCharIndexInLine: 0,
oldLineIndex: 3,
newLineIndex: 1,
},
]);
});
});

View File

@@ -1,4 +1,4 @@
import { DiffLine, DiffLineType } from "../index.js";
import { DiffLine, DiffType } from "../index.js";
import { LineStream, matchLine } from "./util.js";
@@ -33,7 +33,7 @@ export async function* streamDiff(
seenIndentationMistake = true;
}
let type: DiffLineType;
let type: DiffType;
const isNewLine = matchIndex === -1;

View File

@@ -6,11 +6,11 @@ import { describe, expect, test } from "vitest";
// @ts-ignore no typings available
import { changed, diff as myersDiff } from "myers-diff";
import { streamDiff } from "../diff/streamDiff.js";
import { DiffLine, DiffLineType } from "../index.js";
import { DiffLine, DiffType } from "../index.js";
import { generateLines } from "./util.js";
// "modification" is an extra type used to represent an "old" + "new" diff line
type MyersDiffTypes = Extract<DiffLineType, "new" | "old"> | "modification";
type MyersDiffTypes = Extract<DiffType, "new" | "old"> | "modification";
const UNIFIED_DIFF_SYMBOLS = {
same: "",

19
core/index.d.ts vendored
View File

@@ -666,13 +666,26 @@ export type CustomLLM = RequireAtLeastOne<
// IDE
export type DiffLineType = "new" | "old" | "same";
export type DiffType = "new" | "old" | "same";
export interface DiffLine {
type: DiffLineType;
export interface DiffObject {
type: DiffType;
}
export interface DiffLine extends DiffObject {
line: string;
}
interface DiffChar extends DiffObject {
char: string;
oldIndex?: number; // Character index assuming a flattened line string.
newIndex?: number;
oldCharIndexInLine?: number; // Character index assuming new lines reset the character index to 0.
newCharIndexInLine?: number;
oldLineIndex?: number;
newLineIndex?: number;
}
export interface Problem {
filepath: string;
range: Range;

View File

@@ -1,5 +1,5 @@
export const IS_NEXT_EDIT_ACTIVE = false;
export const NEXT_EDIT_EDITABLE_REGION_TOP_MARGIN = 5;
export const NEXT_EDIT_EDITABLE_REGION_TOP_MARGIN = 0;
export const NEXT_EDIT_EDITABLE_REGION_BOTTOM_MARGIN = 5;
export const USER_CURSOR_IS_HERE_TOKEN = "<|user_cursor_is_here|>";
export const EDITABLE_REGION_START_TOKEN = "<|editable_region_start|>";

View File

@@ -3,8 +3,9 @@ import { EXTENSION_NAME } from "core/control-plane/env";
// @ts-ignore
import * as vscode from "vscode";
import { DiffLine } from "core";
import { DiffChar, DiffLine } from "core";
import { CodeRenderer } from "core/codeRenderer/CodeRenderer";
import { myersCharDiff } from "core/diff/myers";
import {
NEXT_EDIT_EDITABLE_REGION_BOTTOM_MARGIN,
NEXT_EDIT_EDITABLE_REGION_TOP_MARGIN,
@@ -295,14 +296,19 @@ export class NextEditWindowManager {
);
// Create and apply decoration with the text.
await this.renderTooltip(
await this.renderWindow(
editor,
currCursorPos,
oldEditRangeSlice,
newEditRangeSlice,
editableRegionStartLine,
diffLines,
);
const diffChars = myersCharDiff(oldEditRangeSlice, newEditRangeSlice);
this.renderDeletes(editor, editableRegionStartLine, diffChars);
// Reserve tab and esc to either accept or reject the displayed next edit contents.
await NextEditWindowManager.reserveTabAndEsc();
}
@@ -531,6 +537,7 @@ export class NextEditWindowManager {
private async createCodeRender(
text: string,
currLineOffsetFromTop: number,
newDiffLines: DiffLine[],
): Promise<
| { uri: vscode.Uri; dimensions: { width: number; height: number } }
| undefined
@@ -546,14 +553,15 @@ export class NextEditWindowManager {
const uri = await this.codeRenderer.getDataUri(
text,
"typescript",
this.fontSize,
this.fontFamily,
dimensions,
SVG_CONFIG.lineHeight,
{
imageType: "svg",
fontSize: this.fontSize,
fontFamily: this.fontFamily,
dimensions: dimensions,
lineHeight: SVG_CONFIG.lineHeight,
},
currLineOffsetFromTop,
newDiffLines,
);
return {
@@ -576,11 +584,13 @@ export class NextEditWindowManager {
predictedCode: string,
position: vscode.Position,
editableRegionStartLine: number,
newDiffLines: DiffLine[],
): Promise<vscode.TextEditorDecorationType | undefined> {
const currLineOffsetFromTop = position.line - editableRegionStartLine;
const uriAndDimensions = await this.createCodeRender(
predictedCode,
currLineOffsetFromTop,
newDiffLines,
);
if (!uriAndDimensions) {
return undefined;
@@ -598,12 +608,12 @@ export class NextEditWindowManager {
SVG_CONFIG.getTipWidth(originalCode) -
SVG_CONFIG.getTipWidth(originalCode.split("\n")[currLineOffsetFromTop]);
console.log(marginLeft);
console.log(SVG_CONFIG.getTipWidth(originalCode));
console.log(
SVG_CONFIG.getTipWidth(originalCode.split("\n")[currLineOffsetFromTop]),
);
console.log(originalCode.split("\n")[currLineOffsetFromTop]);
// console.log(marginLeft);
// console.log(SVG_CONFIG.getTipWidth(originalCode));
// console.log(
// SVG_CONFIG.getTipWidth(originalCode.split("\n")[currLineOffsetFromTop]),
// );
// console.log(originalCode.split("\n")[currLineOffsetFromTop]);
return vscode.window.createTextEditorDecorationType({
before: {
contentIconPath: uri,
@@ -703,16 +713,16 @@ export class NextEditWindowManager {
}
/**
* Render a tooltip with the given text at the specified position.
* Render a window with the given text at the specified position.
*/
private async renderTooltip(
private async renderWindow(
editor: vscode.TextEditor,
position: vscode.Position,
originalCode: string,
predictedCode: string,
editableRegionStartLine: number,
newDiffLines: DiffLine[],
) {
console.log("renderTooltip");
// Capture document version to detect changes.
const docVersion = editor.document.version;
@@ -722,6 +732,7 @@ export class NextEditWindowManager {
predictedCode,
position,
editableRegionStartLine,
newDiffLines,
);
if (!decoration) {
console.error("Failed to create decoration for text:", predictedCode);
@@ -737,7 +748,8 @@ export class NextEditWindowManager {
// Store the decoration and editor.
await this.hideAllNextEditWindows();
this.currentDecoration = decoration;
this.currentDecoration = decoration; // TODO: This might be redundant.
this.disposables.push(decoration);
this.activeEditor = editor;
// Calculate how far off to the right of the cursor the decoration should be.
@@ -773,6 +785,66 @@ export class NextEditWindowManager {
this.mostRecentCompletionId,
);
}
private renderDeletes(
editor: vscode.TextEditor,
editableRegionStartLine: number,
// oldEditRangeSlice: string,
// newEditRangeSlice: string,
oldDiffChars: DiffChar[],
) {
const charsToDelete: vscode.DecorationOptions[] = [];
// const diffChars = myersCharDiff(oldEditRangeSlice, newEditRangeSlice);
oldDiffChars.forEach((diff) => {
// TODO: This check if technically redundant.
if (diff.type === "old") {
charsToDelete.push({
range: new vscode.Range(
new vscode.Position(
editableRegionStartLine + diff.oldLineIndex!,
diff.oldCharIndexInLine!,
),
new vscode.Position(
editableRegionStartLine + diff.oldLineIndex!,
diff.oldCharIndexInLine! + diff.char.length,
),
),
});
}
});
const deleteDecorationType = vscode.window.createTextEditorDecorationType({
backgroundColor: "rgba(255, 0, 0, 0.5)",
textDecoration: "line-through",
});
editor.setDecorations(deleteDecorationType, charsToDelete);
this.disposables.push(deleteDecorationType);
}
async getExactCharacterWidth(): Promise<number> {
// For VS Code extensions, you can sometimes access the editor's text metrics
const activeEditor = vscode.window.activeTextEditor;
if (activeEditor) {
// VS Code has internal methods to measure text, but they're not all exposed
// in the public API. You might need to use reflection or known properties.
// Example accessing through reflection (this is pseudocode)
const editorInstance = activeEditor as any;
if (editorInstance._modelData && editorInstance._modelData.viewModel) {
const viewModel = editorInstance._modelData.viewModel;
return (
viewModel.getLineWidth(0) /
activeEditor.document.lineAt(0).text.length
);
}
}
// If all else fails, return a reasonable default
return SVG_CONFIG.fontSize * 0.6;
}
}
export default async function setupNextEditWindowManager(