deploy: current vibn theia state
Some checks failed
Playwright Tests / Playwright Tests (ubuntu-22.04, Node.js 22.x) (push) Has been cancelled
3PP License Check / 3PP License Check (11, 22.x, ubuntu-22.04) (push) Has been cancelled
Publish packages to NPM / Perform Publishing (push) Has been cancelled

Made-with: Cursor
This commit is contained in:
2026-02-27 12:01:08 -08:00
commit 8bb5110148
3782 changed files with 640947 additions and 0 deletions

View File

@@ -0,0 +1,42 @@
// *****************************************************************************
// Copyright (C) 2018 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import * as chai from 'chai';
import { ContentLines } from './content-lines';
import { expect } from 'chai';
chai.use(require('chai-string'));
describe('content-lines', () => {
it('array-like access of lines without splitting', () => {
const raw = 'abc\ndef\n123\n456';
const linesArray = ContentLines.arrayLike(ContentLines.fromString(raw));
expect(linesArray[0]).to.be.equal('abc');
expect(linesArray[1]).to.be.equal('def');
expect(linesArray[2]).to.be.equal('123');
expect(linesArray[3]).to.be.equal('456');
});
it('works with CRLF', () => {
const raw = 'abc\ndef\r\n123\r456';
const linesArray = ContentLines.arrayLike(ContentLines.fromString(raw));
expect(linesArray[0]).to.be.equal('abc');
expect(linesArray[1]).to.be.equal('def');
expect(linesArray[2]).to.be.equal('123');
expect(linesArray[3]).to.be.equal('456');
});
});

View File

@@ -0,0 +1,121 @@
// *****************************************************************************
// Copyright (C) 2018 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { TextEditorDocument } from '@theia/editor/lib/browser';
export interface ContentLines extends ArrayLike<string> {
readonly length: number,
getLineContent: (line: number) => string,
}
export interface ContentLinesArrayLike extends ContentLines, ArrayLike<string> {
[Symbol.iterator]: () => IterableIterator<string>,
readonly [n: number]: string;
}
export namespace ContentLines {
const NL = '\n'.charCodeAt(0);
const CR = '\r'.charCodeAt(0);
export function fromString(content: string): ContentLines {
const computeLineStarts: (s: string) => number[] = s => {
const result: number[] = [0];
for (let i = 0; i < s.length; i++) {
const chr = s.charCodeAt(i);
if (chr === CR) {
if (i + 1 < s.length && s.charCodeAt(i + 1) === NL) {
result[result.length] = i + 2;
i++;
} else {
result[result.length] = i + 1;
}
} else if (chr === NL) {
result[result.length] = i + 1;
}
}
return result;
};
const lineStarts = computeLineStarts(content);
return {
length: lineStarts.length,
getLineContent: line => {
if (line >= lineStarts.length) {
throw new Error('line index out of bounds');
}
const start = lineStarts[line];
let end = (line === lineStarts.length - 1) ? undefined : lineStarts[line + 1] - 1;
if (!!end && content.charCodeAt(end - 1) === CR) {
end--; // ignore CR at the end
}
const lineContent = content.substring(start, end);
return lineContent;
}
};
}
export function fromTextEditorDocument(document: TextEditorDocument): ContentLines {
return {
length: document.lineCount,
getLineContent: line => document.getLineContent(line + 1),
};
}
export function arrayLike(lines: ContentLines): ContentLinesArrayLike {
return new Proxy(lines as ContentLines, getProxyHandler()) as ContentLinesArrayLike;
}
function getProxyHandler(): ProxyHandler<ContentLinesArrayLike> {
return {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
get(target: ContentLines, p: PropertyKey): any {
switch (p) {
case 'prototype':
return undefined;
case 'length':
return target.length;
case 'slice':
return (start?: number, end?: number) => {
if (start !== undefined) {
return [start, (end !== undefined ? end - 1 : target.length - 1)];
}
return [0, target.length - 1];
};
case Symbol.iterator:
return function* (): IterableIterator<string> {
for (let i = 0; i < target.length; i++) {
yield target.getLineContent(i);
}
};
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const index = Number.parseInt(p as any);
if (Number.isInteger(index)) {
if (index >= 0 && index < target.length) {
const value = target.getLineContent(index);
if (value === undefined) {
console.log(target);
}
return value;
} else {
return undefined;
}
}
throw new Error(`get ${String(p)} not implemented`);
}
};
}
}

View File

@@ -0,0 +1,455 @@
// *****************************************************************************
// Copyright (C) 2018 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import * as chai from 'chai';
import { expect } from 'chai';
chai.use(require('chai-string'));
import { DiffComputer, DirtyDiff } from './diff-computer';
import { ContentLines } from './content-lines';
let diffComputer: DiffComputer;
before(() => {
diffComputer = new DiffComputer();
});
describe('dirty-diff-computer', () => {
it('remove single line', () => {
const dirtyDiff = computeDirtyDiff(
[
'FIRST',
'SECOND TO-BE-REMOVED',
'THIRD'
],
[
'FIRST',
'THIRD'
],
);
expect(dirtyDiff).to.be.deep.equal(<DirtyDiff>{
changes: [
{
previousRange: { start: 1, end: 2 },
currentRange: { start: 1, end: 1 },
},
],
});
});
[1, 2, 3, 20].forEach(lines => {
it(`remove ${formatLines(lines)} at the end`, () => {
const dirtyDiff = computeDirtyDiff(
sequenceOfN(2)
.concat(sequenceOfN(lines, () => 'TO-BE-REMOVED')),
sequenceOfN(2),
);
expect(dirtyDiff).to.be.deep.equal(<DirtyDiff>{
changes: [
{
previousRange: { start: 2, end: 2 + lines },
currentRange: { start: 2, end: 2 },
},
],
});
});
});
it('remove all lines', () => {
const numberOfLines = 10;
const dirtyDiff = computeDirtyDiff(
sequenceOfN(numberOfLines, () => 'TO-BE-REMOVED'),
['']
);
expect(dirtyDiff).to.be.deep.equal(<DirtyDiff>{
changes: [
{
previousRange: { start: 0, end: numberOfLines },
currentRange: { start: 0, end: 0 },
},
],
});
});
[1, 2, 3, 20].forEach(lines => {
it(`remove ${formatLines(lines)} at the beginning`, () => {
const dirtyDiff = computeDirtyDiff(
sequenceOfN(lines, () => 'TO-BE-REMOVED')
.concat(sequenceOfN(2)),
sequenceOfN(2),
);
expect(dirtyDiff).to.be.deep.equal(<DirtyDiff>{
changes: [
{
previousRange: { start: 0, end: lines },
currentRange: { start: 0, end: 0 },
},
],
});
});
});
[1, 2, 3, 20].forEach(lines => {
it(`add ${formatLines(lines)}`, () => {
const previous = sequenceOfN(3);
const modified = insertIntoArray(previous, 2, ...sequenceOfN(lines, () => 'ADDED LINE'));
const dirtyDiff = computeDirtyDiff(previous, modified);
expect(dirtyDiff).to.be.deep.equal(<DirtyDiff>{
changes: [
{
previousRange: { start: 2, end: 2 },
currentRange: { start: 2, end: 2 + lines },
},
],
});
});
});
[1, 2, 3, 20].forEach(lines => {
it(`add ${formatLines(lines)} at the beginning`, () => {
const dirtyDiff = computeDirtyDiff(
sequenceOfN(2),
sequenceOfN(lines, () => 'ADDED LINE')
.concat(sequenceOfN(2))
);
expect(dirtyDiff).to.be.deep.equal(<DirtyDiff>{
changes: [
{
previousRange: { start: 0, end: 0 },
currentRange: { start: 0, end: lines },
},
],
});
});
});
it('add lines to empty file', () => {
const numberOfLines = 3;
const dirtyDiff = computeDirtyDiff(
[''],
sequenceOfN(numberOfLines, () => 'ADDED LINE')
);
expect(dirtyDiff).to.be.deep.equal(<DirtyDiff>{
changes: [
{
previousRange: { start: 0, end: 0 },
currentRange: { start: 0, end: numberOfLines },
},
],
});
});
it('add empty lines', () => {
const dirtyDiff = computeDirtyDiff(
[
'1',
'2'
],
[
'1',
'',
'',
'2'
]
);
expect(dirtyDiff).to.be.deep.equal(<DirtyDiff>{
changes: [
{
previousRange: { start: 1, end: 1 },
currentRange: { start: 1, end: 3 },
},
],
});
});
it('add empty line after single line', () => {
const dirtyDiff = computeDirtyDiff(
[
'1'
],
[
'1',
''
]
);
expect(dirtyDiff).to.be.deep.equal(<DirtyDiff>{
changes: [
{
previousRange: { start: 1, end: 1 },
currentRange: { start: 1, end: 2 },
},
],
});
});
[1, 2, 3, 20].forEach(lines => {
it(`add ${formatLines(lines)} (empty) at the end`, () => {
const dirtyDiff = computeDirtyDiff(
sequenceOfN(2),
sequenceOfN(2)
.concat(new Array(lines).map(() => ''))
);
expect(dirtyDiff).to.be.deep.equal(<DirtyDiff>{
changes: [
{
previousRange: { start: 2, end: 2 },
currentRange: { start: 2, end: 2 + lines },
},
],
});
});
});
it('add empty and non-empty lines', () => {
const dirtyDiff = computeDirtyDiff(
[
'FIRST',
'LAST'
],
[
'FIRST',
'1. ADDED',
'2. ADDED',
'3. ADDED',
'4. ADDED',
'5. ADDED',
'LAST'
]
);
expect(dirtyDiff).to.be.deep.equal(<DirtyDiff>{
changes: [
{
previousRange: { start: 1, end: 1 },
currentRange: { start: 1, end: 6 },
},
],
});
});
[1, 2, 3, 4, 5].forEach(lines => {
it(`add ${formatLines(lines)} after single line`, () => {
const dirtyDiff = computeDirtyDiff(
['0'],
['0'].concat(sequenceOfN(lines, () => 'ADDED LINE'))
);
expect(dirtyDiff).to.be.deep.equal(<DirtyDiff>{
changes: [
{
previousRange: { start: 1, end: 1 },
currentRange: { start: 1, end: lines + 1 },
},
],
});
});
});
it('modify single line', () => {
const dirtyDiff = computeDirtyDiff(
[
'FIRST',
'TO-BE-MODIFIED',
'LAST'
],
[
'FIRST',
'MODIFIED',
'LAST'
]
);
expect(dirtyDiff).to.be.deep.equal(<DirtyDiff>{
changes: [
{
previousRange: { start: 1, end: 2 },
currentRange: { start: 1, end: 2 },
},
],
});
});
it('modify all lines', () => {
const numberOfLines = 10;
const dirtyDiff = computeDirtyDiff(
sequenceOfN(numberOfLines, () => 'TO-BE-MODIFIED'),
sequenceOfN(numberOfLines, () => 'MODIFIED')
);
expect(dirtyDiff).to.be.deep.equal(<DirtyDiff>{
changes: [
{
previousRange: { start: 0, end: numberOfLines },
currentRange: { start: 0, end: numberOfLines },
},
],
});
});
it('modify lines at the end', () => {
const dirtyDiff = computeDirtyDiff(
[
'1',
'2',
'3',
'4'
],
[
'1',
'2-changed',
'3-changed'
]
);
expect(dirtyDiff).to.be.deep.equal(<DirtyDiff>{
changes: [
{
previousRange: { start: 1, end: 4 },
currentRange: { start: 1, end: 3 },
},
],
});
});
it('multiple diffs', () => {
const dirtyDiff = computeDirtyDiff(
[
'TO-BE-CHANGED',
'1',
'2',
'3',
'TO-BE-REMOVED',
'4',
'5',
'6',
'7',
'8',
'9'
],
[
'CHANGED',
'1',
'2',
'3',
'4',
'5',
'6',
'7',
'8',
'9',
'ADDED',
''
]
);
expect(dirtyDiff).to.be.deep.equal(<DirtyDiff>{
changes: [
{
previousRange: { start: 0, end: 1 },
currentRange: { start: 0, end: 1 },
},
{
previousRange: { start: 4, end: 5 },
currentRange: { start: 4, end: 4 },
},
{
previousRange: { start: 11, end: 11 },
currentRange: { start: 10, end: 12 },
},
],
});
});
it('multiple additions', () => {
const dirtyDiff = computeDirtyDiff(
[
'first line',
'',
'foo changed on master',
'bar changed on master',
'',
'',
'',
'',
'',
'last line'
],
[
'first line',
'',
'foo changed on master',
'bar changed on master',
'',
'NEW TEXT',
'',
'',
'',
'last line',
'',
''
]);
expect(dirtyDiff).to.be.deep.equal(<DirtyDiff>{
changes: [
{
previousRange: { start: 5, end: 5 },
currentRange: { start: 5, end: 6 },
},
{
previousRange: { start: 8, end: 8 },
currentRange: { start: 9, end: 10 },
},
{
previousRange: { start: 9, end: 10 },
currentRange: { start: 12, end: 12 },
},
],
});
});
});
function computeDirtyDiff(previous: string[], modified: string[]): DirtyDiff {
const a = ContentLines.arrayLike({
length: previous.length,
getLineContent: line => {
const value = previous[line];
if (value === undefined) {
console.log(undefined);
}
return value;
},
});
const b = ContentLines.arrayLike({
length: modified.length,
getLineContent: line => {
const value = modified[line];
if (value === undefined) {
console.log(undefined);
}
return value;
},
});
return diffComputer.computeDirtyDiff(a, b);
}
function sequenceOfN(n: number, mapFn: (index: number) => string = i => i.toString()): string[] {
return Array.from(new Array(n).keys()).map((value, index) => mapFn(index));
}
function formatLines(n: number): string {
return n + ' line' + (n > 1 ? 's' : '');
}
function insertIntoArray(target: string[], start: number, ...items: string[]): string[] {
const copy = target.slice(0);
copy.splice(start, 0, ...items);
return copy;
}

View File

@@ -0,0 +1,177 @@
// *****************************************************************************
// Copyright (C) 2018 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import * as jsdiff from 'diff';
import { ContentLinesArrayLike } from './content-lines';
import { Position, Range, uinteger } from '@theia/core/shared/vscode-languageserver-protocol';
export class DiffComputer {
computeDiff(previous: ContentLinesArrayLike, current: ContentLinesArrayLike): DiffResult[] {
const diffResult = diffArrays(previous, current);
return diffResult;
}
computeDirtyDiff(previous: ContentLinesArrayLike, current: ContentLinesArrayLike): DirtyDiff {
const changes: Change[] = [];
const diffResult = this.computeDiff(previous, current);
let currentRevisionLine = -1;
let previousRevisionLine = -1;
for (let i = 0; i < diffResult.length; i++) {
const change = diffResult[i];
const next = diffResult[i + 1];
if (change.added) {
// case: addition
changes.push({ previousRange: LineRange.createEmptyLineRange(previousRevisionLine + 1), currentRange: toLineRange(change) });
currentRevisionLine += change.count!;
} else if (change.removed && next && next.added) {
const isFirstChange = i === 0;
const isLastChange = i === diffResult.length - 2;
const isNextEmptyLine = next.value.length > 0 && current[next.value[0]].length === 0;
const isPrevEmptyLine = change.value.length > 0 && previous[change.value[0]].length === 0;
if (isFirstChange && isNextEmptyLine) {
// special case: removing at the beginning
changes.push({ previousRange: toLineRange(change), currentRange: LineRange.createEmptyLineRange(0) });
previousRevisionLine += change.count!;
} else if (isFirstChange && isPrevEmptyLine) {
// special case: adding at the beginning
changes.push({ previousRange: LineRange.createEmptyLineRange(0), currentRange: toLineRange(next) });
currentRevisionLine += next.count!;
} else if (isLastChange && isNextEmptyLine) {
changes.push({ previousRange: toLineRange(change), currentRange: LineRange.createEmptyLineRange(currentRevisionLine + 2) });
previousRevisionLine += change.count!;
} else {
// default case is a modification
changes.push({ previousRange: toLineRange(change), currentRange: toLineRange(next) });
currentRevisionLine += next.count!;
previousRevisionLine += change.count!;
}
i++; // consume next eagerly
} else if (change.removed && !(next && next.added)) {
// case: removal
changes.push({ previousRange: toLineRange(change), currentRange: LineRange.createEmptyLineRange(currentRevisionLine + 1) });
previousRevisionLine += change.count!;
} else {
// case: unchanged region
currentRevisionLine += change.count!;
previousRevisionLine += change.count!;
}
}
return { changes };
}
}
class ArrayDiff extends jsdiff.Diff {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
override tokenize(value: any): any {
return value;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
override join(value: any): any {
return value;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
override removeEmpty(value: any): any {
return value;
}
}
const arrayDiff = new ArrayDiff();
/**
* Computes diff without copying data.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function diffArrays(oldArr: ContentLinesArrayLike, newArr: ContentLinesArrayLike): DiffResult[] {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return arrayDiff.diff(oldArr as any, newArr as any) as any;
}
function toLineRange({ value }: DiffResult): LineRange {
const [start, end] = value;
return LineRange.create(start, end + 1);
}
export interface DiffResult {
value: [number, number];
count?: number;
added?: boolean;
removed?: boolean;
}
export interface DirtyDiff {
readonly changes: readonly Change[];
}
export interface Change {
readonly previousRange: LineRange;
readonly currentRange: LineRange;
}
export namespace Change {
export function isAddition(change: Change): boolean {
return LineRange.isEmpty(change.previousRange);
}
export function isRemoval(change: Change): boolean {
return LineRange.isEmpty(change.currentRange);
}
export function isModification(change: Change): boolean {
return !isAddition(change) && !isRemoval(change);
}
}
export interface LineRange {
readonly start: number;
readonly end: number;
}
export namespace LineRange {
export function create(start: number, end: number): LineRange {
if (start < 0 || end < 0 || start > end) {
throw new Error(`Invalid line range: { start: ${start}, end: ${end} }`);
}
return { start, end };
}
export function createSingleLineRange(line: number): LineRange {
return create(line, line + 1);
}
export function createEmptyLineRange(line: number): LineRange {
return create(line, line);
}
export function isEmpty(range: LineRange): boolean {
return range.start === range.end;
}
export function getStartPosition(range: LineRange): Position {
if (isEmpty(range)) {
return getEndPosition(range);
}
return Position.create(range.start, 0);
}
export function getEndPosition(range: LineRange): Position {
if (range.end < 1) {
return Position.create(0, 0);
}
return Position.create(range.end - 1, uinteger.MAX_VALUE);
}
export function toRange(range: LineRange): Range {
return Range.create(getStartPosition(range), getEndPosition(range));
}
export function getLineCount(range: LineRange): number {
return range.end - range.start;
}
}

View File

@@ -0,0 +1,114 @@
// *****************************************************************************
// Copyright (C) 2018 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { injectable } from '@theia/core/shared/inversify';
import {
EditorDecoration,
EditorDecorationOptions,
OverviewRulerLane,
EditorDecorator,
TextEditor,
MinimapPosition
} from '@theia/editor/lib/browser';
import { DirtyDiff, LineRange, Change } from './diff-computer';
import { URI } from '@theia/core';
export enum DirtyDiffDecorationType {
AddedLine = 'dirty-diff-added-line',
RemovedLine = 'dirty-diff-removed-line',
ModifiedLine = 'dirty-diff-modified-line',
}
const AddedLineDecoration = <EditorDecorationOptions>{
linesDecorationsClassName: 'dirty-diff-glyph dirty-diff-added-line',
overviewRuler: {
color: {
id: 'editorOverviewRuler.addedForeground'
},
position: OverviewRulerLane.Left,
},
minimap: {
color: {
id: 'minimapGutter.addedBackground'
},
position: MinimapPosition.Gutter
},
isWholeLine: true
};
const RemovedLineDecoration = <EditorDecorationOptions>{
linesDecorationsClassName: 'dirty-diff-glyph dirty-diff-removed-line',
overviewRuler: {
color: {
id: 'editorOverviewRuler.deletedForeground'
},
position: OverviewRulerLane.Left,
},
minimap: {
color: {
id: 'minimapGutter.deletedBackground'
},
position: MinimapPosition.Gutter
},
isWholeLine: false
};
const ModifiedLineDecoration = <EditorDecorationOptions>{
linesDecorationsClassName: 'dirty-diff-glyph dirty-diff-modified-line',
overviewRuler: {
color: {
id: 'editorOverviewRuler.modifiedForeground'
},
position: OverviewRulerLane.Left,
},
minimap: {
color: {
id: 'minimapGutter.modifiedBackground'
},
position: MinimapPosition.Gutter
},
isWholeLine: true
};
function getEditorDecorationOptions(change: Change): EditorDecorationOptions {
if (Change.isAddition(change)) {
return AddedLineDecoration;
}
if (Change.isRemoval(change)) {
return RemovedLineDecoration;
}
return ModifiedLineDecoration;
}
export interface DirtyDiffUpdate extends DirtyDiff {
readonly editor: TextEditor;
readonly previousRevisionUri?: URI;
}
@injectable()
export class DirtyDiffDecorator extends EditorDecorator {
applyDecorations(update: DirtyDiffUpdate): void {
const decorations = update.changes.map(change => this.toDeltaDecoration(change));
this.setDecorations(update.editor, decorations);
}
protected toDeltaDecoration(change: Change): EditorDecoration {
const range = LineRange.toRange(change.currentRange);
const options = getEditorDecorationOptions(change);
return { range, options };
}
}

View File

@@ -0,0 +1,33 @@
// *****************************************************************************
// Copyright (C) 2018 TypeFox and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { interfaces } from '@theia/core/shared/inversify';
import { DirtyDiffDecorator } from './dirty-diff-decorator';
import { DirtyDiffNavigator } from './dirty-diff-navigator';
import { DirtyDiffWidget, DirtyDiffWidgetFactory, DirtyDiffWidgetProps } from './dirty-diff-widget';
import '../../../src/browser/style/dirty-diff.css';
export function bindDirtyDiff(bind: interfaces.Bind): void {
bind(DirtyDiffDecorator).toSelf().inSingletonScope();
bind(DirtyDiffNavigator).toSelf().inSingletonScope();
bind(DirtyDiffWidgetFactory).toFactory(({ container }) => props => {
const child = container.createChild();
child.bind(DirtyDiffWidgetProps).toConstantValue(props);
child.bind(DirtyDiffWidget).toSelf();
return child.get(DirtyDiffWidget);
});
}

View File

@@ -0,0 +1,291 @@
// *****************************************************************************
// Copyright (C) 2023 1C-Soft LLC and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { inject, injectable, postConstruct } from '@theia/core/shared/inversify';
import { Disposable, DisposableCollection, URI } from '@theia/core';
import { ContextKey, ContextKeyService } from '@theia/core/lib/browser/context-key-service';
import { EditorManager, EditorMouseEvent, MouseTargetType, TextEditor } from '@theia/editor/lib/browser';
import { MonacoEditor } from '@theia/monaco/lib/browser/monaco-editor';
import { Change, LineRange } from './diff-computer';
import { DirtyDiffUpdate } from './dirty-diff-decorator';
import { DirtyDiffWidget, DirtyDiffWidgetFactory } from './dirty-diff-widget';
@injectable()
export class DirtyDiffNavigator {
protected readonly controllers = new Map<TextEditor, DirtyDiffController>();
@inject(ContextKeyService)
protected readonly contextKeyService: ContextKeyService;
@inject(EditorManager)
protected readonly editorManager: EditorManager;
@inject(DirtyDiffWidgetFactory)
protected readonly widgetFactory: DirtyDiffWidgetFactory;
@postConstruct()
protected init(): void {
const dirtyDiffVisible: ContextKey<boolean> = this.contextKeyService.createKey('dirtyDiffVisible', false);
this.editorManager.onActiveEditorChanged(editorWidget => {
dirtyDiffVisible.set(editorWidget && this.controllers.get(editorWidget.editor)?.isShowingChange());
});
this.editorManager.onCreated(editorWidget => {
const { editor } = editorWidget;
if (editor.uri.scheme !== 'file') {
return;
}
const controller = this.createController(editor);
controller.widgetFactory = props => {
const widget = this.widgetFactory(props);
if (widget.editor === this.editorManager.activeEditor?.editor) {
dirtyDiffVisible.set(true);
}
widget.onDidClose(() => {
if (widget.editor === this.editorManager.activeEditor?.editor) {
dirtyDiffVisible.set(false);
}
});
return widget;
};
this.controllers.set(editor, controller);
editorWidget.disposed.connect(() => {
this.controllers.delete(editor);
controller.dispose();
});
});
}
handleDirtyDiffUpdate(update: DirtyDiffUpdate): void {
const controller = this.controllers.get(update.editor);
controller?.handleDirtyDiffUpdate(update);
}
canNavigate(): boolean {
return !!this.activeController?.canNavigate();
}
gotoNextChange(): void {
this.activeController?.gotoNextChange();
}
gotoPreviousChange(): void {
this.activeController?.gotoPreviousChange();
}
canShowChange(): boolean {
return !!this.activeController?.canShowChange();
}
showNextChange(): void {
this.activeController?.showNextChange();
}
showPreviousChange(): void {
this.activeController?.showPreviousChange();
}
isShowingChange(): boolean {
return !!this.activeController?.isShowingChange();
}
closeChangePeekView(): void {
this.activeController?.closeWidget();
}
protected get activeController(): DirtyDiffController | undefined {
const editor = this.editorManager.activeEditor?.editor;
return editor && this.controllers.get(editor);
}
protected createController(editor: TextEditor): DirtyDiffController {
return new DirtyDiffController(editor);
}
}
export class DirtyDiffController implements Disposable {
protected readonly toDispose = new DisposableCollection();
widgetFactory?: DirtyDiffWidgetFactory;
protected widget?: DirtyDiffWidget;
protected dirtyDiff?: DirtyDiffUpdate;
constructor(protected readonly editor: TextEditor) {
editor.onMouseDown(this.handleEditorMouseDown, this, this.toDispose);
}
dispose(): void {
this.closeWidget();
this.toDispose.dispose();
}
handleDirtyDiffUpdate(dirtyDiff: DirtyDiffUpdate): void {
if (dirtyDiff.editor === this.editor) {
this.dirtyDiff = dirtyDiff;
if (this.widget) {
this.widget.changes = dirtyDiff.changes ;
}
}
}
canNavigate(): boolean {
return !!this.changes?.length;
}
gotoNextChange(): void {
const { editor } = this;
const index = this.findNextClosestChange(editor.cursor.line, false);
const change = this.changes?.[index];
if (change) {
const position = LineRange.getStartPosition(change.currentRange);
editor.cursor = position;
editor.revealPosition(position, { vertical: 'auto' });
}
}
gotoPreviousChange(): void {
const { editor } = this;
const index = this.findPreviousClosestChange(editor.cursor.line, false);
const change = this.changes?.[index];
if (change) {
const position = LineRange.getStartPosition(change.currentRange);
editor.cursor = position;
editor.revealPosition(position, { vertical: 'auto' });
}
}
canShowChange(): boolean {
return !!(this.widget || this.widgetFactory && this.editor instanceof MonacoEditor && this.changes?.length && this.previousRevisionUri);
}
showNextChange(): void {
if (this.widget) {
this.widget.showNextChange();
} else {
(this.widget = this.createWidget())?.showChange(
this.findNextClosestChange(this.editor.cursor.line, true));
}
}
showPreviousChange(): void {
if (this.widget) {
this.widget.showPreviousChange();
} else {
(this.widget = this.createWidget())?.showChange(
this.findPreviousClosestChange(this.editor.cursor.line, true));
}
}
isShowingChange(): boolean {
return !!this.widget;
}
closeWidget(): void {
if (this.widget) {
this.widget.dispose();
this.widget = undefined;
}
}
protected get changes(): readonly Change[] | undefined {
return this.dirtyDiff?.changes;
}
protected get previousRevisionUri(): URI | undefined {
return this.dirtyDiff?.previousRevisionUri;
}
protected createWidget(): DirtyDiffWidget | undefined {
const { widgetFactory, editor, changes, previousRevisionUri } = this;
if (widgetFactory && editor instanceof MonacoEditor && changes?.length && previousRevisionUri) {
const widget = widgetFactory({ editor, previousRevisionUri });
widget.changes = changes;
widget.onDidClose(() => {
this.widget = undefined;
});
return widget;
}
}
protected findNextClosestChange(line: number, inclusive: boolean): number {
const length = this.changes?.length;
if (!length) {
return -1;
}
for (let i = 0; i < length; i++) {
const { currentRange } = this.changes![i];
if (inclusive) {
if (LineRange.getEndPosition(currentRange).line >= line) {
return i;
}
} else {
if (LineRange.getStartPosition(currentRange).line > line) {
return i;
}
}
}
return 0;
}
protected findPreviousClosestChange(line: number, inclusive: boolean): number {
const length = this.changes?.length;
if (!length) {
return -1;
}
for (let i = length - 1; i >= 0; i--) {
const { currentRange } = this.changes![i];
if (inclusive) {
if (LineRange.getStartPosition(currentRange).line <= line) {
return i;
}
} else {
if (LineRange.getEndPosition(currentRange).line < line) {
return i;
}
}
}
return length - 1;
}
protected handleEditorMouseDown({ event, target }: EditorMouseEvent): void {
if (event.button !== 0) {
return;
}
const { range, type, element } = target;
if (!range || type !== MouseTargetType.GUTTER_LINE_DECORATIONS || !element || element.className.indexOf('dirty-diff-glyph') < 0) {
return;
}
const gutterOffsetX = target.detail.offsetX - (element as HTMLElement).offsetLeft;
if (gutterOffsetX < -3 || gutterOffsetX > 3) { // dirty diff decoration on hover is 6px wide
return; // to avoid colliding with folding
}
const index = this.findNextClosestChange(range.start.line, true);
if (index < 0) {
return;
}
if (index === this.widget?.currentChangeIndex) {
this.closeWidget();
return;
}
if (!this.widget) {
this.widget = this.createWidget();
}
this.widget?.showChange(index);
}
}

View File

@@ -0,0 +1,413 @@
// *****************************************************************************
// Copyright (C) 2023 1C-Soft LLC and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { inject, injectable, postConstruct } from '@theia/core/shared/inversify';
import { Position, Range } from '@theia/core/shared/vscode-languageserver-protocol';
import { CommandMenu, Disposable, Emitter, Event, MenuModelRegistry, MenuPath, URI, nls } from '@theia/core';
import { codicon } from '@theia/core/lib/browser';
import { ContextKeyService } from '@theia/core/lib/browser/context-key-service';
import { MonacoEditor } from '@theia/monaco/lib/browser/monaco-editor';
import { MonacoDiffEditor } from '@theia/monaco/lib/browser/monaco-diff-editor';
import { MonacoEditorProvider } from '@theia/monaco/lib/browser/monaco-editor-provider';
import { MonacoEditorPeekViewWidget, peekViewBorder, peekViewTitleBackground, peekViewTitleForeground, peekViewTitleInfoForeground }
from '@theia/monaco/lib/browser/monaco-editor-peek-view-widget';
import { Change, LineRange } from './diff-computer';
import { ScmColors } from '../scm-colors';
import * as monaco from '@theia/monaco-editor-core';
export const SCM_CHANGE_TITLE_MENU: MenuPath = ['scm-change-title-menu'];
/** Reserved for plugin contributions, corresponds to contribution point 'scm/change/title'. */
export const PLUGIN_SCM_CHANGE_TITLE_MENU: MenuPath = ['plugin-scm-change-title-menu'];
export const DirtyDiffWidgetProps = Symbol('DirtyDiffWidgetProps');
export interface DirtyDiffWidgetProps {
readonly editor: MonacoEditor;
readonly previousRevisionUri: URI;
}
export const DirtyDiffWidgetFactory = Symbol('DirtyDiffWidgetFactory');
export type DirtyDiffWidgetFactory = (props: DirtyDiffWidgetProps) => DirtyDiffWidget;
@injectable()
export class DirtyDiffWidget implements Disposable {
private readonly onDidCloseEmitter = new Emitter<unknown>();
readonly onDidClose: Event<unknown> = this.onDidCloseEmitter.event;
protected index: number = -1;
private peekView: DirtyDiffPeekView;
private diffEditorPromise: Promise<MonacoDiffEditor>;
protected _changes?: readonly Change[];
constructor(
@inject(DirtyDiffWidgetProps) protected readonly props: DirtyDiffWidgetProps,
@inject(MonacoEditorProvider) readonly editorProvider: MonacoEditorProvider,
@inject(ContextKeyService) readonly contextKeyService: ContextKeyService,
@inject(MenuModelRegistry) readonly menuModelRegistry: MenuModelRegistry,
) { }
@postConstruct()
create(): void {
this.peekView = new DirtyDiffPeekView(this);
this.peekView.onDidClose(e => this.onDidCloseEmitter.fire(e));
this.diffEditorPromise = this.peekView.create();
}
get changes(): readonly Change[] {
return this._changes ?? [];
}
set changes(changes: readonly Change[]) {
this.handleChangedChanges(changes);
}
get editor(): MonacoEditor {
return this.props.editor;
}
get uri(): URI {
return this.editor.uri;
}
get previousRevisionUri(): URI {
return this.props.previousRevisionUri;
}
get currentChange(): Change | undefined {
return this.changes[this.index];
}
get currentChangeIndex(): number {
return this.index;
}
protected handleChangedChanges(updated: readonly Change[]): void {
if (!updated.length) {
return this.dispose();
}
if (this.currentChange) {
const { previousRange: { start, end } } = this.currentChange;
// Same change or first after it.
const newIndex = updated.findIndex(candidate => (candidate.previousRange.start === start && candidate.previousRange.end === end)
|| candidate.previousRange.start > start);
if (newIndex !== -1) {
this.index = newIndex;
} else {
this.index = Math.min(this.index, updated.length - 1);
}
this.showCurrentChange();
} else {
this.index = -1;
}
this._changes = updated;
this.updateHeading();
}
async showChange(index: number): Promise<void> {
await this.checkCreated();
if (index >= 0 && index < this.changes.length) {
this.index = index;
this.showCurrentChange();
}
}
showNextChange(): void {
this.checkCreated();
const index = this.index;
const length = this.changes.length;
if (length > 0 && (index < 0 || length > 1)) {
this.index = index < 0 ? 0 : cycle(index, 1, length);
this.showCurrentChange();
}
}
showPreviousChange(): void {
this.checkCreated();
const index = this.index;
const length = this.changes.length;
if (length > 0 && (index < 0 || length > 1)) {
this.index = index < 0 ? length - 1 : cycle(index, -1, length);
this.showCurrentChange();
}
}
async getContentWithSelectedChanges(predicate: (change: Change, index: number, changes: readonly Change[]) => boolean): Promise<string> {
await this.checkCreated();
const changes = this.changes.filter(predicate);
const { diffEditor } = await this.diffEditorPromise!;
const diffEditorModel = diffEditor.getModel()!;
return applyChanges(changes, diffEditorModel.original, diffEditorModel.modified);
}
dispose(): void {
this.peekView?.dispose();
this.onDidCloseEmitter.dispose();
}
protected showCurrentChange(): void {
this.updateHeading();
const { previousRange, currentRange } = this.changes[this.index];
this.peekView.show(Position.create(LineRange.getEndPosition(currentRange).line, 0),
this.computeHeightInLines());
this.diffEditorPromise.then(({ diffEditor }) => {
let startLine = LineRange.getStartPosition(currentRange).line;
let endLine = LineRange.getEndPosition(currentRange).line;
if (LineRange.isEmpty(currentRange)) { // the change is a removal
++endLine;
} else if (!LineRange.isEmpty(previousRange)) { // the change is a modification
--startLine;
++endLine;
}
diffEditor.revealLinesInCenter(startLine + 1, endLine + 1, // monaco line numbers are 1-based
monaco.editor.ScrollType.Immediate);
});
this.editor.focus();
}
protected updateHeading(): void {
this.peekView.setTitle(this.computePrimaryHeading(), this.computeSecondaryHeading());
}
protected computePrimaryHeading(): string {
return this.uri.path.base;
}
protected computeSecondaryHeading(): string {
const index = this.index + 1;
const length = this.changes.length;
return length > 1 ? nls.localizeByDefault('{0} of {1} changes', index, length) :
nls.localizeByDefault('{0} of {1} change', index, length);
}
protected computeHeightInLines(): number {
const editor = this.editor.getControl();
const lineHeight = editor.getOption(monaco.editor.EditorOption.lineHeight);
const editorHeight = editor.getLayoutInfo().height;
const editorHeightInLines = Math.floor(editorHeight / lineHeight);
const { previousRange, currentRange } = this.changes[this.index];
const changeHeightInLines = LineRange.getLineCount(currentRange) + LineRange.getLineCount(previousRange);
return Math.min(changeHeightInLines + /* padding */ 8, Math.floor(editorHeightInLines / 3));
}
protected async checkCreated(): Promise<MonacoDiffEditor> {
return this.diffEditorPromise;
}
}
function cycle(index: number, offset: -1 | 1, length: number): number {
return (index + offset + length) % length;
}
// adapted from https://github.com/microsoft/vscode/blob/823d54f86ee13eb357bc6e8e562e89d793f3c43b/extensions/git/src/staging.ts
function applyChanges(changes: readonly Change[], original: monaco.editor.ITextModel, modified: monaco.editor.ITextModel): string {
const result: string[] = [];
let currentLine = 1;
for (const change of changes) {
const { previousRange, currentRange } = change;
const isInsertion = LineRange.isEmpty(previousRange);
const isDeletion = LineRange.isEmpty(currentRange);
const convert = (range: LineRange): [number, number] => {
let startLineNumber;
let endLineNumber;
if (!LineRange.isEmpty(range)) {
startLineNumber = range.start + 1;
endLineNumber = range.end;
} else {
startLineNumber = range.start;
endLineNumber = 0;
}
return [startLineNumber, endLineNumber];
};
const [originalStartLineNumber, originalEndLineNumber] = convert(previousRange);
const [modifiedStartLineNumber, modifiedEndLineNumber] = convert(currentRange);
let toLine = isInsertion ? originalStartLineNumber + 1 : originalStartLineNumber;
let toCharacter = 1;
// if this is a deletion at the very end of the document,
// we need to account for a newline at the end of the last line,
// which may have been deleted
if (isDeletion && originalEndLineNumber === original.getLineCount()) {
toLine--;
toCharacter = original.getLineMaxColumn(toLine);
}
result.push(original.getValueInRange(new monaco.Range(currentLine, 1, toLine, toCharacter)));
if (!isDeletion) {
let fromLine = modifiedStartLineNumber;
let fromCharacter = 1;
// if this is an insertion at the very end of the document,
// we must start the next range after the last character of the previous line,
// in order to take the correct eol
if (isInsertion && originalStartLineNumber === original.getLineCount()) {
fromLine--;
fromCharacter = modified.getLineMaxColumn(fromLine);
}
result.push(modified.getValueInRange(new monaco.Range(fromLine, fromCharacter, modifiedEndLineNumber + 1, 1)));
}
currentLine = isInsertion ? originalStartLineNumber + 1 : originalEndLineNumber + 1;
}
result.push(original.getValueInRange(new monaco.Range(currentLine, 1, original.getLineCount() + 1, 1)));
return result.join('');
}
class DirtyDiffPeekView extends MonacoEditorPeekViewWidget {
private diffEditor?: MonacoDiffEditor;
private height?: number;
constructor(readonly widget: DirtyDiffWidget) {
super(widget.editor, { isResizeable: true, showArrow: true, frameWidth: 1, keepEditorSelection: true, className: 'dirty-diff' });
}
override async create(): Promise<MonacoDiffEditor> {
try {
this.bodyElement = document.createElement('div');
this.bodyElement.classList.add('body');
const diffEditor = await this.widget.editorProvider.createEmbeddedDiffEditor(this.editor, this.bodyElement, this.widget.previousRevisionUri);
this.diffEditor = diffEditor;
this.toDispose.push(diffEditor);
super.create();
return new Promise(resolve => {
// The diff computation is asynchronous and may complete before or after we register the listener.
// This can happen when the file is already open in another editor, causing the model to be cached
// and the diff to compute almost instantly. To handle this race condition, we check if the diff
// is already available before waiting for onDidUpdateDiff.
// setTimeout is needed because the non-side-by-side diff editor might still not have created the view zones;
// otherwise, the first change shown might not be properly revealed in the diff editor.
// See also https://github.com/microsoft/vscode/blob/b30900b56c4b3ca6c65d7ab92032651f4cb23f15/src/vs/workbench/contrib/scm/browser/dirtydiffDecorator.ts#L248
if (diffEditor.diffEditor.getLineChanges()) {
setTimeout(() => resolve(diffEditor));
return;
}
const disposable = diffEditor.diffEditor.onDidUpdateDiff(() => setTimeout(() => {
resolve(diffEditor);
disposable.dispose();
}));
});
} catch (e) {
this.dispose();
throw e;
}
}
override show(rangeOrPos: Range | Position, heightInLines: number): void {
const borderColor = this.getBorderColor();
this.style({
arrowColor: borderColor,
frameColor: borderColor,
headerBackgroundColor: peekViewTitleBackground,
primaryHeadingColor: peekViewTitleForeground,
secondaryHeadingColor: peekViewTitleInfoForeground
});
this.updateActions();
super.show(rangeOrPos, heightInLines);
}
private getBorderColor(): string {
const { currentChange } = this.widget;
if (!currentChange) {
return peekViewBorder;
}
if (Change.isAddition(currentChange)) {
return ScmColors.editorGutterAddedBackground;
} else if (Change.isRemoval(currentChange)) {
return ScmColors.editorGutterDeletedBackground;
} else {
return ScmColors.editorGutterModifiedBackground;
}
}
private updateActions(): void {
this.clearActions();
const { contextKeyService, menuModelRegistry } = this.widget;
contextKeyService.with({ originalResourceScheme: this.widget.previousRevisionUri.scheme }, () => {
for (const menuPath of [SCM_CHANGE_TITLE_MENU, PLUGIN_SCM_CHANGE_TITLE_MENU]) {
const menu = menuModelRegistry.getMenu(menuPath);
if (menu) {
for (const item of menu.children) {
if (CommandMenu.is(item)) {
const { id, label, icon } = item;
const itemPath = [...menuPath, id];
if (icon && item.isVisible(itemPath, contextKeyService, undefined, this.widget)) {
// Close editor on successful contributed action.
// https://github.com/microsoft/vscode/blob/1.99.3/src/vs/workbench/contrib/scm/browser/quickDiffWidget.ts#L357-L361
this.addAction(id, label, icon, item.isEnabled(itemPath, this.widget), () => {
item.run(itemPath, this.widget).then(() => this.dispose());
});
}
}
}
}
}
});
this.addAction('dirtydiff.next', nls.localizeByDefault('Show Next Change'), codicon('arrow-down'), true,
() => this.widget.showNextChange());
this.addAction('dirtydiff.previous', nls.localizeByDefault('Show Previous Change'), codicon('arrow-up'), true,
() => this.widget.showPreviousChange());
this.addAction('peekview.close', nls.localizeByDefault('Close'), codicon('close'), true,
() => this.dispose());
}
protected override fillContainer(container: HTMLElement): void {
this.setCssClass('peekview-widget');
this.headElement = document.createElement('div');
this.headElement.classList.add('head');
container.appendChild(this.headElement);
container.appendChild(this.bodyElement!);
this.fillHead(this.headElement);
}
protected override fillHead(container: HTMLElement): void {
super.fillHead(container, true);
}
protected override doLayoutBody(height: number, width: number): void {
super.doLayoutBody(height, width);
this.layout(height, width);
this.height = height;
}
protected override onWidth(width: number): void {
super.onWidth(width);
const { height } = this;
if (height !== undefined) {
this.layout(height, width);
}
}
private layout(height: number, width: number): void {
this.diffEditor?.diffEditor.layout({ height, width });
}
protected override doRevealRange(range: Range): void {
this.editor.revealPosition(Position.create(range.end.line, 0), { vertical: 'centerIfOutsideViewport' });
}
}