diff --git a/CHANGELOG.md b/CHANGELOG.md
index dd737cd843def..2d3fcdfacf9bc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,9 +4,15 @@
- [Previous Changelogs](https://github.com/eclipse-theia/theia/tree/master/doc/changelogs/)
-
+- [scm] added support for dirty diff peek view [#13104](https://github.com/eclipse-theia/theia/pull/13104)
+
+[Breaking Changes:](#breaking_changes_not_yet_released)
+- [scm] revised some of the dirty diff related types [#13104](https://github.com/eclipse-theia/theia/pull/13104)
+ - replaced `DirtyDiff.added/removed/modified` with `changes`, which provides more detailed information about the changes
+ - changed the semantics of `LineRange` to represent a range that spans up to but not including the `end` line (previously, it included the `end` line)
+ - changed the signature of `DirtyDiffDecorator.toDeltaDecoration(LineRange | number, EditorDecorationOptions)` to `toDeltaDecoration(Change)`
## v1.48.0 - 03/28/2024
@@ -95,7 +101,7 @@
- Moved `ThemaIcon` and `ThemeColor` to the common folder
- Minor typing adjustments in QuickPickService: in parti
- FileUploadService: moved id field from data transfer item to the corresponding file info
- - The way we instantiate monaco services has changed completely: if you touch monaco services in your code, please read the description in the
+ - The way we instantiate monaco services has changed completely: if you touch monaco services in your code, please read the description in the
file comment in `monaco-init.ts`.
## v1.46.0 - 01/25/2024
diff --git a/packages/editor/src/browser/editor.ts b/packages/editor/src/browser/editor.ts
index ce792c7a6929e..0613b7b60936f 100644
--- a/packages/editor/src/browser/editor.ts
+++ b/packages/editor/src/browser/editor.ts
@@ -293,6 +293,8 @@ export interface TextEditor extends Disposable, TextEditorSelection, Navigatable
setEncoding(encoding: string, mode: EncodingMode): void;
readonly onEncodingChanged: Event;
+
+ shouldDisplayDirtyDiff(): boolean;
}
export interface Dimension {
diff --git a/packages/git/src/browser/dirty-diff/dirty-diff-contribution.ts b/packages/git/src/browser/dirty-diff/dirty-diff-contribution.ts
index 0ffc8a4148dfe..ced8be176d5bb 100644
--- a/packages/git/src/browser/dirty-diff/dirty-diff-contribution.ts
+++ b/packages/git/src/browser/dirty-diff/dirty-diff-contribution.ts
@@ -16,6 +16,7 @@
import { inject, injectable } from '@theia/core/shared/inversify';
import { DirtyDiffDecorator } from '@theia/scm/lib/browser/dirty-diff/dirty-diff-decorator';
+import { DirtyDiffNavigator } from '@theia/scm/lib/browser/dirty-diff/dirty-diff-navigator';
import { FrontendApplicationContribution, FrontendApplication } from '@theia/core/lib/browser';
import { DirtyDiffManager } from './dirty-diff-manager';
@@ -25,10 +26,13 @@ export class DirtyDiffContribution implements FrontendApplicationContribution {
constructor(
@inject(DirtyDiffManager) protected readonly dirtyDiffManager: DirtyDiffManager,
@inject(DirtyDiffDecorator) protected readonly dirtyDiffDecorator: DirtyDiffDecorator,
+ @inject(DirtyDiffNavigator) protected readonly dirtyDiffNavigator: DirtyDiffNavigator,
) { }
onStart(app: FrontendApplication): void {
- this.dirtyDiffManager.onDirtyDiffUpdate(update => this.dirtyDiffDecorator.applyDecorations(update));
+ this.dirtyDiffManager.onDirtyDiffUpdate(update => {
+ this.dirtyDiffDecorator.applyDecorations(update);
+ this.dirtyDiffNavigator.handleDirtyDiffUpdate(update);
+ });
}
-
}
diff --git a/packages/git/src/browser/dirty-diff/dirty-diff-manager.ts b/packages/git/src/browser/dirty-diff/dirty-diff-manager.ts
index 714ce0f12f943..31ede34ee21f0 100644
--- a/packages/git/src/browser/dirty-diff/dirty-diff-manager.ts
+++ b/packages/git/src/browser/dirty-diff/dirty-diff-manager.ts
@@ -71,13 +71,13 @@ export class DirtyDiffManager {
protected async handleEditorCreated(editorWidget: EditorWidget): Promise {
const editor = editorWidget.editor;
- const uri = editor.uri.toString();
- if (editor.uri.scheme !== 'file') {
+ if (!this.supportsDirtyDiff(editor)) {
return;
}
const toDispose = new DisposableCollection();
const model = this.createNewModel(editor);
toDispose.push(model);
+ const uri = editor.uri.toString();
this.models.set(uri, model);
toDispose.push(editor.onDocumentContentChanged(throttle((event: TextDocumentChangeEvent) => model.handleDocumentChanged(event.document), 1000)));
editorWidget.disposed.connect(() => {
@@ -93,6 +93,10 @@ export class DirtyDiffManager {
model.handleDocumentChanged(editor.document);
}
+ protected supportsDirtyDiff(editor: TextEditor): boolean {
+ return editor.uri.scheme === 'file' && editor.shouldDisplayDirtyDiff();
+ }
+
protected createNewModel(editor: TextEditor): DirtyDiffModel {
const previousRevision = this.createPreviousFileRevision(editor.uri);
const model = new DirtyDiffModel(editor, this.preferences, previousRevision);
@@ -101,11 +105,14 @@ export class DirtyDiffManager {
}
protected createPreviousFileRevision(fileUri: URI): DirtyDiffModel.PreviousFileRevision {
+ const getOriginalUri = (staged: boolean): URI => {
+ const query = staged ? '' : 'HEAD';
+ return fileUri.withScheme(GIT_RESOURCE_SCHEME).withQuery(query);
+ };
return {
fileUri,
getContents: async (staged: boolean) => {
- const query = staged ? '' : 'HEAD';
- const uri = fileUri.withScheme(GIT_RESOURCE_SCHEME).withQuery(query);
+ const uri = getOriginalUri(staged);
const gitResource = await this.gitResourceResolver.getResource(uri);
return gitResource.readContents();
},
@@ -115,7 +122,8 @@ export class DirtyDiffManager {
return this.git.lsFiles(repository, fileUri.toString(), { errorUnmatch: true });
}
return false;
- }
+ },
+ getOriginalUri
};
}
@@ -128,7 +136,6 @@ export class DirtyDiffManager {
await model.handleGitStatusUpdate(repository, changes);
}
}
-
}
export class DirtyDiffModel implements Disposable {
@@ -137,7 +144,7 @@ export class DirtyDiffModel implements Disposable {
protected enabled = true;
protected staged: boolean;
- protected previousContent: ContentLines | undefined;
+ protected previousContent: DirtyDiffModel.PreviousRevisionContent | undefined;
protected currentContent: ContentLines | undefined;
protected readonly onDirtyDiffUpdateEmitter = new Emitter();
@@ -181,7 +188,7 @@ export class DirtyDiffModel implements Disposable {
update(): void {
const editor = this.editor;
if (!this.shouldRender()) {
- this.onDirtyDiffUpdateEmitter.fire({ editor, added: [], removed: [], modified: [] });
+ this.onDirtyDiffUpdateEmitter.fire({ editor, changes: [] });
return;
}
if (this.updateTimeout) {
@@ -200,7 +207,7 @@ export class DirtyDiffModel implements Disposable {
// a new update task should be scheduled anyway.
return;
}
- const dirtyDiffUpdate = { editor, ...dirtyDiff };
+ const dirtyDiffUpdate = { editor, previousRevisionUri: previous.uri, ...dirtyDiff };
this.onDirtyDiffUpdateEmitter.fire(dirtyDiffUpdate);
}, 100);
}
@@ -251,9 +258,13 @@ export class DirtyDiffModel implements Disposable {
return modelUri.startsWith(repoUri) && this.previousRevision.isVersionControlled();
}
- protected async getPreviousRevisionContent(): Promise {
- const contents = await this.previousRevision.getContents(this.staged);
- return contents ? ContentLines.fromString(contents) : undefined;
+ protected async getPreviousRevisionContent(): Promise {
+ const { previousRevision, staged } = this;
+ const contents = await previousRevision.getContents(staged);
+ if (contents) {
+ const uri = previousRevision.getOriginalUri?.(staged);
+ return { ...ContentLines.fromString(contents), uri };
+ }
}
dispose(): void {
@@ -282,16 +293,18 @@ export namespace DirtyDiffModel {
}
export function documentContentLines(document: TextEditorDocument): ContentLines {
- return {
- length: document.lineCount,
- getLineContent: line => document.getLineContent(line + 1),
- };
+ return ContentLines.fromTextEditorDocument(document);
}
export interface PreviousFileRevision {
readonly fileUri: URI;
getContents(staged: boolean): Promise;
isVersionControlled(): Promise;
+ getOriginalUri?(staged: boolean): URI;
+ }
+
+ export interface PreviousRevisionContent extends ContentLines {
+ readonly uri?: URI;
}
}
diff --git a/packages/git/src/browser/git-contribution.ts b/packages/git/src/browser/git-contribution.ts
index 8232ecf989c0d..de36bd4d7788a 100644
--- a/packages/git/src/browser/git-contribution.ts
+++ b/packages/git/src/browser/git-contribution.ts
@@ -32,7 +32,7 @@ import {
TabBarToolbarRegistry
} from '@theia/core/lib/browser/shell/tab-bar-toolbar';
import { EditorContextMenu, EditorManager, EditorOpenerOptions, EditorWidget } from '@theia/editor/lib/browser';
-import { Git, GitFileChange, GitFileStatus } from '../common';
+import { Git, GitFileChange, GitFileStatus, GitWatcher, Repository } from '../common';
import { GitRepositoryTracker } from './git-repository-tracker';
import { GitAction, GitQuickOpenService } from './git-quick-open-service';
import { GitSyncService } from './git-sync-service';
@@ -42,6 +42,8 @@ import { GitErrorHandler } from '../browser/git-error-handler';
import { ScmWidget } from '@theia/scm/lib/browser/scm-widget';
import { ScmTreeWidget } from '@theia/scm/lib/browser/scm-tree-widget';
import { ScmCommand, ScmResource } from '@theia/scm/lib/browser/scm-provider';
+import { LineRange } from '@theia/scm/lib/browser/dirty-diff/diff-computer';
+import { DirtyDiffWidget, SCM_CHANGE_TITLE_MENU } from '@theia/scm/lib/browser/dirty-diff/dirty-diff-widget';
import { ProgressService } from '@theia/core/lib/common/progress-service';
import { GitPreferences } from './git-preferences';
import { ColorContribution } from '@theia/core/lib/browser/color-application-contribution';
@@ -166,6 +168,18 @@ export namespace GIT_COMMANDS {
label: 'Stage All Changes',
iconClass: codicon('add')
}, 'vscode.git/package/command.stageAll', GIT_CATEGORY_KEY);
+ export const STAGE_CHANGE = Command.toLocalizedCommand({
+ id: 'git.stage.change',
+ category: GIT_CATEGORY,
+ label: 'Stage Change',
+ iconClass: codicon('add')
+ }, 'vscode.git/package/command.stageChange', GIT_CATEGORY_KEY);
+ export const REVERT_CHANGE = Command.toLocalizedCommand({
+ id: 'git.revert.change',
+ category: GIT_CATEGORY,
+ label: 'Revert Change',
+ iconClass: codicon('discard')
+ }, 'vscode.git/package/command.revertChange', GIT_CATEGORY_KEY);
export const UNSTAGE = Command.toLocalizedCommand({
id: 'git.unstage',
category: GIT_CATEGORY,
@@ -280,6 +294,7 @@ export class GitContribution implements CommandContribution, MenuContribution, T
@inject(GitPreferences) protected readonly gitPreferences: GitPreferences;
@inject(DecorationsService) protected readonly decorationsService: DecorationsService;
@inject(GitDecorationProvider) protected readonly gitDecorationProvider: GitDecorationProvider;
+ @inject(GitWatcher) protected readonly gitWatcher: GitWatcher;
onStart(): void {
this.updateStatusBar();
@@ -385,6 +400,15 @@ export class GitContribution implements CommandContribution, MenuContribution, T
commandId: GIT_COMMANDS.DISCARD_ALL.id,
when: 'scmProvider == git && scmResourceGroup == workingTree || scmProvider == git && scmResourceGroup == untrackedChanges',
});
+
+ menus.registerMenuAction(SCM_CHANGE_TITLE_MENU, {
+ commandId: GIT_COMMANDS.STAGE_CHANGE.id,
+ when: 'scmProvider == git'
+ });
+ menus.registerMenuAction(SCM_CHANGE_TITLE_MENU, {
+ commandId: GIT_COMMANDS.REVERT_CHANGE.id,
+ when: 'scmProvider == git'
+ });
}
registerCommands(registry: CommandRegistry): void {
@@ -573,6 +597,14 @@ export class GitContribution implements CommandContribution, MenuContribution, T
isEnabled: widget => this.workspaceService.opened && (!widget || widget instanceof ScmWidget) && !this.repositoryProvider.selectedRepository,
isVisible: widget => this.workspaceService.opened && (!widget || widget instanceof ScmWidget) && !this.repositoryProvider.selectedRepository
});
+ registry.registerCommand(GIT_COMMANDS.STAGE_CHANGE, {
+ execute: (widget: DirtyDiffWidget) => this.withProgress(() => this.stageChange(widget)),
+ isEnabled: widget => widget instanceof DirtyDiffWidget
+ });
+ registry.registerCommand(GIT_COMMANDS.REVERT_CHANGE, {
+ execute: (widget: DirtyDiffWidget) => this.withProgress(() => this.revertChange(widget)),
+ isEnabled: widget => widget instanceof DirtyDiffWidget
+ });
}
async amend(): Promise {
{
@@ -922,6 +954,62 @@ export class GitContribution implements CommandContribution, MenuContribution, T
}
+ async stageChange(widget: DirtyDiffWidget): Promise {
+ const scmRepository = this.repositoryProvider.selectedScmRepository;
+ if (!scmRepository) {
+ return;
+ }
+
+ const repository = scmRepository.provider.repository;
+
+ const path = Repository.relativePath(repository, widget.uri)?.toString();
+ if (!path) {
+ return;
+ }
+
+ const { currentChange } = widget;
+ if (!currentChange) {
+ return;
+ }
+
+ const dataToStage = await widget.getContentWithSelectedChanges(change => change === currentChange);
+
+ try {
+ const hash = (await this.git.exec(repository, ['hash-object', '--stdin', '-w', '--path', path], { stdin: dataToStage, stdinEncoding: 'utf8' })).stdout.trim();
+
+ let mode = (await this.git.exec(repository, ['ls-files', '--format=%(objectmode)', '--', path])).stdout.split('\n').filter(line => !!line.trim())[0];
+ if (!mode) {
+ mode = '100644'; // regular non-executable file
+ }
+
+ await this.git.exec(repository, ['update-index', '--add', '--cacheinfo', mode, hash, path]);
+
+ // enforce a notification as there would be no status update if the file had been staged already
+ this.gitWatcher.onGitChanged({ source: repository, status: await this.git.status(repository) });
+ } catch (error) {
+ this.gitErrorHandler.handleError(error);
+ }
+
+ widget.editor.cursor = LineRange.getStartPosition(currentChange.currentRange);
+ }
+
+ async revertChange(widget: DirtyDiffWidget): Promise {
+ const { currentChange } = widget;
+ if (!currentChange) {
+ return;
+ }
+
+ const editor = widget.editor.getControl();
+ editor.pushUndoStop();
+ editor.executeEdits('Revert Change', [{
+ range: editor.getModel()!.getFullModelRange(),
+ text: await widget.getContentWithSelectedChanges(change => change !== currentChange)
+ }]);
+ editor.pushUndoStop();
+
+ widget.editor.cursor = LineRange.getStartPosition(currentChange.currentRange);
+ }
+
/**
* It should be aligned with https://code.visualstudio.com/api/references/theme-color#git-colors
*/
diff --git a/packages/git/src/node/git-repository-watcher.ts b/packages/git/src/node/git-repository-watcher.ts
index 3e1bc8a43e8b1..455842b8974b3 100644
--- a/packages/git/src/node/git-repository-watcher.ts
+++ b/packages/git/src/node/git-repository-watcher.ts
@@ -96,9 +96,11 @@ export class GitRepositoryWatcher implements Disposable {
} else {
const idleTimeout = this.watching ? 5000 : /* super long */ 1000 * 60 * 60 * 24;
await new Promise(resolve => {
+ this.idle = true;
const id = setTimeout(resolve, idleTimeout);
this.interruptIdle = () => { clearTimeout(id); resolve(); };
}).then(() => {
+ this.idle = false;
this.interruptIdle = undefined;
});
}
diff --git a/packages/monaco/src/browser/monaco-diff-editor.ts b/packages/monaco/src/browser/monaco-diff-editor.ts
index b631ebb8f95da..22f6722e36230 100644
--- a/packages/monaco/src/browser/monaco-diff-editor.ts
+++ b/packages/monaco/src/browser/monaco-diff-editor.ts
@@ -22,8 +22,15 @@ import { EditorServiceOverrides, MonacoEditor, MonacoEditorServices } from './mo
import { MonacoDiffNavigatorFactory } from './monaco-diff-navigator-factory';
import { DiffUris } from '@theia/core/lib/browser/diff-uris';
import * as monaco from '@theia/monaco-editor-core';
-import { IDiffEditorConstructionOptions } from '@theia/monaco-editor-core/esm/vs/editor/browser/editorBrowser';
-import { StandaloneDiffEditor2 } from '@theia/monaco-editor-core/esm/vs/editor/standalone/browser/standaloneCodeEditor';
+import { ICodeEditor, IDiffEditorConstructionOptions } from '@theia/monaco-editor-core/esm/vs/editor/browser/editorBrowser';
+import { IActionDescriptor, IStandaloneCodeEditor, IStandaloneDiffEditor, StandaloneCodeEditor, StandaloneDiffEditor2 }
+ from '@theia/monaco-editor-core/esm/vs/editor/standalone/browser/standaloneCodeEditor';
+import { IEditorConstructionOptions } from '@theia/monaco-editor-core/esm/vs/editor/browser/config/editorConfiguration';
+import { EmbeddedDiffEditorWidget } from '@theia/monaco-editor-core/esm/vs/editor/browser/widget/embeddedCodeEditorWidget';
+import { IInstantiationService } from '@theia/monaco-editor-core/esm/vs/platform/instantiation/common/instantiation';
+import { ContextKeyValue, IContextKey } from '@theia/monaco-editor-core/esm/vs/platform/contextkey/common/contextkey';
+import { IDisposable } from '@theia/monaco-editor-core/esm/vs/base/common/lifecycle';
+import { ICommandHandler } from '@theia/monaco-editor-core/esm/vs/platform/commands/common/commands';
export namespace MonacoDiffEditor {
export interface IOptions extends MonacoEditor.ICommonOptions, IDiffEditorConstructionOptions {
@@ -31,7 +38,7 @@ export namespace MonacoDiffEditor {
}
export class MonacoDiffEditor extends MonacoEditor {
- protected _diffEditor: StandaloneDiffEditor2;
+ protected _diffEditor: IStandaloneDiffEditor;
protected _diffNavigator: DiffNavigator;
constructor(
@@ -42,9 +49,10 @@ export class MonacoDiffEditor extends MonacoEditor {
services: MonacoEditorServices,
protected readonly diffNavigatorFactory: MonacoDiffNavigatorFactory,
options?: MonacoDiffEditor.IOptions,
- override?: EditorServiceOverrides
+ override?: EditorServiceOverrides,
+ parentEditor?: MonacoEditor
) {
- super(uri, modifiedModel, node, services, options, override);
+ super(uri, modifiedModel, node, services, options, override, parentEditor);
this.documents.add(originalModel);
const original = originalModel.textEditorModel;
const modified = modifiedModel.textEditorModel;
@@ -61,13 +69,15 @@ export class MonacoDiffEditor extends MonacoEditor {
}
protected override create(options?: IDiffEditorConstructionOptions, override?: EditorServiceOverrides): Disposable {
+ options = { ...options, fixedOverflowWidgets: true };
const instantiator = this.getInstantiatorWithOverrides(override);
/**
* @monaco-uplift. Should be guaranteed to work.
* Incomparable enums prevent TypeScript from believing that public IStandaloneDiffEditor is satisfied by private StandaloneDiffEditor
*/
- this._diffEditor = instantiator
- .createInstance(StandaloneDiffEditor2, this.node, { ...options, fixedOverflowWidgets: true });
+ this._diffEditor = this.parentEditor ?
+ instantiator.createInstance(EmbeddedDiffEditor, this.node, options, {}, this.parentEditor.getControl() as unknown as ICodeEditor) :
+ instantiator.createInstance(StandaloneDiffEditor2, this.node, options);
this.editor = this._diffEditor.getModifiedEditor() as unknown as monaco.editor.IStandaloneCodeEditor;
return this._diffEditor;
}
@@ -97,4 +107,35 @@ export class MonacoDiffEditor extends MonacoEditor {
return DiffUris.encode(left.withPath(resourceUri.path), right.withPath(resourceUri.path));
}
+ override shouldDisplayDirtyDiff(): boolean {
+ return false;
+ }
+}
+
+class EmbeddedDiffEditor extends EmbeddedDiffEditorWidget implements IStandaloneDiffEditor {
+
+ protected override _createInnerEditor(instantiationService: IInstantiationService, container: HTMLElement,
+ options: Readonly): StandaloneCodeEditor {
+ return instantiationService.createInstance(StandaloneCodeEditor, container, options);
+ }
+
+ override getOriginalEditor(): IStandaloneCodeEditor {
+ return super.getOriginalEditor() as IStandaloneCodeEditor;
+ }
+
+ override getModifiedEditor(): IStandaloneCodeEditor {
+ return super.getModifiedEditor() as IStandaloneCodeEditor;
+ }
+
+ addCommand(keybinding: number, handler: ICommandHandler, context?: string): string | null {
+ return this.getModifiedEditor().addCommand(keybinding, handler, context);
+ }
+
+ createContextKey(key: string, defaultValue: T): IContextKey {
+ return this.getModifiedEditor().createContextKey(key, defaultValue);
+ }
+
+ addAction(descriptor: IActionDescriptor): IDisposable {
+ return this.getModifiedEditor().addAction(descriptor);
+ }
}
diff --git a/packages/monaco/src/browser/monaco-diff-navigator-factory.ts b/packages/monaco/src/browser/monaco-diff-navigator-factory.ts
index 639037c304800..897aaa611359d 100644
--- a/packages/monaco/src/browser/monaco-diff-navigator-factory.ts
+++ b/packages/monaco/src/browser/monaco-diff-navigator-factory.ts
@@ -16,7 +16,7 @@
import { injectable } from '@theia/core/shared/inversify';
import { DiffNavigator } from '@theia/editor/lib/browser';
-import { StandaloneDiffEditor2 } from '@theia/monaco-editor-core/esm/vs/editor/standalone/browser/standaloneCodeEditor';
+import { IDiffEditor } from '@theia/monaco-editor-core/esm/vs/editor/browser/editorBrowser';
@injectable()
export class MonacoDiffNavigatorFactory {
@@ -28,7 +28,7 @@ export class MonacoDiffNavigatorFactory {
previous: () => { },
};
- createdDiffNavigator(editor: StandaloneDiffEditor2): DiffNavigator {
+ createdDiffNavigator(editor: IDiffEditor): DiffNavigator {
return {
hasNext: () => true,
hasPrevious: () => true,
diff --git a/packages/monaco/src/browser/monaco-editor-peek-view-widget.ts b/packages/monaco/src/browser/monaco-editor-peek-view-widget.ts
new file mode 100644
index 0000000000000..4c5d90b455c34
--- /dev/null
+++ b/packages/monaco/src/browser/monaco-editor-peek-view-widget.ts
@@ -0,0 +1,233 @@
+// *****************************************************************************
+// Copyright (C) 2024 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 { Position, Range } from '@theia/core/shared/vscode-languageserver-protocol';
+import { DisposableCollection } from '@theia/core';
+import { MonacoEditor } from './monaco-editor';
+import * as monaco from '@theia/monaco-editor-core';
+import { PeekViewWidget, IPeekViewOptions, IPeekViewStyles } from '@theia/monaco-editor-core/esm/vs/editor/contrib/peekView/browser/peekView';
+import { ICodeEditor } from '@theia/monaco-editor-core/esm/vs/editor/browser/editorBrowser';
+import { ActionBar } from '@theia/monaco-editor-core/esm/vs/base/browser/ui/actionbar/actionbar';
+import { Action } from '@theia/monaco-editor-core/esm/vs/base/common/actions';
+import { StandaloneServices } from '@theia/monaco-editor-core/esm/vs/editor/standalone/browser/standaloneServices';
+import { IInstantiationService } from '@theia/monaco-editor-core/esm/vs/platform/instantiation/common/instantiation';
+import { IThemeService } from '@theia/monaco-editor-core/esm/vs/platform/theme/common/themeService';
+import { Color } from '@theia/monaco-editor-core/esm/vs/base/common/color';
+
+export { peekViewBorder, peekViewTitleBackground, peekViewTitleForeground, peekViewTitleInfoForeground }
+ from '@theia/monaco-editor-core/esm/vs/editor/contrib/peekView/browser/peekView';
+
+export namespace MonacoEditorPeekViewWidget {
+ export interface Styles {
+ frameColor?: string;
+ arrowColor?: string;
+ headerBackgroundColor?: string;
+ primaryHeadingColor?: string;
+ secondaryHeadingColor?: string;
+ }
+ export interface Options {
+ showFrame?: boolean;
+ showArrow?: boolean;
+ frameWidth?: number;
+ className?: string;
+ isAccessible?: boolean;
+ isResizeable?: boolean;
+ keepEditorSelection?: boolean;
+ allowUnlimitedHeight?: boolean;
+ ordinal?: number;
+ showInHiddenAreas?: boolean;
+ supportOnTitleClick?: boolean;
+ }
+ export interface Action {
+ readonly id: string;
+ label: string;
+ tooltip: string;
+ class: string | undefined;
+ enabled: boolean;
+ checked?: boolean;
+ run(...args: unknown[]): unknown;
+ }
+ export interface ActionOptions {
+ icon?: boolean;
+ label?: boolean;
+ keybinding?: string;
+ index?: number;
+ }
+}
+
+export class MonacoEditorPeekViewWidget {
+
+ protected readonly toDispose = new DisposableCollection();
+
+ readonly onDidClose = this.toDispose.onDispose;
+
+ private readonly themeService = StandaloneServices.get(IThemeService);
+
+ private readonly delegate;
+
+ constructor(
+ readonly editor: MonacoEditor,
+ options: MonacoEditorPeekViewWidget.Options = {},
+ protected styles: MonacoEditorPeekViewWidget.Styles = {}
+ ) {
+ const that = this;
+ this.toDispose.push(this.delegate = new class extends PeekViewWidget {
+
+ get actionBar(): ActionBar | undefined {
+ return this._actionbarWidget;
+ }
+
+ fillHead(container: HTMLElement, noCloseAction?: boolean): void {
+ super._fillHead(container, noCloseAction);
+ }
+
+ protected override _fillHead(container: HTMLElement, noCloseAction?: boolean): void {
+ that.fillHead(container, noCloseAction);
+ }
+
+ fillBody(container: HTMLElement): void {
+ // super._fillBody is an abstract method
+ }
+
+ protected override _fillBody(container: HTMLElement): void {
+ that.fillBody(container);
+ };
+
+ doLayoutHead(heightInPixel: number, widthInPixel: number): void {
+ super._doLayoutHead(heightInPixel, widthInPixel);
+ }
+
+ protected override _doLayoutHead(heightInPixel: number, widthInPixel: number): void {
+ that.doLayoutHead(heightInPixel, widthInPixel);
+ }
+
+ doLayoutBody(heightInPixel: number, widthInPixel: number): void {
+ super._doLayoutBody(heightInPixel, widthInPixel);
+ }
+
+ protected override _doLayoutBody(heightInPixel: number, widthInPixel: number): void {
+ that.doLayoutBody(heightInPixel, widthInPixel);
+ }
+
+ onWidth(widthInPixel: number): void {
+ super._onWidth(widthInPixel);
+ }
+
+ protected override _onWidth(widthInPixel: number): void {
+ that.onWidth(widthInPixel);
+ }
+
+ doRevealRange(range: monaco.Range, isLastLine: boolean): void {
+ super.revealRange(range, isLastLine);
+ }
+
+ protected override revealRange(range: monaco.Range, isLastLine: boolean): void {
+ that.doRevealRange(that.editor['m2p'].asRange(range), isLastLine);
+ }
+ }(
+ editor.getControl() as unknown as ICodeEditor,
+ Object.assign({}, options, this.convertStyles(styles)),
+ StandaloneServices.get(IInstantiationService)
+ ));
+ this.toDispose.push(this.themeService.onDidColorThemeChange(() => this.style(this.styles)));
+ }
+
+ dispose(): void {
+ this.toDispose.dispose();
+ }
+
+ create(): void {
+ this.delegate.create();
+ }
+
+ setTitle(primaryHeading: string, secondaryHeading?: string): void {
+ this.delegate.setTitle(primaryHeading, secondaryHeading);
+ }
+
+ style(styles: MonacoEditorPeekViewWidget.Styles): void {
+ this.delegate.style(this.convertStyles(this.styles = styles));
+ }
+
+ show(rangeOrPos: Range | Position, heightInLines: number): void {
+ this.delegate.show(this.convertRangeOrPosition(rangeOrPos), heightInLines);
+ }
+
+ hide(): void {
+ this.delegate.hide();
+ }
+
+ clearActions(): void {
+ this.delegate.actionBar?.clear();
+ }
+
+ addAction(id: string, label: string, cssClass: string | undefined, enabled: boolean, actionCallback: (arg: unknown) => unknown,
+ options?: MonacoEditorPeekViewWidget.ActionOptions): MonacoEditorPeekViewWidget.Action {
+ options = cssClass ? { icon: true, label: false, ...options } : { icon: false, label: true, ...options };
+ const { actionBar } = this.delegate;
+ if (!actionBar) {
+ throw new Error('Action bar has not been created.');
+ }
+ const action = new Action(id, label, cssClass, enabled, actionCallback);
+ actionBar.push(action, options);
+ return action;
+ }
+
+ protected fillHead(container: HTMLElement, noCloseAction?: boolean): void {
+ this.delegate.fillHead(container, noCloseAction);
+ }
+
+ protected fillBody(container: HTMLElement): void {
+ this.delegate.fillBody(container);
+ }
+
+ protected doLayoutHead(heightInPixel: number, widthInPixel: number): void {
+ this.delegate.doLayoutHead(heightInPixel, widthInPixel);
+ }
+
+ protected doLayoutBody(heightInPixel: number, widthInPixel: number): void {
+ this.delegate.doLayoutBody(heightInPixel, widthInPixel);
+ }
+
+ protected onWidth(widthInPixel: number): void {
+ this.delegate.onWidth(widthInPixel);
+ }
+
+ protected doRevealRange(range: Range, isLastLine: boolean): void {
+ this.delegate.doRevealRange(this.editor['p2m'].asRange(range), isLastLine);
+ }
+
+ private convertStyles(styles: MonacoEditorPeekViewWidget.Styles): IPeekViewStyles {
+ return {
+ frameColor: this.convertColor(styles.frameColor),
+ arrowColor: this.convertColor(styles.arrowColor),
+ headerBackgroundColor: this.convertColor(styles.headerBackgroundColor),
+ primaryHeadingColor: this.convertColor(styles.primaryHeadingColor),
+ secondaryHeadingColor: this.convertColor(styles.secondaryHeadingColor),
+ };
+ }
+
+ private convertColor(color?: string): Color | undefined {
+ if (color === undefined) {
+ return undefined;
+ }
+ return this.themeService.getColorTheme().getColor(color) || Color.fromHex(color);
+ }
+
+ private convertRangeOrPosition(arg: Range | Position): monaco.Range | monaco.Position {
+ const p2m = this.editor['p2m'];
+ return Range.is(arg) ? p2m.asRange(arg) : p2m.asPosition(arg);
+ }
+}
diff --git a/packages/monaco/src/browser/monaco-editor-provider.ts b/packages/monaco/src/browser/monaco-editor-provider.ts
index 3391dcdb3c6ea..75fa4dbcb049a 100644
--- a/packages/monaco/src/browser/monaco-editor-provider.ts
+++ b/packages/monaco/src/browser/monaco-editor-provider.ts
@@ -415,4 +415,41 @@ export class MonacoEditorProvider {
}
};
+ async createEmbeddedDiffEditor(parentEditor: MonacoEditor, node: HTMLElement, originalUri: URI, modifiedUri: URI = parentEditor.uri,
+ options?: MonacoDiffEditor.IOptions): Promise {
+ options = {
+ scrollBeyondLastLine: true,
+ overviewRulerLanes: 2,
+ fixedOverflowWidgets: true,
+ minimap: { enabled: false },
+ renderSideBySide: false,
+ readOnly: true,
+ renderIndicators: false,
+ diffAlgorithm: 'advanced',
+ stickyScroll: { enabled: false },
+ ...options,
+ scrollbar: {
+ verticalScrollbarSize: 14,
+ horizontal: 'auto',
+ useShadows: true,
+ verticalHasArrows: false,
+ horizontalHasArrows: false,
+ ...options?.scrollbar
+ }
+ };
+ const uri = DiffUris.encode(originalUri, modifiedUri);
+ return await this.doCreateEditor(uri, async (override, toDispose) =>
+ new MonacoDiffEditor(
+ uri,
+ node,
+ await this.getModel(originalUri, toDispose),
+ await this.getModel(modifiedUri, toDispose),
+ this.services,
+ this.diffNavigatorFactory,
+ options,
+ override,
+ parentEditor
+ )
+ ) as MonacoDiffEditor;
+ }
}
diff --git a/packages/monaco/src/browser/monaco-editor.ts b/packages/monaco/src/browser/monaco-editor.ts
index 350c6293e519f..5df77abe242ca 100644
--- a/packages/monaco/src/browser/monaco-editor.ts
+++ b/packages/monaco/src/browser/monaco-editor.ts
@@ -48,9 +48,20 @@ import { StandaloneServices } from '@theia/monaco-editor-core/esm/vs/editor/stan
import { ILanguageService } from '@theia/monaco-editor-core/esm/vs/editor/common/languages/language';
import { IInstantiationService, ServiceIdentifier } from '@theia/monaco-editor-core/esm/vs/platform/instantiation/common/instantiation';
import { ICodeEditor, IMouseTargetMargin } from '@theia/monaco-editor-core/esm/vs/editor/browser/editorBrowser';
-import { IStandaloneEditorConstructionOptions, StandaloneEditor } from '@theia/monaco-editor-core/esm/vs/editor/standalone/browser/standaloneCodeEditor';
+import { IStandaloneEditorConstructionOptions, StandaloneCodeEditor, StandaloneEditor } from '@theia/monaco-editor-core/esm/vs/editor/standalone/browser/standaloneCodeEditor';
import { ServiceCollection } from '@theia/monaco-editor-core/esm/vs/platform/instantiation/common/serviceCollection';
import { MarkdownString } from '@theia/core/lib/common/markdown-rendering';
+import { ConfigurationChangedEvent, IEditorOptions } from '@theia/monaco-editor-core/esm/vs/editor/common/config/editorOptions';
+import { ICodeEditorService } from '@theia/monaco-editor-core/esm/vs/editor/browser/services/codeEditorService';
+import { ICommandService } from '@theia/monaco-editor-core/esm/vs/platform/commands/common/commands';
+import { IContextKeyService } from '@theia/monaco-editor-core/esm/vs/platform/contextkey/common/contextkey';
+import { IKeybindingService } from '@theia/monaco-editor-core/esm/vs/platform/keybinding/common/keybinding';
+import { IThemeService } from '@theia/monaco-editor-core/esm/vs/platform/theme/common/themeService';
+import { INotificationService } from '@theia/monaco-editor-core/esm/vs/platform/notification/common/notification';
+import { IAccessibilityService } from '@theia/monaco-editor-core/esm/vs/platform/accessibility/common/accessibility';
+import { ILanguageConfigurationService } from '@theia/monaco-editor-core/esm/vs/editor/common/languages/languageConfigurationRegistry';
+import { ILanguageFeaturesService } from '@theia/monaco-editor-core/esm/vs/editor/common/services/languageFeatures';
+import * as objects from '@theia/monaco-editor-core/esm/vs/base/common/objects';
export type ServicePair = [ServiceIdentifier, T];
@@ -103,7 +114,8 @@ export class MonacoEditor extends MonacoEditorServices implements TextEditor {
readonly node: HTMLElement,
services: MonacoEditorServices,
options?: MonacoEditor.IOptions,
- override?: EditorServiceOverrides
+ override?: EditorServiceOverrides,
+ readonly parentEditor?: MonacoEditor
) {
super(services);
this.toDispose.pushAll([
@@ -153,7 +165,9 @@ export class MonacoEditor extends MonacoEditorServices implements TextEditor {
* @monaco-uplift. Should be guaranteed to work.
* Incomparable enums prevent TypeScript from believing that public IStandaloneCodeEditor is satisfied by private StandaloneCodeEditor
*/
- return this.editor = instantiator.createInstance(StandaloneEditor, this.node, combinedOptions) as unknown as monaco.editor.IStandaloneCodeEditor;
+ return this.editor = (this.parentEditor ?
+ instantiator.createInstance(EmbeddedCodeEditor, this.node, combinedOptions, this.parentEditor.getControl() as unknown as ICodeEditor) :
+ instantiator.createInstance(StandaloneEditor, this.node, combinedOptions)) as unknown as monaco.editor.IStandaloneCodeEditor;
}
protected getInstantiatorWithOverrides(override?: EditorServiceOverrides): IInstantiationService {
@@ -589,6 +603,9 @@ export class MonacoEditor extends MonacoEditorServices implements TextEditor {
return this.uri.withPath(resourceUri.path);
}
+ shouldDisplayDirtyDiff(): boolean {
+ return true;
+ }
}
export namespace MonacoEditor {
@@ -661,3 +678,52 @@ export namespace MonacoEditor {
return {};
}
}
+
+// adapted from https://github.com/microsoft/vscode/blob/0bd70d48ad8b3e2fb1922aa54f87c786ff2b4bd8/src/vs/editor/browser/widget/codeEditor/embeddedCodeEditorWidget.ts
+// This class reproduces the logic in EmbeddedCodeEditorWidget but extends StandaloneCodeEditor rather than CodeEditorWidget.
+class EmbeddedCodeEditor extends StandaloneCodeEditor {
+
+ private readonly _parentEditor: ICodeEditor;
+ private readonly _overwriteOptions: IEditorOptions;
+
+ constructor(
+ domElement: HTMLElement,
+ options: Readonly,
+ parentEditor: ICodeEditor,
+ @IInstantiationService instantiationService: IInstantiationService,
+ @ICodeEditorService codeEditorService: ICodeEditorService,
+ @ICommandService commandService: ICommandService,
+ @IContextKeyService contextKeyService: IContextKeyService,
+ @IKeybindingService keybindingService: IKeybindingService,
+ @IThemeService themeService: IThemeService,
+ @INotificationService notificationService: INotificationService,
+ @IAccessibilityService accessibilityService: IAccessibilityService,
+ @ILanguageConfigurationService languageConfigurationService: ILanguageConfigurationService,
+ @ILanguageFeaturesService languageFeaturesService: ILanguageFeaturesService,
+ ) {
+ super(domElement, { ...parentEditor.getRawOptions(), overflowWidgetsDomNode: parentEditor.getOverflowWidgetsDomNode() }, instantiationService, codeEditorService,
+ commandService, contextKeyService, keybindingService, themeService, notificationService, accessibilityService, languageConfigurationService, languageFeaturesService);
+
+ this._parentEditor = parentEditor;
+ this._overwriteOptions = options;
+
+ // Overwrite parent's options
+ super.updateOptions(this._overwriteOptions);
+
+ this._register(parentEditor.onDidChangeConfiguration((e: ConfigurationChangedEvent) => this._onParentConfigurationChanged(e)));
+ }
+
+ getParentEditor(): ICodeEditor {
+ return this._parentEditor;
+ }
+
+ private _onParentConfigurationChanged(e: ConfigurationChangedEvent): void {
+ super.updateOptions(this._parentEditor.getRawOptions());
+ super.updateOptions(this._overwriteOptions);
+ }
+
+ override updateOptions(newOptions: IEditorOptions): void {
+ objects.mixin(this._overwriteOptions, newOptions, true);
+ super.updateOptions(this._overwriteOptions);
+ }
+}
diff --git a/packages/monaco/src/browser/style/index.css b/packages/monaco/src/browser/style/index.css
index 577f853ef3d4e..7977874af744d 100644
--- a/packages/monaco/src/browser/style/index.css
+++ b/packages/monaco/src/browser/style/index.css
@@ -21,7 +21,6 @@
.monaco-editor .zone-widget {
position: absolute;
z-index: 10;
- background-color: var(--theia-editorWidget-background);
}
.monaco-editor .zone-widget .zone-widget-container {
diff --git a/packages/plugin-ext/src/main/browser/menus/plugin-menu-command-adapter.ts b/packages/plugin-ext/src/main/browser/menus/plugin-menu-command-adapter.ts
index 0f7c41b34a4a9..cc8be6f6fe2b2 100644
--- a/packages/plugin-ext/src/main/browser/menus/plugin-menu-command-adapter.ts
+++ b/packages/plugin-ext/src/main/browser/menus/plugin-menu-command-adapter.ts
@@ -21,6 +21,9 @@ import { URI as CodeUri } from '@theia/core/shared/vscode-uri';
import { TreeWidgetSelection } from '@theia/core/lib/browser/tree/tree-widget-selection';
import { ScmRepository } from '@theia/scm/lib/browser/scm-repository';
import { ScmService } from '@theia/scm/lib/browser/scm-service';
+import { DirtyDiffWidget } from '@theia/scm/lib/browser/dirty-diff/dirty-diff-widget';
+import { Change, LineRange } from '@theia/scm/lib/browser/dirty-diff/diff-computer';
+import { IChange } from '@theia/monaco-editor-core/esm/vs/editor/common/diff/legacyLinesDiffComputer';
import { TimelineItem } from '@theia/timeline/lib/common/timeline-model';
import { ScmCommandArg, TimelineCommandArg, TreeViewItemReference } from '../../../common';
import { TestItemReference, TestMessageArg } from '../../../common/test-types';
@@ -105,6 +108,7 @@ export class PluginMenuCommandAdapter implements MenuCommandAdapter {
['scm/resourceState/context', toScmArgs],
['scm/title', () => [this.toScmArg(this.scmService.selectedRepository)]],
['testing/message/context', toTestMessageArgs],
+ ['scm/change/title', (...args) => this.toScmChangeArgs(...args)],
['timeline/item/context', (...args) => this.toTimelineArgs(...args)],
['view/item/context', (...args) => this.toTreeArgs(...args)],
['view/title', noArgs],
@@ -229,6 +233,41 @@ export class PluginMenuCommandAdapter implements MenuCommandAdapter {
}
}
+ protected toScmChangeArgs(...args: any[]): any[] {
+ const arg = args[0];
+ if (arg instanceof DirtyDiffWidget) {
+ const toIChange = (change: Change): IChange => {
+ 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 { previousRange, currentRange } = change;
+ const [originalStartLineNumber, originalEndLineNumber] = convert(previousRange);
+ const [modifiedStartLineNumber, modifiedEndLineNumber] = convert(currentRange);
+ return {
+ originalStartLineNumber,
+ originalEndLineNumber,
+ modifiedStartLineNumber,
+ modifiedEndLineNumber
+ };
+ };
+ return [
+ arg.uri['codeUri'],
+ arg.changes.map(toIChange),
+ arg.currentChangeIndex
+ ];
+ }
+ return [];
+ }
+
protected toTimelineArgs(...args: any[]): any[] {
const timelineArgs: any[] = [];
const arg = args[0];
diff --git a/packages/plugin-ext/src/main/browser/menus/vscode-theia-menu-mappings.ts b/packages/plugin-ext/src/main/browser/menus/vscode-theia-menu-mappings.ts
index 0fc7f7b5925f3..6bbc91f01bfa0 100644
--- a/packages/plugin-ext/src/main/browser/menus/vscode-theia-menu-mappings.ts
+++ b/packages/plugin-ext/src/main/browser/menus/vscode-theia-menu-mappings.ts
@@ -26,6 +26,7 @@ import { DebugVariablesWidget } from '@theia/debug/lib/browser/view/debug-variab
import { EditorWidget, EDITOR_CONTEXT_MENU } from '@theia/editor/lib/browser';
import { NAVIGATOR_CONTEXT_MENU } from '@theia/navigator/lib/browser/navigator-contribution';
import { ScmTreeWidget } from '@theia/scm/lib/browser/scm-tree-widget';
+import { PLUGIN_SCM_CHANGE_TITLE_MENU } from '@theia/scm/lib/browser/dirty-diff/dirty-diff-widget';
import { TIMELINE_ITEM_CONTEXT_MENU } from '@theia/timeline/lib/browser/timeline-tree-widget';
import { COMMENT_CONTEXT, COMMENT_THREAD_CONTEXT, COMMENT_TITLE } from '../comments/comment-thread-widget';
import { VIEW_ITEM_CONTEXT_MENU } from '../view/tree-view-widget';
@@ -53,6 +54,7 @@ export const implementedVSCodeContributionPoints = [
'editor/title/run',
'editor/lineNumber/context',
'explorer/context',
+ 'scm/change/title',
'scm/resourceFolder/context',
'scm/resourceGroup/context',
'scm/resourceState/context',
@@ -84,6 +86,7 @@ export const codeToTheiaMappings = new Map([
['editor/title/run', [PLUGIN_EDITOR_TITLE_RUN_MENU]],
['editor/lineNumber/context', [EDITOR_LINENUMBER_CONTEXT_MENU]],
['explorer/context', [NAVIGATOR_CONTEXT_MENU]],
+ ['scm/change/title', [PLUGIN_SCM_CHANGE_TITLE_MENU]],
['scm/resourceFolder/context', [ScmTreeWidget.RESOURCE_FOLDER_CONTEXT_MENU]],
['scm/resourceGroup/context', [ScmTreeWidget.RESOURCE_GROUP_CONTEXT_MENU]],
['scm/resourceState/context', [ScmTreeWidget.RESOURCE_CONTEXT_MENU]],
diff --git a/packages/scm/package.json b/packages/scm/package.json
index 0971567f6e2b4..cad4914e6fe58 100644
--- a/packages/scm/package.json
+++ b/packages/scm/package.json
@@ -6,6 +6,8 @@
"@theia/core": "1.48.0",
"@theia/editor": "1.48.0",
"@theia/filesystem": "1.48.0",
+ "@theia/monaco": "1.48.0",
+ "@theia/monaco-editor-core": "1.83.101",
"@types/diff": "^3.2.2",
"diff": "^3.4.0",
"p-debounce": "^2.1.0",
diff --git a/packages/scm/src/browser/decorations/scm-decorations-service.ts b/packages/scm/src/browser/decorations/scm-decorations-service.ts
index 53dd72eb16341..c597dd91385dc 100644
--- a/packages/scm/src/browser/decorations/scm-decorations-service.ts
+++ b/packages/scm/src/browser/decorations/scm-decorations-service.ts
@@ -15,64 +15,88 @@
// *****************************************************************************
import { injectable, inject } from '@theia/core/shared/inversify';
-import { ResourceProvider } from '@theia/core';
-import { DirtyDiffDecorator } from '../dirty-diff/dirty-diff-decorator';
+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, TextEditor } from '@theia/editor/lib/browser';
+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: DiffComputer;
- private dirtyState: boolean = true;
+ private readonly diffComputer = new DiffComputer();
+
+ protected readonly onDirtyDiffUpdateEmitter = new Emitter();
+ readonly onDirtyDiffUpdate: Event = this.onDirtyDiffUpdateEmitter.event;
- constructor(@inject(DirtyDiffDecorator) protected readonly decorator: DirtyDiffDecorator,
+ 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) {
- this.diffComputer = new DiffComputer();
- this.editorManager.onCreated(async editor => this.applyEditorDecorations(editor.editor));
- this.scmService.onDidAddRepository(repository => repository.provider.onDidChange(() => {
- const editor = this.editorManager.currentEditor;
- if (editor) {
- if (this.dirtyState) {
- this.applyEditorDecorations(editor.editor);
- this.dirtyState = false;
- } else {
- /** onDidChange event might be called several times one after another, so need to prevent repeated events. */
- setTimeout(() => {
- this.dirtyState = true;
- }, 500);
- }
+ @inject(ResourceProvider) protected readonly resourceProvider: ResourceProvider
+ ) {
+ const updateTasks = new Map();
+ this.editorManager.onCreated(editorWidget => {
+ const { editor } = editorWidget;
+ if (!this.supportsDirtyDiff(editor)) {
+ return;
}
- }));
- this.scmService.onDidChangeSelectedRepository(() => {
- const editor = this.editorManager.currentEditor;
- if (editor) {
- this.applyEditorDecorations(editor.editor);
+ const toDispose = new DisposableCollection();
+ const updateTask = this.createUpdateTask(editor);
+ updateTasks.set(editorWidget, updateTask);
+ toDispose.push(editor.onDocumentContentChanged(() => updateTask()));
+ 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 {
const currentRepo = this.scmService.selectedRepository;
if (currentRepo) {
try {
- const uri = editor.uri.withScheme(currentRepo.provider.id).withQuery(`{"ref":"", "path":"${editor.uri.path.toString()}"}`);
+ // 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);
- const previousContent = await previousResource.readContents();
- const previousLines = ContentLines.fromString(previousContent);
- const currentResource = await this.resourceProvider(editor.uri);
- const currentContent = await currentResource.readContents();
- const currentLines = ContentLines.fromString(currentContent);
- const { added, removed, modified } = this.diffComputer.computeDirtyDiff(ContentLines.arrayLike(previousLines), ContentLines.arrayLike(currentLines));
- this.decorator.applyDecorations({ editor: editor, added, removed, modified });
- currentResource.dispose();
- previousResource.dispose();
+ try {
+ const previousContent = await previousResource.readContents();
+ 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 };
+ 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();
+ }
+
+ protected createUpdateTask(editor: TextEditor): { (): void; cancel(): void; } {
+ return throttle(() => this.applyEditorDecorations(editor), 500);
+ }
}
diff --git a/packages/scm/src/browser/dirty-diff/content-lines.ts b/packages/scm/src/browser/dirty-diff/content-lines.ts
index d3c0a2207ec4d..2e0b40cfbb187 100644
--- a/packages/scm/src/browser/dirty-diff/content-lines.ts
+++ b/packages/scm/src/browser/dirty-diff/content-lines.ts
@@ -14,6 +14,8 @@
// 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 {
readonly length: number,
getLineContent: (line: number) => string,
@@ -65,6 +67,13 @@ export namespace ContentLines {
};
}
+ 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;
}
diff --git a/packages/scm/src/browser/dirty-diff/diff-computer.spec.ts b/packages/scm/src/browser/dirty-diff/diff-computer.spec.ts
index 34fa24e51a27c..62ebf18cd51f5 100644
--- a/packages/scm/src/browser/dirty-diff/diff-computer.spec.ts
+++ b/packages/scm/src/browser/dirty-diff/diff-computer.spec.ts
@@ -42,9 +42,12 @@ describe('dirty-diff-computer', () => {
],
);
expect(dirtyDiff).to.be.deep.equal({
- added: [],
- modified: [],
- removed: [0],
+ changes: [
+ {
+ previousRange: { start: 1, end: 2 },
+ currentRange: { start: 1, end: 1 },
+ },
+ ],
});
});
@@ -56,22 +59,29 @@ describe('dirty-diff-computer', () => {
sequenceOfN(2),
);
expect(dirtyDiff).to.be.deep.equal({
- modified: [],
- removed: [1],
- added: [],
+ changes: [
+ {
+ previousRange: { start: 2, end: 2 + lines },
+ currentRange: { start: 2, end: 2 },
+ },
+ ],
});
});
});
it('remove all lines', () => {
+ const numberOfLines = 10;
const dirtyDiff = computeDirtyDiff(
- sequenceOfN(10, () => 'TO-BE-REMOVED'),
+ sequenceOfN(numberOfLines, () => 'TO-BE-REMOVED'),
['']
);
expect(dirtyDiff).to.be.deep.equal({
- added: [],
- modified: [],
- removed: [0],
+ changes: [
+ {
+ previousRange: { start: 0, end: numberOfLines },
+ currentRange: { start: 0, end: 0 },
+ },
+ ],
});
});
@@ -83,9 +93,12 @@ describe('dirty-diff-computer', () => {
sequenceOfN(2),
);
expect(dirtyDiff).to.be.deep.equal({
- modified: [],
- removed: [0],
- added: [],
+ changes: [
+ {
+ previousRange: { start: 0, end: lines },
+ currentRange: { start: 0, end: 0 },
+ },
+ ],
});
});
});
@@ -96,9 +109,12 @@ describe('dirty-diff-computer', () => {
const modified = insertIntoArray(previous, 2, ...sequenceOfN(lines, () => 'ADDED LINE'));
const dirtyDiff = computeDirtyDiff(previous, modified);
expect(dirtyDiff).to.be.deep.equal({
- modified: [],
- removed: [],
- added: [{ start: 2, end: 2 + lines - 1 }],
+ changes: [
+ {
+ previousRange: { start: 2, end: 2 },
+ currentRange: { start: 2, end: 2 + lines },
+ },
+ ],
});
});
});
@@ -111,9 +127,12 @@ describe('dirty-diff-computer', () => {
.concat(sequenceOfN(2))
);
expect(dirtyDiff).to.be.deep.equal({
- modified: [],
- removed: [],
- added: [{ start: 0, end: lines - 1 }],
+ changes: [
+ {
+ previousRange: { start: 0, end: 0 },
+ currentRange: { start: 0, end: lines },
+ },
+ ],
});
});
});
@@ -125,9 +144,12 @@ describe('dirty-diff-computer', () => {
sequenceOfN(numberOfLines, () => 'ADDED LINE')
);
expect(dirtyDiff).to.be.deep.equal({
- modified: [],
- removed: [],
- added: [{ start: 0, end: numberOfLines - 1 }],
+ changes: [
+ {
+ previousRange: { start: 0, end: 0 },
+ currentRange: { start: 0, end: numberOfLines },
+ },
+ ],
});
});
@@ -145,9 +167,12 @@ describe('dirty-diff-computer', () => {
]
);
expect(dirtyDiff).to.be.deep.equal({
- modified: [],
- removed: [],
- added: [{ start: 1, end: 2 }],
+ changes: [
+ {
+ previousRange: { start: 1, end: 1 },
+ currentRange: { start: 1, end: 3 },
+ },
+ ],
});
});
@@ -162,9 +187,12 @@ describe('dirty-diff-computer', () => {
]
);
expect(dirtyDiff).to.be.deep.equal({
- modified: [],
- removed: [],
- added: [{ start: 1, end: 1 }],
+ changes: [
+ {
+ previousRange: { start: 1, end: 1 },
+ currentRange: { start: 1, end: 2 },
+ },
+ ],
});
});
@@ -176,9 +204,12 @@ describe('dirty-diff-computer', () => {
.concat(new Array(lines).map(() => ''))
);
expect(dirtyDiff).to.be.deep.equal({
- modified: [],
- removed: [],
- added: [{ start: 2, end: 1 + lines }],
+ changes: [
+ {
+ previousRange: { start: 2, end: 2 },
+ currentRange: { start: 2, end: 2 + lines },
+ },
+ ],
});
});
});
@@ -200,9 +231,12 @@ describe('dirty-diff-computer', () => {
]
);
expect(dirtyDiff).to.be.deep.equal({
- modified: [],
- removed: [],
- added: [{ start: 1, end: 5 }],
+ changes: [
+ {
+ previousRange: { start: 1, end: 1 },
+ currentRange: { start: 1, end: 6 },
+ },
+ ],
});
});
@@ -213,9 +247,12 @@ describe('dirty-diff-computer', () => {
['0'].concat(sequenceOfN(lines, () => 'ADDED LINE'))
);
expect(dirtyDiff).to.be.deep.equal({
- modified: [],
- removed: [],
- added: [{ start: 1, end: lines }],
+ changes: [
+ {
+ previousRange: { start: 1, end: 1 },
+ currentRange: { start: 1, end: lines + 1 },
+ },
+ ],
});
});
});
@@ -234,9 +271,12 @@ describe('dirty-diff-computer', () => {
]
);
expect(dirtyDiff).to.be.deep.equal({
- removed: [],
- added: [],
- modified: [{ start: 1, end: 1 }],
+ changes: [
+ {
+ previousRange: { start: 1, end: 2 },
+ currentRange: { start: 1, end: 2 },
+ },
+ ],
});
});
@@ -247,9 +287,12 @@ describe('dirty-diff-computer', () => {
sequenceOfN(numberOfLines, () => 'MODIFIED')
);
expect(dirtyDiff).to.be.deep.equal({
- removed: [],
- added: [],
- modified: [{ start: 0, end: numberOfLines - 1 }],
+ changes: [
+ {
+ previousRange: { start: 0, end: numberOfLines },
+ currentRange: { start: 0, end: numberOfLines },
+ },
+ ],
});
});
@@ -268,9 +311,12 @@ describe('dirty-diff-computer', () => {
]
);
expect(dirtyDiff).to.be.deep.equal({
- removed: [],
- added: [],
- modified: [{ start: 1, end: 2 }],
+ changes: [
+ {
+ previousRange: { start: 1, end: 4 },
+ currentRange: { start: 1, end: 3 },
+ },
+ ],
});
});
@@ -305,9 +351,20 @@ describe('dirty-diff-computer', () => {
]
);
expect(dirtyDiff).to.be.deep.equal({
- removed: [3],
- added: [{ start: 10, end: 11 }],
- modified: [{ start: 0, end: 0 }],
+ 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 },
+ },
+ ],
});
});
@@ -340,9 +397,20 @@ describe('dirty-diff-computer', () => {
''
]);
expect(dirtyDiff).to.be.deep.equal({
- removed: [11],
- added: [{ start: 5, end: 5 }, { start: 9, end: 9 }],
- modified: [],
+ 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 },
+ },
+ ],
});
});
diff --git a/packages/scm/src/browser/dirty-diff/diff-computer.ts b/packages/scm/src/browser/dirty-diff/diff-computer.ts
index 5662cb993a54e..38b95e591798b 100644
--- a/packages/scm/src/browser/dirty-diff/diff-computer.ts
+++ b/packages/scm/src/browser/dirty-diff/diff-computer.ts
@@ -16,6 +16,7 @@
import * as jsdiff from 'diff';
import { ContentLinesArrayLike } from './content-lines';
+import { Position, Range, uinteger } from '@theia/core/shared/vscode-languageserver-protocol';
export class DiffComputer {
@@ -25,52 +26,52 @@ export class DiffComputer {
}
computeDirtyDiff(previous: ContentLinesArrayLike, current: ContentLinesArrayLike): DirtyDiff {
- const added: LineRange[] = [];
- const removed: number[] = [];
- const modified: LineRange[] = [];
- const changes = this.computeDiff(previous, current);
- let lastLine = -1;
- for (let i = 0; i < changes.length; i++) {
- const change = changes[i];
- const next = changes[i + 1];
+ 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
- const start = lastLine + 1;
- const end = lastLine + change.count!;
- added.push({ start, end });
- lastLine = end;
+ 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 === changes.length - 2;
+ 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
- removed.push(0);
+ changes.push({ previousRange: toLineRange(change), currentRange: LineRange.createEmptyLineRange(0) });
+ previousRevisionLine += change.count!;
} else if (isFirstChange && isPrevEmptyLine) {
// special case: adding at the beginning
- const start = 0;
- const end = next.count! - 1;
- added.push({ start, end });
- lastLine = end;
+ changes.push({ previousRange: LineRange.createEmptyLineRange(0), currentRange: toLineRange(next) });
+ currentRevisionLine += next.count!;
} else if (isLastChange && isNextEmptyLine) {
- removed.push(lastLine + 1 /* = empty line */);
+ changes.push({ previousRange: toLineRange(change), currentRange: LineRange.createEmptyLineRange(currentRevisionLine + 2) });
+ previousRevisionLine += change.count!;
} else {
// default case is a modification
- const start = lastLine + 1;
- const end = lastLine + next.count!;
- modified.push({ start, end });
- lastLine = end;
+ 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)) {
- removed.push(Math.max(0, lastLine));
+ // case: removal
+ changes.push({ previousRange: toLineRange(change), currentRange: LineRange.createEmptyLineRange(currentRevisionLine + 1) });
+ previousRevisionLine += change.count!;
} else {
- lastLine += change.count!;
+ // case: unchanged region
+ currentRevisionLine += change.count!;
+ previousRevisionLine += change.count!;
}
}
- return { added, removed, modified };
+ return { changes };
}
}
@@ -101,6 +102,11 @@ function diffArrays(oldArr: ContentLinesArrayLike, newArr: ContentLinesArrayLike
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;
@@ -109,21 +115,63 @@ export interface DiffResult {
}
export interface DirtyDiff {
- /**
- * Lines added by comparison to previous revision.
- */
- readonly added: LineRange[];
- /**
- * Lines, after which lines were removed by comparison to previous revision.
- */
- readonly removed: number[];
- /**
- * Lines modified by comparison to previous revision.
- */
- readonly modified: LineRange[];
+ 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 {
- start: number;
- end: number;
+ 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;
+ }
}
diff --git a/packages/scm/src/browser/dirty-diff/dirty-diff-decorator.ts b/packages/scm/src/browser/dirty-diff/dirty-diff-decorator.ts
index a6ff31676d959..cf17bee272f27 100644
--- a/packages/scm/src/browser/dirty-diff/dirty-diff-decorator.ts
+++ b/packages/scm/src/browser/dirty-diff/dirty-diff-decorator.ts
@@ -16,8 +16,6 @@
import { injectable } from '@theia/core/shared/inversify';
import {
- Range,
- Position,
EditorDecoration,
EditorDecorationOptions,
OverviewRulerLane,
@@ -25,7 +23,8 @@ import {
TextEditor,
MinimapPosition
} from '@theia/editor/lib/browser';
-import { DirtyDiff, LineRange } from './diff-computer';
+import { DirtyDiff, LineRange, Change } from './diff-computer';
+import { URI } from '@theia/core';
export enum DirtyDiffDecorationType {
AddedLine = 'dirty-diff-added-line',
@@ -84,24 +83,32 @@ const ModifiedLineDecoration = {
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 modifications = update.modified.map(range => this.toDeltaDecoration(range, ModifiedLineDecoration));
- const additions = update.added.map(range => this.toDeltaDecoration(range, AddedLineDecoration));
- const removals = update.removed.map(line => this.toDeltaDecoration(line, RemovedLineDecoration));
- const decorations = [...modifications, ...additions, ...removals];
+ const decorations = update.changes.map(change => this.toDeltaDecoration(change));
this.setDecorations(update.editor, decorations);
}
- protected toDeltaDecoration(from: LineRange | number, options: EditorDecorationOptions): EditorDecoration {
- const [start, end] = (typeof from === 'number') ? [from, from] : [from.start, from.end];
- const range = Range.create(Position.create(start, 0), Position.create(end, 0));
+ protected toDeltaDecoration(change: Change): EditorDecoration {
+ const range = LineRange.toRange(change.currentRange);
+ const options = getEditorDecorationOptions(change);
return { range, options };
}
}
diff --git a/packages/scm/src/browser/dirty-diff/dirty-diff-module.ts b/packages/scm/src/browser/dirty-diff/dirty-diff-module.ts
index 1982324afa773..3b2117f0f58f2 100644
--- a/packages/scm/src/browser/dirty-diff/dirty-diff-module.ts
+++ b/packages/scm/src/browser/dirty-diff/dirty-diff-module.ts
@@ -16,9 +16,18 @@
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);
+ });
}
diff --git a/packages/scm/src/browser/dirty-diff/dirty-diff-navigator.ts b/packages/scm/src/browser/dirty-diff/dirty-diff-navigator.ts
new file mode 100644
index 0000000000000..d765797337617
--- /dev/null
+++ b/packages/scm/src/browser/dirty-diff/dirty-diff-navigator.ts
@@ -0,0 +1,288 @@
+// *****************************************************************************
+// 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();
+
+ @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 = 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.closeWidget();
+ this.dirtyDiff = dirtyDiff;
+ }
+ }
+
+ 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, 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);
+ }
+}
diff --git a/packages/scm/src/browser/dirty-diff/dirty-diff-widget.ts b/packages/scm/src/browser/dirty-diff/dirty-diff-widget.ts
new file mode 100644
index 0000000000000..4fe5d5b5e58e9
--- /dev/null
+++ b/packages/scm/src/browser/dirty-diff/dirty-diff-widget.ts
@@ -0,0 +1,364 @@
+// *****************************************************************************
+// 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 { ActionMenuNode, Disposable, Emitter, Event, MenuCommandExecutor, 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;
+ readonly changes: readonly Change[];
+}
+
+export const DirtyDiffWidgetFactory = Symbol('DirtyDiffWidgetFactory');
+export type DirtyDiffWidgetFactory = (props: DirtyDiffWidgetProps) => DirtyDiffWidget;
+
+@injectable()
+export class DirtyDiffWidget implements Disposable {
+
+ private readonly onDidCloseEmitter = new Emitter();
+ readonly onDidClose: Event = this.onDidCloseEmitter.event;
+ protected index: number = -1;
+ private peekView?: DirtyDiffPeekView;
+ private diffEditorPromise?: Promise;
+
+ constructor(
+ @inject(DirtyDiffWidgetProps) protected readonly props: DirtyDiffWidgetProps,
+ @inject(MonacoEditorProvider) readonly editorProvider: MonacoEditorProvider,
+ @inject(ContextKeyService) readonly contextKeyService: ContextKeyService,
+ @inject(MenuModelRegistry) readonly menuModelRegistry: MenuModelRegistry,
+ @inject(MenuCommandExecutor) readonly menuCommandExecutor: MenuCommandExecutor
+ ) { }
+
+ @postConstruct()
+ create(): void {
+ this.peekView = new DirtyDiffPeekView(this);
+ this.peekView.onDidClose(e => this.onDidCloseEmitter.fire(e));
+ this.diffEditorPromise = this.peekView.create();
+ }
+
+ get editor(): MonacoEditor {
+ return this.props.editor;
+ }
+
+ get uri(): URI {
+ return this.editor.uri;
+ }
+
+ get previousRevisionUri(): URI {
+ return this.props.previousRevisionUri;
+ }
+
+ get changes(): readonly Change[] {
+ return this.props.changes;
+ }
+
+ get currentChange(): Change | undefined {
+ return this.changes[this.index];
+ }
+
+ get currentChangeIndex(): number {
+ return this.index;
+ }
+
+ showChange(index: number): void {
+ 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 {
+ 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.peekView!.setTitle(this.computePrimaryHeading(), this.computeSecondaryHeading());
+ 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 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 checkCreated(): void {
+ if (!this.peekView) {
+ throw new Error('create() method needs to be called first.');
+ }
+ }
+}
+
+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 diffEditorPromise?: Promise;
+ 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 {
+ try {
+ super.create();
+ const diffEditor = await this.diffEditorPromise!;
+ return new Promise(resolve => {
+ // setTimeout is needed here 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
+ 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, menuCommandExecutor } = 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);
+ for (const item of menu.children) {
+ if (item instanceof ActionMenuNode) {
+ const { command, id, label, icon, when } = item;
+ if (icon && menuCommandExecutor.isVisible(menuPath, command, this.widget) && (!when || contextKeyService.match(when))) {
+ this.addAction(id, label, icon, menuCommandExecutor.isEnabled(menuPath, command, this.widget), () => {
+ menuCommandExecutor.executeCommand(menuPath, command, this.widget);
+ });
+ }
+ }
+ }
+ }
+ });
+ 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 fillHead(container: HTMLElement): void {
+ super.fillHead(container, true);
+ }
+
+ protected override fillBody(container: HTMLElement): void {
+ this.diffEditorPromise = this.widget.editorProvider.createEmbeddedDiffEditor(this.editor, container, this.widget.previousRevisionUri).then(diffEditor => {
+ this.toDispose.push(diffEditor);
+ return diffEditor;
+ });
+ }
+
+ 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.diffEditorPromise?.then(({ diffEditor }) => diffEditor.layout({ height, width }));
+ }
+
+ protected override doRevealRange(range: Range): void {
+ this.editor.revealPosition(Position.create(range.end.line, 0), { vertical: 'centerIfOutsideViewport' });
+ }
+}
diff --git a/packages/scm/src/browser/scm-colors.ts b/packages/scm/src/browser/scm-colors.ts
new file mode 100644
index 0000000000000..853d218e679d8
--- /dev/null
+++ b/packages/scm/src/browser/scm-colors.ts
@@ -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
+// *****************************************************************************
+
+export namespace ScmColors {
+ export const editorGutterModifiedBackground = 'editorGutter.modifiedBackground';
+ export const editorGutterAddedBackground = 'editorGutter.addedBackground';
+ export const editorGutterDeletedBackground = 'editorGutter.deletedBackground';
+}
diff --git a/packages/scm/src/browser/scm-contribution.ts b/packages/scm/src/browser/scm-contribution.ts
index 032079923632a..f896363f313de 100644
--- a/packages/scm/src/browser/scm-contribution.ts
+++ b/packages/scm/src/browser/scm-contribution.ts
@@ -29,7 +29,7 @@ import {
CssStyleCollector
} from '@theia/core/lib/browser';
import { TabBarToolbarContribution, TabBarToolbarRegistry, TabBarToolbarItem } from '@theia/core/lib/browser/shell/tab-bar-toolbar';
-import { CommandRegistry, Command, Disposable, DisposableCollection, CommandService } from '@theia/core/lib/common';
+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';
@@ -38,10 +38,13 @@ 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 } from '@theia/editor/lib/browser';
+import { DirtyDiffNavigator } from './dirty-diff/dirty-diff-navigator';
export const SCM_WIDGET_FACTORY_ID = ScmWidget.ID;
export const SCM_VIEW_CONTAINER_ID = 'scm-view-container';
@@ -51,6 +54,10 @@ export const SCM_VIEW_CONTAINER_TITLE_OPTIONS: ViewContainerTitleOptions = {
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',
@@ -85,13 +92,36 @@ export namespace SCM_COMMANDS {
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'
+ });
+ export const GOTO_PREVIOUS_CHANGE = Command.toDefaultLocalizedCommand({
+ id: 'workbench.action.editor.previousChange',
+ category: 'Source Control',
+ label: 'Go to Previous Change'
+ });
+ 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 namespace ScmColors {
- export const editorGutterModifiedBackground = 'editorGutter.modifiedBackground';
- export const editorGutterAddedBackground = 'editorGutter.addedBackground';
- export const editorGutterDeletedBackground = 'editorGutter.deletedBackground';
-}
+export { ScmColors };
@injectable()
export class ScmContribution extends AbstractViewContribution implements
@@ -108,6 +138,7 @@ export class ScmContribution extends AbstractViewContribution impleme
@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;
@@ -144,6 +175,8 @@ export class ScmContribution extends AbstractViewContribution impleme
this.updateContextKeys();
this.shell.onDidChangeCurrentWidget(() => this.updateContextKeys());
+
+ this.scmDecorationsService.onDirtyDiffUpdate(update => this.dirtyDiffNavigator.handleDirtyDiffUpdate(update));
}
protected updateContextKeys(): void {
@@ -160,6 +193,39 @@ export class ScmContribution extends AbstractViewContribution impleme
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: () => this.dirtyDiffNavigator.gotoNextChange()
+ });
+ commandRegistry.registerCommand(SCM_COMMANDS.GOTO_PREVIOUS_CHANGE, {
+ execute: () => this.dirtyDiffNavigator.gotoPreviousChange()
+ });
+ 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 {
@@ -219,6 +285,31 @@ export class ScmContribution extends AbstractViewContribution impleme
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 {
diff --git a/packages/scm/src/browser/scm-tree-widget.tsx b/packages/scm/src/browser/scm-tree-widget.tsx
index 105956cf85d4b..3dd0b346c9bc9 100644
--- a/packages/scm/src/browser/scm-tree-widget.tsx
+++ b/packages/scm/src/browser/scm-tree-widget.tsx
@@ -605,7 +605,7 @@ export class ScmResourceComponent extends ScmElement
protected readonly contextMenuPath = ScmTreeWidget.RESOURCE_CONTEXT_MENU;
protected get contextMenuArgs(): any[] {
- if (!this.props.model.selectedNodes.some(node => ScmFileChangeNode.is(node) && node.sourceUri === this.props.sourceUri)) {
+ 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 {
diff --git a/packages/scm/src/browser/style/dirty-diff-decorator.css b/packages/scm/src/browser/style/dirty-diff-decorator.css
index 1b630c3ba07ac..f5f8beeb8c08e 100644
--- a/packages/scm/src/browser/style/dirty-diff-decorator.css
+++ b/packages/scm/src/browser/style/dirty-diff-decorator.css
@@ -19,6 +19,7 @@
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 {
@@ -41,7 +42,7 @@
position: absolute;
content: '';
height: 100%;
- width: 9px;
+ width: 6px;
left: -6px;
}
diff --git a/packages/scm/tsconfig.json b/packages/scm/tsconfig.json
index 41c8ab00ce84c..8f53c0fe2dd53 100644
--- a/packages/scm/tsconfig.json
+++ b/packages/scm/tsconfig.json
@@ -17,6 +17,9 @@
},
{
"path": "../filesystem"
+ },
+ {
+ "path": "../monaco"
}
]
}