Przeglądaj źródła

Adds GitKraken subscriptions support

For upcoming premium features
main
Eric Amodio 3 lat temu
rodzic
commit
70eee1dc9b
30 zmienionych plików z 2276 dodań i 37 usunięć
  1. +8
    -8
      .vscode/launch.json
  2. +82
    -0
      package.json
  3. +16
    -1
      src/commands/gitCommands.ts
  4. +36
    -1
      src/commands/quickCommand.steps.ts
  5. +5
    -2
      src/commands/quickCommand.ts
  6. +5
    -0
      src/commands/showView.ts
  7. +5
    -0
      src/constants.ts
  8. +35
    -0
      src/container.ts
  9. +93
    -1
      src/env/node/git/localGitProvider.ts
  10. +23
    -0
      src/errors.ts
  11. +15
    -0
      src/git/gitProvider.ts
  12. +223
    -1
      src/git/gitProviderService.ts
  13. +41
    -1
      src/premium/github/github.ts
  14. +83
    -0
      src/premium/github/githubGitProvider.ts
  15. +641
    -0
      src/premium/subscription/subscriptionService.ts
  16. +23
    -6
      src/quickpicks/items/directive.ts
  17. +3
    -0
      src/storage.ts
  18. +166
    -0
      src/subscription.ts
  19. +12
    -0
      src/system/function.ts
  20. +2
    -2
      src/views/nodes/repositoriesNode.ts
  21. +13
    -3
      src/views/nodes/viewNode.ts
  22. +180
    -0
      src/webviews/apps/premium/home/home.html
  23. +80
    -0
      src/webviews/apps/premium/home/home.scss
  24. +118
    -0
      src/webviews/apps/premium/home/home.ts
  25. +11
    -10
      src/webviews/apps/shared/appWithConfigBase.ts
  26. +41
    -0
      src/webviews/premium/home/homeWebviewView.ts
  27. +13
    -0
      src/webviews/premium/home/protocol.ts
  28. +240
    -0
      src/webviews/webviewViewBase.ts
  29. +13
    -0
      webpack.config.js
  30. +50
    -1
      yarn.lock

+ 8
- 8
.vscode/launch.json Wyświetl plik

@ -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}",

+ 82
- 0
package.json Wyświetl plik

@ -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",

+ 16
- 1
src/commands/gitCommands.ts Wyświetl plik

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

+ 36
- 1
src/commands/quickCommand.steps.ts Wyświetl plik

@ -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<void> {
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<DirectiveQuickPickItem>({
title: appendReposToTitle(context.title, state, context),
placeholder: placeholder,
items: [DirectiveQuickPickItem.create(directive, true), DirectiveQuickPickItem.create(Directive.Cancel)],
});
const selection: StepSelection<typeof step> = yield step;
return QuickCommand.canPickStepContinue(step, state, selection) ? undefined : StepResult.Break;
}

+ 5
- 2
src/commands/quickCommand.ts Wyświetl plik

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

+ 5
- 0
src/commands/showView.ts Wyświetl plik

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

+ 5
- 0
src/constants.ts Wyświetl plik

@ -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 {

+ 35
- 0
src/container.ts Wyświetl plik

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

+ 93
- 1
src/env/node/git/localGitProvider.ts Wyświetl plik

@ -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<string, Map<PremiumFeatures, boolean>>();
async allows(feature: PremiumFeatures, plan: SubscriptionPlanId, repoPath?: string): Promise<boolean> {
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<PremiumFeatures, boolean>();
this._allowedFeatures.set(repoPath, allowedByRepo);
}
allowedByRepo.set(feature, allowed);
return allowed;
}
private _supportedFeatures = new Map<Features, boolean>();
// eslint-disable-next-line @typescript-eslint/require-await
async supports(feature: Features): Promise<boolean> {
const supported = this._supportedFeatures.get(feature);
if (supported != null) return supported;
return false;
}
async visibility(repoPath: string): Promise<RepositoryVisibility> {
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<RemoteProvider | RichRemoteProvider | undefined>,
): Promise<RepositoryVisibility> {
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<LocalGitProvider['repositorySearch']>({
args: false,
singleLine: true,

+ 23
- 0
src/errors.ts Wyświetl plik

@ -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,

+ 15
- 0
src/git/gitProvider.ts Wyświetl plik

@ -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<RepositoryChangeEvent>;
get onDidCloseRepository(): Event<RepositoryCloseEvent>;
@ -106,6 +116,11 @@ export interface GitProvider extends Disposable {
closed?: boolean,
): Repository;
openRepositoryInitWatcher?(): RepositoryInitWatcher;
allows(feature: PremiumFeatures, plan: SubscriptionPlanId, repoPath?: string): Promise<boolean>;
supports(feature: Features): Promise<boolean>;
visibility(repoPath: string): Promise<RepositoryVisibility>;
getOpenScmRepositories(): Promise<ScmRepository[]>;
getOrOpenScmRepository(repoPath: string): Promise<ScmRepository | undefined>;

+ 223
- 1
src/git/gitProviderService.ts Wyświetl plik

@ -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<PremiumFeatures | undefined, boolean> | undefined; // = new Map();
private readonly _onDidChangeProviders = new EventEmitter<GitProvidersChangeEvent>();
get onDidChangeProviders(): Event<GitProvidersChangeEvent> {
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<GitProviderService['onWindowStateChanged']>({ 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<Subscription> {
return this._subscription ?? (this._subscription = await this.container.subscription.getSubscription());
}
private _accessCache = new Map<string | undefined, Promise<FeatureAccess>>();
async access(feature?: PremiumFeatures, repoPath?: string | Uri): Promise<FeatureAccess> {
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<FeatureAccess> {
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<FeatureAccess> {
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<void> {
const { allowed, subscription } = await this.access(feature, repoPath);
if (!allowed) throw new AccessDeniedError(subscription.current, subscription.required);
}
supports(repoPath: string | Uri, feature: Features): Promise<boolean> {
const { provider } = this.getProvider(repoPath);
return provider.supports(feature);
}
private _visibilityCache: Map<undefined, Promise<RepositoriesVisibility>> &
Map<string, Promise<RepositoryVisibility>> = new Map();
visibility(): Promise<RepositoriesVisibility>;
visibility(repoPath: string | Uri): Promise<RepositoryVisibility>;
async visibility(repoPath?: string | Uri): Promise<RepositoriesVisibility | RepositoryVisibility> {
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<RepositoriesVisibility>;
private visibilityCore(repoPath: string | Uri): Promise<RepositoryVisibility>;
@debug()
private async visibilityCore(repoPath?: string | Uri): Promise<RepositoriesVisibility | RepositoryVisibility> {
function getRepoVisibility(this: GitProviderService, repoPath: string | Uri): Promise<RepositoryVisibility> {
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<void> {

+ 41
- 1
src/premium/github/github.ts Wyświetl plik

@ -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<GitHubApi['getRepositoryVisibility']>({ args: { 0: '<token>' } })
async getRepositoryVisibility(
token: string,
owner: string,
repo: string,
): Promise<RepositoryVisibility | undefined> {
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<QueryResult>(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<GitHubApi['getTags']>({ args: { 0: '<token>' } })
async getTags(
token: string,

+ 83
- 0
src/premium/github/githubGitProvider.ts Wyświetl plik

@ -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<string, Map<PremiumFeatures, boolean>>();
async allows(feature: PremiumFeatures, plan: SubscriptionPlanId, repoPath?: string): Promise<boolean> {
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<PremiumFeatures, boolean>();
this._allowedFeatures.set(repoPath, allowedByRepo);
}
allowedByRepo.set(feature, allowed);
return allowed;
}
private _supportedFeatures = new Map<Features, boolean>();
async supports(feature: Features): Promise<boolean> {
const supported = this._supportedFeatures.get(feature);
if (supported != null) return supported;
return false;
}
async visibility(repoPath: string): Promise<RepositoryVisibility> {
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<RemoteProvider | RichRemoteProvider | undefined>,
): Promise<RepositoryVisibility> {
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<ScmRepository[]> {
return [];
}

+ 641
- 0
src/premium/subscription/subscriptionService.ts Wyświetl plik

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

+ 23
- 6
src/quickpicks/items/directive.ts Wyświetl plik

@ -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<T>(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,

+ 3
- 0
src/storage.ts Wyświetl plik

@ -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',
}

+ 166
- 0
src/subscription.ts Wyświetl plik

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

+ 12
- 0
src/system/function.ts Wyświetl plik

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

+ 2
- 2
src/views/nodes/repositoriesNode.ts Wyświetl plik

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

+ 13
- 3
src/views/nodes/viewNode.ts Wyświetl plik

@ -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<Disposable> {
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 {

+ 180
- 0
src/webviews/apps/premium/home/home.html Wyświetl plik

@ -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>

+ 80
- 0
src/webviews/apps/premium/home/home.scss Wyświetl plik

@ -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';

+ 118
- 0
src/webviews/apps/premium/home/home.ts Wyświetl plik

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

+ 11
- 10
src/webviews/apps/shared/appWithConfigBase.ts Wyświetl plik

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

+ 41
- 0
src/webviews/premium/home/homeWebviewView.ts Wyświetl plik

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

+ 13
- 0
src/webviews/premium/home/protocol.ts Wyświetl plik

@ -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',
);

+ 240
- 0
src/webviews/webviewViewBase.ts Wyświetl plik

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

+ 13
- 0
webpack.config.js Wyświetl plik

@ -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',

+ 50
- 1
yarn.lock Wyświetl plik

@ -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==

Ładowanie…
Anuluj
Zapisz