deploy: current vibn theia state
Made-with: Cursor
This commit is contained in:
10
packages/scm/.eslintrc.js
Normal file
10
packages/scm/.eslintrc.js
Normal 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
32
packages/scm/README.md
Normal 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
59
packages/scm/package.json
Normal 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"
|
||||
}
|
||||
124
packages/scm/src/browser/decorations/scm-decorations-service.ts
Normal file
124
packages/scm/src/browser/decorations/scm-decorations-service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
121
packages/scm/src/browser/decorations/scm-navigator-decorator.ts
Normal file
121
packages/scm/src/browser/decorations/scm-navigator-decorator.ts
Normal 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);
|
||||
}
|
||||
|
||||
}
|
||||
42
packages/scm/src/browser/dirty-diff/content-lines.spec.ts
Normal file
42
packages/scm/src/browser/dirty-diff/content-lines.spec.ts
Normal 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');
|
||||
});
|
||||
|
||||
});
|
||||
121
packages/scm/src/browser/dirty-diff/content-lines.ts
Normal file
121
packages/scm/src/browser/dirty-diff/content-lines.ts
Normal 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`);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
455
packages/scm/src/browser/dirty-diff/diff-computer.spec.ts
Normal file
455
packages/scm/src/browser/dirty-diff/diff-computer.spec.ts
Normal 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;
|
||||
}
|
||||
177
packages/scm/src/browser/dirty-diff/diff-computer.ts
Normal file
177
packages/scm/src/browser/dirty-diff/diff-computer.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
114
packages/scm/src/browser/dirty-diff/dirty-diff-decorator.ts
Normal file
114
packages/scm/src/browser/dirty-diff/dirty-diff-decorator.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
33
packages/scm/src/browser/dirty-diff/dirty-diff-module.ts
Normal file
33
packages/scm/src/browser/dirty-diff/dirty-diff-module.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
291
packages/scm/src/browser/dirty-diff/dirty-diff-navigator.ts
Normal file
291
packages/scm/src/browser/dirty-diff/dirty-diff-navigator.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
413
packages/scm/src/browser/dirty-diff/dirty-diff-widget.ts
Normal file
413
packages/scm/src/browser/dirty-diff/dirty-diff-widget.ts
Normal 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' });
|
||||
}
|
||||
}
|
||||
@@ -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' }
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
134
packages/scm/src/browser/merge-editor/merge-editor-module.ts
Normal file
134
packages/scm/src/browser/merge-editor/merge-editor-module.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
648
packages/scm/src/browser/merge-editor/merge-editor.ts
Normal file
648
packages/scm/src/browser/merge-editor/merge-editor.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
128
packages/scm/src/browser/merge-editor/model/line-range.ts
Normal file
128
packages/scm/src/browser/merge-editor/model/line-range.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
111
packages/scm/src/browser/merge-editor/model/live-diff.ts
Normal file
111
packages/scm/src/browser/merge-editor/model/live-diff.ts
Normal 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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
268
packages/scm/src/browser/merge-editor/model/merge-range.ts
Normal file
268
packages/scm/src/browser/merge-editor/model/merge-range.ts
Normal 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';
|
||||
81
packages/scm/src/browser/merge-editor/model/range-editing.ts
Normal file
81
packages/scm/src/browser/merge-editor/model/range-editing.ts
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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)');
|
||||
});
|
||||
|
||||
});
|
||||
396
packages/scm/src/browser/merge-editor/model/range-mapping.ts
Normal file
396
packages/scm/src/browser/merge-editor/model/range-mapping.ts
Normal 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())
|
||||
);
|
||||
}
|
||||
}
|
||||
122
packages/scm/src/browser/merge-editor/model/range-utils.ts
Normal file
122
packages/scm/src/browser/merge-editor/model/range-utils.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
160
packages/scm/src/browser/merge-editor/view/diff-spacers.ts
Normal file
160
packages/scm/src/browser/merge-editor/view/diff-spacers.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
262
packages/scm/src/browser/scm-action-button-widget.tsx
Normal file
262
packages/scm/src/browser/scm-action-button-widget.tsx
Normal 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';
|
||||
};
|
||||
|
||||
}
|
||||
600
packages/scm/src/browser/scm-amend-component.tsx
Normal file
600
packages/scm/src/browser/scm-amend-component.tsx
Normal 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';
|
||||
}
|
||||
|
||||
}
|
||||
77
packages/scm/src/browser/scm-amend-widget.tsx
Normal file
77
packages/scm/src/browser/scm-amend-widget.tsx
Normal 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;
|
||||
}
|
||||
};
|
||||
|
||||
}
|
||||
27
packages/scm/src/browser/scm-avatar-service.ts
Normal file
27
packages/scm/src/browser/scm-avatar-service.ts
Normal 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`;
|
||||
}
|
||||
}
|
||||
23
packages/scm/src/browser/scm-colors.ts
Normal file
23
packages/scm/src/browser/scm-colors.ts
Normal 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';
|
||||
}
|
||||
216
packages/scm/src/browser/scm-commit-widget.tsx
Normal file
216
packages/scm/src/browser/scm-commit-widget.tsx
Normal 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
|
||||
}
|
||||
}
|
||||
52
packages/scm/src/browser/scm-context-key-service.ts
Normal file
52
packages/scm/src/browser/scm-context-key-service.ts
Normal 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);
|
||||
}
|
||||
|
||||
}
|
||||
493
packages/scm/src/browser/scm-contribution.ts
Normal file
493
packages/scm/src/browser/scm-contribution.ts
Normal 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;
|
||||
}
|
||||
`);
|
||||
}
|
||||
}
|
||||
}
|
||||
155
packages/scm/src/browser/scm-frontend-module.ts
Normal file
155
packages/scm/src/browser/scm-frontend-module.ts
Normal 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;
|
||||
}
|
||||
81
packages/scm/src/browser/scm-groups-tree-model.ts
Normal file
81
packages/scm/src/browser/scm-groups-tree-model.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
164
packages/scm/src/browser/scm-input.ts
Normal file
164
packages/scm/src/browser/scm-input.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
64
packages/scm/src/browser/scm-layout-migrations.ts
Normal file
64
packages/scm/src/browser/scm-layout-migrations.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
41
packages/scm/src/browser/scm-no-repository-widget.tsx
Normal file
41
packages/scm/src/browser/scm-no-repository-widget.tsx
Normal 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')}
|
||||
/>;
|
||||
}
|
||||
|
||||
}
|
||||
102
packages/scm/src/browser/scm-provider.ts
Normal file
102
packages/scm/src/browser/scm-provider.ts
Normal 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;
|
||||
}
|
||||
48
packages/scm/src/browser/scm-quick-open-service.ts
Normal file
48
packages/scm/src/browser/scm-quick-open-service.ts
Normal 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:' });
|
||||
}
|
||||
}
|
||||
}
|
||||
52
packages/scm/src/browser/scm-repository.ts
Normal file
52
packages/scm/src/browser/scm-repository.ts
Normal 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();
|
||||
}
|
||||
|
||||
}
|
||||
108
packages/scm/src/browser/scm-service.ts
Normal file
108
packages/scm/src/browser/scm-service.ts
Normal 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;
|
||||
}
|
||||
|
||||
}
|
||||
44
packages/scm/src/browser/scm-tree-label-provider.ts
Normal file
44
packages/scm/src/browser/scm-tree-label-provider.ts
Normal 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 '';
|
||||
}
|
||||
}
|
||||
409
packages/scm/src/browser/scm-tree-model.ts
Normal file
409
packages/scm/src/browser/scm-tree-model.ts
Normal 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';
|
||||
}
|
||||
|
||||
}
|
||||
830
packages/scm/src/browser/scm-tree-widget.tsx
Normal file
830
packages/scm/src/browser/scm-tree-widget.tsx
Normal 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[];
|
||||
}
|
||||
}
|
||||
218
packages/scm/src/browser/scm-widget.tsx
Normal file
218
packages/scm/src/browser/scm-widget.tsx
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
53
packages/scm/src/browser/style/dirty-diff-decorator.css
Normal file
53
packages/scm/src/browser/style/dirty-diff-decorator.css
Normal 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;
|
||||
}
|
||||
50
packages/scm/src/browser/style/dirty-diff.css
Normal file
50
packages/scm/src/browser/style/dirty-diff.css
Normal 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;
|
||||
}
|
||||
313
packages/scm/src/browser/style/index.css
Normal file
313
packages/scm/src/browser/style/index.css
Normal 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;
|
||||
}
|
||||
221
packages/scm/src/browser/style/merge-editor.css
Normal file
221
packages/scm/src/browser/style/merge-editor.css
Normal 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;
|
||||
}
|
||||
94
packages/scm/src/browser/style/scm-amend-component.css
Normal file
94
packages/scm/src/browser/style/scm-amend-component.css
Normal 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;
|
||||
}
|
||||
4
packages/scm/src/browser/style/scm.svg
Normal file
4
packages/scm/src/browser/style/scm.svg
Normal 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 |
62
packages/scm/src/common/scm-preferences.ts
Normal file
62
packages/scm/src/common/scm-preferences.ts
Normal 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);
|
||||
}
|
||||
21
packages/scm/src/node/scm-backend-module.ts
Normal file
21
packages/scm/src/node/scm-backend-module.ts
Normal 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);
|
||||
});
|
||||
25
packages/scm/tsconfig.json
Normal file
25
packages/scm/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user