From af7cab1ae84ad83f23bc7d7e062edf4d0d6a30a5 Mon Sep 17 00:00:00 2001 From: Vladimir Piskarev Date: Mon, 27 Nov 2023 14:11:31 +0300 Subject: [PATCH 01/14] Add Dirty Diff Peek View Closes #4544. --- .../dirty-diff/dirty-diff-contribution.ts | 8 +- .../browser/dirty-diff/dirty-diff-manager.ts | 38 +- packages/git/src/browser/git-contribution.ts | 90 +++- .../git/src/node/git-repository-watcher.ts | 2 + packages/monaco/src/browser/style/index.css | 1 - .../menus/plugin-menu-command-adapter.ts | 39 ++ .../menus/vscode-theia-menu-mappings.ts | 3 + packages/scm/package.json | 2 + .../decorations/scm-decorations-service.ts | 89 ++-- .../src/browser/dirty-diff/content-lines.ts | 9 + .../browser/dirty-diff/diff-computer.spec.ts | 127 +++++- .../src/browser/dirty-diff/diff-computer.ts | 152 ++++++- .../dirty-diff/dirty-diff-decorator.ts | 2 + .../browser/dirty-diff/dirty-diff-module.ts | 9 + .../dirty-diff/dirty-diff-navigator.ts | 288 +++++++++++++ .../browser/dirty-diff/dirty-diff-widget.ts | 386 ++++++++++++++++++ packages/scm/src/browser/scm-colors.ts | 21 + packages/scm/src/browser/scm-contribution.ts | 104 ++++- packages/scm/src/browser/scm-tree-widget.tsx | 2 +- .../browser/style/dirty-diff-decorator.css | 2 +- packages/scm/tsconfig.json | 3 + 21 files changed, 1293 insertions(+), 84 deletions(-) create mode 100644 packages/scm/src/browser/dirty-diff/dirty-diff-navigator.ts create mode 100644 packages/scm/src/browser/dirty-diff/dirty-diff-widget.ts create mode 100644 packages/scm/src/browser/scm-colors.ts 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..edfa7081abfc2 100644 --- a/packages/git/src/browser/dirty-diff/dirty-diff-manager.ts +++ b/packages/git/src/browser/dirty-diff/dirty-diff-manager.ts @@ -101,11 +101,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 +118,8 @@ export class DirtyDiffManager { return this.git.lsFiles(repository, fileUri.toString(), { errorUnmatch: true }); } return false; - } + }, + getOriginalUri }; } @@ -128,7 +132,6 @@ export class DirtyDiffManager { await model.handleGitStatusUpdate(repository, changes); } } - } export class DirtyDiffModel implements Disposable { @@ -137,7 +140,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(); @@ -200,7 +203,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 +254,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 { @@ -275,23 +282,26 @@ export namespace DirtyDiffModel { */ export function computeDirtyDiff(previous: ContentLines, current: ContentLines): DirtyDiff | undefined { try { - return diffComputer.computeDirtyDiff(ContentLines.arrayLike(previous), ContentLines.arrayLike(current)); + return diffComputer.computeDirtyDiff(ContentLines.arrayLike(previous), ContentLines.arrayLike(current), + { rangeMappings: true }); } catch { return undefined; } } 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/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..1b27eb887c5fc 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 { ChangeRangeMapping, LineRange, NormalizedEmptyLineRange } from '@theia/scm/lib/browser/dirty-diff/diff-computer'; +import { IChange } from '@theia/monaco-editor-core/esm/vs/editor/common/diff/smartLinesDiffComputer'; 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: ChangeRangeMapping): IChange => { + const convert = (range: LineRange | NormalizedEmptyLineRange): [number, number] => { + let startLineNumber; + let endLineNumber; + if (!LineRange.isEmpty(range)) { + startLineNumber = range.start + 1; + endLineNumber = range.end + 1; + } else { + startLineNumber = range.start === 0 ? 0 : range.end + 1; + 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..e30c9abb5d7c0 100644 --- a/packages/scm/src/browser/decorations/scm-decorations-service.ts +++ b/packages/scm/src/browser/decorations/scm-decorations-service.ts @@ -15,64 +15,81 @@ // ***************************************************************************** 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 void>(); + this.editorManager.onCreated(editorWidget => { + const { editor } = editorWidget; + if (editor.uri.scheme !== 'file') { + 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(() => { + 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()}"}`); + const uri = editor.uri.withScheme(currentRepo.provider.id).withQuery(`{"path":"${editor.uri['codeUri'].fsPath}","ref":"~"}`); 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), + { rangeMappings: true }); + 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 createUpdateTask(editor: TextEditor): () => 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..56d57dcc4a55b 100644 --- a/packages/scm/src/browser/dirty-diff/diff-computer.spec.ts +++ b/packages/scm/src/browser/dirty-diff/diff-computer.spec.ts @@ -18,7 +18,7 @@ import * as chai from 'chai'; import { expect } from 'chai'; chai.use(require('chai-string')); -import { DiffComputer, DirtyDiff } from './diff-computer'; +import { DiffComputer, DirtyDiff, EmptyLineRange } from './diff-computer'; import { ContentLines } from './content-lines'; let diffComputer: DiffComputer; @@ -45,6 +45,12 @@ describe('dirty-diff-computer', () => { added: [], modified: [], removed: [0], + rangeMappings: [ + { + previousRange: { start: 1, end: 1 }, + currentRange: EmptyLineRange.afterLine(0), + }, + ], }); }); @@ -59,19 +65,32 @@ describe('dirty-diff-computer', () => { modified: [], removed: [1], added: [], + rangeMappings: [ + { + previousRange: { start: 2, end: 2 + lines - 1 }, + currentRange: EmptyLineRange.afterLine(1), + }, + ], }); }); }); 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], + rangeMappings: [ + { + previousRange: { start: 0, end: numberOfLines - 1 }, + currentRange: EmptyLineRange.atBeginning, + }, + ], }); }); @@ -86,6 +105,12 @@ describe('dirty-diff-computer', () => { modified: [], removed: [0], added: [], + rangeMappings: [ + { + previousRange: { start: 0, end: lines - 1 }, + currentRange: EmptyLineRange.atBeginning, + }, + ], }); }); }); @@ -99,6 +124,12 @@ describe('dirty-diff-computer', () => { modified: [], removed: [], added: [{ start: 2, end: 2 + lines - 1 }], + rangeMappings: [ + { + previousRange: EmptyLineRange.afterLine(1), + currentRange: { start: 2, end: 2 + lines - 1 }, + }, + ], }); }); }); @@ -114,6 +145,12 @@ describe('dirty-diff-computer', () => { modified: [], removed: [], added: [{ start: 0, end: lines - 1 }], + rangeMappings: [ + { + previousRange: EmptyLineRange.atBeginning, + currentRange: { start: 0, end: lines - 1 }, + }, + ], }); }); }); @@ -128,6 +165,12 @@ describe('dirty-diff-computer', () => { modified: [], removed: [], added: [{ start: 0, end: numberOfLines - 1 }], + rangeMappings: [ + { + previousRange: EmptyLineRange.atBeginning, + currentRange: { start: 0, end: numberOfLines - 1 }, + }, + ], }); }); @@ -148,6 +191,12 @@ describe('dirty-diff-computer', () => { modified: [], removed: [], added: [{ start: 1, end: 2 }], + rangeMappings: [ + { + previousRange: EmptyLineRange.afterLine(0), + currentRange: { start: 1, end: 2 }, + }, + ], }); }); @@ -165,6 +214,12 @@ describe('dirty-diff-computer', () => { modified: [], removed: [], added: [{ start: 1, end: 1 }], + rangeMappings: [ + { + previousRange: EmptyLineRange.afterLine(0), + currentRange: { start: 1, end: 1 }, + }, + ], }); }); @@ -178,7 +233,13 @@ describe('dirty-diff-computer', () => { expect(dirtyDiff).to.be.deep.equal({ modified: [], removed: [], - added: [{ start: 2, end: 1 + lines }], + added: [{ start: 2, end: 2 + lines - 1 }], + rangeMappings: [ + { + previousRange: EmptyLineRange.afterLine(1), + currentRange: { start: 2, end: 2 + lines - 1 }, + }, + ], }); }); }); @@ -203,6 +264,12 @@ describe('dirty-diff-computer', () => { modified: [], removed: [], added: [{ start: 1, end: 5 }], + rangeMappings: [ + { + previousRange: EmptyLineRange.afterLine(0), + currentRange: { start: 1, end: 5 }, + }, + ], }); }); @@ -216,6 +283,12 @@ describe('dirty-diff-computer', () => { modified: [], removed: [], added: [{ start: 1, end: lines }], + rangeMappings: [ + { + previousRange: EmptyLineRange.afterLine(0), + currentRange: { start: 1, end: lines }, + }, + ], }); }); }); @@ -237,6 +310,12 @@ describe('dirty-diff-computer', () => { removed: [], added: [], modified: [{ start: 1, end: 1 }], + rangeMappings: [ + { + previousRange: { start: 1, end: 1 }, + currentRange: { start: 1, end: 1 }, + }, + ], }); }); @@ -250,6 +329,12 @@ describe('dirty-diff-computer', () => { removed: [], added: [], modified: [{ start: 0, end: numberOfLines - 1 }], + rangeMappings: [ + { + previousRange: { start: 0, end: numberOfLines - 1 }, + currentRange: { start: 0, end: numberOfLines - 1 }, + }, + ], }); }); @@ -271,6 +356,12 @@ describe('dirty-diff-computer', () => { removed: [], added: [], modified: [{ start: 1, end: 2 }], + rangeMappings: [ + { + previousRange: { start: 1, end: 3 }, + currentRange: { start: 1, end: 2 }, + }, + ], }); }); @@ -308,6 +399,20 @@ describe('dirty-diff-computer', () => { removed: [3], added: [{ start: 10, end: 11 }], modified: [{ start: 0, end: 0 }], + rangeMappings: [ + { + previousRange: { start: 0, end: 0 }, + currentRange: { start: 0, end: 0 }, + }, + { + previousRange: { start: 4, end: 4 }, + currentRange: EmptyLineRange.afterLine(3), + }, + { + previousRange: EmptyLineRange.afterLine(10), + currentRange: { start: 10, end: 11 }, + }, + ], }); }); @@ -343,6 +448,20 @@ describe('dirty-diff-computer', () => { removed: [11], added: [{ start: 5, end: 5 }, { start: 9, end: 9 }], modified: [], + rangeMappings: [ + { + previousRange: EmptyLineRange.afterLine(4), + currentRange: { start: 5, end: 5 }, + }, + { + previousRange: EmptyLineRange.afterLine(7), + currentRange: { start: 9, end: 9 }, + }, + { + previousRange: { start: 9, end: 9 }, + currentRange: EmptyLineRange.afterLine(11), + }, + ], }); }); @@ -369,7 +488,7 @@ function computeDirtyDiff(previous: string[], modified: string[]): DirtyDiff { return value; }, }); - return diffComputer.computeDirtyDiff(a, b); + return diffComputer.computeDirtyDiff(a, b, { rangeMappings: true }); } function sequenceOfN(n: number, mapFn: (index: number) => string = i => i.toString()): string[] { diff --git a/packages/scm/src/browser/dirty-diff/diff-computer.ts b/packages/scm/src/browser/dirty-diff/diff-computer.ts index 5662cb993a54e..78cf41f5f0385 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 } from '@theia/core/shared/vscode-languageserver-protocol'; export class DiffComputer { @@ -24,21 +25,25 @@ export class DiffComputer { return diffResult; } - computeDirtyDiff(previous: ContentLinesArrayLike, current: ContentLinesArrayLike): DirtyDiff { + computeDirtyDiff(previous: ContentLinesArrayLike, current: ContentLinesArrayLike, options?: DirtyDiffOptions): DirtyDiff { const added: LineRange[] = []; const removed: number[] = []; const modified: LineRange[] = []; + const rangeMappings: ChangeRangeMapping[] | undefined = options?.rangeMappings ? [] : undefined; const changes = this.computeDiff(previous, current); - let lastLine = -1; + let currentRevisionLine = -1; + let previousRevisionLine = -1; for (let i = 0; i < changes.length; i++) { const change = changes[i]; const next = changes[i + 1]; if (change.added) { // case: addition - const start = lastLine + 1; - const end = lastLine + change.count!; - added.push({ start, end }); - lastLine = end; + const currentRange = toLineRange(change); + added.push(currentRange); + if (rangeMappings) { + rangeMappings.push({ previousRange: EmptyLineRange.afterLine(previousRevisionLine), currentRange }); + } + currentRevisionLine += change.count!; } else if (change.removed && next && next.added) { const isFirstChange = i === 0; const isLastChange = i === changes.length - 2; @@ -48,29 +53,49 @@ export class DiffComputer { if (isFirstChange && isNextEmptyLine) { // special case: removing at the beginning removed.push(0); + if (rangeMappings) { + rangeMappings.push({ previousRange: toLineRange(change), currentRange: EmptyLineRange.atBeginning }); + } + 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; + const currentRange = toLineRange(next); + added.push(currentRange); + if (rangeMappings) { + rangeMappings.push({ previousRange: EmptyLineRange.atBeginning, currentRange }); + } + currentRevisionLine += next.count!; } else if (isLastChange && isNextEmptyLine) { - removed.push(lastLine + 1 /* = empty line */); + removed.push(currentRevisionLine + 1 /* = empty line */); + if (rangeMappings) { + rangeMappings.push({ previousRange: toLineRange(change), currentRange: EmptyLineRange.afterLine(currentRevisionLine + 1) }); + } + previousRevisionLine += change.count!; } else { // default case is a modification - const start = lastLine + 1; - const end = lastLine + next.count!; - modified.push({ start, end }); - lastLine = end; + const currentRange = toLineRange(next); + modified.push(currentRange); + if (rangeMappings) { + rangeMappings.push({ previousRange: toLineRange(change), currentRange }); + } + 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 + removed.push(Math.max(0, currentRevisionLine)); + if (rangeMappings) { + rangeMappings.push({ previousRange: toLineRange(change), currentRange: EmptyLineRange.afterLine(currentRevisionLine) }); + } + previousRevisionLine += change.count!; } else { - lastLine += change.count!; + // case: unchanged region + currentRevisionLine += change.count!; + previousRevisionLine += change.count!; } } - return { added, removed, modified }; + return { added, removed, modified, rangeMappings }; } } @@ -101,6 +126,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 { start, end }; +} + export interface DiffResult { value: [number, number]; count?: number; @@ -108,6 +138,13 @@ export interface DiffResult { removed?: boolean; } +export interface DirtyDiffOptions { + /** + * Indicates whether {@link DirtyDiff.rangeMappings} need to be computed. + */ + rangeMappings?: boolean; +} + export interface DirtyDiff { /** * Lines added by comparison to previous revision. @@ -121,9 +158,88 @@ export interface DirtyDiff { * Lines modified by comparison to previous revision. */ readonly modified: LineRange[]; + /** + * Range mappings for the diff, if {@link DirtyDiffOptions.rangeMappings requested}. + */ + readonly rangeMappings?: ChangeRangeMapping[]; } +/** + * Represents a range that starts at the beginning of the {@link start} line + * and spans up to the end of the {@link end} line. + */ export interface LineRange { start: number; end: number; } + +/** + * Represents a range that starts and ends either at the beginning of the {@link start} line or at the end of the {@link end} line. + */ +export type EmptyLineRange = { start: number; end?: undefined; } | { start?: undefined; end: number }; + +/** + * Represents a range that starts and ends either at the beginning of the file or at the end of the {@link end} line. + */ +export type NormalizedEmptyLineRange = { start: 0; end?: undefined; } | { start?: undefined; end: number }; + +export namespace LineRange { + export function isEmpty(range: LineRange | EmptyLineRange): range is EmptyLineRange { + return range.start === undefined || range.end === undefined; + } + export function getStartPosition(range: LineRange | EmptyLineRange): Position { + if (range.start === undefined) { + return Position.create(range.end, Number.MAX_SAFE_INTEGER); + } + return Position.create(range.start, 0); + } + export function getEndPosition(range: LineRange | EmptyLineRange): Position { + if (range.end === undefined) { + return Position.create(range.start, 0); + } + return Position.create(range.end, Number.MAX_SAFE_INTEGER); + } + export function getLineCount(range: LineRange | EmptyLineRange): number { + if (isEmpty(range)) { + return 0; + } + return range.end - range.start + 1; + } +} + +export namespace EmptyLineRange { + /** + * A {@link NormalizedEmptyLineRange} that starts and ends at the beginning of the file. + */ + export const atBeginning: { readonly start: 0 } = { start: 0 }; + + /** + * Returns a {@link NormalizedEmptyLineRange} positioned just after the given line. + * @param line line, after which an empty line range is to be returned. + * May be negative, in which case an empty line range at the beginning of the file is returned + * @returns an empty line range that starts and ends just after the given line + */ + export function afterLine(line: number): NormalizedEmptyLineRange { + if (line < 0) { + return atBeginning; + } + return { end: line }; + } +} + +export type ChangeRangeMapping = AddedRangeMapping | RemovedRangeMapping | ModifiedRangeMapping; + +export interface AddedRangeMapping { + previousRange: NormalizedEmptyLineRange; + currentRange: LineRange; +} + +export interface RemovedRangeMapping { + previousRange: LineRange; + currentRange: NormalizedEmptyLineRange; +} + +export interface ModifiedRangeMapping { + previousRange: LineRange; + currentRange: LineRange; +} 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..17de0bfa0711e 100644 --- a/packages/scm/src/browser/dirty-diff/dirty-diff-decorator.ts +++ b/packages/scm/src/browser/dirty-diff/dirty-diff-decorator.ts @@ -26,6 +26,7 @@ import { MinimapPosition } from '@theia/editor/lib/browser'; import { DirtyDiff, LineRange } from './diff-computer'; +import { URI } from '@theia/core'; export enum DirtyDiffDecorationType { AddedLine = 'dirty-diff-added-line', @@ -86,6 +87,7 @@ const ModifiedLineDecoration = { export interface DirtyDiffUpdate extends DirtyDiff { readonly editor: TextEditor; + readonly previousRevisionUri?: URI; } @injectable() 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..99b2b6b49d1bd --- /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 { ChangeRangeMapping, 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 ChangeRangeMapping[] | undefined { + return this.dirtyDiff?.rangeMappings; + } + + 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..9d733d9592a25 --- /dev/null +++ b/packages/scm/src/browser/dirty-diff/dirty-diff-widget.ts @@ -0,0 +1,386 @@ +// ***************************************************************************** +// 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 { ActionMenuNode, Disposable, Event, MenuCommandExecutor, MenuModelRegistry, MenuPath, URI, nls } from '@theia/core'; +import { ContextKeyService } from '@theia/core/lib/browser/context-key-service'; +import { MonacoEditor } from '@theia/monaco/lib/browser/monaco-editor'; +import { ChangeRangeMapping, LineRange, NormalizedEmptyLineRange } from './diff-computer'; +import { ScmColors } from '../scm-colors'; +import * as monaco from '@theia/monaco-editor-core'; +import { PeekViewWidget, peekViewBorder, peekViewTitleBackground, peekViewTitleForeground, peekViewTitleInfoForeground } + from '@theia/monaco-editor-core/esm/vs/editor/contrib/peekView/browser/peekView'; +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 { ICodeEditor } from '@theia/monaco-editor-core/esm/vs/editor/browser/editorBrowser'; +import { IPosition, Position } from '@theia/monaco-editor-core/esm/vs/editor/common/core/position'; +import { IRange } from '@theia/monaco-editor-core/esm/vs/editor/common/core/range'; +import { IDiffEditorOptions } from '@theia/monaco-editor-core/esm/vs/editor/common/config/editorOptions'; +import { EmbeddedDiffEditorWidget } from '@theia/monaco-editor-core/esm/vs/editor/browser/widget/embeddedCodeEditorWidget'; +import { ITextModelService } from '@theia/monaco-editor-core/esm/vs/editor/common/services/resolverService'; +import { Action, IAction } from '@theia/monaco-editor-core/esm/vs/base/common/actions'; +import { Codicon } from '@theia/monaco-editor-core/esm/vs/base/common/codicons'; +import { ScrollType } from '@theia/monaco-editor-core/esm/vs/editor/common/editorCommon'; +import { Color } from '@theia/monaco-editor-core/esm/vs/base/common/color'; +import { IColorTheme, IThemeService } from '@theia/monaco-editor-core/esm/vs/platform/theme/common/themeService'; + +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 ChangeRangeMapping[]; +} + +export const DirtyDiffWidgetFactory = Symbol('DirtyDiffWidgetFactory'); +export type DirtyDiffWidgetFactory = (props: DirtyDiffWidgetProps) => DirtyDiffWidget; + +@injectable() +export class DirtyDiffWidget implements Disposable { + + readonly onDidClose: Event; + protected index: number = -1; + private readonly peekView: DirtyDiffPeekView; + private readonly diffEditorPromise: Promise; + + constructor( + @inject(DirtyDiffWidgetProps) protected readonly props: DirtyDiffWidgetProps, + @inject(ContextKeyService) readonly contextKeyService: ContextKeyService, + @inject(MenuModelRegistry) readonly menuModelRegistry: MenuModelRegistry, + @inject(MenuCommandExecutor) readonly menuCommandExecutor: MenuCommandExecutor + ) { + this.peekView = new DirtyDiffPeekView(this); + this.onDidClose = this.peekView.onDidClose; + 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 ChangeRangeMapping[] { + return this.props.changes; + } + + get currentChange(): ChangeRangeMapping | undefined { + return this.changes[this.index]; + } + + get currentChangeIndex(): number { + return this.index; + } + + 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: ChangeRangeMapping, index: number, changes: readonly ChangeRangeMapping[]) => boolean): Promise { + 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(); + } + + protected showCurrentChange(): void { + this.peekView.setTitle(this.computePrimaryHeading(), this.computeSecondaryHeading()); + const { previousRange, currentRange } = this.changes[this.index]; + this.peekView.show(new Position(LineRange.getEndPosition(currentRange).line + 1, 1), // monaco position is 1-based + 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)); + } +} + +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 ChangeRangeMapping[], 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 | NormalizedEmptyLineRange): [number, number] => { + let startLineNumber; + let endLineNumber; + if (!LineRange.isEmpty(range)) { + startLineNumber = range.start + 1; + endLineNumber = range.end + 1; + } else { + startLineNumber = range.start === 0 ? 0 : range.end + 1; + 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 PeekViewWidget { + + private diffEditor?: EmbeddedDiffEditorWidget; + private height?: number; + + constructor(readonly widget: DirtyDiffWidget) { + super( + widget.editor.getControl() as unknown as ICodeEditor, + { isResizeable: true, showArrow: true, frameWidth: 1, keepEditorSelection: true, className: 'dirty-diff' }, + StandaloneServices.get(IInstantiationService) + ); + StandaloneServices.get(IThemeService).onDidColorThemeChange(this.applyTheme, this, this._disposables); + } + + override create(): Promise { + super.create(); + const { diffEditor } = this; + 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!.onDidUpdateDiff(() => setTimeout(() => { + resolve(diffEditor! as unknown as monaco.editor.IDiffEditor); + disposable.dispose(); + })); + }); + } + + override show(rangeOrPos: IRange | IPosition, heightInLines: number): void { + this.applyTheme(StandaloneServices.get(IThemeService).getColorTheme()); + this.updateActions(); + super.show(rangeOrPos, heightInLines); + } + + private updateActions(): void { + const actionBar = this._actionbarWidget; + if (!actionBar) { + return; + } + const actions: IAction[] = []; + 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))) { + actions.push(new Action(id, label, icon, menuCommandExecutor.isEnabled(menuPath, command, this.widget), () => { + menuCommandExecutor.executeCommand(menuPath, command, this.widget); + })); + } + } + } + } + }); + actions.push(new Action('dirtydiff.next', nls.localizeByDefault('Show Next Change'), Codicon.arrowDown.classNames, true, + () => this.widget.showNextChange())); + actions.push(new Action('dirtydiff.previous', nls.localizeByDefault('Show Previous Change'), Codicon.arrowUp.classNames, true, + () => this.widget.showPreviousChange())); + actions.push(new Action('peekview.close', nls.localizeByDefault('Close'), Codicon.close.classNames, true, + () => this.dispose())); + actionBar.clear(); + actionBar.push(actions, { label: false, icon: true }); + } + + protected override _fillHead(container: HTMLElement): void { + super._fillHead(container, true); + } + + protected override _fillBody(container: HTMLElement): void { + const options: IDiffEditorOptions = { + scrollBeyondLastLine: true, + scrollbar: { + verticalScrollbarSize: 14, + horizontal: 'auto', + useShadows: true, + verticalHasArrows: false, + horizontalHasArrows: false + }, + overviewRulerLanes: 2, + fixedOverflowWidgets: true, + minimap: { enabled: false }, + renderSideBySide: false, + readOnly: true, + renderIndicators: false, + diffAlgorithm: 'experimental', + stickyScroll: { enabled: false } + }; + this.diffEditor = this._disposables.add(this.instantiationService.createInstance( + EmbeddedDiffEditorWidget, container, options, this.editor)); + StandaloneServices.get(ITextModelService).createModelReference(this.widget.previousRevisionUri['codeUri']).then(modelRef => { + this._disposables.add(modelRef); + this.diffEditor!.setModel({ original: modelRef.object.textEditorModel, modified: this.editor.getModel()! }); + }, error => { + console.error(error); + this.dispose(); + }); + } + + protected override _doLayoutBody(height: number, width: number): void { + super._doLayoutBody(height, width); + this.diffEditor?.layout({ height, width }); + this.height = height; + } + + protected override _onWidth(width: number): void { + const { diffEditor, height } = this; + if (diffEditor && height !== undefined) { + diffEditor.layout({ height, width }); + } + } + + protected override revealLine(lineNumber: number): void { + this.editor.revealLineInCenterIfOutsideViewport(lineNumber, ScrollType.Smooth); + } + + private applyTheme(theme: IColorTheme): void { + const borderColor = this.getBorderColor(theme) || Color.transparent; + this.style({ + arrowColor: borderColor, + frameColor: borderColor, + headerBackgroundColor: theme.getColor(peekViewTitleBackground) || Color.transparent, + primaryHeadingColor: theme.getColor(peekViewTitleForeground), + secondaryHeadingColor: theme.getColor(peekViewTitleInfoForeground) + }); + } + + private getBorderColor(theme: IColorTheme): Color | undefined { + const { currentChange } = this.widget; + if (!currentChange) { + return theme.getColor(peekViewBorder); + } + const { previousRange, currentRange } = currentChange; + if (LineRange.isEmpty(previousRange)) { + return theme.getColor(ScmColors.editorGutterAddedBackground); + } else if (LineRange.isEmpty(currentRange)) { + return theme.getColor(ScmColors.editorGutterDeletedBackground); + } else { + return theme.getColor(ScmColors.editorGutterModifiedBackground); + } + } +} 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..9cbde7e92d06b 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,40 @@ export class ScmContribution extends AbstractViewContribution impleme execute: () => this.acceptInput(), isEnabled: () => !!this.scmFocus.get() && !!this.acceptInputCommand() }); + commandRegistry.registerCommand(SCM_COMMANDS.GOTO_NEXT_CHANGE, { + execute: () => this.dirtyDiffNavigator.gotoNextChange(), + isEnabled: () => this.dirtyDiffNavigator.canNavigate() + }); + commandRegistry.registerCommand(SCM_COMMANDS.GOTO_PREVIOUS_CHANGE, { + execute: () => this.dirtyDiffNavigator.gotoPreviousChange(), + isEnabled: () => this.dirtyDiffNavigator.canNavigate() + }); + commandRegistry.registerCommand(SCM_COMMANDS.SHOW_NEXT_CHANGE, { + execute: () => this.dirtyDiffNavigator.showNextChange(), + isEnabled: () => this.dirtyDiffNavigator.canShowChange() + }); + commandRegistry.registerCommand(SCM_COMMANDS.SHOW_PREVIOUS_CHANGE, { + execute: () => this.dirtyDiffNavigator.showPreviousChange(), + isEnabled: () => this.dirtyDiffNavigator.canShowChange() + }); + commandRegistry.registerCommand(SCM_COMMANDS.CLOSE_CHANGE_PEEK_VIEW, { + execute: () => this.dirtyDiffNavigator.closeChangePeekView(), + isEnabled: () => this.dirtyDiffNavigator.isShowingChange() + }); + } + + 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 +286,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..0f70d6d29de62 100644 --- a/packages/scm/src/browser/style/dirty-diff-decorator.css +++ b/packages/scm/src/browser/style/dirty-diff-decorator.css @@ -41,7 +41,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" } ] } From 7c8fab3d914400b372ef1d7594a592964000c190 Mon Sep 17 00:00:00 2001 From: Vladimir Piskarev Date: Wed, 14 Feb 2024 17:01:42 +0300 Subject: [PATCH 02/14] Fix the found issue on Windows Ensure that backslashes in fsPath are escaped in the query JSON. --- .../scm/src/browser/decorations/scm-decorations-service.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/scm/src/browser/decorations/scm-decorations-service.ts b/packages/scm/src/browser/decorations/scm-decorations-service.ts index e30c9abb5d7c0..b0ba67e48421e 100644 --- a/packages/scm/src/browser/decorations/scm-decorations-service.ts +++ b/packages/scm/src/browser/decorations/scm-decorations-service.ts @@ -69,7 +69,8 @@ export class ScmDecorationsService { const currentRepo = this.scmService.selectedRepository; if (currentRepo) { try { - const uri = editor.uri.withScheme(currentRepo.provider.id).withQuery(`{"path":"${editor.uri['codeUri'].fsPath}","ref":"~"}`); + 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); try { const previousContent = await previousResource.readContents(); From 110735ede2d88d9a1d248ced826c365755fa9e8d Mon Sep 17 00:00:00 2001 From: Vladimir Piskarev Date: Wed, 14 Feb 2024 17:28:20 +0300 Subject: [PATCH 03/14] Fix the issue with commands not available in the command pallette Ensure that commands for dirty diff navigation are always available. This is consistent with behavior in VS Code, and also with other similar commands (such as `Next Problem/Previous Problem`) in Theia. --- packages/scm/src/browser/scm-contribution.ts | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/packages/scm/src/browser/scm-contribution.ts b/packages/scm/src/browser/scm-contribution.ts index 9cbde7e92d06b..98a46ece26452 100644 --- a/packages/scm/src/browser/scm-contribution.ts +++ b/packages/scm/src/browser/scm-contribution.ts @@ -194,24 +194,19 @@ export class ScmContribution extends AbstractViewContribution impleme isEnabled: () => !!this.scmFocus.get() && !!this.acceptInputCommand() }); commandRegistry.registerCommand(SCM_COMMANDS.GOTO_NEXT_CHANGE, { - execute: () => this.dirtyDiffNavigator.gotoNextChange(), - isEnabled: () => this.dirtyDiffNavigator.canNavigate() + execute: () => this.dirtyDiffNavigator.gotoNextChange() }); commandRegistry.registerCommand(SCM_COMMANDS.GOTO_PREVIOUS_CHANGE, { - execute: () => this.dirtyDiffNavigator.gotoPreviousChange(), - isEnabled: () => this.dirtyDiffNavigator.canNavigate() + execute: () => this.dirtyDiffNavigator.gotoPreviousChange() }); commandRegistry.registerCommand(SCM_COMMANDS.SHOW_NEXT_CHANGE, { - execute: () => this.dirtyDiffNavigator.showNextChange(), - isEnabled: () => this.dirtyDiffNavigator.canShowChange() + execute: () => this.dirtyDiffNavigator.showNextChange() }); commandRegistry.registerCommand(SCM_COMMANDS.SHOW_PREVIOUS_CHANGE, { - execute: () => this.dirtyDiffNavigator.showPreviousChange(), - isEnabled: () => this.dirtyDiffNavigator.canShowChange() + execute: () => this.dirtyDiffNavigator.showPreviousChange() }); commandRegistry.registerCommand(SCM_COMMANDS.CLOSE_CHANGE_PEEK_VIEW, { - execute: () => this.dirtyDiffNavigator.closeChangePeekView(), - isEnabled: () => this.dirtyDiffNavigator.isShowingChange() + execute: () => this.dirtyDiffNavigator.closeChangePeekView() }); } From 2c823315df25bc4ea23e7c4289778181f0023a13 Mon Sep 17 00:00:00 2001 From: Vladimir Piskarev Date: Fri, 16 Feb 2024 18:08:58 +0300 Subject: [PATCH 04/14] Adopt new version of `@theia/monaco-editor-core` --- .../menus/plugin-menu-command-adapter.ts | 2 +- .../src/browser/dirty-diff/dirty-diff-widget.ts | 17 +++++++++-------- 2 files changed, 10 insertions(+), 9 deletions(-) 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 1b27eb887c5fc..49eeb5e0a8727 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 @@ -23,7 +23,7 @@ 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 { ChangeRangeMapping, LineRange, NormalizedEmptyLineRange } from '@theia/scm/lib/browser/dirty-diff/diff-computer'; -import { IChange } from '@theia/monaco-editor-core/esm/vs/editor/common/diff/smartLinesDiffComputer'; +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'; diff --git a/packages/scm/src/browser/dirty-diff/dirty-diff-widget.ts b/packages/scm/src/browser/dirty-diff/dirty-diff-widget.ts index 9d733d9592a25..ac65f7a06e43f 100644 --- a/packages/scm/src/browser/dirty-diff/dirty-diff-widget.ts +++ b/packages/scm/src/browser/dirty-diff/dirty-diff-widget.ts @@ -27,12 +27,13 @@ import { StandaloneServices } from '@theia/monaco-editor-core/esm/vs/editor/stan import { IInstantiationService } from '@theia/monaco-editor-core/esm/vs/platform/instantiation/common/instantiation'; import { ICodeEditor } from '@theia/monaco-editor-core/esm/vs/editor/browser/editorBrowser'; import { IPosition, Position } from '@theia/monaco-editor-core/esm/vs/editor/common/core/position'; -import { IRange } from '@theia/monaco-editor-core/esm/vs/editor/common/core/range'; +import { IRange, Range } from '@theia/monaco-editor-core/esm/vs/editor/common/core/range'; import { IDiffEditorOptions } from '@theia/monaco-editor-core/esm/vs/editor/common/config/editorOptions'; import { EmbeddedDiffEditorWidget } from '@theia/monaco-editor-core/esm/vs/editor/browser/widget/embeddedCodeEditorWidget'; import { ITextModelService } from '@theia/monaco-editor-core/esm/vs/editor/common/services/resolverService'; import { Action, IAction } from '@theia/monaco-editor-core/esm/vs/base/common/actions'; import { Codicon } from '@theia/monaco-editor-core/esm/vs/base/common/codicons'; +import { ThemeIcon } from '@theia/monaco-editor-core/esm/vs/base/common/themables'; import { ScrollType } from '@theia/monaco-editor-core/esm/vs/editor/common/editorCommon'; import { Color } from '@theia/monaco-editor-core/esm/vs/base/common/color'; import { IColorTheme, IThemeService } from '@theia/monaco-editor-core/esm/vs/platform/theme/common/themeService'; @@ -297,11 +298,11 @@ class DirtyDiffPeekView extends PeekViewWidget { } } }); - actions.push(new Action('dirtydiff.next', nls.localizeByDefault('Show Next Change'), Codicon.arrowDown.classNames, true, + actions.push(new Action('dirtydiff.next', nls.localizeByDefault('Show Next Change'), ThemeIcon.asClassName(Codicon.arrowDown), true, () => this.widget.showNextChange())); - actions.push(new Action('dirtydiff.previous', nls.localizeByDefault('Show Previous Change'), Codicon.arrowUp.classNames, true, + actions.push(new Action('dirtydiff.previous', nls.localizeByDefault('Show Previous Change'), ThemeIcon.asClassName(Codicon.arrowUp), true, () => this.widget.showPreviousChange())); - actions.push(new Action('peekview.close', nls.localizeByDefault('Close'), Codicon.close.classNames, true, + actions.push(new Action('peekview.close', nls.localizeByDefault('Close'), ThemeIcon.asClassName(Codicon.close), true, () => this.dispose())); actionBar.clear(); actionBar.push(actions, { label: false, icon: true }); @@ -327,11 +328,11 @@ class DirtyDiffPeekView extends PeekViewWidget { renderSideBySide: false, readOnly: true, renderIndicators: false, - diffAlgorithm: 'experimental', + diffAlgorithm: 'advanced', stickyScroll: { enabled: false } }; this.diffEditor = this._disposables.add(this.instantiationService.createInstance( - EmbeddedDiffEditorWidget, container, options, this.editor)); + EmbeddedDiffEditorWidget, container, options, {}, this.editor)); StandaloneServices.get(ITextModelService).createModelReference(this.widget.previousRevisionUri['codeUri']).then(modelRef => { this._disposables.add(modelRef); this.diffEditor!.setModel({ original: modelRef.object.textEditorModel, modified: this.editor.getModel()! }); @@ -354,8 +355,8 @@ class DirtyDiffPeekView extends PeekViewWidget { } } - protected override revealLine(lineNumber: number): void { - this.editor.revealLineInCenterIfOutsideViewport(lineNumber, ScrollType.Smooth); + protected override revealRange(range: Range): void { + this.editor.revealLineInCenterIfOutsideViewport(range.endLineNumber, ScrollType.Smooth); } private applyTheme(theme: IColorTheme): void { From ab6717b84223de1059a527d67bac52f3e97b98f1 Mon Sep 17 00:00:00 2001 From: Vladimir Piskarev Date: Wed, 21 Feb 2024 14:30:39 +0300 Subject: [PATCH 05/14] Fix the flickering issue with removed line decoration on hover --- packages/scm/src/browser/style/dirty-diff-decorator.css | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/scm/src/browser/style/dirty-diff-decorator.css b/packages/scm/src/browser/style/dirty-diff-decorator.css index 0f70d6d29de62..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 { From f1e9f994496437d31c0e57d4be9f0d83212dba69 Mon Sep 17 00:00:00 2001 From: Vladimir Piskarev Date: Thu, 29 Feb 2024 16:08:08 +0300 Subject: [PATCH 06/14] Properly dispose `updateTask` in `ScmDecorationsService` Cancel the updateTask when the corresponding editor is disposed. --- .../scm/src/browser/decorations/scm-decorations-service.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/scm/src/browser/decorations/scm-decorations-service.ts b/packages/scm/src/browser/decorations/scm-decorations-service.ts index b0ba67e48421e..a6eb7f7cc2c48 100644 --- a/packages/scm/src/browser/decorations/scm-decorations-service.ts +++ b/packages/scm/src/browser/decorations/scm-decorations-service.ts @@ -37,7 +37,7 @@ export class ScmDecorationsService { @inject(EditorManager) protected readonly editorManager: EditorManager, @inject(ResourceProvider) protected readonly resourceProvider: ResourceProvider ) { - const updateTasks = new Map void>(); + const updateTasks = new Map(); this.editorManager.onCreated(editorWidget => { const { editor } = editorWidget; if (editor.uri.scheme !== 'file') { @@ -48,6 +48,7 @@ export class ScmDecorationsService { updateTasks.set(editorWidget, updateTask); toDispose.push(editor.onDocumentContentChanged(() => updateTask())); editorWidget.disposed.connect(() => { + updateTask.cancel(); updateTasks.delete(editorWidget); toDispose.dispose(); }); @@ -90,7 +91,7 @@ export class ScmDecorationsService { } } - protected createUpdateTask(editor: TextEditor): () => void { + protected createUpdateTask(editor: TextEditor): { (): void; cancel(): void; } { return throttle(() => this.applyEditorDecorations(editor), 500); } } From 04002a5c3bc0d9f445aa8790c804e166bf68f8ce Mon Sep 17 00:00:00 2001 From: Vladimir Piskarev Date: Mon, 4 Mar 2024 15:43:03 +0300 Subject: [PATCH 07/14] Don't call StandaloneServices.get() in the constructor of inversify-constructed object --- .../browser/dirty-diff/dirty-diff-widget.ts | 39 +++++++++++++------ 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/packages/scm/src/browser/dirty-diff/dirty-diff-widget.ts b/packages/scm/src/browser/dirty-diff/dirty-diff-widget.ts index ac65f7a06e43f..b50efe17ad74e 100644 --- a/packages/scm/src/browser/dirty-diff/dirty-diff-widget.ts +++ b/packages/scm/src/browser/dirty-diff/dirty-diff-widget.ts @@ -14,8 +14,8 @@ // 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 { ActionMenuNode, Disposable, Event, MenuCommandExecutor, MenuModelRegistry, MenuPath, URI, nls } from '@theia/core'; +import { inject, injectable, postConstruct } from '@theia/core/shared/inversify'; +import { ActionMenuNode, Disposable, Emitter, Event, MenuCommandExecutor, MenuModelRegistry, MenuPath, URI, nls } from '@theia/core'; import { ContextKeyService } from '@theia/core/lib/browser/context-key-service'; import { MonacoEditor } from '@theia/monaco/lib/browser/monaco-editor'; import { ChangeRangeMapping, LineRange, NormalizedEmptyLineRange } from './diff-computer'; @@ -55,19 +55,23 @@ export type DirtyDiffWidgetFactory = (props: DirtyDiffWidgetProps) => DirtyDiffW @injectable() export class DirtyDiffWidget implements Disposable { - readonly onDidClose: Event; + private readonly onDidCloseEmitter = new Emitter(); + readonly onDidClose: Event = this.onDidCloseEmitter.event; protected index: number = -1; - private readonly peekView: DirtyDiffPeekView; - private readonly diffEditorPromise: Promise; + private peekView?: DirtyDiffPeekView; + private diffEditorPromise?: Promise; constructor( @inject(DirtyDiffWidgetProps) protected readonly props: DirtyDiffWidgetProps, @inject(ContextKeyService) readonly contextKeyService: ContextKeyService, @inject(MenuModelRegistry) readonly menuModelRegistry: MenuModelRegistry, @inject(MenuCommandExecutor) readonly menuCommandExecutor: MenuCommandExecutor - ) { + ) { } + + @postConstruct() + create(): void { this.peekView = new DirtyDiffPeekView(this); - this.onDidClose = this.peekView.onDidClose; + this.peekView.onDidClose(e => this.onDidCloseEmitter.fire(e)); this.diffEditorPromise = this.peekView.create(); } @@ -96,6 +100,7 @@ export class DirtyDiffWidget implements Disposable { } showChange(index: number): void { + this.checkCreated(); if (index >= 0 && index < this.changes.length) { this.index = index; this.showCurrentChange(); @@ -103,6 +108,7 @@ export class DirtyDiffWidget implements Disposable { } showNextChange(): void { + this.checkCreated(); const index = this.index; const length = this.changes.length; if (length > 0 && (index < 0 || length > 1)) { @@ -112,6 +118,7 @@ export class DirtyDiffWidget implements Disposable { } showPreviousChange(): void { + this.checkCreated(); const index = this.index; const length = this.changes.length; if (length > 0 && (index < 0 || length > 1)) { @@ -121,22 +128,24 @@ export class DirtyDiffWidget implements Disposable { } async getContentWithSelectedChanges(predicate: (change: ChangeRangeMapping, index: number, changes: readonly ChangeRangeMapping[]) => boolean): Promise { + this.checkCreated(); const changes = this.changes.filter(predicate); - const diffEditor = await this.diffEditorPromise; + const diffEditor = await this.diffEditorPromise!; const diffEditorModel = diffEditor.getModel()!; return applyChanges(changes, diffEditorModel.original, diffEditorModel.modified); } dispose(): void { - this.peekView.dispose(); + this.peekView?.dispose(); + this.onDidCloseEmitter.dispose(); } protected showCurrentChange(): void { - this.peekView.setTitle(this.computePrimaryHeading(), this.computeSecondaryHeading()); + this.peekView!.setTitle(this.computePrimaryHeading(), this.computeSecondaryHeading()); const { previousRange, currentRange } = this.changes[this.index]; - this.peekView.show(new Position(LineRange.getEndPosition(currentRange).line + 1, 1), // monaco position is 1-based + this.peekView!.show(new Position(LineRange.getEndPosition(currentRange).line + 1, 1), // monaco position is 1-based this.computeHeightInLines()); - this.diffEditorPromise.then(diffEditor => { + 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 @@ -173,6 +182,12 @@ export class DirtyDiffWidget implements Disposable { 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 { From 260cce2993e86cd7017fa9430116913ad6b7d909 Mon Sep 17 00:00:00 2001 From: Vladimir Piskarev Date: Mon, 4 Mar 2024 17:59:25 +0300 Subject: [PATCH 08/14] Rename `ChangeRangeMapping` to `RangeMapping` --- .../browser/menus/plugin-menu-command-adapter.ts | 4 ++-- packages/scm/src/browser/dirty-diff/diff-computer.ts | 6 +++--- .../src/browser/dirty-diff/dirty-diff-navigator.ts | 4 ++-- .../scm/src/browser/dirty-diff/dirty-diff-widget.ts | 12 ++++++------ 4 files changed, 13 insertions(+), 13 deletions(-) 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 49eeb5e0a8727..c1e10d97c5de9 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 @@ -22,7 +22,7 @@ import { TreeWidgetSelection } from '@theia/core/lib/browser/tree/tree-widget-se 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 { ChangeRangeMapping, LineRange, NormalizedEmptyLineRange } from '@theia/scm/lib/browser/dirty-diff/diff-computer'; +import { RangeMapping, LineRange, NormalizedEmptyLineRange } 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'; @@ -236,7 +236,7 @@ export class PluginMenuCommandAdapter implements MenuCommandAdapter { protected toScmChangeArgs(...args: any[]): any[] { const arg = args[0]; if (arg instanceof DirtyDiffWidget) { - const toIChange = (change: ChangeRangeMapping): IChange => { + const toIChange = (change: RangeMapping): IChange => { const convert = (range: LineRange | NormalizedEmptyLineRange): [number, number] => { let startLineNumber; let endLineNumber; diff --git a/packages/scm/src/browser/dirty-diff/diff-computer.ts b/packages/scm/src/browser/dirty-diff/diff-computer.ts index 78cf41f5f0385..fc919e4bd8243 100644 --- a/packages/scm/src/browser/dirty-diff/diff-computer.ts +++ b/packages/scm/src/browser/dirty-diff/diff-computer.ts @@ -29,7 +29,7 @@ export class DiffComputer { const added: LineRange[] = []; const removed: number[] = []; const modified: LineRange[] = []; - const rangeMappings: ChangeRangeMapping[] | undefined = options?.rangeMappings ? [] : undefined; + const rangeMappings: RangeMapping[] | undefined = options?.rangeMappings ? [] : undefined; const changes = this.computeDiff(previous, current); let currentRevisionLine = -1; let previousRevisionLine = -1; @@ -161,7 +161,7 @@ export interface DirtyDiff { /** * Range mappings for the diff, if {@link DirtyDiffOptions.rangeMappings requested}. */ - readonly rangeMappings?: ChangeRangeMapping[]; + readonly rangeMappings?: RangeMapping[]; } /** @@ -227,7 +227,7 @@ export namespace EmptyLineRange { } } -export type ChangeRangeMapping = AddedRangeMapping | RemovedRangeMapping | ModifiedRangeMapping; +export type RangeMapping = AddedRangeMapping | RemovedRangeMapping | ModifiedRangeMapping; export interface AddedRangeMapping { previousRange: NormalizedEmptyLineRange; diff --git a/packages/scm/src/browser/dirty-diff/dirty-diff-navigator.ts b/packages/scm/src/browser/dirty-diff/dirty-diff-navigator.ts index 99b2b6b49d1bd..0f4f090557b06 100644 --- a/packages/scm/src/browser/dirty-diff/dirty-diff-navigator.ts +++ b/packages/scm/src/browser/dirty-diff/dirty-diff-navigator.ts @@ -19,7 +19,7 @@ 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 { ChangeRangeMapping, LineRange } from './diff-computer'; +import { RangeMapping, LineRange } from './diff-computer'; import { DirtyDiffUpdate } from './dirty-diff-decorator'; import { DirtyDiffWidget, DirtyDiffWidgetFactory } from './dirty-diff-widget'; @@ -199,7 +199,7 @@ export class DirtyDiffController implements Disposable { } } - protected get changes(): readonly ChangeRangeMapping[] | undefined { + protected get changes(): readonly RangeMapping[] | undefined { return this.dirtyDiff?.rangeMappings; } diff --git a/packages/scm/src/browser/dirty-diff/dirty-diff-widget.ts b/packages/scm/src/browser/dirty-diff/dirty-diff-widget.ts index b50efe17ad74e..fdca7914ec121 100644 --- a/packages/scm/src/browser/dirty-diff/dirty-diff-widget.ts +++ b/packages/scm/src/browser/dirty-diff/dirty-diff-widget.ts @@ -18,7 +18,7 @@ import { inject, injectable, postConstruct } from '@theia/core/shared/inversify' import { ActionMenuNode, Disposable, Emitter, Event, MenuCommandExecutor, MenuModelRegistry, MenuPath, URI, nls } from '@theia/core'; import { ContextKeyService } from '@theia/core/lib/browser/context-key-service'; import { MonacoEditor } from '@theia/monaco/lib/browser/monaco-editor'; -import { ChangeRangeMapping, LineRange, NormalizedEmptyLineRange } from './diff-computer'; +import { RangeMapping, LineRange, NormalizedEmptyLineRange } from './diff-computer'; import { ScmColors } from '../scm-colors'; import * as monaco from '@theia/monaco-editor-core'; import { PeekViewWidget, peekViewBorder, peekViewTitleBackground, peekViewTitleForeground, peekViewTitleInfoForeground } @@ -46,7 +46,7 @@ export const DirtyDiffWidgetProps = Symbol('DirtyDiffWidgetProps'); export interface DirtyDiffWidgetProps { readonly editor: MonacoEditor; readonly previousRevisionUri: URI; - readonly changes: readonly ChangeRangeMapping[]; + readonly changes: readonly RangeMapping[]; } export const DirtyDiffWidgetFactory = Symbol('DirtyDiffWidgetFactory'); @@ -87,11 +87,11 @@ export class DirtyDiffWidget implements Disposable { return this.props.previousRevisionUri; } - get changes(): readonly ChangeRangeMapping[] { + get changes(): readonly RangeMapping[] { return this.props.changes; } - get currentChange(): ChangeRangeMapping | undefined { + get currentChange(): RangeMapping | undefined { return this.changes[this.index]; } @@ -127,7 +127,7 @@ export class DirtyDiffWidget implements Disposable { } } - async getContentWithSelectedChanges(predicate: (change: ChangeRangeMapping, index: number, changes: readonly ChangeRangeMapping[]) => boolean): Promise { + async getContentWithSelectedChanges(predicate: (change: RangeMapping, index: number, changes: readonly RangeMapping[]) => boolean): Promise { this.checkCreated(); const changes = this.changes.filter(predicate); const diffEditor = await this.diffEditorPromise!; @@ -195,7 +195,7 @@ function cycle(index: number, offset: -1 | 1, length: number): number { } // adapted from https://github.com/microsoft/vscode/blob/823d54f86ee13eb357bc6e8e562e89d793f3c43b/extensions/git/src/staging.ts -function applyChanges(changes: readonly ChangeRangeMapping[], original: monaco.editor.ITextModel, modified: monaco.editor.ITextModel): string { +function applyChanges(changes: readonly RangeMapping[], original: monaco.editor.ITextModel, modified: monaco.editor.ITextModel): string { const result: string[] = []; let currentLine = 1; From d1a953794cb64149b461ecc8d9aee6f8425ee4f1 Mon Sep 17 00:00:00 2001 From: Vladimir Piskarev Date: Mon, 11 Mar 2024 18:01:18 +0300 Subject: [PATCH 09/14] Added a requested comment to `ScmDecorationsService` --- packages/scm/src/browser/decorations/scm-decorations-service.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/scm/src/browser/decorations/scm-decorations-service.ts b/packages/scm/src/browser/decorations/scm-decorations-service.ts index a6eb7f7cc2c48..c56792e542d8a 100644 --- a/packages/scm/src/browser/decorations/scm-decorations-service.ts +++ b/packages/scm/src/browser/decorations/scm-decorations-service.ts @@ -70,6 +70,8 @@ export class ScmDecorationsService { const currentRepo = this.scmService.selectedRepository; if (currentRepo) { try { + // 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); From 622c35421098b3c6b8e81612bea27685d8693546 Mon Sep 17 00:00:00 2001 From: Vladimir Piskarev Date: Thu, 14 Mar 2024 14:36:01 +0300 Subject: [PATCH 10/14] Remove an unnecessary cast --- packages/scm/src/browser/dirty-diff/diff-computer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/scm/src/browser/dirty-diff/diff-computer.ts b/packages/scm/src/browser/dirty-diff/diff-computer.ts index fc919e4bd8243..86f9702098bb2 100644 --- a/packages/scm/src/browser/dirty-diff/diff-computer.ts +++ b/packages/scm/src/browser/dirty-diff/diff-computer.ts @@ -95,7 +95,7 @@ export class DiffComputer { previousRevisionLine += change.count!; } } - return { added, removed, modified, rangeMappings }; + return { added, removed, modified, rangeMappings }; } } From 6429895175d415c497440625c5f91c28cc3ba74b Mon Sep 17 00:00:00 2001 From: Vladimir Piskarev Date: Fri, 15 Mar 2024 14:58:22 +0300 Subject: [PATCH 11/14] Introduce `TextEditor.shouldDisplayDirtyDiff()` method --- packages/editor/src/browser/editor.ts | 2 ++ packages/git/src/browser/dirty-diff/dirty-diff-manager.ts | 8 ++++++-- packages/monaco/src/browser/monaco-diff-editor.ts | 3 +++ packages/monaco/src/browser/monaco-editor.ts | 3 +++ .../src/browser/decorations/scm-decorations-service.ts | 6 +++++- 5 files changed, 19 insertions(+), 3 deletions(-) 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-manager.ts b/packages/git/src/browser/dirty-diff/dirty-diff-manager.ts index edfa7081abfc2..ba185ac212a86 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); diff --git a/packages/monaco/src/browser/monaco-diff-editor.ts b/packages/monaco/src/browser/monaco-diff-editor.ts index b631ebb8f95da..74be5debb22d4 100644 --- a/packages/monaco/src/browser/monaco-diff-editor.ts +++ b/packages/monaco/src/browser/monaco-diff-editor.ts @@ -97,4 +97,7 @@ export class MonacoDiffEditor extends MonacoEditor { return DiffUris.encode(left.withPath(resourceUri.path), right.withPath(resourceUri.path)); } + override shouldDisplayDirtyDiff(): boolean { + return false; + } } diff --git a/packages/monaco/src/browser/monaco-editor.ts b/packages/monaco/src/browser/monaco-editor.ts index 350c6293e519f..847acddec0d10 100644 --- a/packages/monaco/src/browser/monaco-editor.ts +++ b/packages/monaco/src/browser/monaco-editor.ts @@ -589,6 +589,9 @@ export class MonacoEditor extends MonacoEditorServices implements TextEditor { return this.uri.withPath(resourceUri.path); } + shouldDisplayDirtyDiff(): boolean { + return true; + } } export namespace MonacoEditor { diff --git a/packages/scm/src/browser/decorations/scm-decorations-service.ts b/packages/scm/src/browser/decorations/scm-decorations-service.ts index c56792e542d8a..e83886220a944 100644 --- a/packages/scm/src/browser/decorations/scm-decorations-service.ts +++ b/packages/scm/src/browser/decorations/scm-decorations-service.ts @@ -40,7 +40,7 @@ export class ScmDecorationsService { const updateTasks = new Map(); this.editorManager.onCreated(editorWidget => { const { editor } = editorWidget; - if (editor.uri.scheme !== 'file') { + if (!this.supportsDirtyDiff(editor)) { return; } const toDispose = new DisposableCollection(); @@ -93,6 +93,10 @@ export class ScmDecorationsService { } } + protected supportsDirtyDiff(editor: TextEditor): boolean { + return editor.shouldDisplayDirtyDiff(); + } + protected createUpdateTask(editor: TextEditor): { (): void; cancel(): void; } { return throttle(() => this.applyEditorDecorations(editor), 500); } From 1d6ce07cc5acea68e0d9ebd057d89593aef7fb32 Mon Sep 17 00:00:00 2001 From: Vladimir Piskarev Date: Mon, 18 Mar 2024 14:22:03 +0300 Subject: [PATCH 12/14] Revise some of the dirty diff related types --- CHANGELOG.md | 12 +- .../browser/dirty-diff/dirty-diff-manager.ts | 5 +- .../menus/plugin-menu-command-adapter.ts | 10 +- .../decorations/scm-decorations-service.ts | 3 +- .../browser/dirty-diff/diff-computer.spec.ts | 173 ++++++---------- .../src/browser/dirty-diff/diff-computer.ts | 188 ++++++------------ .../dirty-diff/dirty-diff-decorator.ts | 25 ++- .../dirty-diff/dirty-diff-navigator.ts | 6 +- .../browser/dirty-diff/dirty-diff-widget.ts | 23 +-- 9 files changed, 167 insertions(+), 278 deletions(-) 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/git/src/browser/dirty-diff/dirty-diff-manager.ts b/packages/git/src/browser/dirty-diff/dirty-diff-manager.ts index ba185ac212a86..31ede34ee21f0 100644 --- a/packages/git/src/browser/dirty-diff/dirty-diff-manager.ts +++ b/packages/git/src/browser/dirty-diff/dirty-diff-manager.ts @@ -188,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) { @@ -286,8 +286,7 @@ export namespace DirtyDiffModel { */ export function computeDirtyDiff(previous: ContentLines, current: ContentLines): DirtyDiff | undefined { try { - return diffComputer.computeDirtyDiff(ContentLines.arrayLike(previous), ContentLines.arrayLike(current), - { rangeMappings: true }); + return diffComputer.computeDirtyDiff(ContentLines.arrayLike(previous), ContentLines.arrayLike(current)); } catch { return undefined; } 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 c1e10d97c5de9..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 @@ -22,7 +22,7 @@ import { TreeWidgetSelection } from '@theia/core/lib/browser/tree/tree-widget-se 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 { RangeMapping, LineRange, NormalizedEmptyLineRange } from '@theia/scm/lib/browser/dirty-diff/diff-computer'; +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'; @@ -236,15 +236,15 @@ export class PluginMenuCommandAdapter implements MenuCommandAdapter { protected toScmChangeArgs(...args: any[]): any[] { const arg = args[0]; if (arg instanceof DirtyDiffWidget) { - const toIChange = (change: RangeMapping): IChange => { - const convert = (range: LineRange | NormalizedEmptyLineRange): [number, number] => { + 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 + 1; + endLineNumber = range.end; } else { - startLineNumber = range.start === 0 ? 0 : range.end + 1; + startLineNumber = range.start; endLineNumber = 0; } return [startLineNumber, endLineNumber]; diff --git a/packages/scm/src/browser/decorations/scm-decorations-service.ts b/packages/scm/src/browser/decorations/scm-decorations-service.ts index e83886220a944..c597dd91385dc 100644 --- a/packages/scm/src/browser/decorations/scm-decorations-service.ts +++ b/packages/scm/src/browser/decorations/scm-decorations-service.ts @@ -79,8 +79,7 @@ export class ScmDecorationsService { 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), - { rangeMappings: true }); + 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); 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 56d57dcc4a55b..62ebf18cd51f5 100644 --- a/packages/scm/src/browser/dirty-diff/diff-computer.spec.ts +++ b/packages/scm/src/browser/dirty-diff/diff-computer.spec.ts @@ -18,7 +18,7 @@ import * as chai from 'chai'; import { expect } from 'chai'; chai.use(require('chai-string')); -import { DiffComputer, DirtyDiff, EmptyLineRange } from './diff-computer'; +import { DiffComputer, DirtyDiff } from './diff-computer'; import { ContentLines } from './content-lines'; let diffComputer: DiffComputer; @@ -42,13 +42,10 @@ describe('dirty-diff-computer', () => { ], ); expect(dirtyDiff).to.be.deep.equal({ - added: [], - modified: [], - removed: [0], - rangeMappings: [ + changes: [ { - previousRange: { start: 1, end: 1 }, - currentRange: EmptyLineRange.afterLine(0), + previousRange: { start: 1, end: 2 }, + currentRange: { start: 1, end: 1 }, }, ], }); @@ -62,13 +59,10 @@ describe('dirty-diff-computer', () => { sequenceOfN(2), ); expect(dirtyDiff).to.be.deep.equal({ - modified: [], - removed: [1], - added: [], - rangeMappings: [ + changes: [ { - previousRange: { start: 2, end: 2 + lines - 1 }, - currentRange: EmptyLineRange.afterLine(1), + previousRange: { start: 2, end: 2 + lines }, + currentRange: { start: 2, end: 2 }, }, ], }); @@ -82,13 +76,10 @@ describe('dirty-diff-computer', () => { [''] ); expect(dirtyDiff).to.be.deep.equal({ - added: [], - modified: [], - removed: [0], - rangeMappings: [ + changes: [ { - previousRange: { start: 0, end: numberOfLines - 1 }, - currentRange: EmptyLineRange.atBeginning, + previousRange: { start: 0, end: numberOfLines }, + currentRange: { start: 0, end: 0 }, }, ], }); @@ -102,13 +93,10 @@ describe('dirty-diff-computer', () => { sequenceOfN(2), ); expect(dirtyDiff).to.be.deep.equal({ - modified: [], - removed: [0], - added: [], - rangeMappings: [ + changes: [ { - previousRange: { start: 0, end: lines - 1 }, - currentRange: EmptyLineRange.atBeginning, + previousRange: { start: 0, end: lines }, + currentRange: { start: 0, end: 0 }, }, ], }); @@ -121,13 +109,10 @@ 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 }], - rangeMappings: [ + changes: [ { - previousRange: EmptyLineRange.afterLine(1), - currentRange: { start: 2, end: 2 + lines - 1 }, + previousRange: { start: 2, end: 2 }, + currentRange: { start: 2, end: 2 + lines }, }, ], }); @@ -142,13 +127,10 @@ describe('dirty-diff-computer', () => { .concat(sequenceOfN(2)) ); expect(dirtyDiff).to.be.deep.equal({ - modified: [], - removed: [], - added: [{ start: 0, end: lines - 1 }], - rangeMappings: [ + changes: [ { - previousRange: EmptyLineRange.atBeginning, - currentRange: { start: 0, end: lines - 1 }, + previousRange: { start: 0, end: 0 }, + currentRange: { start: 0, end: lines }, }, ], }); @@ -162,13 +144,10 @@ describe('dirty-diff-computer', () => { sequenceOfN(numberOfLines, () => 'ADDED LINE') ); expect(dirtyDiff).to.be.deep.equal({ - modified: [], - removed: [], - added: [{ start: 0, end: numberOfLines - 1 }], - rangeMappings: [ + changes: [ { - previousRange: EmptyLineRange.atBeginning, - currentRange: { start: 0, end: numberOfLines - 1 }, + previousRange: { start: 0, end: 0 }, + currentRange: { start: 0, end: numberOfLines }, }, ], }); @@ -188,13 +167,10 @@ describe('dirty-diff-computer', () => { ] ); expect(dirtyDiff).to.be.deep.equal({ - modified: [], - removed: [], - added: [{ start: 1, end: 2 }], - rangeMappings: [ + changes: [ { - previousRange: EmptyLineRange.afterLine(0), - currentRange: { start: 1, end: 2 }, + previousRange: { start: 1, end: 1 }, + currentRange: { start: 1, end: 3 }, }, ], }); @@ -211,13 +187,10 @@ describe('dirty-diff-computer', () => { ] ); expect(dirtyDiff).to.be.deep.equal({ - modified: [], - removed: [], - added: [{ start: 1, end: 1 }], - rangeMappings: [ + changes: [ { - previousRange: EmptyLineRange.afterLine(0), - currentRange: { start: 1, end: 1 }, + previousRange: { start: 1, end: 1 }, + currentRange: { start: 1, end: 2 }, }, ], }); @@ -231,13 +204,10 @@ describe('dirty-diff-computer', () => { .concat(new Array(lines).map(() => '')) ); expect(dirtyDiff).to.be.deep.equal({ - modified: [], - removed: [], - added: [{ start: 2, end: 2 + lines - 1 }], - rangeMappings: [ + changes: [ { - previousRange: EmptyLineRange.afterLine(1), - currentRange: { start: 2, end: 2 + lines - 1 }, + previousRange: { start: 2, end: 2 }, + currentRange: { start: 2, end: 2 + lines }, }, ], }); @@ -261,13 +231,10 @@ describe('dirty-diff-computer', () => { ] ); expect(dirtyDiff).to.be.deep.equal({ - modified: [], - removed: [], - added: [{ start: 1, end: 5 }], - rangeMappings: [ + changes: [ { - previousRange: EmptyLineRange.afterLine(0), - currentRange: { start: 1, end: 5 }, + previousRange: { start: 1, end: 1 }, + currentRange: { start: 1, end: 6 }, }, ], }); @@ -280,13 +247,10 @@ describe('dirty-diff-computer', () => { ['0'].concat(sequenceOfN(lines, () => 'ADDED LINE')) ); expect(dirtyDiff).to.be.deep.equal({ - modified: [], - removed: [], - added: [{ start: 1, end: lines }], - rangeMappings: [ + changes: [ { - previousRange: EmptyLineRange.afterLine(0), - currentRange: { start: 1, end: lines }, + previousRange: { start: 1, end: 1 }, + currentRange: { start: 1, end: lines + 1 }, }, ], }); @@ -307,13 +271,10 @@ describe('dirty-diff-computer', () => { ] ); expect(dirtyDiff).to.be.deep.equal({ - removed: [], - added: [], - modified: [{ start: 1, end: 1 }], - rangeMappings: [ + changes: [ { - previousRange: { start: 1, end: 1 }, - currentRange: { start: 1, end: 1 }, + previousRange: { start: 1, end: 2 }, + currentRange: { start: 1, end: 2 }, }, ], }); @@ -326,13 +287,10 @@ describe('dirty-diff-computer', () => { sequenceOfN(numberOfLines, () => 'MODIFIED') ); expect(dirtyDiff).to.be.deep.equal({ - removed: [], - added: [], - modified: [{ start: 0, end: numberOfLines - 1 }], - rangeMappings: [ + changes: [ { - previousRange: { start: 0, end: numberOfLines - 1 }, - currentRange: { start: 0, end: numberOfLines - 1 }, + previousRange: { start: 0, end: numberOfLines }, + currentRange: { start: 0, end: numberOfLines }, }, ], }); @@ -353,13 +311,10 @@ describe('dirty-diff-computer', () => { ] ); expect(dirtyDiff).to.be.deep.equal({ - removed: [], - added: [], - modified: [{ start: 1, end: 2 }], - rangeMappings: [ + changes: [ { - previousRange: { start: 1, end: 3 }, - currentRange: { start: 1, end: 2 }, + previousRange: { start: 1, end: 4 }, + currentRange: { start: 1, end: 3 }, }, ], }); @@ -396,21 +351,18 @@ describe('dirty-diff-computer', () => { ] ); expect(dirtyDiff).to.be.deep.equal({ - removed: [3], - added: [{ start: 10, end: 11 }], - modified: [{ start: 0, end: 0 }], - rangeMappings: [ + changes: [ { - previousRange: { start: 0, end: 0 }, - currentRange: { start: 0, end: 0 }, + previousRange: { start: 0, end: 1 }, + currentRange: { start: 0, end: 1 }, }, { - previousRange: { start: 4, end: 4 }, - currentRange: EmptyLineRange.afterLine(3), + previousRange: { start: 4, end: 5 }, + currentRange: { start: 4, end: 4 }, }, { - previousRange: EmptyLineRange.afterLine(10), - currentRange: { start: 10, end: 11 }, + previousRange: { start: 11, end: 11 }, + currentRange: { start: 10, end: 12 }, }, ], }); @@ -445,21 +397,18 @@ describe('dirty-diff-computer', () => { '' ]); expect(dirtyDiff).to.be.deep.equal({ - removed: [11], - added: [{ start: 5, end: 5 }, { start: 9, end: 9 }], - modified: [], - rangeMappings: [ + changes: [ { - previousRange: EmptyLineRange.afterLine(4), - currentRange: { start: 5, end: 5 }, + previousRange: { start: 5, end: 5 }, + currentRange: { start: 5, end: 6 }, }, { - previousRange: EmptyLineRange.afterLine(7), - currentRange: { start: 9, end: 9 }, + previousRange: { start: 8, end: 8 }, + currentRange: { start: 9, end: 10 }, }, { - previousRange: { start: 9, end: 9 }, - currentRange: EmptyLineRange.afterLine(11), + previousRange: { start: 9, end: 10 }, + currentRange: { start: 12, end: 12 }, }, ], }); @@ -488,7 +437,7 @@ function computeDirtyDiff(previous: string[], modified: string[]): DirtyDiff { return value; }, }); - return diffComputer.computeDirtyDiff(a, b, { rangeMappings: true }); + return diffComputer.computeDirtyDiff(a, b); } function sequenceOfN(n: number, mapFn: (index: number) => string = i => i.toString()): string[] { diff --git a/packages/scm/src/browser/dirty-diff/diff-computer.ts b/packages/scm/src/browser/dirty-diff/diff-computer.ts index 86f9702098bb2..38b95e591798b 100644 --- a/packages/scm/src/browser/dirty-diff/diff-computer.ts +++ b/packages/scm/src/browser/dirty-diff/diff-computer.ts @@ -16,7 +16,7 @@ import * as jsdiff from 'diff'; import { ContentLinesArrayLike } from './content-lines'; -import { Position } from '@theia/core/shared/vscode-languageserver-protocol'; +import { Position, Range, uinteger } from '@theia/core/shared/vscode-languageserver-protocol'; export class DiffComputer { @@ -25,69 +25,45 @@ export class DiffComputer { return diffResult; } - computeDirtyDiff(previous: ContentLinesArrayLike, current: ContentLinesArrayLike, options?: DirtyDiffOptions): DirtyDiff { - const added: LineRange[] = []; - const removed: number[] = []; - const modified: LineRange[] = []; - const rangeMappings: RangeMapping[] | undefined = options?.rangeMappings ? [] : undefined; - const changes = this.computeDiff(previous, current); + computeDirtyDiff(previous: ContentLinesArrayLike, current: ContentLinesArrayLike): DirtyDiff { + const changes: Change[] = []; + const diffResult = this.computeDiff(previous, current); let currentRevisionLine = -1; let previousRevisionLine = -1; - for (let i = 0; i < changes.length; i++) { - const change = changes[i]; - const next = changes[i + 1]; + for (let i = 0; i < diffResult.length; i++) { + const change = diffResult[i]; + const next = diffResult[i + 1]; if (change.added) { // case: addition - const currentRange = toLineRange(change); - added.push(currentRange); - if (rangeMappings) { - rangeMappings.push({ previousRange: EmptyLineRange.afterLine(previousRevisionLine), currentRange }); - } + 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); - if (rangeMappings) { - rangeMappings.push({ previousRange: toLineRange(change), currentRange: EmptyLineRange.atBeginning }); - } + changes.push({ previousRange: toLineRange(change), currentRange: LineRange.createEmptyLineRange(0) }); previousRevisionLine += change.count!; } else if (isFirstChange && isPrevEmptyLine) { // special case: adding at the beginning - const currentRange = toLineRange(next); - added.push(currentRange); - if (rangeMappings) { - rangeMappings.push({ previousRange: EmptyLineRange.atBeginning, currentRange }); - } + changes.push({ previousRange: LineRange.createEmptyLineRange(0), currentRange: toLineRange(next) }); currentRevisionLine += next.count!; } else if (isLastChange && isNextEmptyLine) { - removed.push(currentRevisionLine + 1 /* = empty line */); - if (rangeMappings) { - rangeMappings.push({ previousRange: toLineRange(change), currentRange: EmptyLineRange.afterLine(currentRevisionLine + 1) }); - } + changes.push({ previousRange: toLineRange(change), currentRange: LineRange.createEmptyLineRange(currentRevisionLine + 2) }); previousRevisionLine += change.count!; } else { // default case is a modification - const currentRange = toLineRange(next); - modified.push(currentRange); - if (rangeMappings) { - rangeMappings.push({ previousRange: toLineRange(change), currentRange }); - } + 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)) { // case: removal - removed.push(Math.max(0, currentRevisionLine)); - if (rangeMappings) { - rangeMappings.push({ previousRange: toLineRange(change), currentRange: EmptyLineRange.afterLine(currentRevisionLine) }); - } + changes.push({ previousRange: toLineRange(change), currentRange: LineRange.createEmptyLineRange(currentRevisionLine + 1) }); previousRevisionLine += change.count!; } else { // case: unchanged region @@ -95,7 +71,7 @@ export class DiffComputer { previousRevisionLine += change.count!; } } - return { added, removed, modified, rangeMappings }; + return { changes }; } } @@ -128,7 +104,7 @@ function diffArrays(oldArr: ContentLinesArrayLike, newArr: ContentLinesArrayLike function toLineRange({ value }: DiffResult): LineRange { const [start, end] = value; - return { start, end }; + return LineRange.create(start, end + 1); } export interface DiffResult { @@ -138,108 +114,64 @@ export interface DiffResult { removed?: boolean; } -export interface DirtyDiffOptions { - /** - * Indicates whether {@link DirtyDiff.rangeMappings} need to be computed. - */ - rangeMappings?: boolean; -} - 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[]; - /** - * Range mappings for the diff, if {@link DirtyDiffOptions.rangeMappings requested}. - */ - readonly rangeMappings?: RangeMapping[]; + readonly changes: readonly Change[]; } -/** - * Represents a range that starts at the beginning of the {@link start} line - * and spans up to the end of the {@link end} line. - */ -export interface LineRange { - start: number; - end: number; +export interface Change { + readonly previousRange: LineRange; + readonly currentRange: LineRange; } -/** - * Represents a range that starts and ends either at the beginning of the {@link start} line or at the end of the {@link end} line. - */ -export type EmptyLineRange = { start: number; end?: undefined; } | { start?: undefined; end: number }; +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); + } +} -/** - * Represents a range that starts and ends either at the beginning of the file or at the end of the {@link end} line. - */ -export type NormalizedEmptyLineRange = { start: 0; end?: undefined; } | { start?: undefined; end: number }; +export interface LineRange { + readonly start: number; + readonly end: number; +} export namespace LineRange { - export function isEmpty(range: LineRange | EmptyLineRange): range is EmptyLineRange { - return range.start === undefined || range.end === undefined; - } - export function getStartPosition(range: LineRange | EmptyLineRange): Position { - if (range.start === undefined) { - return Position.create(range.end, Number.MAX_SAFE_INTEGER); + 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 Position.create(range.start, 0); + return { start, end }; } - export function getEndPosition(range: LineRange | EmptyLineRange): Position { - if (range.end === undefined) { - return Position.create(range.start, 0); - } - return Position.create(range.end, Number.MAX_SAFE_INTEGER); + 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 getLineCount(range: LineRange | EmptyLineRange): number { + export function getStartPosition(range: LineRange): Position { if (isEmpty(range)) { - return 0; + return getEndPosition(range); } - return range.end - range.start + 1; + return Position.create(range.start, 0); } -} - -export namespace EmptyLineRange { - /** - * A {@link NormalizedEmptyLineRange} that starts and ends at the beginning of the file. - */ - export const atBeginning: { readonly start: 0 } = { start: 0 }; - - /** - * Returns a {@link NormalizedEmptyLineRange} positioned just after the given line. - * @param line line, after which an empty line range is to be returned. - * May be negative, in which case an empty line range at the beginning of the file is returned - * @returns an empty line range that starts and ends just after the given line - */ - export function afterLine(line: number): NormalizedEmptyLineRange { - if (line < 0) { - return atBeginning; + export function getEndPosition(range: LineRange): Position { + if (range.end < 1) { + return Position.create(0, 0); } - return { end: line }; + 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; } -} - -export type RangeMapping = AddedRangeMapping | RemovedRangeMapping | ModifiedRangeMapping; - -export interface AddedRangeMapping { - previousRange: NormalizedEmptyLineRange; - currentRange: LineRange; -} - -export interface RemovedRangeMapping { - previousRange: LineRange; - currentRange: NormalizedEmptyLineRange; -} - -export interface ModifiedRangeMapping { - previousRange: LineRange; - currentRange: LineRange; } 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 17de0bfa0711e..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,7 @@ 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 { @@ -85,6 +83,16 @@ 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; @@ -94,16 +102,13 @@ export interface DirtyDiffUpdate extends DirtyDiff { 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-navigator.ts b/packages/scm/src/browser/dirty-diff/dirty-diff-navigator.ts index 0f4f090557b06..d765797337617 100644 --- a/packages/scm/src/browser/dirty-diff/dirty-diff-navigator.ts +++ b/packages/scm/src/browser/dirty-diff/dirty-diff-navigator.ts @@ -19,7 +19,7 @@ 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 { RangeMapping, LineRange } from './diff-computer'; +import { Change, LineRange } from './diff-computer'; import { DirtyDiffUpdate } from './dirty-diff-decorator'; import { DirtyDiffWidget, DirtyDiffWidgetFactory } from './dirty-diff-widget'; @@ -199,8 +199,8 @@ export class DirtyDiffController implements Disposable { } } - protected get changes(): readonly RangeMapping[] | undefined { - return this.dirtyDiff?.rangeMappings; + protected get changes(): readonly Change[] | undefined { + return this.dirtyDiff?.changes; } protected get previousRevisionUri(): URI | undefined { diff --git a/packages/scm/src/browser/dirty-diff/dirty-diff-widget.ts b/packages/scm/src/browser/dirty-diff/dirty-diff-widget.ts index fdca7914ec121..0f0b440499aa5 100644 --- a/packages/scm/src/browser/dirty-diff/dirty-diff-widget.ts +++ b/packages/scm/src/browser/dirty-diff/dirty-diff-widget.ts @@ -18,7 +18,7 @@ import { inject, injectable, postConstruct } from '@theia/core/shared/inversify' import { ActionMenuNode, Disposable, Emitter, Event, MenuCommandExecutor, MenuModelRegistry, MenuPath, URI, nls } from '@theia/core'; import { ContextKeyService } from '@theia/core/lib/browser/context-key-service'; import { MonacoEditor } from '@theia/monaco/lib/browser/monaco-editor'; -import { RangeMapping, LineRange, NormalizedEmptyLineRange } from './diff-computer'; +import { Change, LineRange } from './diff-computer'; import { ScmColors } from '../scm-colors'; import * as monaco from '@theia/monaco-editor-core'; import { PeekViewWidget, peekViewBorder, peekViewTitleBackground, peekViewTitleForeground, peekViewTitleInfoForeground } @@ -46,7 +46,7 @@ export const DirtyDiffWidgetProps = Symbol('DirtyDiffWidgetProps'); export interface DirtyDiffWidgetProps { readonly editor: MonacoEditor; readonly previousRevisionUri: URI; - readonly changes: readonly RangeMapping[]; + readonly changes: readonly Change[]; } export const DirtyDiffWidgetFactory = Symbol('DirtyDiffWidgetFactory'); @@ -87,11 +87,11 @@ export class DirtyDiffWidget implements Disposable { return this.props.previousRevisionUri; } - get changes(): readonly RangeMapping[] { + get changes(): readonly Change[] { return this.props.changes; } - get currentChange(): RangeMapping | undefined { + get currentChange(): Change | undefined { return this.changes[this.index]; } @@ -127,7 +127,7 @@ export class DirtyDiffWidget implements Disposable { } } - async getContentWithSelectedChanges(predicate: (change: RangeMapping, index: number, changes: readonly RangeMapping[]) => boolean): Promise { + 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!; @@ -195,7 +195,7 @@ function cycle(index: number, offset: -1 | 1, length: number): number { } // adapted from https://github.com/microsoft/vscode/blob/823d54f86ee13eb357bc6e8e562e89d793f3c43b/extensions/git/src/staging.ts -function applyChanges(changes: readonly RangeMapping[], original: monaco.editor.ITextModel, modified: monaco.editor.ITextModel): string { +function applyChanges(changes: readonly Change[], original: monaco.editor.ITextModel, modified: monaco.editor.ITextModel): string { const result: string[] = []; let currentLine = 1; @@ -205,14 +205,14 @@ function applyChanges(changes: readonly RangeMapping[], original: monaco.editor. const isInsertion = LineRange.isEmpty(previousRange); const isDeletion = LineRange.isEmpty(currentRange); - const convert = (range: LineRange | NormalizedEmptyLineRange): [number, number] => { + const convert = (range: LineRange): [number, number] => { let startLineNumber; let endLineNumber; if (!LineRange.isEmpty(range)) { startLineNumber = range.start + 1; - endLineNumber = range.end + 1; + endLineNumber = range.end; } else { - startLineNumber = range.start === 0 ? 0 : range.end + 1; + startLineNumber = range.start; endLineNumber = 0; } return [startLineNumber, endLineNumber]; @@ -390,10 +390,9 @@ class DirtyDiffPeekView extends PeekViewWidget { if (!currentChange) { return theme.getColor(peekViewBorder); } - const { previousRange, currentRange } = currentChange; - if (LineRange.isEmpty(previousRange)) { + if (Change.isAddition(currentChange)) { return theme.getColor(ScmColors.editorGutterAddedBackground); - } else if (LineRange.isEmpty(currentRange)) { + } else if (Change.isRemoval(currentChange)) { return theme.getColor(ScmColors.editorGutterDeletedBackground); } else { return theme.getColor(ScmColors.editorGutterModifiedBackground); From 7f2b3d8ea59317475a9fc21854e83ffbe41b3632 Mon Sep 17 00:00:00 2001 From: Vladimir Piskarev Date: Mon, 18 Mar 2024 19:27:32 +0300 Subject: [PATCH 13/14] Add a comment that commands for dirty diff navigation need to be always available --- packages/scm/src/browser/scm-contribution.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/scm/src/browser/scm-contribution.ts b/packages/scm/src/browser/scm-contribution.ts index 98a46ece26452..f896363f313de 100644 --- a/packages/scm/src/browser/scm-contribution.ts +++ b/packages/scm/src/browser/scm-contribution.ts @@ -193,6 +193,10 @@ 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() }); From 616187f6852ab7fa1ecccf64f4c01b2ece015f7d Mon Sep 17 00:00:00 2001 From: Vladimir Piskarev Date: Tue, 16 Apr 2024 13:02:02 +0300 Subject: [PATCH 14/14] Get rid of monaco *internal* API use in `dirty-diff-widget.ts` Adds the necessary API to the Theia `monaco` package and uses it instead of using monaco internal API directly. --- .../monaco/src/browser/monaco-diff-editor.ts | 52 +++- .../browser/monaco-diff-navigator-factory.ts | 4 +- .../browser/monaco-editor-peek-view-widget.ts | 233 ++++++++++++++++++ .../src/browser/monaco-editor-provider.ts | 37 +++ packages/monaco/src/browser/monaco-editor.ts | 69 +++++- .../browser/dirty-diff/dirty-diff-widget.ts | 193 ++++++--------- 6 files changed, 461 insertions(+), 127 deletions(-) create mode 100644 packages/monaco/src/browser/monaco-editor-peek-view-widget.ts diff --git a/packages/monaco/src/browser/monaco-diff-editor.ts b/packages/monaco/src/browser/monaco-diff-editor.ts index 74be5debb22d4..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; } @@ -101,3 +111,31 @@ export class MonacoDiffEditor extends MonacoEditor { 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 847acddec0d10..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 { @@ -664,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/scm/src/browser/dirty-diff/dirty-diff-widget.ts b/packages/scm/src/browser/dirty-diff/dirty-diff-widget.ts index 0f0b440499aa5..4fe5d5b5e58e9 100644 --- a/packages/scm/src/browser/dirty-diff/dirty-diff-widget.ts +++ b/packages/scm/src/browser/dirty-diff/dirty-diff-widget.ts @@ -15,28 +15,18 @@ // ***************************************************************************** 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'; -import { PeekViewWidget, peekViewBorder, peekViewTitleBackground, peekViewTitleForeground, peekViewTitleInfoForeground } - from '@theia/monaco-editor-core/esm/vs/editor/contrib/peekView/browser/peekView'; -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 { ICodeEditor } from '@theia/monaco-editor-core/esm/vs/editor/browser/editorBrowser'; -import { IPosition, Position } from '@theia/monaco-editor-core/esm/vs/editor/common/core/position'; -import { IRange, Range } from '@theia/monaco-editor-core/esm/vs/editor/common/core/range'; -import { IDiffEditorOptions } from '@theia/monaco-editor-core/esm/vs/editor/common/config/editorOptions'; -import { EmbeddedDiffEditorWidget } from '@theia/monaco-editor-core/esm/vs/editor/browser/widget/embeddedCodeEditorWidget'; -import { ITextModelService } from '@theia/monaco-editor-core/esm/vs/editor/common/services/resolverService'; -import { Action, IAction } from '@theia/monaco-editor-core/esm/vs/base/common/actions'; -import { Codicon } from '@theia/monaco-editor-core/esm/vs/base/common/codicons'; -import { ThemeIcon } from '@theia/monaco-editor-core/esm/vs/base/common/themables'; -import { ScrollType } from '@theia/monaco-editor-core/esm/vs/editor/common/editorCommon'; -import { Color } from '@theia/monaco-editor-core/esm/vs/base/common/color'; -import { IColorTheme, IThemeService } from '@theia/monaco-editor-core/esm/vs/platform/theme/common/themeService'; export const SCM_CHANGE_TITLE_MENU: MenuPath = ['scm-change-title-menu']; /** Reserved for plugin contributions, corresponds to contribution point 'scm/change/title'. */ @@ -59,10 +49,11 @@ export class DirtyDiffWidget implements Disposable { readonly onDidClose: Event = this.onDidCloseEmitter.event; protected index: number = -1; private peekView?: DirtyDiffPeekView; - private diffEditorPromise?: Promise; + 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 @@ -130,7 +121,7 @@ export class DirtyDiffWidget implements Disposable { 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 { diffEditor } = await this.diffEditorPromise!; const diffEditorModel = diffEditor.getModel()!; return applyChanges(changes, diffEditorModel.original, diffEditorModel.modified); } @@ -143,9 +134,9 @@ export class DirtyDiffWidget implements Disposable { protected showCurrentChange(): void { this.peekView!.setTitle(this.computePrimaryHeading(), this.computeSecondaryHeading()); const { previousRange, currentRange } = this.changes[this.index]; - this.peekView!.show(new Position(LineRange.getEndPosition(currentRange).line + 1, 1), // monaco position is 1-based + this.peekView!.show(Position.create(LineRange.getEndPosition(currentRange).line, 0), this.computeHeightInLines()); - this.diffEditorPromise!.then(diffEditor => { + 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 @@ -257,46 +248,63 @@ function applyChanges(changes: readonly Change[], original: monaco.editor.ITextM return result.join(''); } -class DirtyDiffPeekView extends PeekViewWidget { +class DirtyDiffPeekView extends MonacoEditorPeekViewWidget { - private diffEditor?: EmbeddedDiffEditorWidget; + private diffEditorPromise?: Promise; private height?: number; constructor(readonly widget: DirtyDiffWidget) { - super( - widget.editor.getControl() as unknown as ICodeEditor, - { isResizeable: true, showArrow: true, frameWidth: 1, keepEditorSelection: true, className: 'dirty-diff' }, - StandaloneServices.get(IInstantiationService) - ); - StandaloneServices.get(IThemeService).onDidColorThemeChange(this.applyTheme, this, this._disposables); + super(widget.editor, { isResizeable: true, showArrow: true, frameWidth: 1, keepEditorSelection: true, className: 'dirty-diff' }); } - override create(): Promise { - super.create(); - const { diffEditor } = this; - return new Promise(resolve => { + 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!.onDidUpdateDiff(() => setTimeout(() => { - resolve(diffEditor! as unknown as monaco.editor.IDiffEditor); - disposable.dispose(); - })); - }); + const disposable = diffEditor.diffEditor.onDidUpdateDiff(() => setTimeout(() => { + resolve(diffEditor); + disposable.dispose(); + })); + }); + } catch (e) { + this.dispose(); + throw e; + } } - override show(rangeOrPos: IRange | IPosition, heightInLines: number): void { - this.applyTheme(StandaloneServices.get(IThemeService).getColorTheme()); + 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 updateActions(): void { - const actionBar = this._actionbarWidget; - if (!actionBar) { - return; + 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; } - const actions: IAction[] = []; + } + + 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]) { @@ -305,97 +313,52 @@ class DirtyDiffPeekView extends PeekViewWidget { if (item instanceof ActionMenuNode) { const { command, id, label, icon, when } = item; if (icon && menuCommandExecutor.isVisible(menuPath, command, this.widget) && (!when || contextKeyService.match(when))) { - actions.push(new Action(id, label, icon, menuCommandExecutor.isEnabled(menuPath, command, this.widget), () => { + this.addAction(id, label, icon, menuCommandExecutor.isEnabled(menuPath, command, this.widget), () => { menuCommandExecutor.executeCommand(menuPath, command, this.widget); - })); + }); } } } } }); - actions.push(new Action('dirtydiff.next', nls.localizeByDefault('Show Next Change'), ThemeIcon.asClassName(Codicon.arrowDown), true, - () => this.widget.showNextChange())); - actions.push(new Action('dirtydiff.previous', nls.localizeByDefault('Show Previous Change'), ThemeIcon.asClassName(Codicon.arrowUp), true, - () => this.widget.showPreviousChange())); - actions.push(new Action('peekview.close', nls.localizeByDefault('Close'), ThemeIcon.asClassName(Codicon.close), true, - () => this.dispose())); - actionBar.clear(); - actionBar.push(actions, { label: false, icon: true }); + 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 fillHead(container: HTMLElement): void { + super.fillHead(container, true); } - protected override _fillBody(container: HTMLElement): void { - const options: IDiffEditorOptions = { - scrollBeyondLastLine: true, - scrollbar: { - verticalScrollbarSize: 14, - horizontal: 'auto', - useShadows: true, - verticalHasArrows: false, - horizontalHasArrows: false - }, - overviewRulerLanes: 2, - fixedOverflowWidgets: true, - minimap: { enabled: false }, - renderSideBySide: false, - readOnly: true, - renderIndicators: false, - diffAlgorithm: 'advanced', - stickyScroll: { enabled: false } - }; - this.diffEditor = this._disposables.add(this.instantiationService.createInstance( - EmbeddedDiffEditorWidget, container, options, {}, this.editor)); - StandaloneServices.get(ITextModelService).createModelReference(this.widget.previousRevisionUri['codeUri']).then(modelRef => { - this._disposables.add(modelRef); - this.diffEditor!.setModel({ original: modelRef.object.textEditorModel, modified: this.editor.getModel()! }); - }, error => { - console.error(error); - this.dispose(); + 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.diffEditor?.layout({ height, width }); + 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 { - const { diffEditor, height } = this; - if (diffEditor && height !== undefined) { - diffEditor.layout({ height, width }); + protected override onWidth(width: number): void { + super.onWidth(width); + const { height } = this; + if (height !== undefined) { + this.layout(height, width); } } - protected override revealRange(range: Range): void { - this.editor.revealLineInCenterIfOutsideViewport(range.endLineNumber, ScrollType.Smooth); - } - - private applyTheme(theme: IColorTheme): void { - const borderColor = this.getBorderColor(theme) || Color.transparent; - this.style({ - arrowColor: borderColor, - frameColor: borderColor, - headerBackgroundColor: theme.getColor(peekViewTitleBackground) || Color.transparent, - primaryHeadingColor: theme.getColor(peekViewTitleForeground), - secondaryHeadingColor: theme.getColor(peekViewTitleInfoForeground) - }); + private layout(height: number, width: number): void { + this.diffEditorPromise?.then(({ diffEditor }) => diffEditor.layout({ height, width })); } - private getBorderColor(theme: IColorTheme): Color | undefined { - const { currentChange } = this.widget; - if (!currentChange) { - return theme.getColor(peekViewBorder); - } - if (Change.isAddition(currentChange)) { - return theme.getColor(ScmColors.editorGutterAddedBackground); - } else if (Change.isRemoval(currentChange)) { - return theme.getColor(ScmColors.editorGutterDeletedBackground); - } else { - return theme.getColor(ScmColors.editorGutterModifiedBackground); - } + protected override doRevealRange(range: Range): void { + this.editor.revealPosition(Position.create(range.end.line, 0), { vertical: 'centerIfOutsideViewport' }); } }