// ***************************************************************************** // 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 } from '@theia/core/shared/inversify'; import { Position, Range } from '@theia/core/shared/vscode-languageserver-protocol'; import { CommandMenu, Disposable, Emitter, Event, MenuModelRegistry, MenuPath, URI, nls } from '@theia/core'; import { codicon } from '@theia/core/lib/browser'; import { ContextKeyService } from '@theia/core/lib/browser/context-key-service'; import { MonacoEditor } from '@theia/monaco/lib/browser/monaco-editor'; import { MonacoDiffEditor } from '@theia/monaco/lib/browser/monaco-diff-editor'; import { MonacoEditorProvider } from '@theia/monaco/lib/browser/monaco-editor-provider'; import { MonacoEditorPeekViewWidget, peekViewBorder, peekViewTitleBackground, peekViewTitleForeground, peekViewTitleInfoForeground } from '@theia/monaco/lib/browser/monaco-editor-peek-view-widget'; import { Change, LineRange } from './diff-computer'; import { ScmColors } from '../scm-colors'; import * as monaco from '@theia/monaco-editor-core'; export const SCM_CHANGE_TITLE_MENU: MenuPath = ['scm-change-title-menu']; /** Reserved for plugin contributions, corresponds to contribution point 'scm/change/title'. */ export const PLUGIN_SCM_CHANGE_TITLE_MENU: MenuPath = ['plugin-scm-change-title-menu']; export const DirtyDiffWidgetProps = Symbol('DirtyDiffWidgetProps'); export interface DirtyDiffWidgetProps { readonly editor: MonacoEditor; readonly previousRevisionUri: URI; } export const DirtyDiffWidgetFactory = Symbol('DirtyDiffWidgetFactory'); export type DirtyDiffWidgetFactory = (props: DirtyDiffWidgetProps) => DirtyDiffWidget; @injectable() export class DirtyDiffWidget implements Disposable { private readonly onDidCloseEmitter = new Emitter(); readonly onDidClose: Event = this.onDidCloseEmitter.event; protected index: number = -1; private readonly peekView: DirtyDiffPeekView; private readonly diffEditorPromise: Promise; protected _changes?: readonly Change[]; constructor( @inject(DirtyDiffWidgetProps) protected readonly props: DirtyDiffWidgetProps, @inject(MonacoEditorProvider) readonly editorProvider: MonacoEditorProvider, @inject(ContextKeyService) readonly contextKeyService: ContextKeyService, @inject(MenuModelRegistry) readonly menuModelRegistry: MenuModelRegistry, ) { this.peekView = new DirtyDiffPeekView(this); this.peekView.onDidClose(e => this.onDidCloseEmitter.fire(e)); this.diffEditorPromise = this.peekView.create(); } get changes(): readonly Change[] { return this._changes ?? []; } set changes(changes: readonly Change[]) { this.handleChangedChanges(changes); } get editor(): MonacoEditor { return this.props.editor; } get uri(): URI { return this.editor.uri; } get previousRevisionUri(): URI { return this.props.previousRevisionUri; } get currentChange(): Change | undefined { return this.changes[this.index]; } get currentChangeIndex(): number { return this.index; } protected handleChangedChanges(updated: readonly Change[]): void { if (!updated.length) { return this.dispose(); } if (this.currentChange) { const { previousRange: { start, end } } = this.currentChange; // Same change or first after it. const newIndex = updated.findIndex(candidate => (candidate.previousRange.start === start && candidate.previousRange.end === end) || candidate.previousRange.start > start); if (newIndex !== -1) { this.index = newIndex; } else { this.index = Math.min(this.index, updated.length - 1); } this.showCurrentChange(); } else { this.index = -1; } this._changes = updated; this.updateHeading(); } showChange(index: number): void { if (index >= 0 && index < this.changes.length) { this.index = index; this.showCurrentChange(); } } showNextChange(): void { 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 { 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 { const changes = this.changes.filter(predicate); const diffEditor = await this.diffEditorPromise; return applyChanges(changes, diffEditor.originalModel.textEditorModel, diffEditor.modifiedModel.textEditorModel); } dispose(): void { this.peekView.dispose(); this.onDidCloseEmitter.dispose(); } protected showCurrentChange(): void { this.updateHeading(); const { previousRange, currentRange } = this.changes[this.index]; this.peekView.show( Position.create(LineRange.getEndPosition(currentRange).line, 0), this.computeHeightInLines() ).then(async () => { this.editor.focus(); const diffEditor = await this.diffEditorPromise; if (diffEditor.isDisposed()) { return; } 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.diffEditor.revealLinesInCenter(startLine + 1, endLine + 1, // monaco line numbers are 1-based monaco.editor.ScrollType.Immediate); }); } protected updateHeading(): void { this.peekView.setTitle(this.computePrimaryHeading(), this.computeSecondaryHeading()); } protected computePrimaryHeading(): string { return this.uri.path.base; } protected computeSecondaryHeading(): string { const index = this.index + 1; const length = this.changes.length; return length > 1 ? nls.localizeByDefault('{0} of {1} changes', index, length) : nls.localizeByDefault('{0} of {1} change', index, length); } protected computeHeightInLines(): number { const editor = this.editor.getControl(); const lineHeight = editor.getOption(monaco.editor.EditorOption.lineHeight); const editorHeight = editor.getLayoutInfo().height; const editorHeightInLines = Math.floor(editorHeight / lineHeight); const { previousRange, currentRange } = this.changes[this.index]; const changeHeightInLines = LineRange.getLineCount(currentRange) + LineRange.getLineCount(previousRange); return Math.min(changeHeightInLines + /* padding */ 8, Math.floor(editorHeightInLines / 3)); } } 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 diffEditor?: MonacoDiffEditor; private height?: number; constructor(readonly widget: DirtyDiffWidget) { super(widget.editor, { isResizeable: true, showArrow: true, frameWidth: 1, keepEditorSelection: true, className: 'dirty-diff' }); } override async create(): Promise { try { this.bodyElement = document.createElement('div'); this.bodyElement.classList.add('body'); this.diffEditorPromise = this.widget.editorProvider.createEmbeddedDiffEditor(this.editor, this.bodyElement, this.widget.previousRevisionUri); const diffEditor = await this.diffEditorPromise; this.diffEditor = diffEditor; this.toDispose.push(diffEditor); super.create(); return diffEditor; } catch (e) { this.dispose(); throw e; } } override async show(rangeOrPos: Range | Position, heightInLines: number): Promise { const { diffEditorPromise } = this; if (!diffEditorPromise) { throw new Error('Call the create() method first'); } const diffEditor = await diffEditorPromise; if (diffEditor.isDisposed()) { return; } const borderColor = this.getBorderColor(); this.style({ arrowColor: borderColor, frameColor: borderColor, headerBackgroundColor: peekViewTitleBackground, primaryHeadingColor: peekViewTitleForeground, secondaryHeadingColor: peekViewTitleInfoForeground }); this.updateActions(); super.show(rangeOrPos, heightInLines); diffEditor.handleVisibilityChanged(true); return new Promise(resolve => { // The diff computation is asynchronous and may complete before or after we register the listener. // This can happen when the file is already open in another editor, causing the model to be cached // and the diff to compute almost instantly. To handle this race condition, we check if the diff // is already available before waiting for onDidUpdateDiff. // setTimeout is needed because the non-side-by-side diff editor might still not have created the view zones; // otherwise, the first change shown might not be properly revealed in the diff editor. // See also https://github.com/microsoft/vscode/blob/b30900b56c4b3ca6c65d7ab92032651f4cb23f15/src/vs/workbench/contrib/scm/browser/dirtydiffDecorator.ts#L248 if (diffEditor.diffEditor.getLineChanges()) { setTimeout(() => resolve()); return; } const disposable = diffEditor.diffEditor.onDidUpdateDiff(() => setTimeout(() => { resolve(); disposable.dispose(); })); }); } override hide(): void { const { diffEditor } = this; if (diffEditor && !diffEditor.isDisposed()) { diffEditor.handleVisibilityChanged(false); } super.hide(); } private getBorderColor(): string { const { currentChange } = this.widget; if (!currentChange) { return peekViewBorder; } if (Change.isAddition(currentChange)) { return ScmColors.editorGutterAddedBackground; } else if (Change.isRemoval(currentChange)) { return ScmColors.editorGutterDeletedBackground; } else { return ScmColors.editorGutterModifiedBackground; } } private updateActions(): void { this.clearActions(); const { contextKeyService, menuModelRegistry } = this.widget; contextKeyService.with({ originalResourceScheme: this.widget.previousRevisionUri.scheme, originalResource: this.widget.previousRevisionUri.toString(), }, () => { for (const menuPath of [SCM_CHANGE_TITLE_MENU, PLUGIN_SCM_CHANGE_TITLE_MENU]) { const menu = menuModelRegistry.getMenu(menuPath); if (menu) { for (const item of menu.children) { if (CommandMenu.is(item)) { const { id, label, icon } = item; const itemPath = [...menuPath, id]; if (icon && item.isVisible(itemPath, contextKeyService, undefined, this.widget)) { // Close editor on successful contributed action. // https://github.com/microsoft/vscode/blob/1.99.3/src/vs/workbench/contrib/scm/browser/quickDiffWidget.ts#L357-L361 this.addAction(id, label, icon, item.isEnabled(itemPath, this.widget), () => { item.run(itemPath, this.widget).then(() => this.dispose()); }); } } } } } }); this.addAction('dirtydiff.next', nls.localizeByDefault('Show Next Change'), codicon('arrow-down'), true, () => this.widget.showNextChange()); this.addAction('dirtydiff.previous', nls.localizeByDefault('Show Previous Change'), codicon('arrow-up'), true, () => this.widget.showPreviousChange()); this.addAction('peekview.close', nls.localizeByDefault('Close'), codicon('close'), true, () => this.dispose()); } protected override fillContainer(container: HTMLElement): void { this.setCssClass('peekview-widget'); this.headElement = document.createElement('div'); this.headElement.classList.add('head'); container.appendChild(this.headElement); container.appendChild(this.bodyElement!); this.fillHead(this.headElement); } protected override fillHead(container: HTMLElement): void { super.fillHead(container, true); } protected override doLayoutBody(height: number, width: number): void { super.doLayoutBody(height, width); this.layout(height, width); this.height = height; } protected override onWidth(width: number): void { super.onWidth(width); const { height } = this; if (height !== undefined) { this.layout(height, width); } } private layout(height: number, width: number): void { this.diffEditor?.diffEditor.layout({ height, width }); } protected override doRevealRange(range: Range): void { this.editor.revealPosition(Position.create(range.end.line, 0), { vertical: 'centerIfOutsideViewport' }); } }