// ***************************************************************************** // 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.dirtyDiff = dirtyDiff; if (this.widget) { this.widget.changes = dirtyDiff.changes ; } } } canNavigate(): boolean { return !!this.changes?.length; } gotoNextChange(): void { const { editor } = this; const index = this.findNextClosestChange(editor.cursor.line, false); const change = this.changes?.[index]; if (change) { const position = LineRange.getStartPosition(change.currentRange); editor.cursor = position; editor.revealPosition(position, { vertical: 'auto' }); } } gotoPreviousChange(): void { const { editor } = this; const index = this.findPreviousClosestChange(editor.cursor.line, false); const change = this.changes?.[index]; if (change) { const position = LineRange.getStartPosition(change.currentRange); editor.cursor = position; editor.revealPosition(position, { vertical: 'auto' }); } } canShowChange(): boolean { return !!(this.widget || this.widgetFactory && this.editor instanceof MonacoEditor && this.changes?.length && this.previousRevisionUri); } showNextChange(): void { if (this.widget) { this.widget.showNextChange(); } else { (this.widget = this.createWidget())?.showChange( this.findNextClosestChange(this.editor.cursor.line, true)); } } showPreviousChange(): void { if (this.widget) { this.widget.showPreviousChange(); } else { (this.widget = this.createWidget())?.showChange( this.findPreviousClosestChange(this.editor.cursor.line, true)); } } isShowingChange(): boolean { return !!this.widget; } closeWidget(): void { if (this.widget) { this.widget.dispose(); this.widget = undefined; } } protected get changes(): readonly Change[] | undefined { return this.dirtyDiff?.changes; } protected get previousRevisionUri(): URI | undefined { return this.dirtyDiff?.previousRevisionUri; } protected createWidget(): DirtyDiffWidget | undefined { const { widgetFactory, editor, changes, previousRevisionUri } = this; if (widgetFactory && editor instanceof MonacoEditor && changes?.length && previousRevisionUri) { const widget = widgetFactory({ editor, previousRevisionUri }); widget.changes = changes; widget.onDidClose(() => { this.widget = undefined; }); return widget; } } protected findNextClosestChange(line: number, inclusive: boolean): number { const length = this.changes?.length; if (!length) { return -1; } for (let i = 0; i < length; i++) { const { currentRange } = this.changes![i]; if (inclusive) { if (LineRange.getEndPosition(currentRange).line >= line) { return i; } } else { if (LineRange.getStartPosition(currentRange).line > line) { return i; } } } return 0; } protected findPreviousClosestChange(line: number, inclusive: boolean): number { const length = this.changes?.length; if (!length) { return -1; } for (let i = length - 1; i >= 0; i--) { const { currentRange } = this.changes![i]; if (inclusive) { if (LineRange.getStartPosition(currentRange).line <= line) { return i; } } else { if (LineRange.getEndPosition(currentRange).line < line) { return i; } } } return length - 1; } protected handleEditorMouseDown({ event, target }: EditorMouseEvent): void { if (event.button !== 0) { return; } const { range, type, element } = target; if (!range || type !== MouseTargetType.GUTTER_LINE_DECORATIONS || !element || element.className.indexOf('dirty-diff-glyph') < 0) { return; } const gutterOffsetX = target.detail.offsetX - (element as HTMLElement).offsetLeft; if (gutterOffsetX < -3 || gutterOffsetX > 3) { // dirty diff decoration on hover is 6px wide return; // to avoid colliding with folding } const index = this.findNextClosestChange(range.start.line, true); if (index < 0) { return; } if (index === this.widget?.currentChangeIndex) { this.closeWidget(); return; } if (!this.widget) { this.widget = this.createWidget(); } this.widget?.showChange(index); } }