├── .husky ├── .gitignore ├── post-commit └── pre-commit ├── .prettierrc ├── test ├── suite │ ├── unitTest.systemTest │ │ ├── .gitignore │ │ ├── environment.zip │ │ ├── suite │ │ │ ├── index.ts │ │ │ ├── VSCIntegration.test.ts │ │ │ ├── vSCodeConfigurator.test.ts │ │ │ ├── gitConfigurator.test.ts │ │ │ ├── diffLineMapper.test.ts │ │ │ ├── diffedURIs.test.ts │ │ │ ├── gitMergetoolTemporaryFileOpenHandler.test.ts │ │ │ └── gitOptionAssistant.test.ts │ │ └── index.ts │ ├── unstashConflict.systemTest │ │ └── .gitignore │ └── index.ts ├── README.md ├── runTests.ts ├── getExtensionAPI.ts ├── mochaTest.ts └── systemTest.ts ├── media ├── demo.mp4 ├── packaged │ └── icon.png ├── four pane merge.png └── icon.svg ├── SECURITY.md ├── .gitignore ├── .markdownlint.json ├── .eslintignore ├── .vscodeignore ├── src ├── registerableService.ts ├── showInternalError.ts ├── mergeConflictIndicatorDetector.ts ├── editorOpenHandler.ts ├── lazy.ts ├── ids.ts ├── singletonStore.ts ├── uIError.ts ├── documentProviderManager.ts ├── readonlyDocumentProvider.ts ├── backgroundGitTerminal.ts ├── fileNameStamp.ts ├── getPaths.ts ├── gitMergeFile.ts ├── monitor.ts ├── vSCodeConfigurator.ts ├── registeredDocumentContentProvider.ts ├── editorOpenManager.ts ├── layouters │ ├── threeDiffToBaseLayouter.ts │ ├── threeDiffToBaseRowsLayouter.ts │ ├── threeDiffToMergedLayouter.ts │ ├── threeDiffToBaseMergedRightLayouter.ts │ ├── diffLayouter.ts │ ├── fourTransferDownLayouter.ts │ └── fourTransferRightLayouter.ts ├── diffedURIs.ts ├── getPathsWithinVSCode.ts ├── commonMergeCommandsManager.ts ├── mergeAborter.ts ├── arbitraryFilesMerger.ts ├── gitMergetoolTemporaryFileOpenHandler.ts ├── zoom.ts ├── childProcessHandy.ts ├── manualMergeProcess.ts ├── temporarySettingsManager.ts ├── fsHandy.ts ├── extension.ts ├── @types │ └── git.d.ts ├── diffFileSelector.ts └── diffLayouterManager.ts ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── workflows │ ├── shiftleft-analysis.yml │ ├── ossar-analysis.yml │ └── codeql-analysis.yml ├── .vscode ├── extensions.json ├── settings.json ├── tasks.json └── launch.json ├── tools ├── extension.ts ├── pre-commit.ts ├── post-commit.ts └── util.ts ├── tsconfig.json ├── .eslintrc.js ├── webpack.config.js ├── vsc-extension-quickstart.md ├── CODE_OF_CONDUCT.md ├── README.md └── CHANGELOG.md /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 79 3 | } 4 | -------------------------------------------------------------------------------- /test/suite/unitTest.systemTest/.gitignore: -------------------------------------------------------------------------------- 1 | /environment/ 2 | -------------------------------------------------------------------------------- /test/suite/unstashConflict.systemTest/.gitignore: -------------------------------------------------------------------------------- 1 | /environment/ 2 | -------------------------------------------------------------------------------- /.husky/post-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn run post-commit -------------------------------------------------------------------------------- /media/demo.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zawys/vscode-as-git-mergetool/HEAD/media/demo.mp4 -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn run pre-commit 5 | -------------------------------------------------------------------------------- /media/packaged/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zawys/vscode-as-git-mergetool/HEAD/media/packaged/icon.png -------------------------------------------------------------------------------- /media/four pane merge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zawys/vscode-as-git-mergetool/HEAD/media/four pane merge.png -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting a Vulnerability 4 | 5 | Please send an email to: pj94ye@runbox.com. Expect a response within 3 days. 6 | -------------------------------------------------------------------------------- /test/suite/unitTest.systemTest/environment.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zawys/vscode-as-git-mergetool/HEAD/test/suite/unitTest.systemTest/environment.zip -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /dist 2 | /out 3 | /node_modules 4 | /.vscode-test/ 5 | /packages/ 6 | yarn-error.log 7 | .env 8 | .precommit_stash_exists 9 | /local/ 10 | /.debug 11 | -------------------------------------------------------------------------------- /.markdownlint.json: -------------------------------------------------------------------------------- 1 | { 2 | "no-duplicate-header": false, 3 | "no-inline-html": false, 4 | "line-length": { 5 | "strict": false, 6 | "line_length": 79 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /.vscode-test/ 2 | /media/ 3 | /node_modules/ 4 | /out/ 5 | /dist/ 6 | /packages/ 7 | /dist/ 8 | /.eslintrc.js 9 | /webpack.config.js 10 | yarn.lock 11 | /src/@types/ 12 | -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | **/* 2 | !README.md 3 | !SECURITY.md 4 | !CODE_OF_CONDUCT.md 5 | !LICENSE 6 | !CHANGELOG.md 7 | !package.json 8 | !.vscodeignore 9 | !out/dist/**/*.js 10 | !out/dist/**/*.LICENSE.txt 11 | !media/packaged 12 | -------------------------------------------------------------------------------- /src/registerableService.ts: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 zawys. Licensed under AGPL-3.0-or-later. 2 | // See LICENSE file in repository root directory. 3 | 4 | import { Disposable } from "vscode"; 5 | 6 | export interface RegisterableService extends Disposable { 7 | register(): void | Promise; 8 | } 9 | -------------------------------------------------------------------------------- /test/README.md: -------------------------------------------------------------------------------- 1 | # Tests 2 | 3 | - The tests must debugged in another instance then VS Code Main, 4 | e.g. VS Code Insiders (due to a limitation of VS Code). 5 | - The system tests may fail when there are unsaved files 6 | in the opened VS Code instance and thus two VS Code Main windows are open 7 | during the test. 8 | -------------------------------------------------------------------------------- /test/suite/unitTest.systemTest/suite/index.ts: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 zawys. Licensed under AGPL-3.0-or-later. 2 | // See LICENSE file in repository root directory. 3 | 4 | import { runMochaTests } from "../../../mochaTest"; 5 | 6 | export async function run(): Promise { 7 | await runMochaTests(__dirname); 8 | } 9 | -------------------------------------------------------------------------------- /test/suite/unitTest.systemTest/index.ts: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 zawys. Licensed under AGPL-3.0-or-later. 2 | // See LICENSE file in repository root directory. 3 | 4 | import { runSystemTest } from "../../systemTest"; 5 | 6 | export async function runTest(): Promise { 7 | return await runSystemTest(__dirname); 8 | } 9 | -------------------------------------------------------------------------------- /src/showInternalError.ts: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 zawys. Licensed under AGPL-3.0-or-later. 2 | // See LICENSE file in repository root directory. 3 | 4 | import { window } from "vscode"; 5 | 6 | export function showInternalError(id: string): void { 7 | void window.showErrorMessage(`Internal assumption violated. (${id})`); 8 | } 9 | -------------------------------------------------------------------------------- /src/mergeConflictIndicatorDetector.ts: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 zawys. Licensed under AGPL-3.0-or-later. 2 | // See LICENSE file in repository root directory. 3 | 4 | export const mergeConflictIndicatorRE = /^(>{7}|<{7}|\|{7}|={7})/m; 5 | 6 | export function containsMergeConflictIndicators(text: string): boolean { 7 | return mergeConflictIndicatorRE.test(text); 8 | } 9 | -------------------------------------------------------------------------------- /src/editorOpenHandler.ts: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 zawys. Licensed under AGPL-3.0-or-later. 2 | // See LICENSE file in repository root directory. 3 | 4 | import { Uri } from "vscode"; 5 | import { UIError } from "./uIError"; 6 | 7 | export interface EditorOpenHandler { 8 | ignorePathOverride(fsPath: string): boolean; 9 | handleDidOpenURI(uRI: Uri): Promise; 10 | } 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "" 5 | labels: enhancement 6 | assignees: zawys 7 | --- 8 | 9 | ## Which problem is your feature request related to, if any? 10 | 11 | 12 | 13 | ## Solution you'd like 14 | 15 | 16 | 17 | ## Description of alternatives you've considered 18 | 19 | 20 | 21 | ## Additional context 22 | 23 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See http://go.microsoft.com/fwlink/?LinkId=827846 3 | // for the documentation about the extensions.json format 4 | "recommendations": [ 5 | "dbaeumer.vscode-eslint", 6 | "esbenp.prettier-vscode", 7 | "rlnt.keep-a-changelog", 8 | "davidanson.vscode-markdownlint", 9 | "kisstkondoros.vscode-codemetrics", 10 | "streetsidesoftware.code-spell-checker", 11 | "msjsdiag.debugger-for-chrome", 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /test/suite/index.ts: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 zawys. Licensed under AGPL-3.0-or-later. 2 | // See LICENSE file in repository root directory. 3 | 4 | import { runTest as runUnitTests } from "./unitTest.systemTest"; 5 | // TODO [2021-05-01] Rewrite unstashConflictTests for the main branch. 6 | // import { runTest as runUnstashConflictTest } from "./unstashConflict.systemTest"; 7 | 8 | export async function runTests(): Promise { 9 | return await runUnitTests(); 10 | } 11 | -------------------------------------------------------------------------------- /src/lazy.ts: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 zawys. Licensed under AGPL-3.0-or-later. 2 | // See LICENSE file in repository root directory. 3 | 4 | export class Lazy { 5 | public get value(): T { 6 | if (!this.initialized) { 7 | this._value = this.factory(); 8 | this.initialized = true; 9 | } 10 | return this._value as T; 11 | } 12 | 13 | public constructor(private readonly factory: () => T) {} 14 | 15 | private initialized = false; 16 | private _value: T | undefined; 17 | } 18 | -------------------------------------------------------------------------------- /src/ids.ts: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 zawys. Licensed under AGPL-3.0-or-later. 2 | // See LICENSE file in repository root directory. 3 | 4 | export const extensionID = "vscode-as-git-mergetool"; 5 | export const fullExtensionID = `zawys.${extensionID}`; 6 | export const labelsInStatusBarSettingID = `${extensionID}.labelsInStatusBar`; 7 | 8 | export function firstLetterUppercase(value: string): string { 9 | if (value.length === 0) { 10 | return value; 11 | } 12 | return value[0].toLocaleUpperCase() + value.slice(1); 13 | } 14 | -------------------------------------------------------------------------------- /test/runTests.ts: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 zawys. Licensed under AGPL-3.0-or-later. 2 | // See LICENSE file in repository root directory. 3 | 4 | import { config as dotenvConfig } from "dotenv"; 5 | import { runTests } from "./suite"; 6 | 7 | dotenvConfig(); 8 | 9 | export async function main(): Promise { 10 | try { 11 | await runTests(); 12 | } catch (error) { 13 | console.error(error); 14 | // eslint-disable-next-line unicorn/no-process-exit 15 | process.exit(1); 16 | } 17 | } 18 | 19 | void main(); 20 | -------------------------------------------------------------------------------- /src/singletonStore.ts: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 zawys. Licensed under AGPL-3.0-or-later. 2 | // See LICENSE file in repository root directory. 3 | 4 | export class SingletonStore { 5 | get value(): T { 6 | if (this._context === undefined) { 7 | throw new Error("get"); 8 | } 9 | return this._context; 10 | } 11 | set value(value: T) { 12 | if (this._context !== undefined) { 13 | throw new Error("set"); 14 | } 15 | this._context = value; 16 | } 17 | 18 | private _context: T | undefined; 19 | } 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Report something is not as expected 4 | title: "" 5 | labels: bug 6 | assignees: zawys 7 | --- 8 | 9 | ## Steps to reproduce 10 | 11 | 1. 12 | 2. 13 | 3. 14 | 4. 15 | 16 | ## Expected behavior 17 | 18 | 19 | 20 | ## [Optional] screenshots 21 | 22 | 23 | 24 | ## Environment 25 | 26 | - Version of the extension: 27 | - Operating system: 28 | - VS Code version [use Code command “Help: About”]: 29 | - Other installed extensions (if applicable): 30 | 31 | ## Additional context 32 | 33 | -------------------------------------------------------------------------------- /tools/extension.ts: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 zawys. Licensed under AGPL-3.0-or-later. 2 | // See LICENSE file in repository root directory. 3 | 4 | import { 5 | deactivate as extensionDeactivate, 6 | activate as extensionActivate, 7 | ExtensionAPI, 8 | } from "../src/extension"; 9 | import { ExtensionContext } from "vscode"; 10 | 11 | export function activate(context: ExtensionContext): Promise { 12 | return extensionActivate(context); 13 | } 14 | 15 | // this method is called when your extension is deactivated 16 | export function deactivate(): void { 17 | extensionDeactivate(); 18 | } 19 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "rootDirs": ["src", "test", "tools"], 4 | "outDir": "out", 5 | "module": "commonjs", 6 | "target": "es6", 7 | "lib": ["es6"], 8 | "esModuleInterop": true, 9 | "sourceMap": true, 10 | "strict": true /* enable all strict type-checking options */, 11 | /* Additional Checks */ 12 | "noImplicitReturns": true /* Report error when not all code paths in function return a value. */, 13 | "noFallthroughCasesInSwitch": true /* Report errors for fallthrough cases in switch statement. */, 14 | "noUnusedParameters": true, /* Report errors on unused parameters. */ 15 | }, 16 | "exclude": ["node_modules", ".vscode-test"] 17 | } 18 | -------------------------------------------------------------------------------- /src/uIError.ts: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 zawys. Licensed under AGPL-3.0-or-later. 2 | // See LICENSE file in repository root directory. 3 | 4 | export const uIErrorTypeName = "UIError"; 5 | export interface UIError { 6 | readonly typeName: typeof uIErrorTypeName; 7 | readonly message: string; 8 | } 9 | export function isUIError(x: unknown): x is UIError { 10 | return ( 11 | typeof x === "object" && 12 | x !== null && 13 | (x as { typeName?: unknown }).typeName === uIErrorTypeName 14 | ); 15 | } 16 | export function createUIError(message: string): UIError { 17 | return { typeName: uIErrorTypeName, message }; 18 | } 19 | 20 | export function combineUIErrors(errors: UIError[]): UIError { 21 | return createUIError( 22 | [ 23 | "Multiple errors occurred:", 24 | ...errors.map((error) => error.message), 25 | ].join("\n") 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /src/documentProviderManager.ts: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 zawys. Licensed under AGPL-3.0-or-later. 2 | // See LICENSE file in repository root directory. 3 | 4 | import { Disposable, TextDocumentContentProvider, workspace } from "vscode"; 5 | import { RegisterableService } from "./registerableService"; 6 | 7 | export class DocumentProviderManager< 8 | T extends TextDocumentContentProvider = TextDocumentContentProvider 9 | > implements RegisterableService { 10 | public register(): void { 11 | this.dispose(); 12 | this.registration = workspace.registerTextDocumentContentProvider( 13 | this.scheme, 14 | this.documentProvider 15 | ); 16 | } 17 | public dispose(): void { 18 | this.registration?.dispose(); 19 | this.registration = undefined; 20 | } 21 | 22 | public constructor( 23 | public readonly scheme: string, 24 | public readonly documentProvider: T 25 | ) {} 26 | 27 | private registration: Disposable | undefined; 28 | } 29 | -------------------------------------------------------------------------------- /test/suite/unitTest.systemTest/suite/VSCIntegration.test.ts: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 zawys. Licensed under AGPL-3.0-or-later. 2 | // See LICENSE file in repository root directory. 3 | 4 | import assert from "assert"; 5 | import { GitMergetoolReplacement } from "../../../../src/gitMergetoolReplacement"; 6 | 7 | suite("lsFilesURE", () => { 8 | const sut: RegExp = GitMergetoolReplacement.lsFilesURE; 9 | 10 | test("parses output correctly", () => { 11 | const input = 12 | "100644 3d5a36862e75d6cfc7007d6e90150b33a574a4fc 1 some synthetic/path"; 13 | const actual = sut.exec(input); 14 | assert(actual !== null); 15 | assert.strictEqual((actual.groups || {})["mode"], "100644"); 16 | assert.strictEqual( 17 | (actual.groups || {})["object"], 18 | "3d5a36862e75d6cfc7007d6e90150b33a574a4fc" 19 | ); 20 | assert.strictEqual((actual.groups || {})["stage"], "1"); 21 | assert.strictEqual((actual.groups || {})["path"], "some synthetic/path"); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/readonlyDocumentProvider.ts: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 zawys. Licensed under AGPL-3.0-or-later. 2 | // See LICENSE file in repository root directory. 3 | 4 | import { TextDocumentContentProvider, Uri } from "vscode"; 5 | import { DocumentProviderManager } from "./documentProviderManager"; 6 | import { getContents } from "./fsHandy"; 7 | 8 | export class ReadonlyDocumentProvider implements TextDocumentContentProvider { 9 | public async provideTextDocumentContent(uri: Uri): Promise { 10 | return (await getContents(uri.fsPath)) || ""; 11 | } 12 | public readonlyFileURI(filePath: string): Uri { 13 | return Uri.file(filePath).with({ scheme: this.scheme }); 14 | } 15 | constructor(public readonly scheme: string) {} 16 | } 17 | 18 | export function createReadonlyDocumentProviderManager(): DocumentProviderManager { 19 | const readonlyScheme = "readonly-file"; 20 | return new DocumentProviderManager( 21 | readonlyScheme, 22 | new ReadonlyDocumentProvider(readonlyScheme) 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /tools/pre-commit.ts: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 zawys. Licensed under AGPL-3.0-or-later. 2 | // See LICENSE file in repository root directory. 3 | 4 | import { appendFileSync } from "fs"; 5 | import { asyncWhich, runAsync, runCommand } from "./util"; 6 | 7 | void runAsync(async () => { 8 | const git = await asyncWhich("git"); 9 | const yarn = await asyncWhich("yarn"); 10 | 11 | const result = await runCommand(yarn, ["run", "working_dir_is_clean"]); 12 | if (result === null) { 13 | return 1; 14 | } 15 | const stash = result !== 0; 16 | 17 | if (stash) { 18 | if ((await runCommand(git, ["stash", "-ku"])) !== 0) { 19 | return 1; 20 | } 21 | 22 | appendFileSync(".precommit_stash_exists", ""); 23 | } 24 | 25 | if ((await runCommand(yarn, ["run", "test"])) !== 0) { 26 | return 1; 27 | } 28 | if ((await runCommand(yarn, ["run", "package"])) !== 0) { 29 | return 1; 30 | } 31 | if ((await runCommand(yarn, ["run", "working_dir_is_clean"])) !== 0) { 32 | return 1; 33 | } 34 | 35 | return 0; 36 | }); 37 | -------------------------------------------------------------------------------- /test/getExtensionAPI.ts: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 zawys. Licensed under AGPL-3.0-or-later. 2 | // See LICENSE file in repository root directory. 3 | 4 | import { extensions } from "vscode"; 5 | import { ExtensionAPI } from "../src/extension"; 6 | import { fullExtensionID } from "../src/ids"; 7 | 8 | export async function getExtensionAPI(): Promise { 9 | const extension = extensions.getExtension(fullExtensionID); 10 | if (extension === undefined) { 11 | throw new Error("extension not found"); 12 | } 13 | const extensionAPI = (await extension.activate()) as unknown; 14 | if (!extension.isActive) { 15 | throw new Error("extension is not active"); 16 | } 17 | if (extensionAPI === undefined) { 18 | throw new Error("extension API not found"); 19 | } else if ( 20 | !(extensionAPI as ExtensionAPI).register && 21 | !(extensionAPI as ExtensionAPI).dispose && 22 | !(extensionAPI as ExtensionAPI).services?.diffLayouterManager 23 | ) { 24 | throw new TypeError("extensionAPI has unexpected type"); 25 | } 26 | return extensionAPI as ExtensionAPI; 27 | } 28 | -------------------------------------------------------------------------------- /src/backgroundGitTerminal.ts: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 zawys. Licensed under AGPL-3.0-or-later. 2 | // See LICENSE file in repository root directory. 3 | 4 | import { Terminal, TerminalOptions, window } from "vscode"; 5 | import { 6 | getVSCGitPathInteractively, 7 | getWorkspaceDirectoryUriInteractively, 8 | } from "./getPathsWithinVSCode"; 9 | 10 | export async function createBackgroundGitTerminal( 11 | terminalOptions: TerminalOptions 12 | ): Promise { 13 | const gitPath = await getVSCGitPathInteractively(); 14 | if (gitPath === undefined) { 15 | return; 16 | } 17 | const workingDirectory = getWorkspaceDirectoryUriInteractively(); 18 | if (workingDirectory === undefined) { 19 | return; 20 | } 21 | const term = window.createTerminal({ 22 | name: ["git", ...(terminalOptions.shellArgs || [])].join(" "), 23 | cwd: workingDirectory, 24 | shellPath: gitPath, 25 | ...terminalOptions, 26 | }); 27 | if (term === undefined) { 28 | void window.showErrorMessage("Failed to create a terminal."); 29 | return; 30 | } 31 | return term; 32 | } 33 | -------------------------------------------------------------------------------- /src/fileNameStamp.ts: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 zawys. Licensed under AGPL-3.0-or-later. 2 | // See LICENSE file in repository root directory. 3 | 4 | import { createUIError, UIError } from "./uIError"; 5 | 6 | export function generateFileNameStamp(increment = 0): string { 7 | return ( 8 | new Date().toISOString().replace(/[.:]/g, "-") + 9 | (increment === 0 ? "" : `-${increment}`) 10 | ); 11 | } 12 | 13 | export async function generateFileNameStampUntil< 14 | T extends Exclude 15 | >( 16 | condition: (stamp: string) => Promise 17 | ): Promise { 18 | let increment = 0; 19 | // eslint-disable-next-line no-constant-condition 20 | while (true) { 21 | const stamp = generateFileNameStamp(increment); 22 | const conditionResult = await condition(stamp); 23 | if (conditionResult !== false) { 24 | return conditionResult; 25 | } 26 | const newIncrement = increment + 1 + Math.floor(Math.random() * increment); 27 | if (newIncrement < increment) { 28 | return createUIError("Failed generating an available file stamp"); 29 | } 30 | increment = newIncrement; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/getPaths.ts: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 zawys. Licensed under AGPL-3.0-or-later. 2 | // See LICENSE file in repository root directory. 3 | 4 | import which from "which"; 5 | import { createUIError, UIError } from "./uIError"; 6 | 7 | // This file is imported by the test runner and thus must not import `vscode`. 8 | 9 | let gitPathPromise: Promise | undefined; 10 | 11 | export function getGitPath(): Promise { 12 | if (gitPathPromise === undefined) { 13 | gitPathPromise = getGitPathInner(); 14 | } 15 | return gitPathPromise; 16 | } 17 | 18 | export async function getGitPathInner(): Promise { 19 | const whichResult = await whichPromise("git"); 20 | if (whichResult === undefined) { 21 | return createUIError("Could not find Git binary."); 22 | } 23 | return whichResult; 24 | } 25 | 26 | export function whichPromise( 27 | executableName: string 28 | ): Promise { 29 | return new Promise((resolve, reject) => { 30 | which(executableName, (error, path) => { 31 | if (error) { 32 | reject(error); 33 | } else { 34 | resolve(path); 35 | } 36 | }); 37 | }); 38 | } 39 | -------------------------------------------------------------------------------- /tools/post-commit.ts: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 zawys. Licensed under AGPL-3.0-or-later. 2 | // See LICENSE file in repository root directory. 3 | 4 | import { existsSync, unlinkSync } from "fs"; 5 | import { asyncWhich, runAsync, runCommand } from "./util"; 6 | 7 | void runAsync(async () => { 8 | const git = await asyncWhich("git"); 9 | 10 | if (existsSync(".precommit_stash_exists")) { 11 | unlinkSync(".precommit_stash_exists"); 12 | 13 | // Based on: https://stackoverflow.com/a/19328859 14 | if ( 15 | (await runCommand(git, [ 16 | "cherry-pick", 17 | "-n", 18 | "-m1", 19 | "-Xtheirs", 20 | "stash", 21 | ])) !== 0 22 | ) { 23 | return 1; 24 | } 25 | if ( 26 | (await runCommand(git, [ 27 | "cherry-pick", 28 | "-n", 29 | "-m1", 30 | "-Xtheirs", 31 | "stash^3", 32 | ])) !== 0 33 | ) { 34 | return 1; 35 | } 36 | 37 | unlinkSync(".git/MERGE_MSG"); 38 | if ((await runCommand(git, ["restore", "--staged", "."])) !== 0) { 39 | return 1; 40 | } 41 | if ((await runCommand(git, ["stash", "drop", "stash@{0}"])) !== 0) { 42 | return 1; 43 | } 44 | } 45 | 46 | return 0; 47 | }); 48 | -------------------------------------------------------------------------------- /src/gitMergeFile.ts: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 zawys. Licensed under AGPL-3.0-or-later. 2 | // See LICENSE file in repository root directory. 3 | 4 | import { createUIError, UIError } from "./uIError"; 5 | import nodePath from "path"; 6 | import { execFilePromise } from "./childProcessHandy"; 7 | import { setContents } from "./fsHandy"; 8 | 9 | export async function gitMergeFile( 10 | gitPath: string, 11 | { base, local, remote, merged }: MergeFilePaths 12 | ): Promise { 13 | const gitResult = await execFilePromise({ 14 | filePath: gitPath, 15 | arguments_: ["merge-file", "--stdout", local, base, remote], 16 | options: { 17 | cwd: nodePath.dirname(merged), 18 | timeout: 10000, 19 | windowsHide: true, 20 | }, 21 | }); 22 | const error = gitResult.error; 23 | if ( 24 | error !== null && 25 | (error.code === undefined || error.code < 0 || error.code > 127) 26 | ) { 27 | return createUIError( 28 | `Error when merging files by Git: ${gitResult.stderr}.` 29 | ); 30 | } 31 | return setContents(merged, gitResult.stdout); 32 | } 33 | 34 | export interface MergeFilePaths { 35 | readonly base: string; 36 | readonly local: string; 37 | readonly remote: string; 38 | readonly merged: string; 39 | } 40 | -------------------------------------------------------------------------------- /src/monitor.ts: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 zawys. Licensed under AGPL-3.0-or-later. 2 | // See LICENSE file in repository root directory. 3 | 4 | /** 5 | * FIFO-1-Semaphore 6 | */ 7 | export class Monitor { 8 | public async enter(): Promise { 9 | const previousLastIndex = this.pendingOperations.length - 1; 10 | const thisOperation: PendingOperation = {}; 11 | thisOperation.promise = new Promise((resolve) => { 12 | thisOperation.resolver = resolve; 13 | }); 14 | this.pendingOperations.push(thisOperation); 15 | if (previousLastIndex >= 0) { 16 | await this.pendingOperations[previousLastIndex].promise; 17 | } 18 | } 19 | 20 | public async leave(): Promise { 21 | const thisOperation = this.pendingOperations[0]; 22 | while (thisOperation.resolver === undefined) { 23 | await new Promise((resolve) => setTimeout(resolve, 0)); 24 | } 25 | this.pendingOperations.splice(0, 1); 26 | thisOperation.resolver(); 27 | } 28 | 29 | public get inUse(): boolean { 30 | return this.pendingOperations.length > 0; 31 | } 32 | 33 | public get someoneIsWaiting(): boolean { 34 | return this.pendingOperations.length > 1; 35 | } 36 | 37 | private readonly pendingOperations: PendingOperation[] = []; 38 | } 39 | 40 | interface PendingOperation { 41 | promise?: Promise; 42 | resolver?: () => void; 43 | } 44 | -------------------------------------------------------------------------------- /test/mochaTest.ts: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 zawys. Licensed under AGPL-3.0-or-later. 2 | // See LICENSE file in repository root directory. 3 | 4 | import path from "path"; 5 | import Mocha from "mocha"; 6 | import glob from "glob"; 7 | 8 | export function runMochaTests( 9 | testDirectory: string, 10 | timeout?: number 11 | ): Promise { 12 | // Create the mocha test 13 | const debug = /--debug|--inspect/.test( 14 | [...process.argv, ...process.execArgv].join(" ") 15 | ); 16 | const mocha = new Mocha({ 17 | ui: "tdd", 18 | color: true, 19 | timeout: debug ? 0 : timeout, 20 | }); 21 | 22 | const testsRoot = path.resolve(testDirectory, ".."); 23 | 24 | return new Promise((resolve, reject) => { 25 | glob("**/**.test.js", { cwd: testsRoot }, (error, files) => { 26 | if (error) { 27 | return reject(error); 28 | } 29 | 30 | // Add files to the test suite 31 | for (const f of files) { 32 | mocha.addFile(path.resolve(testsRoot, f)); 33 | } 34 | 35 | try { 36 | // Run the mocha test 37 | mocha.run((failures) => { 38 | if (failures > 0) { 39 | reject(new Error(`${failures} tests failed.`)); 40 | } else { 41 | resolve(); 42 | } 43 | }); 44 | } catch (error_) { 45 | console.error(error_); 46 | reject(error_); 47 | } 48 | }); 49 | }); 50 | } 51 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: "@typescript-eslint/parser", 4 | parserOptions: { 5 | ecmaVersion: 6, 6 | sourceType: "module", 7 | tsconfigRootDir: __dirname, 8 | project: ["./tsconfig.json"], 9 | }, 10 | plugins: [ 11 | "@typescript-eslint", 12 | "eslint-comments", 13 | "promise", 14 | "unicorn", 15 | "prettier", 16 | "header", 17 | ], 18 | extends: [ 19 | "eslint:recommended", 20 | "plugin:@typescript-eslint/recommended", 21 | "plugin:@typescript-eslint/recommended-requiring-type-checking", 22 | "plugin:eslint-comments/recommended", 23 | "plugin:promise/recommended", 24 | "plugin:unicorn/recommended", 25 | "plugin:prettier/recommended", 26 | "prettier", 27 | ], 28 | rules: { 29 | "@typescript-eslint/naming-convention": "error", 30 | "@typescript-eslint/semi": ["error", "always"], 31 | "unicorn/filename-case": "off", 32 | "unicorn/no-useless-undefined": "off", 33 | "unicorn/no-nested-ternary": "off", 34 | "unicorn/no-null": "off", 35 | "prettier/prettier": "warn", 36 | "header/header": [ 37 | 2, 38 | "line", 39 | [ 40 | { 41 | pattern: 42 | " Copyright \\(C\\) 2\\d{3} zawys\\. Licensed under AGPL-3\\.0-or-later\\.", 43 | template: 44 | " Copyright (C) 2020 zawys. Licensed under AGPL-3.0-or-later.", 45 | }, 46 | " See LICENSE file in repository root directory.", 47 | ], 48 | 2, 49 | ], 50 | "unicorn/expiring-todo-comments": [ 51 | "error", 52 | { terms: ["todo", "fixme", "xxx", "debug"] }, 53 | ], 54 | }, 55 | }; 56 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 zawys. Licensed under AGPL-3.0-or-later. 2 | // See LICENSE file in repository root directory. 3 | 4 | //@ts-check 5 | 6 | "use strict"; 7 | 8 | const path = require("path"); 9 | 10 | /**@type {import('webpack').Configuration}*/ 11 | const config = { 12 | target: "node", // vscode extensions run in a Node.js-context 📖 -> https://webpack.js.org/configuration/node/ 13 | 14 | entry: "./src/extension.ts", // the entry point of this extension, 📖 -> https://webpack.js.org/configuration/entry-context/ 15 | output: { 16 | // the bundle is stored in the 'dist' folder (check package.json), 📖 -> https://webpack.js.org/configuration/output/ 17 | path: path.resolve(__dirname, "out/dist"), 18 | filename: "extension.js", 19 | libraryTarget: "commonjs2", 20 | devtoolModuleFilenameTemplate: "../[resource-path]", 21 | }, 22 | devtool: "source-map", 23 | externals: { 24 | vscode: "commonjs vscode", // the vscode-module is created on-the-fly and must be excluded. Add other modules that cannot be webpack'ed, 📖 -> https://webpack.js.org/configuration/externals/ 25 | }, 26 | resolve: { 27 | // support reading TypeScript and JavaScript files, 📖 -> https://github.com/TypeStrong/ts-loader 28 | extensions: [".ts", ".js"], 29 | }, 30 | module: { 31 | parser: { 32 | javascript: { 33 | commonjsMagicComments: true, 34 | } 35 | }, 36 | rules: [ 37 | { 38 | test: /\.ts$/, 39 | exclude: /node_modules/, 40 | use: [ 41 | { 42 | loader: "ts-loader", 43 | }, 44 | ], 45 | }, 46 | ], 47 | }, 48 | }; 49 | module.exports = config; 50 | -------------------------------------------------------------------------------- /tools/util.ts: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 zawys. Licensed under AGPL-3.0-or-later. 2 | // See LICENSE file in repository root directory. 3 | 4 | import { 5 | spawn, 6 | SpawnOptions, 7 | spawnSync, 8 | SpawnSyncReturns, 9 | } from "child_process"; 10 | import { exit } from "process"; 11 | import which from "which"; 12 | 13 | export async function runCommand( 14 | command: string, 15 | arguments_: string[] 16 | ): Promise { 17 | return await new Promise((resolve) => { 18 | console.log(`+${command} ${arguments_.join(" ")}`); 19 | const process = spawn(command, arguments_, { 20 | stdio: "inherit", 21 | }); 22 | process.on("exit", (code) => { 23 | resolve(code); 24 | }); 25 | }); 26 | } 27 | 28 | export function spawnAndCapture( 29 | file: string, 30 | arguments_: string[], 31 | options?: SpawnOptions 32 | ): SpawnSyncReturns { 33 | console.log(`+${file} ${arguments_.join(" ")}`); 34 | const child = spawnSync(file, arguments_, { 35 | ...options, 36 | stdio: "pipe", 37 | encoding: "utf-8", 38 | }); 39 | console.log(child.stdout); 40 | console.error(child.error); 41 | return child; 42 | } 43 | 44 | export function asyncWhich(command: string): Promise { 45 | return new Promise((resolve, reject) => { 46 | which(command, (error, path) => 47 | error 48 | ? reject(error) 49 | : path === undefined 50 | ? reject(new Error(`${command} was not found in PATH`)) 51 | : resolve(path) 52 | ); 53 | }); 54 | } 55 | 56 | export async function runAsync(run: () => Promise): Promise { 57 | try { 58 | exit(await run()); 59 | } catch (error) { 60 | console.error(error); 61 | exit(1); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /.github/workflows/shiftleft-analysis.yml: -------------------------------------------------------------------------------- 1 | # This workflow integrates Scan with GitHub's code scanning feature 2 | # Scan is a free open-source security tool for modern DevOps teams from ShiftLeft 3 | # Visit https://slscan.io/en/latest/integrations/code-scan for help 4 | name: SL Scan 5 | 6 | # This section configures the trigger for the workflow. Feel free to customize depending on your convention 7 | on: push 8 | 9 | jobs: 10 | Scan-Build: 11 | # Scan runs on ubuntu, mac and windows 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v1 15 | with: 16 | fetch-depth: 2 17 | 18 | # If this run was triggered by a pull request event, then checkout 19 | # the head of the pull request instead of the merge commit. 20 | - run: git checkout HEAD^2 21 | if: ${{ github.event_name == 'pull_request' }} 22 | 23 | # Instructions 24 | # 1. Setup JDK, Node.js, Python etc depending on your project type 25 | # 2. Compile or build the project before invoking scan 26 | # Example: mvn compile, or npm install or pip install goes here 27 | # 3. Invoke Scan with the github token. Leave the workspace empty to use relative url 28 | 29 | - run: | 30 | yarn run build 31 | 32 | - name: Perform Scan 33 | uses: ShiftLeftSecurity/scan-action@master 34 | env: 35 | WORKSPACE: "" 36 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 37 | SCAN_AUTO_BUILD: true 38 | with: 39 | output: reports 40 | # Scan auto-detects the languages in your project. To override uncomment the below variable and set the type 41 | # type: credscan,java 42 | # type: python 43 | 44 | - name: Upload report 45 | uses: github/codeql-action/upload-sarif@v1 46 | with: 47 | sarif_file: reports 48 | -------------------------------------------------------------------------------- /src/vSCodeConfigurator.ts: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 zawys. Licensed under AGPL-3.0-or-later. 2 | // See LICENSE file in repository root directory. 3 | 4 | import { ConfigurationTarget, workspace } from "vscode"; 5 | 6 | export class VSCodeConfigurator { 7 | public get(section: string): unknown | undefined { 8 | const [parent, key] = separateSmallestKey(section); 9 | const result = workspace.getConfiguration(parent).get(key); 10 | return result; 11 | } 12 | 13 | public async set( 14 | section: string, 15 | value: unknown, 16 | global = true 17 | ): Promise { 18 | const [parent, key] = separateSmallestKey(section); 19 | global ||= 20 | workspace.workspaceFolders === undefined || 21 | workspace.workspaceFolders.length === 0; 22 | await workspace 23 | .getConfiguration(parent) 24 | .update( 25 | key, 26 | value, 27 | global ? ConfigurationTarget.Global : ConfigurationTarget.Workspace 28 | ); 29 | } 30 | 31 | public inspect(section: string): InspectResult | undefined { 32 | const [parent, key] = separateSmallestKey(section); 33 | return workspace.getConfiguration(parent).inspect(key); 34 | } 35 | } 36 | 37 | export function separateSmallestKey( 38 | section: string 39 | ): [string | undefined, string] { 40 | const match = smallestKeyRE.exec(section); 41 | return match === null ? [undefined, section] : [match[1], match[2]]; 42 | } 43 | 44 | export interface InspectResult { 45 | key: string; 46 | 47 | defaultValue?: T; 48 | globalValue?: T; 49 | workspaceValue?: T; 50 | workspaceFolderValue?: T; 51 | 52 | defaultLanguageValue?: T; 53 | globalLanguageValue?: T; 54 | workspaceLanguageValue?: T; 55 | workspaceFolderLanguageValue?: T; 56 | 57 | languageIds?: string[]; 58 | } 59 | 60 | const smallestKeyRE = /^(.+)\.([^.]+)$/; 61 | -------------------------------------------------------------------------------- /src/registeredDocumentContentProvider.ts: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 zawys. Licensed under AGPL-3.0-or-later. 2 | // See LICENSE file in repository root directory. 3 | 4 | import { ProviderResult, TextDocumentContentProvider, Uri } from "vscode"; 5 | import { DocumentProviderManager } from "./documentProviderManager"; 6 | import { showInternalError } from "./showInternalError"; 7 | 8 | export class RegisteredDocumentContentProvider 9 | implements TextDocumentContentProvider { 10 | public provideTextDocumentContent(uRI: Uri): ProviderResult { 11 | if (uRI.scheme !== this.scheme) { 12 | return undefined; 13 | } 14 | return this.contents[uRI.path]; 15 | } 16 | 17 | public registerDocumentContent(content: string): Uri { 18 | const key = (this.nextID++).toString(); 19 | this.contents[key] = content; 20 | return Uri.parse("").with({ scheme: this.scheme, path: key }); 21 | } 22 | public unregisterDocumentContent(uRI: Uri): void { 23 | if (uRI.scheme !== this.scheme) { 24 | showInternalError("tried to unregister URI with unexpected scheme"); 25 | return; 26 | } 27 | delete this.contents[uRI.path]; 28 | } 29 | public getEmptyDocumentURI(): Uri { 30 | if (this.emptyDocumentURI === undefined) { 31 | this.emptyDocumentURI = this.registerDocumentContent(""); 32 | } 33 | return this.emptyDocumentURI; 34 | } 35 | 36 | public constructor(public readonly scheme: string) {} 37 | 38 | private readonly contents: { [k in string]?: string } = {}; 39 | private nextID = 0; 40 | private emptyDocumentURI: Uri | undefined; 41 | } 42 | 43 | export function createRegisteredDocumentProviderManager(): DocumentProviderManager { 44 | const registeredScheme = "registered"; 45 | return new DocumentProviderManager( 46 | registeredScheme, 47 | new RegisteredDocumentContentProvider(registeredScheme) 48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /test/suite/unitTest.systemTest/suite/vSCodeConfigurator.test.ts: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 zawys. Licensed under AGPL-3.0-or-later. 2 | // See LICENSE file in repository root directory. 3 | 4 | import assert from "assert"; 5 | 6 | import { 7 | separateSmallestKey, 8 | VSCodeConfigurator, 9 | } from "../../../../src/vSCodeConfigurator"; 10 | import { hrtime } from "process"; 11 | import { extensionID } from "../../../../src/ids"; 12 | 13 | suite("separateSmallestKey", () => { 14 | const sut = separateSmallestKey; 15 | 16 | test("works correctly", () => { 17 | const testCases = [ 18 | { input: "ab", expected: [undefined, "ab"] }, 19 | { input: "ab.cd", expected: ["ab", "cd"] }, 20 | { input: "ab.cd.ef", expected: ["ab.cd", "ef"] }, 21 | ]; 22 | for (const { input, expected } of testCases) { 23 | assert.deepStrictEqual(sut(input), expected); 24 | } 25 | }); 26 | }); 27 | 28 | suite("VSCodeConfigurator", () => { 29 | const sut = new VSCodeConfigurator(); 30 | 31 | test("persists settings", async () => { 32 | const key = `${extensionID}.layout`; 33 | const original = sut.get(key); 34 | { 35 | await sut.set(key, "3DiffToBase"); 36 | const start = hrtime.bigint(); 37 | const actual = sut.get(key); 38 | const end = hrtime.bigint(); 39 | console.warn(`1. took: ${end - start}`); 40 | assert(end - start < 1 * 1000 * 1000, `1. took: ${end - start}ns`); 41 | assert.strictEqual(actual, "3DiffToBase"); 42 | } 43 | { 44 | await sut.set(key, "4TransferDown"); 45 | const start = hrtime.bigint(); 46 | const actual = sut.get(key); 47 | const end = hrtime.bigint(); 48 | console.warn(`2. took: ${end - start}`); 49 | assert(end - start < 1 * 1000 * 1000, `2. took: ${end - start}ns`); 50 | assert.strictEqual(actual, "4TransferDown"); 51 | } 52 | await sut.set(key, original); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /.github/workflows/ossar-analysis.yml: -------------------------------------------------------------------------------- 1 | # This workflow integrates a collection of open source static analysis tools 2 | # with GitHub code scanning. For documentation, or to provide feedback, visit 3 | # https://github.com/github/ossar-action 4 | name: OSSAR 5 | 6 | on: 7 | push: 8 | pull_request: 9 | 10 | jobs: 11 | OSSAR-Scan: 12 | # OSSAR runs on windows-latest. 13 | # ubuntu-latest and macos-latest support coming soon 14 | runs-on: windows-latest 15 | 16 | steps: 17 | # Checkout your code repository to scan 18 | - name: Checkout repository 19 | uses: actions/checkout@v2 20 | with: 21 | # We must fetch at least the immediate parents so that if this is 22 | # a pull request then we can checkout the head. 23 | fetch-depth: 2 24 | 25 | # If this run was triggered by a pull request event, then checkout 26 | # the head of the pull request instead of the merge commit. 27 | - run: git checkout HEAD^2 28 | if: ${{ github.event_name == 'pull_request' }} 29 | 30 | # Ensure a compatible version of dotnet is installed. 31 | # The [Microsoft Security Code Analysis CLI](https://aka.ms/mscadocs) is built with dotnet v3.1.201. 32 | # A version greater than or equal to v3.1.201 of dotnet must be installed on the agent in order to run this action. 33 | # Remote agents already have a compatible version of dotnet installed and this step may be skipped. 34 | # For local agents, ensure dotnet version 3.1.201 or later is installed by including this action: 35 | # - name: Install .NET 36 | # uses: actions/setup-dotnet@v1 37 | # with: 38 | # dotnet-version: '3.1.x' 39 | 40 | # Run open source static analysis tools 41 | - name: Run OSSAR 42 | uses: github/ossar-action@v1 43 | id: ossar 44 | 45 | # Upload results to the Security tab 46 | - name: Upload OSSAR results 47 | uses: github/codeql-action/upload-sarif@v1 48 | with: 49 | sarif_file: ${{ steps.ossar.outputs.sarifFile }} 50 | -------------------------------------------------------------------------------- /.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 | "yarn.lock": true, 9 | }, 10 | // Turn off tsc task auto detection since we have the necessary tasks as npm scripts 11 | "typescript.tsc.autoDetect": "off", 12 | "cSpell.words": [ 13 | "AGPL", 14 | "EDITMSG", 15 | "Git", 16 | "Git’s", 17 | "Liberapay", 18 | "OLRE", 19 | "Pseudoterminal", 20 | "SBIs", 21 | "Symm", 22 | "asar", 23 | "configurator", 24 | "conflictstyle", 25 | "disposabe", 26 | "enablement", 27 | "focussable", 28 | "layouter", 29 | "layouters", 30 | "mergetool", 31 | "mktemp", 32 | "overridable", 33 | "pinst", 34 | "postpublish", 35 | "postversion", 36 | "precommit", 37 | "preversion", 38 | "protocolled", 39 | "quickstart", 40 | "s", 41 | "screencast", 42 | "serializable", 43 | "submodule", 44 | "submodules", 45 | "toplevel", 46 | "unpackaged", 47 | "unpublish", 48 | "unstash", 49 | "upvoting", 50 | "vsce", 51 | "vsix", 52 | "zawys" 53 | ], 54 | "editor.defaultFormatter": "esbenp.prettier-vscode", 55 | "editor.formatOnSave": true, 56 | "cSpell.ignorePaths": [ 57 | "**/package-lock.json", 58 | "**/node_modules/**", 59 | "**/.git/objects/**", 60 | "src/@types/**", 61 | "**/yarn.lock", 62 | "dist", 63 | "out", 64 | "packages", 65 | ], 66 | "files.associations": { 67 | "**/.github/ISSUE_TEMPLATE/**/*.md": "plaintext" 68 | }, 69 | "[plaintext]": { 70 | "files.trimFinalNewlines": false, 71 | "files.trimTrailingWhitespace": false, 72 | "editor.formatOnSave": false, 73 | "editor.trimAutoWhitespace": false 74 | }, 75 | "codemetrics.basics.CodeLensHiddenUnder": 15, 76 | "codemetrics.basics.ComplexityLevelNormal": 15, 77 | "codemetrics.basics.ComplexityLevelHigh": 20, 78 | } 79 | -------------------------------------------------------------------------------- /test/suite/unitTest.systemTest/suite/gitConfigurator.test.ts: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 zawys. Licensed under AGPL-3.0-or-later. 2 | // See LICENSE file in repository root directory. 3 | 4 | import assert from "assert"; 5 | import { getGitPath } from "../../../../src/getPaths"; 6 | import { getWorkspaceDirectoryUri } from "../../../../src/getPathsWithinVSCode"; 7 | import { GitConfigurator } from "../../../../src/settingsAssistant"; 8 | import { isUIError } from "../../../../src/uIError"; 9 | 10 | suite("GitConfigurator", function () { 11 | let sut: GitConfigurator | undefined = undefined; 12 | 13 | this.beforeAll(async () => { 14 | const gitPath = await getGitPath(); 15 | if (isUIError(gitPath)) throw new Error(gitPath.message); 16 | const workingDirectory = getWorkspaceDirectoryUri(); 17 | if (workingDirectory === undefined) { 18 | throw new Error("workingDirectory undefined"); 19 | } 20 | sut = new GitConfigurator(gitPath, workingDirectory); 21 | }); 22 | 23 | test("reads and stores local configuration", async () => { 24 | if (sut === undefined) throw new Error("sut undefined"); 25 | const configName = "user.name"; 26 | const targetConfigValue = "Random Stuff"; 27 | const currentValue = await sut.get(configName); 28 | assert.strictEqual(currentValue, "Betty Smith", "initial value"); 29 | await sut.set(configName, targetConfigValue, false); 30 | const newValue = await sut.get(configName); 31 | assert.strictEqual(newValue, targetConfigValue, "setting is updated"); 32 | }); 33 | 34 | test("reads global configuration", async () => { 35 | if (sut === undefined) throw new Error("sut undefined"); 36 | const configName = "user.name"; 37 | const localTargetValue = "Betty Smith"; 38 | await sut.set(configName, localTargetValue, false); 39 | const actualLocalValue = await sut.get(configName); 40 | assert.strictEqual( 41 | actualLocalValue, 42 | localTargetValue, 43 | "does return local value" 44 | ); 45 | const actualGlobalValue = await sut.get(configName, true); 46 | assert( 47 | actualGlobalValue !== localTargetValue, 48 | "does not return local value" 49 | ); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /test/suite/unitTest.systemTest/suite/diffLineMapper.test.ts: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 zawys. Licensed under AGPL-3.0-or-later. 2 | // See LICENSE file in repository root directory. 3 | 4 | import { strictEqual } from "assert"; 5 | import { DiffLineMapper } from "../../../../src/scrollSynchronizer"; 6 | 7 | suite("DiffLineMapper", () => { 8 | test("works", () => { 9 | const testCases: { 10 | old: string; 11 | new: string; 12 | from: number; 13 | expectedTo: number; 14 | }[] = [ 15 | { old: "abcd", new: "abcd", from: 0, expectedTo: 0 }, 16 | { old: "abcd", new: "abcd", from: 3, expectedTo: 3 }, 17 | { old: "a", new: "abcd", from: 0, expectedTo: 0 }, 18 | { old: "ab", new: "abcd", from: 1, expectedTo: 1 }, 19 | { old: "abd", new: "abcd", from: 2, expectedTo: 2.5 }, 20 | { old: "abx", new: "abcde", from: 2, expectedTo: 2 }, 21 | { old: "abx", new: "abcde", from: 2.5, expectedTo: 3.5 }, 22 | { old: "abx", new: "abcde", from: 3, expectedTo: 5 }, 23 | { old: "abcdef", new: "ce", from: 1, expectedTo: 0 }, 24 | { old: "abcdef", new: "ce", from: 2, expectedTo: 0 }, 25 | { old: "abcdef", new: "ce", from: 2.5, expectedTo: 0.5 }, 26 | { old: "abcdef", new: "ce", from: 3.5, expectedTo: 1 }, 27 | { old: "abcdef", new: "ce", from: 4.5, expectedTo: 1.5 }, 28 | { old: "abcdef", new: "ce", from: 5.5, expectedTo: 2 }, 29 | { old: "", new: "b", from: 0, expectedTo: 0.5 }, 30 | { old: "a", new: "ba", from: 0, expectedTo: 0.5 }, 31 | { old: "a", new: "ab", from: 1, expectedTo: 1.5 }, 32 | ]; 33 | for (const testCase of testCases) { 34 | const sut = DiffLineMapper.create( 35 | testCase.old.split(""), 36 | testCase.new.split("") 37 | ); 38 | if (sut === undefined) { 39 | throw new Error("sut === undefined"); 40 | } 41 | // eslint-disable-next-line unicorn/no-array-callback-reference 42 | const actual = sut.map(testCase.from); 43 | strictEqual( 44 | actual, 45 | testCase.expectedTo, 46 | `{ old: "${testCase.old}", new: "${testCase.new}", from: ${testCase.from}, expectedTo: ${testCase.expectedTo} }` 47 | ); 48 | } 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | # The branches below must be a subset of the branches above 8 | branches: [master] 9 | schedule: 10 | - cron: '1 4 * * 1' 11 | 12 | jobs: 13 | analyze: 14 | name: Analyze 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | # Override automatic language detection by changing the below list 21 | # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python'] 22 | language: ['javascript'] 23 | # Learn more... 24 | # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection 25 | 26 | steps: 27 | - name: Checkout repository 28 | uses: actions/checkout@v2 29 | with: 30 | # We must fetch at least the immediate parents so that if this is 31 | # a pull request then we can checkout the head. 32 | fetch-depth: 2 33 | 34 | # If this run was triggered by a pull request event, then checkout 35 | # the head of the pull request instead of the merge commit. 36 | - run: git checkout HEAD^2 37 | if: ${{ github.event_name == 'pull_request' }} 38 | 39 | # Initializes the CodeQL tools for scanning. 40 | - name: Initialize CodeQL 41 | uses: github/codeql-action/init@v1 42 | with: 43 | languages: ${{ matrix.language }} 44 | 45 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 46 | # If this step fails, then you should remove it and run the build manually (see below) 47 | # - name: Autobuild 48 | # uses: github/codeql-action/autobuild@v1 49 | 50 | # ℹ️ Command-line programs to run using the OS shell. 51 | # 📚 https://git.io/JvXDl 52 | 53 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 54 | # and modify them (or add more) to build your code if your project 55 | # uses a compiled language 56 | 57 | #- run: | 58 | # make bootstrap 59 | # make release 60 | 61 | - name: Perform CodeQL Analysis 62 | uses: github/codeql-action/analyze@v1 63 | -------------------------------------------------------------------------------- /src/editorOpenManager.ts: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 zawys. Licensed under AGPL-3.0-or-later. 2 | // See LICENSE file in repository root directory. 3 | 4 | import { Disposable, TextEditor, window } from "vscode"; 5 | import { EditorOpenHandler } from "./editorOpenHandler"; 6 | import { RegisterableService } from "./registerableService"; 7 | import { isUIError, UIError } from "./uIError"; 8 | 9 | export class EditorOpenManager implements RegisterableService { 10 | public async register(): Promise { 11 | this.disposables.push( 12 | window.onDidChangeVisibleTextEditors( 13 | this.handleDidChangeVisibleTextEditors.bind(this) 14 | ) 15 | ); 16 | for (const editor of window.visibleTextEditors) { 17 | if (await this.handleDidOpenEditor(editor)) { 18 | return; 19 | } 20 | } 21 | } 22 | 23 | public dispose(): void { 24 | for (const disposable of this.disposables) { 25 | disposable.dispose(); 26 | } 27 | } 28 | 29 | public constructor( 30 | private readonly editorOpenHandlers: ReadonlyArray<{ 31 | handler: EditorOpenHandler; 32 | name: string; 33 | }> 34 | ) {} 35 | 36 | private disposables: Disposable[] = []; 37 | 38 | private async handleDidChangeVisibleTextEditors(editors: TextEditor[]) { 39 | for (const editor of editors) { 40 | await this.handleDidOpenEditor(editor); 41 | } 42 | } 43 | private async handleDidOpenEditor(editor: TextEditor): Promise { 44 | const uRI = editor.document.uri; 45 | const fsPath = uRI.fsPath; 46 | for (const { handler } of this.editorOpenHandlers) { 47 | if (handler.ignorePathOverride(fsPath)) { 48 | return false; 49 | } 50 | } 51 | for (const { handler, name } of this.editorOpenHandlers) { 52 | const handleResult = await handler.handleDidOpenURI(uRI); 53 | if (isUIError(handleResult)) { 54 | this.showError(handleResult); 55 | } else if (handleResult === true) { 56 | console.log(`Opened ${uRI.fsPath} with ${name}`); 57 | return true; 58 | } 59 | } 60 | return false; 61 | } 62 | 63 | private showError(error: UIError): void { 64 | void window.showErrorMessage( 65 | `Could not check opened document status: ${error.message}` 66 | ); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | // See https://go.microsoft.com/fwlink/?LinkId=733558 2 | // for the documentation about the tasks.json format 3 | { 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "label": "watch", 8 | "type": "npm", 9 | "script": "watch", 10 | "problemMatcher": "$tsc-watch", 11 | "isBackground": true, 12 | "presentation": { 13 | "reveal": "never" 14 | }, 15 | "group": { 16 | "kind": "build", 17 | "isDefault": true 18 | }, 19 | "runOptions": { 20 | "runOn": "folderOpen" 21 | } 22 | }, 23 | { 24 | "label": "debug:pre", 25 | "type": "npm", 26 | "script": "debug:pre", 27 | "group": "build", 28 | "runOptions": { 29 | "instanceLimit": 1 30 | }, 31 | "presentation": { 32 | "echo": true, 33 | "reveal": "silent", 34 | "focus": false, 35 | "panel": "shared", 36 | "showReuseMessage": true, 37 | "clear": true 38 | }, 39 | "problemMatcher": [] 40 | }, 41 | { 42 | "label": "start_test_for_debugging", 43 | "type": "npm", 44 | "script": "test:dev", 45 | "options": { 46 | "env": { 47 | "DEBUG_CURRENT_FILE_PATH": "${file}" 48 | } 49 | }, 50 | "runOptions": { 51 | "instanceLimit": 1 52 | }, 53 | "presentation": { 54 | "echo": true, 55 | "reveal": "always", 56 | "focus": false, 57 | "panel": "shared", 58 | "showReuseMessage": true, 59 | "clear": true 60 | }, 61 | "isBackground": true, 62 | "problemMatcher": { 63 | "pattern": [ 64 | { 65 | "regexp": "^(\\S.*)\\((\\d+,\\d+)\\):\\s*(.*)$", 66 | "file": 1, 67 | "location": 2, 68 | "message": 3 69 | } 70 | ], 71 | "background": { 72 | "activeOnStart": true, 73 | "beginsPattern": "$never^", 74 | "endsPattern": "^Debugging test .+\\. Waiting on port" 75 | } 76 | } 77 | }, 78 | { 79 | "label": "test:dev:pre", 80 | "type": "npm", 81 | "script": "test:dev:pre", 82 | "group": "build", 83 | "runOptions": { 84 | "instanceLimit": 1 85 | }, 86 | "presentation": { 87 | "echo": true, 88 | "reveal": "silent", 89 | "focus": false, 90 | "panel": "shared", 91 | "showReuseMessage": true, 92 | "clear": true 93 | } 94 | } 95 | ] 96 | } 97 | -------------------------------------------------------------------------------- /test/suite/unitTest.systemTest/suite/diffedURIs.test.ts: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 zawys. Licensed under AGPL-3.0-or-later. 2 | // See LICENSE file in repository root directory. 3 | 4 | import assert from "assert"; 5 | import { Uri } from "vscode"; 6 | import { parseBaseFileNameRE } from "../../../../src/diffedURIs"; 7 | 8 | suite("parseBaseFileNameRE", () => { 9 | const sut: RegExp = parseBaseFileNameRE; 10 | 11 | test("parses base file URI correctly", () => { 12 | const input = Uri.file("/example/changed_BASE_13546.xml"); 13 | const actual = sut.exec(input.path); 14 | assert(actual, "matches"); 15 | assert(actual[1] === "changed", "file name group value"); 16 | assert(actual[2] === "BASE", "restWOGit group value"); 17 | assert(actual[3] === "13546.xml", "restWOGit group value"); 18 | assert(actual[4] === ".xml", "extension group value"); 19 | }); 20 | 21 | test("parses base file URI with .git extension correctly", () => { 22 | const input = Uri.file("/example/changed_BASE_13546.xml.git"); 23 | const actual = sut.exec(input.path); 24 | assert(actual, "matches"); 25 | assert(actual[1] === "changed", "file name group value"); 26 | assert(actual[3] === "13546.xml", "restWOGit group value"); 27 | assert(actual[4] === ".xml", "extension group value"); 28 | }); 29 | 30 | test("parses base file URI with single digit correctly", () => { 31 | const input = Uri.file("/example/changed_BASE_9.xml"); 32 | const actual = sut.exec(input.path); 33 | assert(actual, "matches"); 34 | assert.strictEqual(actual[3], "9.xml", "restWOGit group value"); 35 | assert.strictEqual(actual[4], ".xml", "extension group value"); 36 | }); 37 | 38 | test("parses base file URI with 6 digits correctly", () => { 39 | const input = Uri.file("/example/changed_BASE_123456.xml"); 40 | const actual = sut.exec(input.path); 41 | assert(actual, "matches"); 42 | assert.strictEqual(actual[3], "123456.xml", "restWOGit group value"); 43 | assert.strictEqual(actual[4], ".xml", "extension group value"); 44 | }); 45 | 46 | test("parses base file URI with other capitals part correctly", () => { 47 | const input = Uri.file("/example/changed_REMOTE_13546.xml.git"); 48 | const actual = sut.exec(input.path); 49 | assert(actual, "matches"); 50 | assert(actual[1] === "changed", "file name group value"); 51 | assert(actual[2] === "REMOTE", "restWOGit group value"); 52 | assert(actual[3] === "13546.xml", "restWOGit group value"); 53 | assert(actual[4] === ".xml", "extension group value"); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /src/layouters/threeDiffToBaseLayouter.ts: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 zawys. Licensed under AGPL-3.0-or-later. 2 | // See LICENSE file in repository root directory. 3 | 4 | import { Zoom } from "../zoom"; 5 | import { 6 | DiffLayouter, 7 | DiffLayouterFactory, 8 | DiffLayouterFactoryParameters, 9 | } from "./diffLayouter"; 10 | import { 11 | GroupOrientation, 12 | LayoutElementType, 13 | SplitDiffLayouter, 14 | } from "./splitDiffLayouter"; 15 | 16 | export class ThreeDiffToBaseLayouterFactory implements DiffLayouterFactory { 17 | public readonly settingValue = "3DiffToBase"; 18 | 19 | public create(parameters: DiffLayouterFactoryParameters): DiffLayouter { 20 | return new SplitDiffLayouter({ 21 | ...parameters, 22 | supportedZooms: [Zoom.left, Zoom.center, Zoom.right], 23 | createLayoutDescription: (diffedURIs, zoom) => { 24 | let leftSize = 0.3; 25 | let centerSize = 0.4; 26 | switch (zoom) { 27 | case Zoom.left: 28 | leftSize = 0.55; 29 | centerSize = 0.4; 30 | break; 31 | case Zoom.right: 32 | leftSize = 0.05; 33 | centerSize = 0.4; 34 | break; 35 | case Zoom.center: 36 | leftSize = 0.05; 37 | centerSize = 0.9; 38 | break; 39 | } 40 | const rightSize = 1 - leftSize - centerSize; 41 | return { 42 | orientation: GroupOrientation.horizontal, 43 | groups: [ 44 | { 45 | size: leftSize, 46 | type: LayoutElementType.diffEditor, 47 | oldUri: diffedURIs.base, 48 | newUri: diffedURIs.local, 49 | title: "(1) Current changes on base", 50 | save: false, 51 | notFocussable: zoom === Zoom.right, 52 | }, 53 | { 54 | size: centerSize, 55 | type: LayoutElementType.diffEditor, 56 | oldUri: diffedURIs.base, 57 | newUri: diffedURIs.merged, 58 | title: "(2) Merged changes on base", 59 | save: true, 60 | isMergeEditor: true, 61 | }, 62 | { 63 | size: rightSize, 64 | type: LayoutElementType.diffEditor, 65 | oldUri: diffedURIs.base, 66 | newUri: diffedURIs.remote, 67 | title: "(3) Incoming changes on base", 68 | save: false, 69 | notFocussable: zoom === Zoom.left, 70 | }, 71 | ], 72 | }; 73 | }, 74 | }); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/diffedURIs.ts: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 zawys. Licensed under AGPL-3.0-or-later. 2 | // See LICENSE file in repository root directory. 3 | 4 | import { Uri } from "vscode"; 5 | 6 | // The number in the file name is the PID of git-mergetool 7 | export const parseBaseFileNameRE = /\/([^/]*)_(BASE|REMOTE|LOCAL)_(\d+(.*?))(\.git)?$/; 8 | 9 | export function uRIOccursIn( 10 | diffedURIs: DiffedURIs, 11 | containedURI: Uri 12 | ): boolean { 13 | return fsPathOccursIn(diffedURIs, containedURI.fsPath); 14 | } 15 | export function fsPathOccursIn( 16 | diffedURIs: DiffedURIs, 17 | containedFsPath: string 18 | ): boolean { 19 | return toURIList(diffedURIs).some((diffedURI) => { 20 | const diffedPath = diffedURI.fsPath; 21 | return pathsRoughlyEqual(containedFsPath, diffedPath); 22 | }); 23 | } 24 | export function toURIList(diffedURIs: DiffedURIs): Uri[] { 25 | const result = [ 26 | diffedURIs.base, 27 | diffedURIs.local, 28 | diffedURIs.merged, 29 | diffedURIs.remote, 30 | ]; 31 | if (diffedURIs.backup !== undefined) { 32 | result.push(diffedURIs.backup); 33 | } 34 | return result; 35 | } 36 | export function uRIsOrUndefEqual( 37 | first: Uri | undefined, 38 | second: Uri | undefined 39 | ): boolean { 40 | if (first === undefined) { 41 | return second === undefined; 42 | } 43 | if (second === undefined) { 44 | return false; 45 | } 46 | return uRIsEqual(first, second); 47 | } 48 | export function uRIsEqual(first: Uri, second: Uri): boolean { 49 | return pathsRoughlyEqual(first.fsPath, second.fsPath); 50 | } 51 | export function pathsRoughlyEqual(first: string, second: string): boolean { 52 | return ( 53 | first === second || first + ".git" === second || first === second + ".git" 54 | ); 55 | } 56 | 57 | export class DiffedURIs { 58 | public equals(other: DiffedURIs): boolean { 59 | return ( 60 | uRIsEqual(this.base, other.base) && 61 | uRIsEqual(this.local, other.local) && 62 | uRIsEqual(this.remote, other.remote) && 63 | uRIsEqual(this.merged, other.merged) && 64 | uRIsOrUndefEqual(this.backup, other.backup) 65 | ); 66 | } 67 | 68 | public equalsWithoutBackup(other: DiffedURIs): boolean { 69 | return ( 70 | uRIsEqual(this.base, other.base) && 71 | uRIsEqual(this.local, other.local) && 72 | uRIsEqual(this.remote, other.remote) && 73 | uRIsEqual(this.merged, other.merged) 74 | ); 75 | } 76 | 77 | public constructor( 78 | public readonly base: Uri, 79 | public readonly local: Uri, 80 | public readonly remote: Uri, 81 | public readonly merged: Uri, 82 | public readonly backup?: Uri 83 | ) {} 84 | } 85 | -------------------------------------------------------------------------------- /src/getPathsWithinVSCode.ts: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 zawys. Licensed under AGPL-3.0-or-later. 2 | // See LICENSE file in repository root directory. 3 | 4 | import { extensions, Uri, window, workspace } from "vscode"; 5 | import { GitExtension } from "./@types/git"; 6 | import { getGitPath } from "./getPaths"; 7 | import { UIError } from "./uIError"; 8 | 9 | const pathSeparator = "/"; 10 | function uriStartsWith(parent: Uri, child: Uri): boolean { 11 | if (parent.authority !== child.authority) { 12 | return false; 13 | } 14 | const childParts = child.path.split(pathSeparator); 15 | const parentParts = parent.path.split(pathSeparator); 16 | for (const [index, parentPart] of parentParts.entries()) { 17 | if (parentPart !== childParts[index]) { 18 | return false; 19 | } 20 | } 21 | return true; 22 | } 23 | 24 | export function getWorkspaceDirectoryUri(): Uri | undefined { 25 | if (window.activeTextEditor !== undefined) { 26 | const textEditorUri = window.activeTextEditor.document.uri; 27 | for (const folder of workspace.workspaceFolders || []) { 28 | if (uriStartsWith(folder.uri, textEditorUri)) { 29 | return folder.uri; 30 | } 31 | } 32 | } 33 | if ( 34 | workspace.workspaceFolders === undefined || 35 | workspace.workspaceFolders.length !== 1 36 | ) { 37 | return undefined; 38 | } 39 | return workspace.workspaceFolders[0].uri; 40 | } 41 | 42 | export function getWorkspaceDirectoryUriInteractively(): Uri | undefined { 43 | const result = getWorkspaceDirectoryUri(); 44 | if (result === undefined) { 45 | void window.showErrorMessage( 46 | "You need need to have exactly one workspace opened." 47 | ); 48 | } 49 | return result; 50 | } 51 | let vSCGitPathPromise: Promise | undefined; 52 | export function getVSCGitPath(): Promise { 53 | if (vSCGitPathPromise === undefined) { 54 | vSCGitPathPromise = getVSCGitPathInner(); 55 | } 56 | return vSCGitPathPromise; 57 | } 58 | async function getVSCGitPathInner(): Promise { 59 | const gitExtension = await extensions 60 | .getExtension("vscode.git") 61 | ?.activate(); 62 | if (gitExtension !== undefined && gitExtension.enabled) { 63 | const api = gitExtension.getAPI(1); 64 | return api.git.path; 65 | } 66 | return await getGitPath(); 67 | } 68 | export async function getVSCGitPathInteractively(): Promise< 69 | string | undefined 70 | > { 71 | const gitPath = await getVSCGitPath(); 72 | if (typeof gitPath === "string") { 73 | return gitPath; 74 | } 75 | void window.showErrorMessage(gitPath.message); 76 | return undefined; 77 | } 78 | -------------------------------------------------------------------------------- /src/layouters/threeDiffToBaseRowsLayouter.ts: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 zawys. Licensed under AGPL-3.0-or-later. 2 | // See LICENSE file in repository root directory. 3 | 4 | import { Zoom } from "../zoom"; 5 | import { 6 | DiffLayouter, 7 | DiffLayouterFactory, 8 | DiffLayouterFactoryParameters, 9 | } from "./diffLayouter"; 10 | import { 11 | GroupOrientation, 12 | LayoutElementType, 13 | SplitDiffLayouter, 14 | } from "./splitDiffLayouter"; 15 | 16 | export class ThreeDiffToBaseRowsLayouterFactory 17 | implements DiffLayouterFactory { 18 | public readonly settingValue = "3DiffToBaseRows"; 19 | 20 | public create(parameters: DiffLayouterFactoryParameters): DiffLayouter { 21 | return new SplitDiffLayouter({ 22 | ...parameters, 23 | supportedZooms: [Zoom.top, Zoom.center, Zoom.bottom], 24 | createLayoutDescription: (diffedURIs, zoom) => { 25 | let topSize = 0.275; 26 | let centerSize = 0.45; 27 | switch (zoom) { 28 | case Zoom.top: 29 | topSize = 0.55; 30 | centerSize = 0.4; 31 | break; 32 | case Zoom.bottom: 33 | topSize = 0.05; 34 | centerSize = 0.4; 35 | break; 36 | case Zoom.center: 37 | topSize = 0.05; 38 | centerSize = 0.9; 39 | break; 40 | } 41 | const bottomSize = 1 - topSize - centerSize; 42 | return { 43 | orientation: GroupOrientation.vertical, 44 | groups: [ 45 | { 46 | size: topSize, 47 | type: LayoutElementType.diffEditor, 48 | oldUri: diffedURIs.base, 49 | newUri: diffedURIs.local, 50 | title: "(1) Current changes on base", 51 | save: false, 52 | notFocussable: zoom === Zoom.bottom || zoom === Zoom.center, 53 | }, 54 | { 55 | size: centerSize, 56 | type: LayoutElementType.diffEditor, 57 | oldUri: diffedURIs.base, 58 | newUri: diffedURIs.merged, 59 | title: "(2) Merged changes on base", 60 | save: true, 61 | isMergeEditor: true, 62 | }, 63 | { 64 | size: bottomSize, 65 | type: LayoutElementType.diffEditor, 66 | oldUri: diffedURIs.base, 67 | newUri: diffedURIs.remote, 68 | title: "(3) Incoming changes on base", 69 | save: false, 70 | notFocussable: zoom === Zoom.top || zoom === Zoom.center, 71 | }, 72 | ], 73 | }; 74 | }, 75 | }); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/layouters/threeDiffToMergedLayouter.ts: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 zawys. Licensed under AGPL-3.0-or-later. 2 | // See LICENSE file in repository root directory. 3 | 4 | import { Zoom } from "../zoom"; 5 | import { 6 | DiffLayouter, 7 | DiffLayouterFactory, 8 | DiffLayouterFactoryParameters, 9 | } from "./diffLayouter"; 10 | import { 11 | GroupOrientation, 12 | LayoutElementType, 13 | SplitDiffLayouter, 14 | } from "./splitDiffLayouter"; 15 | 16 | export class ThreeDiffToMergedLayouterFactory implements DiffLayouterFactory { 17 | public readonly settingValue = "3DiffToMerged"; 18 | 19 | public create(parameters: DiffLayouterFactoryParameters): DiffLayouter { 20 | return new SplitDiffLayouter({ 21 | ...parameters, 22 | supportedZooms: [Zoom.left, Zoom.center, Zoom.right], 23 | createLayoutDescription: (diffedURIs, zoom) => { 24 | let leftSize = 1 / 3; 25 | let centerSize = 1 / 3; 26 | switch (zoom) { 27 | case Zoom.left: 28 | leftSize = 0.55; 29 | centerSize = 0.4; 30 | break; 31 | case Zoom.right: 32 | leftSize = 0.05; 33 | centerSize = 0.4; 34 | break; 35 | case Zoom.center: 36 | leftSize = 0.05; 37 | centerSize = 0.9; 38 | break; 39 | } 40 | const rightSize = 1 - leftSize - centerSize; 41 | return { 42 | orientation: GroupOrientation.horizontal, 43 | groups: [ 44 | { 45 | size: leftSize, 46 | type: LayoutElementType.diffEditor, 47 | oldUri: diffedURIs.remote, 48 | newUri: diffedURIs.merged, 49 | title: "(1) Current changes on incoming", 50 | save: false, 51 | notFocussable: zoom === Zoom.right, 52 | }, 53 | { 54 | size: centerSize, 55 | type: LayoutElementType.diffEditor, 56 | oldUri: diffedURIs.base, 57 | newUri: diffedURIs.merged, 58 | title: "(2) Merged changes on base", 59 | save: true, 60 | isMergeEditor: true, 61 | notFocussable: false, 62 | }, 63 | { 64 | size: rightSize, 65 | type: LayoutElementType.diffEditor, 66 | oldUri: diffedURIs.local, 67 | newUri: diffedURIs.merged, 68 | title: "(3) Incoming changes on current", 69 | save: false, 70 | notFocussable: zoom === Zoom.left, 71 | }, 72 | ], 73 | }; 74 | }, 75 | }); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /vsc-extension-quickstart.md: -------------------------------------------------------------------------------- 1 | # Welcome to your VS Code Extension 2 | 3 | ## What's in the folder 4 | 5 | - This folder contains all of the files necessary for your extension. 6 | - `package.json` - this is the manifest file in which you declare your extension and command. 7 | - The sample plugin registers a command and defines its title and command name. With this information VS Code can show the command in the command palette. It doesn’t yet need to load the plugin. 8 | - `src/extension.ts` - this is the main file where you will provide the implementation of your command. 9 | - The file exports one function, `activate`, which is called the very first time your extension is activated (in this case by executing the command). Inside the `activate` function we call `registerCommand`. 10 | - We pass the function containing the implementation of the command as the second parameter to `registerCommand`. 11 | 12 | ## Get up and running straight away 13 | 14 | - Press `F5` to open a new window with your extension loaded. 15 | - Run your command from the command palette by pressing (`Ctrl+Shift+P` or `Cmd+Shift+P` on Mac) and typing `Hello World`. 16 | - Set breakpoints in your code inside `src/extension.ts` to debug your extension. 17 | - Find output from your extension in the debug console. 18 | 19 | ## Make changes 20 | 21 | - You can relaunch the extension from the debug toolbar after changing code in `src/extension.ts`. 22 | - You can also reload (`Ctrl+R` or `Cmd+R` on Mac) the VS Code window with your extension to load your changes. 23 | 24 | ## Explore the API 25 | 26 | - You can open the full set of our API when you open the file `node_modules/@types/vscode/index.d.ts`. 27 | 28 | ## Run tests 29 | 30 | - Open the debug viewlet (`Ctrl+Shift+D` or `Cmd+Shift+D` on Mac) and from the launch configuration dropdown pick `Extension Tests`. 31 | - Press `F5` to run the tests in a new window with your extension loaded. 32 | - See the output of the test result in the debug console. 33 | - Make changes to `test/suite/extension.test.ts` or create new test files inside the `test/suite` folder. 34 | - The provided test runner will only consider files matching the name pattern `**.test.ts`. 35 | - You can create folders inside the `test` folder to structure your tests any way you want. 36 | 37 | ## Go further 38 | 39 | - Reduce the extension size and improve the startup time by [bundling your extension](https://code.visualstudio.com/api/working-with-extensions/bundling-extension). 40 | - [Publish your extension](https://code.visualstudio.com/api/working-with-extensions/publishing-extension) on the VSCode extension marketplace. 41 | - Automate builds by setting up [Continuous Integration](https://code.visualstudio.com/api/working-with-extensions/continuous-integration). 42 | -------------------------------------------------------------------------------- /src/commonMergeCommandsManager.ts: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 zawys. Licensed under AGPL-3.0-or-later. 2 | // See LICENSE file in repository root directory. 3 | 4 | import { commands, Disposable, window } from "vscode"; 5 | import { extensionID } from "./ids"; 6 | import { RegisterableService } from "./registerableService"; 7 | 8 | export class CommonMergeCommandsManager implements RegisterableService { 9 | public addHandler(handler: CommonMergeCommandHandler): Disposable { 10 | this.handlers.add(handler); 11 | return new Disposable(() => { 12 | this.removeHandler(handler); 13 | }); 14 | } 15 | public register(): void | Promise { 16 | this.disposables.push( 17 | commands.registerCommand( 18 | gitMergetoolContinueCommandID, 19 | this.handleContinueCommand.bind(this) 20 | ), 21 | commands.registerCommand( 22 | gitMergetoolStopCommandID, 23 | this.handleStopCommand.bind(this) 24 | ), 25 | commands.registerCommand( 26 | nextMergeStepCommandID, 27 | this.handleNextMergeStepCommand.bind(this) 28 | ) 29 | ); 30 | } 31 | public dispose(): void { 32 | this.handlers = new Set(); 33 | for (const disposable of this.disposables) { 34 | disposable.dispose(); 35 | } 36 | } 37 | public removeHandler(handler: CommonMergeCommandHandler): void { 38 | this.handlers.add(handler); 39 | } 40 | private disposables: Disposable[] = []; 41 | private handlers = new Set(); 42 | private handleContinueCommand(): void { 43 | void this.handleCommand((handler) => handler.continueMergeProcess()); 44 | } 45 | private handleStopCommand(): void { 46 | void this.handleCommand((handler) => handler.stopMergeProcess()); 47 | } 48 | private handleNextMergeStepCommand(): void { 49 | void this.handleCommand((handler) => handler.doNextStepInMergeProcess()); 50 | } 51 | private async handleCommand( 52 | action: (handler: CommonMergeCommandHandler) => boolean | Promise 53 | ): Promise { 54 | for (const handler of this.handlers) { 55 | if (await action(handler)) { 56 | return; 57 | } 58 | } 59 | void window.showErrorMessage( 60 | "Command not applicable in the current situation" 61 | ); 62 | } 63 | } 64 | 65 | export const gitMergetoolContinueCommandID = `${extensionID}.gitMergetoolContinue`; 66 | export const gitMergetoolStopCommandID = `${extensionID}.gitMergetoolStop`; 67 | export const nextMergeStepCommandID = `${extensionID}.nextMergeStep`; 68 | 69 | export interface CommonMergeCommandHandler { 70 | stopMergeProcess(): boolean | Promise; 71 | continueMergeProcess(): boolean | Promise; 72 | doNextStepInMergeProcess(): boolean | Promise; 73 | } 74 | -------------------------------------------------------------------------------- /src/mergeAborter.ts: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 zawys. Licensed under AGPL-3.0-or-later. 2 | // See LICENSE file in repository root directory. 3 | 4 | import path from "path"; 5 | import { commands, Disposable, QuickPickItem, window } from "vscode"; 6 | import { createBackgroundGitTerminal } from "./backgroundGitTerminal"; 7 | import { getStats } from "./fsHandy"; 8 | import { getWorkspaceDirectoryUri } from "./getPathsWithinVSCode"; 9 | import { RegisterableService } from "./registerableService"; 10 | 11 | export class MergeAborter implements RegisterableService { 12 | public register(): void | Promise { 13 | this.abortMergeCommandRegistration = commands.registerCommand( 14 | abortMergeCommandID, 15 | this.abortMerge.bind(this) 16 | ); 17 | } 18 | public dispose(): void { 19 | this.abortMergeCommandRegistration?.dispose(); 20 | this.abortMergeCommandRegistration = undefined; 21 | } 22 | 23 | // by https://stackoverflow.com/a/30783114/1717752 24 | public async isMergeInProgress(): Promise { 25 | const workingDirectoryUri = getWorkspaceDirectoryUri(); 26 | if (workingDirectoryUri === undefined) { 27 | return undefined; 28 | } 29 | const stats = await getStats( 30 | path.join(workingDirectoryUri.fsPath, ".git/MERGE_HEAD") 31 | ); 32 | if (stats === undefined) { 33 | return undefined; 34 | } 35 | return stats.isFile(); 36 | } 37 | 38 | public async abortMerge(): Promise { 39 | if (!(await this.isMergeInProgress())) { 40 | this.warnNoMergeInProgress(); 41 | return; 42 | } 43 | 44 | const quitMerge: QuickPickItem = { 45 | label: "Keep working directory and index", 46 | detail: "runs `git merge --quit`", 47 | }; 48 | const abortMerge: QuickPickItem = { 49 | label: "DISCARD changes in working directory and index", 50 | detail: "runs `git merge --abort`", 51 | }; 52 | const nothing: QuickPickItem = { 53 | label: "Do nothing", 54 | }; 55 | const pickedItem = await window.showQuickPick( 56 | [quitMerge, abortMerge, nothing], 57 | { 58 | ignoreFocusOut: true, 59 | matchOnDetail: true, 60 | placeHolder: "Select an action", 61 | } 62 | ); 63 | if (pickedItem === nothing || pickedItem === undefined) { 64 | return; 65 | } 66 | 67 | if (!(await this.isMergeInProgress())) { 68 | this.warnNoMergeInProgress(); 69 | return; 70 | } 71 | await createBackgroundGitTerminal({ 72 | shellArgs: ["merge", pickedItem === abortMerge ? "--abort" : "--quit"], 73 | }); 74 | } 75 | 76 | private abortMergeCommandRegistration: Disposable | undefined = undefined; 77 | 78 | private warnNoMergeInProgress(): void { 79 | void window.showWarningMessage("No git merge in progress"); 80 | } 81 | } 82 | 83 | const abortMergeCommandID = "vscode-as-git-mergetool.gitMergeAbort"; 84 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | // A launch configuration that compiles the extension and then opens it inside a new window 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | { 6 | "version": "0.2.0", 7 | "configurations": [ 8 | { 9 | "name": "debug extension", 10 | "type": "extensionHost", 11 | "request": "launch", 12 | "runtimeExecutable": "${execPath}", 13 | "args": ["--extensionDevelopmentPath=${workspaceFolder}"], 14 | "outFiles": [ 15 | "${workspaceFolder}/out/dist/extension.js", 16 | "${workspaceFolder}/out/src/**/*.js" 17 | ], 18 | "preLaunchTask": "debug:pre" 19 | }, 20 | { 21 | "name": "debug extension, start paused", 22 | "type": "extensionHost", 23 | "request": "launch", 24 | "runtimeExecutable": "${execPath}", 25 | "args": [ 26 | "--extensionDevelopmentPath=${workspaceFolder}", 27 | "--inspect-brk-extensions=3714" 28 | ], 29 | "outFiles": [ 30 | "${workspaceFolder}/out/dist/extension.js", 31 | "${workspaceFolder}/out/src/**/*.js" 32 | ], 33 | "preLaunchTask": "debug:pre" 34 | }, 35 | { 36 | "name": "debug extension in selected path", 37 | "type": "extensionHost", 38 | "request": "launch", 39 | "runtimeExecutable": "${execPath}", 40 | "args": [ 41 | "--extensionDevelopmentPath=${workspaceFolder}", 42 | "--inspect-brk-extensions=3714", 43 | "--user-data-dir=${input:data-dir}/user-data-dir" 44 | ], 45 | "outFiles": [ 46 | "${workspaceFolder}/out/dist/extension.js", 47 | "${workspaceFolder}/out/src/**/*.js" 48 | ], 49 | "preLaunchTask": "debug:pre", 50 | "env": { 51 | "HOME": "${input:data-dir}" 52 | } 53 | }, 54 | { 55 | "name": "debug opened test", 56 | "port": 3714, 57 | "request": "attach", 58 | "skipFiles": ["/**"], 59 | "type": "pwa-node", 60 | "preLaunchTask": "start_test_for_debugging", 61 | "continueOnAttach": true, 62 | "showAsyncStacks": true, 63 | "timeout": 5000, 64 | "outFiles": [ 65 | "${workspaceFolder}/out/dist/extension.js", 66 | "${workspaceFolder}/out/src/**/*.js", 67 | "${workspaceFolder}/out/test/**/*.js" 68 | ] 69 | }, 70 | { 71 | "name": "debug test runner", 72 | "program": "${workspaceFolder}/out/test/runTests.js", 73 | "request": "launch", 74 | "skipFiles": ["/**"], 75 | "type": "pwa-node", 76 | "preLaunchTask": "test:dev:pre" 77 | } 78 | ], 79 | "inputs": [ 80 | { 81 | "id": "data-dir", 82 | "type": "promptString", 83 | "default": "${workspaceFolder}/.debug", 84 | "description": "Select the environment data directory" 85 | } 86 | ] 87 | } 88 | -------------------------------------------------------------------------------- /src/layouters/threeDiffToBaseMergedRightLayouter.ts: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 zawys. Licensed under AGPL-3.0-or-later. 2 | // See LICENSE file in repository root directory. 3 | 4 | import { Zoom } from "../zoom"; 5 | import { 6 | DiffLayouter, 7 | DiffLayouterFactory, 8 | DiffLayouterFactoryParameters, 9 | } from "./diffLayouter"; 10 | import { 11 | GroupOrientation, 12 | LayoutElementType, 13 | SplitDiffLayouter, 14 | } from "./splitDiffLayouter"; 15 | 16 | export class ThreeDiffToBaseMergedRightLayouterFactory 17 | implements DiffLayouterFactory { 18 | public readonly settingValue = "3DiffToBaseMergedRight"; 19 | 20 | public create(parameters: DiffLayouterFactoryParameters): DiffLayouter { 21 | return new SplitDiffLayouter({ 22 | ...parameters, 23 | supportedZooms: [Zoom.top, Zoom.bottom, Zoom.left, Zoom.right], 24 | createLayoutDescription: (diffedURIs, zoom) => { 25 | let topSize = 0.5; 26 | let leftSize = 0.5; 27 | switch (zoom) { 28 | case Zoom.left: 29 | leftSize = 0.95; 30 | break; 31 | case Zoom.right: 32 | leftSize = 0.05; 33 | break; 34 | case Zoom.top: 35 | topSize = 0.95; 36 | leftSize = 0.58; 37 | break; 38 | case Zoom.bottom: 39 | topSize = 0.05; 40 | leftSize = 0.58; 41 | break; 42 | } 43 | const bottomSize = 1 - topSize; 44 | const rightSize = 1 - leftSize; 45 | return { 46 | orientation: GroupOrientation.horizontal, 47 | groups: [ 48 | { 49 | size: leftSize, 50 | groups: [ 51 | { 52 | size: topSize, 53 | type: LayoutElementType.diffEditor, 54 | oldUri: diffedURIs.base, 55 | newUri: diffedURIs.local, 56 | title: "(1) Current changes on base", 57 | notFocussable: zoom === Zoom.right || zoom === Zoom.bottom, 58 | save: false, 59 | }, 60 | { 61 | size: bottomSize, 62 | type: LayoutElementType.diffEditor, 63 | oldUri: diffedURIs.base, 64 | newUri: diffedURIs.remote, 65 | title: "(2) Incoming changes on base", 66 | notFocussable: zoom === Zoom.right || zoom === Zoom.top, 67 | save: false, 68 | }, 69 | ], 70 | }, 71 | { 72 | size: rightSize, 73 | type: LayoutElementType.diffEditor, 74 | oldUri: diffedURIs.base, 75 | newUri: diffedURIs.merged, 76 | title: "(3) Merged changes on base", 77 | save: true, 78 | notFocussable: zoom === Zoom.left, 79 | isMergeEditor: true, 80 | }, 81 | ], 82 | }; 83 | }, 84 | }); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/arbitraryFilesMerger.ts: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 zawys. Licensed under AGPL-3.0-or-later. 2 | // See LICENSE file in repository root directory. 3 | 4 | import { commands, Disposable, Uri, Memento, window } from "vscode"; 5 | import { DiffedURIs } from "./diffedURIs"; 6 | import { DiffFileSelector } from "./diffFileSelector"; 7 | import { DiffLayouterManager } from "./diffLayouterManager"; 8 | import { getVSCGitPathInteractively } from "./getPathsWithinVSCode"; 9 | import { gitMergeFile } from "./gitMergeFile"; 10 | import { extensionID } from "./ids"; 11 | import { Lazy } from "./lazy"; 12 | import { ReadonlyDocumentProvider } from "./readonlyDocumentProvider"; 13 | import { RegisterableService } from "./registerableService"; 14 | import { isUIError } from "./uIError"; 15 | 16 | export class ArbitraryFilesMerger implements RegisterableService { 17 | public register(): void { 18 | this.disposables = [ 19 | commands.registerCommand( 20 | mergeArbitraryFilesCommandID, 21 | this.mergeArbitraryFiles.bind(this) 22 | ), 23 | ]; 24 | } 25 | 26 | public dispose(): void { 27 | for (const disposabe of this.disposables) { 28 | disposabe.dispose(); 29 | } 30 | } 31 | 32 | public async mergeArbitraryFiles(): Promise { 33 | const selectionResult = await this.diffFileSelectorLazy.value.doSelection(); 34 | if (selectionResult === undefined) { 35 | return false; 36 | } 37 | const gitPath = await getVSCGitPathInteractively(); 38 | if (gitPath === undefined) { 39 | return false; 40 | } 41 | const mergedPath = selectionResult.merged.fsPath; 42 | if (selectionResult.merged.validationResult?.emptyLoc === true) { 43 | const mergeFileResult = await gitMergeFile(gitPath, { 44 | local: selectionResult.local.fsPath, 45 | base: selectionResult.base.fsPath, 46 | remote: selectionResult.remote.fsPath, 47 | merged: mergedPath, 48 | }); 49 | if (isUIError(mergeFileResult)) { 50 | void window.showErrorMessage(mergeFileResult.message); 51 | return false; 52 | } 53 | } 54 | const diffedURIs: DiffedURIs = new DiffedURIs( 55 | this.readonlyDocumentProvider.readonlyFileURI( 56 | selectionResult.base.fsPath 57 | ), 58 | this.readonlyDocumentProvider.readonlyFileURI( 59 | selectionResult.local.fsPath 60 | ), 61 | this.readonlyDocumentProvider.readonlyFileURI( 62 | selectionResult.remote.fsPath 63 | ), 64 | Uri.file(selectionResult.merged.fsPath) 65 | ); 66 | return await this.diffLayouterManager.openDiffedURIs(diffedURIs, false); 67 | } 68 | 69 | public constructor( 70 | private readonly diffLayouterManager: DiffLayouterManager, 71 | private readonly readonlyDocumentProvider: ReadonlyDocumentProvider, 72 | private readonly workspaceState: Memento, 73 | private diffFileSelectorLazy = new Lazy( 74 | () => new DiffFileSelector(workspaceState) 75 | ) 76 | ) {} 77 | 78 | private disposables: Disposable[] = []; 79 | } 80 | 81 | const mergeArbitraryFilesCommandID = `${extensionID}.mergeArbitraryFiles`; 82 | -------------------------------------------------------------------------------- /src/gitMergetoolTemporaryFileOpenHandler.ts: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 zawys. Licensed under AGPL-3.0-or-later. 2 | // See LICENSE file in repository root directory. 3 | 4 | import { Event, EventEmitter, Uri } from "vscode"; 5 | import { 6 | toURIList, 7 | DiffedURIs, 8 | parseBaseFileNameRE, 9 | fsPathOccursIn, 10 | } from "./diffedURIs"; 11 | import { DiffLayouterManager } from "./diffLayouterManager"; 12 | import { EditorOpenHandler } from "./editorOpenHandler"; 13 | import { getStats } from "./fsHandy"; 14 | import { ReadonlyDocumentProvider } from "./readonlyDocumentProvider"; 15 | 16 | export class GitMergetoolTemporaryFileOpenHandler 17 | implements EditorOpenHandler { 18 | public get onDidLayoutReact(): Event { 19 | return this.didLayoutReact.event; 20 | } 21 | public async handleDidOpenURI(uRI: Uri): Promise { 22 | if (this.diffLayouterManager.layoutSwitchInProgress) { 23 | return false; 24 | } 25 | const diffedURIs = this.getDiffedURIs(uRI); 26 | if (diffedURIs === undefined || !(await this.filesExist(diffedURIs))) { 27 | return false; 28 | } 29 | if (this.diffLayouterManager.layoutSwitchInProgress) { 30 | return false; 31 | } 32 | this.didLayoutReact.fire(); 33 | return await this.diffLayouterManager.openDiffedURIs(diffedURIs, true); 34 | } 35 | 36 | public getDiffedURIs(baseURI: Uri): DiffedURIs | undefined { 37 | const parseResult = parseBaseFileNameRE.exec(baseURI.path); 38 | if (parseResult === null) { 39 | return undefined; 40 | } 41 | const baseFileName = parseResult[1]; 42 | const restWOGit = parseResult[3]; 43 | const extension = parseResult[4]; 44 | function joinBasePath(scheme: string, appendedParts: string[]) { 45 | return Uri.joinPath( 46 | baseURI, 47 | ["../", baseFileName, ...appendedParts].join("") 48 | ).with({ scheme }); 49 | } 50 | const readonlyScheme = this.readonlyDocumentProvider.scheme; 51 | return new DiffedURIs( 52 | joinBasePath(readonlyScheme, ["_BASE_", restWOGit]), 53 | joinBasePath(readonlyScheme, ["_LOCAL_", restWOGit]), 54 | joinBasePath(readonlyScheme, ["_REMOTE_", restWOGit]), 55 | joinBasePath("file", [extension]), 56 | joinBasePath(readonlyScheme, ["_BACKUP_", restWOGit]) 57 | ); 58 | } 59 | 60 | public async filesExist(diffedURIs: DiffedURIs): Promise { 61 | return ( 62 | await Promise.all( 63 | toURIList(diffedURIs).map(async (uRI) => { 64 | if (uRI.fsPath.endsWith(".git")) { 65 | console.warn( 66 | 'Path ends with ".git" ' + 67 | "which might be wrong and causing problems." 68 | ); 69 | } 70 | const stats = await getStats(uRI.fsPath); 71 | return stats?.isFile() === true; 72 | }) 73 | ) 74 | ).every((exists) => exists); 75 | } 76 | 77 | public ignorePathOverride(fsPath: string): boolean { 78 | return ( 79 | this.diffLayouterManager.diffedURIs !== undefined && 80 | fsPathOccursIn(this.diffLayouterManager.diffedURIs, fsPath) 81 | ); 82 | } 83 | 84 | public constructor( 85 | private readonly diffLayouterManager: DiffLayouterManager, 86 | private readonly readonlyDocumentProvider: ReadonlyDocumentProvider 87 | ) {} 88 | private readonly didLayoutReact = new EventEmitter(); 89 | } 90 | -------------------------------------------------------------------------------- /src/zoom.ts: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 zawys. Licensed under AGPL-3.0-or-later. 2 | // See LICENSE file in repository root directory. 3 | 4 | import { 5 | commands, 6 | Disposable, 7 | Event, 8 | EventEmitter, 9 | StatusBarAlignment, 10 | window, 11 | } from "vscode"; 12 | import { extensionID, firstLetterUppercase } from "./ids"; 13 | import { RegisterableService } from "./registerableService"; 14 | 15 | export const enum Zoom { 16 | default, 17 | center, 18 | left, 19 | right, 20 | top, 21 | bottom, 22 | } 23 | export interface ZoomInfo { 24 | commandID: string; 25 | name: string; 26 | symbol: string; 27 | } 28 | export const allZooms = new Map(); 29 | 30 | for (const [zoom, name, symbol] of [ 31 | [Zoom.default, "default", "$(discard)"], 32 | [Zoom.center, "center", "▣"], 33 | [Zoom.left, "left", "◧"], 34 | [Zoom.right, "right", "◨"], 35 | [Zoom.top, "top", "⬒"], 36 | [Zoom.bottom, "bottom", "⬓"], 37 | ] as [Zoom, string, string][]) { 38 | allZooms.set(zoom, { 39 | commandID: `${extensionID}.zoom${firstLetterUppercase(name)}`, 40 | name, 41 | symbol, 42 | }); 43 | } 44 | 45 | export class ZoomManager implements RegisterableService { 46 | public register(): void { 47 | for (const [zoom, { commandID }] of allZooms.entries()) { 48 | commands.registerCommand(commandID, () => { 49 | this.wasZoomRequested.fire(zoom); 50 | }); 51 | } 52 | } 53 | 54 | public dispose(): void { 55 | for (const disposable of this.disposables) { 56 | disposable.dispose(); 57 | } 58 | this.wasZoomRequested.dispose(); 59 | this.removeStatusBarItems(); 60 | } 61 | 62 | public get onWasZoomRequested(): Event { 63 | return this.wasZoomRequested.event; 64 | } 65 | 66 | public createStatusBarItems( 67 | supportedZooms: Zoom[], 68 | startPriority = 0 69 | ): void { 70 | this.removeStatusBarItems(); 71 | this.priority = startPriority; 72 | this.addStatusBarItem({ text: "Zoom:" }); 73 | for (const zoom of [Zoom.default, ...supportedZooms]) { 74 | const zoomInfo = allZooms.get(zoom); 75 | if (zoomInfo === undefined) { 76 | continue; 77 | } 78 | this.addZoomStatusBarItem(zoomInfo); 79 | } 80 | } 81 | 82 | public removeStatusBarItems(): void { 83 | for (const disposable of this.statusbarItems) { 84 | disposable.dispose(); 85 | } 86 | this.statusbarItems = []; 87 | } 88 | 89 | private disposables: Disposable[] = []; 90 | private wasZoomRequested = new EventEmitter(); 91 | private statusbarItems: Disposable[] = []; 92 | private priority = 0; 93 | 94 | private addZoomStatusBarItem({ name, symbol, commandID }: ZoomInfo): void { 95 | this.addStatusBarItem({ text: symbol, tooltip: name, commandID }); 96 | } 97 | 98 | private addStatusBarItem({ 99 | text, 100 | tooltip, 101 | commandID, 102 | }: { 103 | text: string; 104 | tooltip?: string; 105 | commandID?: string; 106 | }) { 107 | const item = window.createStatusBarItem( 108 | StatusBarAlignment.Left, 109 | this.priority-- 110 | ); 111 | item.text = text; 112 | item.tooltip = tooltip; 113 | item.command = commandID; 114 | this.statusbarItems.push(item); 115 | item.show(); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/layouters/diffLayouter.ts: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 zawys. Licensed under AGPL-3.0-or-later. 2 | // See LICENSE file in repository root directory. 3 | 4 | import { Disposable, Event, window, workspace } from "vscode"; 5 | import { toURIList, DiffedURIs } from "../diffedURIs"; 6 | import { extensionID } from "../ids"; 7 | import { Monitor } from "../monitor"; 8 | import { TemporarySettingsManager } from "../temporarySettingsManager"; 9 | import { UIError } from "../uIError"; 10 | import { VSCodeConfigurator } from "../vSCodeConfigurator"; 11 | import { Zoom, ZoomManager } from "../zoom"; 12 | 13 | export interface DiffLayouter extends Disposable { 14 | /** 15 | * Try start layout. 16 | * 17 | * @returns if layout could be activated. 18 | */ 19 | tryActivate(zoom: Zoom, onlyGrid?: boolean): Promise; 20 | 21 | /** 22 | * Reset the layout. 23 | */ 24 | setLayout(zoom: Zoom): Promise; 25 | 26 | /** 27 | * Save the merge result. 28 | */ 29 | save(): Promise; 30 | 31 | /** 32 | * Switch back to previous layout. 33 | */ 34 | deactivate(onlyGrid?: boolean): Promise; 35 | 36 | /** 37 | * Focus merge conflict. 38 | * 39 | * @returns Promise, if merge conflict indicators exist, undefined on error. 40 | */ 41 | focusMergeConflict(type: SearchType): boolean | UIError; 42 | 43 | /** 44 | * If layout is currently applied. 45 | */ 46 | readonly isActive: boolean; 47 | 48 | /** 49 | * If layout going to be activated. 50 | */ 51 | readonly isActivating: boolean; 52 | 53 | /** 54 | * Passed to `tryStartMerge`. Used to find out if layout switch needed. 55 | * Should change at the beginning of layout start and 56 | */ 57 | readonly diffedURIs: DiffedURIs; 58 | 59 | /** 60 | * Fired when the layout was deactivated. 61 | */ 62 | readonly onDidDeactivate: Event; 63 | 64 | readonly wasInitiatedByMergetool: boolean; 65 | 66 | setWasInitiatedByMergetool(): void; 67 | } 68 | 69 | export interface DiffLayouterFactoryParameters { 70 | readonly monitor: Monitor; 71 | readonly diffedURIs: DiffedURIs; 72 | readonly temporarySettingsManager: TemporarySettingsManager; 73 | readonly vSCodeConfigurator: VSCodeConfigurator; 74 | readonly zoomManager: ZoomManager; 75 | } 76 | 77 | export interface DiffLayouterFactory { 78 | readonly settingValue: string; 79 | 80 | create(parameters: DiffLayouterFactoryParameters): DiffLayouter; 81 | } 82 | 83 | export function watchDiffedURIs( 84 | uRIs: DiffedURIs, 85 | handler: () => void 86 | ): Disposable[] { 87 | const disposables: Disposable[] = []; 88 | for (const uRI of toURIList(uRIs)) { 89 | if (uRI.fsPath.endsWith(".git")) { 90 | void window.showErrorMessage("path ends with .git"); 91 | } 92 | const watcher = workspace.createFileSystemWatcher( 93 | uRI.fsPath, 94 | true, 95 | true, 96 | false 97 | ); 98 | disposables.push(watcher); 99 | watcher.onDidDelete(handler, disposables); 100 | } 101 | return disposables; 102 | } 103 | 104 | export enum SearchType { 105 | first, 106 | next, 107 | previous, 108 | } 109 | 110 | export const focusPreviousConflictCommandID = `${extensionID}.focusPreviousConflict`; 111 | export const focusNextConflictCommandID = `${extensionID}.focusNextConflict`; 112 | -------------------------------------------------------------------------------- /src/childProcessHandy.ts: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 zawys. Licensed under AGPL-3.0-or-later. 2 | // See LICENSE file in repository root directory. 3 | 4 | import { ExecException, execFile, ExecFileOptions } from "child_process"; 5 | import { window } from "vscode"; 6 | export function execFilePromise({ 7 | filePath, 8 | arguments_, 9 | options, 10 | }: ExecFileArguments): Promise { 11 | return new Promise((resolve) => 12 | execFile(filePath, arguments_, options || {}, (error, stdout, stderr) => 13 | resolve({ error, stdout, stderr }) 14 | ) 15 | ); 16 | } 17 | 18 | export async function execFileStdout( 19 | arguments_: ExecFileArguments 20 | ): Promise { 21 | const result = await execFilePromise(arguments_); 22 | return result.error === null ? result.stdout : result; 23 | } 24 | 25 | export async function execFileStdoutInteractively( 26 | arguments_: ExecFileArguments 27 | ): Promise { 28 | const result = await execFilePromise(arguments_); 29 | if (result.error === null) { 30 | return result.stdout; 31 | } 32 | const command = [arguments_.filePath, ...(arguments_.arguments_ || [])].join( 33 | "" 34 | ); 35 | void window.showErrorMessage( 36 | `Could not execute command \`${command}\`: ${formatExecFileError(result)}` 37 | ); 38 | return undefined; 39 | } 40 | 41 | const maxOutputEllipsisLength = 160; // two full 80-character terminal lines 42 | const startPartLength = Math.floor(maxOutputEllipsisLength / 2); 43 | const endPartLength = maxOutputEllipsisLength - startPartLength - 1; 44 | function commandOutputEllipsis(output: string): string { 45 | if (output.length <= maxOutputEllipsisLength) { 46 | return output; 47 | } 48 | return `${output.slice(0, endPartLength)}…${output.slice( 49 | output.length - endPartLength 50 | )}`; 51 | } 52 | 53 | export function formatExecFileError(result: ExecFileResult): string { 54 | if (result.error === null) { 55 | return "unknown"; 56 | } 57 | return `${result.error.name}: ${result.error.message}. \nExit code: ${ 58 | result.error.code || "unknown" 59 | }; Signal: ${result.error.signal || "unknown"}. \nStdout: ${ 60 | result.stdout ? "\n" : "" 61 | }${commandOutputEllipsis(result.stdout) || "none"}\nStderr: ${ 62 | result.error ? "\n" : "" 63 | }${commandOutputEllipsis(result.stderr) || "none"}`; 64 | } 65 | 66 | export async function execFileStdoutTrimEOL( 67 | arguments_: ExecFileArguments 68 | ): Promise { 69 | const result = await execFileStdout(arguments_); 70 | return typeof result !== "string" ? result : trimLastEOL(result); 71 | } 72 | 73 | export async function execFileStdoutInteractivelyTrimEOL( 74 | arguments_: ExecFileArguments 75 | ): Promise { 76 | const result = await execFileStdoutInteractively(arguments_); 77 | return result === undefined ? undefined : trimLastEOL(result); 78 | } 79 | 80 | function trimLastEOL(value: string): string { 81 | const lastIndex = value.length - 1; 82 | if (lastIndex < 0) { 83 | return value; 84 | } 85 | return value[lastIndex] === "\n" ? value.slice(0, lastIndex) : value; 86 | } 87 | 88 | export interface ExecFileArguments { 89 | filePath: string; 90 | arguments_?: string[]; 91 | options?: ExecFileOptions; 92 | } 93 | export interface ExecFileResult { 94 | error: ExecException | null; 95 | stdout: string; 96 | stderr: string; 97 | } 98 | -------------------------------------------------------------------------------- /test/suite/unitTest.systemTest/suite/gitMergetoolTemporaryFileOpenHandler.test.ts: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 zawys. Licensed under AGPL-3.0-or-later. 2 | // See LICENSE file in repository root directory. 3 | 4 | import { Uri } from "vscode"; 5 | import { createReadonlyDocumentProviderManager } from "../../../../src/readonlyDocumentProvider"; 6 | import { DiffedURIs } from "../../../../src/diffedURIs"; 7 | import { GitMergetoolTemporaryFileOpenHandler } from "../../../../src/gitMergetoolTemporaryFileOpenHandler"; 8 | import { DiffLayouterManager } from "../../../../src/diffLayouterManager"; 9 | import assert = require("assert"); 10 | 11 | suite("getDiffedURIs", () => { 12 | const readonlyScheme = createReadonlyDocumentProviderManager().scheme; 13 | 14 | const temporaryFileOpenManager = new GitMergetoolTemporaryFileOpenHandler( 15 | {} as DiffLayouterManager, 16 | createReadonlyDocumentProviderManager().documentProvider 17 | ); 18 | // eslint-disable-next-line unicorn/consistent-function-scoping 19 | const sut = (uri: Uri): DiffedURIs | undefined => 20 | temporaryFileOpenManager.getDiffedURIs(uri); 21 | 22 | test("returns correct paths for the diffed files", () => { 23 | const input = Uri.file("/somewhere/to_merge_BASE_12345.txt"); 24 | const actual = sut(input); 25 | const expected: DiffedURIs = new DiffedURIs( 26 | Uri.file("/somewhere/to_merge_BASE_12345.txt"), 27 | Uri.file("/somewhere/to_merge_LOCAL_12345.txt"), 28 | Uri.file("/somewhere/to_merge_REMOTE_12345.txt"), 29 | Uri.file("/somewhere/to_merge.txt"), 30 | Uri.file("/somewhere/to_merge_BACKUP_12345.txt") 31 | ); 32 | assert(actual?.equals(expected)); 33 | }); 34 | 35 | test("returns correct paths for the diffed files with .git extension", () => { 36 | const input = Uri.file("/somewhere/to_merge_BASE_12345.txt.git"); 37 | const actual = sut(input); 38 | const expected: DiffedURIs = new DiffedURIs( 39 | Uri.file("/somewhere/to_merge_BASE_12345.txt"), 40 | Uri.file("/somewhere/to_merge_LOCAL_12345.txt"), 41 | Uri.file("/somewhere/to_merge_REMOTE_12345.txt"), 42 | Uri.file("/somewhere/to_merge.txt"), 43 | Uri.file("/somewhere/to_merge_BACKUP_12345.txt") 44 | ); 45 | assert(actual?.equals(expected)); 46 | }); 47 | 48 | test("the returned paths have file or readonly-file scheme respectively", () => { 49 | const input = Uri.file("/somewhere/to_merge_BASE_12345.txt.git").with({ 50 | scheme: "git", 51 | }); 52 | const actual = sut(input); 53 | assert.strictEqual(actual?.base.scheme, readonlyScheme, "base"); 54 | assert.strictEqual(actual?.local.scheme, readonlyScheme, "local"); 55 | assert.strictEqual(actual?.remote.scheme, readonlyScheme, "remote"); 56 | assert.strictEqual(actual?.merged.scheme, "file", "merged"); 57 | assert.strictEqual(actual?.backup?.scheme, readonlyScheme, "merged"); 58 | }); 59 | 60 | test("return valid paths when other capitals part is input", () => { 61 | const input = Uri.file("/somewhere/to_merge_LOCAL_12345.txt"); 62 | const actual = sut(input); 63 | const expected: DiffedURIs = new DiffedURIs( 64 | Uri.file("/somewhere/to_merge_BASE_12345.txt"), 65 | Uri.file("/somewhere/to_merge_LOCAL_12345.txt"), 66 | Uri.file("/somewhere/to_merge_REMOTE_12345.txt"), 67 | Uri.file("/somewhere/to_merge.txt"), 68 | Uri.file("/somewhere/to_merge_BACKUP_12345.txt") 69 | ); 70 | assert(actual?.equals(expected)); 71 | }); 72 | }); 73 | -------------------------------------------------------------------------------- /src/layouters/fourTransferDownLayouter.ts: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 zawys. Licensed under AGPL-3.0-or-later. 2 | // See LICENSE file in repository root directory. 3 | 4 | import { Zoom } from "../zoom"; 5 | import { 6 | DiffLayouter, 7 | DiffLayouterFactory, 8 | DiffLayouterFactoryParameters, 9 | } from "./diffLayouter"; 10 | import { 11 | GroupOrientation, 12 | LayoutElementType, 13 | SplitDiffLayouter, 14 | } from "./splitDiffLayouter"; 15 | 16 | export class FourTransferDownLayouterFactory implements DiffLayouterFactory { 17 | public readonly settingValue = "4TransferDown"; 18 | 19 | public create(parameters: DiffLayouterFactoryParameters): DiffLayouter { 20 | return new SplitDiffLayouter({ 21 | ...parameters, 22 | supportedZooms: [Zoom.left, Zoom.right, Zoom.top, Zoom.bottom], 23 | createLayoutDescription: (diffedURIs, zoom) => { 24 | let leftSize = 0.5; 25 | let topSize = 0.45; 26 | switch (zoom) { 27 | case Zoom.top: 28 | topSize = 0.95; 29 | break; 30 | case Zoom.bottom: 31 | topSize = 0.05; 32 | break; 33 | case Zoom.left: 34 | leftSize = 0.95; 35 | break; 36 | case Zoom.right: 37 | leftSize = 0.05; 38 | break; 39 | } 40 | const rightSize = 1 - leftSize; 41 | const bottomSize = 1 - topSize; 42 | return { 43 | orientation: GroupOrientation.horizontal, 44 | groups: [ 45 | { 46 | size: leftSize, 47 | groups: [ 48 | { 49 | size: topSize, 50 | type: LayoutElementType.diffEditor, 51 | oldUri: diffedURIs.base, 52 | newUri: diffedURIs.local, 53 | title: "(1) Current changes on base", 54 | save: false, 55 | notFocussable: zoom === Zoom.right || zoom === Zoom.bottom, 56 | }, 57 | { 58 | size: bottomSize, 59 | type: LayoutElementType.diffEditor, 60 | oldUri: diffedURIs.remote, 61 | newUri: diffedURIs.merged, 62 | title: "(2) Current changes on incoming", 63 | save: true, 64 | notFocussable: zoom === Zoom.right || zoom === Zoom.top, 65 | isMergeEditor: true, 66 | }, 67 | ], 68 | }, 69 | { 70 | size: rightSize, 71 | groups: [ 72 | { 73 | size: topSize, 74 | type: LayoutElementType.diffEditor, 75 | oldUri: diffedURIs.base, 76 | newUri: diffedURIs.remote, 77 | title: "(3) Incoming changes on base", 78 | save: false, 79 | notFocussable: zoom === Zoom.left || zoom === Zoom.bottom, 80 | }, 81 | { 82 | size: bottomSize, 83 | type: LayoutElementType.diffEditor, 84 | oldUri: diffedURIs.local, 85 | newUri: diffedURIs.merged, 86 | title: "(4) Incoming changes on current", 87 | save: true, 88 | notFocussable: zoom === Zoom.left || zoom === Zoom.top, 89 | isMergeEditor: true, 90 | }, 91 | ], 92 | }, 93 | ], 94 | }; 95 | }, 96 | }); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/layouters/fourTransferRightLayouter.ts: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 zawys. Licensed under AGPL-3.0-or-later. 2 | // See LICENSE file in repository root directory. 3 | 4 | import { Zoom } from "../zoom"; 5 | import { 6 | DiffLayouter, 7 | DiffLayouterFactory, 8 | DiffLayouterFactoryParameters, 9 | } from "./diffLayouter"; 10 | import { 11 | GroupOrientation, 12 | LayoutElementType, 13 | SplitDiffLayouter, 14 | } from "./splitDiffLayouter"; 15 | 16 | export class FourTransferRightLayouterFactory implements DiffLayouterFactory { 17 | public readonly settingValue = "4TransferRight"; 18 | 19 | public create(parameters: DiffLayouterFactoryParameters): DiffLayouter { 20 | return new SplitDiffLayouter({ 21 | ...parameters, 22 | supportedZooms: [Zoom.top, Zoom.bottom, Zoom.left, Zoom.right], 23 | createLayoutDescription: (diffedURIs, zoom: Zoom) => { 24 | let leftSize = 0.5; 25 | let topSize = 0.5; 26 | switch (zoom) { 27 | case Zoom.top: 28 | topSize = 0.95; 29 | break; 30 | case Zoom.bottom: 31 | topSize = 0.05; 32 | break; 33 | case Zoom.left: 34 | leftSize = 0.95; 35 | break; 36 | case Zoom.right: 37 | leftSize = 0.05; 38 | break; 39 | } 40 | const rightSize = 1 - leftSize; 41 | const bottomSize = 1 - topSize; 42 | return { 43 | orientation: GroupOrientation.vertical, 44 | groups: [ 45 | { 46 | size: topSize, 47 | groups: [ 48 | { 49 | size: leftSize, 50 | type: LayoutElementType.diffEditor, 51 | oldUri: diffedURIs.base, 52 | newUri: diffedURIs.local, 53 | title: "(1) Current changes on base", 54 | save: false, 55 | notFocussable: zoom === Zoom.right || zoom === Zoom.bottom, 56 | }, 57 | { 58 | size: rightSize, 59 | type: LayoutElementType.diffEditor, 60 | oldUri: diffedURIs.remote, 61 | newUri: diffedURIs.merged, 62 | title: "(2) Current changes on incoming", 63 | save: true, 64 | notFocussable: zoom === Zoom.left || zoom === Zoom.bottom, 65 | isMergeEditor: true, 66 | }, 67 | ], 68 | }, 69 | { 70 | size: bottomSize, 71 | groups: [ 72 | { 73 | size: leftSize, 74 | type: LayoutElementType.diffEditor, 75 | oldUri: diffedURIs.base, 76 | newUri: diffedURIs.remote, 77 | title: "(3) Incoming changes on base", 78 | save: false, 79 | notFocussable: zoom === Zoom.right || zoom === Zoom.top, 80 | }, 81 | { 82 | size: rightSize, 83 | type: LayoutElementType.diffEditor, 84 | oldUri: diffedURIs.local, 85 | newUri: diffedURIs.merged, 86 | title: "(4) Incoming changes on current", 87 | save: true, 88 | notFocussable: zoom === Zoom.left || zoom === Zoom.top, 89 | isMergeEditor: true, 90 | }, 91 | ], 92 | }, 93 | ], 94 | }; 95 | }, 96 | }); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at pj94ye@runbox.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /test/suite/unitTest.systemTest/suite/gitOptionAssistant.test.ts: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 zawys. Licensed under AGPL-3.0-or-later. 2 | // See LICENSE file in repository root directory. 3 | 4 | import assert from "assert"; 5 | import { 6 | GitConfigurator, 7 | GitOptionAssistant, 8 | OptionChangeProtocol, 9 | } from "../../../../src/settingsAssistant"; 10 | import { 11 | getWorkspaceDirectoryUri, 12 | getVSCGitPath, 13 | } from "../../../../src/getPathsWithinVSCode"; 14 | import { isUIError } from "../../../../src/uIError"; 15 | 16 | suite("GitOptionAssistant", function () { 17 | let gitConfigurator: GitConfigurator | undefined = undefined; 18 | let sut: GitOptionAssistant | undefined = undefined; 19 | let changeProtocol: OptionChangeProtocol | undefined = undefined; 20 | const targetValue = "random.stuff@example.com"; 21 | const configKey = "user.email"; 22 | this.beforeAll(async () => { 23 | const gitPath = await getVSCGitPath(); 24 | if (isUIError(gitPath)) throw new Error(gitPath.message); 25 | const workingDirectory = getWorkspaceDirectoryUri(); 26 | if (workingDirectory === undefined) { 27 | throw new Error("workingDirectory undefined"); 28 | } 29 | gitConfigurator = new GitConfigurator(gitPath, workingDirectory); 30 | changeProtocol = new OptionChangeProtocol(); 31 | sut = new GitOptionAssistant( 32 | gitConfigurator, 33 | configKey, 34 | targetValue, 35 | "some text" 36 | ); 37 | }); 38 | 39 | test("shows that it needs a change", async () => { 40 | if (sut === undefined) throw new Error("sut undefined"); 41 | if (gitConfigurator === undefined) 42 | throw new Error("gitConfigurator undefined"); 43 | const needsChange = await sut.getNeedsChange(); 44 | assert(needsChange); 45 | }); 46 | 47 | test("shows that it does not need a change", async () => { 48 | if (gitConfigurator === undefined) { 49 | throw new Error("gitConfigurator undefined"); 50 | } 51 | if (changeProtocol === undefined) { 52 | throw new Error("changeProtocol undefined"); 53 | } 54 | const configKey2 = "user.name"; 55 | const actualValue = await gitConfigurator.get(configKey2); 56 | if (actualValue === undefined) { 57 | throw new Error("improper environment for test"); 58 | } 59 | const sut2 = new GitOptionAssistant( 60 | gitConfigurator, 61 | configKey2, 62 | actualValue, 63 | "" 64 | ); 65 | assert(!(await sut2.getNeedsChange())); 66 | }); 67 | 68 | test("updates the configuration", async () => { 69 | if (gitConfigurator === undefined) { 70 | throw new Error("gitConfigurator undefined"); 71 | } 72 | if (sut === undefined) { 73 | throw new Error("sut undefined"); 74 | } 75 | if (changeProtocol === undefined) { 76 | throw new Error("changeProtocol undefined"); 77 | } 78 | const oldValue = await gitConfigurator.get(configKey, false); 79 | if (oldValue === undefined || oldValue == targetValue) { 80 | throw new Error("improper test environment"); 81 | } 82 | const questionsData = await sut.provideQuestionData(); 83 | const inRepositoryOption = questionsData.options.find( 84 | (option) => option.value === "in repository" 85 | ); 86 | assert( 87 | inRepositoryOption !== undefined, 88 | 'provides option with value "in repository"' 89 | ); 90 | if (inRepositoryOption === undefined) { 91 | throw new Error("assertion did not work?!"); 92 | } 93 | await sut.handlePickedOption(inRepositoryOption, changeProtocol); 94 | const newValue = await gitConfigurator.get(configKey, false); 95 | assert.strictEqual(newValue, targetValue, "updates the option"); 96 | }); 97 | }); 98 | -------------------------------------------------------------------------------- /src/manualMergeProcess.ts: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 zawys. Licensed under AGPL-3.0-or-later. 2 | // See LICENSE file in repository root directory. 3 | 4 | import { StatusBarAlignment, StatusBarItem, window } from "vscode"; 5 | import { 6 | gitMergetoolStopCommandID, 7 | nextMergeStepCommandID, 8 | } from "./commonMergeCommandsManager"; 9 | import { DiffedURIs } from "./diffedURIs"; 10 | import { DiffLayouterManager } from "./diffLayouterManager"; 11 | import { SearchType } from "./layouters/diffLayouter"; 12 | import { createUIError, isUIError, UIError } from "./uIError"; 13 | 14 | export class ManualMergeProcess { 15 | public async mergeManually( 16 | diffedURIs: DiffedURIs, 17 | labelText: string 18 | ): Promise { 19 | const sBIs = this.createManualMergeProcessSBIs(labelText); 20 | const diffLayoutPromise = new Promise((async ( 21 | resolve 22 | ) => { 23 | const uRIOpenResult = await this.diffLayouterManager.openDiffedURIs( 24 | diffedURIs, 25 | false, 26 | () => { 27 | resolve(ManualMergeResult.stop); 28 | } 29 | ); 30 | if (!uRIOpenResult) { 31 | resolve( 32 | createUIError("Could not open diff layout for merging paths.") 33 | ); 34 | return; 35 | } 36 | // const focusMergeConflictResult = this.diffLayouterManager.focusMergeConflict( 37 | // SearchType.first 38 | // ); 39 | // if (isUIError(focusMergeConflictResult)) { 40 | // resolve(focusMergeConflictResult); 41 | // return; 42 | // } 43 | }) as (resolve: (result: ManualMergeResult | UIError) => void) => void); 44 | for (const sBI of sBIs) { 45 | sBI.show(); 46 | } 47 | let diffLayoutResult; 48 | try { 49 | diffLayoutResult = await diffLayoutPromise; 50 | } finally { 51 | for (const sBI of sBIs) { 52 | sBI.dispose(); 53 | } 54 | } 55 | if (diffLayoutResult === ManualMergeResult.continue) { 56 | return diffLayoutResult; 57 | } 58 | if (isUIError(diffLayoutResult)) { 59 | void window.showErrorMessage(diffLayoutResult.message); 60 | return ManualMergeResult.error; 61 | } 62 | return diffLayoutResult; 63 | } 64 | public async doNextStepInMergeProcess(): Promise { 65 | if (this.resolve === undefined) { 66 | return; 67 | } 68 | const focusMergeConflictResult = this.diffLayouterManager.focusMergeConflict( 69 | SearchType.next 70 | ); 71 | switch (focusMergeConflictResult) { 72 | case undefined: 73 | this.resolve( 74 | createUIError("Could not determine next merge conflict indicator.") 75 | ); 76 | break; 77 | case false: 78 | this.resolve(ManualMergeResult.continue); 79 | await this.diffLayouterManager.deactivateLayout(); 80 | } 81 | } 82 | public async stopMergeProcess(): Promise { 83 | const oldResolve = this.resolve; 84 | if (oldResolve !== undefined) { 85 | this.resolve = undefined; 86 | await this.diffLayouterManager.deactivateLayout(); 87 | } 88 | } 89 | public constructor( 90 | private readonly diffLayouterManager: DiffLayouterManager 91 | ) {} 92 | private resolve?: (result: ManualMergeResult | UIError) => void; 93 | private createManualMergeProcessSBIs(labelText: string): StatusBarItem[] { 94 | const labelSBI = window.createStatusBarItem(StatusBarAlignment.Left, 15); 95 | labelSBI.text = `${labelText}:`; 96 | 97 | const acceptSBI = window.createStatusBarItem(StatusBarAlignment.Left, 14); 98 | acceptSBI.text = "$(check)"; 99 | acceptSBI.command = nextMergeStepCommandID; 100 | 101 | const deactivateSBI = window.createStatusBarItem( 102 | StatusBarAlignment.Left, 103 | 13 104 | ); 105 | deactivateSBI.text = "$(stop)"; 106 | deactivateSBI.command = gitMergetoolStopCommandID; 107 | return [labelSBI, acceptSBI, deactivateSBI]; 108 | } 109 | } 110 | export const enum ManualMergeResult { 111 | continue, 112 | stop, 113 | error, 114 | } 115 | -------------------------------------------------------------------------------- /src/temporarySettingsManager.ts: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 zawys. Licensed under AGPL-3.0-or-later. 2 | // See LICENSE file in repository root directory. 3 | 4 | import { VSCodeConfigurator } from "./vSCodeConfigurator"; 5 | import { Monitor } from "./monitor"; 6 | import { extensionID } from "./ids"; 7 | import { RegisterableService } from "./registerableService"; 8 | import { commands, Disposable, Memento } from "vscode"; 9 | 10 | export class TemporarySettingsManager implements RegisterableService { 11 | public async activateSettings(): Promise { 12 | await this.monitor.enter(); 13 | try { 14 | const oldOrigActual = this.getStorageState(origActualKey); 15 | const oldOrigTarget = this.getStorageState(origTargetKey); 16 | const newOrigActual: StorageState = {}; 17 | const newOrigTarget: StorageState = {}; 18 | 19 | const obsoleteSettingKeys: Set = new Set( 20 | Object.keys(oldOrigActual) 21 | ); 22 | 23 | for (const targetID of this.targetIDs) { 24 | obsoleteSettingKeys.delete(targetID); 25 | const newTarget = this.targetSettings[targetID]; 26 | const newActual = this.vSCodeConfigurator.get(targetID); 27 | if (newActual !== newTarget) { 28 | await this.vSCodeConfigurator.set(targetID, newTarget); 29 | } 30 | 31 | // in the latter case, user did change config setting afterwards, 32 | // so we will restore it later to the new value 33 | newOrigActual[targetID] = 34 | newActual === oldOrigTarget[targetID] 35 | ? oldOrigActual[targetID] 36 | : newActual; 37 | 38 | newOrigTarget[targetID] = newTarget; 39 | } 40 | for (const obsoleteID of obsoleteSettingKeys) { 41 | await this.vSCodeConfigurator.set( 42 | obsoleteID, 43 | oldOrigActual[obsoleteID] 44 | ); 45 | } 46 | await this.setStorageState(origActualKey, newOrigActual); 47 | await this.setStorageState(origTargetKey, newOrigTarget); 48 | } finally { 49 | await this.monitor.leave(); 50 | } 51 | } 52 | 53 | public async resetSettings(): Promise { 54 | await this.monitor.enter(); 55 | try { 56 | const origActual = this.getStorageState(origActualKey); 57 | const origTarget = this.getStorageState(origTargetKey); 58 | const origChangedIDs = Object.keys(origActual); 59 | for (const changedID of origChangedIDs) { 60 | const newActual = this.vSCodeConfigurator.get(changedID); 61 | if (newActual === origTarget[changedID]) { 62 | await this.vSCodeConfigurator.set(changedID, origActual[changedID]); 63 | } 64 | } 65 | await this.setStorageState(origActualKey, undefined); 66 | await this.setStorageState(origTargetKey, undefined); 67 | } finally { 68 | await this.monitor.leave(); 69 | } 70 | } 71 | 72 | public register(): void { 73 | this.disposables.push( 74 | commands.registerCommand(resetTemporarySettingsCommandID, async () => { 75 | await this.resetSettings(); 76 | }) 77 | ); 78 | } 79 | 80 | public dispose(): void { 81 | for (const disposable of this.disposables) { 82 | disposable.dispose(); 83 | } 84 | this.disposables = []; 85 | } 86 | 87 | public constructor( 88 | private readonly vSCodeConfigurator: VSCodeConfigurator, 89 | private readonly globalState: Memento, 90 | private readonly monitor = new Monitor(), 91 | private readonly targetSettings: StorageState = { 92 | "diffEditor.renderSideBySide": false, 93 | "editor.lineNumbers": "none", 94 | "workbench.editor.showTabs": false, 95 | "editor.glyphMargin": false, 96 | "workbench.activityBar.visible": false, 97 | } 98 | ) { 99 | this.targetIDs = Object.keys(targetSettings); 100 | } 101 | 102 | private targetIDs: string[]; 103 | private disposables: Disposable[] = []; 104 | 105 | private getStorageState(key: string): StorageState { 106 | const value = this.globalState.get(key); 107 | if (typeof value === "object") { 108 | // workspaceState.get returns JSON serializable objects. 109 | return value as StorageState; 110 | } 111 | return {}; 112 | } 113 | 114 | private async setStorageState( 115 | key: string, 116 | value: StorageState | undefined 117 | ): Promise { 118 | await this.globalState.update(key, value); 119 | } 120 | } 121 | 122 | type StorageState = { [k: string]: unknown }; 123 | 124 | const origActualKey = `${extensionID}.temporarySettings.origActual`; 125 | const origTargetKey = `${extensionID}.temporarySettings.origTarget`; 126 | const resetTemporarySettingsCommandID = `${extensionID}.resetTemporarySettings`; 127 | -------------------------------------------------------------------------------- /test/systemTest.ts: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 zawys. Licensed under AGPL-3.0-or-later. 2 | // See LICENSE file in repository root directory. 3 | 4 | import { execFileSync, spawnSync } from "child_process"; 5 | import { existsSync, unlinkSync } from "fs"; 6 | import path from "path"; 7 | import { runTests } from "vscode-test"; 8 | import { whichPromise } from "../src/getPaths"; 9 | 10 | /** 11 | * 12 | * @param testDirectory directory containing `environment.zip` and `suite` 13 | */ 14 | export async function runSystemTest( 15 | testDirectory: string, 16 | dirtyEnvironment = false 17 | ): Promise { 18 | // The folder containing the Extension Manifest package.json 19 | // Passed to `--extensionDevelopmentPath`. 20 | // Relative to the location of the compiled files (`out/test`) 21 | const extensionDevelopmentPath = path.resolve(__dirname, "../../"); 22 | // The path to test runner 23 | // Passed to --extensionTestsPath 24 | const extensionTestsPath = path.resolve(testDirectory, "./suite/index"); 25 | 26 | const launchArguments = ["--new-window", "--disable-extensions"]; 27 | if (!dirtyEnvironment) { 28 | const zipPath = path.resolve(testDirectory, "environment.zip"); 29 | const filesPath = await unpackToTemporaryDirectory(zipPath); 30 | const environmentPath = path.resolve(filesPath, "environment"); 31 | const workspaceDirectory = path.resolve(environmentPath, "workspace"); 32 | const userDataDirectory = path.resolve(environmentPath, "user_data_dir"); 33 | if (existsSync(userDataDirectory)) { 34 | launchArguments.push(`--user-data-dir=${userDataDirectory}`); 35 | } 36 | if (existsSync(workspaceDirectory)) { 37 | launchArguments.push(workspaceDirectory); 38 | } 39 | } 40 | const debugTestFilePath = process.env["DEBUG_CURRENT_FILE_PATH"]; 41 | let debugging = false; 42 | if (debugTestFilePath !== undefined) { 43 | const testName = (/[^/\\]+$/.exec( 44 | path.relative(extensionDevelopmentPath, testDirectory) 45 | ) || [undefined])[0]; 46 | if ( 47 | testName !== undefined && 48 | path 49 | .relative(extensionDevelopmentPath, debugTestFilePath) 50 | .split(path.sep) 51 | .includes(testName) 52 | ) { 53 | const port = 3714; 54 | // VS Code waits with debugging until this line appears. 55 | // See `tasks.json`. 56 | console.log( 57 | `Debugging test ${debugTestFilePath}. Waiting on port ${port}.` 58 | ); 59 | launchArguments.push(`--inspect-brk-extensions=${port}`); 60 | debugging = true; 61 | } else { 62 | console.log(`extensionTestsPath: ${extensionTestsPath}`); 63 | console.log(`testName: ${testName || "undefined"}`); 64 | console.log(`debugTestFilePath: ${debugTestFilePath}`); 65 | console.log("-> skipping"); 66 | return false; 67 | } 68 | } 69 | 70 | try { 71 | // Download VS Code, unzip it and run the integration test 72 | await runTests({ 73 | extensionDevelopmentPath, 74 | extensionTestsPath, 75 | launchArgs: launchArguments, 76 | extensionTestsEnv: { 77 | ...process.env, 78 | // eslint-disable-next-line @typescript-eslint/naming-convention 79 | ELECTRON_RUN_AS_NODE: undefined, 80 | }, 81 | vscodeExecutablePath: process.env["stable_code_path"], 82 | }); 83 | } catch (error) { 84 | console.error(`Failed to run tests in ${testDirectory}`); 85 | throw error; 86 | } 87 | return debugging; 88 | } 89 | 90 | export function unwrap(value: T | undefined): T { 91 | if (value === undefined) { 92 | throw new Error("value was undefined"); 93 | } 94 | return value; 95 | } 96 | 97 | export async function unwrappedWhich(executableName: string): Promise { 98 | return unwrap(await whichPromise(executableName)); 99 | } 100 | 101 | const mktemp = unwrappedWhich("mktemp"); 102 | const unzip = unwrappedWhich("unzip"); 103 | 104 | /** 105 | * 106 | * @param zipPath absolute path to ZIP file containing the repository. 107 | * @returns path to the unpacked temporary directory. 108 | */ 109 | export async function unpackToTemporaryDirectory( 110 | zipPath: string 111 | ): Promise { 112 | const temporaryDirectoryPath = spawnSync(await mktemp, ["-d"], { 113 | encoding: "utf-8", 114 | }).stdout.trimEnd(); 115 | console.log(`unzipped at: ${temporaryDirectoryPath}`); 116 | execFileSync(await unzip, [zipPath, "-d", temporaryDirectoryPath]); 117 | return temporaryDirectoryPath; 118 | } 119 | 120 | export function deleteTemporaryDirectory(path: string): void { 121 | unlinkSync(path); 122 | } 123 | 124 | export function isContainedIn( 125 | parentPath: string, 126 | comparedPath: string 127 | ): boolean { 128 | const relative = path.relative(parentPath, comparedPath); 129 | console.log(`relative: ${relative}`); 130 | return ( 131 | relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative) 132 | ); 133 | } 134 | -------------------------------------------------------------------------------- /src/fsHandy.ts: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 zawys. Licensed under AGPL-3.0-or-later. 2 | // See LICENSE file in repository root directory. 3 | 4 | import { ENOENT } from "constants"; 5 | import { 6 | access, 7 | copyFile, 8 | readFile, 9 | realpath, 10 | rename as fsRename, 11 | stat, 12 | Stats, 13 | unlink, 14 | writeFile, 15 | mkdir as fsMkdir, 16 | } from "fs"; 17 | import { createUIError, UIError } from "./uIError"; 18 | 19 | export function getStats(path: string): Promise { 20 | return new Promise((resolve) => { 21 | stat(path, (error, stats) => { 22 | resolve(error ? undefined : stats); 23 | }); 24 | }); 25 | } 26 | 27 | export function getRealPath(path: string): Promise { 28 | return new Promise((resolve) => { 29 | realpath(path, (error, resolvedPath) => { 30 | resolve(error ? undefined : resolvedPath); 31 | }); 32 | }); 33 | } 34 | 35 | export enum FileType { 36 | notExisting, 37 | regular, 38 | directory, 39 | fIFO, 40 | socket, 41 | blockDevice, 42 | characterDevice, 43 | symbolicLink, 44 | } 45 | 46 | export async function getFileType( 47 | path: string 48 | ): Promise { 49 | const statResult = await new Promise<{ 50 | error: null | NodeJS.ErrnoException; 51 | stats?: Stats; 52 | }>((resolve) => { 53 | stat(path, (error, stats) => { 54 | resolve({ error, stats }); 55 | }); 56 | }); 57 | if (statResult.error !== null) { 58 | if (statResult.error.errno === -ENOENT) { 59 | return FileType.notExisting; 60 | } 61 | return undefined; 62 | } 63 | const stats = statResult.stats; 64 | if (stats === undefined) { 65 | return undefined; 66 | } 67 | return stats.isFile() 68 | ? FileType.regular 69 | : stats.isDirectory() 70 | ? FileType.directory 71 | : stats.isSocket() 72 | ? FileType.socket 73 | : stats.isFIFO() 74 | ? FileType.fIFO 75 | : stats.isSymbolicLink() 76 | ? FileType.symbolicLink 77 | : stats.isBlockDevice() 78 | ? FileType.blockDevice 79 | : stats.isCharacterDevice() 80 | ? FileType.characterDevice 81 | : undefined; 82 | } 83 | 84 | export function testFile(path: string, mode: number): Promise { 85 | return new Promise((resolve) => { 86 | access(path, mode, (error) => resolve(!error)); 87 | }); 88 | } 89 | 90 | export function getContents(path: string): Promise { 91 | return new Promise((resolve) => { 92 | readFile(path, { encoding: "utf-8" }, (error, data) => { 93 | resolve(error ? undefined : data); 94 | }); 95 | }); 96 | } 97 | 98 | export function setContents( 99 | path: string, 100 | contents: string 101 | ): Promise { 102 | return new Promise((resolve) => { 103 | writeFile(path, contents, (error) => { 104 | resolve( 105 | error !== null ? createUIError(formatErrnoException(error)) : undefined 106 | ); 107 | }); 108 | }); 109 | } 110 | 111 | export function formatErrnoException(result: NodeJS.ErrnoException): string { 112 | return `${result.name}: ${result.message}. \nCode: ${ 113 | result.code || "unknown" 114 | }; Error number: ${result.errno || "unknown"}`; 115 | } 116 | 117 | /** 118 | * 119 | * @param firstPath 120 | * @param secondPath 121 | * @returns `true` iff the file contents equal. `false` in any other case. 122 | */ 123 | export async function fileContentsEqual( 124 | firstPath: string, 125 | secondPath: string 126 | ): Promise { 127 | const promises = [getContents(firstPath), getContents(secondPath)]; 128 | if ((await Promise.race(promises)) === undefined) { 129 | return false; 130 | } 131 | const [firstContents, secondContents] = await Promise.all(promises); 132 | if (firstContents === undefined || secondContents === undefined) { 133 | return false; 134 | } 135 | return firstContents === secondContents; 136 | } 137 | 138 | export function copy( 139 | sourcePath: string, 140 | destinationPath: string 141 | ): Promise { 142 | return new Promise((resolve) => { 143 | copyFile(sourcePath, destinationPath, (error) => { 144 | resolve( 145 | error === null ? undefined : createUIError(formatErrnoException(error)) 146 | ); 147 | }); 148 | }); 149 | } 150 | 151 | export function rename( 152 | sourcePath: string, 153 | destinationPath: string 154 | ): Promise { 155 | return new Promise((resolve) => { 156 | fsRename(sourcePath, destinationPath, (error) => { 157 | resolve( 158 | error === null 159 | ? undefined 160 | : createUIError( 161 | `Could not move ${sourcePath} to ${destinationPath}: ${formatErrnoException( 162 | error 163 | )}` 164 | ) 165 | ); 166 | }); 167 | }); 168 | } 169 | 170 | export function remove(path: string): Promise { 171 | return new Promise((resolve) => { 172 | unlink(path, (error) => { 173 | resolve( 174 | error === null ? undefined : createUIError(formatErrnoException(error)) 175 | ); 176 | }); 177 | }); 178 | } 179 | 180 | export function mkdir( 181 | path: string, 182 | recursive = false, 183 | mode?: string | number 184 | ): Promise { 185 | return new Promise((resolve) => { 186 | fsMkdir( 187 | path, 188 | { 189 | recursive, 190 | mode, 191 | }, 192 | (error) => { 193 | resolve( 194 | error === null 195 | ? undefined 196 | : createUIError( 197 | `Could not create directory ${path}: ${formatErrnoException( 198 | error 199 | )}` 200 | ) 201 | ); 202 | } 203 | ); 204 | }); 205 | } 206 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | # VS Code as Git Mergetool 5 | 6 | [![Visual Studio Marketplace installs](https://img.shields.io/visual-studio-marketplace/i/zawys.vscode-as-git-mergetool) 7 | ![Visual Studio Marketplace downloads](https://img.shields.io/visual-studio-marketplace/d/zawys.vscode-as-git-mergetool) 8 | ![Visual Studio Marketplace Rating](https://img.shields.io/visual-studio-marketplace/r/zawys.vscode-as-git-mergetool)](https://marketplace.visualstudio.com/items?itemName=zawys.vscode-as-git-mergetool&ssr=false#review-details) 9 | [![GitHub stars](https://img.shields.io/github/stars/zawys/vscode-as-git-mergetool) 10 | ![GitHub watchers](https://img.shields.io/github/watchers/zawys/vscode-as-git-mergetool) 11 | ![GitHub commit activity](https://img.shields.io/github/commit-activity/m/zawys/vscode-as-git-mergetool) 12 | ](https://github.com/zawys/vscode-as-git-mergetool) 13 | [![Liberapay patron count](https://img.shields.io/liberapay/patrons/zawys.svg?logo=liberapay&style=so)](https://liberapay.com/on/github/zawys/) 14 | 15 |
16 | 17 | This extension provides diff editor layouts and more 18 | for common-base merges (aka. “3-way merges”) directly in VS Code. 19 | 20 | ![Four pane merge](media/four%20pane%20merge.png) 21 | 22 | [Demo screencast](media/demo.mp4) 23 | 24 | ## Features 25 | 26 | - Assists in setting up suitable Git and VS Code configuration options, 27 | which allows that VS Code is invoked 28 | when an external `git mergetool` is executed. 29 | - Shows a 3- or 4-pane diff layout when VS Code opens a merge situation. 30 | These layouts can be switched during merging. 31 | There is an additional mechanism called “zooming”, 32 | allowing to quickly change layout proportions using keyboard shortcuts. 33 | - Synchronizes the scroll and cursor position of the editors 34 | according to a text diff. 35 | - Provides commands for launching/continuing/stopping `git mergetool`, 36 | as well as a super command guiding through the whole merge process 37 | by invoking other commands as appropriate (by default `shift+alt+m` `m`). 38 | - Adds key bindings for commands most useful during merge (`shift+alt+m` …). 39 | See the contributions tab in VS Code or 40 | the `keybindings` section in [`package.json`](package.json). 41 | - Optionally opens the Git commit message in an editor 42 | after a successful `git mergetool` execution 43 | (as a workaround for few Git extension bugs). 44 | - Allows to select arbitrary files for merging, 45 | invoking `git merge-files` and the diff layout. 46 | - Provides a command for `git merge --abort` and `git merge --quit`. 47 | 48 | At time of release this has been tested only on my Linux machine, 49 | so especially Windows and MacOS users are welcomed 50 | to report any compatibility issues. See [Contribute](#Contribute). 51 | 52 | ## Installation 53 | 54 | This is extension is available 55 | [in the official Marketplace](https://marketplace.visualstudio.com/items?itemName=zawys.vscode-as-git-mergetool). 56 | 57 | > Launch VS Code Quick Open (Ctrl+P), paste the following command, and press enter. 58 | > 59 | > `ext install zawys.vscode-as-git-mergetool` 60 | 61 | Alternatively, you can get the build from GitHub: 62 | 63 | - Go to the 64 | [latest Release](https://github.com/zawys/vscode-as-git-mergetool/releases/latest) 65 | and download the VSIX. 66 | - Skip this if you do not want to verify the signature: 67 | - Download the other files into the same directory. 68 | - `sha256sum -c SHA256SUMS` 69 | - `gpg --recv-keys '4A5D 4A5F B953 7A3A B931 6463 41B3 FBF3 7F23 3754'` 70 | - `gpg --verify SHA256SUMS.sig SHA256SUMS` 71 | - Run the command “Install from VSIX…” inside VS Code and select the VSIX. 72 | 73 | ## Usage 74 | 75 | When you have a merge conflict in an opened repo, 76 | you can run the command “Start `git mergetool`” from the command palette 77 | or the command menu in the SCM panel. 78 | Then the layout should change and new buttons in the status bar should appear. 79 | 80 | When you start `git mergetool` from the command line, 81 | that process is not controlled by the extension 82 | but still a diff layout in VS Code should open. 83 | 84 | ## Layout 85 | 86 | The default layout `4TransferRight` has 4 panes 87 | showing the changes distributed over two dimensions: 88 | 89 | - Top vs. bottom panes: local vs. remote changes 90 | - Left vs. right panes: changes applied starting from base vs. ending in merged 91 | 92 | In the right panes you can edit the (same) merged file 93 | and the goal of the game is to make the right side 94 | semantically equal the left side. 😅 95 | 96 | There are also several other layouts available. 97 | 98 | ## Known issues 99 | 100 | - When you have an file with conflicts opened, 101 | start a diff layout for that file and stop the diff layout, 102 | then it may happen that the originally opened editor is closed 103 | and a diff editor remains instead. 104 | This is due to a limitation of VS Code that editors seem to be recreated 105 | when previously covered by other editors and 106 | then there is no reliable way to find out who the editor belongs to. 107 | 108 | **TL;DR**: Use the command “Deactivate diff layout”, 109 | “Stop mergetool” or `Ctrl+W` 110 | to stop the diff editor layout. 111 | Auto save is useful, too. 112 | 113 | ## Contribute 114 | 115 | Feel free to file feature requests and bug reports 116 | [on GitHub](https://github.com/zawys/vscode-as-git-mergetool/issues). 117 | 118 | You can help making new features for this extension possible 119 | by adding your 👍 to following issues 120 | ([info](https://github.com/microsoft/vscode/wiki/Issues-Triaging#up-voting-a-feature-request)). 121 | But DO NOT post comments there 122 | which provide no additional information or ideas. 123 | 124 | - [#105487: Invoke initial command via process arguments](https://github.com/microsoft/vscode/issues/105487): 125 | 126 | For using the mergetool functionality 127 | via the command line independent from Git’s file naming convention. 128 | 129 | - [#105625: API for reacting to and setting horizontal scroll position](https://github.com/microsoft/vscode/issues/105625): 130 | 131 | For synchronized horizontal scrolling. 132 | 133 | ## Build 134 | 135 | 1. Ensure you have a [stable version of `node`](https://nodejs.org/en/) 136 | 2. [Install Yarn globally](https://classic.yarnpkg.com/en/docs/install) 137 | 3. `yarn` 138 | 4. `yarn run build` 139 | 140 | The generated VSIX should then be in `packages/`. 141 | 142 | ### Development environment setup 143 | 144 | Run the steps listed in [section Build](#Build). 145 | 146 | Additionally, see the 147 | [VSC Extension Quickstart](vsc-extension-quickstart.md). 148 | 149 | You probably also want to install 150 | [VS Code Insiders](https://code.visualstudio.com/insiders/) to run the tests, 151 | see [reason](https://code.visualstudio.com/api/working-with-extensions/testing-extension#using-insiders-version-for-extension-development). 152 | 153 | ## Further info 154 | 155 | - [Change log](CHANGELOG.md) 156 | -------------------------------------------------------------------------------- /src/extension.ts: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 zawys. Licensed under AGPL-3.0-or-later. 2 | // See LICENSE file in repository root directory. 3 | 4 | import "regenerator-runtime"; 5 | import { ExtensionContext } from "vscode"; 6 | import { ArbitraryFilesMerger } from "./arbitraryFilesMerger"; 7 | import { DiffLayouterManager } from "./diffLayouterManager"; 8 | import { DocumentProviderManager } from "./documentProviderManager"; 9 | import { EditorOpenManager } from "./editorOpenManager"; 10 | import { 11 | createRegisteredDocumentProviderManager, 12 | RegisteredDocumentContentProvider, 13 | } from "./registeredDocumentContentProvider"; 14 | import { GitMergetoolReplacement } from "./gitMergetoolReplacement"; 15 | import { 16 | createReadonlyDocumentProviderManager, 17 | ReadonlyDocumentProvider, 18 | } from "./readonlyDocumentProvider"; 19 | import { RegisterableService } from "./registerableService"; 20 | import { 21 | OptionChangeProtocolExporter, 22 | SettingsAssistantLauncher, 23 | } from "./settingsAssistant"; 24 | import { GitMergetoolTemporaryFileOpenHandler } from "./gitMergetoolTemporaryFileOpenHandler"; 25 | import { TemporarySettingsManager } from "./temporarySettingsManager"; 26 | import { VSCodeConfigurator } from "./vSCodeConfigurator"; 27 | import { ZoomManager } from "./zoom"; 28 | import { setGracefulCleanup } from "tmp"; 29 | import { CommonMergeCommandsManager } from "./commonMergeCommandsManager"; 30 | import { ManualMergeProcess } from "./manualMergeProcess"; 31 | import { MergeAborter } from "./mergeAborter"; 32 | 33 | let extensionAPI: ExtensionAPI | undefined; 34 | 35 | export async function activate( 36 | context: ExtensionContext 37 | ): Promise { 38 | extensionAPI = new ExtensionAPI( 39 | ...new ExtensionServicesCreator().createServices(context) 40 | ); 41 | await extensionAPI.register(); 42 | return extensionAPI; 43 | } 44 | 45 | // this method is called when your extension is deactivated 46 | export function deactivate(): void { 47 | extensionAPI?.dispose(); 48 | extensionAPI = undefined; 49 | } 50 | 51 | export class ExtensionAPI implements RegisterableService { 52 | public async register(): Promise { 53 | setGracefulCleanup(); 54 | 55 | // inverse order as below 56 | for (const service of this.registrationOrder) { 57 | await service.register(); 58 | } 59 | } 60 | 61 | public dispose(): void { 62 | // inverse order as above 63 | for (let index = this.registrationOrder.length - 1; index >= 0; index--) { 64 | const service = this.registrationOrder[index]; 65 | service.dispose(); 66 | } 67 | } 68 | 69 | public constructor( 70 | public readonly services: Readonly, 71 | public readonly registrationOrder: Readonly 72 | ) {} 73 | } 74 | 75 | class ExtensionServicesCreator { 76 | public createServices( 77 | context: ExtensionContext, 78 | services: Readonly> = {} 79 | ): [ExtensionServices, RegisterableService[]] { 80 | const registrationOrder: RegisterableService[] = []; 81 | 82 | const vSCodeConfigurator = 83 | services?.vSCodeConfigurator ?? new VSCodeConfigurator(); 84 | 85 | const readonlyDocumentProviderManager = 86 | services.readonlyDocumentProviderManager ?? 87 | createReadonlyDocumentProviderManager(); 88 | registrationOrder.push(readonlyDocumentProviderManager); 89 | const readonlyDocumentProvider = 90 | readonlyDocumentProviderManager.documentProvider; 91 | 92 | const registeredDocumentProviderManager = 93 | services.registeredDocumentProviderManager ?? 94 | createRegisteredDocumentProviderManager(); 95 | registrationOrder.push(registeredDocumentProviderManager); 96 | const registeredDocumentProvider = 97 | registeredDocumentProviderManager.documentProvider; 98 | 99 | const zoomManager = services.zoomManager ?? new ZoomManager(); 100 | registrationOrder.push(zoomManager); 101 | 102 | const temporarySettingsManager = 103 | services.temporarySettingsManager ?? 104 | new TemporarySettingsManager(vSCodeConfigurator, context.globalState); 105 | registrationOrder.push(temporarySettingsManager); 106 | 107 | const diffLayouterManager = 108 | services.diffLayouterManager ?? 109 | new DiffLayouterManager( 110 | vSCodeConfigurator, 111 | zoomManager, 112 | temporarySettingsManager 113 | ); 114 | registrationOrder.push(diffLayouterManager); 115 | 116 | const commonMergeCommandsManager = new CommonMergeCommandsManager(); 117 | registrationOrder.push(commonMergeCommandsManager); 118 | 119 | const manualMergeProcess = new ManualMergeProcess(diffLayouterManager); 120 | 121 | const gitMergetoolReplacement = 122 | services.gitMergetoolReplacement ?? 123 | new GitMergetoolReplacement( 124 | registeredDocumentProvider, 125 | readonlyDocumentProvider, 126 | commonMergeCommandsManager, 127 | manualMergeProcess, 128 | diffLayouterManager 129 | ); 130 | registrationOrder.push(gitMergetoolReplacement); 131 | 132 | const temporaryFileOpenManager = 133 | services.temporaryFileOpenManager ?? 134 | new GitMergetoolTemporaryFileOpenHandler( 135 | diffLayouterManager, 136 | readonlyDocumentProvider 137 | ); 138 | 139 | const editorOpenManager = 140 | services.editorOpenManager ?? 141 | new EditorOpenManager([ 142 | { 143 | handler: gitMergetoolReplacement, 144 | name: "gitMergetoolReplacement", 145 | }, 146 | { 147 | handler: temporaryFileOpenManager, 148 | name: "temporaryFileOpenManager", 149 | }, 150 | ]); 151 | registrationOrder.push(editorOpenManager); 152 | 153 | const arbitraryFilesMerger = 154 | services.arbitraryFilesMerger ?? 155 | new ArbitraryFilesMerger( 156 | diffLayouterManager, 157 | readonlyDocumentProvider, 158 | context.workspaceState 159 | ); 160 | registrationOrder.push(arbitraryFilesMerger); 161 | 162 | const optionChangeProtocolExporter = 163 | services.optionChangeProtocolExporter ?? 164 | new OptionChangeProtocolExporter(); 165 | 166 | const settingsAssistantLauncher = 167 | services.settingsAssistantLauncher ?? 168 | new SettingsAssistantLauncher( 169 | vSCodeConfigurator, 170 | optionChangeProtocolExporter 171 | ); 172 | registrationOrder.push(settingsAssistantLauncher); 173 | 174 | const mergeAborter = services.mergeAborter ?? new MergeAborter(); 175 | registrationOrder.push(mergeAborter); 176 | 177 | return [ 178 | { 179 | arbitraryFilesMerger, 180 | diffLayouterManager, 181 | gitMergetoolReplacement, 182 | readonlyDocumentProviderManager, 183 | registeredDocumentProviderManager, 184 | settingsAssistantLauncher, 185 | optionChangeProtocolExporter, 186 | temporarySettingsManager, 187 | vSCodeConfigurator, 188 | zoomManager, 189 | temporaryFileOpenManager, 190 | editorOpenManager, 191 | commonMergeCommandsManager, 192 | manualMergeProcess, 193 | mergeAborter, 194 | }, 195 | registrationOrder, 196 | ]; 197 | } 198 | } 199 | 200 | export interface ExtensionServices { 201 | vSCodeConfigurator: VSCodeConfigurator; 202 | readonlyDocumentProviderManager: DocumentProviderManager; 203 | registeredDocumentProviderManager: DocumentProviderManager; 204 | zoomManager: ZoomManager; 205 | temporarySettingsManager: TemporarySettingsManager; 206 | gitMergetoolReplacement: GitMergetoolReplacement; 207 | diffLayouterManager: DiffLayouterManager; 208 | arbitraryFilesMerger: ArbitraryFilesMerger; 209 | settingsAssistantLauncher: SettingsAssistantLauncher; 210 | optionChangeProtocolExporter: OptionChangeProtocolExporter; 211 | temporaryFileOpenManager: GitMergetoolTemporaryFileOpenHandler; 212 | editorOpenManager: EditorOpenManager; 213 | commonMergeCommandsManager: CommonMergeCommandsManager; 214 | manualMergeProcess: ManualMergeProcess; 215 | mergeAborter: MergeAborter; 216 | } 217 | -------------------------------------------------------------------------------- /media/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 19 | 21 | 41 | 48 | 49 | 51 | 52 | 54 | image/svg+xml 55 | 57 | 58 | 60 | 61 | 62 | 63 | 67 | 74 | 81 | 88 | 95 | 102 | 109 | 116 | 123 | 130 | 137 | 144 | 151 | 158 | 165 | 172 | 179 | 183 | 190 | 197 | 204 | 209 | 210 | 211 | -------------------------------------------------------------------------------- /src/@types/git.d.ts: -------------------------------------------------------------------------------- 1 | // This file is copied out of 2 | // vscode/extensions/git/src/api/git.d.ts 3 | /*--------------------------------------------------------------------------------------------- 4 | * Copyright (c) Microsoft Corporation. All rights reserved. 5 | * Licensed under the MIT License. See License.txt in the project root for license information. 6 | *--------------------------------------------------------------------------------------------*/ 7 | 8 | import { Uri, Event, Disposable, ProviderResult } from 'vscode'; 9 | export { ProviderResult } from 'vscode'; 10 | 11 | export interface Git { 12 | readonly path: string; 13 | } 14 | 15 | export interface InputBox { 16 | value: string; 17 | } 18 | 19 | export const enum RefType { 20 | Head, 21 | RemoteHead, 22 | Tag 23 | } 24 | 25 | export interface Ref { 26 | readonly type: RefType; 27 | readonly name?: string; 28 | readonly commit?: string; 29 | readonly remote?: string; 30 | } 31 | 32 | export interface UpstreamRef { 33 | readonly remote: string; 34 | readonly name: string; 35 | } 36 | 37 | export interface Branch extends Ref { 38 | readonly upstream?: UpstreamRef; 39 | readonly ahead?: number; 40 | readonly behind?: number; 41 | } 42 | 43 | export interface Commit { 44 | readonly hash: string; 45 | readonly message: string; 46 | readonly parents: string[]; 47 | readonly authorDate?: Date; 48 | readonly authorName?: string; 49 | readonly authorEmail?: string; 50 | readonly commitDate?: Date; 51 | } 52 | 53 | export interface Submodule { 54 | readonly name: string; 55 | readonly path: string; 56 | readonly url: string; 57 | } 58 | 59 | export interface Remote { 60 | readonly name: string; 61 | readonly fetchUrl?: string; 62 | readonly pushUrl?: string; 63 | readonly isReadOnly: boolean; 64 | } 65 | 66 | export const enum Status { 67 | INDEX_MODIFIED, 68 | INDEX_ADDED, 69 | INDEX_DELETED, 70 | INDEX_RENAMED, 71 | INDEX_COPIED, 72 | 73 | MODIFIED, 74 | DELETED, 75 | UNTRACKED, 76 | IGNORED, 77 | INTENT_TO_ADD, 78 | 79 | ADDED_BY_US, 80 | ADDED_BY_THEM, 81 | DELETED_BY_US, 82 | DELETED_BY_THEM, 83 | BOTH_ADDED, 84 | BOTH_DELETED, 85 | BOTH_MODIFIED 86 | } 87 | 88 | export interface Change { 89 | 90 | /** 91 | * Returns either `originalUri` or `renameUri`, depending 92 | * on whether this change is a rename change. When 93 | * in doubt always use `uri` over the other two alternatives. 94 | */ 95 | readonly uri: Uri; 96 | readonly originalUri: Uri; 97 | readonly renameUri: Uri | undefined; 98 | readonly status: Status; 99 | } 100 | 101 | export interface RepositoryState { 102 | readonly HEAD: Branch | undefined; 103 | readonly refs: Ref[]; 104 | readonly remotes: Remote[]; 105 | readonly submodules: Submodule[]; 106 | readonly rebaseCommit: Commit | undefined; 107 | 108 | readonly mergeChanges: Change[]; 109 | readonly indexChanges: Change[]; 110 | readonly workingTreeChanges: Change[]; 111 | 112 | readonly onDidChange: Event; 113 | } 114 | 115 | export interface RepositoryUIState { 116 | readonly selected: boolean; 117 | readonly onDidChange: Event; 118 | } 119 | 120 | /** 121 | * Log options. 122 | */ 123 | export interface LogOptions { 124 | /** Max number of log entries to retrieve. If not specified, the default is 32. */ 125 | readonly maxEntries?: number; 126 | readonly path?: string; 127 | } 128 | 129 | export interface CommitOptions { 130 | all?: boolean | 'tracked'; 131 | amend?: boolean; 132 | signoff?: boolean; 133 | signCommit?: boolean; 134 | empty?: boolean; 135 | } 136 | 137 | export interface BranchQuery { 138 | readonly remote?: boolean; 139 | readonly pattern?: string; 140 | readonly count?: number; 141 | readonly contains?: string; 142 | } 143 | 144 | export interface Repository { 145 | 146 | readonly rootUri: Uri; 147 | readonly inputBox: InputBox; 148 | readonly state: RepositoryState; 149 | readonly ui: RepositoryUIState; 150 | 151 | getConfigs(): Promise<{ key: string; value: string; }[]>; 152 | getConfig(key: string): Promise; 153 | setConfig(key: string, value: string): Promise; 154 | getGlobalConfig(key: string): Promise; 155 | 156 | getObjectDetails(treeish: string, path: string): Promise<{ mode: string, object: string, size: number }>; 157 | detectObjectType(object: string): Promise<{ mimetype: string, encoding?: string }>; 158 | buffer(ref: string, path: string): Promise; 159 | show(ref: string, path: string): Promise; 160 | getCommit(ref: string): Promise; 161 | 162 | clean(paths: string[]): Promise; 163 | 164 | apply(patch: string, reverse?: boolean): Promise; 165 | diff(cached?: boolean): Promise; 166 | diffWithHEAD(): Promise; 167 | diffWithHEAD(path: string): Promise; 168 | diffWith(ref: string): Promise; 169 | diffWith(ref: string, path: string): Promise; 170 | diffIndexWithHEAD(): Promise; 171 | diffIndexWithHEAD(path: string): Promise; 172 | diffIndexWith(ref: string): Promise; 173 | diffIndexWith(ref: string, path: string): Promise; 174 | diffBlobs(object1: string, object2: string): Promise; 175 | diffBetween(ref1: string, ref2: string): Promise; 176 | diffBetween(ref1: string, ref2: string, path: string): Promise; 177 | 178 | hashObject(data: string): Promise; 179 | 180 | createBranch(name: string, checkout: boolean, ref?: string): Promise; 181 | deleteBranch(name: string, force?: boolean): Promise; 182 | getBranch(name: string): Promise; 183 | getBranches(query: BranchQuery): Promise; 184 | setBranchUpstream(name: string, upstream: string): Promise; 185 | 186 | getMergeBase(ref1: string, ref2: string): Promise; 187 | 188 | status(): Promise; 189 | checkout(treeish: string): Promise; 190 | 191 | addRemote(name: string, url: string): Promise; 192 | removeRemote(name: string): Promise; 193 | renameRemote(name: string, newName: string): Promise; 194 | 195 | fetch(remote?: string, ref?: string, depth?: number): Promise; 196 | pull(unshallow?: boolean): Promise; 197 | push(remoteName?: string, branchName?: string, setUpstream?: boolean): Promise; 198 | 199 | blame(path: string): Promise; 200 | log(options?: LogOptions): Promise; 201 | 202 | commit(message: string, opts?: CommitOptions): Promise; 203 | } 204 | 205 | export interface RemoteSource { 206 | readonly name: string; 207 | readonly description?: string; 208 | readonly url: string | string[]; 209 | } 210 | 211 | export interface RemoteSourceProvider { 212 | readonly name: string; 213 | readonly icon?: string; // codicon name 214 | readonly supportsQuery?: boolean; 215 | getRemoteSources(query?: string): ProviderResult; 216 | publishRepository?(repository: Repository): Promise; 217 | } 218 | 219 | export interface Credentials { 220 | readonly username: string; 221 | readonly password: string; 222 | } 223 | 224 | export interface CredentialsProvider { 225 | getCredentials(host: Uri): ProviderResult; 226 | } 227 | 228 | export interface PushErrorHandler { 229 | handlePushError(repository: Repository, remote: Remote, refspec: string, error: Error & { gitErrorCode: GitErrorCodes }): Promise; 230 | } 231 | 232 | export type APIState = 'uninitialized' | 'initialized'; 233 | 234 | export interface API { 235 | readonly state: APIState; 236 | readonly onDidChangeState: Event; 237 | readonly git: Git; 238 | readonly repositories: Repository[]; 239 | readonly onDidOpenRepository: Event; 240 | readonly onDidCloseRepository: Event; 241 | 242 | toGitUri(uri: Uri, ref: string): Uri; 243 | getRepository(uri: Uri): Repository | null; 244 | init(root: Uri): Promise; 245 | 246 | registerRemoteSourceProvider(provider: RemoteSourceProvider): Disposable; 247 | registerCredentialsProvider(provider: CredentialsProvider): Disposable; 248 | registerPushErrorHandler(handler: PushErrorHandler): Disposable; 249 | } 250 | 251 | export interface GitExtension { 252 | 253 | readonly enabled: boolean; 254 | readonly onDidChangeEnablement: Event; 255 | 256 | /** 257 | * Returns a specific API version. 258 | * 259 | * Throws error if git extension is disabled. You can listed to the 260 | * [GitExtension.onDidChangeEnablement](#GitExtension.onDidChangeEnablement) event 261 | * to know when the extension becomes enabled/disabled. 262 | * 263 | * @param version Version number. 264 | * @returns API instance 265 | */ 266 | getAPI(version: 1): API; 267 | } 268 | 269 | export const enum GitErrorCodes { 270 | BadConfigFile = 'BadConfigFile', 271 | AuthenticationFailed = 'AuthenticationFailed', 272 | NoUserNameConfigured = 'NoUserNameConfigured', 273 | NoUserEmailConfigured = 'NoUserEmailConfigured', 274 | NoRemoteRepositorySpecified = 'NoRemoteRepositorySpecified', 275 | NotAGitRepository = 'NotAGitRepository', 276 | NotAtRepositoryRoot = 'NotAtRepositoryRoot', 277 | Conflict = 'Conflict', 278 | StashConflict = 'StashConflict', 279 | UnmergedChanges = 'UnmergedChanges', 280 | PushRejected = 'PushRejected', 281 | RemoteConnectionError = 'RemoteConnectionError', 282 | DirtyWorkTree = 'DirtyWorkTree', 283 | CantOpenResource = 'CantOpenResource', 284 | GitNotFound = 'GitNotFound', 285 | CantCreatePipe = 'CantCreatePipe', 286 | PermissionDenied = 'PermissionDenied', 287 | CantAccessRemote = 'CantAccessRemote', 288 | RepositoryNotFound = 'RepositoryNotFound', 289 | RepositoryIsLocked = 'RepositoryIsLocked', 290 | BranchNotFullyMerged = 'BranchNotFullyMerged', 291 | NoRemoteReference = 'NoRemoteReference', 292 | InvalidBranchName = 'InvalidBranchName', 293 | BranchAlreadyExists = 'BranchAlreadyExists', 294 | NoLocalChanges = 'NoLocalChanges', 295 | NoStashFound = 'NoStashFound', 296 | LocalChangesOverwritten = 'LocalChangesOverwritten', 297 | NoUpstreamBranch = 'NoUpstreamBranch', 298 | IsInSubmodule = 'IsInSubmodule', 299 | WrongCase = 'WrongCase', 300 | CantLockRef = 'CantLockRef', 301 | CantRebaseMultipleBranches = 'CantRebaseMultipleBranches', 302 | PatchDoesNotApply = 'PatchDoesNotApply', 303 | NoPathFound = 'NoPathFound', 304 | UnknownPath = 'UnknownPath', 305 | } 306 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to the "VS Code as Git Mergetool" 4 | extension will be documented in this file. 5 | 6 | The format is based on 7 | [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 8 | and this project adheres to 9 | [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 10 | 11 | ## [Unreleased] 12 | 13 | ### Added 14 | 15 | - Open the diff layout when opening a file containing conflicts 16 | being an alternative to the management through a `git mergetool` process 17 | - Running the command “Git: Abort merge” now shows an error message 18 | when no merge is in progress. 19 | 20 | ### Changed 21 | 22 | - Removed the spawning and controlling of `git mergetool` processes. 23 | The functionality of `git mergetool` 24 | is now contained in the extension’s own code 25 | which avoids the constant fragility of the integration. 26 | - Tweaked the scrolling synchronizer so that one parameter less is required. 27 | That makes the scrolling synchronization slightly non-deterministic, 28 | but that should hopefully be not noticeable. 29 | It should be slightly more stable now. 30 | - Switched to Webpack as bundler 31 | 32 | ## [0.14.0] - 2021-04-07 33 | 34 | ### Added 35 | 36 | - Command to run the initial settings assistant on demand. 37 | 38 | ### Changed 39 | 40 | - Initial settings assistant 41 | - Fixes. It now aggregates the decisions and allows to apply them at the end. 42 | - The settings assistant startup is now rather configured per repository. 43 | - Malfunctioning creation of backup setting entries 44 | was replaced by creating a log file of changes made. 45 | - The messages were improved. 46 | - Switched to Webpack as bundler 47 | 48 | ### Fixed 49 | 50 | - Wrong file paths were opened when `git mergetool` was launched 51 | by the extension. 52 | 53 | ## [0.13.3] - 2021-02-21 54 | 55 | ## Fixed 56 | 57 | - Fixed ascertaining of the git path. 58 | The bug was introduced in 0.13.1 by a too aggressive search-and-replace. 59 | 60 | ## [0.13.2] - 2021-02-21 61 | 62 | ## Fixed 63 | 64 | - Unfortunately, working around the bug mentioned in section 0.13.1 65 | unveiled that the parcel bundler seems to contain more bugs. 66 | Thus, for a quick&dirty fix, this release is the previous release 67 | with almost all dependencies as in version 0.11.0. 68 | - Fixed command IDs. 69 | The bug was introduced in 0.13.1 by a too aggressive search-and-replace. 70 | 71 | ## [0.13.1] - 2021-02-21 72 | 73 | ### Fixed 74 | 75 | - The extension went into an exception at runtime due to a 76 | [bug in the Parcel bundler](https://github.com/parcel-bundler/parcel/issues/5862). 77 | This prevented the extension from loading entirely. 78 | I did not notice this until I tried to use my extension myself 79 | because running the extension from the repository worked without flaws. 80 | So if you were disappointed, please consider creating a bug report next time. 81 | 82 | ### Internal 83 | 84 | - Upgrade Husky 85 | 86 | ## [0.13.0] - 2021-02-06 87 | 88 | ### Added 89 | 90 | - Save initially changed configuration values into backup entries 91 | 92 | ## [0.12.0] - 2021-02-06 93 | 94 | ### Fixed 95 | 96 | - Texts of notifications from initial settings assistant 97 | 98 | ## [0.11.0] - 2020-12-19 99 | 100 | ### Added 101 | 102 | - Layout `3DiffToMerged` diffing current, base, and remote to the merged version. 103 | This seems to be the best solution for merges 104 | where the changes in both versions are similar, 105 | e.g. for merging already partially cherry-picked branches 106 | or half-applied stashes. 107 | 108 | ### Changed 109 | 110 | - Minor: Diff editor titles have been unified to always contain 111 | the name of the previous version of the comparison. 112 | 113 | ## [0.10.0] - 2020-09-09 114 | 115 | ### Changed 116 | 117 | - The "next merge step" command introduced in [v0.4.0](#040---2020-08-30) 118 | is now assigned to the check mark button in the status bar. 119 | 120 | ## [0.9.0] - 2020-09-05 121 | 122 | ### Added 123 | 124 | - Status bar items for zooming 125 | - Make diffed files except merge result readonly. 126 | 127 | ## [0.8.0] - 2020-09-05 128 | 129 | ### Note 130 | 131 | I have rebuilt and re-signed the packages of versions v0.6.0–v0.7.2 132 | as in these packages were files which I did not want to publish. 133 | As it is 134 | [impossible to unpublish a specific version](https://github.com/microsoft/vscode-vsce/issues/176) 135 | on the Marketplace, 136 | I needed to temporarily completely purge the project from the Marketplace. 137 | If—against my hope—that should have caused any trouble, please file an issue. 138 | 139 | ### Added 140 | 141 | - *Zoom*: Quickly change the layout proportions using keyboard shortcuts: 142 | `shift+alt+m ↑`, `shift+alt+m ↓`, etc.; `shift+alt+m backspace` to reset. 143 | 144 | ## [0.7.3] - 2020-09-05 145 | 146 | ### Changed 147 | 148 | - Bundle extension with [Parcel](https://v2.parceljs.org/) 149 | - Include only necessary files in the package 150 | 151 | ## [0.7.2] - 2020-09-04 152 | 153 | ### Added 154 | 155 | - Command “Switch diff layout temporarily…”; also available on the status bar. 156 | Reapplied from v0.7.0 with fixes. 157 | 158 | ### Fixed 159 | 160 | - Diff editor layouts were being reopened on closing them 161 | when some extensions were enabled which caused additional “open” events. 162 | - Prevent a seeming deadlock (“…other operation pending…”) 163 | by making the dialog asking for reset confirmation modal. 164 | 165 | ## [0.7.1] - 2020-09-04 166 | 167 | ### Fixed 168 | 169 | - Reverted addition of command “Switch diff layout temporarily…” 170 | 171 | ## [0.7.0] - 2020-09-04 172 | 173 | ### Added 174 | 175 | - Layout `3DiffToBaseMergedRight` with the merged file editor 176 | taking up the whole right half. 177 | - Command “Switch diff layout temporarily…”; also available on the status bar 178 | - Synchronization of the cursor position 179 | - Command “Reset temporary settings activated on diff layout start” 180 | in case one has two VS Code instances open, 181 | activates a diff layout in one instance and simply closes it afterwards. 182 | 183 | ### Changed 184 | 185 | - Merge the best of the scroll synchronization methods 186 | `centered` and `interval` into one, replacing them. 187 | 188 | ### Fixed 189 | 190 | - Do not create a backup on close when the merged file is unchanged. 191 | 192 | ## [0.6.0] - 2020-09-03 193 | 194 | This will be the first version published on the 195 | [Marketplace](https://marketplace.visualstudio.com/items?itemName=zawys.vscode-as-git-mergetool). 196 | 197 | ### Added 198 | 199 | - Layout `3DiffToBaseRows`, which is useful for long lines. 200 | - Show message about `git commit` result. 201 | 202 | ### Changed 203 | 204 | - Use node-pty to host `git mergetool` more reliably. 205 | 206 | ## [0.5.0] - 2020-09-01 207 | 208 | ### Added 209 | 210 | - Asking for confirmation and creating a backup when skipping a file, 211 | as `git mergetool` automatically resets any changes made to the merged file 212 | when telling `git mergetool` that the merge was not successful. 213 | - Also creating a backup when VS Code is closed 214 | while `git mergetool` is running. 215 | - Showing by which number an editor is reachable in the title 216 | (reachable using `ctrl+`) 217 | - When `git mergetool` does not seem to respond, show the terminal. 218 | That way the other merge conflict types 219 | (symbolic links, directories and submodules) 220 | can be (with some care) managed. 221 | - Prevent “Terminal process exited …” message from VS Code 222 | 223 | ### Changed 224 | 225 | - Shorter editor titles 226 | 227 | ### Fixes 228 | 229 | - Reopening the layout by using the start command failed 230 | - Various issues with the process management 231 | 232 | ## [0.4.0] - 2020-08-30 233 | 234 | ### Added 235 | 236 | - Keybindings: Most functionality is now available as keyboard shortcuts 237 | by first typing `shift+alt+m` and then a single letter. 238 | - Powerful new command: “Start mergetool, go to next conflict, or do commit”. 239 | This combines several commands of the extension into one 240 | and guides through the whole merge process. 241 | - Terminal process detached from renderer process, 242 | allowing to cleanly shut down `git mergetool` when VS Code is closed, 243 | deleting the temporary merge conflict files in the process. 244 | 245 | ### Changed 246 | 247 | - Slight optimization of the speed of the diff layout stop 248 | 249 | ### Fixed 250 | 251 | - Large reworking of the mergetool process management, fixing several bugs 252 | 253 | ## [0.3.0] - 2020-08-30 254 | 255 | ### Changed 256 | 257 | - Scroll synchronization method by default is now the new “interval” method. 258 | The ID and meaning of the setting have changed 259 | and so the setting will be reset to the default. 260 | 261 | ### Added 262 | 263 | - Scroll synchronization method “interval” 264 | - Reset settings directly on VS Code startup 265 | - Also disable glyph margin and activity bar during diff layout 266 | - Merge arbitrary files: Allow to select an existing file for merge 267 | - Message when no merge conflicts exist on opening a diff layout 268 | 269 | ### Fixed 270 | 271 | - Change settings synchronously to prevent confused VS Code and congestion 272 | - Several small bugs discovered thanks to stricter linting 273 | 274 | ## [0.2.2] - 2020-08-29 275 | 276 | ### Fixed 277 | 278 | - Use the global storage instead of the workspace storage 279 | for restoring settings 280 | - Wrong setting type declared for `scrollingSynchronizedAt` 281 | 282 | ## [0.2.1] - 2020-08-29 283 | 284 | ### Added 285 | 286 | - Disable tabs during editor layout 287 | 288 | ### Fixed 289 | 290 | - Automatic upload of releases 291 | 292 | ## [0.2.0] - 2020-08-28 293 | 294 | ### Added 295 | 296 | - Command "Merge arbitrary files" showing a quick pick for file selection. 297 | This is available in the SCM panel title menu. 298 | - Restoring of previous “line numbers” and “inline diff” settings 299 | even if VS Code was closed abruptly 300 | - Option to synchronize the scroll position vertically at the center 301 | - Short explanation for installation and usage in README.md 302 | - More automated release process 303 | 304 | ### Changed 305 | 306 | - Change temporary settings globally, not in workspace 307 | - `yarn run package` → `yarn run build` 308 | 309 | ### Fixed 310 | 311 | - Check for MERGE_MSG existence before opening it 312 | - Spelling 313 | - Null reference exception in DiffLineMapper 314 | 315 | ## [0.1.0] - 2020-08-27 316 | 317 | ### Added 318 | 319 | - Reset merge file command is now always available 320 | when a diff layout is opened. 321 | - Warn when closing a diff layout with a file containing conflict markers 322 | opened from outside of VS Code. 323 | 324 | ### Fixed 325 | 326 | - Fix crash when starting a merge layout without opened workspace. 327 | - More aggressively match editors when deactivating a diff layout. 328 | Recommended setting: auto save on. 329 | - Use default foreground color for status bar items. 330 | - Use colorless icon for skip command status bar item. 331 | 332 | ### Changed 333 | 334 | - Use current Code executable path for Git config `mergetool.code.cmd` 335 | - Some error messages were improved. 336 | 337 | ## [0.0.1] - 2020-08-26 338 | 339 | ### Added 340 | 341 | - Layouts: 3DiffToBase, 4TransferRight, 4TransferDown 342 | - Scroll position synchronization using the NPM package `diff` 343 | - Settings configuration assistant 344 | - Provides commands for launching/continuing/stopping `git mergetool` 345 | - Optionally opens the Git commit message for committing 346 | after a successful `git mergetool` execution 347 | (as a workaround for some Git extension bugs). 348 | - Provides a command for `git merge --abort` and `git merge --quit`. 349 | - Disables line numbers and sets diff layout to “inline” 350 | while a diff layout is active 351 | 352 | [Unreleased]: https://github.com/zawys/vscode-as-git-mergetool/compare/v0.14.0...HEAD 353 | [0.14.0]: https://github.com/zawys/vscode-as-git-mergetool/releases/tag/v0.14.0 354 | [0.13.3]: https://github.com/zawys/vscode-as-git-mergetool/releases/tag/v0.13.3 355 | [0.13.2]: https://github.com/zawys/vscode-as-git-mergetool/releases/tag/v0.13.2 356 | [0.13.1]: https://github.com/zawys/vscode-as-git-mergetool/releases/tag/v0.13.1 357 | [0.13.0]: https://github.com/zawys/vscode-as-git-mergetool/releases/tag/v0.13.0 358 | [0.12.0]: https://github.com/zawys/vscode-as-git-mergetool/releases/tag/v0.12.0 359 | [0.11.0]: https://github.com/zawys/vscode-as-git-mergetool/releases/tag/v0.11.0 360 | [0.10.0]: https://github.com/zawys/vscode-as-git-mergetool/releases/tag/v0.10.0 361 | [0.9.0]: https://github.com/zawys/vscode-as-git-mergetool/releases/tag/v0.9.0 362 | [0.8.0]: https://github.com/zawys/vscode-as-git-mergetool/releases/tag/v0.8.0 363 | [0.7.3]: https://github.com/zawys/vscode-as-git-mergetool/releases/tag/v0.7.3 364 | [0.7.2]: https://github.com/zawys/vscode-as-git-mergetool/releases/tag/v0.7.2 365 | [0.7.1]: https://github.com/zawys/vscode-as-git-mergetool/releases/tag/v0.7.1 366 | [0.7.0]: https://github.com/zawys/vscode-as-git-mergetool/releases/tag/v0.7.0 367 | [0.6.0]: https://github.com/zawys/vscode-as-git-mergetool/releases/tag/v0.6.0 368 | [0.5.0]: https://github.com/zawys/vscode-as-git-mergetool/releases/tag/v0.5.0 369 | [0.4.0]: https://github.com/zawys/vscode-as-git-mergetool/releases/tag/v0.4.0 370 | [0.3.0]: https://github.com/zawys/vscode-as-git-mergetool/releases/tag/v0.3.0 371 | [0.2.2]: https://github.com/zawys/vscode-as-git-mergetool/releases/tag/v0.2.2 372 | [0.2.1]: https://github.com/zawys/vscode-as-git-mergetool/releases/tag/v0.2.1 373 | [0.2.0]: https://github.com/zawys/vscode-as-git-mergetool/releases/tag/v0.2.0 374 | [0.1.0]: https://github.com/zawys/vscode-as-git-mergetool/releases/tag/v0.1.0 375 | [0.0.1]: https://github.com/zawys/vscode-as-git-mergetool/releases/tag/v0.0.1 376 | -------------------------------------------------------------------------------- /src/diffFileSelector.ts: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 zawys. Licensed under AGPL-3.0-or-later. 2 | // See LICENSE file in repository root directory. 3 | 4 | import { R_OK, W_OK } from "constants"; 5 | import path from "path"; 6 | import { Memento, QuickPickItem, Uri, window } from "vscode"; 7 | import { FileType, getFileType, getRealPath, testFile } from "./fsHandy"; 8 | import { getWorkspaceDirectoryUri } from "./getPathsWithinVSCode"; 9 | import { extensionID, firstLetterUppercase } from "./ids"; 10 | 11 | export class DiffFileSelector { 12 | public async doSelection(): Promise { 13 | const innerResult = await this.selector.doSelection(); 14 | if (innerResult === undefined) { 15 | return undefined; 16 | } 17 | const result: { 18 | [k in DiffFileKey]?: FileSelectionResult; 19 | } = {}; 20 | for (const value of innerResult) { 21 | result[value.key] = value; 22 | } 23 | if ( 24 | result.base === undefined || 25 | result.local === undefined || 26 | result.remote === undefined || 27 | result.merged === undefined 28 | ) { 29 | return undefined; 30 | } 31 | return result as DiffFileSelectionResult; 32 | } 33 | 34 | public constructor( 35 | private readonly workspaceState: Memento, 36 | public readonly id: string = `${extensionID}.mergeFileSelector` 37 | ) { 38 | this.selector = new MultiFileSelector( 39 | this.selectableFiles, 40 | new FileSelectionStateStore(id, workspaceState) 41 | ); 42 | } 43 | 44 | private readonly selectableFiles: SelectableFile[] = [ 45 | new SelectableFile("base", "base", true, validateAsReadableFile), 46 | new SelectableFile("local", "local", true, validateAsReadableFile), 47 | new SelectableFile("remote", "remote", true, validateAsReadableFile), 48 | new SelectableFile( 49 | "merged", 50 | "merged", 51 | true, 52 | validateAsExistingReadableOrEmptyWritableFileLocation 53 | ), 54 | ]; 55 | 56 | private readonly selector: MultiFileSelector; 57 | } 58 | 59 | export type DiffFileKey = "base" | "local" | "remote" | "merged"; 60 | export type DiffFileSelectionResult = { 61 | [k in DiffFileKey]: FileSelectionResult; 62 | }; 63 | 64 | export class MultiFileSelector { 65 | /** 66 | * Returns undefined iff the process was cancelled. 67 | * Otherwise it returns only valid data. 68 | */ 69 | public async doSelection(): Promise< 70 | FileSelectionResult[] | undefined 71 | > { 72 | // eslint-disable-next-line no-constant-condition 73 | while (true) { 74 | const [validationResults, pickItems] = await this.createPickItems(); 75 | 76 | const pickResult = await window.showQuickPick(pickItems, { 77 | ignoreFocusOut: true, 78 | }); 79 | if (pickResult === this.acceptItem) { 80 | const result: FileSelectionResult[] = []; 81 | for ( 82 | let fileIndex = 0; 83 | fileIndex < this.selectableFiles.length; 84 | fileIndex++ 85 | ) { 86 | const vr = validationResults[fileIndex]; 87 | if (vr?.valid === false) { 88 | return undefined; 89 | } 90 | const file = this.selectableFiles[fileIndex]; 91 | const key = file.key; 92 | const path = await this.stateStore.getSelection(key); 93 | if (path === undefined) { 94 | return undefined; 95 | } 96 | result.push({ 97 | key, 98 | fsPath: path, 99 | validationResult: vr, 100 | }); 101 | } 102 | return result; 103 | } else if (pickResult === this.unsetAll) { 104 | for (const file of this.selectableFiles) { 105 | await this.stateStore.setSelection(file.key, undefined); 106 | } 107 | continue; 108 | } 109 | const fileIndex = pickResult?.fileIndex; 110 | if (fileIndex === undefined) { 111 | return undefined; 112 | } 113 | const file = this.selectableFiles[fileIndex]; 114 | const inputUri = await this.inputURI(file); 115 | if (inputUri === undefined) { 116 | continue; 117 | } 118 | await this.stateStore.setSelection( 119 | file.key, 120 | inputUri === null ? undefined : inputUri 121 | ); 122 | } 123 | } 124 | 125 | public constructor( 126 | public readonly selectableFiles: SelectableFile[], 127 | public readonly stateStore: FileSelectionStateStore, 128 | private readonly acceptItem: QuickPickItem = { 129 | label: "$(check) Accept selection", 130 | alwaysShow: true, 131 | }, 132 | private readonly unsetAll: QuickPickItem = { 133 | label: "$(discard) Clear selection", 134 | alwaysShow: true, 135 | }, 136 | private readonly abortItem: QuickPickItem = { 137 | label: "$(close) Abort", 138 | alwaysShow: true, 139 | } 140 | ) {} 141 | 142 | private async createPickItems(): Promise< 143 | [(FileValidationResult | undefined)[], FileOrActionPickItem[]] 144 | > { 145 | const validationResults: (FileValidationResult | undefined)[] = []; 146 | const pickItems: FileOrActionPickItem[] = []; 147 | let allValid = true; 148 | for ( 149 | let fileIndex = 0; 150 | fileIndex < this.selectableFiles.length; 151 | fileIndex++ 152 | ) { 153 | const file = this.selectableFiles[fileIndex]; 154 | const fsPath = await this.stateStore.getSelection(file.key); 155 | const validationResult = 156 | fsPath !== undefined && file.validate 157 | ? await file.validate(fsPath) 158 | : undefined; 159 | validationResults.push(validationResult); 160 | const comment = 161 | fsPath === undefined 162 | ? file.required 163 | ? "Required." 164 | : "Optional." 165 | : validationResult === undefined 166 | ? "" 167 | : validationResult.message !== undefined 168 | ? validationResult.message 169 | : validationResult.valid 170 | ? "" 171 | : "Error."; 172 | const value = fsPath !== undefined ? `\`${fsPath}\`` : "unset"; 173 | pickItems.push({ 174 | fileIndex, 175 | label: `${firstLetterUppercase(file.label)}: ${value}`, 176 | detail: comment, 177 | }); 178 | allValid &&= 179 | fsPath !== undefined 180 | ? validationResult?.valid === true 181 | : !file.required; 182 | } 183 | if (allValid) { 184 | pickItems.push(this.acceptItem); 185 | } 186 | pickItems.push(this.unsetAll, this.abortItem); 187 | return [validationResults, pickItems]; 188 | } 189 | 190 | /** 191 | * 192 | * @param file 193 | * @returns `undefined` means NOOP, `null` means unset. 194 | */ 195 | private async inputURI( 196 | file: SelectableFile 197 | ): Promise { 198 | const pasteItem: QuickPickItem = { 199 | label: "Type or paste", 200 | }; 201 | const dialogItem: QuickPickItem = { label: "Use dialog" }; 202 | const unsetItem: QuickPickItem = { label: "Unset" }; 203 | const abortItem: QuickPickItem = { label: "Abort" }; 204 | const result = await window.showQuickPick([pasteItem, dialogItem], { 205 | ignoreFocusOut: true, 206 | }); 207 | if (result === undefined || result === abortItem) { 208 | return; 209 | } 210 | if (result === unsetItem) { 211 | return null; 212 | } 213 | if (result === pasteItem) { 214 | const result = await window.showInputBox({ 215 | ignoreFocusOut: true, 216 | prompt: `Input ${file.required ? "required" : ""} path for ${ 217 | file.label 218 | }.`, 219 | value: 220 | (await this.stateStore.getSelection(file.key)) || 221 | (await this.getDefaultPath()), 222 | }); 223 | if (!result) { 224 | return undefined; 225 | } 226 | if (result.startsWith("./") || result.startsWith(".\\")) { 227 | const workingDirectory = getWorkspaceDirectoryUri()?.fsPath; 228 | if (workingDirectory !== undefined) { 229 | return path.join(workingDirectory, result); 230 | } 231 | } 232 | return result; 233 | } else { 234 | const fSPath = await this.stateStore.getSelection(file.key); 235 | let defaultURI = 236 | fSPath === undefined ? undefined : Uri.file(path.dirname(fSPath)); 237 | if (!defaultURI) { 238 | const defaultPath = await this.getDefaultPath(); 239 | if (defaultPath !== undefined) { 240 | defaultURI = Uri.file(defaultPath); 241 | } 242 | } 243 | const result = await window.showOpenDialog({ 244 | canSelectFiles: true, 245 | canSelectFolders: false, 246 | canSelectMany: false, 247 | defaultUri: defaultURI, 248 | openLabel: "Select", 249 | title: `Select ${file.required ? "required" : ""} ${file.label}`, 250 | }); 251 | if (result === undefined || result.length === 0) { 252 | return undefined; 253 | } 254 | return result[0].fsPath; 255 | } 256 | } 257 | 258 | private async getDefaultPath(): Promise { 259 | for (const file of this.selectableFiles) { 260 | const fSPath = await this.stateStore.getSelection(file.key); 261 | if (fSPath !== undefined) { 262 | return fSPath; 263 | } 264 | } 265 | return undefined; 266 | } 267 | } 268 | 269 | export class SelectableFile { 270 | public constructor( 271 | public readonly key: TKey, 272 | public readonly label: string, 273 | public readonly required = false, 274 | public readonly validate?: ( 275 | fsPath: string 276 | ) => Promise 277 | ) {} 278 | } 279 | 280 | export interface FileValidationResult { 281 | valid: boolean; 282 | message?: string; 283 | readable?: boolean; 284 | writable?: boolean; 285 | emptyLoc?: boolean; 286 | } 287 | export interface FileSelectionResult { 288 | key: TKey; 289 | validationResult?: FileValidationResult; 290 | fsPath: string; 291 | } 292 | 293 | async function validateAsReadableFile( 294 | fsPath: string 295 | ): Promise { 296 | const resolvedPath = await getRealPath(fsPath); 297 | if (resolvedPath === undefined) { 298 | return fileValidationResultFromErrorMessage("Could not find file."); 299 | } 300 | const fileType = await getFileType(resolvedPath); 301 | if (fileType === undefined) { 302 | return fileValidationResultFromErrorMessage("Could not test file."); 303 | } 304 | if (fileType !== FileType.regular) { 305 | return fileValidationResultFromErrorMessage("Not a regular file."); 306 | } 307 | if (!(await testFile(resolvedPath, R_OK))) { 308 | return fileValidationResultFromErrorMessage("Cannot read file."); 309 | } 310 | return { valid: true, readable: true, emptyLoc: false }; 311 | } 312 | 313 | async function validateAsExistingReadableOrEmptyWritableFileLocation( 314 | fsPath: string 315 | ): Promise { 316 | const fileType = await getFileType(fsPath); 317 | if (fileType !== undefined && fileType !== FileType.notExisting) { 318 | const resolvedPath = await getRealPath(fsPath); 319 | if (resolvedPath === undefined) { 320 | return fileValidationResultFromErrorMessage( 321 | "Error resolving existing file." 322 | ); 323 | } 324 | const fileType = await getFileType(resolvedPath); 325 | if (fileType === undefined) { 326 | return fileValidationResultFromErrorMessage("Could not test file."); 327 | } 328 | if (fileType !== FileType.regular) { 329 | return fileValidationResultFromErrorMessage( 330 | "Existing and not a regular file." 331 | ); 332 | } 333 | if (!(await testFile(resolvedPath, R_OK))) { 334 | return fileValidationResultFromErrorMessage("Cannot read file."); 335 | } 336 | const writable = !(await testFile(resolvedPath, W_OK)); 337 | return { 338 | valid: true, 339 | writable, 340 | readable: true, 341 | emptyLoc: false, 342 | message: "File will not be overwritten; only loaded.", 343 | }; 344 | } 345 | const parent = path.dirname(fsPath); 346 | if (!(await testFile(parent, W_OK))) { 347 | return fileValidationResultFromErrorMessage( 348 | "Cannot write parent directory." 349 | ); 350 | } 351 | return { 352 | valid: true, 353 | writable: true, 354 | readable: false, 355 | emptyLoc: true, 356 | }; 357 | } 358 | 359 | function fileValidationResultFromErrorMessage( 360 | message: string 361 | ): FileValidationResult { 362 | return { valid: false, message }; 363 | } 364 | 365 | export interface FileOrActionPickItem extends QuickPickItem { 366 | fileIndex?: number; 367 | } 368 | export interface FilePickItem extends FileOrActionPickItem { 369 | fileIndex: number; 370 | } 371 | 372 | export type FileSelectionState = { 373 | [key: string]: Uri | undefined; 374 | }; 375 | 376 | export class FileSelectionStateStore { 377 | public async getSelection(key: string): Promise { 378 | const keyID = this.getKeyID(key); 379 | const value = this.workspaceState.get(keyID); 380 | if (value === undefined) { 381 | return undefined; 382 | } else if (typeof value === "string") { 383 | return value; 384 | } 385 | await this.workspaceState.update(keyID, undefined); 386 | return undefined; 387 | } 388 | 389 | public async setSelection( 390 | key: string, 391 | value: string | undefined 392 | ): Promise { 393 | await this.workspaceState.update(this.getKeyID(key), value); 394 | } 395 | 396 | public constructor( 397 | public readonly id: string, 398 | public readonly workspaceState: Memento 399 | ) {} 400 | 401 | private getKeyID(key: string): string { 402 | return `${this.id}.${key}`; 403 | } 404 | } 405 | -------------------------------------------------------------------------------- /src/diffLayouterManager.ts: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2020 zawys. Licensed under AGPL-3.0-or-later. 2 | // See LICENSE file in repository root directory. 3 | 4 | import { readFile } from "fs"; 5 | import { 6 | Disposable, 7 | window, 8 | commands, 9 | Event, 10 | EventEmitter, 11 | StatusBarItem, 12 | StatusBarAlignment, 13 | MessageItem, 14 | } from "vscode"; 15 | import { DiffedURIs } from "./diffedURIs"; 16 | import { copy } from "./fsHandy"; 17 | import { extensionID } from "./ids"; 18 | import { 19 | DiffLayouter, 20 | DiffLayouterFactory, 21 | focusNextConflictCommandID, 22 | focusPreviousConflictCommandID, 23 | SearchType, 24 | } from "./layouters/diffLayouter"; 25 | import { FourTransferDownLayouterFactory } from "./layouters/fourTransferDownLayouter"; 26 | import { FourTransferRightLayouterFactory } from "./layouters/fourTransferRightLayouter"; 27 | import { ThreeDiffToBaseLayouterFactory } from "./layouters/threeDiffToBaseLayouter"; 28 | import { ThreeDiffToBaseMergedRightLayouterFactory } from "./layouters/threeDiffToBaseMergedRightLayouter"; 29 | import { ThreeDiffToBaseRowsLayouterFactory } from "./layouters/threeDiffToBaseRowsLayouter"; 30 | import { ThreeDiffToMergedLayouterFactory } from "./layouters/threeDiffToMergedLayouter"; 31 | import { containsMergeConflictIndicators } from "./mergeConflictIndicatorDetector"; 32 | import { Monitor } from "./monitor"; 33 | import { RegisterableService } from "./registerableService"; 34 | import { TemporarySettingsManager } from "./temporarySettingsManager"; 35 | import { createUIError, isUIError, UIError } from "./uIError"; 36 | import { VSCodeConfigurator } from "./vSCodeConfigurator"; 37 | import { Zoom, ZoomManager } from "./zoom"; 38 | 39 | export class DiffLayouterManager implements RegisterableService { 40 | public async register(): Promise { 41 | for (const disposabe of this.disposables) { 42 | disposabe.dispose(); 43 | } 44 | this.disposables = [ 45 | commands.registerCommand( 46 | focusPreviousConflictCommandID, 47 | this.focusMergeConflictInteractively.bind(this, SearchType.previous) 48 | ), 49 | commands.registerCommand( 50 | focusNextConflictCommandID, 51 | this.focusMergeConflictInteractively.bind(this, SearchType.next) 52 | ), 53 | commands.registerCommand( 54 | deactivateLayoutCommandID, 55 | this.deactivateLayout.bind(this) 56 | ), 57 | commands.registerCommand( 58 | resetMergedFileCommandID, 59 | this.resetMergedFile.bind(this) 60 | ), 61 | commands.registerCommand( 62 | switchLayoutCommandID, 63 | this.switchLayout.bind(this) 64 | ), 65 | this.zoomManager.onWasZoomRequested( 66 | this.handleWasZoomRequested.bind(this) 67 | ), 68 | ]; 69 | await this.temporarySettingsManager.resetSettings(); 70 | } 71 | 72 | public async deactivateLayout(): Promise { 73 | await this.layouterManagerMonitor.enter(); 74 | try { 75 | await this.layouter?.deactivate(); 76 | this.layouter = undefined; 77 | this.layouterFactory = undefined; 78 | } finally { 79 | await this.layouterManagerMonitor.leave(); 80 | } 81 | } 82 | 83 | public async save(): Promise { 84 | await this.layouter?.save(); 85 | } 86 | 87 | public focusMergeConflict(type: SearchType): UIError | boolean { 88 | return this.layouter?.isActive === true 89 | ? this.layouter.focusMergeConflict(type) 90 | : createUIError("No diff layout active."); 91 | } 92 | 93 | public focusMergeConflictInteractively( 94 | type: SearchType 95 | ): undefined | boolean { 96 | const result = this.focusMergeConflict(type); 97 | if (isUIError(result)) { 98 | void window.showErrorMessage(result.message); 99 | return undefined; 100 | } else if (!result) { 101 | void window.showInformationMessage("No merge conflict found."); 102 | } 103 | return result; 104 | } 105 | 106 | public get onDidLayoutDeactivate(): Event { 107 | return this.didLayoutDeactivate.event; 108 | } 109 | 110 | public get onDidLayoutActivate(): Event { 111 | return this.didLayoutActivate.event; 112 | } 113 | public get diffedURIs(): DiffedURIs | undefined { 114 | return this.layouter?.isActivating || this.layouter?.isActive 115 | ? this.layouter.diffedURIs 116 | : undefined; 117 | } 118 | 119 | public get layoutSwitchInProgress(): boolean { 120 | return this.layouter !== undefined && !this.layouter.isActive; 121 | } 122 | 123 | public dispose(): void { 124 | for (const disposable of this.disposables) { 125 | disposable.dispose(); 126 | } 127 | this.disposables = []; 128 | this.layouter?.dispose(); 129 | } 130 | 131 | public async resetMergedFile(): Promise { 132 | const diffedURIs = this.diffedURIs; 133 | if (this.layouter?.isActive === undefined || diffedURIs === undefined) { 134 | void window.showErrorMessage( 135 | "Reset not applicable; no merge situation opened." 136 | ); 137 | return; 138 | } 139 | if (diffedURIs?.backup === undefined) { 140 | void window.showErrorMessage("Backup file is unknown."); 141 | return; 142 | } 143 | const copyResult = await copy( 144 | diffedURIs.backup.fsPath, 145 | diffedURIs.merged.fsPath 146 | ); 147 | if (isUIError(copyResult)) { 148 | void window.showErrorMessage( 149 | `Resetting the merged file failed: ${copyResult.message}` 150 | ); 151 | return; 152 | } 153 | } 154 | 155 | public openDiffedURIs( 156 | diffedURIs: DiffedURIs, 157 | closeActiveEditor: boolean 158 | ): Promise; 159 | public async openDiffedURIs( 160 | diffedURIs: DiffedURIs, 161 | closeActiveEditor: boolean, 162 | deactivationHandler?: () => void 163 | ): Promise; 164 | public async openDiffedURIs( 165 | diffedURIs: DiffedURIs, 166 | closeActiveEditor: boolean, 167 | deactivationHandler?: () => void 168 | ): Promise { 169 | await this.layouterManagerMonitor.enter(); 170 | try { 171 | const activeDiffedURIs = this.layouter?.diffedURIs; 172 | if ( 173 | (this.layouter?.isActivating || this.layouter?.isActive) === true && 174 | activeDiffedURIs !== undefined && 175 | diffedURIs.equalsWithoutBackup(activeDiffedURIs) 176 | ) { 177 | return true; 178 | } 179 | 180 | const newLayouterFactory = await this.getLayoutFactory(); 181 | if (newLayouterFactory === undefined) { 182 | return false; 183 | } 184 | 185 | // point of no return 186 | 187 | if (closeActiveEditor) { 188 | await this.closeActiveEditor(); 189 | } 190 | await this.activateLayouter(diffedURIs, newLayouterFactory); 191 | if (deactivationHandler !== undefined) { 192 | const result: Disposable | undefined = this.onDidLayoutDeactivate( 193 | () => { 194 | deactivationHandler(); 195 | result?.dispose(); 196 | } 197 | ); 198 | } 199 | } finally { 200 | await this.layouterManagerMonitor.leave(); 201 | } 202 | if (this.layouter !== undefined) { 203 | this.didLayoutActivate.fire(this.layouter); 204 | } 205 | return true; 206 | } 207 | 208 | public async switchLayout(layoutName?: unknown): Promise { 209 | if (this.layouter?.diffedURIs === undefined) { 210 | void window.showErrorMessage( 211 | "This requires the diff layout to be active" 212 | ); 213 | return; 214 | } 215 | let targetFactory: DiffLayouterFactory | undefined; 216 | if (typeof layoutName === "string") { 217 | targetFactory = this.factories.find( 218 | (factory) => factory.settingValue === layoutName 219 | ); 220 | } 221 | if (targetFactory === undefined) { 222 | const pickResult = await window.showQuickPick( 223 | this.factories 224 | .filter((factory) => factory !== this.layouterFactory) 225 | .map((factory) => factory.settingValue) 226 | ); 227 | if (pickResult === undefined) { 228 | return; 229 | } 230 | targetFactory = this.factories.find( 231 | (factory) => factory.settingValue === pickResult 232 | ); 233 | if (targetFactory === undefined) { 234 | return; 235 | } 236 | } 237 | if ( 238 | targetFactory === this.layouterFactory || 239 | this.layouter?.diffedURIs === undefined 240 | ) { 241 | void window.showErrorMessage( 242 | "The situation has changed meanwhile. Please try again." 243 | ); 244 | } 245 | await this.layouterManagerMonitor.enter(); 246 | try { 247 | await this.activateLayouter(this.layouter.diffedURIs, targetFactory); 248 | } finally { 249 | await this.layouterManagerMonitor.leave(); 250 | } 251 | if (this.layouter !== undefined) { 252 | this.didLayoutActivate.fire(this.layouter); 253 | } 254 | } 255 | 256 | public async closeActiveEditor(): Promise { 257 | await commands.executeCommand("workbench.action.closeActiveEditor"); 258 | } 259 | 260 | public constructor( 261 | public readonly vSCodeConfigurator: VSCodeConfigurator, 262 | public readonly zoomManager: ZoomManager, 263 | public readonly temporarySettingsManager: TemporarySettingsManager, 264 | public readonly factories: DiffLayouterFactory[] = [ 265 | new ThreeDiffToMergedLayouterFactory(), 266 | new ThreeDiffToBaseLayouterFactory(), 267 | new ThreeDiffToBaseRowsLayouterFactory(), 268 | new ThreeDiffToBaseMergedRightLayouterFactory(), 269 | new FourTransferRightLayouterFactory(), 270 | new FourTransferDownLayouterFactory(), 271 | ] 272 | ) { 273 | if (factories.length === 0) { 274 | throw new Error("internal error: no factory registered"); 275 | } 276 | const defaultFactory = factories.find( 277 | (factory) => 278 | factory.settingValue === 279 | new FourTransferRightLayouterFactory().settingValue 280 | ); 281 | if (defaultFactory === undefined) { 282 | throw new Error("could not find default factory"); 283 | } 284 | this.defaultFactory = defaultFactory; 285 | } 286 | 287 | private layouterFactory: DiffLayouterFactory | undefined; 288 | private layouter: DiffLayouter | undefined; 289 | private readonly layouterMonitor = new Monitor(); 290 | private readonly layouterManagerMonitor = new Monitor(); 291 | private disposables: Disposable[] = []; 292 | private readonly defaultFactory: DiffLayouterFactory; 293 | private readonly didLayoutDeactivate = new EventEmitter(); 294 | private readonly didLayoutActivate = new EventEmitter(); 295 | private switchLayoutStatusBarItem: StatusBarItem | undefined; 296 | 297 | private activateSwitchLayoutStatusBarItem(): void { 298 | if (this.switchLayoutStatusBarItem !== undefined) { 299 | return; 300 | } 301 | this.switchLayoutStatusBarItem = window.createStatusBarItem( 302 | StatusBarAlignment.Left, 303 | 5 304 | ); 305 | this.switchLayoutStatusBarItem.text = "$(editor-layout)"; 306 | this.switchLayoutStatusBarItem.command = switchLayoutCommandID; 307 | this.switchLayoutStatusBarItem.tooltip = "Switch diff editor layout…"; 308 | this.switchLayoutStatusBarItem.show(); 309 | } 310 | 311 | private deactivateSwitchLayoutStatusBarItem(): void { 312 | this.switchLayoutStatusBarItem?.dispose(); 313 | this.switchLayoutStatusBarItem = undefined; 314 | } 315 | 316 | private async activateLayouter( 317 | diffedURIs: DiffedURIs, 318 | newLayouterFactory: DiffLayouterFactory 319 | ): Promise { 320 | const oldLayouter = this.layouter; 321 | if (oldLayouter !== undefined) { 322 | await oldLayouter.deactivate(true); 323 | } 324 | 325 | this.layouterFactory = newLayouterFactory; 326 | this.layouter = newLayouterFactory.create({ 327 | monitor: this.layouterMonitor, 328 | temporarySettingsManager: this.temporarySettingsManager, 329 | diffedURIs, 330 | vSCodeConfigurator: this.vSCodeConfigurator, 331 | zoomManager: this.zoomManager, 332 | }); 333 | this.layouter.onDidDeactivate(this.handleLayouterDidDeactivate.bind(this)); 334 | await this.layouter.tryActivate(Zoom.default, oldLayouter !== undefined); 335 | this.activateSwitchLayoutStatusBarItem(); 336 | } 337 | 338 | private async handleLayouterDidDeactivate(layouter: DiffLayouter) { 339 | this.layouter = undefined; 340 | this.deactivateSwitchLayoutStatusBarItem(); 341 | this.didLayoutDeactivate.fire(layouter); 342 | if (!layouter.wasInitiatedByMergetool) { 343 | const text = await new Promise((resolve) => 344 | readFile(layouter.diffedURIs.merged.fsPath, "utf8", (error, data) => 345 | resolve(error ? undefined : data) 346 | ) 347 | ); 348 | if (text !== undefined && containsMergeConflictIndicators(text)) { 349 | const reopen = "Reopen"; 350 | const keepClosed = "Keep closed"; 351 | const result = await window.showWarningMessage( 352 | "Merge conflict markers are included in closed file.", 353 | reopen, 354 | keepClosed 355 | ); 356 | if ( 357 | result === reopen && 358 | !(await this.openDiffedURIs(layouter.diffedURIs, false)) 359 | ) { 360 | void window.showErrorMessage( 361 | "Opening failed, probably because one of the files was removed." 362 | ); 363 | } 364 | } 365 | } 366 | } 367 | 368 | private async getLayoutFactory(): Promise { 369 | let layoutSetting = this.vSCodeConfigurator.get(layoutSettingID); 370 | // eslint-disable-next-line no-constant-condition 371 | while (true) { 372 | for (const factory of this.factories) { 373 | if (factory.settingValue === layoutSetting) { 374 | return factory; 375 | } 376 | } 377 | const restoreItem: MessageItem = { 378 | title: "Restore default", 379 | }; 380 | const onceItem: MessageItem = { 381 | title: "Use default once", 382 | }; 383 | const cancelItem: MessageItem = { title: "Cancel" }; 384 | const selectedItem = await window.showErrorMessage( 385 | "Diff layout setting has an unknown value.", 386 | restoreItem, 387 | onceItem, 388 | cancelItem 389 | ); 390 | if (selectedItem === cancelItem || selectedItem === undefined) { 391 | return; 392 | } 393 | if (selectedItem === restoreItem) { 394 | await this.vSCodeConfigurator.set( 395 | layoutSettingID, 396 | this.defaultFactory.settingValue 397 | ); 398 | } 399 | layoutSetting = this.defaultFactory.settingValue; 400 | } 401 | } 402 | 403 | private async handleWasZoomRequested(zoom: Zoom): Promise { 404 | await this.layouterManagerMonitor.enter(); 405 | try { 406 | if (this.layouterManagerMonitor.someoneIsWaiting) { 407 | return; 408 | } 409 | if (!this.layouter?.isActive) { 410 | void window.showErrorMessage( 411 | "Diff layout must be active to use zoom commands." 412 | ); 413 | return; 414 | } 415 | await this.layouter.setLayout(zoom); 416 | } finally { 417 | await this.layouterManagerMonitor.leave(); 418 | } 419 | } 420 | } 421 | 422 | export const layoutSettingID = `${extensionID}.layout`; 423 | export const deactivateLayoutCommandID = `${extensionID}.deactivateLayout`; 424 | export const resetMergedFileCommandID = `${extensionID}.resetMergedFile`; 425 | export const switchLayoutCommandID = `${extensionID}.switchLayout`; 426 | --------------------------------------------------------------------------------