Skip to content

Commit

Permalink
Allow conflicts to be resolved on a non-checked-out PR (#5858)
Browse files Browse the repository at this point in the history
* Allow conflicts to be resolved on a non-checked-out PR
Fixes #5802

* Fix incorrect use of previous file name in conflict resolution

* Fix more rename handling

* loc fix
  • Loading branch information
alexr00 authored Apr 5, 2024
1 parent 9b76ca3 commit bb0a2fb
Show file tree
Hide file tree
Showing 17 changed files with 1,766 additions and 101 deletions.
53 changes: 42 additions & 11 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,23 @@
},
"enabledApiProposals": [
"activeComment",
"commentingRangeHint",
"commentThreadApplicability",
"contribCommentsViewThreadMenus",
"tokenInformation",
"contribShareMenu",
"fileComments",
"codiconDecoration",
"codeActionRanges",
"commentingRangeHint",
"commentReactor",
"commentThreadApplicability",
"contribCommentEditorActionsMenu",
"contribCommentPeekContext",
"contribCommentThreadAdditionalMenu",
"codiconDecoration",
"contribCommentsViewThreadMenus",
"contribEditorContentMenu",
"contribShareMenu",
"diffCommand",
"contribCommentEditorActionsMenu",
"shareProvider",
"fileComments",
"quickDiffProvider",
"shareProvider",
"tabInputTextMerge",
"tokenInformation",
"treeViewMarkdownMessage"
],
"version": "0.86.0",
Expand Down Expand Up @@ -660,14 +661,20 @@
{
"id": "pr:github",
"name": "%view.pr.github.name%",
"when": "ReposManagerStateContext != NeedsAuthentication",
"when": "ReposManagerStateContext != NeedsAuthentication && !github:resolvingConflicts",
"icon": "$(git-pull-request)"
},
{
"id": "issues:github",
"name": "%view.issues.github.name%",
"when": "ReposManagerStateContext != NeedsAuthentication",
"when": "ReposManagerStateContext != NeedsAuthentication && !github:resolvingConflicts",
"icon": "$(issues)"
},
{
"id": "github:conflictResolution",
"name": "%view.github.conflictResolution.name%",
"when": "github:resolvingConflicts",
"icon": "$(git-merge)"
}
],
"github-pull-request": [
Expand Down Expand Up @@ -1113,6 +1120,16 @@
"title": "%command.pr.pick.title%",
"category": "%command.pull.request.category%"
},
{
"command": "pr.resolveConflict",
"title": "%command.pr.resolveConflict.title%",
"category": "%command.pull.request.category%"
},
{
"command": "pr.acceptMerge",
"title": "%command.pr.acceptMerge.title%",
"category": "%command.pull.request.category%"
},
{
"command": "review.diffWithPrHead",
"title": "%command.review.diffWithPrHead.title%",
Expand Down Expand Up @@ -1852,6 +1869,14 @@
"command": "pr.refreshComments",
"when": "gitHubOpenRepositoryCount != 0"
},
{
"command": "pr.resolveConflict",
"when": "false"
},
{
"command": "pr.acceptMerge",
"when": "isMergeResultEditor && mergeEditorBaseUri =~ /^(githubpr|gitpr):/"
},
{
"command": "issue.copyGithubPermalink",
"when": "github:hasGitHubRemotes"
Expand Down Expand Up @@ -2361,6 +2386,12 @@
"when": "(resourceScheme == pr) || (resourcePath in github:viewedFiles) || (resourcePath in github:unviewedFiles)"
}
],
"editor/content": [
{
"command": "pr.acceptMerge",
"when": "isMergeResultEditor && mergeEditorBaseUri =~ /^(githubpr|gitpr):/"
}
],
"scm/title": [
{
"command": "review.suggestDiff",
Expand Down
3 changes: 3 additions & 0 deletions package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@
"view.github.login.name": "Login",
"view.pr.github.name": "Pull Requests",
"view.issues.github.name": "Issues",
"view.github.conflictResolution.name": "Conflict Resolution",
"view.github.create.pull.request.name": "Create",
"view.github.compare.changes.name": "Files Changed",
"view.github.compare.changesCommits.name": "Commits",
Expand Down Expand Up @@ -237,6 +238,8 @@
"command.pr.createPrMenuMerge.title": "Create + Auto-Merge",
"command.pr.createPrMenuRebase.title": "Create + Auto-Rebase",
"command.pr.refreshComments.title": "Refresh Pull Request Comments",
"command.pr.resolveConflict.title": "Resolve Conflict",
"command.pr.acceptMerge.title": "Accept Merge",
"command.issue.copyGithubDevLink.title": "Copy github.dev Link",
"command.issue.copyGithubPermalink.title": "Copy GitHub Permalink",
"command.issue.copyGithubHeadLink.title": "Copy GitHub Head Link",
Expand Down
1 change: 1 addition & 0 deletions src/common/executeCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export namespace contexts {
export const LOADING_PRS_TREE = 'github:loadingPrsTree';
export const LOADING_ISSUES_TREE = 'github:loadingIssuesTree';
export const CREATE_PR_PERMISSIONS = 'github:createPrPermissions';
export const RESOLVING_CONFLICTS = 'github:resolvingConflicts';
}

export namespace commands {
Expand Down
11 changes: 10 additions & 1 deletion src/common/uri.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export function fromPRNodeUri(uri: vscode.Uri): PRNodeUriParams | undefined {
export interface GitHubUriParams {
fileName: string;
branch: string;
owner?: string;
isEmpty?: boolean;
}
export function fromGitHubURI(uri: vscode.Uri): GitHubUriParams | undefined {
Expand All @@ -67,6 +68,13 @@ export function fromGitHubURI(uri: vscode.Uri): GitHubUriParams | undefined {
} catch (e) { }
}

export function toGitHubUri(fileUri: vscode.Uri, scheme: Schemes.GithubPr | Schemes.GitPr, params: GitHubUriParams): vscode.Uri {
return fileUri.with({
scheme,
query: JSON.stringify(params)
});
}

export interface GitUriOptions {
replaceFileExtension?: boolean;
submoduleOf?: string;
Expand Down Expand Up @@ -400,7 +408,8 @@ export enum Schemes {
GithubPr = 'githubpr', // File content from GitHub in create flow
GitPr = 'gitpr', // File content from git in create flow
VscodeVfs = 'vscode-vfs', // Remote Repository
Comment = 'comment' // Comments from the VS Code comment widget
Comment = 'comment', // Comments from the VS Code comment widget
MergeOutput = 'merge-output', // Merge output
}

export function resolvePath(from: vscode.Uri, to: string) {
Expand Down
151 changes: 151 additions & 0 deletions src/github/conflictResolutionCoordinator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import * as vscode from 'vscode';
import { commands, contexts } from '../common/executeCommands';
import { Schemes } from '../common/uri';
import { asPromise } from '../common/utils';
import { ConflictResolutionTreeView } from '../view/conflictResolution/conflictResolutionTreeView';
import { GitHubContentProvider } from '../view/gitHubContentProvider';
import { Conflict, ConflictResolutionModel } from './conflictResolutionModel';
import { GitHubRepository } from './githubRepository';

interface MergeEditorInputData { uri: vscode.Uri; title?: string; detail?: string; description?: string }

class MergeOutputProvider implements vscode.FileSystemProvider {
private _createTime: number = 0;
private _modifiedTime: number = 0;
private _mergedFiles: Map<string, Uint8Array> = new Map();
get mergeResults(): Map<string, Uint8Array> {
return this._mergedFiles;
}
onDidChangeFile: vscode.Event<vscode.FileChangeEvent[]> = new vscode.EventEmitter<vscode.FileChangeEvent[]>().event;
constructor(private readonly _conflictResolutionModel: ConflictResolutionModel) {
this._createTime = new Date().getTime();
}
watch(_uri: vscode.Uri, _options: { readonly recursive: boolean; readonly excludes: readonly string[]; }): vscode.Disposable {
// no-op because no one else can modify this file.
return {
dispose: () => { }
};
}
stat(uri: vscode.Uri): vscode.FileStat {
return {
type: vscode.FileType.File,
ctime: this._createTime,
mtime: this._modifiedTime,
size: this._mergedFiles.get(uri.path)?.length ?? 0,
};
}
readDirectory(_uri: vscode.Uri): [string, vscode.FileType][] {
throw new Error('Method not implemented.');
}
createDirectory(_uri: vscode.Uri): void {
throw new Error('Method not implemented.');
}
async readFile(uri: vscode.Uri): Promise<Uint8Array> {
if (!this._mergedFiles.has(uri.path)) {
const original = this._conflictResolutionModel.mergeBaseUri({ prHeadFilePath: uri.path });
const content = await vscode.workspace.fs.readFile(original);
this._mergedFiles.set(uri.path, content);
}
return this._mergedFiles.get(uri.path)!;
}
writeFile(uri: vscode.Uri, content: Uint8Array, _options: { readonly create: boolean; readonly overwrite: boolean; }): void {
this._modifiedTime = new Date().getTime();
this._mergedFiles.set(uri.path, content);
}
delete(_uri: vscode.Uri, _options: { readonly recursive: boolean; }): void {
throw new Error('Method not implemented.');
}
rename(_oldUri: vscode.Uri, _newUri: vscode.Uri, _options: { readonly overwrite: boolean; }): void {
throw new Error('Method not implemented.');
}
}

export class ConflictResolutionCoordinator {
private _disposables: vscode.Disposable[] = [];
private _mergeOutputProvider: MergeOutputProvider;

constructor(private readonly _conflictResolutionModel: ConflictResolutionModel, private readonly _githubRepositories: GitHubRepository[]) {
this._mergeOutputProvider = new MergeOutputProvider(this._conflictResolutionModel);
}

private register(): void {
this._disposables.push(vscode.workspace.registerFileSystemProvider(Schemes.GithubPr, new GitHubContentProvider(this._githubRepositories), { isReadonly: true }));
this._disposables.push(vscode.workspace.registerFileSystemProvider(Schemes.MergeOutput, this._mergeOutputProvider));
this._disposables.push(vscode.commands.registerCommand('pr.resolveConflict', async (conflict: Conflict) => {
const prHeadUri = this._conflictResolutionModel.prHeadUri(conflict);
const baseUri = this._conflictResolutionModel.baseUri(conflict);

const prHead: MergeEditorInputData = { uri: prHeadUri, title: vscode.l10n.t('Pull Request Head') };
const base: MergeEditorInputData = { uri: baseUri, title: vscode.l10n.t('{0} Branch', this._conflictResolutionModel.prBaseBranchName) };

const mergeBaseUri: vscode.Uri = this._conflictResolutionModel.mergeBaseUri(conflict);
const mergeOutput = this._conflictResolutionModel.mergeOutputUri(conflict);
const options = {
base: mergeBaseUri,
input1: prHead,
input2: base,
output: mergeOutput
};
await commands.executeCommand(
'_open.mergeEditor',
options
);
}));
this._disposables.push(vscode.commands.registerCommand('pr.acceptMerge', async (uri: vscode.Uri | unknown) => {
return this.acceptMerge(uri);
}));
this._disposables.push(vscode.commands.registerCommand('pr.exitConflictResolutionMode', async () => {
const exit = vscode.l10n.t('Exit and lose changes');
const result = await vscode.window.showWarningMessage(vscode.l10n.t('Are you sure you want to exit conflict resolution mode? All changes will be lost.'), { modal: true }, exit);
if (result === exit) {
return this.exitConflictResolutionMode(false);
}
}));
this._disposables.push(vscode.commands.registerCommand('pr.completeMerge', async () => {
return this.exitConflictResolutionMode(true);
}));
this._disposables.push(new ConflictResolutionTreeView(this._conflictResolutionModel));
}

private async acceptMerge(uri: vscode.Uri | unknown): Promise<void> {
if (!(uri instanceof vscode.Uri)) {
return;
}
const { activeTab } = vscode.window.tabGroups.activeTabGroup;
if (!activeTab || !(activeTab.input instanceof vscode.TabInputTextMerge)) {
return;
}

const result = await commands.executeCommand('mergeEditor.acceptMerge') as { successful: boolean };
if (result.successful) {
const contents = new TextDecoder().decode(this._mergeOutputProvider.mergeResults.get(uri.path)!);
this._conflictResolutionModel.addResolution(uri.path.substring(1), contents);
}
}

async enterConflictResolutionMode(): Promise<void> {
await commands.setContext(contexts.RESOLVING_CONFLICTS, true);
this.register();
}

private _onExitConflictResolutionMode = new vscode.EventEmitter<boolean>();
async exitConflictResolutionMode(allConflictsResolved: boolean): Promise<void> {
await commands.setContext(contexts.RESOLVING_CONFLICTS, false);
this._onExitConflictResolutionMode.fire(allConflictsResolved);
this.dispose();
}

async enterConflictResolutionAndWaitForExit(): Promise<boolean> {
await this.enterConflictResolutionMode();
return asPromise(this._onExitConflictResolutionMode.event);
}

dispose(): void {
this._disposables.forEach(d => d.dispose());
}
}
87 changes: 87 additions & 0 deletions src/github/conflictResolutionModel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import * as vscode from 'vscode';
import { Schemes, toGitHubUri } from '../common/uri';

export interface Conflict {
prHeadFilePath: string;
contentsConflict: boolean;
filePathConflict: boolean;
modeConflict: boolean;
}

export interface ResolvedConflict {
prHeadFilePath: string;
resolvedContents?: string;
// The other two fields can be added later. To begin with, we only support resolving the contents.
// resolvedFilePath: string;
// resolvedMode: string;
}

export class ConflictResolutionModel {
private _startingConflicts: Map<string, Conflict> = new Map();
private readonly _resolvedConflicts: Map<string, ResolvedConflict> = new Map();
private readonly _onAddedResolution: vscode.EventEmitter<void> = new vscode.EventEmitter<void>();
public readonly onAddedResolution: vscode.Event<void> = this._onAddedResolution.event;

constructor(public readonly startingConflicts: Conflict[], public readonly repositoryName: string, public readonly prBaseOwner: string,
public readonly latestPrBaseSha: string,
public readonly prHeadOwner: string, public readonly prHeadBranchName: string,
public readonly prBaseBranchName: string, public readonly prMergeBaseRef: string) {

for (const conflict of startingConflicts) {
this._startingConflicts.set(conflict.prHeadFilePath, conflict);
}
}

isResolvable(): boolean {
return Array.from(this._startingConflicts.values()).every(conflict => {
return !conflict.filePathConflict && !conflict.modeConflict;
});
}

addResolution(filePath: string, contents: string): void {
this._resolvedConflicts.set(filePath, { prHeadFilePath: filePath, resolvedContents: contents });
this._onAddedResolution.fire();
}

isResolved(filePath: string): boolean {
if (!this._startingConflicts.has(filePath)) {
throw new Error('Not a conflict file');
}
return this._resolvedConflicts.has(filePath);
}

get areAllConflictsResolved(): boolean {
return this._resolvedConflicts.size === this._startingConflicts.size;
}

get resolvedConflicts(): Map<string, ResolvedConflict> {
if (this._resolvedConflicts.size !== this._startingConflicts.size) {
throw new Error('Not all conflicts have been resolved');
}
return this._resolvedConflicts;
}

public mergeOutputUri(conflict: Conflict) {
return vscode.Uri.parse(`${Schemes.MergeOutput}:/${conflict.prHeadFilePath}`);
}

public mergeBaseUri(conflict: { prHeadFilePath: string }): vscode.Uri {
const fileUri = vscode.Uri.file(conflict.prHeadFilePath);
return toGitHubUri(fileUri, Schemes.GithubPr, { fileName: conflict.prHeadFilePath, branch: this.prMergeBaseRef, owner: this.prBaseOwner });
}

public baseUri(conflict: Conflict): vscode.Uri {
const fileUri = vscode.Uri.file(conflict.prHeadFilePath);
return toGitHubUri(fileUri, Schemes.GithubPr, { fileName: conflict.prHeadFilePath, branch: this.latestPrBaseSha, owner: this.prBaseOwner });
}

public prHeadUri(conflict: Conflict): vscode.Uri {
const fileUri = vscode.Uri.file(conflict.prHeadFilePath);
return toGitHubUri(fileUri, Schemes.GithubPr, { fileName: conflict.prHeadFilePath, branch: this.prHeadBranchName, owner: this.prHeadOwner });
}
}
Loading

0 comments on commit bb0a2fb

Please sign in to comment.