diff --git a/packages/diffs/src/renderers/DiffHunksRenderer.ts b/packages/diffs/src/renderers/DiffHunksRenderer.ts index 6daaed9f0..0f81d345e 100644 --- a/packages/diffs/src/renderers/DiffHunksRenderer.ts +++ b/packages/diffs/src/renderers/DiffHunksRenderer.ts @@ -281,11 +281,21 @@ export class DiffHunksRenderer { * Enter edit-session mode: hunk updates preserve the current region * skeleton instead of recomputing hunks, and rendering happens locally * with the token transformer forced on (worker-pool requests/results are - * suspended for this renderer). Called on every editor attach, including - * a re-attach after recycle. + * suspended for this renderer). An empty additions document gets one row so + * the editor has a line for its caret. Called on every editor attach, + * including a re-attach after recycle. */ public beginEditSession(): void { this.editSessionActive = true; + const diff = this.diffCache; + if (diff != null && !diff.isPartial && diff.additionLines.length === 0) { + Object.assign( + diff, + recomputeEmptyDocumentDiff(diff, this.options.parseDiffOptions) + ); + this.markEditSessionPass(diff); + this.clearRenderCache(); + } } /** Leave edit-session mode. The exit recompute is the host's concern. */ @@ -652,7 +662,7 @@ export class DiffHunksRenderer { ); result.code.additionLines[0] = createPlainAdditionLineElement( 0, - textDocument + textDocument.getLineText(0) ); this.markEditSessionPass(diff); } else if (this.editSessionActive) { @@ -1110,6 +1120,34 @@ export class DiffHunksRenderer { expandedHunks: forcePlainText ? true : undefined, collapsedContextThreshold, }); + if ( + this.editSessionActive && + diff.additionLines.length === 1 && + diff.additionLines[0] === '' && + result.code.additionLines[0] == null + ) { + let fallbackLine: DiffLineMetadata | undefined; + iterateOverDiff({ + diff, + diffStyle: 'both', + expandedHunks: forcePlainText ? true : undefined, + collapsedContextThreshold, + callback: ({ additionLine }) => { + if (additionLine?.lineIndex !== 0) return; + fallbackLine = additionLine; + return true; + }, + }); + if (fallbackLine == null) { + throw new Error('DiffHunksRenderer: missing empty addition line'); + } + result.code.additionLines[0] = createPlainAdditionLineElement( + 0, + '', + fallbackLine.unifiedLineIndex, + fallbackLine.splitLineIndex + ); + } return { result, options }; } @@ -2339,21 +2377,26 @@ function realignAdditionHastLines( realigned[index] ??= hastLines[index]; } for (let index = prefix; index < nextLines.length; index++) { - realigned[index] ??= createPlainAdditionLineElement(index, textDocument); + realigned[index] ??= createPlainAdditionLineElement( + index, + textDocument.getLineText(index) + ); } return realigned; } function createPlainAdditionLineElement( lineIndex: number, - textDocument: DiffsTextDocument + lineText: string, + unifiedLineIndex = lineIndex, + splitLineIndex = lineIndex ): HASTElement { return { type: 'element', tagName: 'div', properties: { 'data-line': lineIndex + 1, - 'data-line-index': `${lineIndex},${lineIndex}`, + 'data-line-index': `${unifiedLineIndex},${splitLineIndex}`, 'data-line-type': 'context', }, children: [ @@ -2366,7 +2409,7 @@ function createPlainAdditionLineElement( children: [ { type: 'text', - value: textDocument.getLineText(lineIndex), + value: lineText, }, ], }, diff --git a/packages/diffs/src/utils/editSessionHunks.ts b/packages/diffs/src/utils/editSessionHunks.ts index 12070712b..8fd827179 100644 --- a/packages/diffs/src/utils/editSessionHunks.ts +++ b/packages/diffs/src/utils/editSessionHunks.ts @@ -12,6 +12,7 @@ import { parseDiffFromFile } from './parseDiffFromFile'; import { offsetHunkContent, preserveTrailingEditorBlankLine, + recomputeDiffHunks, recomputeDiffHunksForEdit, recomputeDiffRenderLineCounts, recomputeHunkRenderLineCounts, @@ -419,7 +420,13 @@ export function finishEditSessionForDiff( return false; } diff.editSessionDirty = undefined; - Object.assign(diff, recomputeDiffHunksForEdit(diff, parseDiffOptions)); + // The empty editor row only hosts a caret; it is not file content after exit. + Object.assign( + diff, + diff.additionLines.length <= 1 && diff.additionLines.join('') === '' + ? recomputeDiffHunks(diff, parseDiffOptions) + : recomputeDiffHunksForEdit(diff, parseDiffOptions) + ); return true; } diff --git a/packages/diffs/test/editorDiffEmptyDocument.test.ts b/packages/diffs/test/editorDiffEmptyDocument.test.ts index 0f11ecfe5..c2e78f3e8 100644 --- a/packages/diffs/test/editorDiffEmptyDocument.test.ts +++ b/packages/diffs/test/editorDiffEmptyDocument.test.ts @@ -56,6 +56,7 @@ function countEditableLineEls(content: HTMLElement): number { interface DiffEditorFixture { container: HTMLElement; editor: Editor; + fileDiff: FileDiff; cleanup(): Promise; } @@ -96,6 +97,7 @@ async function createDiffEditorFixture( return { container, editor, + fileDiff, async cleanup() { // Drain any pending highlighter/sync callbacks before tearing down the DOM // so a late re-attach does not run against a destroyed document. @@ -122,8 +124,75 @@ function replaceAll(editor: Editor, newText: string): void { ); } -describe('diff editor: select-all then delete', () => { +describe('diff editor: empty document', () => { for (const diffStyle of ['split', 'unified'] as const) { + test(`renders line 1 and a caret when the new file starts empty (${diffStyle})`, async () => { + const fixture = await createDiffEditorFixture(diffStyle, 'removed\n', ''); + const { editor, container } = fixture; + + try { + const content = findAdditionContent(container); + expect(content).toBeDefined(); + if (content == null) return; + expect(countEditableLineEls(content)).toBe(1); + expect( + [...content.children].some( + (child) => (child as HTMLElement).dataset.line === '1' + ) + ).toBe(true); + const editableLine = [...content.children].find((child) => { + const el = child as HTMLElement; + return ( + el.dataset.line === '1' && el.dataset.lineType !== 'change-deletion' + ); + }) as HTMLElement | undefined; + expect(editableLine?.dataset.lineIndex).toBe('1,0'); + + editor.setSelections([ + { + start: { line: 0, character: 0 }, + end: { line: 0, character: 0 }, + direction: 'none', + }, + ]); + expect( + container.shadowRoot?.querySelector('[data-caret]') != null + ).toBe(true); + } finally { + await fixture.cleanup(); + } + }); + + test(`restores the zero-line diff after an attach-only session (${diffStyle})`, async () => { + const fixture = await createDiffEditorFixture( + diffStyle, + 'removed 1\nremoved 2\n', + '' + ); + + try { + expect(fixture.fileDiff.fileDiff?.additionLines).toEqual(['']); + + fixture.editor.cleanUp(); + for (let attempt = 0; attempt < 40; attempt++) { + const content = findAdditionContent(fixture.container); + if ( + fixture.fileDiff.fileDiff?.additionLines.length === 0 && + (content == null || countEditableLineEls(content) === 0) + ) { + break; + } + await wait(0); + } + + expect(fixture.fileDiff.fileDiff?.additionLines).toEqual([]); + const content = findAdditionContent(fixture.container); + expect(content == null ? 0 : countEditableLineEls(content)).toBe(0); + } finally { + await fixture.cleanup(); + } + }); + test(`keeps an editable line, accepts typing, and undoes (${diffStyle})`, async () => { const fixture = await createDiffEditorFixture( diffStyle,