@ -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); | |||||
} | |||||
} |