├── .gitignore ├── .vscode ├── launch.json ├── settings.json └── tasks.json ├── .vscodeignore ├── LICENCE ├── README.md ├── content └── 1.gif ├── img ├── app.png ├── merge-black.png └── merge-white.png ├── package.json ├── src ├── delayer.ts ├── extension.ts └── services │ ├── codelensProvider.ts │ ├── commandHandler.ts │ ├── configurationService.ts │ ├── contentProvider.ts │ ├── documentMergeConflict.ts │ ├── documentTracker.ts │ ├── index.ts │ ├── interfaces.ts │ ├── mergeConflictParser.ts │ └── mergeDecorator.ts ├── test ├── extension.test.ts └── index.ts ├── tsconfig.json └── tslint.json /.gitignore: -------------------------------------------------------------------------------- 1 | out 2 | node_modules -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | // A launch configuration that compiles the extension and then opens it inside a new window 2 | { 3 | "version": "0.1.0", 4 | "configurations": [ 5 | { 6 | "name": "Launch Extension", 7 | "type": "extensionHost", 8 | "request": "launch", 9 | "runtimeExecutable": "${execPath}", 10 | "args": ["--extensionDevelopmentPath=${workspaceRoot}" ], 11 | "stopOnEntry": false, // NOTE: To debug startup path in vscode 1.11.0 set this to true 12 | "sourceMaps": true, 13 | "outFiles": [ "${workspaceRoot}/out/src/**/*.js" ], 14 | "preLaunchTask": "npm" 15 | }, 16 | { 17 | "name": "Launch Tests", 18 | "type": "extensionHost", 19 | "request": "launch", 20 | "runtimeExecutable": "${execPath}", 21 | "args": ["--extensionDevelopmentPath=${workspaceRoot}", "--extensionTestsPath=${workspaceRoot}/out/test" ], 22 | "stopOnEntry": false, 23 | "sourceMaps": true, 24 | "outFiles": [ "${workspaceRoot}/out/test/**/*.js" ], 25 | "preLaunchTask": "npm" 26 | } 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | // Place your settings in this file to overwrite default and user settings. 2 | { 3 | "files.exclude": { 4 | "out": false // set this to true to hide the "out" folder with the compiled JS files 5 | }, 6 | "search.exclude": { 7 | "out": true // set this to false to include "out" folder in search results 8 | } 9 | // "typescript.tsdk": "./node_modules/typescript/lib", // we want to use the TS server from our node_modules folder to control its version 10 | // "tslint.enable": true 11 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | // Available variables which can be used inside of strings. 2 | // ${workspaceRoot}: the root folder of the team 3 | // ${file}: the current opened file 4 | // ${fileBasename}: the current opened file's basename 5 | // ${fileDirname}: the current opened file's dirname 6 | // ${fileExtname}: the current opened file's extension 7 | // ${cwd}: the current working directory of the spawned process 8 | 9 | // A task runner that calls a custom npm script that compiles the extension. 10 | { 11 | "version": "0.1.0", 12 | 13 | // we want to run npm 14 | "command": "npm", 15 | 16 | // the command is a shell script 17 | "isShellCommand": true, 18 | 19 | // show the output window only if unrecognized errors occur. 20 | "showOutput": "silent", 21 | 22 | // we run the custom script "compile" as defined in package.json 23 | "args": ["run", "compile", "--loglevel", "silent"], 24 | 25 | // The tsc compiler is started in watching mode 26 | "isWatching": true, 27 | 28 | // use the standard tsc in watch mode problem matcher to find compile problems in the output. 29 | "problemMatcher": "$tsc-watch" 30 | } -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | .vscode/** 2 | .vscode-test/** 3 | out/test/** 4 | test/** 5 | src/** 6 | **/*.map 7 | .gitignore 8 | tsconfig.json 9 | vsc-extension-quickstart.md 10 | content/** -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Phil Price 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🎉Extension merged into [vscode](https://github.com/Microsoft/vscode) 🎉 2 | 3 | This extension is now part of the official Visual Studio Code feature set, as of 1.13.0. This version of the extension will no longer be maintained and any issues or pull requests should be opened against [Microsoft/vscode](https://github.com/Microsoft/vscode). 4 | 5 | See [PR #27150](https://github.com/Microsoft/vscode/pull/27150). 6 | 7 | ## Migration from extension 8 | 9 | - Configuration 10 | - `better-merge.enableCodeLens` --> `merge-conflict.codeLens.enabled` 11 | - `better-merge.enableDecorations` and `better-merge.enableEditorOverview` --> `merge-conflict.decorators.enabled` 12 | - Commands. All commands are identical, but have been moved from `better-merge.*` to `merge-conflict.*` 13 | 14 | The code here is now a reference for anyone interested 😎 15 | 16 | # vscode-better-merge (deprecated) 17 | Better visual merge conflict support for [Visual Studio Code](http://code.visualstudio.com/), inspired by [merge-conflicts](https://atom.io/packages/merge-conflicts) for Atom. 18 | 19 | ![Demo animation 1](content/1.gif) 20 | 21 | Available on the [Visual Studio Extension Marketplace](https://marketplace.visualstudio.com/items?itemName=pprice.better-merge). 22 | 23 | ## Features 24 | 25 | - Individual conflicts are highlighted in each file 26 | - Command palette commands for resolving and navigating between merge conflicts (see below) 27 | - CodeLens actions to either accept "current", "incoming" or "both" changes 28 | - Navigation shortcuts between conflicts 29 | 30 | ### Commands 31 | 32 | All commands use a double key chord combination by default. First press `Alt+M` then press the second key. 33 | 34 | - `Accept current` - `Alt+M, 1` - Accept current (local) change in the current conflict 35 | - `Accept incoming` - `Alt+M, 2` - Accept incoming change in the current conflict 36 | - `Accept both` - `Alt+M, 3` - Accept the union of both the current and incoming change for the current conflict 37 | - `Accept selection` - `Alt+M, Enter` - Accept the change the editor cursor is currently within 38 | - `Next conflict` - `Alm+M, Down Arrow` - Navigate to the next conflict in the current file 39 | - `Previous conflict` - `Alm+M, Down Arrow` - Navigate to the previous conflict in the current file 40 | - `Accept all current` - Accept all current changes in the current file 41 | - `Accept all incoming` - Accept all incoming changes in the current file 42 | - `Accept all both` - Accept all changes as a "both" merge in the current file 43 | - `Compare current conflict` - Compares the active conflict in the VSCode diff utility 44 | 45 | *NOTE*: All accept commands can be undone with Undo (`Ctrl+Z` / `Cmd+Z`) 46 | 47 | ### Key bindings 48 | 49 | The following commands are exposed if you wish to customize key bindings (*Preferences > Keyboard Shortcuts*) 50 | 51 | ``` 52 | better-merge.accept.current 53 | better-merge.accept.incoming 54 | better-merge.accept.both 55 | better-merge.accept.selection 56 | better-merge.next 57 | better-merge.previous 58 | better-merge.accept.all-current 59 | better-merge.accept.all-incoming 60 | better-merge.accept.all-both 61 | better-merge.compare 62 | ``` 63 | 64 | ### Configuration 65 | 66 | - `better-merge.enableCodeLens` (default: `true`) - Enable / disable inline code lens actions above merge conflict blocks 67 | - `better-merge.enableDecorations` (default: `true`) - Enable / disable additional editor decoration (background color, etc) of merge conflict blocks 68 | - `better-merge.enableEditorOverview` (default: `true`) - Enable / disable highlighting of merge conflicts in the editor overview area (right hand side) 69 | -------------------------------------------------------------------------------- /content/1.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pprice/vscode-better-merge/976ad76b56c190cf8a1213924b00686ff1c4379c/content/1.gif -------------------------------------------------------------------------------- /img/app.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pprice/vscode-better-merge/976ad76b56c190cf8a1213924b00686ff1c4379c/img/app.png -------------------------------------------------------------------------------- /img/merge-black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pprice/vscode-better-merge/976ad76b56c190cf8a1213924b00686ff1c4379c/img/merge-black.png -------------------------------------------------------------------------------- /img/merge-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pprice/vscode-better-merge/976ad76b56c190cf8a1213924b00686ff1c4379c/img/merge-white.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "better-merge", 3 | "displayName": "Better Merge", 4 | "description": "Improved git merge conflict support", 5 | "version": "0.7.0", 6 | "publisher": "pprice", 7 | "author": { 8 | "name": "Phil Price", 9 | "url": "https://philprice.me" 10 | }, 11 | "license": "MIT", 12 | "bugs": { 13 | "url": "https://github.com/pprice/vscode-better-merge/issues" 14 | }, 15 | "engines": { 16 | "vscode": "^1.10.0" 17 | }, 18 | "icon": "img/app.png", 19 | "repository": { 20 | "type": "git", 21 | "url": "https://github.com/pprice/vscode-better-merge.git" 22 | }, 23 | "categories": [ 24 | "Other" 25 | ], 26 | "activationEvents": [ 27 | "*" 28 | ], 29 | "main": "./out/src/extension", 30 | "contributes": { 31 | "configuration": { 32 | "type": "object", 33 | "title": "Better Merge extension configuation", 34 | "properties": { 35 | "better-merge.enableEditorOverview": { 36 | "type": "boolean", 37 | "default": true, 38 | "description": "Show merge conflicts in editor overview bar" 39 | }, 40 | "better-merge.enableCodeLens": { 41 | "type": "boolean", 42 | "default": true, 43 | "description": "Show codelens actions above merge conflicts" 44 | }, 45 | "better-merge.enableDecorations": { 46 | "type": "boolean", 47 | "default": true, 48 | "description": "Show inline editor decorations (coloring) for merge conflicts" 49 | } 50 | } 51 | }, 52 | "commands": [ 53 | { 54 | "category": "Better Merge", 55 | "title": "Accept all current", 56 | "command": "better-merge.accept.all-current" 57 | }, 58 | { 59 | "category": "Better Merge", 60 | "title": "Accept all incoming", 61 | "command": "better-merge.accept.all-incoming" 62 | }, 63 | { 64 | "category": "Better Merge", 65 | "title": "Accept all both", 66 | "command": "better-merge.accept.all-both" 67 | }, 68 | { 69 | "category": "Better Merge", 70 | "title": "Accept current", 71 | "command": "better-merge.accept.current" 72 | }, 73 | { 74 | "category": "Better Merge", 75 | "title": "Accept incoming", 76 | "command": "better-merge.accept.incoming" 77 | }, 78 | { 79 | "category": "Better Merge", 80 | "title": "Accept selection", 81 | "command": "better-merge.accept.selection" 82 | }, 83 | { 84 | "category": "Better Merge", 85 | "title": "Accept both", 86 | "command": "better-merge.accept.both" 87 | }, 88 | { 89 | "category": "Better Merge", 90 | "title": "Next conflict", 91 | "command": "better-merge.next" 92 | }, 93 | { 94 | "category": "Better Merge", 95 | "title": "Previous conflict", 96 | "command": "better-merge.previous" 97 | }, 98 | { 99 | "category": "Better Merge", 100 | "title": "Compare current conflict", 101 | "command": "better-merge.compare" 102 | } 103 | ], 104 | "keybindings": [ 105 | { 106 | "command": "better-merge.next", 107 | "when": "editorTextFocus", 108 | "key": "alt+m down" 109 | }, 110 | { 111 | "command": "better-merge.previous", 112 | "when": "editorTextFocus", 113 | "key": "alt+m up" 114 | }, 115 | { 116 | "command": "better-merge.accept.selection", 117 | "when": "editorTextFocus", 118 | "key": "alt+m enter" 119 | }, 120 | { 121 | "command": "better-merge.accept.current", 122 | "when": "editorTextFocus", 123 | "key": "alt+m 1" 124 | }, 125 | { 126 | "command": "better-merge.accept.incoming", 127 | "when": "editorTextFocus", 128 | "key": "alt+m 2" 129 | }, 130 | { 131 | "command": "better-merge.accept.both", 132 | "when": "editorTextFocus", 133 | "key": "alt+m 3" 134 | } 135 | ] 136 | }, 137 | "scripts": { 138 | "vscode:prepublish": "tsc -p ./", 139 | "compile": "tsc -watch -p ./", 140 | "postinstall": "node ./node_modules/vscode/bin/install" 141 | }, 142 | "devDependencies": { 143 | "typescript": "^2.0.3", 144 | "vscode": "^1.0.0", 145 | "mocha": "^2.3.3", 146 | "@types/node": "^6.0.40", 147 | "@types/mocha": "^2.2.32" 148 | } 149 | } -------------------------------------------------------------------------------- /src/delayer.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | export interface ITask { 4 | (): T; 5 | } 6 | 7 | export class Delayer { 8 | 9 | public defaultDelay: number; 10 | private timeout: any; // Timer 11 | private completionPromise: Promise; 12 | private onSuccess: (value?: T | Thenable) => void; 13 | private task: ITask; 14 | 15 | constructor(defaultDelay: number) { 16 | this.defaultDelay = defaultDelay; 17 | this.timeout = null; 18 | this.completionPromise = null; 19 | this.onSuccess = null; 20 | this.task = null; 21 | } 22 | 23 | public trigger(task: ITask, delay: number = this.defaultDelay): Promise { 24 | this.task = task; 25 | if (delay >= 0) { 26 | this.cancelTimeout(); 27 | } 28 | 29 | if (!this.completionPromise) { 30 | this.completionPromise = new Promise((resolve) => { 31 | this.onSuccess = resolve; 32 | }).then(() => { 33 | this.completionPromise = null; 34 | this.onSuccess = null; 35 | var result = this.task(); 36 | this.task = null; 37 | return result; 38 | }); 39 | } 40 | 41 | if (delay >= 0 || this.timeout === null) { 42 | this.timeout = setTimeout(() => { 43 | this.timeout = null; 44 | this.onSuccess(null); 45 | }, delay >= 0 ? delay : this.defaultDelay); 46 | } 47 | 48 | return this.completionPromise; 49 | } 50 | 51 | public forceDelivery(): Promise { 52 | if (!this.completionPromise) { 53 | return null; 54 | } 55 | this.cancelTimeout(); 56 | let result = this.completionPromise; 57 | this.onSuccess(null); 58 | return result; 59 | } 60 | 61 | public isTriggered(): boolean { 62 | return this.timeout !== null; 63 | } 64 | 65 | public cancel(): void { 66 | this.cancelTimeout(); 67 | this.completionPromise = null; 68 | } 69 | 70 | private cancelTimeout(): void { 71 | if (this.timeout !== null) { 72 | clearTimeout(this.timeout); 73 | this.timeout = null; 74 | } 75 | } 76 | } -------------------------------------------------------------------------------- /src/extension.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | import * as vscode from 'vscode'; 3 | import Services from './services'; 4 | 5 | export function activate(context: vscode.ExtensionContext) { 6 | // Register disposables 7 | const services = new Services(context); 8 | services.begin(); 9 | 10 | context.subscriptions.push(services); 11 | } 12 | 13 | export function deactivate() { 14 | } 15 | 16 | -------------------------------------------------------------------------------- /src/services/codelensProvider.ts: -------------------------------------------------------------------------------- 1 | 2 | import * as vscode from 'vscode'; 3 | import * as interfaces from './interfaces'; 4 | 5 | export default class MergeConflictCodeLensProvider implements vscode.CodeLensProvider, vscode.Disposable { 6 | 7 | private disposables: vscode.Disposable[] = []; 8 | private config : interfaces.IExtensionConfiguration; 9 | 10 | constructor(private context: vscode.ExtensionContext, private tracker: interfaces.IDocumentMergeConflictTracker) { 11 | } 12 | 13 | begin(config : interfaces.IExtensionConfiguration) { 14 | this.config = config; 15 | this.disposables.push( 16 | vscode.languages.registerCodeLensProvider({ pattern: '**/*' }, this) 17 | ); 18 | } 19 | 20 | configurationUpdated(config : interfaces.IExtensionConfiguration) { 21 | this.config = config; 22 | } 23 | 24 | dispose() { 25 | if (this.disposables) { 26 | this.disposables.forEach(disposable => disposable.dispose()); 27 | this.disposables = null; 28 | } 29 | } 30 | 31 | async provideCodeLenses(document: vscode.TextDocument, token: vscode.CancellationToken): Promise { 32 | 33 | if(!this.config || !this.config.enableCodeLens) { 34 | return null; 35 | } 36 | 37 | let conflicts = await this.tracker.getConflicts(document); 38 | 39 | if (!conflicts || conflicts.length === 0) { 40 | return null; 41 | } 42 | 43 | let items: vscode.CodeLens[] = []; 44 | 45 | conflicts.forEach(conflict => { 46 | let acceptCurrentCommand: vscode.Command = { 47 | command: 'better-merge.accept.current', 48 | title: `Accept current change`, 49 | arguments: ['known-conflict', conflict] 50 | }; 51 | 52 | let acceptIncomingCommand: vscode.Command = { 53 | command: 'better-merge.accept.incoming', 54 | title: `Accept incoming change`, 55 | arguments: ['known-conflict', conflict] 56 | }; 57 | 58 | let acceptBothCommand: vscode.Command = { 59 | command: 'better-merge.accept.both', 60 | title: `Accept both changes`, 61 | arguments: ['known-conflict', conflict] 62 | }; 63 | 64 | let diffCommand: vscode.Command = { 65 | command: 'better-merge.compare', 66 | title: `Compare changes`, 67 | arguments: [conflict] 68 | }; 69 | 70 | items.push( 71 | new vscode.CodeLens(conflict.range, acceptCurrentCommand), 72 | new vscode.CodeLens(conflict.range.with(conflict.range.start.with({ character: conflict.range.start.character + 1 })), acceptIncomingCommand), 73 | new vscode.CodeLens(conflict.range.with(conflict.range.start.with({ character: conflict.range.start.character + 2 })), acceptBothCommand), 74 | new vscode.CodeLens(conflict.range.with(conflict.range.start.with({ character: conflict.range.start.character + 3 })), diffCommand) 75 | ); 76 | }); 77 | 78 | return items; 79 | } 80 | 81 | resolveCodeLens(codeLens: vscode.CodeLens, token: vscode.CancellationToken): vscode.CodeLens { 82 | return; 83 | } 84 | } -------------------------------------------------------------------------------- /src/services/commandHandler.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import * as interfaces from './interfaces'; 3 | import ContentProvider from './contentProvider'; 4 | import * as path from 'path'; 5 | 6 | const messages = { 7 | cursorNotInConflict: 'Editor cursor is not within a merge conflict', 8 | cursorOnSplitterRange: 'Editor cursor is within the merge conflict splitter, please move it to either the "current" or "incoming" block', 9 | noConflicts: 'No merge conflicts found in this file', 10 | noOtherConflictsInThisFile: 'No other merge conflicts within this file' 11 | }; 12 | 13 | interface IDocumentMergeConflictNavigationResults { 14 | canNavigate: boolean; 15 | conflict?: interfaces.IDocumentMergeConflict; 16 | } 17 | 18 | enum NavigationDirection { 19 | Forwards, 20 | Backwards 21 | } 22 | 23 | export default class CommandHandler implements vscode.Disposable { 24 | 25 | private disposables: vscode.Disposable[] = []; 26 | 27 | constructor(private context: vscode.ExtensionContext, private tracker: interfaces.IDocumentMergeConflictTracker) { 28 | } 29 | 30 | begin() { 31 | this.disposables.push( 32 | vscode.commands.registerTextEditorCommand('better-merge.accept.current', this.acceptCurrent, this), 33 | vscode.commands.registerTextEditorCommand('better-merge.accept.incoming', this.acceptIncoming, this), 34 | vscode.commands.registerTextEditorCommand('better-merge.accept.selection', this.acceptSelection, this), 35 | vscode.commands.registerTextEditorCommand('better-merge.accept.both', this.acceptBoth, this), 36 | vscode.commands.registerTextEditorCommand('better-merge.accept.all-current', this.acceptAllCurrent, this), 37 | vscode.commands.registerTextEditorCommand('better-merge.accept.all-incoming', this.acceptAllIncoming, this), 38 | vscode.commands.registerTextEditorCommand('better-merge.accept.all-both', this.acceptAllBoth, this), 39 | vscode.commands.registerTextEditorCommand('better-merge.next', this.navigateNext, this), 40 | vscode.commands.registerTextEditorCommand('better-merge.previous', this.navigatePrevious, this), 41 | vscode.commands.registerTextEditorCommand('better-merge.compare', this.compare, this) 42 | ); 43 | } 44 | 45 | acceptCurrent(editor: vscode.TextEditor, edit: vscode.TextEditorEdit, ...args): Promise { 46 | return this.accept(interfaces.CommitType.Current, editor, ...args); 47 | } 48 | 49 | acceptIncoming(editor: vscode.TextEditor, edit: vscode.TextEditorEdit, ...args): Promise { 50 | return this.accept(interfaces.CommitType.Incoming, editor, ...args); 51 | } 52 | 53 | acceptBoth(editor: vscode.TextEditor, edit: vscode.TextEditorEdit, ...args): Promise { 54 | return this.accept(interfaces.CommitType.Both, editor, ...args); 55 | } 56 | 57 | acceptAllCurrent(editor: vscode.TextEditor, edit: vscode.TextEditorEdit, ...args): Promise { 58 | return this.acceptAll(interfaces.CommitType.Current, editor); 59 | } 60 | 61 | acceptAllIncoming(editor: vscode.TextEditor, edit: vscode.TextEditorEdit, ...args): Promise { 62 | return this.acceptAll(interfaces.CommitType.Incoming, editor); 63 | } 64 | 65 | acceptAllBoth(editor: vscode.TextEditor, edit: vscode.TextEditorEdit, ...args): Promise { 66 | return this.acceptAll(interfaces.CommitType.Both, editor); 67 | } 68 | 69 | async compare(editor: vscode.TextEditor, edit: vscode.TextEditorEdit, conflict: interfaces.IDocumentMergeConflict, ...args) { 70 | const fileName = path.basename(editor.document.uri.fsPath); 71 | 72 | // No conflict, command executed from command palette 73 | if(!conflict) { 74 | conflict = await this.findConflictContainingSelection(editor); 75 | 76 | // Still failed to find conflict, warn the user and exit 77 | if(!conflict) { 78 | vscode.window.showWarningMessage(messages.cursorNotInConflict); 79 | return; 80 | } 81 | } 82 | 83 | let range = conflict.current.content; 84 | const leftUri = editor.document.uri.with({ 85 | scheme: ContentProvider.scheme, 86 | query: JSON.stringify(range) 87 | }); 88 | 89 | const leftTitle = `Current changes`; // (Ln ${range.start.line}${!range.isSingleLine ? `-${range.end.line}` : ''})`; 90 | 91 | range = conflict.incoming.content; 92 | const rightUri = leftUri.with({ query: JSON.stringify(range) }); 93 | 94 | const rightTitle = `Incoming changes`; // (Ln${range.start.line}${!range.isSingleLine ? `-${range.end.line}` : ''})`; 95 | 96 | const title = `${fileName}: ${leftTitle} \u2194 ${rightTitle}`; 97 | vscode.commands.executeCommand('vscode.diff', leftUri, rightUri, title); 98 | } 99 | 100 | navigateNext(editor: vscode.TextEditor, edit: vscode.TextEditorEdit, ...args): Promise { 101 | return this.navigate(editor, NavigationDirection.Forwards); 102 | } 103 | 104 | navigatePrevious(editor: vscode.TextEditor, edit: vscode.TextEditorEdit, ...args): Promise { 105 | return this.navigate(editor, NavigationDirection.Backwards); 106 | } 107 | 108 | async acceptSelection(editor: vscode.TextEditor, edit: vscode.TextEditorEdit, ...args): Promise { 109 | let conflict = await this.findConflictContainingSelection(editor); 110 | 111 | if (!conflict) { 112 | vscode.window.showWarningMessage(messages.cursorNotInConflict); 113 | return; 114 | } 115 | 116 | let typeToAccept: interfaces.CommitType = null; 117 | 118 | // Figure out if the cursor is in current or incoming, we do this by seeing if 119 | // the active position is before or after the range of the splitter. We can 120 | // use this trick as the previous check in findConflictByActiveSelection will 121 | // ensure it's within the conflict range, so we don't falsely identify "current" 122 | // or "incoming" if outside of a conflict range. 123 | if (editor.selection.active.isBefore(conflict.splitter.start)) { 124 | typeToAccept = interfaces.CommitType.Current; 125 | } 126 | else if (editor.selection.active.isAfter(conflict.splitter.end)) { 127 | typeToAccept = interfaces.CommitType.Incoming; 128 | } 129 | else { 130 | vscode.window.showWarningMessage(messages.cursorOnSplitterRange); 131 | return; 132 | } 133 | 134 | this.tracker.forget(editor.document); 135 | conflict.commitEdit(typeToAccept, editor); 136 | } 137 | 138 | dispose() { 139 | if (this.disposables) { 140 | this.disposables.forEach(disposable => disposable.dispose()); 141 | this.disposables = null; 142 | } 143 | } 144 | 145 | private async navigate(editor: vscode.TextEditor, direction: NavigationDirection): Promise { 146 | let navigationResult = await this.findConflictForNavigation(editor, direction); 147 | 148 | if (!navigationResult) { 149 | vscode.window.showWarningMessage(messages.noConflicts); 150 | return; 151 | } 152 | else if (!navigationResult.canNavigate) { 153 | vscode.window.showWarningMessage(messages.noOtherConflictsInThisFile); 154 | return; 155 | } 156 | 157 | // Move the selection to the first line of the conflict 158 | editor.selection = new vscode.Selection(navigationResult.conflict.range.start, navigationResult.conflict.range.start); 159 | editor.revealRange(navigationResult.conflict.range, vscode.TextEditorRevealType.Default); 160 | } 161 | 162 | private async accept(type: interfaces.CommitType, editor: vscode.TextEditor, ...args): Promise { 163 | 164 | let conflict: interfaces.IDocumentMergeConflict = null; 165 | 166 | // If launched with known context, take the conflict from that 167 | if (args[0] === 'known-conflict') { 168 | conflict = args[1]; 169 | } 170 | else { 171 | // Attempt to find a conflict that matches the current curosr position 172 | conflict = await this.findConflictContainingSelection(editor); 173 | } 174 | 175 | if (!conflict) { 176 | vscode.window.showWarningMessage(messages.cursorNotInConflict); 177 | return; 178 | } 179 | 180 | // Tracker can forget as we know we are going to do an edit 181 | this.tracker.forget(editor.document); 182 | conflict.commitEdit(type, editor); 183 | } 184 | 185 | private async acceptAll(type: interfaces.CommitType, editor: vscode.TextEditor): Promise { 186 | let conflicts = await this.tracker.getConflicts(editor.document); 187 | 188 | if (!conflicts || conflicts.length === 0) { 189 | vscode.window.showWarningMessage(messages.noConflicts); 190 | return; 191 | } 192 | 193 | // For get the current state of the document, as we know we are doing to do a large edit 194 | this.tracker.forget(editor.document); 195 | 196 | // Apply all changes as one edit 197 | await editor.edit((edit) => conflicts.forEach(conflict => { 198 | conflict.applyEdit(type, editor, edit); 199 | })); 200 | } 201 | 202 | private async findConflictContainingSelection(editor: vscode.TextEditor, conflicts?: interfaces.IDocumentMergeConflict[]): Promise { 203 | 204 | if (!conflicts) { 205 | conflicts = await this.tracker.getConflicts(editor.document); 206 | } 207 | 208 | if (!conflicts || conflicts.length === 0) { 209 | return null; 210 | } 211 | 212 | for (let i = 0; i < conflicts.length; i++) { 213 | if (conflicts[i].range.contains(editor.selection.active)) { 214 | return conflicts[i]; 215 | } 216 | } 217 | 218 | return null; 219 | } 220 | 221 | private async findConflictForNavigation(editor: vscode.TextEditor, direction: NavigationDirection, conflicts?: interfaces.IDocumentMergeConflict[]): Promise { 222 | if (!conflicts) { 223 | conflicts = await this.tracker.getConflicts(editor.document); 224 | } 225 | 226 | if (!conflicts || conflicts.length === 0) { 227 | return null; 228 | } 229 | 230 | let selection = editor.selection.active; 231 | if (conflicts.length === 1) { 232 | if (conflicts[0].range.contains(selection)) { 233 | return { 234 | canNavigate: false 235 | }; 236 | } 237 | 238 | return { 239 | canNavigate: true, 240 | conflict: conflicts[0] 241 | }; 242 | } 243 | 244 | let predicate: (conflict) => boolean = null; 245 | let fallback: () => interfaces.IDocumentMergeConflict = null; 246 | 247 | if (direction === NavigationDirection.Forwards) { 248 | predicate = (conflict) => selection.isBefore(conflict.range.start); 249 | fallback = () => conflicts[0]; 250 | } else if (direction === NavigationDirection.Backwards) { 251 | predicate = (conflict) => selection.isAfter(conflict.range.start); 252 | fallback = () => conflicts[conflicts.length - 1]; 253 | } else { 254 | throw new Error(`Unsupported direction ${direction}`); 255 | } 256 | 257 | for (let i = 0; i < conflicts.length; i++) { 258 | if (predicate(conflicts[i]) && !conflicts[i].range.contains(selection)) { 259 | return { 260 | canNavigate: true, 261 | conflict: conflicts[i] 262 | }; 263 | } 264 | } 265 | 266 | // Went all the way to the end, return the head 267 | return { 268 | canNavigate: true, 269 | conflict: fallback() 270 | }; 271 | } 272 | } -------------------------------------------------------------------------------- /src/services/configurationService.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pprice/vscode-better-merge/976ad76b56c190cf8a1213924b00686ff1c4379c/src/services/configurationService.ts -------------------------------------------------------------------------------- /src/services/contentProvider.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | import * as vscode from 'vscode'; 3 | import * as interfaces from './interfaces'; 4 | 5 | export default class MergeConflictContentProvider implements vscode.TextDocumentContentProvider, vscode.Disposable { 6 | 7 | static scheme = 'better-merge.conflict-diff'; 8 | 9 | constructor(private context: vscode.ExtensionContext) { 10 | } 11 | 12 | begin(config : interfaces.IExtensionConfiguration) { 13 | this.context.subscriptions.push( 14 | vscode.workspace.registerTextDocumentContentProvider(MergeConflictContentProvider.scheme, this) 15 | ); 16 | } 17 | 18 | dispose() { 19 | } 20 | 21 | async provideTextDocumentContent(uri: vscode.Uri): Promise { 22 | try { 23 | const [start, end] = JSON.parse(uri.query) as { line: number, character: number }[]; 24 | 25 | const document = await vscode.workspace.openTextDocument(uri.with({ scheme: 'file', query: '' })); 26 | const text = document.getText(new vscode.Range(start.line, start.character, end.line, end.character)); 27 | return text; 28 | } 29 | catch (ex) { 30 | await vscode.window.showErrorMessage('Unable to show comparison'); 31 | return undefined; 32 | } 33 | } 34 | } -------------------------------------------------------------------------------- /src/services/documentMergeConflict.ts: -------------------------------------------------------------------------------- 1 | import * as interfaces from './interfaces'; 2 | import * as vscode from 'vscode'; 3 | 4 | export class DocumentMergeConflict implements interfaces.IDocumentMergeConflict { 5 | 6 | public range: vscode.Range; 7 | public current: interfaces.IMergeRegion; 8 | public incoming: interfaces.IMergeRegion; 9 | public splitter: vscode.Range; 10 | 11 | constructor(document: vscode.TextDocument, descriptor: interfaces.IDocumentMergeConflictDescriptor) { 12 | this.range = descriptor.range; 13 | this.current = descriptor.current; 14 | this.incoming = descriptor.incoming; 15 | this.splitter = descriptor.splitter; 16 | } 17 | 18 | public commitEdit(type: interfaces.CommitType, editor: vscode.TextEditor, edit?: vscode.TextEditorEdit): Thenable { 19 | 20 | if (edit) { 21 | 22 | this.applyEdit(type, editor, edit); 23 | return Promise.resolve(true); 24 | }; 25 | 26 | return editor.edit((edit) => this.applyEdit(type, editor, edit)); 27 | } 28 | 29 | public applyEdit(type: interfaces.CommitType, editor: vscode.TextEditor, edit: vscode.TextEditorEdit): void { 30 | 31 | // Each conflict is a set of ranges as follows, note placements or newlines 32 | // which may not in in spans 33 | // [ Conflict Range -- (Entire content below) 34 | // [ Current Header ]\n -- >>>>> Header 35 | // [ Current Content ] -- (content) 36 | // [ Splitter ]\n -- ===== 37 | // [ Incoming Content ] -- (content) 38 | // [ Incoming Header ]\n -- <<<<< Incoming 39 | // ] 40 | if (type === interfaces.CommitType.Current) { 41 | // Replace [ Conflict Range ] with [ Current Content ] 42 | let content = editor.document.getText(this.current.content); 43 | this.replaceRangeWithContent(content, edit); 44 | } 45 | else if (type === interfaces.CommitType.Incoming) { 46 | let content = editor.document.getText(this.incoming.content); 47 | this.replaceRangeWithContent(content, edit); 48 | } 49 | else if (type === interfaces.CommitType.Both) { 50 | // Replace [ Conflict Range ] with [ Current Content ] + \n + [ Incoming Content ] 51 | // 52 | // NOTE: Due to headers and splitters NOT covering \n (this is so newlines inserted) 53 | // by the user after (e.g. <<<<< HEAD do not fall into the header range but the 54 | // content ranges), we can't push 3x deletes, we need to replace the range with the 55 | // union of the content. 56 | 57 | const currentContent = editor.document.getText(this.current.content); 58 | const incomingContent = editor.document.getText(this.incoming.content); 59 | 60 | let finalContent = ''; 61 | 62 | if(!this.isNewlineOnly(currentContent)) { 63 | finalContent += currentContent; 64 | } 65 | 66 | if(!this.isNewlineOnly(incomingContent)) { 67 | if(finalContent.length > 0) { 68 | finalContent += '\n'; 69 | } 70 | 71 | finalContent += incomingContent; 72 | } 73 | 74 | if(finalContent.length > 0 && !this.isNewlineOnly(finalContent)) { 75 | finalContent += '\n'; 76 | } 77 | 78 | edit.setEndOfLine(vscode.EndOfLine.LF); 79 | edit.replace(this.range, finalContent); 80 | } 81 | } 82 | 83 | private replaceRangeWithContent(content: string, edit: vscode.TextEditorEdit) { 84 | if (this.isNewlineOnly(content)) { 85 | edit.replace(this.range, ''); 86 | return; 87 | } 88 | 89 | let updatedContent = content.concat('\n'); 90 | edit.setEndOfLine(vscode.EndOfLine.LF); 91 | 92 | // Replace [ Conflict Range ] with [ Current Content ] 93 | 94 | edit.replace(this.range, updatedContent); 95 | } 96 | 97 | private isNewlineOnly(text: string) { 98 | return text === '\n' || text === '\r\n'; 99 | } 100 | } -------------------------------------------------------------------------------- /src/services/documentTracker.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { MergeConflictParser } from './mergeConflictParser'; 3 | import * as interfaces from './interfaces'; 4 | import { Delayer } from '../delayer'; 5 | 6 | export default class DocumentMergeConflictTracker implements vscode.Disposable, interfaces.IDocumentMergeConflictTracker { 7 | 8 | private cache: Map> = new Map(); 9 | private delayExpireTime: number = 150; 10 | 11 | getConflicts(document: vscode.TextDocument): PromiseLike { 12 | // Attempt from cache 13 | 14 | let key = this.getCacheKey(document); 15 | 16 | if (!key) { 17 | // Document doesnt have a uri, can't cache it, so return 18 | return Promise.resolve(this.getConflictsOrEmpty(document)); 19 | } 20 | 21 | let cacheItem: Delayer = this.cache.get(key); 22 | if(!cacheItem) { 23 | cacheItem = new Delayer(this.delayExpireTime); 24 | this.cache.set(key, cacheItem); 25 | } 26 | 27 | return cacheItem.trigger(() => { 28 | let conflicts = this.getConflictsOrEmpty(document); 29 | 30 | if(this.cache) { 31 | this.cache.delete(key); 32 | } 33 | 34 | return conflicts; 35 | }); 36 | } 37 | 38 | forget(document: vscode.TextDocument) { 39 | let key = this.getCacheKey(document); 40 | 41 | if (key) { 42 | this.cache.delete(key); 43 | } 44 | } 45 | 46 | dispose() { 47 | if (this.cache) { 48 | this.cache.clear(); 49 | this.cache = null; 50 | } 51 | } 52 | 53 | private getConflictsOrEmpty(document : vscode.TextDocument) : interfaces.IDocumentMergeConflict[] { 54 | return MergeConflictParser.containsConflict(document) ? MergeConflictParser.scanDocument(document) : []; 55 | } 56 | 57 | private getCacheKey(document: vscode.TextDocument) { 58 | if (document.uri && document.uri) { 59 | return document.uri.toString(); 60 | } 61 | 62 | return null; 63 | } 64 | } -------------------------------------------------------------------------------- /src/services/index.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import DocumentTracker from './documentTracker'; 3 | import CodeLensProvider from './codelensProvider'; 4 | import CommandHandler from './commandHandler'; 5 | import ContentProvider from './contentProvider'; 6 | import Decorator from './mergeDecorator'; 7 | 8 | const ConfigurationSectionName = 'better-merge'; 9 | 10 | export default class ServiceWrapper implements vscode.Disposable { 11 | 12 | private services: vscode.Disposable[] = []; 13 | 14 | constructor(private context: vscode.ExtensionContext) { 15 | } 16 | 17 | begin() { 18 | 19 | let configuration = vscode.workspace.getConfiguration(ConfigurationSectionName); 20 | const documentTracker = new DocumentTracker(); 21 | 22 | this.services.push( 23 | documentTracker, 24 | new CommandHandler(this.context, documentTracker), 25 | new CodeLensProvider(this.context, documentTracker), 26 | new ContentProvider(this.context), 27 | new Decorator(this.context, documentTracker), 28 | ); 29 | 30 | this.services.forEach((service: any) => { 31 | if (service.begin && service.begin instanceof Function) { 32 | service.begin(configuration); 33 | } 34 | }); 35 | 36 | vscode.workspace.onDidChangeConfiguration(() => { 37 | this.services.forEach((service: any) => { 38 | if (service.configurationUpdated && service.configurationUpdated instanceof Function) { 39 | service.configurationUpdated(vscode.workspace.getConfiguration(ConfigurationSectionName)); 40 | } 41 | }); 42 | }); 43 | } 44 | 45 | dispose() { 46 | if (this.services) { 47 | this.services.forEach(disposable => disposable.dispose()); 48 | this.services = null; 49 | } 50 | } 51 | } 52 | 53 | -------------------------------------------------------------------------------- /src/services/interfaces.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | 3 | export interface IMergeRegion { 4 | name: string; 5 | header: vscode.Range; 6 | content: vscode.Range; 7 | } 8 | 9 | export enum CommitType { 10 | Current, 11 | Incoming, 12 | Both 13 | } 14 | 15 | export interface IExtensionConfiguration { 16 | enableCodeLens: boolean; 17 | enableDecorations: boolean; 18 | enableEditorOverview: boolean; 19 | } 20 | 21 | export interface IDocumentMergeConflict extends IDocumentMergeConflictDescriptor { 22 | commitEdit(type: CommitType, editor: vscode.TextEditor, edit?: vscode.TextEditorEdit); 23 | applyEdit(type: CommitType, editor: vscode.TextEditor, edit: vscode.TextEditorEdit); 24 | } 25 | 26 | export interface IDocumentMergeConflictDescriptor { 27 | range: vscode.Range; 28 | current: IMergeRegion; 29 | incoming: IMergeRegion; 30 | splitter: vscode.Range; 31 | } 32 | 33 | export interface IDocumentMergeConflictTracker { 34 | getConflicts(document: vscode.TextDocument): PromiseLike; 35 | forget(document: vscode.TextDocument); 36 | } 37 | -------------------------------------------------------------------------------- /src/services/mergeConflictParser.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import * as interfaces from './interfaces'; 3 | import { DocumentMergeConflict } from './documentMergeConflict'; 4 | import * as vm from 'vm'; 5 | import * as util from 'util'; 6 | 7 | export class MergeConflictParser { 8 | 9 | static scanDocument(document: vscode.TextDocument): interfaces.IDocumentMergeConflict[] { 10 | 11 | // Conflict matching regex, comments are in the format of "description - [group index] group name" 12 | // Premise is: Match the current change (<<<<<<), match anything up to the splitter (======) then 13 | // match anything up to the incoming change (>>>>>>), this leaves some oddities with newlines not being 14 | // pulled into the "body" of each change, DocumentMergeConflict.applyEdit will deal with these cases 15 | // and append newlines when needed 16 | 17 | const conflictMatcher = new RegExp([ 18 | /(^<<<<<<<\s(.+)\r?\n)/, // "Current" conflict header - [1] entire line, [2] name 19 | /([\s\S]*?)/, // "Current" conflict body - [3] body text 20 | /(^=======\r?\n)/, // Splitter - [4] entire line 21 | /([\s\S]*?)/, // Incoming conflict body - [5] 22 | /(^>>>>>>>\s(.+)\r?\n)/ // Incoming conflict header - [6] entire line, [7] name 23 | ].map(r => r.source).join(''), 24 | 'mg'); 25 | 26 | const offsetGroups = [1, 3, 4, 5, 6]; // Skip inner matches when calculating length 27 | 28 | let text = document.getText(); 29 | let sandboxScope = { 30 | result: [], 31 | conflictMatcher, 32 | text: text 33 | }; 34 | const context = vm.createContext(sandboxScope); 35 | const script = new vm.Script(` 36 | let match; 37 | while (match = conflictMatcher.exec(text)) { 38 | // Ensure we don't get stuck in an infinite loop 39 | if (match.index === conflictMatcher.lastIndex) { 40 | conflictMatcher.lastIndex++; 41 | } 42 | 43 | result.push(match); 44 | }`); 45 | 46 | try { 47 | // If the regex takes longer than 1s consider it dead 48 | script.runInContext(context, { timeout: 1000 }); 49 | } 50 | catch (ex) { 51 | return []; 52 | } 53 | 54 | return sandboxScope.result.map(match => new DocumentMergeConflict(document, MergeConflictParser.matchesToDescriptor(document, match, offsetGroups))); 55 | } 56 | 57 | static containsConflict(document: vscode.TextDocument): boolean { 58 | if (!document) { 59 | return false; 60 | } 61 | 62 | // TODO: Ask source control if the file contains a conflict 63 | let text = document.getText(); 64 | return text.includes('<<<<<<<') && text.includes('>>>>>>>'); 65 | } 66 | 67 | static matchesToDescriptor(document: vscode.TextDocument, match: RegExpExecArray, offsets?: number[]) : interfaces.IDocumentMergeConflictDescriptor { 68 | 69 | var item : interfaces.IDocumentMergeConflictDescriptor = { 70 | range: new vscode.Range(document.positionAt(match.index), document.positionAt(match.index + match[0].length)), 71 | current: { 72 | name: match[2], 73 | header: this.getMatchPositions(document, match, 1, offsets), 74 | content: this.getMatchPositions(document, match, 3, offsets), 75 | }, 76 | splitter: this.getMatchPositions(document, match, 4, offsets), 77 | incoming: { 78 | name: match[9], 79 | header: this.getMatchPositions(document, match, 6, offsets), 80 | content: this.getMatchPositions(document, match, 5, offsets), 81 | } 82 | } 83 | 84 | return item; 85 | } 86 | 87 | 88 | static getMatchPositions(document: vscode.TextDocument, match: RegExpExecArray, groupIndex: number, offsetGroups?: number[]): vscode.Range { 89 | // Javascript doesnt give of offsets within the match, we need to calculate these 90 | // based of the prior groups, skipping nested matches (yuck). 91 | if (!offsetGroups) { 92 | offsetGroups = match.map((i, idx) => idx); 93 | } 94 | 95 | let start = match.index; 96 | 97 | for (var i = 0; i < offsetGroups.length; i++) { 98 | let value = offsetGroups[i]; 99 | 100 | if (value >= groupIndex) { 101 | break; 102 | } 103 | 104 | start += match[value] !== undefined ? match[value].length : 0; 105 | } 106 | 107 | const groupMatch = match[groupIndex]; 108 | let targetMatchLength = groupMatch !== undefined ? groupMatch.length : -1; 109 | let end = (start + targetMatchLength); 110 | 111 | if (groupMatch !== undefined) { 112 | // Move the end up if it's capped by a trailing \r\n, this is so regions don't expand into 113 | // the line below, and can be "pulled down" by editing the line below 114 | if (match[groupIndex].lastIndexOf('\n') === targetMatchLength - 1) { 115 | end--; 116 | 117 | // .. for windows encodings of new lines 118 | if (match[groupIndex].lastIndexOf('\r') === targetMatchLength - 2) { 119 | end--; 120 | } 121 | } 122 | } 123 | 124 | return new vscode.Range(document.positionAt(start), document.positionAt(end)); 125 | } 126 | 127 | } 128 | -------------------------------------------------------------------------------- /src/services/mergeDecorator.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import * as interfaces from './interfaces'; 3 | 4 | 5 | export default class MergeDectorator implements vscode.Disposable { 6 | 7 | private decorations: { [key: string]: vscode.TextEditorDecorationType } = {}; 8 | 9 | private decorationUsesWholeLine: boolean = true; // Useful for debugging, set to false to see exact match ranges 10 | 11 | // TODO: Move to config? 12 | private currentColorRgb = `32,200,94`; 13 | private incomingColorRgb = `24,134,255`; 14 | private config: interfaces.IExtensionConfiguration = null; 15 | 16 | constructor(private context: vscode.ExtensionContext, private tracker: interfaces.IDocumentMergeConflictTracker) { 17 | } 18 | 19 | begin(config: interfaces.IExtensionConfiguration) { 20 | this.config = config; 21 | this.registerDecorationTypes(config); 22 | 23 | // Check if we already have a set of active windows, attempt to track these. 24 | vscode.window.visibleTextEditors.forEach(e => this.applyDecorations(e)); 25 | 26 | vscode.workspace.onDidOpenTextDocument(event => { 27 | this.applyDecorationsFromEvent(event); 28 | }, null, this.context.subscriptions); 29 | 30 | vscode.workspace.onDidChangeTextDocument(event => { 31 | this.applyDecorationsFromEvent(event.document); 32 | }, null, this.context.subscriptions); 33 | 34 | vscode.window.onDidChangeActiveTextEditor((e) => { 35 | // New editor attempt to apply 36 | this.applyDecorations(e); 37 | }, null, this.context.subscriptions); 38 | } 39 | 40 | configurationUpdated(config: interfaces.IExtensionConfiguration) { 41 | this.config = config; 42 | this.registerDecorationTypes(config); 43 | 44 | // Re-apply the decoration 45 | vscode.window.visibleTextEditors.forEach(e => { 46 | this.removeDecorations(e); 47 | this.applyDecorations(e); 48 | }); 49 | } 50 | 51 | private registerDecorationTypes(config: interfaces.IExtensionConfiguration) { 52 | 53 | // Dispose of existing decorations 54 | Object.keys(this.decorations).forEach(k => this.decorations[k].dispose()); 55 | this.decorations = {}; 56 | 57 | // None of our features are enabled 58 | if (!config.enableDecorations || !config.enableEditorOverview) { 59 | return; 60 | } 61 | 62 | // Create decorators 63 | if (config.enableDecorations || config.enableEditorOverview) { 64 | this.decorations['current.content'] = vscode.window.createTextEditorDecorationType( 65 | this.generateBlockRenderOptions(this.currentColorRgb, config) 66 | ); 67 | 68 | this.decorations['incoming.content'] = vscode.window.createTextEditorDecorationType( 69 | this.generateBlockRenderOptions(this.incomingColorRgb, config) 70 | ); 71 | } 72 | 73 | if (config.enableDecorations) { 74 | this.decorations['current.header'] = vscode.window.createTextEditorDecorationType({ 75 | // backgroundColor: 'rgba(255, 0, 0, 0.01)', 76 | // border: '2px solid red', 77 | isWholeLine: this.decorationUsesWholeLine, 78 | backgroundColor: `rgba(${this.currentColorRgb}, 1.0)`, 79 | color: 'white', 80 | after: { 81 | contentText: ' (Current change)', 82 | color: 'rgba(0, 0, 0, 0.7)' 83 | } 84 | }); 85 | 86 | this.decorations['splitter'] = vscode.window.createTextEditorDecorationType({ 87 | backgroundColor: 'rgba(0, 0, 0, 0.25)', 88 | color: 'white', 89 | isWholeLine: this.decorationUsesWholeLine, 90 | }); 91 | 92 | this.decorations['incoming.header'] = vscode.window.createTextEditorDecorationType({ 93 | backgroundColor: `rgba(${this.incomingColorRgb}, 1.0)`, 94 | color: 'white', 95 | isWholeLine: this.decorationUsesWholeLine, 96 | after: { 97 | contentText: ' (Incoming change)', 98 | color: 'rgba(0, 0, 0, 0.7)' 99 | } 100 | }); 101 | } 102 | } 103 | 104 | dispose() { 105 | if (this.decorations) { 106 | Object.keys(this.decorations).forEach(name => { 107 | this.decorations[name].dispose(); 108 | }); 109 | 110 | this.decorations = null; 111 | } 112 | } 113 | 114 | private generateBlockRenderOptions(color: string, config: interfaces.IExtensionConfiguration): vscode.DecorationRenderOptions { 115 | 116 | let renderOptions: any = {}; 117 | 118 | if (config.enableDecorations) { 119 | renderOptions.backgroundColor = `rgba(${color}, 0.2)`; 120 | renderOptions.isWholeLine = this.decorationUsesWholeLine; 121 | } 122 | 123 | if (config.enableEditorOverview) { 124 | renderOptions.overviewRulerColor = `rgba(${color}, 0.5)`; 125 | renderOptions.overviewRulerLane = vscode.OverviewRulerLane.Full; 126 | } 127 | 128 | return renderOptions; 129 | } 130 | 131 | private applyDecorationsFromEvent(eventDocument: vscode.TextDocument) { 132 | for (var i = 0; i < vscode.window.visibleTextEditors.length; i++) { 133 | if (vscode.window.visibleTextEditors[i].document === eventDocument) { 134 | // Attempt to apply 135 | this.applyDecorations(vscode.window.visibleTextEditors[i]); 136 | } 137 | } 138 | } 139 | 140 | private async applyDecorations(editor: vscode.TextEditor) { 141 | if (!editor || !editor.document) { return; } 142 | 143 | if (!this.config || (!this.config.enableDecorations && !this.config.enableEditorOverview)) { 144 | return; 145 | } 146 | 147 | let conflicts = await this.tracker.getConflicts(editor.document); 148 | 149 | if (conflicts.length === 0) { 150 | // TODO: Remove decorations 151 | this.removeDecorations(editor); 152 | return; 153 | } 154 | 155 | // Store decorations keyed by the type of decoration, set decoration wants a "style" 156 | // to go with it, which will match this key (see constructor); 157 | let matchDecorations: { [key: string]: vscode.DecorationOptions[] } = {}; 158 | 159 | let pushDecoration = (key: string, d: vscode.DecorationOptions) => { 160 | matchDecorations[key] = matchDecorations[key] || []; 161 | matchDecorations[key].push(d); 162 | }; 163 | 164 | conflicts.forEach(conflict => { 165 | // TODO, this could be more effective, just call getMatchPositions once with a map of decoration to position 166 | pushDecoration('current.content', { range: conflict.current.content }); 167 | pushDecoration('incoming.content', { range: conflict.incoming.content }); 168 | 169 | if (this.config.enableDecorations) { 170 | pushDecoration('current.header', { range: conflict.current.header }); 171 | pushDecoration('splitter', { range: conflict.splitter }); 172 | pushDecoration('incoming.header', { range: conflict.incoming.header }); 173 | } 174 | }); 175 | 176 | // For each match we've generated, apply the generated decoration with the matching decoration type to the 177 | // editor instance. Keys in both matches and decorations should match. 178 | Object.keys(matchDecorations).forEach(decorationKey => { 179 | let decorationType = this.decorations[decorationKey]; 180 | 181 | if (decorationType) { 182 | editor.setDecorations(decorationType, matchDecorations[decorationKey]); 183 | } 184 | }); 185 | } 186 | 187 | private removeDecorations(editor: vscode.TextEditor) { 188 | // Remove all decorations, there might be none 189 | Object.keys(this.decorations).forEach(decorationKey => { 190 | 191 | // Race condition, while editing the settings, it's possible to 192 | // generate regions before the configuration has been refreshed 193 | let decorationType = this.decorations[decorationKey]; 194 | 195 | if (decorationType) { 196 | editor.setDecorations(decorationType, []); 197 | } 198 | }); 199 | } 200 | } -------------------------------------------------------------------------------- /test/extension.test.ts: -------------------------------------------------------------------------------- 1 | // 2 | // Note: This example test is leveraging the Mocha test framework. 3 | // Please refer to their documentation on https://mochajs.org/ for help. 4 | // 5 | 6 | // The module 'assert' provides assertion methods from node 7 | import * as assert from 'assert'; 8 | 9 | // You can import and use all API from the 'vscode' module 10 | // as well as import your extension to test it 11 | import * as vscode from 'vscode'; 12 | import * as myExtension from '../src/extension'; 13 | 14 | // Defines a Mocha test suite to group tests of similar kind together 15 | suite("Extension Tests", () => { 16 | 17 | // Defines a Mocha unit test 18 | test("Something 1", () => { 19 | assert.equal(-1, [1, 2, 3].indexOf(5)); 20 | assert.equal(-1, [1, 2, 3].indexOf(0)); 21 | }); 22 | }); -------------------------------------------------------------------------------- /test/index.ts: -------------------------------------------------------------------------------- 1 | // 2 | // PLEASE DO NOT MODIFY / DELETE UNLESS YOU KNOW WHAT YOU ARE DOING 3 | // 4 | // This file is providing the test runner to use when running extension tests. 5 | // By default the test runner in use is Mocha based. 6 | // 7 | // You can provide your own test runner if you want to override it by exporting 8 | // a function run(testRoot: string, clb: (error:Error) => void) that the extension 9 | // host can call to run the tests. The test runner is expected to use console.log 10 | // to report the results back to the caller. When the tests are finished, return 11 | // a possible error to the callback or null if none. 12 | 13 | var testRunner = require('vscode/lib/testrunner'); 14 | 15 | // You can directly control Mocha options by uncommenting the following lines 16 | // See https://github.com/mochajs/mocha/wiki/Using-mocha-programmatically#set-options for more info 17 | testRunner.configure({ 18 | ui: 'tdd', // the TDD UI is being used in extension.test.ts (suite, test, etc.) 19 | useColors: true // colored output from test results 20 | }); 21 | 22 | module.exports = testRunner; -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es6", 5 | "outDir": "out", 6 | "lib": [ 7 | "es6" 8 | ], 9 | "sourceMap": true, 10 | "rootDir": "." 11 | }, 12 | "exclude": [ 13 | "node_modules", 14 | ".vscode-test" 15 | ] 16 | } -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "no-unused-expression": true, 4 | "no-duplicate-variable": true, 5 | "no-duplicate-key": true, 6 | "no-unused-variable": true, 7 | "curly": true, 8 | "class-name": true, 9 | "semicolon": ["always"], 10 | "triple-equals": true 11 | } 12 | } --------------------------------------------------------------------------------