Skip to content

Commit 375feae

Browse files
committed
fix: scrolling with ghost lines and improve diff display
- Fix scrolling by using visual indices for cursor positioning - Display deleted ghost lines in diff view - Adjust diff background opacity and remove line-through styling - Simplify AcpInput component
1 parent aa803a2 commit 375feae

File tree

6 files changed

+497
-245
lines changed

6 files changed

+497
-245
lines changed

anycode-backend/Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

anycode-base/src/diff.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,15 @@ export type ChangeType = 'added' | 'modified' | 'deleted';
1818
export type DiffInfo = {
1919
changeType: ChangeType;
2020
oldLines?: string[];
21+
ghostAnchorLine?: number;
2122
hunkId: number;
2223
};
2324

2425
export function computeGitChanges(
2526
original: string, current: string
2627
): Map<number, DiffInfo> {
2728
const changes = new Map<number, DiffInfo>();
29+
const currentLineCount = current === '' ? 1 : current.split('\n').length;
2830
const patch = JsDiff.createTwoFilesPatch(
2931
'a', 'b', original, current, '', '', { context: 0 }
3032
);
@@ -99,8 +101,17 @@ export function computeGitChanges(
99101
}
100102
} else if (deletedLines.length > 0) {
101103
// deleted
102-
changes.set(newLine, {
104+
// JsDiff can emit +0 for deletions before the first line.
105+
// ghostAnchorLine is the line BEFORE which ghost lines appear.
106+
// markerLine is the line where the deletion marker appears in gutter
107+
// (the last real line before the deletion, i.e. anchorLine - 1).
108+
const ghostAnchorLine = Math.max(1, newLine + 1);
109+
// Marker should be on the line BEFORE the ghosts
110+
const markerLine = Math.max(1, Math.min(ghostAnchorLine - 1, currentLineCount));
111+
changes.set(markerLine, {
103112
changeType: 'deleted',
113+
oldLines: deletedLines,
114+
ghostAnchorLine,
104115
hunkId: hunkId,
105116
});
106117
}
@@ -128,4 +139,4 @@ export function computeGitChanges(
128139
}
129140

130141
return changes;
131-
}
142+
}

anycode-base/src/renderer/DiffRenderer.ts

Lines changed: 118 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { AnycodeLine } from "../utils";
22
import { EditorSettings } from "../editor";
33
import { DiffInfo, ChangeType } from "../diff";
4+
import { GhostRow } from "./Renderer";
45

56
export interface GhostLine {
67
code: HTMLElement;
@@ -46,78 +47,110 @@ export class DiffRenderer {
4647

4748
// ========== Ghost Lines Rendering ==========
4849

50+
private hasGhostContent(diffInfo: DiffInfo): boolean {
51+
return (
52+
(diffInfo.changeType === 'modified' || diffInfo.changeType === 'deleted') &&
53+
!!diffInfo.oldLines &&
54+
diffInfo.oldLines.length > 0
55+
);
56+
}
57+
58+
private getGhostAnchorLine(lineNumber: number, diffInfo: DiffInfo): number {
59+
return diffInfo.ghostAnchorLine ?? lineNumber;
60+
}
61+
62+
/**
63+
* @deprecated Use buildVisualRows + createGhostRowElements instead.
64+
* This method was used for dynamic ghost line insertion during render.
65+
*/
4966
public renderGhostsForLine(
5067
lineIndex: number,
5168
diffResult: Map<number, DiffInfo>,
5269
renderedHunks: Set<number>,
5370
settings: EditorSettings
5471
): GhostLine[] | null {
55-
const diffInfo = diffResult.get(lineIndex + 1);
56-
if (diffInfo?.changeType === 'modified' && diffInfo.oldLines && diffInfo.oldLines.length > 0) {
57-
const hunkId = diffInfo.hunkId;
72+
const anchorLine = lineIndex + 1;
73+
const lines: GhostLine[] = [];
5874

59-
// Check if this is the first line in the hunk within the visible range
60-
// And we haven't rendered this hunk yet
61-
if (!renderedHunks.has(hunkId)) {
62-
renderedHunks.add(hunkId);
75+
for (const [lineNumber, diffInfo] of diffResult) {
76+
if (!this.hasGhostContent(diffInfo)) {
77+
continue;
78+
}
79+
if (this.getGhostAnchorLine(lineNumber, diffInfo) !== anchorLine) {
80+
continue;
81+
}
6382

64-
const lines: GhostLine[] = [];
83+
const hunkId = diffInfo.hunkId;
84+
if (renderedHunks.has(hunkId)) {
85+
continue;
86+
}
87+
renderedHunks.add(hunkId);
6588

66-
// Add ghost lines for deleted content
67-
for (const oldLine of diffInfo.oldLines) {
68-
const ghostLine = this.createDeletedGhostLine(oldLine, settings, hunkId);
89+
for (const oldLine of diffInfo.oldLines!) {
90+
const ghostLine = this.createDeletedGhostLine(oldLine, settings, hunkId);
6991

70-
// Add empty gutter and button elements to keep alignment
71-
const emptyGutter = document.createElement('div');
72-
emptyGutter.className = 'ln';
73-
emptyGutter.style.height = `${settings.lineHeight}px`;
74-
emptyGutter.setAttribute('data-ghost', 'true');
75-
emptyGutter.setAttribute('data-hunk-id', hunkId.toString());
92+
// Add empty gutter and button elements to keep alignment
93+
const emptyGutter = document.createElement('div');
94+
emptyGutter.className = 'ln';
95+
emptyGutter.style.height = `${settings.lineHeight}px`;
96+
emptyGutter.setAttribute('data-ghost', 'true');
97+
emptyGutter.setAttribute('data-hunk-id', hunkId.toString());
7698

77-
const emptyButton = document.createElement('div');
78-
emptyButton.className = 'bt';
79-
emptyButton.style.height = `${settings.lineHeight}px`;
80-
emptyButton.setAttribute('data-ghost', 'true');
81-
emptyButton.setAttribute('data-hunk-id', hunkId.toString());
99+
const emptyButton = document.createElement('div');
100+
emptyButton.className = 'bt';
101+
emptyButton.style.height = `${settings.lineHeight}px`;
102+
emptyButton.setAttribute('data-ghost', 'true');
103+
emptyButton.setAttribute('data-hunk-id', hunkId.toString());
82104

83-
lines.push({ code: ghostLine, gutter: emptyGutter, btn: emptyButton });
84-
}
85-
return lines;
105+
lines.push({ code: ghostLine, gutter: emptyGutter, btn: emptyButton });
86106
}
87107
}
88-
return null;
108+
109+
return lines.length > 0 ? lines : null;
89110
}
90111

112+
/**
113+
* @deprecated No longer needed with visual rows model.
114+
* Ghost lines are now part of the unified visual rows and rendered with stable indices.
115+
*/
91116
public syncVisibleGhosts(
92117
startLine: number,
93118
endLine: number,
94119
diffResult: Map<number, DiffInfo>,
95120
settings: EditorSettings,
96-
lines: AnycodeLine[]
121+
lines: AnycodeLine[],
122+
totalLines: number
97123
): void {
98124
if (!diffResult || diffResult.size === 0) return;
99125

100126
const visibleHunks = new Set<number>();
127+
const includeEofAnchor = endLine === totalLines;
128+
129+
// Collect all hunks whose ghost anchor is visible.
130+
for (const [lineNumber, diffInfo] of diffResult) {
131+
if (!this.hasGhostContent(diffInfo)) {
132+
continue;
133+
}
134+
const anchorLine = this.getGhostAnchorLine(lineNumber, diffInfo);
135+
const inVisibleRange = anchorLine >= startLine + 1 && anchorLine <= endLine;
136+
const atVisibleEof = includeEofAnchor && anchorLine === totalLines + 1;
101137

102-
// Collect all hunks in visible range
103-
for (let i = startLine; i < endLine; i++) {
104-
const diffInfo = diffResult.get(i + 1);
105-
if (diffInfo?.changeType === 'modified' && diffInfo.oldLines && diffInfo.oldLines.length > 0) {
138+
if (inVisibleRange || atVisibleEof) {
106139
visibleHunks.add(diffInfo.hunkId);
107140
}
108141
}
109142

110143
// Update ghost lines for each visible hunk
111144
for (const hunkId of visibleHunks) {
112-
let oldLines: string[] | undefined;
145+
let oldLinesForHunk: string[] | undefined;
113146
for (const [_, info] of diffResult) {
114-
if (info.hunkId === hunkId && info.oldLines) {
115-
oldLines = info.oldLines;
147+
if (info.hunkId === hunkId && info.oldLines && info.oldLines.length > 0) {
148+
oldLinesForHunk = info.oldLines;
116149
break;
117150
}
118151
}
119-
if (oldLines) {
120-
this.updateGhostLinesForHunk(hunkId, oldLines, settings, diffResult, lines);
152+
if (oldLinesForHunk) {
153+
this.updateGhostLinesForHunk(hunkId, oldLinesForHunk, settings, diffResult, lines);
121154
}
122155
}
123156

@@ -163,13 +196,12 @@ export class DiffRenderer {
163196
// Remove old ghost lines and corresponding gutter/button elements
164197
this.removeGhostLinesForHunk(hunkId);
165198

166-
// Find the first line in this hunk
167-
const firstLineNum = this.getFirstLineInHunk(hunkId, diffResult);
168-
if (firstLineNum === null) return;
199+
// Find ghost anchor for this hunk.
200+
const anchorLineNum = this.getGhostAnchorLineInHunk(hunkId, diffResult);
201+
if (anchorLineNum === null) return;
169202

170-
// Find firstLine from lines array instead of DOM query
171-
const firstLine = lines.find(line => line.lineNumber === firstLineNum - 1);
172-
if (!firstLine) return;
203+
// Find anchor line from lines array (can be absent for EOF anchors).
204+
const anchorLine = lines.find(line => line.lineNumber === anchorLineNum - 1);
173205

174206
// Insert new ghost lines before the first line
175207
const codeFrag = document.createDocumentFragment();
@@ -195,19 +227,18 @@ export class DiffRenderer {
195227
btnFrag.appendChild(emptyButton);
196228
}
197229

198-
// Find corresponding gutter and button elements by data-line attribute
199-
const firstGutterEl = this.gutter.querySelector(`.ln[data-line="${firstLineNum - 1}"]`);
200-
const firstBtnEl = this.buttonsColumn.querySelector(`.bt[data-line="${firstLineNum - 1}"]`);
230+
// Find corresponding gutter and button elements by data-line attribute.
231+
const anchorGutterEl = this.gutter.querySelector(`.ln[data-line="${anchorLineNum - 1}"]`);
232+
const anchorBtnEl = this.buttonsColumn.querySelector(`.bt[data-line="${anchorLineNum - 1}"]`);
201233

202-
// Insert at the correct positions in all three containers
203-
this.codeContent.insertBefore(codeFrag, firstLine);
234+
// Insert at the correct positions in all three containers.
235+
const codeInsertBefore = anchorLine ?? this.codeContent.lastElementChild;
236+
const gutterInsertBefore = anchorGutterEl ?? this.gutter.lastElementChild;
237+
const btnInsertBefore = anchorBtnEl ?? this.buttonsColumn.lastElementChild;
204238

205-
if (firstGutterEl) {
206-
this.gutter.insertBefore(gutterFrag, firstGutterEl);
207-
}
208-
if (firstBtnEl) {
209-
this.buttonsColumn.insertBefore(btnFrag, firstBtnEl);
210-
}
239+
this.codeContent.insertBefore(codeFrag, codeInsertBefore);
240+
this.gutter.insertBefore(gutterFrag, gutterInsertBefore);
241+
this.buttonsColumn.insertBefore(btnFrag, btnInsertBefore);
211242
}
212243

213244
private createDeletedGhostLine(
@@ -228,6 +259,33 @@ export class DiffRenderer {
228259
return ghostLine;
229260
}
230261

262+
/**
263+
* Create DOM elements for a ghost row (from visual rows model)
264+
* Returns code, gutter, and button elements for the ghost line
265+
*/
266+
public createGhostRowElements(
267+
ghostRow: GhostRow,
268+
settings: EditorSettings
269+
): GhostLine {
270+
const { hunkId, text } = ghostRow;
271+
272+
const ghostLine = this.createDeletedGhostLine(text, settings, hunkId);
273+
274+
const emptyGutter = document.createElement('div');
275+
emptyGutter.className = 'ln';
276+
emptyGutter.style.height = `${settings.lineHeight}px`;
277+
emptyGutter.setAttribute('data-ghost', 'true');
278+
emptyGutter.setAttribute('data-hunk-id', hunkId.toString());
279+
280+
const emptyButton = document.createElement('div');
281+
emptyButton.className = 'bt';
282+
emptyButton.style.height = `${settings.lineHeight}px`;
283+
emptyButton.setAttribute('data-ghost', 'true');
284+
emptyButton.setAttribute('data-hunk-id', hunkId.toString());
285+
286+
return { code: ghostLine, gutter: emptyGutter, btn: emptyButton };
287+
}
288+
231289
private findGhostLinesForHunk(hunkId: number): HTMLElement[] {
232290
const ghostLines: HTMLElement[] = [];
233291
const allGhostLines = this.codeContent.querySelectorAll('[data-ghost="true"]');
@@ -250,18 +308,19 @@ export class DiffRenderer {
250308
btnGhosts.forEach(ghost => ghost.remove());
251309
}
252310

253-
private getFirstLineInHunk(
311+
private getGhostAnchorLineInHunk(
254312
hunkId: number, diffResult: Map<number, DiffInfo>
255313
): number | null {
256-
let minLine: number | null = null;
314+
let minAnchorLine: number | null = null;
257315
for (const [lineNum, info] of diffResult) {
258-
if (info.hunkId === hunkId && info.changeType === 'modified') {
259-
if (minLine === null || lineNum < minLine) {
260-
minLine = lineNum;
316+
if (info.hunkId === hunkId && this.hasGhostContent(info)) {
317+
const anchorLine = this.getGhostAnchorLine(lineNum, info);
318+
if (minAnchorLine === null || anchorLine < minAnchorLine) {
319+
minAnchorLine = anchorLine;
261320
}
262321
}
263322
}
264-
return minLine;
323+
return minAnchorLine;
265324
}
266325

267326
public clearAllGhostLines(): void {

0 commit comments

Comments
 (0)