diff --git a/.vscode/launch.json b/.vscode/launch.json index fbad7b7..2e84f4a 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -12,8 +12,8 @@ "debugWebviews": true, "rendererDebugOptions": { "sourceMaps": true, - "urlFilter": "*eamodio.gitlens*", - "webRoot": "${workspaceFolder}/src/webviews/apps" + // "urlFilter": "*eamodio.gitlens*", + "webRoot": "${workspaceFolder}/src/webviews" }, "outFiles": ["${workspaceFolder}/dist/**/*.js"], "presentation": { @@ -35,8 +35,8 @@ "debugWebviews": true, "rendererDebugOptions": { "sourceMaps": true, - "urlFilter": "*eamodio.gitlens*", - "webRoot": "${workspaceFolder}/src/webviews/apps" + // "urlFilter": "*eamodio.gitlens*", + "webRoot": "${workspaceFolder}/src/webviews" }, "outFiles": ["${workspaceFolder}/dist/**/*.js"], "presentation": { @@ -76,8 +76,8 @@ "debugWebviews": true, "rendererDebugOptions": { "sourceMaps": true, - "urlFilter": "*eamodio.gitlens*", - "webRoot": "${workspaceFolder}/src/webviews/apps" + // "urlFilter": "*eamodio.gitlens*", + "webRoot": "${workspaceFolder}/src/webviews" }, "outFiles": ["${workspaceFolder}/dist/**/*.js"], "preLaunchTask": "${defaultBuildTask}", @@ -128,8 +128,8 @@ "debugWebviews": true, "rendererDebugOptions": { "sourceMaps": true, - "urlFilter": "*eamodio.gitlens*", - "webRoot": "${workspaceFolder}/src/webviews/apps" + // "urlFilter": "*eamodio.gitlens*", + "webRoot": "${workspaceFolder}/src/webviews" }, "outFiles": ["${workspaceFolder}/dist/**/*.js"], "preLaunchTask": "${defaultBuildTask}", diff --git a/package.json b/package.json index a8e76b6..279e23a 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "activationEvents": [ "onCustomEditor:gitlens.rebase", "onFileSystem:gitlens", + "onView:gitlens.views.home", "onView:gitlens.views.repositories", "onView:gitlens.views.commits", "onView:gitlens.views.fileHistory", @@ -60,6 +61,15 @@ "onView:gitlens.views.tags", "onView:gitlens.views.contributors", "onView:gitlens.views.searchAndCompare", + "onWebviewPanel:gitlens.welcome", + "onWebviewPanel:gitlens.settings", + "onCommand:gitlens.premium.login", + "onCommand:gitlens.premium.loginOrSignUp", + "onCommand:gitlens.premium.signUp", + "onCommand:gitlens.premium.logout", + "onCommand:gitlens.premium.startPreview", + "onCommand:gitlens.premium.purchase", + "onCommand:gitlens.premium.reset", "onCommand:gitlens.showSettingsPage", "onCommand:gitlens.showSettingsPage#views", "onCommand:gitlens.showSettingsPage#branches-view", @@ -3456,6 +3466,41 @@ ], "commands": [ { + "command": "gitlens.premium.login", + "title": "Login...", + "category": "GitLens Premium" + }, + { + "command": "gitlens.premium.loginOrSignUp", + "title": "Unlock Premium Features...", + "category": "GitLens Premium" + }, + { + "command": "gitlens.premium.signUp", + "title": "Sign Up for Free+...", + "category": "GitLens Premium" + }, + { + "command": "gitlens.premium.logout", + "title": "Logout", + "category": "GitLens Premium" + }, + { + "command": "gitlens.premium.startPreview", + "title": "Try Premium Features...", + "category": "GitLens Premium" + }, + { + "command": "gitlens.premium.purchase", + "title": "Unlock Premium Features for Private Code...", + "category": "GitLens Premium" + }, + { + "command": "gitlens.premium.reset", + "title": "Reset...", + "category": "GitLens Premium" + }, + { "command": "gitlens.showSettingsPage", "title": "Open Settings", "category": "GitLens", @@ -5601,6 +5646,33 @@ "menus": { "commandPalette": [ { + "command": "gitlens.premium.login", + "when": "gitlens:premium == free" + }, + { + "command": "gitlens.premium.loginOrSignUp", + "when": "gitlens:premium == free" + }, + { + "command": "gitlens.premium.signUp", + "when": "gitlens:premium == free" + }, + { + "command": "gitlens.premium.logout", + "when": "gitlens:premium != free" + }, + { + "command": "gitlens.premium.startPreview", + "when": "gitlens:premium == free" + }, + { + "command": "gitlens.premium.purchase", + "when": "!gitlens:premium:paid" + }, + { + "command": "gitlens.premium.reset" + }, + { "command": "gitlens.showSettingsPage#views", "when": "false" }, @@ -10030,6 +10102,15 @@ "views": { "gitlens": [ { + "type": "webview", + "id": "gitlens.views.home", + "name": "Home", + "-when": "!gitlens:disabled && gitlens:views:home:visible != false", + "contextualTitle": "GitLens", + "icon": "images/gitlens-activitybar.svg", + "visibility": "visible" + }, + { "id": "gitlens.views.welcome", "name": "Welcome", "when": "gitlens:views:welcome:visible != false", @@ -10178,6 +10259,7 @@ "dependencies": { "@octokit/core": "3.5.1", "@vscode/codicons": "0.0.28", + "@vscode/webview-ui-toolkit": "0.9.1", "ansi-regex": "6.0.1", "chroma-js": "2.3.0", "iconv-lite": "0.6.3", diff --git a/src/commands/gitCommands.ts b/src/commands/gitCommands.ts index b72f610..e7fe73c 100644 --- a/src/commands/gitCommands.ts +++ b/src/commands/gitCommands.ts @@ -1,7 +1,7 @@ import { Disposable, InputBox, QuickInputButton, QuickInputButtons, QuickPick, QuickPickItem, window } from 'vscode'; import { configuration } from '../configuration'; import { Commands } from '../constants'; -import type { Container } from '../container'; +import { Container } from '../container'; import { KeyMapping } from '../keyboard'; import { Directive, DirectiveQuickPickItem } from '../quickpicks/items/directive'; import { command } from '../system/command'; @@ -653,6 +653,21 @@ export class GitCommandsCommand extends Command { case Directive.LoadMore: void loadMore(); return; + + case Directive.RequiresVerification: + void Container.instance.subscription.resendVerification(); + resolve(undefined); + return; + + case Directive.RequiresFreeSubscription: + void Container.instance.subscription.loginOrSignUp(); + resolve(undefined); + return; + + case Directive.RequiresPaidSubscription: + void Container.instance.subscription.purchase(); + resolve(undefined); + return; } } } diff --git a/src/commands/quickCommand.steps.ts b/src/commands/quickCommand.steps.ts index 1926bf3..68f39ad 100644 --- a/src/commands/quickCommand.steps.ts +++ b/src/commands/quickCommand.steps.ts @@ -2,7 +2,7 @@ import { QuickInputButton, QuickPick } from 'vscode'; import { BranchSorting, configuration, TagSorting } from '../configuration'; import { Commands, GlyphChars, quickPickTitleMaxChars } from '../constants'; import { Container } from '../container'; -import { PagedResult } from '../git/gitProvider'; +import { PagedResult, PremiumFeatures } from '../git/gitProvider'; import { BranchSortOptions, GitBranch, @@ -64,6 +64,7 @@ import { CopyRemoteResourceCommandQuickPickItem, OpenRemoteResourceCommandQuickPickItem, } from '../quickpicks/remoteProviderPicker'; +import { isPaidSubscriptionPlan } from '../subscription'; import { filterMap, intersection, isStringArray } from '../system/array'; import { formatPath } from '../system/formatPath'; import { map } from '../system/iterable'; @@ -2070,3 +2071,37 @@ function getShowRepositoryStatusStepItems< return items; } + +export async function* ensureAccessStep< + State extends PartialStepState & { repo: Repository }, + Context extends { repos: Repository[]; title: string }, +>(state: State, context: Context, feature: PremiumFeatures): AsyncStepResultGenerator { + const access = await Container.instance.git.access(feature, state.repo.path); + if (access.allowed) return undefined; + + let directive: Directive; + let placeholder: string; + if (access.subscription.current.account?.verified === false) { + directive = Directive.RequiresVerification; + placeholder = 'You must verify your account email address before you can continue'; + } else { + if (access.subscription.required == null) return undefined; + + if (isPaidSubscriptionPlan(access.subscription.required)) { + directive = Directive.RequiresPaidSubscription; + placeholder = 'Requires a paid subscription'; + } else { + directive = Directive.RequiresFreeSubscription; + placeholder = 'Requires a Free+ account'; + } + } + + const step = QuickCommand.createPickStep({ + title: appendReposToTitle(context.title, state, context), + placeholder: placeholder, + items: [DirectiveQuickPickItem.create(directive, true), DirectiveQuickPickItem.create(Directive.Cancel)], + }); + + const selection: StepSelection = yield step; + return QuickCommand.canPickStepContinue(step, state, selection) ? undefined : StepResult.Break; +} diff --git a/src/commands/quickCommand.ts b/src/commands/quickCommand.ts index a399bb2..5e53e09 100644 --- a/src/commands/quickCommand.ts +++ b/src/commands/quickCommand.ts @@ -278,8 +278,11 @@ export namespace QuickCommand { case Directive.Cancel: endSteps(state); break; - case Directive.Noop: - break; + // case Directive.Noop: + // case Directive.RequiresVerification: + // case Directive.RequiresFreeSubscription: + // case Directive.RequiresProSubscription: + // break; } return false; } diff --git a/src/commands/showView.ts b/src/commands/showView.ts index 9c7709f..7306e65 100644 --- a/src/commands/showView.ts +++ b/src/commands/showView.ts @@ -20,6 +20,7 @@ export class ShowViewCommand extends Command { Commands.ShowStashesView, Commands.ShowTagsView, Commands.ShowWelcomeView, + Commands.ShowHomeView, ]); } @@ -53,6 +54,10 @@ export class ShowViewCommand extends Command { await setContext(ContextKeys.ViewsWelcomeVisible, true); void this.container.storage.store(SyncedStorageKeys.WelcomeViewVisible, true); void (await executeCommand('gitlens.views.welcome.focus')); + break; + case Commands.ShowHomeView: + void (await executeCommand('gitlens.views.home.focus')); + break; } return Promise.resolve(undefined); diff --git a/src/constants.ts b/src/constants.ts index 3d61f8c..7c761da 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -147,6 +147,7 @@ export const enum Commands { ShowCommitsInView = 'gitlens.showCommitsInView', ShowCommitsView = 'gitlens.showCommitsView', ShowContributorsView = 'gitlens.showContributorsView', + ShowHomeView = 'gitlens.showHomeView', ShowFileHistoryView = 'gitlens.showFileHistoryView', ShowLastQuickPick = 'gitlens.showLastQuickPick', ShowLineHistoryView = 'gitlens.showLineHistoryView', @@ -236,6 +237,10 @@ export const enum ContextKeys { ViewsSearchAndCompareKeepResults = 'gitlens:views:searchAndCompare:keepResults', ViewsWelcomeVisible = 'gitlens:views:welcome:visible', Vsls = 'gitlens:vsls', + + Premium = 'gitlens:premium', + PremiumPaid = 'gitlens:premium:paid', + PremiumUpgradeRequired = 'gitlens:premium:upgradeRequired', } export const enum CoreCommands { diff --git a/src/container.ts b/src/container.ts index c5cf1a7..81770c3 100644 --- a/src/container.ts +++ b/src/container.ts @@ -29,6 +29,7 @@ import { GitProviderService } from './git/gitProviderService'; import { LineHoverController } from './hovers/lineHoverController'; import { Keyboard } from './keyboard'; import { Logger } from './logger'; +import { SubscriptionService } from './premium/subscription/subscriptionService'; import { StatusBarController } from './statusbar/statusBarController'; import { Storage } from './storage'; import { executeCommand } from './system/command'; @@ -50,6 +51,7 @@ import { TagsView } from './views/tagsView'; import { ViewCommands } from './views/viewCommands'; import { ViewFileDecorationProvider } from './views/viewDecorationProvider'; import { VslsController } from './vsls/vsls'; +import { HomeWebviewView } from './webviews/premium/home/homeWebviewView'; import { RebaseEditorProvider } from './webviews/rebase/rebaseEditor'; import { SettingsWebview } from './webviews/settings/settingsWebview'; import { WelcomeWebview } from './webviews/welcome/welcomeWebview'; @@ -150,6 +152,8 @@ export class Container { context.subscriptions.push(configuration.onWillChange(this.onConfigurationChanging, this)); + context.subscriptions.push((this._subscription = new SubscriptionService(this))); + context.subscriptions.push((this._git = new GitProviderService(this))); context.subscriptions.push(new GitFileSystemProvider(this)); @@ -181,6 +185,8 @@ export class Container { context.subscriptions.push((this._contributorsView = new ContributorsView(this))); context.subscriptions.push((this._searchAndCompareView = new SearchAndCompareView(this))); + context.subscriptions.push((this._homeWebviewView = new HomeWebviewView(this))); + if (config.terminalLinks.enabled) { context.subscriptions.push((this._terminalLinks = new GitTerminalLinkProvider(this))); } @@ -304,6 +310,17 @@ export class Container { return this._context.extensionMode === ExtensionMode.Development; } + @memoize() + get env(): 'dev' | 'staging' | 'production' { + if (this.insiders || this.debugging) { + const env = configuration.getAny('gitkraken.env'); + if (env === 'dev') return 'dev'; + if (env === 'staging') return 'staging'; + } + + return 'production'; + } + private _fileAnnotationController: FileAnnotationController; get fileAnnotations() { return this._fileAnnotationController; @@ -384,6 +401,15 @@ export class Container { return this._rebaseEditor; } + private _homeWebviewView: HomeWebviewView | undefined; + get homeWebviewView() { + if (this._homeWebviewView == null) { + this._context.subscriptions.push((this._homeWebviewView = new HomeWebviewView(this))); + } + + return this._homeWebviewView; + } + private _remotesView: RemotesView | undefined; get remotesView() { if (this._remotesView == null) { @@ -411,6 +437,15 @@ export class Container { return this._searchAndCompareView; } + private _subscription: SubscriptionService | undefined; + get subscription() { + if (this._subscription == null) { + this._subscription = new SubscriptionService(this); + } + + return this._subscription; + } + private _settingsWebview: SettingsWebview; get settingsWebview() { return this._settingsWebview; diff --git a/src/env/node/git/localGitProvider.ts b/src/env/node/git/localGitProvider.ts index 522f67a..a6b21c2 100644 --- a/src/env/node/git/localGitProvider.ts +++ b/src/env/node/git/localGitProvider.ts @@ -15,6 +15,7 @@ import { workspace, WorkspaceFolder, } from 'vscode'; +import { fetch } from '@env/fetch'; import { hrtime } from '@env/hrtime'; import { isLinux, isWindows } from '@env/platform'; import type { @@ -27,16 +28,19 @@ import { CoreGitConfiguration, GlyphChars, Schemes } from '../../../constants'; import type { Container } from '../../../container'; import { StashApplyError, StashApplyErrorReason } from '../../../git/errors'; import { + Features, GitProvider, GitProviderDescriptor, GitProviderId, NextComparisionUrisResult, PagedResult, + PremiumFeatures, PreviousComparisionUrisResult, PreviousLineComparisionUrisResult, RepositoryCloseEvent, RepositoryInitWatcher, RepositoryOpenEvent, + RepositoryVisibility, RevisionUriData, ScmRepository, } from '../../../git/gitProvider'; @@ -91,11 +95,12 @@ import { LogType, } from '../../../git/parsers'; import { RemoteProviderFactory, RemoteProviders } from '../../../git/remotes/factory'; -import { RemoteProvider, RichRemoteProvider } from '../../../git/remotes/provider'; +import { RemoteProvider, RemoteResourceType, RichRemoteProvider } from '../../../git/remotes/provider'; import { SearchPattern } from '../../../git/search'; import { LogCorrelationContext, Logger } from '../../../logger'; import { Messages } from '../../../messages'; import { WorkspaceStorageKeys } from '../../../storage'; +import { SubscriptionPlanId } from '../../../subscription'; import { countStringLength, filterMap } from '../../../system/array'; import { gate } from '../../../system/decorators/gate'; import { debug, log } from '../../../system/decorators/log'; @@ -383,6 +388,93 @@ export class LocalGitProvider implements GitProvider, Disposable { }; } + private _allowedFeatures = new Map>(); + async allows(feature: PremiumFeatures, plan: SubscriptionPlanId, repoPath?: string): Promise { + if (plan === SubscriptionPlanId.Free) return false; + if (plan === SubscriptionPlanId.Pro) return true; + + if (repoPath == null) { + const repositories = [...this.container.git.getOpenRepositories(this.descriptor.id)]; + const results = await Promise.allSettled(repositories.map(r => this.allows(feature, plan, r.path))); + return results.every(r => r.status === 'fulfilled' && r.value); + } + + let allowedByRepo = this._allowedFeatures.get(repoPath); + let allowed = allowedByRepo?.get(feature); + if (allowed != null) return allowed; + + allowed = GitProviderService.previewFeatures?.get(feature) + ? true + : (await this.visibility(repoPath)) === RepositoryVisibility.Public; + if (allowedByRepo == null) { + allowedByRepo = new Map(); + this._allowedFeatures.set(repoPath, allowedByRepo); + } + + allowedByRepo.set(feature, allowed); + return allowed; + } + + private _supportedFeatures = new Map(); + // eslint-disable-next-line @typescript-eslint/require-await + async supports(feature: Features): Promise { + const supported = this._supportedFeatures.get(feature); + if (supported != null) return supported; + + return false; + } + + async visibility(repoPath: string): Promise { + const remotes = await this.getRemotes(repoPath); + if (remotes.length === 0) return RepositoryVisibility.Private; + + const origin = remotes.find(r => r.name === 'origin'); + if (origin != null) { + return this.getRemoteVisibility(origin); + } + + const upstream = remotes.find(r => r.name === 'upstream'); + if (upstream != null) { + return this.getRemoteVisibility(upstream); + } + + const results = await Promise.allSettled(remotes.map(r => this.getRemoteVisibility(r))); + for (const result of results) { + if (result.status !== 'fulfilled') continue; + + if (result.value === RepositoryVisibility.Public) return RepositoryVisibility.Public; + } + + return RepositoryVisibility.Private; + } + + private async getRemoteVisibility( + remote: GitRemote, + ): Promise { + switch (remote.provider?.id) { + case 'github': + case 'gitlab': + case 'bitbucket': + case 'azure-devops': + case 'gitea': + case 'gerrit': { + const url = remote.provider.url({ type: RemoteResourceType.Repo }); + if (url == null) return RepositoryVisibility.Private; + + // Check if the url returns a 200 status code + try { + const response = await fetch(url, { method: 'HEAD' }); + if (response.status === 200) { + return RepositoryVisibility.Public; + } + } catch {} + return RepositoryVisibility.Private; + } + default: + return RepositoryVisibility.Private; + } + } + @log({ args: false, singleLine: true, diff --git a/src/errors.ts b/src/errors.ts index 52c08cd..43eb3e2 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1,4 +1,27 @@ import { Uri } from 'vscode'; +import { isPaidSubscriptionPlan, RequiredSubscriptionPlans, Subscription } from './subscription'; + +export class AccessDeniedError extends Error { + public readonly subscription: Subscription; + public readonly required: RequiredSubscriptionPlans | undefined; + + constructor(subscription: Subscription, required: RequiredSubscriptionPlans | undefined) { + let message; + if (subscription.account?.verified === false) { + message = 'Email verification required'; + } else if (required != null && isPaidSubscriptionPlan(required)) { + message = 'Paid subscription required'; + } else { + message = 'Subscription required'; + } + + super(message); + + this.subscription = subscription; + this.required = required; + Error.captureStackTrace?.(this, AccessDeniedError); + } +} export const enum AuthenticationErrorReason { UserDidNotConsent = 1, diff --git a/src/git/gitProvider.ts b/src/git/gitProvider.ts index e57474d..cc0646a 100644 --- a/src/git/gitProvider.ts +++ b/src/git/gitProvider.ts @@ -1,5 +1,6 @@ import { Disposable, Event, Range, TextDocument, Uri, WorkspaceFolder } from 'vscode'; import { Commit, InputBox } from '../@types/vscode.git'; +import { SubscriptionPlanId } from '../subscription'; import { GitUri } from './gitUri'; import { BranchSortOptions, @@ -88,6 +89,15 @@ export interface RepositoryOpenEvent { readonly uri: Uri; } +export const enum Features {} + +export const enum PremiumFeatures {} + +export const enum RepositoryVisibility { + Private = 'private', + Public = 'public', +} + export interface GitProvider extends Disposable { get onDidChangeRepository(): Event; get onDidCloseRepository(): Event; @@ -106,6 +116,11 @@ export interface GitProvider extends Disposable { closed?: boolean, ): Repository; openRepositoryInitWatcher?(): RepositoryInitWatcher; + + allows(feature: PremiumFeatures, plan: SubscriptionPlanId, repoPath?: string): Promise; + supports(feature: Features): Promise; + visibility(repoPath: string): Promise; + getOpenScmRepositories(): Promise; getOrOpenScmRepository(repoPath: string): Promise; diff --git a/src/git/gitProviderService.ts b/src/git/gitProviderService.ts index 4b1c492..73291f7 100644 --- a/src/git/gitProviderService.ts +++ b/src/git/gitProviderService.ts @@ -20,10 +20,19 @@ import { configuration } from '../configuration'; import { ContextKeys, CoreGitConfiguration, GlyphChars, Schemes } from '../constants'; import type { Container } from '../container'; import { setContext } from '../context'; -import { ProviderNotFoundError } from '../errors'; +import { AccessDeniedError, ProviderNotFoundError } from '../errors'; import { Logger } from '../logger'; +import type { SubscriptionChangeEvent } from '../premium/subscription/subscriptionService'; import { asRepoComparisonKey, RepoComparisionKey, Repositories } from '../repositories'; import { WorkspaceStorageKeys } from '../storage'; +import { + FreeSubscriptionPlans, + getSubscriptionPlanPriority, + isPaidSubscriptionPlan, + RequiredSubscriptionPlans, + Subscription, + SubscriptionPlanId, +} from '../subscription'; import { groupByFilterMap, groupByMap } from '../system/array'; import { gate } from '../system/decorators/gate'; import { debug, log } from '../system/decorators/log'; @@ -32,13 +41,16 @@ import { dirname, getBestPath, getScheme, isAbsolute, maybeUri, normalizePath } import { cancellable, isPromise, PromiseCancelledError } from '../system/promise'; import { VisitedPathsTrie } from '../system/trie'; import { + Features, GitProvider, GitProviderDescriptor, GitProviderId, NextComparisionUrisResult, PagedResult, + PremiumFeatures, PreviousComparisionUrisResult, PreviousLineComparisionUrisResult, + RepositoryVisibility, ScmRepository, } from './gitProvider'; import { GitUri } from './gitUri'; @@ -100,12 +112,24 @@ export type RepositoriesChangeEvent = { readonly removed: readonly Repository[]; }; +export type FeatureAccess = + | { allowed: true; subscription: { current: Subscription; required?: undefined } } + | { allowed: false; subscription: { current: Subscription; required?: RequiredSubscriptionPlans } }; + export interface GitProviderResult { provider: GitProvider; path: string; } +export const enum RepositoriesVisibility { + Private = 'private', + Public = 'public', + Mixed = 'mixed', +} + export class GitProviderService implements Disposable { + static readonly previewFeatures: Map | undefined; // = new Map(); + private readonly _onDidChangeProviders = new EventEmitter(); get onDidChangeProviders(): Event { return this._onDidChangeProviders.event; @@ -123,6 +147,11 @@ export class GitProviderService implements Disposable { private fireRepositoriesChanged(added?: Repository[], removed?: Repository[]) { this._etag = Date.now(); + this._accessCache.clear(); + this._visibilityCache.delete(undefined); + if (removed?.length) { + this._visibilityCache.clear(); + } this._onDidChangeRepositories.fire({ added: added ?? [], removed: removed ?? [] }); } @@ -142,6 +171,7 @@ export class GitProviderService implements Disposable { constructor(private readonly container: Container) { this._disposable = Disposable.from( + container.subscription.onDidChange(this.onSubscriptionChanged, this), window.onDidChangeWindowState(this.onWindowStateChanged, this), workspace.onDidChangeWorkspaceFolders(this.onWorkspaceFoldersChanged, this), configuration.onDidChange(this.onConfigurationChanged, this), @@ -196,6 +226,12 @@ export class GitProviderService implements Disposable { } } + @debug() + onSubscriptionChanged(e: SubscriptionChangeEvent) { + this._accessCache.clear(); + this._subscription = e.current; + } + @debug({ args: { 0: e => `focused=${e.focused}` } }) private onWindowStateChanged(e: WindowState) { if (e.focused) { @@ -334,6 +370,7 @@ export class GitProviderService implements Disposable { queueMicrotask(() => this.fireRepositoriesChanged([], [e.repository])); } + this._visibilityCache.delete(e.repository.path); this._onDidChangeRepository.fire(e); }), provider.onDidCloseRepository(e => { @@ -492,6 +529,191 @@ export class GitProviderService implements Disposable { } } + private _subscription: Subscription | undefined; + private async getSubscription(): Promise { + return this._subscription ?? (this._subscription = await this.container.subscription.getSubscription()); + } + + private _accessCache = new Map>(); + async access(feature?: PremiumFeatures, repoPath?: string | Uri): Promise { + let cacheKey; + if (repoPath != null) { + const { path } = this.getProvider(repoPath); + cacheKey = path; + } + + let access = this._accessCache.get(cacheKey); + if (access == null) { + access = this.accessCore(feature, repoPath); + this._accessCache.set(cacheKey, access); + } + return access; + } + + @debug() + private async accessCore(feature?: PremiumFeatures, repoPath?: string | Uri): Promise { + const subscription = await this.getSubscription(); + if (subscription.account?.verified === false) { + return { allowed: false, subscription: { current: subscription } }; + } + + const plan = subscription.plan.effective.id; + if (isPaidSubscriptionPlan(plan) || GitProviderService.previewFeatures?.get(feature)) { + return { allowed: true, subscription: { current: subscription } }; + } + + function getRepoAccess( + this: GitProviderService, + repoPath: string | Uri, + plan: FreeSubscriptionPlans, + ): Promise { + const { path: cacheKey } = this.getProvider(repoPath); + + let access = this._accessCache.get(cacheKey); + if (access == null) { + access = this.visibility(repoPath).then(visibility => { + if (visibility === RepositoryVisibility.Public) { + switch (plan) { + case SubscriptionPlanId.Free: + return { + allowed: false, + subscription: { current: subscription, required: SubscriptionPlanId.FreePlus }, + }; + case SubscriptionPlanId.FreePlus: + return { allowed: true, subscription: { current: subscription } }; + } + } + + return { + allowed: false, + subscription: { current: subscription, required: SubscriptionPlanId.Pro }, + }; + }); + + this._accessCache.set(cacheKey, access); + } + + return access; + } + + if (repoPath == null) { + const repositories = this.openRepositories; + if (repositories.length === 0) { + return { allowed: false, subscription: { current: subscription } }; + } + + if (repositories.length === 1) { + return getRepoAccess.call(this, repositories[0].path, plan); + } + + let allowed = true; + let requiredPlan: RequiredSubscriptionPlans | undefined; + let requiredPriority = -1; + + const results = await Promise.allSettled(repositories.map(r => getRepoAccess.call(this, r.path, plan))); + for (const result of results) { + if (result.status !== 'fulfilled') continue; + + if (result.value.allowed) continue; + + allowed = false; + const priority = getSubscriptionPlanPriority(result.value.subscription.required); + if (requiredPriority < priority) { + requiredPriority = priority; + requiredPlan = result.value.subscription.required; + } + } + + return allowed + ? { allowed: true, subscription: { current: subscription } } + : { allowed: false, subscription: { current: subscription, required: requiredPlan } }; + } + + return getRepoAccess.call(this, repoPath, plan); + } + + async ensureAccess(feature: PremiumFeatures, repoPath?: string): Promise { + const { allowed, subscription } = await this.access(feature, repoPath); + if (!allowed) throw new AccessDeniedError(subscription.current, subscription.required); + } + + supports(repoPath: string | Uri, feature: Features): Promise { + const { provider } = this.getProvider(repoPath); + return provider.supports(feature); + } + + private _visibilityCache: Map> & + Map> = new Map(); + visibility(): Promise; + visibility(repoPath: string | Uri): Promise; + async visibility(repoPath?: string | Uri): Promise { + if (repoPath == null) { + let visibility = this._visibilityCache.get(undefined); + if (visibility == null) { + visibility = this.visibilityCore(); + this._visibilityCache.set(undefined, visibility); + } + return visibility; + } + + const { path: cacheKey } = this.getProvider(repoPath); + + let visibility = this._visibilityCache.get(cacheKey); + if (visibility == null) { + visibility = this.visibilityCore(repoPath); + this._visibilityCache.set(cacheKey, visibility); + } + return visibility; + } + + private visibilityCore(): Promise; + private visibilityCore(repoPath: string | Uri): Promise; + @debug() + private async visibilityCore(repoPath?: string | Uri): Promise { + function getRepoVisibility(this: GitProviderService, repoPath: string | Uri): Promise { + const { provider, path } = this.getProvider(repoPath); + + let visibility = this._visibilityCache.get(path); + if (visibility == null) { + visibility = provider.visibility(path); + this._visibilityCache.set(path, visibility); + } + + return visibility; + } + + if (repoPath == null) { + const repositories = this.openRepositories; + if (repositories.length === 0) return RepositoriesVisibility.Private; + + if (repositories.length === 1) { + return getRepoVisibility.call(this, repositories[0].path); + } + + const results = await Promise.allSettled(repositories.map(r => getRepoVisibility.call(this, r.path))); + + let isPublic: boolean | undefined = undefined; + let isPrivate: boolean | undefined = undefined; + + for (const result of results) { + if (result.status !== 'fulfilled') continue; + + if (result.value === RepositoryVisibility.Public) { + isPublic = true; + } else if (result.value === RepositoryVisibility.Private) { + isPrivate = true; + } + + if (isPublic && isPrivate) break; + } + + if (isPublic) return isPrivate ? RepositoriesVisibility.Mixed : RepositoriesVisibility.Public; + return RepositoriesVisibility.Private; + } + + return getRepoVisibility.call(this, repoPath); + } + private _context: { enabled: boolean; disabled: boolean } = { enabled: false, disabled: false }; async setEnabledContext(enabled: boolean): Promise { diff --git a/src/premium/github/github.ts b/src/premium/github/github.ts index b9512a4..2f66270 100644 --- a/src/premium/github/github.ts +++ b/src/premium/github/github.ts @@ -11,7 +11,7 @@ import { ProviderRequestClientError, ProviderRequestNotFoundError, } from '../../errors'; -import { PagedResult } from '../../git/gitProvider'; +import { PagedResult, RepositoryVisibility } from '../../git/gitProvider'; import { type DefaultBranch, GitFileIndexStatus, @@ -1363,6 +1363,46 @@ export class GitHubApi { } } + @debug({ args: { 0: '' } }) + async getRepositoryVisibility( + token: string, + owner: string, + repo: string, + ): Promise { + const cc = Logger.getCorrelationContext(); + + interface QueryResult { + repository: + | { + visibility: 'PUBLIC' | 'PRIVATE' | 'INTERNAL'; + } + | null + | undefined; + } + + try { + const query = `query getRepositoryVisibility( + $owner: String! + $repo: String! +) { + repository(owner: $owner, name: $repo) { + visibility + } +}`; + + const rsp = await this.graphql(token, query, { + owner: owner, + repo: repo, + }); + if (rsp?.repository?.visibility == null) return undefined; + + return rsp.repository.visibility === 'PUBLIC' ? RepositoryVisibility.Public : RepositoryVisibility.Private; + } catch (ex) { + debugger; + return this.handleRequestError(ex, cc, undefined); + } + } + @debug({ args: { 0: '' } }) async getTags( token: string, diff --git a/src/premium/github/githubGitProvider.ts b/src/premium/github/githubGitProvider.ts index 524a4ed..71140e2 100644 --- a/src/premium/github/githubGitProvider.ts +++ b/src/premium/github/githubGitProvider.ts @@ -26,16 +26,20 @@ import { OpenVirtualRepositoryErrorReason, } from '../../errors'; import { + Features, GitProvider, GitProviderId, NextComparisionUrisResult, PagedResult, + PremiumFeatures, PreviousComparisionUrisResult, PreviousLineComparisionUrisResult, RepositoryCloseEvent, RepositoryOpenEvent, + RepositoryVisibility, ScmRepository, } from '../../git/gitProvider'; +import { GitProviderService } from '../../git/gitProviderService'; import { GitUri } from '../../git/gitUri'; import { BranchSortOptions, @@ -79,6 +83,7 @@ import { RemoteProviderFactory, RemoteProviders } from '../../git/remotes/factor import { RemoteProvider, RichRemoteProvider } from '../../git/remotes/provider'; import { SearchPattern } from '../../git/search'; import { LogCorrelationContext, Logger } from '../../logger'; +import { SubscriptionPlanId } from '../../subscription'; import { gate } from '../../system/decorators/gate'; import { debug, log } from '../../system/decorators/log'; import { filterMap, some } from '../../system/iterable'; @@ -180,6 +185,84 @@ export class GitHubGitProvider implements GitProvider, Disposable { ); } + private _allowedFeatures = new Map>(); + async allows(feature: PremiumFeatures, plan: SubscriptionPlanId, repoPath?: string): Promise { + if (plan === SubscriptionPlanId.Free) return false; + if (plan === SubscriptionPlanId.Pro) return true; + + if (repoPath == null) { + const repositories = [...this.container.git.getOpenRepositories(this.descriptor.id)]; + const results = await Promise.allSettled(repositories.map(r => this.allows(feature, plan, r.path))); + return results.every(r => r.status === 'fulfilled' && r.value); + } + + let allowedByRepo = this._allowedFeatures.get(repoPath); + let allowed = allowedByRepo?.get(feature); + if (allowed != null) return allowed; + + allowed = GitProviderService.previewFeatures?.get(feature) + ? true + : (await this.visibility(repoPath)) === RepositoryVisibility.Public; + if (allowedByRepo == null) { + allowedByRepo = new Map(); + this._allowedFeatures.set(repoPath, allowedByRepo); + } + + allowedByRepo.set(feature, allowed); + return allowed; + } + + private _supportedFeatures = new Map(); + async supports(feature: Features): Promise { + const supported = this._supportedFeatures.get(feature); + if (supported != null) return supported; + + return false; + } + + async visibility(repoPath: string): Promise { + const remotes = await this.getRemotes(repoPath); + if (remotes.length === 0) return RepositoryVisibility.Private; + + const origin = remotes.find(r => r.name === 'origin'); + if (origin != null) { + return this.getRemoteVisibility(origin); + } + + const upstream = remotes.find(r => r.name === 'upstream'); + if (upstream != null) { + return this.getRemoteVisibility(upstream); + } + + const results = await Promise.allSettled(remotes.map(r => this.getRemoteVisibility(r))); + for (const result of results) { + if (result.status !== 'fulfilled') continue; + + if (result.value === RepositoryVisibility.Public) return RepositoryVisibility.Public; + } + + return RepositoryVisibility.Private; + } + + private async getRemoteVisibility( + remote: GitRemote, + ): Promise { + switch (remote.provider?.id) { + case 'github': { + const { github, metadata, session } = await this.ensureRepositoryContext(remote.repoPath); + const visibility = await github.getRepositoryVisibility( + session.accessToken, + metadata.repo.owner, + metadata.repo.name, + ); + + return visibility ?? RepositoryVisibility.Private; + } + default: + return RepositoryVisibility.Private; + } + } + async getOpenScmRepositories(): Promise { return []; } diff --git a/src/premium/subscription/subscriptionService.ts b/src/premium/subscription/subscriptionService.ts new file mode 100644 index 0000000..8018cc0 --- /dev/null +++ b/src/premium/subscription/subscriptionService.ts @@ -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(); + get onDidChange(): Event { + 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 { + void (await this.ensureSession(false)); + return this._subscription; + } + + async loginOrSignUp(): Promise { + const session = await this.ensureSession(true); + return Boolean(session); + } + + logout(): void { + this._sessionPromise = undefined; + this.reset(false); + } + + async purchase(): Promise { + void this.showPlans(); + await this.showHomeView(); + } + + async resendVerification(): Promise { + 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 { + await executeCommand(Commands.ShowHomeView); + } + + private showPlans(): void { + void env.openExternal(Uri.joinPath(this.baseSiteUri, 'gitlens/pricing')); + } + + async startPreview(): Promise { + 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 { + const session = await this.ensureSession(false); + if (session == null) return; + + await this.checkInAndValidate(session); + } + + private async checkInAndValidate(session: AuthenticationSession): Promise { + 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 | undefined; + private _session: AuthenticationSession | null | undefined; + private async ensureSession(createIfNeeded: boolean): Promise { + 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 { + 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 | 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).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).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>(StorageKeys.PremiumSubscription); + return storedSubscription?.data; + } + + private async storeSubscription(subscription: Subscription): Promise { + return this.container.storage.store>(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): asserts subscription is Subscription {} + +interface GKLicenseInfo { + user: GKUser; + licenses: { + paidLicenses: Record; + effectiveLicenses: Record; + }; +} + +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 { + v: SchemaVersion; + data: T; +} diff --git a/src/quickpicks/items/directive.ts b/src/quickpicks/items/directive.ts index 149c53e..044d7fa 100644 --- a/src/quickpicks/items/directive.ts +++ b/src/quickpicks/items/directive.ts @@ -1,12 +1,16 @@ +import { QuickPickItem } from 'vscode'; +import type { Subscription } from '../../subscription'; + export enum Directive { Back, Cancel, LoadMore, Noop, + RequiresVerification, + RequiresFreeSubscription, + RequiresPaidSubscription, } -import { QuickPickItem } from 'vscode'; - export namespace Directive { export function is(value: Directive | T): value is Directive { return typeof value === 'number' && Directive[value] != null; @@ -21,9 +25,10 @@ export namespace DirectiveQuickPickItem { export function create( directive: Directive, picked?: boolean, - options: { label?: string; description?: string; detail?: string } = {}, + options?: { label?: string; description?: string; detail?: string; subscription?: Subscription }, ) { - let label = options.label; + let label = options?.label; + let detail = options?.detail; if (label == null) { switch (directive) { case Directive.Back: @@ -38,13 +43,25 @@ export namespace DirectiveQuickPickItem { case Directive.Noop: label = 'Try again'; break; + case Directive.RequiresVerification: + label = 'Resend Verification Email'; + detail = 'You must verify your account email address before you can continue'; + break; + case Directive.RequiresFreeSubscription: + label = 'Create a Free+ Account'; + detail = 'To unlock all premium features for public code'; + break; + case Directive.RequiresPaidSubscription: + label = 'Upgrade to a paid subscription'; + detail = 'To unlock all premium features for private code'; + break; } } const item: DirectiveQuickPickItem = { label: label, - description: options.description, - detail: options.detail, + description: options?.description, + detail: detail, alwaysShow: true, picked: picked, directive: directive, diff --git a/src/storage.ts b/src/storage.ts index 40dd38d..52375d3 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -62,6 +62,9 @@ export const enum StorageKeys { PendingWhatsNewOnFocus = 'gitlens:pendingWhatsNewOnFocus', Version = 'gitlens:version', + PremiumSubscription = 'gitlens:premium:subscription', + PremiumPreview = 'gitlens:premium:preview', + Deprecated_Version = 'gitlensVersion', } diff --git a/src/subscription.ts b/src/subscription.ts new file mode 100644 index 0000000..78fb058 --- /dev/null +++ b/src/subscription.ts @@ -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; +export type PaidSubscriptionPlans = Exclude; +export type RequiredSubscriptionPlans = Exclude; + +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): 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([ + [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, + 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): boolean { + const remaining = getSubscriptionTimeRemaining(subscription); + return remaining != null && remaining <= 0; +} + +export function isSubscriptionTrial(subscription: Optional): boolean { + return subscription.plan.actual.id !== subscription.plan.effective.id; +} diff --git a/src/system/function.ts b/src/system/function.ts index c372501..1d5d5ec 100644 --- a/src/system/function.ts +++ b/src/system/function.ts @@ -152,3 +152,15 @@ export function disposableInterval(fn: (...args: any[]) => void, ms: number): Di return disposable; } + +/** + * Szudzik elegant pairing function + * http://szudzik.com/ElegantPairing.pdf + */ +export function szudzikPairing(x: number, y: number): number { + return x >= y ? x * x + x + y : x + y * y; +} + +export async function wait(ms: number) { + await new Promise(resolve => setTimeout(resolve, ms)); +} diff --git a/src/views/nodes/repositoriesNode.ts b/src/views/nodes/repositoriesNode.ts index fe28c9a..31e7866 100644 --- a/src/views/nodes/repositoriesNode.ts +++ b/src/views/nodes/repositoriesNode.ts @@ -4,7 +4,7 @@ import { GitUri } from '../../git/gitUri'; import { Logger } from '../../logger'; import { gate } from '../../system/decorators/gate'; import { debug } from '../../system/decorators/log'; -import { debounce } from '../../system/function'; +import { debounce, szudzikPairing } from '../../system/function'; import { RepositoriesView } from '../repositoriesView'; import { MessageNode } from './common'; import { RepositoryNode } from './repositoryNode'; @@ -109,7 +109,7 @@ export class RepositoriesNode extends SubscribeableViewNode { } protected override etag(): number { - return this.view.container.git.etag; + return szudzikPairing(this.view.container.git.etag, this.view.container.subscription.etag); } @debug({ args: false }) diff --git a/src/views/nodes/viewNode.ts b/src/views/nodes/viewNode.ts index 0206a1b..b45a519 100644 --- a/src/views/nodes/viewNode.ts +++ b/src/views/nodes/viewNode.ts @@ -21,9 +21,10 @@ import { RepositoryChangeEvent, } from '../../git/models'; import { Logger } from '../../logger'; +import { SubscriptionChangeEvent } from '../../premium/subscription/subscriptionService'; import { gate } from '../../system/decorators/gate'; import { debug, log, logName } from '../../system/decorators/log'; -import { is as isA } from '../../system/function'; +import { is as isA, szudzikPairing } from '../../system/function'; import { pad } from '../../system/string'; import { TreeViewNodeCollapsibleStateChangeEvent, View } from '../viewBase'; @@ -541,17 +542,26 @@ export abstract class RepositoriesSubscribeableNode< } protected override etag(): number { - return this.view.container.git.etag; + return szudzikPairing(this.view.container.git.etag, this.view.container.subscription.etag); } @debug() protected subscribe(): Disposable | Promise { - return this.view.container.git.onDidChangeRepositories(this.onRepositoriesChanged, this); + return Disposable.from( + this.view.container.git.onDidChangeRepositories(this.onRepositoriesChanged, this), + this.view.container.subscription.onDidChange(this.onSubscriptionChanged, this), + ); } private onRepositoriesChanged(_e: RepositoriesChangeEvent) { void this.triggerChange(true); } + + private onSubscriptionChanged(e: SubscriptionChangeEvent) { + if (e.current.plan !== e.previous.plan) { + void this.triggerChange(true); + } + } } interface AutoRefreshableView { diff --git a/src/webviews/apps/premium/home/home.html b/src/webviews/apps/premium/home/home.html new file mode 100644 index 0000000..5a1199e --- /dev/null +++ b/src/webviews/apps/premium/home/home.html @@ -0,0 +1,180 @@ + + + + + + + + +
+
+ +
+
+ + #{endOfBody} + + + + + + + + + + + + + + + + + + diff --git a/src/webviews/apps/premium/home/home.scss b/src/webviews/apps/premium/home/home.scss new file mode 100644 index 0000000..bc29766 --- /dev/null +++ b/src/webviews/apps/premium/home/home.scss @@ -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'; diff --git a/src/webviews/apps/premium/home/home.ts b/src/webviews/apps/premium/home/home.ts new file mode 100644 index 0000000..454009f --- /dev/null +++ b/src/webviews/apps/premium/home/home.ts @@ -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 { + 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): 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(); diff --git a/src/webviews/apps/shared/appWithConfigBase.ts b/src/webviews/apps/shared/appWithConfigBase.ts index 377b9e0..fd58c24 100644 --- a/src/webviews/apps/shared/appWithConfigBase.ts +++ b/src/webviews/apps/shared/appWithConfigBase.ts @@ -98,9 +98,9 @@ export abstract class AppWithConfig extends Ap protected onInputBlurred(element: HTMLInputElement) { this.log(`${this.appName}.onInputBlurred: name=${element.name}, value=${element.value}`); - const popup = document.getElementById(`${element.name}.popup`); - if (popup != null) { - popup.classList.add('hidden'); + const $popup = document.getElementById(`${element.name}.popup`); + if ($popup != null) { + $popup.classList.add('hidden'); } let value: string | null | undefined = element.value; @@ -189,14 +189,15 @@ export abstract class AppWithConfig extends Ap protected onInputFocused(element: HTMLInputElement) { this.log(`${this.appName}.onInputFocused: name=${element.name}, value=${element.value}`); - const popup = document.getElementById(`${element.name}.popup`); - if (popup != null) { - if (popup.childElementCount === 0) { - const template = document.querySelector('#token-popup') as HTMLTemplateElement; - const instance = document.importNode(template.content, true); - popup.appendChild(instance); + const $popup = document.getElementById(`${element.name}.popup`); + if ($popup != null) { + if ($popup.childElementCount === 0) { + const $template = (document.querySelector('#token-popup') as HTMLTemplateElement)?.content.cloneNode( + true, + ); + $popup.appendChild($template); } - popup.classList.remove('hidden'); + $popup.classList.remove('hidden'); } } diff --git a/src/webviews/premium/home/homeWebviewView.ts b/src/webviews/premium/home/homeWebviewView.ts new file mode 100644 index 0000000..2bbd6b1 --- /dev/null +++ b/src/webviews/premium/home/homeWebviewView.ts @@ -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 { + 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 { + 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 }), + ); + } +} diff --git a/src/webviews/premium/home/protocol.ts b/src/webviews/premium/home/protocol.ts new file mode 100644 index 0000000..916090b --- /dev/null +++ b/src/webviews/premium/home/protocol.ts @@ -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( + 'subscription/didChange', +); diff --git a/src/webviews/webviewViewBase.ts b/src/webviews/webviewViewBase.ts new file mode 100644 index 0000000..a5fc6ee --- /dev/null +++ b/src/webviews/webviewViewBase.ts @@ -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 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; + protected includeHead?(): string | Promise; + protected includeBody?(): string | Promise; + protected includeEndOfBody?(): string | Promise; + + async resolveWebviewView( + webviewView: WebviewView, + _context: WebviewViewResolveContext, + _token: CancellationToken, + ): Promise { + 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 { + 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 + ? `${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>(type: T, params: IpcMessageParams): Thenable { + 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); + } +} diff --git a/webpack.config.js b/webpack.config.js index 17d587f..38b20cf 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -257,6 +257,7 @@ function getWebviewsConfig(mode, env) { }, }), new MiniCssExtractPlugin({ filename: '[name].css' }), + getHtmlPlugin('home', true, mode, env), getHtmlPlugin('rebase', false, mode, env), getHtmlPlugin('settings', false, mode, env), getHtmlPlugin('welcome', false, mode, env), @@ -279,6 +280,17 @@ function getWebviewsConfig(mode, env) { ), to: path.posix.join(__dirname.replace(/\\/g, '/'), 'dist', 'webviews'), }, + { + from: path.posix.join( + __dirname.replace(/\\/g, '/'), + 'node_modules', + '@vscode', + 'webview-ui-toolkit', + 'dist', + 'toolkit.min.js', + ), + to: path.posix.join(__dirname.replace(/\\/g, '/'), 'dist', 'webviews'), + }, ], }), ]; @@ -298,6 +310,7 @@ function getWebviewsConfig(mode, env) { name: 'webviews', context: basePath, entry: { + home: './premium/home/home.ts', rebase: './rebase/rebase.ts', settings: './settings/settings.ts', welcome: './welcome/welcome.ts', diff --git a/yarn.lock b/yarn.lock index 7bada03..b5b3d7c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -68,6 +68,36 @@ methods "^1.1.2" path-to-regexp "^6.1.0" +"@microsoft/fast-element@^1.6.2", "@microsoft/fast-element@^1.7.0": + version "1.7.0" + resolved "https://registry.yarnpkg.com/@microsoft/fast-element/-/fast-element-1.7.0.tgz#7e4b164c77c07d93b28f057231b37e7e2e8fed04" + integrity sha512-2qpAWuiOSdQfH/XdO8ZtVhlvQVCjHlojWUPoGbvHJDizBccZib+4uGReG87RIBp2Fi0s7ngYPRUioS1Lr+Xe0A== + +"@microsoft/fast-foundation@^2.33.1", "@microsoft/fast-foundation@^2.33.2": + version "2.33.2" + resolved "https://registry.yarnpkg.com/@microsoft/fast-foundation/-/fast-foundation-2.33.2.tgz#884355a6246dfd4b835e5ffb26635e0d14598818" + integrity sha512-wTVo8u1eG9zUYSGDNPl4R+Yo9MTVJe7LvyI8sOqKmdggsbnYzhEa8wN0uBCnqo7W61nyhRL5FqPSh6WZGT5hUQ== + dependencies: + "@microsoft/fast-element" "^1.7.0" + "@microsoft/fast-web-utilities" "^5.1.0" + tabbable "^5.2.0" + tslib "^1.13.0" + +"@microsoft/fast-react-wrapper@^0.1.18": + version "0.1.29" + resolved "https://registry.yarnpkg.com/@microsoft/fast-react-wrapper/-/fast-react-wrapper-0.1.29.tgz#d83355b635d5db7821a6a3432f33b5f8f9f2c339" + integrity sha512-x8y/1qaKTv/UWPsLj3hyK1teSLLCSuKTctMzqBoWSoq5VfEVypm7fNGoHG4mi4AE8fIt865+R7I+QfYARYp5fQ== + dependencies: + "@microsoft/fast-element" "^1.7.0" + "@microsoft/fast-foundation" "^2.33.2" + +"@microsoft/fast-web-utilities@^5.1.0": + version "5.1.0" + resolved "https://registry.yarnpkg.com/@microsoft/fast-web-utilities/-/fast-web-utilities-5.1.0.tgz#e060fea2b47c2dcfb4a9ba90a55559f0844d1cdb" + integrity sha512-S2PCxI4XqtIxLM1N7i/NuIAgx+mJM01+mDzyB3vZlYibAkOT0bzp5YZCp+coXowokSin/nK5T2kqShMXEzI6Jg== + dependencies: + exenv-es6 "^1.0.0" + "@nodelib/fs.scandir@2.1.5": version "2.1.5" resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" @@ -429,6 +459,15 @@ playwright "^1.18.1" vscode-uri "^3.0.3" +"@vscode/webview-ui-toolkit@0.9.1": + version "0.9.1" + resolved "https://registry.yarnpkg.com/@vscode/webview-ui-toolkit/-/webview-ui-toolkit-0.9.1.tgz#295a9b08a22f15acc32e92174e49dcded441df2c" + integrity sha512-nCy9r2PpoLJW9Ie3T6V8zGUAYY1eL/K42eGVYYjwm5ogdYC3OynuiOf7tDBIgzJECRJlJvgVCPZ5S4ky1sjIGg== + dependencies: + "@microsoft/fast-element" "^1.6.2" + "@microsoft/fast-foundation" "^2.33.1" + "@microsoft/fast-react-wrapper" "^0.1.18" + "@webassemblyjs/ast@1.11.1": version "1.11.1" resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.11.1.tgz#2bfd767eae1a6996f432ff7e8d7fc75679c0b6a7" @@ -2250,6 +2289,11 @@ executable@^4.1.0: dependencies: pify "^2.2.0" +exenv-es6@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/exenv-es6/-/exenv-es6-1.0.0.tgz#bd459136369af17cf33f959b5af58803d4068c80" + integrity sha512-fcG/TX8Ruv9Ma6PBaiNsUrHRJzVzuFMP6LtPn/9iqR+nr9mcLeEOGzXQGLC5CVQSXGE98HtzW2mTZkrCA3XrDg== + expand-template@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/expand-template/-/expand-template-2.0.3.tgz#6e14b3fcee0f3a6340ecb57d2e8918692052a47c" @@ -5425,6 +5469,11 @@ supports-preserve-symlinks-flag@^1.0.0: resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== +tabbable@^5.2.0: + version "5.2.1" + resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-5.2.1.tgz#e3fda7367ddbb172dcda9f871c0fdb36d1c4cd9c" + integrity sha512-40pEZ2mhjaZzK0BnI+QGNjJO8UYx9pP5v7BGe17SORTO0OEuuaAwQTkAp8whcZvqon44wKFOikD+Al11K3JICQ== + tapable@^1.0.0: version "1.1.3" resolved "https://registry.yarnpkg.com/tapable/-/tapable-1.1.3.tgz#a1fccc06b58db61fd7a45da2da44f5f3a3e67ba2" @@ -5618,7 +5667,7 @@ tsconfig-paths@^3.12.0, tsconfig-paths@^3.9.0: minimist "^1.2.0" strip-bom "^3.0.0" -tslib@^1.8.1, tslib@^1.9.0: +tslib@^1.13.0, tslib@^1.8.1, tslib@^1.9.0: version "1.14.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==