From e52ef7c70acb7b2302abdbf3937cacc88f4a5224 Mon Sep 17 00:00:00 2001 From: Eric Amodio Date: Fri, 6 Sep 2024 18:44:51 -0400 Subject: [PATCH 1/3] Adds context to upgrade url --- src/constants.telemetry.ts | 2 + .../gk/account/authenticationConnection.ts | 8 +- src/plus/gk/account/authenticationProvider.ts | 4 +- src/plus/gk/account/subscriptionService.ts | 93 +++++++++++-------- 4 files changed, 62 insertions(+), 45 deletions(-) diff --git a/src/constants.telemetry.ts b/src/constants.telemetry.ts index 19c0f40050211..2245c282adffa 100644 --- a/src/constants.telemetry.ts +++ b/src/constants.telemetry.ts @@ -466,3 +466,5 @@ type SubscriptionEventData = { Record<`previous.subscription.${string}`, string | number | boolean | undefined> & Record<`previous.subscription.previewTrial.${string}`, string | number | boolean | undefined> >; + +export type TrackingContext = 'graph' | 'launchpad' | 'visual_file_history' | 'worktrees'; diff --git a/src/plus/gk/account/authenticationConnection.ts b/src/plus/gk/account/authenticationConnection.ts index 33c2bc8c5a8d9..efb2c7599d398 100644 --- a/src/plus/gk/account/authenticationConnection.ts +++ b/src/plus/gk/account/authenticationConnection.ts @@ -2,6 +2,7 @@ import type { CancellationToken, Disposable, StatusBarItem } from 'vscode'; import { CancellationTokenSource, env, StatusBarAlignment, Uri, window } from 'vscode'; import { uuid } from '@env/crypto'; import type { Response } from '@env/fetch'; +import type { TrackingContext } from '../../../constants.telemetry'; import type { Container } from '../../../container'; import { debug } from '../../../system/decorators/log'; import type { DeferredEvent, DeferredEventExecutor } from '../../../system/event'; @@ -13,11 +14,6 @@ import type { ServerConnection } from '../serverConnection'; export const LoginUriPathPrefix = 'login'; export const AuthenticationUriPathPrefix = 'did-authenticate'; -export const enum AuthenticationContext { - Graph = 'graph', - Worktrees = 'worktrees', - VisualFileHistory = 'visual_file_history', -} interface AccountInfo { id: string; @@ -71,7 +67,7 @@ export class AuthenticationConnection implements Disposable { scopes: string[], scopeKey: string, signUp: boolean = false, - context?: AuthenticationContext, + context?: TrackingContext, ): Promise { this.updateStatusBarItem(true); diff --git a/src/plus/gk/account/authenticationProvider.ts b/src/plus/gk/account/authenticationProvider.ts index 4228b95e7288b..8733a358f1f31 100644 --- a/src/plus/gk/account/authenticationProvider.ts +++ b/src/plus/gk/account/authenticationProvider.ts @@ -5,13 +5,13 @@ import type { } from 'vscode'; import { Disposable, EventEmitter, window } from 'vscode'; import { uuid } from '@env/crypto'; +import type { TrackingContext } from '../../../constants.telemetry'; import type { Container, Environment } from '../../../container'; import { CancellationError } from '../../../errors'; import { debug } from '../../../system/decorators/log'; import { Logger } from '../../../system/logger'; import { getLogScope, setLogScopeExit } from '../../../system/logger.scope'; import type { ServerConnection } from '../serverConnection'; -import type { AuthenticationContext } from './authenticationConnection'; import { AuthenticationConnection } from './authenticationConnection'; interface StoredSession { @@ -31,7 +31,7 @@ export const authenticationProviderScopes = ['gitlens']; export interface AuthenticationProviderOptions { signUp?: boolean; signIn?: { code: string; state?: string }; - context?: AuthenticationContext; + context?: TrackingContext; } export class AccountAuthenticationProvider implements AuthenticationProvider, Disposable { diff --git a/src/plus/gk/account/subscriptionService.ts b/src/plus/gk/account/subscriptionService.ts index 45fa1064b24ec..eb6e58554cb52 100644 --- a/src/plus/gk/account/subscriptionService.ts +++ b/src/plus/gk/account/subscriptionService.ts @@ -24,7 +24,7 @@ import type { OpenWalkthroughCommandArgs } from '../../../commands/walkthroughs' import { urls } from '../../../constants'; import type { CoreColors } from '../../../constants.colors'; import { Commands } from '../../../constants.commands'; -import type { Source } from '../../../constants.telemetry'; +import type { Source, TrackingContext } from '../../../constants.telemetry'; import type { Container } from '../../../container'; import { AccountValidationError } from '../../../errors'; import type { RepositoriesChangeEvent } from '../../../git/gitProviderService'; @@ -48,7 +48,6 @@ import type { GKCheckInResponse } from '../checkin'; import { getSubscriptionFromCheckIn } from '../checkin'; import type { ServerConnection } from '../serverConnection'; import { ensurePlusFeaturesEnabled } from '../utils'; -import { AuthenticationContext } from './authenticationConnection'; import { authenticationProviderScopes } from './authenticationProvider'; import type { Organization } from './organization'; import { getApplicablePromo } from './promos'; @@ -364,30 +363,7 @@ export class SubscriptionService implements Disposable { ); } - let context: AuthenticationContext | undefined; - switch (source?.source) { - case 'graph': - context = AuthenticationContext.Graph; - break; - case 'timeline': - context = AuthenticationContext.VisualFileHistory; - break; - case 'git-commands': - if ( - source.detail != null && - typeof source.detail !== 'string' && - (source.detail['action'] === 'worktree' || - source.detail['step.title'] === 'Create Worktree' || - source.detail['step.title'] === 'Open Worktree') - ) { - context = AuthenticationContext.Worktrees; - } - break; - case 'worktrees': - context = AuthenticationContext.Worktrees; - break; - } - + const context = getTrackingContextFromSource(source); return this.loginCore({ signUp: signUp, source: source, context: context }); } @@ -409,7 +385,7 @@ export class SubscriptionService implements Disposable { signUp?: boolean; source?: Source; signIn?: { code: string; state?: string }; - context?: AuthenticationContext; + context?: TrackingContext; }): Promise { // Abort any waiting authentication to ensure we can start a new flow await this.container.accountAuthentication.abort(); @@ -759,25 +735,42 @@ export class SubscriptionService implements Disposable { } } catch {} - const promoCode = getApplicablePromo(this._subscription.state)?.code; - const activeOrgId = this._subscription.activeOrganization?.id; + const query = new URLSearchParams(); + query.set('source', 'gitlens'); + query.set('product', 'gitlens'); + const successUri = await env.asExternalUri( Uri.parse( `${env.uriScheme}://${this.container.context.extension.id}/${SubscriptionUpdatedUriPathPrefix}`, ), ); - const query = `source=gitlens&product=gitlens&success_uri=${encodeURIComponent(successUri.toString(true))}${ - promoCode != null ? `&promoCode=${promoCode}` : '' - }${activeOrgId != null ? `&org=${activeOrgId}` : ''}`; + query.set('success_uri', successUri.toString(true)); + + const promoCode = getApplicablePromo(this._subscription.state)?.code; + if (promoCode != null) { + query.set('promoCode', promoCode); + } + + const activeOrgId = this._subscription.activeOrganization?.id; + if (activeOrgId != null) { + query.set('org', activeOrgId); + } + + const context = getTrackingContextFromSource(source); + if (context != null) { + query.set('context', context); + } + try { const token = await this.container.accountAuthentication.getExchangeToken( SubscriptionUpdatedUriPathPrefix, ); - const purchasePath = `purchase/checkout?${query}`; + const purchasePath = `purchase/checkout?${query.toString()}`; if (!(await openUrl(this.container.getGkDevExchangeUri(token, purchasePath).toString(true)))) return; } catch (ex) { Logger.error(ex, scope); - if (!(await env.openExternal(this.container.getGkDevUri('purchase/checkout', query)))) return; + if (!(await env.openExternal(this.container.getGkDevUri('purchase/checkout', query.toString())))) + return; } const refresh = await Promise.race([ @@ -1007,7 +1000,7 @@ export class SubscriptionService implements Disposable { force?: boolean; signUp?: boolean; signIn?: { code: string; state?: string }; - context?: AuthenticationContext; + context?: TrackingContext; }, ): Promise { if (this._sessionPromise != null) { @@ -1043,7 +1036,7 @@ export class SubscriptionService implements Disposable { @debug() private async getOrCreateSession( createIfNeeded: boolean, - options?: { signUp?: boolean; signIn?: { code: string; state?: string }; context?: AuthenticationContext }, + options?: { signUp?: boolean; signIn?: { code: string; state?: string }; context?: TrackingContext }, ): Promise { const scope = getLogScope(); @@ -1453,7 +1446,7 @@ export class SubscriptionService implements Disposable { onLoginUri(uri: Uri) { const scope = getLogScope(); - const queryParams: URLSearchParams = new URLSearchParams(uri.query); + const queryParams = new URLSearchParams(uri.query); const code = queryParams.get('code'); const state = queryParams.get('state'); const context = queryParams.get('context'); @@ -1530,3 +1523,29 @@ function flattenSubscription( 'subscription.stateString': getSubscriptionStateString(subscription.state), }; } + +function getTrackingContextFromSource(source: Source | undefined): TrackingContext | undefined { + switch (source?.source) { + case 'graph': + return 'graph'; + case 'launchpad': + return 'launchpad'; + case 'timeline': + return 'visual_file_history'; + case 'git-commands': + if (source.detail != null && typeof source.detail !== 'string' && 'action' in source.detail) { + switch (source.detail.action) { + case 'worktree': + return 'worktrees'; + case 'focus': + case 'launchpad': + return 'launchpad'; + } + } + break; + case 'worktrees': + return 'worktrees'; + } + + return undefined; +} From 0123512df78c51bff0585a62a0c0053b624c0022 Mon Sep 17 00:00:00 2001 From: Eric Amodio Date: Mon, 9 Sep 2024 09:59:01 -0700 Subject: [PATCH 2/3] Graduates Launchpad out of preview - Adds a promotion for Launchpad - Allows access until promo ends Removes (disables) legacy "focus" editor --- package.json | 28 ++--- src/commands/quickCommand.buttons.ts | 5 + src/commands/quickCommand.steps.ts | 73 ++++++++++-- src/constants.telemetry.ts | 1 + src/constants.ts | 30 ++--- src/container.ts | 8 +- src/git/gitProviderService.ts | 38 +++++-- src/plus/focus/focus.ts | 32 +++++- src/plus/focus/focusIndicator.ts | 6 +- src/plus/gk/account/promos.ts | 49 +++++--- src/plus/gk/account/subscriptionService.ts | 111 +++++++++---------- src/quickpicks/items/directive.ts | 13 ++- src/webviews/apps/shared/components/promo.ts | 55 ++------- src/webviews/apps/welcome/welcome.html | 4 +- 14 files changed, 270 insertions(+), 183 deletions(-) diff --git a/package.json b/package.json index 3abb56c7638f8..3b1d4b6037718 100644 --- a/package.json +++ b/package.json @@ -929,7 +929,7 @@ }, { "id": "graph", - "title": "Commit Graph", + "title": "Commit Graph (ᴘʀᴏ)", "order": 50, "properties": { "gitlens.graph.layout": { @@ -1226,7 +1226,7 @@ }, { "id": "focus", - "title": "Launchpad (Preview)", + "title": "Launchpad (ᴘʀᴏ)", "order": 60, "properties": { "gitlens.launchpad.ignoredRepositories": { @@ -1376,7 +1376,7 @@ }, { "id": "cloud-patches", - "title": "Cloud Patches (Preview)", + "title": "Cloud Patches (ᴘʀᴇᴠɪᴇᴡ)", "order": 70, "properties": { "gitlens.cloudPatches.enabled": { @@ -1542,7 +1542,7 @@ }, { "id": "launchpad-view", - "title": "Launchpad View", + "title": "Launchpad View (ᴇxᴘᴇʀɪᴍᴇɴᴛᴀʟ)", "order": 101, "properties": { "gitlens.views.launchpad.enabled": { @@ -2182,7 +2182,7 @@ }, { "id": "visual-history", - "title": "Visual File History", + "title": "Visual File History (ᴘʀᴏ)", "order": 155, "properties": { "gitlens.visualHistory.allowMultiple": { @@ -2587,7 +2587,7 @@ }, { "id": "worktrees-view", - "title": "Worktrees View", + "title": "Worktrees View (ᴘʀᴏ)", "order": 210, "properties": { "gitlens.worktrees.promptForLocation": { @@ -2897,7 +2897,7 @@ }, { "id": "cloud-patches-view", - "title": "Cloud Patches View", + "title": "Cloud Patches View (ᴘʀᴇᴠɪᴇᴡ)", "order": 240, "properties": { "gitlens.views.drafts.files.layout": { @@ -3017,7 +3017,7 @@ }, { "id": "workspaces-view", - "title": "GitKraken Workspaces View", + "title": "GitKraken Workspaces View (ᴘʀᴇᴠɪᴇᴡ)", "order": 260, "properties": { "gitlens.views.workspaces.showBranchComparison": { @@ -3654,7 +3654,7 @@ }, { "id": "ai", - "title": "AI (Experimental)", + "title": "AI (ᴇxᴘᴇʀɪᴍᴇɴᴛᴀʟ)", "order": 1000, "properties": { "gitlens.ai.experimental.generateCommitMessage.enabled": { @@ -9911,7 +9911,7 @@ }, { "command": "gitlens.showFocusPage", - "when": "gitlens:enabled" + "when": "false && gitlens:enabled" }, { "command": "gitlens.launchpad.split", @@ -17590,13 +17590,13 @@ }, { "view": "gitlens.views.worktrees", - "contents": "Special: 1st seat of Pro is now 50%+ off", + "contents": "Limited-time Sale: Save 33% or more on your 1st seat of Pro.", "when": "gitlens:plus:required && gitlens:plus:state == 4 && (gitlens:promo == pro50 || !gitlens:promo)" }, { "view": "gitlens.views.worktrees", - "contents": "DevEx Days 24 Sale: Save up to 80% on GitLens Pro - lowest price of the year!", - "when": "gitlens:plus:required && gitlens:plus:state == 4 && gitlens:promo == devexdays24" + "contents": "Launchpad Sale: Save 75% or more on GitLens Pro", + "when": "gitlens:plus:required && gitlens:plus:state == 4 && gitlens:promo =~ /(launchpad|launchpad-extended)/" }, { "view": "gitlens.views.worktrees", @@ -17899,7 +17899,7 @@ { "id": "launchpad", "title": "Unblock your team with Launchpad", - "description": "**Launchpad** ᴘʀᴇᴠɪᴇᴡ brings all of your GitHub pull requests into a unified, actionable list to better track work in progress, pending work, reviews, and more. Stay focused and take action on the most important items to keep your team unblocked. [Learn more](https://gitkraken.com/solutions/launchpad?utm_source=gitlens-extension&utm_medium=in-app-links)\n\n[Open Launchpad](command:gitlens.showLaunchpad?%7B%22source%22%3A%22walkthrough%22%7D)", + "description": "**Launchpad** ᴘʀᴏ brings all of your GitHub pull requests into a unified, actionable list to better track work in progress, pending work, reviews, and more. Stay focused and take action on the most important items to keep your team unblocked. [Learn more](https://gitkraken.com/solutions/launchpad?utm_source=gitlens-extension&utm_medium=in-app-links)\n\n[Open Launchpad](command:gitlens.showLaunchpad?%7B%22source%22%3A%22walkthrough%22%7D)", "media": { "altText": "Illustrations of Launchpad", "svg": "walkthroughs/welcome/launchpad-quick.svg" diff --git a/src/commands/quickCommand.buttons.ts b/src/commands/quickCommand.buttons.ts index e5363777ff1f2..da8af40380168 100644 --- a/src/commands/quickCommand.buttons.ts +++ b/src/commands/quickCommand.buttons.ts @@ -122,6 +122,11 @@ export const PickCommitToggleQuickInputButton = class extends ToggleQuickInputBu } }; +export const LearnAboutProQuickInputButton: QuickInputButton = { + iconPath: new ThemeIcon('info'), + tooltip: 'Learn about GitLens Pro', +}; + export const MergeQuickInputButton: QuickInputButton = { iconPath: new ThemeIcon('merge'), tooltip: 'Merge...', diff --git a/src/commands/quickCommand.steps.ts b/src/commands/quickCommand.steps.ts index d12cb01d2b2be..8f92c20d128d0 100644 --- a/src/commands/quickCommand.steps.ts +++ b/src/commands/quickCommand.steps.ts @@ -3,7 +3,8 @@ import { ThemeIcon } from 'vscode'; import { GlyphChars, quickPickTitleMaxChars } from '../constants'; import { Commands } from '../constants.commands'; import { Container } from '../container'; -import type { PlusFeatures } from '../features'; +import type { FeatureAccess, RepoFeatureAccess } from '../features'; +import { PlusFeatures } from '../features'; import * as BranchActions from '../git/actions/branch'; import * as CommitActions from '../git/actions/commit'; import * as ContributorActions from '../git/actions/contributor'; @@ -43,6 +44,7 @@ import type { GitWorktree, WorktreeQuickPickItem } from '../git/models/worktree' import { createWorktreeQuickPickItem, getWorktreesByBranch, sortWorktrees } from '../git/models/worktree'; import { remoteUrlRegex } from '../git/parsers/remoteParser'; import type { FocusCommandArgs } from '../plus/focus/focus'; +import { getApplicablePromo } from '../plus/gk/account/promos'; import { isSubscriptionPaidPlan, isSubscriptionPreviewTrialExpired } from '../plus/gk/account/subscription'; import { CommitApplyFileChangesCommandQuickPickItem, @@ -98,6 +100,7 @@ import { OpenRemoteResourceCommandQuickPickItem, } from '../quickpicks/remoteProviderPicker'; import { filterMap, filterMapAsync, intersection, isStringArray } from '../system/array'; +import { executeCommand } from '../system/command'; import { configuration } from '../system/configuration'; import { formatPath } from '../system/formatPath'; import { debounce } from '../system/function'; @@ -106,6 +109,7 @@ import { Logger } from '../system/logger'; import { getSettledValue } from '../system/promise'; import { pad, pluralize, truncate } from '../system/string'; import { openWorkspace } from '../system/utils'; +import { getIconPathUris } from '../system/vscode'; import type { ViewsWithRepositoryFolders } from '../views/viewBase'; import type { AsyncStepResultGenerator, @@ -136,6 +140,7 @@ import { ShowDetailsViewQuickInputButton, ShowTagsToggleQuickInputButton, } from './quickCommand.buttons'; +import type { OpenWalkthroughCommandArgs } from './walkthroughs'; export function appendReposToTitle< State extends { repo: Repository } | { repos: Repository[] }, @@ -2599,11 +2604,11 @@ function getShowRepositoryStatusStepItems< } export async function* ensureAccessStep< - State extends PartialStepState & { repo: Repository }, - Context extends { repos: Repository[]; title: string }, ->(state: State, context: Context, feature: PlusFeatures): AsyncStepResultGenerator { - const access = await Container.instance.git.access(feature, state.repo.path); - if (access.allowed) return undefined; + State extends PartialStepState & { repo?: Repository }, + Context extends { title: string }, +>(state: State, context: Context, feature: PlusFeatures): AsyncStepResultGenerator { + const access = await Container.instance.git.access(feature, state.repo?.path); + if (access.allowed) return access; const directives: DirectiveQuickPickItem[] = []; let placeholder: string; @@ -2615,13 +2620,28 @@ export async function* ensureAccessStep< ); placeholder = 'You must verify your email before you can continue'; } else { - if (access.subscription.required == null) return undefined; + if (access.subscription.required == null) return access; + + let detail; + const promo = getApplicablePromo(access.subscription.current.state); + if (promo != null) { + // NOTE: Don't add a default case, so that if we add a new promo the build will break without handling it + switch (promo.key) { + case 'pro50': + detail = '$(star-full) Limited-Time Sale: Save 33% or more on your 1st seat of Pro'; + break; + case 'launchpad': + case 'launchpad-extended': + detail = `$(rocket) Launchpad Sale: Save 75% or more on GitLens Pro`; + break; + } + } placeholder = 'Pro feature — requires a trial or paid plan for use on privately-hosted repos'; if (isSubscriptionPaidPlan(access.subscription.required) && access.subscription.current.account != null) { placeholder = 'Pro feature — requires a paid plan for use on privately-hosted repos'; directives.push( - createDirectiveQuickPickItem(Directive.RequiresPaidSubscription, true), + createDirectiveQuickPickItem(Directive.RequiresPaidSubscription, true, { detail: detail }), createQuickPickSeparator(), createDirectiveQuickPickItem(Directive.Cancel), ); @@ -2644,12 +2664,45 @@ export async function* ensureAccessStep< } } + switch (feature) { + case PlusFeatures.Focus: + directives.splice( + 0, + 0, + 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: 'launchpad', + source: 'launchpad', + detail: 'info', + }), + }), + createQuickPickSeparator(), + ); + break; + case PlusFeatures.Worktrees: + directives.splice( + 0, + 0, + createDirectiveQuickPickItem(Directive.Noop, undefined, { + label: 'Worktrees minimize context switching by allowing simultaneous work on multiple branches', + iconPath: getIconPathUris(Container.instance, 'icon-repo.svg'), + }), + ); + break; + } + const step = createPickStep({ - title: appendReposToTitle(context.title, state, context), + title: context.title, placeholder: placeholder, items: directives, + buttons: [], + isConfirmationStep: true, }); const selection: StepSelection = yield step; - return canPickStepContinue(step, state, selection) ? undefined : StepResultBreak; + return canPickStepContinue(step, state, selection) ? access : StepResultBreak; } diff --git a/src/constants.telemetry.ts b/src/constants.telemetry.ts index 2245c282adffa..4855411b4ec26 100644 --- a/src/constants.telemetry.ts +++ b/src/constants.telemetry.ts @@ -467,4 +467,5 @@ type SubscriptionEventData = { Record<`previous.subscription.previewTrial.${string}`, string | number | boolean | undefined> >; +/** Used to provide a "source context" to gk.dev for both tracking and customization purposes */ export type TrackingContext = 'graph' | 'launchpad' | 'visual_file_history' | 'worktrees'; diff --git a/src/constants.ts b/src/constants.ts index 418897fdc73ae..8a9d890a05876 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -124,7 +124,7 @@ export const keys = Object.freeze([ ] as const); export type Keys = (typeof keys)[number]; -export type PromoKeys = 'devexdays24' | 'pro50'; +export type PromoKeys = 'launchpad' | 'launchpad-extended' | 'pro50'; export const enum Schemes { File = 'file', @@ -152,24 +152,24 @@ export const trackableSchemes = Object.freeze( ]), ); +const utm = 'utm_source=gitlens-extension&utm_medium=in-app-links'; export const urls = Object.freeze({ - codeSuggest: 'https://gitkraken.com/solutions/code-suggest?utm_source=gitlens-extension&utm_medium=in-app-links', - cloudPatches: 'https://gitkraken.com/solutions/cloud-patches?utm_source=gitlens-extension&utm_medium=in-app-links', - graph: 'https://gitkraken.com/solutions/commit-graph?utm_source=gitlens-extension&utm_medium=in-app-links', - launchpad: 'https://gitkraken.com/solutions/launchpad?utm_source=gitlens-extension&utm_medium=in-app-links', - platform: 'https://gitkraken.com/devex?utm_source=gitlens-extension&utm_medium=in-app-links', - pricing: 'https://gitkraken.com/gitlens/pricing?utm_source=gitlens-extension&utm_medium=in-app-links', - proFeatures: 'https://gitkraken.com/gitlens/pro-features?utm_source=gitlens-extension&utm_medium=in-app-links', - security: 'https://help.gitkraken.com/gitlens/security?utm_source=gitlens-extension&utm_medium=in-app-links', - workspaces: 'https://gitkraken.com/solutions/workspaces?utm_source=gitlens-extension&utm_medium=in-app-links', + codeSuggest: `https://gitkraken.com/solutions/code-suggest?${utm}`, + cloudPatches: `https://gitkraken.com/solutions/cloud-patches?${utm}`, + graph: `https://gitkraken.com/solutions/commit-graph?${utm}`, + launchpad: `https://gitkraken.com/solutions/launchpad?${utm}`, + platform: `https://gitkraken.com/devex?${utm}`, + pricing: `https://gitkraken.com/gitlens/pricing?${utm}`, + proFeatures: `https://gitkraken.com/gitlens/pro-features?${utm}`, + security: `https://help.gitkraken.com/gitlens/security?${utm}`, + workspaces: `https://gitkraken.com/solutions/workspaces?${utm}`, - cli: 'https://gitkraken.com/cli?utm_source=gitlens-extension&utm_medium=in-app-links', - browserExtension: 'https://gitkraken.com/browser-extension?utm_source=gitlens-extension&utm_medium=in-app-links', - desktop: 'https://gitkraken.com/git-client?utm_source=gitlens-extension&utm_medium=in-app-links', + cli: `https://gitkraken.com/cli?${utm}`, + browserExtension: `https://gitkraken.com/browser-extension?${utm}`, + desktop: `https://gitkraken.com/git-client?${utm}`, releaseNotes: 'https://help.gitkraken.com/gitlens/gitlens-release-notes-current/', - releaseAnnouncement: - 'https://www.gitkraken.com/blog/gitkraken-launches-devex-platform-acquires-codesee?utm_source=gitlens-extension&utm_medium=in-app-links', + releaseAnnouncement: `https://www.gitkraken.com/blog/gitkraken-launches-devex-platform-acquires-codesee?${utm}`, }); export type WalkthroughSteps = diff --git a/src/container.ts b/src/container.ts index e20b0744b6eda..3fbc7fbabade8 100644 --- a/src/container.ts +++ b/src/container.ts @@ -34,7 +34,6 @@ import type { GitHubApi } from './plus/integrations/providers/github/github'; import type { GitLabApi } from './plus/integrations/providers/gitlab/gitlab'; import { RepositoryIdentityService } from './plus/repos/repositoryIdentityService'; import { registerAccountWebviewView } from './plus/webviews/account/registration'; -import { registerFocusWebviewCommands, registerFocusWebviewPanel } from './plus/webviews/focus/registration'; import type { GraphWebviewShowingArgs } from './plus/webviews/graph/registration'; import { registerGraphWebviewCommands, @@ -251,9 +250,10 @@ export class Container { this._disposables.push((this._graphView = registerGraphWebviewView(this._webviews))); this._disposables.push(new GraphStatusBarController(this)); - const focusPanels = registerFocusWebviewPanel(this._webviews); - this._disposables.push(focusPanels); - this._disposables.push(registerFocusWebviewCommands(focusPanels)); + // NOTE: Commenting out for now as we are deprecating this + // const focusPanels = registerFocusWebviewPanel(this._webviews); + // this._disposables.push(focusPanels); + // this._disposables.push(registerFocusWebviewCommands(focusPanels)); const timelinePanels = registerTimelineWebviewPanel(this._webviews); this._disposables.push(timelinePanels); diff --git a/src/git/gitProviderService.ts b/src/git/gitProviderService.ts index 9c83dc9fdc5b6..083e005758144 100644 --- a/src/git/gitProviderService.ts +++ b/src/git/gitProviderService.ts @@ -18,6 +18,7 @@ import type { SearchQuery } from '../constants.search'; import type { Container } from '../container'; import { AccessDeniedError, CancellationError, ProviderNotFoundError } from '../errors'; import type { FeatureAccess, Features, PlusFeatures, RepoFeatureAccess } from '../features'; +import { getApplicablePromo } from '../plus/gk/account/promos'; import type { Subscription } from '../plus/gk/account/subscription'; import { isSubscriptionPaidPlan, SubscriptionPlanId } from '../plus/gk/account/subscription'; import type { SubscriptionChangeEvent } from '../plus/gk/account/subscriptionService'; @@ -171,7 +172,7 @@ export class GitProviderService implements Disposable { this._etag = Date.now(); - this._accessCache.clear(); + this.clearAccessCache(); this._reposVisibilityCache = undefined; this._onDidChangeRepositories.fire({ added: added ?? [], removed: removed ?? [], etag: this._etag }); @@ -290,7 +291,7 @@ export class GitProviderService implements Disposable { @debug() onSubscriptionChanged(e: SubscriptionChangeEvent) { - this._accessCache.clear(); + this.clearAccessCache(); this._subscription = e.current; } @@ -694,17 +695,22 @@ export class GitProviderService implements Disposable { return this._subscription ?? (this._subscription = await this.container.subscription.getSubscription()); } - private _accessCache: Map> & - Map> = new Map(); + private _accessCache = new Map>(); + private _accessCacheByRepo = new Map>(); + private clearAccessCache(): void { + this._accessCache.clear(); + this._accessCacheByRepo.clear(); + } + async access(feature: PlusFeatures | undefined, repoPath: string | Uri): Promise; async access(feature?: PlusFeatures, repoPath?: string | Uri): Promise; @debug({ exit: true }) async access(feature?: PlusFeatures, repoPath?: string | Uri): Promise { if (repoPath == null) { - let access = this._accessCache.get(undefined); + let access = this._accessCache.get(feature); if (access == null) { - access = this.accessCore(feature, repoPath); - this._accessCache.set(undefined, access); + access = this.accessCore(feature); + this._accessCache.set(feature, access); } return access; } @@ -712,10 +718,10 @@ export class GitProviderService implements Disposable { const { path } = this.getProvider(repoPath); const cacheKey = path; - let access = this._accessCache.get(cacheKey); + let access = this._accessCacheByRepo.get(cacheKey); if (access == null) { access = this.accessCore(feature, repoPath); - this._accessCache.set(cacheKey, access); + this._accessCacheByRepo.set(cacheKey, access); } return access; @@ -728,7 +734,7 @@ export class GitProviderService implements Disposable { ): Promise; @debug({ exit: true }) private async accessCore( - _feature?: PlusFeatures, + feature?: PlusFeatures, repoPath?: string | Uri, ): Promise { const subscription = await this.getSubscription(); @@ -742,6 +748,14 @@ export class GitProviderService implements Disposable { return { allowed: subscription.account?.verified !== false, subscription: { current: subscription } }; } + if (feature === 'focus') { + // If our launchpad graduation promo is active allow access for everyone + if (getApplicablePromo(subscription.state, 'launchpad')) { + return { allowed: true, subscription: { current: subscription } }; + } + return { allowed: false, subscription: { current: subscription, required: SubscriptionPlanId.Pro } }; + } + function getRepoAccess( this: GitProviderService, repoPath: string | Uri, @@ -749,7 +763,7 @@ export class GitProviderService implements Disposable { ): Promise { const { path: cacheKey } = this.getProvider(repoPath); - let access = force ? undefined : this._accessCache.get(cacheKey); + let access = force ? undefined : this._accessCacheByRepo.get(cacheKey); if (access == null) { access = this.visibility(repoPath).then( visibility => { @@ -771,7 +785,7 @@ export class GitProviderService implements Disposable { () => ({ allowed: true, subscription: { current: subscription } }), ); - this._accessCache.set(cacheKey, access); + this._accessCacheByRepo.set(cacheKey, access); } return access; diff --git a/src/plus/focus/focus.ts b/src/plus/focus/focus.ts index 403cf941b290f..10c7855474607 100644 --- a/src/plus/focus/focus.ts +++ b/src/plus/focus/focus.ts @@ -1,5 +1,5 @@ import type { QuickInputButton, QuickPick, QuickPickItem } from 'vscode'; -import { commands, Uri } from 'vscode'; +import { commands, ThemeIcon, Uri } from 'vscode'; import { getAvatarUri } from '../../avatars'; import type { AsyncStepResultGenerator, @@ -21,6 +21,7 @@ import { ConnectIntegrationButton, FeedbackQuickInputButton, LaunchpadSettingsQuickInputButton, + LearnAboutProQuickInputButton, MergeQuickInputButton, OpenOnGitHubQuickInputButton, OpenOnGitLabQuickInputButton, @@ -32,9 +33,11 @@ import { UnpinQuickInputButton, UnsnoozeQuickInputButton, } from '../../commands/quickCommand.buttons'; -import { previewBadge } from '../../constants'; +import { ensureAccessStep } from '../../commands/quickCommand.steps'; +import { proBadge, urls } from '../../constants'; import type { LaunchpadTelemetryContext, Source, Sources, TelemetryEvents } from '../../constants.telemetry'; import type { Container } from '../../container'; +import { PlusFeatures } from '../../features'; import type { QuickPickItemOfT } from '../../quickpicks/items/common'; import { createQuickPickItemOfT, createQuickPickSeparator } from '../../quickpicks/items/common'; import type { DirectiveQuickPickItem } from '../../quickpicks/items/directive'; @@ -45,6 +48,7 @@ import { fromNow } from '../../system/date'; import { some } from '../../system/iterable'; import { interpolate, pluralize } from '../../system/string'; import { openUrl } from '../../system/utils'; +import { getApplicablePromo } from '../gk/account/promos'; import type { IntegrationId } from '../integrations/providers/models'; import { HostingIntegrationId, @@ -109,6 +113,7 @@ interface Context { collapsed: Map; telemetryContext: LaunchpadTelemetryContext | undefined; connectedIntegrations: Map; + showGraduationPromo: boolean; } interface GroupedFocusItem extends FocusItem { @@ -147,7 +152,7 @@ export class FocusCommand extends QuickCommand { private readonly telemetryContext: LaunchpadTelemetryContext | undefined; constructor(container: Container, args?: FocusCommandArgs) { - super(container, 'focus', 'focus', `GitLens Launchpad\u00a0\u00a0${previewBadge}`, { + super(container, 'focus', 'focus', `GitLens Launchpad\u00a0\u00a0${proBadge}`, { description: 'focus on a pull request or issue', }); @@ -217,6 +222,7 @@ export class FocusCommand extends QuickCommand { collapsed: collapsed, telemetryContext: this.telemetryContext, connectedIntegrations: await this.container.focus.getConnectedIntegrations(), + showGraduationPromo: false, }; let opened = false; @@ -258,6 +264,11 @@ export class FocusCommand extends QuickCommand { newlyConnected = Boolean(connected); } + const result = yield* ensureAccessStep(state, context, PlusFeatures.Focus); + if (result === StepResultBreak) continue; + + context.showGraduationPromo = getApplicablePromo(result.subscription.current.state, 'launchpad') != null; + await updateContextItems(this.container, context, { force: newlyConnected }); if (state.counter < 1 || state.item == null) { @@ -367,6 +378,16 @@ export class FocusCommand extends QuickCommand { const hasDisconnectedIntegrations = [...context.connectedIntegrations.values()].some(c => !c); const getItems = (result: FocusCategorizedResult) => { const items: (FocusItemQuickPickItem | DirectiveQuickPickItem | ConnectMoreIntegrationsItem)[] = []; + if (context.showGraduationPromo) { + items.push( + createDirectiveQuickPickItem(Directive.RequiresPaidSubscription, undefined, { + label: `Preview access of Launchpad will end on September 27th`, + detail: '$(blank) Upgrade before then to save 75% or more on GitLens Pro', + iconPath: new ThemeIcon('megaphone'), + buttons: [LearnAboutProQuickInputButton], + }), + ); + } if (result.items?.length) { const uiGroups = groupAndSortFocusItems(result.items); @@ -563,6 +584,11 @@ export class FocusCommand extends QuickCommand { }, onDidClickItemButton: async (quickpick, button, { group, item }) => { + if (button === LearnAboutProQuickInputButton) { + void openUrl(urls.proFeatures); + return; + } + if (!item) return; switch (button) { diff --git a/src/plus/focus/focusIndicator.ts b/src/plus/focus/focusIndicator.ts index 326f850e42bd7..80a21d11eb9ad 100644 --- a/src/plus/focus/focusIndicator.ts +++ b/src/plus/focus/focusIndicator.ts @@ -1,7 +1,7 @@ import type { ConfigurationChangeEvent, StatusBarItem } from 'vscode'; import { Disposable, MarkdownString, StatusBarAlignment, ThemeColor, window } from 'vscode'; import type { OpenWalkthroughCommandArgs } from '../../commands/walkthroughs'; -import { previewBadge } from '../../constants'; +import { proBadge } from '../../constants'; import type { Colors } from '../../constants.colors'; import { Commands } from '../../constants.commands'; import type { Container } from '../../container'; @@ -239,9 +239,7 @@ export class FocusIndicator implements Disposable { tooltip.supportHtml = true; tooltip.isTrusted = true; - tooltip.appendMarkdown( - `GitLens Launchpad ${previewBadge}\u00a0\u00a0\u00a0\u00a0—\u00a0\u00a0\u00a0\u00a0`, - ); + tooltip.appendMarkdown(`GitLens Launchpad ${proBadge}\u00a0\u00a0\u00a0\u00a0—\u00a0\u00a0\u00a0\u00a0`); tooltip.appendMarkdown(`[$(question)](command:gitlens.launchpad.indicator.action?%22info%22 "What is this?")`); tooltip.appendMarkdown('\u00a0'); tooltip.appendMarkdown(`[$(gear)](command:workbench.action.openSettings?%22gitlens.launchpad%22 "Settings")`); diff --git a/src/plus/gk/account/promos.ts b/src/plus/gk/account/promos.ts index fef2e8f93337d..40ad735c6e604 100644 --- a/src/plus/gk/account/promos.ts +++ b/src/plus/gk/account/promos.ts @@ -15,42 +15,63 @@ export interface Promo { // Must be ordered by applicable order const promos: Promo[] = [ { - key: 'devexdays24', - code: 'DEVEXDAYS24', + key: 'launchpad', + code: 'GLLAUNCHPAD24', states: [ + SubscriptionState.Free, + SubscriptionState.FreeInPreviewTrial, + SubscriptionState.FreePreviewTrialExpired, SubscriptionState.FreePlusInTrial, SubscriptionState.FreePlusTrialExpired, SubscriptionState.FreePlusTrialReactivationEligible, ], - expiresOn: new Date('2024-09-10T06:59:00.000Z').getTime(), - commandTooltip: 'Sale: Save up to 80% on GitLens Pro - lowest price of the year!', + expiresOn: new Date('2024-09-27T06:59:00.000Z').getTime(), + commandTooltip: 'Launchpad Sale: Save 75% or more on GitLens Pro', + }, + { + key: 'launchpad-extended', + code: 'GLLAUNCHPAD24', + states: [ + SubscriptionState.Free, + SubscriptionState.FreeInPreviewTrial, + SubscriptionState.FreePreviewTrialExpired, + SubscriptionState.FreePlusInTrial, + SubscriptionState.FreePlusTrialExpired, + SubscriptionState.FreePlusTrialReactivationEligible, + ], + startsOn: new Date('2024-09-27T06:59:00.000Z').getTime(), + expiresOn: new Date('2024-10-14T06:59:00.000Z').getTime(), + commandTooltip: 'Launchpad Sale: Save 75% or more on GitLens Pro', }, { key: 'pro50', states: [ SubscriptionState.Free, SubscriptionState.FreeInPreviewTrial, + SubscriptionState.FreePreviewTrialExpired, SubscriptionState.FreePlusInTrial, SubscriptionState.FreePlusTrialExpired, SubscriptionState.FreePlusTrialReactivationEligible, ], - commandTooltip: 'Special: 1st seat of Pro is now 50%+ off. See your special price.', + commandTooltip: 'Limited-Time Sale: Save 33% or more on your 1st seat of Pro. See your special price', }, ]; -export function getApplicablePromo(state: number | undefined): Promo | undefined { +export function getApplicablePromo(state: number | undefined, key?: PromoKeys): Promo | undefined { if (state == null) return undefined; - const now = Date.now(); for (const promo of promos) { - if ( - (promo.states == null || promo.states.includes(state)) && - (promo.expiresOn == null || promo.expiresOn > now) && - (promo.startsOn == null || promo.startsOn < now) - ) { - return promo; - } + if ((key == null || key === promo.key) && isPromoApplicable(promo, state)) return promo; } return undefined; } + +function isPromoApplicable(promo: Promo, state: number): boolean { + const now = Date.now(); + return ( + (promo.states == null || promo.states.includes(state)) && + (promo.expiresOn == null || promo.expiresOn > now) && + (promo.startsOn == null || promo.startsOn < now) + ); +} diff --git a/src/plus/gk/account/subscriptionService.ts b/src/plus/gk/account/subscriptionService.ts index eb6e58554cb52..f14d00dfff993 100644 --- a/src/plus/gk/account/subscriptionService.ts +++ b/src/plus/gk/account/subscriptionService.ts @@ -721,77 +721,72 @@ export class SubscriptionService implements Disposable { this.container.telemetry.sendEvent('subscription/action', { action: 'upgrade' }, source); } - if (this._subscription.account == null) { - this.showPlans(source); - } else { + if (this._subscription.account != null) { // Do a pre-check-in to see if we've already upgraded to a paid plan. try { const session = await this.ensureSession(false); - if (session == null) return; - - if ((await this.checkUpdatedSubscription()) === SubscriptionState.Paid) { - void this.showAccountView(); - return; + if (session != null) { + if ((await this.checkUpdatedSubscription()) === SubscriptionState.Paid) { + void this.showAccountView(); + return; + } } } catch {} + } - const query = new URLSearchParams(); - query.set('source', 'gitlens'); - query.set('product', 'gitlens'); - - const successUri = await env.asExternalUri( - Uri.parse( - `${env.uriScheme}://${this.container.context.extension.id}/${SubscriptionUpdatedUriPathPrefix}`, - ), - ); - query.set('success_uri', successUri.toString(true)); + const query = new URLSearchParams(); + query.set('source', 'gitlens'); + query.set('product', 'gitlens'); - const promoCode = getApplicablePromo(this._subscription.state)?.code; - if (promoCode != null) { - query.set('promoCode', promoCode); - } + const successUri = await env.asExternalUri( + Uri.parse(`${env.uriScheme}://${this.container.context.extension.id}/${SubscriptionUpdatedUriPathPrefix}`), + ); + query.set('success_uri', successUri.toString(true)); - const activeOrgId = this._subscription.activeOrganization?.id; - if (activeOrgId != null) { - query.set('org', activeOrgId); - } + const promoCode = getApplicablePromo(this._subscription.state)?.code; + if (promoCode != null) { + query.set('promoCode', promoCode); + } - const context = getTrackingContextFromSource(source); - if (context != null) { - query.set('context', context); - } + const activeOrgId = this._subscription.activeOrganization?.id; + if (activeOrgId != null) { + query.set('org', activeOrgId); + } - try { - const token = await this.container.accountAuthentication.getExchangeToken( - SubscriptionUpdatedUriPathPrefix, - ); - const purchasePath = `purchase/checkout?${query.toString()}`; - if (!(await openUrl(this.container.getGkDevExchangeUri(token, purchasePath).toString(true)))) return; - } catch (ex) { - Logger.error(ex, scope); - if (!(await env.openExternal(this.container.getGkDevUri('purchase/checkout', query.toString())))) - return; - } + const context = getTrackingContextFromSource(source); + if (context != null) { + query.set('context', context); + } - const refresh = await Promise.race([ - new Promise(resolve => setTimeout(() => resolve(false), 5 * 60 * 1000)), - new Promise(resolve => - take( - window.onDidChangeWindowState, - 2, - )(e => { - if (e.focused) resolve(true); - }), - ), - new Promise(resolve => - once(this.container.uri.onDidReceiveSubscriptionUpdatedUri)(() => resolve(false)), - ), - ]); + try { + const token = await this.container.accountAuthentication.getExchangeToken(SubscriptionUpdatedUriPathPrefix); + const purchasePath = `purchase/checkout?${query.toString()}`; + if (!(await openUrl(this.container.getGkDevExchangeUri(token, purchasePath).toString(true)))) return; + } catch (ex) { + Logger.error(ex, scope); + if (!(await env.openExternal(this.container.getGkDevUri('purchase/checkout', query.toString())))) return; + } + + const refresh = await Promise.race([ + new Promise(resolve => setTimeout(() => resolve(false), 5 * 60 * 1000)), + new Promise(resolve => + take( + window.onDidChangeWindowState, + 2, + )(e => { + if (e.focused) resolve(true); + }), + ), + new Promise(resolve => + once(this.container.uri.onDidReceiveSubscriptionUpdatedUri)(() => resolve(false)), + ), + ]); - if (refresh) { - void this.checkUpdatedSubscription(); - } + if (refresh) { + void this.checkUpdatedSubscription(); } + + // TODO: Can we remove this? await this.showAccountView(); } diff --git a/src/quickpicks/items/directive.ts b/src/quickpicks/items/directive.ts index e7b460e3fcd7a..f1a2f34c90856 100644 --- a/src/quickpicks/items/directive.ts +++ b/src/quickpicks/items/directive.ts @@ -1,5 +1,4 @@ import type { QuickPickItem, ThemeIcon, Uri } from 'vscode'; -import type { Subscription } from '../../plus/gk/account/subscription'; export enum Directive { Back, @@ -31,13 +30,14 @@ export function createDirectiveQuickPickItem( label?: string; description?: string; detail?: string; + buttons?: QuickPickItem['buttons']; iconPath?: Uri | { light: Uri; dark: Uri } | ThemeIcon; - subscription?: Subscription; onDidSelect?: () => void | Promise; }, ) { let label = options?.label; let detail = options?.detail; + let description = options?.description; if (label == null) { switch (directive) { case Directive.Back: @@ -72,16 +72,21 @@ export function createDirectiveQuickPickItem( break; case Directive.RequiresPaidSubscription: label = 'Upgrade to Pro'; - detail = 'Upgrading to a paid plan is required to use this Pro feature'; + if (detail != null) { + description ??= ' \u2014\u00a0\u00a0 a paid plan is required to use this Pro feature'; + } else { + detail = 'Upgrading to a paid plan is required to use this Pro feature'; + } break; } } const item: DirectiveQuickPickItem = { label: label, - description: options?.description, + description: description, detail: detail, iconPath: options?.iconPath, + buttons: options?.buttons, alwaysShow: true, picked: picked, directive: directive, diff --git a/src/webviews/apps/shared/components/promo.ts b/src/webviews/apps/shared/components/promo.ts index 633a8a52e6237..d2ccad7225484 100644 --- a/src/webviews/apps/shared/components/promo.ts +++ b/src/webviews/apps/shared/components/promo.ts @@ -1,4 +1,4 @@ -import { css, html, LitElement, nothing, svg } from 'lit'; +import { css, html, LitElement, nothing } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import type { Promo } from '../../../../plus/gk/account/promos'; @@ -71,51 +71,18 @@ export class GlPromo extends LitElement { } private renderPromo(promo: Promo) { + // NOTE: Don't add a default case or return at the end, so that if we add a new promo the build will break without handling it switch (promo.key) { - case 'devexdays24': - return html`Sale:Save up to 80% on GitLens Pro - lowest price of the year!`; - case 'pro50': - if (this.type === 'link') { - return html`Special: 1st seat of Pro is now 50%+ off. See your special price.`; - } - - return html`Special: 1st seat of Pro is now 50%+ off`; + return html`Limited-Time Sale: Save 33% or more on your 1st seat of Pro.`; + + case 'launchpad': + case 'launchpad-extended': + return html`Launchpad Sale: Save 75% or more on GitLens Pro.`; } - - return nothing; - } -} - -@customElement('gl-svg-devexdays24-promo') -export class GlSvgDevExDays24Promo extends LitElement { - static override styles = [ - css` - svg { - max-width: 8rem; - height: auto; - vertical-align: text-bottom; - } - `, - ]; - override render() { - return svg` - - - - - - - `; } } diff --git a/src/webviews/apps/welcome/welcome.html b/src/webviews/apps/welcome/welcome.html index 2a2cf4c408f75..188b0172d7039 100644 --- a/src/webviews/apps/welcome/welcome.html +++ b/src/webviews/apps/welcome/welcome.html @@ -626,7 +626,9 @@

Popular

>Commit Graph - Launchpad From fdd22f3b9613571c1619d5c706c4b47495202a50 Mon Sep 17 00:00:00 2001 From: Ramin Tadayon Date: Wed, 11 Sep 2024 22:10:13 -0700 Subject: [PATCH 3/3] Uses code when returned on redirect --- src/plus/gk/account/subscriptionService.ts | 88 +++++++++++++-------- src/plus/integrations/integrationService.ts | 4 +- 2 files changed, 59 insertions(+), 33 deletions(-) diff --git a/src/plus/gk/account/subscriptionService.ts b/src/plus/gk/account/subscriptionService.ts index f14d00dfff993..89906159c1729 100644 --- a/src/plus/gk/account/subscriptionService.ts +++ b/src/plus/gk/account/subscriptionService.ts @@ -48,6 +48,7 @@ import type { GKCheckInResponse } from '../checkin'; import { getSubscriptionFromCheckIn } from '../checkin'; import type { ServerConnection } from '../serverConnection'; import { ensurePlusFeaturesEnabled } from '../utils'; +import { LoginUriPathPrefix } from './authenticationConnection'; import { authenticationProviderScopes } from './authenticationProvider'; import type { Organization } from './organization'; import { getApplicablePromo } from './promos'; @@ -273,14 +274,18 @@ export class SubscriptionService implements Disposable { const learn: MessageItem = { title: 'See Pro Features' }; const confirm: MessageItem = { title: 'Continue', isCloseAffordance: true }; const result = await window.showInformationMessage( - `Welcome to your ${ - effective.name - } Trial.\n\nYou must first verify your email. Once verified, you will have full access to Pro features for ${ - days < 1 ? '<1 more day' : pluralize('day', days, { infix: ' more ' }) - }.`, + isSubscriptionPaid(this._subscription) + ? `You are now on the ${actual.name} plan. \n\nYou must first verify your email. Once verified, you will have full access to Pro features.` + : `Welcome to your ${ + effective.name + } Trial.\n\nYou must first verify your email. Once verified, you will have full access to Pro features for ${ + days < 1 ? '<1 more day' : pluralize('day', days, { infix: ' more ' }) + }.`, { modal: true, - detail: 'Your trial also includes access to our DevEx platform, unleashing powerful Git visualization & productivity capabilities everywhere you work: IDE, desktop, browser, and terminal.', + detail: `Your ${ + isSubscriptionPaid(this._subscription) ? 'plan' : 'trial' + } also includes access to our DevEx platform, unleashing powerful Git visualization & productivity capabilities everywhere you work: IDE, desktop, browser, and terminal.`, }, verify, learn, @@ -727,7 +732,6 @@ export class SubscriptionService implements Disposable { const session = await this.ensureSession(false); if (session != null) { if ((await this.checkUpdatedSubscription()) === SubscriptionState.Paid) { - void this.showAccountView(); return; } } @@ -738,8 +742,14 @@ export class SubscriptionService implements Disposable { query.set('source', 'gitlens'); query.set('product', 'gitlens'); + const hasAccount = this._subscription.account != null; + const successUri = await env.asExternalUri( - Uri.parse(`${env.uriScheme}://${this.container.context.extension.id}/${SubscriptionUpdatedUriPathPrefix}`), + Uri.parse( + `${env.uriScheme}://${this.container.context.extension.id}/${ + hasAccount ? SubscriptionUpdatedUriPathPrefix : LoginUriPathPrefix + }`, + ), ); query.set('success_uri', successUri.toString(true)); @@ -759,35 +769,51 @@ export class SubscriptionService implements Disposable { } try { - const token = await this.container.accountAuthentication.getExchangeToken(SubscriptionUpdatedUriPathPrefix); - const purchasePath = `purchase/checkout?${query.toString()}`; - if (!(await openUrl(this.container.getGkDevExchangeUri(token, purchasePath).toString(true)))) return; + if (hasAccount) { + const token = await this.container.accountAuthentication.getExchangeToken( + SubscriptionUpdatedUriPathPrefix, + ); + const purchasePath = `purchase/checkout?${query.toString()}`; + if (!(await openUrl(this.container.getGkDevExchangeUri(token, purchasePath).toString(true)))) return; + } else if ( + !(await openUrl(this.container.getGkDevUri('purchase/checkout', query.toString()).toString(true))) + ) { + return; + } } catch (ex) { Logger.error(ex, scope); - if (!(await env.openExternal(this.container.getGkDevUri('purchase/checkout', query.toString())))) return; - } - - const refresh = await Promise.race([ - new Promise(resolve => setTimeout(() => resolve(false), 5 * 60 * 1000)), - new Promise(resolve => - take( - window.onDidChangeWindowState, - 2, - )(e => { - if (e.focused) resolve(true); - }), - ), - new Promise(resolve => - once(this.container.uri.onDidReceiveSubscriptionUpdatedUri)(() => resolve(false)), - ), - ]); + if (!(await openUrl(this.container.getGkDevUri('purchase/checkout', query.toString()).toString(true)))) { + return; + } + } + + const completionPromises = [new Promise(resolve => setTimeout(() => resolve(false), 5 * 60 * 1000))]; + + if (hasAccount) { + completionPromises.push( + new Promise(resolve => + take( + window.onDidChangeWindowState, + 2, + )(e => { + if (e.focused) resolve(true); + }), + ), + new Promise(resolve => + once(this.container.uri.onDidReceiveSubscriptionUpdatedUri)(() => resolve(false)), + ), + ); + } else { + completionPromises.push( + new Promise(resolve => once(this.container.uri.onDidReceiveLoginUri)(() => resolve(false))), + ); + } + + const refresh = await Promise.race(completionPromises); if (refresh) { void this.checkUpdatedSubscription(); } - - // TODO: Can we remove this? - await this.showAccountView(); } @gate(o => `${o?.force ?? false}`) diff --git a/src/plus/integrations/integrationService.ts b/src/plus/integrations/integrationService.ts index 823aa38c0f249..4333994a3cbb7 100644 --- a/src/plus/integrations/integrationService.ts +++ b/src/plus/integrations/integrationService.ts @@ -248,11 +248,11 @@ export class IntegrationService implements Disposable { await openUrl(this.container.getGkDevExchangeUri(exchangeToken, `connect?${query}`).toString(true)); } catch (ex) { Logger.error(ex, scope); - if (!(await env.openExternal(this.container.getGkDevUri('connect', query)))) { + if (!(await openUrl(this.container.getGkDevUri('connect', query).toString(true)))) { return false; } } - } else if (!(await env.openExternal(this.container.getGkDevUri('connect', query)))) { + } else if (!(await openUrl(this.container.getGkDevUri('connect', query).toString(true)))) { return false; }