diff --git a/extensions/git/package.json b/extensions/git/package.json index f000da7f3f2..fc6ed3a1627 100644 --- a/extensions/git/package.json +++ b/extensions/git/package.json @@ -15,6 +15,7 @@ "contribEditorContentMenu", "contribMergeEditorMenus", "contribMultiDiffEditorMenus", + "contribDiffEditorGutterToolBarMenus", "contribSourceControlHistoryItemGroupMenu", "contribSourceControlHistoryItemMenu", "contribSourceControlInputBoxMenu", @@ -188,6 +189,18 @@ "category": "Git", "enablement": "!operationInProgress" }, + { + "command": "git.diff.stageHunk", + "title": "%command.stageBlock%", + "category": "Git", + "icon": "$(plus)" + }, + { + "command": "git.diff.stageSelection", + "title": "%command.stageSelection%", + "category": "Git", + "icon": "$(plus)" + }, { "command": "git.revertSelectedRanges", "title": "%command.revertSelectedRanges%", @@ -2047,6 +2060,20 @@ "when": "scmProvider == git && scmResourceGroup == index" } ], + "diffEditor/gutter/hunk": [ + { + "command": "git.diff.stageHunk", + "group": "primary@10", + "when": "diffEditorOriginalUri =~ /^git\\:.*%22ref%22%3A%22~%22%7D$/" + } + ], + "diffEditor/gutter/selection": [ + { + "command": "git.diff.stageSelection", + "group": "primary@10", + "when": "diffEditorOriginalUri =~ /^git\\:.*%22ref%22%3A%22~%22%7D$/" + } + ], "scm/change/title": [ { "command": "git.stageChange", diff --git a/extensions/git/package.nls.json b/extensions/git/package.nls.json index 6eba0f44d8f..3d0411c53dc 100644 --- a/extensions/git/package.nls.json +++ b/extensions/git/package.nls.json @@ -23,6 +23,8 @@ "command.stageSelectedRanges": "Stage Selected Ranges", "command.revertSelectedRanges": "Revert Selected Ranges", "command.stageChange": "Stage Change", + "command.stageSelection": "Stage Selection", + "command.stageBlock": "Stage Block", "command.revertChange": "Revert Change", "command.unstage": "Unstage Changes", "command.unstageAll": "Unstage All Changes", diff --git a/extensions/git/src/commands.ts b/extensions/git/src/commands.ts index 01a8434c448..d986c0a6f8f 100644 --- a/extensions/git/src/commands.ts +++ b/extensions/git/src/commands.ts @@ -12,7 +12,7 @@ import { ForcePushMode, GitErrorCodes, Ref, RefType, Status, CommitOptions, Remo import { Git, Stash } from './git'; import { Model } from './model'; import { Repository, Resource, ResourceGroupType } from './repository'; -import { applyLineChanges, getModifiedRange, intersectDiffWithRange, invertLineChange, toLineRanges } from './staging'; +import { DiffEditorSelectionHunkToolbarContext, applyLineChanges, getModifiedRange, intersectDiffWithRange, invertLineChange, toLineRanges } from './staging'; import { fromGitUri, toGitUri, isGitUri, toMergeUris, toMultiFileDiffEditorUris } from './uri'; import { dispose, grep, isDefined, isDescendant, pathEquals, relativePath } from './util'; import { GitTimelineItem } from './timelineProvider'; @@ -1511,6 +1511,30 @@ export class CommandCenter { textEditor.selections = [new Selection(firstStagedLine, 0, firstStagedLine, 0)]; } + @command('git.diff.stageHunk') + async diffStageHunk(changes: DiffEditorSelectionHunkToolbarContext): Promise { + this.diffStageHunkOrSelection(changes); + } + + @command('git.diff.stageSelection') + async diffStageSelection(changes: DiffEditorSelectionHunkToolbarContext): Promise { + this.diffStageHunkOrSelection(changes); + } + + async diffStageHunkOrSelection(changes: DiffEditorSelectionHunkToolbarContext): Promise { + const textEditor = window.activeTextEditor; + if (!textEditor) { + return; + } + const modifiedDocument = textEditor.document; + const modifiedUri = modifiedDocument.uri; + if (modifiedUri.scheme !== 'file') { + return; + } + const result = changes.originalWithModifiedChanges; + await this.runByRepository(modifiedUri, async (repository, resource) => await repository.stage(resource, result)); + } + @command('git.stageSelectedRanges', { diff: true }) async stageSelectedChanges(changes: LineChange[]): Promise { const textEditor = window.activeTextEditor; diff --git a/extensions/git/src/staging.ts b/extensions/git/src/staging.ts index 2813bfb1ee9..bbc7055cfd4 100644 --- a/extensions/git/src/staging.ts +++ b/extensions/git/src/staging.ts @@ -142,3 +142,11 @@ export function invertLineChange(diff: LineChange): LineChange { originalEndLineNumber: diff.modifiedEndLineNumber }; } + +export interface DiffEditorSelectionHunkToolbarContext { + mapping: unknown; + /** + * The original text with the selected modified changes applied. + */ + originalWithModifiedChanges: string; +} diff --git a/src/vs/editor/browser/widget/diffEditor/commands.ts b/src/vs/editor/browser/widget/diffEditor/commands.ts index 5297e77c086..0d29830b56e 100644 --- a/src/vs/editor/browser/widget/diffEditor/commands.ts +++ b/src/vs/editor/browser/widget/diffEditor/commands.ts @@ -18,6 +18,7 @@ import { IConfigurationService } from 'vs/platform/configuration/common/configur import { ContextKeyExpr } from 'vs/platform/contextkey/common/contextkey'; import { KeybindingWeight } from 'vs/platform/keybinding/common/keybindingsRegistry'; import './registrations.contribution'; +import { DiffEditorSelectionHunkToolbarContext } from 'vs/editor/browser/widget/diffEditor/features/gutterFeature'; export class ToggleCollapseUnchangedRegions extends Action2 { constructor() { @@ -165,6 +166,26 @@ export class ShowAllUnchangedRegions extends EditorAction2 { } } +export class RevertHunkOrSelection extends EditorAction2 { + constructor() { + super({ + id: 'diffEditor.revert', + title: localize2('revert', 'Revert'), + precondition: ContextKeyExpr.has('isInDiffEditor'), + f1: false, + category: diffEditorCategory, + }); + } + + runEditorCommand(accessor: ServicesAccessor, editor: ICodeEditor, arg: DiffEditorSelectionHunkToolbarContext): unknown { + const diffEditor = findFocusedDiffEditor(accessor); + if (diffEditor instanceof DiffEditorWidget) { + diffEditor.revertRangeMappings(arg.mapping.innerChanges ?? []); + } + return undefined; + } +} + const accessibleDiffViewerCategory: ILocalizedString = localize2('accessibleDiffViewer', "Accessible Diff Viewer"); export class AccessibleDiffViewerNext extends Action2 { diff --git a/src/vs/editor/browser/widget/diffEditor/components/diffEditorEditors.ts b/src/vs/editor/browser/widget/diffEditor/components/diffEditorEditors.ts index 1818ead661f..2abc8e74bad 100644 --- a/src/vs/editor/browser/widget/diffEditor/components/diffEditorEditors.ts +++ b/src/vs/editor/browser/widget/diffEditor/components/diffEditorEditors.ts @@ -3,64 +3,56 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Emitter } from 'vs/base/common/event'; +import { Emitter, Event } from 'vs/base/common/event'; import { Disposable } from 'vs/base/common/lifecycle'; -import { IObservable, IReader, autorunHandleChanges, derivedOpts, observableFromEvent } from 'vs/base/common/observable'; +import { IReader, autorunHandleChanges, derived, derivedOpts, observableFromEvent } from 'vs/base/common/observable'; import { IEditorConstructionOptions } from 'vs/editor/browser/config/editorConfiguration'; import { IDiffEditorConstructionOptions } from 'vs/editor/browser/editorBrowser'; import { CodeEditorWidget, ICodeEditorWidgetOptions } from 'vs/editor/browser/widget/codeEditor/codeEditorWidget'; +import { IDiffCodeEditorWidgetOptions } from 'vs/editor/browser/widget/diffEditor/diffEditorWidget'; import { OverviewRulerFeature } from 'vs/editor/browser/widget/diffEditor/features/overviewRulerFeature'; import { EditorOptions, IEditorOptions } from 'vs/editor/common/config/editorOptions'; +import { Position } from 'vs/editor/common/core/position'; import { IContentSizeChangedEvent } from 'vs/editor/common/editorCommon'; import { localize } from 'vs/nls'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; import { DiffEditorOptions } from '../diffEditorOptions'; -import { ITextModel } from 'vs/editor/common/model'; -import { IDiffCodeEditorWidgetOptions } from 'vs/editor/browser/widget/diffEditor/diffEditorWidget'; -import { Selection } from 'vs/editor/common/core/selection'; -import { Position } from 'vs/editor/common/core/position'; export class DiffEditorEditors extends Disposable { - public readonly modified: CodeEditorWidget; - public readonly original: CodeEditorWidget; + public readonly original = this._register(this._createLeftHandSideEditor(this._options.editorOptions.get(), this._argCodeEditorWidgetOptions.originalEditor || {})); + public readonly modified = this._register(this._createRightHandSideEditor(this._options.editorOptions.get(), this._argCodeEditorWidgetOptions.modifiedEditor || {})); private readonly _onDidContentSizeChange = this._register(new Emitter()); public get onDidContentSizeChange() { return this._onDidContentSizeChange.event; } - public readonly modifiedScrollTop: IObservable; - public readonly modifiedScrollHeight: IObservable; + public readonly modifiedScrollTop = observableFromEvent(this.modified.onDidScrollChange, () => /** @description modified.getScrollTop */ this.modified.getScrollTop()); + public readonly modifiedScrollHeight = observableFromEvent(this.modified.onDidScrollChange, () => /** @description modified.getScrollHeight */ this.modified.getScrollHeight()); - public readonly modifiedModel: IObservable; + public readonly modifiedModel = observableFromEvent(this.modified.onDidChangeModel, () => /** @description modified.model */ this.modified.getModel()); - public readonly modifiedSelections: IObservable; - public readonly modifiedCursor: IObservable; + public readonly modifiedSelections = observableFromEvent(this.modified.onDidChangeCursorSelection, () => this.modified.getSelections() ?? []); + public readonly modifiedCursor = derivedOpts({ owner: this, equalityComparer: Position.equals }, reader => this.modifiedSelections.read(reader)[0]?.getPosition() ?? new Position(1, 1)); - public readonly originalCursor: IObservable; + public readonly originalCursor = observableFromEvent(this.original.onDidChangeCursorPosition, () => this.original.getPosition() ?? new Position(1, 1)); + + public readonly isOriginalFocused = observableFromEvent(Event.any(this.original.onDidFocusEditorWidget, this.original.onDidBlurEditorWidget), () => this.original.hasWidgetFocus()); + public readonly isModifiedFocused = observableFromEvent(Event.any(this.modified.onDidFocusEditorWidget, this.modified.onDidBlurEditorWidget), () => this.modified.hasWidgetFocus()); + + public readonly isFocused = derived(this, reader => this.isOriginalFocused.read(reader) || this.isModifiedFocused.read(reader)); constructor( private readonly originalEditorElement: HTMLElement, private readonly modifiedEditorElement: HTMLElement, private readonly _options: DiffEditorOptions, - codeEditorWidgetOptions: IDiffCodeEditorWidgetOptions, + private _argCodeEditorWidgetOptions: IDiffCodeEditorWidgetOptions, private readonly _createInnerEditor: (instantiationService: IInstantiationService, container: HTMLElement, options: Readonly, editorWidgetOptions: ICodeEditorWidgetOptions) => CodeEditorWidget, @IInstantiationService private readonly _instantiationService: IInstantiationService, - @IKeybindingService private readonly _keybindingService: IKeybindingService, + @IKeybindingService private readonly _keybindingService: IKeybindingService ) { super(); - this.original = this._register(this._createLeftHandSideEditor(_options.editorOptions.get(), codeEditorWidgetOptions.originalEditor || {})); - this.modified = this._register(this._createRightHandSideEditor(_options.editorOptions.get(), codeEditorWidgetOptions.modifiedEditor || {})); - - this.modifiedModel = observableFromEvent(this.modified.onDidChangeModel, () => /** @description modified.model */ this.modified.getModel()); - - this.modifiedScrollTop = observableFromEvent(this.modified.onDidScrollChange, () => /** @description modified.getScrollTop */ this.modified.getScrollTop()); - this.modifiedScrollHeight = observableFromEvent(this.modified.onDidScrollChange, () => /** @description modified.getScrollHeight */ this.modified.getScrollHeight()); - - this.modifiedSelections = observableFromEvent(this.modified.onDidChangeCursorSelection, () => this.modified.getSelections() ?? []); - this.modifiedCursor = derivedOpts({ owner: this, equalityComparer: Position.equals }, reader => this.modifiedSelections.read(reader)[0]?.getPosition() ?? new Position(1, 1)); - - this.originalCursor = observableFromEvent(this.original.onDidChangeCursorPosition, () => this.original.getPosition() ?? new Position(1, 1)); + this._argCodeEditorWidgetOptions = null as any; this._register(autorunHandleChanges({ createEmptyChangeSummary: () => ({} as IDiffEditorConstructionOptions), diff --git a/src/vs/editor/browser/widget/diffEditor/components/diffEditorViewZones/diffEditorViewZones.ts b/src/vs/editor/browser/widget/diffEditor/components/diffEditorViewZones/diffEditorViewZones.ts index d6bcac91567..23a75bac47d 100644 --- a/src/vs/editor/browser/widget/diffEditor/components/diffEditorViewZones/diffEditorViewZones.ts +++ b/src/vs/editor/browser/widget/diffEditor/components/diffEditorViewZones/diffEditorViewZones.ts @@ -312,7 +312,7 @@ export class DiffEditorViewZones extends Disposable { } let marginDomNode: HTMLElement | undefined = undefined; - if (a.diff && a.diff.modified.isEmpty && this._options.shouldRenderRevertArrows.read(reader)) { + if (a.diff && a.diff.modified.isEmpty && this._options.shouldRenderOldRevertArrows.read(reader)) { marginDomNode = createViewZoneMarginArrow(); } diff --git a/src/vs/editor/browser/widget/diffEditor/diffEditor.contribution.ts b/src/vs/editor/browser/widget/diffEditor/diffEditor.contribution.ts index bb8ab9ccca7..75017bce4c3 100644 --- a/src/vs/editor/browser/widget/diffEditor/diffEditor.contribution.ts +++ b/src/vs/editor/browser/widget/diffEditor/diffEditor.contribution.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Codicon } from 'vs/base/common/codicons'; -import { AccessibleDiffViewerNext, AccessibleDiffViewerPrev, CollapseAllUnchangedRegions, ExitCompareMove, ShowAllUnchangedRegions, SwitchSide, ToggleCollapseUnchangedRegions, ToggleShowMovedCodeBlocks, ToggleUseInlineViewWhenSpaceIsLimited } from 'vs/editor/browser/widget/diffEditor/commands'; +import { AccessibleDiffViewerNext, AccessibleDiffViewerPrev, CollapseAllUnchangedRegions, ExitCompareMove, RevertHunkOrSelection, ShowAllUnchangedRegions, SwitchSide, ToggleCollapseUnchangedRegions, ToggleShowMovedCodeBlocks, ToggleUseInlineViewWhenSpaceIsLimited } from 'vs/editor/browser/widget/diffEditor/commands'; import { EditorContextKeys } from 'vs/editor/common/editorContextKeys'; import { localize } from 'vs/nls'; import { MenuId, MenuRegistry, registerAction2 } from 'vs/platform/actions/common/actions'; @@ -44,6 +44,35 @@ MenuRegistry.appendMenuItem(MenuId.EditorTitle, { when: ContextKeyExpr.has('isInDiffEditor'), }); +registerAction2(RevertHunkOrSelection); + +for (const ctx of [ + { icon: Codicon.arrowRight, key: EditorContextKeys.diffEditorInlineMode.toNegated() }, + { icon: Codicon.discard, key: EditorContextKeys.diffEditorInlineMode } +]) { + MenuRegistry.appendMenuItem(MenuId.DiffEditorHunkToolbar, { + command: { + id: new RevertHunkOrSelection().desc.id, + title: localize('revertHunk', "Revert Block"), + icon: ctx.icon, + }, + when: ContextKeyExpr.and(EditorContextKeys.diffEditorModifiedWritable, ctx.key), + order: 5, + group: 'primary', + }); + + MenuRegistry.appendMenuItem(MenuId.DiffEditorSelectionToolbar, { + command: { + id: new RevertHunkOrSelection().desc.id, + title: localize('revertSelection', "Revert Selection"), + icon: ctx.icon, + }, + when: ContextKeyExpr.and(EditorContextKeys.diffEditorModifiedWritable, ctx.key), + order: 5, + group: 'primary', + }); + +} registerAction2(SwitchSide); registerAction2(ExitCompareMove); diff --git a/src/vs/editor/browser/widget/diffEditor/diffEditorOptions.ts b/src/vs/editor/browser/widget/diffEditor/diffEditorOptions.ts index 1db9c84ce24..4056521fc97 100644 --- a/src/vs/editor/browser/widget/diffEditor/diffEditorOptions.ts +++ b/src/vs/editor/browser/widget/diffEditor/diffEditorOptions.ts @@ -32,12 +32,15 @@ export class DiffEditorOptions { ); public readonly readOnly = derived(this, reader => this._options.read(reader).readOnly); - public readonly shouldRenderRevertArrows = derived(this, reader => { + public readonly shouldRenderOldRevertArrows = derived(this, reader => { if (!this._options.read(reader).renderMarginRevertIcon) { return false; } if (!this.renderSideBySide.read(reader)) { return false; } if (this.readOnly.read(reader)) { return false; } + if (this.shouldRenderGutterMenu.read(reader)) { return false; } return true; }); + + public readonly shouldRenderGutterMenu = derived(this, reader => this._options.read(reader).renderGutterMenu); public readonly renderIndicators = derived(this, reader => this._options.read(reader).renderIndicators); public readonly enableSplitViewResizing = derived(this, reader => this._options.read(reader).enableSplitViewResizing); public readonly splitViewDefaultRatio = derived(this, reader => this._options.read(reader).splitViewDefaultRatio); @@ -99,5 +102,6 @@ function validateDiffEditorOptions(options: Readonly, defaul onlyShowAccessibleDiffViewer: validateBooleanOption(options.onlyShowAccessibleDiffViewer, defaults.onlyShowAccessibleDiffViewer), renderSideBySideInlineBreakpoint: clampedInt(options.renderSideBySideInlineBreakpoint, defaults.renderSideBySideInlineBreakpoint, 0, Constants.MAX_SAFE_SMALL_INTEGER), useInlineViewWhenSpaceIsLimited: validateBooleanOption(options.useInlineViewWhenSpaceIsLimited, defaults.useInlineViewWhenSpaceIsLimited), + renderGutterMenu: validateBooleanOption(options.renderGutterMenu, defaults.renderGutterMenu), }; } diff --git a/src/vs/editor/browser/widget/diffEditor/diffEditorWidget.ts b/src/vs/editor/browser/widget/diffEditor/diffEditorWidget.ts index 417d75045a3..e69946f7ea3 100644 --- a/src/vs/editor/browser/widget/diffEditor/diffEditorWidget.ts +++ b/src/vs/editor/browser/widget/diffEditor/diffEditorWidget.ts @@ -45,6 +45,7 @@ import { DiffEditorEditors } from './components/diffEditorEditors'; import { DelegatingEditor } from './delegatingEditorImpl'; import { DiffEditorOptions } from './diffEditorOptions'; import { DiffEditorViewModel, DiffMapping, DiffState } from './diffEditorViewModel'; +import { DiffEditorGutter } from 'vs/editor/browser/widget/diffEditor/features/gutterFeature'; export interface IDiffCodeEditorWidgetOptions { originalEditor?: ICodeEditorWidgetOptions; @@ -56,8 +57,8 @@ export class DiffEditorWidget extends DelegatingEditor implements IDiffEditor { private readonly elements = h('div.monaco-diff-editor.side-by-side', { style: { position: 'relative', height: '100%' } }, [ h('div.noModificationsOverlay@overlay', { style: { position: 'absolute', height: '100%', visibility: 'hidden', } }, [$('span', {}, 'No Changes')]), - h('div.editor.original@original', { style: { position: 'absolute', height: '100%' } }), - h('div.editor.modified@modified', { style: { position: 'absolute', height: '100%' } }), + h('div.editor.original@original', { style: { position: 'absolute', height: '100%', zIndex: '1', } }), + h('div.editor.modified@modified', { style: { position: 'absolute', height: '100%', zIndex: '1', } }), h('div.accessibleDiffViewer@accessibleDiffViewer', { style: { position: 'absolute', height: '100%' } }), ]); private readonly _diffModel = observableValue(this, undefined); @@ -72,6 +73,9 @@ export class DiffEditorWidget extends DelegatingEditor implements IDiffEditor { ); private readonly _rootSizeObserver: ObservableElementSizeObserver; + /** + * Is undefined if and only if side-by-side + */ private readonly _sash: IObservable; private readonly _boundarySashes = observableValue(this, undefined); @@ -88,6 +92,8 @@ export class DiffEditorWidget extends DelegatingEditor implements IDiffEditor { private readonly _overviewRulerPart: IObservable; private readonly _movedBlocksLinesPart = observableValue(this, undefined); + private readonly _gutter: IObservable; + public get collapseUnchangedRegions() { return this._options.hideUnchangedRegions.get(); } constructor( @@ -126,6 +132,9 @@ export class DiffEditorWidget extends DelegatingEditor implements IDiffEditor { this._register(bindContextKey(EditorContextKeys.diffEditorRenderSideBySideInlineBreakpointReached, this._contextKeyService, reader => this._options.couldShowInlineViewBecauseOfSize.read(reader) )); + this._register(bindContextKey(EditorContextKeys.diffEditorInlineMode, this._contextKeyService, + reader => !this._options.renderSideBySide.read(reader) + )); this._register(bindContextKey(EditorContextKeys.hasChanges, this._contextKeyService, reader => (this._diffModel.read(reader)?.diff.read(reader)?.mappings.length ?? 0) > 0 @@ -140,6 +149,19 @@ export class DiffEditorWidget extends DelegatingEditor implements IDiffEditor { (i, c, o, o2) => this._createInnerEditor(i, c, o, o2), )); + this._register(bindContextKey(EditorContextKeys.diffEditorOriginalWritable, this._contextKeyService, + reader => this._options.originalEditable.read(reader) + )); + this._register(bindContextKey(EditorContextKeys.diffEditorModifiedWritable, this._contextKeyService, + reader => !this._options.readOnly.read(reader) + )); + this._register(bindContextKey(EditorContextKeys.diffEditorOriginalUri, this._contextKeyService, + reader => this._diffModel.read(reader)?.model.original.uri.toString() ?? '' + )); + this._register(bindContextKey(EditorContextKeys.diffEditorModifiedUri, this._contextKeyService, + reader => this._diffModel.read(reader)?.model.modified.uri.toString() ?? '' + )); + this._overviewRulerPart = derivedDisposable(this, reader => !this._options.renderOverviewRuler.read(reader) ? undefined @@ -245,6 +267,17 @@ export class DiffEditorWidget extends DelegatingEditor implements IDiffEditor { codeEditorService.addDiffEditor(this); + this._gutter = derivedDisposable(this, reader => { + return this._options.shouldRenderGutterMenu.read(reader) + ? this._instantiationService.createInstance( + readHotReloadableExport(DiffEditorGutter, reader), + this.elements.root, + this._diffModel, + this._editors + ) + : undefined; + }); + this._register(recomputeInitiallyAndOnChange(this._layoutInfo)); derivedDisposable(this, reader => /** @description MovedBlocksLinesPart */ @@ -290,7 +323,9 @@ export class DiffEditorWidget extends DelegatingEditor implements IDiffEditor { } })); - this._register(new RevertButtonsFeature(this._editors, this._diffModel, this._options, this)); + this._register(autorunWithStore((reader, store) => { + store.add(new (readHotReloadableExport(RevertButtonsFeature, reader))(this._editors, this._diffModel, this._options, this)); + })); } public getViewWidth(): number { @@ -307,23 +342,49 @@ export class DiffEditorWidget extends DelegatingEditor implements IDiffEditor { } private readonly _layoutInfo = derived(this, reader => { - const width = this._rootSizeObserver.width.read(reader); - const height = this._rootSizeObserver.height.read(reader); - const sashLeft = this._sash.read(reader)?.sashLeft.read(reader); + const fullWidth = this._rootSizeObserver.width.read(reader); + const fullHeight = this._rootSizeObserver.height.read(reader); - const originalWidth = sashLeft ?? Math.max(5, this._editors.original.getLayoutInfo().decorationsLeft); - const modifiedWidth = width - originalWidth - (this._overviewRulerPart.read(reader)?.width ?? 0); + const sash = this._sash.read(reader); - const movedBlocksLinesWidth = this._movedBlocksLinesPart.read(reader)?.width.read(reader) ?? 0; - const originalWidthWithoutMovedBlockLines = originalWidth - movedBlocksLinesWidth; - this.elements.original.style.width = originalWidthWithoutMovedBlockLines + 'px'; - this.elements.original.style.left = '0px'; + const gutter = this._gutter.read(reader); + const gutterWidth = gutter?.width.read(reader) ?? 0; + const overviewRulerPartWidth = this._overviewRulerPart.read(reader)?.width ?? 0; + + let originalLeft: number, originalWidth: number, modifiedLeft: number, modifiedWidth: number, gutterLeft: number; + + const sideBySide = !!sash; + if (sideBySide) { + const sashLeft = sash.sashLeft.read(reader); + const movedBlocksLinesWidth = this._movedBlocksLinesPart.read(reader)?.width.read(reader) ?? 0; + + originalLeft = 0; + originalWidth = sashLeft - gutterWidth - movedBlocksLinesWidth; + + gutterLeft = sashLeft - gutterWidth; + + modifiedLeft = sashLeft; + modifiedWidth = fullWidth - modifiedLeft - overviewRulerPartWidth; + } else { + gutterLeft = 0; + + originalLeft = gutterWidth; + originalWidth = Math.max(5, this._editors.original.getLayoutInfo().decorationsLeft); + + modifiedLeft = gutterWidth + originalWidth; + modifiedWidth = fullWidth - modifiedLeft - overviewRulerPartWidth; + } + + this.elements.original.style.left = originalLeft + 'px'; + this.elements.original.style.width = originalWidth + 'px'; + this._editors.original.layout({ width: originalWidth, height: fullHeight }, true); + + gutter?.layout(gutterLeft); + + this.elements.modified.style.left = modifiedLeft + 'px'; this.elements.modified.style.width = modifiedWidth + 'px'; - this.elements.modified.style.left = originalWidth + 'px'; - - this._editors.original.layout({ width: originalWidthWithoutMovedBlockLines, height }, true); - this._editors.modified.layout({ width: modifiedWidth, height }, true); + this._editors.modified.layout({ width: modifiedWidth, height: fullHeight }, true); return { modifiedEditor: this._editors.modified.getLayoutInfo(), diff --git a/src/vs/editor/browser/widget/diffEditor/features/gutterFeature.ts b/src/vs/editor/browser/widget/diffEditor/features/gutterFeature.ts new file mode 100644 index 00000000000..900184578b5 --- /dev/null +++ b/src/vs/editor/browser/widget/diffEditor/features/gutterFeature.ts @@ -0,0 +1,276 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { EventType, addDisposableListener, h } from 'vs/base/browser/dom'; +import { IMouseWheelEvent } from 'vs/base/browser/mouseEvent'; +import { ActionsOrientation } from 'vs/base/browser/ui/actionbar/actionbar'; +import { HoverPosition } from 'vs/base/browser/ui/hover/hoverWidget'; +import { Disposable } from 'vs/base/common/lifecycle'; +import { IObservable, autorun, autorunWithStore, derived, observableFromEvent, observableValue } from 'vs/base/common/observable'; +import { DiffEditorEditors } from 'vs/editor/browser/widget/diffEditor/components/diffEditorEditors'; +import { DiffEditorViewModel } from 'vs/editor/browser/widget/diffEditor/diffEditorViewModel'; +import { appendRemoveOnDispose, applyStyle } from 'vs/editor/browser/widget/diffEditor/utils'; +import { EditorGutter, IGutterItemInfo, IGutterItemView } from 'vs/editor/browser/widget/diffEditor/utils/editorGutter'; +import { ActionRunnerWithContext } from 'vs/editor/browser/widget/multiDiffEditor/utils'; +import { EditorOption } from 'vs/editor/common/config/editorOptions'; +import { LineRange, LineRangeSet } from 'vs/editor/common/core/lineRange'; +import { OffsetRange } from 'vs/editor/common/core/offsetRange'; +import { Range } from 'vs/editor/common/core/range'; +import { SingleTextEdit, TextEdit } from 'vs/editor/common/core/textEdit'; +import { DetailedLineRangeMapping } from 'vs/editor/common/diff/rangeMapping'; +import { TextModelText } from 'vs/editor/common/model/textModelText'; +import { HiddenItemStrategy, MenuWorkbenchToolBar } from 'vs/platform/actions/browser/toolbar'; +import { IMenuService, MenuId } from 'vs/platform/actions/common/actions'; +import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; +import { WorkbenchHoverDelegate } from 'vs/platform/hover/browser/hover'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; + +const emptyArr: never[] = []; +const width = 35; + +export class DiffEditorGutter extends Disposable { + private readonly _menu = this._register(this._menuService.createMenu(MenuId.DiffEditorHunkToolbar, this._contextKeyService)); + private readonly _actions = observableFromEvent(this._menu.onDidChange, () => this._menu.getActions()); + private readonly _hasActions = this._actions.map(a => a.length > 0); + + public readonly width = derived(this, reader => this._hasActions.read(reader) ? width : 0); + + private readonly elements = h('div.gutter@gutter', { style: { position: 'absolute', height: '100%', width: width + 'px', zIndex: '0' } }, []); + + constructor( + diffEditorRoot: HTMLDivElement, + private readonly _diffModel: IObservable, + private readonly _editors: DiffEditorEditors, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + @IContextKeyService private readonly _contextKeyService: IContextKeyService, + @IMenuService private readonly _menuService: IMenuService, + ) { + super(); + + this._register(appendRemoveOnDispose(diffEditorRoot, this.elements.root)); + + this._register(addDisposableListener(this.elements.root, 'click', () => { + this._editors.modified.focus(); + })); + + this._register(applyStyle(this.elements.root, { display: this._hasActions.map(a => a ? 'block' : 'none') })); + + this._register(new EditorGutter(this._editors.modified, this.elements.root, { + getIntersectingGutterItems: (range, reader) => { + const model = this._diffModel.read(reader); + if (!model) { + return []; + } + const diffs = model.diff.read(reader); + if (!diffs) { return []; } + + const selection = this._selectedDiffs.read(reader); + if (selection.length > 0) { + const m = DetailedLineRangeMapping.fromRangeMappings(selection.flatMap(s => s.rangeMappings)); + return [new DiffGutterItem(m, true, MenuId.DiffEditorSelectionToolbar, undefined)]; + } + + const currentDiff = this._currentDiff.read(reader); + + return diffs.mappings.map(m => new DiffGutterItem( + m.lineRangeMapping, + m.lineRangeMapping === currentDiff?.lineRangeMapping, + MenuId.DiffEditorHunkToolbar, + undefined, + )); + }, + createView: (item, target) => { + return this._instantiationService.createInstance(DiffToolBar, item, target, this); + }, + })); + + this._register(addDisposableListener(this.elements.gutter, EventType.MOUSE_WHEEL, (e: IMouseWheelEvent) => { + if (!this._editors.modified.getOption(EditorOption.scrollbar).handleMouseWheel) { + this._editors.modified.delegateScrollFromMouseWheelEvent(e); + } + }, { passive: false })); + } + + public computeStagedValue(mapping: DetailedLineRangeMapping): string { + const c = mapping.innerChanges ?? []; + const edit = new TextEdit(c.map(c => new SingleTextEdit(c.originalRange, this._editors.modifiedModel.get()!.getValueInRange(c.modifiedRange)))); + const value = edit.apply(new TextModelText(this._editors.original.getModel()!)); + return value; + } + + private readonly _currentDiff = derived(this, (reader) => { + const model = this._diffModel.read(reader); + if (!model) { + return undefined; + } + const mappings = model.diff.read(reader)?.mappings; + + const cursorPosition = this._editors.modifiedCursor.read(reader); + if (!cursorPosition) { return undefined; } + + return mappings?.find(m => m.lineRangeMapping.modified.contains(cursorPosition.lineNumber)); + }); + + private readonly _selectedDiffs = derived(this, (reader) => { + /** @description selectedDiffs */ + const model = this._diffModel.read(reader); + const diff = model?.diff.read(reader); + // Return `emptyArr` because it is a constant. [] is always a new array and would trigger a change. + if (!diff) { return emptyArr; } + + const selections = this._editors.modifiedSelections.read(reader); + if (selections.every(s => s.isEmpty())) { return emptyArr; } + + const selectedLineNumbers = new LineRangeSet(selections.map(s => LineRange.fromRangeInclusive(s))); + + const selectedMappings = diff.mappings.filter(m => + m.lineRangeMapping.innerChanges && selectedLineNumbers.intersects(m.lineRangeMapping.modified) + ); + const result = selectedMappings.map(mapping => ({ + mapping, + rangeMappings: mapping.lineRangeMapping.innerChanges!.filter( + c => selections.some(s => Range.areIntersecting(c.modifiedRange, s)) + ) + })); + if (result.length === 0 || result.every(r => r.rangeMappings.length === 0)) { return emptyArr; } + return result; + }); + + layout(left: number) { + this.elements.gutter.style.left = left + 'px'; + } +} + +class DiffGutterItem implements IGutterItemInfo { + constructor( + public readonly mapping: DetailedLineRangeMapping, + public readonly showAlways: boolean, + public readonly menuId: MenuId, + public readonly rangeOverride: LineRange | undefined, + ) { + } + get id(): string { return this.mapping.modified.toString(); } + get range(): LineRange { return this.rangeOverride ?? this.mapping.modified; } +} + + +class DiffToolBar extends Disposable implements IGutterItemView { + private readonly _elements = h('div.gutterItem', { style: { height: '20px', width: '34px' } }, [ + h('div.background@background', {}, []), + h('div.buttons@buttons', {}, []), + ]); + + private readonly _showAlways = this._item.map(this, item => item.showAlways); + private readonly _menuId = this._item.map(this, item => item.menuId); + + private readonly _isSmall = observableValue(this, false); + + constructor( + private readonly _item: IObservable, + target: HTMLElement, + gutter: DiffEditorGutter, + @IInstantiationService instantiationService: IInstantiationService + ) { + super(); + + //const r = new ObservableElementSizeObserver + + const hoverDelegate = this._register(instantiationService.createInstance( + WorkbenchHoverDelegate, + 'element', + true, + { position: { hoverPosition: HoverPosition.RIGHT } } + )); + + this._register(appendRemoveOnDispose(target, this._elements.root)); + + this._register(autorun(reader => { + /** @description update showAlways */ + const showAlways = this._showAlways.read(reader); + this._elements.root.classList.toggle('noTransition', true); + this._elements.root.classList.toggle('showAlways', showAlways); + setTimeout(() => { + this._elements.root.classList.toggle('noTransition', false); + }, 0); + })); + + + this._register(autorunWithStore((reader, store) => { + this._elements.buttons.replaceChildren(); + const i = store.add(instantiationService.createInstance(MenuWorkbenchToolBar, this._elements.buttons, this._menuId.read(reader), { + orientation: ActionsOrientation.VERTICAL, + hoverDelegate, + toolbarOptions: { + primaryGroup: g => g.startsWith('primary'), + }, + overflowBehavior: { maxItems: this._isSmall.read(reader) ? 1 : 3 }, + hiddenItemStrategy: HiddenItemStrategy.Ignore, + actionRunner: new ActionRunnerWithContext(() => { + const mapping = this._item.get().mapping; + return { + mapping, + originalWithModifiedChanges: gutter.computeStagedValue(mapping), + } satisfies DiffEditorSelectionHunkToolbarContext; + }), + menuOptions: { + shouldForwardArgs: true, + }, + })); + store.add(i.onDidChangeMenuItems(() => { + if (this._lastItemRange) { + this.layout(this._lastItemRange, this._lastViewRange!); + } + })); + })); + } + + private _lastItemRange: OffsetRange | undefined = undefined; + private _lastViewRange: OffsetRange | undefined = undefined; + + layout(itemRange: OffsetRange, viewRange: OffsetRange): void { + this._lastItemRange = itemRange; + this._lastViewRange = viewRange; + + let itemHeight = this._elements.buttons.clientHeight; + this._isSmall.set(this._item.get().mapping.original.startLineNumber === 1 && itemRange.length < 30, undefined); + // Item might have changed + itemHeight = this._elements.buttons.clientHeight; + + this._elements.root.style.top = itemRange.start + 'px'; + this._elements.root.style.height = itemRange.length + 'px'; + + const middleHeight = itemRange.length / 2 - itemHeight / 2; + + const margin = itemHeight; + + let effectiveCheckboxTop = itemRange.start + middleHeight; + + const preferredViewPortRange = OffsetRange.tryCreate( + margin, + viewRange.endExclusive - margin - itemHeight + ); + + const preferredParentRange = OffsetRange.tryCreate( + itemRange.start + margin, + itemRange.endExclusive - itemHeight - margin + ); + + if (preferredParentRange && preferredViewPortRange && preferredParentRange.start < preferredParentRange.endExclusive) { + effectiveCheckboxTop = preferredViewPortRange!.clip(effectiveCheckboxTop); + effectiveCheckboxTop = preferredParentRange!.clip(effectiveCheckboxTop); + } + + this._elements.buttons.style.top = `${effectiveCheckboxTop - itemRange.start}px`; + } +} + +export interface DiffEditorSelectionHunkToolbarContext { + mapping: DetailedLineRangeMapping; + + /** + * The original text with the selected modified changes applied. + */ + originalWithModifiedChanges: string; +} diff --git a/src/vs/editor/browser/widget/diffEditor/features/revertButtonsFeature.ts b/src/vs/editor/browser/widget/diffEditor/features/revertButtonsFeature.ts index 532786f2da1..b2a7d382320 100644 --- a/src/vs/editor/browser/widget/diffEditor/features/revertButtonsFeature.ts +++ b/src/vs/editor/browser/widget/diffEditor/features/revertButtonsFeature.ts @@ -31,7 +31,7 @@ export class RevertButtonsFeature extends Disposable { super(); this._register(autorunWithStore((reader, store) => { - if (!this._options.shouldRenderRevertArrows.read(reader)) { return; } + if (!this._options.shouldRenderOldRevertArrows.read(reader)) { return; } const model = this._diffModel.read(reader); const diff = model?.diff.read(reader); if (!model || !diff) { return; } diff --git a/src/vs/editor/browser/widget/diffEditor/style.css b/src/vs/editor/browser/widget/diffEditor/style.css index 032ff0f19d7..4be99158351 100644 --- a/src/vs/editor/browser/widget/diffEditor/style.css +++ b/src/vs/editor/browser/widget/diffEditor/style.css @@ -294,6 +294,11 @@ border-left: 1px solid var(--vscode-diffEditor-border); } +.monaco-diff-editor.side-by-side .editor.original { + box-shadow: 6px 0 5px -5px var(--vscode-scrollbar-shadow); + border-right: 1px solid var(--vscode-diffEditor-border); +} + .monaco-diff-editor .diffViewport { background: var(--vscode-scrollbarSlider-background); } @@ -316,3 +321,76 @@ ); background-size: 8px 8px; } + +.monaco-diff-editor .gutter { + position: relative; + overflow: hidden; + flex-shrink: 0; + flex-grow: 0; + + .gutterItem { + opacity: 0; + transition: opacity 0.7s; + + &.showAlways { + opacity: 1; + transition: none; + } + + &.noTransition { + transition: none; + } + } + + &:hover .gutterItem { + opacity: 1; + transition: opacity 0.1s ease-in-out; + } + + .gutterItem { + .background { + position: absolute; + height: 100%; + left: 50%; + width: 1px; + + border-left: 2px rgb(214, 215, 229) solid; + } + + .buttons { + position: absolute; + /*height: 100%;*/ + width: 100%; + + display: flex; + justify-content: center; + align-items: center; + + .monaco-toolbar { + height: fit-content; + .monaco-action-bar { + line-height: 1; + + .actions-container { + width: fit-content; + border-radius: 4px; + border: 1px rgba(214, 215, 229, 0.644) solid; + background: white; + + .action-item { + &:hover { + background: var(--vscode-toolbar-hoverBackground); + } + + .action-label { + padding: 0.5px 1px; + } + } + } + } + } + + + } + } +} diff --git a/src/vs/editor/browser/widget/diffEditor/utils/editorGutter.ts b/src/vs/editor/browser/widget/diffEditor/utils/editorGutter.ts new file mode 100644 index 00000000000..8459f2a1c66 --- /dev/null +++ b/src/vs/editor/browser/widget/diffEditor/utils/editorGutter.ts @@ -0,0 +1,164 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { h, reset } from 'vs/base/browser/dom'; +import { Disposable, IDisposable, toDisposable } from 'vs/base/common/lifecycle'; +import { autorun, IObservable, IReader, ISettableObservable, observableFromEvent, observableSignal, observableSignalFromEvent, observableValue, transaction } from 'vs/base/common/observable'; +import { CodeEditorWidget } from 'vs/editor/browser/widget/codeEditor/codeEditorWidget'; +import { LineRange } from 'vs/editor/common/core/lineRange'; +import { OffsetRange } from 'vs/editor/common/core/offsetRange'; + +export class EditorGutter extends Disposable { + private readonly scrollTop = observableFromEvent( + this._editor.onDidScrollChange, + (e) => /** @description editor.onDidScrollChange */ this._editor.getScrollTop() + ); + private readonly isScrollTopZero = this.scrollTop.map((scrollTop) => /** @description isScrollTopZero */ scrollTop === 0); + private readonly modelAttached = observableFromEvent( + this._editor.onDidChangeModel, + (e) => /** @description editor.onDidChangeModel */ this._editor.hasModel() + ); + + private readonly editorOnDidChangeViewZones = observableSignalFromEvent('onDidChangeViewZones', this._editor.onDidChangeViewZones); + private readonly editorOnDidContentSizeChange = observableSignalFromEvent('onDidContentSizeChange', this._editor.onDidContentSizeChange); + private readonly domNodeSizeChanged = observableSignal('domNodeSizeChanged'); + + constructor( + private readonly _editor: CodeEditorWidget, + private readonly _domNode: HTMLElement, + private readonly itemProvider: IGutterItemProvider + ) { + super(); + this._domNode.className = 'gutter monaco-editor'; + const scrollDecoration = this._domNode.appendChild( + h('div.scroll-decoration', { role: 'presentation', ariaHidden: 'true', style: { width: '100%' } }) + .root + ); + + const o = new ResizeObserver(() => { + transaction(tx => { + /** @description ResizeObserver: size changed */ + this.domNodeSizeChanged.trigger(tx); + }); + }); + o.observe(this._domNode); + this._register(toDisposable(() => o.disconnect())); + + this._register(autorun(reader => { + /** @description update scroll decoration */ + scrollDecoration.className = this.isScrollTopZero.read(reader) ? '' : 'scroll-decoration'; + })); + + this._register(autorun(reader => /** @description EditorGutter.Render */ this.render(reader))); + } + + override dispose(): void { + super.dispose(); + + reset(this._domNode); + } + + private readonly views = new Map(); + + private render(reader: IReader): void { + if (!this.modelAttached.read(reader)) { + return; + } + + this.domNodeSizeChanged.read(reader); + this.editorOnDidChangeViewZones.read(reader); + this.editorOnDidContentSizeChange.read(reader); + + const scrollTop = this.scrollTop.read(reader); + + const visibleRanges = this._editor.getVisibleRanges(); + const unusedIds = new Set(this.views.keys()); + + const viewRange = OffsetRange.ofStartAndLength(0, this._domNode.clientHeight); + + if (!viewRange.isEmpty) { + for (const visibleRange of visibleRanges) { + const visibleRange2 = new LineRange( + visibleRange.startLineNumber, + visibleRange.endLineNumber + 1 + ); + + const gutterItems = this.itemProvider.getIntersectingGutterItems( + visibleRange2, + reader + ); + + transaction(tx => { + /** EditorGutter.render */ + + for (const gutterItem of gutterItems) { + if (!gutterItem.range.intersect(visibleRange2)) { + continue; + } + + unusedIds.delete(gutterItem.id); + let view = this.views.get(gutterItem.id); + if (!view) { + const viewDomNode = document.createElement('div'); + this._domNode.appendChild(viewDomNode); + const gutterItemObs = observableValue('item', gutterItem); + const itemView = this.itemProvider.createView( + gutterItemObs, + viewDomNode + ); + view = new ManagedGutterItemView(gutterItemObs, itemView, viewDomNode); + this.views.set(gutterItem.id, view); + } else { + view.item.set(gutterItem, tx); + } + + const top = + gutterItem.range.startLineNumber <= this._editor.getModel()!.getLineCount() + ? this._editor.getTopForLineNumber(gutterItem.range.startLineNumber, true) - scrollTop + : this._editor.getBottomForLineNumber(gutterItem.range.startLineNumber - 1, false) - scrollTop; + const bottom = this._editor.getBottomForLineNumber(gutterItem.range.endLineNumberExclusive - 1, true) - scrollTop; + + const height = bottom - top; + + view.domNode.style.top = `${top}px`; + view.domNode.style.height = `${height}px`; + + view.gutterItemView.layout(OffsetRange.ofStartAndLength(top, height), viewRange); + } + }); + } + } + + for (const id of unusedIds) { + const view = this.views.get(id)!; + view.gutterItemView.dispose(); + this._domNode.removeChild(view.domNode); + this.views.delete(id); + } + } +} + +class ManagedGutterItemView { + constructor( + public readonly item: ISettableObservable, + public readonly gutterItemView: IGutterItemView, + public readonly domNode: HTMLDivElement, + ) { } +} + +export interface IGutterItemProvider { + getIntersectingGutterItems(range: LineRange, reader: IReader): TItem[]; + + createView(item: IObservable, target: HTMLElement): IGutterItemView; +} + +export interface IGutterItemInfo { + id: string; + range: LineRange; +} + +export interface IGutterItemView extends IDisposable { + layout(itemRange: OffsetRange, viewRange: OffsetRange): void; +} diff --git a/src/vs/editor/browser/widget/multiDiffEditor/utils.ts b/src/vs/editor/browser/widget/multiDiffEditor/utils.ts index 43449e5827d..be9240267e1 100644 --- a/src/vs/editor/browser/widget/multiDiffEditor/utils.ts +++ b/src/vs/editor/browser/widget/multiDiffEditor/utils.ts @@ -6,11 +6,12 @@ import { ActionRunner, IAction } from 'vs/base/common/actions'; export class ActionRunnerWithContext extends ActionRunner { - constructor(private readonly _getContext: () => any) { + constructor(private readonly _getContext: () => unknown) { super(); } protected override runAction(action: IAction, _context?: unknown): Promise { - return super.runAction(action, this._getContext()); + const ctx = this._getContext(); + return super.runAction(action, ctx); } } diff --git a/src/vs/editor/common/config/diffEditor.ts b/src/vs/editor/common/config/diffEditor.ts index 2a62c479848..2d0a312357e 100644 --- a/src/vs/editor/common/config/diffEditor.ts +++ b/src/vs/editor/common/config/diffEditor.ts @@ -10,6 +10,7 @@ export const diffEditorDefaultOptions = { splitViewDefaultRatio: 0.5, renderSideBySide: true, renderMarginRevertIcon: true, + renderGutterMenu: true, maxComputationTime: 5000, maxFileSize: 50, ignoreTrimWhitespace: true, diff --git a/src/vs/editor/common/config/editorConfigurationSchema.ts b/src/vs/editor/common/config/editorConfigurationSchema.ts index 3b22985f00d..ab2b8cc70c6 100644 --- a/src/vs/editor/common/config/editorConfigurationSchema.ts +++ b/src/vs/editor/common/config/editorConfigurationSchema.ts @@ -175,6 +175,11 @@ const editorConfiguration: IConfigurationNode = { default: diffEditorDefaultOptions.renderMarginRevertIcon, description: nls.localize('renderMarginRevertIcon', "When enabled, the diff editor shows arrows in its glyph margin to revert changes.") }, + 'diffEditor.renderGutterMenu': { + type: 'boolean', + default: diffEditorDefaultOptions.renderGutterMenu, + description: nls.localize('renderGutterMenu', "When enabled, the diff editor shows a special gutter for revert and stage actions.") + }, 'diffEditor.ignoreTrimWhitespace': { type: 'boolean', default: diffEditorDefaultOptions.ignoreTrimWhitespace, diff --git a/src/vs/editor/common/config/editorOptions.ts b/src/vs/editor/common/config/editorOptions.ts index 9eaa2858b05..a5b819abf6d 100644 --- a/src/vs/editor/common/config/editorOptions.ts +++ b/src/vs/editor/common/config/editorOptions.ts @@ -803,6 +803,10 @@ export interface IDiffEditorBaseOptions { * Default to true. */ renderMarginRevertIcon?: boolean; + /** + * Indicates if the gutter menu should be rendered. + */ + renderGutterMenu?: boolean; /** * Original model should be editable? * Defaults to false. diff --git a/src/vs/editor/common/core/lineRange.ts b/src/vs/editor/common/core/lineRange.ts index da150f47952..1cbe63ceba1 100644 --- a/src/vs/editor/common/core/lineRange.ts +++ b/src/vs/editor/common/core/lineRange.ts @@ -52,6 +52,19 @@ export class LineRange { return result.ranges; } + public static join(lineRanges: LineRange[]): LineRange { + if (lineRanges.length === 0) { + throw new BugIndicatingError('lineRanges cannot be empty'); + } + let startLineNumber = lineRanges[0].startLineNumber; + let endLineNumberExclusive = lineRanges[0].endLineNumberExclusive; + for (let i = 1; i < lineRanges.length; i++) { + startLineNumber = Math.min(startLineNumber, lineRanges[i].startLineNumber); + endLineNumberExclusive = Math.max(endLineNumberExclusive, lineRanges[i].endLineNumberExclusive); + } + return new LineRange(startLineNumber, endLineNumberExclusive); + } + public static ofLength(startLineNumber: number, length: number): LineRange { return new LineRange(startLineNumber, startLineNumber + length); } diff --git a/src/vs/editor/common/diff/rangeMapping.ts b/src/vs/editor/common/diff/rangeMapping.ts index d00f0061698..1d8b154367e 100644 --- a/src/vs/editor/common/diff/rangeMapping.ts +++ b/src/vs/editor/common/diff/rangeMapping.ts @@ -92,6 +92,12 @@ export class LineRangeMapping { * Also contains inner range mappings. */ export class DetailedLineRangeMapping extends LineRangeMapping { + public static fromRangeMappings(rangeMappings: RangeMapping[]): DetailedLineRangeMapping { + const originalRange = LineRange.join(rangeMappings.map(r => LineRange.fromRangeInclusive(r.originalRange))); + const modifiedRange = LineRange.join(rangeMappings.map(r => LineRange.fromRangeInclusive(r.modifiedRange))); + return new DetailedLineRangeMapping(originalRange, modifiedRange, rangeMappings); + } + /** * If inner changes have not been computed, this is set to undefined. * Otherwise, it represents the character-level diff in this line range. diff --git a/src/vs/editor/common/editorContextKeys.ts b/src/vs/editor/common/editorContextKeys.ts index a38c21c4717..2311fbd3a85 100644 --- a/src/vs/editor/common/editorContextKeys.ts +++ b/src/vs/editor/common/editorContextKeys.ts @@ -30,10 +30,16 @@ export namespace EditorContextKeys { export const inMultiDiffEditor = new RawContextKey('inMultiDiffEditor', false, nls.localize('inMultiDiffEditor', "Whether the context is a multi diff editor")); export const multiDiffEditorAllCollapsed = new RawContextKey('multiDiffEditorAllCollapsed', undefined, nls.localize('multiDiffEditorAllCollapsed', "Whether all files in multi diff editor are collapsed")); export const hasChanges = new RawContextKey('diffEditorHasChanges', false, nls.localize('diffEditorHasChanges', "Whether the diff editor has changes")); - export const comparingMovedCode = new RawContextKey('comparingMovedCode', false, nls.localize('comparingMovedCode', "Whether a moved code block is selected for comparison")); export const accessibleDiffViewerVisible = new RawContextKey('accessibleDiffViewerVisible', false, nls.localize('accessibleDiffViewerVisible', "Whether the accessible diff viewer is visible")); export const diffEditorRenderSideBySideInlineBreakpointReached = new RawContextKey('diffEditorRenderSideBySideInlineBreakpointReached', false, nls.localize('diffEditorRenderSideBySideInlineBreakpointReached', "Whether the diff editor render side by side inline breakpoint is reached")); + export const diffEditorInlineMode = new RawContextKey('diffEditorInlineMode', false, nls.localize('diffEditorInlineMode', "Whether inline mode is active")); + + export const diffEditorOriginalWritable = new RawContextKey('diffEditorOriginalWritable', false, nls.localize('diffEditorOriginalWritable', "Whether modified is writable in the diff editor")); + export const diffEditorModifiedWritable = new RawContextKey('diffEditorModifiedWritable', false, nls.localize('diffEditorModifiedWritable', "Whether modified is writable in the diff editor")); + export const diffEditorOriginalUri = new RawContextKey('diffEditorOriginalUri', '', nls.localize('diffEditorOriginalUri', "The uri of the original document")); + export const diffEditorModifiedUri = new RawContextKey('diffEditorModifiedUri', '', nls.localize('diffEditorModifiedUri', "The uri of the modified document")); + export const columnSelection = new RawContextKey('editorColumnSelection', false, nls.localize('editorColumnSelection', "Whether `editor.columnSelection` is enabled")); export const writable = readOnly.toNegated(); export const hasNonEmptySelection = new RawContextKey('editorHasSelection', false, nls.localize('editorHasSelection', "Whether the editor has text selected")); diff --git a/src/vs/monaco.d.ts b/src/vs/monaco.d.ts index 7553f3a2623..bff01f77cd4 100644 --- a/src/vs/monaco.d.ts +++ b/src/vs/monaco.d.ts @@ -3818,6 +3818,10 @@ declare namespace monaco.editor { * Default to true. */ renderMarginRevertIcon?: boolean; + /** + * Indicates if the gutter menu should be rendered. + */ + renderGutterMenu?: boolean; /** * Original model should be editable? * Defaults to false. diff --git a/src/vs/platform/actions/common/actions.ts b/src/vs/platform/actions/common/actions.ts index a7087c66a5d..9df058fc89a 100644 --- a/src/vs/platform/actions/common/actions.ts +++ b/src/vs/platform/actions/common/actions.ts @@ -220,6 +220,9 @@ export class MenuId { static readonly ChatInputSide = new MenuId('ChatInputSide'); static readonly AccessibleView = new MenuId('AccessibleView'); static readonly MultiDiffEditorFileToolbar = new MenuId('MultiDiffEditorFileToolbar'); + static readonly DiffEditorHunkToolbar = new MenuId('DiffEditorHunkToolbar'); + static readonly DiffEditorSelectionToolbar = new MenuId('DiffEditorSelectionToolbar'); + /** * Create or reuse a `MenuId` with the given identifier diff --git a/src/vs/workbench/services/actions/common/menusExtensionPoint.ts b/src/vs/workbench/services/actions/common/menusExtensionPoint.ts index 0f0346f9fb3..6791e00042c 100644 --- a/src/vs/workbench/services/actions/common/menusExtensionPoint.ts +++ b/src/vs/workbench/services/actions/common/menusExtensionPoint.ts @@ -441,6 +441,18 @@ const apiMenus: IAPIMenu[] = [ id: MenuId.MultiDiffEditorFileToolbar, description: localize('menus.multiDiffEditorResource', "The resource toolbar in the multi diff editor"), proposed: 'contribMultiDiffEditorMenus' + }, + { + key: 'diffEditor/gutter/hunk', + id: MenuId.DiffEditorHunkToolbar, + description: localize('menus.diffEditorGutterToolBarMenus', "The gutter toolbar in the diff editor"), + proposed: 'contribDiffEditorGutterToolBarMenus' + }, + { + key: 'diffEditor/gutter/selection', + id: MenuId.DiffEditorSelectionToolbar, + description: localize('menus.diffEditorGutterToolBarMenus', "The gutter toolbar in the diff editor"), + proposed: 'contribDiffEditorGutterToolBarMenus' } ]; diff --git a/src/vs/workbench/services/extensions/common/extensionsApiProposals.ts b/src/vs/workbench/services/extensions/common/extensionsApiProposals.ts index 2eaaf1725a1..0968a659412 100644 --- a/src/vs/workbench/services/extensions/common/extensionsApiProposals.ts +++ b/src/vs/workbench/services/extensions/common/extensionsApiProposals.ts @@ -29,6 +29,7 @@ export const allApiProposals = Object.freeze({ contribCommentPeekContext: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.contribCommentPeekContext.d.ts', contribCommentThreadAdditionalMenu: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.contribCommentThreadAdditionalMenu.d.ts', contribCommentsViewThreadMenus: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.contribCommentsViewThreadMenus.d.ts', + contribDiffEditorGutterToolBarMenus: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.contribDiffEditorGutterToolBarMenus.d.ts', contribEditSessions: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.contribEditSessions.d.ts', contribEditorContentMenu: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.contribEditorContentMenu.d.ts', contribIssueReporter: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.contribIssueReporter.d.ts', diff --git a/src/vscode-dts/vscode.proposed.contribDiffEditorGutterToolBarMenus.d.ts b/src/vscode-dts/vscode.proposed.contribDiffEditorGutterToolBarMenus.d.ts new file mode 100644 index 00000000000..f6fb8c9f3a6 --- /dev/null +++ b/src/vscode-dts/vscode.proposed.contribDiffEditorGutterToolBarMenus.d.ts @@ -0,0 +1,6 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// empty placeholder declaration for `diffEditor/gutter/*` menus