From 8b551e9df0457dd87e8200165b21b13a63d3b8f0 Mon Sep 17 00:00:00 2001 From: Vladimir Piskarev Date: Mon, 27 Nov 2023 14:11:31 +0300 Subject: [PATCH] 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 0a3cccc5e4593..e3459b6cb6a3f 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], @@ -226,6 +230,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 bf6ea3df16029..c0300132a1ccb 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'; @@ -52,6 +53,7 @@ export const implementedVSCodeContributionPoints = [ 'editor/title/run', 'editor/lineNumber/context', 'explorer/context', + 'scm/change/title', 'scm/resourceFolder/context', 'scm/resourceGroup/context', 'scm/resourceState/context', @@ -80,6 +82,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 23dfd44e7cbd2..2f84e1fd0726a 100644 --- a/packages/scm/package.json +++ b/packages/scm/package.json @@ -6,6 +6,8 @@ "@theia/core": "1.45.0", "@theia/editor": "1.45.0", "@theia/filesystem": "1.45.0", + "@theia/monaco": "1.45.0", + "@theia/monaco-editor-core": "1.72.3", "@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" } ] }