diff --git a/common/views.ts b/common/views.ts index 9e2f35fdf2..0c70a2f7c8 100644 --- a/common/views.ts +++ b/common/views.ts @@ -83,6 +83,10 @@ export interface CreatePullRequestNew { milestone?: IMilestone; } +export interface CancelCreatePullRequestNew extends CreatePullRequestNew { + hasUnsavedChanges: boolean; +} + // #region new create view export interface CreateParamsNew { diff --git a/package.json b/package.json index 388c22181e..cb0bf15ed3 100644 --- a/package.json +++ b/package.json @@ -153,6 +153,11 @@ "deprecationMessage": "Use the setting 'githubPullRequests.defaultCreateOption' instead.", "description": "%githubPullRequests.createDraft%" }, + "githubPullRequests.showPullRequestCancelConfirmation": { + "type": "boolean", + "default": true, + "description": "%githubPullRequests.showPullRequestCancelConfirmation%" + }, "githubPullRequests.logLevel": { "type": "string", "enum": [ diff --git a/package.nls.json b/package.nls.json index ed80e9bbf5..698f50859c 100644 --- a/package.nls.json +++ b/package.nls.json @@ -13,6 +13,7 @@ "githubPullRequests.defaultCreateOption.createDraft": "The pull request will be created as a draft.", "githubPullRequests.defaultCreateOption.createAutoMerge": "The pull request will be created with auto-merge enabled. The merge method selected will be the default for the repo or the value of `githubPullRequests.defaultMergeMethod` if set.", "githubPullRequests.createDraft": "Whether the \"Draft\" checkbox will be checked by default when creating a pull request.", + "githubPullRequests.showPullRequestCancelConfirmation": "Show a confirmation dialog when canceling pull request creation if there are unsaved changes (a manually edited title or description, or any selected labels, assignees, reviewers, projects, or milestone).", "githubPullRequests.logLevel.description": "Logging for GitHub Pull Request extension. The log is emitted to the output channel named GitHub Pull Request.", "githubPullRequests.logLevel.markdownDeprecationMessage": { "message": "Log level is now controlled by the [Developer: Set Log Level...](command:workbench.action.setLogLevel) command. You can set the log level for the current session and also the default log level from there.", diff --git a/src/common/settingKeys.ts b/src/common/settingKeys.ts index ba0654cc23..2ac936b813 100644 --- a/src/common/settingKeys.ts +++ b/src/common/settingKeys.ts @@ -29,6 +29,7 @@ export const QUERIES = 'queries'; export const PULL_REQUEST_LABELS = 'labelCreated'; export const FOCUSED_MODE = 'focusedMode'; export const CREATE_DRAFT = 'createDraft'; +export const SHOW_PULL_REQUEST_CANCEL_CONFIRMATION = 'showPullRequestCancelConfirmation'; export const QUICK_DIFF = 'quickDiff'; export const SET_AUTO_MERGE = 'setAutoMerge'; export const SHOW_PULL_REQUEST_NUMBER_IN_TREE = 'showPullRequestNumberInTree'; diff --git a/src/github/createPRViewProvider.ts b/src/github/createPRViewProvider.ts index 1a9d245633..cd1a8a9d66 100644 --- a/src/github/createPRViewProvider.ts +++ b/src/github/createPRViewProvider.ts @@ -19,7 +19,7 @@ import { branchPicks, cachedBranchPicks, getAssigneesQuickPickItems, getLabelOpt import { ISSUE_EXPRESSION, parseIssueExpressionOutput, variableSubstitution } from './utils'; import { ChangeTemplateReply, DisplayLabel, PreReviewState } from './views'; import { RemoteInfo } from '../../common/types'; -import { ChooseBaseRemoteAndBranchResult, ChooseCompareRemoteAndBranchResult, ChooseRemoteAndBranchArgs, CreateParamsNew, CreatePullRequestNew, TitleAndDescriptionArgs } from '../../common/views'; +import { CancelCreatePullRequestNew, ChooseBaseRemoteAndBranchResult, ChooseCompareRemoteAndBranchResult, ChooseRemoteAndBranchArgs, CreateParamsNew, CreatePullRequestNew, TitleAndDescriptionArgs } from '../../common/views'; import type { Branch } from '../api/api'; import { debounce } from '../common/async'; import { GitHubServerType } from '../common/authentication'; @@ -35,7 +35,8 @@ import { PR_SETTINGS_NAMESPACE, PULL_REQUEST_DESCRIPTION, PULL_REQUEST_LABELS, - PUSH_BRANCH + PUSH_BRANCH, + SHOW_PULL_REQUEST_CANCEL_CONFIRMATION } from '../common/settingKeys'; import { ITelemetry } from '../common/telemetry'; import { asPromise, compareIgnoreCase, formatError, promiseWithTimeout } from '../common/utils'; @@ -560,11 +561,28 @@ export abstract class BaseCreatePullRequestViewProvider) { + private async cancel(message: IRequestMessage) { + if (message.args.hasUnsavedChanges + && vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(SHOW_PULL_REQUEST_CANCEL_CONFIRMATION, true)) { + const discard = vscode.l10n.t('Discard'); + const dontAskAgain = vscode.l10n.t('Don\'t Ask Again'); + const result = await vscode.window.showWarningMessage( + vscode.l10n.t('Are you sure you want to cancel creating this pull request?'), + { modal: true, detail: vscode.l10n.t('Your unsaved changes to this pull request will be lost.') }, + discard, + dontAskAgain + ); + if (result !== discard && result !== dontAskAgain) { + return this._replyMessage(message, { cancelled: false }); + } + if (result === dontAskAgain) { + await vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).update(SHOW_PULL_REQUEST_CANCEL_CONFIRMATION, false, vscode.ConfigurationTarget.Global); + } + } this._onDone.fire(undefined); // Re-fetch the automerge info so that it's updated for next time. await this.getMergeConfiguration(message.args.owner, message.args.repo, true); - return this._replyMessage(message, undefined); + return this._replyMessage(message, { cancelled: true }); } private async openDescriptionSettings(): Promise { diff --git a/webviews/common/createContextNew.ts b/webviews/common/createContextNew.ts index b1bec93809..0e4de1a04b 100644 --- a/webviews/common/createContextNew.ts +++ b/webviews/common/createContextNew.ts @@ -6,7 +6,7 @@ import { createContext } from 'react'; import { getMessageHandler, MessageHandler, vscode } from './message'; import { RemoteInfo } from '../../common/types'; -import { ChooseBaseRemoteAndBranchResult, ChooseCompareRemoteAndBranchResult, ChooseRemoteAndBranchArgs, CreateParamsNew, CreatePullRequestNew, ScrollPosition, TitleAndDescriptionArgs, TitleAndDescriptionResult } from '../../common/views'; +import { CancelCreatePullRequestNew, ChooseBaseRemoteAndBranchResult, ChooseCompareRemoteAndBranchResult, ChooseRemoteAndBranchArgs, CreateParamsNew, CreatePullRequestNew, ScrollPosition, TitleAndDescriptionArgs, TitleAndDescriptionResult } from '../../common/views'; import { compareIgnoreCase } from '../../src/common/utils'; import { PreReviewState } from '../../src/github/views'; @@ -92,12 +92,33 @@ export class CreatePRContextNew { } }; - public cancelCreate = (): Promise => { - const args = this.copyParams(); - vscode.setState(defaultCreateParams); - return this.postMessage({ command: 'pr.cancelCreate', args }); + public cancelCreate = async (): Promise => { + const args: CancelCreatePullRequestNew = { + ...this.copyParams(), + hasUnsavedChanges: this.hasUnsavedChanges() + }; + const result = await this.postMessage({ command: 'pr.cancelCreate', args }) as { cancelled?: boolean } | undefined; + // Only clear persisted state if the extension explicitly confirmed the + // cancellation. Otherwise (e.g. the user declined the confirmation + // dialog, or the message did not get a response) preserve the user's + // in-progress title/description. + if (result?.cancelled === true) { + vscode.setState(defaultCreateParams); + } }; + private hasUnsavedChanges(): boolean { + const params = this.createParams; + const titleEdited = !!params.pendingTitle && params.pendingTitle !== params.defaultTitle; + const descriptionEdited = !!params.pendingDescription && params.pendingDescription !== params.defaultDescription; + const hasLabels = (params.labels?.length ?? 0) > 0; + const hasAssignees = (params.assignees?.length ?? 0) > 0; + const hasReviewers = (params.reviewers?.length ?? 0) > 0; + const hasProjects = (params.projects?.length ?? 0) > 0; + const hasMilestone = !!params.milestone; + return titleEdited || descriptionEdited || hasLabels || hasAssignees || hasReviewers || hasProjects || hasMilestone; + } + public updateState = (params: Partial, reset: boolean = false): void => { this.createParams = reset ? { ...defaultCreateParams, ...params } : { ...this.createParams, ...params }; vscode.setState(this.createParams);