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

10
packages/scm/.eslintrc.js Normal file
View File

@@ -0,0 +1,10 @@
/** @type {import('eslint').Linter.Config} */
module.exports = {
extends: [
'../../configs/build.eslintrc.json'
],
parserOptions: {
tsconfigRootDir: __dirname,
project: 'tsconfig.json'
}
};

32
packages/scm/README.md Normal file
View File

@@ -0,0 +1,32 @@
<div align='center'>
<br />
<img src='https://raw.githubusercontent.com/eclipse-theia/theia/master/logo/theia.svg?sanitize=true' alt='theia-ext-logo' width='100px' />
<h2>ECLIPSE THEIA - SCM EXTENSION</h2>
<hr />
</div>
## Description
The `@theia/scm` extension adds support for handling multiple source control managers, and includes a `SCM` (Source Control Manager) View which different source control providers (such as `Git` and `Mercurial`) can contribute to.
## Additional Information
- [API documentation for `@theia/scm`](https://eclipse-theia.github.io/theia/docs/next/modules/_theia_scm.html)
- [Theia - GitHub](https://github.com/eclipse-theia/theia)
- [Theia - Website](https://theia-ide.org/)
- [VS Code SCM Documentation](https://code.visualstudio.com/docs/editor/versioncontrol)
## License
- [Eclipse Public License 2.0](http://www.eclipse.org/legal/epl-2.0/)
- [一 (Secondary) GNU General Public License, version 2 with the GNU Classpath Exception](https://projects.eclipse.org/license/secondary-gpl-2.0-cp)
## Trademark
"Theia" is a trademark of the Eclipse Foundation
<https://www.eclipse.org/theia>

59
packages/scm/package.json Normal file
View File

@@ -0,0 +1,59 @@
{
"name": "@theia/scm",
"version": "1.68.0",
"description": "Theia - Source control Extension",
"dependencies": {
"@theia/core": "1.68.0",
"@theia/editor": "1.68.0",
"@theia/filesystem": "1.68.0",
"@theia/monaco": "1.68.0",
"@theia/monaco-editor-core": "1.96.302",
"@types/diff": "^5.2.1",
"diff": "^5.2.0",
"p-debounce": "^2.1.0",
"react-textarea-autosize": "^8.5.5",
"ts-md5": "^1.2.2",
"tslib": "^2.6.2"
},
"publishConfig": {
"access": "public"
},
"theiaExtensions": [
{
"frontend": "lib/browser/scm-frontend-module",
"backend": "lib/node/scm-backend-module"
}
],
"keywords": [
"theia-extension"
],
"license": "EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0",
"repository": {
"type": "git",
"url": "https://github.com/eclipse-theia/theia.git"
},
"bugs": {
"url": "https://github.com/eclipse-theia/theia/issues"
},
"homepage": "https://github.com/eclipse-theia/theia",
"files": [
"lib",
"src"
],
"scripts": {
"build": "theiaext build",
"clean": "theiaext clean",
"compile": "theiaext compile",
"docs": "theiaext docs",
"lint": "theiaext lint",
"test": "theiaext test",
"watch": "theiaext watch"
},
"devDependencies": {
"@theia/ext-scripts": "1.68.0"
},
"nyc": {
"extends": "../../configs/nyc.json"
},
"gitHead": "21358137e41342742707f660b8e222f940a27652"
}

View File

@@ -0,0 +1,124 @@
// *****************************************************************************
// Copyright (C) 2019 Red Hat, Inc. 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, inject } from '@theia/core/shared/inversify';
import { DisposableCollection, Emitter, Event, ResourceProvider } from '@theia/core';
import { DirtyDiffDecorator, DirtyDiffUpdate } from '../dirty-diff/dirty-diff-decorator';
import { DiffComputer } from '../dirty-diff/diff-computer';
import { ContentLines } from '../dirty-diff/content-lines';
import { EditorManager, EditorWidget, TextEditor } from '@theia/editor/lib/browser';
import { ScmService } from '../scm-service';
import throttle = require('@theia/core/shared/lodash.throttle');
@injectable()
export class ScmDecorationsService {
private readonly diffComputer = new DiffComputer();
protected readonly onDirtyDiffUpdateEmitter = new Emitter<DirtyDiffUpdate>();
readonly onDirtyDiffUpdate: Event<DirtyDiffUpdate> = this.onDirtyDiffUpdateEmitter.event;
constructor(
@inject(DirtyDiffDecorator) protected readonly decorator: DirtyDiffDecorator,
@inject(ScmService) protected readonly scmService: ScmService,
@inject(EditorManager) protected readonly editorManager: EditorManager,
@inject(ResourceProvider) protected readonly resourceProvider: ResourceProvider
) {
const updateTasks = new Map<EditorWidget, { (): void; cancel(): void }>();
this.editorManager.onCreated(editorWidget => {
const { editor } = editorWidget;
if (!this.supportsDirtyDiff(editor)) {
return;
}
const toDispose = new DisposableCollection();
const updateTask = this.createUpdateTask(editor);
updateTasks.set(editorWidget, updateTask);
toDispose.push(editor.onDocumentContentChanged(() => updateTask()));
toDispose.push(editorWidget.onDidChangeVisibility(visible => {
if (visible) {
updateTask();
}
}));
if (editor.onShouldDisplayDirtyDiffChanged) {
toDispose.push(editor.onShouldDisplayDirtyDiffChanged(shouldDisplayDirtyDiff => {
if (shouldDisplayDirtyDiff) {
updateTask();
} else {
const update: DirtyDiffUpdate = { editor, changes: [] };
this.decorator.applyDecorations(update);
this.onDirtyDiffUpdateEmitter.fire(update);
}
}));
}
editorWidget.disposed.connect(() => {
updateTask.cancel();
updateTasks.delete(editorWidget);
toDispose.dispose();
});
updateTask();
});
const runUpdateTasks = () => {
for (const updateTask of updateTasks.values()) {
updateTask();
}
};
this.scmService.onDidAddRepository(({ provider }) => {
provider.onDidChange(runUpdateTasks);
provider.onDidChangeResources?.(runUpdateTasks);
});
this.scmService.onDidChangeSelectedRepository(runUpdateTasks);
}
async applyEditorDecorations(editor: TextEditor): Promise<void> {
if (!editor.shouldDisplayDirtyDiff()) {
return;
}
const currentRepo = this.scmService.selectedRepository;
if (currentRepo) {
try {
// Currently, the uri used here is specific to vscode.git; other SCM providers are thus not supported.
// See https://github.com/eclipse-theia/theia/pull/13104#discussion_r1494540628 for a detailed discussion.
const query = { path: editor.uri['codeUri'].fsPath, ref: '~' };
const uri = editor.uri.withScheme(currentRepo.provider.id).withQuery(JSON.stringify(query));
const previousResource = await this.resourceProvider(uri);
try {
const previousContent = await previousResource.readContents();
if (!editor.shouldDisplayDirtyDiff()) { // check again; it might have changed in the meantime, since this is an async method
return;
}
const previousLines = ContentLines.fromString(previousContent);
const currentLines = ContentLines.fromTextEditorDocument(editor.document);
const dirtyDiff = this.diffComputer.computeDirtyDiff(ContentLines.arrayLike(previousLines), ContentLines.arrayLike(currentLines));
const update = { editor, previousRevisionUri: uri, ...dirtyDiff } satisfies DirtyDiffUpdate;
this.decorator.applyDecorations(update);
this.onDirtyDiffUpdateEmitter.fire(update);
} finally {
previousResource.dispose();
}
} catch (e) {
// Scm resource may not be found, do nothing.
}
}
}
protected supportsDirtyDiff(editor: TextEditor): boolean {
return editor.shouldDisplayDirtyDiff() || !!editor.onShouldDisplayDirtyDiffChanged;
}
protected createUpdateTask(editor: TextEditor): { (): void; cancel(): void; } {
return throttle(() => this.applyEditorDecorations(editor), 500);
}
}

View File

@@ -0,0 +1,121 @@
// *****************************************************************************
// Copyright (C) 2019 Red Hat, Inc. 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 } from '@theia/core/shared/inversify';
import { ILogger } from '@theia/core/lib/common/logger';
import { Event, Emitter } from '@theia/core/lib/common/event';
import { Tree } from '@theia/core/lib/browser/tree/tree';
import { TreeDecorator, TreeDecoration } from '@theia/core/lib/browser/tree/tree-decorator';
import { DepthFirstTreeIterator } from '@theia/core/lib/browser';
import { FileStatNode } from '@theia/filesystem/lib/browser';
import URI from '@theia/core/lib/common/uri';
import { ColorRegistry } from '@theia/core/lib/browser/color-registry';
import { Decoration, DecorationsService } from '@theia/core/lib/browser/decorations-service';
/**
* @deprecated since 1.25.0
* URI-based decorators should implement `DecorationsProvider` and contribute decorations via the `DecorationsService`.
*/
@injectable()
export class ScmNavigatorDecorator implements TreeDecorator {
readonly id = 'theia-scm-decorator';
private decorationsMap: Map<string, Decoration> | undefined;
@inject(ILogger) protected readonly logger: ILogger;
@inject(ColorRegistry)
protected readonly colors: ColorRegistry;
constructor(@inject(DecorationsService) protected readonly decorationsService: DecorationsService) {
this.decorationsService.onDidChangeDecorations(data => {
this.decorationsMap = data;
this.fireDidChangeDecorations((tree: Tree) => this.collectDecorators(tree));
});
}
protected collectDecorators(tree: Tree): Map<string, TreeDecoration.Data> {
const result = new Map();
if (tree.root === undefined || !this.decorationsMap) {
return result;
}
const markers = this.appendContainerChanges(this.decorationsMap);
for (const treeNode of new DepthFirstTreeIterator(tree.root)) {
const uri = FileStatNode.getUri(treeNode);
if (uri) {
const marker = markers.get(uri);
if (marker) {
result.set(treeNode.id, marker);
}
}
}
return new Map(Array.from(result.entries()).map(m => [m[0], this.toDecorator(m[1])] as [string, TreeDecoration.Data]));
}
protected toDecorator(change: Decoration): TreeDecoration.Data {
const colorVariable = change.colorId && this.colors.toCssVariableName(change.colorId);
return {
tailDecorations: [
{
data: change.letter ? change.letter : '',
fontData: {
color: colorVariable && `var(${colorVariable})`
},
tooltip: change.tooltip ? change.tooltip : ''
}
]
};
}
protected readonly emitter = new Emitter<(tree: Tree) => Map<string, TreeDecoration.Data>>();
async decorations(tree: Tree): Promise<Map<string, TreeDecoration.Data>> {
if (this.decorationsMap) {
return this.collectDecorators(tree);
} else {
return new Map();
}
}
protected appendContainerChanges(decorationsMap: Map<string, Decoration>): Map<string, Decoration> {
const result: Map<string, Decoration> = new Map();
for (const [uri, data] of decorationsMap.entries()) {
const uriString = uri.toString();
result.set(uriString, data);
let parentUri: URI | undefined = new URI(uri).parent;
while (parentUri && !parentUri.path.isRoot) {
const parentUriString = parentUri.toString();
const existing = result.get(parentUriString);
if (existing === undefined) {
result.set(parentUriString, data);
parentUri = parentUri.parent;
} else {
parentUri = undefined;
}
}
}
return result;
}
get onDidChangeDecorations(): Event<(tree: Tree) => Map<string, TreeDecoration.Data>> {
return this.emitter.event;
}
fireDidChangeDecorations(event: (tree: Tree) => Map<string, TreeDecoration.Data>): void {
this.emitter.fire(event);
}
}

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' });
}
}

View File

@@ -0,0 +1,346 @@
// *****************************************************************************
// Copyright (C) 2025 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 } from '@theia/core/shared/inversify';
import { Command, CommandContribution, CommandRegistry, MenuContribution, MenuModelRegistry, nls } from '@theia/core';
import { TabBarToolbarContribution, TabBarToolbarRegistry } from '@theia/core/lib/browser/shell/tab-bar-toolbar';
import {
ApplicationShell,
codicon,
ConfirmDialog,
FrontendApplicationContribution,
KeybindingContribution,
KeybindingRegistry,
LabelProvider
} from '@theia/core/lib/browser';
import { ColorContribution } from '@theia/core/lib/browser/color-application-contribution';
import { ColorRegistry } from '@theia/core/lib/browser/color-registry';
import { Color } from '@theia/core/lib/common/color';
import { ScmColors } from '../scm-colors';
import { MergeEditor, MergeEditorSettings } from './merge-editor';
export namespace MergeEditorCommands {
export const MERGE_EDITOR_CATEGORY = 'Merge Editor';
export const ACCEPT_MERGE = Command.toDefaultLocalizedCommand({
id: 'mergeEditor.acceptMerge', // don't change: this is an API command
label: 'Complete Merge',
category: MERGE_EDITOR_CATEGORY
});
export const GO_TO_NEXT_UNHANDLED_CONFLICT = Command.toDefaultLocalizedCommand({
id: 'mergeEditor.goToNextUnhandledConflict',
label: 'Go to Next Unhandled Conflict',
category: MERGE_EDITOR_CATEGORY
});
export const GO_TO_PREVIOUS_UNHANDLED_CONFLICT = Command.toDefaultLocalizedCommand({
id: 'mergeEditor.goToPreviousUnhandledConflict',
label: 'Go to Previous Unhandled Conflict',
category: MERGE_EDITOR_CATEGORY
});
export const SET_MIXED_LAYOUT = Command.toDefaultLocalizedCommand({
id: 'mergeEditor.setMixedLayout',
label: 'Mixed Layout',
category: MERGE_EDITOR_CATEGORY
});
export const SET_COLUMN_LAYOUT = Command.toDefaultLocalizedCommand({
id: 'mergeEditor.setColumnLayout',
label: 'Column Layout',
category: MERGE_EDITOR_CATEGORY
});
export const SHOW_BASE = Command.toDefaultLocalizedCommand({
id: 'mergeEditor.showBase',
label: 'Show Base',
category: MERGE_EDITOR_CATEGORY
});
export const SHOW_BASE_TOP = Command.toDefaultLocalizedCommand({
id: 'mergeEditor.showBaseTop',
label: 'Show Base Top',
category: MERGE_EDITOR_CATEGORY
});
export const SHOW_BASE_CENTER = Command.toDefaultLocalizedCommand({
id: 'mergeEditor.showBaseCenter',
label: 'Show Base Center',
category: MERGE_EDITOR_CATEGORY
});
}
@injectable()
export class MergeEditorContribution implements FrontendApplicationContribution,
CommandContribution, MenuContribution, TabBarToolbarContribution, KeybindingContribution, ColorContribution {
@inject(MergeEditorSettings)
protected readonly settings: MergeEditorSettings;
@inject(ApplicationShell)
protected readonly shell: ApplicationShell;
@inject(LabelProvider)
protected readonly labelProvider: LabelProvider;
onStart(): void {
this.settings.load();
}
onStop(): void {
this.settings.save();
}
protected getMergeEditor(widget = this.shell.currentWidget): MergeEditor | undefined {
return widget instanceof MergeEditor ? widget : (widget?.parent ? this.getMergeEditor(widget.parent) : undefined);
}
registerCommands(commands: CommandRegistry): void {
commands.registerCommand(MergeEditorCommands.ACCEPT_MERGE, {
execute: async widget => {
const editor = this.getMergeEditor(widget);
if (editor) {
let canceled = false;
if (editor.model.unhandledMergeRangesCount > 0) {
canceled = !(await new ConfirmDialog({
title: nls.localizeByDefault('Do you want to complete the merge of {0}?', this.labelProvider.getName(editor.resultUri)),
msg: nls.localizeByDefault('The file contains unhandled conflicts.'),
ok: nls.localizeByDefault('Complete with Conflicts')
}).open());
}
if (!canceled) {
await editor.model.resultDocument.save();
editor.close();
return {
successful: true
};
}
}
return {
successful: false
};
},
isEnabled: widget => !!this.getMergeEditor(widget),
isVisible: widget => !!this.getMergeEditor(widget)
});
commands.registerCommand(MergeEditorCommands.GO_TO_NEXT_UNHANDLED_CONFLICT, {
execute: widget => {
const editor = this.getMergeEditor(widget);
if (editor) {
editor.goToNextMergeRange(mergeRange => !editor.model.isMergeRangeHandled(mergeRange));
editor.activate();
}
},
isEnabled: widget => !!this.getMergeEditor(widget),
isVisible: widget => !!this.getMergeEditor(widget)
});
commands.registerCommand(MergeEditorCommands.GO_TO_PREVIOUS_UNHANDLED_CONFLICT, {
execute: widget => {
const editor = this.getMergeEditor(widget);
if (editor) {
editor.goToPreviousMergeRange(mergeRange => !editor.model.isMergeRangeHandled(mergeRange));
editor.activate();
}
},
isEnabled: widget => !!this.getMergeEditor(widget),
isVisible: widget => !!this.getMergeEditor(widget)
});
commands.registerCommand(MergeEditorCommands.SET_MIXED_LAYOUT, {
execute: widget => {
const editor = this.getMergeEditor(widget);
if (editor) {
editor.layoutKind = 'mixed';
editor.activate();
}
},
isEnabled: widget => !!this.getMergeEditor(widget),
isVisible: widget => !!this.getMergeEditor(widget),
isToggled: widget => this.getMergeEditor(widget)?.layoutKind === 'mixed',
});
commands.registerCommand(MergeEditorCommands.SET_COLUMN_LAYOUT, {
execute: widget => {
const editor = this.getMergeEditor(widget);
if (editor) {
editor.layoutKind = 'columns';
editor.activate();
}
},
isEnabled: widget => !!this.getMergeEditor(widget),
isVisible: widget => !!this.getMergeEditor(widget),
isToggled: widget => this.getMergeEditor(widget)?.layoutKind === 'columns'
});
commands.registerCommand(MergeEditorCommands.SHOW_BASE, {
execute: widget => {
const editor = this.getMergeEditor(widget);
if (editor) {
editor.toggleShowBase();
editor.activate();
}
},
isEnabled: widget => this.getMergeEditor(widget)?.layoutKind === 'columns',
isVisible: widget => this.getMergeEditor(widget)?.layoutKind === 'columns',
isToggled: widget => !!this.getMergeEditor(widget)?.isShowingBase
});
commands.registerCommand(MergeEditorCommands.SHOW_BASE_TOP, {
execute: widget => {
const editor = this.getMergeEditor(widget);
if (editor) {
editor.toggleShowBaseTop();
editor.activate();
}
},
isEnabled: widget => this.getMergeEditor(widget)?.layoutKind === 'mixed',
isVisible: widget => this.getMergeEditor(widget)?.layoutKind === 'mixed',
isToggled: widget => !!this.getMergeEditor(widget)?.isShowingBaseAtTop
});
commands.registerCommand(MergeEditorCommands.SHOW_BASE_CENTER, {
execute: widget => {
const editor = this.getMergeEditor(widget);
if (editor) {
editor.toggleShowBaseCenter();
editor.activate();
}
},
isEnabled: widget => this.getMergeEditor(widget)?.layoutKind === 'mixed',
isVisible: widget => this.getMergeEditor(widget)?.layoutKind === 'mixed',
isToggled: widget => {
const editor = this.getMergeEditor(widget);
return !!(editor?.isShowingBase && !editor.isShowingBaseAtTop);
}
});
}
registerMenus(menus: MenuModelRegistry): void {
}
registerToolbarItems(registry: TabBarToolbarRegistry): void {
registry.registerItem({
id: MergeEditorCommands.GO_TO_NEXT_UNHANDLED_CONFLICT.id,
command: MergeEditorCommands.GO_TO_NEXT_UNHANDLED_CONFLICT.id,
icon: codicon('arrow-down', true),
group: 'navigation',
order: 'a'
});
registry.registerItem({
id: MergeEditorCommands.GO_TO_PREVIOUS_UNHANDLED_CONFLICT.id,
command: MergeEditorCommands.GO_TO_PREVIOUS_UNHANDLED_CONFLICT.id,
icon: codicon('arrow-up', true),
group: 'navigation',
order: 'b'
});
registry.registerItem({
id: MergeEditorCommands.SET_MIXED_LAYOUT.id,
command: MergeEditorCommands.SET_MIXED_LAYOUT.id,
group: '1_merge',
order: 'a'
});
registry.registerItem({
id: MergeEditorCommands.SET_COLUMN_LAYOUT.id,
command: MergeEditorCommands.SET_COLUMN_LAYOUT.id,
group: '1_merge',
order: 'b'
});
registry.registerItem({
id: MergeEditorCommands.SHOW_BASE.id,
command: MergeEditorCommands.SHOW_BASE.id,
group: '2_merge',
order: 'a'
});
registry.registerItem({
id: MergeEditorCommands.SHOW_BASE_TOP.id,
command: MergeEditorCommands.SHOW_BASE_TOP.id,
group: '2_merge',
order: 'b'
});
registry.registerItem({
id: MergeEditorCommands.SHOW_BASE_CENTER.id,
command: MergeEditorCommands.SHOW_BASE_CENTER.id,
group: '2_merge',
order: 'c'
});
}
registerKeybindings(keybindings: KeybindingRegistry): void {
}
/**
* It should be aligned with https://code.visualstudio.com/api/references/theme-color#merge-conflicts-colors
*/
registerColors(colors: ColorRegistry): void {
colors.register({
id: 'mergeEditor.change.background',
description: 'The background color for changes.',
defaults: { dark: '#9bb95533', light: '#9bb95533', hcDark: '#9bb95533', hcLight: '#9bb95533' }
}, {
id: 'mergeEditor.change.word.background',
description: 'The background color for word changes.',
defaults: { dark: '#9ccc2c33', light: '#9ccc2c66', hcDark: '#9ccc2c33', hcLight: '#9ccc2c66' }
}, {
id: 'mergeEditor.changeBase.background',
description: 'The background color for changes in base.',
defaults: { dark: '#4B1818FF', light: '#FFCCCCFF', hcDark: '#4B1818FF', hcLight: '#FFCCCCFF' }
}, {
id: 'mergeEditor.changeBase.word.background',
description: 'The background color for word changes in base.',
defaults: { dark: '#6F1313FF', light: '#FFA3A3FF', hcDark: '#6F1313FF', hcLight: '#FFA3A3FF' }
}, {
id: 'mergeEditor.conflict.unhandledUnfocused.border',
description: 'The border color of unhandled unfocused conflicts.',
defaults: { dark: '#ffa6007a', light: '#ffa600FF', hcDark: '#ffa6007a', hcLight: '#ffa6007a' }
}, {
id: 'mergeEditor.conflict.unhandledUnfocused.background',
description: 'The background color of unhandled unfocused conflicts.',
defaults: {
dark: Color.transparent('mergeEditor.conflict.unhandledUnfocused.border', 0.05),
light: Color.transparent('mergeEditor.conflict.unhandledUnfocused.border', 0.05)
}
}, {
id: 'mergeEditor.conflict.unhandledFocused.border',
description: 'The border color of unhandled focused conflicts.',
defaults: { dark: '#ffa600', light: '#ffa600', hcDark: '#ffa600', hcLight: '#ffa600' }
}, {
id: 'mergeEditor.conflict.unhandledFocused.background',
description: 'The background color of unhandled focused conflicts.',
defaults: {
dark: Color.transparent('mergeEditor.conflict.unhandledFocused.border', 0.05),
light: Color.transparent('mergeEditor.conflict.unhandledFocused.border', 0.05)
}
}, {
id: 'mergeEditor.conflict.handledUnfocused.border',
description: 'The border color of handled unfocused conflicts.',
defaults: { dark: '#86868649', light: '#86868649', hcDark: '#86868649', hcLight: '#86868649' }
}, {
id: 'mergeEditor.conflict.handledUnfocused.background',
description: 'The background color of handled unfocused conflicts.',
defaults: {
dark: Color.transparent('mergeEditor.conflict.handledUnfocused.border', 0.1),
light: Color.transparent('mergeEditor.conflict.handledUnfocused.border', 0.1)
}
}, {
id: 'mergeEditor.conflict.handledFocused.border',
description: 'The border color of handled focused conflicts.',
defaults: { dark: '#c1c1c1cc', light: '#c1c1c1cc', hcDark: '#c1c1c1cc', hcLight: '#c1c1c1cc' }
}, {
id: 'mergeEditor.conflict.handledFocused.background',
description: 'The background color of handled focused conflicts.',
defaults: {
dark: Color.transparent('mergeEditor.conflict.handledFocused.border', 0.1),
light: Color.transparent('mergeEditor.conflict.handledFocused.border', 0.1)
}
}, {
id: ScmColors.handledConflictMinimapOverviewRulerColor,
description: 'Minimap gutter and overview ruler marker color for handled conflicts.',
defaults: { dark: '#adaca8ee', light: '#adaca8ee', hcDark: '#adaca8ee', hcLight: '#adaca8ee' }
}, {
id: ScmColors.unhandledConflictMinimapOverviewRulerColor,
description: 'Minimap gutter and overview ruler marker color for unhandled conflicts.',
defaults: { dark: '#fcba03FF', light: '#fcba03FF', hcDark: '#fcba03FF', hcLight: '#fcba03FF' }
});
}
}

View File

@@ -0,0 +1,154 @@
// *****************************************************************************
// Copyright (C) 2025 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 } from '@theia/core/shared/inversify';
import { Command, CommandContribution, CommandRegistry, DisposableCollection, generateUuid, InMemoryResources, MessageService, nls, QuickInputService, URI } from '@theia/core';
import { ApplicationShell, open, OpenerService } from '@theia/core/lib/browser';
import { ClipboardService } from '@theia/core/lib/browser/clipboard-service';
import { LanguageService } from '@theia/core/lib/browser/language-service';
import { MergeEditor, MergeEditorOpenerOptions, MergeEditorUri } from './merge-editor';
export namespace MergeEditorDevCommands {
export const MERGE_EDITOR_DEV_CATEGORY = 'Merge Editor (Dev)';
export const COPY_CONTENTS_TO_JSON = Command.toDefaultLocalizedCommand({
id: 'mergeEditor.dev.copyContentsToJSON',
label: 'Copy Merge Editor State as JSON',
category: MERGE_EDITOR_DEV_CATEGORY
});
export const OPEN_CONTENTS_FROM_JSON = Command.toDefaultLocalizedCommand({
id: 'mergeEditor.dev.openContentsFromJSON',
label: 'Open Merge Editor State from JSON',
category: MERGE_EDITOR_DEV_CATEGORY
});
}
@injectable()
export class MergeEditorDevContribution implements CommandContribution {
@inject(ApplicationShell)
protected readonly shell: ApplicationShell;
@inject(ClipboardService)
protected readonly clipboardService: ClipboardService;
@inject(MessageService)
protected readonly messageService: MessageService;
@inject(QuickInputService)
protected readonly quickInputService: QuickInputService;
@inject(LanguageService)
protected readonly languageService: LanguageService;
@inject(InMemoryResources)
protected readonly inMemoryResources: InMemoryResources;
@inject(OpenerService)
protected readonly openerService: OpenerService;
protected getMergeEditor(widget = this.shell.currentWidget): MergeEditor | undefined {
return widget instanceof MergeEditor ? widget : (widget?.parent ? this.getMergeEditor(widget.parent) : undefined);
}
registerCommands(commands: CommandRegistry): void {
commands.registerCommand(MergeEditorDevCommands.COPY_CONTENTS_TO_JSON, {
execute: widget => {
const editor = this.getMergeEditor(widget);
if (editor) {
this.copyContentsToJSON(editor);
}
},
isEnabled: widget => !!this.getMergeEditor(widget),
isVisible: widget => !!this.getMergeEditor(widget)
});
commands.registerCommand(MergeEditorDevCommands.OPEN_CONTENTS_FROM_JSON, {
execute: () => this.openContentsFromJSON().catch(error => this.messageService.error(error.message))
});
}
protected copyContentsToJSON(editor: MergeEditor): void {
const { model } = editor;
const editorContents: MergeEditorContents = {
base: model.baseDocument.getText(),
input1: model.side1Document.getText(),
input2: model.side2Document.getText(),
result: model.resultDocument.getText(),
languageId: model.resultDocument.getLanguageId()
};
this.clipboardService.writeText(JSON.stringify(editorContents, undefined, 2));
this.messageService.info(nls.localizeByDefault('Successfully copied merge editor state'));
}
protected async openContentsFromJSON(): Promise<void> {
const inputText = await this.quickInputService.input({
prompt: nls.localizeByDefault('Enter JSON'),
value: await this.clipboardService.readText()
});
if (!inputText) {
return;
}
const { base, input1, input2, result, languageId } = Object.assign<MergeEditorContents, unknown>({
base: '',
input1: '',
input2: '',
result: '',
languageId: 'plaintext'
}, JSON.parse(inputText));
const extension = Array.from(this.languageService.getLanguage(languageId!)?.extensions ?? [''])[0];
const parentUri = new URI('merge-editor-dev://' + generateUuid());
const baseUri = parentUri.resolve('base' + extension);
const side1Uri = parentUri.resolve('side1' + extension);
const side2Uri = parentUri.resolve('side2' + extension);
const resultUri = parentUri.resolve('result' + extension);
const toDispose = new DisposableCollection();
try {
toDispose.push(this.inMemoryResources.add(baseUri, base));
toDispose.push(this.inMemoryResources.add(side1Uri, input1));
toDispose.push(this.inMemoryResources.add(side2Uri, input2));
toDispose.push(this.inMemoryResources.add(resultUri, result));
const uri = MergeEditorUri.encode({ baseUri, side1Uri, side2Uri, resultUri });
const options: MergeEditorOpenerOptions = {
widgetState: {
side1State: {
title: 'Left',
description: '(from JSON)'
},
side2State: {
title: 'Right',
description: '(from JSON)'
}
}
};
await open(this.openerService, uri, options);
} finally {
toDispose.dispose();
}
}
}
export interface MergeEditorContents {
base: string;
input1: string;
input2: string;
result: string;
languageId?: string;
}

View File

@@ -0,0 +1,134 @@
// *****************************************************************************
// Copyright (C) 2025 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 '../../../src/browser/style/merge-editor.css';
import { Container, interfaces } from '@theia/core/shared/inversify';
import { CommandContribution, DisposableCollection, MenuContribution, URI } from '@theia/core';
import { TabBarToolbarContribution } from '@theia/core/lib/browser/shell/tab-bar-toolbar';
import { FrontendApplicationContribution, KeybindingContribution, NavigatableWidgetOptions, OpenHandler, WidgetFactory } from '@theia/core/lib/browser';
import { ColorContribution } from '@theia/core/lib/browser/color-application-contribution';
import { EditorManager, EditorWidget } from '@theia/editor/lib/browser';
import { MonacoEditor } from '@theia/monaco/lib/browser/monaco-editor';
import { MergeEditorModel, MergeEditorModelProps } from './model/merge-editor-model';
import { MergeEditorBasePane, MergeEditorPaneHeader, MergeEditorResultPane, MergeEditorSide1Pane, MergeEditorSide2Pane } from './view/merge-editor-panes';
import { DiffSpacerService } from './view/diff-spacers';
import { MergeEditorViewZoneComputer } from './view/merge-editor-view-zones';
import { MergeEditor, MergeEditorOpenHandler, MergeEditorSettings, MergeEditorUri, MergeUris } from './merge-editor';
import { MergeEditorContribution } from './merge-editor-contribution';
import { MergeEditorDevContribution } from './merge-editor-dev-contribution';
export function bindMergeEditor(bind: interfaces.Bind): void {
bind(MergeEditorSettings).toSelf().inSingletonScope();
bind(DiffSpacerService).toSelf().inSingletonScope();
bind(MergeEditorViewZoneComputer).toSelf().inSingletonScope();
bind(MergeEditorFactory).toDynamicValue(ctx => new MergeEditorFactory(ctx.container)).inSingletonScope();
bind(WidgetFactory).toDynamicValue(ctx => ({
id: MergeEditorOpenHandler.ID,
createWidget: (options: NavigatableWidgetOptions) => ctx.container.get(MergeEditorFactory).createMergeEditor(MergeEditorUri.decode(new URI(options.uri)))
})).inSingletonScope();
bind(MergeEditorOpenHandler).toSelf().inSingletonScope();
bind(OpenHandler).toService(MergeEditorOpenHandler);
bind(MergeEditorContribution).toSelf().inSingletonScope();
[FrontendApplicationContribution, CommandContribution, MenuContribution, TabBarToolbarContribution, KeybindingContribution, ColorContribution].forEach(serviceIdentifier =>
bind(serviceIdentifier).toService(MergeEditorContribution)
);
bind(MergeEditorDevContribution).toSelf().inSingletonScope();
bind(CommandContribution).toService(MergeEditorDevContribution);
}
export class MergeEditorFactory {
constructor(
protected readonly container: interfaces.Container,
protected readonly editorManager = container.get(EditorManager)
) { }
async createMergeEditor({ baseUri, side1Uri, side2Uri, resultUri }: MergeUris): Promise<MergeEditor> {
const toDisposeOnError = new DisposableCollection();
const createEditorWidget = (uri: URI) => this.createEditorWidget(uri, toDisposeOnError);
try {
const [baseEditorWidget, side1EditorWidget, side2EditorWidget, resultEditorWidget] = await Promise.all(
[createEditorWidget(baseUri), createEditorWidget(side1Uri), createEditorWidget(side2Uri), createEditorWidget(resultUri)]
);
const resultDocument = MonacoEditor.get(resultEditorWidget)!.document;
const hasConflictMarkers = resultDocument.textEditorModel.getLinesContent().some(lineContent => lineContent.startsWith('<<<<<<<'));
return this.createMergeEditorContainer({
baseEditorWidget,
side1EditorWidget,
side2EditorWidget,
resultEditorWidget,
options: {
resetResult: hasConflictMarkers
}
}).get(MergeEditor);
} catch (error) {
toDisposeOnError.dispose();
throw error;
}
}
protected async createEditorWidget(uri: URI, disposables: DisposableCollection): Promise<EditorWidget> {
const editorWidget = await this.editorManager.createByUri(uri);
disposables.push(editorWidget);
const editor = MonacoEditor.get(editorWidget);
if (!editor) {
throw new Error('The merge editor only supports Monaco editors as its parts');
}
editor.getControl().updateOptions({ folding: false, codeLens: false, minimap: { enabled: false } });
editor.setShouldDisplayDirtyDiff(false);
return editorWidget;
}
protected createMergeEditorContainer({
baseEditorWidget,
side1EditorWidget,
side2EditorWidget,
resultEditorWidget,
options
}: MergeEditorContainerProps): interfaces.Container {
const child = new Container({ defaultScope: 'Singleton' });
child.parent = this.container;
const [baseEditor, side1Editor, side2Editor, resultEditor] = [baseEditorWidget, side1EditorWidget, side2EditorWidget, resultEditorWidget].map(
editorWidget => MonacoEditor.get(editorWidget)!
);
child.bind(MergeEditorModelProps).toConstantValue({ baseEditor, side1Editor, side2Editor, resultEditor, options });
child.bind(MergeEditorModel).toSelf();
child.bind(MergeEditorPaneHeader).toSelf().inTransientScope();
child.bind(MergeEditorBasePane).toSelf();
child.bind(MergeEditorSide1Pane).toSelf();
child.bind(MergeEditorSide2Pane).toSelf();
child.bind(MergeEditorResultPane).toSelf();
child.bind(EditorWidget).toConstantValue(baseEditorWidget).whenInjectedInto(MergeEditorBasePane);
child.bind(EditorWidget).toConstantValue(side1EditorWidget).whenInjectedInto(MergeEditorSide1Pane);
child.bind(EditorWidget).toConstantValue(side2EditorWidget).whenInjectedInto(MergeEditorSide2Pane);
child.bind(EditorWidget).toConstantValue(resultEditorWidget).whenInjectedInto(MergeEditorResultPane);
child.bind(MergeEditor).toSelf();
return child;
}
}
export interface MergeEditorContainerProps {
baseEditorWidget: EditorWidget;
side1EditorWidget: EditorWidget;
side2EditorWidget: EditorWidget;
resultEditorWidget: EditorWidget;
options?: {
resetResult?: boolean;
}
}

View File

@@ -0,0 +1,648 @@
// *****************************************************************************
// Copyright (C) 2025 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 { ArrayUtils, Disposable, DisposableCollection, nls, URI } from '@theia/core';
import {
ApplicationShell, BaseWidget, FocusTracker, LabelProvider, Message, Navigatable, NavigatableWidgetOpenHandler, PanelLayout,
Saveable, SaveableSource, SplitPanel, StatefulWidget, StorageService, Widget, WidgetOpenerOptions
} from '@theia/core/lib/browser';
import { Autorun, DerivedObservable, Observable, ObservableUtils, SettableObservable } from '@theia/core/lib/common/observable';
import { Range } from '@theia/editor/lib/browser';
import { MergeRange } from './model/merge-range';
import { MergeEditorModel } from './model/merge-editor-model';
import { MergeEditorBasePane, MergeEditorPane, MergeEditorResultPane, MergeEditorSide1Pane, MergeEditorSide2Pane, MergeEditorSidePane } from './view/merge-editor-panes';
import { MergeEditorViewZone, MergeEditorViewZoneComputer } from './view/merge-editor-view-zones';
import { MergeEditorScrollSync } from './view/merge-editor-scroll-sync';
export interface MergeUris {
baseUri: URI;
side1Uri: URI;
side2Uri: URI;
resultUri: URI;
}
export namespace MergeEditorUri {
const SCHEME = 'merge-editor';
export function isMergeEditorUri(uri: URI): boolean {
return uri.scheme === SCHEME;
}
export function encode({ baseUri, side1Uri, side2Uri, resultUri }: MergeUris): URI {
return new URI().withScheme(SCHEME).withQuery(JSON.stringify([baseUri.toString(), side1Uri.toString(), side2Uri.toString(), resultUri.toString()]));
}
export function decode(uri: URI): MergeUris {
if (uri.scheme !== SCHEME) {
throw new Error(`The URI must have scheme ${SCHEME}. The URI was: ${uri}`);
}
const mergeUris = JSON.parse(uri.query);
if (!Array.isArray(mergeUris) || !mergeUris.every(mergeUri => typeof mergeUri === 'string')) {
throw new Error(`The URI ${uri} is not a valid URI for scheme ${SCHEME}`);
}
return {
baseUri: new URI(mergeUris[0]),
side1Uri: new URI(mergeUris[1]),
side2Uri: new URI(mergeUris[2]),
resultUri: new URI(mergeUris[3])
};
}
}
export type MergeEditorLayoutKind = 'mixed' | 'columns';
export interface MergeEditorLayoutMode {
readonly kind: MergeEditorLayoutKind;
readonly showBase: boolean;
readonly showBaseAtTop: boolean;
}
export namespace MergeEditorLayoutMode {
export const DEFAULT: MergeEditorLayoutMode = { kind: 'mixed', showBase: true, showBaseAtTop: false };
}
export interface MergeEditorSideWidgetState {
title?: string;
description?: string;
detail?: string;
}
export interface MergeEditorWidgetState {
layoutMode?: MergeEditorLayoutMode;
side1State?: MergeEditorSideWidgetState;
side2State?: MergeEditorSideWidgetState;
}
@injectable()
export class MergeEditorSettings {
protected static LAYOUT_MODE = 'mergeEditor/layoutMode';
@inject(StorageService)
protected readonly storageService: StorageService;
layoutMode = MergeEditorLayoutMode.DEFAULT;
async load(): Promise<void> {
await Promise.allSettled([
this.storageService.getData(MergeEditorSettings.LAYOUT_MODE, this.layoutMode).then(
layoutMode => this.layoutMode = layoutMode
),
]);
}
async save(): Promise<void> {
await Promise.allSettled([
this.storageService.setData(MergeEditorSettings.LAYOUT_MODE, this.layoutMode),
]);
}
}
@injectable()
export class MergeEditor extends BaseWidget implements StatefulWidget, SaveableSource, Navigatable, ApplicationShell.TrackableWidgetProvider {
@inject(MergeEditorModel)
readonly model: MergeEditorModel;
@inject(MergeEditorBasePane)
readonly basePane: MergeEditorBasePane;
@inject(MergeEditorSide1Pane)
readonly side1Pane: MergeEditorSide1Pane;
@inject(MergeEditorSide2Pane)
readonly side2Pane: MergeEditorSide2Pane;
@inject(MergeEditorResultPane)
readonly resultPane: MergeEditorResultPane;
@inject(MergeEditorViewZoneComputer)
protected readonly viewZoneComputer: MergeEditorViewZoneComputer;
@inject(MergeEditorSettings)
protected readonly settings: MergeEditorSettings;
@inject(LabelProvider)
protected readonly labelProvider: LabelProvider;
protected readonly visibilityObservable = SettableObservable.create(true);
protected readonly currentPaneObservable = SettableObservable.create<MergeEditorPane | undefined>(undefined);
protected readonly layoutModeObservable = SettableObservable.create(MergeEditorLayoutMode.DEFAULT, {
isEqual: (a, b) => JSON.stringify(a) === JSON.stringify(b)
});
protected readonly currentMergeRangeObservable = this.createCurrentMergeRangeObservable();
protected readonly selectionInBaseObservable = this.createSelectionInBaseObservable();
protected verticalSplitPanel: SplitPanel;
protected horizontalSplitPanel: SplitPanel;
protected scrollSync: MergeEditorScrollSync;
@postConstruct()
protected init(): void {
this.addClass('theia-merge-editor');
const { baseUri, side1Uri, side2Uri, resultUri } = this;
this.id = MergeEditorUri.encode({ baseUri, side1Uri, side2Uri, resultUri }).toString();
const setLabels = () => {
this.title.label = nls.localizeByDefault('Merging: {0}', this.labelProvider.getName(resultUri));
this.title.iconClass = this.labelProvider.getIcon(resultUri) + ' file-icon';
this.resultPane.header.description = this.labelProvider.getLongName(resultUri);
};
setLabels();
this.toDispose.push(this.labelProvider.onDidChange(event => {
if (event.affects(resultUri)) {
setLabels();
}
}));
this.title.caption = resultUri.path.fsPath();
this.title.closable = true;
this.basePane.header.title.label = nls.localizeByDefault('Base');
this.side1Pane.header.title.label = nls.localizeByDefault('Input 1');
this.side2Pane.header.title.label = nls.localizeByDefault('Input 2');
this.resultPane.header.title.label = nls.localizeByDefault('Result');
this.panes.forEach(pane => pane.mergeEditor = this);
const layout = this.layout = new PanelLayout();
this.verticalSplitPanel = new SplitPanel({
spacing: 1, // --theia-border-width
orientation: 'vertical'
});
layout.addWidget(this.verticalSplitPanel);
this.horizontalSplitPanel = new SplitPanel({
spacing: 1, // --theia-border-width
orientation: 'horizontal'
});
this.verticalSplitPanel.addWidget(this.horizontalSplitPanel);
this.layoutMode = this.settings.layoutMode;
this.toDispose.push(this.scrollSync = this.createScrollSynchronizer());
this.initCurrentPaneTracker();
}
protected createScrollSynchronizer(): MergeEditorScrollSync {
return new MergeEditorScrollSync(this);
}
protected initCurrentPaneTracker(): void {
const focusTracker = new FocusTracker<MergeEditorPane>();
this.toDispose.push(focusTracker);
focusTracker.currentChanged.connect((_, { oldValue, newValue }) => {
oldValue?.removeClass('focused');
newValue?.addClass('focused');
this.currentPaneObservable.set(newValue || undefined);
});
this.panes.forEach(pane => focusTracker.add(pane));
}
protected layoutInitialized = false;
protected ensureLayoutInitialized(): void {
if (!this.layoutInitialized) {
this.layoutInitialized = true;
this.doInitializeLayout();
this.onLayoutInitialized();
}
}
protected doInitializeLayout(): void {
this.toDispose.push(Autorun.create(({ isFirstRun }) => {
const { layoutMode } = this;
const scrollState = this.scrollSync.storeScrollState();
const currentPane = this.currentPaneObservable.getUntracked();
this.applyLayoutMode(layoutMode);
const pane = currentPane?.isVisible ? currentPane : this.resultPane;
this.currentPaneObservable.set(pane);
pane.activate();
this.scrollSync.restoreScrollState(scrollState);
if (!isFirstRun) {
this.settings.layoutMode = layoutMode;
}
}));
let storedState: {
scrollState: unknown;
currentPane: MergeEditorPane | undefined;
} | undefined;
this.toDispose.push(ObservableUtils.autorunWithDisposables(({ toDispose }) => {
if (this.isShown) {
toDispose.push(this.createViewZones());
if (storedState) {
const { currentPane, scrollState } = storedState;
storedState = undefined;
const pane = currentPane ?? this.resultPane;
this.currentPaneObservable.set(pane);
pane.activate();
this.scrollSync.restoreScrollState(scrollState);
} else {
this.scrollSync.update();
}
} else {
storedState = {
scrollState: this.scrollSync.storeScrollState(),
currentPane: this.currentPaneObservable.getUntracked()
};
}
}));
}
protected onLayoutInitialized(): void {
const shouldGoToInitialMergeRange = () => {
const { cursorPosition } = this.currentPane ?? this.resultPane;
return cursorPosition.line === 0 && cursorPosition.character === 0;
};
if (shouldGoToInitialMergeRange()) {
this.model.onInitialized.then(() => {
if (!this.isDisposed && shouldGoToInitialMergeRange()) {
this.goToFirstMergeRange(mergeRange => !this.model.isMergeRangeHandled(mergeRange));
}
});
}
}
protected override onResize(msg: Widget.ResizeMessage): void {
super.onResize(msg);
if (msg.width >= 0 && msg.height >= 0) {
// Don't try to initialize layout until the merge editor itself is positioned.
// Otherwise, SplitPanel.setRelativeSizes might not work properly when initializing layout.
this.ensureLayoutInitialized();
}
}
get isShown(): boolean {
return this.visibilityObservable.get();
}
get currentPane(): MergeEditorPane | undefined {
return this.currentPaneObservable.get();
}
protected createCurrentMergeRangeObservable(): Observable<MergeRange | undefined> {
return DerivedObservable.create(() => {
const { currentPane } = this;
if (!currentPane) {
return undefined;
}
const { cursorLine } = currentPane;
return this.model.mergeRanges.find(mergeRange => {
const lineRange = currentPane.getLineRangeForMergeRange(mergeRange);
return lineRange.isEmpty ? lineRange.startLineNumber === cursorLine : lineRange.containsLine(cursorLine);
});
});
}
get currentMergeRange(): MergeRange | undefined {
return this.currentMergeRangeObservable.get();
}
protected createSelectionInBaseObservable(): Observable<Range[] | undefined> {
return DerivedObservable.create(() => {
const { currentPane } = this;
return currentPane?.selection?.map(range => {
if (currentPane === this.side1Pane) {
return this.model.translateSideRangeToBase(range, 1);
}
if (currentPane === this.side2Pane) {
return this.model.translateSideRangeToBase(range, 2);
}
if (currentPane === this.resultPane) {
return this.model.translateResultRangeToBase(range);
}
return range;
});
});
}
get selectionInBase(): Range[] | undefined {
return this.selectionInBaseObservable.get();
}
get panes(): MergeEditorPane[] {
return [this.basePane, this.side1Pane, this.side2Pane, this.resultPane];
}
get baseUri(): URI {
return this.basePane.editor.uri;
}
get side1Uri(): URI {
return this.side1Pane.editor.uri;
}
get side1Title(): string {
return this.side1Pane.header.title.label;
}
get side2Uri(): URI {
return this.side2Pane.editor.uri;
}
get side2Title(): string {
return this.side2Pane.header.title.label;
}
get resultUri(): URI {
return this.resultPane.editor.uri;
}
storeState(): MergeEditorWidgetState {
const getSideState = ({ header }: MergeEditorSidePane): MergeEditorSideWidgetState => ({
title: header.title.label,
description: header.description,
detail: header.detail
});
return {
layoutMode: this.layoutMode,
side1State: getSideState(this.side1Pane),
side2State: getSideState(this.side2Pane)
};
}
restoreState(state: MergeEditorWidgetState): void {
const { layoutMode, side1State, side2State } = state;
if (layoutMode) {
this.layoutMode = layoutMode;
}
const restoreSideState = ({ header }: MergeEditorSidePane, { title, description, detail }: MergeEditorSideWidgetState) => {
if (title) {
header.title.label = title;
}
if (description) {
header.description = description;
}
if (detail) {
header.detail = detail;
}
};
if (side1State) {
restoreSideState(this.side1Pane, side1State);
}
if (side2State) {
restoreSideState(this.side2Pane, side2State);
}
}
get saveable(): Saveable {
return this.resultPane.editor.document;
}
getResourceUri(): URI | undefined {
return this.resultUri;
}
createMoveToUri(resourceUri: URI): URI | undefined {
const { baseUri, side1Uri, side2Uri, resultUri } = this;
return MergeEditorUri.encode({ baseUri, side1Uri, side2Uri, resultUri: resultUri.withPath(resourceUri.path) });
}
getTrackableWidgets(): Widget[] {
return this.panes.map(pane => pane.editorWidget);
}
goToFirstMergeRange(predicate: (mergeRange: MergeRange) => boolean = () => true): void {
const firstMergeRange = this.model.mergeRanges.find(mergeRange => predicate(mergeRange));
if (firstMergeRange) {
const pane = this.currentPane ?? this.resultPane;
pane.goToMergeRange(firstMergeRange);
}
}
goToNextMergeRange(predicate: (mergeRange: MergeRange) => boolean = () => true): void {
const pane = this.currentPane ?? this.resultPane;
const { cursorLine } = pane;
const isAfterCursorLine = (mergeRange: MergeRange) => pane.getLineRangeForMergeRange(mergeRange).startLineNumber > cursorLine;
const nextMergeRange =
this.model.mergeRanges.find(mergeRange => predicate(mergeRange) && isAfterCursorLine(mergeRange)) ||
this.model.mergeRanges.find(mergeRange => predicate(mergeRange));
if (nextMergeRange) {
pane.goToMergeRange(nextMergeRange);
}
}
goToPreviousMergeRange(predicate: (mergeRange: MergeRange) => boolean = () => true): void {
const pane = this.currentPane ?? this.resultPane;
const { cursorLine } = pane;
const isBeforeCursorLine = (mergeRange: MergeRange) => {
const lineRange = pane.getLineRangeForMergeRange(mergeRange);
return lineRange.isEmpty ? lineRange.startLineNumber < cursorLine : lineRange.endLineNumberExclusive <= cursorLine;
};
const previousMergeRange =
ArrayUtils.findLast(this.model.mergeRanges, mergeRange => predicate(mergeRange) && isBeforeCursorLine(mergeRange)) ||
ArrayUtils.findLast(this.model.mergeRanges, mergeRange => predicate(mergeRange));
if (previousMergeRange) {
pane.goToMergeRange(previousMergeRange);
}
}
get layoutMode(): MergeEditorLayoutMode {
return this.layoutModeObservable.get();
}
set layoutMode(value: MergeEditorLayoutMode) {
this.layoutModeObservable.set(value);
}
get layoutKind(): MergeEditorLayoutKind {
return this.layoutMode.kind;
}
set layoutKind(kind: MergeEditorLayoutKind) {
this.layoutMode = {
...this.layoutMode,
kind
};
}
get isShowingBase(): boolean {
return this.layoutMode.showBase;
}
get isShowingBaseAtTop(): boolean {
const { layoutMode } = this;
return layoutMode.showBase && layoutMode.showBaseAtTop;
}
toggleShowBase(): void {
const { layoutMode } = this;
this.layoutMode = {
...layoutMode,
showBase: !layoutMode.showBase
};
}
toggleShowBaseTop(): void {
const { layoutMode } = this;
const isToggled = layoutMode.showBase && layoutMode.showBaseAtTop;
this.layoutMode = {
...layoutMode,
showBaseAtTop: true,
showBase: !isToggled,
};
}
toggleShowBaseCenter(): void {
const { layoutMode } = this;
const isToggled = layoutMode.showBase && !layoutMode.showBaseAtTop;
this.layoutMode = {
...layoutMode,
showBaseAtTop: false,
showBase: !isToggled,
};
}
get shouldAlignResult(): boolean {
return this.layoutKind === 'columns';
}
get shouldAlignBase(): boolean {
const { layoutMode } = this;
return layoutMode.kind === 'mixed' && layoutMode.showBase && !layoutMode.showBaseAtTop;
}
protected applyLayoutMode(layoutMode: MergeEditorLayoutMode): void {
const oldVerticalSplitWidgets = [...this.verticalSplitPanel.widgets];
if (!layoutMode.showBase) {
// eslint-disable-next-line no-null/no-null
this.basePane.parent = null;
}
this.horizontalSplitPanel.insertWidget(0, this.side1Pane);
this.horizontalSplitPanel.insertWidget(2, this.side2Pane);
let horizontalSplitRatio = [50, 50];
let verticalSplitRatio: number[];
if (layoutMode.kind === 'columns') {
horizontalSplitRatio = [33, 34, 33];
verticalSplitRatio = [100];
this.horizontalSplitPanel.insertWidget(1, this.resultPane);
if (layoutMode.showBase) {
verticalSplitRatio = [30, 70];
this.verticalSplitPanel.insertWidget(0, this.basePane);
}
} else {
verticalSplitRatio = [45, 55];
if (layoutMode.showBase) {
if (layoutMode.showBaseAtTop) {
verticalSplitRatio = [30, 33, 37];
this.verticalSplitPanel.insertWidget(0, this.basePane);
} else {
horizontalSplitRatio = [33, 34, 33];
this.horizontalSplitPanel.insertWidget(1, this.basePane);
}
}
this.verticalSplitPanel.insertWidget(2, this.resultPane);
}
this.horizontalSplitPanel.setRelativeSizes(horizontalSplitRatio);
// Keep the existing vertical split ratio if the layout mode change has not affected the vertical split layout.
if (!ArrayUtils.equals(oldVerticalSplitWidgets, this.verticalSplitPanel.widgets)) {
this.verticalSplitPanel.setRelativeSizes(verticalSplitRatio);
}
}
protected createViewZones(): Disposable {
const { baseViewZones, side1ViewZones, side2ViewZones, resultViewZones } = this.viewZoneComputer.computeViewZones(this);
const toDispose = new DisposableCollection();
const addViewZones = (pane: MergeEditorPane, viewZones: readonly MergeEditorViewZone[]) => {
const editor = pane.editor.getControl();
const viewZoneIds: string[] = [];
toDispose.push(Disposable.create(() => {
editor.changeViewZones(accessor => {
for (const viewZoneId of viewZoneIds) {
accessor.removeZone(viewZoneId);
}
});
}));
editor.changeViewZones(accessor => {
const ctx: MergeEditorViewZone.CreationContext = {
createViewZone: viewZone => viewZoneIds.push(accessor.addZone(viewZone)),
register: disposable => toDispose.push(disposable)
};
for (const viewZone of viewZones) {
viewZone.create(ctx);
}
});
};
addViewZones(this.basePane, baseViewZones);
addViewZones(this.side1Pane, side1ViewZones);
addViewZones(this.side2Pane, side2ViewZones);
addViewZones(this.resultPane, resultViewZones);
return toDispose;
}
protected override onBeforeHide(msg: Message): void {
this.visibilityObservable.set(false);
}
protected override onAfterShow(msg: Message): void {
this.visibilityObservable.set(true);
}
protected override onActivateRequest(msg: Message): void {
super.onActivateRequest(msg);
const { currentPane } = this;
if (currentPane) {
currentPane.activate();
} else {
this.resultPane.activate();
}
}
}
export interface MergeEditorOpenerOptions extends WidgetOpenerOptions {
widgetState?: MergeEditorWidgetState;
}
@injectable()
export class MergeEditorOpenHandler extends NavigatableWidgetOpenHandler<MergeEditor> {
static readonly ID = 'merge-editor-opener';
readonly id = MergeEditorOpenHandler.ID;
readonly label = nls.localizeByDefault('Merge Editor');
override canHandle(uri: URI, options?: MergeEditorOpenerOptions): number {
return MergeEditorUri.isMergeEditorUri(uri) ? 1000 : 0;
}
override open(uri: URI, options?: MergeEditorOpenerOptions): Promise<MergeEditor> {
return super.open(uri, options);
}
protected override async getOrCreateWidget(uri: URI, options?: MergeEditorOpenerOptions): Promise<MergeEditor> {
const widget = await super.getOrCreateWidget(uri, options);
if (options?.widgetState) {
widget.restoreState(options.widgetState);
}
return widget;
}
}

View File

@@ -0,0 +1,128 @@
// *****************************************************************************
// Copyright (C) 2025 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
// *****************************************************************************
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
// copied and modified from https://github.com/microsoft/vscode/blob/1.96.3/src/vs/workbench/contrib/mergeEditor/browser/model/lineRange.ts
import { Range, uinteger } from '@theia/core/shared/vscode-languageserver-protocol';
import { TextEditorDocument } from '@theia/editor/lib/browser/editor';
/**
* Represents a range of whole lines of text. Line numbers are zero-based.
*/
export class LineRange {
static compareByStart(a: LineRange, b: LineRange): number {
return a.startLineNumber - b.startLineNumber;
}
static fromLineNumbers(startLineNumber: number, endExclusiveLineNumber: number): LineRange {
return new LineRange(startLineNumber, endExclusiveLineNumber - startLineNumber);
}
constructor(
/** A zero-based number of the start line. The range starts exactly at the beginning of this line. */
readonly startLineNumber: number,
readonly lineCount: number
) {
if (startLineNumber < 0 || lineCount < 0) {
throw new Error('Invalid line range: ' + this.toString());
}
}
join(other: LineRange): LineRange {
return LineRange.fromLineNumbers(Math.min(this.startLineNumber, other.startLineNumber), Math.max(this.endLineNumberExclusive, other.endLineNumberExclusive));
}
/** A zero-based number of the end line. The range ends just before the beginning of this line. */
get endLineNumberExclusive(): number {
return this.startLineNumber + this.lineCount;
}
get isEmpty(): boolean {
return this.lineCount === 0;
}
/**
* Returns `false` iff there is at least one line between `this` and `other`.
*/
touches(other: LineRange): boolean {
return this.startLineNumber <= other.endLineNumberExclusive && other.startLineNumber <= this.endLineNumberExclusive;
}
isAfter(other: LineRange): boolean {
return this.startLineNumber >= other.endLineNumberExclusive;
}
isBefore(other: LineRange): boolean {
return other.startLineNumber >= this.endLineNumberExclusive;
}
delta(lineDelta: number): LineRange {
return new LineRange(this.startLineNumber + lineDelta, this.lineCount);
}
deltaStart(lineDelta: number): LineRange {
return new LineRange(this.startLineNumber + lineDelta, this.lineCount - lineDelta);
}
deltaEnd(lineDelta: number): LineRange {
return new LineRange(this.startLineNumber, this.lineCount + lineDelta);
}
toString(): string {
return `[${this.startLineNumber},${this.endLineNumberExclusive})`;
}
equals(other: LineRange): boolean {
return this.startLineNumber === other.startLineNumber && this.lineCount === other.lineCount;
}
contains(other: LineRange): boolean {
return this.startLineNumber <= other.startLineNumber && other.endLineNumberExclusive <= this.endLineNumberExclusive;
}
containsLine(lineNumber: number): boolean {
return this.startLineNumber <= lineNumber && lineNumber < this.endLineNumberExclusive;
}
getLines(document: TextEditorDocument): string[] {
const result = new Array(this.lineCount);
for (let i = 0; i < this.lineCount; i++) {
result[i] = document.getLineContent(this.startLineNumber + i + 1); // note that getLineContent expects a one-based line number
}
return result;
}
toRange(): Range {
return Range.create(this.startLineNumber, 0, this.endLineNumberExclusive, 0);
}
toInclusiveRange(): Range | undefined {
if (this.isEmpty) {
return undefined;
}
return Range.create(this.startLineNumber, 0, this.endLineNumberExclusive - 1, uinteger.MAX_VALUE);
}
toInclusiveRangeOrEmpty(): Range {
if (this.isEmpty) {
return Range.create(this.startLineNumber, 0, this.startLineNumber, 0);
}
return Range.create(this.startLineNumber, 0, this.endLineNumberExclusive - 1, uinteger.MAX_VALUE);
}
}

View File

@@ -0,0 +1,111 @@
// *****************************************************************************
// Copyright (C) 2025 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
// *****************************************************************************
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
// copied and modified from https://github.com/microsoft/vscode/blob/1.96.3/src/vs/workbench/contrib/mergeEditor/browser/model/textModelDiffs.ts
import { Disposable, DisposableCollection, URI } from '@theia/core';
import { Autorun, Observable, ObservableSignal, SettableObservable } from '@theia/core/lib/common/observable';
import { DiffComputer, LineRange as DiffLineRange } from '@theia/core/lib/common/diff';
import { MonacoEditorModel } from '@theia/monaco/lib/browser/monaco-editor-model';
import { DetailedLineRangeMapping, RangeMapping } from './range-mapping';
import { LineRange } from './line-range';
export class LiveDiff implements Disposable {
protected recomputeCount = 0;
protected readonly stateObservable = SettableObservable.create(LiveDiffState.Initializing);
protected readonly changesObservable = SettableObservable.create<readonly DetailedLineRangeMapping[]>([]);
protected readonly toDispose = new DisposableCollection();
constructor(
protected readonly originalDocument: MonacoEditorModel,
protected readonly modifiedDocument: MonacoEditorModel,
protected readonly diffComputer: DiffComputer
) {
const recomputeSignal = ObservableSignal.create();
this.toDispose.push(Autorun.create(() => {
recomputeSignal.get();
this.recompute();
}));
this.toDispose.push(originalDocument.onDidChangeContent(
() => recomputeSignal.trigger()
));
this.toDispose.push(modifiedDocument.onDidChangeContent(
() => recomputeSignal.trigger()
));
}
dispose(): void {
this.toDispose.dispose();
}
get state(): LiveDiffState {
return this.stateObservable.get();
}
get changes(): readonly DetailedLineRangeMapping[] {
return this.changesObservable.get();
}
protected recompute(): void {
const recomputeCount = ++this.recomputeCount;
if (this.stateObservable.getUntracked() !== LiveDiffState.Initializing) { // untracked to avoid an infinite change loop in the autorun
this.stateObservable.set(LiveDiffState.Updating);
}
this.diffComputer.computeDiff(new URI(this.originalDocument.uri), new URI(this.modifiedDocument.uri)).then(diff => {
if (this.toDispose.disposed || this.originalDocument.isDisposed() || this.modifiedDocument.isDisposed()) {
return;
}
if (recomputeCount !== this.recomputeCount) {
// There is a newer recompute call
return;
}
const toLineRange = (r: DiffLineRange) => new LineRange(r.start, r.end - r.start);
const changes = diff?.changes.map(change => new DetailedLineRangeMapping(
toLineRange(change.left),
this.originalDocument,
toLineRange(change.right),
this.modifiedDocument,
change.innerChanges?.map(innerChange => new RangeMapping(innerChange.left, innerChange.right))
));
Observable.update(() => {
if (changes) {
this.stateObservable.set(LiveDiffState.UpToDate);
this.changesObservable.set(changes);
} else {
this.stateObservable.set(LiveDiffState.Error);
}
});
});
}
}
export const enum LiveDiffState {
Initializing,
UpToDate,
Updating,
Error
}

View File

@@ -0,0 +1,673 @@
// *****************************************************************************
// Copyright (C) 2025 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
// *****************************************************************************
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
// copied and modified from https://github.com/microsoft/vscode/blob/1.96.3/src/vs/workbench/contrib/mergeEditor/browser/model/mergeEditorModel.ts,
// https://github.com/microsoft/vscode/blob/1.96.3/src/vs/workbench/contrib/mergeEditor/browser/view/viewModel.ts
import { inject, injectable, postConstruct } from '@theia/core/shared/inversify';
import { ArrayUtils, Disposable, DisposableCollection, nls } from '@theia/core';
import { Autorun, DerivedObservable, Observable, ObservableUtils, SettableObservable } from '@theia/core/lib/common/observable';
import { DiffComputer } from '@theia/core/lib/common/diff';
import { Range } from '@theia/core/shared/vscode-languageserver-protocol';
import { MonacoEditor } from '@theia/monaco/lib/browser/monaco-editor';
import { MonacoEditorModel } from '@theia/monaco/lib/browser/monaco-editor-model';
import { MonacoToProtocolConverter } from '@theia/monaco/lib/browser/monaco-to-protocol-converter';
import { StandaloneServices } from '@theia/monaco-editor-core/esm/vs/editor/standalone/browser/standaloneServices';
import { IUndoRedoService, UndoRedoElementType } from '@theia/monaco-editor-core/esm/vs/platform/undoRedo/common/undoRedo';
import { MergeRange, MergeRangeAcceptedState, MergeRangeResultState, MergeSide } from './merge-range';
import { DetailedLineRangeMapping, DocumentLineRangeMap, DocumentRangeMap, LineRangeMapping, RangeMapping } from './range-mapping';
import { LiveDiff, LiveDiffState } from './live-diff';
import { LineRange } from './line-range';
import { LineRangeEdit } from './range-editing';
import { RangeUtils } from './range-utils';
export const MergeEditorModelProps = Symbol('MergeEditorModelProps');
export interface MergeEditorModelProps {
readonly baseEditor: MonacoEditor;
readonly side1Editor: MonacoEditor;
readonly side2Editor: MonacoEditor;
readonly resultEditor: MonacoEditor;
readonly options?: {
readonly resetResult?: boolean;
}
}
@injectable()
export class MergeEditorModel implements Disposable {
@inject(MergeEditorModelProps)
protected readonly props: MergeEditorModelProps;
@inject(DiffComputer)
protected readonly diffComputer: DiffComputer;
@inject(MonacoToProtocolConverter)
private readonly m2p: MonacoToProtocolConverter;
protected readonly toDispose = new DisposableCollection();
protected side1LiveDiff: LiveDiff;
protected side2LiveDiff: LiveDiff;
protected resultLiveDiff: LiveDiff;
protected shouldRecomputeHandledState = true;
protected readonly mergeRangesObservable = DerivedObservable.create(() => this.computeMergeRanges());
get mergeRanges(): readonly MergeRange[] {
return this.mergeRangesObservable.get();
}
protected readonly mergeRangesDataObservable = DerivedObservable.create(() => new Map(
this.mergeRanges.map(mergeRange => [mergeRange, this.newMergeRangeData()])
));
// #region Line Range Mapping
protected readonly side1ToResultLineRangeMapObservable = DerivedObservable.create(() => this.newDocumentLineRangeMap(
this.computeSideToResultDiff(this.side1Changes, this.resultChanges)
));
get side1ToResultLineRangeMap(): DocumentLineRangeMap {
return this.side1ToResultLineRangeMapObservable.get();
}
protected readonly resultToSide1LineRangeMapObservable = DerivedObservable.create(() => this.side1ToResultLineRangeMap.reverse());
get resultToSide1LineRangeMap(): DocumentLineRangeMap {
return this.resultToSide1LineRangeMapObservable.get();
}
protected readonly side2ToResultLineRangeMapObservable = DerivedObservable.create(() => this.newDocumentLineRangeMap(
this.computeSideToResultDiff(this.side2Changes, this.resultChanges)
));
get side2ToResultLineRangeMap(): DocumentLineRangeMap {
return this.side2ToResultLineRangeMapObservable.get();
}
protected readonly resultToSide2LineRangeMapObservable = DerivedObservable.create(() => this.side2ToResultLineRangeMap.reverse());
get resultToSide2LineRangeMap(): DocumentLineRangeMap {
return this.resultToSide2LineRangeMapObservable.get();
}
protected readonly baseToSide1LineRangeMapObservable = DerivedObservable.create(() => this.newDocumentLineRangeMap(this.side1Changes));
get baseToSide1LineRangeMap(): DocumentLineRangeMap {
return this.baseToSide1LineRangeMapObservable.get();
}
protected readonly side1ToBaseLineRangeMapObservable = DerivedObservable.create(() => this.baseToSide1LineRangeMap.reverse());
get side1ToBaseLineRangeMap(): DocumentLineRangeMap {
return this.side1ToBaseLineRangeMapObservable.get();
}
protected readonly baseToSide2LineRangeMapObservable = DerivedObservable.create(() => this.newDocumentLineRangeMap(this.side2Changes));
get baseToSide2LineRangeMap(): DocumentLineRangeMap {
return this.baseToSide2LineRangeMapObservable.get();
}
protected readonly side2ToBaseLineRangeMapObservable = DerivedObservable.create(() => this.baseToSide2LineRangeMap.reverse());
get side2ToBaseLineRangeMap(): DocumentLineRangeMap {
return this.side2ToBaseLineRangeMapObservable.get();
}
protected readonly baseToResultLineRangeMapObservable = DerivedObservable.create(() => this.newDocumentLineRangeMap(this.resultChanges));
get baseToResultLineRangeMap(): DocumentLineRangeMap {
return this.baseToResultLineRangeMapObservable.get();
}
protected readonly resultToBaseLineRangeMapObservable = DerivedObservable.create(() => this.baseToResultLineRangeMap.reverse());
get resultToBaseLineRangeMap(): DocumentLineRangeMap {
return this.resultToBaseLineRangeMapObservable.get();
}
// #endregion
// #region Range Mapping
protected readonly baseToSide1RangeMapObservable = DerivedObservable.create(() => this.newDocumentRangeMap(
this.side1Changes.flatMap(change => change.rangeMappings)
));
get baseToSide1RangeMap(): DocumentRangeMap {
return this.baseToSide1RangeMapObservable.get();
}
protected readonly side1ToBaseRangeMapObservable = DerivedObservable.create(() => this.baseToSide1RangeMap.reverse());
get side1ToBaseRangeMap(): DocumentRangeMap {
return this.side1ToBaseRangeMapObservable.get();
}
protected readonly baseToSide2RangeMapObservable = DerivedObservable.create(() => this.newDocumentRangeMap(
this.side2Changes.flatMap(change => change.rangeMappings)
));
get baseToSide2RangeMap(): DocumentRangeMap {
return this.baseToSide2RangeMapObservable.get();
}
protected readonly side2ToBaseRangeMapObservable = DerivedObservable.create(() => this.baseToSide2RangeMap.reverse());
get side2ToBaseRangeMap(): DocumentRangeMap {
return this.side2ToBaseRangeMapObservable.get();
}
protected readonly baseToResultRangeMapObservable = DerivedObservable.create(() => this.newDocumentRangeMap(
this.resultChanges.flatMap(change => change.rangeMappings)
));
get baseToResultRangeMap(): DocumentRangeMap {
return this.baseToResultRangeMapObservable.get();
}
protected readonly resultToBaseRangeMapObservable = DerivedObservable.create(() => this.baseToResultRangeMap.reverse());
get resultToBaseRangeMap(): DocumentRangeMap {
return this.resultToBaseRangeMapObservable.get();
}
// #endregion
protected readonly diffComputingStateObservable = DerivedObservable.create(() => this.getDiffComputingState(this.side1LiveDiff, this.side2LiveDiff, this.resultLiveDiff));
protected readonly diffComputingStateForSidesObservable = DerivedObservable.create(() => this.getDiffComputingState(this.side1LiveDiff, this.side2LiveDiff));
readonly isUpToDateObservable = DerivedObservable.create(() => this.diffComputingStateObservable.get() === DiffComputingState.UpToDate);
protected readonly unhandledMergeRangesCountObservable = DerivedObservable.create(() => {
let result = 0;
const mergeRangesData = this.mergeRangesDataObservable.get();
for (const mergeRangeData of mergeRangesData.values()) {
if (!mergeRangeData.isHandledObservable.get()) {
result++;
}
}
return result;
});
get unhandledMergeRangesCount(): number {
return this.unhandledMergeRangesCountObservable.get();
}
protected _onInitialized: Promise<void>;
get onInitialized(): Promise<void> {
return this._onInitialized;
}
get baseDocument(): MonacoEditorModel {
return this.props.baseEditor.document;
}
get side1Document(): MonacoEditorModel {
return this.props.side1Editor.document;
}
get side2Document(): MonacoEditorModel {
return this.props.side2Editor.document;
}
get resultDocument(): MonacoEditorModel {
return this.props.resultEditor.document;
}
protected get resultEditor(): MonacoEditor {
return this.props.resultEditor;
}
get side1Changes(): readonly DetailedLineRangeMapping[] {
return this.side1LiveDiff.changes;
}
get side2Changes(): readonly DetailedLineRangeMapping[] {
return this.side2LiveDiff.changes;
}
get resultChanges(): readonly DetailedLineRangeMapping[] {
return this.resultLiveDiff.changes;
}
@postConstruct()
protected init(): void {
this.toDispose.push(this.side1LiveDiff = this.newLiveDiff(this.baseDocument, this.side1Document));
this.toDispose.push(this.side2LiveDiff = this.newLiveDiff(this.baseDocument, this.side2Document));
this.toDispose.push(this.resultLiveDiff = this.newLiveDiff(this.baseDocument, this.resultDocument));
this.toDispose.push(Observable.keepObserved(this.mergeRangesDataObservable));
this.toDispose.push(Observable.keepObserved(this.side1ToResultLineRangeMapObservable));
this.toDispose.push(Observable.keepObserved(this.resultToSide1LineRangeMapObservable));
this.toDispose.push(Observable.keepObserved(this.side2ToResultLineRangeMapObservable));
this.toDispose.push(Observable.keepObserved(this.resultToSide2LineRangeMapObservable));
this.toDispose.push(Observable.keepObserved(this.baseToSide1LineRangeMapObservable));
this.toDispose.push(Observable.keepObserved(this.side1ToBaseLineRangeMapObservable));
this.toDispose.push(Observable.keepObserved(this.baseToSide2LineRangeMapObservable));
this.toDispose.push(Observable.keepObserved(this.side2ToBaseLineRangeMapObservable));
this.toDispose.push(Observable.keepObserved(this.baseToResultLineRangeMapObservable));
this.toDispose.push(Observable.keepObserved(this.resultToBaseLineRangeMapObservable));
this.toDispose.push(Observable.keepObserved(this.baseToSide1RangeMapObservable));
this.toDispose.push(Observable.keepObserved(this.side1ToBaseRangeMapObservable));
this.toDispose.push(Observable.keepObserved(this.baseToSide2RangeMapObservable));
this.toDispose.push(Observable.keepObserved(this.side2ToBaseRangeMapObservable));
this.toDispose.push(Observable.keepObserved(this.baseToResultRangeMapObservable));
this.toDispose.push(Observable.keepObserved(this.resultToBaseRangeMapObservable));
const initializePromise = this.doInit();
this._onInitialized = ObservableUtils.waitForState(this.isUpToDateObservable).then(() => initializePromise);
initializePromise.then(() => {
this.toDispose.push(Autorun.create(() => {
if (!this.isUpToDateObservable.get()) {
return;
}
Observable.update(() => {
const mergeRangesData = this.mergeRangesDataObservable.get();
for (const [mergeRange, mergeRangeData] of mergeRangesData) {
const state = this.computeMergeRangeStateFromResult(mergeRange);
mergeRangeData.resultStateObservable.set(state);
if (this.shouldRecomputeHandledState) {
mergeRangeData.isHandledObservable.set(state !== 'Base');
}
}
this.shouldRecomputeHandledState = false;
});
}, {
willHandleChange: ctx => {
if (ctx.isChangeOf(this.mergeRangesDataObservable)) {
this.shouldRecomputeHandledState = true;
}
return true;
}
}));
const attachedHistory = new AttachedHistory(this.resultDocument);
this.toDispose.push(attachedHistory);
this.toDispose.push(this.resultDocument.textEditorModel.onDidChangeContent(event => {
if (event.isRedoing || event.isUndoing) {
return;
}
// Mark merge ranges affected by content changes as handled.
const mergeRanges: MergeRange[] = [];
for (const change of event.changes) {
const changeBaseRange = this.translateResultRangeToBase(this.m2p.asRange(change.range));
const affectedMergeRanges = this.mergeRanges.filter(mergeRange =>
RangeUtils.touches(mergeRange.baseRange.toRange(), changeBaseRange)
);
for (const mergeRange of affectedMergeRanges) {
if (!this.isMergeRangeHandled(mergeRange)) {
mergeRanges.push(mergeRange);
}
}
}
if (mergeRanges.length === 0) {
return;
}
const markMergeRangesAsHandled = (handled: boolean) => {
Observable.update(() => {
const mergeRangesData = this.mergeRangesDataObservable.get();
for (const mergeRange of mergeRanges) {
const mergeRangeData = mergeRangesData.get(mergeRange);
if (mergeRangeData) {
mergeRangeData.isHandledObservable.set(handled);
}
}
});
};
const element: IAttachedHistoryElement = {
redo: () => {
markMergeRangesAsHandled(true);
},
undo: () => {
markMergeRangesAsHandled(false);
}
};
attachedHistory.pushAttachedHistoryElement(element);
element.redo();
}));
});
}
protected computeMergeRangeStateFromResult(mergeRange: MergeRange): MergeRangeResultState {
const { originalRange: baseRange, modifiedRange: resultRange } = this.getResultLineRangeMapping(mergeRange);
if (!mergeRange.baseRange.equals(baseRange)) {
return 'Unrecognized';
}
const existingLines = resultRange.getLines(this.resultDocument);
const states: MergeRangeAcceptedState[] = [
'Base',
'Side1',
'Side2',
'Side1Side2Smart',
'Side2Side1Smart',
'Side1Side2',
'Side2Side1'
];
for (const state of states) {
const edit = mergeRange.getBaseRangeEdit(state);
if (ArrayUtils.equals(edit.newLines, existingLines)) {
return state;
}
}
return 'Unrecognized';
}
protected async doInit(): Promise<void> {
if (this.props.options?.resetResult) {
await this.reset();
}
}
dispose(): void {
this.toDispose.dispose();
}
isDisposed(): boolean {
return this.toDispose.disposed;
}
async reset(): Promise<void> {
await ObservableUtils.waitForState(this.diffComputingStateForSidesObservable, state => state === DiffComputingState.UpToDate);
this.shouldRecomputeHandledState = true;
this.resultDocument.textEditorModel.setValue(this.computeAutoMergedResult());
}
protected computeAutoMergedResult(): string {
const baseLines = this.baseDocument.textEditorModel.getLinesContent();
const side1Lines = this.side1Document.textEditorModel.getLinesContent();
const side2Lines = this.side2Document.textEditorModel.getLinesContent();
const resultLines: string[] = [];
function appendLinesToResult(documentLines: string[], lineRange: LineRange): void {
for (let i = lineRange.startLineNumber; i < lineRange.endLineNumberExclusive; i++) {
resultLines.push(documentLines[i]);
}
}
let baseStartLineNumber = 0;
for (const mergeRange of this.mergeRanges) {
appendLinesToResult(baseLines, LineRange.fromLineNumbers(baseStartLineNumber, mergeRange.baseRange.startLineNumber));
if (mergeRange.side1Changes.length === 0) {
appendLinesToResult(side2Lines, mergeRange.side2Range);
} else if (mergeRange.side2Changes.length === 0) {
appendLinesToResult(side1Lines, mergeRange.side1Range);
} else if (mergeRange.isEqualChange) {
appendLinesToResult(side1Lines, mergeRange.side1Range);
} else {
appendLinesToResult(baseLines, mergeRange.baseRange);
}
baseStartLineNumber = mergeRange.baseRange.endLineNumberExclusive;
}
appendLinesToResult(baseLines, LineRange.fromLineNumbers(baseStartLineNumber, baseLines.length));
return resultLines.join(this.resultDocument.textEditorModel.getEOL());
}
protected computeMergeRanges(): MergeRange[] {
return MergeRange.computeMergeRanges(this.side1Changes, this.side2Changes, this.baseDocument, this.side1Document, this.side2Document);
}
hasMergeRange(mergeRange: MergeRange): boolean {
return this.mergeRangesDataObservable.get().has(mergeRange);
}
protected getMergeRangeData(mergeRange: MergeRange): MergeRangeData {
const mergeRangeData = this.mergeRangesDataObservable.get().get(mergeRange);
if (!mergeRangeData) {
throw new Error('Unknown merge range');
}
return mergeRangeData;
}
getMergeRangeResultState(mergeRange: MergeRange): MergeRangeResultState {
return this.getMergeRangeData(mergeRange).resultStateObservable.get();
}
applyMergeRangeAcceptedState(mergeRange: MergeRange, state: MergeRangeAcceptedState): void {
if (!this.isUpToDateObservable.get()) {
throw new Error('Cannot apply merge range accepted state while updating');
}
if (state !== 'Base' && this.getMergeRangeResultState(mergeRange) === 'Unrecognized') {
throw new Error('Cannot apply merge range accepted state to an unrecognized result state');
}
const { originalRange: baseRange, modifiedRange: resultRange } = this.getResultLineRangeMapping(mergeRange);
let newLines: string[];
if (state === 'Base') {
newLines = baseRange.getLines(this.baseDocument);
} else {
if (!baseRange.equals(mergeRange.baseRange)) {
throw new Error('Assertion error');
}
newLines = mergeRange.getBaseRangeEdit(state).newLines;
}
const resultEdit = new LineRangeEdit(resultRange, newLines);
const editOperation = resultEdit.toRangeEdit(this.resultDocument.lineCount).toMonacoEdit();
const cursorState = this.resultEditor.getControl().getSelections();
this.resultDocument.textEditorModel.pushStackElement();
this.resultDocument.textEditorModel.pushEditOperations(cursorState, [editOperation], () => cursorState);
this.resultDocument.textEditorModel.pushStackElement();
}
isMergeRangeHandled(mergeRange: MergeRange): boolean {
return this.getMergeRangeData(mergeRange).isHandledObservable.get();
}
markMergeRangeAsHandled(mergeRange: MergeRange, options?: {
undoRedo?: false | {
callback?: {
didUndo(): void;
didRedo(): void;
}
}
}): void {
const mergeRangeData = this.getMergeRangeData(mergeRange);
if (mergeRangeData.isHandledObservable.get()) {
return;
}
mergeRangeData.isHandledObservable.set(true);
if (options?.undoRedo === false) {
return;
}
const undoRedoCallback = options?.undoRedo?.callback;
const modelRef = new WeakRef(this);
const dataRef = new WeakRef(mergeRangeData);
StandaloneServices.get(IUndoRedoService).pushElement({
type: UndoRedoElementType.Resource,
resource: this.resultDocument.textEditorModel.uri,
label: nls.localizeByDefault('Undo Mark As Handled'),
code: 'markMergeRangeAsHandled',
undo(): void {
const model = modelRef.deref();
const data = dataRef.deref();
if (model && !model.isDisposed() && data) {
data.isHandledObservable.set(false);
undoRedoCallback?.didUndo();
}
},
redo(): void {
const model = modelRef.deref();
const data = dataRef.deref();
if (model && !model.isDisposed() && data) {
data.isHandledObservable.set(true);
undoRedoCallback?.didRedo();
}
}
});
}
getLineRangeInResult(mergeRange: MergeRange): LineRange {
return this.getResultLineRangeMapping(mergeRange).modifiedRange;
}
protected getResultLineRangeMapping(mergeRange: MergeRange): LineRangeMapping {
const projectLine = (lineNumber: number): number | LineRangeMapping => {
let offset = 0;
const changes = this.resultChanges;
for (const change of changes) {
const { originalRange } = change;
if (originalRange.containsLine(lineNumber) || originalRange.endLineNumberExclusive === lineNumber) {
return change;
} else if (originalRange.endLineNumberExclusive < lineNumber) {
offset = change.modifiedRange.endLineNumberExclusive - originalRange.endLineNumberExclusive;
} else {
break;
}
}
return lineNumber + offset;
};
let startBase = mergeRange.baseRange.startLineNumber;
let startResult = projectLine(startBase);
if (typeof startResult !== 'number') {
startBase = startResult.originalRange.startLineNumber;
startResult = startResult.modifiedRange.startLineNumber;
}
let endExclusiveBase = mergeRange.baseRange.endLineNumberExclusive;
let endExclusiveResult = projectLine(endExclusiveBase);
if (typeof endExclusiveResult !== 'number') {
endExclusiveBase = endExclusiveResult.originalRange.endLineNumberExclusive;
endExclusiveResult = endExclusiveResult.modifiedRange.endLineNumberExclusive;
}
return new LineRangeMapping(LineRange.fromLineNumbers(startBase, endExclusiveBase), LineRange.fromLineNumbers(startResult, endExclusiveResult));
}
translateBaseRangeToSide(range: Range, side: MergeSide): Range {
const rangeMap = side === 1 ? this.baseToSide1RangeMap : this.baseToSide2RangeMap;
return rangeMap.projectRange(range).modifiedRange;
}
translateSideRangeToBase(range: Range, side: MergeSide): Range {
const rangeMap = side === 1 ? this.side1ToBaseRangeMap : this.side2ToBaseRangeMap;
return rangeMap.projectRange(range).modifiedRange;
}
translateBaseRangeToResult(range: Range): Range {
return this.baseToResultRangeMap.projectRange(range).modifiedRange;
}
translateResultRangeToBase(range: Range): Range {
return this.resultToBaseRangeMap.projectRange(range).modifiedRange;
}
protected computeSideToResultDiff(sideChanges: readonly LineRangeMapping[], resultChanges: readonly LineRangeMapping[]): readonly LineRangeMapping[] {
return DocumentLineRangeMap.betweenModifiedSides(sideChanges, resultChanges).lineRangeMappings;
}
protected newMergeRangeData(): MergeRangeData {
return new MergeRangeData();
}
protected newLiveDiff(originalDocument: MonacoEditorModel, modifiedDocument: MonacoEditorModel): LiveDiff {
return new LiveDiff(originalDocument, modifiedDocument, this.diffComputer);
}
protected newDocumentLineRangeMap(lineRangeMappings: readonly LineRangeMapping[]): DocumentLineRangeMap {
return new DocumentLineRangeMap(lineRangeMappings);
}
protected newDocumentRangeMap(rangeMappings: readonly RangeMapping[]): DocumentRangeMap {
return new DocumentRangeMap(rangeMappings);
}
protected getDiffComputingState(...liveDiffs: LiveDiff[]): DiffComputingState {
const liveDiffStates = liveDiffs.map(liveDiff => liveDiff.state);
if (liveDiffStates.some(state => state === LiveDiffState.Initializing)) {
return DiffComputingState.Initializing;
}
if (liveDiffStates.some(state => state === LiveDiffState.Updating)) {
return DiffComputingState.Updating;
}
return DiffComputingState.UpToDate;
}
}
export const enum DiffComputingState {
Initializing,
UpToDate,
Updating
}
export class MergeRangeData {
readonly resultStateObservable = SettableObservable.create<MergeRangeResultState>('Base');
readonly isHandledObservable = SettableObservable.create(false);
}
class AttachedHistory implements Disposable {
private readonly toDispose = new DisposableCollection();
private readonly attachedHistory: { element: IAttachedHistoryElement; altId: number }[] = [];
constructor(private readonly model: MonacoEditorModel) {
let previousAltId = this.model.textEditorModel.getAlternativeVersionId();
this.toDispose.push(model.textEditorModel.onDidChangeContent(event => {
const currentAltId = model.textEditorModel.getAlternativeVersionId();
if (event.isRedoing) {
for (const item of this.attachedHistory) {
if (previousAltId < item.altId && item.altId <= currentAltId) {
item.element.redo();
}
}
} else if (event.isUndoing) {
for (let i = this.attachedHistory.length - 1; i >= 0; i--) {
const item = this.attachedHistory[i];
if (currentAltId < item.altId && item.altId <= previousAltId) {
item.element.undo();
}
}
} else {
// The user destroyed the redo stack by performing a non redo/undo operation.
while (
this.attachedHistory.length > 0
&& this.attachedHistory[this.attachedHistory.length - 1].altId > previousAltId
) {
this.attachedHistory.pop();
}
}
previousAltId = currentAltId;
}));
}
dispose(): void {
this.toDispose.dispose();
}
pushAttachedHistoryElement(element: IAttachedHistoryElement): void {
this.attachedHistory.push({ altId: this.model.textEditorModel.getAlternativeVersionId(), element });
}
}
interface IAttachedHistoryElement {
undo(): void;
redo(): void;
}

View File

@@ -0,0 +1,268 @@
// *****************************************************************************
// Copyright (C) 2025 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
// *****************************************************************************
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
// copied and modified from https://github.com/microsoft/vscode/blob/1.96.3/src/vs/workbench/contrib/mergeEditor/browser/model/modifiedBaseRange.ts
import { ArrayUtils } from '@theia/core';
import { uinteger, Position, Range } from '@theia/core/shared/vscode-languageserver-protocol';
import { TextEditorDocument } from '@theia/editor/lib/browser/editor';
import { DetailedLineRangeMapping, MappingAlignment } from './range-mapping';
import { LineRange } from './line-range';
import { LineRangeEdit, RangeEdit } from './range-editing';
import { PositionUtils, RangeUtils } from './range-utils';
/**
* Describes modifications in side 1 and side 2 for a specific range in base.
*/
export class MergeRange {
static computeMergeRanges(
side1Diff: readonly DetailedLineRangeMapping[],
side2Diff: readonly DetailedLineRangeMapping[],
baseDocument: TextEditorDocument,
side1Document: TextEditorDocument,
side2Document: TextEditorDocument
): MergeRange[] {
const alignments = MappingAlignment.computeAlignments(side1Diff, side2Diff);
return alignments.map(
alignment => new MergeRange(
alignment.baseRange,
baseDocument,
alignment.side1Range,
alignment.side1Mappings,
side1Document,
alignment.side2Range,
alignment.side2Mappings,
side2Document
)
);
}
readonly side1CombinedChange = DetailedLineRangeMapping.join(this.side1Changes);
readonly side2CombinedChange = DetailedLineRangeMapping.join(this.side2Changes);
readonly isEqualChange = ArrayUtils.equals(this.side1Changes, this.side2Changes, (a, b) => a.getLineEdit().equals(b.getLineEdit()));
constructor(
readonly baseRange: LineRange,
readonly baseDocument: TextEditorDocument,
readonly side1Range: LineRange,
readonly side1Changes: readonly DetailedLineRangeMapping[],
readonly side1Document: TextEditorDocument,
readonly side2Range: LineRange,
readonly side2Changes: readonly DetailedLineRangeMapping[],
readonly side2Document: TextEditorDocument
) {
if (side1Changes.length === 0 && side2Changes.length === 0) {
throw new Error('At least one change is expected');
}
}
getModifiedRange(side: MergeSide): LineRange {
return side === 1 ? this.side1Range : this.side2Range;
}
getCombinedChange(side: MergeSide): DetailedLineRangeMapping | undefined {
return side === 1 ? this.side1CombinedChange : this.side2CombinedChange;
}
getChanges(side: MergeSide): readonly DetailedLineRangeMapping[] {
return side === 1 ? this.side1Changes : this.side2Changes;
}
get isConflicting(): boolean {
return this.side1Changes.length > 0 && this.side2Changes.length > 0 && !this.isEqualChange;
}
canBeSmartCombined(firstSide: MergeSide): boolean {
return this.isConflicting && this.smartCombineChanges(firstSide) !== undefined;
}
get isSmartCombinationOrderRelevant(): boolean {
const edit1 = this.smartCombineChanges(1);
const edit2 = this.smartCombineChanges(2);
if (!edit1 || !edit2) {
return false;
}
return !edit1.equals(edit2);
}
getBaseRangeEdit(state: MergeRangeAcceptedState): LineRangeEdit {
if (state === 'Base') {
return new LineRangeEdit(this.baseRange, this.baseRange.getLines(this.baseDocument));
}
if (state === 'Side1') {
return new LineRangeEdit(this.baseRange, this.side1Range.getLines(this.side1Document));
}
if (state === 'Side2') {
return new LineRangeEdit(this.baseRange, this.side2Range.getLines(this.side2Document));
}
let edit: LineRangeEdit | undefined;
const firstSide = state.startsWith('Side1') ? 1 : 2;
if (state.endsWith('Smart')) {
edit = this.smartCombineChanges(firstSide);
}
if (!edit) {
edit = this.dumbCombineChanges(firstSide);
}
return edit;
}
protected smartCombinationEdit1?: { value: LineRangeEdit | undefined };
protected smartCombinationEdit2?: { value: LineRangeEdit | undefined };
protected smartCombineChanges(firstSide: MergeSide): LineRangeEdit | undefined {
if (firstSide === 1 && this.smartCombinationEdit1) {
return this.smartCombinationEdit1.value;
} else if (firstSide === 2 && this.smartCombinationEdit2) {
return this.smartCombinationEdit2.value;
}
const combinedChanges =
this.side1Changes.flatMap(change => change.rangeMappings.map(rangeMapping => ({ rangeMapping, side: 1 }))).concat(
this.side2Changes.flatMap(change => change.rangeMappings.map(rangeMapping => ({ rangeMapping, side: 2 })))).sort(
(a, b) => {
let result = RangeUtils.compareUsingStarts(a.rangeMapping.originalRange, b.rangeMapping.originalRange);
if (result === 0) {
const sideWeight = (side: number) => side === firstSide ? 1 : 2;
result = sideWeight(a.side) - sideWeight(b.side);
}
return result;
}
);
const sortedEdits = combinedChanges.map(change => {
const modifiedDocument = change.side === 1 ? this.side1Document : this.side2Document;
return new RangeEdit(change.rangeMapping.originalRange, modifiedDocument.getText(change.rangeMapping.modifiedRange));
});
const edit = this.editsToLineRangeEdit(this.baseRange, sortedEdits, this.baseDocument);
if (firstSide === 1) {
this.smartCombinationEdit1 = { value: edit };
} else {
this.smartCombinationEdit2 = { value: edit };
}
return edit;
}
protected editsToLineRangeEdit(range: LineRange, sortedEdits: RangeEdit[], document: TextEditorDocument): LineRangeEdit | undefined {
let text = '';
const startsLineBefore = range.startLineNumber > 0;
let currentPosition = startsLineBefore
? Position.create(
range.startLineNumber - 1,
document.getLineMaxColumn((range.startLineNumber - 1) + 1) // note that getLineMaxColumn expects a 1-based line number
)
: Position.create(range.startLineNumber, 0);
for (const edit of sortedEdits) {
const diffStart = edit.range.start;
if (!PositionUtils.isBeforeOrEqual(currentPosition, diffStart)) {
return undefined;
}
let originalText = document.getText(Range.create(currentPosition, diffStart));
if (diffStart.line >= document.lineCount) {
// getText doesn't include this virtual line break, as the document ends the line before.
// endsLineAfter will be false.
originalText += '\n';
}
text += originalText;
text += edit.newText;
currentPosition = edit.range.end;
}
const endsLineAfter = range.endLineNumberExclusive < document.lineCount;
const end = endsLineAfter ?
Position.create(range.endLineNumberExclusive, 0) :
Position.create(range.endLineNumberExclusive - 1, uinteger.MAX_VALUE);
text += document.getText(Range.create(currentPosition, end));
const lines = text.split(/\r\n|\r|\n/);
if (startsLineBefore) {
if (lines[0] !== '') {
return undefined;
}
lines.shift();
}
if (endsLineAfter) {
if (lines[lines.length - 1] !== '') {
return undefined;
}
lines.pop();
}
return new LineRangeEdit(range, lines);
}
protected dumbCombinationEdit1?: LineRangeEdit;
protected dumbCombinationEdit2?: LineRangeEdit;
protected dumbCombineChanges(firstSide: MergeSide): LineRangeEdit {
if (firstSide === 1 && this.dumbCombinationEdit1) {
return this.dumbCombinationEdit1;
} else if (firstSide === 2 && this.dumbCombinationEdit2) {
return this.dumbCombinationEdit2;
}
const modifiedLines1 = this.side1Range.getLines(this.side1Document);
const modifiedLines2 = this.side2Range.getLines(this.side2Document);
const combinedLines = firstSide === 1 ? modifiedLines1.concat(modifiedLines2) : modifiedLines2.concat(modifiedLines1);
const edit = new LineRangeEdit(this.baseRange, combinedLines);
if (firstSide === 1) {
this.dumbCombinationEdit1 = edit;
} else {
this.dumbCombinationEdit2 = edit;
}
return edit;
}
}
export type MergeSide = 1 | 2;
export type MergeRangeAcceptedState = 'Base' | 'Side1' | 'Side2' | 'Side1Side2' | 'Side2Side1' | 'Side1Side2Smart' | 'Side2Side1Smart';
export namespace MergeRangeAcceptedState {
export function addSide(state: MergeRangeAcceptedState, side: MergeSide, options?: { smartCombination?: boolean }): MergeRangeAcceptedState {
if (state === 'Base') {
return side === 1 ? 'Side1' : 'Side2';
}
if (state.includes('Side' + side)) {
return state;
}
if (side === 2) {
return options?.smartCombination ? 'Side1Side2Smart' : 'Side1Side2';
} else {
return options?.smartCombination ? 'Side2Side1Smart' : 'Side2Side1';
}
}
export function removeSide(state: MergeRangeAcceptedState, side: MergeSide): MergeRangeAcceptedState {
if (!state.includes('Side' + side)) {
return state;
}
if (state === 'Side' + side) {
return 'Base';
}
return side === 1 ? 'Side2' : 'Side1';
}
}
export type MergeRangeResultState = MergeRangeAcceptedState | 'Unrecognized';

View File

@@ -0,0 +1,81 @@
// *****************************************************************************
// Copyright (C) 2025 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
// *****************************************************************************
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
// copied and modified from https://github.com/microsoft/vscode/blob/1.96.3/src/vs/workbench/contrib/mergeEditor/browser/model/editing.ts
import { ArrayUtils } from '@theia/core';
import { Range, uinteger } from '@theia/core/shared/vscode-languageserver-protocol';
import { LineRange } from './line-range';
import * as monaco from '@theia/monaco-editor-core';
/**
* Represents an edit, expressed in whole lines:
* At (before) {@link LineRange.startLineNumber}, delete {@link LineRange.lineCount} many lines and insert {@link newLines}.
*/
export class LineRangeEdit {
constructor(
readonly range: LineRange,
readonly newLines: string[]
) { }
equals(other: LineRangeEdit): boolean {
return this.range.equals(other.range) && ArrayUtils.equals(this.newLines, other.newLines);
}
toRangeEdit(documentLineCount: number): RangeEdit {
if (this.range.endLineNumberExclusive < documentLineCount) {
return new RangeEdit(
Range.create(this.range.startLineNumber, 0, this.range.endLineNumberExclusive, 0),
this.newLines.map(s => s + '\n').join('')
);
}
if (this.range.startLineNumber === 0) {
return new RangeEdit(
Range.create(0, 0, documentLineCount - 1, uinteger.MAX_VALUE),
this.newLines.join('\n')
);
}
return new RangeEdit(
Range.create(this.range.startLineNumber - 1, uinteger.MAX_VALUE, documentLineCount - 1, uinteger.MAX_VALUE),
this.newLines.map(s => '\n' + s).join('')
);
}
}
export class RangeEdit {
constructor(
readonly range: Range,
readonly newText: string
) { }
toMonacoEdit(): monaco.editor.ISingleEditOperation {
const { start, end } = this.range;
return {
range: {
startLineNumber: start.line + 1,
startColumn: start.character + 1,
endLineNumber: end.line + 1,
endColumn: end.character + 1
},
text: this.newText
};
}
}

View File

@@ -0,0 +1,52 @@
// *****************************************************************************
// Copyright (C) 2025 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
// *****************************************************************************
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
// copied and modified from https://github.com/microsoft/vscode/blob/1.96.3/src/vs/workbench/contrib/mergeEditor/test/browser/mapping.test.ts
import { expect } from 'chai';
import { Range } from '@theia/core/shared/vscode-languageserver-protocol';
import { DocumentRangeMap, RangeMapping } from './range-mapping';
describe('document-range-map', () => {
it('project', () => {
const documentRangeMap = new DocumentRangeMap([
new RangeMapping(Range.create(2, 4, 2, 6), Range.create(2, 4, 2, 7)),
new RangeMapping(Range.create(3, 2, 4, 3), Range.create(3, 2, 6, 4)),
new RangeMapping(Range.create(4, 5, 4, 7), Range.create(6, 6, 6, 9)),
]);
const project = (line: number, character: number) =>
documentRangeMap.projectPosition({ line, character }).toString();
expect(project(1, 1)).to.be.equal('[1:1, 1:1) -> [1:1, 1:1)');
expect(project(2, 3)).to.be.equal('[2:3, 2:3) -> [2:3, 2:3)');
expect(project(2, 4)).to.be.equal('[2:4, 2:6) -> [2:4, 2:7)');
expect(project(2, 5)).to.be.equal('[2:4, 2:6) -> [2:4, 2:7)');
expect(project(2, 6)).to.be.equal('[2:6, 2:6) -> [2:7, 2:7)');
expect(project(2, 7)).to.be.equal('[2:7, 2:7) -> [2:8, 2:8)');
expect(project(3, 1)).to.be.equal('[3:1, 3:1) -> [3:1, 3:1)');
expect(project(3, 2)).to.be.equal('[3:2, 4:3) -> [3:2, 6:4)');
expect(project(4, 2)).to.be.equal('[3:2, 4:3) -> [3:2, 6:4)');
expect(project(4, 3)).to.be.equal('[4:3, 4:3) -> [6:4, 6:4)');
expect(project(4, 4)).to.be.equal('[4:4, 4:4) -> [6:5, 6:5)');
expect(project(4, 5)).to.be.equal('[4:5, 4:7) -> [6:6, 6:9)');
});
});

View File

@@ -0,0 +1,396 @@
// *****************************************************************************
// Copyright (C) 2025 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
// *****************************************************************************
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
// copied and modified from https://github.com/microsoft/vscode/blob/1.96.3/src/vs/workbench/contrib/mergeEditor/browser/model/mapping.ts
import { ArrayUtils } from '@theia/core';
import { Position, Range, TextEditorDocument } from '@theia/editor/lib/browser/editor';
import { LineRange } from './line-range';
import { LineRangeEdit } from './range-editing';
import { PositionUtils, RangeUtils } from './range-utils';
/**
* Maps a line range in the original text document to a line range in the modified text document.
*/
export class LineRangeMapping {
static join(mappings: readonly LineRangeMapping[]): LineRangeMapping | undefined {
return mappings.reduce<undefined | LineRangeMapping>((acc, cur) => acc ? acc.join(cur) : cur, undefined);
}
constructor(
readonly originalRange: LineRange,
readonly modifiedRange: LineRange
) { }
toString(): string {
return `${this.originalRange.toString()} -> ${this.modifiedRange.toString()}`;
}
join(other: LineRangeMapping): LineRangeMapping {
return new LineRangeMapping(
this.originalRange.join(other.originalRange),
this.modifiedRange.join(other.modifiedRange)
);
}
addModifiedLineDelta(delta: number): LineRangeMapping {
return new LineRangeMapping(
this.originalRange,
this.modifiedRange.delta(delta)
);
}
addOriginalLineDelta(delta: number): LineRangeMapping {
return new LineRangeMapping(
this.originalRange.delta(delta),
this.modifiedRange
);
}
reverse(): LineRangeMapping {
return new LineRangeMapping(this.modifiedRange, this.originalRange);
}
}
/**
* Represents a total monotonous mapping of line ranges in one document to another document.
*/
export class DocumentLineRangeMap {
static betweenModifiedSides(
side1Diff: readonly LineRangeMapping[],
side2Diff: readonly LineRangeMapping[]
): DocumentLineRangeMap {
const alignments = MappingAlignment.computeAlignments(side1Diff, side2Diff);
const mappings = alignments.map(alignment => new LineRangeMapping(alignment.side1Range, alignment.side2Range));
return new DocumentLineRangeMap(mappings);
}
constructor(
/**
* The line range mappings that define this document mapping.
* The number of lines between two adjacent original ranges must equal the number of lines between their corresponding modified ranges.
*/
readonly lineRangeMappings: readonly LineRangeMapping[]
) {
if (!ArrayUtils.checkAdjacentItems(lineRangeMappings,
(m1, m2) => m1.originalRange.isBefore(m2.originalRange) && m1.modifiedRange.isBefore(m2.modifiedRange) &&
m2.originalRange.startLineNumber - m1.originalRange.endLineNumberExclusive === m2.modifiedRange.startLineNumber - m1.modifiedRange.endLineNumberExclusive
)) {
throw new Error('Illegal line range mappings');
}
}
/**
* @param lineNumber 0-based line number in the original text document
*/
projectLine(lineNumber: number): LineRangeMapping {
const lastBefore = ArrayUtils.findLast(this.lineRangeMappings, m => m.originalRange.startLineNumber <= lineNumber);
if (!lastBefore) {
return new LineRangeMapping(
new LineRange(lineNumber, 1),
new LineRange(lineNumber, 1)
);
}
if (lastBefore.originalRange.containsLine(lineNumber)) {
return lastBefore;
}
return new LineRangeMapping(
new LineRange(lineNumber, 1),
new LineRange(lineNumber + lastBefore.modifiedRange.endLineNumberExclusive - lastBefore.originalRange.endLineNumberExclusive, 1)
);
}
reverse(): DocumentLineRangeMap {
return new DocumentLineRangeMap(
this.lineRangeMappings.map(m => m.reverse())
);
}
}
/**
* Aligns mappings for two modified sides with a common base range.
*/
export class MappingAlignment<T extends LineRangeMapping> {
static computeAlignments<T extends LineRangeMapping>(
side1Mappings: readonly T[],
side2Mappings: readonly T[]
): MappingAlignment<T>[] {
const combinedMappings =
side1Mappings.map(mapping => ({ source: 0, mapping })).concat(
side2Mappings.map(mapping => ({ source: 1, mapping }))).sort(
(a, b) => LineRange.compareByStart(a.mapping.originalRange, b.mapping.originalRange));
const currentMappings = [new Array<T>(), new Array<T>()];
const currentDelta = [0, 0];
const alignments = new Array<MappingAlignment<T>>();
function pushAlignment(baseRange: LineRange): void {
const mapping1 = LineRangeMapping.join(currentMappings[0]) || new LineRangeMapping(baseRange, baseRange.delta(currentDelta[0]));
const mapping2 = LineRangeMapping.join(currentMappings[1]) || new LineRangeMapping(baseRange, baseRange.delta(currentDelta[1]));
function getAlignedModifiedRange(m: LineRangeMapping): LineRange {
const startDelta = baseRange.startLineNumber - m.originalRange.startLineNumber;
const endDelta = baseRange.endLineNumberExclusive - m.originalRange.endLineNumberExclusive;
return new LineRange(
m.modifiedRange.startLineNumber + startDelta,
m.modifiedRange.lineCount - startDelta + endDelta
);
}
alignments.push(
new MappingAlignment(
baseRange,
getAlignedModifiedRange(mapping1),
currentMappings[0],
getAlignedModifiedRange(mapping2),
currentMappings[1]
)
);
currentMappings[0] = [];
currentMappings[1] = [];
}
let currentBaseRange: LineRange | undefined;
for (const current of combinedMappings) {
const { originalRange, modifiedRange } = current.mapping;
if (currentBaseRange && !currentBaseRange.touches(originalRange)) {
pushAlignment(currentBaseRange);
currentBaseRange = undefined;
}
currentBaseRange = currentBaseRange ? currentBaseRange.join(originalRange) : originalRange;
currentMappings[current.source].push(current.mapping);
currentDelta[current.source] = modifiedRange.endLineNumberExclusive - originalRange.endLineNumberExclusive;
}
if (currentBaseRange) {
pushAlignment(currentBaseRange);
}
return alignments;
}
constructor(
readonly baseRange: LineRange,
readonly side1Range: LineRange,
readonly side1Mappings: readonly T[],
readonly side2Range: LineRange,
readonly side2Mappings: readonly T[]
) { }
toString(): string {
return `${this.side1Range} <- ${this.baseRange} -> ${this.side2Range}`;
}
}
/**
* A line range mapping with inner range mappings.
*/
export class DetailedLineRangeMapping extends LineRangeMapping {
static override join(mappings: readonly DetailedLineRangeMapping[]): DetailedLineRangeMapping | undefined {
return mappings.reduce<undefined | DetailedLineRangeMapping>((acc, cur) => acc ? acc.join(cur) : cur, undefined);
}
readonly rangeMappings: readonly RangeMapping[];
constructor(
originalRange: LineRange,
readonly originalDocument: TextEditorDocument,
modifiedRange: LineRange,
readonly modifiedDocument: TextEditorDocument,
rangeMappings?: readonly RangeMapping[]
) {
super(originalRange, modifiedRange);
this.rangeMappings = rangeMappings || [new RangeMapping(originalRange.toRange(), modifiedRange.toRange())];
}
override join(other: DetailedLineRangeMapping): DetailedLineRangeMapping {
return new DetailedLineRangeMapping(
this.originalRange.join(other.originalRange),
this.originalDocument,
this.modifiedRange.join(other.modifiedRange),
this.modifiedDocument
);
}
override addModifiedLineDelta(delta: number): DetailedLineRangeMapping {
return new DetailedLineRangeMapping(
this.originalRange,
this.originalDocument,
this.modifiedRange.delta(delta),
this.modifiedDocument,
this.rangeMappings.map(m => m.addModifiedLineDelta(delta))
);
}
override addOriginalLineDelta(delta: number): DetailedLineRangeMapping {
return new DetailedLineRangeMapping(
this.originalRange.delta(delta),
this.originalDocument,
this.modifiedRange,
this.modifiedDocument,
this.rangeMappings.map(m => m.addOriginalLineDelta(delta))
);
}
override reverse(): DetailedLineRangeMapping {
return new DetailedLineRangeMapping(
this.modifiedRange,
this.modifiedDocument,
this.originalRange,
this.originalDocument,
this.rangeMappings.map(m => m.reverse())
);
}
getLineEdit(): LineRangeEdit {
return new LineRangeEdit(this.originalRange, this.getModifiedLines());
}
getReverseLineEdit(): LineRangeEdit {
return new LineRangeEdit(this.modifiedRange, this.getOriginalLines());
}
getModifiedLines(): string[] {
return this.modifiedRange.getLines(this.modifiedDocument);
}
getOriginalLines(): string[] {
return this.originalRange.getLines(this.originalDocument);
}
}
/**
* Maps a range in the original text document to a range in the modified text document.
*/
export class RangeMapping {
constructor(
readonly originalRange: Readonly<Range>,
readonly modifiedRange: Readonly<Range>
) { }
toString(): string {
function rangeToString(range: Range): string {
return `[${range.start.line}:${range.start.character}, ${range.end.line}:${range.end.character})`;
}
return `${rangeToString(this.originalRange)} -> ${rangeToString(this.modifiedRange)}`;
}
addModifiedLineDelta(deltaLines: number): RangeMapping {
return new RangeMapping(
this.originalRange,
Range.create(
this.modifiedRange.start.line + deltaLines,
this.modifiedRange.start.character,
this.modifiedRange.end.line + deltaLines,
this.modifiedRange.end.character
)
);
}
addOriginalLineDelta(deltaLines: number): RangeMapping {
return new RangeMapping(
Range.create(
this.originalRange.start.line + deltaLines,
this.originalRange.start.character,
this.originalRange.end.line + deltaLines,
this.originalRange.end.character
),
this.modifiedRange
);
}
reverse(): RangeMapping {
return new RangeMapping(this.modifiedRange, this.originalRange);
}
}
/**
* Represents a total monotonous mapping of ranges in one document to another document.
*/
export class DocumentRangeMap {
constructor(
/**
* The range mappings that define this document mapping.
*/
readonly rangeMappings: readonly RangeMapping[]
) {
if (!ArrayUtils.checkAdjacentItems(
rangeMappings,
(m1, m2) =>
RangeUtils.isBefore(m1.originalRange, m2.originalRange) &&
RangeUtils.isBefore(m1.modifiedRange, m2.modifiedRange)
)) {
throw new Error('Illegal range mappings');
}
}
/**
* @param position position in the original text document
*/
projectPosition(position: Position): RangeMapping {
const lastBefore = ArrayUtils.findLast(this.rangeMappings, m => PositionUtils.isBeforeOrEqual(m.originalRange.start, position));
if (!lastBefore) {
return new RangeMapping(
Range.create(position, position),
Range.create(position, position)
);
}
if (RangeUtils.containsPosition(lastBefore.originalRange, position)) {
return lastBefore;
}
const relativePosition = PositionUtils.relativize(lastBefore.originalRange.end, position);
const modifiedRangePosition = PositionUtils.resolve(lastBefore.modifiedRange.end, relativePosition);
return new RangeMapping(
Range.create(position, position),
Range.create(modifiedRangePosition, modifiedRangePosition)
);
}
/**
* @param range range in the original text document
*/
projectRange(range: Range): RangeMapping {
const start = this.projectPosition(range.start);
const end = this.projectPosition(range.end);
return new RangeMapping(
RangeUtils.union(start.originalRange, end.originalRange),
RangeUtils.union(start.modifiedRange, end.modifiedRange)
);
}
reverse(): DocumentRangeMap {
return new DocumentRangeMap(
this.rangeMappings.map(m => m.reverse())
);
}
}

View File

@@ -0,0 +1,122 @@
// *****************************************************************************
// Copyright (C) 2025 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
// *****************************************************************************
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
// copied and modified from https://github.com/microsoft/vscode/blob/1.96.3/src/vs/workbench/contrib/mergeEditor/browser/model/rangeUtils.ts,
// https://github.com/microsoft/vscode/blob/1.96.3/src/vs/editor/common/core/range.ts,
// https://github.com/microsoft/vscode/blob/1.96.3/src/vs/editor/common/core/position.ts,
// https://github.com/microsoft/vscode/blob/1.96.3/src/vs/editor/common/core/textLength.ts
import { Position, Range } from '@theia/core/shared/vscode-languageserver-protocol';
export namespace RangeUtils {
export function isEmpty(range: Range): boolean {
return range.start.line === range.end.line && range.start.character === range.end.character;
}
export function containsPosition(range: Range, position: Position): boolean {
if (position.line < range.start.line || position.line > range.end.line) {
return false;
}
if (position.line === range.start.line && position.character < range.start.character) {
return false;
}
if (position.line === range.end.line && position.character >= range.end.character) {
return false;
}
return true;
}
/**
* Returns `false` iff there is at least one character between `range` and `other`.
*/
export function touches(range: Range, other: Range): boolean {
return PositionUtils.isBeforeOrEqual(range.start, other.end) && PositionUtils.isBeforeOrEqual(other.start, range.end);
}
export function isBefore(range: Range, other: Range): boolean {
return (
range.end.line < other.start.line ||
(range.end.line === other.start.line &&
range.end.character <= other.start.character)
);
}
export function union(range: Range, other: Range): Range {
const start = PositionUtils.isBeforeOrEqual(range.start, other.start) ? range.start : other.start;
const end = PositionUtils.isBeforeOrEqual(range.end, other.end) ? other.end : range.end;
return { start, end };
}
/**
* A function that compares ranges, useful for sorting ranges.
* It will first compare ranges on the start position and then on the end position.
*/
export function compareUsingStarts(range: Range, other: Range): number {
if (range.start.line === other.start.line) {
if (range.start.character === other.start.character) {
if (range.end.line === other.end.line) {
return range.end.character - other.end.character;
}
return range.end.line - other.end.line;
}
return range.start.character - other.start.character;
}
return range.start.line - other.start.line;
}
}
export namespace PositionUtils {
export function isBeforeOrEqual(position: Position, other: Position): boolean {
return compare(position, other) <= 0;
}
export function compare(position: Position, other: Position): number {
if (position.line === other.line) {
return position.character - other.character;
}
return position.line - other.line;
}
/**
* Given two positions, computes the relative position of the greater position against the lesser position.
*/
export function relativize(position: Position, other: Position): Position {
if (compare(position, other) > 0) {
[position, other] = [other, position];
}
if (position.line === other.line) {
return Position.create(0, other.character - position.character);
} else {
return Position.create(other.line - position.line, other.character);
}
}
/**
* Resolves the given relative position against the given position and returns the resulting position.
*/
export function resolve(position: Position, relativePosition: Position): Position {
if (relativePosition.line === 0) {
return Position.create(position.line, position.character + relativePosition.character);
} else {
return Position.create(position.line + relativePosition.line, relativePosition.character);
}
}
}

View File

@@ -0,0 +1,160 @@
// *****************************************************************************
// Copyright (C) 2025 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 { injectable } from '@theia/core/shared/inversify';
import { ArrayUtils } from '@theia/core';
import { LineRangeMapping } from '../model/range-mapping';
export interface DiffSpacers {
/**
* An array representing spacers in the original side of the diff.
* Indices are line numbers in the original document, and values are the height in lines of the spacer directly above the given line.
* If a value is missing for a line number, the corresponding spacer is assumed to have zero height.
*/
originalSpacers: number[];
/**
* An array representing spacers in the modified side of the diff.
* Indices are line numbers in the modified document, and values are the height in lines of the spacer directly above the given line.
* If a value is missing for a line number, the corresponding spacer is assumed to have zero height.
*/
modifiedSpacers: number[];
/**
* An array respresenting a mapping of line numbers for the diff.
* Indices are line numbers in the original document, and values are the corresponding line numbers in the modified document.
* If a value is missing for a line number, it is assumed that the line was deleted.
*/
lineMapping: number[];
}
export type ModifiedSideSpacers = Omit<DiffSpacers, 'originalSpacers'>;
export interface CombinedMultiDiffSpacers {
originalSpacers: number[];
modifiedSides: ModifiedSideSpacers[];
}
@injectable()
export class DiffSpacerService {
computeDiffSpacers(changes: readonly LineRangeMapping[], originalLineCount: number): DiffSpacers {
const lineMapping: number[] = [];
const originalSpacers: number[] = [];
const modifiedSpacers: number[] = [];
let originalLine = 0;
let deltaSum = 0;
for (const { originalRange, modifiedRange } of changes) {
while (originalLine < originalRange.startLineNumber + Math.min(originalRange.lineCount, modifiedRange.lineCount)) {
lineMapping[originalLine] = originalLine + deltaSum;
originalLine++;
}
const delta = modifiedRange.lineCount - originalRange.lineCount;
deltaSum += delta;
if (delta > 0) {
originalSpacers[originalLine] = delta;
}
if (delta < 0) {
modifiedSpacers[modifiedRange.endLineNumberExclusive] = -delta;
originalLine += -delta;
}
}
while (originalLine <= originalLineCount) {
lineMapping[originalLine] = originalLine + deltaSum;
originalLine++;
}
return { originalSpacers, modifiedSpacers, lineMapping };
}
/**
* Combines multiple {@link DiffSpacers} objects into a {@link CombinedMultiDiffSpacers} object with the appropriately adjusted spacers.
* The given {@link DiffSpacers} objects are not modified.
*
* It is assumed that all of the given {@link DiffSpacers} objects have been computed from diffs against the same original side.
*/
combineMultiDiffSpacers(multiDiffSpacers: DiffSpacers[]): CombinedMultiDiffSpacers {
if (multiDiffSpacers.length < 2) {
throw new Error('At least two items are required');
}
this.checkLineMappingsHaveEqualLength(multiDiffSpacers);
const originalSpacers: number[] = [];
const modifiedSides: ModifiedSideSpacers[] = [];
for (const { modifiedSpacers, lineMapping } of multiDiffSpacers) {
const modifiedSpacersCopy = modifiedSpacers.concat(); // note: copying by concat() preserves empty slots of the sparse array
modifiedSides.push({ modifiedSpacers: modifiedSpacersCopy, lineMapping });
}
const originalLineCount = modifiedSides[0].lineMapping.length;
for (let originalLine = 0; originalLine < originalLineCount; originalLine++) {
const max = Math.max(...multiDiffSpacers.map(diffSpacers => diffSpacers.originalSpacers[originalLine] ?? 0));
if (max > 0) {
originalSpacers[originalLine] = max;
for (let i = 0; i < multiDiffSpacers.length; i++) {
const delta = max - (multiDiffSpacers[i].originalSpacers[originalLine] ?? 0);
if (delta > 0) {
const { modifiedSpacers, lineMapping } = modifiedSides[i];
const modifiedLine = this.projectLine(originalLine, lineMapping);
modifiedSpacers[modifiedLine] = (modifiedSpacers[modifiedLine] ?? 0) + delta;
}
}
}
}
return { originalSpacers, modifiedSides };
}
/**
* Given a {@link CombinedMultiDiffSpacers} object, excludes the original side, returning the modified sides with the appropriately adjusted spacers.
* The given {@link CombinedMultiDiffSpacers} object is not modified.
*/
excludeOriginalSide({ modifiedSides }: CombinedMultiDiffSpacers): { modifiedSides: { modifiedSpacers: number[] }[] } {
if (modifiedSides.length < 2) {
throw new Error('At least two modified sides are required');
}
this.checkLineMappingsHaveEqualLength(modifiedSides);
const modifiedSidesCopy: { modifiedSpacers: number[] }[] = [];
for (const { modifiedSpacers } of modifiedSides) {
const modifiedSpacersCopy = modifiedSpacers.concat(); // note: copying by concat() preserves empty slots of the sparse array
modifiedSidesCopy.push({ modifiedSpacers: modifiedSpacersCopy });
}
// When the original side is excluded, the adjoining spacers in the modified sides can be deflated by removing their intersecting parts.
const originalLineCount = modifiedSides[0].lineMapping.length;
for (let originalLine = 0; originalLine < originalLineCount; originalLine++) {
if (modifiedSides.every(({ lineMapping }) => lineMapping[originalLine] === undefined)) {
modifiedSides.forEach(({ lineMapping }, index) => {
const modifiedLine = this.projectLine(originalLine, lineMapping);
const { modifiedSpacers } = modifiedSidesCopy[index];
modifiedSpacers[modifiedLine]--;
});
}
}
return { modifiedSides: modifiedSidesCopy };
}
protected checkLineMappingsHaveEqualLength(items: { lineMapping: number[] }[]): void {
if (!ArrayUtils.checkAdjacentItems(items, (item1, item2) => item1.lineMapping.length === item2.lineMapping.length)) {
throw new Error('Line mappings must have equal length');
}
}
protected projectLine(originalLine: number, lineMapping: number[]): number {
let modifiedLine: number | undefined;
const originalLineCount = lineMapping.length;
while (originalLine < originalLineCount) {
modifiedLine = lineMapping[originalLine++];
if (modifiedLine !== undefined) {
return modifiedLine;
}
}
throw new Error('Assertion failed');
}
}

View File

@@ -0,0 +1,21 @@
// *****************************************************************************
// Copyright (C) 2025 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
// *****************************************************************************
export * from './merge-editor-pane';
export * from './merge-editor-pane-header';
export * from './merge-editor-base-pane';
export * from './merge-editor-side-pane';
export * from './merge-editor-result-pane';

View File

@@ -0,0 +1,71 @@
// *****************************************************************************
// Copyright (C) 2025 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 { injectable } from '@theia/core/shared/inversify';
import { nls } from '@theia/core';
import { Autorun } from '@theia/core/lib/common/observable';
import { EditorDecoration, Range } from '@theia/editor/lib/browser';
import { MergeEditorPane } from './merge-editor-pane';
import { MergeRange } from '../../model/merge-range';
import { LineRange } from '../../model/line-range';
@injectable()
export class MergeEditorBasePane extends MergeEditorPane {
constructor() {
super();
this.addClass('base');
}
override getLineRangeForMergeRange(mergeRange: MergeRange): LineRange {
return mergeRange.baseRange;
}
protected override translateBaseRange(range: Range): Range {
return range;
}
protected override onAfterMergeEditorSet(): void {
super.onAfterMergeEditorSet();
this.toDispose.push(Autorun.create(() => {
const { currentPane, side1Pane, side1Title, side2Pane, side2Title } = this.mergeEditor;
this.header.description = currentPane === this ? '' : nls.localizeByDefault('Comparing with {0}',
currentPane === side1Pane ? side1Title : currentPane === side2Pane ? side2Title : nls.localizeByDefault('Result')
);
}));
}
protected override computeEditorDecorations(): EditorDecoration[] {
const result: EditorDecoration[] = [];
const { model, currentPane, side1Pane, side2Pane, currentMergeRange } = this.mergeEditor;
for (const mergeRange of model.mergeRanges) {
const lineRange = mergeRange.baseRange;
result.push(this.toMergeRangeDecoration(lineRange, {
isHandled: model.isMergeRangeHandled(mergeRange),
isFocused: mergeRange === currentMergeRange,
isAfterEnd: lineRange.startLineNumber > model.baseDocument.lineCount,
}));
}
if (currentPane !== this) {
const changes = currentPane === side1Pane ? model.side1Changes : currentPane === side2Pane ? model.side2Changes : model.resultChanges;
result.push(...this.toChangeDecorations(changes, { diffSide: 'original' }));
}
return result;
}
}

View File

@@ -0,0 +1,106 @@
// *****************************************************************************
// Copyright (C) 2025 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 * as React from '@theia/core/shared/react';
import { codicon, Message, onDomEvent, ReactWidget } from '@theia/core/lib/browser';
import { LabelParser } from '@theia/core/lib/browser/label-parser';
@injectable()
export class MergeEditorPaneHeader extends ReactWidget {
@inject(LabelParser)
protected readonly labelParser: LabelParser;
private _description = '';
get description(): string {
return this._description;
}
set description(description: string) {
this._description = description;
this.update();
}
private _detail = '';
get detail(): string {
return this._detail;
}
set detail(detail: string) {
this._detail = detail;
this.update();
}
private _toolbarItems: readonly MergeEditorPaneToolbarItem[];
get toolbarItems(): readonly MergeEditorPaneToolbarItem[] {
return this._toolbarItems;
}
set toolbarItems(toolbarItems: readonly MergeEditorPaneToolbarItem[]) {
this._toolbarItems = toolbarItems;
this.update();
}
@postConstruct()
protected init(): void {
this.addClass('header');
this.scrollOptions = undefined;
this.node.tabIndex = -1;
this.toDispose.push(onDomEvent(this.node, 'click', () => this.activate()));
this.title.changed.connect(this.update, this);
}
protected override onActivateRequest(msg: Message): void {
super.onActivateRequest(msg);
this.parent?.activate();
}
protected override render(): React.ReactNode {
return (
<React.Fragment>
<span className='title'>{this.renderWithIcons(this.title.label)}</span>
<span className='description'>{this.renderWithIcons(this.description)}</span>
<span className='detail'>{this.renderWithIcons(this.detail)}</span>
<span className='toolbar' onClick={this.handleToolbarClick}>{this.toolbarItems.map(toolbarItem => this.renderToolbarItem(toolbarItem))}</span>
</React.Fragment>
);
}
private readonly handleToolbarClick = (event: React.MouseEvent) => event.nativeEvent.stopImmediatePropagation();
protected renderWithIcons(text: string): React.ReactNode[] {
const result: React.ReactNode[] = [];
const labelParts = this.labelParser.parse(text);
labelParts.forEach((labelPart, index) => {
if (typeof labelPart === 'string') {
result.push(labelPart);
} else {
result.push(<span key={index} className={codicon(labelPart.name)}></span>);
}
});
return result;
}
protected renderToolbarItem({ id, label, tooltip, className, onClick }: MergeEditorPaneToolbarItem): React.ReactNode {
return <span key={id} title={tooltip} onClick={onClick} className={className}>{label}</span>;
}
}
export interface MergeEditorPaneToolbarItem {
readonly id: string;
readonly label?: string;
readonly tooltip?: string;
readonly className?: string;
readonly onClick?: (event: React.MouseEvent) => void;
}

View File

@@ -0,0 +1,246 @@
// *****************************************************************************
// Copyright (C) 2025 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 } from '@theia/core';
import { Autorun, DerivedObservable, Observable, ObservableFromEvent } from '@theia/core/lib/common/observable';
import { BoxPanel, Message } from '@theia/core/lib/browser';
import { EditorDecoration, EditorWidget, MinimapPosition, OverviewRulerLane, Position, Range, TrackedRangeStickiness } from '@theia/editor/lib/browser';
import { MonacoEditor } from '@theia/monaco/lib/browser/monaco-editor';
import { MonacoToProtocolConverter } from '@theia/monaco/lib/browser/monaco-to-protocol-converter';
import { Selection } from '@theia/monaco-editor-core';
import { MergeEditorPaneHeader, MergeEditorPaneToolbarItem } from './merge-editor-pane-header';
import { MergeEditor } from '../../merge-editor';
import { MergeRange } from '../../model/merge-range';
import { DetailedLineRangeMapping } from '../../model/range-mapping';
import { LineRange } from '../../model/line-range';
import { RangeUtils } from '../../model/range-utils';
import { ScmColors } from '../../../scm-colors';
@injectable()
export abstract class MergeEditorPane extends BoxPanel {
@inject(MergeEditorPaneHeader)
readonly header: MergeEditorPaneHeader;
@inject(EditorWidget)
readonly editorWidget: EditorWidget;
@inject(MonacoToProtocolConverter)
private readonly m2p: MonacoToProtocolConverter;
get editor(): MonacoEditor {
return MonacoEditor.get(this.editorWidget)!;
}
protected _mergeEditor: MergeEditor;
protected cursorPositionObservable: Observable<Position>;
protected cursorLineObservable: Observable<number>;
protected selectionObservable: Observable<Range[] | undefined>;
protected readonly toDispose = new DisposableCollection();
constructor() {
super({ spacing: 0 });
this.addClass('editor-pane');
}
@postConstruct()
protected init(): void {
this.cursorPositionObservable = ObservableFromEvent.create(this.editor.onCursorPositionChanged, () => this.editor.cursor);
this.cursorLineObservable = DerivedObservable.create(() => this.cursorPositionObservable.get().line);
this.selectionObservable = ObservableFromEvent.create(this.editor.getControl().onDidChangeCursorSelection, () => {
const selections = this.editor.getControl().getSelections();
return selections?.map(selection => this.m2p.asRange(selection));
});
BoxPanel.setStretch(this.header, 0);
BoxPanel.setStretch(this.editorWidget, 1);
this.addWidget(this.header);
this.addWidget(this.editorWidget);
}
override dispose(): void {
super.dispose();
this.toDispose.dispose();
}
get mergeEditor(): MergeEditor {
return this._mergeEditor;
}
set mergeEditor(mergeEditor: MergeEditor) {
if (this._mergeEditor) {
throw new Error('Merge editor has already been set');
}
this._mergeEditor = mergeEditor;
this.onAfterMergeEditorSet();
}
protected onAfterMergeEditorSet(): void {
this.initContextKeys();
const toolbarItems = DerivedObservable.create(() => this.getToolbarItems());
this.toDispose.push(Autorun.create(() => {
this.header.toolbarItems = toolbarItems.get();
}));
this.initSelectionSynchronizer();
let decorationIds: string[] = [];
const decorations = DerivedObservable.create(() => this.computeEditorDecorations());
const isVisible = ObservableFromEvent.create(this.editorWidget.onDidChangeVisibility, () => this.editorWidget.isVisible);
this.toDispose.push(Autorun.create(() => {
if (this.mergeEditor.isShown && isVisible.get()) {
decorationIds = this.editor.deltaDecorations({ oldDecorations: decorationIds, newDecorations: decorations.get() });
}
}));
this.toDispose.push(Disposable.create(() =>
decorationIds = this.editor.deltaDecorations({ oldDecorations: decorationIds, newDecorations: [] })
));
}
get cursorPosition(): Position {
return this.cursorPositionObservable.get();
}
get cursorLine(): number {
return this.cursorLineObservable.get();
}
get selection(): Range[] | undefined {
return this.selectionObservable.get();
}
goToMergeRange(mergeRange: MergeRange, options?: { reveal?: boolean }): void {
const { editor } = this;
const { startLineNumber, endLineNumberExclusive } = this.getLineRangeForMergeRange(mergeRange);
editor.cursor = { line: startLineNumber, character: 0 };
const reveal = options?.reveal ?? true;
if (reveal) {
editor.getControl().revealLinesNearTop(startLineNumber + 1, endLineNumberExclusive + 1);
}
}
abstract getLineRangeForMergeRange(mergeRange: MergeRange): LineRange;
protected abstract translateBaseRange(range: Range): Range;
protected getToolbarItems(): MergeEditorPaneToolbarItem[] {
return [];
}
protected computeEditorDecorations(): EditorDecoration[] {
return [];
}
protected toMergeRangeDecoration(lineRange: LineRange,
{ isHandled, isFocused, isAfterEnd }: { isHandled: boolean, isFocused: boolean, isAfterEnd: boolean }
): EditorDecoration {
const blockClassNames = ['merge-range'];
let blockPadding: [top: number, right: number, bottom: number, left: number] = [0, 0, 0, 0];
if (isHandled) {
blockClassNames.push('handled');
}
if (isFocused) {
blockClassNames.push('focused');
blockPadding = [0, 2, 0, 2];
}
return {
range: lineRange.toInclusiveRangeOrEmpty(),
options: {
blockClassName: blockClassNames.join(' '),
blockPadding,
blockIsAfterEnd: isAfterEnd,
minimap: {
position: MinimapPosition.Gutter,
color: { id: isHandled ? ScmColors.handledConflictMinimapOverviewRulerColor : ScmColors.unhandledConflictMinimapOverviewRulerColor },
},
overviewRuler: {
position: OverviewRulerLane.Center,
color: { id: isHandled ? ScmColors.handledConflictMinimapOverviewRulerColor : ScmColors.unhandledConflictMinimapOverviewRulerColor },
},
showIfCollapsed: true,
stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges,
}
};
}
protected toChangeDecorations(changes: readonly DetailedLineRangeMapping[],
{ diffSide }: { diffSide: 'original' | 'modified' }
): EditorDecoration[] {
const result: EditorDecoration[] = [];
for (const change of changes) {
const changeRange = (diffSide === 'original' ? change.originalRange : change.modifiedRange).toInclusiveRange();
if (changeRange) {
result.push({
range: changeRange,
options: {
className: 'diff',
isWholeLine: true,
stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges,
}
});
}
for (const rangeMapping of change.rangeMappings) {
const range = diffSide === 'original' ? rangeMapping.originalRange : rangeMapping.modifiedRange;
result.push({
range,
options: {
className: RangeUtils.isEmpty(range) ? 'diff-empty-word' : 'diff-word',
showIfCollapsed: true,
stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges,
},
});
}
}
return result;
}
protected initContextKeys(): void {
const editor = this.editor.getControl();
editor.createContextKey('isMergeEditor', true);
editor.createContextKey('mergeEditorBaseUri', this.mergeEditor.baseUri.toString());
editor.createContextKey('mergeEditorResultUri', this.mergeEditor.resultUri.toString());
}
protected initSelectionSynchronizer(): void {
const selectionObservable = DerivedObservable.create(() => {
const { selectionInBase, currentPane } = this.mergeEditor;
if (!selectionInBase || currentPane === this) {
return [];
}
return selectionInBase.map(range => this.translateBaseRange(range));
});
this.toDispose.push(Autorun.create(() => {
const selection = selectionObservable.get();
if (selection.length) {
this.editor.getControl().setSelections(selection.map(
({ start, end }) => new Selection(start.line + 1, start.character + 1, end.line + 1, end.character + 1)
));
}
}));
}
protected override onActivateRequest(msg: Message): void {
super.onActivateRequest(msg);
this.editorWidget.activate();
}
}

View File

@@ -0,0 +1,115 @@
// *****************************************************************************
// Copyright (C) 2025 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 { injectable } from '@theia/core/shared/inversify';
import { nls } from '@theia/core';
import { ACTION_ITEM, codicon, ConfirmDialog, Dialog, DISABLED_CLASS } from '@theia/core/lib/browser';
import { ObservableUtils } from '@theia/core/lib/common/observable';
import { EditorDecoration, Range } from '@theia/editor/lib/browser';
import { MergeEditorPane } from './merge-editor-pane';
import { MergeEditorPaneToolbarItem } from './merge-editor-pane-header';
import { LineRange } from '../../model/line-range';
import { MergeRange } from '../../model/merge-range';
@injectable()
export class MergeEditorResultPane extends MergeEditorPane {
constructor() {
super();
this.addClass('result');
}
protected override initContextKeys(): void {
super.initContextKeys();
this.editor.getControl().createContextKey('isMergeResultEditor', true);
}
override getLineRangeForMergeRange(mergeRange: MergeRange): LineRange {
return this.mergeEditor.model.getLineRangeInResult(mergeRange);
}
protected override translateBaseRange(range: Range): Range {
return this.mergeEditor.model.translateBaseRangeToResult(range);
}
protected goToNextUnhandledMergeRange(): void {
this.mergeEditor.goToNextMergeRange(mergeRange => !this.mergeEditor.model.isMergeRangeHandled(mergeRange));
this.mergeEditor.activate();
}
reset(): void {
new ConfirmDialog({
title: nls.localize('theia/scm/mergeEditor/resetConfirmationTitle', 'Do you really want to reset the merge result in this editor?'),
msg: nls.localizeByDefault('This action cannot be undone.'),
ok: Dialog.YES,
cancel: Dialog.NO,
}).open().then(async confirmed => {
if (confirmed) {
this.activate();
const { model } = this.mergeEditor;
await model.reset();
await ObservableUtils.waitForState(model.isUpToDateObservable);
this.mergeEditor.goToFirstMergeRange(mergeRange => !model.isMergeRangeHandled(mergeRange));
}
}).catch(e => console.error(e));
}
protected override getToolbarItems(): MergeEditorPaneToolbarItem[] {
const { model } = this.mergeEditor;
const { unhandledMergeRangesCount } = model;
return [
{
id: 'nextConflict',
label: unhandledMergeRangesCount === 1 ?
nls.localizeByDefault('{0} Conflict Remaining', unhandledMergeRangesCount) :
nls.localizeByDefault('{0} Conflicts Remaining ', unhandledMergeRangesCount),
tooltip: unhandledMergeRangesCount ?
nls.localizeByDefault('Go to next conflict') :
nls.localizeByDefault('All conflicts handled, the merge can be completed now.'),
className: ACTION_ITEM + (unhandledMergeRangesCount ? '' : ' ' + DISABLED_CLASS),
onClick: unhandledMergeRangesCount ?
() => this.goToNextUnhandledMergeRange() :
undefined
},
{
id: 'reset',
tooltip: nls.localizeByDefault('Reset'),
className: codicon('discard', true),
onClick: () => this.reset()
}
];
}
protected override computeEditorDecorations(): EditorDecoration[] {
const result: EditorDecoration[] = [];
const { model, currentMergeRange } = this.mergeEditor;
for (const mergeRange of model.mergeRanges) {
if (mergeRange) {
const lineRange = model.getLineRangeInResult(mergeRange);
result.push(this.toMergeRangeDecoration(lineRange, {
isHandled: model.isMergeRangeHandled(mergeRange),
isFocused: mergeRange === currentMergeRange,
isAfterEnd: lineRange.startLineNumber > model.resultDocument.lineCount,
}));
}
}
result.push(...this.toChangeDecorations(model.resultChanges, { diffSide: 'modified' }));
return result;
}
}

View File

@@ -0,0 +1,139 @@
// *****************************************************************************
// Copyright (C) 2025 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 } from '@theia/core/shared/inversify';
import { nls } from '@theia/core';
import { ObservableUtils } from '@theia/core/lib/common/observable';
import { codicon, DiffUris, LabelProvider, open, OpenerService } from '@theia/core/lib/browser';
import { EditorDecoration, EditorOpenerOptions, Range } from '@theia/editor/lib/browser';
import { MergeRange, MergeRangeAcceptedState, MergeSide } from '../../model/merge-range';
import { MergeEditorPane } from './merge-editor-pane';
import { MergeEditorPaneToolbarItem } from './merge-editor-pane-header';
import { LineRange } from '../../model/line-range';
@injectable()
export abstract class MergeEditorSidePane extends MergeEditorPane {
@inject(LabelProvider)
protected readonly labelProvider: LabelProvider;
@inject(OpenerService)
protected readonly openerService: OpenerService;
abstract get mergeSide(): MergeSide;
constructor() {
super();
this.addClass('side');
}
override getLineRangeForMergeRange(mergeRange: MergeRange): LineRange {
return mergeRange.getModifiedRange(this.mergeSide);
}
protected override translateBaseRange(range: Range): Range {
return this.mergeEditor.model.translateBaseRangeToSide(range, this.mergeSide);
}
async acceptAllChanges(): Promise<void> {
const { model, resultPane } = this.mergeEditor;
resultPane.activate();
const selections = resultPane.editor.getControl().getSelections();
for (const mergeRange of model.mergeRanges) {
await ObservableUtils.waitForState(model.isUpToDateObservable);
resultPane.goToMergeRange(mergeRange, { reveal: false });
let state = model.getMergeRangeResultState(mergeRange);
if (state === 'Unrecognized') {
state = 'Base';
}
model.applyMergeRangeAcceptedState(mergeRange, MergeRangeAcceptedState.addSide(state, this.mergeSide));
}
if (selections) {
resultPane.editor.getControl().setSelections(selections);
}
}
compareWithBase(): void {
let label = this.labelProvider.getName(this.editor.uri);
if (label) {
label += ': ';
}
label += `${nls.localizeByDefault('Base')}${this.header.title.label}`;
const options: EditorOpenerOptions = { selection: { start: this.editor.cursor } };
open(this.openerService, DiffUris.encode(this.mergeEditor.baseUri, this.editor.uri, label), options).catch(e => {
console.error(e);
});
}
protected override getToolbarItems(): MergeEditorPaneToolbarItem[] {
return [
{
id: 'acceptAllChanges',
tooltip: nls.localizeByDefault(this.mergeSide === 1 ? 'Accept All Changes from Left' : 'Accept All Changes from Right'),
className: codicon('check-all', true),
onClick: () => this.acceptAllChanges()
},
{
id: 'compareWithBase',
tooltip: nls.localizeByDefault('Compare With Base'),
className: codicon('compare-changes', true),
onClick: () => this.compareWithBase()
}
];
}
protected override computeEditorDecorations(): EditorDecoration[] {
const result: EditorDecoration[] = [];
const { model, currentMergeRange } = this.mergeEditor;
const document = this.mergeSide === 1 ? model.side1Document : model.side2Document;
for (const mergeRange of model.mergeRanges) {
const lineRange = mergeRange.getModifiedRange(this.mergeSide);
result.push(this.toMergeRangeDecoration(lineRange, {
isHandled: model.isMergeRangeHandled(mergeRange),
isFocused: mergeRange === currentMergeRange,
isAfterEnd: lineRange.startLineNumber > document.lineCount,
}));
}
const changes = this.mergeSide === 1 ? model.side1Changes : model.side2Changes;
result.push(...this.toChangeDecorations(changes, { diffSide: 'modified' }));
return result;
}
}
@injectable()
export class MergeEditorSide1Pane extends MergeEditorSidePane {
readonly mergeSide = 1;
constructor() {
super();
this.addClass('side1');
}
}
@injectable()
export class MergeEditorSide2Pane extends MergeEditorSidePane {
readonly mergeSide = 2;
constructor() {
super();
this.addClass('side2');
}
}

View File

@@ -0,0 +1,241 @@
// *****************************************************************************
// Copyright (C) 2025 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
// *****************************************************************************
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
// copied and modified from https://github.com/microsoft/vscode/blob/1.96.3/src/vs/workbench/contrib/mergeEditor/browser/view/scrollSynchronizer.ts
import { Disposable, DisposableCollection } from '@theia/core';
import { MonacoEditor } from '@theia/monaco/lib/browser/monaco-editor';
import { MergeEditor } from '../merge-editor';
import { DocumentLineRangeMap } from '../model/range-mapping';
export class MergeEditorScrollSync implements Disposable {
protected readonly toDispose = new DisposableCollection();
protected isSyncing = false;
constructor(protected readonly mergeEditor: MergeEditor) {
const { side1Pane, side2Pane, resultPane, basePane } = mergeEditor;
const syncingHandler = <T>(handler: (event: T) => void) => (event: T) => {
if (this.isSyncing) {
return;
}
this.isSyncing = true;
try {
handler(event);
} finally {
this.isSyncing = false;
}
};
this.toDispose.push(side1Pane.editor.getControl().onDidScrollChange(syncingHandler(event => {
if (event.scrollTopChanged) {
this.handleSide1ScrollTopChanged(event.scrollTop);
}
if (event.scrollLeftChanged) {
basePane.editor.getControl().setScrollLeft(event.scrollLeft);
side2Pane.editor.getControl().setScrollLeft(event.scrollLeft);
resultPane.editor.getControl().setScrollLeft(event.scrollLeft);
}
})));
this.toDispose.push(side2Pane.editor.getControl().onDidScrollChange(syncingHandler(event => {
if (event.scrollTopChanged) {
this.handleSide2ScrollTopChanged(event.scrollTop);
}
if (event.scrollLeftChanged) {
basePane.editor.getControl().setScrollLeft(event.scrollLeft);
side1Pane.editor.getControl().setScrollLeft(event.scrollLeft);
resultPane.editor.getControl().setScrollLeft(event.scrollLeft);
}
})));
this.toDispose.push(resultPane.editor.getControl().onDidScrollChange(syncingHandler(event => {
if (event.scrollTopChanged) {
this.handleResultScrollTopChanged(event.scrollTop);
}
if (event.scrollLeftChanged) {
basePane.editor.getControl().setScrollLeft(event.scrollLeft);
side1Pane.editor.getControl().setScrollLeft(event.scrollLeft);
side2Pane.editor.getControl().setScrollLeft(event.scrollLeft);
}
})));
this.toDispose.push(basePane.editor.getControl().onDidScrollChange(syncingHandler(event => {
if (event.scrollTopChanged) {
this.handleBaseScrollTopChanged(event.scrollTop);
}
if (event.scrollLeftChanged) {
side1Pane.editor.getControl().setScrollLeft(event.scrollLeft);
side2Pane.editor.getControl().setScrollLeft(event.scrollLeft);
resultPane.editor.getControl().setScrollLeft(event.scrollLeft);
}
})));
}
dispose(): void {
this.toDispose.dispose();
}
storeScrollState(): unknown {
return this.mergeEditor.side1Pane.editor.getControl().getScrollTop();
}
restoreScrollState(state: unknown): void {
if (typeof state === 'number') {
const scrollTop = this.mergeEditor.side1Pane.editor.getControl().getScrollTop();
if (state !== scrollTop) {
this.mergeEditor.side1Pane.editor.getControl().setScrollTop(state);
} else {
this.update();
}
}
}
update(): void {
if (this.isSyncing) {
return;
}
this.isSyncing = true;
try {
const scrollTop = this.mergeEditor.side1Pane.editor.getControl().getScrollTop();
this.handleSide1ScrollTopChanged(scrollTop);
} finally {
this.isSyncing = false;
}
}
protected handleSide1ScrollTopChanged(scrollTop: number): void {
const { side1Pane, side2Pane, resultPane, basePane, shouldAlignBase, shouldAlignResult, model } = this.mergeEditor;
side2Pane.editor.getControl().setScrollTop(scrollTop);
if (shouldAlignResult) {
resultPane.editor.getControl().setScrollTop(scrollTop);
} else {
const targetScrollTop = this.computeTargetScrollTop(side1Pane.editor, resultPane.editor, model.side1ToResultLineRangeMap);
resultPane.editor.getControl().setScrollTop(targetScrollTop);
}
if (shouldAlignBase) {
basePane.editor.getControl().setScrollTop(scrollTop);
} else {
const targetScrollTop = this.computeTargetScrollTop(side1Pane.editor, basePane.editor, model.side1ToBaseLineRangeMap);
basePane.editor.getControl().setScrollTop(targetScrollTop);
}
}
protected handleSide2ScrollTopChanged(scrollTop: number): void {
const { side1Pane, side2Pane, resultPane, basePane, shouldAlignBase, shouldAlignResult, model } = this.mergeEditor;
side1Pane.editor.getControl().setScrollTop(scrollTop);
if (shouldAlignResult) {
resultPane.editor.getControl().setScrollTop(scrollTop);
} else {
const targetScrollTop = this.computeTargetScrollTop(side2Pane.editor, resultPane.editor, model.side2ToResultLineRangeMap);
resultPane.editor.getControl().setScrollTop(targetScrollTop);
}
if (shouldAlignBase) {
basePane.editor.getControl().setScrollTop(scrollTop);
} else {
const targetScrollTop = this.computeTargetScrollTop(side2Pane.editor, basePane.editor, model.side2ToBaseLineRangeMap);
basePane.editor.getControl().setScrollTop(targetScrollTop);
}
}
protected handleResultScrollTopChanged(scrollTop: number): void {
const { side1Pane, side2Pane, resultPane, basePane, shouldAlignBase, shouldAlignResult, model } = this.mergeEditor;
if (shouldAlignResult) {
side1Pane.editor.getControl().setScrollTop(scrollTop);
side2Pane.editor.getControl().setScrollTop(scrollTop);
} else {
const targetScrollTop = this.computeTargetScrollTop(resultPane.editor, side1Pane.editor, model.resultToSide1LineRangeMap);
side1Pane.editor.getControl().setScrollTop(targetScrollTop);
side2Pane.editor.getControl().setScrollTop(targetScrollTop);
if (shouldAlignBase) {
basePane.editor.getControl().setScrollTop(targetScrollTop);
}
}
if (!shouldAlignBase) {
const targetScrollTop = this.computeTargetScrollTop(resultPane.editor, basePane.editor, model.resultToBaseLineRangeMap);
basePane.editor.getControl().setScrollTop(targetScrollTop);
}
}
protected handleBaseScrollTopChanged(scrollTop: number): void {
const { side1Pane, side2Pane, resultPane, basePane, shouldAlignBase, shouldAlignResult, model } = this.mergeEditor;
if (shouldAlignBase) {
side1Pane.editor.getControl().setScrollTop(scrollTop);
side2Pane.editor.getControl().setScrollTop(scrollTop);
} else {
const targetScrollTop = this.computeTargetScrollTop(basePane.editor, side1Pane.editor, model.baseToSide1LineRangeMap);
side1Pane.editor.getControl().setScrollTop(targetScrollTop);
side2Pane.editor.getControl().setScrollTop(targetScrollTop);
if (shouldAlignResult) {
resultPane.editor.getControl().setScrollTop(targetScrollTop);
}
}
if (!shouldAlignResult) {
const targetScrollTop = this.computeTargetScrollTop(basePane.editor, resultPane.editor, model.baseToResultLineRangeMap);
resultPane.editor.getControl().setScrollTop(targetScrollTop);
}
}
protected computeTargetScrollTop(sourceEditor: MonacoEditor, targetEditor: MonacoEditor, lineRangeMap: DocumentLineRangeMap): number {
const visibleRanges = sourceEditor.getVisibleRanges();
if (visibleRanges.length === 0) {
return 0;
}
const topLineNumber = visibleRanges[0].start.line;
const scrollTop = sourceEditor.getControl().getScrollTop();
let sourceStartTopPx: number;
let sourceEndPx: number;
let targetStartTopPx: number;
let targetEndPx: number;
if (topLineNumber === 0 && scrollTop <= sourceEditor.getControl().getTopForLineNumber(1)) { // special case: scrollTop is before or at the top of the first line
sourceStartTopPx = 0;
sourceEndPx = sourceEditor.getControl().getTopForLineNumber(1);
targetStartTopPx = 0;
targetEndPx = targetEditor.getControl().getTopForLineNumber(1);
} else {
const { originalRange: sourceRange, modifiedRange: targetRange } = lineRangeMap.projectLine(Math.max(topLineNumber - 1, 0));
sourceStartTopPx = sourceEditor.getControl().getTopForLineNumber(sourceRange.startLineNumber + 1);
sourceEndPx = sourceEditor.getControl().getTopForLineNumber(sourceRange.endLineNumberExclusive + 1);
targetStartTopPx = targetEditor.getControl().getTopForLineNumber(targetRange.startLineNumber + 1);
targetEndPx = targetEditor.getControl().getTopForLineNumber(targetRange.endLineNumberExclusive + 1);
}
const factor = Math.min(sourceEndPx === sourceStartTopPx ? 0 : (scrollTop - sourceStartTopPx) / (sourceEndPx - sourceStartTopPx), 1);
const targetScrollTop = targetStartTopPx + (targetEndPx - targetStartTopPx) * factor;
return targetScrollTop;
}
}

View File

@@ -0,0 +1,264 @@
// *****************************************************************************
// Copyright (C) 2025 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 } from '@theia/core/shared/inversify';
import { Disposable } from '@theia/core';
import { Autorun, Observable } from '@theia/core/lib/common/observable';
import * as monaco from '@theia/monaco-editor-core';
import { MonacoEditorViewZone } from '@theia/monaco/lib/browser/monaco-editor-zone-widget';
import { MergeEditor } from '../merge-editor';
import { MergeRange } from '../model/merge-range';
import { MergeRangeAction, MergeRangeActions } from './merge-range-actions';
import { MergeEditorPane } from './merge-editor-panes';
import { DiffSpacers, DiffSpacerService } from './diff-spacers';
export interface MergeEditorViewZone {
create(ctx: MergeEditorViewZone.CreationContext): void;
}
export namespace MergeEditorViewZone {
export interface CreationContext {
createViewZone(viewZone: Omit<MonacoEditorViewZone, 'id'>): void;
register(disposable: Disposable): void;
}
}
export interface MergeEditorViewZones {
readonly baseViewZones: readonly MergeEditorViewZone[];
readonly side1ViewZones: readonly MergeEditorViewZone[];
readonly side2ViewZones: readonly MergeEditorViewZone[];
readonly resultViewZones: readonly MergeEditorViewZone[];
}
@injectable()
export class MergeEditorViewZoneComputer {
@inject(DiffSpacerService)
protected readonly diffSpacerService: DiffSpacerService;
computeViewZones(mergeEditor: MergeEditor): MergeEditorViewZones {
const baseViewZones: MergeEditorViewZone[] = [];
const side1ViewZones: MergeEditorViewZone[] = [];
const side2ViewZones: MergeEditorViewZone[] = [];
const resultViewZones: MergeEditorViewZone[] = [];
const { model, shouldAlignResult, shouldAlignBase } = mergeEditor;
for (const mergeRange of model.mergeRanges) {
const { side1Pane, side2Pane, resultPane } = mergeEditor;
const actions = this.newMergeRangeActions(mergeEditor, mergeRange);
let resultActionZoneHeight = this.getActionZoneMinHeight(resultPane);
if (actions.hasSideActions || (shouldAlignResult && actions.hasResultActions)) {
let actionZoneHeight = Math.max(this.getActionZoneMinHeight(side1Pane), this.getActionZoneMinHeight(side2Pane));
if (shouldAlignResult) {
resultActionZoneHeight = actionZoneHeight = Math.max(actionZoneHeight, resultActionZoneHeight);
}
side1ViewZones.push(this.newActionZone(side1Pane, actions.side1ActionsObservable, mergeRange.side1Range.startLineNumber - 1, actionZoneHeight));
side2ViewZones.push(this.newActionZone(side2Pane, actions.side2ActionsObservable, mergeRange.side2Range.startLineNumber - 1, actionZoneHeight));
if (shouldAlignBase) {
baseViewZones.push(this.newActionZonePlaceholder(mergeRange.baseRange.startLineNumber - 1, actionZoneHeight));
}
}
if (actions.hasResultActions) {
resultViewZones.push(
this.newActionZone(resultPane, actions.resultActionsObservable, model.getLineRangeInResult(mergeRange).startLineNumber - 1, resultActionZoneHeight)
);
} else if (shouldAlignResult && actions.hasSideActions) {
resultViewZones.push(this.newActionZonePlaceholder(model.getLineRangeInResult(mergeRange).startLineNumber - 1, resultActionZoneHeight));
}
}
const baseLineCount = model.baseDocument.lineCount;
const multiDiffSpacers: DiffSpacers[] = [];
multiDiffSpacers.push(this.diffSpacerService.computeDiffSpacers(model.side1Changes, baseLineCount));
multiDiffSpacers.push(this.diffSpacerService.computeDiffSpacers(model.side2Changes, baseLineCount));
if (shouldAlignResult) {
multiDiffSpacers.push(this.diffSpacerService.computeDiffSpacers(model.resultChanges, baseLineCount));
}
const combinedMultiDiffSpacers = this.diffSpacerService.combineMultiDiffSpacers(multiDiffSpacers);
if (shouldAlignBase) {
this.createSpacerZones(combinedMultiDiffSpacers.originalSpacers, baseViewZones);
}
const { modifiedSides } = shouldAlignBase ? combinedMultiDiffSpacers : this.diffSpacerService.excludeOriginalSide(combinedMultiDiffSpacers);
this.createSpacerZones(modifiedSides[0].modifiedSpacers, side1ViewZones);
this.createSpacerZones(modifiedSides[1].modifiedSpacers, side2ViewZones);
if (shouldAlignResult) {
this.createSpacerZones(modifiedSides[2].modifiedSpacers, resultViewZones);
}
return { baseViewZones, side1ViewZones, side2ViewZones, resultViewZones };
}
protected createSpacerZones(spacers: number[], viewZones: MergeEditorViewZone[]): void {
const lineNumbers = Object.keys(spacers).map(Number); // note: spacers is a sparse array
for (const lineNumber of lineNumbers) {
const heightInLines = spacers[lineNumber];
if (heightInLines) {
viewZones.push(this.newSpacerZone(lineNumber - 1, heightInLines));
}
}
}
protected newMergeRangeActions(mergeEditor: MergeEditor, mergeRange: MergeRange): MergeRangeActions {
return new MergeRangeActions(mergeEditor, mergeRange);
}
protected getActionZoneMinHeight(pane: MergeEditorPane): number {
return pane.editor.getControl().getOption(monaco.editor.EditorOption.lineHeight);
}
protected newActionZone(pane: MergeEditorPane, actions: Observable<readonly MergeRangeAction[]>, afterLineNumber: number, heightInPx: number): MergeEditorViewZone {
return new MergeEditorActionZone(pane, actions, afterLineNumber, heightInPx);
}
protected newActionZonePlaceholder(afterLineNumber: number, heightInPx: number): MergeEditorViewZone {
return new MergeEditorActionZonePlaceholder(afterLineNumber, heightInPx);
}
protected newSpacerZone(afterLineNumber: number, heightInLines: number): MergeEditorViewZone {
return new MergeEditorSpacerZone(afterLineNumber, heightInLines);
}
}
export class MergeEditorActionZone implements MergeEditorViewZone {
protected static counter = 0;
constructor(
protected readonly pane: MergeEditorPane,
protected readonly actionsObservable: Observable<readonly MergeRangeAction[]>,
protected readonly afterLineNumber: number,
protected readonly heightInPx: number
) {}
create(ctx: MergeEditorViewZone.CreationContext): void {
const overlayWidgetNode = document.createElement('div');
overlayWidgetNode.className = 'action-zone';
ctx.createViewZone({
domNode: document.createElement('div'),
afterLineNumber: this.afterLineNumber + 1, // + 1, since line numbers in Monaco are 1-based
heightInPx: this.heightInPx,
onComputedHeight: height => overlayWidgetNode.style.height = `${height}px`,
onDomNodeTop: top => overlayWidgetNode.style.top = `${top}px`
});
const editor = this.pane.editor.getControl();
const setLeftPosition = () => overlayWidgetNode.style.left = editor.getLayoutInfo().contentLeft + 'px';
setLeftPosition();
ctx.register(editor.onDidLayoutChange(setLeftPosition));
const overlayWidgetId = `mergeEditorActionZone${MergeEditorActionZone.counter++}`;
const overlayWidget = {
getId: () => overlayWidgetId,
getDomNode: () => overlayWidgetNode,
// eslint-disable-next-line no-null/no-null
getPosition: () => null
};
editor.addOverlayWidget(overlayWidget);
ctx.register(Disposable.create(() => {
editor.removeOverlayWidget(overlayWidget);
}));
const actionContainer = document.createElement('div');
actionContainer.className = 'codelens-decoration';
overlayWidgetNode.appendChild(actionContainer);
ctx.register(Autorun.create(() => this.renderActions(actionContainer, this.actionsObservable.get())));
};
protected renderActions(parent: HTMLElement, actions: readonly MergeRangeAction[]): void {
const children: HTMLElement[] = [];
let isFirst = true;
for (const action of actions) {
if (isFirst) {
isFirst = false;
} else {
const actionSeparator = document.createElement('span');
actionSeparator.append('\u00a0|\u00a0');
children.push(actionSeparator);
}
const title = this.getActionTitle(action);
if (action.run) {
const actionLink = document.createElement('a');
actionLink.role = 'button';
actionLink.onclick = () => action.run!();
if (action.tooltip) {
actionLink.title = action.tooltip;
}
actionLink.append(title);
children.push(actionLink);
} else {
const actionLabel = document.createElement('span');
if (action.tooltip) {
actionLabel.title = action.tooltip;
}
actionLabel.append(title);
children.push(actionLabel);
}
}
parent.innerText = ''; // reset children
parent.append(...children);
}
protected getActionTitle(action: MergeRangeAction): string {
return action.text;
}
}
export class MergeEditorActionZonePlaceholder implements MergeEditorViewZone {
constructor(
protected readonly afterLineNumber: number,
protected readonly heightInPx: number
) {}
create(ctx: MergeEditorViewZone.CreationContext): void {
const domNode = document.createElement('div');
domNode.className = 'action-zone-placeholder';
ctx.createViewZone({
afterLineNumber: this.afterLineNumber + 1, // + 1, since line numbers in Monaco are 1-based
heightInPx: this.heightInPx,
domNode
});
}
}
export class MergeEditorSpacerZone implements MergeEditorViewZone {
constructor(
protected readonly afterLineNumber: number,
protected readonly heightInLines: number
) { }
create(ctx: MergeEditorViewZone.CreationContext): void {
const domNode = document.createElement('div');
domNode.className = 'diagonal-fill';
ctx.createViewZone({
afterLineNumber: this.afterLineNumber + 1, // + 1, since line numbers in Monaco are 1-based
heightInLines: this.heightInLines,
domNode
});
}
}

View File

@@ -0,0 +1,187 @@
// *****************************************************************************
// Copyright (C) 2025 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
// *****************************************************************************
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
// copied and modified from https://github.com/microsoft/vscode/blob/1.96.3/src/vs/workbench/contrib/mergeEditor/browser/view/conflictActions.ts
import { DerivedObservable, ObservableUtils } from '@theia/core/lib/common/observable';
import { MergeRange, MergeRangeAcceptedState, MergeSide } from '../model/merge-range';
import { MergeEditor } from '../merge-editor';
import { nls } from '@theia/core';
export interface MergeRangeAction {
readonly text: string;
readonly tooltip?: string;
run?(): unknown;
}
export class MergeRangeActions {
readonly side1ActionsObservable = DerivedObservable.create(() => this.getActionsForSide(1));
readonly side2ActionsObservable = DerivedObservable.create(() => this.getActionsForSide(2));
readonly resultActionsObservable = DerivedObservable.create(() => this.getResultActions());
protected readonly hasSideActionsObservable = DerivedObservable.create(() => this.side1ActionsObservable.get().length + this.side2ActionsObservable.get().length > 0);
get hasSideActions(): boolean { return this.hasSideActionsObservable.get(); }
protected readonly hasResultActionsObservable = DerivedObservable.create(() => this.resultActionsObservable.get().length > 0);
get hasResultActions(): boolean { return this.hasResultActionsObservable.get(); }
constructor(
protected readonly mergeEditor: MergeEditor,
protected readonly mergeRange: MergeRange
) {}
protected getActionsForSide(side: MergeSide): readonly MergeRangeAction[] {
const { mergeEditor, mergeRange } = this;
const { model, side1Title, side2Title } = mergeEditor;
if (!model.hasMergeRange(mergeRange)) {
return [];
}
const result: MergeRangeAction[] = [];
const sideTitle = side === 1 ? side1Title : side2Title;
const state = model.getMergeRangeResultState(mergeRange);
if (state !== 'Unrecognized' && !state.includes('Side' + side)) {
if (state !== 'Base' || mergeRange.getChanges(side).length) {
result.push({
text: nls.localizeByDefault('Accept {0}', sideTitle),
tooltip: nls.localizeByDefault('Accept {0} in the result document.', sideTitle),
run: () => this.applyMergeRangeAcceptedState(mergeRange, MergeRangeAcceptedState.addSide(state, side))
});
}
if (mergeRange.canBeSmartCombined(side)) {
result.push({
text: mergeRange.isSmartCombinationOrderRelevant ?
nls.localizeByDefault('Accept Combination ({0} First)', sideTitle) :
nls.localizeByDefault('Accept Combination'),
tooltip: nls.localizeByDefault('Accept an automatic combination of both sides in the result document.'),
run: () => this.applyMergeRangeAcceptedState(mergeRange, MergeRangeAcceptedState.addSide(
side === 1 ? 'Side1' : 'Side2', side === 1 ? 2 : 1, { smartCombination: true }))
});
}
}
return result;
}
protected getResultActions(): readonly MergeRangeAction[] {
const { mergeEditor, mergeRange } = this;
const { model, side1Title, side2Title } = mergeEditor;
if (!model.hasMergeRange(mergeRange)) {
return [];
}
const result: MergeRangeAction[] = [];
const state = model.getMergeRangeResultState(mergeRange);
if (state === 'Unrecognized') {
result.push({
text: nls.localizeByDefault('Manual Resolution'),
tooltip: nls.localizeByDefault('This conflict has been resolved manually.')
});
result.push({
text: nls.localizeByDefault('Reset to base'),
tooltip: nls.localizeByDefault('Reset this conflict to the common ancestor of both the right and left changes.'),
run: () => this.applyMergeRangeAcceptedState(mergeRange, 'Base')
});
} else if (state === 'Base') {
result.push({
text: nls.localizeByDefault('No Changes Accepted'),
tooltip: nls.localizeByDefault('The current resolution of this conflict equals the common ancestor of both the right and left changes.')
});
if (!model.isMergeRangeHandled(mergeRange)) {
result.push({
text: nls.localizeByDefault('Mark as Handled'),
run: () => this.markMergeRangeAsHandled(mergeRange)
});
}
} else {
const labels: string[] = [];
const stateToggles: MergeRangeAction[] = [];
if (state.includes('Side1')) {
labels.push(side1Title);
stateToggles.push({
text: nls.localizeByDefault('Remove {0}', side1Title),
tooltip: nls.localizeByDefault('Remove {0} from the result document.', side1Title),
run: () => this.applyMergeRangeAcceptedState(mergeRange, MergeRangeAcceptedState.removeSide(state, 1))
});
}
if (state.includes('Side2')) {
labels.push(side2Title);
stateToggles.push({
text: nls.localizeByDefault('Remove {0}', side2Title),
tooltip: nls.localizeByDefault('Remove {0} from the result document.', side2Title),
run: () => this.applyMergeRangeAcceptedState(mergeRange, MergeRangeAcceptedState.removeSide(state, 2))
});
}
if (state.startsWith('Side2')) {
labels.reverse();
stateToggles.reverse();
}
if (labels.length) {
result.push({
text: labels.join(' + ')
});
}
result.push(...stateToggles);
}
return result;
}
protected async applyMergeRangeAcceptedState(mergeRange: MergeRange, state: MergeRangeAcceptedState): Promise<void> {
const { model, resultPane } = this.mergeEditor;
resultPane.activate();
await ObservableUtils.waitForState(model.isUpToDateObservable);
resultPane.goToMergeRange(mergeRange, { reveal: false }); // set the cursor state that will be restored when undoing the operation
model.applyMergeRangeAcceptedState(mergeRange, state);
await ObservableUtils.waitForState(model.isUpToDateObservable);
resultPane.goToMergeRange(mergeRange, { reveal: false }); // set the resulting cursor state
}
protected async markMergeRangeAsHandled(mergeRange: MergeRange): Promise<void> {
const { model, resultPane } = this.mergeEditor;
resultPane.activate();
await ObservableUtils.waitForState(model.isUpToDateObservable);
resultPane.goToMergeRange(mergeRange, { reveal: false });
const { cursor } = resultPane.editor;
const editorRef = new WeakRef(resultPane.editor);
const revealMergeRange = () => {
const editor = editorRef.deref();
if (editor && !editor.isDisposed()) {
editor.cursor = cursor;
editor.revealPosition(cursor, { vertical: 'auto' });
}
};
model.markMergeRangeAsHandled(mergeRange, {
undoRedo: {
callback: {
didUndo(): void {
revealMergeRange();
},
didRedo(): void {
revealMergeRange();
}
}
}
});
}
}

View File

@@ -0,0 +1,262 @@
// *****************************************************************************
// Copyright (C) 2025 EclipseSource GmbH 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, inject } from '@theia/core/shared/inversify';
import { CommandService, DisposableCollection, MenuNode, CommandMenu, nls } from '@theia/core';
import { Message } from '@theia/core/shared/@lumino/messaging';
import * as React from '@theia/core/shared/react';
import { codicon, ContextMenuRenderer, ReactWidget } from '@theia/core/lib/browser';
import { ScmService } from './scm-service';
import { ScmRepository } from './scm-repository';
import { ScmActionButton, ScmCommand, ScmProvider } from './scm-provider';
import { LabelParser } from '@theia/core/lib/browser/label-parser';
import { BrowserMenuNodeFactory } from '@theia/core/lib/browser/menu/browser-menu-node-factory';
@injectable()
export class ScmActionButtonWidget extends ReactWidget {
static ID = 'scm-action-button-widget';
@inject(ScmService) protected readonly scmService: ScmService;
@inject(CommandService) protected readonly commandService: CommandService;
@inject(ContextMenuRenderer) protected readonly contextMenuRenderer: ContextMenuRenderer;
@inject(LabelParser) protected readonly labelParser: LabelParser;
@inject(BrowserMenuNodeFactory) protected readonly menuNodeFactory: BrowserMenuNodeFactory;
protected readonly toDisposeOnRepositoryChange = new DisposableCollection();
constructor() {
super();
this.addClass('theia-scm-commit');
this.id = ScmActionButtonWidget.ID;
};
protected override onAfterAttach(msg: Message): void {
super.onAfterAttach(msg);
this.refreshOnRepositoryChange();
this.toDisposeOnDetach.push(this.scmService.onDidChangeSelectedRepository(() => {
this.refreshOnRepositoryChange();
this.update();
}));
};
protected refreshOnRepositoryChange(): void {
this.toDisposeOnRepositoryChange.dispose();
const repository = this.scmService.selectedRepository;
if (repository) {
this.toDisposeOnRepositoryChange.push(repository.provider.onDidChange(async () => {
this.update();
}));
const actionButtonListener = repository.provider.onDidChangeActionButton;
if (actionButtonListener) {
this.toDisposeOnRepositoryChange.push(actionButtonListener(() => {
this.update();
}));
}
}
};
protected render(): React.ReactNode {
const repository = this.scmService.selectedRepository;
if (repository) {
return React.createElement('div', this.createContainerAttributes(), this.renderButton());
}
// eslint-disable-next-line no-null/no-null
return null;
};
/**
* Create the container attributes for the widget.
*/
protected createContainerAttributes(): React.HTMLAttributes<HTMLElement> {
return {
style: { flexGrow: 0 }
};
};
protected renderButton(): React.ReactNode {
const repo: ScmRepository | undefined = this.scmService.selectedRepository;
const provider: ScmProvider | undefined = repo?.provider;
const actionButton = provider?.actionButton;
if (actionButton === undefined) {
return undefined;
}
return <>
<ScmActionButtonComponent
actionButton={actionButton}
onExecuteCommand={this.handleExecuteCommand}
onShowSecondaryMenu={this.handleShowSecondaryMenu}
renderLabel={this.renderLabel}
/>
</>;
};
protected handleExecuteCommand = (commandId: string, args: unknown[]): void => {
this.commandService.executeCommand(commandId, ...args);
};
protected handleShowSecondaryMenu = (
event: React.MouseEvent,
actionButton: ScmActionButton
): void => {
event.preventDefault();
event.stopPropagation();
const element = event.currentTarget as HTMLElement;
const rect = element.getBoundingClientRect();
// Build menu with commands that have their arguments baked in
const menuGroups: MenuNode[] = this.buildMenuGroupsWithCommands(actionButton);
this.contextMenuRenderer.render({
anchor: { x: rect.left, y: rect.bottom },
menu: {
children: menuGroups,
isEmpty: () => menuGroups.length === 0,
id: 'scm-action-button-dynamic-menu',
isVisible: () => true,
sortString: '0'
},
menuPath: ['scm-action-button-context-menu'],
context: element,
includeAnchorArg: false
});
};
protected buildMenuGroupsWithCommands(actionButton: ScmActionButton): MenuNode[] {
const menuGroups: MenuNode[] = [];
actionButton.secondaryCommands?.forEach((commandGroup: ScmCommand[], groupIndex: number) => {
const menuGroup = this.menuNodeFactory.createGroup(`group-${groupIndex}`);
commandGroup.forEach((cmd: ScmCommand, cmdIndex: number) => {
// Create a custom CommandMenu node that executes the command with its arguments
const customNode: CommandMenu = {
id: `${cmd.command}-${groupIndex}-${cmdIndex}`,
sortString: String(cmdIndex),
label: this.stripIcons(cmd.title || ''),
icon: undefined,
isVisible: () => true,
isEnabled: () => true,
isToggled: () => false,
run: async () => {
await this.commandService.executeCommand(cmd.command || '', ...(cmd.arguments || []));
}
};
menuGroup.addNode(customNode);
});
if (menuGroup.children.length > 0) {
menuGroups.push(menuGroup);
}
});
return menuGroups;
}
protected renderLabel = (text: string): React.ReactNode[] => {
const result: React.ReactNode[] = [];
const labelParts = this.labelParser.parse(text);
labelParts.forEach((labelPart, index) => {
if (typeof labelPart === 'string') {
result.push(labelPart);
} else {
result.push(<span key={index} className={codicon(labelPart.name)}></span>);
}
});
return result;
};
protected stripIcons(text: string): string {
let result = '';
const labelParts = this.labelParser.parse(text);
labelParts.forEach(labelPart => {
if (typeof labelPart === 'string') {
result += labelPart;
}
});
return result;
};
}
class ScmActionButtonComponent extends React.Component<ScmActionButtonComponent.Props> {
override render(): React.ReactNode {
const { actionButton, onExecuteCommand, onShowSecondaryMenu, renderLabel } = this.props;
const isDisabled = !actionButton.enabled;
const result: React.ReactNode[] = renderLabel(actionButton.command.title || '');
return (
<div className={ScmActionButtonWidget.Styles.ACTION_BUTTON_CONTAINER}>
<button
className={ScmActionButtonWidget.Styles.ACTION_BUTTON}
onClick={() => onExecuteCommand(
actionButton.command.command ?? '',
actionButton.command.arguments || []
)}
disabled={isDisabled}
title={actionButton.command.tooltip || ''}>
{result}
</button>
{actionButton.secondaryCommands && actionButton.secondaryCommands.length > 0 &&
<>
<div
className={ScmActionButtonWidget.Styles.ACTION_BUTTON_DIVIDER +
(isDisabled ? ` ${ScmActionButtonWidget.Styles.ACTION_BUTTON_DIVIDER_DISABLED}` : '')}
/>
<button
className={`${ScmActionButtonWidget.Styles.ACTION_BUTTON_SECONDARY} ${ScmActionButtonWidget.Styles.ACTION_BUTTON}`}
onClick={e => onShowSecondaryMenu(e, actionButton)}
disabled={isDisabled}
title={nls.localizeByDefault('More Actions...')}
>
<span className={codicon('chevron-down')}></span>
</button>
</>
}
</div>
);
}
}
namespace ScmActionButtonComponent {
export interface Props {
actionButton: ScmActionButton;
onExecuteCommand: (commandId: string, args: unknown[]) => void;
onShowSecondaryMenu: (event: React.MouseEvent<HTMLButtonElement>, actionButton: ScmActionButton) => void;
renderLabel: (text: string) => React.ReactNode[];
};
}
export namespace ScmActionButtonWidget {
export namespace Styles {
export const ACTION_BUTTON = 'theia-scm-action-button';
export const ACTION_BUTTON_SECONDARY = 'theia-scm-action-button-secondary';
export const ACTION_BUTTON_DIVIDER = 'theia-scm-action-button-divider';
export const ACTION_BUTTON_DIVIDER_DISABLED = 'theia-scm-action-button-divider-disabled';
export const ACTION_BUTTON_CONTAINER = 'theia-scm-action-button-container';
};
}

View File

@@ -0,0 +1,600 @@
// *****************************************************************************
// Copyright (C) 2019 Arm 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 '../../src/browser/style/scm-amend-component.css';
import * as React from '@theia/core/shared/react';
import { ScmAvatarService } from './scm-avatar-service';
import { codicon, StorageService } from '@theia/core/lib/browser';
import { Disposable, DisposableCollection } from '@theia/core';
import { ScmRepository } from './scm-repository';
import { ScmAmendSupport, ScmCommit } from './scm-provider';
import { nls } from '@theia/core/lib/common/nls';
export interface ScmAmendComponentProps {
style: React.CSSProperties | undefined,
repository: ScmRepository,
scmAmendSupport: ScmAmendSupport,
setCommitMessage: (message: string) => void,
avatarService: ScmAvatarService,
storageService: StorageService,
}
interface ScmAmendComponentState {
/**
* This is used for transitioning. When setting up a transition, we first set to render
* the elements in their starting positions. This includes creating the elements to be
* transitioned in, even though those controls will not be visible when state is 'start'.
* On the next frame after 'start', we render elements with their final positions and with
* the transition properties.
*/
transition: {
state: 'none'
} | {
state: 'start' | 'transitioning',
direction: 'up' | 'down',
previousLastCommit: { commit: ScmCommit, avatar: string }
};
amendingCommits: { commit: ScmCommit, avatar: string }[];
lastCommit: { commit: ScmCommit, avatar: string } | undefined;
}
const TRANSITION_TIME_MS = 300;
const REPOSITORY_STORAGE_KEY = 'scmRepository';
export class ScmAmendComponent extends React.Component<ScmAmendComponentProps, ScmAmendComponentState> {
/**
* a hint on how to animate an update, set by certain user action handlers
* and used when updating the view based on a repository change
*/
protected transitionHint: 'none' | 'amend' | 'unamend' = 'none';
protected lastCommitHeight: number = 0;
lastCommitScrollRef = (instance: HTMLDivElement) => {
if (instance && this.lastCommitHeight === 0) {
this.lastCommitHeight = instance.getBoundingClientRect().height;
}
};
constructor(props: ScmAmendComponentProps) {
super(props);
this.state = {
transition: { state: 'none' },
amendingCommits: [],
lastCommit: undefined
};
const setState = this.setState.bind(this);
this.setState = newState => {
if (!this.toDisposeOnUnmount.disposed) {
setState(newState);
}
};
}
protected readonly toDisposeOnUnmount = new DisposableCollection();
override async componentDidMount(): Promise<void> {
this.toDisposeOnUnmount.push(Disposable.create(() => { /* mark as mounted */ }));
const lastCommit = await this.getLastCommit();
this.setState({ amendingCommits: await this.buildAmendingList(lastCommit ? lastCommit.commit : undefined), lastCommit });
if (this.toDisposeOnUnmount.disposed) {
return;
}
this.toDisposeOnUnmount.push(
this.props.repository.provider.onDidChange(() => this.fetchStatusAndSetState())
);
}
override componentWillUnmount(): void {
this.toDisposeOnUnmount.dispose();
}
async fetchStatusAndSetState(): Promise<void> {
const storageKey = this.getStorageKey();
const nextCommit = await this.getLastCommit();
if (nextCommit && this.state.lastCommit && nextCommit.commit.id === this.state.lastCommit.commit.id) {
// No change here
} else if (nextCommit === undefined && this.state.lastCommit === undefined) {
// No change here
} else if (this.transitionHint === 'none') {
// If the 'last' commit changes, but we are not expecting an 'amend'
// or 'unamend' to occur, then we clear out the list of amended commits.
// This is because an unexpected change has happened to the repository,
// perhaps the user committed, merged, or something. The amended commits
// will no longer be valid.
// Note that there may or may not have been a previous lastCommit (if the
// repository was previously empty with no initial commit then lastCommit
// will be undefined). Either way we clear the amending commits.
await this.clearAmendingCommits();
// There is a change to the last commit, but no transition hint so
// the view just updates without transition.
this.setState({ amendingCommits: [], lastCommit: nextCommit });
} else {
const amendingCommits = this.state.amendingCommits.concat([]); // copy the array
const direction: 'up' | 'down' = this.transitionHint === 'amend' ? 'up' : 'down';
switch (this.transitionHint) {
case 'amend':
if (this.state.lastCommit) {
amendingCommits.push(this.state.lastCommit);
const serializedState = JSON.stringify({
amendingHeadCommitSha: amendingCommits[0].commit.id,
latestCommitSha: nextCommit ? nextCommit.commit.id : undefined
});
this.props.storageService.setData<string | undefined>(storageKey, serializedState);
}
break;
case 'unamend':
amendingCommits.pop();
if (amendingCommits.length === 0) {
this.props.storageService.setData<string | undefined>(storageKey, undefined);
} else {
const serializedState = JSON.stringify({
amendingHeadCommitSha: amendingCommits[0].commit.id,
latestCommitSha: nextCommit ? nextCommit.commit.id : undefined
});
this.props.storageService.setData<string | undefined>(storageKey, serializedState);
}
break;
}
if (this.state.lastCommit && nextCommit) {
const transitionData = { direction, previousLastCommit: this.state.lastCommit };
this.setState({ lastCommit: nextCommit, amendingCommits, transition: { ...transitionData, state: 'start' } });
this.onNextFrame(() => {
this.setState({ transition: { ...transitionData, state: 'transitioning' } });
});
setTimeout(
() => {
this.setState({ transition: { state: 'none' } });
},
TRANSITION_TIME_MS);
} else {
// No previous last commit so no transition
this.setState({ transition: { state: 'none' }, amendingCommits, lastCommit: nextCommit });
}
}
this.transitionHint = 'none';
}
private async clearAmendingCommits(): Promise<void> {
const storageKey = this.getStorageKey();
await this.props.storageService.setData<string | undefined>(storageKey, undefined);
}
private async buildAmendingList(lastCommit: ScmCommit | undefined): Promise<{ commit: ScmCommit, avatar: string }[]> {
const storageKey = this.getStorageKey();
const storedState = await this.props.storageService.getData<string | undefined>(storageKey, undefined);
// Restore list of commits from saved amending head commit up through parents until the
// current commit. (If we don't reach the current commit, the repository has been changed in such
// a way then unamending commits can no longer be done).
if (storedState) {
const { amendingHeadCommitSha, latestCommitSha } = JSON.parse(storedState);
if (!this.commitsAreEqual(lastCommit, latestCommitSha)) {
// The head commit in the repository has changed. It is not the same commit that was the
// head commit after the last 'amend'.
return [];
}
const commits = await this.props.scmAmendSupport.getInitialAmendingCommits(amendingHeadCommitSha, lastCommit ? lastCommit.id : undefined);
const amendingCommitPromises = commits.map(async commit => {
const avatar = await this.props.avatarService.getAvatar(commit.authorEmail);
return { commit, avatar };
});
return Promise.all(amendingCommitPromises);
} else {
return [];
}
}
private getStorageKey(): string {
return REPOSITORY_STORAGE_KEY + ':' + this.props.repository.provider.rootUri;
}
/**
* Commits are equal if the ids are equal or if both are undefined.
* (If a commit is undefined, it represents the initial empty state of a repository,
* before the initial commit).
*/
private commitsAreEqual(lastCommit: ScmCommit | undefined, savedLastCommitId: string | undefined): boolean {
return lastCommit
? lastCommit.id === savedLastCommitId
: savedLastCommitId === undefined;
}
/**
* This function will update the 'model' (lastCommit, amendingCommits) only
* when the repository sees the last commit change.
* 'render' can be called at any time, so be sure we don't update any 'model'
* fields until we actually start the transition.
*/
protected amend = async (): Promise<void> => {
if (this.state.transition.state !== 'none' && this.transitionHint !== 'none') {
return;
}
this.transitionHint = 'amend';
await this.resetAndSetMessage('HEAD~', 'HEAD');
};
protected unamend = async (): Promise<void> => {
if (this.state.transition.state !== 'none' && this.transitionHint !== 'none') {
return;
}
const commitToRestore = (this.state.amendingCommits.length >= 1)
? this.state.amendingCommits[this.state.amendingCommits.length - 1]
: undefined;
const oldestAmendCommit = (this.state.amendingCommits.length >= 2)
? this.state.amendingCommits[this.state.amendingCommits.length - 2]
: undefined;
if (commitToRestore) {
const commitToUseForMessage = oldestAmendCommit
? oldestAmendCommit.commit.id
: undefined;
this.transitionHint = 'unamend';
await this.resetAndSetMessage(commitToRestore.commit.id, commitToUseForMessage);
}
};
private async resetAndSetMessage(commitToRestore: string, commitToUseForMessage: string | undefined): Promise<void> {
const message = commitToUseForMessage
? await this.props.scmAmendSupport.getMessage(commitToUseForMessage)
: '';
await this.props.scmAmendSupport.reset(commitToRestore);
this.props.setCommitMessage(message);
}
override render(): JSX.Element {
const neverShrink = this.state.amendingCommits.length <= 3;
const style: React.CSSProperties = neverShrink
? {
...this.props.style,
flexShrink: 0,
}
: {
...this.props.style,
flexShrink: 1,
minHeight: 240 // height with three commits
};
return (
<div className={ScmAmendComponent.Styles.COMMIT_CONTAINER + ' no-select'} style={style}>
{
this.state.amendingCommits.length > 0 || (this.state.lastCommit && this.state.transition.state !== 'none' && this.state.transition.direction === 'down')
? this.renderAmendingCommits()
: ''
}
{
this.state.lastCommit ?
<div>
<div id='lastCommit' className='theia-scm-amend'>
<div className='theia-header scm-theia-header'>
{nls.localize('theia/scm/amendHeadCommit', 'HEAD Commit')}
</div>
{this.renderLastCommit()}
</div>
</div>
: ''
}
</div>
);
}
protected async getLastCommit(): Promise<{ commit: ScmCommit, avatar: string } | undefined> {
const commit = await this.props.scmAmendSupport.getLastCommit();
if (commit) {
const avatar = await this.props.avatarService.getAvatar(commit.authorEmail);
return { commit, avatar };
}
return undefined;
}
protected renderAmendingCommits(): React.ReactNode {
const neverShrink = this.state.amendingCommits.length <= 3;
const style: React.CSSProperties = neverShrink
? {
flexShrink: 0,
}
: {
flexShrink: 1,
// parent minHeight controls height, we just need any value smaller than
// what the height would be when the parent is at its minHeight
minHeight: 0
};
return <div id='amendedCommits' className='theia-scm-amend-outer-container' style={style}>
<div className='theia-header scm-theia-header'>
<div className='noWrapInfo'>Commits being Amended</div>
{this.renderAmendCommitListButtons()}
{this.renderCommitCount(this.state.amendingCommits.length)}
</div>
<div style={this.styleAmendedCommits()}>
{this.state.amendingCommits.map((commitData, index, array) =>
this.renderCommitBeingAmended(commitData, index === array.length - 1)
)}
{
this.state.lastCommit && this.state.transition.state !== 'none' && this.state.transition.direction === 'down'
? this.renderCommitBeingAmended(this.state.lastCommit, false)
: ''
}
</div>
</div>;
}
protected renderAmendCommitListButtons(): React.ReactNode {
return <div className='theia-scm-inline-actions-container'>
<div className='theia-scm-inline-actions'>
<div className='theia-scm-inline-action'>
<a className={codicon('dash')} title='Unamend All Commits' onClick={this.unamendAll} />
</div>
<div className='theia-scm-inline-action' >
<a className={codicon('close')} title='Clear Amending Commits' onClick={this.clearAmending} />
</div>
</div>
</div>;
}
protected renderLastCommit(): React.ReactNode {
if (!this.state.lastCommit) {
return '';
}
const canAmend: boolean = true;
return <div className={ScmAmendComponent.Styles.COMMIT_AND_BUTTON} style={{ flexGrow: 0, flexShrink: 0 }} key={this.state.lastCommit.commit.id}>
{this.renderLastCommitNoButton(this.state.lastCommit)}
{
canAmend
? <div className={ScmAmendComponent.Styles.FLEX_CENTER}>
<button className='theia-button' title={nls.localize('theia/scm/amendLastCommit', 'Amend last commit')} onClick={this.amend}>
{nls.localize('theia/scm/amend', 'Amend')}
</button>
</div>
: ''
}
</div>;
}
protected renderLastCommitNoButton(lastCommit: { commit: ScmCommit, avatar: string }): React.ReactNode {
switch (this.state.transition.state) {
case 'none':
return <div ref={this.lastCommitScrollRef} className='theia-scm-scrolling-container'>
{this.renderCommitAvatarAndDetail(lastCommit)}
</div>;
case 'start':
case 'transitioning':
switch (this.state.transition.direction) {
case 'up':
return <div style={this.styleLastCommitMovingUp(this.state.transition.state)}>
{this.renderCommitAvatarAndDetail(this.state.transition.previousLastCommit)}
{this.renderCommitAvatarAndDetail(lastCommit)}
</div>;
case 'down':
return <div style={this.styleLastCommitMovingDown(this.state.transition.state)}>
{this.renderCommitAvatarAndDetail(lastCommit)}
{this.renderCommitAvatarAndDetail(this.state.transition.previousLastCommit)}
</div>;
}
}
}
/**
* See https://stackoverflow.com/questions/26556436/react-after-render-code
*
* @param callback
*/
protected onNextFrame(callback: FrameRequestCallback): void {
setTimeout(
() => window.requestAnimationFrame(callback),
0);
}
protected renderCommitAvatarAndDetail(commitData: { commit: ScmCommit, avatar: string }): React.ReactNode {
const { commit, avatar } = commitData;
return <div className={ScmAmendComponent.Styles.COMMIT_AVATAR_AND_TEXT} key={commit.id}>
<div className={ScmAmendComponent.Styles.COMMIT_MESSAGE_AVATAR}>
<img src={avatar} />
</div>
<div className={ScmAmendComponent.Styles.COMMIT_DETAILS}>
<div className={ScmAmendComponent.Styles.COMMIT_MESSAGE_SUMMARY}>{commit.summary}</div>
<div className={ScmAmendComponent.Styles.LAST_COMMIT_MESSAGE_TIME}>{`${commit.authorDateRelative} by ${commit.authorName}`}</div>
</div>
</div>;
}
protected renderCommitCount(commits: number): React.ReactNode {
return <div className='notification-count-container scm-change-count'>
<span className='notification-count'>{commits}</span>
</div>;
}
protected renderCommitBeingAmended(commitData: { commit: ScmCommit, avatar: string }, isOldestAmendCommit: boolean): JSX.Element {
if (isOldestAmendCommit && this.state.transition.state !== 'none' && this.state.transition.direction === 'up') {
return <div className={ScmAmendComponent.Styles.COMMIT_AVATAR_AND_TEXT} style={{ flexGrow: 0, flexShrink: 0 }} key={commitData.commit.id}>
<div className='fixed-height-commit-container'>
{this.renderCommitAvatarAndDetail(commitData)}
</div>
</div>;
} else {
return <div className={ScmAmendComponent.Styles.COMMIT_AVATAR_AND_TEXT} style={{ flexGrow: 0, flexShrink: 0 }} key={commitData.commit.id}>
{this.renderCommitAvatarAndDetail(commitData)}
{
isOldestAmendCommit
? <div className={ScmAmendComponent.Styles.FLEX_CENTER}>
<button className='theia-button' title={nls.localize('theia/scm/unamendCommit', 'Unamend commit')} onClick={this.unamend}>
{nls.localize('theia/scm/unamend', 'Unamend')}
</button>
</div>
: ''
}
</div>;
}
}
/*
* The style for the <div> containing the list of commits being amended.
* This div is scrollable.
*/
protected styleAmendedCommits(): React.CSSProperties {
const base = {
display: 'flex',
whitespace: 'nowrap',
width: '100%',
minHeight: 0,
flexShrink: 1,
paddingTop: '2px',
};
switch (this.state.transition.state) {
case 'none':
return {
...base,
flexDirection: 'column',
overflowY: 'auto',
marginBottom: '0',
};
case 'start':
case 'transitioning':
let startingMargin: number = 0;
let endingMargin: number = 0;
switch (this.state.transition.direction) {
case 'down':
startingMargin = 0;
endingMargin = -32;
break;
case 'up':
startingMargin = -32;
endingMargin = 0;
break;
}
switch (this.state.transition.state) {
case 'start':
return {
...base,
flexDirection: 'column',
overflowY: 'hidden',
marginBottom: `${startingMargin}px`,
};
case 'transitioning':
return {
...base,
flexDirection: 'column',
overflowY: 'hidden',
marginBottom: `${endingMargin}px`,
transitionProperty: 'margin-bottom',
transitionDuration: `${TRANSITION_TIME_MS}ms`,
transitionTimingFunction: 'linear'
};
}
}
}
protected styleLastCommitMovingUp(transitionState: 'start' | 'transitioning'): React.CSSProperties {
return this.styleLastCommit(transitionState, 0, -28);
}
protected styleLastCommitMovingDown(transitionState: 'start' | 'transitioning'): React.CSSProperties {
return this.styleLastCommit(transitionState, -28, 0);
}
protected styleLastCommit(transitionState: 'start' | 'transitioning', startingMarginTop: number, startingMarginBottom: number): React.CSSProperties {
const base = {
display: 'flex',
width: '100%',
overflow: 'hidden',
paddingTop: 0,
paddingBottom: 0,
borderTop: 0,
borderBottom: 0,
height: this.lastCommitHeight * 2
};
// We end with top and bottom margins switched
const endingMarginTop = startingMarginBottom;
const endingMarginBottom = startingMarginTop;
switch (transitionState) {
case 'start':
return {
...base,
position: 'relative',
flexDirection: 'column',
marginTop: startingMarginTop,
marginBottom: startingMarginBottom,
};
case 'transitioning':
return {
...base,
position: 'relative',
flexDirection: 'column',
marginTop: endingMarginTop,
marginBottom: endingMarginBottom,
transitionProperty: 'margin-top margin-bottom',
transitionDuration: `${TRANSITION_TIME_MS}ms`,
transitionTimingFunction: 'linear'
};
}
}
readonly unamendAll = () => this.doUnamendAll();
protected async doUnamendAll(): Promise<void> {
while (this.state.amendingCommits.length > 0) {
this.unamend();
await new Promise(resolve => setTimeout(resolve, TRANSITION_TIME_MS));
}
}
readonly clearAmending = () => this.doClearAmending();
protected async doClearAmending(): Promise<void> {
await this.clearAmendingCommits();
this.setState({ amendingCommits: [] });
}
}
export namespace ScmAmendComponent {
export namespace Styles {
export const COMMIT_CONTAINER = 'theia-scm-commit-container';
export const COMMIT_AND_BUTTON = 'theia-scm-commit-and-button';
export const COMMIT_AVATAR_AND_TEXT = 'theia-scm-commit-avatar-and-text';
export const COMMIT_DETAILS = 'theia-scm-commit-details';
export const COMMIT_MESSAGE_AVATAR = 'theia-scm-commit-message-avatar';
export const COMMIT_MESSAGE_SUMMARY = 'theia-scm-commit-message-summary';
export const LAST_COMMIT_MESSAGE_TIME = 'theia-scm-commit-message-time';
export const FLEX_CENTER = 'theia-scm-flex-container-center';
}
}

View File

@@ -0,0 +1,77 @@
// *****************************************************************************
// Copyright (C) 2020 Arm 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, inject } from '@theia/core/shared/inversify';
import { SelectionService } from '@theia/core/lib/common';
import * as React from '@theia/core/shared/react';
import {
ContextMenuRenderer, ReactWidget, LabelProvider, KeybindingRegistry, StorageService
} from '@theia/core/lib/browser';
import { ScmService } from './scm-service';
import { ScmAvatarService } from './scm-avatar-service';
import { ScmAmendComponent } from './scm-amend-component';
@injectable()
export class ScmAmendWidget extends ReactWidget {
static ID = 'scm-amend-widget';
@inject(ScmService) protected readonly scmService: ScmService;
@inject(ScmAvatarService) protected readonly avatarService: ScmAvatarService;
@inject(StorageService) protected readonly storageService: StorageService;
@inject(SelectionService) protected readonly selectionService: SelectionService;
@inject(LabelProvider) protected readonly labelProvider: LabelProvider;
@inject(KeybindingRegistry) protected readonly keybindings: KeybindingRegistry;
protected shouldScrollToRow = true;
constructor(
@inject(ContextMenuRenderer) protected readonly contextMenuRenderer: ContextMenuRenderer,
) {
super();
this.scrollOptions = {
suppressScrollX: true,
minScrollbarLength: 35
};
this.id = ScmAmendWidget.ID;
}
protected render(): React.ReactNode {
const repository = this.scmService.selectedRepository;
if (repository && repository.provider.amendSupport) {
return React.createElement(
ScmAmendComponent,
{
key: `amend:${repository.provider.rootUri}`,
style: { flexGrow: 0 },
repository: repository,
scmAmendSupport: repository.provider.amendSupport,
setCommitMessage: this.setInputValue,
avatarService: this.avatarService,
storageService: this.storageService,
}
);
}
}
protected setInputValue = (event: React.FormEvent<HTMLTextAreaElement> | React.ChangeEvent<HTMLTextAreaElement> | string) => {
const repository = this.scmService.selectedRepository;
if (repository) {
repository.input.value = typeof event === 'string' ? event : event.currentTarget.value;
}
};
}

View File

@@ -0,0 +1,27 @@
// *****************************************************************************
// 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 { Md5 } from 'ts-md5';
@injectable()
export class ScmAvatarService {
async getAvatar(email: string): Promise<string> {
const hash = Md5.hashStr(email);
return `https://www.gravatar.com/avatar/${hash}?d=robohash`;
}
}

View File

@@ -0,0 +1,23 @@
// *****************************************************************************
// Copyright (C) 2019 Red Hat, Inc. 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
// *****************************************************************************
export namespace ScmColors {
export const editorGutterModifiedBackground = 'editorGutter.modifiedBackground';
export const editorGutterAddedBackground = 'editorGutter.addedBackground';
export const editorGutterDeletedBackground = 'editorGutter.deletedBackground';
export const handledConflictMinimapOverviewRulerColor = 'mergeEditor.conflict.handled.minimapOverViewRuler';
export const unhandledConflictMinimapOverviewRulerColor = 'mergeEditor.conflict.unhandled.minimapOverViewRuler';
}

View File

@@ -0,0 +1,216 @@
// *****************************************************************************
// 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, inject } from '@theia/core/shared/inversify';
import { DisposableCollection } from '@theia/core';
import { Message } from '@theia/core/shared/@lumino/messaging';
import * as React from '@theia/core/shared/react';
import TextareaAutosize from 'react-textarea-autosize';
import { ScmInput, ScmInputIssueType } from './scm-input';
import {
ContextMenuRenderer, ReactWidget, KeybindingRegistry, StatefulWidget
} from '@theia/core/lib/browser';
import { ScmService } from './scm-service';
@injectable()
export class ScmCommitWidget extends ReactWidget implements StatefulWidget {
static ID = 'scm-commit-widget';
@inject(ScmService) protected readonly scmService: ScmService;
@inject(KeybindingRegistry) protected readonly keybindings: KeybindingRegistry;
protected readonly toDisposeOnRepositoryChange = new DisposableCollection();
protected shouldScrollToRow = true;
/**
* Don't modify DOM use React! only exposed for `focusInput`
* Use `this.scmService.selectedRepository?.input.value` as a single source of truth!
*/
protected readonly inputRef = React.createRef<HTMLTextAreaElement>();
constructor(
@inject(ContextMenuRenderer) protected readonly contextMenuRenderer: ContextMenuRenderer,
) {
super();
this.scrollOptions = {
suppressScrollX: true,
minScrollbarLength: 35
};
this.addClass('theia-scm-commit');
this.id = ScmCommitWidget.ID;
}
protected override onAfterAttach(msg: Message): void {
super.onAfterAttach(msg);
this.refreshOnRepositoryChange();
this.toDisposeOnDetach.push(this.scmService.onDidChangeSelectedRepository(() => {
this.refreshOnRepositoryChange();
this.update();
}));
}
protected refreshOnRepositoryChange(): void {
this.toDisposeOnRepositoryChange.dispose();
const repository = this.scmService.selectedRepository;
if (repository) {
this.toDisposeOnRepositoryChange.push(repository.provider.onDidChange(async () => {
this.update();
}));
this.toDisposeOnRepositoryChange.push(repository.provider.onDidChangeCommitTemplate(e => {
this.setInputValue(e);
}));
}
}
protected override onActivateRequest(msg: Message): void {
super.onActivateRequest(msg);
this.focus();
}
public focus(): void {
(this.inputRef.current || this.node).focus();
}
protected render(): React.ReactNode {
const repository = this.scmService.selectedRepository;
if (repository) {
return React.createElement('div', this.createContainerAttributes(), this.renderInput(repository.input));
}
}
/**
* Create the container attributes for the widget.
*/
protected createContainerAttributes(): React.HTMLAttributes<HTMLElement> {
return {
style: { flexGrow: 0 }
};
}
protected renderInput(input: ScmInput): React.ReactNode {
let validationStatus = 'idle';
if (input.issue) {
switch (input.issue.type) {
case ScmInputIssueType.Error:
validationStatus = 'error';
break;
case ScmInputIssueType.Information:
validationStatus = 'info';
break;
case ScmInputIssueType.Warning:
validationStatus = 'warning';
break;
}
}
const validationMessage = input.issue ? input.issue.message : '';
const format = (value: string, ...args: string[]): string => {
if (args.length !== 0) {
return value.replace(/{(\d+)}/g, (found, n) => {
const i = parseInt(n);
return isNaN(i) || i < 0 || i >= args.length ? found : args[i];
});
}
return value;
};
const keybinding = this.keybindings.acceleratorFor(this.keybindings.getKeybindingsForCommand('scm.acceptInput')[0]).join('+');
const message = format(input.placeholder || '', keybinding);
const textArea = input.visible &&
<TextareaAutosize
className={`${ScmCommitWidget.Styles.INPUT_MESSAGE} theia-input theia-scm-input-message-${validationStatus}`}
id={ScmCommitWidget.Styles.INPUT_MESSAGE}
placeholder={message}
spellCheck={false}
autoFocus={true}
value={input.value}
disabled={!input.enabled}
onChange={this.setInputValue}
ref={this.inputRef}
rows={1}
maxRows={6} /* from VS Code */
>
</TextareaAutosize>;
return <div className={ScmCommitWidget.Styles.INPUT_MESSAGE_CONTAINER}>
{textArea}
<div
className={
`${ScmCommitWidget.Styles.VALIDATION_MESSAGE} ${ScmCommitWidget.Styles.NO_SELECT}
theia-scm-validation-message-${validationStatus} theia-scm-input-message-${validationStatus}`
}
style={{
display: !!input.issue ? 'block' : 'none'
}}>{validationMessage}</div>
</div>;
}
protected setInputValue = (event: React.FormEvent<HTMLTextAreaElement> | React.ChangeEvent<HTMLTextAreaElement> | string) => {
const repository = this.scmService.selectedRepository;
if (repository) {
repository.input.value = typeof event === 'string' ? event : event.currentTarget.value;
}
};
/**
* Store the tree state.
*/
storeState(): ScmCommitWidget.State {
const message = this.scmService.selectedRepository?.input.value;
return { message };
}
/**
* Restore the state.
* @param oldState the old state object.
*/
restoreState(oldState: ScmCommitWidget.State): void {
const value = oldState.message;
if (!value) {
return;
}
let repository = this.scmService.selectedRepository;
if (repository) {
repository.input.value = value;
} else {
const listener = this.scmService.onDidChangeSelectedRepository(() => {
repository = this.scmService.selectedRepository;
if (repository) {
listener.dispose();
if (!repository.input.value) {
repository.input.value = value;
}
}
});
this.toDispose.push(listener);
}
}
}
export namespace ScmCommitWidget {
export namespace Styles {
export const INPUT_MESSAGE_CONTAINER = 'theia-scm-input-message-container';
export const INPUT_MESSAGE = 'theia-scm-input-message';
export const VALIDATION_MESSAGE = 'theia-scm-input-validation-message';
export const NO_SELECT = 'no-select';
}
export interface State {
message?: string
}
}

View File

@@ -0,0 +1,52 @@
// *****************************************************************************
// Copyright (C) 2019 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, inject, postConstruct } from '@theia/core/shared/inversify';
import { ContextKeyService, ContextKey } from '@theia/core/lib/browser/context-key-service';
@injectable()
export class ScmContextKeyService {
@inject(ContextKeyService)
protected readonly contextKeyService: ContextKeyService;
protected _scmProvider: ContextKey<string | undefined>;
get scmProvider(): ContextKey<string | undefined> {
return this._scmProvider;
}
protected _scmResourceGroup: ContextKey<string | undefined>;
get scmResourceGroup(): ContextKey<string | undefined> {
return this._scmResourceGroup;
}
protected _scmResourceGroupState: ContextKey<string | undefined>;
get scmResourceGroupState(): ContextKey<string | undefined> {
return this._scmResourceGroupState;
}
@postConstruct()
protected init(): void {
this._scmProvider = this.contextKeyService.createKey<string | undefined>('scmProvider', undefined);
this._scmResourceGroup = this.contextKeyService.createKey<string | undefined>('scmResourceGroup', undefined);
this._scmResourceGroupState = this.contextKeyService.createKey<string | undefined>('scmResourceGroupState', undefined);
}
match(expression: string | undefined): boolean {
return !expression || this.contextKeyService.match(expression);
}
}

View File

@@ -0,0 +1,493 @@
// *****************************************************************************
// Copyright (C) 2019 Red Hat, Inc. 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 { Emitter } from '@theia/core/lib/common/event';
import {
AbstractViewContribution,
FrontendApplicationContribution, LabelProvider,
StatusBar,
StatusBarAlignment,
StatusBarEntry,
KeybindingRegistry,
ViewContainerTitleOptions,
codicon,
StylingParticipant,
ColorTheme,
CssStyleCollector
} from '@theia/core/lib/browser';
import { TabBarToolbarContribution, TabBarToolbarRegistry, TabBarToolbarAction } from '@theia/core/lib/browser/shell/tab-bar-toolbar';
import { CommandRegistry, Command, Disposable, DisposableCollection, CommandService, MenuModelRegistry } from '@theia/core/lib/common';
import { ContextKeyService, ContextKey } from '@theia/core/lib/browser/context-key-service';
import { ScmService } from './scm-service';
import { ScmWidget } from '../browser/scm-widget';
import URI from '@theia/core/lib/common/uri';
import { ScmQuickOpenService } from './scm-quick-open-service';
import { ColorContribution } from '@theia/core/lib/browser/color-application-contribution';
import { ColorRegistry } from '@theia/core/lib/browser/color-registry';
import { Color } from '@theia/core/lib/common/color';
import { ScmColors } from './scm-colors';
import { ScmCommand } from './scm-provider';
import { ScmDecorationsService } from '../browser/decorations/scm-decorations-service';
import { nls } from '@theia/core/lib/common/nls';
import { isHighContrast } from '@theia/core/lib/common/theme';
import { EditorMainMenu, EditorWidget } from '@theia/editor/lib/browser';
import { DirtyDiffNavigator } from './dirty-diff/dirty-diff-navigator';
import { MonacoDiffEditor } from '@theia/monaco/lib/browser/monaco-diff-editor';
export const SCM_WIDGET_FACTORY_ID = ScmWidget.ID;
export const SCM_VIEW_CONTAINER_ID = 'scm-view-container';
export const SCM_VIEW_CONTAINER_TITLE_OPTIONS: ViewContainerTitleOptions = {
label: nls.localizeByDefault('Source Control'),
iconClass: codicon('source-control'),
closeable: true
};
export namespace ScmMenus {
export const CHANGES_GROUP = [...EditorMainMenu.GO, '6_changes_group'];
}
export namespace SCM_COMMANDS {
export const CHANGE_REPOSITORY = {
id: 'scm.change.repository',
category: nls.localizeByDefault('Source Control'),
originalCategory: 'Source Control',
label: nls.localize('theia/scm/changeRepository', 'Change Repository...'),
originalLabel: 'Change Repository...'
};
export const ACCEPT_INPUT = {
id: 'scm.acceptInput'
};
export const TREE_VIEW_MODE = {
id: 'scm.viewmode.tree',
tooltip: nls.localizeByDefault('View as Tree'),
iconClass: codicon('list-tree'),
originalLabel: 'View as Tree',
label: nls.localizeByDefault('View as Tree')
};
export const LIST_VIEW_MODE = {
id: 'scm.viewmode.list',
tooltip: nls.localizeByDefault('View as List'),
iconClass: codicon('list-flat'),
originalLabel: 'View as List',
label: nls.localizeByDefault('View as List')
};
export const COLLAPSE_ALL = {
id: 'scm.collapseAll',
category: nls.localizeByDefault('Source Control'),
originalCategory: 'Source Control',
tooltip: nls.localizeByDefault('Collapse All'),
iconClass: codicon('collapse-all'),
label: nls.localizeByDefault('Collapse All'),
originalLabel: 'Collapse All'
};
export const GOTO_NEXT_CHANGE = Command.toDefaultLocalizedCommand({
id: 'workbench.action.editor.nextChange',
category: 'Source Control',
label: 'Go to Next Change',
iconClass: codicon('arrow-down')
});
export const GOTO_PREVIOUS_CHANGE = Command.toDefaultLocalizedCommand({
id: 'workbench.action.editor.previousChange',
category: 'Source Control',
label: 'Go to Previous Change',
iconClass: codicon('arrow-up')
});
export const SHOW_NEXT_CHANGE = Command.toDefaultLocalizedCommand({
id: 'editor.action.dirtydiff.next',
category: 'Source Control',
label: 'Show Next Change'
});
export const SHOW_PREVIOUS_CHANGE = Command.toDefaultLocalizedCommand({
id: 'editor.action.dirtydiff.previous',
category: 'Source Control',
label: 'Show Previous Change'
});
export const CLOSE_CHANGE_PEEK_VIEW = {
id: 'editor.action.dirtydiff.close',
category: nls.localizeByDefault('Source Control'),
originalCategory: 'Source Control',
label: nls.localize('theia/scm/dirtyDiff/close', 'Close Change Peek View'),
originalLabel: 'Close Change Peek View'
};
}
export { ScmColors };
@injectable()
export class ScmContribution extends AbstractViewContribution<ScmWidget> implements
FrontendApplicationContribution,
TabBarToolbarContribution,
ColorContribution,
StylingParticipant {
@inject(StatusBar) protected readonly statusBar: StatusBar;
@inject(ScmService) protected readonly scmService: ScmService;
@inject(ScmQuickOpenService) protected readonly scmQuickOpenService: ScmQuickOpenService;
@inject(LabelProvider) protected readonly labelProvider: LabelProvider;
@inject(CommandService) protected readonly commands: CommandService;
@inject(CommandRegistry) protected readonly commandRegistry: CommandRegistry;
@inject(ContextKeyService) protected readonly contextKeys: ContextKeyService;
@inject(ScmDecorationsService) protected readonly scmDecorationsService: ScmDecorationsService;
@inject(DirtyDiffNavigator) protected readonly dirtyDiffNavigator: DirtyDiffNavigator;
protected scmFocus: ContextKey<boolean>;
constructor() {
super({
viewContainerId: SCM_VIEW_CONTAINER_ID,
widgetId: SCM_WIDGET_FACTORY_ID,
widgetName: SCM_VIEW_CONTAINER_TITLE_OPTIONS.label,
defaultWidgetOptions: {
area: 'left',
rank: 300
},
toggleCommandId: 'scmView:toggle',
toggleKeybinding: 'ctrlcmd+shift+g'
});
}
@postConstruct()
protected init(): void {
this.scmFocus = this.contextKeys.createKey('scmFocus', false);
}
async initializeLayout(): Promise<void> {
await this.openView();
}
onStart(): void {
this.updateStatusBar();
this.scmService.onDidAddRepository(() => this.updateStatusBar());
this.scmService.onDidRemoveRepository(() => this.updateStatusBar());
this.scmService.onDidChangeSelectedRepository(() => this.updateStatusBar());
this.scmService.onDidChangeStatusBarCommands(() => this.updateStatusBar());
this.labelProvider.onDidChange(() => this.updateStatusBar());
this.updateContextKeys();
this.shell.onDidChangeCurrentWidget(() => this.updateContextKeys());
this.scmDecorationsService.onDirtyDiffUpdate(update => this.dirtyDiffNavigator.handleDirtyDiffUpdate(update));
}
protected updateContextKeys(): void {
this.scmFocus.set(this.shell.currentWidget instanceof ScmWidget);
}
override registerCommands(commandRegistry: CommandRegistry): void {
super.registerCommands(commandRegistry);
commandRegistry.registerCommand(SCM_COMMANDS.CHANGE_REPOSITORY, {
execute: () => this.scmQuickOpenService.changeRepository(),
isEnabled: () => this.scmService.repositories.length > 1
});
commandRegistry.registerCommand(SCM_COMMANDS.ACCEPT_INPUT, {
execute: () => this.acceptInput(),
isEnabled: () => !!this.scmFocus.get() && !!this.acceptInputCommand()
});
// Note that commands for dirty diff navigation need to be always available.
// This is consistent with behavior in VS Code, and also with other similar commands (such as `Next Problem/Previous Problem`) in Theia.
// See https://github.com/eclipse-theia/theia/pull/13104#discussion_r1497316614 for a detailed discussion.
commandRegistry.registerCommand(SCM_COMMANDS.GOTO_NEXT_CHANGE, {
execute: widget => {
if (widget instanceof EditorWidget && widget.editor instanceof MonacoDiffEditor) {
widget.editor.diffNavigator.next();
widget.activate();
} else {
this.dirtyDiffNavigator.gotoNextChange();
}
},
isEnabled: widget => {
if (widget instanceof EditorWidget && widget.editor instanceof MonacoDiffEditor) {
return widget.editor.diffNavigator.hasNext();
}
return true;
}
});
commandRegistry.registerCommand(SCM_COMMANDS.GOTO_PREVIOUS_CHANGE, {
execute: widget => {
if (widget instanceof EditorWidget && widget.editor instanceof MonacoDiffEditor) {
widget.editor.diffNavigator.previous();
widget.activate();
} else {
this.dirtyDiffNavigator.gotoPreviousChange();
}
},
isEnabled: widget => {
if (widget instanceof EditorWidget && widget.editor instanceof MonacoDiffEditor) {
return widget.editor.diffNavigator.hasPrevious();
}
return true;
}
});
commandRegistry.registerCommand(SCM_COMMANDS.SHOW_NEXT_CHANGE, {
execute: () => this.dirtyDiffNavigator.showNextChange()
});
commandRegistry.registerCommand(SCM_COMMANDS.SHOW_PREVIOUS_CHANGE, {
execute: () => this.dirtyDiffNavigator.showPreviousChange()
});
commandRegistry.registerCommand(SCM_COMMANDS.CLOSE_CHANGE_PEEK_VIEW, {
execute: () => this.dirtyDiffNavigator.closeChangePeekView()
});
}
override registerMenus(menus: MenuModelRegistry): void {
super.registerMenus(menus);
menus.registerMenuAction(ScmMenus.CHANGES_GROUP, {
commandId: SCM_COMMANDS.SHOW_NEXT_CHANGE.id,
label: nls.localizeByDefault('Next Change'),
order: '1'
});
menus.registerMenuAction(ScmMenus.CHANGES_GROUP, {
commandId: SCM_COMMANDS.SHOW_PREVIOUS_CHANGE.id,
label: nls.localizeByDefault('Previous Change'),
order: '2'
});
}
registerToolbarItems(registry: TabBarToolbarRegistry): void {
const viewModeEmitter = new Emitter<void>();
const registerToggleViewItem = (command: Command, mode: 'tree' | 'list') => {
const id = command.id;
const item: TabBarToolbarAction = {
id,
command: id,
tooltip: command.label,
onDidChange: viewModeEmitter.event
};
this.commandRegistry.registerCommand({ id, iconClass: command && command.iconClass }, {
execute: widget => {
if (widget instanceof ScmWidget) {
widget.viewMode = mode;
viewModeEmitter.fire();
}
},
isVisible: widget => {
if (widget instanceof ScmWidget) {
return !!this.scmService.selectedRepository
&& widget.viewMode !== mode;
}
return false;
},
});
registry.registerItem(item);
};
registerToggleViewItem(SCM_COMMANDS.TREE_VIEW_MODE, 'tree');
registerToggleViewItem(SCM_COMMANDS.LIST_VIEW_MODE, 'list');
this.commandRegistry.registerCommand(SCM_COMMANDS.COLLAPSE_ALL, {
execute: widget => {
if (widget instanceof ScmWidget && widget.viewMode === 'tree') {
widget.collapseScmTree();
}
},
isVisible: widget => {
if (widget instanceof ScmWidget) {
return !!this.scmService.selectedRepository && widget.viewMode === 'tree';
}
return false;
}
});
registry.registerItem({
id: SCM_COMMANDS.GOTO_PREVIOUS_CHANGE.id,
command: SCM_COMMANDS.GOTO_PREVIOUS_CHANGE.id,
isVisible: widget => widget instanceof EditorWidget && widget.editor instanceof MonacoDiffEditor,
});
registry.registerItem({
id: SCM_COMMANDS.GOTO_NEXT_CHANGE.id,
command: SCM_COMMANDS.GOTO_NEXT_CHANGE.id,
isVisible: widget => widget instanceof EditorWidget && widget.editor instanceof MonacoDiffEditor,
});
registry.registerItem({
...SCM_COMMANDS.COLLAPSE_ALL,
command: SCM_COMMANDS.COLLAPSE_ALL.id
});
}
override registerKeybindings(keybindings: KeybindingRegistry): void {
super.registerKeybindings(keybindings);
keybindings.registerKeybinding({
command: SCM_COMMANDS.ACCEPT_INPUT.id,
keybinding: 'ctrlcmd+enter',
when: 'scmFocus'
});
keybindings.registerKeybinding({
command: SCM_COMMANDS.GOTO_NEXT_CHANGE.id,
keybinding: 'alt+f5',
when: 'editorTextFocus'
});
keybindings.registerKeybinding({
command: SCM_COMMANDS.GOTO_PREVIOUS_CHANGE.id,
keybinding: 'shift+alt+f5',
when: 'editorTextFocus'
});
keybindings.registerKeybinding({
command: SCM_COMMANDS.SHOW_NEXT_CHANGE.id,
keybinding: 'alt+f3',
when: 'editorTextFocus'
});
keybindings.registerKeybinding({
command: SCM_COMMANDS.SHOW_PREVIOUS_CHANGE.id,
keybinding: 'shift+alt+f3',
when: 'editorTextFocus'
});
keybindings.registerKeybinding({
command: SCM_COMMANDS.CLOSE_CHANGE_PEEK_VIEW.id,
keybinding: 'esc',
when: 'dirtyDiffVisible'
});
}
protected async acceptInput(): Promise<void> {
const command = this.acceptInputCommand();
if (command && command.command) {
await this.commands.executeCommand(command.command, ...command.arguments ? command.arguments : []);
}
}
protected acceptInputCommand(): ScmCommand | undefined {
const repository = this.scmService.selectedRepository;
if (!repository) {
return undefined;
}
return repository.provider.acceptInputCommand;
}
protected readonly statusBarDisposable = new DisposableCollection();
protected updateStatusBar(): void {
this.statusBarDisposable.dispose();
const repository = this.scmService.selectedRepository;
if (!repository) {
return;
}
const name = this.labelProvider.getName(new URI(repository.provider.rootUri));
if (this.scmService.repositories.length > 1) {
this.setStatusBarEntry(SCM_COMMANDS.CHANGE_REPOSITORY.id, {
text: `$(database) ${name}`,
tooltip: name.toString(),
command: SCM_COMMANDS.CHANGE_REPOSITORY.id,
alignment: StatusBarAlignment.LEFT,
priority: 100
});
}
const label = repository.provider.rootUri ? `${name} (${repository.provider.label})` : repository.provider.label;
this.scmService.statusBarCommands.forEach((value, index) => this.setStatusBarEntry(`scm.status.${index}`, {
text: value.title,
tooltip: label + (value.tooltip ? ` - ${value.tooltip}` : ''),
command: value.command,
arguments: value.arguments,
alignment: StatusBarAlignment.LEFT,
priority: 100
}));
}
protected setStatusBarEntry(id: string, entry: StatusBarEntry): void {
this.statusBar.setElement(id, entry);
this.statusBarDisposable.push(Disposable.create(() => this.statusBar.removeElement(id)));
}
/**
* It should be aligned with https://github.com/microsoft/vscode/blob/0dfa355b3ad185a6289ba28a99c141ab9e72d2be/src/vs/workbench/contrib/scm/browser/dirtydiffDecorator.ts#L808
*/
registerColors(colors: ColorRegistry): void {
colors.register(
{
id: ScmColors.editorGutterModifiedBackground, defaults: {
dark: '#1B81A8',
light: '#2090D3',
hcDark: '#1B81A8',
hcLight: '#2090D3'
}, description: 'Editor gutter background color for lines that are modified.'
},
{
id: ScmColors.editorGutterAddedBackground, defaults: {
dark: '#487E02',
light: '#48985D',
hcDark: '#487E02',
hcLight: '#48985D'
}, description: 'Editor gutter background color for lines that are added.'
},
{
id: ScmColors.editorGutterDeletedBackground, defaults: {
dark: 'editorError.foreground',
light: 'editorError.foreground',
hcDark: 'editorError.foreground',
hcLight: 'editorError.foreground'
}, description: 'Editor gutter background color for lines that are deleted.'
},
{
id: 'minimapGutter.modifiedBackground', defaults: {
dark: 'editorGutter.modifiedBackground',
light: 'editorGutter.modifiedBackground',
hcDark: 'editorGutter.modifiedBackground',
hcLight: 'editorGutter.modifiedBackground'
}, description: 'Minimap gutter background color for lines that are modified.'
},
{
id: 'minimapGutter.addedBackground', defaults: {
dark: 'editorGutter.addedBackground',
light: 'editorGutter.addedBackground',
hcDark: 'editorGutter.modifiedBackground',
hcLight: 'editorGutter.modifiedBackground'
}, description: 'Minimap gutter background color for lines that are added.'
},
{
id: 'minimapGutter.deletedBackground', defaults: {
dark: 'editorGutter.deletedBackground',
light: 'editorGutter.deletedBackground',
hcDark: 'editorGutter.deletedBackground',
hcLight: 'editorGutter.deletedBackground'
}, description: 'Minimap gutter background color for lines that are deleted.'
},
{
id: 'editorOverviewRuler.modifiedForeground', defaults: {
dark: Color.transparent(ScmColors.editorGutterModifiedBackground, 0.6),
light: Color.transparent(ScmColors.editorGutterModifiedBackground, 0.6),
hcDark: Color.transparent(ScmColors.editorGutterModifiedBackground, 0.6),
hcLight: Color.transparent(ScmColors.editorGutterModifiedBackground, 0.6)
}, description: 'Overview ruler marker color for modified content.'
},
{
id: 'editorOverviewRuler.addedForeground', defaults: {
dark: Color.transparent(ScmColors.editorGutterAddedBackground, 0.6),
light: Color.transparent(ScmColors.editorGutterAddedBackground, 0.6),
hcDark: Color.transparent(ScmColors.editorGutterAddedBackground, 0.6),
hcLight: Color.transparent(ScmColors.editorGutterAddedBackground, 0.6)
}, description: 'Overview ruler marker color for added content.'
},
{
id: 'editorOverviewRuler.deletedForeground', defaults: {
dark: Color.transparent(ScmColors.editorGutterDeletedBackground, 0.6),
light: Color.transparent(ScmColors.editorGutterDeletedBackground, 0.6),
hcDark: Color.transparent(ScmColors.editorGutterDeletedBackground, 0.6),
hcLight: Color.transparent(ScmColors.editorGutterDeletedBackground, 0.6)
}, description: 'Overview ruler marker color for deleted content.'
}
);
}
registerThemeStyle(theme: ColorTheme, collector: CssStyleCollector): void {
const contrastBorder = theme.getColor('contrastBorder');
if (contrastBorder && isHighContrast(theme.type)) {
collector.addRule(`
.theia-scm-input-message-container textarea {
outline: var(--theia-border-width) solid ${contrastBorder};
outline-offset: -1px;
}
`);
}
}
}

View File

@@ -0,0 +1,155 @@
// *****************************************************************************
// Copyright (C) 2019 Red Hat, Inc. 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 '../../src/browser/style/index.css';
import { interfaces, ContainerModule, Container } from '@theia/core/shared/inversify';
import {
bindViewContribution, FrontendApplicationContribution,
WidgetFactory, ViewContainer,
WidgetManager, ApplicationShellLayoutMigration,
createTreeContainer, TreeModel, TreeModelImpl, StylingParticipant
} from '@theia/core/lib/browser';
import { ScmService } from './scm-service';
import { SCM_WIDGET_FACTORY_ID, ScmContribution, SCM_VIEW_CONTAINER_ID, SCM_VIEW_CONTAINER_TITLE_OPTIONS } from './scm-contribution';
import { ScmWidget } from './scm-widget';
import { ScmTreeWidget } from './scm-tree-widget';
import { ScmCommitWidget } from './scm-commit-widget';
import { ScmActionButtonWidget } from './scm-action-button-widget';
import { ScmAmendWidget } from './scm-amend-widget';
import { ScmNoRepositoryWidget } from './scm-no-repository-widget';
import { ScmTreeModelProps } from './scm-tree-model';
import { ScmGroupsTreeModel } from './scm-groups-tree-model';
import { ScmQuickOpenService } from './scm-quick-open-service';
import { bindDirtyDiff } from './dirty-diff/dirty-diff-module';
import { ScmDecorationsService } from './decorations/scm-decorations-service';
import { ScmAvatarService } from './scm-avatar-service';
import { ScmContextKeyService } from './scm-context-key-service';
import { ScmLayoutVersion3Migration, ScmLayoutVersion5Migration } from './scm-layout-migrations';
import { ScmTreeLabelProvider } from './scm-tree-label-provider';
import { TabBarToolbarContribution } from '@theia/core/lib/browser/shell/tab-bar-toolbar';
import { ColorContribution } from '@theia/core/lib/browser/color-application-contribution';
import { LabelProviderContribution } from '@theia/core/lib/browser/label-provider';
import { bindScmPreferences } from '../common/scm-preferences';
import { bindMergeEditor } from './merge-editor/merge-editor-module';
export default new ContainerModule(bind => {
bind(ScmContextKeyService).toSelf().inSingletonScope();
bind(ScmService).toSelf().inSingletonScope();
bind(ScmWidget).toSelf();
bind(WidgetFactory).toDynamicValue(({ container }) => ({
id: SCM_WIDGET_FACTORY_ID,
createWidget: () => {
const child = createScmWidgetContainer(container);
return child.get(ScmWidget);
}
})).inSingletonScope();
bind(ScmCommitWidget).toSelf();
bind(WidgetFactory).toDynamicValue(({ container }) => ({
id: ScmCommitWidget.ID,
createWidget: () => container.get(ScmCommitWidget)
})).inSingletonScope();
bind(ScmActionButtonWidget).toSelf();
bind(WidgetFactory).toDynamicValue(({ container }) => ({
id: ScmActionButtonWidget.ID,
createWidget: () => container.get(ScmActionButtonWidget)
})).inSingletonScope();
bind(WidgetFactory).toDynamicValue(({ container }) => ({
id: ScmTreeWidget.ID,
createWidget: () => container.get(ScmTreeWidget)
})).inSingletonScope();
bind(ScmAmendWidget).toSelf();
bind(WidgetFactory).toDynamicValue(({ container }) => ({
id: ScmAmendWidget.ID,
createWidget: () => container.get(ScmAmendWidget)
})).inSingletonScope();
bind(ScmNoRepositoryWidget).toSelf();
bind(WidgetFactory).toDynamicValue(({ container }) => ({
id: ScmNoRepositoryWidget.ID,
createWidget: () => container.get(ScmNoRepositoryWidget)
})).inSingletonScope();
bind(WidgetFactory).toDynamicValue(({ container }) => ({
id: SCM_VIEW_CONTAINER_ID,
createWidget: async () => {
const viewContainer = container.get<ViewContainer.Factory>(ViewContainer.Factory)({
id: SCM_VIEW_CONTAINER_ID,
progressLocationId: 'scm'
});
viewContainer.setTitleOptions(SCM_VIEW_CONTAINER_TITLE_OPTIONS);
const widget = await container.get(WidgetManager).getOrCreateWidget(SCM_WIDGET_FACTORY_ID);
viewContainer.addWidget(widget, {
canHide: false,
initiallyCollapsed: false
});
return viewContainer;
}
})).inSingletonScope();
bind(ApplicationShellLayoutMigration).to(ScmLayoutVersion3Migration).inSingletonScope();
bind(ApplicationShellLayoutMigration).to(ScmLayoutVersion5Migration).inSingletonScope();
bind(ScmQuickOpenService).toSelf().inSingletonScope();
bindViewContribution(bind, ScmContribution);
bind(FrontendApplicationContribution).toService(ScmContribution);
bind(TabBarToolbarContribution).toService(ScmContribution);
bind(ColorContribution).toService(ScmContribution);
bind(StylingParticipant).toService(ScmContribution);
bind(ScmDecorationsService).toSelf().inSingletonScope();
bind(ScmAvatarService).toSelf().inSingletonScope();
bindDirtyDiff(bind);
bind(ScmTreeLabelProvider).toSelf().inSingletonScope();
bind(LabelProviderContribution).toService(ScmTreeLabelProvider);
bindScmPreferences(bind);
bindMergeEditor(bind);
});
export function createScmTreeContainer(parent: interfaces.Container): Container {
const child = createTreeContainer(parent, {
props: {
virtualized: true,
search: true,
multiSelect: true,
},
widget: ScmTreeWidget,
});
child.unbind(TreeModel);
child.unbind(TreeModelImpl);
child.bind(ScmTreeModelProps).toConstantValue({
defaultExpansion: 'expanded',
});
return child;
}
export function createScmWidgetContainer(parent: interfaces.Container): Container {
const child = createScmTreeContainer(parent);
child.bind(ScmGroupsTreeModel).toSelf();
child.bind(TreeModel).toService(ScmGroupsTreeModel);
return child;
}

View File

@@ -0,0 +1,81 @@
// *****************************************************************************
// Copyright (C) 2020 Arm 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, inject, postConstruct } from '@theia/core/shared/inversify';
import { Disposable, DisposableCollection } from '@theia/core/lib/common/disposable';
import { ScmService } from './scm-service';
import { ScmTreeModel } from './scm-tree-model';
import { ScmResourceGroup, ScmProvider } from './scm-provider';
@injectable()
export class ScmGroupsTreeModel extends ScmTreeModel {
@inject(ScmService) protected readonly scmService: ScmService;
protected readonly toDisposeOnRepositoryChange = new DisposableCollection();
@postConstruct()
protected override init(): void {
super.init();
this.refreshOnRepositoryChange();
this.toDispose.pushAll([
Disposable.create(() => this.toDisposeOnRepositoryChange.dispose()),
this.scmService.onDidChangeSelectedRepository(() => {
this.refreshOnRepositoryChange();
})
]);
}
protected refreshOnRepositoryChange(): void {
const repository = this.scmService.selectedRepository;
if (repository) {
this.changeRepository(repository.provider);
} else {
this.changeRepository(undefined);
}
}
protected changeRepository(provider: ScmProvider | undefined): void {
this.toDisposeOnRepositoryChange.dispose();
this.contextKeys.scmProvider.set(provider ? provider.id : undefined);
this.provider = provider;
if (provider) {
this.toDisposeOnRepositoryChange.push(provider.onDidChange(() => this.root = this.createTree()));
if (provider.onDidChangeResources) {
this.toDisposeOnRepositoryChange.push(provider.onDidChangeResources(() => this.root = this.createTree()));
}
this.root = this.createTree();
}
}
get rootUri(): string | undefined {
if (this.provider) {
return this.provider.rootUri;
}
};
get groups(): ScmResourceGroup[] {
if (this.provider) {
return this.provider.groups;
} else {
return [];
}
};
canTabToWidget(): boolean {
return !!this.provider;
}
}

View File

@@ -0,0 +1,164 @@
// *****************************************************************************
// Copyright (C) 2019 Red Hat, Inc. 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
// *****************************************************************************
/* eslint-disable @typescript-eslint/no-explicit-any */
import * as debounce from 'p-debounce';
import { Disposable, DisposableCollection, Emitter } from '@theia/core/lib/common';
import { JSONExt, JSONObject } from '@theia/core/shared/@lumino/coreutils';
export interface ScmInputIssue {
message: string;
type: ScmInputIssueType;
}
export enum ScmInputIssueType {
Error = 0,
Warning = 1,
Information = 2
}
export interface ScmInputValidator {
(value: string): Promise<ScmInputIssue | undefined>;
}
export interface ScmInputOptions {
placeholder?: string;
validator?: ScmInputValidator;
visible?: boolean;
enabled?: boolean;
}
export interface ScmInputData {
value?: string;
issue?: ScmInputIssue;
}
export class ScmInput implements Disposable {
protected readonly onDidChangeEmitter = new Emitter<void>();
readonly onDidChange = this.onDidChangeEmitter.event;
protected fireDidChange(): void {
this.onDidChangeEmitter.fire(undefined);
}
protected readonly onDidFocusEmitter = new Emitter<void>();
readonly onDidFocus = this.onDidFocusEmitter.event;
protected readonly toDispose = new DisposableCollection(
this.onDidChangeEmitter,
this.onDidFocusEmitter
);
constructor(
protected readonly options: ScmInputOptions = {}
) { }
dispose(): void {
this.toDispose.dispose();
}
protected _placeholder = this.options.placeholder;
get placeholder(): string | undefined {
return this._placeholder;
}
set placeholder(placeholder: string | undefined) {
if (this._placeholder === placeholder) {
return;
}
this._placeholder = placeholder;
this.fireDidChange();
}
protected _value: string | undefined;
get value(): string {
return this._value || '';
}
set value(value: string) {
if (this.value === value) {
return;
}
this._value = value;
this.fireDidChange();
this.validate();
}
protected _visible = this.options.visible;
get visible(): boolean {
return this._visible ?? true;
}
set visible(visible: boolean) {
if (this.visible === visible) {
return;
}
this._visible = visible;
this.fireDidChange();
this.validate();
}
protected _enabled = this.options.enabled ?? true;
get enabled(): boolean {
return this._enabled;
}
set enabled(enabled: boolean) {
if (this._enabled === enabled) {
return;
}
this._enabled = enabled;
this.fireDidChange();
this.validate();
}
protected _issue: ScmInputIssue | undefined;
get issue(): ScmInputIssue | undefined {
return this._issue;
}
set issue(issue: ScmInputIssue | undefined) {
if (JSONExt.deepEqual(<JSONObject>(this._issue || {}), <JSONObject>(issue || {}))) {
return;
}
this._issue = issue;
this.fireDidChange();
}
validate = debounce(async (): Promise<void> => {
if (this.options.validator) {
this.issue = await this.options.validator(this.value);
}
}, 200);
focus(): void {
this.onDidFocusEmitter.fire(undefined);
}
toJSON(): ScmInputData {
return {
value: this._value,
issue: this._issue
};
}
fromJSON(data: ScmInputData | any): void {
if (this._value !== undefined) {
return;
}
if ('value' in data) {
this._value = data.value;
this._issue = data.issue;
this.fireDidChange();
}
}
}

View File

@@ -0,0 +1,64 @@
// *****************************************************************************
// Copyright (C) 2019 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 { ApplicationShellLayoutMigration, WidgetDescription, ApplicationShellLayoutMigrationContext } from '@theia/core/lib/browser/shell/shell-layout-restorer';
import { SCM_VIEW_CONTAINER_TITLE_OPTIONS, SCM_VIEW_CONTAINER_ID, SCM_WIDGET_FACTORY_ID } from './scm-contribution';
@injectable()
export class ScmLayoutVersion3Migration implements ApplicationShellLayoutMigration {
readonly layoutVersion = 3.0;
onWillInflateWidget(desc: WidgetDescription, { parent }: ApplicationShellLayoutMigrationContext): WidgetDescription | undefined {
if (desc.constructionOptions.factoryId === 'scm' && !parent) {
return {
constructionOptions: {
factoryId: SCM_VIEW_CONTAINER_ID
},
innerWidgetState: {
parts: [
{
widget: {
constructionOptions: {
factoryId: SCM_WIDGET_FACTORY_ID
},
innerWidgetState: desc.innerWidgetState
},
partId: {
factoryId: SCM_WIDGET_FACTORY_ID
},
collapsed: false,
hidden: false
}
],
title: SCM_VIEW_CONTAINER_TITLE_OPTIONS
}
};
}
return undefined;
}
}
@injectable()
export class ScmLayoutVersion5Migration implements ApplicationShellLayoutMigration {
readonly layoutVersion = 5.0;
onWillInflateWidget(desc: WidgetDescription): WidgetDescription | undefined {
if (desc.constructionOptions.factoryId === SCM_VIEW_CONTAINER_ID && typeof desc.innerWidgetState === 'string') {
desc.innerWidgetState = desc.innerWidgetState.replace(/scm-tab-icon/g, SCM_VIEW_CONTAINER_TITLE_OPTIONS.iconClass!);
return desc;
}
return undefined;
}
}

View File

@@ -0,0 +1,41 @@
// *****************************************************************************
// Copyright (C) 2020 Arm 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 * as React from '@theia/core/shared/react';
import { ReactWidget } from '@theia/core/lib/browser';
import { AlertMessage } from '@theia/core/lib/browser/widgets/alert-message';
import { nls } from '@theia/core/lib/common/nls';
@injectable()
export class ScmNoRepositoryWidget extends ReactWidget {
static ID = 'scm-no-repository-widget';
constructor() {
super();
this.addClass('theia-scm-no-repository');
this.id = ScmNoRepositoryWidget.ID;
}
protected render(): React.ReactNode {
return <AlertMessage
type='WARNING'
header={nls.localize('theia/scm/noRepositoryFound', 'No repository found')}
/>;
}
}

View File

@@ -0,0 +1,102 @@
// *****************************************************************************
// Copyright (C) 2019 Red Hat, Inc. 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
// *****************************************************************************
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Disposable, Event } from '@theia/core/lib/common';
import URI from '@theia/core/lib/common/uri';
export interface ScmProvider extends Disposable {
readonly id: string;
readonly label: string;
readonly rootUri: string;
readonly acceptInputCommand?: ScmCommand;
readonly groups: ScmResourceGroup[];
readonly onDidChange: Event<void>;
readonly onDidChangeResources?: Event<void>;
readonly statusBarCommands?: ScmCommand[];
readonly onDidChangeStatusBarCommands?: Event<ScmCommand[] | undefined>;
readonly onDidChangeCommitTemplate: Event<string>;
readonly amendSupport?: ScmAmendSupport;
readonly actionButton?: ScmActionButton;
readonly onDidChangeActionButton?: Event<ScmActionButton | undefined>;
}
export const ScmResourceGroup = Symbol('ScmResourceGroup');
export interface ScmResourceGroup extends Disposable {
readonly id: string;
readonly label: string;
readonly resources: ScmResource[];
readonly hideWhenEmpty?: boolean;
readonly contextValue?: string;
readonly provider: ScmProvider;
}
export interface ScmResource {
/** The uri of the underlying resource inside the workspace. */
readonly sourceUri: URI;
readonly decorations?: ScmResourceDecorations;
open(): Promise<void>;
readonly group: ScmResourceGroup;
}
export interface ScmResourceDecorations {
icon?: string;
iconDark?: string;
tooltip?: string;
source?: string;
letter?: string;
color?: string;
strikeThrough?: boolean;
}
export interface ScmCommand {
title: string;
tooltip?: string;
command?: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
arguments?: any[];
}
export interface ScmCommit {
readonly id: string; // eg Git sha or Mercurial revision number
readonly summary: string;
readonly authorName: string;
readonly authorEmail: string;
readonly authorDateRelative: string;
}
export interface ScmAmendSupport {
getInitialAmendingCommits(amendingHeadCommitId: string, latestCommitId: string | undefined): Promise<ScmCommit[]>
getMessage(commit: string): Promise<string>;
reset(commit: string): Promise<void>;
getLastCommit(): Promise<ScmCommit | undefined>;
}
export interface ScmActionButton {
command: ScmCommand;
secondaryCommands?: ScmCommand[][];
enabled?: boolean;
description?: string;
}

View File

@@ -0,0 +1,48 @@
// *****************************************************************************
// Copyright (C) 2017 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, inject, optional } from '@theia/core/shared/inversify';
import { MessageService } from '@theia/core/lib/common/message-service';
import URI from '@theia/core/lib/common/uri';
import { ScmService } from './scm-service';
import { LabelProvider } from '@theia/core/lib/browser/label-provider';
import { QuickInputService } from '@theia/core/lib/browser';
@injectable()
export class ScmQuickOpenService {
@inject(QuickInputService) @optional() protected readonly quickInputService: QuickInputService;
@inject(MessageService) protected readonly messageService: MessageService;
@inject(LabelProvider) protected readonly labelProvider: LabelProvider;
@inject(ScmService) protected readonly scmService: ScmService;
async changeRepository(): Promise<void> {
const repositories = this.scmService.repositories;
if (repositories.length > 1) {
const items = await Promise.all(repositories.map(async repository => {
const uri = new URI(repository.provider.rootUri);
return {
label: this.labelProvider.getName(uri),
description: this.labelProvider.getLongName(uri),
execute: () => {
this.scmService.selectedRepository = repository;
}
};
}));
this.quickInputService?.showQuickPick(items, { placeholder: 'Select repository to work with:' });
}
}
}

View File

@@ -0,0 +1,52 @@
// *****************************************************************************
// Copyright (C) 2019 Red Hat, Inc. 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 { Disposable, DisposableCollection, Emitter } from '@theia/core/lib/common';
import { ScmInput, ScmInputOptions } from './scm-input';
import { ScmProvider } from './scm-provider';
export interface ScmProviderOptions {
input?: ScmInputOptions
}
export class ScmRepository implements Disposable {
protected readonly onDidChangeEmitter = new Emitter<void>();
readonly onDidChange = this.onDidChangeEmitter.event;
protected fireDidChange(): void {
this.onDidChangeEmitter.fire(undefined);
}
protected readonly toDispose = new DisposableCollection(this.onDidChangeEmitter);
readonly input: ScmInput;
constructor(
readonly provider: ScmProvider,
protected readonly options: ScmProviderOptions = {}
) {
this.toDispose.pushAll([
this.provider,
this.input = new ScmInput(options.input),
this.input.onDidChange(() => this.fireDidChange())
]);
}
dispose(): void {
this.toDispose.dispose();
}
}

View File

@@ -0,0 +1,108 @@
// *****************************************************************************
// Copyright (C) 2019 Red Hat, Inc. 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
// *****************************************************************************
/* eslint-disable @typescript-eslint/no-explicit-any */
import { DisposableCollection, Emitter } from '@theia/core/lib/common';
import { injectable, inject } from '@theia/core/shared/inversify';
import { ScmContextKeyService } from './scm-context-key-service';
import { ScmRepository, ScmProviderOptions } from './scm-repository';
import { ScmCommand, ScmProvider } from './scm-provider';
import URI from '@theia/core/lib/common/uri';
@injectable()
export class ScmService {
@inject(ScmContextKeyService)
protected readonly contextKeys: ScmContextKeyService;
protected readonly _repositories = new Map<string, ScmRepository>();
protected _selectedRepository: ScmRepository | undefined;
protected readonly onDidChangeSelectedRepositoryEmitter = new Emitter<ScmRepository | undefined>();
readonly onDidChangeSelectedRepository = this.onDidChangeSelectedRepositoryEmitter.event;
protected readonly onDidAddRepositoryEmitter = new Emitter<ScmRepository>();
readonly onDidAddRepository = this.onDidAddRepositoryEmitter.event;
protected readonly onDidRemoveRepositoryEmitter = new Emitter<ScmRepository>();
readonly onDidRemoveRepository = this.onDidAddRepositoryEmitter.event;
protected readonly onDidChangeStatusBarCommandsEmitter = new Emitter<ScmCommand[]>();
readonly onDidChangeStatusBarCommands = this.onDidChangeStatusBarCommandsEmitter.event;
protected fireDidChangeStatusBarCommands(): void {
this.onDidChangeStatusBarCommandsEmitter.fire(this.statusBarCommands);
}
get statusBarCommands(): ScmCommand[] {
const repository = this.selectedRepository;
return repository && repository.provider.statusBarCommands || [];
}
get repositories(): ScmRepository[] {
return [...this._repositories.values()];
}
get selectedRepository(): ScmRepository | undefined {
return this._selectedRepository;
}
protected readonly toDisposeOnSelected = new DisposableCollection();
set selectedRepository(repository: ScmRepository | undefined) {
if (this._selectedRepository === repository) {
return;
}
this.toDisposeOnSelected.dispose();
this._selectedRepository = repository;
if (this._selectedRepository) {
if (this._selectedRepository.provider.onDidChangeStatusBarCommands) {
this.toDisposeOnSelected.push(this._selectedRepository.provider.onDidChangeStatusBarCommands(() => this.fireDidChangeStatusBarCommands()));
}
}
this.onDidChangeSelectedRepositoryEmitter.fire(this._selectedRepository);
this.fireDidChangeStatusBarCommands();
}
findRepository(uri: URI): ScmRepository | undefined {
const reposSorted = this.repositories.sort(
(ra: ScmRepository, rb: ScmRepository) => rb.provider.rootUri.length - ra.provider.rootUri.length
);
return reposSorted.find(repo => new URI(repo.provider.rootUri).isEqualOrParent(uri));
}
registerScmProvider(provider: ScmProvider, options: ScmProviderOptions = {}): ScmRepository {
const key = provider.id + ':' + provider.rootUri;
if (this._repositories.has(key)) {
throw new Error(`${provider.label} provider for '${provider.rootUri}' already exists.`);
}
const repository = new ScmRepository(provider, options);
const dispose = repository.dispose;
repository.dispose = () => {
this._repositories.delete(key);
dispose.bind(repository)();
this.onDidRemoveRepositoryEmitter.fire(repository);
if (this._selectedRepository === repository) {
this.selectedRepository = this._repositories.values().next().value;
}
};
this._repositories.set(key, repository);
this.onDidAddRepositoryEmitter.fire(repository);
if (this._repositories.size === 1) {
this.selectedRepository = repository;
}
return repository;
}
}

View File

@@ -0,0 +1,44 @@
// *****************************************************************************
// Copyright (C) 2020 Arm 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 } from '@theia/core/shared/inversify';
import URI from '@theia/core/lib/common/uri';
import { LabelProviderContribution, LabelProvider } from '@theia/core/lib/browser/label-provider';
import { TreeNode } from '@theia/core/lib/browser/tree';
import { ScmFileChangeFolderNode, ScmFileChangeNode, ScmFileChangeGroupNode } from './scm-tree-model';
@injectable()
export class ScmTreeLabelProvider implements LabelProviderContribution {
@inject(LabelProvider) protected readonly labelProvider: LabelProvider;
canHandle(element: object): number {
return TreeNode.is(element) && (ScmFileChangeGroupNode.is(element) || ScmFileChangeFolderNode.is(element) || ScmFileChangeNode.is(element)) ? 60 : 0;
}
getName(node: ScmFileChangeFolderNode | ScmFileChangeNode): string {
if (ScmFileChangeGroupNode.is(node)) {
return node.groupLabel;
}
if (ScmFileChangeFolderNode.is(node)) {
return node.path;
}
if (ScmFileChangeNode.is(node)) {
return this.labelProvider.getName(new URI(node.sourceUri));
}
return '';
}
}

View File

@@ -0,0 +1,409 @@
// *****************************************************************************
// Copyright (C) 2020 Arm 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, inject } from '@theia/core/shared/inversify';
import { TreeModelImpl, TreeNode, TreeProps, CompositeTreeNode, SelectableTreeNode, ExpandableTreeNode } from '@theia/core/lib/browser/tree';
import URI from '@theia/core/lib/common/uri';
import { ScmProvider, ScmResourceGroup, ScmResource, ScmResourceDecorations } from './scm-provider';
import { ScmContextKeyService } from './scm-context-key-service';
export const ScmTreeModelProps = Symbol('ScmTreeModelProps');
export interface ScmTreeModelProps {
defaultExpansion?: 'collapsed' | 'expanded';
nestingThreshold?: number;
}
export interface ScmFileChangeRootNode extends CompositeTreeNode {
rootUri: string;
children: ScmFileChangeGroupNode[];
}
export interface ScmFileChangeGroupNode extends ExpandableTreeNode {
groupId: string;
groupLabel: string;
children: (ScmFileChangeFolderNode | ScmFileChangeNode)[];
}
export namespace ScmFileChangeGroupNode {
export function is(node: TreeNode): node is ScmFileChangeGroupNode {
return 'groupId' in node && 'children' in node
&& !ScmFileChangeFolderNode.is(node);
}
}
export interface ScmFileChangeFolderNode extends ExpandableTreeNode, SelectableTreeNode {
groupId: string;
path: string;
sourceUri: string;
children: (ScmFileChangeFolderNode | ScmFileChangeNode)[];
}
export namespace ScmFileChangeFolderNode {
export function is(node: TreeNode): node is ScmFileChangeFolderNode {
return 'groupId' in node && 'sourceUri' in node && 'path' in node && 'children' in node;
}
}
export interface ScmFileChangeNode extends SelectableTreeNode {
sourceUri: string;
decorations?: ScmResourceDecorations;
}
export namespace ScmFileChangeNode {
export function is(node: TreeNode): node is ScmFileChangeNode {
return 'sourceUri' in node
&& !ScmFileChangeFolderNode.is(node);
}
export function getGroupId(node: ScmFileChangeNode): string {
const parentNode = node.parent;
if (!(parentNode && (ScmFileChangeFolderNode.is(parentNode) || ScmFileChangeGroupNode.is(parentNode)))) {
throw new Error('bad node');
}
return parentNode.groupId;
}
}
@injectable()
export abstract class ScmTreeModel extends TreeModelImpl {
private _languageId: string | undefined;
protected provider: ScmProvider | undefined;
get scmProvider(): ScmProvider | undefined {
return this.provider;
}
@inject(TreeProps) protected readonly props: ScmTreeModelProps;
@inject(ScmContextKeyService) protected readonly contextKeys: ScmContextKeyService;
get languageId(): string | undefined {
return this._languageId;
}
abstract canTabToWidget(): boolean;
protected _viewMode: 'tree' | 'list' = 'list';
set viewMode(id: 'tree' | 'list') {
const oldSelection = this.selectedNodes;
this._viewMode = id;
if (this.root) {
this.root = this.createTree();
for (const oldSelectedNode of oldSelection) {
const newNode = this.getNode(oldSelectedNode.id);
if (SelectableTreeNode.is(newNode)) {
this.revealNode(newNode); // this call can run asynchronously
}
}
}
}
get viewMode(): 'tree' | 'list' {
return this._viewMode;
}
abstract get rootUri(): string | undefined;
abstract get groups(): ScmResourceGroup[];
protected createTree(): ScmFileChangeRootNode {
const root = {
id: 'file-change-tree-root',
parent: undefined,
visible: false,
rootUri: this.rootUri,
children: []
} as ScmFileChangeRootNode;
const groupNodes = this.groups
.filter(group => !!group.resources.length || !group.hideWhenEmpty)
.map(group => this.toGroupNode(group, root));
root.children = groupNodes;
return root;
}
protected toGroupNode(group: ScmResourceGroup, parent: CompositeTreeNode): ScmFileChangeGroupNode {
const groupNode: ScmFileChangeGroupNode = {
id: `${group.id}`,
groupId: group.id,
groupLabel: group.label,
parent,
children: [],
expanded: true,
};
const sortedResources = group.resources.sort((r1, r2) =>
r1.sourceUri.toString().localeCompare(r2.sourceUri.toString())
);
switch (this._viewMode) {
case 'list':
groupNode.children = sortedResources.map(resource => this.toFileChangeNode(resource, groupNode));
break;
case 'tree':
const rootUri = group.provider.rootUri;
if (rootUri) {
const resourcePaths = sortedResources.map(resource => {
const relativePath = new URI(rootUri).relative(resource.sourceUri);
const pathParts = relativePath ? relativePath.toString().split('/') : [];
return { resource, pathParts };
});
groupNode.children = this.buildFileChangeTree(resourcePaths, 0, sortedResources.length, 0, groupNode);
}
break;
}
return groupNode;
}
protected buildFileChangeTree(
sortedResources: { resource: ScmResource, pathParts: string[] }[],
start: number,
end: number,
level: number,
parent: (ScmFileChangeGroupNode | ScmFileChangeFolderNode)
): (ScmFileChangeFolderNode | ScmFileChangeNode)[] {
const result: (ScmFileChangeFolderNode | ScmFileChangeNode)[] = [];
let folderStart = start;
while (folderStart < end) {
const firstFileChange = sortedResources[folderStart];
if (level === firstFileChange.pathParts.length - 1) {
result.push(this.toFileChangeNode(firstFileChange.resource, parent));
folderStart++;
} else {
let index = folderStart + 1;
while (index < end) {
if (sortedResources[index].pathParts[level] !== firstFileChange.pathParts[level]) {
break;
}
index++;
}
const folderEnd = index;
const nestingThreshold = this.props.nestingThreshold || 1;
if (folderEnd - folderStart < nestingThreshold) {
// Inline these (i.e. do not create another level in the tree)
for (let i = folderStart; i < folderEnd; i++) {
result.push(this.toFileChangeNode(sortedResources[i].resource, parent));
}
} else {
const firstFileParts = firstFileChange.pathParts;
const lastFileParts = sortedResources[folderEnd - 1].pathParts;
// Multiple files with first folder.
// See if more folder levels match and include those if so.
let thisLevel = level + 1;
while (thisLevel < firstFileParts.length - 1 && thisLevel < lastFileParts.length - 1 && firstFileParts[thisLevel] === lastFileParts[thisLevel]) {
thisLevel++;
}
const nodeRelativePath = firstFileParts.slice(level, thisLevel).join('/');
result.push(this.toFileChangeFolderNode(sortedResources, folderStart, folderEnd, thisLevel, nodeRelativePath, parent));
}
folderStart = folderEnd;
}
};
return result.sort(this.compareNodes);
}
protected compareNodes = (a: ScmFileChangeFolderNode | ScmFileChangeNode, b: ScmFileChangeFolderNode | ScmFileChangeNode) => this.doCompareNodes(a, b);
protected doCompareNodes(a: ScmFileChangeFolderNode | ScmFileChangeNode, b: ScmFileChangeFolderNode | ScmFileChangeNode): number {
const isFolderA = ScmFileChangeFolderNode.is(a);
const isFolderB = ScmFileChangeFolderNode.is(b);
if (isFolderA && !isFolderB) {
return -1;
}
if (isFolderB && !isFolderA) {
return 1;
}
return a.sourceUri.localeCompare(b.sourceUri);
}
protected toFileChangeFolderNode(
resources: { resource: ScmResource, pathParts: string[] }[],
start: number,
end: number,
level: number,
nodeRelativePath: string,
parent: (ScmFileChangeGroupNode | ScmFileChangeFolderNode)
): ScmFileChangeFolderNode {
const rootUri = this.getRoot(parent).rootUri;
let parentPath: string = rootUri;
if (ScmFileChangeFolderNode.is(parent)) {
parentPath = parent.sourceUri;
}
const sourceUri = new URI(parentPath).resolve(nodeRelativePath);
const defaultExpansion = this.props.defaultExpansion ? (this.props.defaultExpansion === 'expanded') : true;
const id = `${parent.groupId}:${String(sourceUri)}`;
const oldNode = this.getNode(id);
const folderNode: ScmFileChangeFolderNode = {
id,
groupId: parent.groupId,
path: nodeRelativePath,
sourceUri: String(sourceUri),
children: [],
parent,
expanded: ExpandableTreeNode.is(oldNode) ? oldNode.expanded : defaultExpansion,
selected: SelectableTreeNode.is(oldNode) && oldNode.selected,
};
folderNode.children = this.buildFileChangeTree(resources, start, end, level, folderNode);
return folderNode;
}
protected getRoot(node: ScmFileChangeGroupNode | ScmFileChangeFolderNode): ScmFileChangeRootNode {
let parent = node.parent!;
while (ScmFileChangeGroupNode.is(parent) && ScmFileChangeFolderNode.is(parent)) {
parent = parent.parent!;
}
return parent as ScmFileChangeRootNode;
}
protected toFileChangeNode(resource: ScmResource, parent: CompositeTreeNode): ScmFileChangeNode {
const id = `${resource.group.id}:${String(resource.sourceUri)}`;
const oldNode = this.getNode(id);
const node = {
id,
sourceUri: String(resource.sourceUri),
decorations: resource.decorations,
parent,
selected: SelectableTreeNode.is(oldNode) && oldNode.selected,
};
if (node.selected) {
this.selectionService.addSelection(node);
}
return node;
}
protected async revealNode(node: TreeNode): Promise<void> {
if (ScmFileChangeFolderNode.is(node) || ScmFileChangeNode.is(node)) {
const parentNode = node.parent;
if (ExpandableTreeNode.is(parentNode)) {
await this.revealNode(parentNode);
if (!parentNode.expanded) {
await this.expandNode(parentNode);
}
}
}
}
getResourceFromNode(node: ScmFileChangeNode): ScmResource | undefined {
const groupId = ScmFileChangeNode.getGroupId(node);
const group = this.findGroup(groupId);
if (group) {
return group.resources.find(r => String(r.sourceUri) === node.sourceUri)!;
}
}
getResourceGroupFromNode(node: ScmFileChangeGroupNode): ScmResourceGroup | undefined {
return this.findGroup(node.groupId);
}
getResourcesFromFolderNode(node: ScmFileChangeFolderNode): ScmResource[] {
const resources: ScmResource[] = [];
const group = this.findGroup(node.groupId);
if (group) {
this.collectResources(resources, node, group);
}
return resources;
}
getSelectionArgs(selectedNodes: Readonly<SelectableTreeNode[]>): ScmResource[] {
const resources: ScmResource[] = [];
for (const node of selectedNodes) {
if (ScmFileChangeNode.is(node)) {
const groupId = ScmFileChangeNode.getGroupId(node);
const group = this.findGroup(groupId);
if (group) {
const selectedResource = group.resources.find(r => String(r.sourceUri) === node.sourceUri);
if (selectedResource) {
resources.push(selectedResource);
}
}
}
if (ScmFileChangeFolderNode.is(node)) {
const group = this.findGroup(node.groupId);
if (group) {
this.collectResources(resources, node, group);
}
}
}
// Remove duplicates which may occur if user selected folder and nested folder
return resources.filter((item1, index) => resources.findIndex(item2 => item1.sourceUri === item2.sourceUri) === index);
}
protected collectResources(resources: ScmResource[], node: TreeNode, group: ScmResourceGroup): void {
if (ScmFileChangeFolderNode.is(node)) {
for (const child of node.children) {
this.collectResources(resources, child, group);
}
} else if (ScmFileChangeNode.is(node)) {
const resource = group.resources.find(r => String(r.sourceUri) === node.sourceUri)!;
resources.push(resource);
}
}
execInNodeContext(node: TreeNode, callback: () => void): void {
if (!this.provider) {
return;
}
let groupId: string;
if (ScmFileChangeGroupNode.is(node) || ScmFileChangeFolderNode.is(node)) {
groupId = node.groupId;
} else if (ScmFileChangeNode.is(node)) {
groupId = ScmFileChangeNode.getGroupId(node);
} else {
return;
}
this.contextKeys.scmProvider.set(this.provider.id);
this.contextKeys.scmResourceGroup.set(groupId);
this.contextKeys.scmResourceGroupState.set(this.findGroup(groupId)?.contextValue);
try {
callback();
} finally {
}
}
/*
* Normally the group would always be expected to be found. However if the tree is restored
* in restoreState then the tree may be rendered before the groups have been created
* in the provider. The provider's groups property will be empty in such a situation.
* We want to render the tree (as that is the point of restoreState, we can render
* the tree in the saved state before the provider has provided status). We therefore must
* be prepared to render the tree without having the ScmResourceGroup or ScmResource
* objects.
*/
findGroup(groupId: string): ScmResourceGroup | undefined {
return this.groups.find(g => g.id === groupId);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
override storeState(): any {
return {
...super.storeState(),
mode: this.viewMode,
};
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
override restoreState(oldState: any): void {
super.restoreState(oldState);
this.viewMode = oldState.mode === 'tree' ? 'tree' : 'list';
}
}

View File

@@ -0,0 +1,830 @@
// *****************************************************************************
// Copyright (C) 2020 Arm 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
// *****************************************************************************
/* eslint-disable no-null/no-null, @typescript-eslint/no-explicit-any */
import * as React from '@theia/core/shared/react';
import { injectable, inject, postConstruct } from '@theia/core/shared/inversify';
import URI from '@theia/core/lib/common/uri';
import { isOSX } from '@theia/core/lib/common/os';
import { DisposableCollection, Disposable } from '@theia/core/lib/common/disposable';
import { TreeWidget, TreeNode, SelectableTreeNode, TreeModel, TreeProps, NodeProps, TREE_NODE_SEGMENT_CLASS, TREE_NODE_SEGMENT_GROW_CLASS } from '@theia/core/lib/browser/tree';
import { ScmTreeModel, ScmFileChangeRootNode, ScmFileChangeGroupNode, ScmFileChangeFolderNode, ScmFileChangeNode } from './scm-tree-model';
import { MenuModelRegistry, CompoundMenuNode, MenuPath, CommandMenu } from '@theia/core/lib/common/menu';
import { ScmResource } from './scm-provider';
import { ContextMenuRenderer, LabelProvider, DiffUris, ACTION_ITEM } from '@theia/core/lib/browser';
import { ScmContextKeyService } from './scm-context-key-service';
import { EditorWidget, EditorManager, DiffNavigatorProvider } from '@theia/editor/lib/browser';
import { IconThemeService } from '@theia/core/lib/browser/icon-theme-service';
import { ColorRegistry } from '@theia/core/lib/browser/color-registry';
import { Decoration, DecorationsService } from '@theia/core/lib/browser/decorations-service';
import { FileStat } from '@theia/filesystem/lib/common/files';
import { ThemeService } from '@theia/core/lib/browser/theming';
import { CorePreferences } from '@theia/core/lib/common';
@injectable()
export class ScmTreeWidget extends TreeWidget {
static ID = 'scm-resource-widget';
static RESOURCE_GROUP_CONTEXT_MENU = ['RESOURCE_GROUP_CONTEXT_MENU'];
static RESOURCE_GROUP_INLINE_MENU = ['RESOURCE_GROUP_CONTEXT_MENU', 'inline'];
static RESOURCE_FOLDER_CONTEXT_MENU = ['RESOURCE_FOLDER_CONTEXT_MENU'];
static RESOURCE_FOLDER_INLINE_MENU = ['RESOURCE_FOLDER_CONTEXT_MENU', 'inline'];
static RESOURCE_CONTEXT_MENU = ['RESOURCE_CONTEXT_MENU'];
static RESOURCE_INLINE_MENU = ['RESOURCE_CONTEXT_MENU', 'inline'];
@inject(MenuModelRegistry) protected readonly menus: MenuModelRegistry;
@inject(ScmContextKeyService) protected readonly contextKeys: ScmContextKeyService;
@inject(EditorManager) protected readonly editorManager: EditorManager;
@inject(DiffNavigatorProvider) protected readonly diffNavigatorProvider: DiffNavigatorProvider;
@inject(IconThemeService) protected readonly iconThemeService: IconThemeService;
@inject(DecorationsService) protected readonly decorationsService: DecorationsService;
@inject(ColorRegistry) protected readonly colors: ColorRegistry;
@inject(ThemeService) protected readonly themeService: ThemeService;
// TODO: Make TreeWidget generic to better type those fields.
override readonly model: ScmTreeModel;
constructor(
@inject(TreeProps) props: TreeProps,
@inject(TreeModel) treeModel: ScmTreeModel,
@inject(ContextMenuRenderer) contextMenuRenderer: ContextMenuRenderer,
) {
super(props, treeModel, contextMenuRenderer);
this.id = ScmTreeWidget.ID;
this.addClass('groups-outer-container');
}
@postConstruct()
protected override init(): void {
super.init();
this.toDispose.push(this.themeService.onDidColorThemeChange(() => this.update()));
}
set viewMode(id: 'tree' | 'list') {
// Close the search box because the structure of the tree will change dramatically
// and the search results will be out of date.
this.searchBox.hide();
this.model.viewMode = id;
}
get viewMode(): 'tree' | 'list' {
return this.model.viewMode;
}
/**
* Render the node given the tree node and node properties.
* @param node the tree node.
* @param props the node properties.
*/
protected override renderNode(node: TreeNode, props: NodeProps): React.ReactNode {
if (!TreeNode.isVisible(node)) {
return undefined;
}
const attributes = this.createNodeAttributes(node, props);
const label = this.labelProvider.getName(node);
const searchHighlights = this.searchHighlights?.get(node.id);
// The group nodes should not be subject to highlighting.
const caption = (searchHighlights && !ScmFileChangeGroupNode.is(node)) ? this.toReactNode(label, searchHighlights) : label;
if (ScmFileChangeGroupNode.is(node)) {
const content = <ScmResourceGroupElement
key={`${node.groupId}`}
model={this.model}
treeNode={node}
renderExpansionToggle={() => this.renderExpansionToggle(node, props)}
contextMenuRenderer={this.contextMenuRenderer}
menus={this.menus}
contextKeys={this.contextKeys}
labelProvider={this.labelProvider}
corePreferences={this.corePreferences}
caption={caption}
/>;
return React.createElement('div', attributes, content);
}
if (ScmFileChangeFolderNode.is(node)) {
const content = <ScmResourceFolderElement
key={String(node.sourceUri)}
model={this.model}
treeNode={node}
sourceUri={node.sourceUri}
renderExpansionToggle={() => this.renderExpansionToggle(node, props)}
contextMenuRenderer={this.contextMenuRenderer}
menus={this.menus}
contextKeys={this.contextKeys}
labelProvider={this.labelProvider}
corePreferences={this.corePreferences}
caption={caption}
/>;
return React.createElement('div', attributes, content);
}
if (ScmFileChangeNode.is(node)) {
const parentPath =
(node.parent && ScmFileChangeFolderNode.is(node.parent))
? new URI(node.parent.sourceUri) : new URI(this.model.rootUri);
const content = <ScmResourceComponent
key={node.sourceUri}
model={this.model}
treeNode={node}
contextMenuRenderer={this.contextMenuRenderer}
menus={this.menus}
contextKeys={this.contextKeys}
labelProvider={this.labelProvider}
corePreferences={this.corePreferences}
caption={caption}
{...{
...this.props,
parentPath,
sourceUri: node.sourceUri,
decoration: this.decorationsService.getDecoration(new URI(node.sourceUri), true)[0],
colors: this.colors,
isLightTheme: this.isCurrentThemeLight(),
renderExpansionToggle: () => this.renderExpansionToggle(node, props),
}}
/>;
return React.createElement('div', attributes, content);
}
return super.renderNode(node, props);
}
protected override createContainerAttributes(): React.HTMLAttributes<HTMLElement> {
if (this.model.canTabToWidget()) {
return {
...super.createContainerAttributes(),
tabIndex: 0
};
}
return super.createContainerAttributes();
}
/**
* The ARROW_LEFT key controls both the movement around the file tree and also
* the movement through the change chunks within a file.
*
* If the selected tree node is a folder then the ARROW_LEFT key behaves exactly
* as it does in explorer. It collapses the tree node if the folder is expanded and
* it moves the selection up to the parent folder if the folder is collapsed (no-op if no parent folder, as
* group headers are not selectable). This behavior is the default behavior implemented
* in the TreeWidget super class.
*
* If the selected tree node is a file then the ARROW_LEFT key moves up through the
* change chunks within each file. If the selected chunk is the first chunk in the file
* then the file selection is moved to the previous file (no-op if no previous file).
*
* Note that when cursoring through change chunks, the ARROW_LEFT key cannot be used to
* move up through the parent folders of the file tree. If users want to do this, using
* keys only, then they must press ARROW_UP repeatedly until the selected node is the folder
* node and then press ARROW_LEFT.
*/
protected override async handleLeft(event: KeyboardEvent): Promise<void> {
if (this.model.selectedNodes.length === 1) {
const selectedNode = this.model.selectedNodes[0];
if (ScmFileChangeNode.is(selectedNode)) {
const selectedResource = this.model.getResourceFromNode(selectedNode);
if (!selectedResource) {
return super.handleLeft(event);
}
const widget = await this.openResource(selectedResource);
if (widget) {
const diffNavigator = this.diffNavigatorProvider(widget.editor);
if (diffNavigator.hasPrevious()) {
diffNavigator.previous();
} else {
const previousNode = this.moveToPreviousFileNode();
if (previousNode) {
const previousResource = this.model.getResourceFromNode(previousNode);
if (previousResource) {
this.openResource(previousResource);
}
}
}
return;
}
}
}
return super.handleLeft(event);
}
/**
* The ARROW_RIGHT key controls both the movement around the file tree and also
* the movement through the change chunks within a file.
*
* If the selected tree node is a folder then the ARROW_RIGHT key behaves exactly
* as it does in explorer. It expands the tree node if the folder is collapsed and
* it moves the selection to the first child node if the folder is expanded.
* This behavior is the default behavior implemented
* in the TreeWidget super class.
*
* If the selected tree node is a file then the ARROW_RIGHT key moves down through the
* change chunks within each file. If the selected chunk is the last chunk in the file
* then the file selection is moved to the next file (no-op if no next file).
*/
protected override async handleRight(event: KeyboardEvent): Promise<void> {
if (this.model.selectedNodes.length === 0) {
const firstNode = this.getFirstSelectableNode();
// Selects the first visible resource as none are selected.
if (!firstNode) {
return;
}
this.model.selectNode(firstNode);
return;
}
if (this.model.selectedNodes.length === 1) {
const selectedNode = this.model.selectedNodes[0];
if (ScmFileChangeNode.is(selectedNode)) {
const selectedResource = this.model.getResourceFromNode(selectedNode);
if (!selectedResource) {
return super.handleRight(event);
}
const widget = await this.openResource(selectedResource);
if (widget) {
const diffNavigator = this.diffNavigatorProvider(widget.editor);
if (diffNavigator.hasNext()) {
diffNavigator.next();
} else {
const nextNode = this.moveToNextFileNode();
if (nextNode) {
const nextResource = this.model.getResourceFromNode(nextNode);
if (nextResource) {
this.openResource(nextResource);
}
}
}
}
return;
}
}
return super.handleRight(event);
}
protected override handleEnter(event: KeyboardEvent): void {
if (this.model.selectedNodes.length === 1) {
const selectedNode = this.model.selectedNodes[0];
if (ScmFileChangeNode.is(selectedNode)) {
const selectedResource = this.model.getResourceFromNode(selectedNode);
if (selectedResource) {
this.openResource(selectedResource);
}
return;
}
}
super.handleEnter(event);
}
async goToPreviousChange(): Promise<void> {
if (this.model.selectedNodes.length === 1) {
const selectedNode = this.model.selectedNodes[0];
if (ScmFileChangeNode.is(selectedNode)) {
if (ScmFileChangeNode.is(selectedNode)) {
const selectedResource = this.model.getResourceFromNode(selectedNode);
if (!selectedResource) {
return;
}
const widget = await this.openResource(selectedResource);
if (widget) {
const diffNavigator = this.diffNavigatorProvider(widget.editor);
if (diffNavigator.hasPrevious()) {
diffNavigator.previous();
} else {
const previousNode = this.moveToPreviousFileNode();
if (previousNode) {
const previousResource = this.model.getResourceFromNode(previousNode);
if (previousResource) {
this.openResource(previousResource);
}
}
}
}
}
}
}
}
async goToNextChange(): Promise<void> {
if (this.model.selectedNodes.length === 0) {
const firstNode = this.getFirstSelectableNode();
// Selects the first visible resource as none are selected.
if (!firstNode) {
return;
}
this.model.selectNode(firstNode);
return;
}
if (this.model.selectedNodes.length === 1) {
const selectedNode = this.model.selectedNodes[0];
if (ScmFileChangeNode.is(selectedNode)) {
const selectedResource = this.model.getResourceFromNode(selectedNode);
if (!selectedResource) {
return;
}
const widget = await this.openResource(selectedResource);
if (widget) {
const diffNavigator = this.diffNavigatorProvider(widget.editor);
if (diffNavigator.hasNext()) {
diffNavigator.next();
} else {
const nextNode = this.moveToNextFileNode();
if (nextNode) {
const nextResource = this.model.getResourceFromNode(nextNode);
if (nextResource) {
this.openResource(nextResource);
}
}
}
}
}
}
}
selectNodeByUri(uri: URI): void {
for (const group of this.model.groups) {
const sourceUri = new URI(uri.path.toString());
const id = `${group.id}:${sourceUri.toString()}`;
const node = this.model.getNode(id);
if (SelectableTreeNode.is(node)) {
this.model.selectNode(node);
return;
}
}
}
protected getFirstSelectableNode(): SelectableTreeNode | undefined {
if (this.model.root) {
const root = this.model.root as ScmFileChangeRootNode;
const groupNode = root.children[0];
return groupNode.children[0];
}
}
protected moveToPreviousFileNode(): ScmFileChangeNode | undefined {
let previousNode = this.model.getPrevSelectableNode();
while (previousNode) {
if (ScmFileChangeNode.is(previousNode)) {
this.model.selectNode(previousNode);
return previousNode;
}
previousNode = this.model.getPrevSelectableNode(previousNode);
};
}
protected moveToNextFileNode(): ScmFileChangeNode | undefined {
let nextNode = this.model.getNextSelectableNode();
while (nextNode) {
if (ScmFileChangeNode.is(nextNode)) {
this.model.selectNode(nextNode);
return nextNode;
}
nextNode = this.model.getNextSelectableNode(nextNode);
};
}
protected async openResource(resource: ScmResource): Promise<EditorWidget | undefined> {
try {
await resource.open();
} catch (e) {
console.error('Failed to open a SCM resource', e);
return undefined;
}
let standaloneEditor: EditorWidget | undefined;
const resourcePath = resource.sourceUri.path.toString();
for (const widget of this.editorManager.all) {
const resourceUri = widget.editor.document.uri;
const editorResourcePath = new URI(resourceUri).path.toString();
if (resourcePath === editorResourcePath) {
if (widget.editor.uri.scheme === DiffUris.DIFF_SCHEME) {
// prefer diff editor
return widget;
} else {
standaloneEditor = widget;
}
}
if (widget.editor.uri.scheme === DiffUris.DIFF_SCHEME
&& resourceUri === resource.sourceUri.toString()) {
return widget;
}
}
// fallback to standalone editor
return standaloneEditor;
}
protected override getPaddingLeft(node: TreeNode, props: NodeProps): number {
if (this.viewMode === 'list') {
if (props.depth === 1) {
return this.props.expansionTogglePadding;
}
}
return super.getPaddingLeft(node, props);
}
protected override getDepthPadding(depth: number): number {
return super.getDepthPadding(depth) + 5;
}
protected isCurrentThemeLight(): boolean {
const type = this.themeService.getCurrentTheme().type;
return type.toLocaleLowerCase().includes('light');
}
protected override needsExpansionTogglePadding(node: TreeNode): boolean {
const theme = this.iconThemeService.getDefinition(this.iconThemeService.current);
if (theme && (theme.hidesExplorerArrows || (theme.hasFileIcons && !theme.hasFolderIcons))) {
return false;
}
return super.needsExpansionTogglePadding(node);
}
}
export namespace ScmTreeWidget {
export namespace Styles {
export const NO_SELECT = 'no-select';
}
// This is an 'abstract' base interface for all the element component props.
export interface Props {
treeNode: TreeNode;
model: ScmTreeModel;
menus: MenuModelRegistry;
contextKeys: ScmContextKeyService;
labelProvider: LabelProvider;
contextMenuRenderer: ContextMenuRenderer;
corePreferences?: CorePreferences;
caption: React.ReactNode;
}
}
export abstract class ScmElement<P extends ScmElement.Props = ScmElement.Props> extends React.Component<P, ScmElement.State> {
constructor(props: P) {
super(props);
this.state = {
hover: false
};
const setState = this.setState.bind(this);
this.setState = newState => {
if (!this.toDisposeOnUnmount.disposed) {
setState(newState);
}
};
}
protected readonly toDisposeOnUnmount = new DisposableCollection();
override componentDidMount(): void {
this.toDisposeOnUnmount.push(Disposable.create(() => { /* mark as mounted */ }));
}
override componentWillUnmount(): void {
this.toDisposeOnUnmount.dispose();
}
protected detectHover = (element: HTMLElement | null) => {
if (element) {
window.requestAnimationFrame(() => {
const hover = element.matches(':hover');
this.setState({ hover });
});
}
};
protected showHover = () => this.setState({ hover: true });
protected hideHover = () => this.setState({ hover: false });
protected renderContextMenu = (event: React.MouseEvent<HTMLElement>) => {
event.preventDefault();
const { treeNode: node, contextMenuRenderer } = this.props;
this.props.model.execInNodeContext(node, () => {
contextMenuRenderer.render({
menuPath: this.contextMenuPath,
anchor: event.nativeEvent,
args: this.contextMenuArgs,
context: event.currentTarget
});
});
};
protected abstract get contextMenuPath(): MenuPath;
protected abstract get contextMenuArgs(): any[];
}
export namespace ScmElement {
export interface Props extends ScmTreeWidget.Props {
renderExpansionToggle: () => React.ReactNode;
}
export interface State {
hover: boolean
}
}
export class ScmResourceComponent extends ScmElement<ScmResourceComponent.Props> {
override render(): JSX.Element | undefined {
const { hover } = this.state;
const { model, treeNode, colors, parentPath, sourceUri, decoration, labelProvider, menus, contextKeys, caption, isLightTheme } = this.props;
const resourceUri = new URI(sourceUri);
const decorationIcon = treeNode.decorations;
const themedIcon = isLightTheme ? decorationIcon?.icon : decorationIcon?.iconDark;
const classNames: string[] = themedIcon ? ['decoration-icon', themedIcon] : ['decoration-icon', 'status'];
const icon = labelProvider.getIcon(resourceUri);
const color = decoration && decoration.colorId && !themedIcon ? `var(${colors.toCssVariableName(decoration.colorId)})` : '';
const letter = decoration && decoration.letter && !themedIcon ? decoration.letter : '';
const tooltip = decoration && decoration.tooltip || '';
const textDecoration = treeNode.decorations?.strikeThrough === true ? 'line-through' : 'normal';
const relativePath = parentPath.relative(resourceUri.parent);
const path = relativePath ? relativePath.fsPath() : labelProvider.getLongName(resourceUri.parent);
const title = tooltip.length !== 0
? `${resourceUri.path.fsPath()}${tooltip}`
: resourceUri.path.fsPath();
return <div key={sourceUri}
className={`scmItem ${TREE_NODE_SEGMENT_CLASS} ${TREE_NODE_SEGMENT_GROW_CLASS}`}
onContextMenu={this.renderContextMenu}
onMouseEnter={this.showHover}
onMouseLeave={this.hideHover}
ref={this.detectHover}
title={title}
onClick={this.handleClick}
onDoubleClick={this.handleDoubleClick} >
<span className={icon + ' file-icon'} />
{this.props.renderExpansionToggle()}
<div className={`noWrapInfo ${TREE_NODE_SEGMENT_GROW_CLASS}`} >
<span className='name' style={{ textDecoration }}>{caption}</span>
<span className='path' style={{ textDecoration }}>{path}</span>
</div>
<ScmInlineActions {...{
hover,
menu: menus.getMenu(ScmTreeWidget.RESOURCE_INLINE_MENU),
menuPath: ScmTreeWidget.RESOURCE_INLINE_MENU,
args: this.contextMenuArgs,
contextKeys,
model,
treeNode
}}>
<div title={tooltip} className={classNames.join(' ')} style={{ color }}>
{letter}
</div>
</ScmInlineActions>
</div >;
}
protected open = () => {
const resource = this.props.model.getResourceFromNode(this.props.treeNode);
if (resource) {
resource.open();
}
};
protected readonly contextMenuPath = ScmTreeWidget.RESOURCE_CONTEXT_MENU;
protected get contextMenuArgs(): any[] {
if (!this.props.model.selectedNodes.some(node => ScmFileChangeNode.is(node) && node === this.props.treeNode)) {
// Clicked node is not in selection, so ignore selection and action on just clicked node
return this.singleNodeArgs;
} else {
return this.props.model.getSelectionArgs(this.props.model.selectedNodes);
}
}
protected get singleNodeArgs(): any[] {
const selectedResource = this.props.model.getResourceFromNode(this.props.treeNode);
if (selectedResource) {
return [selectedResource];
} else {
// Repository status not yet available. Empty args disables the action.
return [];
}
}
protected hasCtrlCmdOrShiftMask(event: TreeWidget.ModifierAwareEvent): boolean {
const { metaKey, ctrlKey, shiftKey } = event;
return (isOSX && metaKey) || ctrlKey || shiftKey;
}
/**
* Handle the single clicking of nodes present in the widget.
*/
protected handleClick = (event: React.MouseEvent) => {
if (!this.hasCtrlCmdOrShiftMask(event)) {
// Determine the behavior based on the preference value.
const isSingle = this.props.corePreferences && this.props.corePreferences['workbench.list.openMode'] === 'singleClick';
if (isSingle) {
this.open();
}
}
};
/**
* Handle the double clicking of nodes present in the widget.
*/
protected handleDoubleClick = () => {
// Determine the behavior based on the preference value.
const isDouble = this.props.corePreferences && this.props.corePreferences['workbench.list.openMode'] === 'doubleClick';
// Nodes should only be opened through double clicking if the correct preference is set.
if (isDouble) {
this.open();
}
};
}
export namespace ScmResourceComponent {
export interface Props extends ScmElement.Props {
treeNode: ScmFileChangeNode;
parentPath: URI;
sourceUri: string;
decoration: Decoration | undefined;
colors: ColorRegistry;
isLightTheme: boolean
}
}
export class ScmResourceGroupElement extends ScmElement<ScmResourceGroupComponent.Props> {
override render(): JSX.Element {
const { hover } = this.state;
const { model, treeNode, menus, contextKeys, caption } = this.props;
return <div className={`theia-header scm-theia-header ${TREE_NODE_SEGMENT_GROW_CLASS}`}
onContextMenu={this.renderContextMenu}
onMouseEnter={this.showHover}
onMouseLeave={this.hideHover}
ref={this.detectHover}>
{this.props.renderExpansionToggle()}
<div className={`noWrapInfo ${TREE_NODE_SEGMENT_GROW_CLASS}`}>{caption}</div>
<ScmInlineActions {...{
hover,
args: this.contextMenuArgs,
menu: menus.getMenu(ScmTreeWidget.RESOURCE_GROUP_INLINE_MENU),
menuPath: ScmTreeWidget.RESOURCE_GROUP_INLINE_MENU,
contextKeys,
model,
treeNode
}}>
{this.renderChangeCount()}
</ScmInlineActions>
</div>;
}
protected renderChangeCount(): React.ReactNode {
const group = this.props.model.getResourceGroupFromNode(this.props.treeNode);
return <div className='notification-count-container scm-change-count'>
<span className='notification-count'>{group ? group.resources.length : 0}</span>
</div>;
}
protected readonly contextMenuPath = ScmTreeWidget.RESOURCE_GROUP_CONTEXT_MENU;
protected get contextMenuArgs(): any[] {
const group = this.props.model.getResourceGroupFromNode(this.props.treeNode);
if (group) {
return [group];
} else {
// Repository status not yet available. Empty args disables the action.
return [];
}
}
}
export namespace ScmResourceGroupComponent {
export interface Props extends ScmElement.Props {
treeNode: ScmFileChangeGroupNode;
}
}
export class ScmResourceFolderElement extends ScmElement<ScmResourceFolderElement.Props> {
override render(): JSX.Element {
const { hover } = this.state;
const { model, treeNode, sourceUri, labelProvider, menus, contextKeys, caption } = this.props;
const sourceFileStat = FileStat.dir(sourceUri);
const icon = labelProvider.getIcon(sourceFileStat);
const title = new URI(sourceUri).path.fsPath();
return <div key={sourceUri}
className={`scmItem ${TREE_NODE_SEGMENT_CLASS} ${TREE_NODE_SEGMENT_GROW_CLASS} ${ScmTreeWidget.Styles.NO_SELECT}`}
title={title}
onContextMenu={this.renderContextMenu}
onMouseEnter={this.showHover}
onMouseLeave={this.hideHover}
ref={this.detectHover}
>
{this.props.renderExpansionToggle()}
<span className={icon + ' file-icon'} />
<div className={`noWrapInfo ${TREE_NODE_SEGMENT_GROW_CLASS}`} >
<span className='name'>{caption}</span>
</div>
<ScmInlineActions {...{
hover,
menu: menus.getMenu(ScmTreeWidget.RESOURCE_FOLDER_INLINE_MENU),
menuPath: ScmTreeWidget.RESOURCE_FOLDER_INLINE_MENU,
args: this.contextMenuArgs,
contextKeys,
model,
treeNode
}}>
</ScmInlineActions>
</div >;
}
protected readonly contextMenuPath = ScmTreeWidget.RESOURCE_FOLDER_CONTEXT_MENU;
protected get contextMenuArgs(): any[] {
if (!this.props.model.selectedNodes.some(node => ScmFileChangeFolderNode.is(node) && node.sourceUri === this.props.sourceUri)) {
// Clicked node is not in selection, so ignore selection and action on just clicked node
return this.singleNodeArgs;
} else {
return this.props.model.getSelectionArgs(this.props.model.selectedNodes);
}
}
protected get singleNodeArgs(): any[] {
return this.props.model.getResourcesFromFolderNode(this.props.treeNode);
}
}
export namespace ScmResourceFolderElement {
export interface Props extends ScmElement.Props {
treeNode: ScmFileChangeFolderNode;
sourceUri: string;
}
}
export class ScmInlineActions extends React.Component<ScmInlineActions.Props> {
override render(): React.ReactNode {
const { hover, menu, menuPath, args, model, treeNode, contextKeys, children } = this.props;
return <div className='theia-scm-inline-actions-container'>
<div className='theia-scm-inline-actions'>
{hover && menu?.children
.map((node, index) => CommandMenu.is(node) &&
<ScmInlineAction key={index} {...{ node, menuPath, args, model, treeNode, contextKeys }} />)}
</div>
{children}
</div>;
}
}
export namespace ScmInlineActions {
export interface Props {
hover: boolean;
menu: CompoundMenuNode | undefined;
menuPath: MenuPath;
model: ScmTreeModel;
treeNode: TreeNode;
contextKeys: ScmContextKeyService;
args: any[];
children?: React.ReactNode;
}
}
export class ScmInlineAction extends React.Component<ScmInlineAction.Props> {
override render(): React.ReactNode {
const { node, menuPath, model, treeNode, args, contextKeys } = this.props;
let isActive: boolean = false;
model.execInNodeContext(treeNode, () => {
isActive = node.isVisible(menuPath, contextKeys, undefined, ...args);
});
if (!isActive) {
return false;
}
return <div className='theia-scm-inline-action'>
<a className={`${node.icon} ${ACTION_ITEM}`} title={node.label} onClick={this.execute} />
</div>;
}
protected execute = (event: React.MouseEvent) => {
event.stopPropagation();
const { node, menuPath, args } = this.props;
node.run(menuPath, ...args);
};
}
export namespace ScmInlineAction {
export interface Props {
node: CommandMenu;
menuPath: MenuPath;
model: ScmTreeModel;
treeNode: TreeNode;
contextKeys: ScmContextKeyService;
args: any[];
}
}

View File

@@ -0,0 +1,218 @@
// *****************************************************************************
// Copyright (C) 2019 Red Hat, Inc. 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
// *****************************************************************************
/* eslint-disable no-null/no-null, @typescript-eslint/no-explicit-any */
import { Message } from '@theia/core/shared/@lumino/messaging';
import { injectable, inject, postConstruct } from '@theia/core/shared/inversify';
import { DisposableCollection } from '@theia/core/lib/common/disposable';
import {
BaseWidget, Widget, StatefulWidget, Panel, PanelLayout, MessageLoop, CompositeTreeNode, SelectableTreeNode, ApplicationShell, NavigatableWidget,
BadgeService,
} from '@theia/core/lib/browser';
import { ScmCommitWidget } from './scm-commit-widget';
import { ScmActionButtonWidget } from './scm-action-button-widget';
import { ScmAmendWidget } from './scm-amend-widget';
import { ScmNoRepositoryWidget } from './scm-no-repository-widget';
import { ScmService } from './scm-service';
import { ScmTreeWidget } from './scm-tree-widget';
import { ScmPreferences } from '../common/scm-preferences';
import { nls } from '@theia/core/lib/common/nls';
@injectable()
export class ScmWidget extends BaseWidget implements StatefulWidget {
protected panel: Panel;
static ID = 'scm-view';
@inject(ApplicationShell) protected readonly shell: ApplicationShell;
@inject(ScmService) protected readonly scmService: ScmService;
@inject(ScmCommitWidget) protected readonly commitWidget: ScmCommitWidget;
@inject(ScmActionButtonWidget) protected readonly actionButtonWidget: ScmActionButtonWidget;
@inject(ScmTreeWidget) readonly resourceWidget: ScmTreeWidget;
@inject(ScmAmendWidget) protected readonly amendWidget: ScmAmendWidget;
@inject(ScmNoRepositoryWidget) readonly noRepositoryWidget: ScmNoRepositoryWidget;
@inject(ScmPreferences) protected readonly scmPreferences: ScmPreferences;
@inject(BadgeService) protected readonly badgeService: BadgeService;
set viewMode(mode: 'tree' | 'list') {
this.resourceWidget.viewMode = mode;
}
get viewMode(): 'tree' | 'list' {
return this.resourceWidget.viewMode;
}
constructor() {
super();
this.node.tabIndex = 0;
this.id = ScmWidget.ID;
this.addClass('theia-scm');
this.addClass('theia-scm-main-container');
}
@postConstruct()
protected init(): void {
const layout = new PanelLayout();
this.layout = layout;
this.panel = new Panel({
layout: new PanelLayout({
})
});
this.panel.node.tabIndex = -1;
this.panel.node.setAttribute('class', 'theia-scm-panel');
layout.addWidget(this.panel);
this.containerLayout.addWidget(this.commitWidget);
this.containerLayout.addWidget(this.actionButtonWidget);
this.containerLayout.addWidget(this.resourceWidget);
this.containerLayout.addWidget(this.amendWidget);
this.containerLayout.addWidget(this.noRepositoryWidget);
this.toDispose.push(this.resourceWidget.model.onNodeRefreshed(() => {
const totalChanges = this.resourceWidget.model.scmProvider?.groups.reduce((prev, curr) => prev + curr.resources.length, 0);
this.badgeService.showBadge(this, totalChanges ? { value: totalChanges, tooltip: nls.localizeByDefault('{0} pending changes', totalChanges) } : undefined);
}));
this.refresh();
this.toDispose.push(this.scmService.onDidChangeSelectedRepository(() => this.refresh()));
this.updateViewMode(this.scmPreferences.get('scm.defaultViewMode'));
this.toDispose.push(this.scmPreferences.onPreferenceChanged(
e => {
if (e.preferenceName === 'scm.defaultViewMode') {
this.updateViewMode(this.scmPreferences.get('scm.defaultViewMode'));
}
}));
this.toDispose.push(this.shell.onDidChangeCurrentWidget(({ newValue }) => {
const uri = NavigatableWidget.getUri(newValue || undefined);
if (uri) {
this.resourceWidget.selectNodeByUri(uri);
}
}));
}
get containerLayout(): PanelLayout {
return this.panel.layout as PanelLayout;
}
/**
* Updates the view mode based on the preference value.
* @param preference the view mode preference.
*/
protected updateViewMode(preference: 'tree' | 'list'): void {
this.viewMode = preference;
}
protected readonly toDisposeOnRefresh = new DisposableCollection();
protected refresh(): void {
this.toDisposeOnRefresh.dispose();
this.toDispose.push(this.toDisposeOnRefresh);
const repository = this.scmService.selectedRepository;
this.title.label = repository ? repository.provider.label : nls.localize('theia/scm/noRepositoryFound', 'No repository found');
this.title.caption = this.title.label;
this.update();
if (repository) {
this.toDisposeOnRefresh.push(repository.onDidChange(() => this.update()));
// render synchronously to avoid cursor jumping
// see https://stackoverflow.com/questions/28922275/in-reactjs-why-does-setstate-behave-differently-when-called-synchronously/28922465#28922465
this.toDisposeOnRefresh.push(repository.input.onDidChange(() => this.updateImmediately()));
this.toDisposeOnRefresh.push(repository.input.onDidFocus(() => this.focusInput()));
this.commitWidget.show();
this.actionButtonWidget.show();
this.resourceWidget.show();
this.amendWidget.show();
this.noRepositoryWidget.hide();
} else {
this.commitWidget.hide();
this.actionButtonWidget.hide();
this.resourceWidget.hide();
this.amendWidget.hide();
this.noRepositoryWidget.show();
}
}
protected updateImmediately(): void {
this.onUpdateRequest(Widget.Msg.UpdateRequest);
}
protected override onUpdateRequest(msg: Message): void {
MessageLoop.sendMessage(this.commitWidget, msg);
MessageLoop.sendMessage(this.actionButtonWidget, msg);
MessageLoop.sendMessage(this.resourceWidget, msg);
MessageLoop.sendMessage(this.amendWidget, msg);
MessageLoop.sendMessage(this.noRepositoryWidget, msg);
super.onUpdateRequest(msg);
}
protected override onAfterAttach(msg: Message): void {
this.node.appendChild(this.commitWidget.node);
this.node.appendChild(this.actionButtonWidget.node);
this.node.appendChild(this.resourceWidget.node);
this.node.appendChild(this.amendWidget.node);
this.node.appendChild(this.noRepositoryWidget.node);
super.onAfterAttach(msg);
this.update();
}
protected override onActivateRequest(msg: Message): void {
super.onActivateRequest(msg);
this.refresh();
if (this.commitWidget.isVisible) {
this.commitWidget.focus();
} else {
this.node.focus();
}
}
protected focusInput(): void {
this.commitWidget.focus();
}
storeState(): any {
const state: object = {
commitState: this.commitWidget.storeState(),
changesTreeState: this.resourceWidget.storeState(),
};
return state;
}
restoreState(oldState: any): void {
const { commitState, changesTreeState } = oldState;
this.commitWidget.restoreState(commitState);
this.resourceWidget.restoreState(changesTreeState);
}
collapseScmTree(): void {
const { model } = this.resourceWidget;
const root = model.root;
if (CompositeTreeNode.is(root)) {
root.children.map(group => {
if (CompositeTreeNode.is(group)) {
group.children.map(folderNode => {
if (CompositeTreeNode.is(folderNode)) {
model.collapseAll(folderNode);
}
if (SelectableTreeNode.isSelected(folderNode)) {
model.toggleNode(folderNode);
}
});
}
});
}
}
}

View File

@@ -0,0 +1,53 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
.dirty-diff-glyph {
margin-left: 5px;
cursor: pointer;
}
.dirty-diff-removed-line:after {
content: '';
position: absolute;
bottom: -4px;
box-sizing: border-box;
width: 4px;
height: 0;
z-index: 9;
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
transition: border-top-width 80ms linear, border-bottom-width 80ms linear, bottom 80ms linear;
pointer-events: none;
}
.dirty-diff-glyph:before {
position: absolute;
content: '';
height: 100%;
width: 0;
left: -2px;
transition: width 80ms linear, left 80ms linear;
}
.dirty-diff-removed-line:before {
margin-left: 3px;
height: 0;
bottom: 0;
transition: height 80ms linear;
}
.margin-view-overlays > div:hover > .dirty-diff-glyph:before {
position: absolute;
content: '';
height: 100%;
width: 6px;
left: -6px;
}
.margin-view-overlays > div:hover > .dirty-diff-removed-line:after {
bottom: 0;
border-top-width: 0;
border-bottom-width: 0;
}

View File

@@ -0,0 +1,50 @@
/********************************************************************************
* 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 "dirty-diff-decorator.css";
.monaco-editor .dirty-diff-added-line {
border-left: 3px solid var(--theia-editorGutter-addedBackground);
transition: opacity 0.5s;
}
.monaco-editor .dirty-diff-added-line:before {
background: var(--theia-editorGutter-addedBackground);
}
.monaco-editor .margin:hover .dirty-diff-added-line {
opacity: 1;
}
.monaco-editor .dirty-diff-removed-line:after {
border-left: 4px solid var(--theia-editorGutter-deletedBackground);
transition: opacity 0.5s;
}
.monaco-editor .dirty-diff-removed-line:before {
background: var(--theia-editorGutter-deletedBackground);
}
.monaco-editor .margin:hover .dirty-diff-removed-line {
opacity: 1;
}
.monaco-editor .dirty-diff-modified-line {
border-left: 3px solid var(--theia-editorGutter-modifiedBackground);
transition: opacity 0.5s;
}
.monaco-editor .dirty-diff-modified-line:before {
background: var(--theia-editorGutter-modifiedBackground);
}
.monaco-editor .margin:hover .dirty-diff-modified-line {
opacity: 1;
}

View File

@@ -0,0 +1,313 @@
/********************************************************************************
* Copyright (C) 2019 Red Hat, Inc. 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
********************************************************************************/
.theia-scm-commit {
overflow: hidden;
font-size: var(--theia-ui-font-size1);
max-height: calc(100% - var(--theia-border-width));
position: relative;
padding: var(--theia-ui-padding)
max(var(--theia-ui-padding), var(--theia-scrollbar-width))
var(--theia-ui-padding)
calc(var(--theia-ui-padding) * 3);
}
.theia-scm {
box-sizing: border-box;
height: 100%;
}
.groups-outer-container:focus {
outline: 0;
box-shadow: none;
border: none;
}
.theia-scm .noWrapInfo {
width: 100%;
align-items: center;
}
.theia-scm:focus,
.theia-scm :focus {
outline: 0;
box-shadow: none;
border: none;
}
.theia-scm .space-between {
justify-content: space-between;
}
.theia-scm .changesHeader {
font-weight: bold;
}
.theia-scm .theia-scm-amend {
margin: 5px 0;
}
.theia-scm #messageInputContainer {
position: relative;
}
.theia-scm #repositoryListContainer {
display: flex;
margin-bottom: 5px;
flex: 1;
}
.theia-scm .groups-outer-container {
overflow-y: auto;
width: 100%;
flex-grow: 1;
}
.theia-scm .warn {
background-color: var(--theia-inputValidation-warningBackground) !important;
}
.theia-scm-main-container {
display: flex;
flex-direction: column;
height: 100%;
}
.theia-scm-input-message-container {
display: flex;
flex-direction: column;
max-height: 400px;
}
.theia-scm-input-message-container textarea {
line-height: var(--theia-content-line-height);
resize: none;
box-sizing: border-box;
border: none;
}
.theia-scm-input-message-container textarea:placeholder-shown {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.theia-scm-input-message-container textarea:focus {
outline: var(--theia-border-width) solid var(--theia-focusBorder);
}
.theia-scm-input-message {
width: 100%;
}
.theia-scm-input-message-idle:not(:focus) {
border-color: var(--theia-input-border);
}
.theia-scm-input-message-info {
border-color: var(--theia-inputValidation-infoBorder) !important;
}
.theia-scm-input-message-success {
border-color: var(--theia-successBackground) !important;
}
.theia-scm-input-message-warning {
border-color: var(--theia-inputValidation-warningBorder) !important;
}
.theia-scm-input-message-error {
border-color: var(--theia-inputValidation-errorBorder) !important;
}
.theia-scm-message,
.theia-scm-input-validation-message {
padding: 4px 4px 4px 4px;
}
.theia-scm-validation-message-info {
background-color: var(--theia-inputValidation-infoBackground) !important;
color: var(--theia-inputValidation-infoForeground);
border: var(--theia-border-width) solid
var(--theia-inputValidation-infoBorder);
border-top: none; /* remove top border since the input declares it already */
}
.theia-scm-validation-message-success {
background-color: var(--theia-successBackground) !important;
color: var(--theia-inputValidation-warningBackground);
}
.theia-scm-message-warning,
.theia-scm-validation-message-warning {
background-color: var(--theia-inputValidation-warningBackground) !important;
color: var(--theia-inputValidation-warningForeground);
border: var(--theia-border-width) solid
var(--theia-inputValidation-warningBorder);
border-top: none; /* remove top border since the input declares it already */
}
.theia-scm-validation-message-error {
background-color: var(--theia-inputValidation-errorBackground) !important;
color: var(--theia-inputValidation-errorForeground);
border: var(--theia-border-width) solid
var(--theia-inputValidation-errorBorder);
border-top: none; /* remove top border since the input declares it already */
}
.theia-scm-action-button-container {
width: 100%;
display: flex;
background-color: var(--theia-button-background);
}
.theia-scm-action-button {
display: flex;
align-items: center;
justify-content: center;
background-color: var(--theia-button-background);
color: var(--theia-button-foreground);
width: 100%;
height: 28px;
gap: 4px;
border: 1px solid var(--theia-button-border);
cursor: pointer;
}
.theia-scm-action-button:hover {
background-color: var(--theia-button-hoverBackground);
}
.theia-scm-action-button:disabled {
background-color: var(--theia-button-disabledBackground);
color: var(--theia-button-disabledForeground);
cursor: default;
}
.theia-scm-action-button .codicon {
vertical-align: middle;
}
.theia-scm-action-button-secondary {
width: 28px;
}
.theia-scm-action-button-divider {
width: 1px;
background-color: var(--theia-button-foreground);
margin: 4px 0;
}
.theia-scm-action-button-divider-disabled {
background-color: var(--theia-button-disabledForeground);
}
.no-select:focus {
outline: none;
}
.theia-scm .scmItem {
font-size: var(--theia-ui-font-size1);
display: flex;
align-items: center;
justify-content: space-between;
height: var(--theia-content-line-height);
line-height: var(--theia-content-line-height);
padding: 0px calc(var(--theia-ui-padding) / 2);
}
.theia-scm .scmItem:hover {
cursor: pointer;
}
.theia-scm:focus-within .scmItem:focus {
background: var(--theia-list-focusBackground);
color: var(--theia-list-focusForeground);
}
.theia-scm:not(:focus-within) .scmItem:not(:focus) {
background: var(--theia-list-inactiveFocusBackground);
}
.theia-scm:focus-within .scmItem.theia-mod-selected {
background: var(--theia-list-activeSelectionBackground);
color: var(--theia-list-activeSelectionForeground);
}
.theia-scm:not(:focus-within) .scmItem.theia-mod-selected {
background: var(--theia-list-inactiveSelectionBackground);
color: var(--theia-list-inactiveSelectionForeground);
}
.theia-scm .scmItem .path {
font-size: var(--theia-ui-font-size0);
margin-left: var(--theia-ui-padding);
opacity: 0.7;
}
.theia-scm .scmItem .status {
text-align: center;
font-size: var(--theia-ui-font-size0);
margin-right: var(--theia-ui-padding);
}
.scm-theia-header {
display: flex;
align-items: center;
padding-right: var(--theia-ui-padding);
}
.scm-theia-header:hover {
cursor: pointer;
}
.theia-scm-inline-actions-container {
display: flex;
justify-content: flex-end;
margin-left: 3px;
min-height: 16px;
}
.theia-scm-inline-actions {
display: flex;
align-items: center;
gap: 2px;
margin-right: var(--theia-ui-padding);
}
.theia-scm-inline-actions a {
color: var(--theia-icon-foreground);
}
.theia-scm-inline-action {
display: flex;
align-items: center;
min-width: var(--theia-icon-size);
height: var(--theia-icon-size);
line-height: var(--theia-icon-size);
font-size: var(--theia-ui-font-size1);
cursor: pointer;
}
.theia-scm-inline-action .open-file {
height: var(--theia-icon-size);
width: 12px;
background: var(--theia-icon-open-file) no-repeat center center;
}
.theia-scm-panel {
overflow: visible;
}

View File

@@ -0,0 +1,221 @@
/********************************************************************************
* Copyright (C) 2025 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
********************************************************************************/
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
/* copied and modified from https://github.com/microsoft/vscode/blob/1.96.3/src/vs/workbench/contrib/mergeEditor/browser/view/media/mergeEditor.css */
.theia-merge-editor .lm-SplitPanel {
height: 100%;
width: 100%;
outline: none;
}
.theia-merge-editor .lm-SplitPanel > .lm-SplitPanel-child {
min-width: 50px;
min-height: var(--theia-content-line-height);
}
.theia-merge-editor .lm-SplitPanel > .lm-SplitPanel-handle {
box-sizing: border-box;
}
.theia-merge-editor .lm-SplitPanel[data-orientation="horizontal"] > .lm-SplitPanel-handle {
border-left: var(--theia-border-width) solid var(--theia-sideBarSectionHeader-border);
}
.theia-merge-editor .lm-SplitPanel[data-orientation="vertical"] > .lm-SplitPanel-handle {
border-top: var(--theia-border-width) solid var(--theia-sideBarSectionHeader-border);
}
.theia-merge-editor .editor-pane .monaco-editor {
outline: none;
}
.theia-merge-editor .editor-pane > .header {
padding: 0 10px;
min-height: 30px;
display: flex;
align-content: center;
overflow: hidden;
outline: none;
user-select: none;
}
.theia-merge-editor .editor-pane > .header > span {
align-self: center;
text-overflow: ellipsis;
overflow: hidden;
padding-right: 6px;
white-space: nowrap;
}
.theia-merge-editor .editor-pane > .header > .title {
flex-shrink: 0;
}
.theia-merge-editor .editor-pane > .header > .description {
flex-shrink: 0;
display: flex;
font-size: 12px;
align-items: center;
color: var(--theia-descriptionForeground);
}
.theia-merge-editor .editor-pane > .header > .description .codicon {
font-size: 14px;
color: var(--theia-descriptionForeground);
}
.theia-merge-editor .editor-pane > .header > .detail {
margin-left: auto;
font-size: 12px;
color: var(--theia-descriptionForeground);
}
.theia-merge-editor .editor-pane > .header > .detail .codicon {
font-size: 13px;
}
.theia-merge-editor .editor-pane > .header > .toolbar {
flex-shrink: 0;
display: flex;
font-size: 11px;
align-items: center;
color: var(--theia-descriptionForeground);
}
.theia-merge-editor .editor-pane > .header > .toolbar .codicon {
color: var(--theia-icon-foreground);
}
.theia-merge-editor .editor-pane > .header > .toolbar .action-label.theia-mod-disabled {
cursor: default;
background: none;
}
.theia-merge-editor .editor-pane.side > .header > .detail::before {
content: '•';
opacity: .5;
padding-right: 3px;
}
.theia-merge-editor .editor-pane.side > .header > .detail {
margin-left: 0;
}
.theia-merge-editor .editor-pane.side > .header > .toolbar {
margin-left: auto;
}
.theia-merge-editor .editor-pane.result > .header > .description {
display: inline;
flex-shrink: 1;
}
.theia-merge-editor .editor-pane.result > .header > .detail {
flex-shrink: 0;
}
.theia-merge-editor .editor-pane .action-zone .codelens-decoration {
font-family: var(--vscode-editorCodeLens-fontFamily, var(--theia-ui-font-family));
}
.theia-merge-editor .editor-pane .merge-range:not(.handled):not(.focused) {
border: 1px solid var(--theia-mergeEditor-conflict-unhandledUnfocused-border);
background-color: var(--theia-mergeEditor-conflict-unhandledUnfocused-background);
}
.theia-merge-editor .editor-pane .merge-range:not(.handled).focused {
border: 2px solid var(--theia-mergeEditor-conflict-unhandledFocused-border);
background-color: var(--theia-mergeEditor-conflict-unhandledFocused-background);
}
.theia-merge-editor .editor-pane .merge-range.handled:not(.focused) {
border: 1px solid var(--theia-mergeEditor-conflict-handledUnfocused-border);
background-color: var(--theia-mergeEditor-conflict-handledUnfocused-background);
}
.theia-merge-editor .editor-pane .merge-range.handled.focused {
border: 2px solid var(--theia-mergeEditor-conflict-handledFocused-border);
background-color: var(--theia-mergeEditor-conflict-handledFocused-background);
}
.theia-merge-editor .editor-pane .diff {
background-color: var(--theia-mergeEditor-change-background);
}
.theia-merge-editor .editor-pane.base .diff {
background-color: var(--theia-mergeEditor-changeBase-background);
}
.theia-merge-editor .editor-pane.side1:not(.focused) .diff {
background-color: var(--theia-merge-currentContentBackground);
}
.theia-merge-editor .editor-pane.side2:not(.focused) .diff {
background-color: var(--theia-merge-incomingContentBackground);
}
.theia-merge-editor .editor-pane.result:not(.focused) .diff {
background-color: var(--theia-merge-commonContentBackground);
}
.theia-merge-editor .editor-pane .diff-word {
background-color: var(--theia-mergeEditor-change-word-background);
}
.theia-merge-editor .editor-pane.base .diff-word {
background-color: var(--theia-mergeEditor-changeBase-word-background);
}
.theia-merge-editor .editor-pane.side1:not(.focused) .diff-word {
background-color: var(--theia-merge-currentHeaderBackground);
}
.theia-merge-editor .editor-pane.side2:not(.focused) .diff-word {
background-color: var(--theia-merge-incomingHeaderBackground);
}
.theia-merge-editor .editor-pane.result:not(.focused) .diff-word {
background-color: var(--theia-merge-commonHeaderBackground);
}
.theia-merge-editor .editor-pane .diff-empty-word {
margin-left: 3px;
border-left: solid var(--theia-mergeEditor-change-word-background) 3px;
}
.theia-merge-editor .editor-pane.base .diff-empty-word {
margin-left: 3px;
border-left: solid var(--theia-mergeEditor-changeBase-word-background) 3px;
}
.theia-merge-editor .editor-pane.side1:not(.focused) .diff-empty-word {
margin-left: 3px;
border-left: solid var(--theia-merge-currentHeaderBackground) 3px;
}
.theia-merge-editor .editor-pane.side2:not(.focused) .diff-empty-word {
margin-left: 3px;
border-left: solid var(--theia-merge-incomingHeaderBackground) 3px;
}
.theia-merge-editor .editor-pane.result:not(.focused) .diff-empty-word {
margin-left: 3px;
border-left: solid var(--theia-merge-commonHeaderBackground) 3px;
}

View File

@@ -0,0 +1,94 @@
/********************************************************************************
* 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
********************************************************************************/
.theia-scm-commit-container {
display: flex;
flex-direction: column;
border-top: 1px solid var(--theia-sideBarSectionHeader-border);
width: 100%;
padding-top: 6px;
}
.theia-scm-amend-outer-container {
display: flex;
flex-direction: column;
width: 100%;
overflow: auto;
}
.theia-scm-commit-and-button {
display: flex;
white-space: nowrap;
overflow: hidden;
width: 100%;
}
.theia-scm-commit-avatar-and-text {
display: flex;
white-space: nowrap;
overflow: hidden;
width: 100%;
padding-top: 2px;
}
.theia-scm-commit-avatar-and-text img {
width: 27px;
}
.theia-scm-commit-details {
display: flex;
flex-direction: column;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: 100%;
}
.theia-scm-commit-message-avatar {
margin-right: 5px;
}
.theia-scm-commit-message-summary {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.theia-scm-commit-message-time {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--theia-descriptionForeground);
font-size: smaller;
}
.theia-scm-flex-container-center {
display: flex;
align-items: center;
}
.theia-scm-scrolling-container {
position: relative;
width: 100%;
overflow: hidden;
margin-top: 0;
margin-bottom: 0;
padding-top: 0;
padding-bottom: 0;
border-top: 0;
border-bottom: 0;
}

View File

@@ -0,0 +1,4 @@
<!--Copyright (c) Microsoft Corporation. All rights reserved.-->
<!--Copyright (C) 2019 TypeFox and others.-->
<!--Licensed under the MIT License. See License.txt in the project root for license information.-->
<svg fill="#F6F6F6" height="28" viewBox="0 0 28 28" width="28" xmlns="http://www.w3.org/2000/svg"><path clip-rule="evenodd" d="m20 0c-2.22 0-4 1.78-4 4 0 1.46.82 2.76 2 3.44v2.56l-4 4-4-4v-2.56c1.18-.68 2-1.96 2-3.44 0-2.22-1.78-4-4-4s-4 1.78-4 4c0 1.46.82 2.76 2 3.44v3.56l6 6v3.56c-1.18.68-2 1.96-2 3.44 0 2.22 1.78 4 4 4s4-1.78 4-4c0-1.46-.82-2.76-2-3.44v-3.56l6-6v-3.56c1.18-.68 2-1.96 2-3.44 0-2.22-1.78-4-4-4zm-12 6.4c-1.32 0-2.4-1.1-2.4-2.4s1.1-2.4 2.4-2.4 2.4 1.1 2.4 2.4-1.1 2.4-2.4 2.4zm6 20c-1.32 0-2.4-1.1-2.4-2.4s1.1-2.4 2.4-2.4 2.4 1.1 2.4 2.4-1.1 2.4-2.4 2.4zm6-20c-1.32 0-2.4-1.1-2.4-2.4s1.1-2.4 2.4-2.4 2.4 1.1 2.4 2.4-1.1 2.4-2.4 2.4z" fill="#F6F6F6" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 908 B

View File

@@ -0,0 +1,62 @@
// *****************************************************************************
// Copyright (C) 2020 Ericsson 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 {
createPreferenceProxy,
PreferenceContribution,
PreferenceProxy,
PreferenceSchema,
PreferenceService,
} from '@theia/core/lib/common/preferences';
import { nls } from '@theia/core/lib/common/nls';
export const scmPreferenceSchema: PreferenceSchema = {
properties: {
'scm.defaultViewMode': {
type: 'string',
enum: ['tree', 'list'],
enumDescriptions: [
nls.localizeByDefault('Show the repository changes as a tree.'),
nls.localizeByDefault('Show the repository changes as a list.')
],
description: nls.localizeByDefault('Controls the default Source Control repository view mode.'),
default: 'list'
}
}
};
export interface ScmConfiguration {
'scm.defaultViewMode': 'tree' | 'list'
}
export const ScmPreferenceContribution = Symbol('ScmPreferenceContribution');
export const ScmPreferences = Symbol('ScmPreferences');
export type ScmPreferences = PreferenceProxy<ScmConfiguration>;
export function createScmPreferences(preferences: PreferenceService, schema: PreferenceSchema = scmPreferenceSchema): ScmPreferences {
return createPreferenceProxy(preferences, schema);
}
export function bindScmPreferences(bind: interfaces.Bind): void {
bind(ScmPreferences).toDynamicValue((ctx: interfaces.Context) => {
const preferences = ctx.container.get<PreferenceService>(PreferenceService);
const contribution = ctx.container.get<PreferenceContribution>(ScmPreferenceContribution);
return createScmPreferences(preferences, contribution.schema);
}).inSingletonScope();
bind(ScmPreferenceContribution).toConstantValue({ schema: scmPreferenceSchema });
bind(PreferenceContribution).toService(ScmPreferenceContribution);
}

View File

@@ -0,0 +1,21 @@
// *****************************************************************************
// Copyright (C) 2019 Red Hat, Inc. 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 { ContainerModule } from '@theia/core/shared/inversify';
import { bindScmPreferences } from '../common/scm-preferences';
export default new ContainerModule(bind => {
bindScmPreferences(bind);
});

View File

@@ -0,0 +1,25 @@
{
"extends": "../../configs/base.tsconfig",
"compilerOptions": {
"composite": true,
"rootDir": "src",
"outDir": "lib"
},
"include": [
"src"
],
"references": [
{
"path": "../core"
},
{
"path": "../editor"
},
{
"path": "../filesystem"
},
{
"path": "../monaco"
}
]
}