diff --git a/src/commands/git/search.ts b/src/commands/git/search.ts index ec33f75434eb4..058f0e0f8dafe 100644 --- a/src/commands/git/search.ts +++ b/src/commands/git/search.ts @@ -28,14 +28,7 @@ import type { StepSelection, StepState, } from '../quickCommand'; -import { - canPickStepContinue, - createPickStep, - endSteps, - freezeStep, - QuickCommand, - StepResultBreak, -} from '../quickCommand'; +import { canPickStepContinue, createPickStep, endSteps, QuickCommand, StepResultBreak } from '../quickCommand'; import { MatchAllToggleQuickInputButton, MatchCaseToggleQuickInputButton, @@ -470,7 +463,7 @@ async function updateSearchQuery( let append = false; if (usePickers?.author && item.item === 'author:') { - using frozen = freezeStep(step, quickpick); + using _frozen = step.freeze?.(); const authors = ops.get('author:'); @@ -492,8 +485,6 @@ async function updateSearchQuery( }, ); - frozen[Symbol.dispose](); - if (contributors != null) { const authors = contributors .map(c => c.email ?? c.name ?? c.username) @@ -507,7 +498,7 @@ async function updateSearchQuery( append = true; } } else if (usePickers?.file && item.item === 'file:') { - using frozen = freezeStep(step, quickpick); + using _frozen = step.freeze?.(); let files = ops.get('file:'); @@ -520,8 +511,6 @@ async function updateSearchQuery( defaultUri: state.repo.folder?.uri, }); - frozen[Symbol.dispose](); - if (uris?.length) { if (files == null) { files = new Set(); diff --git a/src/commands/quickCommand.ts b/src/commands/quickCommand.ts index 6f5da346e8943..58bf2291577c3 100644 --- a/src/commands/quickCommand.ts +++ b/src/commands/quickCommand.ts @@ -66,6 +66,8 @@ export interface QuickPickStep { value?: string; selectValueWhenShown?: boolean; + quickpick?: QuickPick; + freeze?: () => Disposable; frozen?: boolean; onDidActivate?(quickpick: QuickPick): void; @@ -361,7 +363,28 @@ export function createInputStep(step: Optional(step: Optional, 'type'>): QuickPickStep { - return { type: 'pick', ...step }; + const original = step.onDidActivate; + step = { type: 'pick' as const, ...step }; + step.onDidActivate = qp => { + step.quickpick = qp; + step.freeze = () => { + qp.enabled = false; + const originalFocusOut = qp.ignoreFocusOut; + qp.ignoreFocusOut = true; + step.frozen = true; + return { + [Symbol.dispose]: () => { + step.frozen = false; + qp.enabled = true; + qp.ignoreFocusOut = originalFocusOut; + qp.show(); + }, + }; + }; + original?.(qp); + }; + + return step as QuickPickStep; } export function createCustomStep(step: Optional, 'type'>): CustomStep { @@ -372,18 +395,6 @@ export function endSteps(state: PartialStepState) { state.counter = -1; } -export function freezeStep(step: QuickPickStep, quickpick: QuickPick): Disposable { - quickpick.enabled = false; - step.frozen = true; - return { - [Symbol.dispose]: () => { - step.frozen = false; - quickpick.enabled = true; - quickpick.show(); - }, - }; -} - export interface CrossCommandReference { command: Commands; args?: T; diff --git a/src/constants.commands.ts b/src/constants.commands.ts index fe6e5d08e1f8c..1f604ea6be657 100644 --- a/src/constants.commands.ts +++ b/src/constants.commands.ts @@ -329,6 +329,17 @@ export type CoreGitCommands = | 'git.pushForce' | 'git.undoCommit'; +export type CustomViewCommands = + | 'gitlens.home.openInGraph' + | 'gitlens.home.fetch' + | 'gitlens.home.openPullRequestChanges' + | 'gitlens.home.openPullRequestOnRemote' + | 'gitlens.home.createPullRequest' + | 'gitlens.home.openWorktree' + | 'gitlens.home.switchToBranch' + | 'gitlens.home.createBranch' + | 'gitlens.home.startWork'; + export type TreeViewCommands = `gitlens.views.${ | `branches.${ | 'copy' diff --git a/src/constants.telemetry.ts b/src/constants.telemetry.ts index aa922169b0026..1816775e5145c 100644 --- a/src/constants.telemetry.ts +++ b/src/constants.telemetry.ts @@ -8,7 +8,6 @@ import type { CustomEditorTypes, TreeViewTypes, WebviewTypes, WebviewViewTypes } import type { FeaturePreviews, FeaturePreviewStatus } from './features'; import type { GitContributionTiers } from './git/models/contributor'; import type { Subscription, SubscriptionAccount } from './plus/gk/account/subscription'; -import type { StartWorkType } from './plus/startWork/startWork'; import type { GraphColumnConfig } from './plus/webviews/graph/protocol'; import type { Period } from './plus/webviews/timeline/protocol'; import type { Flatten } from './system/object'; @@ -227,6 +226,10 @@ export type TelemetryEvents = { enabled: boolean; version: string; }; + /** Sent when the user chooses to create a branch from the home view */ + 'home/createBranch': void; + /** Sent when the user chooses to start work on an issue from the home view */ + 'home/startWork': void; /** Sent when the user takes an action on the Launchpad title bar */ 'launchpad/title/action': { @@ -360,25 +363,26 @@ export type TelemetryEvents = { 'startWork/open': StartWorkEventDataBase; /** Sent when the launchpad is opened; use `instance` to correlate a StartWork "session" */ 'startWork/opened': StartWorkConnectedEventData; - /** Sent when the user chooses an option to start work in the first step */ - 'startWork/type/chosen': { - type: StartWorkType; - } & StartWorkConnectedEventData; /** Sent when the user takes an action on a StartWork issue */ 'startWork/issue/action': { action: 'soft-open'; - type: StartWorkType; } & StartWorkConnectedEventData & Partial>; /** Sent when the user chooses an issue to start work in the second step */ - 'startWork/issue/chosen': { - type: StartWorkType; - } & StartWorkConnectedEventData & + 'startWork/issue/chosen': StartWorkConnectedEventData & Partial>; - /** Sent when the Start Work has "reloaded" (while open, e.g. user refreshed or back button) and is disconnected; use `instance` to correlate a Start Work "session" */ - 'startWork/steps/type': StartWorkConnectedEventData; + /** Sent when the user reaches the "connect an integration" step of Start Work */ 'startWork/steps/connect': StartWorkConnectedEventData; + /** Sent when the user reaches the "choose an issue" step of Start Work */ 'startWork/steps/issue': StartWorkConnectedEventData; + /** Sent when the user chooses to connect an integration */ + 'startWork/title/action': StartWorkConnectedEventData & { + action: 'connect'; + }; + /** Sent when the user chooses to manage integrations */ + 'startWork/action': StartWorkConnectedEventData & { + action: 'manage' | 'connect'; + }; /** Sent when the subscription is loaded */ subscription: SubscriptionEventData; @@ -602,7 +606,6 @@ interface RepositoryOpenedEventData extends RepositoryEventData, RepositoryContr type StartWorkEventDataBase = { instance: number; - type?: StartWorkType; }; type StartWorkEventData = { diff --git a/src/plus/launchpad/launchpad.ts b/src/plus/launchpad/launchpad.ts index 25115f0079320..b3e3cd996c69a 100644 --- a/src/plus/launchpad/launchpad.ts +++ b/src/plus/launchpad/launchpad.ts @@ -13,7 +13,6 @@ import { canPickStepContinue, createPickStep, endSteps, - freezeStep, QuickCommand, StepResultBreak, } from '../../commands/quickCommand'; @@ -978,20 +977,23 @@ export class LaunchpadCommand extends QuickCommand { state: StepState, context: Context, ): AsyncStepResultGenerator<{ connected: boolean | IntegrationId; resume: () => void }> { - const confirmations: (QuickPickItemOfT | DirectiveQuickPickItem)[] = [ - createDirectiveQuickPickItem(Directive.Cancel, undefined, { - label: 'Launchpad prioritizes your pull requests to keep you focused and your team unblocked', - detail: 'Click to learn more about Launchpad', - iconPath: new ThemeIcon('rocket'), - onDidSelect: () => - void executeCommand(Commands.OpenWalkthrough, { - step: 'accelerate-pr-reviews', - source: 'launchpad', - detail: 'info', + const hasConnectedIntegration = some(context.connectedIntegrations.values(), c => c); + const confirmations: (QuickPickItemOfT | DirectiveQuickPickItem)[] = !hasConnectedIntegration + ? [ + createDirectiveQuickPickItem(Directive.Cancel, undefined, { + label: 'Launchpad prioritizes your pull requests to keep you focused and your team unblocked', + detail: 'Click to learn more about Launchpad', + iconPath: new ThemeIcon('rocket'), + onDidSelect: () => + void executeCommand(Commands.OpenWalkthrough, { + step: 'accelerate-pr-reviews', + source: 'launchpad', + detail: 'info', + }), }), - }), - createQuickPickSeparator(), - ]; + createQuickPickSeparator(), + ] + : []; for (const integration of supportedLaunchpadIntegrations) { if (context.connectedIntegrations.get(integration)) { @@ -1032,19 +1034,12 @@ export class LaunchpadCommand extends QuickCommand { { placeholder: 'Connect an integration to get started with Launchpad', buttons: [], ignoreFocusOut: false }, ); - // Note: This is a hack to allow the quickpick to stay alive after the user finishes connecting the integration. - // Otherwise it disappears. - let freeze!: () => Disposable; - step.onDidActivate = qp => { - freeze = () => freezeStep(step, qp); - }; - const selection: StepSelection = yield step; if (canPickStepContinue(step, state, selection)) { - const resume = freeze(); + const resume = step.freeze?.(); const chosenIntegrationId = selection[0].item; const connected = await this.ensureIntegrationConnected(chosenIntegrationId); - return { connected: connected ? chosenIntegrationId : false, resume: () => resume[Symbol.dispose]() }; + return { connected: connected ? chosenIntegrationId : false, resume: () => resume?.[Symbol.dispose]() }; } return StepResultBreak; @@ -1058,18 +1053,22 @@ export class LaunchpadCommand extends QuickCommand { const step = this.createConfirmStep( `${this.title} \u00a0\u2022\u00a0 Connect an ${hasConnectedIntegration ? 'Additional ' : ''}Integration`, [ - createDirectiveQuickPickItem(Directive.Cancel, undefined, { - label: 'Launchpad prioritizes your pull requests to keep you focused and your team unblocked', - detail: 'Click to learn more about Launchpad', - iconPath: new ThemeIcon('rocket'), - onDidSelect: () => - void executeCommand(Commands.OpenWalkthrough, { - step: 'accelerate-pr-reviews', - source: 'launchpad', - detail: 'info', - }), - }), - createQuickPickSeparator(), + ...(hasConnectedIntegration + ? [] + : [ + createDirectiveQuickPickItem(Directive.Cancel, undefined, { + label: 'Launchpad prioritizes your pull requests to keep you focused and your team unblocked', + detail: 'Click to learn more about Launchpad', + iconPath: new ThemeIcon('rocket'), + onDidSelect: () => + void executeCommand(Commands.OpenWalkthrough, { + step: 'accelerate-pr-reviews', + source: 'launchpad', + detail: 'info', + }), + }), + createQuickPickSeparator(), + ]), createQuickPickItemOfT( { label: `Connect an ${hasConnectedIntegration ? 'Additional ' : ''}Integration...`, @@ -1091,30 +1090,25 @@ export class LaunchpadCommand extends QuickCommand { }, ); - // Note: This is a hack to allow the quickpick to stay alive after the user finishes connecting the integration. - // Otherwise it disappears. - let freeze!: () => Disposable; - let quickpick!: QuickPick; - step.onDidActivate = qp => { - quickpick = qp; - freeze = () => freezeStep(step, qp); - }; - const selection: StepSelection = yield step; if (canPickStepContinue(step, state, selection)) { - const previousPlaceholder = quickpick.placeholder; - quickpick.placeholder = 'Connecting integrations...'; - quickpick.ignoreFocusOut = true; - const resume = freeze(); + let previousPlaceholder: string | undefined; + if (step.quickpick) { + previousPlaceholder = step.quickpick.placeholder; + step.quickpick.placeholder = 'Connecting integrations...'; + } + const resume = step.freeze?.(); const connected = await this.container.integrations.connectCloudIntegrations( { integrationIds: supportedLaunchpadIntegrations }, { source: 'launchpad', }, ); - quickpick.placeholder = previousPlaceholder; - return { connected: connected, resume: () => resume[Symbol.dispose]() }; + if (step.quickpick) { + step.quickpick.placeholder = previousPlaceholder; + } + return { connected: connected, resume: () => resume?.[Symbol.dispose]() }; } return StepResultBreak; diff --git a/src/plus/launchpad/launchpadProvider.ts b/src/plus/launchpad/launchpadProvider.ts index ff8e34a1c0e75..a22d3bc9c337c 100644 --- a/src/plus/launchpad/launchpadProvider.ts +++ b/src/plus/launchpad/launchpadProvider.ts @@ -921,7 +921,9 @@ export class LaunchpadProvider implements Disposable { await Promise.allSettled( supportedLaunchpadIntegrations.map(async integrationId => { const integration = await this.container.integrations.get(integrationId); - connected.set(integrationId, integration.maybeConnected ?? (await integration.isConnected())); + const isConnected = integration.maybeConnected ?? (await integration.isConnected()); + const hasAccess = isConnected && (await integration.access()); + connected.set(integrationId, hasAccess); }), ); diff --git a/src/plus/startWork/startWork.ts b/src/plus/startWork/startWork.ts index 0d0251587f0d8..478076a3b7317 100644 --- a/src/plus/startWork/startWork.ts +++ b/src/plus/startWork/startWork.ts @@ -1,12 +1,12 @@ import { md5 } from '@env/crypto'; import slug from 'slug'; -import type { QuickInputButton, QuickPick } from 'vscode'; +import type { QuickInputButton, QuickPick, QuickPickItem } from 'vscode'; import { Uri } from 'vscode'; import type { AsyncStepResultGenerator, PartialStepState, + QuickPickStep, StepGenerator, - StepResultGenerator, StepSelection, StepState, } from '../../commands/quickCommand'; @@ -14,20 +14,21 @@ import { canPickStepContinue, createPickStep, endSteps, - freezeStep, QuickCommand, StepResultBreak, } from '../../commands/quickCommand'; import { + ConnectIntegrationButton, OpenOnGitHubQuickInputButton, OpenOnGitLabQuickInputButton, OpenOnJiraQuickInputButton, } from '../../commands/quickCommand.buttons'; import { getSteps } from '../../commands/quickWizard.utils'; import { proBadge } from '../../constants'; +import { Commands } from '../../constants.commands'; import type { IntegrationId } from '../../constants.integrations'; import { HostingIntegrationId, IssueIntegrationId } from '../../constants.integrations'; -import type { Source, Sources, StartWorkTelemetryContext } from '../../constants.telemetry'; +import type { Source, Sources, StartWorkTelemetryContext, TelemetryEvents } from '../../constants.telemetry'; import type { Container } from '../../container'; import type { Issue, IssueShape, SearchedIssue } from '../../git/models/issue'; import { getOrOpenIssueRepository } from '../../git/models/issue'; @@ -39,6 +40,7 @@ import { createDirectiveQuickPickItem, Directive } from '../../quickpicks/items/ import { getScopedCounter } from '../../system/counter'; import { fromNow } from '../../system/date'; import { some } from '../../system/iterable'; +import { executeCommand } from '../../system/vscode/command'; import { configuration } from '../../system/vscode/configuration'; import { openUrl } from '../../system/vscode/utils'; @@ -49,7 +51,7 @@ export type StartWorkItem = { export type StartWorkResult = { items: StartWorkItem[] }; interface Context { - result: StartWorkResult; + result?: StartWorkResult; title: string; telemetryContext: StartWorkTelemetryContext | undefined; connectedIntegrations: Map; @@ -57,19 +59,20 @@ interface Context { interface State { item?: StartWorkItem; - type?: StartWorkType; -} -interface StateWithType extends State { - type: StartWorkType; } -export type StartWorkType = 'branch' | 'issue'; -type StartWorkTypeItem = { type: StartWorkType }; +type StartWorkStepState = RequireSome, 'item'>; + +function assertsStartWorkStepState(state: StepState): asserts state is StartWorkStepState { + if (state.item != null) return; + + debugger; + throw new Error('Missing item'); +} export interface StartWorkCommandArgs { readonly command: 'startWork'; source?: Sources; - type?: StartWorkType; } export const supportedStartWorkIntegrations = [ @@ -80,9 +83,38 @@ export const supportedStartWorkIntegrations = [ export type SupportedStartWorkIntegrationIds = (typeof supportedStartWorkIntegrations)[number]; const instanceCounter = getScopedCounter(); +type ConnectMoreIntegrationsItem = QuickPickItem & { + item: undefined; +}; + +type ManageIntegrationsItem = QuickPickItem & { + item: undefined; +}; + +const connectMoreIntegrationsItem: ConnectMoreIntegrationsItem = { + label: 'Connect more integrations', + detail: 'Connect integration with more issue providers', + item: undefined, +}; + +const manageIntegrationsItem: ManageIntegrationsItem = { + label: 'Manage integrations...', + detail: 'Manage your connected integrations', + item: undefined, +}; + +function isConnectMoreIntegrationsItem(item: unknown): item is ConnectMoreIntegrationsItem { + return item === connectMoreIntegrationsItem; +} + +function isManageIntegrationsItem(item: unknown): item is ManageIntegrationsItem { + return item === manageIntegrationsItem; +} + export class StartWorkCommand extends QuickCommand { private readonly source: Source; private readonly telemetryContext: StartWorkTelemetryContext | undefined; + constructor(container: Container, args?: StartWorkCommandArgs) { super(container, 'startWork', 'startWork', `Start Work\u00a0\u00a0${proBadge}`, { description: 'Start work on an issue', @@ -96,8 +128,7 @@ export class StartWorkCommand extends QuickCommand { } this.initialState = { - counter: args?.type != null ? 1 : 0, - type: args?.type, + counter: 0, }; } @@ -107,101 +138,80 @@ export class StartWorkCommand extends QuickCommand { } const context: Context = { - result: { items: [] }, + result: undefined, title: this.title, telemetryContext: this.telemetryContext, - connectedIntegrations: await this.getConnectedIntegrations(), + connectedIntegrations: await getConnectedIntegrations(this.container), }; let opened = false; while (this.canStepsContinue(state)) { - const hasConnectedIntegrations = this.hasConnectedIntegrations(context); context.title = this.title; + const hasConnectedIntegrations = [...context.connectedIntegrations.values()].some(c => c); - if (state.counter < 1 || state.type == null) { + if (!hasConnectedIntegrations) { if (this.container.telemetry.enabled) { this.container.telemetry.sendEvent( - opened ? 'startWork/steps/type' : 'startWork/opened', + opened ? 'startWork/steps/connect' : 'startWork/opened', { ...context.telemetryContext!, - connected: hasConnectedIntegrations, + connected: false, }, this.source, ); } opened = true; - const result = yield* this.selectTypeStep(state); - if (result === StepResultBreak) continue; - state.type = result.type; + const isUsingCloudIntegrations = configuration.get('cloudIntegrations.enabled', undefined, false); + const result = isUsingCloudIntegrations + ? yield* this.confirmCloudIntegrationsConnectStep(state, context) + : yield* this.confirmLocalIntegrationConnectStep(state, context); + if (result === StepResultBreak) { + return result; + } + + result.resume(); + + const connected = result.connected; + if (!connected) { + continue; + } + } + + if (state.counter < 1 || state.item == null) { if (this.container.telemetry.enabled) { this.container.telemetry.sendEvent( - 'startWork/type/chosen', + opened ? 'startWork/steps/issue' : 'startWork/opened', { ...context.telemetryContext!, - connected: hasConnectedIntegrations, - type: state.type, + connected: true, }, this.source, ); } - } - if (state.counter < 2 && state.type === 'issue') { - if (!hasConnectedIntegrations) { - if (this.container.telemetry.enabled) { - this.container.telemetry.sendEvent( - opened ? 'startWork/steps/connect' : 'startWork/opened', - { - ...context.telemetryContext!, - connected: false, - type: state.type, - }, - this.source, - ); - } - - opened = true; - - const isUsingCloudIntegrations = configuration.get('cloudIntegrations.enabled', undefined, false); - const result = isUsingCloudIntegrations - ? yield* this.confirmCloudIntegrationsConnectStep(state, context) - : yield* this.confirmLocalIntegrationConnectStep(state, context); - if (result === StepResultBreak) { - return result; - } - context.connectedIntegrations = await this.getConnectedIntegrations(); - if (!this.hasConnectedIntegrations(context)) { - state.counter--; - continue; - } - } - - assertsTypeStepState(state); - const result = yield* this.pickIssueStep(state, context, opened); opened = true; + + const result = yield* this.pickStartWorkIssueStep(state, context); if (result === StepResultBreak) continue; - if (!isStartWorkTypeItem(result)) { - state.item = result; - if (this.container.telemetry.enabled) { - this.container.telemetry.sendEvent( - 'startWork/issue/chosen', - { - ...context.telemetryContext!, - ...buildItemTelemetryData(result), - connected: true, - type: state.type, - }, - this.source, - ); - } - } else { - state.type = result.type; + state.item = result; + if (this.container.telemetry.enabled) { + this.container.telemetry.sendEvent( + 'startWork/issue/chosen', + { + ...context.telemetryContext!, + ...buildItemTelemetryData(result), + connected: true, + }, + this.source, + ); } } - const issue = state.item?.item?.issue; + assertsStartWorkStepState(state); + + const issue = state.item.item.issue; const repo = issue && (await this.getIssueRepositoryIfExists(issue)); const result = yield* getSteps( @@ -220,38 +230,14 @@ export class StartWorkCommand extends QuickCommand { this.pickedVia, ); if (result === StepResultBreak) { - endSteps(state); - } else { - state.counter--; + state.counter = 0; + continue; } } return state.counter < 0 ? StepResultBreak : undefined; } - private *selectTypeStep(state: StepState): StepResultGenerator<{ type: StartWorkType }> { - const step = createPickStep({ - placeholder: 'Choose how to start work', - items: [ - createQuickPickItemOfT( - { - label: 'Create Branch from Issue...', - detail: 'Will create a new branch after selecting an issue', - }, - { type: 'issue' }, - ), - createQuickPickItemOfT( - { label: 'Create Branch...', detail: 'Will create a new branch after selecting a reference' }, - { - type: 'branch', - }, - ), - ], - }); - const selection: StepSelection = yield step; - return canPickStepContinue(step, state, selection) ? selection[0].item : StepResultBreak; - } - private async getIssueRepositoryIfExists(issue: IssueShape | Issue): Promise { try { return await getOrOpenIssueRepository(this.container, issue); @@ -264,6 +250,7 @@ export class StartWorkCommand extends QuickCommand { state: StepState, context: Context, ): AsyncStepResultGenerator<{ connected: boolean | IntegrationId; resume: () => void }> { + context.result = undefined; const confirmations: (QuickPickItemOfT | DirectiveQuickPickItem)[] = []; for (const integration of supportedStartWorkIntegrations) { @@ -298,19 +285,12 @@ export class StartWorkCommand extends QuickCommand { }, ); - // Note: This is a hack to allow the quickpick to stay alive after the user finishes connecting the integration. - // Otherwise it disappears. - let freeze!: () => Disposable; - step.onDidActivate = qp => { - freeze = () => freezeStep(step, qp); - }; - const selection: StepSelection = yield step; if (canPickStepContinue(step, state, selection)) { - const resume = freeze(); + const resume = step.freeze?.(); const chosenIntegrationId = selection[0].item; const connected = await this.ensureIntegrationConnected(chosenIntegrationId); - return { connected: connected ? chosenIntegrationId : false, resume: () => resume[Symbol.dispose]() }; + return { connected: connected ? chosenIntegrationId : false, resume: () => resume?.[Symbol.dispose]() }; } return StepResultBreak; @@ -329,70 +309,77 @@ export class StartWorkCommand extends QuickCommand { private async *confirmCloudIntegrationsConnectStep( state: StepState, context: Context, + overrideStep?: QuickPickStep>, ): AsyncStepResultGenerator<{ connected: boolean | IntegrationId; resume: () => void }> { // TODO: This step is almost an exact copy of the similar one from launchpad.ts. Do we want to do anything about it? Maybe to move it to an util function with ability to parameterize labels? const hasConnectedIntegration = some(context.connectedIntegrations.values(), c => c); - const step = this.createConfirmStep( - `${this.title} \u00a0\u2022\u00a0 Connect an ${hasConnectedIntegration ? 'Additional ' : ''}Integration`, - [ - createQuickPickItemOfT( - { - label: `Connect an ${hasConnectedIntegration ? 'Additional ' : ''}Integration...`, - detail: hasConnectedIntegration - ? 'Connect additional integrations to view their issues in Start Work' - : 'Connect an integration to accelerate your work', - picked: true, - }, - true, - ), - ], - createDirectiveQuickPickItem(Directive.Cancel, false, { label: 'Cancel' }), - { - placeholder: hasConnectedIntegration - ? 'Connect additional integrations to Start Work' - : 'Connect an integration to get started with Start Work', - buttons: [], - ignoreFocusOut: true, - }, - ); - - // Note: This is a hack to allow the quickpick to stay alive after the user finishes connecting the integration. - // Otherwise it disappears. - let freeze!: () => Disposable; - let quickpick!: QuickPick; - step.onDidActivate = qp => { - quickpick = qp; - freeze = () => freezeStep(step, qp); - }; + context.result = undefined; + let step; + let selection; + if (overrideStep == null) { + step = this.createConfirmStep( + `${this.title} \u00a0\u2022\u00a0 Connect an ${ + hasConnectedIntegration ? 'Additional ' : '' + }Integration`, + [ + createQuickPickItemOfT( + { + label: `Connect an ${hasConnectedIntegration ? 'Additional ' : ''}Integration...`, + detail: hasConnectedIntegration + ? 'Connect additional integrations to view their issues in Start Work' + : 'Connect an integration to accelerate your work', + picked: true, + }, + true, + ), + ], + createDirectiveQuickPickItem(Directive.Cancel, false, { label: 'Cancel' }), + { + placeholder: hasConnectedIntegration + ? 'Connect additional integrations to Start Work' + : 'Connect an integration to get started with Start Work', + buttons: [], + ignoreFocusOut: true, + }, + ); - const selection: StepSelection = yield step; + selection = yield step; + } else { + step = overrideStep; + selection = [true]; + } if (canPickStepContinue(step, state, selection)) { - const previousPlaceholder = quickpick.placeholder; - quickpick.placeholder = 'Connecting integrations...'; - quickpick.ignoreFocusOut = true; - const resume = freeze(); + let previousPlaceholder: string | undefined; + if (step.quickpick) { + previousPlaceholder = step.quickpick.placeholder; + step.quickpick.placeholder = 'Connecting integrations...'; + } + const resume = step.freeze?.(); const connected = await this.container.integrations.connectCloudIntegrations( { integrationIds: supportedStartWorkIntegrations }, { source: 'startWork', }, ); - quickpick.placeholder = previousPlaceholder; - return { connected: connected, resume: () => resume[Symbol.dispose]() }; + if (step.quickpick) { + step.quickpick.placeholder = previousPlaceholder; + } + return { connected: connected, resume: () => resume?.[Symbol.dispose]() }; } return StepResultBreak; } - private *pickIssueStep( - state: StepState, + private async *pickStartWorkIssueStep( + state: StepState, context: Context, - opened: boolean, - ): StepResultGenerator { - const buildIssueItem = (i: StartWorkItem) => { - const onWebbButton = i.item.issue.url ? getOpenOnWebQuickInputButton(i.item.issue.provider.id) : undefined; - const buttons = onWebbButton ? [onWebbButton] : []; + ): AsyncStepResultGenerator { + const hasDisconnectedIntegrations = [...context.connectedIntegrations.values()].some(c => !c); + + const buildStartWorkQuickPickItem = (i: StartWorkItem) => { + const onWebButton = i.item.issue.url ? getOpenOnWebQuickInputButton(i.item.issue.provider.id) : undefined; + const buttons = onWebButton ? [onWebButton] : []; const hoverContent = i.item.issue.body ? `${repeatSpaces(200)}\n\n${i.item.issue.body}` : ''; return { label: @@ -414,7 +401,7 @@ export class StartWorkCommand extends QuickCommand { const items: QuickPickItemOfT[] = []; if (result.items?.length) { - items.push(...result.items.map(buildIssueItem)); + items.push(...result.items.map(buildStartWorkQuickPickItem)); } return items; @@ -422,18 +409,21 @@ export class StartWorkCommand extends QuickCommand { function getItemsAndPlaceholder(): { placeholder: string; - items: QuickPickItemOfT[]; + items: (DirectiveQuickPickItem | QuickPickItemOfT)[]; } { - if (!context.result.items.length) { + if (!context.result?.items.length) { return { - placeholder: 'No issues found. Start work anyway.', - items: [createQuickPickItemOfT('Create a branch', { type: 'branch' })], + placeholder: 'No issues found for your open repositories.', + items: [ + hasDisconnectedIntegrations ? connectMoreIntegrationsItem : manageIntegrationsItem, + createDirectiveQuickPickItem(Directive.Cancel), + ], }; } return { - placeholder: 'Choose an item to focus on', - items: getItems(context.result), + placeholder: 'Choose an issue to start working on', + items: [...getItems(context.result), createDirectiveQuickPickItem(Directive.Cancel)], }; } @@ -444,43 +434,36 @@ export class StartWorkCommand extends QuickCommand { const { items, placeholder } = getItemsAndPlaceholder(); quickpick.placeholder = placeholder; quickpick.items = items; - - if (this.container.telemetry.enabled) { - this.container.telemetry.sendEvent( - opened ? 'startWork/steps/issue' : 'startWork/opened', - { - ...context.telemetryContext!, - connected: true, - type: state.type, - }, - this.source, - ); - } } catch { quickpick.placeholder = 'Error retrieving issues'; - quickpick.items = []; + quickpick.items = [createDirectiveQuickPickItem(Directive.Cancel)]; } finally { quickpick.busy = false; } }; - const step = createPickStep>({ + const step = createPickStep>({ title: context.title, placeholder: 'Loading...', matchOnDescription: true, matchOnDetail: true, items: [], + buttons: [...(hasDisconnectedIntegrations ? [ConnectIntegrationButton] : [])], onDidActivate: updateItems, - onDidClickItemButton: (_quickpick, button, { item }) => { - if (isStartWorkTypeItem(item)) { - return false; + onDidClickButton: async (_quickpick, button) => { + switch (button) { + case ConnectIntegrationButton: + this.sendTitleActionTelemetry('connect', context); + return this.next([connectMoreIntegrationsItem]); } - + return undefined; + }, + onDidClickItemButton: (_quickpick, button, { item }) => { switch (button) { case OpenOnGitHubQuickInputButton: case OpenOnGitLabQuickInputButton: case OpenOnJiraQuickInputButton: - this.sendItemActionTelemetry('soft-open', item, state, context); + this.sendItemActionTelemetry('soft-open', item, context); this.open(item); return undefined; default: @@ -495,7 +478,24 @@ export class StartWorkCommand extends QuickCommand { return StepResultBreak; } const element = selection[0]; - return typeof element.item === 'string' ? element.item : { ...element.item }; + if (isConnectMoreIntegrationsItem(element)) { + this.sendTitleActionTelemetry('connect', context); + const isUsingCloudIntegrations = configuration.get('cloudIntegrations.enabled', undefined, false); + const result = isUsingCloudIntegrations + ? yield* this.confirmCloudIntegrationsConnectStep(state, context, step) + : yield* this.confirmLocalIntegrationConnectStep(state, context); + if (result === StepResultBreak) return result; + + result.resume(); + return StepResultBreak; + } else if (isManageIntegrationsItem(element)) { + this.sendActionTelemetry('manage', context); + executeCommand(Commands.PlusManageCloudIntegrations, { source: 'startWork' }); + endSteps(state); + return StepResultBreak; + } + + return { ...element.item }; } private open(item: StartWorkItem): void { @@ -503,46 +503,42 @@ export class StartWorkCommand extends QuickCommand { void openUrl(item.item.issue.url); } - private sendItemActionTelemetry( - action: 'soft-open', - item: StartWorkItem, - state: StepState, - context: Context, - ) { + private sendItemActionTelemetry(action: 'soft-open', item: StartWorkItem, context: Context) { this.container.telemetry.sendEvent('startWork/issue/action', { ...context.telemetryContext!, ...buildItemTelemetryData(item), action: action, connected: true, - type: state.type, }); } - private async getConnectedIntegrations(): Promise> { - const connected = new Map(); - await Promise.allSettled( - supportedStartWorkIntegrations.map(async integrationId => { - const integration = await this.container.integrations.get(integrationId); - const isConnected = integration.maybeConnected ?? (await integration.isConnected()); - const hasAccess = isConnected && (await integration.access()); - connected.set(integrationId, hasAccess); - }), - ); + private sendTitleActionTelemetry(action: TelemetryEvents['startWork/title/action']['action'], context: Context) { + if (!this.container.telemetry.enabled) return; - return connected; + this.container.telemetry.sendEvent( + 'startWork/title/action', + { ...context.telemetryContext!, connected: true, action: action }, + this.source, + ); } - private hasConnectedIntegrations(context: Context) { - return [...context.connectedIntegrations.values()].some(c => c); + private sendActionTelemetry(action: TelemetryEvents['startWork/action']['action'], context: Context) { + if (!this.container.telemetry.enabled) return; + + this.container.telemetry.sendEvent( + 'startWork/action', + { ...context.telemetryContext!, connected: true, action: action }, + this.source, + ); } } async function updateContextItems(container: Container, context: Context) { - const connectedIntegrationsMap = context.connectedIntegrations; - const connectedIntegrations = [...connectedIntegrationsMap.keys()].filter(integrationId => - Boolean(connectedIntegrationsMap.get(integrationId)), + context.connectedIntegrations = await getConnectedIntegrations(container); + const connectedIntegrations = [...context.connectedIntegrations.keys()].filter(integrationId => + Boolean(context.connectedIntegrations.get(integrationId)), ); - context.result = { + context.result ??= { items: (await container.integrations.getMyIssues(connectedIntegrations, { openRepositoriesOnly: true }))?.map( i => ({ @@ -558,14 +554,10 @@ async function updateContextItems(container: Container, context: Context) { function updateTelemetryContext(context: Context) { context.telemetryContext = { ...context.telemetryContext!, - 'items.count': context.result.items.length, + 'items.count': context.result?.items.length ?? 0, }; } -function isStartWorkTypeItem(item: unknown): item is StartWorkTypeItem { - return item != null && typeof item === 'object' && 'type' in item; -} - function repeatSpaces(count: number) { return ' '.repeat(count); } @@ -603,11 +595,16 @@ function getOpenOnWebQuickInputButton(integrationId: string): QuickInputButton | } } -function assertsTypeStepState(state: StepState): asserts state is StepState { - if (state.type != null) { - return; - } +async function getConnectedIntegrations(container: Container): Promise> { + const connected = new Map(); + await Promise.allSettled( + supportedStartWorkIntegrations.map(async integrationId => { + const integration = await container.integrations.get(integrationId); + const isConnected = integration.maybeConnected ?? (await integration.isConnected()); + const hasAccess = isConnected && (await integration.access()); + connected.set(integrationId, hasAccess); + }), + ); - debugger; - throw new Error('Missing `item` field in state of StartWork'); + return connected; } diff --git a/src/system/commands.ts b/src/system/commands.ts index 21727d5807c80..2ea3e4d71c740 100644 --- a/src/system/commands.ts +++ b/src/system/commands.ts @@ -1,6 +1,6 @@ -import type { Commands, TreeViewCommands } from '../constants.commands'; +import type { Commands, CustomViewCommands, TreeViewCommands } from '../constants.commands'; -export function createCommandLink(command: Commands | TreeViewCommands, args: T) { +export function createCommandLink(command: Commands | TreeViewCommands | CustomViewCommands, args: T) { if (args == null) return `command:${command}`; return `command:${command}?${encodeURIComponent(typeof args === 'string' ? args : JSON.stringify(args))}`; diff --git a/src/webviews/apps/plus/home/components/active-work.ts b/src/webviews/apps/plus/home/components/active-work.ts index e82d59d140698..451ffe9f77cfb 100644 --- a/src/webviews/apps/plus/home/components/active-work.ts +++ b/src/webviews/apps/plus/home/components/active-work.ts @@ -5,13 +5,14 @@ import { customElement, state } from 'lit/decorators.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import { when } from 'lit/directives/when.js'; import type { GitTrackingState } from '../../../../../git/models/branch'; +import { createCommandLink } from '../../../../../system/commands'; import { createWebviewCommandLink } from '../../../../../system/webview'; import type { GetOverviewBranch, OpenInGraphParams, State } from '../../../../home/protocol'; import { stateContext } from '../../../home/context'; import { ipcContext } from '../../../shared/context'; import type { HostIpc } from '../../../shared/ipc'; import { linkStyles } from '../../shared/components/vscode.css'; -import { branchCardStyles, createCommandLink } from './branch-section'; +import { branchCardStyles } from './branch-section'; import type { Overview, OverviewState } from './overviewState'; import { overviewStateContext } from './overviewState'; import '../../../shared/components/button'; diff --git a/src/webviews/apps/plus/home/components/branch-section.ts b/src/webviews/apps/plus/home/components/branch-section.ts index 56908b87eddac..681b96795c65e 100644 --- a/src/webviews/apps/plus/home/components/branch-section.ts +++ b/src/webviews/apps/plus/home/components/branch-section.ts @@ -1,8 +1,9 @@ import { css, html, LitElement, nothing } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { when } from 'lit/directives/when.js'; -import type { Commands } from '../../../../../constants.commands'; +import type { Commands, CustomViewCommands } from '../../../../../constants.commands'; import type { GitTrackingState } from '../../../../../git/models/branch'; +import { createCommandLink } from '../../../../../system/commands'; import type { GetOverviewBranch, OpenInGraphParams } from '../../../../home/protocol'; import { srOnlyStyles } from '../../../shared/components/styles/lit/a11y.css'; import { linkStyles } from '../../shared/components/vscode.css'; @@ -427,13 +428,7 @@ export class GlBranchCard extends LitElement { return html`${actions}`; } - private createCommandLink(command: string) { + private createCommandLink(command: Commands | CustomViewCommands) { return createCommandLink(command, this.branchRefs); } } - -export function createCommandLink(command: Commands | string, args: T) { - if (args == null) return `command:${command}`; - - return `command:${command}?${encodeURIComponent(typeof args === 'string' ? args : JSON.stringify(args))}`; -} diff --git a/src/webviews/apps/plus/home/components/launchpad.ts b/src/webviews/apps/plus/home/components/launchpad.ts index 1161014b1ebde..ff9c50b28ef1b 100644 --- a/src/webviews/apps/plus/home/components/launchpad.ts +++ b/src/webviews/apps/plus/home/components/launchpad.ts @@ -3,9 +3,7 @@ import { SignalWatcher } from '@lit-labs/signals'; import type { TemplateResult } from 'lit'; import { css, html, LitElement, nothing } from 'lit'; import { customElement, state } from 'lit/decorators.js'; -import { Commands } from '../../../../../constants.commands'; import type { LaunchpadCommandArgs } from '../../../../../plus/launchpad/launchpad'; -import type { StartWorkCommandArgs } from '../../../../../plus/startWork/startWork'; import { createCommandLink } from '../../../../../system/commands'; import { pluralize } from '../../../../../system/string'; import type { GetLaunchpadSummaryResponse, State } from '../../../../home/protocol'; @@ -112,30 +110,11 @@ export class GlLaunchpad extends SignalWatcher(LitElement) { }); get startWorkCommand() { - return createCommandLink(Commands.StartWork, { - command: 'startWork', - source: 'home', - type: 'issue', - }); + return createCommandLink('gitlens.home.startWork', undefined); } get createBranchCommand() { - return createCommandLink(Commands.StartWork, { - command: 'startWork', - source: 'home', - type: 'branch', - }); - // TODO: Switch to using the base git command once we support sending source telemetry to that command, and then clean up start work - // command to just be for issues and remove "type" param - /*return createCommandLink(Commands.GitCommands, { - command: 'branch', - state: { - subcommand: 'create', - suggestNameOnly: true, - suggestRepoOnly: true, - confirmOptions: ['--switch', '--worktree'], - }, - });*/ + return createCommandLink('gitlens.home.createBranch', undefined); } override connectedCallback() { diff --git a/src/webviews/home/homeWebview.ts b/src/webviews/home/homeWebview.ts index c7dd62ed0d380..3ba60b8ccb495 100644 --- a/src/webviews/home/homeWebview.ts +++ b/src/webviews/home/homeWebview.ts @@ -3,6 +3,7 @@ import { Disposable, workspace } from 'vscode'; import type { CreatePullRequestActionContext } from '../../api/gitlens'; import type { EnrichedAutolink } from '../../autolinks'; import { getAvatarUriFromGravatarEmail } from '../../avatars'; +import type { BranchGitCommandArgs } from '../../commands/git/branch'; import type { OpenPullRequestOnRemoteCommandArgs } from '../../commands/openPullRequestOnRemote'; import { GlyphChars, urls } from '../../constants'; import { Commands } from '../../constants.commands'; @@ -28,6 +29,7 @@ import type { Subscription } from '../../plus/gk/account/subscription'; import { isSubscriptionStatePaidOrTrial } from '../../plus/gk/account/subscription'; import type { SubscriptionChangeEvent } from '../../plus/gk/account/subscriptionService'; import { getLaunchpadSummary } from '../../plus/launchpad/utils'; +import type { StartWorkCommandArgs } from '../../plus/startWork/startWork'; import type { ShowInCommitGraphCommandArgs } from '../../plus/webviews/graph/protocol'; import { showRepositoryPicker } from '../../quickpicks/repositoryPicker'; import type { Deferrable } from '../../system/function'; @@ -277,6 +279,8 @@ export class HomeWebviewProvider implements WebviewProvider(Commands.GitCommands, { + command: 'branch', + state: { + subcommand: 'create', + suggestNameOnly: true, + suggestRepoOnly: true, + confirmOptions: ['--switch', '--worktree'], + }, + }); + } + + private startWork() { + this.container.telemetry.sendEvent('home/startWork'); + void executeCommand(Commands.StartWork, { + command: 'startWork', + source: 'home', + }); + } + private onTogglePreviewEnabled(isEnabled?: boolean) { if (isEnabled === undefined) { isEnabled = !this.getPreviewEnabled();