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" } ] }