@ -0,0 +1,641 @@ | |||
import { | |||
authentication, | |||
AuthenticationSession, | |||
commands, | |||
Disposable, | |||
env, | |||
Event, | |||
EventEmitter, | |||
MarkdownString, | |||
StatusBarAlignment, | |||
StatusBarItem, | |||
Uri, | |||
window, | |||
} from 'vscode'; | |||
import { fetch } from '@env/fetch'; | |||
import { Commands, ContextKeys } from '../../constants'; | |||
import type { Container } from '../../container'; | |||
import { setContext } from '../../context'; | |||
import { RepositoriesChangeEvent } from '../../git/gitProviderService'; | |||
import { Logger } from '../../logger'; | |||
import { StorageKeys } from '../../storage'; | |||
import { | |||
computeSubscriptionState, | |||
getSubscriptionPlan, | |||
getSubscriptionPlanPriority, | |||
getSubscriptionTimeRemaining, | |||
getTimeRemaining, | |||
isPaidSubscriptionPlan, | |||
isSubscriptionExpired, | |||
isSubscriptionTrial, | |||
Subscription, | |||
SubscriptionPlanId, | |||
SubscriptionState, | |||
} from '../../subscription'; | |||
import { executeCommand } from '../../system/command'; | |||
import { createFromDateDelta } from '../../system/date'; | |||
import { memoize } from '../../system/decorators/memoize'; | |||
import { pluralize } from '../../system/string'; | |||
// TODO: What user-agent should we use? | |||
const userAgent = 'Visual-Studio-Code-GitLens'; | |||
export interface SubscriptionChangeEvent { | |||
readonly current: Subscription; | |||
readonly previous: Subscription; | |||
} | |||
export class SubscriptionService implements Disposable { | |||
private static authenticationProviderId = 'gitkraken'; | |||
private static authenticationScopes = ['gitlens']; | |||
private _onDidChange = new EventEmitter<SubscriptionChangeEvent>(); | |||
get onDidChange(): Event<SubscriptionChangeEvent> { | |||
return this._onDidChange.event; | |||
} | |||
private _disposable: Disposable; | |||
private _subscription!: Subscription; | |||
private _statusBarSubscription: StatusBarItem | undefined; | |||
constructor(private readonly container: Container) { | |||
this._disposable = this.container.onReady(this.onReady, this); | |||
this.changeSubscription(this.getStoredSubscription(), true); | |||
setTimeout(() => void this.ensureSession(false), 10000); | |||
} | |||
dispose(): void { | |||
this._statusBarSubscription?.dispose(); | |||
this._disposable.dispose(); | |||
} | |||
@memoize() | |||
private get baseApiUri(): Uri { | |||
const { env } = this.container; | |||
if (env === 'staging') { | |||
return Uri.parse('https://stagingapi.gitkraken.com'); | |||
} | |||
if (env === 'dev' || this.container.debugging) { | |||
return Uri.parse('https://devapi.gitkraken.com'); | |||
} | |||
return Uri.parse('https://api.gitkraken.com'); | |||
} | |||
@memoize() | |||
private get baseAccountUri(): Uri { | |||
const { env } = this.container; | |||
if (env === 'staging') { | |||
return Uri.parse('https://stagingaccount.gitkraken.com'); | |||
} | |||
if (env === 'dev' || this.container.debugging) { | |||
return Uri.parse('https://devaccount.gitkraken.com'); | |||
} | |||
return Uri.parse('https://account.gitkraken.com'); | |||
} | |||
@memoize() | |||
private get baseSiteUri(): Uri { | |||
const { env } = this.container; | |||
if (env === 'staging') { | |||
return Uri.parse('https://staging.gitkraken.com'); | |||
} | |||
if (env === 'dev' || this.container.debugging) { | |||
return Uri.parse('https://dev.gitkraken.com'); | |||
} | |||
return Uri.parse('https://gitkraken.com'); | |||
} | |||
private _etag: number = 0; | |||
get etag(): number { | |||
return this._etag; | |||
} | |||
private onReady() { | |||
this._disposable = Disposable.from( | |||
this._disposable, | |||
this.container.git.onDidChangeRepositories(this.onRepositoriesChanged, this), | |||
...this.registerCommands(), | |||
); | |||
this.updateContext(); | |||
} | |||
private onRepositoriesChanged(_e: RepositoriesChangeEvent): void { | |||
this.updateContext(); | |||
} | |||
private registerCommands(): Disposable[] { | |||
void this.container.viewCommands; | |||
return [ | |||
commands.registerCommand('gitlens.premium.login', () => this.loginOrSignUp()), | |||
commands.registerCommand('gitlens.premium.loginOrSignUp', () => this.loginOrSignUp()), | |||
commands.registerCommand('gitlens.premium.signUp', () => this.loginOrSignUp()), | |||
commands.registerCommand('gitlens.premium.logout', () => this.logout()), | |||
commands.registerCommand('gitlens.premium.startPreview', () => this.startPreview()), | |||
commands.registerCommand('gitlens.premium.purchase', () => this.purchase()), | |||
commands.registerCommand('gitlens.premium.reset', () => this.reset()), | |||
commands.registerCommand('gitlens.premium.resendVerification', () => this.resendVerification()), | |||
commands.registerCommand('gitlens.premium.validate', () => this.validate()), | |||
commands.registerCommand('gitlens.premium.showPlans', () => this.showPlans()), | |||
]; | |||
} | |||
async getSubscription(): Promise<Subscription> { | |||
void (await this.ensureSession(false)); | |||
return this._subscription; | |||
} | |||
async loginOrSignUp(): Promise<boolean> { | |||
const session = await this.ensureSession(true); | |||
return Boolean(session); | |||
} | |||
logout(): void { | |||
this._sessionPromise = undefined; | |||
this.reset(false); | |||
} | |||
async purchase(): Promise<void> { | |||
void this.showPlans(); | |||
await this.showHomeView(); | |||
} | |||
async resendVerification(): Promise<void> { | |||
if (this._subscription.account?.verified) return; | |||
void this.showHomeView(); | |||
const session = await this.ensureSession(false); | |||
if (session == null) return; | |||
const rsp = await fetch(Uri.joinPath(this.baseApiUri, 'resend-email').toString(), { | |||
method: 'POST', | |||
headers: { | |||
Authorization: `Bearer ${session.accessToken}`, | |||
'User-Agent': userAgent, | |||
}, | |||
body: JSON.stringify({ id: session.account.id }), | |||
}); | |||
if (!rsp.ok) { | |||
debugger; | |||
return; | |||
} | |||
const ok = { title: 'Recheck' }; | |||
const cancel = { title: 'Cancel' }; | |||
const result = await window.showInformationMessage( | |||
"Once you have verified your email address, click 'Recheck'.", | |||
ok, | |||
cancel, | |||
); | |||
if (result === ok) { | |||
await this.validate(); | |||
} | |||
} | |||
reset(all: boolean = true): void { | |||
if (all && this.container.debugging) { | |||
this.changeSubscription(undefined); | |||
} | |||
this.changeSubscription({ | |||
...this._subscription, | |||
plan: { | |||
actual: getSubscriptionPlan(SubscriptionPlanId.Free), | |||
effective: getSubscriptionPlan(SubscriptionPlanId.Free), | |||
}, | |||
account: undefined, | |||
}); | |||
} | |||
async showHomeView(): Promise<void> { | |||
await executeCommand(Commands.ShowHomeView); | |||
} | |||
private showPlans(): void { | |||
void env.openExternal(Uri.joinPath(this.baseSiteUri, 'gitlens/pricing')); | |||
} | |||
async startPreview(): Promise<void> { | |||
let { plan, preview } = this._subscription; | |||
if (preview != null || plan.effective.id !== SubscriptionPlanId.Free) { | |||
if (plan.effective.id === SubscriptionPlanId.Free) { | |||
const ok = { title: 'Create Free+ Account' }; | |||
const cancel = { title: 'Cancel' }; | |||
const result = await window.showInformationMessage( | |||
'Your premium feature preview has expired. Please create a Free+ account to extend your trial.', | |||
ok, | |||
cancel, | |||
); | |||
if (result === ok) { | |||
void this.loginOrSignUp(); | |||
} | |||
} | |||
return; | |||
} | |||
const startedOn = new Date(); | |||
let expiresOn = new Date(startedOn); | |||
if (!this.container.debugging && this.container.env !== 'dev') { | |||
// Normalize the date to just before midnight on the same day | |||
expiresOn.setHours(23, 59, 59, 999); | |||
expiresOn = createFromDateDelta(expiresOn, { days: 3 }); | |||
} else { | |||
expiresOn = createFromDateDelta(expiresOn, { minutes: 1 }); | |||
} | |||
preview = { | |||
startedOn: startedOn.toISOString(), | |||
expiresOn: expiresOn.toISOString(), | |||
}; | |||
this.changeSubscription({ | |||
...this._subscription, | |||
plan: { | |||
...this._subscription.plan, | |||
effective: getSubscriptionPlan(SubscriptionPlanId.Pro, startedOn, expiresOn), | |||
}, | |||
preview: preview, | |||
}); | |||
} | |||
async validate(): Promise<void> { | |||
const session = await this.ensureSession(false); | |||
if (session == null) return; | |||
await this.checkInAndValidate(session); | |||
} | |||
private async checkInAndValidate(session: AuthenticationSession): Promise<void> { | |||
try { | |||
const rsp = await fetch(Uri.joinPath(this.baseApiUri, 'gitlens/checkin').toString(), { | |||
method: 'POST', | |||
headers: { | |||
Authorization: `Bearer ${session.accessToken}`, | |||
'User-Agent': userAgent, | |||
}, | |||
}); | |||
if (!rsp.ok) { | |||
// TODO@eamodio clear the details if there is an error? | |||
debugger; | |||
this.logout(); | |||
return; | |||
} | |||
const data: GKLicenseInfo = await rsp.json(); | |||
this.validateSubscription(data); | |||
} catch (ex) { | |||
Logger.error(ex); | |||
debugger; | |||
// TODO@eamodio clear the details if there is an error? | |||
this.logout(); | |||
} | |||
} | |||
private validateSubscription(data: GKLicenseInfo) { | |||
const account: Subscription['account'] = { | |||
id: data.user.id, | |||
name: data.user.name, | |||
email: data.user.email, | |||
verified: data.user.status === 'activated', | |||
}; | |||
const effectiveLicenses = Object.entries(data.licenses.effectiveLicenses) as [GKLicenseType, GKLicense][]; | |||
const paidLicenses = Object.entries(data.licenses.paidLicenses) as [GKLicenseType, GKLicense][]; | |||
let actual: Subscription['plan']['actual'] | undefined; | |||
if (paidLicenses.length > 0) { | |||
paidLicenses.sort( | |||
(a, b) => | |||
licenseStatusPriority(b[1].latestStatus) - licenseStatusPriority(a[1].latestStatus) || | |||
getSubscriptionPlanPriority(convertLicenseTypeToPlanId(b[0])) - | |||
getSubscriptionPlanPriority(convertLicenseTypeToPlanId(a[0])), | |||
); | |||
const [licenseType, license] = paidLicenses[0]; | |||
actual = getSubscriptionPlan( | |||
convertLicenseTypeToPlanId(licenseType), | |||
new Date(license.latestStartDate), | |||
new Date(license.latestEndDate), | |||
); | |||
} | |||
if (actual == null) { | |||
actual = getSubscriptionPlan( | |||
SubscriptionPlanId.FreePlus, | |||
data.user.firstGitLensCheckIn != null ? new Date(data.user.firstGitLensCheckIn) : undefined, | |||
); | |||
} | |||
let effective: Subscription['plan']['effective'] | undefined; | |||
if (effectiveLicenses.length > 0) { | |||
effectiveLicenses.sort( | |||
(a, b) => | |||
licenseStatusPriority(b[1].latestStatus) - licenseStatusPriority(a[1].latestStatus) || | |||
getSubscriptionPlanPriority(convertLicenseTypeToPlanId(b[0])) - | |||
getSubscriptionPlanPriority(convertLicenseTypeToPlanId(a[0])), | |||
); | |||
const [licenseType, license] = effectiveLicenses[0]; | |||
effective = getSubscriptionPlan( | |||
convertLicenseTypeToPlanId(licenseType), | |||
new Date(license.latestStartDate), | |||
new Date(license.latestEndDate), | |||
); | |||
} | |||
if (effective == null) { | |||
effective = actual; | |||
} | |||
this.changeSubscription({ | |||
plan: { | |||
actual: actual, | |||
effective: effective, | |||
}, | |||
account: account, | |||
}); | |||
} | |||
private _sessionPromise: Promise<AuthenticationSession | null> | undefined; | |||
private _session: AuthenticationSession | null | undefined; | |||
private async ensureSession(createIfNeeded: boolean): Promise<AuthenticationSession | undefined> { | |||
if (this._sessionPromise != null && this._session === undefined) { | |||
this._session = await this._sessionPromise; | |||
this._sessionPromise = undefined; | |||
} | |||
if (this._session != null) return this._session; | |||
if (this._session === null && !createIfNeeded) return undefined; | |||
if (this._sessionPromise === undefined) { | |||
this._sessionPromise = this.getOrCreateSession(createIfNeeded); | |||
} | |||
this._session = await this._sessionPromise; | |||
this._sessionPromise = undefined; | |||
return this._session ?? undefined; | |||
} | |||
private async getOrCreateSession(createIfNeeded: boolean): Promise<AuthenticationSession | null> { | |||
let session: AuthenticationSession | null | undefined; | |||
this.updateStatusBar(true); | |||
try { | |||
session = await authentication.getSession( | |||
SubscriptionService.authenticationProviderId, | |||
SubscriptionService.authenticationScopes, | |||
{ | |||
createIfNone: createIfNeeded, | |||
silent: !createIfNeeded, | |||
}, | |||
); | |||
} catch (ex) { | |||
session = null; | |||
if (ex instanceof Error && ex.message.includes('User did not consent')) { | |||
this.logout(); | |||
return null; | |||
} | |||
} | |||
if (session == null) { | |||
this.updateContext(); | |||
return session ?? null; | |||
} | |||
await this.checkInAndValidate(session); | |||
return session; | |||
} | |||
private changeSubscription( | |||
subscription: Optional<Subscription, 'state'> | undefined, | |||
silent: boolean = false, | |||
): void { | |||
if (subscription == null) { | |||
subscription = { | |||
plan: { | |||
actual: getSubscriptionPlan(SubscriptionPlanId.Free), | |||
effective: getSubscriptionPlan(SubscriptionPlanId.Free), | |||
}, | |||
account: undefined, | |||
state: SubscriptionState.Free, | |||
}; | |||
} | |||
// If the effective plan is Free, then check if the preview has expired, if not apply it | |||
if ( | |||
subscription.plan.effective.id === SubscriptionPlanId.Free && | |||
subscription.preview != null && | |||
(getTimeRemaining(subscription.preview.expiresOn) ?? 0) > 0 | |||
) { | |||
(subscription.plan as PickMutable<Subscription['plan'], 'effective'>).effective = getSubscriptionPlan( | |||
SubscriptionPlanId.Pro, | |||
new Date(subscription.preview.startedOn), | |||
new Date(subscription.preview.expiresOn), | |||
); | |||
} | |||
// If the effective plan has expired, then replace it with the actual plan | |||
if (isSubscriptionExpired(subscription)) { | |||
(subscription.plan as PickMutable<Subscription['plan'], 'effective'>).effective = subscription.plan.actual; | |||
} | |||
subscription.state = computeSubscriptionState(subscription); | |||
assertSubscriptionState(subscription); | |||
void this.storeSubscription(subscription); | |||
const previous = this._subscription; // Can be undefined here, since we call this in the constructor | |||
this._subscription = subscription; | |||
this._etag = Date.now(); | |||
this.updateContext(); | |||
if (!silent && previous != null) { | |||
this._onDidChange.fire({ current: subscription, previous: previous }); | |||
} | |||
} | |||
private getStoredSubscription(): Subscription | undefined { | |||
const storedSubscription = this.container.storage.get<Stored<Subscription>>(StorageKeys.PremiumSubscription); | |||
return storedSubscription?.data; | |||
} | |||
private async storeSubscription(subscription: Subscription): Promise<void> { | |||
return this.container.storage.store<Stored<Subscription>>(StorageKeys.PremiumSubscription, { | |||
v: 1, | |||
data: subscription, | |||
}); | |||
} | |||
private updateContext(): void { | |||
void this.updateStatusBar(); | |||
queueMicrotask(async () => { | |||
const { allowed, subscription } = await this.container.git.access(); | |||
void setContext( | |||
ContextKeys.PremiumUpgradeRequired, | |||
allowed | |||
? false | |||
: subscription.required != null && isPaidSubscriptionPlan(subscription.required) | |||
? 'paid' | |||
: 'free+', | |||
); | |||
}); | |||
const { | |||
plan: { actual }, | |||
} = this._subscription; | |||
void setContext(ContextKeys.Premium, actual.id); | |||
void setContext(ContextKeys.PremiumPaid, isPaidSubscriptionPlan(actual.id)); | |||
} | |||
private updateStatusBar(pending: boolean = false): void { | |||
this._statusBarSubscription = | |||
this._statusBarSubscription ?? | |||
window.createStatusBarItem('gitlens.subscription', StatusBarAlignment.Left, 1); | |||
this._statusBarSubscription.name = 'GitLens Subscription'; | |||
if (pending) { | |||
this._statusBarSubscription.text = `$(sync~spin) GitLens signing in...`; | |||
this._statusBarSubscription.tooltip = 'Signing in or validating your subscription...'; | |||
return; | |||
} | |||
const { | |||
account, | |||
plan: { effective }, | |||
} = this._subscription; | |||
switch (effective.id) { | |||
case SubscriptionPlanId.Free: | |||
this._statusBarSubscription.text = effective.name; | |||
this._statusBarSubscription.command = Commands.ShowHomeView; | |||
this._statusBarSubscription.tooltip = new MarkdownString( | |||
`You are on **${effective.name}**\n\nClick to upgrade to Free+ for access to premium features for public code`, | |||
true, | |||
); | |||
break; | |||
case SubscriptionPlanId.FreePlus: | |||
case SubscriptionPlanId.Pro: | |||
case SubscriptionPlanId.Teams: | |||
case SubscriptionPlanId.Enterprise: { | |||
const trial = isSubscriptionTrial(this._subscription); | |||
this._statusBarSubscription.text = trial ? `${effective.name} (Trial)` : effective.name; | |||
this._statusBarSubscription.command = Commands.ShowHomeView; | |||
if (account?.verified === false) { | |||
this._statusBarSubscription.tooltip = new MarkdownString( | |||
trial | |||
? `Before you can trial **${effective.name}**, you must verify your email address.\n\nClick to verify your email address` | |||
: `Before you can access **${effective.name}**, you must verify your email address.\n\nClick to verify your email address`, | |||
true, | |||
); | |||
} else { | |||
const remaining = getSubscriptionTimeRemaining(this._subscription, 'days'); | |||
this._statusBarSubscription.tooltip = new MarkdownString( | |||
trial | |||
? `You are trialing **${effective.name}**\n\nYou have ${pluralize( | |||
'day', | |||
remaining ?? 0, | |||
)} remaining in your trial.\n\nClick to see your subscription details` | |||
: `You are on **${effective.name}**\n\nClick to see your subscription details`, | |||
true, | |||
); | |||
} | |||
break; | |||
} | |||
} | |||
this._statusBarSubscription.show(); | |||
} | |||
} | |||
function assertSubscriptionState(subscription: Optional<Subscription, 'state'>): asserts subscription is Subscription {} | |||
interface GKLicenseInfo { | |||
user: GKUser; | |||
licenses: { | |||
paidLicenses: Record<GKLicenseType, GKLicense>; | |||
effectiveLicenses: Record<GKLicenseType, GKLicense>; | |||
}; | |||
} | |||
type GKLicenseType = | |||
| 'gitlens-pro' | |||
| 'gitlens-hosted-enterprise' | |||
| 'gitlens-self-hosted-enterprise' | |||
| 'gitlens-standalone-enterprise' | |||
| 'bundle-pro' | |||
| 'bundle-hosted-enterprise' | |||
| 'bundle-self-hosted-enterprise' | |||
| 'bundle-standalone-enterprise'; | |||
function convertLicenseTypeToPlanId(licenseType: GKLicenseType): SubscriptionPlanId { | |||
switch (licenseType) { | |||
case 'gitlens-pro': | |||
case 'bundle-pro': | |||
return SubscriptionPlanId.Pro; | |||
case 'gitlens-hosted-enterprise': | |||
case 'gitlens-self-hosted-enterprise': | |||
case 'gitlens-standalone-enterprise': | |||
case 'bundle-hosted-enterprise': | |||
case 'bundle-self-hosted-enterprise': | |||
case 'bundle-standalone-enterprise': | |||
return SubscriptionPlanId.Enterprise; | |||
default: | |||
return SubscriptionPlanId.FreePlus; | |||
} | |||
} | |||
function licenseStatusPriority(status: GKLicense['latestStatus']): number { | |||
switch (status) { | |||
case 'active': | |||
return 100; | |||
case 'expired': | |||
return -100; | |||
case 'trial': | |||
return 1; | |||
case 'canceled': | |||
return 0; | |||
} | |||
} | |||
interface GKLicense { | |||
latestStatus: 'active' | 'canceled' | 'expired' | 'trial'; | |||
latestStartDate: string; | |||
latestEndDate: string; | |||
} | |||
interface GKUser { | |||
id: string; | |||
name: string; | |||
email: string; | |||
status: 'activated' | 'pending'; | |||
firstGitLensCheckIn?: string; | |||
} | |||
interface Stored<T, SchemaVersion extends number = 1> { | |||
v: SchemaVersion; | |||
data: T; | |||
} |
@ -0,0 +1,166 @@ | |||
import { getDateDifference } from './system/date'; | |||
export const enum SubscriptionPlanId { | |||
Free = 'free', | |||
FreePlus = 'free+', | |||
Pro = 'pro', | |||
Teams = 'teams', | |||
Enterprise = 'enterprise', | |||
} | |||
export type FreeSubscriptionPlans = Extract<SubscriptionPlanId, SubscriptionPlanId.Free | SubscriptionPlanId.FreePlus>; | |||
export type PaidSubscriptionPlans = Exclude<SubscriptionPlanId, SubscriptionPlanId.Free | SubscriptionPlanId.FreePlus>; | |||
export type RequiredSubscriptionPlans = Exclude<SubscriptionPlanId, SubscriptionPlanId.Free>; | |||
export interface Subscription { | |||
readonly plan: { | |||
readonly actual: SubscriptionPlan; | |||
readonly effective: SubscriptionPlan; | |||
}; | |||
account: SubscriptionAccount | undefined; | |||
preview?: SubscriptionPreview; | |||
state: SubscriptionState; | |||
} | |||
export interface SubscriptionPlan { | |||
readonly id: SubscriptionPlanId; | |||
readonly name: string; | |||
readonly startedOn: string; | |||
readonly expiresOn?: string | undefined; | |||
} | |||
export interface SubscriptionAccount { | |||
readonly id: string; | |||
readonly name: string; | |||
readonly email: string | undefined; | |||
readonly verified: boolean; | |||
} | |||
export interface SubscriptionPreview { | |||
readonly startedOn: string; | |||
readonly expiresOn: string; | |||
} | |||
export const enum SubscriptionState { | |||
/** Indicates a user who hasn't verified their email address yet */ | |||
VerificationRequired = -1, | |||
/** Indicates a Free user who hasn't yet started the preview */ | |||
Free = 0, | |||
/** Indicates a Free user who is in preview */ | |||
FreeInPreview, | |||
/** Indicates a Free user who's preview has expired */ | |||
FreePreviewExpired, | |||
/** Indicates a Free+ user with a completed trial */ | |||
FreePlusInTrial, | |||
/** Indicates a Free+ user who's trial has expired */ | |||
FreePlusTrialExpired, | |||
/** Indicates a Paid user */ | |||
Paid, | |||
} | |||
export function computeSubscriptionState(subscription: Optional<Subscription, 'state'>): SubscriptionState { | |||
const { | |||
account, | |||
plan: { actual, effective }, | |||
preview, | |||
} = subscription; | |||
if (account?.verified === false) return SubscriptionState.VerificationRequired; | |||
if (actual.id === effective.id) { | |||
switch (effective.id) { | |||
case SubscriptionPlanId.Free: | |||
return preview == null ? SubscriptionState.Free : SubscriptionState.FreePreviewExpired; | |||
case SubscriptionPlanId.FreePlus: | |||
return SubscriptionState.FreePlusTrialExpired; | |||
case SubscriptionPlanId.Pro: | |||
case SubscriptionPlanId.Teams: | |||
case SubscriptionPlanId.Enterprise: | |||
return SubscriptionState.Paid; | |||
} | |||
} | |||
switch (effective.id) { | |||
case SubscriptionPlanId.Free: | |||
return preview == null ? SubscriptionState.Free : SubscriptionState.FreeInPreview; | |||
case SubscriptionPlanId.FreePlus: | |||
return SubscriptionState.FreePlusTrialExpired; | |||
case SubscriptionPlanId.Pro: | |||
return actual.id === SubscriptionPlanId.Free | |||
? SubscriptionState.FreeInPreview | |||
: SubscriptionState.FreePlusInTrial; | |||
case SubscriptionPlanId.Teams: | |||
case SubscriptionPlanId.Enterprise: | |||
return SubscriptionState.Paid; | |||
} | |||
} | |||
export function getSubscriptionPlan(id: SubscriptionPlanId, startedOn?: Date, expiresOn?: Date): SubscriptionPlan { | |||
return { | |||
id: id, | |||
name: getSubscriptionPlanName(id), | |||
startedOn: (startedOn ?? new Date()).toISOString(), | |||
expiresOn: expiresOn != null ? expiresOn.toISOString() : undefined, | |||
}; | |||
} | |||
export function getSubscriptionPlanName(id: SubscriptionPlanId) { | |||
switch (id) { | |||
case SubscriptionPlanId.FreePlus: | |||
return 'GitLens Free+'; | |||
case SubscriptionPlanId.Pro: | |||
return 'GitLens Pro'; | |||
case SubscriptionPlanId.Teams: | |||
return 'GitLens Teams'; | |||
case SubscriptionPlanId.Enterprise: | |||
return 'GitLens Enterprise'; | |||
case SubscriptionPlanId.Free: | |||
default: | |||
return 'GitLens Free'; | |||
} | |||
} | |||
const plansPriority = new Map<SubscriptionPlanId | undefined, number>([ | |||
[undefined, -1], | |||
[SubscriptionPlanId.Free, 0], | |||
[SubscriptionPlanId.FreePlus, 1], | |||
[SubscriptionPlanId.Pro, 2], | |||
[SubscriptionPlanId.Teams, 3], | |||
[SubscriptionPlanId.Enterprise, 4], | |||
]); | |||
export function getSubscriptionPlanPriority(id: SubscriptionPlanId | undefined): number { | |||
return plansPriority.get(id)!; | |||
} | |||
export function getSubscriptionTimeRemaining( | |||
subscription: Optional<Subscription, 'state'>, | |||
unit?: 'days' | 'hours' | 'minutes' | 'seconds', | |||
): number | undefined { | |||
return getTimeRemaining(subscription.plan.effective.expiresOn, unit); | |||
} | |||
export function getTimeRemaining( | |||
expiresOn: string | undefined, | |||
unit?: 'days' | 'hours' | 'minutes' | 'seconds', | |||
): number | undefined { | |||
return expiresOn != null ? getDateDifference(Date.now(), new Date(expiresOn), unit) : undefined; | |||
} | |||
export function isPaidSubscriptionPlan(id: SubscriptionPlanId): id is PaidSubscriptionPlans { | |||
return id !== SubscriptionPlanId.Free && id !== SubscriptionPlanId.FreePlus; | |||
} | |||
export function isSubscriptionExpired(subscription: Optional<Subscription, 'state'>): boolean { | |||
const remaining = getSubscriptionTimeRemaining(subscription); | |||
return remaining != null && remaining <= 0; | |||
} | |||
export function isSubscriptionTrial(subscription: Optional<Subscription, 'state'>): boolean { | |||
return subscription.plan.actual.id !== subscription.plan.effective.id; | |||
} |
@ -0,0 +1,180 @@ | |||
<!DOCTYPE html> | |||
<html lang="en"> | |||
<head> | |||
<meta charset="utf-8" /> | |||
<style nonce="#{cspNonce}"> | |||
@font-face { | |||
font-family: 'codicon'; | |||
src: url('#{root}/dist/webviews/codicon.ttf?404cbc4fe3a64b9a93064eef76704c79') format('truetype'); | |||
} | |||
</style> | |||
</head> | |||
<body class="preload"> | |||
<div class="container"> | |||
<div id="slot1"></div> | |||
<vscode-divider></vscode-divider> | |||
<div id="slot2"></div> | |||
</div> | |||
<script type="module" src="#{root}/dist/webviews/toolkit.min.js"></script> | |||
#{endOfBody} | |||
</body> | |||
<template id="welcome"> | |||
<section> | |||
<h3>Welcome to GitLens 12!</h3> | |||
<p> | |||
GitLens <b>supercharges</b> Git inside VS Code and <b>unlocks</b> the untapped <b>knowledge</b> within | |||
each repository. While GitLens is both very <b>powerful</b> and feature rich, it is <b>intuitive</b> and | |||
highly customizable to meet your needs. | |||
</p> | |||
<p> | |||
Jump over to the getting started experience to familiarize yourself with the features GitLens provides. | |||
</p> | |||
<vscode-button data-action="command:gitlens.getStarted">Get Started</vscode-button> | |||
<p>Or, you can use the GitLens welcome experience to get setup quickly.</p> | |||
<vscode-button data-action="command:gitlens.showWelcomePage">Welcome (Quick Setup)</vscode-button> | |||
<ul> | |||
<li> | |||
To view all GitLens settings, run | |||
<a href="command:workbench.action.quickOpen?%22>GitLens%3A%20Open%20Settings%22" | |||
>GitLens: Open Settings</a | |||
> | |||
from the Command Palette. | |||
</li> | |||
<li> | |||
<a href="command:gitlens.showSettingsPage%23views">GitLens side bar views</a> | |||
for commits, branches, etc. are displayed within the Source Control side bar by default. | |||
<a href="command:workbench.action.quickOpen?%22>GitLens%3A%20Set%20Views%20Layout%22" | |||
>Run GitLens: Set Views Layout</a | |||
> | |||
from the Command Palette to display them here in the GitLens side bar, or drag & drop them | |||
individually. | |||
</li> | |||
</ul> | |||
<vscode-button data-action="command:gitlens.home.hideWelcome" appearance="secondary">Hide</vscode-button> | |||
</section> | |||
</template> | |||
<template id="state:free"> | |||
<section> | |||
<h3>Try GitLens Premium Features</h3> | |||
<p> | |||
Premium features like <a href="">Git Worktrees</a> and <a href="">Visual File History</a> are available | |||
with a free account, with many more features coming soon, including a commit graph and GitHub Enterprise | |||
integration. Access to premium features for private code is available with a paid account. | |||
<a href="">Learn more</a> about GitLens premium features. | |||
</p> | |||
<p> | |||
You can try these premium features for free, without an account, for 3 days. All non-premium features | |||
will continue to be free without an account. | |||
</p> | |||
<vscode-button data-action="command:gitlens.premium.startPreview">Try premium features now</vscode-button> | |||
<vscode-button data-action="command:gitlens.premium.loginOrSignUp">Create a free account</vscode-button> | |||
<vscode-button data-action="command:gitlens.premium.showPlans" appearance="secondary" | |||
>View paid plans</vscode-button | |||
> | |||
</section> | |||
</template> | |||
<template id="state:free-preview"> | |||
<section> | |||
<h3>Trying Premium Features</h3> | |||
<p> | |||
You are currently trying premium GitLens features, like <a href="">Git Worktrees</a> and | |||
<a href="">Visual File History</a>, for <span data-bind="previewDays">3 more days</span>. | |||
<a href="">Learn more</a> about GitLens premium features. | |||
</p> | |||
<p> | |||
After that time, a free account will be required to continue using these premium features for public | |||
code, or you can puchase a paid plan to access premium features for private code. | |||
</p> | |||
<vscode-button data-action="command:gitlens.premium.loginOrSignUp">Create a free account</vscode-button> | |||
<vscode-button data-action="command:gitlens.premium.purchase">Purchase a plan</vscode-button> | |||
<p>All non-premium features will continue to be free without an account.</p> | |||
</section> | |||
</template> | |||
<template id="state:free-preview-expired"> | |||
<section> | |||
<h3>Continue using Premium Features</h3> | |||
<p> | |||
Premium GitLens features like <a href="">Git Worktrees</a> and <a href="">Visual File History</a>, a | |||
commit graph (coming soon), and GitHub Enterprise integration (coming soon) are only available with an | |||
account. <a href="">Learn more</a> about GitLens premium features. | |||
</p> | |||
<p>Create a free account to continue trialing premium features for all code for an additional 7 days.</p> | |||
<vscode-button data-action="command:gitlens.premium.loginOrSignUp">Create a free account</vscode-button> | |||
<vscode-button data-action="command:gitlens.premium.purchase">Purchase a plan</vscode-button> | |||
<p>All non-premium features will continue to be free without an account.</p> | |||
<vscode-button data-action="command:gitlens.home.hideSubscription" appearance="secondary" | |||
>Close</vscode-button | |||
> | |||
</section> | |||
</template> | |||
<template id="state:plus-trial"> | |||
<section> | |||
<h3>Premium Feature Trial</h3> | |||
<p> | |||
You are currently trialing premium GitLens features like <a href="">Git Worktrees</a> and | |||
<a href="">Visual File History</a> for both public and private code. In | |||
<span data-bind="trialDays">7 days</span>, accessing these premium features for private code will | |||
require a paid account. | |||
</p> | |||
<vscode-button data-action="command:gitlens.premium.purchase">Purchase a plan</vscode-button> | |||
<p> | |||
With your free account, you will continue to have access to premium features for public code, as well as | |||
all non-premium features. | |||
</p> | |||
<!-- <vscode-button appearance="secondary">Close</vscode-button> --> | |||
</section> | |||
</template> | |||
<template id="state:verify-email"> | |||
<section> | |||
<h3>Please validate your email</h3> | |||
<p>To continue using premium GitLens features, please validate the email for the account you created.</p> | |||
<vscode-button data-action="command:gitlens.premium.resendVerification" | |||
>Resend verification email</vscode-button | |||
> | |||
<vscode-button data-action="command:gitlens.premium.validate">Refresh validation</vscode-button> | |||
<p>All non-premium features will continue to be free without an account.</p> | |||
</section> | |||
</template> | |||
<template id="state:plus-trial-expired"> | |||
<section> | |||
<h3>GitLens Free+</h3> | |||
<p> | |||
With your free account, you have access to GitLens Free+, which unlocks premium features like | |||
<a href="">Git Worktrees</a> and <a href="">Visual File History</a> for public code. More premium | |||
features like a commit graph and GitHub Enterprise integration are coming soon. | |||
<a href="">Learn more</a> about GitLens premium features. | |||
</p> | |||
<p>Access to premium features for private code requires a paid plan.</p> | |||
<vscode-button data-action="command:gitlens.premium.purchase">Purchase a plan</vscode-button> | |||
<vscode-button data-action="command:gitlens.home.hideSubscription" appearance="secondary" | |||
>Close</vscode-button | |||
> | |||
</section> | |||
</template> | |||
<template id="state:paid"> | |||
<section> | |||
<h3 data-bind="plan">GitLens Pro</h3> | |||
<p> | |||
Thank you for purchasing <span data-bind="plan">GitLens Pro</span>! With a | |||
<span data-bind="plan">GitLens Pro</span> account, you can access premium features like | |||
<a href="">Git Worktrees</a> and <a href="">Visual File History</a> for all of your code. | |||
</p> | |||
<p> | |||
Additional premium featues like a commit graph and GitHub Enterprise integration are coming soon. | |||
<a href="">Learn more</a> about GitLens premium features. | |||
</p> | |||
<vscode-button data-action="command:gitlens.home.hideSubscription" appearance="secondary" | |||
>Close</vscode-button | |||
> | |||
</section> | |||
</template> | |||
</html> |
@ -0,0 +1,80 @@ | |||
// @import '../../scss/base'; | |||
// @import '../../scss/buttons'; | |||
// @import '../../scss/utils'; | |||
html { | |||
height: 100%; | |||
font-size: 62.5%; | |||
box-sizing: border-box; | |||
} | |||
body { | |||
background-color: var(--color-background); | |||
color: var(--color-foreground); | |||
font-family: var(--font-family); | |||
height: 100%; | |||
line-height: 1.4; | |||
font-size: 100% !important; | |||
} | |||
.container { | |||
color: var(--color-view-foreground); | |||
display: grid; | |||
font-size: 1.3em; | |||
padding-bottom: 1.5rem; | |||
} | |||
section { | |||
box-sizing: border-box; | |||
display: flex; | |||
flex-direction: column; | |||
padding: 0; | |||
} | |||
h3 { | |||
border: none; | |||
color: var(--color-view-header-foreground); | |||
font-size: 1.5rem; | |||
font-weight: 600; | |||
margin-bottom: 0; | |||
white-space: nowrap; | |||
} | |||
a { | |||
text-decoration: none; | |||
&:focus { | |||
outline-color: var(--focus-border); | |||
} | |||
&:hover { | |||
text-decoration: underline; | |||
} | |||
} | |||
b { | |||
font-weight: 600; | |||
} | |||
p { | |||
margin-bottom: 0; | |||
} | |||
vscode-button { | |||
align-self: center; | |||
margin-top: 1.5rem; | |||
max-width: 300px; | |||
width: 100%; | |||
} | |||
@media (min-width: 640px) { | |||
vscode-button { | |||
align-self: flex-start; | |||
} | |||
} | |||
vscode-divider { | |||
margin-top: 2rem; | |||
} | |||
@import '../../scss/codicons'; |
@ -0,0 +1,118 @@ | |||
/*global window*/ | |||
import './home.scss'; | |||
import { Disposable } from 'vscode'; | |||
import { getSubscriptionTimeRemaining, SubscriptionState } from '../../../../subscription'; | |||
import { DidChangeSubscriptionNotificationType, State } from '../../../premium/home/protocol'; | |||
import { ExecuteCommandType, IpcMessage, onIpc } from '../../../protocol'; | |||
import { App } from '../../shared/appBase'; | |||
import { DOM } from '../../shared/dom'; | |||
export class HomeApp extends App<State> { | |||
private $slot1!: HTMLDivElement; | |||
private $slot2!: HTMLDivElement; | |||
constructor() { | |||
super('HomeApp', (window as any).bootstrap); | |||
(window as any).bootstrap = undefined; | |||
} | |||
protected override onInitialize() { | |||
this.$slot1 = document.getElementById('slot1') as HTMLDivElement; | |||
this.$slot2 = document.getElementById('slot2') as HTMLDivElement; | |||
this.updateState(); | |||
} | |||
protected override onBind(): Disposable[] { | |||
const disposables = super.onBind?.() ?? []; | |||
disposables.push(DOM.on('[data-action]', 'click', (e, target: HTMLElement) => this.onClicked(e, target))); | |||
return disposables; | |||
} | |||
protected override onMessageReceived(e: MessageEvent) { | |||
const msg = e.data as IpcMessage; | |||
switch (msg.method) { | |||
case DidChangeSubscriptionNotificationType.method: | |||
onIpc(DidChangeSubscriptionNotificationType, msg, params => { | |||
this.state = params; | |||
this.updateState(); | |||
}); | |||
break; | |||
default: | |||
super.onMessageReceived?.(e); | |||
break; | |||
} | |||
} | |||
private onClicked(e: MouseEvent, target: HTMLElement) { | |||
const action = target.dataset.action; | |||
if (action?.startsWith('command:')) { | |||
this.sendCommand(ExecuteCommandType, { command: action.slice(8) }); | |||
} | |||
} | |||
private updateState() { | |||
const { subscription } = this.state; | |||
if (subscription.account?.verified === false) { | |||
this.insertTemplate('state:verify-email', this.$slot1); | |||
this.insertTemplate('welcome', this.$slot2); | |||
return; | |||
} | |||
switch (subscription.state) { | |||
case SubscriptionState.Free: | |||
this.insertTemplate('welcome', this.$slot1); | |||
this.insertTemplate('state:free', this.$slot2); | |||
break; | |||
case SubscriptionState.FreeInPreview: { | |||
const remaining = getSubscriptionTimeRemaining(subscription, 'days'); | |||
this.insertTemplate('state:free-preview', this.$slot1, { | |||
previewDays: `${remaining === 1 ? `${remaining} more day` : `${remaining} more days`}`, | |||
}); | |||
this.insertTemplate('welcome', this.$slot2); | |||
break; | |||
} | |||
case SubscriptionState.FreePreviewExpired: | |||
this.insertTemplate('state:free-preview-expired', this.$slot1); | |||
this.insertTemplate('welcome', this.$slot2); | |||
break; | |||
case SubscriptionState.FreePlusInTrial: { | |||
const remaining = getSubscriptionTimeRemaining(subscription, 'days'); | |||
this.insertTemplate('state:plus-trial', this.$slot1, { | |||
trialDays: `${remaining === 1 ? `${remaining} day` : `${remaining} days`}`, | |||
}); | |||
this.insertTemplate('welcome', this.$slot2); | |||
break; | |||
} | |||
case SubscriptionState.FreePlusTrialExpired: | |||
this.insertTemplate('state:plus-trial-expired', this.$slot1); | |||
this.insertTemplate('welcome', this.$slot2); | |||
break; | |||
case SubscriptionState.Paid: | |||
this.insertTemplate('state:paid', this.$slot1); | |||
this.insertTemplate('welcome', this.$slot2); | |||
break; | |||
} | |||
} | |||
private insertTemplate(id: string, $slot: HTMLDivElement, bindings?: Record<string, unknown>): void { | |||
const $template = (document.getElementById(id) as HTMLTemplateElement)?.content.cloneNode(true); | |||
$slot.replaceChildren($template); | |||
if (bindings != null) { | |||
for (const [key, value] of Object.entries(bindings)) { | |||
const $el = $slot.querySelector(`[data-bind="${key}"]`); | |||
if ($el != null) { | |||
$el.textContent = String(value); | |||
} | |||
} | |||
} | |||
} | |||
} | |||
new HomeApp(); |
@ -0,0 +1,41 @@ | |||
import { commands, Disposable, window } from 'vscode'; | |||
import type { Container } from '../../../container'; | |||
import type { SubscriptionChangeEvent } from '../../../premium/subscription/subscriptionService'; | |||
import type { Subscription } from '../../../subscription'; | |||
import { WebviewViewBase } from '../../webviewViewBase'; | |||
import { DidChangeSubscriptionNotificationType, State } from './protocol'; | |||
export class HomeWebviewView extends WebviewViewBase<State> { | |||
constructor(container: Container) { | |||
super(container, 'gitlens.views.home', 'home.html', 'Home'); | |||
this.disposables.push(this.container.subscription.onDidChange(this.onSubscriptionChanged, this)); | |||
} | |||
private onSubscriptionChanged(e: SubscriptionChangeEvent) { | |||
void this.notifyDidChangeData(e.current); | |||
} | |||
protected override registerCommands(): Disposable[] { | |||
// TODO@eamodio implement hide commands | |||
return [ | |||
commands.registerCommand('gitlens.home.hideWelcome', () => {}), | |||
commands.registerCommand('gitlens.home.hideSubscription', () => {}), | |||
]; | |||
} | |||
protected override async includeBootstrap(): Promise<State> { | |||
const subscription = await this.container.subscription.getSubscription(); | |||
return { | |||
subscription: subscription, | |||
}; | |||
} | |||
private notifyDidChangeData(subscription: Subscription) { | |||
if (!this.isReady) return false; | |||
return window.withProgress({ location: { viewId: this.id } }, () => | |||
this.notify(DidChangeSubscriptionNotificationType, { subscription: subscription }), | |||
); | |||
} | |||
} |
@ -0,0 +1,13 @@ | |||
import type { Subscription } from '../../../subscription'; | |||
import { IpcNotificationType } from '../../protocol'; | |||
export interface State { | |||
subscription: Subscription; | |||
} | |||
export interface DidChangeSubscriptionParams { | |||
subscription: Subscription; | |||
} | |||
export const DidChangeSubscriptionNotificationType = new IpcNotificationType<DidChangeSubscriptionParams>( | |||
'subscription/didChange', | |||
); |
@ -0,0 +1,240 @@ | |||
import { | |||
CancellationToken, | |||
Disposable, | |||
Uri, | |||
Webview, | |||
WebviewView, | |||
WebviewViewProvider, | |||
WebviewViewResolveContext, | |||
window, | |||
workspace, | |||
} from 'vscode'; | |||
import { getNonce } from '@env/crypto'; | |||
import { Commands } from '../constants'; | |||
import type { Container } from '../container'; | |||
import { Logger } from '../logger'; | |||
import { executeCommand } from '../system/command'; | |||
import { | |||
ExecuteCommandType, | |||
IpcMessage, | |||
IpcMessageParams, | |||
IpcNotificationType, | |||
onIpc, | |||
WebviewReadyCommandType, | |||
} from './protocol'; | |||
let ipcSequence = 0; | |||
function nextIpcId() { | |||
if (ipcSequence === Number.MAX_SAFE_INTEGER) { | |||
ipcSequence = 1; | |||
} else { | |||
ipcSequence++; | |||
} | |||
return `host:${ipcSequence}`; | |||
} | |||
const emptyCommands: Disposable[] = [ | |||
{ | |||
dispose: function () { | |||
/* noop */ | |||
}, | |||
}, | |||
]; | |||
export abstract class WebviewViewBase<State> implements WebviewViewProvider, Disposable { | |||
protected readonly disposables: Disposable[] = []; | |||
protected isReady: boolean = false; | |||
private _disposableView: Disposable | undefined; | |||
private _view: WebviewView | undefined; | |||
constructor( | |||
protected readonly container: Container, | |||
public readonly id: string, | |||
protected readonly fileName: string, | |||
title: string, | |||
) { | |||
this._title = title; | |||
this.disposables.push(window.registerWebviewViewProvider(id, this)); | |||
} | |||
dispose() { | |||
this.disposables.forEach(d => d.dispose()); | |||
this._disposableView?.dispose(); | |||
} | |||
get description(): string | undefined { | |||
return this._view?.description; | |||
} | |||
set description(description: string | undefined) { | |||
if (this._view == null) return; | |||
this._view.description = description; | |||
} | |||
private _title: string; | |||
get title(): string { | |||
return this._view?.title ?? this._title; | |||
} | |||
set title(title: string) { | |||
this._title = title; | |||
if (this._view == null) return; | |||
this._view.title = title; | |||
} | |||
get visible() { | |||
return this._view?.visible ?? false; | |||
} | |||
protected onReady?(): void; | |||
protected onMessageReceived?(e: IpcMessage): void; | |||
protected registerCommands(): Disposable[] { | |||
return emptyCommands; | |||
} | |||
protected includeBootstrap?(): State | Promise<State>; | |||
protected includeHead?(): string | Promise<string>; | |||
protected includeBody?(): string | Promise<string>; | |||
protected includeEndOfBody?(): string | Promise<string>; | |||
async resolveWebviewView( | |||
webviewView: WebviewView, | |||
_context: WebviewViewResolveContext, | |||
_token: CancellationToken, | |||
): Promise<void> { | |||
this._view = webviewView; | |||
webviewView.webview.options = { | |||
enableCommandUris: true, | |||
enableScripts: true, | |||
}; | |||
webviewView.title = this._title; | |||
this._disposableView = Disposable.from( | |||
this._view.onDidDispose(this.onViewDisposed, this), | |||
// this._view.onDidChangeVisibility(this.onViewVisibilityChanged, this), | |||
this._view.webview.onDidReceiveMessage(this.onMessageReceivedCore, this), | |||
...this.registerCommands(), | |||
); | |||
webviewView.webview.html = await this.getHtml(webviewView.webview); | |||
// this.onViewVisibilityChanged(); | |||
} | |||
private onViewDisposed() { | |||
this._disposableView?.dispose(); | |||
this._disposableView = undefined; | |||
this._view = undefined; | |||
} | |||
// private _disposableVisibility: Disposable | undefined; | |||
// private onViewVisibilityChanged() { | |||
// if (this._view?.visible) { | |||
// console.log('became visible'); | |||
// if (this._disposableVisibility == null) { | |||
// // this._disposableVisibility = window.onDidChangeActiveTextEditor( | |||
// // debounce(this.onActiveEditorChanged, 500), | |||
// // this, | |||
// // ); | |||
// // this.onActiveEditorChanged(window.activeTextEditor); | |||
// } | |||
// } else { | |||
// console.log('became hidden'); | |||
// this._disposableVisibility?.dispose(); | |||
// this._disposableVisibility = undefined; | |||
// // this.setTitle(this.title); | |||
// } | |||
// } | |||
private onMessageReceivedCore(e: IpcMessage) { | |||
if (e == null) return; | |||
Logger.log(`WebviewView(${this.id}).onMessageReceived: method=${e.method}, data=${JSON.stringify(e)}`); | |||
switch (e.method) { | |||
case WebviewReadyCommandType.method: | |||
onIpc(WebviewReadyCommandType, e, () => { | |||
this.isReady = true; | |||
this.onReady?.(); | |||
}); | |||
break; | |||
case ExecuteCommandType.method: | |||
onIpc(ExecuteCommandType, e, params => { | |||
if (params.args != null) { | |||
void executeCommand(params.command as Commands, ...params.args); | |||
} else { | |||
void executeCommand(params.command as Commands); | |||
} | |||
}); | |||
break; | |||
default: | |||
this.onMessageReceived?.(e); | |||
break; | |||
} | |||
} | |||
private async getHtml(webview: Webview): Promise<string> { | |||
const uri = Uri.joinPath(this.container.context.extensionUri, 'dist', 'webviews', this.fileName); | |||
const content = new TextDecoder('utf8').decode(await workspace.fs.readFile(uri)); | |||
const [bootstrap, head, body, endOfBody] = await Promise.all([ | |||
this.includeBootstrap?.(), | |||
this.includeHead?.(), | |||
this.includeBody?.(), | |||
this.includeEndOfBody?.(), | |||
]); | |||
const cspSource = webview.cspSource; | |||
const cspNonce = getNonce(); | |||
const root = webview.asWebviewUri(this.container.context.extensionUri).toString(); | |||
const html = content | |||
.replace(/#{(head|body|endOfBody)}/i, (_substring, token) => { | |||
switch (token) { | |||
case 'head': | |||
return head ?? ''; | |||
case 'body': | |||
return body ?? ''; | |||
case 'endOfBody': | |||
return bootstrap != null | |||
? `<script type="text/javascript" nonce="#{cspNonce}">window.bootstrap = ${JSON.stringify( | |||
bootstrap, | |||
)};</script>${endOfBody ?? ''}` | |||
: endOfBody ?? ''; | |||
default: | |||
return ''; | |||
} | |||
}) | |||
.replace(/#{(cspSource|cspNonce|root)}/g, (_substring, token) => { | |||
switch (token) { | |||
case 'cspSource': | |||
return cspSource; | |||
case 'cspNonce': | |||
return cspNonce; | |||
case 'root': | |||
return root; | |||
default: | |||
return ''; | |||
} | |||
}); | |||
return html; | |||
} | |||
protected notify<T extends IpcNotificationType<any>>(type: T, params: IpcMessageParams<T>): Thenable<boolean> { | |||
return this.postMessage({ id: nextIpcId(), method: type.method, params: params }); | |||
} | |||
private postMessage(message: IpcMessage) { | |||
if (this._view == null) return Promise.resolve(false); | |||
return this._view.webview.postMessage(message); | |||
} | |||
} |