diff --git a/build/npm/postinstall.js b/build/npm/postinstall.js index d8703441b7c..7a0b972546d 100644 --- a/build/npm/postinstall.js +++ b/build/npm/postinstall.js @@ -34,7 +34,8 @@ const extensions = [ 'git', 'gulp', 'grunt', - 'jake' + 'jake', + 'merge-conflict' ]; extensions.forEach(extension => npmInstall(`extensions/${extension}`)); diff --git a/extensions/git/src/main.ts b/extensions/git/src/main.ts index edc5f6c19cc..f75ed0fef00 100644 --- a/extensions/git/src/main.ts +++ b/extensions/git/src/main.ts @@ -13,7 +13,6 @@ import { CommandCenter } from './commands'; import { StatusBarCommands } from './statusbar'; import { GitContentProvider } from './contentProvider'; import { AutoFetcher } from './autofetch'; -import { MergeDecorator } from './merge'; import { Askpass } from './askpass'; import { toDisposable } from './util'; import TelemetryReporter from 'vscode-extension-telemetry'; @@ -58,14 +57,12 @@ async function init(context: ExtensionContext, disposables: Disposable[]): Promi const provider = new GitSCMProvider(model, commandCenter, statusBarCommands); const contentProvider = new GitContentProvider(model); const autoFetcher = new AutoFetcher(model); - const mergeDecorator = new MergeDecorator(model); disposables.push( commandCenter, provider, contentProvider, autoFetcher, - mergeDecorator, model ); diff --git a/extensions/git/src/merge.ts b/extensions/git/src/merge.ts deleted file mode 100644 index 457e966d89a..00000000000 --- a/extensions/git/src/merge.ts +++ /dev/null @@ -1,96 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -'use strict'; - -import { window, workspace, Disposable, TextEditor, TextDocument, Range } from 'vscode'; -import { Model, Status } from './model'; -import { filterEvent } from './util'; -import { debounce } from './decorators'; -import { iterate } from './iterators'; - -function* lines(document: TextDocument): IterableIterator { - for (let i = 0; i < document.lineCount; i++) { - yield document.lineAt(i).text; - } -} - -const pattern = /^<<<<<<<|^=======|^>>>>>>>/; - -function decorate(document: TextDocument): Range[] { - return iterate(lines(document)) - .map((line, i) => pattern.test(line) ? i : null) - .filter(i => i !== null) - .map((i: number) => new Range(i, 1, i, 1)) - .toArray(); -} - -class TextEditorMergeDecorator { - - private static DecorationType = window.createTextEditorDecorationType({ - backgroundColor: 'rgba(255, 139, 0, 0.3)', - isWholeLine: true, - dark: { - backgroundColor: 'rgba(235, 59, 0, 0.3)' - } - }); - - private uri: string; - private disposables: Disposable[] = []; - - constructor( - private model: Model, - private editor: TextEditor - ) { - this.uri = this.editor.document.uri.toString(); - - const onDidChange = filterEvent(workspace.onDidChangeTextDocument, e => e.document && e.document.uri.toString() === this.uri); - onDidChange(this.redecorate, this, this.disposables); - model.onDidChange(this.redecorate, this, this.disposables); - - this.redecorate(); - } - - @debounce(300) - private redecorate(): void { - let decorations: Range[] = []; - - if (window.visibleTextEditors.every(e => e !== this.editor)) { - this.dispose(); - return; - } - - if (this.model.mergeGroup.resources.some(r => r.type === Status.BOTH_MODIFIED && r.resourceUri.toString() === this.uri)) { - decorations = decorate(this.editor.document); - } - - this.editor.setDecorations(TextEditorMergeDecorator.DecorationType, decorations); - } - - dispose(): void { - this.disposables.forEach(d => d.dispose()); - } -} - -export class MergeDecorator { - - private textEditorDecorators: TextEditorMergeDecorator[] = []; - private disposables: Disposable[] = []; - - constructor(private model: Model) { - window.onDidChangeVisibleTextEditors(this.onDidChangeVisibleTextEditors, this, this.disposables); - this.onDidChangeVisibleTextEditors(window.visibleTextEditors); - } - - private onDidChangeVisibleTextEditors(editors: TextEditor[]): void { - this.textEditorDecorators.forEach(d => d.dispose()); - this.textEditorDecorators = editors.map(e => new TextEditorMergeDecorator(this.model, e)); - } - - dispose(): void { - this.textEditorDecorators.forEach(d => d.dispose()); - this.disposables.forEach(d => d.dispose()); - } -} diff --git a/extensions/merge-conflict/package.json b/extensions/merge-conflict/package.json new file mode 100644 index 00000000000..1d099d83c23 --- /dev/null +++ b/extensions/merge-conflict/package.json @@ -0,0 +1,127 @@ +{ + "name": "merge-conflict", + "publisher": "vscode", + "displayName": "merge-conflict", + "description": "Merge Conflict", + "version": "0.7.0", + "aiKey": "AIF-d9b70cd4-b9f9-4d70-929b-a071c400b217", + "engines": { + "vscode": "^1.5.0" + }, + "categories": [ + "Other" + ], + "activationEvents": [ + "*" + ], + "main": "./out/extension", + "scripts": { + "compile": "gulp compile-extension:merge-conflict", + "watch": "gulp watch-extension:merge-conflict" + }, + "contributes": { + "commands": [ + { + "category": "%command.category%", + "title": "%command.accept.all-incoming%", + "command": "merge-conflict.accept.all-incoming" + }, + { + "category": "%command.category%", + "title": "%command.accept.all-both%", + "command": "merge-conflict.accept.all-both" + }, + { + "category": "%command.category%", + "title": "%command.accept.current%", + "command": "merge-conflict.accept.current" + }, + { + "category": "%command.category%", + "title": "%command.accept.incoming%", + "command": "merge-conflict.accept.incoming" + }, + { + "category": "%command.category%", + "title": "Accept selection", + "command": "merge-conflict.accept.selection" + }, + { + "category": "%command.category%", + "title": "%command.accept.both%", + "command": "merge-conflict.accept.both" + }, + { + "category": "%command.category%", + "title": "%command.next%", + "command": "merge-conflict.next" + }, + { + "category": "%command.category%", + "title": "%command.previous%", + "command": "merge-conflict.previous" + }, + { + "category": "%command.category%", + "title": "%command.compare%", + "command": "merge-conflict.compare" + } + ], + "keybindings": [ + { + "command": "merge-conflict.next", + "when": "editorTextFocus", + "key": "alt+m down" + }, + { + "command": "merge-conflict.previous", + "when": "editorTextFocus", + "key": "alt+m up" + }, + { + "command": "merge-conflict.accept.selection", + "when": "editorTextFocus", + "key": "alt+m enter" + }, + { + "command": "merge-conflict.accept.current", + "when": "editorTextFocus", + "key": "alt+m 1" + }, + { + "command": "merge-conflict.accept.incoming", + "when": "editorTextFocus", + "key": "alt+m 2" + }, + { + "command": "merge-conflict.accept.both", + "when": "editorTextFocus", + "key": "alt+m 3" + } + ], + "configuration": { + "title": "%config.title%", + "properties": { + "merge-conflict.codeLens.enabled": { + "type": "boolean", + "description": "%config.codeLensEnabled%", + "default": true + }, + "merge-conflict.decorators.enabled": { + "type": "boolean", + "description": "%config.decoratorsEnabled%", + "default": true + } + } + } + }, + "dependencies": { + "vscode-extension-telemetry": "^0.0.7", + "vscode-nls": "^2.0.2" + }, + "devDependencies": { + "@types/mocha": "^2.2.41", + "@types/node": "^7.0.4", + "mocha": "^3.2.0" + } +} \ No newline at end of file diff --git a/extensions/merge-conflict/package.nls.json b/extensions/merge-conflict/package.nls.json new file mode 100644 index 00000000000..d991f872b44 --- /dev/null +++ b/extensions/merge-conflict/package.nls.json @@ -0,0 +1,15 @@ +{ + "command.category": "Merge Conflict", + "command.accept.all-incoming": "Accept all incoming", + "command.accept.all-both": "Accept all both", + "command.accept.current": "Accept current", + "command.accept.incoming": "Accept incoming", + "command.accept.selection": "Accept selection", + "command.accept.both": "Accept Both", + "command.next": "Next conflict", + "command.previous": "Previous conflict", + "command.compare": "Compare current conflict", + "config.title": "Merge Conflict", + "config.codeLensEnabled": "Enable/disable merge conflict block CodeLens within editor", + "config.decoratorsEnabled": "Enable/disable merge conflict decorators within editor" +} \ No newline at end of file diff --git a/extensions/merge-conflict/src/codelensProvider.ts b/extensions/merge-conflict/src/codelensProvider.ts new file mode 100644 index 00000000000..72135945054 --- /dev/null +++ b/extensions/merge-conflict/src/codelensProvider.ts @@ -0,0 +1,102 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import * as interfaces from './interfaces'; +import { loadMessageBundle } from 'vscode-nls'; +const localize = loadMessageBundle(); + +export default class MergeConflictCodeLensProvider implements vscode.CodeLensProvider, vscode.Disposable { + private codeLensRegistrationHandle: vscode.Disposable | null; + private config: interfaces.IExtensionConfiguration; + private tracker: interfaces.IDocumentMergeConflictTracker; + + constructor(private context: vscode.ExtensionContext, trackerService: interfaces.IDocumentMergeConflictTrackerService) { + this.tracker = trackerService.createTracker('codelens'); + } + + begin(config: interfaces.IExtensionConfiguration) { + this.config = config; + + if (this.config.enableCodeLens) { + this.registerCodeLensProvider(); + } + } + + configurationUpdated(updatedConfig: interfaces.IExtensionConfiguration) { + + if (updatedConfig.enableCodeLens === false && this.codeLensRegistrationHandle) { + this.codeLensRegistrationHandle.dispose(); + this.codeLensRegistrationHandle = null; + } + else if (updatedConfig.enableCodeLens === true && !this.codeLensRegistrationHandle) { + this.registerCodeLensProvider(); + } + + this.config = updatedConfig; + } + + + dispose() { + if (this.codeLensRegistrationHandle) { + this.codeLensRegistrationHandle.dispose(); + this.codeLensRegistrationHandle = null; + } + } + + async provideCodeLenses(document: vscode.TextDocument, token: vscode.CancellationToken): Promise { + + if (!this.config || !this.config.enableCodeLens) { + return null; + } + + let conflicts = await this.tracker.getConflicts(document); + + if (!conflicts || conflicts.length === 0) { + return null; + } + + let items: vscode.CodeLens[] = []; + + conflicts.forEach(conflict => { + let acceptCurrentCommand: vscode.Command = { + command: 'merge-conflict.accept.current', + title: localize('acceptCurrentChange', 'Accept current change'), + arguments: ['known-conflict', conflict] + }; + + let acceptIncomingCommand: vscode.Command = { + command: 'merge-conflict.accept.incoming', + title: localize('acceptIncomingChange', 'Accept incoming change'), + arguments: ['known-conflict', conflict] + }; + + let acceptBothCommand: vscode.Command = { + command: 'merge-conflict.accept.both', + title: localize('acceptBothChanges', 'Accept both changes'), + arguments: ['known-conflict', conflict] + }; + + let diffCommand: vscode.Command = { + command: 'merge-conflict.compare', + title: localize('compareChanges', 'Compare changes'), + arguments: [conflict] + }; + + items.push( + new vscode.CodeLens(conflict.range, acceptCurrentCommand), + new vscode.CodeLens(conflict.range.with(conflict.range.start.with({ character: conflict.range.start.character + 1 })), acceptIncomingCommand), + new vscode.CodeLens(conflict.range.with(conflict.range.start.with({ character: conflict.range.start.character + 2 })), acceptBothCommand), + new vscode.CodeLens(conflict.range.with(conflict.range.start.with({ character: conflict.range.start.character + 3 })), diffCommand) + ); + }); + + return items; + } + + private registerCodeLensProvider() { + this.codeLensRegistrationHandle = vscode.languages.registerCodeLensProvider({ pattern: '**/*' }, this); + } +} diff --git a/extensions/merge-conflict/src/commandHandler.ts b/extensions/merge-conflict/src/commandHandler.ts new file mode 100644 index 00000000000..8b8517063e4 --- /dev/null +++ b/extensions/merge-conflict/src/commandHandler.ts @@ -0,0 +1,278 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as vscode from 'vscode'; +import * as interfaces from './interfaces'; +import ContentProvider from './contentProvider'; +import * as path from 'path'; +import { loadMessageBundle } from 'vscode-nls'; +const localize = loadMessageBundle(); + +const messages = { + cursorNotInConflict: 'Editor cursor is not within a merge conflict', + cursorOnSplitterRange: 'Editor cursor is within the merge conflict splitter, please move it to either the "current" or "incoming" block', + noConflicts: 'No merge conflicts found in this file', + noOtherConflictsInThisFile: 'No other merge conflicts within this file' +}; + +interface IDocumentMergeConflictNavigationResults { + canNavigate: boolean; + conflict?: interfaces.IDocumentMergeConflict; +} + +enum NavigationDirection { + Forwards, + Backwards +} + +export default class CommandHandler implements vscode.Disposable { + + private disposables: vscode.Disposable[] = []; + private tracker: interfaces.IDocumentMergeConflictTracker; + + constructor(private context: vscode.ExtensionContext, trackerService: interfaces.IDocumentMergeConflictTrackerService) { + this.tracker = trackerService.createTracker('commands'); + } + + begin() { + this.disposables.push( + vscode.commands.registerTextEditorCommand('merge-conflict.accept.current', this.acceptCurrent, this), + vscode.commands.registerTextEditorCommand('merge-conflict.accept.incoming', this.acceptIncoming, this), + vscode.commands.registerTextEditorCommand('merge-conflict.accept.selection', this.acceptSelection, this), + vscode.commands.registerTextEditorCommand('merge-conflict.accept.both', this.acceptBoth, this), + vscode.commands.registerTextEditorCommand('merge-conflict.accept.all-current', this.acceptAllCurrent, this), + vscode.commands.registerTextEditorCommand('merge-conflict.accept.all-incoming', this.acceptAllIncoming, this), + vscode.commands.registerTextEditorCommand('merge-conflict.accept.all-both', this.acceptAllBoth, this), + vscode.commands.registerTextEditorCommand('merge-conflict.next', this.navigateNext, this), + vscode.commands.registerTextEditorCommand('merge-conflict.previous', this.navigatePrevious, this), + vscode.commands.registerTextEditorCommand('merge-conflict.compare', this.compare, this) + ); + } + + acceptCurrent(editor: vscode.TextEditor, edit: vscode.TextEditorEdit, ...args): Promise { + return this.accept(interfaces.CommitType.Current, editor, ...args); + } + + acceptIncoming(editor: vscode.TextEditor, edit: vscode.TextEditorEdit, ...args): Promise { + return this.accept(interfaces.CommitType.Incoming, editor, ...args); + } + + acceptBoth(editor: vscode.TextEditor, edit: vscode.TextEditorEdit, ...args): Promise { + return this.accept(interfaces.CommitType.Both, editor, ...args); + } + + acceptAllCurrent(editor: vscode.TextEditor, edit: vscode.TextEditorEdit, ...args): Promise { + return this.acceptAll(interfaces.CommitType.Current, editor); + } + + acceptAllIncoming(editor: vscode.TextEditor, edit: vscode.TextEditorEdit, ...args): Promise { + return this.acceptAll(interfaces.CommitType.Incoming, editor); + } + + acceptAllBoth(editor: vscode.TextEditor, edit: vscode.TextEditorEdit, ...args): Promise { + return this.acceptAll(interfaces.CommitType.Both, editor); + } + + async compare(editor: vscode.TextEditor, edit: vscode.TextEditorEdit, conflict: interfaces.IDocumentMergeConflict | null, ...args) { + const fileName = path.basename(editor.document.uri.fsPath); + + // No conflict, command executed from command palette + if (!conflict) { + conflict = await this.findConflictContainingSelection(editor); + + // Still failed to find conflict, warn the user and exit + if (!conflict) { + vscode.window.showWarningMessage(localize('cursorNotInConflict', messages.cursorNotInConflict)); + return; + } + } + + let range = conflict.current.content; + const leftUri = editor.document.uri.with({ + scheme: ContentProvider.scheme, + query: JSON.stringify(range) + }); + + range = conflict.incoming.content; + const rightUri = leftUri.with({ query: JSON.stringify(range) }); + + const title = localize('compareChangesTitle', '{0}: Current changes \u2194 Incoming changes', fileName); + vscode.commands.executeCommand('vscode.diff', leftUri, rightUri, title); + } + + navigateNext(editor: vscode.TextEditor, edit: vscode.TextEditorEdit, ...args): Promise { + return this.navigate(editor, NavigationDirection.Forwards); + } + + navigatePrevious(editor: vscode.TextEditor, edit: vscode.TextEditorEdit, ...args): Promise { + return this.navigate(editor, NavigationDirection.Backwards); + } + + async acceptSelection(editor: vscode.TextEditor, edit: vscode.TextEditorEdit, ...args): Promise { + let conflict = await this.findConflictContainingSelection(editor); + + if (!conflict) { + vscode.window.showWarningMessage(localize('cursorNotInConflict', messages.cursorNotInConflict)); + return; + } + + let typeToAccept: interfaces.CommitType; + + // Figure out if the cursor is in current or incoming, we do this by seeing if + // the active position is before or after the range of the splitter. We can + // use this trick as the previous check in findConflictByActiveSelection will + // ensure it's within the conflict range, so we don't falsely identify "current" + // or "incoming" if outside of a conflict range. + if (editor.selection.active.isBefore(conflict.splitter.start)) { + typeToAccept = interfaces.CommitType.Current; + } + else if (editor.selection.active.isAfter(conflict.splitter.end)) { + typeToAccept = interfaces.CommitType.Incoming; + } + else { + vscode.window.showWarningMessage(localize('cursorOnSplitterRange', messages.cursorOnSplitterRange)); + return; + } + + this.tracker.forget(editor.document); + conflict.commitEdit(typeToAccept, editor); + } + + dispose() { + this.disposables.forEach(disposable => disposable.dispose()); + this.disposables = []; + } + + private async navigate(editor: vscode.TextEditor, direction: NavigationDirection): Promise { + let navigationResult = await this.findConflictForNavigation(editor, direction); + + if (!navigationResult) { + vscode.window.showWarningMessage(localize('noConflicts', messages.noConflicts)); + return; + } + else if (!navigationResult.canNavigate) { + vscode.window.showWarningMessage(localize('noOtherConflictsInThisFile', messages.noOtherConflictsInThisFile)); + return; + } + else if (!navigationResult.conflict) { + // TODO: Show error message? + return; + } + + // Move the selection to the first line of the conflict + editor.selection = new vscode.Selection(navigationResult.conflict.range.start, navigationResult.conflict.range.start); + editor.revealRange(navigationResult.conflict.range, vscode.TextEditorRevealType.Default); + } + + private async accept(type: interfaces.CommitType, editor: vscode.TextEditor, ...args): Promise { + + let conflict: interfaces.IDocumentMergeConflict | null; + + // If launched with known context, take the conflict from that + if (args[0] === 'known-conflict') { + conflict = args[1]; + } + else { + // Attempt to find a conflict that matches the current curosr position + conflict = await this.findConflictContainingSelection(editor); + } + + if (!conflict) { + vscode.window.showWarningMessage(localize('cursorNotInConflict', messages.cursorNotInConflict)); + return; + } + + // Tracker can forget as we know we are going to do an edit + this.tracker.forget(editor.document); + conflict.commitEdit(type, editor); + } + + private async acceptAll(type: interfaces.CommitType, editor: vscode.TextEditor): Promise { + let conflicts = await this.tracker.getConflicts(editor.document); + + if (!conflicts || conflicts.length === 0) { + vscode.window.showWarningMessage(localize('noConflicts', messages.noConflicts)); + return; + } + + // For get the current state of the document, as we know we are doing to do a large edit + this.tracker.forget(editor.document); + + // Apply all changes as one edit + await editor.edit((edit) => conflicts.forEach(conflict => { + conflict.applyEdit(type, editor, edit); + })); + } + + private async findConflictContainingSelection(editor: vscode.TextEditor, conflicts?: interfaces.IDocumentMergeConflict[]): Promise { + + if (!conflicts) { + conflicts = await this.tracker.getConflicts(editor.document); + } + + if (!conflicts || conflicts.length === 0) { + return null; + } + + for (let i = 0; i < conflicts.length; i++) { + if (conflicts[i].range.contains(editor.selection.active)) { + return conflicts[i]; + } + } + + return null; + } + + private async findConflictForNavigation(editor: vscode.TextEditor, direction: NavigationDirection, conflicts?: interfaces.IDocumentMergeConflict[]): Promise { + if (!conflicts) { + conflicts = await this.tracker.getConflicts(editor.document); + } + + if (!conflicts || conflicts.length === 0) { + return null; + } + + let selection = editor.selection.active; + if (conflicts.length === 1) { + if (conflicts[0].range.contains(selection)) { + return { + canNavigate: false + }; + } + + return { + canNavigate: true, + conflict: conflicts[0] + }; + } + + let predicate: (conflict) => boolean; + let fallback: () => interfaces.IDocumentMergeConflict; + + if (direction === NavigationDirection.Forwards) { + predicate = (conflict) => selection.isBefore(conflict.range.start); + fallback = () => conflicts![0]; + } else if (direction === NavigationDirection.Backwards) { + predicate = (conflict) => selection.isAfter(conflict.range.start); + fallback = () => conflicts![conflicts!.length - 1]; + } else { + throw new Error(`Unsupported direction ${direction}`); + } + + for (let i = 0; i < conflicts.length; i++) { + if (predicate(conflicts[i]) && !conflicts[i].range.contains(selection)) { + return { + canNavigate: true, + conflict: conflicts[i] + }; + } + } + + // Went all the way to the end, return the head + return { + canNavigate: true, + conflict: fallback() + }; + } +} \ No newline at end of file diff --git a/extensions/merge-conflict/src/contentProvider.ts b/extensions/merge-conflict/src/contentProvider.ts new file mode 100644 index 00000000000..910a4b6d2d0 --- /dev/null +++ b/extensions/merge-conflict/src/contentProvider.ts @@ -0,0 +1,38 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +'use strict'; +import * as vscode from 'vscode'; +import * as interfaces from './interfaces'; + +export default class MergeConflictContentProvider implements vscode.TextDocumentContentProvider, vscode.Disposable { + + static scheme = 'merge-conflict.conflict-diff'; + + constructor(private context: vscode.ExtensionContext) { + } + + begin(config: interfaces.IExtensionConfiguration) { + this.context.subscriptions.push( + vscode.workspace.registerTextDocumentContentProvider(MergeConflictContentProvider.scheme, this) + ); + } + + dispose() { + } + + async provideTextDocumentContent(uri: vscode.Uri): Promise { + try { + const [start, end] = JSON.parse(uri.query) as { line: number, character: number }[]; + + const document = await vscode.workspace.openTextDocument(uri.with({ scheme: 'file', query: '' })); + const text = document.getText(new vscode.Range(start.line, start.character, end.line, end.character)); + return text; + } + catch (ex) { + await vscode.window.showErrorMessage('Unable to show comparison'); + return null; + } + } +} \ No newline at end of file diff --git a/extensions/merge-conflict/src/delayer.ts b/extensions/merge-conflict/src/delayer.ts new file mode 100644 index 00000000000..59ec77ed503 --- /dev/null +++ b/extensions/merge-conflict/src/delayer.ts @@ -0,0 +1,80 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +'use strict'; + +export interface ITask { + (): T; +} + +export class Delayer { + + public defaultDelay: number; + private timeout: any; // Timer + private completionPromise: Promise | null; + private onSuccess: ((value?: T | Thenable | null) => void) | null; + private task: ITask | null; + + constructor(defaultDelay: number) { + this.defaultDelay = defaultDelay; + this.timeout = null; + this.completionPromise = null; + this.onSuccess = null; + this.task = null; + } + + public trigger(task: ITask, delay: number = this.defaultDelay): Promise { + this.task = task; + if (delay >= 0) { + this.cancelTimeout(); + } + + if (!this.completionPromise) { + this.completionPromise = new Promise((resolve) => { + this.onSuccess = resolve; + }).then(() => { + this.completionPromise = null; + this.onSuccess = null; + var result = this.task!(); + this.task = null; + return result; + }); + } + + if (delay >= 0 || this.timeout === null) { + this.timeout = setTimeout(() => { + this.timeout = null; + this.onSuccess!(null); + }, delay >= 0 ? delay : this.defaultDelay); + } + + return this.completionPromise; + } + + public forceDelivery(): Promise | null { + if (!this.completionPromise) { + return null; + } + this.cancelTimeout(); + let result = this.completionPromise; + this.onSuccess!(null); + return result; + } + + public isTriggered(): boolean { + return this.timeout !== null; + } + + public cancel(): void { + this.cancelTimeout(); + this.completionPromise = null; + } + + private cancelTimeout(): void { + if (this.timeout !== null) { + clearTimeout(this.timeout); + this.timeout = null; + } + } +} \ No newline at end of file diff --git a/extensions/merge-conflict/src/documentMergeConflict.ts b/extensions/merge-conflict/src/documentMergeConflict.ts new file mode 100644 index 00000000000..221ee8f36bc --- /dev/null +++ b/extensions/merge-conflict/src/documentMergeConflict.ts @@ -0,0 +1,76 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as interfaces from './interfaces'; +import * as vscode from 'vscode'; + +export class DocumentMergeConflict implements interfaces.IDocumentMergeConflict { + + public range: vscode.Range; + public current: interfaces.IMergeRegion; + public incoming: interfaces.IMergeRegion; + public splitter: vscode.Range; + + constructor(document: vscode.TextDocument, descriptor: interfaces.IDocumentMergeConflictDescriptor) { + this.range = descriptor.range; + this.current = descriptor.current; + this.incoming = descriptor.incoming; + this.splitter = descriptor.splitter; + } + + public commitEdit(type: interfaces.CommitType, editor: vscode.TextEditor, edit?: vscode.TextEditorEdit): Thenable { + + if (edit) { + + this.applyEdit(type, editor, edit); + return Promise.resolve(true); + }; + + return editor.edit((edit) => this.applyEdit(type, editor, edit)); + } + + public applyEdit(type: interfaces.CommitType, editor: vscode.TextEditor, edit: vscode.TextEditorEdit): void { + + // Each conflict is a set of ranges as follows, note placements or newlines + // which may not in in spans + // [ Conflict Range -- (Entire content below) + // [ Current Header ]\n -- >>>>> Header + // [ Current Content ] -- (content) + // [ Splitter ]\n -- ===== + // [ Incoming Content ] -- (content) + // [ Incoming Header ]\n -- <<<<< Incoming + // ] + if (type === interfaces.CommitType.Current) { + // Replace [ Conflict Range ] with [ Current Content ] + let content = editor.document.getText(this.current.content); + this.replaceRangeWithContent(content, edit); + } + else if (type === interfaces.CommitType.Incoming) { + let content = editor.document.getText(this.incoming.content); + this.replaceRangeWithContent(content, edit); + } + else if (type === interfaces.CommitType.Both) { + // Replace [ Conflict Range ] with [ Current Content ] + \n + [ Incoming Content ] + + const currentContent = editor.document.getText(this.current.content); + const incomingContent = editor.document.getText(this.incoming.content); + + edit.replace(this.range, currentContent.concat(incomingContent)); + } + } + + private replaceRangeWithContent(content: string, edit: vscode.TextEditorEdit) { + if (this.isNewlineOnly(content)) { + edit.replace(this.range, ''); + return; + } + + // Replace [ Conflict Range ] with [ Current Content ] + edit.replace(this.range, content); + } + + private isNewlineOnly(text: string) { + return text === '\n' || text === '\r\n'; + } +} \ No newline at end of file diff --git a/extensions/merge-conflict/src/documentTracker.ts b/extensions/merge-conflict/src/documentTracker.ts new file mode 100644 index 00000000000..e6b50fec1a8 --- /dev/null +++ b/extensions/merge-conflict/src/documentTracker.ts @@ -0,0 +1,138 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { MergeConflictParser } from './mergeConflictParser'; +import * as interfaces from './interfaces'; +import { Delayer } from './delayer'; + +class ScanTask { + public origins: Set = new Set(); + public delayTask: Delayer; + + constructor(delayTime: number, initialOrigin: string) { + this.origins.add(initialOrigin); + this.delayTask = new Delayer(delayTime); + } + + public addOrigin(name: string): boolean { + if (this.origins.has(name)) { + return false; + } + + return false; + } + + public hasOrigin(name: string): boolean { + return this.origins.has(name); + } +} + +class OriginDocumentMergeConflictTracker implements interfaces.IDocumentMergeConflictTracker { + constructor(private parent: DocumentMergeConflictTracker, private origin: string) { + } + + getConflicts(document: vscode.TextDocument): PromiseLike { + return this.parent.getConflicts(document, this.origin); + } + + isPending(document: vscode.TextDocument): boolean { + return this.parent.isPending(document, this.origin); + } + + forget(document: vscode.TextDocument) { + this.parent.forget(document); + } +} + +export default class DocumentMergeConflictTracker implements vscode.Disposable, interfaces.IDocumentMergeConflictTrackerService { + private cache: Map = new Map(); + private delayExpireTime: number = 250; + + getConflicts(document: vscode.TextDocument, origin: string): PromiseLike { + // Attempt from cache + + let key = this.getCacheKey(document); + + if (!key) { + // Document doesnt have a uri, can't cache it, so return + return Promise.resolve(this.getConflictsOrEmpty(document, [origin])); + } + + let cacheItem = this.cache.get(key); + if (!cacheItem) { + cacheItem = new ScanTask(this.delayExpireTime, origin); + this.cache.set(key, cacheItem); + } + else { + cacheItem.addOrigin(origin); + } + + return cacheItem.delayTask.trigger(() => { + let conflicts = this.getConflictsOrEmpty(document, Array.from(cacheItem!.origins)); + + if (this.cache) { + this.cache.delete(key!); + } + + return conflicts; + }); + } + + isPending(document: vscode.TextDocument, origin: string): boolean { + if (!document) { + return false; + } + + let key = this.getCacheKey(document); + if (!key) { + return false; + } + + var task = this.cache.get(key); + + if (!task) { + return false; + } + + return task.hasOrigin(origin); + } + + createTracker(origin: string): interfaces.IDocumentMergeConflictTracker { + return new OriginDocumentMergeConflictTracker(this, origin); + } + + forget(document: vscode.TextDocument) { + let key = this.getCacheKey(document); + + if (key) { + this.cache.delete(key); + } + } + + dispose() { + this.cache.clear(); + } + + private getConflictsOrEmpty(document: vscode.TextDocument, origins: string[]): interfaces.IDocumentMergeConflict[] { + const containsConflict = MergeConflictParser.containsConflict(document); + + if (!containsConflict) { + return []; + } + + const conflicts = MergeConflictParser.scanDocument(document); + return conflicts; + } + + private getCacheKey(document: vscode.TextDocument): string | null { + if (document.uri && document.uri) { + return document.uri.toString(); + } + + return null; + } +} + diff --git a/extensions/merge-conflict/src/extension.ts b/extensions/merge-conflict/src/extension.ts new file mode 100644 index 00000000000..33c996955db --- /dev/null +++ b/extensions/merge-conflict/src/extension.ts @@ -0,0 +1,18 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import MergeConflictServices from './services'; + +export function activate(context: vscode.ExtensionContext) { + // Register disposables + const services = new MergeConflictServices(context); + services.begin(); + context.subscriptions.push(services); +} + +export function deactivate() { +} + diff --git a/extensions/merge-conflict/src/interfaces.ts b/extensions/merge-conflict/src/interfaces.ts new file mode 100644 index 00000000000..9d411befaba --- /dev/null +++ b/extensions/merge-conflict/src/interfaces.ts @@ -0,0 +1,47 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as vscode from 'vscode'; + +export interface IMergeRegion { + name: string; + header: vscode.Range; + content: vscode.Range; + decoratorContent: vscode.Range; +} + +export enum CommitType { + Current, + Incoming, + Both +} + +export interface IExtensionConfiguration { + enableCodeLens: boolean; + enableDecorations: boolean; + enableEditorOverview: boolean; +} + +export interface IDocumentMergeConflict extends IDocumentMergeConflictDescriptor { + commitEdit(type: CommitType, editor: vscode.TextEditor, edit?: vscode.TextEditorEdit); + applyEdit(type: CommitType, editor: vscode.TextEditor, edit: vscode.TextEditorEdit); +} + +export interface IDocumentMergeConflictDescriptor { + range: vscode.Range; + current: IMergeRegion; + incoming: IMergeRegion; + splitter: vscode.Range; +} + +export interface IDocumentMergeConflictTracker { + getConflicts(document: vscode.TextDocument): PromiseLike; + isPending(document: vscode.TextDocument): boolean; + forget(document: vscode.TextDocument); +} + +export interface IDocumentMergeConflictTrackerService { + createTracker(origin: string): IDocumentMergeConflictTracker; + forget(document: vscode.TextDocument); +} diff --git a/extensions/merge-conflict/src/mergeConflictParser.ts b/extensions/merge-conflict/src/mergeConflictParser.ts new file mode 100644 index 00000000000..fa316a0701c --- /dev/null +++ b/extensions/merge-conflict/src/mergeConflictParser.ts @@ -0,0 +1,141 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as vscode from 'vscode'; +import * as interfaces from './interfaces'; +import { DocumentMergeConflict } from './documentMergeConflict'; + +const startHeaderMarker = '<<<<<<< '; +const splitterMarker = '======='; +const endFooterMarker = '>>>>>>> '; + +interface IScanMergedConflict { + startHeader: vscode.TextLine; + splitter?: vscode.TextLine; + endFooter?: vscode.TextLine; +} + +export class MergeConflictParser { + + static scanDocument(document: vscode.TextDocument): interfaces.IDocumentMergeConflict[] { + + // Scan each line in the document, we already know there is atleast a <<<<<<< and + // >>>>>> marker within the document, we need to group these into conflict ranges. + // We initially build a scan match, that references the lines of the header, splitter + // and footer. This is then converted into a full descriptor containing all required + // ranges. + + let currentConflict: IScanMergedConflict | null = null; + const conflictDescriptors: interfaces.IDocumentMergeConflictDescriptor[] = []; + + for (let i = 0; i < document.lineCount; i++) { + const line = document.lineAt(i); + + // Ignore empty lines + if (!line || line.isEmptyOrWhitespace) { + continue; + } + + // Is this a start line? <<<<<<< + if (line.text.startsWith(startHeaderMarker)) { + if (currentConflict !== null) { + // Error, we should not see a startMarker before we've seen an endMarker + currentConflict = null; + + // Give up parsing, anything matched up this to this point will be decorated + // anything after will not + break; + } + + // Create a new conflict starting at this line + currentConflict = { startHeader: line }; + } + // Are we within a conflict block and is this a splitter? ======= + else if (currentConflict && line.text.startsWith(splitterMarker)) { + currentConflict.splitter = line; + } + // Are we withon a conflict block and is this a footer? >>>>>>> + else if (currentConflict && line.text.startsWith(endFooterMarker)) { + currentConflict.endFooter = line; + + // Create a full descriptor from the lines that we matched. This can return + // null if the descriptor could not be completed. + let completeDescriptor = MergeConflictParser.scanItemTolMergeConflictDescriptor(document, currentConflict); + + if (completeDescriptor !== null) { + conflictDescriptors.push(completeDescriptor); + } + + // Reset the current conflict to be empty, so we can match the next + // starting header marker. + currentConflict = null; + } + } + + return conflictDescriptors + .filter(Boolean) + .map(descriptor => new DocumentMergeConflict(document, descriptor)); + } + + private static scanItemTolMergeConflictDescriptor(document: vscode.TextDocument, scanned: IScanMergedConflict): interfaces.IDocumentMergeConflictDescriptor | null { + // Validate we have all the required lines within the scan item. + if (!scanned.startHeader || !scanned.splitter || !scanned.endFooter) { + return null; + } + + // Assume that descriptor.current.header, descriptor.incoming.header and descriptor.spliiter + // have valid ranges, fill in content and total ranges from these parts. + // NOTE: We need to shift the decortator range back one character so the splitter does not end up with + // two decoration colors (current and splitter), if we take the new line from the content into account + // the decorator will wrap to the next line. + return { + current: { + header: scanned.startHeader.range, + decoratorContent: new vscode.Range( + scanned.startHeader.rangeIncludingLineBreak.end, + MergeConflictParser.shiftBackOneCharacter(document, scanned.splitter.range.start)), + // Current content is range between header (shifted for linebreak) and splitter start + content: new vscode.Range( + scanned.startHeader.rangeIncludingLineBreak.end, + scanned.splitter.range.start), + name: scanned.startHeader.text.substring(startHeaderMarker.length) + }, + splitter: scanned.splitter.range, + incoming: { + header: scanned.endFooter.range, + decoratorContent: new vscode.Range( + scanned.splitter.rangeIncludingLineBreak.end, + MergeConflictParser.shiftBackOneCharacter(document, scanned.endFooter.range.start)), + // Incoming content is range between splitter (shifted for linebreak) and footer start + content: new vscode.Range( + scanned.splitter.rangeIncludingLineBreak.end, + scanned.endFooter.range.start), + name: scanned.endFooter.text.substring(endFooterMarker.length) + }, + // Entire range is between current header start and incoming header end (including line break) + range: new vscode.Range(scanned.startHeader.range.start, scanned.endFooter.rangeIncludingLineBreak.end) + }; + } + + static containsConflict(document: vscode.TextDocument): boolean { + if (!document) { + return false; + } + + let text = document.getText(); + return text.includes(startHeaderMarker) && text.includes(endFooterMarker); + } + + private static shiftBackOneCharacter(document: vscode.TextDocument, range: vscode.Position): vscode.Position { + let line = range.line; + let character = range.character - 1; + + if (character < 0) { + line--; + character = document.lineAt(line).range.end.character; + } + + return new vscode.Position(line, character); + } +} diff --git a/extensions/merge-conflict/src/mergeDecorator.ts b/extensions/merge-conflict/src/mergeDecorator.ts new file mode 100644 index 00000000000..f6d0135aad9 --- /dev/null +++ b/extensions/merge-conflict/src/mergeDecorator.ts @@ -0,0 +1,211 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as vscode from 'vscode'; +import * as interfaces from './interfaces'; +import { loadMessageBundle } from 'vscode-nls'; +const localize = loadMessageBundle(); + +export default class MergeDectorator implements vscode.Disposable { + + private decorations: { [key: string]: vscode.TextEditorDecorationType } = {}; + + private decorationUsesWholeLine: boolean = true; // Useful for debugging, set to false to see exact match ranges + + // TODO: Move to config? + private currentColorRgb = `32,200,94`; + private incomingColorRgb = `24,134,255`; + private config: interfaces.IExtensionConfiguration; + private tracker: interfaces.IDocumentMergeConflictTracker; + + constructor(private context: vscode.ExtensionContext, trackerService: interfaces.IDocumentMergeConflictTrackerService) { + this.tracker = trackerService.createTracker('decorator'); + } + + begin(config: interfaces.IExtensionConfiguration) { + this.config = config; + this.registerDecorationTypes(config); + + // Check if we already have a set of active windows, attempt to track these. + vscode.window.visibleTextEditors.forEach(e => this.applyDecorations(e)); + + vscode.workspace.onDidOpenTextDocument(event => { + this.applyDecorationsFromEvent(event); + }, null, this.context.subscriptions); + + vscode.workspace.onDidChangeTextDocument(event => { + this.applyDecorationsFromEvent(event.document); + }, null, this.context.subscriptions); + + vscode.window.onDidChangeActiveTextEditor((e) => { + // New editor attempt to apply + this.applyDecorations(e); + }, null, this.context.subscriptions); + } + + configurationUpdated(config: interfaces.IExtensionConfiguration) { + this.config = config; + this.registerDecorationTypes(config); + + // Re-apply the decoration + vscode.window.visibleTextEditors.forEach(e => { + this.removeDecorations(e); + this.applyDecorations(e); + }); + } + + private registerDecorationTypes(config: interfaces.IExtensionConfiguration) { + + // Dispose of existing decorations + Object.keys(this.decorations).forEach(k => this.decorations[k].dispose()); + this.decorations = {}; + + // None of our features are enabled + if (!config.enableDecorations || !config.enableEditorOverview) { + return; + } + + // Create decorators + if (config.enableDecorations || config.enableEditorOverview) { + this.decorations['current.content'] = vscode.window.createTextEditorDecorationType( + this.generateBlockRenderOptions(this.currentColorRgb, config) + ); + + this.decorations['incoming.content'] = vscode.window.createTextEditorDecorationType( + this.generateBlockRenderOptions(this.incomingColorRgb, config) + ); + } + + if (config.enableDecorations) { + this.decorations['current.header'] = vscode.window.createTextEditorDecorationType({ + // backgroundColor: 'rgba(255, 0, 0, 0.01)', + // border: '2px solid red', + isWholeLine: this.decorationUsesWholeLine, + backgroundColor: `rgba(${this.currentColorRgb}, 1.0)`, + color: 'white', + after: { + contentText: ' ' + localize('currentChange', '(Current change)'), + color: 'rgba(0, 0, 0, 0.7)' + } + }); + + this.decorations['splitter'] = vscode.window.createTextEditorDecorationType({ + backgroundColor: 'rgba(0, 0, 0, 0.25)', + color: 'white', + isWholeLine: this.decorationUsesWholeLine, + }); + + this.decorations['incoming.header'] = vscode.window.createTextEditorDecorationType({ + backgroundColor: `rgba(${this.incomingColorRgb}, 1.0)`, + color: 'white', + isWholeLine: this.decorationUsesWholeLine, + after: { + contentText: ' ' + localize('incomingChange', '(Incoming change)'), + color: 'rgba(0, 0, 0, 0.7)' + } + }); + } + } + + dispose() { + + // TODO: Replace with Map + Object.keys(this.decorations).forEach(name => { + this.decorations[name].dispose(); + }); + + this.decorations = {}; + } + + private generateBlockRenderOptions(color: string, config: interfaces.IExtensionConfiguration): vscode.DecorationRenderOptions { + + let renderOptions: any = {}; + + if (config.enableDecorations) { + renderOptions.backgroundColor = `rgba(${color}, 0.2)`; + renderOptions.isWholeLine = this.decorationUsesWholeLine; + } + + if (config.enableEditorOverview) { + renderOptions.overviewRulerColor = `rgba(${color}, 0.5)`; + renderOptions.overviewRulerLane = vscode.OverviewRulerLane.Full; + } + + return renderOptions; + } + + private applyDecorationsFromEvent(eventDocument: vscode.TextDocument) { + for (var i = 0; i < vscode.window.visibleTextEditors.length; i++) { + if (vscode.window.visibleTextEditors[i].document === eventDocument) { + // Attempt to apply + this.applyDecorations(vscode.window.visibleTextEditors[i]); + } + } + } + + private async applyDecorations(editor: vscode.TextEditor) { + if (!editor || !editor.document) { return; } + + if (!this.config || (!this.config.enableDecorations && !this.config.enableEditorOverview)) { + return; + } + + // If we have a pending scan from the same origin, exit early. + if (this.tracker.isPending(editor.document)) { + return; + } + + let conflicts = await this.tracker.getConflicts(editor.document); + + if (conflicts.length === 0) { + this.removeDecorations(editor); + return; + } + + // Store decorations keyed by the type of decoration, set decoration wants a "style" + // to go with it, which will match this key (see constructor); + let matchDecorations: { [key: string]: vscode.DecorationOptions[] } = {}; + + let pushDecoration = (key: string, d: vscode.DecorationOptions) => { + matchDecorations[key] = matchDecorations[key] || []; + matchDecorations[key].push(d); + }; + + conflicts.forEach(conflict => { + // TODO, this could be more effective, just call getMatchPositions once with a map of decoration to position + pushDecoration('current.content', { range: conflict.current.decoratorContent }); + pushDecoration('incoming.content', { range: conflict.incoming.decoratorContent }); + + if (this.config.enableDecorations) { + pushDecoration('current.header', { range: conflict.current.header }); + pushDecoration('splitter', { range: conflict.splitter }); + pushDecoration('incoming.header', { range: conflict.incoming.header }); + } + }); + + // For each match we've generated, apply the generated decoration with the matching decoration type to the + // editor instance. Keys in both matches and decorations should match. + Object.keys(matchDecorations).forEach(decorationKey => { + let decorationType = this.decorations[decorationKey]; + + if (decorationType) { + editor.setDecorations(decorationType, matchDecorations[decorationKey]); + } + }); + } + + private removeDecorations(editor: vscode.TextEditor) { + // Remove all decorations, there might be none + Object.keys(this.decorations).forEach(decorationKey => { + + // Race condition, while editing the settings, it's possible to + // generate regions before the configuration has been refreshed + let decorationType = this.decorations[decorationKey]; + + if (decorationType) { + editor.setDecorations(decorationType, []); + } + }); + } +} \ No newline at end of file diff --git a/extensions/merge-conflict/src/package.json b/extensions/merge-conflict/src/package.json new file mode 100644 index 00000000000..2f79e20bf7f --- /dev/null +++ b/extensions/merge-conflict/src/package.json @@ -0,0 +1,722 @@ +{ + "name": "git", + "publisher": "vscode", + "displayName": "git", + "description": "Git", + "version": "0.0.1", + "engines": { + "vscode": "^1.5.0" + }, + "aiKey": "AIF-d9b70cd4-b9f9-4d70-929b-a071c400b217", + "enableProposedApi": true, + "categories": [ + "Other" + ], + "activationEvents": [ + "*" + ], + "main": "./out/main", + "scripts": { + "compile": "gulp compile-extension:git", + "watch": "gulp watch-extension:git" + }, + "contributes": { + "commands": [ + { + "command": "git.clone", + "title": "%command.clone%", + "category": "Git" + }, + { + "command": "git.init", + "title": "%command.init%", + "category": "Git" + }, + { + "command": "git.refresh", + "title": "%command.refresh%", + "category": "Git", + "icon": { + "light": "resources/icons/light/refresh.svg", + "dark": "resources/icons/dark/refresh.svg" + } + }, + { + "command": "git.openChange", + "title": "%command.openChange%", + "category": "Git", + "icon": { + "light": "resources/icons/light/open-change.svg", + "dark": "resources/icons/dark/open-change.svg" + } + }, + { + "command": "git.openFile", + "title": "%command.openFile%", + "category": "Git", + "icon": { + "light": "resources/icons/light/open-file.svg", + "dark": "resources/icons/dark/open-file.svg" + } + }, + { + "command": "git.stage", + "title": "%command.stage%", + "category": "Git", + "icon": { + "light": "resources/icons/light/stage.svg", + "dark": "resources/icons/dark/stage.svg" + } + }, + { + "command": "git.stageAll", + "title": "%command.stageAll%", + "category": "Git", + "icon": { + "light": "resources/icons/light/stage.svg", + "dark": "resources/icons/dark/stage.svg" + } + }, + { + "command": "git.stageSelectedRanges", + "title": "%command.stageSelectedRanges%", + "category": "Git" + }, + { + "command": "git.revertSelectedRanges", + "title": "%command.revertSelectedRanges%", + "category": "Git" + }, + { + "command": "git.unstage", + "title": "%command.unstage%", + "category": "Git", + "icon": { + "light": "resources/icons/light/unstage.svg", + "dark": "resources/icons/dark/unstage.svg" + } + }, + { + "command": "git.unstageAll", + "title": "%command.unstageAll%", + "category": "Git", + "icon": { + "light": "resources/icons/light/unstage.svg", + "dark": "resources/icons/dark/unstage.svg" + } + }, + { + "command": "git.unstageSelectedRanges", + "title": "%command.unstageSelectedRanges%", + "category": "Git" + }, + { + "command": "git.clean", + "title": "%command.clean%", + "category": "Git", + "icon": { + "light": "resources/icons/light/clean.svg", + "dark": "resources/icons/dark/clean.svg" + } + }, + { + "command": "git.cleanAll", + "title": "%command.cleanAll%", + "category": "Git", + "icon": { + "light": "resources/icons/light/clean.svg", + "dark": "resources/icons/dark/clean.svg" + } + }, + { + "command": "git.commit", + "title": "%command.commit%", + "category": "Git", + "icon": { + "light": "resources/icons/light/check.svg", + "dark": "resources/icons/dark/check.svg" + } + }, + { + "command": "git.commitStaged", + "title": "%command.commitStaged%", + "category": "Git" + }, + { + "command": "git.commitStagedSigned", + "title": "%command.commitStagedSigned%", + "category": "Git" + }, + { + "command": "git.commitAll", + "title": "%command.commitAll%", + "category": "Git" + }, + { + "command": "git.commitAllSigned", + "title": "%command.commitAllSigned%", + "category": "Git" + }, + { + "command": "git.undoCommit", + "title": "%command.undoCommit%", + "category": "Git" + }, + { + "command": "git.checkout", + "title": "%command.checkout%", + "category": "Git" + }, + { + "command": "git.branch", + "title": "%command.branch%", + "category": "Git" + }, + { + "command": "git.pull", + "title": "%command.pull%", + "category": "Git" + }, + { + "command": "git.pullRebase", + "title": "%command.pullRebase%", + "category": "Git" + }, + { + "command": "git.push", + "title": "%command.push%", + "category": "Git" + }, + { + "command": "git.pushTo", + "title": "%command.pushTo%", + "category": "Git" + }, + { + "command": "git.sync", + "title": "%command.sync%", + "category": "Git" + }, + { + "command": "git.publish", + "title": "%command.publish%", + "category": "Git" + }, + { + "command": "git.showOutput", + "title": "%command.showOutput%", + "category": "Git" + }, + { + "command": "git.merge.accept.all-current", + "title": "Accept all current", + "category": "Git Merge" + }, + { + "category": "Git Merge", + "title": "Accept all incoming", + "command": "git.merge.accept.all-incoming" + }, + { + "category": "Git Merge", + "title": "Accept all both", + "command": "git.merge.accept.all-both" + }, + { + "category": "Git Merge", + "title": "Accept current", + "command": "git.merge.accept.current" + }, + { + "category": "Git Merge", + "title": "Accept incoming", + "command": "git.merge.accept.incoming" + }, + { + "category": "Git Merge", + "title": "Accept selection", + "command": "git.merge.accept.selection" + }, + { + "category": "Git Merge", + "title": "Accept both", + "command": "git.merge.accept.both" + }, + { + "category": "Git Merge", + "title": "Next conflict", + "command": "git.merge.next" + }, + { + "category": "Git Merge", + "title": "Previous conflict", + "command": "git.merge.previous" + }, + { + "category": "Git Merge", + "title": "Compare current conflict", + "command": "git.merge.compare" + } + ], + "keybindings": [ + { + "command": "git.merge.next", + "when": "editorTextFocus", + "key": "alt+m down" + }, + { + "command": "git.merge.previous", + "when": "editorTextFocus", + "key": "alt+m up" + }, + { + "command": "git.merge.accept.selection", + "when": "editorTextFocus", + "key": "alt+m enter" + }, + { + "command": "git.merge.accept.current", + "when": "editorTextFocus", + "key": "alt+m 1" + }, + { + "command": "git.merge.accept.incoming", + "when": "editorTextFocus", + "key": "alt+m 2" + }, + { + "command": "git.merge.accept.both", + "when": "editorTextFocus", + "key": "alt+m 3" + } + ], + "menus": { + "commandPalette": [ + { + "command": "git.clone", + "when": "config.git.enabled" + }, + { + "command": "git.init", + "when": "config.git.enabled && scmProvider == git && gitState == norepo" + }, + { + "command": "git.refresh", + "when": "config.git.enabled && scmProvider == git && gitState == idle" + }, + { + "command": "git.openFile", + "when": "config.git.enabled && scmProvider == git && gitState == idle" + }, + { + "command": "git.openChange", + "when": "config.git.enabled && scmProvider == git && gitState == idle" + }, + { + "command": "git.stage", + "when": "config.git.enabled && scmProvider == git && gitState == idle" + }, + { + "command": "git.stageAll", + "when": "config.git.enabled && scmProvider == git && gitState == idle" + }, + { + "command": "git.stageSelectedRanges", + "when": "config.git.enabled && scmProvider == git && gitState == idle" + }, + { + "command": "git.revertSelectedRanges", + "when": "config.git.enabled && scmProvider == git && gitState == idle" + }, + { + "command": "git.unstage", + "when": "config.git.enabled && scmProvider == git && gitState == idle" + }, + { + "command": "git.unstageAll", + "when": "config.git.enabled && scmProvider == git && gitState == idle" + }, + { + "command": "git.unstageSelectedRanges", + "when": "config.git.enabled && scmProvider == git && gitState == idle" + }, + { + "command": "git.clean", + "when": "config.git.enabled && scmProvider == git && gitState == idle" + }, + { + "command": "git.cleanAll", + "when": "config.git.enabled && scmProvider == git && gitState == idle" + }, + { + "command": "git.commit", + "when": "config.git.enabled && scmProvider == git && gitState == idle" + }, + { + "command": "git.commitStaged", + "when": "config.git.enabled && scmProvider == git && gitState == idle" + }, + { + "command": "git.commitStagedSigned", + "when": "config.git.enabled && scmProvider == git && gitState == idle" + }, + { + "command": "git.commitAll", + "when": "config.git.enabled && scmProvider == git && gitState == idle" + }, + { + "command": "git.commitAllSigned", + "when": "config.git.enabled && scmProvider == git && gitState == idle" + }, + { + "command": "git.undoCommit", + "when": "config.git.enabled && scmProvider == git && gitState == idle" + }, + { + "command": "git.checkout", + "when": "config.git.enabled && scmProvider == git && gitState == idle" + }, + { + "command": "git.branch", + "when": "config.git.enabled && scmProvider == git && gitState == idle" + }, + { + "command": "git.pull", + "when": "config.git.enabled && scmProvider == git && gitState == idle" + }, + { + "command": "git.pullRebase", + "when": "config.git.enabled && scmProvider == git && gitState == idle" + }, + { + "command": "git.push", + "when": "config.git.enabled && scmProvider == git && gitState == idle" + }, + { + "command": "git.pushTo", + "when": "config.git.enabled && scmProvider == git && gitState == idle" + }, + { + "command": "git.sync", + "when": "config.git.enabled && scmProvider == git && gitState == idle" + }, + { + "command": "git.publish", + "when": "config.git.enabled && scmProvider == git && gitState == idle" + }, + { + "command": "git.showOutput", + "when": "config.git.enabled && scmProvider == git && gitState == idle" + } + ], + "scm/title": [ + { + "command": "git.init", + "group": "navigation", + "when": "config.git.enabled && scmProvider == git && gitState == norepo" + }, + { + "command": "git.commit", + "group": "navigation", + "when": "config.git.enabled && scmProvider == git && gitState == idle" + }, + { + "command": "git.refresh", + "group": "navigation", + "when": "config.git.enabled && scmProvider == git && gitState == idle" + }, + { + "command": "git.sync", + "group": "1_sync", + "when": "config.git.enabled && scmProvider == git && gitState == idle" + }, + { + "command": "git.pull", + "group": "1_sync", + "when": "config.git.enabled && scmProvider == git && gitState == idle" + }, + { + "command": "git.pullRebase", + "group": "1_sync", + "when": "config.git.enabled && scmProvider == git && gitState == idle" + }, + { + "command": "git.push", + "group": "1_sync", + "when": "config.git.enabled && scmProvider == git && gitState == idle" + }, + { + "command": "git.pushTo", + "group": "1_sync", + "when": "config.git.enabled && scmProvider == git && gitState == idle" + }, + { + "command": "git.publish", + "group": "2_publish", + "when": "config.git.enabled && scmProvider == git && gitState == idle" + }, + { + "command": "git.commitStaged", + "group": "3_commit", + "when": "config.git.enabled && scmProvider == git && gitState == idle" + }, + { + "command": "git.commitStagedSigned", + "group": "3_commit", + "when": "config.git.enabled && scmProvider == git && gitState == idle" + }, + { + "command": "git.commitAll", + "group": "3_commit", + "when": "config.git.enabled && scmProvider == git && gitState == idle" + }, + { + "command": "git.commitAllSigned", + "group": "3_commit", + "when": "config.git.enabled && scmProvider == git && gitState == idle" + }, + { + "command": "git.undoCommit", + "group": "3_commit", + "when": "config.git.enabled && scmProvider == git && gitState == idle" + }, + { + "command": "git.unstageAll", + "group": "4_stage", + "when": "config.git.enabled && scmProvider == git && gitState == idle" + }, + { + "command": "git.cleanAll", + "group": "4_stage", + "when": "config.git.enabled && scmProvider == git && gitState == idle" + }, + { + "command": "git.showOutput", + "group": "5_output", + "when": "config.git.enabled && scmProvider == git && gitState == idle" + } + ], + "scm/resourceGroup/context": [ + { + "command": "git.stageAll", + "when": "config.git.enabled && scmProvider == git && gitState == idle && scmResourceGroup == merge", + "group": "1_modification" + }, + { + "command": "git.stageAll", + "when": "config.git.enabled && scmProvider == git && gitState == idle && scmResourceGroup == merge", + "group": "inline" + }, + { + "command": "git.unstageAll", + "when": "config.git.enabled && scmProvider == git && gitState == idle && scmResourceGroup == index", + "group": "1_modification" + }, + { + "command": "git.unstageAll", + "when": "config.git.enabled && scmProvider == git && gitState == idle && scmResourceGroup == index", + "group": "inline" + }, + { + "command": "git.cleanAll", + "when": "config.git.enabled && scmProvider == git && gitState == idle && scmResourceGroup == workingTree", + "group": "1_modification" + }, + { + "command": "git.stageAll", + "when": "config.git.enabled && scmProvider == git && gitState == idle && scmResourceGroup == workingTree", + "group": "1_modification" + }, + { + "command": "git.cleanAll", + "when": "config.git.enabled && scmProvider == git && gitState == idle && scmResourceGroup == workingTree", + "group": "inline" + }, + { + "command": "git.stageAll", + "when": "config.git.enabled && scmProvider == git && gitState == idle && scmResourceGroup == workingTree", + "group": "inline" + } + ], + "scm/resourceState/context": [ + { + "command": "git.stage", + "when": "config.git.enabled && scmProvider == git && gitState == idle && scmResourceGroup == merge", + "group": "1_modification" + }, + { + "command": "git.stage", + "when": "config.git.enabled && scmProvider == git && gitState == idle && scmResourceGroup == merge", + "group": "inline" + }, + { + "command": "git.openChange", + "when": "config.git.enabled && scmProvider == git && gitState == idle && scmResourceGroup == index", + "group": "navigation" + }, + { + "command": "git.openFile", + "when": "config.git.enabled && scmProvider == git && gitState == idle && scmResourceGroup == index", + "group": "navigation" + }, + { + "command": "git.unstage", + "when": "config.git.enabled && scmProvider == git && gitState == idle && scmResourceGroup == index", + "group": "1_modification" + }, + { + "command": "git.unstage", + "when": "config.git.enabled && scmProvider == git && gitState == idle && scmResourceGroup == index", + "group": "inline" + }, + { + "command": "git.openChange", + "when": "config.git.enabled && scmProvider == git && gitState == idle && scmResourceGroup == workingTree", + "group": "navigation" + }, + { + "command": "git.openFile", + "when": "config.git.enabled && scmProvider == git && gitState == idle && scmResourceGroup == workingTree", + "group": "navigation" + }, + { + "command": "git.stage", + "when": "config.git.enabled && scmProvider == git && gitState == idle && scmResourceGroup == workingTree", + "group": "1_modification" + }, + { + "command": "git.clean", + "when": "config.git.enabled && scmProvider == git && gitState == idle && scmResourceGroup == workingTree", + "group": "1_modification" + }, + { + "command": "git.clean", + "when": "config.git.enabled && scmProvider == git && gitState == idle && scmResourceGroup == workingTree", + "group": "inline" + }, + { + "command": "git.stage", + "when": "config.git.enabled && scmProvider == git && gitState == idle && scmResourceGroup == workingTree", + "group": "inline" + } + ], + "editor/title": [ + { + "command": "git.openFile", + "group": "navigation", + "when": "config.git.enabled && scmProvider == git && isInDiffEditor && resourceScheme != extension" + }, + { + "command": "git.openChange", + "group": "navigation", + "when": "config.git.enabled && scmProvider == git && !isInDiffEditor && resourceScheme != extension" + }, + { + "command": "git.stageSelectedRanges", + "group": "2_git@1", + "when": "config.git.enabled && scmProvider == git && isInDiffEditor" + }, + { + "command": "git.unstageSelectedRanges", + "group": "2_git@2", + "when": "config.git.enabled && scmProvider == git && isInDiffEditor" + }, + { + "command": "git.revertSelectedRanges", + "group": "2_git@3", + "when": "config.git.enabled && scmProvider == git && isInDiffEditor" + } + ] + }, + "configuration": { + "title": "Git", + "properties": { + "git.enabled": { + "type": "boolean", + "description": "%config.enabled%", + "default": true + }, + "git.path": { + "type": [ + "string", + "null" + ], + "description": "%config.path%", + "default": null, + "isExecutable": true + }, + "git.autorefresh": { + "type": "boolean", + "description": "%config.autorefresh%", + "default": true + }, + "git.autofetch": { + "type": "boolean", + "description": "%config.autofetch%", + "default": true + }, + "git.confirmSync": { + "type": "boolean", + "description": "%config.confirmSync%", + "default": true + }, + "git.countBadge": { + "type": "string", + "enum": [ + "all", + "tracked", + "off" + ], + "description": "%config.countBadge%", + "default": "all" + }, + "git.checkoutType": { + "type": "string", + "enum": [ + "all", + "local", + "tags", + "remote" + ], + "description": "%config.checkoutType%", + "default": "all" + }, + "git.ignoreLegacyWarning": { + "type": "boolean", + "description": "%config.ignoreLegacyWarning%", + "default": false + }, + "git.ignoreLimitWarning": { + "type": "boolean", + "description": "%config.ignoreLimitWarning%", + "default": false + }, + "git.defaultCloneDirectory": { + "type": "string", + "default": null, + "description": "%config.defaultCloneDirectory%" + }, + "git.enableSmartCommit": { + "type": "boolean", + "description": "%config.enableSmartCommit%", + "default": false + }, + "git.enableEditorMerge": { + "type": "boolean", + "description": "%config.enableEditorMerge%", + "default": true + } + } + } + }, + "dependencies": { + "iconv-lite": "0.4.15", + "vscode-extension-telemetry": "^0.0.7", + "vscode-nls": "^2.0.1" + }, + "devDependencies": { + "@types/mocha": "^2.2.41", + "@types/node": "^7.0.4", + "mocha": "^3.2.0" + } +} \ No newline at end of file diff --git a/extensions/merge-conflict/src/services.ts b/extensions/merge-conflict/src/services.ts new file mode 100644 index 00000000000..320adb3e53e --- /dev/null +++ b/extensions/merge-conflict/src/services.ts @@ -0,0 +1,67 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as vscode from 'vscode'; +import DocumentTracker from './documentTracker'; +import CodeLensProvider from './codelensProvider'; +import CommandHandler from './commandHandler'; +import ContentProvider from './contentProvider'; +import Decorator from './mergeDecorator'; +import * as interfaces from './interfaces'; + +const ConfigurationSectionName = 'merge-conflict'; + +export default class ServiceWrapper implements vscode.Disposable { + + private services: vscode.Disposable[] = []; + + constructor(private context: vscode.ExtensionContext) { + } + + begin() { + + let configuration = this.createExtensionConfiguration(); + const documentTracker = new DocumentTracker(); + + this.services.push( + documentTracker, + new CommandHandler(this.context, documentTracker), + new CodeLensProvider(this.context, documentTracker), + new ContentProvider(this.context), + new Decorator(this.context, documentTracker), + ); + + this.services.forEach((service: any) => { + if (service.begin && service.begin instanceof Function) { + service.begin(configuration); + } + }); + + vscode.workspace.onDidChangeConfiguration(() => { + this.services.forEach((service: any) => { + if (service.configurationUpdated && service.configurationUpdated instanceof Function) { + service.configurationUpdated(this.createExtensionConfiguration()); + } + }); + }); + } + + createExtensionConfiguration(): interfaces.IExtensionConfiguration { + const workspaceConfiguration = vscode.workspace.getConfiguration(ConfigurationSectionName); + const codeLensEnabled: boolean = workspaceConfiguration.get('codeLens.enabled', true); + const decoratorsEnabled: boolean = workspaceConfiguration.get('decorators.enabled', true); + + return { + enableCodeLens: codeLensEnabled, + enableDecorations: decoratorsEnabled, + enableEditorOverview: decoratorsEnabled + }; + } + + dispose() { + this.services.forEach(disposable => disposable.dispose()); + this.services = []; + } +} + diff --git a/extensions/merge-conflict/src/typings/refs.d.ts b/extensions/merge-conflict/src/typings/refs.d.ts new file mode 100644 index 00000000000..0188b6f9ffc --- /dev/null +++ b/extensions/merge-conflict/src/typings/refs.d.ts @@ -0,0 +1,8 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/// +/// +/// diff --git a/extensions/merge-conflict/tsconfig.json b/extensions/merge-conflict/tsconfig.json new file mode 100644 index 00000000000..ce3e55fff2d --- /dev/null +++ b/extensions/merge-conflict/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "es6", + "lib": [ + "es2016" + ], + "module": "commonjs", + "outDir": "./out", + "strictNullChecks": true, + "experimentalDecorators": true + }, + "include": [ + "src/**/*" + ] +}