From 2b6fa71de3dd1884281b7ed3560a997693eeb690 Mon Sep 17 00:00:00 2001 From: Eric Amodio Date: Wed, 2 Mar 2022 21:58:13 -0500 Subject: [PATCH] Moves code into plus folders instead of premium --- CONTRIBUTING.md | 4 +- LICENSE | 4 +- LICENSE.premium | 2 +- README.md | 2 +- src/container.ts | 14 +- src/env/browser/providers.ts | 2 +- src/env/node/providers.ts | 7 +- src/git/gitProviderService.ts | 2 +- src/git/gitUri.ts | 2 +- src/git/remotes/github.ts | 2 +- src/plus/LICENSE.plus | 40 + src/plus/github/github.ts | 1959 ++++++++++++++ src/plus/github/githubGitProvider.ts | 2727 ++++++++++++++++++++ src/plus/remotehub.ts | 90 + src/plus/subscription/authenticationProvider.ts | 248 ++ src/plus/subscription/serverConnection.ts | 183 ++ src/plus/subscription/subscriptionService.ts | 863 +++++++ src/plus/subscription/utils.ts | 20 + src/plus/webviews/timeline/protocol.ts | 44 + src/plus/webviews/timeline/timelineWebview.ts | 399 +++ src/plus/webviews/timeline/timelineWebviewView.ts | 425 +++ src/premium/LICENSE.premium | 40 - src/premium/github/github.ts | 1959 -------------- src/premium/github/githubGitProvider.ts | 2727 -------------------- src/premium/remotehub.ts | 90 - src/premium/subscription/authenticationProvider.ts | 248 -- src/premium/subscription/serverConnection.ts | 183 -- src/premium/subscription/subscriptionService.ts | 863 ------- src/premium/subscription/utils.ts | 20 - src/premium/webviews/timeline/protocol.ts | 44 - src/premium/webviews/timeline/timelineWebview.ts | 399 --- .../webviews/timeline/timelineWebviewView.ts | 425 --- src/views/nodes/viewNode.ts | 2 +- src/views/worktreesView.ts | 2 +- src/webviews/apps/plus/LICENSE.plus | 40 + src/webviews/apps/plus/timeline/chart.scss | 466 ++++ src/webviews/apps/plus/timeline/chart.ts | 426 +++ .../partials/state.free-preview-expired.html | 20 + .../apps/plus/timeline/partials/state.free.html | 23 + .../partials/state.plus-trial-expired.html | 19 + .../plus/timeline/partials/state.verify-email.html | 8 + src/webviews/apps/plus/timeline/plugins.d.ts | 5 + src/webviews/apps/plus/timeline/timeline.html | 64 + src/webviews/apps/plus/timeline/timeline.scss | 223 ++ src/webviews/apps/plus/timeline/timeline.ts | 177 ++ src/webviews/apps/premium/LICENSE.premium | 40 - src/webviews/apps/premium/timeline/chart.scss | 466 ---- src/webviews/apps/premium/timeline/chart.ts | 426 --- .../partials/state.free-preview-expired.html | 20 - .../apps/premium/timeline/partials/state.free.html | 23 - .../partials/state.plus-trial-expired.html | 19 - .../timeline/partials/state.verify-email.html | 8 - src/webviews/apps/premium/timeline/plugins.d.ts | 5 - src/webviews/apps/premium/timeline/timeline.html | 64 - src/webviews/apps/premium/timeline/timeline.scss | 223 -- src/webviews/apps/premium/timeline/timeline.ts | 177 -- src/webviews/home/homeWebviewView.ts | 4 +- webpack.config.js | 8 +- 58 files changed, 8497 insertions(+), 8498 deletions(-) create mode 100644 src/plus/LICENSE.plus create mode 100644 src/plus/github/github.ts create mode 100644 src/plus/github/githubGitProvider.ts create mode 100644 src/plus/remotehub.ts create mode 100644 src/plus/subscription/authenticationProvider.ts create mode 100644 src/plus/subscription/serverConnection.ts create mode 100644 src/plus/subscription/subscriptionService.ts create mode 100644 src/plus/subscription/utils.ts create mode 100644 src/plus/webviews/timeline/protocol.ts create mode 100644 src/plus/webviews/timeline/timelineWebview.ts create mode 100644 src/plus/webviews/timeline/timelineWebviewView.ts delete mode 100644 src/premium/LICENSE.premium delete mode 100644 src/premium/github/github.ts delete mode 100644 src/premium/github/githubGitProvider.ts delete mode 100644 src/premium/remotehub.ts delete mode 100644 src/premium/subscription/authenticationProvider.ts delete mode 100644 src/premium/subscription/serverConnection.ts delete mode 100644 src/premium/subscription/subscriptionService.ts delete mode 100644 src/premium/subscription/utils.ts delete mode 100644 src/premium/webviews/timeline/protocol.ts delete mode 100644 src/premium/webviews/timeline/timelineWebview.ts delete mode 100644 src/premium/webviews/timeline/timelineWebviewView.ts create mode 100644 src/webviews/apps/plus/LICENSE.plus create mode 100644 src/webviews/apps/plus/timeline/chart.scss create mode 100644 src/webviews/apps/plus/timeline/chart.ts create mode 100644 src/webviews/apps/plus/timeline/partials/state.free-preview-expired.html create mode 100644 src/webviews/apps/plus/timeline/partials/state.free.html create mode 100644 src/webviews/apps/plus/timeline/partials/state.plus-trial-expired.html create mode 100644 src/webviews/apps/plus/timeline/partials/state.verify-email.html create mode 100644 src/webviews/apps/plus/timeline/plugins.d.ts create mode 100644 src/webviews/apps/plus/timeline/timeline.html create mode 100644 src/webviews/apps/plus/timeline/timeline.scss create mode 100644 src/webviews/apps/plus/timeline/timeline.ts delete mode 100644 src/webviews/apps/premium/LICENSE.premium delete mode 100644 src/webviews/apps/premium/timeline/chart.scss delete mode 100644 src/webviews/apps/premium/timeline/chart.ts delete mode 100644 src/webviews/apps/premium/timeline/partials/state.free-preview-expired.html delete mode 100644 src/webviews/apps/premium/timeline/partials/state.free.html delete mode 100644 src/webviews/apps/premium/timeline/partials/state.plus-trial-expired.html delete mode 100644 src/webviews/apps/premium/timeline/partials/state.verify-email.html delete mode 100644 src/webviews/apps/premium/timeline/plugins.d.ts delete mode 100644 src/webviews/apps/premium/timeline/timeline.html delete mode 100644 src/webviews/apps/premium/timeline/timeline.scss delete mode 100644 src/webviews/apps/premium/timeline/timeline.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cfcdd50..786a5d3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -112,6 +112,6 @@ Please follow all the instructions in the [PR template](.github/PULL_REQUEST_TEM ### Contributions to GitLens+ Licensed Files -This repository contains both OSS-licensed and non-OSS-licensed files. All files in or under any directory named "premium" fall under LICENSE.premium. The remaining files fall under LICENSE, the MIT license. +This repository contains both OSS-licensed and non-OSS-licensed files. All files in or under any directory named "plus" fall under LICENSE.plus. The remaining files fall under LICENSE, the MIT license. -If a pull request is submitted which contains changes to files in or under any directory named "premium", then you agree that GitKraken and/or its licensors (as applicable) retain all right, title and interest in and to all such modifications and/or patches. +If a pull request is submitted which contains changes to files in or under any directory named "plus", then you agree that GitKraken and/or its licensors (as applicable) retain all right, title and interest in and to all such modifications and/or patches. diff --git a/LICENSE b/LICENSE index b75a3a1..774edb7 100644 --- a/LICENSE +++ b/LICENSE @@ -1,8 +1,8 @@ =============================================================================== The following license applies to all files in this repository, -except for those in or under any directory named "premium", -which are covered by LICENSE.premium. +except for those in or under any directory named "plus", +which are covered by LICENSE.plus. =============================================================================== diff --git a/LICENSE.premium b/LICENSE.premium index b6045e3..2e551e8 100644 --- a/LICENSE.premium +++ b/LICENSE.premium @@ -2,7 +2,7 @@ GitLens+ License Copyright (c) 2021-2022 Axosoft, LLC dba GitKraken ("GitKraken") -With regard to the software set forth in or under any directory named "premium". +With regard to the software set forth in or under any directory named "plus". This software and associated documentation files (the "Software") may be compiled as part of the gitkraken/vscode-gitlens open source project (the diff --git a/README.md b/README.md index 01b80ae..0d46936 100644 --- a/README.md +++ b/README.md @@ -1183,6 +1183,6 @@ And of course the awesome [vscode](https://github.com/Microsoft/vscode/graphs/co This repository contains both OSS-licensed and non-OSS-licensed files. -All files in or under any directory named "premium" fall under LICENSE.premium. +All files in or under any directory named "plus" fall under LICENSE.plus. The remaining files fall under the MIT license. diff --git a/src/container.ts b/src/container.ts index 91497f1..5a459bd 100644 --- a/src/container.ts +++ b/src/container.ts @@ -29,11 +29,11 @@ import { GitProviderService } from './git/gitProviderService'; import { LineHoverController } from './hovers/lineHoverController'; import { Keyboard } from './keyboard'; import { Logger } from './logger'; -import { SubscriptionAuthenticationProvider } from './premium/subscription/authenticationProvider'; -import { ServerConnection } from './premium/subscription/serverConnection'; -import { SubscriptionService } from './premium/subscription/subscriptionService'; -import { TimelineWebview } from './premium/webviews/timeline/timelineWebview'; -import { TimelineWebviewView } from './premium/webviews/timeline/timelineWebviewView'; +import { SubscriptionAuthenticationProvider } from './plus/subscription/authenticationProvider'; +import { ServerConnection } from './plus/subscription/serverConnection'; +import { SubscriptionService } from './plus/subscription/subscriptionService'; +import { TimelineWebview } from './plus/webviews/timeline/timelineWebview'; +import { TimelineWebviewView } from './plus/webviews/timeline/timelineWebviewView'; import { StatusBarController } from './statusbar/statusBarController'; import { Storage } from './storage'; import { executeCommand } from './system/command'; @@ -353,7 +353,7 @@ export class Container { return this._git; } - private _github: Promise | undefined; + private _github: Promise | undefined; get github() { if (this._github == null) { this._github = this._loadGitHubApi(); @@ -364,7 +364,7 @@ export class Container { private async _loadGitHubApi() { try { - return new (await import(/* webpackChunkName: "github" */ './premium/github/github')).GitHubApi(); + return new (await import(/* webpackChunkName: "github" */ './plus/github/github')).GitHubApi(); } catch (ex) { Logger.error(ex); return undefined; diff --git a/src/env/browser/providers.ts b/src/env/browser/providers.ts index 8e0ae36..e85bc0a 100644 --- a/src/env/browser/providers.ts +++ b/src/env/browser/providers.ts @@ -1,7 +1,7 @@ import { Container } from '../../container'; import { GitCommandOptions } from '../../git/commandOptions'; // Force import of GitHub since dynamic imports are not supported in the WebWorker ExtensionHost -import { GitHubGitProvider } from '../../premium/github/githubGitProvider'; +import { GitHubGitProvider } from '../../plus/github/githubGitProvider'; import { GitProvider } from '../../git/gitProvider'; export function git(_options: GitCommandOptions, ..._args: any[]): Promise { diff --git a/src/env/node/providers.ts b/src/env/node/providers.ts index 6bf3ad3..94c0916 100644 --- a/src/env/node/providers.ts +++ b/src/env/node/providers.ts @@ -1,7 +1,7 @@ import { Container } from '../../container'; import { GitCommandOptions } from '../../git/commandOptions'; import { GitProvider } from '../../git/gitProvider'; -// import { GitHubGitProvider } from '../../premium/github/githubGitProvider'; +// import { GitHubGitProvider } from '../../plus/github/githubGitProvider'; import { Git } from './git/git'; import { LocalGitProvider } from './git/localGitProvider'; import { VslsGit, VslsGitProvider } from './git/vslsGitProvider'; @@ -27,9 +27,8 @@ export async function getSupportedGitProviders(container: Container): Promise = Object.freeze({ values: [] }); +const emptyBlameResult: GitHubBlame = Object.freeze({ ranges: [] }); + +export class GitHubApi { + private readonly _onDidReauthenticate = new EventEmitter(); + get onDidReauthenticate(): Event { + return this._onDidReauthenticate.event; + } + + @debug({ args: { 0: p => p.name, 1: '' } }) + async getAccountForCommit( + provider: RichRemoteProvider, + token: string, + owner: string, + repo: string, + ref: string, + options?: { + baseUrl?: string; + avatarSize?: number; + }, + ): Promise { + const cc = Logger.getCorrelationContext(); + + interface QueryResult { + repository: + | { + object: + | { + author?: { + name: string | null; + email: string | null; + avatarUrl: string; + }; + } + | null + | undefined; + } + | null + | undefined; + } + + try { + const query = `query getAccountForCommit( + $owner: String! + $repo: String! + $ref: GitObjectID! + $avatarSize: Int +) { + repository(name: $repo, owner: $owner) { + object(oid: $ref) { + ... on Commit { + author { + name + email + avatarUrl(size: $avatarSize) + } + } + } + } +}`; + + const rsp = await this.graphql(token, query, { + ...options, + owner: owner, + repo: repo, + ref: ref, + }); + + const author = rsp?.repository?.object?.author; + if (author == null) return undefined; + + return { + provider: provider, + name: author.name ?? undefined, + email: author.email ?? undefined, + avatarUrl: author.avatarUrl, + }; + } catch (ex) { + debugger; + return this.handleException(ex, cc, undefined); + } + } + + @debug({ args: { 0: p => p.name, 1: '' } }) + async getAccountForEmail( + provider: RichRemoteProvider, + token: string, + owner: string, + repo: string, + email: string, + options?: { + baseUrl?: string; + avatarSize?: number; + }, + ): Promise { + const cc = Logger.getCorrelationContext(); + + interface QueryResult { + search: + | { + nodes: + | { + name: string | null; + email: string | null; + avatarUrl: string; + }[] + | null + | undefined; + } + | null + | undefined; + } + + try { + const query = `query getAccountForEmail( + $emailQuery: String! + $avatarSize: Int +) { + search(type: USER, query: $emailQuery, first: 1) { + nodes { + ... on User { + name + email + avatarUrl(size: $avatarSize) + } + } + } +}`; + + const rsp = await this.graphql(token, query, { + ...options, + owner: owner, + repo: repo, + emailQuery: `in:email ${email}`, + }); + + const author = rsp?.search?.nodes?.[0]; + if (author == null) return undefined; + + return { + provider: provider, + name: author.name ?? undefined, + email: author.email ?? undefined, + avatarUrl: author.avatarUrl, + }; + } catch (ex) { + debugger; + return this.handleException(ex, cc, undefined); + } + } + + @debug({ args: { 0: p => p.name, 1: '' } }) + async getDefaultBranch( + provider: RichRemoteProvider, + token: string, + owner: string, + repo: string, + options?: { + baseUrl?: string; + }, + ): Promise { + const cc = Logger.getCorrelationContext(); + + interface QueryResult { + repository: + | { + defaultBranchRef: { name: string } | null | undefined; + } + | null + | undefined; + } + + try { + const query = `query getDefaultBranch( + $owner: String! + $repo: String! +) { + repository(name: $repo, owner: $owner) { + defaultBranchRef { + name + } + } +}`; + + const rsp = await this.graphql(token, query, { + ...options, + owner: owner, + repo: repo, + }); + + const defaultBranch = rsp?.repository?.defaultBranchRef?.name ?? undefined; + if (defaultBranch == null) return undefined; + + return { + provider: provider, + name: defaultBranch, + }; + } catch (ex) { + debugger; + return this.handleException(ex, cc, undefined); + } + } + + @debug({ args: { 0: p => p.name, 1: '' } }) + async getIssueOrPullRequest( + provider: RichRemoteProvider, + token: string, + owner: string, + repo: string, + number: number, + options?: { + baseUrl?: string; + }, + ): Promise { + const cc = Logger.getCorrelationContext(); + + interface QueryResult { + repository?: { issueOrPullRequest?: GitHubIssueOrPullRequest }; + } + + try { + const query = `query getIssueOrPullRequest( + $owner: String! + $repo: String! + $number: Int! + ) { + repository(name: $repo, owner: $owner) { + issueOrPullRequest(number: $number) { + __typename + ... on Issue { + createdAt + closed + closedAt + title + url + } + ... on PullRequest { + createdAt + closed + closedAt + title + url + } + } + } + }`; + + const rsp = await this.graphql(token, query, { + ...options, + owner: owner, + repo: repo, + number: number, + }); + + const issue = rsp?.repository?.issueOrPullRequest; + if (issue == null) return undefined; + + return { + provider: provider, + type: issue.type, + id: String(number), + date: new Date(issue.createdAt), + title: issue.title, + closed: issue.closed, + closedDate: issue.closedAt == null ? undefined : new Date(issue.closedAt), + url: issue.url, + }; + } catch (ex) { + debugger; + return this.handleException(ex, cc, undefined); + } + } + + @debug({ args: { 0: p => p.name, 1: '' } }) + async getPullRequestForBranch( + provider: RichRemoteProvider, + token: string, + owner: string, + repo: string, + branch: string, + options?: { + baseUrl?: string; + avatarSize?: number; + include?: GitHubPullRequestState[]; + }, + ): Promise { + const cc = Logger.getCorrelationContext(); + + interface QueryResult { + repository: + | { + refs: { + nodes: { + associatedPullRequests?: { + nodes?: GitHubPullRequest[]; + }; + }[]; + }; + } + | null + | undefined; + } + + try { + const query = `query getPullRequestForBranch( + $owner: String! + $repo: String! + $branch: String! + $limit: Int! + $include: [PullRequestState!] + $avatarSize: Int +) { + repository(name: $repo, owner: $owner) { + refs(query: $branch, refPrefix: "refs/heads/", first: 1) { + nodes { + associatedPullRequests(first: $limit, orderBy: {field: UPDATED_AT, direction: DESC}, states: $include) { + nodes { + author { + login + avatarUrl(size: $avatarSize) + url + } + permalink + number + title + state + updatedAt + closedAt + mergedAt + repository { + isFork + owner { + login + } + } + } + } + } + } + } +}`; + + const rsp = await this.graphql(token, query, { + ...options, + owner: owner, + repo: repo, + branch: branch, + // Since GitHub sort doesn't seem to really work, look for a max of 10 PRs and then sort them ourselves + limit: 10, + }); + + // If the pr is not from a fork, keep it e.g. show root pr's on forks, otherwise, ensure the repo owners match + const prs = rsp?.repository?.refs.nodes[0]?.associatedPullRequests?.nodes?.filter( + pr => !pr.repository.isFork || pr.repository.owner.login === owner, + ); + if (prs == null || prs.length === 0) return undefined; + + if (prs.length > 1) { + prs.sort( + (a, b) => + (a.repository.owner.login === owner ? -1 : 1) - (b.repository.owner.login === owner ? -1 : 1) || + (a.state === 'OPEN' ? -1 : 1) - (b.state === 'OPEN' ? -1 : 1) || + new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(), + ); + } + + return GitHubPullRequest.from(prs[0], provider); + } catch (ex) { + debugger; + return this.handleException(ex, cc, undefined); + } + } + + @debug({ args: { 0: p => p.name, 1: '' } }) + async getPullRequestForCommit( + provider: RichRemoteProvider, + token: string, + owner: string, + repo: string, + ref: string, + options?: { + baseUrl?: string; + avatarSize?: number; + }, + ): Promise { + const cc = Logger.getCorrelationContext(); + + interface QueryResult { + repository: + | { + object?: { + associatedPullRequests?: { + nodes?: GitHubPullRequest[]; + }; + }; + } + | null + | undefined; + } + + try { + const query = `query getPullRequestForCommit( + $owner: String! + $repo: String! + $ref: GitObjectID! + $avatarSize: Int +) { + repository(name: $repo, owner: $owner) { + object(oid: $ref) { + ... on Commit { + associatedPullRequests(first: 2, orderBy: {field: UPDATED_AT, direction: DESC}) { + nodes { + author { + login + avatarUrl(size: $avatarSize) + url + } + permalink + number + title + state + updatedAt + closedAt + mergedAt + repository { + isFork + owner { + login + } + } + } + } + } + } + } +}`; + + const rsp = await this.graphql(token, query, { + ...options, + owner: owner, + repo: repo, + ref: ref, + }); + + // If the pr is not from a fork, keep it e.g. show root pr's on forks, otherwise, ensure the repo owners match + const prs = rsp?.repository?.object?.associatedPullRequests?.nodes?.filter( + pr => !pr.repository.isFork || pr.repository.owner.login === owner, + ); + if (prs == null || prs.length === 0) return undefined; + + if (prs.length > 1) { + prs.sort( + (a, b) => + (a.repository.owner.login === owner ? -1 : 1) - (b.repository.owner.login === owner ? -1 : 1) || + (a.state === 'OPEN' ? -1 : 1) - (b.state === 'OPEN' ? -1 : 1) || + new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(), + ); + } + + return GitHubPullRequest.from(prs[0], provider); + } catch (ex) { + debugger; + return this.handleException(ex, cc, undefined); + } + } + + @debug({ args: { 0: '' } }) + async getBlame(token: string, owner: string, repo: string, ref: string, path: string): Promise { + const cc = Logger.getCorrelationContext(); + + interface QueryResult { + viewer: { name: string }; + repository: + | { + object: { + blame: { + ranges: GitHubBlameRange[]; + }; + }; + } + | null + | undefined; + } + + try { + const query = `query getBlameRanges( + $owner: String! + $repo: String! + $ref: String! + $path: String! +) { + viewer { name } + repository(owner: $owner, name: $repo) { + object(expression: $ref) { + ...on Commit { + blame(path: $path) { + ranges { + startingLine + endingLine + commit { + oid + parents(first: 3) { nodes { oid } } + message + additions + changedFiles + deletions + author { + avatarUrl + date + email + name + } + committer { + date + email + name + } + } + } + } + } + } + } +}`; + const rsp = await this.graphql(token, query, { + owner: owner, + repo: repo, + ref: ref, + path: path, + }); + if (rsp == null) return emptyBlameResult; + + const ranges = rsp.repository?.object?.blame?.ranges; + if (ranges == null || ranges.length === 0) return { ranges: [], viewer: rsp.viewer?.name }; + + return { ranges: ranges, viewer: rsp.viewer?.name }; + } catch (ex) { + debugger; + return this.handleException(ex, cc, emptyBlameResult); + } + } + + @debug({ args: { 0: '' } }) + async getBranches( + token: string, + owner: string, + repo: string, + options?: { query?: string; cursor?: string; limit?: number }, + ): Promise> { + const cc = Logger.getCorrelationContext(); + + interface QueryResult { + repository: + | { + refs: { + pageInfo: { + endCursor: string; + hasNextPage: boolean; + }; + nodes: GitHubBranch[]; + }; + } + | null + | undefined; + } + + try { + const query = `query getBranches( + $owner: String! + $repo: String! + $branchQuery: String + $cursor: String + $limit: Int = 100 +) { + repository(owner: $owner, name: $repo) { + refs(query: $branchQuery, refPrefix: "refs/heads/", first: $limit, after: $cursor, orderBy: { field: TAG_COMMIT_DATE, direction: DESC }) { + pageInfo { + endCursor + hasNextPage + } + nodes { + name + target { + oid + commitUrl + ...on Commit { + authoredDate + committedDate + } + } + } + } + } +}`; + + const rsp = await this.graphql(token, query, { + owner: owner, + repo: repo, + branchQuery: options?.query, + cursor: options?.cursor, + limit: Math.min(100, options?.limit ?? 100), + }); + if (rsp == null) return emptyPagedResult; + + const refs = rsp.repository?.refs; + if (refs == null) return emptyPagedResult; + + return { + paging: { + cursor: refs.pageInfo.endCursor, + more: refs.pageInfo.hasNextPage, + }, + values: refs.nodes, + }; + } catch (ex) { + debugger; + return this.handleException(ex, cc, emptyPagedResult); + } + } + + @debug({ args: { 0: '' } }) + async getCommit( + token: string, + owner: string, + repo: string, + ref: string, + ): Promise<(GitHubCommit & { viewer?: string }) | undefined> { + const cc = Logger.getCorrelationContext(); + + try { + const rsp = await this.request(token, 'GET /repos/{owner}/{repo}/commits/{ref}', { + owner: owner, + repo: repo, + ref: ref, + }); + + const result = rsp?.data; + if (result == null) return undefined; + + const { commit } = result; + return { + oid: result.sha, + parents: { nodes: result.parents.map(p => ({ oid: p.sha })) }, + message: commit.message, + additions: result.stats?.additions, + changedFiles: result.files?.length, + deletions: result.stats?.deletions, + author: { + avatarUrl: result.author?.avatar_url ?? undefined, + date: commit.author?.date ?? new Date().toString(), + email: commit.author?.email ?? undefined, + name: commit.author?.name ?? '', + }, + committer: { + date: commit.committer?.date ?? new Date().toString(), + email: commit.committer?.email ?? undefined, + name: commit.committer?.name ?? '', + }, + files: result.files, + }; + } catch (ex) { + debugger; + return this.handleException(ex, cc, undefined); + } + + // const results = await this.getCommits(token, owner, repo, ref, { limit: 1 }); + // if (results.values.length === 0) return undefined; + + // return { ...results.values[0], viewer: results.viewer }; + } + + @debug({ args: { 0: '' } }) + async getCommitForFile( + token: string, + owner: string, + repo: string, + ref: string, + path: string, + ): Promise<(GitHubCommit & { viewer?: string }) | undefined> { + if (GitRevision.isSha(ref)) return this.getCommit(token, owner, repo, ref); + + // TODO: optimize this -- only need to get the sha for the ref + const results = await this.getCommits(token, owner, repo, ref, { limit: 1, path: path }); + if (results.values.length === 0) return undefined; + + const commit = await this.getCommit(token, owner, repo, results.values[0].oid); + return { ...(commit ?? results.values[0]), viewer: results.viewer }; + } + + @debug({ args: { 0: '' } }) + async getCommitBranches(token: string, owner: string, repo: string, ref: string, date: Date): Promise { + const cc = Logger.getCorrelationContext(); + + interface QueryResult { + repository: { + refs: { + nodes: { + name: string; + target: { + history: { + nodes: { oid: string }[]; + }; + }; + }[]; + }; + }; + } + + try { + const query = `query getCommitBranches( + $owner: String! + $repo: String! + $since: GitTimestamp! + $until: GitTimestamp! +) { + repository(owner: $owner, name: $repo) { + refs(first: 20, refPrefix: "refs/heads/", orderBy: { field: TAG_COMMIT_DATE, direction: DESC }) { + nodes { + name + target { + ... on Commit { + history(first: 3, since: $since until: $until) { + nodes { oid } + } + } + } + } + } + } +}`; + const rsp = await this.graphql(token, query, { + owner: owner, + repo: repo, + since: date.toISOString(), + until: date.toISOString(), + }); + + const nodes = rsp?.repository?.refs?.nodes; + if (nodes == null) return []; + + const branches = []; + + for (const branch of nodes) { + for (const commit of branch.target.history.nodes) { + if (commit.oid === ref) { + branches.push(branch.name); + break; + } + } + } + + return branches; + } catch (ex) { + debugger; + return this.handleException(ex, cc, []); + } + } + + @debug({ args: { 0: '' } }) + async getCommitCount(token: string, owner: string, repo: string, ref: string): Promise { + const cc = Logger.getCorrelationContext(); + + interface QueryResult { + repository: { + ref: { + target: { + history: { totalCount: number }; + }; + }; + }; + } + + try { + const query = `query getCommitCount( + $owner: String! + $repo: String! + $ref: String! +) { + repository(owner: $owner, name: $repo) { + ref(qualifiedName: $ref) { + target { + ... on Commit { + history(first: 1) { + totalCount + } + } + } + } + } +}`; + + const rsp = await this.graphql(token, query, { + owner: owner, + repo: repo, + ref: ref, + }); + + const count = rsp?.repository?.ref?.target.history.totalCount; + return count; + } catch (ex) { + debugger; + return this.handleException(ex, cc, undefined); + } + } + + @debug({ args: { 0: '' } }) + async getCommitOnBranch( + token: string, + owner: string, + repo: string, + branch: string, + ref: string, + date: Date, + ): Promise { + const cc = Logger.getCorrelationContext(); + + interface QueryResult { + repository: { + ref: { + target: { + history: { + nodes: { oid: string }[]; + }; + }; + }; + }; + } + try { + const query = `query getCommitOnBranch( + $owner: String! + $repo: String! + $ref: String! + $since: GitTimestamp! + $until: GitTimestamp! +) { + repository(owner: $owner, name: $repo) { + ref(qualifiedName: $ref) { + target { + ... on Commit { + history(first: 3, since: $since until: $until) { + nodes { oid } + } + } + } + } + } +}`; + const rsp = await this.graphql(token, query, { + owner: owner, + repo: repo, + ref: `refs/heads/${branch}`, + since: date.toISOString(), + until: date.toISOString(), + }); + + const nodes = rsp?.repository?.ref.target.history.nodes; + if (nodes == null) return []; + + const branches = []; + + for (const commit of nodes) { + if (commit.oid === ref) { + branches.push(branch); + break; + } + } + + return branches; + } catch (ex) { + debugger; + return this.handleException(ex, cc, []); + } + } + + @debug({ args: { 0: '' } }) + async getCommits( + token: string, + owner: string, + repo: string, + ref: string, + options?: { + after?: string; + all?: boolean; + authors?: GitUser[]; + before?: string; + limit?: number; + path?: string; + since?: string | Date; + until?: string | Date; + }, + ): Promise & { viewer?: string }> { + const cc = Logger.getCorrelationContext(); + + if (options?.limit === 1 && options?.path == null) { + return this.getCommitsCoreSingle(token, owner, repo, ref); + } + + interface QueryResult { + viewer: { name: string }; + repository: + | { + object: + | { + history: { + pageInfo: GitHubPageInfo; + nodes: GitHubCommit[]; + }; + } + | null + | undefined; + } + | null + | undefined; + } + + try { + const query = `query getCommits( + $owner: String! + $repo: String! + $ref: String! + $path: String + $author: CommitAuthor + $after: String + $before: String + $limit: Int = 100 + $since: GitTimestamp + $until: GitTimestamp +) { + viewer { name } + repository(name: $repo, owner: $owner) { + object(expression: $ref) { + ... on Commit { + history(first: $limit, author: $author, path: $path, after: $after, before: $before, since: $since, until: $until) { + pageInfo { + startCursor + endCursor + hasNextPage + hasPreviousPage + } + nodes { + ... on Commit { + oid + message + parents(first: 3) { nodes { oid } } + additions + changedFiles + deletions + author { + avatarUrl + date + email + name + } + committer { + date + email + name + } + } + } + } + } + } + } +}`; + + let authors: { id?: string; emails?: string[] } | undefined; + if (options?.authors != null) { + if (options.authors.length === 1) { + const [author] = options.authors; + authors = { + id: author.id, + emails: author.email ? [author.email] : undefined, + }; + } else { + const emails = options.authors.filter(a => a.email).map(a => a.email!); + authors = emails.length ? { emails: emails } : undefined; + } + } + + const rsp = await this.graphql(token, query, { + owner: owner, + repo: repo, + ref: ref, + after: options?.after, + before: options?.before, + path: options?.path, + author: authors, + limit: Math.min(100, options?.limit ?? 100), + since: typeof options?.since === 'string' ? options?.since : options?.since?.toISOString(), + until: typeof options?.until === 'string' ? options?.until : options?.until?.toISOString(), + }); + const history = rsp?.repository?.object?.history; + if (history == null) return emptyPagedResult; + + return { + paging: + history.pageInfo.endCursor != null + ? { + cursor: history.pageInfo.endCursor ?? undefined, + more: history.pageInfo.hasNextPage, + } + : undefined, + values: history.nodes, + viewer: rsp?.viewer.name, + }; + } catch (ex) { + debugger; + return this.handleException(ex, cc, emptyPagedResult); + } + } + + private async getCommitsCoreSingle( + token: string, + owner: string, + repo: string, + ref: string, + ): Promise & { viewer?: string }> { + const cc = Logger.getCorrelationContext(); + + interface QueryResult { + viewer: { name: string }; + repository: { object: GitHubCommit } | null | undefined; + } + + try { + const query = `query getCommit( + $owner: String! + $repo: String! + $ref: String! +) { + viewer { name } + repository(name: $repo owner: $owner) { + object(expression: $ref) { + ...on Commit { + oid + parents(first: 3) { nodes { oid } } + message + additions + changedFiles + deletions + author { + avatarUrl + date + email + name + } + committer { + date + email + name + } + } + } + } +}`; + + const rsp = await this.graphql(token, query, { + owner: owner, + repo: repo, + ref: ref, + }); + if (rsp == null) return emptyPagedResult; + + const commit = rsp.repository?.object; + return commit != null ? { values: [commit], viewer: rsp.viewer.name } : emptyPagedResult; + } catch (ex) { + debugger; + return this.handleException(ex, cc, emptyPagedResult); + } + } + + @debug({ args: { 0: '' } }) + async getCommitRefs( + token: string, + owner: string, + repo: string, + ref: string, + options?: { + after?: string; + before?: string; + first?: number; + last?: number; + path?: string; + since?: string; + until?: string; + }, + ): Promise | undefined> { + const cc = Logger.getCorrelationContext(); + + interface QueryResult { + repository: + | { + object: + | { + history: { + pageInfo: GitHubPageInfo; + totalCount: number; + nodes: GitHubCommitRef[]; + }; + } + | null + | undefined; + } + | null + | undefined; + } + + try { + const query = `query getCommitRefs( + $owner: String! + $repo: String! + $ref: String! + $after: String + $before: String + $first: Int + $last: Int + $path: String + $since: GitTimestamp + $until: GitTimestamp +) { + repository(name: $repo, owner: $owner) { + object(expression: $ref) { + ... on Commit { + history(first: $first, last: $last, path: $path, since: $since, until: $until, after: $after, before: $before) { + pageInfo { startCursor, endCursor, hasNextPage, hasPreviousPage } + totalCount + nodes { oid } + } + } + } + } +}`; + + const rsp = await this.graphql(token, query, { + owner: owner, + repo: repo, + ref: ref, + path: options?.path, + first: options?.first, + last: options?.last, + after: options?.after, + before: options?.before, + since: options?.since, + until: options?.until, + }); + const history = rsp?.repository?.object?.history; + if (history == null) return undefined; + + return { + pageInfo: history.pageInfo, + totalCount: history.totalCount, + values: history.nodes, + }; + } catch (ex) { + debugger; + return this.handleException(ex, cc, undefined); + } + } + + @debug({ args: { 0: '' } }) + async getNextCommitRefs( + token: string, + owner: string, + repo: string, + ref: string, + path: string, + sha: string, + ): Promise { + // Get the commit date of the current commit + const commitDate = await this.getCommitDate(token, owner, repo, sha); + if (commitDate == null) return []; + + // Get a resultset (just need the cursor and totals), to get the page info we need to construct a cursor to page backwards + let result = await this.getCommitRefs(token, owner, repo, ref, { path: path, first: 1, since: commitDate }); + if (result == null) return []; + + // Construct a cursor to allow use to walk backwards in time (starting at the tip going back in time until the commit date) + const cursor = `${result.pageInfo.startCursor!.split(' ', 1)[0]} ${result.totalCount}`; + + let last; + [, last] = cursor.split(' ', 2); + // We can't ask for more commits than are left in the cursor (but try to get more to be safe, since the date isn't exact enough) + last = Math.min(parseInt(last, 10), 5); + + // Get the set of refs before the cursor + result = await this.getCommitRefs(token, owner, repo, ref, { path: path, last: last, before: cursor }); + if (result == null) return []; + + const nexts: string[] = []; + + for (const { oid } of result.values) { + if (oid === sha) break; + + nexts.push(oid); + } + + return nexts.reverse(); + } + + private async getCommitDate(token: string, owner: string, repo: string, sha: string): Promise { + const cc = Logger.getCorrelationContext(); + + interface QueryResult { + repository: + | { + object: { committer: { date: string } } | null | undefined; + } + | null + | undefined; + } + + try { + const query = `query getCommitDate( + $owner: String! + $repo: String! + $sha: GitObjectID! +) { + repository(name: $repo, owner: $owner) { + object(oid: $sha) { + ... on Commit { committer { date } } + } + } +}`; + + const rsp = await this.graphql(token, query, { + owner: owner, + repo: repo, + sha: sha, + }); + const date = rsp?.repository?.object?.committer.date; + return date; + } catch (ex) { + debugger; + return this.handleException(ex, cc, undefined); + } + } + + @debug({ args: { 0: '' } }) + async getContributors(token: string, owner: string, repo: string): Promise { + const cc = Logger.getCorrelationContext(); + + // TODO@eamodio implement pagination + + try { + const rsp = await this.request(token, 'GET /repos/{owner}/{repo}/contributors', { + owner: owner, + repo: repo, + per_page: 100, + }); + + const result = rsp?.data; + if (result == null) return []; + + return rsp.data; + } catch (ex) { + debugger; + return this.handleException(ex, cc, []); + } + } + + @debug({ args: { 0: '' } }) + async getDefaultBranchName(token: string, owner: string, repo: string): Promise { + const cc = Logger.getCorrelationContext(); + + interface QueryResult { + repository: + | { + defaultBranchRef: { name: string } | null | undefined; + } + | null + | undefined; + } + + try { + const query = `query getDefaultBranch( + $owner: String! + $repo: String! +) { + repository(owner: $owner, name: $repo) { + defaultBranchRef { + name + } + } +}`; + + const rsp = await this.graphql(token, query, { + owner: owner, + repo: repo, + }); + if (rsp == null) return undefined; + + return rsp.repository?.defaultBranchRef?.name ?? undefined; + } catch (ex) { + debugger; + return this.handleException(ex, cc, undefined); + } + } + + @debug({ args: { 0: '' } }) + async getCurrentUser(token: string, owner: string, repo: string): Promise { + const cc = Logger.getCorrelationContext(); + + interface QueryResult { + viewer: { + name: string; + email: string; + login: string; + id: string; + }; + repository: { viewerPermission: string } | null | undefined; + } + + try { + const query = `query getCurrentUser( + $owner: String! + $repo: String! +) { + viewer { name, email, login, id } + repository(owner: $owner, name: $repo) { viewerPermission } +}`; + + const rsp = await this.graphql(token, query, { + owner: owner, + repo: repo, + }); + if (rsp == null) return undefined; + + return { + name: rsp.viewer?.name, + email: rsp.viewer?.email, + username: rsp.viewer?.login, + id: rsp.viewer?.id, + }; + } catch (ex) { + debugger; + return this.handleException(ex, cc, undefined); + } + } + + @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.handleException(ex, cc, undefined); + } + } + + @debug({ args: { 0: '' } }) + async getTags( + token: string, + owner: string, + repo: string, + options?: { query?: string; cursor?: string; limit?: number }, + ): Promise> { + const cc = Logger.getCorrelationContext(); + + interface QueryResult { + repository: + | { + refs: { + pageInfo: { + endCursor: string; + hasNextPage: boolean; + }; + nodes: GitHubTag[]; + }; + } + | null + | undefined; + } + + try { + const query = `query getTags( + $owner: String! + $repo: String! + $tagQuery: String + $cursor: String + $limit: Int = 100 +) { + repository(owner: $owner, name: $repo) { + refs(query: $tagQuery, refPrefix: "refs/tags/", first: $limit, after: $cursor, orderBy: { field: TAG_COMMIT_DATE, direction: DESC }) { + pageInfo { + endCursor + hasNextPage + } + nodes { + name + target { + oid + commitUrl + ...on Commit { + authoredDate + committedDate + message + } + ...on Tag { + message + tagger { date } + } + } + } + } + } +}`; + + const rsp = await this.graphql(token, query, { + owner: owner, + repo: repo, + tagQuery: options?.query, + cursor: options?.cursor, + limit: Math.min(100, options?.limit ?? 100), + }); + if (rsp == null) return emptyPagedResult; + + const refs = rsp.repository?.refs; + if (refs == null) return emptyPagedResult; + + return { + paging: { + cursor: refs.pageInfo.endCursor, + more: refs.pageInfo.hasNextPage, + }, + values: refs.nodes, + }; + } catch (ex) { + debugger; + return this.handleException(ex, cc, emptyPagedResult); + } + } + + @debug({ args: { 0: '' } }) + async resolveReference( + token: string, + owner: string, + repo: string, + ref: string, + path?: string, + ): Promise { + const cc = Logger.getCorrelationContext(); + + try { + if (!path) { + interface QueryResult { + repository: { object: GitHubCommitRef } | null | undefined; + } + + const query = `query resolveReference( + $owner: String! + $repo: String! + $ref: String! +) { + repository(owner: $owner, name: $repo) { + object(expression: $ref) { + oid + } + } +}`; + + const rsp = await this.graphql(token, query, { + owner: owner, + repo: repo, + ref: ref, + }); + return rsp?.repository?.object?.oid ?? undefined; + } + + interface QueryResult { + repository: + | { + object: { + history: { + nodes: GitHubCommitRef[]; + }; + }; + } + | null + | undefined; + } + + const query = `query resolveReference( + $owner: String! + $repo: String! + $ref: String! + $path: String! +) { + repository(owner: $owner, name: $repo) { + object(expression: $ref) { + ... on Commit { + history(first: 1, path: $path) { + nodes { oid } + } + } + } + } +}`; + + const rsp = await this.graphql(token, query, { + owner: owner, + repo: repo, + ref: ref, + path: path, + }); + return rsp?.repository?.object?.history.nodes?.[0]?.oid ?? undefined; + } catch (ex) { + debugger; + return this.handleException(ex, cc, undefined); + } + } + + @debug({ args: { 0: '' } }) + async searchCommits( + token: string, + query: string, + options?: { + cursor?: string; + limit?: number; + order?: 'asc' | 'desc' | undefined; + sort?: 'author-date' | 'committer-date' | undefined; + }, + ): Promise | undefined> { + const cc = Logger.getCorrelationContext(); + + const limit = Math.min(100, options?.limit ?? 100); + + let page; + let pageSize; + let previousCount; + if (options?.cursor != null) { + [page, pageSize, previousCount] = options.cursor.split(' ', 3); + page = parseInt(page, 10); + // TODO@eamodio need to figure out how allow different page sizes if the limit changes + pageSize = parseInt(pageSize, 10); + previousCount = parseInt(previousCount, 10); + } else { + page = 1; + pageSize = limit; + previousCount = 0; + } + + try { + const rsp = await this.request(token, 'GET /search/commits', { + q: query, + sort: options?.sort, + order: options?.order, + per_page: pageSize, + page: page, + }); + + const data = rsp?.data; + if (data == null || data.items.length === 0) return undefined; + + const commits = data.items.map(result => ({ + oid: result.sha, + parents: { nodes: result.parents.map(p => ({ oid: p.sha! })) }, + message: result.commit.message, + author: { + avatarUrl: result.author?.avatar_url ?? undefined, + date: result.commit.author?.date ?? result.commit.author?.date ?? new Date().toString(), + email: result.author?.email ?? result.commit.author?.email ?? undefined, + name: result.author?.name ?? result.commit.author?.name ?? '', + }, + committer: { + date: result.commit.committer?.date ?? result.committer?.date ?? new Date().toString(), + email: result.committer?.email ?? result.commit.committer?.email ?? undefined, + name: result.committer?.name ?? result.commit.committer?.name ?? '', + }, + })); + + const count = previousCount + data.items.length; + const hasMore = data.incomplete_results || data.total_count > count; + + return { + pageInfo: { + startCursor: `${page} ${pageSize} ${previousCount}`, + endCursor: hasMore ? `${page + 1} ${pageSize} ${count}` : undefined, + hasPreviousPage: data.total_count > 0 && page > 1, + hasNextPage: hasMore, + }, + totalCount: data.total_count, + values: commits, + }; + } catch (ex) { + debugger; + return this.handleException(ex, cc, undefined); + } + } + + private _octokits = new Map(); + private octokit(token: string, options?: ConstructorParameters[0]): Octokit { + let octokit = this._octokits.get(token); + if (octokit == null) { + let defaults; + if (isWeb) { + function fetchCore(url: string, options: { headers?: Record }) { + if (options.headers != null) { + // Strip out the user-agent (since it causes warnings in a webworker) + const { 'user-agent': userAgent, ...headers } = options.headers; + if (userAgent) { + options.headers = headers; + } + } + return fetch(url, options); + } + + defaults = Octokit.defaults({ + auth: `token ${token}`, + request: { fetch: fetchCore }, + }); + } else { + defaults = Octokit.defaults({ auth: `token ${token}` }); + } + + octokit = new defaults(options); + this._octokits.set(token, octokit); + + if (Logger.logLevel === LogLevel.Debug || Logger.isDebugging) { + octokit.hook.wrap('request', async (request, options) => { + const stopwatch = new Stopwatch(`[GITHUB] ${options.method} ${options.url}`, { log: false }); + try { + return await request(options); + } finally { + let message; + try { + if (typeof options.query === 'string') { + const match = /(^[^({\n]+)/.exec(options.query); + message = ` ${match?.[1].trim() ?? options.query}`; + } + } catch {} + stopwatch.stop({ message: message }); + } + }); + } + } + + return octokit; + } + + private async graphql(token: string, query: string, variables: { [key: string]: any }): Promise { + try { + return await this.octokit(token).graphql(query, variables); + } catch (ex) { + if (ex instanceof GraphqlResponseError) { + switch (ex.errors?.[0]?.type) { + case 'NOT_FOUND': + throw new ProviderRequestNotFoundError(ex); + case 'FORBIDDEN': + throw new AuthenticationError('github', AuthenticationErrorReason.Forbidden, ex); + } + + void window.showErrorMessage(`GitHub request failed: ${ex.errors?.[0]?.message ?? ex.message}`, 'OK'); + } else if (ex instanceof RequestError) { + this.handleRequestError(ex); + } else { + void window.showErrorMessage(`GitHub request failed: ${ex.message}`, 'OK'); + } + + throw ex; + } + } + + private async request( + token: string, + route: keyof Endpoints | R, + options?: R extends keyof Endpoints ? Endpoints[R]['parameters'] & RequestParameters : RequestParameters, + ): Promise> { + try { + return (await this.octokit(token).request(route, options)) as any; + } catch (ex) { + if (ex instanceof RequestError) { + this.handleRequestError(ex); + } else { + void window.showErrorMessage(`GitHub request failed: ${ex.message}`, 'OK'); + } + + throw ex; + } + } + + private handleRequestError(ex: RequestError): void { + switch (ex.status) { + case 404: // Not found + case 410: // Gone + case 422: // Unprocessable Entity + throw new ProviderRequestNotFoundError(ex); + // case 429: //Too Many Requests + case 401: // Unauthorized + throw new AuthenticationError('github', AuthenticationErrorReason.Unauthorized, ex); + case 403: // Forbidden + throw new AuthenticationError('github', AuthenticationErrorReason.Forbidden, ex); + case 500: // Internal Server Error + if (ex.response != null) { + void window.showErrorMessage( + 'GitHub failed to respond and might be experiencing issues. Please visit the [GitHub status page](https://githubstatus.com) for more information.', + 'OK', + ); + } + break; + case 502: // Bad Gateway + // GitHub seems to return this status code for timeouts + if (ex.message.includes('timeout')) { + void window.showErrorMessage('GitHub request timed out', 'OK'); + return; + } + break; + default: + if (ex.status >= 400 && ex.status < 500) throw new ProviderRequestClientError(ex); + break; + } + + void window.showErrorMessage( + `GitHub request failed: ${(ex.response as any)?.errors?.[0]?.message ?? ex.message}`, + 'OK', + ); + } + + private handleException(ex: unknown | Error, cc: LogCorrelationContext | undefined, defaultValue: T): T { + if (ex instanceof ProviderRequestNotFoundError) return defaultValue; + + Logger.error(ex, cc); + debugger; + + if (ex instanceof AuthenticationError) { + void this.showAuthenticationErrorMessage(ex); + } + throw ex; + } + + private async showAuthenticationErrorMessage(ex: AuthenticationError) { + if (ex.reason === AuthenticationErrorReason.Unauthorized || ex.reason === AuthenticationErrorReason.Forbidden) { + const confirm = 'Reauthenticate'; + const result = await window.showErrorMessage( + `${ex.message}. Would you like to try reauthenticating${ + ex.reason === AuthenticationErrorReason.Forbidden ? ' to provide additional access' : '' + }?`, + confirm, + ); + + if (result === confirm) { + this._onDidReauthenticate.fire(); + } + } else { + void window.showErrorMessage(ex.message, 'OK'); + } + } +} + +export interface GitHubBlame { + ranges: GitHubBlameRange[]; + viewer?: string; +} + +export interface GitHubBlameRange { + startingLine: number; + endingLine: number; + commit: GitHubCommit; +} + +export interface GitHubBranch { + name: string; + target: { + oid: string; + commitUrl: string; + authoredDate: string; + committedDate: string; + }; +} + +export interface GitHubCommit { + oid: string; + parents: { nodes: { oid: string }[] }; + message: string; + additions?: number | undefined; + changedFiles?: number | undefined; + deletions?: number | undefined; + author: { avatarUrl: string | undefined; date: string; email: string | undefined; name: string }; + committer: { date: string; email: string | undefined; name: string }; + + files?: Endpoints['GET /repos/{owner}/{repo}/commits/{ref}']['response']['data']['files']; +} + +export interface GitHubCommitRef { + oid: string; +} + +export type GitHubContributor = Endpoints['GET /repos/{owner}/{repo}/contributors']['response']['data'][0]; + +interface GitHubIssueOrPullRequest { + type: IssueOrPullRequestType; + number: number; + createdAt: string; + closed: boolean; + closedAt: string | null; + title: string; + url: string; +} + +export interface GitHubPagedResult { + pageInfo: GitHubPageInfo; + totalCount: number; + values: T[]; +} + +interface GitHubPageInfo { + startCursor?: string | null; + endCursor?: string | null; + hasNextPage: boolean; + hasPreviousPage: boolean; +} + +type GitHubPullRequestState = 'OPEN' | 'CLOSED' | 'MERGED'; + +interface GitHubPullRequest { + author: { + login: string; + avatarUrl: string; + url: string; + }; + permalink: string; + number: number; + title: string; + state: GitHubPullRequestState; + updatedAt: string; + closedAt: string | null; + mergedAt: string | null; + repository: { + isFork: boolean; + owner: { + login: string; + }; + }; +} + +export namespace GitHubPullRequest { + export function from(pr: GitHubPullRequest, provider: RichRemoteProvider): PullRequest { + return new PullRequest( + provider, + { + name: pr.author.login, + avatarUrl: pr.author.avatarUrl, + url: pr.author.url, + }, + String(pr.number), + pr.title, + pr.permalink, + fromState(pr.state), + new Date(pr.updatedAt), + pr.closedAt == null ? undefined : new Date(pr.closedAt), + pr.mergedAt == null ? undefined : new Date(pr.mergedAt), + ); + } + + export function fromState(state: GitHubPullRequestState): PullRequestState { + return state === 'MERGED' + ? PullRequestState.Merged + : state === 'CLOSED' + ? PullRequestState.Closed + : PullRequestState.Open; + } + + export function toState(state: PullRequestState): GitHubPullRequestState { + return state === PullRequestState.Merged ? 'MERGED' : state === PullRequestState.Closed ? 'CLOSED' : 'OPEN'; + } +} + +export interface GitHubTag { + name: string; + target: { + oid: string; + commitUrl: string; + authoredDate: string; + committedDate: string; + message?: string | null; + tagger?: { + date: string; + } | null; + }; +} + +export function fromCommitFileStatus( + status: NonNullable[0]['status'], +): GitFileIndexStatus | undefined { + switch (status) { + case 'added': + return GitFileIndexStatus.Added; + case 'changed': + case 'modified': + return GitFileIndexStatus.Modified; + case 'removed': + return GitFileIndexStatus.Deleted; + case 'renamed': + return GitFileIndexStatus.Renamed; + case 'copied': + return GitFileIndexStatus.Copied; + } + return undefined; +} diff --git a/src/plus/github/githubGitProvider.ts b/src/plus/github/githubGitProvider.ts new file mode 100644 index 0000000..dc00175 --- /dev/null +++ b/src/plus/github/githubGitProvider.ts @@ -0,0 +1,2727 @@ +/* eslint-disable @typescript-eslint/require-await */ +import { + authentication, + AuthenticationSession, + Disposable, + Event, + EventEmitter, + FileType, + Range, + TextDocument, + Uri, + window, + workspace, + WorkspaceFolder, +} from 'vscode'; +import { encodeUtf8Hex } from '@env/hex'; +import { configuration } from '../../configuration'; +import { CharCode, ContextKeys, Schemes } from '../../constants'; +import type { Container } from '../../container'; +import { setContext } from '../../context'; +import { + AuthenticationError, + AuthenticationErrorReason, + ExtensionNotFoundError, + OpenVirtualRepositoryError, + OpenVirtualRepositoryErrorReason, +} from '../../errors'; +import { Features, PlusFeatures } from '../../features'; +import { + GitProvider, + GitProviderId, + NextComparisionUrisResult, + PagedResult, + PreviousComparisionUrisResult, + PreviousLineComparisionUrisResult, + RepositoryCloseEvent, + RepositoryOpenEvent, + RepositoryVisibility, + ScmRepository, +} from '../../git/gitProvider'; +import { GitProviderService } from '../../git/gitProviderService'; +import { GitUri } from '../../git/gitUri'; +import { + BranchSortOptions, + GitBlame, + GitBlameAuthor, + GitBlameLine, + GitBlameLines, + GitBranch, + GitBranchReference, + GitCommit, + GitCommitIdentity, + GitCommitLine, + GitContributor, + GitDiff, + GitDiffFilter, + GitDiffHunkLine, + GitDiffShortStat, + GitFile, + GitFileChange, + GitFileIndexStatus, + GitLog, + GitMergeStatus, + GitRebaseStatus, + GitReference, + GitReflog, + GitRemote, + GitRemoteType, + GitRevision, + GitStash, + GitStatus, + GitStatusFile, + GitTag, + GitTreeEntry, + GitUser, + isUserMatch, + Repository, + RepositoryChangeEvent, + TagSortOptions, +} from '../../git/models'; +import { RemoteProviderFactory, RemoteProviders } from '../../git/remotes/factory'; +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'; +import { isAbsolute, isFolderGlob, maybeUri, normalizePath, relative } from '../../system/path'; +import { CachedBlame, CachedLog, GitDocumentState } from '../../trackers/gitDocumentTracker'; +import { TrackedDocument } from '../../trackers/trackedDocument'; +import { getRemoteHubApi, GitHubAuthorityMetadata, Metadata, RemoteHubApi } from '../remotehub'; +import { fromCommitFileStatus, GitHubApi } from './github'; + +const emptyPagedResult: PagedResult = Object.freeze({ values: [] }); +const emptyPromise: Promise = Promise.resolve(undefined); + +const githubAuthenticationScopes = ['repo', 'read:user', 'user:email']; + +// Since negative lookbehind isn't supported in all browsers, this leaves out the negative lookbehind condition `(? = new Set([Schemes.Virtual, Schemes.GitHub, Schemes.PRs]); + + private _onDidChangeRepository = new EventEmitter(); + get onDidChangeRepository(): Event { + return this._onDidChangeRepository.event; + } + + private _onDidCloseRepository = new EventEmitter(); + get onDidCloseRepository(): Event { + return this._onDidCloseRepository.event; + } + + private _onDidOpenRepository = new EventEmitter(); + get onDidOpenRepository(): Event { + return this._onDidOpenRepository.event; + } + + private readonly _branchesCache = new Map>>(); + private readonly _repoInfoCache = new Map(); + private readonly _tagsCache = new Map>>(); + + private readonly _disposables: Disposable[] = []; + + constructor(private readonly container: Container) {} + + dispose() { + this._disposables.forEach(d => d.dispose()); + } + + private onRepositoryChanged(repo: Repository, e: RepositoryChangeEvent) { + // if (e.changed(RepositoryChange.Config, RepositoryChangeComparisonMode.Any)) { + // this._repoInfoCache.delete(repo.path); + // } + + // if (e.changed(RepositoryChange.Heads, RepositoryChange.Remotes, RepositoryChangeComparisonMode.Any)) { + // this._branchesCache.delete(repo.path); + // } + + this._branchesCache.delete(repo.path); + this._tagsCache.delete(repo.path); + this._repoInfoCache.delete(repo.path); + + this._onDidChangeRepository.fire(e); + } + + async discoverRepositories(uri: Uri): Promise { + if (!this.supportedSchemes.has(uri.scheme)) return []; + + try { + const { remotehub } = await this.ensureRepositoryContext(uri.toString(), true); + const workspaceUri = remotehub.getVirtualWorkspaceUri(uri); + if (workspaceUri == null) return []; + + return [this.openRepository(undefined, workspaceUri, true)]; + } catch { + return []; + } + } + + updateContext(): void { + void setContext(ContextKeys.HasVirtualFolders, this.container.git.hasOpenRepositories(this.descriptor.id)); + } + + openRepository( + folder: WorkspaceFolder | undefined, + uri: Uri, + root: boolean, + suspended?: boolean, + closed?: boolean, + ): Repository { + return new Repository( + this.container, + this.onRepositoryChanged.bind(this), + this.descriptor, + folder, + uri, + root, + suspended ?? !window.state.focused, + closed, + ); + } + + private _allowedFeatures = new Map>(); + async allows(feature: PlusFeatures, 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; + + switch (feature) { + case Features.Worktrees: + return false; + default: + return true; + } + } + + 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); + } + + 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 []; + } + + async getOrOpenScmRepository(_repoPath: string): Promise { + return undefined; + } + + canHandlePathOrUri(scheme: string, pathOrUri: string | Uri): string | undefined { + if (!this.supportedSchemes.has(scheme)) return undefined; + return typeof pathOrUri === 'string' ? pathOrUri : pathOrUri.toString(); + } + + getAbsoluteUri(pathOrUri: string | Uri, base: string | Uri): Uri { + // Convert the base to a Uri if it isn't one + if (typeof base === 'string') { + // If it looks like a Uri parse it, otherwise throw + if (maybeUri(base)) { + base = Uri.parse(base, true); + } else { + debugger; + void window.showErrorMessage( + `Unable to get absolute uri between ${ + typeof pathOrUri === 'string' ? pathOrUri : pathOrUri.toString(false) + } and ${base}; Base path '${base}' must be a uri`, + ); + throw new Error(`Base path '${base}' must be a uri`); + } + } + + if (typeof pathOrUri === 'string' && !maybeUri(pathOrUri) && !isAbsolute(pathOrUri)) { + return Uri.joinPath(base, normalizePath(pathOrUri)); + } + + const relativePath = this.getRelativePath(pathOrUri, base); + return Uri.joinPath(base, relativePath); + } + + @log() + async getBestRevisionUri(repoPath: string, path: string, ref: string | undefined): Promise { + return ref ? this.createProviderUri(repoPath, ref, path) : this.createVirtualUri(repoPath, ref, path); + } + + getRelativePath(pathOrUri: string | Uri, base: string | Uri): string { + // Convert the base to a Uri if it isn't one + if (typeof base === 'string') { + // If it looks like a Uri parse it, otherwise throw + if (maybeUri(base)) { + base = Uri.parse(base, true); + } else { + debugger; + void window.showErrorMessage( + `Unable to get relative path between ${ + typeof pathOrUri === 'string' ? pathOrUri : pathOrUri.toString(false) + } and ${base}; Base path '${base}' must be a uri`, + ); + throw new Error(`Base path '${base}' must be a uri`); + } + } + + let relativePath; + + // Convert the path to a Uri if it isn't one + if (typeof pathOrUri === 'string') { + if (maybeUri(pathOrUri)) { + pathOrUri = Uri.parse(pathOrUri, true); + } else { + pathOrUri = normalizePath(pathOrUri); + relativePath = + isAbsolute(pathOrUri) && pathOrUri.startsWith(base.path) + ? pathOrUri.slice(base.path.length) + : pathOrUri; + if (relativePath.charCodeAt(0) === CharCode.Slash) { + relativePath = relativePath.slice(1); + } + return relativePath; + } + } + + relativePath = normalizePath(relative(base.path.slice(1), pathOrUri.path.slice(1))); + return relativePath; + } + + getRevisionUri(repoPath: string, path: string, ref: string): Uri { + const uri = this.createProviderUri(repoPath, ref, path); + return ref === GitRevision.deletedOrMissing ? uri.with({ query: '~' }) : uri; + } + + @log() + async getWorkingUri(repoPath: string, uri: Uri) { + return this.createVirtualUri(repoPath, undefined, uri.path); + } + + @log() + async addRemote(_repoPath: string, _name: string, _url: string): Promise {} + + @log() + async pruneRemote(_repoPath: string, _remoteName: string): Promise {} + + @log() + async applyChangesToWorkingFile(_uri: GitUri, _ref1?: string, _ref2?: string): Promise {} + + @log() + async branchContainsCommit(_repoPath: string, _name: string, _ref: string): Promise { + return false; + } + + @log() + async checkout( + _repoPath: string, + _ref: string, + _options?: { createBranch?: string } | { path?: string }, + ): Promise {} + + @log() + resetCaches( + ...affects: ('branches' | 'contributors' | 'providers' | 'remotes' | 'stashes' | 'status' | 'tags')[] + ): void { + if (affects.length === 0 || affects.includes('branches')) { + this._branchesCache.clear(); + } + + if (affects.length === 0 || affects.includes('tags')) { + this._tagsCache.clear(); + } + + if (affects.length === 0) { + this._repoInfoCache.clear(); + } + } + + @log({ args: { 1: uris => uris.length } }) + async excludeIgnoredUris(_repoPath: string, uris: Uri[]): Promise { + return uris; + } + + // @gate() + @log() + async fetch( + _repoPath: string, + _options?: { all?: boolean; branch?: GitBranchReference; prune?: boolean; pull?: boolean; remote?: string }, + ): Promise {} + + @gate() + @debug() + async findRepositoryUri(uri: Uri, _isDirectory?: boolean): Promise { + const cc = Logger.getCorrelationContext(); + + try { + const remotehub = await this.ensureRemoteHubApi(); + const rootUri = remotehub.getProviderRootUri(uri).with({ scheme: Schemes.Virtual }); + return rootUri; + } catch (ex) { + if (!(ex instanceof ExtensionNotFoundError)) { + debugger; + } + Logger.error(ex, cc); + + return undefined; + } + } + + @log({ args: { 1: refs => refs.join(',') } }) + async getAheadBehindCommitCount( + _repoPath: string, + _refs: string[], + ): Promise<{ ahead: number; behind: number } | undefined> { + return undefined; + } + + @gate() + @log() + async getBlame(uri: GitUri, document?: TextDocument | undefined): Promise { + const cc = Logger.getCorrelationContext(); + + // TODO@eamodio we need to figure out when to do this, since dirty isn't enough, we need to know if there are any uncommitted changes + if (document?.isDirty) return undefined; //this.getBlameContents(uri, document.getText()); + + let key = 'blame'; + if (uri.sha != null) { + key += `:${uri.sha}`; + } + + const doc = await this.container.tracker.getOrAdd(uri); + if (doc.state != null) { + const cachedBlame = doc.state.getBlame(key); + if (cachedBlame != null) { + Logger.debug(cc, `Cache hit: '${key}'`); + return cachedBlame.item; + } + } + + Logger.debug(cc, `Cache miss: '${key}'`); + + if (doc.state == null) { + doc.state = new GitDocumentState(doc.key); + } + + const promise = this.getBlameCore(uri, doc, key, cc); + + if (doc.state != null) { + Logger.debug(cc, `Cache add: '${key}'`); + + const value: CachedBlame = { + item: promise as Promise, + }; + doc.state.setBlame(key, value); + } + + return promise; + } + + private async getBlameCore( + uri: GitUri, + document: TrackedDocument, + key: string, + cc: LogCorrelationContext | undefined, + ): Promise { + try { + const context = await this.ensureRepositoryContext(uri.repoPath!); + if (context == null) return undefined; + const { metadata, github, remotehub, session } = context; + + const root = remotehub.getVirtualUri(remotehub.getProviderRootUri(uri)); + const relativePath = this.getRelativePath(uri, root); + + if (uri.scheme === Schemes.Virtual) { + const [working, committed] = await Promise.allSettled([ + workspace.fs.stat(uri), + workspace.fs.stat(uri.with({ scheme: Schemes.GitHub })), + ]); + if ( + working.status !== 'fulfilled' || + committed.status !== 'fulfilled' || + working.value.mtime !== committed.value.mtime + ) { + return undefined; + } + } + + const ref = !uri.sha || uri.sha === 'HEAD' ? (await metadata.getRevision()).revision : uri.sha; + const blame = await github.getBlame( + session.accessToken, + metadata.repo.owner, + metadata.repo.name, + ref, + relativePath, + ); + + const authors = new Map(); + const commits = new Map(); + const lines: GitCommitLine[] = []; + + for (const range of blame.ranges) { + const c = range.commit; + + const { viewer = session.account.label } = blame; + const authorName = viewer != null && c.author.name === viewer ? 'You' : c.author.name; + const committerName = viewer != null && c.committer.name === viewer ? 'You' : c.committer.name; + + let author = authors.get(authorName); + if (author == null) { + author = { + name: authorName, + lineCount: 0, + }; + authors.set(authorName, author); + } + + author.lineCount += range.endingLine - range.startingLine + 1; + + let commit = commits.get(c.oid); + if (commit == null) { + commit = new GitCommit( + this.container, + uri.repoPath!, + c.oid, + new GitCommitIdentity(authorName, c.author.email, new Date(c.author.date), c.author.avatarUrl), + new GitCommitIdentity(committerName, c.committer.email, new Date(c.author.date)), + c.message.split('\n', 1)[0], + c.parents.nodes[0]?.oid ? [c.parents.nodes[0]?.oid] : [], + c.message, + new GitFileChange(root.toString(), relativePath, GitFileIndexStatus.Modified), + { changedFiles: c.changedFiles ?? 0, additions: c.additions ?? 0, deletions: c.deletions ?? 0 }, + [], + ); + + commits.set(c.oid, commit); + } + + for (let i = range.startingLine; i <= range.endingLine; i++) { + // GitHub doesn't currently support returning the original line number, so we are just using the current one + const line: GitCommitLine = { sha: c.oid, originalLine: i, line: i }; + + commit.lines.push(line); + lines[i - 1] = line; + } + } + + const sortedAuthors = new Map([...authors.entries()].sort((a, b) => b[1].lineCount - a[1].lineCount)); + + return { + repoPath: uri.repoPath!, + authors: sortedAuthors, + commits: commits, + lines: lines, + }; + } catch (ex) { + debugger; + // Trap and cache expected blame errors + if (document.state != null && !/No provider registered with/.test(String(ex))) { + const msg = ex?.toString() ?? ''; + Logger.debug(cc, `Cache replace (with empty promise): '${key}'`); + + const value: CachedBlame = { + item: emptyPromise as Promise, + errorMessage: msg, + }; + document.state.setBlame(key, value); + + document.setBlameFailure(); + + return emptyPromise as Promise; + } + + return undefined; + } + } + + @log({ args: { 1: '' } }) + async getBlameContents(_uri: GitUri, _contents: string): Promise { + // TODO@eamodio figure out how to actually generate a blame given the contents (need to generate a diff) + return undefined; //this.getBlame(uri); + } + + @gate() + @log() + async getBlameForLine( + uri: GitUri, + editorLine: number, // 0-based, Git is 1-based + document?: TextDocument | undefined, + options?: { forceSingleLine?: boolean }, + ): Promise { + const cc = Logger.getCorrelationContext(); + + // TODO@eamodio we need to figure out when to do this, since dirty isn't enough, we need to know if there are any uncommitted changes + if (document?.isDirty) return undefined; //this.getBlameForLineContents(uri, editorLine, document.getText(), options); + + if (!options?.forceSingleLine) { + const blame = await this.getBlame(uri); + if (blame == null) return undefined; + + let blameLine = blame.lines[editorLine]; + if (blameLine == null) { + if (blame.lines.length !== editorLine) return undefined; + blameLine = blame.lines[editorLine - 1]; + } + + const commit = blame.commits.get(blameLine.sha); + if (commit == null) return undefined; + + const author = blame.authors.get(commit.author.name)!; + return { + author: { ...author, lineCount: commit.lines.length }, + commit: commit, + line: blameLine, + }; + } + + try { + const context = await this.ensureRepositoryContext(uri.repoPath!); + if (context == null) return undefined; + const { metadata, github, remotehub, session } = context; + + const root = remotehub.getVirtualUri(remotehub.getProviderRootUri(uri)); + const relativePath = this.getRelativePath(uri, root); + + const ref = !uri.sha || uri.sha === 'HEAD' ? (await metadata.getRevision()).revision : uri.sha; + const blame = await github.getBlame( + session.accessToken, + metadata.repo.owner, + metadata.repo.name, + ref, + relativePath, + ); + + const startingLine = editorLine + 1; + const range = blame.ranges.find(r => r.startingLine === startingLine); + if (range == null) return undefined; + + const c = range.commit; + + const { viewer = session.account.label } = blame; + const authorName = viewer != null && c.author.name === viewer ? 'You' : c.author.name; + const committerName = viewer != null && c.committer.name === viewer ? 'You' : c.committer.name; + + const commit = new GitCommit( + this.container, + uri.repoPath!, + c.oid, + new GitCommitIdentity(authorName, c.author.email, new Date(c.author.date), c.author.avatarUrl), + new GitCommitIdentity(committerName, c.committer.email, new Date(c.author.date)), + c.message.split('\n', 1)[0], + c.parents.nodes[0]?.oid ? [c.parents.nodes[0]?.oid] : [], + c.message, + new GitFileChange(root.toString(), relativePath, GitFileIndexStatus.Modified), + { changedFiles: c.changedFiles ?? 0, additions: c.additions ?? 0, deletions: c.deletions ?? 0 }, + [], + ); + + for (let i = range.startingLine; i <= range.endingLine; i++) { + // GitHub doesn't currently support returning the original line number, so we are just using the current one + const line: GitCommitLine = { sha: c.oid, originalLine: i, line: i }; + + commit.lines.push(line); + } + + return { + author: { + name: authorName, + lineCount: range.endingLine - range.startingLine + 1, + }, + commit: commit, + // GitHub doesn't currently support returning the original line number, so we are just using the current one + line: { sha: c.oid, originalLine: range.startingLine, line: range.startingLine }, + }; + } catch (ex) { + debugger; + Logger.error(cc, ex); + return undefined; + } + } + + @log({ args: { 2: '' } }) + async getBlameForLineContents( + _uri: GitUri, + _editorLine: number, // 0-based, Git is 1-based + _contents: string, + _options?: { forceSingleLine?: boolean }, + ): Promise { + // TODO@eamodio figure out how to actually generate a blame given the contents (need to generate a diff) + return undefined; //this.getBlameForLine(uri, editorLine); + } + + @log() + async getBlameForRange(uri: GitUri, range: Range): Promise { + const blame = await this.getBlame(uri); + if (blame == null) return undefined; + + return this.getBlameRange(blame, uri, range); + } + + @log({ args: { 2: '' } }) + async getBlameForRangeContents(uri: GitUri, range: Range, contents: string): Promise { + const blame = await this.getBlameContents(uri, contents); + if (blame == null) return undefined; + + return this.getBlameRange(blame, uri, range); + } + + @log({ args: { 0: '' } }) + getBlameRange(blame: GitBlame, uri: GitUri, range: Range): GitBlameLines | undefined { + if (blame.lines.length === 0) return { allLines: blame.lines, ...blame }; + + if (range.start.line === 0 && range.end.line === blame.lines.length - 1) { + return { allLines: blame.lines, ...blame }; + } + + const lines = blame.lines.slice(range.start.line, range.end.line + 1); + const shas = new Set(lines.map(l => l.sha)); + + // ranges are 0-based + const startLine = range.start.line + 1; + const endLine = range.end.line + 1; + + const authors = new Map(); + const commits = new Map(); + for (const c of blame.commits.values()) { + if (!shas.has(c.sha)) continue; + + const commit = c.with({ + lines: c.lines.filter(l => l.line >= startLine && l.line <= endLine), + }); + commits.set(c.sha, commit); + + let author = authors.get(commit.author.name); + if (author == null) { + author = { + name: commit.author.name, + lineCount: 0, + }; + authors.set(author.name, author); + } + + author.lineCount += commit.lines.length; + } + + const sortedAuthors = new Map([...authors.entries()].sort((a, b) => b[1].lineCount - a[1].lineCount)); + + return { + repoPath: uri.repoPath!, + authors: sortedAuthors, + commits: commits, + lines: lines, + allLines: blame.lines, + }; + } + + @log() + async getBranch(repoPath: string | undefined): Promise { + const { + values: [branch], + } = await this.getBranches(repoPath, { filter: b => b.current }); + return branch; + } + + @log({ args: { 1: false } }) + async getBranches( + repoPath: string | undefined, + options?: { + cursor?: string; + filter?: (b: GitBranch) => boolean; + sort?: boolean | BranchSortOptions; + }, + ): Promise> { + if (repoPath == null) return emptyPagedResult; + + const cc = Logger.getCorrelationContext(); + + let branchesPromise = options?.cursor ? undefined : this._branchesCache.get(repoPath); + if (branchesPromise == null) { + async function load(this: GitHubGitProvider): Promise> { + try { + const { metadata, github, session } = await this.ensureRepositoryContext(repoPath!); + + const revision = await metadata.getRevision(); + const current = revision.type === 0 /* HeadType.Branch */ ? revision.name : undefined; + + const branches: GitBranch[] = []; + + let cursor = options?.cursor; + const loadAll = cursor == null; + + while (true) { + const result = await github.getBranches( + session.accessToken, + metadata.repo.owner, + metadata.repo.name, + { cursor: cursor }, + ); + + for (const branch of result.values) { + const date = new Date( + this.container.config.advanced.commitOrdering === 'author-date' + ? branch.target.authoredDate + : branch.target.committedDate, + ); + const ref = branch.target.oid; + + branches.push( + new GitBranch(repoPath!, branch.name, false, branch.name === current, date, ref, { + name: `origin/${branch.name}`, + missing: false, + }), + new GitBranch(repoPath!, `origin/${branch.name}`, true, false, date, ref), + ); + } + + if (!result.paging?.more || !loadAll) return { ...result, values: branches }; + + cursor = result.paging.cursor; + } + } catch (ex) { + Logger.error(ex, cc); + debugger; + + this._branchesCache.delete(repoPath!); + return emptyPagedResult; + } + } + + branchesPromise = load.call(this); + if (options?.cursor == null) { + this._branchesCache.set(repoPath, branchesPromise); + } + } + + let result = await branchesPromise; + if (options?.filter != null) { + result = { + ...result, + values: result.values.filter(options.filter), + }; + } + + if (options?.sort != null) { + GitBranch.sort(result.values, typeof options.sort === 'boolean' ? undefined : options.sort); + } + + return result; + } + + @log() + async getChangedFilesCount(repoPath: string, ref?: string): Promise { + // TODO@eamodio if there is no ref we can't return anything, until we can get at the change store from RemoteHub + if (!ref) return undefined; + + const commit = await this.getCommit(repoPath, ref); + if (commit?.stats == null) return undefined; + + const { stats } = commit; + + const changedFiles = + typeof stats.changedFiles === 'number' + ? stats.changedFiles + : stats.changedFiles.added + stats.changedFiles.changed + stats.changedFiles.deleted; + return { additions: stats.additions, deletions: stats.deletions, changedFiles: changedFiles }; + } + + @log() + async getCommit(repoPath: string, ref: string): Promise { + if (repoPath == null) return undefined; + + const cc = Logger.getCorrelationContext(); + + try { + const { metadata, github, session } = await this.ensureRepositoryContext(repoPath); + + const commit = await github.getCommit(session.accessToken, metadata.repo.owner, metadata.repo.name, ref); + if (commit == null) return undefined; + + const { viewer = session.account.label } = commit; + const authorName = viewer != null && commit.author.name === viewer ? 'You' : commit.author.name; + const committerName = viewer != null && commit.committer.name === viewer ? 'You' : commit.committer.name; + + return new GitCommit( + this.container, + repoPath, + commit.oid, + new GitCommitIdentity( + authorName, + commit.author.email, + new Date(commit.author.date), + commit.author.avatarUrl, + ), + new GitCommitIdentity(committerName, commit.committer.email, new Date(commit.committer.date)), + commit.message.split('\n', 1)[0], + commit.parents.nodes.map(p => p.oid), + commit.message, + commit.files?.map( + f => + new GitFileChange( + repoPath, + f.filename ?? '', + fromCommitFileStatus(f.status) ?? GitFileIndexStatus.Modified, + f.previous_filename, + undefined, + { additions: f.additions ?? 0, deletions: f.deletions ?? 0, changes: f.changes ?? 0 }, + ), + ) ?? [], + { + changedFiles: commit.changedFiles ?? 0, + additions: commit.additions ?? 0, + deletions: commit.deletions ?? 0, + }, + [], + ); + } catch (ex) { + Logger.error(ex, cc); + debugger; + return undefined; + } + } + + @log() + async getCommitBranches( + repoPath: string, + ref: string, + options?: { branch?: string; commitDate?: Date; mode?: 'contains' | 'pointsAt'; remotes?: boolean }, + ): Promise { + if (repoPath == null || options?.commitDate == null) return []; + + const cc = Logger.getCorrelationContext(); + + try { + const { metadata, github, session } = await this.ensureRepositoryContext(repoPath); + + let branches; + + if (options?.branch) { + branches = await github.getCommitOnBranch( + session.accessToken, + metadata.repo.owner, + metadata.repo.name, + options?.branch, + ref, + options?.commitDate, + ); + } else { + branches = await github.getCommitBranches( + session.accessToken, + metadata.repo.owner, + metadata.repo.name, + ref, + options?.commitDate, + ); + } + + return branches; + } catch (ex) { + Logger.error(ex, cc); + debugger; + return []; + } + } + + @log() + async getCommitCount(repoPath: string, ref: string): Promise { + if (repoPath == null) return undefined; + + const cc = Logger.getCorrelationContext(); + + try { + const { metadata, github, session } = await this.ensureRepositoryContext(repoPath); + + const count = await github.getCommitCount( + session?.accessToken, + metadata.repo.owner, + metadata.repo.name, + ref, + ); + + return count; + } catch (ex) { + Logger.error(ex, cc); + debugger; + return undefined; + } + } + + @log() + async getCommitForFile( + repoPath: string | undefined, + uri: Uri, + options?: { ref?: string; firstIfNotFound?: boolean; range?: Range }, + ): Promise { + if (repoPath == null) return undefined; + + const cc = Logger.getCorrelationContext(); + + try { + const { metadata, github, remotehub, session } = await this.ensureRepositoryContext(repoPath); + + const file = this.getRelativePath(uri, remotehub.getProviderRootUri(uri)); + + const ref = !options?.ref || options.ref === 'HEAD' ? (await metadata.getRevision()).revision : options.ref; + const commit = await github.getCommitForFile( + session.accessToken, + metadata.repo.owner, + metadata.repo.name, + ref, + file, + ); + if (commit == null) return undefined; + + const { viewer = session.account.label } = commit; + const authorName = viewer != null && commit.author.name === viewer ? 'You' : commit.author.name; + const committerName = viewer != null && commit.committer.name === viewer ? 'You' : commit.committer.name; + + const files = commit.files?.map( + f => + new GitFileChange( + repoPath, + f.filename ?? '', + fromCommitFileStatus(f.status) ?? GitFileIndexStatus.Modified, + f.previous_filename, + undefined, + { additions: f.additions ?? 0, deletions: f.deletions ?? 0, changes: f.changes ?? 0 }, + ), + ); + const foundFile = files?.find(f => f.path === file); + + return new GitCommit( + this.container, + repoPath, + commit.oid, + new GitCommitIdentity( + authorName, + commit.author.email, + new Date(commit.author.date), + commit.author.avatarUrl, + ), + new GitCommitIdentity(committerName, commit.committer.email, new Date(commit.committer.date)), + commit.message.split('\n', 1)[0], + commit.parents.nodes.map(p => p.oid), + commit.message, + { file: foundFile, files: files }, + { + changedFiles: commit.changedFiles ?? 0, + additions: commit.additions ?? 0, + deletions: commit.deletions ?? 0, + }, + [], + ); + } catch (ex) { + Logger.error(ex, cc); + debugger; + return undefined; + } + } + + @log() + async getOldestUnpushedRefForFile(_repoPath: string, _uri: Uri): Promise { + // TODO@eamodio until we have access to the RemoteHub change store there isn't anything we can do here + return undefined; + } + + @log() + async getContributors( + repoPath: string, + _options?: { all?: boolean; ref?: string; stats?: boolean }, + ): Promise { + if (repoPath == null) return []; + + const cc = Logger.getCorrelationContext(); + + try { + const { metadata, github, session } = await this.ensureRepositoryContext(repoPath); + + const results = await github.getContributors(session.accessToken, metadata.repo.owner, metadata.repo.name); + const currentUser = await this.getCurrentUser(repoPath); + + const contributors = []; + for (const c of results) { + if (c.type !== 'User') continue; + + contributors.push( + new GitContributor( + repoPath, + c.name, + c.email, + c.contributions, + undefined, + isUserMatch(currentUser, c.name, c.email, c.login), + undefined, + c.login, + c.avatar_url, + c.node_id, + ), + ); + } + + return contributors; + } catch (ex) { + Logger.error(ex, cc); + debugger; + return []; + } + } + + @gate() + @log() + async getCurrentUser(repoPath: string): Promise { + if (!repoPath) return undefined; + + const cc = Logger.getCorrelationContext(); + + const repo = this._repoInfoCache.get(repoPath); + + let user = repo?.user; + if (user != null) return user; + // If we found the repo, but no user data was found just return + if (user === null) return undefined; + + try { + const { metadata, github, session } = await this.ensureRepositoryContext(repoPath); + user = await github.getCurrentUser(session.accessToken, metadata.repo.owner, metadata.repo.name); + + this._repoInfoCache.set(repoPath, { ...repo, user: user ?? null }); + return user; + } catch (ex) { + Logger.error(ex, cc); + debugger; + + // Mark it so we won't bother trying again + this._repoInfoCache.set(repoPath, { ...repo, user: null }); + return undefined; + } + } + + @log() + async getDefaultBranchName(repoPath: string | undefined, _remote?: string): Promise { + if (repoPath == null) return undefined; + + const cc = Logger.getCorrelationContext(); + + try { + const { metadata, github, session } = await this.ensureRepositoryContext(repoPath); + return await github.getDefaultBranchName(session.accessToken, metadata.repo.owner, metadata.repo.name); + } catch (ex) { + Logger.error(ex, cc); + debugger; + return undefined; + } + } + + @log() + async getDiffForFile(_uri: GitUri, _ref1: string | undefined, _ref2?: string): Promise { + return undefined; + } + + @log({ + args: { + 1: _contents => '', + }, + }) + async getDiffForFileContents(_uri: GitUri, _ref: string, _contents: string): Promise { + return undefined; + } + + @log() + async getDiffForLine( + _uri: GitUri, + _editorLine: number, // 0-based, Git is 1-based + _ref1: string | undefined, + _ref2?: string, + ): Promise { + return undefined; + } + + @log() + async getDiffStatus( + _repoPath: string, + _ref1?: string, + _ref2?: string, + _options?: { filters?: GitDiffFilter[]; similarityThreshold?: number }, + ): Promise { + return undefined; + } + + @log() + async getFileStatusForCommit(repoPath: string, uri: Uri, ref: string): Promise { + if (ref === GitRevision.deletedOrMissing || GitRevision.isUncommitted(ref)) return undefined; + + const commit = await this.getCommitForFile(repoPath, uri, { ref: ref }); + if (commit == null) return undefined; + + return commit.findFile(uri); + } + + async getLastFetchedTimestamp(_repoPath: string): Promise { + return undefined; + } + + @log() + async getLog( + repoPath: string, + options?: { + all?: boolean; + authors?: GitUser[]; + cursor?: string; + limit?: number; + merges?: boolean; + ordering?: string | null; + ref?: string; + since?: string; + }, + ): Promise { + if (repoPath == null) return undefined; + + const cc = Logger.getCorrelationContext(); + + const limit = this.getPagingLimit(options?.limit); + + try { + const { metadata, github, session } = await this.ensureRepositoryContext(repoPath); + + const ref = !options?.ref || options.ref === 'HEAD' ? (await metadata.getRevision()).revision : options.ref; + const result = await github.getCommits(session.accessToken, metadata.repo.owner, metadata.repo.name, ref, { + all: options?.all, + authors: options?.authors, + after: options?.cursor, + limit: limit, + since: options?.since ? new Date(options.since) : undefined, + }); + + const commits = new Map(); + + const { viewer = session.account.label } = result; + for (const commit of result.values) { + const authorName = viewer != null && commit.author.name === viewer ? 'You' : commit.author.name; + const committerName = + viewer != null && commit.committer.name === viewer ? 'You' : commit.committer.name; + + let c = commits.get(commit.oid); + if (c == null) { + c = new GitCommit( + this.container, + repoPath, + commit.oid, + new GitCommitIdentity( + authorName, + commit.author.email, + new Date(commit.author.date), + commit.author.avatarUrl, + ), + new GitCommitIdentity(committerName, commit.committer.email, new Date(commit.committer.date)), + commit.message.split('\n', 1)[0], + commit.parents.nodes.map(p => p.oid), + commit.message, + commit.files?.map( + f => + new GitFileChange( + repoPath, + f.filename ?? '', + fromCommitFileStatus(f.status) ?? GitFileIndexStatus.Modified, + f.previous_filename, + undefined, + { + additions: f.additions ?? 0, + deletions: f.deletions ?? 0, + changes: f.changes ?? 0, + }, + ), + ), + { + changedFiles: commit.changedFiles ?? 0, + additions: commit.additions ?? 0, + deletions: commit.deletions ?? 0, + }, + [], + ); + commits.set(commit.oid, c); + } + } + + const log: GitLog = { + repoPath: repoPath, + commits: commits, + sha: ref, + range: undefined, + count: commits.size, + limit: limit, + hasMore: result.paging?.more ?? false, + cursor: result.paging?.cursor, + query: (limit: number | undefined) => this.getLog(repoPath, { ...options, limit: limit }), + }; + + if (log.hasMore) { + log.more = this.getLogMoreFn(log, options); + } + + return log; + } catch (ex) { + Logger.error(ex, cc); + debugger; + return undefined; + } + } + + @log() + async getLogRefsOnly( + repoPath: string, + options?: { + authors?: GitUser[]; + cursor?: string; + limit?: number; + merges?: boolean; + ordering?: string | null; + ref?: string; + since?: string; + }, + ): Promise | undefined> { + // TODO@eamodio optimize this + const result = await this.getLog(repoPath, options); + if (result == null) return undefined; + + return new Set([...result.commits.values()].map(c => c.ref)); + } + + private getLogMoreFn( + log: GitLog, + options?: { + authors?: GitUser[]; + limit?: number; + merges?: boolean; + ordering?: string | null; + ref?: string; + }, + ): (limit: number | { until: string } | undefined) => Promise { + return async (limit: number | { until: string } | undefined) => { + const moreUntil = limit != null && typeof limit === 'object' ? limit.until : undefined; + let moreLimit = typeof limit === 'number' ? limit : undefined; + + if (moreUntil && some(log.commits.values(), c => c.ref === moreUntil)) { + return log; + } + + moreLimit = this.getPagingLimit(moreLimit); + + // // If the log is for a range, then just get everything prior + more + // if (GitRevision.isRange(log.sha)) { + // const moreLog = await this.getLog(log.repoPath, { + // ...options, + // limit: moreLimit === 0 ? 0 : (options?.limit ?? 0) + moreLimit, + // }); + // // If we can't find any more, assume we have everything + // if (moreLog == null) return { ...log, hasMore: false }; + + // return moreLog; + // } + + // const ref = Iterables.last(log.commits.values())?.ref; + // const moreLog = await this.getLog(log.repoPath, { + // ...options, + // limit: moreUntil == null ? moreLimit : 0, + // ref: moreUntil == null ? `${ref}^` : `${moreUntil}^..${ref}^`, + // }); + // // If we can't find any more, assume we have everything + // if (moreLog == null) return { ...log, hasMore: false }; + + const moreLog = await this.getLog(log.repoPath, { + ...options, + limit: moreLimit, + cursor: log.cursor, + }); + // If we can't find any more, assume we have everything + if (moreLog == null) return { ...log, hasMore: false }; + + const commits = new Map([...log.commits, ...moreLog.commits]); + + const mergedLog: GitLog = { + repoPath: log.repoPath, + commits: commits, + sha: log.sha, + range: undefined, + count: commits.size, + limit: moreUntil == null ? (log.limit ?? 0) + moreLimit : undefined, + hasMore: moreUntil == null ? moreLog.hasMore : true, + cursor: moreLog.cursor, + query: log.query, + }; + mergedLog.more = this.getLogMoreFn(mergedLog, options); + + return mergedLog; + }; + } + + @log() + async getLogForSearch( + repoPath: string, + search: SearchPattern, + options?: { cursor?: string; limit?: number; ordering?: 'date' | 'author-date' | 'topo' | null; skip?: number }, + ): Promise { + if (repoPath == null) return undefined; + + const cc = Logger.getCorrelationContext(); + + const operations = SearchPattern.parseSearchOperations(search.pattern); + + let op; + let values = operations.get('commit:'); + if (values != null) { + const commit = await this.getCommit(repoPath, values[0]); + if (commit == null) return undefined; + + return { + repoPath: repoPath, + commits: new Map([[commit.sha, commit]]), + sha: commit.sha, + range: undefined, + count: 1, + limit: 1, + hasMore: false, + }; + } + + const query = []; + + for ([op, values] of operations.entries()) { + switch (op) { + case 'message:': + query.push(...values.map(m => m.replace(/ /g, '+'))); + break; + + case 'author:': + query.push( + ...values.map(a => { + a = a.replace(/ /g, '+'); + if (a.startsWith('@')) return `author:${a.slice(1)}`; + if (a.startsWith('"@')) return `author:"${a.slice(2)}`; + if (a.includes('@')) return `author-email:${a}`; + return `author-name:${a}`; + }), + ); + break; + + // case 'change:': + // case 'file:': + // break; + } + } + + if (query.length === 0) return undefined; + + const limit = this.getPagingLimit(options?.limit); + + try { + const { metadata, github, session } = await this.ensureRepositoryContext(repoPath); + + const result = await github.searchCommits( + session.accessToken, + `repo:${metadata.repo.owner}/${metadata.repo.name}+${query.join('+').trim()}`, + { + cursor: options?.cursor, + limit: limit, + sort: + options?.ordering === 'date' + ? 'committer-date' + : options?.ordering === 'author-date' + ? 'author-date' + : undefined, + }, + ); + if (result == null) return undefined; + + const commits = new Map(); + + const viewer = session.account.label; + for (const commit of result.values) { + const authorName = viewer != null && commit.author.name === viewer ? 'You' : commit.author.name; + const committerName = + viewer != null && commit.committer.name === viewer ? 'You' : commit.committer.name; + + let c = commits.get(commit.oid); + if (c == null) { + c = new GitCommit( + this.container, + repoPath, + commit.oid, + new GitCommitIdentity( + authorName, + commit.author.email, + new Date(commit.author.date), + commit.author.avatarUrl, + ), + new GitCommitIdentity(committerName, commit.committer.email, new Date(commit.committer.date)), + commit.message.split('\n', 1)[0], + commit.parents.nodes.map(p => p.oid), + commit.message, + commit.files?.map( + f => + new GitFileChange( + repoPath, + f.filename ?? '', + fromCommitFileStatus(f.status) ?? GitFileIndexStatus.Modified, + f.previous_filename, + undefined, + { + additions: f.additions ?? 0, + deletions: f.deletions ?? 0, + changes: f.changes ?? 0, + }, + ), + ), + { + changedFiles: commit.changedFiles ?? 0, + additions: commit.additions ?? 0, + deletions: commit.deletions ?? 0, + }, + [], + ); + commits.set(commit.oid, c); + } + } + + const log: GitLog = { + repoPath: repoPath, + commits: commits, + sha: undefined, + range: undefined, + count: commits.size, + limit: limit, + hasMore: result.pageInfo?.hasNextPage ?? false, + cursor: result.pageInfo?.endCursor ?? undefined, + query: (limit: number | undefined) => this.getLog(repoPath, { ...options, limit: limit }), + }; + + if (log.hasMore) { + log.more = this.getLogForSearchMoreFn(log, search, options); + } + + return log; + } catch (ex) { + Logger.error(ex, cc); + debugger; + return undefined; + } + + return undefined; + } + + private getLogForSearchMoreFn( + log: GitLog, + search: SearchPattern, + options?: { limit?: number; ordering?: 'date' | 'author-date' | 'topo' | null; skip?: number }, + ): (limit: number | undefined) => Promise { + return async (limit: number | undefined) => { + limit = this.getPagingLimit(limit); + + const moreLog = await this.getLogForSearch(log.repoPath, search, { + ...options, + limit: limit, + cursor: log.cursor, + }); + // If we can't find any more, assume we have everything + if (moreLog == null) return { ...log, hasMore: false }; + + const commits = new Map([...log.commits, ...moreLog.commits]); + + const mergedLog: GitLog = { + repoPath: log.repoPath, + commits: commits, + sha: log.sha, + range: undefined, + count: commits.size, + limit: (log.limit ?? 0) + limit, + hasMore: moreLog.hasMore, + cursor: moreLog.cursor, + query: log.query, + }; + mergedLog.more = this.getLogForSearchMoreFn(mergedLog, search, options); + + return mergedLog; + }; + } + + @log() + async getLogForFile( + repoPath: string | undefined, + pathOrUri: string | Uri, + options?: { + all?: boolean; + cursor?: string; + force?: boolean | undefined; + limit?: number; + ordering?: string | null; + range?: Range; + ref?: string; + renames?: boolean; + reverse?: boolean; + since?: string; + skip?: number; + }, + ): Promise { + if (repoPath == null) return undefined; + + const cc = Logger.getCorrelationContext(); + + const relativePath = this.getRelativePath(pathOrUri, repoPath); + + if (repoPath != null && repoPath === relativePath) { + throw new Error(`File name cannot match the repository path; path=${relativePath}`); + } + + options = { reverse: false, ...options }; + + // Not currently supported + options.renames = false; + options.all = false; + + // if (options.renames == null) { + // options.renames = this.container.config.advanced.fileHistoryFollowsRenames; + // } + + let key = 'log'; + if (options.ref != null) { + key += `:${options.ref}`; + } + + // if (options.all == null) { + // options.all = this.container.config.advanced.fileHistoryShowAllBranches; + // } + // if (options.all) { + // key += ':all'; + // } + + options.limit = this.getPagingLimit(options?.limit); + if (options.limit) { + key += `:n${options.limit}`; + } + + if (options.renames) { + key += ':follow'; + } + + if (options.reverse) { + key += ':reverse'; + } + + if (options.since) { + key += `:since=${options.since}`; + } + + if (options.skip) { + key += `:skip${options.skip}`; + } + + if (options.cursor) { + key += `:cursor=${options.cursor}`; + } + + const doc = await this.container.tracker.getOrAdd(GitUri.fromFile(relativePath, repoPath, options.ref)); + if (!options.force && options.range == null) { + if (doc.state != null) { + const cachedLog = doc.state.getLog(key); + if (cachedLog != null) { + Logger.debug(cc, `Cache hit: '${key}'`); + return cachedLog.item; + } + + if (options.ref != null || options.limit != null) { + // Since we are looking for partial log, see if we have the log of the whole file + const cachedLog = doc.state.getLog( + `log${options.renames ? ':follow' : ''}${options.reverse ? ':reverse' : ''}`, + ); + if (cachedLog != null) { + if (options.ref == null) { + Logger.debug(cc, `Cache hit: ~'${key}'`); + return cachedLog.item; + } + + Logger.debug(cc, `Cache ?: '${key}'`); + let log = await cachedLog.item; + if (log != null && !log.hasMore && log.commits.has(options.ref)) { + Logger.debug(cc, `Cache hit: '${key}'`); + + // Create a copy of the log starting at the requested commit + let skip = true; + let i = 0; + const commits = new Map( + filterMap<[string, GitCommit], [string, GitCommit]>( + log.commits.entries(), + ([ref, c]) => { + if (skip) { + if (ref !== options?.ref) return undefined; + skip = false; + } + + i++; + if (options?.limit != null && i > options.limit) { + return undefined; + } + + return [ref, c]; + }, + ), + ); + + const opts = { ...options }; + log = { + ...log, + limit: options.limit, + count: commits.size, + commits: commits, + query: (limit: number | undefined) => + this.getLogForFile(repoPath, pathOrUri, { ...opts, limit: limit }), + }; + + return log; + } + } + } + } + + Logger.debug(cc, `Cache miss: '${key}'`); + + if (doc.state == null) { + doc.state = new GitDocumentState(doc.key); + } + } + + const promise = this.getLogForFileCore(repoPath, relativePath, doc, key, cc, options); + + if (doc.state != null && options.range == null) { + Logger.debug(cc, `Cache add: '${key}'`); + + const value: CachedLog = { + item: promise as Promise, + }; + doc.state.setLog(key, value); + } + + return promise; + } + + private async getLogForFileCore( + repoPath: string | undefined, + path: string, + document: TrackedDocument, + key: string, + cc: LogCorrelationContext | undefined, + options?: { + all?: boolean; + cursor?: string; + limit?: number; + ordering?: string | null; + range?: Range; + ref?: string; + renames?: boolean; + reverse?: boolean; + since?: string; + skip?: number; + }, + ): Promise { + if (repoPath == null) return undefined; + + const limit = this.getPagingLimit(options?.limit); + + try { + const context = await this.ensureRepositoryContext(repoPath); + if (context == null) return undefined; + const { metadata, github, remotehub, session } = context; + + const uri = this.getAbsoluteUri(path, repoPath); + const relativePath = this.getRelativePath(uri, remotehub.getProviderRootUri(uri)); + + // if (range != null && range.start.line > range.end.line) { + // range = new Range(range.end, range.start); + // } + + const ref = !options?.ref || options.ref === 'HEAD' ? (await metadata.getRevision()).revision : options.ref; + const result = await github.getCommits(session.accessToken, metadata.repo.owner, metadata.repo.name, ref, { + all: options?.all, + after: options?.cursor, + path: relativePath, + limit: limit, + since: options?.since ? new Date(options.since) : undefined, + }); + + const commits = new Map(); + + const { viewer = session.account.label } = result; + for (const commit of result.values) { + const authorName = viewer != null && commit.author.name === viewer ? 'You' : commit.author.name; + const committerName = + viewer != null && commit.committer.name === viewer ? 'You' : commit.committer.name; + + let c = commits.get(commit.oid); + if (c == null) { + const files = commit.files?.map( + f => + new GitFileChange( + repoPath, + f.filename ?? '', + fromCommitFileStatus(f.status) ?? GitFileIndexStatus.Modified, + f.previous_filename, + undefined, + { additions: f.additions ?? 0, deletions: f.deletions ?? 0, changes: f.changes ?? 0 }, + ), + ); + const foundFile = isFolderGlob(relativePath) + ? undefined + : files?.find(f => f.path === relativePath) ?? + new GitFileChange( + repoPath, + relativePath, + GitFileIndexStatus.Modified, + undefined, + undefined, + commit.changedFiles === 1 + ? { additions: commit.additions ?? 0, deletions: commit.deletions ?? 0, changes: 0 } + : undefined, + ); + + c = new GitCommit( + this.container, + repoPath, + commit.oid, + new GitCommitIdentity( + authorName, + commit.author.email, + new Date(commit.author.date), + commit.author.avatarUrl, + ), + new GitCommitIdentity(committerName, commit.committer.email, new Date(commit.committer.date)), + commit.message.split('\n', 1)[0], + commit.parents.nodes.map(p => p.oid), + commit.message, + { file: foundFile, files: files }, + { + changedFiles: commit.changedFiles ?? 0, + additions: commit.additions ?? 0, + deletions: commit.deletions ?? 0, + }, + [], + ); + commits.set(commit.oid, c); + } + } + + const log: GitLog = { + repoPath: repoPath, + commits: commits, + sha: ref, + range: undefined, + count: commits.size, + limit: limit, + hasMore: result.paging?.more ?? false, + cursor: result.paging?.cursor, + query: (limit: number | undefined) => this.getLogForFile(repoPath, path, { ...options, limit: limit }), + }; + + if (log.hasMore) { + log.more = this.getLogForFileMoreFn(log, path, options); + } + + return log; + } catch (ex) { + debugger; + // Trap and cache expected log errors + if (document.state != null && options?.range == null && !options?.reverse) { + const msg: string = ex?.toString() ?? ''; + Logger.debug(cc, `Cache replace (with empty promise): '${key}'`); + + const value: CachedLog = { + item: emptyPromise as Promise, + errorMessage: msg, + }; + document.state.setLog(key, value); + + return emptyPromise as Promise; + } + + return undefined; + } + } + + private getLogForFileMoreFn( + log: GitLog, + relativePath: string, + options?: { + all?: boolean; + limit?: number; + ordering?: string | null; + range?: Range; + ref?: string; + renames?: boolean; + reverse?: boolean; + }, + ): (limit: number | { until: string } | undefined) => Promise { + return async (limit: number | { until: string } | undefined) => { + const moreUntil = limit != null && typeof limit === 'object' ? limit.until : undefined; + let moreLimit = typeof limit === 'number' ? limit : undefined; + + if (moreUntil && some(log.commits.values(), c => c.ref === moreUntil)) { + return log; + } + + moreLimit = this.getPagingLimit(moreLimit); + + // const ref = Iterables.last(log.commits.values())?.ref; + const moreLog = await this.getLogForFile(log.repoPath, relativePath, { + ...options, + limit: moreUntil == null ? moreLimit : 0, + cursor: log.cursor, + // ref: options.all ? undefined : moreUntil == null ? `${ref}^` : `${moreUntil}^..${ref}^`, + // skip: options.all ? log.count : undefined, + }); + // If we can't find any more, assume we have everything + if (moreLog == null) return { ...log, hasMore: false }; + + const commits = new Map([...log.commits, ...moreLog.commits]); + + const mergedLog: GitLog = { + repoPath: log.repoPath, + commits: commits, + sha: log.sha, + range: log.range, + count: commits.size, + limit: moreUntil == null ? (log.limit ?? 0) + moreLimit : undefined, + hasMore: moreUntil == null ? moreLog.hasMore : true, + cursor: moreLog.cursor, + query: log.query, + }; + + // if (options.renames) { + // const renamed = find( + // moreLog.commits.values(), + // c => Boolean(c.file?.originalPath) && c.file?.originalPath !== fileName, + // ); + // fileName = renamed?.file?.originalPath ?? fileName; + // } + + mergedLog.more = this.getLogForFileMoreFn(mergedLog, relativePath, options); + + return mergedLog; + }; + } + + @log() + async getMergeBase( + _repoPath: string, + _ref1: string, + _ref2: string, + _options: { forkPoint?: boolean }, + ): Promise { + return undefined; + } + + // @gate() + @log() + async getMergeStatus(_repoPath: string): Promise { + return undefined; + } + + // @gate() + @log() + async getRebaseStatus(_repoPath: string): Promise { + return undefined; + } + + @log() + async getNextComparisonUris( + repoPath: string, + uri: Uri, + ref: string | undefined, + skip: number = 0, + ): Promise { + // If we have no ref there is no next commit + if (!ref) return undefined; + + const cc = Logger.getCorrelationContext(); + + try { + const context = await this.ensureRepositoryContext(repoPath); + if (context == null) return undefined; + + const { metadata, github, remotehub, session } = context; + const relativePath = this.getRelativePath(uri, remotehub.getProviderRootUri(uri)); + const revision = (await metadata.getRevision()).revision; + + if (ref === 'HEAD') { + ref = revision; + } + + const refs = await github.getNextCommitRefs( + session.accessToken, + metadata.repo.owner, + metadata.repo.name, + revision, + relativePath, + ref, + ); + + return { + current: + skip === 0 + ? GitUri.fromFile(relativePath, repoPath, ref) + : new GitUri(await this.getBestRevisionUri(repoPath, relativePath, refs[skip - 1])), + next: new GitUri(await this.getBestRevisionUri(repoPath, relativePath, refs[skip])), + }; + } catch (ex) { + Logger.error(ex, cc); + debugger; + + throw ex; + } + } + + @log() + async getPreviousComparisonUris( + repoPath: string, + uri: Uri, + ref: string | undefined, + skip: number = 0, + _firstParent: boolean = false, + ): Promise { + if (ref === GitRevision.deletedOrMissing) return undefined; + + const cc = Logger.getCorrelationContext(); + + if (ref === GitRevision.uncommitted) { + ref = undefined; + } + + try { + const context = await this.ensureRepositoryContext(repoPath); + if (context == null) return undefined; + + const { metadata, github, remotehub, session } = context; + const relativePath = this.getRelativePath(uri, remotehub.getProviderRootUri(uri)); + + const offset = ref != null ? 1 : 0; + + const result = await github.getCommitRefs( + session.accessToken, + metadata.repo.owner, + metadata.repo.name, + !ref || ref === 'HEAD' ? (await metadata.getRevision()).revision : ref, + { + path: relativePath, + first: offset + skip + 1, + }, + ); + if (result == null) return undefined; + + // If we are at a commit, diff commit with previous + const current = + skip === 0 + ? GitUri.fromFile(relativePath, repoPath, ref) + : new GitUri( + await this.getBestRevisionUri( + repoPath, + relativePath, + result.values[offset + skip - 1]?.oid ?? GitRevision.deletedOrMissing, + ), + ); + if (current == null || current.sha === GitRevision.deletedOrMissing) return undefined; + + return { + current: current, + previous: new GitUri( + await this.getBestRevisionUri( + repoPath, + relativePath, + result.values[offset + skip]?.oid ?? GitRevision.deletedOrMissing, + ), + ), + }; + } catch (ex) { + Logger.error(ex, cc); + debugger; + + throw ex; + } + } + + @log() + async getPreviousComparisonUrisForLine( + repoPath: string, + uri: Uri, + editorLine: number, // 0-based, Git is 1-based + ref: string | undefined, + skip: number = 0, + ): Promise { + if (ref === GitRevision.deletedOrMissing) return undefined; + + const cc = Logger.getCorrelationContext(); + + try { + const context = await this.ensureRepositoryContext(repoPath); + if (context == null) return undefined; + + const { remotehub } = context; + + let relativePath = this.getRelativePath(uri, remotehub.getProviderRootUri(uri)); + + // FYI, GitHub doesn't currently support returning the original line number, nor the previous sha, so this is untrustworthy + + let current = GitUri.fromFile(relativePath, repoPath, ref); + let currentLine = editorLine; + let previous; + let previousLine = editorLine; + let nextLine = editorLine; + + for (let i = 0; i < Math.max(0, skip) + 2; i++) { + const blameLine = await this.getBlameForLine(previous ?? current, nextLine, undefined, { + forceSingleLine: true, + }); + if (blameLine == null) break; + + // Diff with line ref with previous + ref = blameLine.commit.sha; + relativePath = blameLine.commit.file?.path ?? blameLine.commit.file?.originalPath ?? relativePath; + nextLine = blameLine.line.originalLine - 1; + + const gitUri = GitUri.fromFile(relativePath, repoPath, ref); + if (previous == null) { + previous = gitUri; + previousLine = nextLine; + } else { + current = previous; + currentLine = previousLine; + previous = gitUri; + previousLine = nextLine; + } + } + + if (current == null) return undefined; + + return { + current: current, + previous: previous, + line: (currentLine ?? editorLine) + 1, // 1-based + }; + } catch (ex) { + Logger.error(ex, cc); + debugger; + + throw ex; + } + } + + @log() + async getIncomingActivity( + _repoPath: string, + _options?: { all?: boolean; branch?: string; limit?: number; ordering?: string | null; skip?: number }, + ): Promise { + return undefined; + } + + @log({ args: { 1: false } }) + async getRemotes( + repoPath: string | undefined, + options?: { providers?: RemoteProviders; sort?: boolean }, + ): Promise[]> { + if (repoPath == null) return []; + + const providers = options?.providers ?? RemoteProviderFactory.loadProviders(configuration.get('remotes', null)); + + const uri = Uri.parse(repoPath, true); + const [, owner, repo] = uri.path.split('/', 3); + + const url = `https://github.com/${owner}/${repo}.git`; + const domain = 'github.com'; + const path = `${owner}/${repo}`; + + return [ + new GitRemote( + repoPath, + `${domain}/${path}`, + 'origin', + 'https', + domain, + path, + RemoteProviderFactory.factory(providers)(url, domain, path), + [ + { type: GitRemoteType.Fetch, url: url }, + { type: GitRemoteType.Push, url: url }, + ], + ), + ]; + } + + @log() + async getRevisionContent(repoPath: string, path: string, ref: string): Promise { + const uri = ref ? this.createProviderUri(repoPath, ref, path) : this.createVirtualUri(repoPath, ref, path); + return workspace.fs.readFile(uri); + } + + // @gate() + @log() + async getStash(_repoPath: string | undefined): Promise { + return undefined; + } + + @log() + async getStatusForFile(_repoPath: string, _uri: Uri): Promise { + return undefined; + } + + @log() + async getStatusForFiles(_repoPath: string, _pathOrGlob: Uri): Promise { + return undefined; + } + + @log() + async getStatusForRepo(_repoPath: string | undefined): Promise { + return undefined; + } + + @log({ args: { 1: false } }) + async getTags( + repoPath: string | undefined, + options?: { cursor?: string; filter?: (t: GitTag) => boolean; sort?: boolean | TagSortOptions }, + ): Promise> { + if (repoPath == null) return emptyPagedResult; + + const cc = Logger.getCorrelationContext(); + + let tagsPromise = options?.cursor ? undefined : this._tagsCache.get(repoPath); + if (tagsPromise == null) { + async function load(this: GitHubGitProvider): Promise> { + try { + const { metadata, github, session } = await this.ensureRepositoryContext(repoPath!); + + const tags: GitTag[] = []; + + let cursor = options?.cursor; + const loadAll = cursor == null; + + while (true) { + const result = await github.getTags( + session.accessToken, + metadata.repo.owner, + metadata.repo.name, + { cursor: cursor }, + ); + + for (const tag of result.values) { + tags.push( + new GitTag( + repoPath!, + tag.name, + tag.target.oid, + tag.target.message ?? '', + new Date(tag.target.authoredDate ?? tag.target.tagger?.date), + new Date(tag.target.committedDate ?? tag.target.tagger?.date), + ), + ); + } + + if (!result.paging?.more || !loadAll) return { ...result, values: tags }; + + cursor = result.paging.cursor; + } + } catch (ex) { + Logger.error(ex, cc); + debugger; + + this._tagsCache.delete(repoPath!); + return emptyPagedResult; + } + } + + tagsPromise = load.call(this); + if (options?.cursor == null) { + this._tagsCache.set(repoPath, tagsPromise); + } + } + + let result = await tagsPromise; + if (options?.filter != null) { + result = { + ...result, + values: result.values.filter(options.filter), + }; + } + + if (options?.sort != null) { + GitTag.sort(result.values, typeof options.sort === 'boolean' ? undefined : options.sort); + } + + return result; + } + + @log() + async getTreeEntryForRevision(repoPath: string, path: string, ref: string): Promise { + if (repoPath == null || !path) return undefined; + + if (ref === 'HEAD') { + const context = await this.ensureRepositoryContext(repoPath); + if (context == null) return undefined; + + const revision = await context.metadata.getRevision(); + ref = revision?.revision; + } + + const uri = ref ? this.createProviderUri(repoPath, ref, path) : this.createVirtualUri(repoPath, ref, path); + + const stats = await workspace.fs.stat(uri); + if (stats == null) return undefined; + + return { + path: this.getRelativePath(uri, repoPath), + commitSha: ref, + size: stats.size, + type: stats.type === FileType.Directory ? 'tree' : 'blob', + }; + } + + @log() + async getTreeForRevision(repoPath: string, ref: string): Promise { + if (repoPath == null) return []; + + if (ref === 'HEAD') { + const context = await this.ensureRepositoryContext(repoPath); + if (context == null) return []; + + const revision = await context.metadata.getRevision(); + ref = revision?.revision; + } + + const baseUri = ref ? this.createProviderUri(repoPath, ref) : this.createVirtualUri(repoPath, ref); + + const entries = await workspace.fs.readDirectory(baseUri); + if (entries == null) return []; + + const result: GitTreeEntry[] = []; + for (const [path, type] of entries) { + const uri = this.getAbsoluteUri(path, baseUri); + + // TODO:@eamodio do we care about size? + // const stats = await workspace.fs.stat(uri); + + result.push({ + path: this.getRelativePath(path, uri), + commitSha: ref, + size: 0, // stats?.size, + type: type === FileType.Directory ? 'tree' : 'blob', + }); + } + + // TODO@eamodio: Implement this + return []; + } + + @log() + async hasBranchOrTag( + repoPath: string | undefined, + options?: { + filter?: { branches?: (b: GitBranch) => boolean; tags?: (t: GitTag) => boolean }; + }, + ) { + const [{ values: branches }, { values: tags }] = await Promise.all([ + this.getBranches(repoPath, { + filter: options?.filter?.branches, + sort: false, + }), + this.getTags(repoPath, { + filter: options?.filter?.tags, + sort: false, + }), + ]); + + return branches.length !== 0 || tags.length !== 0; + } + + @log() + async hasCommitBeenPushed(_repoPath: string, _ref: string): Promise { + // In this env we can't have unpushed commits + return true; + } + + isTrackable(uri: Uri): boolean { + return this.supportedSchemes.has(uri.scheme); + } + + isTracked(uri: Uri): Promise { + return Promise.resolve(this.isTrackable(uri) && this.container.git.getRepository(uri) != null); + } + + @log() + async getDiffTool(_repoPath?: string): Promise { + return undefined; + } + + @log() + async openDiffTool( + _repoPath: string, + _uri: Uri, + _options?: { ref1?: string; ref2?: string; staged?: boolean; tool?: string }, + ): Promise {} + + @log() + async openDirectoryCompare(_repoPath: string, _ref1: string, _ref2?: string, _tool?: string): Promise {} + + @log() + async resolveReference(repoPath: string, ref: string, pathOrUri?: string | Uri, _options?: { timeout?: number }) { + if ( + !ref || + ref === GitRevision.deletedOrMissing || + (pathOrUri == null && GitRevision.isSha(ref)) || + (pathOrUri != null && GitRevision.isUncommitted(ref)) + ) { + return ref; + } + + let relativePath; + if (pathOrUri != null) { + relativePath = this.getRelativePath(pathOrUri, repoPath); + } else if (!GitRevision.isShaLike(ref) || ref.endsWith('^3')) { + // If it doesn't look like a sha at all (e.g. branch name) or is a stash ref (^3) don't try to resolve it + return ref; + } + + const context = await this.ensureRepositoryContext(repoPath); + if (context == null) return ref; + + const { metadata, github, session } = context; + + const resolved = await github.resolveReference( + session.accessToken, + metadata.repo.owner, + metadata.repo.name, + ref, + relativePath, + ); + + if (resolved != null) return resolved; + + return relativePath ? GitRevision.deletedOrMissing : ref; + } + + @log() + async validateBranchOrTagName(ref: string, _repoPath?: string): Promise { + return validBranchOrTagRegex.test(ref); + } + + @log() + async validateReference(_repoPath: string, _ref: string): Promise { + return true; + } + + @log() + async stageFile(_repoPath: string, _pathOrUri: string | Uri): Promise {} + + @log() + async stageDirectory(_repoPath: string, _directoryOrUri: string | Uri): Promise {} + + @log() + async unStageFile(_repoPath: string, _pathOrUri: string | Uri): Promise {} + + @log() + async unStageDirectory(_repoPath: string, _directoryOrUri: string | Uri): Promise {} + + @log() + async stashApply(_repoPath: string, _stashName: string, _options?: { deleteAfter?: boolean }): Promise {} + + @log() + async stashDelete(_repoPath: string, _stashName: string, _ref?: string): Promise {} + + @log({ args: { 2: uris => uris?.length } }) + async stashSave( + _repoPath: string, + _message?: string, + _uris?: Uri[], + _options?: { includeUntracked?: boolean; keepIndex?: boolean }, + ): Promise {} + + @gate() + private async ensureRepositoryContext( + repoPath: string, + open?: boolean, + ): Promise<{ github: GitHubApi; metadata: Metadata; remotehub: RemoteHubApi; session: AuthenticationSession }> { + let uri = Uri.parse(repoPath, true); + if (!/^github\+?/.test(uri.authority)) { + throw new OpenVirtualRepositoryError(repoPath, OpenVirtualRepositoryErrorReason.NotAGitHubRepository); + } + + if (!open) { + const repo = this.container.git.getRepository(uri); + if (repo == null) { + throw new OpenVirtualRepositoryError(repoPath, OpenVirtualRepositoryErrorReason.NotAGitHubRepository); + } + + uri = repo.uri; + } + + let remotehub = this._remotehub; + if (remotehub == null) { + try { + remotehub = await this.ensureRemoteHubApi(); + } catch (ex) { + if (!(ex instanceof ExtensionNotFoundError)) { + debugger; + } + throw new OpenVirtualRepositoryError( + repoPath, + OpenVirtualRepositoryErrorReason.RemoteHubApiNotFound, + ex, + ); + } + } + + const metadata = await remotehub?.getMetadata(uri); + if (metadata?.provider.id !== 'github') { + throw new OpenVirtualRepositoryError(repoPath, OpenVirtualRepositoryErrorReason.NotAGitHubRepository); + } + + let github; + let session; + try { + [github, session] = await Promise.all([this.ensureGitHub(), this.ensureSession()]); + } catch (ex) { + debugger; + if (ex instanceof AuthenticationError) { + throw new OpenVirtualRepositoryError( + repoPath, + ex.reason === AuthenticationErrorReason.UserDidNotConsent + ? OpenVirtualRepositoryErrorReason.GitHubAuthenticationDenied + : OpenVirtualRepositoryErrorReason.GitHubAuthenticationNotFound, + ex, + ); + } + + throw new OpenVirtualRepositoryError(repoPath); + } + if (github == null) { + debugger; + throw new OpenVirtualRepositoryError(repoPath); + } + + return { github: github, metadata: metadata, remotehub: remotehub, session: session }; + } + + private _github: GitHubApi | undefined; + @gate() + private async ensureGitHub() { + if (this._github == null) { + const github = await this.container.github; + if (github != null) { + this._disposables.push( + github.onDidReauthenticate(() => { + this._sessionPromise = undefined; + void this.ensureSession(true); + }), + ); + } + this._github = github; + } + return this._github; + } + + /** Only use this if you NEED non-promise access to RemoteHub */ + private _remotehub: RemoteHubApi | undefined; + private _remotehubPromise: Promise | undefined; + private async ensureRemoteHubApi(): Promise; + private async ensureRemoteHubApi(silent: false): Promise; + private async ensureRemoteHubApi(silent: boolean): Promise; + private async ensureRemoteHubApi(silent?: boolean): Promise { + if (this._remotehubPromise == null) { + this._remotehubPromise = getRemoteHubApi(); + // Not a fan of this, but we need to be able to access RemoteHub without a promise + this._remotehubPromise.then( + api => (this._remotehub = api), + () => (this._remotehub = undefined), + ); + } + + if (!silent) return this._remotehubPromise; + + try { + return await this._remotehubPromise; + } catch { + return undefined; + } + } + + private _sessionPromise: Promise | undefined; + private async ensureSession(force: boolean = false): Promise { + if (this._sessionPromise == null) { + async function getSession(): Promise { + try { + if (force) { + return await authentication.getSession('github', githubAuthenticationScopes, { + forceNewSession: true, + }); + } + + return await authentication.getSession('github', githubAuthenticationScopes, { + createIfNone: true, + }); + } catch (ex) { + if (ex instanceof Error && ex.message.includes('User did not consent')) { + throw new AuthenticationError('github', AuthenticationErrorReason.UserDidNotConsent); + } + + Logger.error(ex); + debugger; + throw new AuthenticationError('github', undefined, ex); + } + } + + this._sessionPromise = getSession(); + } + + return this._sessionPromise; + } + + private createVirtualUri(base: string | Uri, ref?: GitReference | string, path?: string): Uri { + let metadata: GitHubAuthorityMetadata | undefined; + + if (typeof ref === 'string') { + if (ref) { + if (GitRevision.isSha(ref)) { + metadata = { v: 1, ref: { id: ref, type: 2 /* RepositoryRefType.Commit */ } }; + } else { + metadata = { v: 1, ref: { id: ref, type: 4 /* RepositoryRefType.Tree */ } }; + } + } + } else { + switch (ref?.refType) { + case 'revision': + case 'stash': + metadata = { v: 1, ref: { id: ref.ref, type: 2 /* RepositoryRefType.Commit */ } }; + break; + case 'branch': + case 'tag': + metadata = { v: 1, ref: { id: ref.name, type: 4 /* RepositoryRefType.Tree */ } }; + break; + } + } + + if (typeof base === 'string') { + base = Uri.parse(base, true); + } + + if (path) { + let basePath = base.path; + if (basePath.endsWith('/')) { + basePath = basePath.slice(0, -1); + } + + path = this.getRelativePath(path, base); + path = `${basePath}/${path.startsWith('/') ? path.slice(0, -1) : path}`; + } + + return base.with({ + scheme: Schemes.Virtual, + authority: encodeAuthority('github', metadata), + path: path ?? base.path, + }); + } + + private createProviderUri(base: string | Uri, ref?: GitReference | string, path?: string): Uri { + const uri = this.createVirtualUri(base, ref, path); + if (this._remotehub == null) { + debugger; + return uri.scheme !== Schemes.Virtual ? uri : uri.with({ scheme: Schemes.GitHub }); + } + + return this._remotehub.getProviderUri(uri); + } + + private getPagingLimit(limit?: number): number { + limit = Math.min(100, limit ?? this.container.config.advanced.maxListItems ?? 100); + if (limit === 0) { + limit = 100; + } + return limit; + } + + private async resolveReferenceCore( + repoPath: string, + metadata: Metadata, + ref?: string, + ): Promise { + if (ref == null || ref === 'HEAD') { + const revision = await metadata.getRevision(); + return revision.revision; + } + + if (GitRevision.isSha(ref)) return ref; + + // TODO@eamodio need to handle ranges + if (GitRevision.isRange(ref)) return undefined; + + const [branchResults, tagResults] = await Promise.allSettled([ + this.getBranches(repoPath, { filter: b => b.name === ref }), + this.getTags(repoPath, { filter: t => t.name === ref }), + ]); + + ref = + (branchResults.status === 'fulfilled' ? branchResults.value.values[0]?.sha : undefined) ?? + (tagResults.status === 'fulfilled' ? tagResults.value.values[0]?.sha : undefined); + if (ref == null) debugger; + + return ref; + } +} + +function encodeAuthority(scheme: string, metadata?: T): string { + return `${scheme}${metadata != null ? `+${encodeUtf8Hex(JSON.stringify(metadata))}` : ''}`; +} diff --git a/src/plus/remotehub.ts b/src/plus/remotehub.ts new file mode 100644 index 0000000..29429c8 --- /dev/null +++ b/src/plus/remotehub.ts @@ -0,0 +1,90 @@ +import { extensions, Uri } from 'vscode'; +import { ExtensionNotFoundError } from '../errors'; +import { Logger } from '../logger'; + +export async function getRemoteHubApi(): Promise; +export async function getRemoteHubApi(silent: false): Promise; +export async function getRemoteHubApi(silent: boolean): Promise; +export async function getRemoteHubApi(silent?: boolean): Promise { + try { + const extension = + extensions.getExtension('GitHub.remotehub') ?? + extensions.getExtension('GitHub.remotehub-insiders'); + if (extension == null) { + Logger.log('GitHub Repositories extension is not installed or enabled'); + throw new ExtensionNotFoundError('GitHub Repositories', 'GitHub.remotehub'); + } + + const api = extension.isActive ? extension.exports : await extension.activate(); + return api; + } catch (ex) { + Logger.error(ex, 'Unable to get required api from the GitHub Repositories extension'); + if (!(ex instanceof ExtensionNotFoundError)) { + debugger; + } + + if (silent) return undefined; + throw ex; + } +} + +export interface Provider { + readonly id: 'github' | 'azdo'; + readonly name: string; +} + +export enum HeadType { + Branch = 0, + RemoteBranch = 1, + Tag = 2, + Commit = 3, +} + +export interface Metadata { + readonly provider: Provider; + readonly repo: { owner: string; name: string } & Record; + getRevision(): Promise<{ type: HeadType; name: string; revision: string }>; +} + +// export type CreateUriOptions = Omit; + +export interface RemoteHubApi { + getMetadata(uri: Uri): Promise; + + // createProviderUri(provider: string, options: CreateUriOptions, path: string): Uri | undefined; + getProvider(uri: Uri): Provider | undefined; + getProviderUri(uri: Uri): Uri; + getProviderRootUri(uri: Uri): Uri; + isProviderUri(uri: Uri, provider?: string): boolean; + + // createVirtualUri(provider: string, options: CreateUriOptions, path: string): Uri | undefined; + getVirtualUri(uri: Uri): Uri; + getVirtualWorkspaceUri(uri: Uri): Uri | undefined; + + /** + * Returns whether RemoteHub has the full workspace contents for a vscode-vfs:// URI. + * This will download workspace contents if fetching full workspace contents is enabled + * for the requested URI and the contents are not already available locally. + * @param workspaceUri A vscode-vfs:// URI for a RemoteHub workspace folder. + * @returns boolean indicating whether the workspace contents were successfully loaded. + */ + loadWorkspaceContents(workspaceUri: Uri): Promise; +} + +export interface RepositoryRef { + type: RepositoryRefType; + id: string; +} + +export const enum RepositoryRefType { + Branch = 0, + Tag = 1, + Commit = 2, + PullRequest = 3, + Tree = 4, +} + +export interface GitHubAuthorityMetadata { + v: 1; + ref?: RepositoryRef; +} diff --git a/src/plus/subscription/authenticationProvider.ts b/src/plus/subscription/authenticationProvider.ts new file mode 100644 index 0000000..10e432b --- /dev/null +++ b/src/plus/subscription/authenticationProvider.ts @@ -0,0 +1,248 @@ +import { v4 as uuid } from 'uuid'; +import { + authentication, + AuthenticationProvider, + AuthenticationProviderAuthenticationSessionsChangeEvent, + AuthenticationSession, + Disposable, + EventEmitter, + window, +} from 'vscode'; +import type { Container } from '../../container'; +import { Logger } from '../../logger'; +import { debug } from '../../system/decorators/log'; +import { ServerConnection } from './serverConnection'; + +interface StoredSession { + id: string; + accessToken: string; + account?: { + label?: string; + displayName?: string; + id: string; + }; + scopes: string[]; +} + +const authenticationId = 'gitlens+'; +const authenticationLabel = 'GitLens+'; +const authenticationSecretKey = `gitlens.plus.auth`; + +export class SubscriptionAuthenticationProvider implements AuthenticationProvider, Disposable { + private _onDidChangeSessions = new EventEmitter(); + get onDidChangeSessions() { + return this._onDidChangeSessions.event; + } + + private readonly _disposable: Disposable; + private _sessionsPromise: Promise; + + constructor(private readonly container: Container, private readonly server: ServerConnection) { + // Contains the current state of the sessions we have available. + this._sessionsPromise = this.getSessionsFromStorage(); + + this._disposable = Disposable.from( + authentication.registerAuthenticationProvider(authenticationId, authenticationLabel, this, { + supportsMultipleAccounts: false, + }), + this.container.storage.onDidChangeSecrets(() => this.checkForUpdates()), + ); + } + + dispose() { + this._disposable.dispose(); + } + + @debug() + public async createSession(scopes: string[]): Promise { + const cc = Logger.getCorrelationContext(); + + // Ensure that the scopes are sorted consistently (since we use them for matching and order doesn't matter) + scopes = scopes.sort(); + const scopesKey = getScopesKey(scopes); + + try { + const token = await this.server.login(scopes, scopesKey); + const session = await this.createSessionForToken(token, scopes); + + const sessions = await this._sessionsPromise; + const sessionIndex = sessions.findIndex(s => s.id === session.id || getScopesKey(s.scopes) === scopesKey); + if (sessionIndex > -1) { + sessions.splice(sessionIndex, 1, session); + } else { + sessions.push(session); + } + await this.storeSessions(sessions); + + this._onDidChangeSessions.fire({ added: [session], removed: [], changed: [] }); + + return session; + } catch (ex) { + // If login was cancelled, do not notify user. + if (ex === 'Cancelled') throw ex; + + Logger.error(ex, cc); + void window.showErrorMessage(`Unable to sign in to GitLens+: ${ex}`); + throw ex; + } + } + + @debug() + async getSessions(scopes?: string[]): Promise { + const cc = Logger.getCorrelationContext(); + + scopes = scopes?.sort(); + const scopesKey = getScopesKey(scopes); + + const sessions = await this._sessionsPromise; + const filtered = scopes != null ? sessions.filter(s => getScopesKey(s.scopes) === scopesKey) : sessions; + + if (cc != null) { + cc.exitDetails = ` \u2022 Found ${filtered.length} sessions`; + } + + return filtered; + } + + @debug() + public async removeSession(id: string) { + const cc = Logger.getCorrelationContext(); + + try { + const sessions = await this._sessionsPromise; + const sessionIndex = sessions.findIndex(session => session.id === id); + if (sessionIndex === -1) { + Logger.log(`Unable to remove session ${id}; Not found`); + return; + } + + const session = sessions[sessionIndex]; + sessions.splice(sessionIndex, 1); + + await this.storeSessions(sessions); + + this._onDidChangeSessions.fire({ added: [], removed: [session], changed: [] }); + } catch (ex) { + Logger.error(ex, cc); + void window.showErrorMessage(`Unable to sign out: ${ex}`); + throw ex; + } + } + + private async checkForUpdates() { + const previousSessions = await this._sessionsPromise; + this._sessionsPromise = this.getSessionsFromStorage(); + const storedSessions = await this._sessionsPromise; + + const added: AuthenticationSession[] = []; + const removed: AuthenticationSession[] = []; + + for (const session of storedSessions) { + if (previousSessions.some(s => s.id === session.id)) continue; + + // Another window added a session, so let our window know about it + added.push(session); + } + + for (const session of previousSessions) { + if (storedSessions.some(s => s.id === session.id)) continue; + + // Another window has removed this session (or logged out), so let our window know about it + removed.push(session); + } + + if (added.length || removed.length) { + Logger.debug(`Firing sessions changed event; added=${added.length}, removed=${removed.length}`); + this._onDidChangeSessions.fire({ added: added, removed: removed, changed: [] }); + } + } + + private async createSessionForToken(token: string, scopes: string[]): Promise { + const userInfo = await this.server.getAccountInfo(token); + return { + id: uuid(), + accessToken: token, + account: { label: userInfo.accountName, id: userInfo.id }, + scopes: scopes, + }; + } + + private async getSessionsFromStorage(): Promise { + let storedSessions: StoredSession[]; + + try { + const sessionsJSON = await this.container.storage.getSecret(authenticationSecretKey); + if (!sessionsJSON || sessionsJSON === '[]') return []; + + try { + storedSessions = JSON.parse(sessionsJSON); + } catch (ex) { + try { + await this.container.storage.deleteSecret(authenticationSecretKey); + } catch {} + + throw ex; + } + } catch (ex) { + Logger.error(ex, 'Unable to read sessions from storage'); + return []; + } + + const sessionPromises = storedSessions.map(async (session: StoredSession) => { + const scopesKey = getScopesKey(session.scopes); + + Logger.debug(`Read session from storage with scopes=${scopesKey}`); + + let userInfo: { id: string; accountName: string } | undefined; + if (session.account == null) { + try { + userInfo = await this.server.getAccountInfo(session.accessToken); + Logger.debug(`Verified session with scopes=${scopesKey}`); + } catch (ex) { + // Remove sessions that return unauthorized response + if (ex.message === 'Unauthorized') return undefined; + } + } + + return { + id: session.id, + account: { + label: + session.account != null + ? session.account.label ?? session.account.displayName ?? '' + : userInfo?.accountName ?? '', + id: session.account?.id ?? userInfo?.id ?? '', + }, + scopes: session.scopes, + accessToken: session.accessToken, + }; + }); + + const verifiedSessions = (await Promise.allSettled(sessionPromises)) + .filter(p => p.status === 'fulfilled') + .map(p => (p as PromiseFulfilledResult).value) + .filter((p?: T): p is T => Boolean(p)); + + Logger.debug(`Found ${verifiedSessions.length} verified sessions`); + if (verifiedSessions.length !== storedSessions.length) { + await this.storeSessions(verifiedSessions); + } + return verifiedSessions; + } + + private async storeSessions(sessions: AuthenticationSession[]): Promise { + try { + this._sessionsPromise = Promise.resolve(sessions); + await this.container.storage.storeSecret(authenticationSecretKey, JSON.stringify(sessions)); + } catch (ex) { + Logger.error(ex, `Unable to store ${sessions.length} sessions`); + } + } +} + +function getScopesKey(scopes: readonly string[]): string; +function getScopesKey(scopes: undefined): string | undefined; +function getScopesKey(scopes: readonly string[] | undefined): string | undefined; +function getScopesKey(scopes: readonly string[] | undefined): string | undefined { + return scopes?.join('|'); +} diff --git a/src/plus/subscription/serverConnection.ts b/src/plus/subscription/serverConnection.ts new file mode 100644 index 0000000..cbe857f --- /dev/null +++ b/src/plus/subscription/serverConnection.ts @@ -0,0 +1,183 @@ +import { v4 as uuid } from 'uuid'; +import { Disposable, env, EventEmitter, StatusBarAlignment, StatusBarItem, Uri, UriHandler, window } from 'vscode'; +import { fetch, Response } from '@env/fetch'; +import { Container } from '../../container'; +import { Logger } from '../../logger'; +import { debug, log } from '../../system/decorators/log'; +import { memoize } from '../../system/decorators/memoize'; +import { DeferredEvent, DeferredEventExecutor, promisifyDeferred } from '../../system/event'; + +interface AccountInfo { + id: string; + accountName: string; +} + +export class ServerConnection implements Disposable { + private _deferredCodeExchanges = new Map>(); + private _disposable: Disposable; + private _pendingStates = new Map(); + private _statusBarItem: StatusBarItem | undefined; + private _uriHandler = new UriEventHandler(); + + constructor(private readonly container: Container) { + this._disposable = window.registerUriHandler(this._uriHandler); + } + + dispose() { + this._disposable.dispose(); + } + + @memoize() + private get baseApiUri(): Uri { + if (this.container.env === 'staging') { + return Uri.parse('https://stagingapi.gitkraken.com'); + } + + if (this.container.env === 'dev') { + return Uri.parse('https://devapi.gitkraken.com'); + } + + return Uri.parse('https://api.gitkraken.com'); + } + + @memoize() + private get baseAccountUri(): Uri { + if (this.container.env === 'staging') { + return Uri.parse('https://stagingaccount.gitkraken.com'); + } + + if (this.container.env === 'dev') { + return Uri.parse('https://devaccount.gitkraken.com'); + } + + return Uri.parse('https://account.gitkraken.com'); + } + + @debug({ args: false }) + public async getAccountInfo(token: string): Promise { + const cc = Logger.getCorrelationContext(); + + let rsp: Response; + try { + rsp = await fetch(Uri.joinPath(this.baseApiUri, 'user').toString(), { + headers: { + Authorization: `Bearer ${token}`, + // TODO: What user-agent should we use? + 'User-Agent': 'Visual-Studio-Code-GitLens', + }, + }); + } catch (ex) { + Logger.error(ex, cc); + throw ex; + } + + if (!rsp.ok) { + Logger.error(undefined, `Getting account info failed: (${rsp.status}) ${rsp.statusText}`); + throw new Error(rsp.statusText); + } + + const json: { id: string; username: string } = await rsp.json(); + return { id: json.id, accountName: json.username }; + } + + @debug() + public async login(scopes: string[], scopeKey: string): Promise { + this.updateStatusBarItem(true); + + // Include a state parameter here to prevent CSRF attacks + const gkstate = uuid(); + const existingStates = this._pendingStates.get(scopeKey) ?? []; + this._pendingStates.set(scopeKey, [...existingStates, gkstate]); + + const callbackUri = await env.asExternalUri( + Uri.parse(`${env.uriScheme}://${this.container.context.extension.id}/did-authenticate?gkstate=${gkstate}`), + ); + + const uri = Uri.joinPath(this.baseAccountUri, 'register').with({ + query: `${ + scopes.includes('gitlens') ? 'referrer=gitlens&' : '' + }pass-token=true&return-url=${encodeURIComponent(callbackUri.toString())}`, + }); + void (await env.openExternal(uri)); + + // Ensure there is only a single listener for the URI callback, in case the user starts the login process multiple times before completing it + let deferredCodeExchange = this._deferredCodeExchanges.get(scopeKey); + if (deferredCodeExchange == null) { + deferredCodeExchange = promisifyDeferred( + this._uriHandler.event, + this.getUriHandlerDeferredExecutor(scopeKey), + ); + this._deferredCodeExchanges.set(scopeKey, deferredCodeExchange); + } + + return Promise.race([ + deferredCodeExchange.promise, + new Promise((_, reject) => setTimeout(() => reject('Cancelled'), 60000)), + ]).finally(() => { + this._pendingStates.delete(scopeKey); + deferredCodeExchange?.cancel(); + this._deferredCodeExchanges.delete(scopeKey); + this.updateStatusBarItem(false); + }); + } + + private getUriHandlerDeferredExecutor(_scopeKey: string): DeferredEventExecutor { + return (uri: Uri, resolve, reject) => { + // TODO: We should really support a code to token exchange, but just return the token from the query string + // await this.exchangeCodeForToken(uri.query); + // As the backend still doesn't implement yet the code to token exchange, we just validate the state returned + const query = parseQuery(uri); + + const acceptedStates = this._pendingStates.get(_scopeKey); + + if (acceptedStates == null || !acceptedStates.includes(query.gkstate)) { + // A common scenario of this happening is if you: + // 1. Trigger a sign in with one set of scopes + // 2. Before finishing 1, you trigger a sign in with a different set of scopes + // In this scenario we should just return and wait for the next UriHandler event + // to run as we are probably still waiting on the user to hit 'Continue' + Logger.log('State not found in accepted state. Skipping this execution...'); + return; + } + + const token = query['access-token']; + if (token == null) { + reject('Token not returned'); + } else { + resolve(token); + } + }; + } + + private updateStatusBarItem(signingIn?: boolean) { + if (signingIn && this._statusBarItem == null) { + this._statusBarItem = window.createStatusBarItem( + 'gitkraken-authentication.signIn', + StatusBarAlignment.Left, + ); + this._statusBarItem.name = 'GitKraken Sign-in'; + this._statusBarItem.text = 'Signing into gitkraken.com...'; + this._statusBarItem.show(); + } + + if (!signingIn && this._statusBarItem != null) { + this._statusBarItem.dispose(); + this._statusBarItem = undefined; + } + } +} + +class UriEventHandler extends EventEmitter implements UriHandler { + @log() + public handleUri(uri: Uri) { + this.fire(uri); + } +} + +function parseQuery(uri: Uri): Record { + return uri.query.split('&').reduce((prev, current) => { + const queryString = current.split('='); + prev[queryString[0]] = queryString[1]; + return prev; + }, {} as Record); +} diff --git a/src/plus/subscription/subscriptionService.ts b/src/plus/subscription/subscriptionService.ts new file mode 100644 index 0000000..2504617 --- /dev/null +++ b/src/plus/subscription/subscriptionService.ts @@ -0,0 +1,863 @@ +import { + authentication, + AuthenticationSession, + AuthenticationSessionsChangeEvent, + version as codeVersion, + commands, + Disposable, + env, + Event, + EventEmitter, + MarkdownString, + MessageItem, + StatusBarAlignment, + StatusBarItem, + ThemeColor, + Uri, + window, +} from 'vscode'; +import { fetch } from '@env/fetch'; +import { getPlatform } from '@env/platform'; +import { configuration } from '../../configuration'; +import { Commands, ContextKeys } from '../../constants'; +import type { Container } from '../../container'; +import { setContext } from '../../context'; +import { AccountValidationError } from '../../errors'; +import { RepositoriesChangeEvent } from '../../git/gitProviderService'; +import { Logger } from '../../logger'; +import { StorageKeys, WorkspaceStorageKeys } from '../../storage'; +import { + computeSubscriptionState, + getSubscriptionPlan, + getSubscriptionPlanPriority, + getSubscriptionTimeRemaining, + getTimeRemaining, + isSubscriptionExpired, + isSubscriptionPaidPlan, + isSubscriptionTrial, + Subscription, + SubscriptionPlanId, + SubscriptionState, +} from '../../subscription'; +import { executeCommand } from '../../system/command'; +import { createFromDateDelta } from '../../system/date'; +import { gate } from '../../system/decorators/gate'; +import { debug, log } from '../../system/decorators/log'; +import { memoize } from '../../system/decorators/memoize'; +import { once } from '../../system/function'; +import { pluralize } from '../../system/string'; +import { openWalkthrough } from '../../system/utils'; +import { ensurePlusFeaturesEnabled } from './utils'; + +// TODO: What user-agent should we use? +const userAgent = 'Visual-Studio-Code-GitLens'; + +export interface SubscriptionChangeEvent { + readonly current: Subscription; + readonly previous: Subscription; + readonly etag: number; +} + +export class SubscriptionService implements Disposable { + private static authenticationProviderId = 'gitlens+'; + 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 = Disposable.from( + once(container.onReady)(this.onReady, this), + authentication.onDidChangeSessions(this.onAuthenticationChanged, this), + ); + + this.changeSubscription(this.getStoredSubscription(), true); + setTimeout(() => void this.ensureSession(false), 10000); + } + + dispose(): void { + this._statusBarSubscription?.dispose(); + + this._disposable.dispose(); + } + + private onAuthenticationChanged(e: AuthenticationSessionsChangeEvent): void { + if (e.provider.id !== SubscriptionService.authenticationProviderId) return; + + void this.ensureSession(false, true); + } + + @memoize() + private get baseApiUri(): Uri { + const { env } = this.container; + if (env === 'staging') { + return Uri.parse('https://stagingapi.gitkraken.com'); + } + + if (env === 'dev') { + 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') { + 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') { + return Uri.parse('https://dev.gitkraken.com'); + } + + return Uri.parse('https://gitkraken.com'); + } + + private get connectedKey(): `${WorkspaceStorageKeys.ConnectedPrefix}${string}` { + return `${WorkspaceStorageKeys.ConnectedPrefix}gitkraken`; + } + + 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(Commands.PlusLearn, openToSide => this.learn(openToSide)), + commands.registerCommand(Commands.PlusLoginOrSignUp, () => this.loginOrSignUp()), + commands.registerCommand(Commands.PlusLogout, () => this.logout()), + + commands.registerCommand(Commands.PlusStartPreviewTrial, () => this.startPreviewTrial()), + commands.registerCommand(Commands.PlusManage, () => this.manage()), + commands.registerCommand(Commands.PlusPurchase, () => this.purchase()), + + commands.registerCommand(Commands.PlusResendVerification, () => this.resendVerification()), + commands.registerCommand(Commands.PlusValidate, () => this.validate()), + + commands.registerCommand(Commands.PlusShowPlans, () => this.showPlans()), + + commands.registerCommand(Commands.PlusHide, () => + configuration.updateEffective('plusFeatures.enabled', false), + ), + commands.registerCommand(Commands.PlusRestore, () => + configuration.updateEffective('plusFeatures.enabled', true), + ), + + commands.registerCommand('gitlens.plus.reset', () => this.logout(true)), + ]; + } + + async getSubscription(): Promise { + void (await this.ensureSession(false)); + return this._subscription; + } + + @debug() + learn(openToSide: boolean = true): void { + void openWalkthrough(this.container.context.extension.id, 'gitlens.plus', undefined, openToSide); + } + + @gate() + @log() + async loginOrSignUp(): Promise { + if (!(await ensurePlusFeaturesEnabled())) return false; + + void this.showHomeView(); + + await this.container.storage.deleteWorkspace(this.connectedKey); + + const session = await this.ensureSession(true); + const loggedIn = Boolean(session); + if (loggedIn) { + const { + account, + plan: { actual, effective }, + } = this._subscription; + + if (account?.verified === false) { + const confirm: MessageItem = { title: 'Resend Verification', isCloseAffordance: true }; + const cancel: MessageItem = { title: 'Cancel' }; + const result = await window.showInformationMessage( + `Before you can access your ${actual.name} account, you must verify your email address.`, + confirm, + cancel, + ); + + if (result === confirm) { + void this.resendVerification(); + } + } else if (isSubscriptionTrial(this._subscription)) { + const remaining = getSubscriptionTimeRemaining(this._subscription, 'days'); + + const confirm: MessageItem = { title: 'OK', isCloseAffordance: true }; + const learn: MessageItem = { title: 'Learn More' }; + const result = await window.showInformationMessage( + `You are now signed in to your ${ + actual.name + } account which gives you access to GitLens+ features on public repos.\n\nYou were also granted a trial of ${ + effective.name + } for both public and private repos for ${pluralize('more day', remaining ?? 0)}.`, + { modal: true }, + confirm, + learn, + ); + + if (result === learn) { + void this.learn(); + } + } else { + void window.showInformationMessage(`You are now signed in to your ${actual.name} account.`, 'OK'); + } + } + return loggedIn; + } + + @gate() + @log() + logout(reset: boolean = false): void { + this._sessionPromise = undefined; + this._session = undefined; + void this.container.storage.storeWorkspace(this.connectedKey, false); + + if (reset && this.container.debugging) { + this.changeSubscription(undefined); + + return; + } + + this.changeSubscription({ + ...this._subscription, + plan: { + actual: getSubscriptionPlan(SubscriptionPlanId.Free), + effective: getSubscriptionPlan(SubscriptionPlanId.Free), + }, + account: undefined, + }); + } + + @log() + manage(): void { + void env.openExternal(this.baseAccountUri); + } + + @log() + async purchase(): Promise { + if (!(await ensurePlusFeaturesEnabled())) return; + + if (this._subscription.account == null) { + void this.showPlans(); + } else { + void env.openExternal( + Uri.joinPath(this.baseAccountUri, 'create-organization').with({ query: 'product=gitlens' }), + ); + } + await this.showHomeView(); + } + + @gate() + @log() + async resendVerification(): Promise { + if (this._subscription.account?.verified) return; + + const cc = Logger.getCorrelationContext(); + + void this.showHomeView(true); + + const session = await this.ensureSession(false); + if (session == null) return; + + try { + const rsp = await fetch(Uri.joinPath(this.baseApiUri, 'resend-email').toString(), { + method: 'POST', + headers: { + Authorization: `Bearer ${session.accessToken}`, + 'User-Agent': userAgent, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ id: session.account.id }), + }); + + if (!rsp.ok) { + debugger; + Logger.error('', cc, `Unable to resend verification email; status=(${rsp.status}): ${rsp.statusText}`); + + void window.showErrorMessage(`Unable to resend verification email; Status: ${rsp.statusText}`, 'OK'); + + return; + } + + const confirm = { title: 'Recheck' }; + const cancel = { title: 'Cancel' }; + const result = await window.showInformationMessage( + "Once you have verified your email address, click 'Recheck'.", + confirm, + cancel, + ); + if (result === confirm) { + await this.validate(); + } + } catch (ex) { + Logger.error(ex, cc); + debugger; + + void window.showErrorMessage('Unable to resend verification email', 'OK'); + } + } + + @log() + async showHomeView(silent: boolean = false): Promise { + if (silent && !configuration.get('plusFeatures.enabled', undefined, true)) return; + + if (!this.container.homeView.visible) { + await executeCommand(Commands.ShowHomeView); + } + } + + private showPlans(): void { + void env.openExternal(Uri.joinPath(this.baseSiteUri, 'gitlens/pricing')); + } + + @gate() + @log() + async startPreviewTrial(): Promise { + if (!(await ensurePlusFeaturesEnabled())) return; + + let { plan, previewTrial } = this._subscription; + if (previewTrial != null || plan.effective.id !== SubscriptionPlanId.Free) { + void this.showHomeView(); + + if (plan.effective.id === SubscriptionPlanId.Free) { + const confirm: MessageItem = { title: 'Sign in to GitLens+', isCloseAffordance: true }; + const cancel: MessageItem = { title: 'Cancel' }; + const result = await window.showInformationMessage( + 'Your GitLens+ features trial has ended.\nPlease sign in to use GitLens+ features on public repos and get a free 7-day trial for both public and private repos.', + { modal: true }, + confirm, + cancel, + ); + + if (result === confirm) { + void this.loginOrSignUp(); + } + } + return; + } + + const startedOn = new Date(); + + let days; + let expiresOn = new Date(startedOn); + if (!this.container.debugging) { + // Normalize the date to just before midnight on the same day + expiresOn.setHours(23, 59, 59, 999); + expiresOn = createFromDateDelta(expiresOn, { days: 3 }); + days = 3; + } else { + expiresOn = createFromDateDelta(expiresOn, { minutes: 1 }); + days = 0; + } + + previewTrial = { + startedOn: startedOn.toISOString(), + expiresOn: expiresOn.toISOString(), + }; + + this.changeSubscription({ + ...this._subscription, + plan: { + ...this._subscription.plan, + effective: getSubscriptionPlan(SubscriptionPlanId.Pro, startedOn, expiresOn), + }, + previewTrial: previewTrial, + }); + + const confirm: MessageItem = { title: 'OK', isCloseAffordance: true }; + const learn: MessageItem = { title: 'Learn More' }; + const result = await window.showInformationMessage( + `You have started a ${days} day trial of GitLens+ features for both public and private repos.`, + { modal: true }, + confirm, + learn, + ); + + if (result === learn) { + void this.learn(); + } + } + + @gate() + @log() + async validate(): Promise { + const cc = Logger.getCorrelationContext(); + + const session = await this.ensureSession(false); + if (session == null) { + this.changeSubscription(this._subscription); + return; + } + + try { + await this.checkInAndValidate(session); + } catch (ex) { + Logger.error(ex, cc); + debugger; + } + } + + private _lastCheckInDate: Date | undefined; + @debug({ args: { 0: s => s?.account.label } }) + private async checkInAndValidate(session: AuthenticationSession): Promise { + const cc = Logger.getCorrelationContext(); + + try { + const checkInData = { + id: session.account.id, + platform: getPlatform(), + gitlensVersion: this.container.version, + vscodeEdition: env.appName, + vscodeHost: env.appHost, + vscodeVersion: codeVersion, + previewStartedOn: this._subscription.previewTrial?.startedOn, + previewExpiresOn: this._subscription.previewTrial?.expiresOn, + }; + + const rsp = await fetch(Uri.joinPath(this.baseApiUri, 'gitlens/checkin').toString(), { + method: 'POST', + headers: { + Authorization: `Bearer ${session.accessToken}`, + 'User-Agent': userAgent, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(checkInData), + }); + + if (!rsp.ok) { + throw new AccountValidationError('Unable to validate account', undefined, rsp.status, rsp.statusText); + } + + const data: GKLicenseInfo = await rsp.json(); + this.validateSubscription(data); + this._lastCheckInDate = new Date(); + } catch (ex) { + Logger.error(ex, cc); + debugger; + if (ex instanceof AccountValidationError) throw ex; + + throw new AccountValidationError('Unable to validate account', ex); + } finally { + this.startDailyCheckInTimer(); + } + } + + private _dailyCheckInTimer: ReturnType | undefined; + private startDailyCheckInTimer(): void { + if (this._dailyCheckInTimer != null) { + clearInterval(this._dailyCheckInTimer); + } + + // Check twice a day to ensure we check in at least once a day + this._dailyCheckInTimer = setInterval(() => { + if (this._lastCheckInDate == null || this._lastCheckInDate.getDate() !== new Date().getDate()) { + void this.ensureSession(false, true); + } + }, 1000 * 60 * 60 * 12); + } + + @debug() + 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({ + ...this._subscription, + plan: { + actual: actual, + effective: effective, + }, + account: account, + }); + } + + private _sessionPromise: Promise | undefined; + private _session: AuthenticationSession | null | undefined; + + @gate() + @debug() + private async ensureSession(createIfNeeded: boolean, force?: boolean): Promise { + if (this._sessionPromise != null && this._session === undefined) { + this._session = await this._sessionPromise; + this._sessionPromise = undefined; + } + + if (!force && this._session != null) return this._session; + if (this._session === null && !createIfNeeded) return undefined; + + if (createIfNeeded) { + await this.container.storage.deleteWorkspace(this.connectedKey); + } else if (this.container.storage.getWorkspace(this.connectedKey) === false) { + return undefined; + } + + if (this._sessionPromise === undefined) { + this._sessionPromise = this.getOrCreateSession(createIfNeeded); + } + + this._session = await this._sessionPromise; + this._sessionPromise = undefined; + return this._session ?? undefined; + } + + @debug() + private async getOrCreateSession(createIfNeeded: boolean): Promise { + const cc = Logger.getCorrelationContext(); + + let session: AuthenticationSession | null | undefined; + + 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; + } + + Logger.error(ex, cc); + } + + if (session == null) { + this.logout(); + return session ?? null; + } + + try { + await this.checkInAndValidate(session); + } catch (ex) { + Logger.error(ex, cc); + debugger; + + const name = session.account.label; + session = null; + if (ex instanceof AccountValidationError) { + this.logout(); + + if (createIfNeeded) { + void window.showErrorMessage( + `Unable to sign in to your GitLens+ account. Please try again. If this issue persists, please contact support. Account=${name} Error=${ex.message}`, + 'OK', + ); + } + } + } + + return session; + } + + @debug() + 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.previewTrial != null && + (getTimeRemaining(subscription.previewTrial.expiresOn) ?? 0) > 0 + ) { + (subscription.plan as PickMutable).effective = getSubscriptionPlan( + SubscriptionPlanId.Pro, + new Date(subscription.previewTrial.startedOn), + new Date(subscription.previewTrial.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, etag: this._etag }); + } + } + + private getStoredSubscription(): Subscription | undefined { + const storedSubscription = this.container.storage.get>(StorageKeys.Subscription); + return storedSubscription?.data; + } + + private async storeSubscription(subscription: Subscription): Promise { + return this.container.storage.store>(StorageKeys.Subscription, { + v: 1, + data: subscription, + }); + } + + private updateContext(): void { + void this.updateStatusBar(); + + queueMicrotask(async () => { + const { allowed, subscription } = await this.container.git.access(); + const required = allowed + ? false + : subscription.required != null && isSubscriptionPaidPlan(subscription.required) + ? 'paid' + : 'free+'; + void setContext(ContextKeys.PlusAllowed, allowed); + void setContext(ContextKeys.PlusRequired, required); + }); + + const { + plan: { actual }, + state, + } = this._subscription; + + void setContext(ContextKeys.Plus, actual.id != SubscriptionPlanId.Free ? actual.id : undefined); + void setContext(ContextKeys.PlusState, state); + } + + private updateStatusBar(): void { + const { + account, + plan: { effective }, + } = this._subscription; + + if (effective.id === SubscriptionPlanId.Free) { + this._statusBarSubscription?.dispose(); + this._statusBarSubscription = undefined; + return; + } + + const trial = isSubscriptionTrial(this._subscription); + if (!trial && account?.verified !== false) { + this._statusBarSubscription?.dispose(); + this._statusBarSubscription = undefined; + return; + } + + if (this._statusBarSubscription == null) { + this._statusBarSubscription = window.createStatusBarItem( + 'gitlens.subscription', + StatusBarAlignment.Left, + 1, + ); + } + + this._statusBarSubscription.name = 'GitLens Subscription'; + this._statusBarSubscription.command = Commands.ShowHomeView; + + if (account?.verified === false) { + this._statusBarSubscription.text = `$(warning) ${effective.name} (Unverified)`; + this._statusBarSubscription.backgroundColor = new ThemeColor('statusBarItem.warningBackground'); + this._statusBarSubscription.tooltip = new MarkdownString( + trial + ? `**Please verify your email**\n\nBefore you can start your **${effective.name}** trial, please verify the email for the account you created.\n\nClick for details` + : `**Please verify your email**\n\nBefore you can use GitLens+ features, please verify the email for the account you created.\n\nClick for details`, + true, + ); + } else { + const remaining = getSubscriptionTimeRemaining(this._subscription, 'days'); + + this._statusBarSubscription.text = `${effective.name} (Trial)`; + this._statusBarSubscription.tooltip = new MarkdownString( + `You are currently trialing **${ + effective.name + }**, which gives you access to GitLens+ features on both public and private repos. You have ${pluralize( + 'day', + remaining ?? 0, + )} remaining in your trial.\n\nClick for details`, + true, + ); + } + + 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/plus/subscription/utils.ts b/src/plus/subscription/utils.ts new file mode 100644 index 0000000..00dcb62 --- /dev/null +++ b/src/plus/subscription/utils.ts @@ -0,0 +1,20 @@ +import { MessageItem, window } from 'vscode'; +import { configuration } from '../../configuration'; + +export async function ensurePlusFeaturesEnabled(): Promise { + if (configuration.get('plusFeatures.enabled', undefined, true)) return true; + + const confirm: MessageItem = { title: 'Enable' }; + const cancel: MessageItem = { title: 'Cancel', isCloseAffordance: true }; + const result = await window.showInformationMessage( + 'GitLens+ features are currently disabled. Would you like to enable them?', + { modal: true }, + confirm, + cancel, + ); + + if (result !== confirm) return false; + + void (await configuration.updateEffective('plusFeatures.enabled', true)); + return true; +} diff --git a/src/plus/webviews/timeline/protocol.ts b/src/plus/webviews/timeline/protocol.ts new file mode 100644 index 0000000..13cd453 --- /dev/null +++ b/src/plus/webviews/timeline/protocol.ts @@ -0,0 +1,44 @@ +import type { FeatureAccess } from '../../../features'; +import { IpcCommandType, IpcNotificationType } from '../../../webviews/protocol'; + +export interface State { + dataset?: Commit[]; + period: Period; + title: string; + uri?: string; + + dateFormat: string; + access: FeatureAccess; +} + +export interface Commit { + commit: string; + author: string; + date: string; + message: string; + + additions: number | undefined; + deletions: number | undefined; + + sort: number; +} + +export type Period = `${number}|${'D' | 'M' | 'Y'}`; + +export interface DidChangeStateParams { + state: State; +} +export const DidChangeStateNotificationType = new IpcNotificationType('timeline/data/didChange'); + +export interface OpenDataPointParams { + data?: { + id: string; + selected: boolean; + }; +} +export const OpenDataPointCommandType = new IpcCommandType('timeline/point/click'); + +export interface UpdatePeriodParams { + period: Period; +} +export const UpdatePeriodCommandType = new IpcCommandType('timeline/period/update'); diff --git a/src/plus/webviews/timeline/timelineWebview.ts b/src/plus/webviews/timeline/timelineWebview.ts new file mode 100644 index 0000000..280c987 --- /dev/null +++ b/src/plus/webviews/timeline/timelineWebview.ts @@ -0,0 +1,399 @@ +'use strict'; +import { commands, Disposable, TextEditor, Uri, ViewColumn, window } from 'vscode'; +import type { ShowQuickCommitCommandArgs } from '../../../commands'; +import { configuration } from '../../../configuration'; +import { Commands, ContextKeys } from '../../../constants'; +import type { Container } from '../../../container'; +import { setContext } from '../../../context'; +import { PlusFeatures } from '../../../features'; +import { GitUri } from '../../../git/gitUri'; +import { RepositoryChange, RepositoryChangeComparisonMode, RepositoryChangeEvent } from '../../../git/models'; +import { createFromDateDelta } from '../../../system/date'; +import { debug } from '../../../system/decorators/log'; +import { debounce, Deferrable } from '../../../system/function'; +import { filter } from '../../../system/iterable'; +import { hasVisibleTextEditor, isTextEditor } from '../../../system/utils'; +import { IpcMessage, onIpc } from '../../../webviews/protocol'; +import { WebviewBase } from '../../../webviews/webviewBase'; +import type { SubscriptionChangeEvent } from '../../subscription/subscriptionService'; +import { ensurePlusFeaturesEnabled } from '../../subscription/utils'; +import { + Commit, + DidChangeStateNotificationType, + OpenDataPointCommandType, + Period, + State, + UpdatePeriodCommandType, +} from './protocol'; +import { generateRandomTimelineDataset } from './timelineWebviewView'; + +interface Context { + uri: Uri | undefined; + period: Period | undefined; + etagRepository: number | undefined; + etagSubscription: number | undefined; +} + +const defaultPeriod: Period = '3|M'; + +export class TimelineWebview extends WebviewBase { + private _bootstraping = true; + /** The context the webview has */ + private _context: Context; + /** The context the webview should have */ + private _pendingContext: Partial | undefined; + private _originalTitle: string; + + constructor(container: Container) { + super( + container, + 'gitlens.timeline', + 'timeline.html', + 'images/gitlens-icon.png', + 'Visual File History', + Commands.ShowTimelinePage, + ); + this._originalTitle = this.title; + this._context = { + uri: undefined, + period: defaultPeriod, + etagRepository: 0, + etagSubscription: 0, + }; + } + + override async show(column: ViewColumn = ViewColumn.Beside): Promise { + if (!(await ensurePlusFeaturesEnabled())) return; + return super.show(column); + } + + protected override onInitializing(): Disposable[] | undefined { + this._context = { + uri: undefined, + period: defaultPeriod, + etagRepository: 0, + etagSubscription: this.container.subscription.etag, + }; + + this.updatePendingEditor(window.activeTextEditor); + this._context = { ...this._context, ...this._pendingContext }; + this._pendingContext = undefined; + + return [ + this.container.subscription.onDidChange(this.onSubscriptionChanged, this), + this.container.git.onDidChangeRepository(this.onRepositoryChanged, this), + ]; + } + + protected override onShowCommand(uri?: Uri): void { + if (uri != null) { + this.updatePendingUri(uri); + } else { + this.updatePendingEditor(window.activeTextEditor); + } + this._context = { ...this._context, ...this._pendingContext }; + this._pendingContext = undefined; + + super.onShowCommand(); + } + + protected override async includeBootstrap(): Promise { + this._bootstraping = true; + + this._context = { ...this._context, ...this._pendingContext }; + this._pendingContext = undefined; + + return this.getState(this._context); + } + + protected override registerCommands(): Disposable[] { + return [commands.registerCommand(Commands.RefreshTimelinePage, () => this.refresh())]; + } + + protected override onFocusChanged(focused: boolean): void { + if (focused) { + // If we are becoming focused, delay it a bit to give the UI time to update + setTimeout(() => void setContext(ContextKeys.TimelinePageFocused, focused), 0); + return; + } + + void setContext(ContextKeys.TimelinePageFocused, focused); + } + + protected override onVisibilityChanged(visible: boolean) { + if (!visible) return; + + // Since this gets called even the first time the webview is shown, avoid sending an update, because the bootstrap has the data + if (this._bootstraping) { + this._bootstraping = false; + + // If the uri changed since bootstrap still send the update + if (this._pendingContext == null || !('uri' in this._pendingContext)) { + return; + } + } + + // Should be immediate, but it causes the bubbles to go missing on the chart, since the update happens while it still rendering + this.updateState(); + } + + protected override onMessageReceived(e: IpcMessage) { + switch (e.method) { + case OpenDataPointCommandType.method: + onIpc(OpenDataPointCommandType, e, params => { + if (params.data == null || !params.data.selected || this._context.uri == null) return; + + const repository = this.container.git.getRepository(this._context.uri); + if (repository == null) return; + + const commandArgs: ShowQuickCommitCommandArgs = { + repoPath: repository.path, + sha: params.data.id, + }; + + void commands.executeCommand(Commands.ShowQuickCommit, commandArgs); + + // const commandArgs: DiffWithPreviousCommandArgs = { + // line: 0, + // showOptions: { + // preserveFocus: true, + // preview: true, + // viewColumn: ViewColumn.Beside, + // }, + // }; + + // void commands.executeCommand( + // Commands.DiffWithPrevious, + // new GitUri(gitUri, { repoPath: gitUri.repoPath!, sha: params.data.id }), + // commandArgs, + // ); + }); + + break; + + case UpdatePeriodCommandType.method: + onIpc(UpdatePeriodCommandType, e, params => { + if (this.updatePendingContext({ period: params.period })) { + this.updateState(true); + } + }); + + break; + } + } + + @debug({ args: false }) + private onRepositoryChanged(e: RepositoryChangeEvent) { + if (!e.changed(RepositoryChange.Heads, RepositoryChange.Index, RepositoryChangeComparisonMode.Any)) { + return; + } + + if (this.updatePendingContext({ etagRepository: e.repository.etag })) { + this.updateState(); + } + } + + @debug({ args: false }) + private onSubscriptionChanged(e: SubscriptionChangeEvent) { + if (this.updatePendingContext({ etagSubscription: e.etag })) { + this.updateState(); + } + } + + @debug({ args: false }) + private async getState(current: Context): Promise { + const access = await this.container.git.access(PlusFeatures.Timeline); + const dateFormat = this.container.config.defaultDateFormat ?? 'MMMM Do, YYYY h:mma'; + const period = current.period ?? defaultPeriod; + + if (!access.allowed) { + const dataset = generateRandomTimelineDataset(); + return { + dataset: dataset.sort((a, b) => b.sort - a.sort), + period: period, + title: 'src/app/index.ts', + uri: Uri.file('src/app/index.ts').toString(), + dateFormat: dateFormat, + access: access, + }; + } + + if (current.uri == null) { + return { + period: period, + title: 'There are no editors open that can provide file history information', + dateFormat: dateFormat, + access: access, + }; + } + + const gitUri = await GitUri.fromUri(current.uri); + const repoPath = gitUri.repoPath!; + const title = gitUri.relativePath; + + this.title = `${this._originalTitle}: ${gitUri.fileName}`; + + const [currentUser, log] = await Promise.all([ + this.container.git.getCurrentUser(repoPath), + this.container.git.getLogForFile(repoPath, gitUri.fsPath, { + limit: 0, + ref: gitUri.sha, + since: this.getPeriodDate(period).toISOString(), + }), + ]); + + if (log == null) { + return { + dataset: [], + period: period, + title: 'No commits found for the specified time period', + uri: current.uri.toString(), + dateFormat: dateFormat, + access: access, + }; + } + + let queryRequiredCommits = [ + ...filter(log.commits.values(), c => c.file?.stats == null && c.stats?.changedFiles !== 1), + ]; + + if (queryRequiredCommits.length !== 0) { + const limit = configuration.get('visualHistory.queryLimit') ?? 20; + + const repository = this.container.git.getRepository(current.uri); + const name = repository?.provider.name; + + if (queryRequiredCommits.length > limit) { + void window.showWarningMessage( + `Unable able to show more than the first ${limit} commits for the specified time period because of ${ + name ? `${name} ` : '' + }rate limits.`, + ); + queryRequiredCommits = queryRequiredCommits.slice(0, 20); + } + + void (await Promise.allSettled(queryRequiredCommits.map(c => c.ensureFullDetails()))); + } + + const name = currentUser?.name ? `${currentUser.name} (you)` : 'You'; + + const dataset: Commit[] = []; + for (const commit of log.commits.values()) { + const stats = commit.file?.stats ?? (commit.stats?.changedFiles === 1 ? commit.stats : undefined); + dataset.push({ + author: commit.author.name === 'You' ? name : commit.author.name, + additions: stats?.additions, + deletions: stats?.deletions, + commit: commit.sha, + date: commit.date.toISOString(), + message: commit.message ?? commit.summary, + sort: commit.date.getTime(), + }); + } + + dataset.sort((a, b) => b.sort - a.sort); + + return { + dataset: dataset, + period: period, + title: title, + uri: current.uri.toString(), + dateFormat: dateFormat, + access: access, + }; + } + + private getPeriodDate(period: Period): Date { + const [number, unit] = period.split('|'); + + switch (unit) { + case 'D': + return createFromDateDelta(new Date(), { days: -parseInt(number, 10) }); + case 'M': + return createFromDateDelta(new Date(), { months: -parseInt(number, 10) }); + case 'Y': + return createFromDateDelta(new Date(), { years: -parseInt(number, 10) }); + default: + return createFromDateDelta(new Date(), { months: -3 }); + } + } + + private updatePendingContext(context: Partial): boolean { + let changed = false; + for (const [key, value] of Object.entries(context)) { + const current = (this._context as unknown as Record)[key]; + if ( + current === value || + ((current instanceof Uri || value instanceof Uri) && (current as any)?.toString() === value?.toString()) + ) { + continue; + } + + if (this._pendingContext == null) { + this._pendingContext = {}; + } + + (this._pendingContext as Record)[key] = value; + changed = true; + } + + return changed; + } + + private updatePendingEditor(editor: TextEditor | undefined): boolean { + if (editor == null && hasVisibleTextEditor()) return false; + if (editor != null && !isTextEditor(editor)) return false; + + return this.updatePendingUri(editor?.document.uri); + } + + private updatePendingUri(uri: Uri | undefined): boolean { + let etag; + if (uri != null) { + const repository = this.container.git.getRepository(uri); + etag = repository?.etag ?? 0; + } else { + etag = 0; + } + + return this.updatePendingContext({ uri: uri, etagRepository: etag }); + } + + private _notifyDidChangeStateDebounced: Deferrable<() => void> | undefined = undefined; + + @debug() + private updateState(immediate: boolean = false) { + if (!this.isReady || !this.visible) return; + + if (immediate) { + void this.notifyDidChangeState(); + return; + } + + if (this._notifyDidChangeStateDebounced == null) { + this._notifyDidChangeStateDebounced = debounce(this.notifyDidChangeState.bind(this), 500); + } + + this._notifyDidChangeStateDebounced(); + } + + @debug() + private async notifyDidChangeState() { + if (!this.isReady || !this.visible) return false; + + this._notifyDidChangeStateDebounced?.cancel(); + if (this._pendingContext == null) return false; + + const context = { ...this._context, ...this._pendingContext }; + + return window.withProgress({ location: { viewId: this.id } }, async () => { + const success = await this.notify(DidChangeStateNotificationType, { + state: await this.getState(context), + }); + if (success) { + this._context = context; + this._pendingContext = undefined; + } + }); + } +} diff --git a/src/plus/webviews/timeline/timelineWebviewView.ts b/src/plus/webviews/timeline/timelineWebviewView.ts new file mode 100644 index 0000000..f4bdbf8 --- /dev/null +++ b/src/plus/webviews/timeline/timelineWebviewView.ts @@ -0,0 +1,425 @@ +'use strict'; +import { commands, Disposable, TextEditor, Uri, window } from 'vscode'; +import type { ShowQuickCommitCommandArgs } from '../../../commands'; +import { configuration } from '../../../configuration'; +import { Commands } from '../../../constants'; +import { Container } from '../../../container'; +import { PlusFeatures } from '../../../features'; +import type { RepositoriesChangeEvent } from '../../../git/gitProviderService'; +import { GitUri } from '../../../git/gitUri'; +import { RepositoryChange, RepositoryChangeComparisonMode, RepositoryChangeEvent } from '../../../git/models'; +import { createFromDateDelta } from '../../../system/date'; +import { debug } from '../../../system/decorators/log'; +import { debounce, Deferrable } from '../../../system/function'; +import { filter } from '../../../system/iterable'; +import { hasVisibleTextEditor, isTextEditor } from '../../../system/utils'; +import { IpcMessage, onIpc } from '../../../webviews/protocol'; +import { WebviewViewBase } from '../../../webviews/webviewViewBase'; +import type { SubscriptionChangeEvent } from '../../subscription/subscriptionService'; +import { ensurePlusFeaturesEnabled } from '../../subscription/utils'; +import { + Commit, + DidChangeStateNotificationType, + OpenDataPointCommandType, + Period, + State, + UpdatePeriodCommandType, +} from './protocol'; + +interface Context { + uri: Uri | undefined; + period: Period | undefined; + etagRepositories: number | undefined; + etagRepository: number | undefined; + etagSubscription: number | undefined; +} + +const defaultPeriod: Period = '3|M'; + +export class TimelineWebviewView extends WebviewViewBase { + private _bootstraping = true; + /** The context the webview has */ + private _context: Context; + /** The context the webview should have */ + private _pendingContext: Partial | undefined; + + constructor(container: Container) { + super(container, 'gitlens.views.timeline', 'timeline.html', 'Visual File History'); + + this._context = { + uri: undefined, + period: defaultPeriod, + etagRepositories: 0, + etagRepository: 0, + etagSubscription: 0, + }; + } + + override async show(options?: { preserveFocus?: boolean | undefined }): Promise { + if (!(await ensurePlusFeaturesEnabled())) return; + return super.show(options); + } + + protected override onInitializing(): Disposable[] | undefined { + this._context = { + uri: undefined, + period: defaultPeriod, + etagRepositories: this.container.git.etag, + etagRepository: 0, + etagSubscription: this.container.subscription.etag, + }; + + this.updatePendingEditor(window.activeTextEditor); + this._context = { ...this._context, ...this._pendingContext }; + this._pendingContext = undefined; + + return [ + this.container.subscription.onDidChange(this.onSubscriptionChanged, this), + window.onDidChangeActiveTextEditor(this.onActiveEditorChanged, this), + this.container.git.onDidChangeRepository(this.onRepositoryChanged, this), + this.container.git.onDidChangeRepositories(this.onRepositoriesChanged, this), + ]; + } + + protected override async includeBootstrap(): Promise { + this._bootstraping = true; + + this._context = { ...this._context, ...this._pendingContext }; + this._pendingContext = undefined; + + return this.getState(this._context); + } + + protected override registerCommands(): Disposable[] { + return [ + commands.registerCommand(`${this.id}.refresh`, () => this.refresh(), this), + commands.registerCommand(`${this.id}.openInTab`, () => this.openInTab(), this), + ]; + } + + protected override onVisibilityChanged(visible: boolean) { + if (!visible) return; + + // Since this gets called even the first time the webview is shown, avoid sending an update, because the bootstrap has the data + if (this._bootstraping) { + this._bootstraping = false; + + // If the uri changed since bootstrap still send the update + if (this._pendingContext == null || !('uri' in this._pendingContext)) { + return; + } + } + + // Should be immediate, but it causes the bubbles to go missing on the chart, since the update happens while it still rendering + this.updateState(); + } + + protected override onMessageReceived(e: IpcMessage) { + switch (e.method) { + case OpenDataPointCommandType.method: + onIpc(OpenDataPointCommandType, e, params => { + if (params.data == null || !params.data.selected || this._context.uri == null) return; + + const repository = this.container.git.getRepository(this._context.uri); + if (repository == null) return; + + const commandArgs: ShowQuickCommitCommandArgs = { + repoPath: repository.path, + sha: params.data.id, + }; + + void commands.executeCommand(Commands.ShowQuickCommit, commandArgs); + + // const commandArgs: DiffWithPreviousCommandArgs = { + // line: 0, + // showOptions: { + // preserveFocus: true, + // preview: true, + // viewColumn: ViewColumn.Beside, + // }, + // }; + + // void commands.executeCommand( + // Commands.DiffWithPrevious, + // new GitUri(gitUri, { repoPath: gitUri.repoPath!, sha: params.data.id }), + // commandArgs, + // ); + }); + + break; + + case UpdatePeriodCommandType.method: + onIpc(UpdatePeriodCommandType, e, params => { + if (this.updatePendingContext({ period: params.period })) { + this.updateState(true); + } + }); + + break; + } + } + + @debug({ args: false }) + private onActiveEditorChanged(editor: TextEditor | undefined) { + if (!this.updatePendingEditor(editor)) return; + + this.updateState(); + } + + @debug({ args: false }) + private onRepositoriesChanged(e: RepositoriesChangeEvent) { + const changed = this.updatePendingUri(this._context.uri); + + if (this.updatePendingContext({ etagRepositories: e.etag }) || changed) { + this.updateState(); + } + } + + @debug({ args: false }) + private onRepositoryChanged(e: RepositoryChangeEvent) { + if (!e.changed(RepositoryChange.Heads, RepositoryChange.Index, RepositoryChangeComparisonMode.Any)) { + return; + } + + if (this.updatePendingContext({ etagRepository: e.repository.etag })) { + this.updateState(); + } + } + + @debug({ args: false }) + private onSubscriptionChanged(e: SubscriptionChangeEvent) { + if (this.updatePendingContext({ etagSubscription: e.etag })) { + this.updateState(); + } + } + + @debug({ args: false }) + private async getState(current: Context): Promise { + const access = await this.container.git.access(PlusFeatures.Timeline); + const dateFormat = this.container.config.defaultDateFormat ?? 'MMMM Do, YYYY h:mma'; + const period = current.period ?? defaultPeriod; + + if (!access.allowed) { + const dataset = generateRandomTimelineDataset(); + return { + dataset: dataset.sort((a, b) => b.sort - a.sort), + period: period, + title: 'src/app/index.ts', + uri: Uri.file('src/app/index.ts').toString(), + dateFormat: dateFormat, + access: access, + }; + } + + if (current.uri == null) { + return { + period: period, + title: 'There are no editors open that can provide file history information', + dateFormat: dateFormat, + access: access, + }; + } + + const gitUri = await GitUri.fromUri(current.uri); + const repoPath = gitUri.repoPath!; + const title = gitUri.relativePath; + + this.description = gitUri.fileName; + + const [currentUser, log] = await Promise.all([ + this.container.git.getCurrentUser(repoPath), + this.container.git.getLogForFile(repoPath, gitUri.fsPath, { + limit: 0, + ref: gitUri.sha, + since: this.getPeriodDate(period).toISOString(), + }), + ]); + + if (log == null) { + return { + dataset: [], + period: period, + title: 'No commits found for the specified time period', + uri: current.uri.toString(), + dateFormat: dateFormat, + access: access, + }; + } + + let queryRequiredCommits = [ + ...filter(log.commits.values(), c => c.file?.stats == null && c.stats?.changedFiles !== 1), + ]; + + if (queryRequiredCommits.length !== 0) { + const limit = configuration.get('visualHistory.queryLimit') ?? 20; + + const repository = this.container.git.getRepository(current.uri); + const name = repository?.provider.name; + + if (queryRequiredCommits.length > limit) { + void window.showWarningMessage( + `Unable able to show more than the first ${limit} commits for the specified time period because of ${ + name ? `${name} ` : '' + }rate limits.`, + ); + queryRequiredCommits = queryRequiredCommits.slice(0, 20); + } + + void (await Promise.allSettled(queryRequiredCommits.map(c => c.ensureFullDetails()))); + } + + const name = currentUser?.name ? `${currentUser.name} (you)` : 'You'; + + const dataset: Commit[] = []; + for (const commit of log.commits.values()) { + const stats = commit.file?.stats ?? (commit.stats?.changedFiles === 1 ? commit.stats : undefined); + dataset.push({ + author: commit.author.name === 'You' ? name : commit.author.name, + additions: stats?.additions, + deletions: stats?.deletions, + commit: commit.sha, + date: commit.date.toISOString(), + message: commit.message ?? commit.summary, + sort: commit.date.getTime(), + }); + } + + dataset.sort((a, b) => b.sort - a.sort); + + return { + dataset: dataset, + period: period, + title: title, + uri: current.uri.toString(), + dateFormat: dateFormat, + access: access, + }; + } + + private getPeriodDate(period: Period): Date { + const [number, unit] = period.split('|'); + + switch (unit) { + case 'D': + return createFromDateDelta(new Date(), { days: -parseInt(number, 10) }); + case 'M': + return createFromDateDelta(new Date(), { months: -parseInt(number, 10) }); + case 'Y': + return createFromDateDelta(new Date(), { years: -parseInt(number, 10) }); + default: + return createFromDateDelta(new Date(), { months: -3 }); + } + } + + private openInTab() { + const uri = this._context.uri; + if (uri == null) return; + + void commands.executeCommand(Commands.ShowTimelinePage, uri); + } + + private updatePendingContext(context: Partial): boolean { + let changed = false; + for (const [key, value] of Object.entries(context)) { + const current = (this._context as unknown as Record)[key]; + if ( + current === value || + ((current instanceof Uri || value instanceof Uri) && (current as any)?.toString() === value?.toString()) + ) { + continue; + } + + if (this._pendingContext == null) { + this._pendingContext = {}; + } + + (this._pendingContext as Record)[key] = value; + changed = true; + } + + return changed; + } + + private updatePendingEditor(editor: TextEditor | undefined): boolean { + if (editor == null && hasVisibleTextEditor()) return false; + if (editor != null && !isTextEditor(editor)) return false; + + return this.updatePendingUri(editor?.document.uri); + } + + private updatePendingUri(uri: Uri | undefined): boolean { + let etag; + if (uri != null) { + const repository = this.container.git.getRepository(uri); + etag = repository?.etag ?? 0; + } else { + etag = 0; + } + + return this.updatePendingContext({ uri: uri, etagRepository: etag }); + } + + private _notifyDidChangeStateDebounced: Deferrable<() => void> | undefined = undefined; + + @debug() + private updateState(immediate: boolean = false) { + if (!this.isReady || !this.visible) return; + + this.updatePendingEditor(window.activeTextEditor); + + if (immediate) { + void this.notifyDidChangeState(); + return; + } + + if (this._notifyDidChangeStateDebounced == null) { + this._notifyDidChangeStateDebounced = debounce(this.notifyDidChangeState.bind(this), 500); + } + + this._notifyDidChangeStateDebounced(); + } + + @debug() + private async notifyDidChangeState() { + if (!this.isReady || !this.visible) return false; + + this._notifyDidChangeStateDebounced?.cancel(); + if (this._pendingContext == null) return false; + + const context = { ...this._context, ...this._pendingContext }; + + return window.withProgress({ location: { viewId: this.id } }, async () => { + const success = await this.notify(DidChangeStateNotificationType, { + state: await this.getState(context), + }); + if (success) { + this._context = context; + this._pendingContext = undefined; + } + }); + } +} + +export function generateRandomTimelineDataset(): Commit[] { + const dataset: Commit[] = []; + const authors = ['Eric Amodio', 'Justin Roberts', 'Ada Lovelace', 'Grace Hopper']; + + const count = 10; + for (let i = 0; i < count; i++) { + // Generate a random date between now and 3 months ago + const date = new Date(new Date().getTime() - Math.floor(Math.random() * (3 * 30 * 24 * 60 * 60 * 1000))); + + dataset.push({ + commit: String(i), + author: authors[Math.floor(Math.random() * authors.length)], + date: date.toISOString(), + message: '', + // Generate random additions/deletions between 1 and 20, but ensure we have a tiny and large commit + additions: i === 0 ? 2 : i === count - 1 ? 50 : Math.floor(Math.random() * 20) + 1, + deletions: i === 0 ? 1 : i === count - 1 ? 25 : Math.floor(Math.random() * 20) + 1, + sort: date.getTime(), + }); + } + + return dataset; +} diff --git a/src/premium/LICENSE.premium b/src/premium/LICENSE.premium deleted file mode 100644 index b6045e3..0000000 --- a/src/premium/LICENSE.premium +++ /dev/null @@ -1,40 +0,0 @@ -GitLens+ License - -Copyright (c) 2021-2022 Axosoft, LLC dba GitKraken ("GitKraken") - -With regard to the software set forth in or under any directory named "premium". - -This software and associated documentation files (the "Software") may be -compiled as part of the gitkraken/vscode-gitlens open source project (the -"GitLens") to the extent the Software is a required component of the GitLens; -provided, however, that the Software and its functionality may only be used if -you (and any entity that you represent) have agreed to, and are in compliance -with, the GitKraken End User License Agreement, available at -https://www.gitkraken.com/eula (the "EULA"), or other agreement governing the -use of the Software, as agreed by you and GitKraken, and otherwise have a valid -subscription for the correct number of user seats for the applicable version of -the Software (e.g., GitLens Free+, GitLens Pro, GitLens Teams, and GitLens -Enterprise). Subject to the foregoing sentence, you are free to modify this -Software and publish patches to the Software. You agree that GitKraken and/or -its licensors (as applicable) retain all right, title and interest in and to all -such modifications and/or patches, and all such modifications and/or patches may -only be used, copied, modified, displayed, distributed, or otherwise exploited -with a valid subscription for the correct number of user seats for the Software. -In furtherance of the foregoing, you hereby assign to GitKraken all such -modifications and/or patches. You are not granted any other rights beyond what -is expressly stated herein. Except as set forth above, it is forbidden to copy, -merge, publish, distribute, sublicense, and/or sell the Software. - -The full text of this GitLens+ License shall be included in all copies or -substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -For all third party components incorporated into the Software, those components -are licensed under the original license provided by the owner of the applicable -component. diff --git a/src/premium/github/github.ts b/src/premium/github/github.ts deleted file mode 100644 index 4316665..0000000 --- a/src/premium/github/github.ts +++ /dev/null @@ -1,1959 +0,0 @@ -import { Octokit } from '@octokit/core'; -import { GraphqlResponseError } from '@octokit/graphql'; -import { RequestError } from '@octokit/request-error'; -import type { Endpoints, OctokitResponse, RequestParameters } from '@octokit/types'; -import { Event, EventEmitter, window } from 'vscode'; -import { fetch } from '@env/fetch'; -import { isWeb } from '@env/platform'; -import { - AuthenticationError, - AuthenticationErrorReason, - ProviderRequestClientError, - ProviderRequestNotFoundError, -} from '../../errors'; -import { PagedResult, RepositoryVisibility } from '../../git/gitProvider'; -import { - type DefaultBranch, - GitFileIndexStatus, - GitRevision, - type GitUser, - type IssueOrPullRequest, - type IssueOrPullRequestType, - PullRequest, - PullRequestState, -} from '../../git/models'; -import type { Account } from '../../git/models/author'; -import type { RichRemoteProvider } from '../../git/remotes/provider'; -import { LogCorrelationContext, Logger, LogLevel } from '../../logger'; -import { debug } from '../../system/decorators/log'; -import { Stopwatch } from '../../system/stopwatch'; - -const emptyPagedResult: PagedResult = Object.freeze({ values: [] }); -const emptyBlameResult: GitHubBlame = Object.freeze({ ranges: [] }); - -export class GitHubApi { - private readonly _onDidReauthenticate = new EventEmitter(); - get onDidReauthenticate(): Event { - return this._onDidReauthenticate.event; - } - - @debug({ args: { 0: p => p.name, 1: '' } }) - async getAccountForCommit( - provider: RichRemoteProvider, - token: string, - owner: string, - repo: string, - ref: string, - options?: { - baseUrl?: string; - avatarSize?: number; - }, - ): Promise { - const cc = Logger.getCorrelationContext(); - - interface QueryResult { - repository: - | { - object: - | { - author?: { - name: string | null; - email: string | null; - avatarUrl: string; - }; - } - | null - | undefined; - } - | null - | undefined; - } - - try { - const query = `query getAccountForCommit( - $owner: String! - $repo: String! - $ref: GitObjectID! - $avatarSize: Int -) { - repository(name: $repo, owner: $owner) { - object(oid: $ref) { - ... on Commit { - author { - name - email - avatarUrl(size: $avatarSize) - } - } - } - } -}`; - - const rsp = await this.graphql(token, query, { - ...options, - owner: owner, - repo: repo, - ref: ref, - }); - - const author = rsp?.repository?.object?.author; - if (author == null) return undefined; - - return { - provider: provider, - name: author.name ?? undefined, - email: author.email ?? undefined, - avatarUrl: author.avatarUrl, - }; - } catch (ex) { - debugger; - return this.handleException(ex, cc, undefined); - } - } - - @debug({ args: { 0: p => p.name, 1: '' } }) - async getAccountForEmail( - provider: RichRemoteProvider, - token: string, - owner: string, - repo: string, - email: string, - options?: { - baseUrl?: string; - avatarSize?: number; - }, - ): Promise { - const cc = Logger.getCorrelationContext(); - - interface QueryResult { - search: - | { - nodes: - | { - name: string | null; - email: string | null; - avatarUrl: string; - }[] - | null - | undefined; - } - | null - | undefined; - } - - try { - const query = `query getAccountForEmail( - $emailQuery: String! - $avatarSize: Int -) { - search(type: USER, query: $emailQuery, first: 1) { - nodes { - ... on User { - name - email - avatarUrl(size: $avatarSize) - } - } - } -}`; - - const rsp = await this.graphql(token, query, { - ...options, - owner: owner, - repo: repo, - emailQuery: `in:email ${email}`, - }); - - const author = rsp?.search?.nodes?.[0]; - if (author == null) return undefined; - - return { - provider: provider, - name: author.name ?? undefined, - email: author.email ?? undefined, - avatarUrl: author.avatarUrl, - }; - } catch (ex) { - debugger; - return this.handleException(ex, cc, undefined); - } - } - - @debug({ args: { 0: p => p.name, 1: '' } }) - async getDefaultBranch( - provider: RichRemoteProvider, - token: string, - owner: string, - repo: string, - options?: { - baseUrl?: string; - }, - ): Promise { - const cc = Logger.getCorrelationContext(); - - interface QueryResult { - repository: - | { - defaultBranchRef: { name: string } | null | undefined; - } - | null - | undefined; - } - - try { - const query = `query getDefaultBranch( - $owner: String! - $repo: String! -) { - repository(name: $repo, owner: $owner) { - defaultBranchRef { - name - } - } -}`; - - const rsp = await this.graphql(token, query, { - ...options, - owner: owner, - repo: repo, - }); - - const defaultBranch = rsp?.repository?.defaultBranchRef?.name ?? undefined; - if (defaultBranch == null) return undefined; - - return { - provider: provider, - name: defaultBranch, - }; - } catch (ex) { - debugger; - return this.handleException(ex, cc, undefined); - } - } - - @debug({ args: { 0: p => p.name, 1: '' } }) - async getIssueOrPullRequest( - provider: RichRemoteProvider, - token: string, - owner: string, - repo: string, - number: number, - options?: { - baseUrl?: string; - }, - ): Promise { - const cc = Logger.getCorrelationContext(); - - interface QueryResult { - repository?: { issueOrPullRequest?: GitHubIssueOrPullRequest }; - } - - try { - const query = `query getIssueOrPullRequest( - $owner: String! - $repo: String! - $number: Int! - ) { - repository(name: $repo, owner: $owner) { - issueOrPullRequest(number: $number) { - __typename - ... on Issue { - createdAt - closed - closedAt - title - url - } - ... on PullRequest { - createdAt - closed - closedAt - title - url - } - } - } - }`; - - const rsp = await this.graphql(token, query, { - ...options, - owner: owner, - repo: repo, - number: number, - }); - - const issue = rsp?.repository?.issueOrPullRequest; - if (issue == null) return undefined; - - return { - provider: provider, - type: issue.type, - id: String(number), - date: new Date(issue.createdAt), - title: issue.title, - closed: issue.closed, - closedDate: issue.closedAt == null ? undefined : new Date(issue.closedAt), - url: issue.url, - }; - } catch (ex) { - debugger; - return this.handleException(ex, cc, undefined); - } - } - - @debug({ args: { 0: p => p.name, 1: '' } }) - async getPullRequestForBranch( - provider: RichRemoteProvider, - token: string, - owner: string, - repo: string, - branch: string, - options?: { - baseUrl?: string; - avatarSize?: number; - include?: GitHubPullRequestState[]; - }, - ): Promise { - const cc = Logger.getCorrelationContext(); - - interface QueryResult { - repository: - | { - refs: { - nodes: { - associatedPullRequests?: { - nodes?: GitHubPullRequest[]; - }; - }[]; - }; - } - | null - | undefined; - } - - try { - const query = `query getPullRequestForBranch( - $owner: String! - $repo: String! - $branch: String! - $limit: Int! - $include: [PullRequestState!] - $avatarSize: Int -) { - repository(name: $repo, owner: $owner) { - refs(query: $branch, refPrefix: "refs/heads/", first: 1) { - nodes { - associatedPullRequests(first: $limit, orderBy: {field: UPDATED_AT, direction: DESC}, states: $include) { - nodes { - author { - login - avatarUrl(size: $avatarSize) - url - } - permalink - number - title - state - updatedAt - closedAt - mergedAt - repository { - isFork - owner { - login - } - } - } - } - } - } - } -}`; - - const rsp = await this.graphql(token, query, { - ...options, - owner: owner, - repo: repo, - branch: branch, - // Since GitHub sort doesn't seem to really work, look for a max of 10 PRs and then sort them ourselves - limit: 10, - }); - - // If the pr is not from a fork, keep it e.g. show root pr's on forks, otherwise, ensure the repo owners match - const prs = rsp?.repository?.refs.nodes[0]?.associatedPullRequests?.nodes?.filter( - pr => !pr.repository.isFork || pr.repository.owner.login === owner, - ); - if (prs == null || prs.length === 0) return undefined; - - if (prs.length > 1) { - prs.sort( - (a, b) => - (a.repository.owner.login === owner ? -1 : 1) - (b.repository.owner.login === owner ? -1 : 1) || - (a.state === 'OPEN' ? -1 : 1) - (b.state === 'OPEN' ? -1 : 1) || - new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(), - ); - } - - return GitHubPullRequest.from(prs[0], provider); - } catch (ex) { - debugger; - return this.handleException(ex, cc, undefined); - } - } - - @debug({ args: { 0: p => p.name, 1: '' } }) - async getPullRequestForCommit( - provider: RichRemoteProvider, - token: string, - owner: string, - repo: string, - ref: string, - options?: { - baseUrl?: string; - avatarSize?: number; - }, - ): Promise { - const cc = Logger.getCorrelationContext(); - - interface QueryResult { - repository: - | { - object?: { - associatedPullRequests?: { - nodes?: GitHubPullRequest[]; - }; - }; - } - | null - | undefined; - } - - try { - const query = `query getPullRequestForCommit( - $owner: String! - $repo: String! - $ref: GitObjectID! - $avatarSize: Int -) { - repository(name: $repo, owner: $owner) { - object(oid: $ref) { - ... on Commit { - associatedPullRequests(first: 2, orderBy: {field: UPDATED_AT, direction: DESC}) { - nodes { - author { - login - avatarUrl(size: $avatarSize) - url - } - permalink - number - title - state - updatedAt - closedAt - mergedAt - repository { - isFork - owner { - login - } - } - } - } - } - } - } -}`; - - const rsp = await this.graphql(token, query, { - ...options, - owner: owner, - repo: repo, - ref: ref, - }); - - // If the pr is not from a fork, keep it e.g. show root pr's on forks, otherwise, ensure the repo owners match - const prs = rsp?.repository?.object?.associatedPullRequests?.nodes?.filter( - pr => !pr.repository.isFork || pr.repository.owner.login === owner, - ); - if (prs == null || prs.length === 0) return undefined; - - if (prs.length > 1) { - prs.sort( - (a, b) => - (a.repository.owner.login === owner ? -1 : 1) - (b.repository.owner.login === owner ? -1 : 1) || - (a.state === 'OPEN' ? -1 : 1) - (b.state === 'OPEN' ? -1 : 1) || - new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(), - ); - } - - return GitHubPullRequest.from(prs[0], provider); - } catch (ex) { - debugger; - return this.handleException(ex, cc, undefined); - } - } - - @debug({ args: { 0: '' } }) - async getBlame(token: string, owner: string, repo: string, ref: string, path: string): Promise { - const cc = Logger.getCorrelationContext(); - - interface QueryResult { - viewer: { name: string }; - repository: - | { - object: { - blame: { - ranges: GitHubBlameRange[]; - }; - }; - } - | null - | undefined; - } - - try { - const query = `query getBlameRanges( - $owner: String! - $repo: String! - $ref: String! - $path: String! -) { - viewer { name } - repository(owner: $owner, name: $repo) { - object(expression: $ref) { - ...on Commit { - blame(path: $path) { - ranges { - startingLine - endingLine - commit { - oid - parents(first: 3) { nodes { oid } } - message - additions - changedFiles - deletions - author { - avatarUrl - date - email - name - } - committer { - date - email - name - } - } - } - } - } - } - } -}`; - const rsp = await this.graphql(token, query, { - owner: owner, - repo: repo, - ref: ref, - path: path, - }); - if (rsp == null) return emptyBlameResult; - - const ranges = rsp.repository?.object?.blame?.ranges; - if (ranges == null || ranges.length === 0) return { ranges: [], viewer: rsp.viewer?.name }; - - return { ranges: ranges, viewer: rsp.viewer?.name }; - } catch (ex) { - debugger; - return this.handleException(ex, cc, emptyBlameResult); - } - } - - @debug({ args: { 0: '' } }) - async getBranches( - token: string, - owner: string, - repo: string, - options?: { query?: string; cursor?: string; limit?: number }, - ): Promise> { - const cc = Logger.getCorrelationContext(); - - interface QueryResult { - repository: - | { - refs: { - pageInfo: { - endCursor: string; - hasNextPage: boolean; - }; - nodes: GitHubBranch[]; - }; - } - | null - | undefined; - } - - try { - const query = `query getBranches( - $owner: String! - $repo: String! - $branchQuery: String - $cursor: String - $limit: Int = 100 -) { - repository(owner: $owner, name: $repo) { - refs(query: $branchQuery, refPrefix: "refs/heads/", first: $limit, after: $cursor, orderBy: { field: TAG_COMMIT_DATE, direction: DESC }) { - pageInfo { - endCursor - hasNextPage - } - nodes { - name - target { - oid - commitUrl - ...on Commit { - authoredDate - committedDate - } - } - } - } - } -}`; - - const rsp = await this.graphql(token, query, { - owner: owner, - repo: repo, - branchQuery: options?.query, - cursor: options?.cursor, - limit: Math.min(100, options?.limit ?? 100), - }); - if (rsp == null) return emptyPagedResult; - - const refs = rsp.repository?.refs; - if (refs == null) return emptyPagedResult; - - return { - paging: { - cursor: refs.pageInfo.endCursor, - more: refs.pageInfo.hasNextPage, - }, - values: refs.nodes, - }; - } catch (ex) { - debugger; - return this.handleException(ex, cc, emptyPagedResult); - } - } - - @debug({ args: { 0: '' } }) - async getCommit( - token: string, - owner: string, - repo: string, - ref: string, - ): Promise<(GitHubCommit & { viewer?: string }) | undefined> { - const cc = Logger.getCorrelationContext(); - - try { - const rsp = await this.request(token, 'GET /repos/{owner}/{repo}/commits/{ref}', { - owner: owner, - repo: repo, - ref: ref, - }); - - const result = rsp?.data; - if (result == null) return undefined; - - const { commit } = result; - return { - oid: result.sha, - parents: { nodes: result.parents.map(p => ({ oid: p.sha })) }, - message: commit.message, - additions: result.stats?.additions, - changedFiles: result.files?.length, - deletions: result.stats?.deletions, - author: { - avatarUrl: result.author?.avatar_url ?? undefined, - date: commit.author?.date ?? new Date().toString(), - email: commit.author?.email ?? undefined, - name: commit.author?.name ?? '', - }, - committer: { - date: commit.committer?.date ?? new Date().toString(), - email: commit.committer?.email ?? undefined, - name: commit.committer?.name ?? '', - }, - files: result.files, - }; - } catch (ex) { - debugger; - return this.handleException(ex, cc, undefined); - } - - // const results = await this.getCommits(token, owner, repo, ref, { limit: 1 }); - // if (results.values.length === 0) return undefined; - - // return { ...results.values[0], viewer: results.viewer }; - } - - @debug({ args: { 0: '' } }) - async getCommitForFile( - token: string, - owner: string, - repo: string, - ref: string, - path: string, - ): Promise<(GitHubCommit & { viewer?: string }) | undefined> { - if (GitRevision.isSha(ref)) return this.getCommit(token, owner, repo, ref); - - // TODO: optimize this -- only need to get the sha for the ref - const results = await this.getCommits(token, owner, repo, ref, { limit: 1, path: path }); - if (results.values.length === 0) return undefined; - - const commit = await this.getCommit(token, owner, repo, results.values[0].oid); - return { ...(commit ?? results.values[0]), viewer: results.viewer }; - } - - @debug({ args: { 0: '' } }) - async getCommitBranches(token: string, owner: string, repo: string, ref: string, date: Date): Promise { - const cc = Logger.getCorrelationContext(); - - interface QueryResult { - repository: { - refs: { - nodes: { - name: string; - target: { - history: { - nodes: { oid: string }[]; - }; - }; - }[]; - }; - }; - } - - try { - const query = `query getCommitBranches( - $owner: String! - $repo: String! - $since: GitTimestamp! - $until: GitTimestamp! -) { - repository(owner: $owner, name: $repo) { - refs(first: 20, refPrefix: "refs/heads/", orderBy: { field: TAG_COMMIT_DATE, direction: DESC }) { - nodes { - name - target { - ... on Commit { - history(first: 3, since: $since until: $until) { - nodes { oid } - } - } - } - } - } - } -}`; - const rsp = await this.graphql(token, query, { - owner: owner, - repo: repo, - since: date.toISOString(), - until: date.toISOString(), - }); - - const nodes = rsp?.repository?.refs?.nodes; - if (nodes == null) return []; - - const branches = []; - - for (const branch of nodes) { - for (const commit of branch.target.history.nodes) { - if (commit.oid === ref) { - branches.push(branch.name); - break; - } - } - } - - return branches; - } catch (ex) { - debugger; - return this.handleException(ex, cc, []); - } - } - - @debug({ args: { 0: '' } }) - async getCommitCount(token: string, owner: string, repo: string, ref: string): Promise { - const cc = Logger.getCorrelationContext(); - - interface QueryResult { - repository: { - ref: { - target: { - history: { totalCount: number }; - }; - }; - }; - } - - try { - const query = `query getCommitCount( - $owner: String! - $repo: String! - $ref: String! -) { - repository(owner: $owner, name: $repo) { - ref(qualifiedName: $ref) { - target { - ... on Commit { - history(first: 1) { - totalCount - } - } - } - } - } -}`; - - const rsp = await this.graphql(token, query, { - owner: owner, - repo: repo, - ref: ref, - }); - - const count = rsp?.repository?.ref?.target.history.totalCount; - return count; - } catch (ex) { - debugger; - return this.handleException(ex, cc, undefined); - } - } - - @debug({ args: { 0: '' } }) - async getCommitOnBranch( - token: string, - owner: string, - repo: string, - branch: string, - ref: string, - date: Date, - ): Promise { - const cc = Logger.getCorrelationContext(); - - interface QueryResult { - repository: { - ref: { - target: { - history: { - nodes: { oid: string }[]; - }; - }; - }; - }; - } - try { - const query = `query getCommitOnBranch( - $owner: String! - $repo: String! - $ref: String! - $since: GitTimestamp! - $until: GitTimestamp! -) { - repository(owner: $owner, name: $repo) { - ref(qualifiedName: $ref) { - target { - ... on Commit { - history(first: 3, since: $since until: $until) { - nodes { oid } - } - } - } - } - } -}`; - const rsp = await this.graphql(token, query, { - owner: owner, - repo: repo, - ref: `refs/heads/${branch}`, - since: date.toISOString(), - until: date.toISOString(), - }); - - const nodes = rsp?.repository?.ref.target.history.nodes; - if (nodes == null) return []; - - const branches = []; - - for (const commit of nodes) { - if (commit.oid === ref) { - branches.push(branch); - break; - } - } - - return branches; - } catch (ex) { - debugger; - return this.handleException(ex, cc, []); - } - } - - @debug({ args: { 0: '' } }) - async getCommits( - token: string, - owner: string, - repo: string, - ref: string, - options?: { - after?: string; - all?: boolean; - authors?: GitUser[]; - before?: string; - limit?: number; - path?: string; - since?: string | Date; - until?: string | Date; - }, - ): Promise & { viewer?: string }> { - const cc = Logger.getCorrelationContext(); - - if (options?.limit === 1 && options?.path == null) { - return this.getCommitsCoreSingle(token, owner, repo, ref); - } - - interface QueryResult { - viewer: { name: string }; - repository: - | { - object: - | { - history: { - pageInfo: GitHubPageInfo; - nodes: GitHubCommit[]; - }; - } - | null - | undefined; - } - | null - | undefined; - } - - try { - const query = `query getCommits( - $owner: String! - $repo: String! - $ref: String! - $path: String - $author: CommitAuthor - $after: String - $before: String - $limit: Int = 100 - $since: GitTimestamp - $until: GitTimestamp -) { - viewer { name } - repository(name: $repo, owner: $owner) { - object(expression: $ref) { - ... on Commit { - history(first: $limit, author: $author, path: $path, after: $after, before: $before, since: $since, until: $until) { - pageInfo { - startCursor - endCursor - hasNextPage - hasPreviousPage - } - nodes { - ... on Commit { - oid - message - parents(first: 3) { nodes { oid } } - additions - changedFiles - deletions - author { - avatarUrl - date - email - name - } - committer { - date - email - name - } - } - } - } - } - } - } -}`; - - let authors: { id?: string; emails?: string[] } | undefined; - if (options?.authors != null) { - if (options.authors.length === 1) { - const [author] = options.authors; - authors = { - id: author.id, - emails: author.email ? [author.email] : undefined, - }; - } else { - const emails = options.authors.filter(a => a.email).map(a => a.email!); - authors = emails.length ? { emails: emails } : undefined; - } - } - - const rsp = await this.graphql(token, query, { - owner: owner, - repo: repo, - ref: ref, - after: options?.after, - before: options?.before, - path: options?.path, - author: authors, - limit: Math.min(100, options?.limit ?? 100), - since: typeof options?.since === 'string' ? options?.since : options?.since?.toISOString(), - until: typeof options?.until === 'string' ? options?.until : options?.until?.toISOString(), - }); - const history = rsp?.repository?.object?.history; - if (history == null) return emptyPagedResult; - - return { - paging: - history.pageInfo.endCursor != null - ? { - cursor: history.pageInfo.endCursor ?? undefined, - more: history.pageInfo.hasNextPage, - } - : undefined, - values: history.nodes, - viewer: rsp?.viewer.name, - }; - } catch (ex) { - debugger; - return this.handleException(ex, cc, emptyPagedResult); - } - } - - private async getCommitsCoreSingle( - token: string, - owner: string, - repo: string, - ref: string, - ): Promise & { viewer?: string }> { - const cc = Logger.getCorrelationContext(); - - interface QueryResult { - viewer: { name: string }; - repository: { object: GitHubCommit } | null | undefined; - } - - try { - const query = `query getCommit( - $owner: String! - $repo: String! - $ref: String! -) { - viewer { name } - repository(name: $repo owner: $owner) { - object(expression: $ref) { - ...on Commit { - oid - parents(first: 3) { nodes { oid } } - message - additions - changedFiles - deletions - author { - avatarUrl - date - email - name - } - committer { - date - email - name - } - } - } - } -}`; - - const rsp = await this.graphql(token, query, { - owner: owner, - repo: repo, - ref: ref, - }); - if (rsp == null) return emptyPagedResult; - - const commit = rsp.repository?.object; - return commit != null ? { values: [commit], viewer: rsp.viewer.name } : emptyPagedResult; - } catch (ex) { - debugger; - return this.handleException(ex, cc, emptyPagedResult); - } - } - - @debug({ args: { 0: '' } }) - async getCommitRefs( - token: string, - owner: string, - repo: string, - ref: string, - options?: { - after?: string; - before?: string; - first?: number; - last?: number; - path?: string; - since?: string; - until?: string; - }, - ): Promise | undefined> { - const cc = Logger.getCorrelationContext(); - - interface QueryResult { - repository: - | { - object: - | { - history: { - pageInfo: GitHubPageInfo; - totalCount: number; - nodes: GitHubCommitRef[]; - }; - } - | null - | undefined; - } - | null - | undefined; - } - - try { - const query = `query getCommitRefs( - $owner: String! - $repo: String! - $ref: String! - $after: String - $before: String - $first: Int - $last: Int - $path: String - $since: GitTimestamp - $until: GitTimestamp -) { - repository(name: $repo, owner: $owner) { - object(expression: $ref) { - ... on Commit { - history(first: $first, last: $last, path: $path, since: $since, until: $until, after: $after, before: $before) { - pageInfo { startCursor, endCursor, hasNextPage, hasPreviousPage } - totalCount - nodes { oid } - } - } - } - } -}`; - - const rsp = await this.graphql(token, query, { - owner: owner, - repo: repo, - ref: ref, - path: options?.path, - first: options?.first, - last: options?.last, - after: options?.after, - before: options?.before, - since: options?.since, - until: options?.until, - }); - const history = rsp?.repository?.object?.history; - if (history == null) return undefined; - - return { - pageInfo: history.pageInfo, - totalCount: history.totalCount, - values: history.nodes, - }; - } catch (ex) { - debugger; - return this.handleException(ex, cc, undefined); - } - } - - @debug({ args: { 0: '' } }) - async getNextCommitRefs( - token: string, - owner: string, - repo: string, - ref: string, - path: string, - sha: string, - ): Promise { - // Get the commit date of the current commit - const commitDate = await this.getCommitDate(token, owner, repo, sha); - if (commitDate == null) return []; - - // Get a resultset (just need the cursor and totals), to get the page info we need to construct a cursor to page backwards - let result = await this.getCommitRefs(token, owner, repo, ref, { path: path, first: 1, since: commitDate }); - if (result == null) return []; - - // Construct a cursor to allow use to walk backwards in time (starting at the tip going back in time until the commit date) - const cursor = `${result.pageInfo.startCursor!.split(' ', 1)[0]} ${result.totalCount}`; - - let last; - [, last] = cursor.split(' ', 2); - // We can't ask for more commits than are left in the cursor (but try to get more to be safe, since the date isn't exact enough) - last = Math.min(parseInt(last, 10), 5); - - // Get the set of refs before the cursor - result = await this.getCommitRefs(token, owner, repo, ref, { path: path, last: last, before: cursor }); - if (result == null) return []; - - const nexts: string[] = []; - - for (const { oid } of result.values) { - if (oid === sha) break; - - nexts.push(oid); - } - - return nexts.reverse(); - } - - private async getCommitDate(token: string, owner: string, repo: string, sha: string): Promise { - const cc = Logger.getCorrelationContext(); - - interface QueryResult { - repository: - | { - object: { committer: { date: string } } | null | undefined; - } - | null - | undefined; - } - - try { - const query = `query getCommitDate( - $owner: String! - $repo: String! - $sha: GitObjectID! -) { - repository(name: $repo, owner: $owner) { - object(oid: $sha) { - ... on Commit { committer { date } } - } - } -}`; - - const rsp = await this.graphql(token, query, { - owner: owner, - repo: repo, - sha: sha, - }); - const date = rsp?.repository?.object?.committer.date; - return date; - } catch (ex) { - debugger; - return this.handleException(ex, cc, undefined); - } - } - - @debug({ args: { 0: '' } }) - async getContributors(token: string, owner: string, repo: string): Promise { - const cc = Logger.getCorrelationContext(); - - // TODO@eamodio implement pagination - - try { - const rsp = await this.request(token, 'GET /repos/{owner}/{repo}/contributors', { - owner: owner, - repo: repo, - per_page: 100, - }); - - const result = rsp?.data; - if (result == null) return []; - - return rsp.data; - } catch (ex) { - debugger; - return this.handleException(ex, cc, []); - } - } - - @debug({ args: { 0: '' } }) - async getDefaultBranchName(token: string, owner: string, repo: string): Promise { - const cc = Logger.getCorrelationContext(); - - interface QueryResult { - repository: - | { - defaultBranchRef: { name: string } | null | undefined; - } - | null - | undefined; - } - - try { - const query = `query getDefaultBranch( - $owner: String! - $repo: String! -) { - repository(owner: $owner, name: $repo) { - defaultBranchRef { - name - } - } -}`; - - const rsp = await this.graphql(token, query, { - owner: owner, - repo: repo, - }); - if (rsp == null) return undefined; - - return rsp.repository?.defaultBranchRef?.name ?? undefined; - } catch (ex) { - debugger; - return this.handleException(ex, cc, undefined); - } - } - - @debug({ args: { 0: '' } }) - async getCurrentUser(token: string, owner: string, repo: string): Promise { - const cc = Logger.getCorrelationContext(); - - interface QueryResult { - viewer: { - name: string; - email: string; - login: string; - id: string; - }; - repository: { viewerPermission: string } | null | undefined; - } - - try { - const query = `query getCurrentUser( - $owner: String! - $repo: String! -) { - viewer { name, email, login, id } - repository(owner: $owner, name: $repo) { viewerPermission } -}`; - - const rsp = await this.graphql(token, query, { - owner: owner, - repo: repo, - }); - if (rsp == null) return undefined; - - return { - name: rsp.viewer?.name, - email: rsp.viewer?.email, - username: rsp.viewer?.login, - id: rsp.viewer?.id, - }; - } catch (ex) { - debugger; - return this.handleException(ex, cc, undefined); - } - } - - @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.handleException(ex, cc, undefined); - } - } - - @debug({ args: { 0: '' } }) - async getTags( - token: string, - owner: string, - repo: string, - options?: { query?: string; cursor?: string; limit?: number }, - ): Promise> { - const cc = Logger.getCorrelationContext(); - - interface QueryResult { - repository: - | { - refs: { - pageInfo: { - endCursor: string; - hasNextPage: boolean; - }; - nodes: GitHubTag[]; - }; - } - | null - | undefined; - } - - try { - const query = `query getTags( - $owner: String! - $repo: String! - $tagQuery: String - $cursor: String - $limit: Int = 100 -) { - repository(owner: $owner, name: $repo) { - refs(query: $tagQuery, refPrefix: "refs/tags/", first: $limit, after: $cursor, orderBy: { field: TAG_COMMIT_DATE, direction: DESC }) { - pageInfo { - endCursor - hasNextPage - } - nodes { - name - target { - oid - commitUrl - ...on Commit { - authoredDate - committedDate - message - } - ...on Tag { - message - tagger { date } - } - } - } - } - } -}`; - - const rsp = await this.graphql(token, query, { - owner: owner, - repo: repo, - tagQuery: options?.query, - cursor: options?.cursor, - limit: Math.min(100, options?.limit ?? 100), - }); - if (rsp == null) return emptyPagedResult; - - const refs = rsp.repository?.refs; - if (refs == null) return emptyPagedResult; - - return { - paging: { - cursor: refs.pageInfo.endCursor, - more: refs.pageInfo.hasNextPage, - }, - values: refs.nodes, - }; - } catch (ex) { - debugger; - return this.handleException(ex, cc, emptyPagedResult); - } - } - - @debug({ args: { 0: '' } }) - async resolveReference( - token: string, - owner: string, - repo: string, - ref: string, - path?: string, - ): Promise { - const cc = Logger.getCorrelationContext(); - - try { - if (!path) { - interface QueryResult { - repository: { object: GitHubCommitRef } | null | undefined; - } - - const query = `query resolveReference( - $owner: String! - $repo: String! - $ref: String! -) { - repository(owner: $owner, name: $repo) { - object(expression: $ref) { - oid - } - } -}`; - - const rsp = await this.graphql(token, query, { - owner: owner, - repo: repo, - ref: ref, - }); - return rsp?.repository?.object?.oid ?? undefined; - } - - interface QueryResult { - repository: - | { - object: { - history: { - nodes: GitHubCommitRef[]; - }; - }; - } - | null - | undefined; - } - - const query = `query resolveReference( - $owner: String! - $repo: String! - $ref: String! - $path: String! -) { - repository(owner: $owner, name: $repo) { - object(expression: $ref) { - ... on Commit { - history(first: 1, path: $path) { - nodes { oid } - } - } - } - } -}`; - - const rsp = await this.graphql(token, query, { - owner: owner, - repo: repo, - ref: ref, - path: path, - }); - return rsp?.repository?.object?.history.nodes?.[0]?.oid ?? undefined; - } catch (ex) { - debugger; - return this.handleException(ex, cc, undefined); - } - } - - @debug({ args: { 0: '' } }) - async searchCommits( - token: string, - query: string, - options?: { - cursor?: string; - limit?: number; - order?: 'asc' | 'desc' | undefined; - sort?: 'author-date' | 'committer-date' | undefined; - }, - ): Promise | undefined> { - const cc = Logger.getCorrelationContext(); - - const limit = Math.min(100, options?.limit ?? 100); - - let page; - let pageSize; - let previousCount; - if (options?.cursor != null) { - [page, pageSize, previousCount] = options.cursor.split(' ', 3); - page = parseInt(page, 10); - // TODO@eamodio need to figure out how allow different page sizes if the limit changes - pageSize = parseInt(pageSize, 10); - previousCount = parseInt(previousCount, 10); - } else { - page = 1; - pageSize = limit; - previousCount = 0; - } - - try { - const rsp = await this.request(token, 'GET /search/commits', { - q: query, - sort: options?.sort, - order: options?.order, - per_page: pageSize, - page: page, - }); - - const data = rsp?.data; - if (data == null || data.items.length === 0) return undefined; - - const commits = data.items.map(result => ({ - oid: result.sha, - parents: { nodes: result.parents.map(p => ({ oid: p.sha! })) }, - message: result.commit.message, - author: { - avatarUrl: result.author?.avatar_url ?? undefined, - date: result.commit.author?.date ?? result.commit.author?.date ?? new Date().toString(), - email: result.author?.email ?? result.commit.author?.email ?? undefined, - name: result.author?.name ?? result.commit.author?.name ?? '', - }, - committer: { - date: result.commit.committer?.date ?? result.committer?.date ?? new Date().toString(), - email: result.committer?.email ?? result.commit.committer?.email ?? undefined, - name: result.committer?.name ?? result.commit.committer?.name ?? '', - }, - })); - - const count = previousCount + data.items.length; - const hasMore = data.incomplete_results || data.total_count > count; - - return { - pageInfo: { - startCursor: `${page} ${pageSize} ${previousCount}`, - endCursor: hasMore ? `${page + 1} ${pageSize} ${count}` : undefined, - hasPreviousPage: data.total_count > 0 && page > 1, - hasNextPage: hasMore, - }, - totalCount: data.total_count, - values: commits, - }; - } catch (ex) { - debugger; - return this.handleException(ex, cc, undefined); - } - } - - private _octokits = new Map(); - private octokit(token: string, options?: ConstructorParameters[0]): Octokit { - let octokit = this._octokits.get(token); - if (octokit == null) { - let defaults; - if (isWeb) { - function fetchCore(url: string, options: { headers?: Record }) { - if (options.headers != null) { - // Strip out the user-agent (since it causes warnings in a webworker) - const { 'user-agent': userAgent, ...headers } = options.headers; - if (userAgent) { - options.headers = headers; - } - } - return fetch(url, options); - } - - defaults = Octokit.defaults({ - auth: `token ${token}`, - request: { fetch: fetchCore }, - }); - } else { - defaults = Octokit.defaults({ auth: `token ${token}` }); - } - - octokit = new defaults(options); - this._octokits.set(token, octokit); - - if (Logger.logLevel === LogLevel.Debug || Logger.isDebugging) { - octokit.hook.wrap('request', async (request, options) => { - const stopwatch = new Stopwatch(`[GITHUB] ${options.method} ${options.url}`, { log: false }); - try { - return await request(options); - } finally { - let message; - try { - if (typeof options.query === 'string') { - const match = /(^[^({\n]+)/.exec(options.query); - message = ` ${match?.[1].trim() ?? options.query}`; - } - } catch {} - stopwatch.stop({ message: message }); - } - }); - } - } - - return octokit; - } - - private async graphql(token: string, query: string, variables: { [key: string]: any }): Promise { - try { - return await this.octokit(token).graphql(query, variables); - } catch (ex) { - if (ex instanceof GraphqlResponseError) { - switch (ex.errors?.[0]?.type) { - case 'NOT_FOUND': - throw new ProviderRequestNotFoundError(ex); - case 'FORBIDDEN': - throw new AuthenticationError('github', AuthenticationErrorReason.Forbidden, ex); - } - - void window.showErrorMessage(`GitHub request failed: ${ex.errors?.[0]?.message ?? ex.message}`, 'OK'); - } else if (ex instanceof RequestError) { - this.handleRequestError(ex); - } else { - void window.showErrorMessage(`GitHub request failed: ${ex.message}`, 'OK'); - } - - throw ex; - } - } - - private async request( - token: string, - route: keyof Endpoints | R, - options?: R extends keyof Endpoints ? Endpoints[R]['parameters'] & RequestParameters : RequestParameters, - ): Promise> { - try { - return (await this.octokit(token).request(route, options)) as any; - } catch (ex) { - if (ex instanceof RequestError) { - this.handleRequestError(ex); - } else { - void window.showErrorMessage(`GitHub request failed: ${ex.message}`, 'OK'); - } - - throw ex; - } - } - - private handleRequestError(ex: RequestError): void { - switch (ex.status) { - case 404: // Not found - case 410: // Gone - case 422: // Unprocessable Entity - throw new ProviderRequestNotFoundError(ex); - // case 429: //Too Many Requests - case 401: // Unauthorized - throw new AuthenticationError('github', AuthenticationErrorReason.Unauthorized, ex); - case 403: // Forbidden - throw new AuthenticationError('github', AuthenticationErrorReason.Forbidden, ex); - case 500: // Internal Server Error - if (ex.response != null) { - void window.showErrorMessage( - 'GitHub failed to respond and might be experiencing issues. Please visit the [GitHub status page](https://githubstatus.com) for more information.', - 'OK', - ); - } - break; - case 502: // Bad Gateway - // GitHub seems to return this status code for timeouts - if (ex.message.includes('timeout')) { - void window.showErrorMessage('GitHub request timed out', 'OK'); - return; - } - break; - default: - if (ex.status >= 400 && ex.status < 500) throw new ProviderRequestClientError(ex); - break; - } - - void window.showErrorMessage( - `GitHub request failed: ${(ex.response as any)?.errors?.[0]?.message ?? ex.message}`, - 'OK', - ); - } - - private handleException(ex: unknown | Error, cc: LogCorrelationContext | undefined, defaultValue: T): T { - if (ex instanceof ProviderRequestNotFoundError) return defaultValue; - - Logger.error(ex, cc); - debugger; - - if (ex instanceof AuthenticationError) { - void this.showAuthenticationErrorMessage(ex); - } - throw ex; - } - - private async showAuthenticationErrorMessage(ex: AuthenticationError) { - if (ex.reason === AuthenticationErrorReason.Unauthorized || ex.reason === AuthenticationErrorReason.Forbidden) { - const confirm = 'Reauthenticate'; - const result = await window.showErrorMessage( - `${ex.message}. Would you like to try reauthenticating${ - ex.reason === AuthenticationErrorReason.Forbidden ? ' to provide additional access' : '' - }?`, - confirm, - ); - - if (result === confirm) { - this._onDidReauthenticate.fire(); - } - } else { - void window.showErrorMessage(ex.message, 'OK'); - } - } -} - -export interface GitHubBlame { - ranges: GitHubBlameRange[]; - viewer?: string; -} - -export interface GitHubBlameRange { - startingLine: number; - endingLine: number; - commit: GitHubCommit; -} - -export interface GitHubBranch { - name: string; - target: { - oid: string; - commitUrl: string; - authoredDate: string; - committedDate: string; - }; -} - -export interface GitHubCommit { - oid: string; - parents: { nodes: { oid: string }[] }; - message: string; - additions?: number | undefined; - changedFiles?: number | undefined; - deletions?: number | undefined; - author: { avatarUrl: string | undefined; date: string; email: string | undefined; name: string }; - committer: { date: string; email: string | undefined; name: string }; - - files?: Endpoints['GET /repos/{owner}/{repo}/commits/{ref}']['response']['data']['files']; -} - -export interface GitHubCommitRef { - oid: string; -} - -export type GitHubContributor = Endpoints['GET /repos/{owner}/{repo}/contributors']['response']['data'][0]; - -interface GitHubIssueOrPullRequest { - type: IssueOrPullRequestType; - number: number; - createdAt: string; - closed: boolean; - closedAt: string | null; - title: string; - url: string; -} - -export interface GitHubPagedResult { - pageInfo: GitHubPageInfo; - totalCount: number; - values: T[]; -} - -interface GitHubPageInfo { - startCursor?: string | null; - endCursor?: string | null; - hasNextPage: boolean; - hasPreviousPage: boolean; -} - -type GitHubPullRequestState = 'OPEN' | 'CLOSED' | 'MERGED'; - -interface GitHubPullRequest { - author: { - login: string; - avatarUrl: string; - url: string; - }; - permalink: string; - number: number; - title: string; - state: GitHubPullRequestState; - updatedAt: string; - closedAt: string | null; - mergedAt: string | null; - repository: { - isFork: boolean; - owner: { - login: string; - }; - }; -} - -export namespace GitHubPullRequest { - export function from(pr: GitHubPullRequest, provider: RichRemoteProvider): PullRequest { - return new PullRequest( - provider, - { - name: pr.author.login, - avatarUrl: pr.author.avatarUrl, - url: pr.author.url, - }, - String(pr.number), - pr.title, - pr.permalink, - fromState(pr.state), - new Date(pr.updatedAt), - pr.closedAt == null ? undefined : new Date(pr.closedAt), - pr.mergedAt == null ? undefined : new Date(pr.mergedAt), - ); - } - - export function fromState(state: GitHubPullRequestState): PullRequestState { - return state === 'MERGED' - ? PullRequestState.Merged - : state === 'CLOSED' - ? PullRequestState.Closed - : PullRequestState.Open; - } - - export function toState(state: PullRequestState): GitHubPullRequestState { - return state === PullRequestState.Merged ? 'MERGED' : state === PullRequestState.Closed ? 'CLOSED' : 'OPEN'; - } -} - -export interface GitHubTag { - name: string; - target: { - oid: string; - commitUrl: string; - authoredDate: string; - committedDate: string; - message?: string | null; - tagger?: { - date: string; - } | null; - }; -} - -export function fromCommitFileStatus( - status: NonNullable[0]['status'], -): GitFileIndexStatus | undefined { - switch (status) { - case 'added': - return GitFileIndexStatus.Added; - case 'changed': - case 'modified': - return GitFileIndexStatus.Modified; - case 'removed': - return GitFileIndexStatus.Deleted; - case 'renamed': - return GitFileIndexStatus.Renamed; - case 'copied': - return GitFileIndexStatus.Copied; - } - return undefined; -} diff --git a/src/premium/github/githubGitProvider.ts b/src/premium/github/githubGitProvider.ts deleted file mode 100644 index bd372a5..0000000 --- a/src/premium/github/githubGitProvider.ts +++ /dev/null @@ -1,2727 +0,0 @@ -/* eslint-disable @typescript-eslint/require-await */ -import { - authentication, - AuthenticationSession, - Disposable, - Event, - EventEmitter, - FileType, - Range, - TextDocument, - Uri, - window, - workspace, - WorkspaceFolder, -} from 'vscode'; -import { encodeUtf8Hex } from '@env/hex'; -import { configuration } from '../../configuration'; -import { CharCode, ContextKeys, Schemes } from '../../constants'; -import type { Container } from '../../container'; -import { setContext } from '../../context'; -import { - AuthenticationError, - AuthenticationErrorReason, - ExtensionNotFoundError, - OpenVirtualRepositoryError, - OpenVirtualRepositoryErrorReason, -} from '../../errors'; -import { Features, PlusFeatures } from '../../features'; -import { - GitProvider, - GitProviderId, - NextComparisionUrisResult, - PagedResult, - PreviousComparisionUrisResult, - PreviousLineComparisionUrisResult, - RepositoryCloseEvent, - RepositoryOpenEvent, - RepositoryVisibility, - ScmRepository, -} from '../../git/gitProvider'; -import { GitProviderService } from '../../git/gitProviderService'; -import { GitUri } from '../../git/gitUri'; -import { - BranchSortOptions, - GitBlame, - GitBlameAuthor, - GitBlameLine, - GitBlameLines, - GitBranch, - GitBranchReference, - GitCommit, - GitCommitIdentity, - GitCommitLine, - GitContributor, - GitDiff, - GitDiffFilter, - GitDiffHunkLine, - GitDiffShortStat, - GitFile, - GitFileChange, - GitFileIndexStatus, - GitLog, - GitMergeStatus, - GitRebaseStatus, - GitReference, - GitReflog, - GitRemote, - GitRemoteType, - GitRevision, - GitStash, - GitStatus, - GitStatusFile, - GitTag, - GitTreeEntry, - GitUser, - isUserMatch, - Repository, - RepositoryChangeEvent, - TagSortOptions, -} from '../../git/models'; -import { RemoteProviderFactory, RemoteProviders } from '../../git/remotes/factory'; -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'; -import { isAbsolute, isFolderGlob, maybeUri, normalizePath, relative } from '../../system/path'; -import { CachedBlame, CachedLog, GitDocumentState } from '../../trackers/gitDocumentTracker'; -import { TrackedDocument } from '../../trackers/trackedDocument'; -import { fromCommitFileStatus, GitHubApi } from '../github/github'; -import { getRemoteHubApi, GitHubAuthorityMetadata, Metadata, RemoteHubApi } from '../remotehub'; - -const emptyPagedResult: PagedResult = Object.freeze({ values: [] }); -const emptyPromise: Promise = Promise.resolve(undefined); - -const githubAuthenticationScopes = ['repo', 'read:user', 'user:email']; - -// Since negative lookbehind isn't supported in all browsers, this leaves out the negative lookbehind condition `(? = new Set([Schemes.Virtual, Schemes.GitHub, Schemes.PRs]); - - private _onDidChangeRepository = new EventEmitter(); - get onDidChangeRepository(): Event { - return this._onDidChangeRepository.event; - } - - private _onDidCloseRepository = new EventEmitter(); - get onDidCloseRepository(): Event { - return this._onDidCloseRepository.event; - } - - private _onDidOpenRepository = new EventEmitter(); - get onDidOpenRepository(): Event { - return this._onDidOpenRepository.event; - } - - private readonly _branchesCache = new Map>>(); - private readonly _repoInfoCache = new Map(); - private readonly _tagsCache = new Map>>(); - - private readonly _disposables: Disposable[] = []; - - constructor(private readonly container: Container) {} - - dispose() { - this._disposables.forEach(d => d.dispose()); - } - - private onRepositoryChanged(repo: Repository, e: RepositoryChangeEvent) { - // if (e.changed(RepositoryChange.Config, RepositoryChangeComparisonMode.Any)) { - // this._repoInfoCache.delete(repo.path); - // } - - // if (e.changed(RepositoryChange.Heads, RepositoryChange.Remotes, RepositoryChangeComparisonMode.Any)) { - // this._branchesCache.delete(repo.path); - // } - - this._branchesCache.delete(repo.path); - this._tagsCache.delete(repo.path); - this._repoInfoCache.delete(repo.path); - - this._onDidChangeRepository.fire(e); - } - - async discoverRepositories(uri: Uri): Promise { - if (!this.supportedSchemes.has(uri.scheme)) return []; - - try { - const { remotehub } = await this.ensureRepositoryContext(uri.toString(), true); - const workspaceUri = remotehub.getVirtualWorkspaceUri(uri); - if (workspaceUri == null) return []; - - return [this.openRepository(undefined, workspaceUri, true)]; - } catch { - return []; - } - } - - updateContext(): void { - void setContext(ContextKeys.HasVirtualFolders, this.container.git.hasOpenRepositories(this.descriptor.id)); - } - - openRepository( - folder: WorkspaceFolder | undefined, - uri: Uri, - root: boolean, - suspended?: boolean, - closed?: boolean, - ): Repository { - return new Repository( - this.container, - this.onRepositoryChanged.bind(this), - this.descriptor, - folder, - uri, - root, - suspended ?? !window.state.focused, - closed, - ); - } - - private _allowedFeatures = new Map>(); - async allows(feature: PlusFeatures, 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; - - switch (feature) { - case Features.Worktrees: - return false; - default: - return true; - } - } - - 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); - } - - 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 []; - } - - async getOrOpenScmRepository(_repoPath: string): Promise { - return undefined; - } - - canHandlePathOrUri(scheme: string, pathOrUri: string | Uri): string | undefined { - if (!this.supportedSchemes.has(scheme)) return undefined; - return typeof pathOrUri === 'string' ? pathOrUri : pathOrUri.toString(); - } - - getAbsoluteUri(pathOrUri: string | Uri, base: string | Uri): Uri { - // Convert the base to a Uri if it isn't one - if (typeof base === 'string') { - // If it looks like a Uri parse it, otherwise throw - if (maybeUri(base)) { - base = Uri.parse(base, true); - } else { - debugger; - void window.showErrorMessage( - `Unable to get absolute uri between ${ - typeof pathOrUri === 'string' ? pathOrUri : pathOrUri.toString(false) - } and ${base}; Base path '${base}' must be a uri`, - ); - throw new Error(`Base path '${base}' must be a uri`); - } - } - - if (typeof pathOrUri === 'string' && !maybeUri(pathOrUri) && !isAbsolute(pathOrUri)) { - return Uri.joinPath(base, normalizePath(pathOrUri)); - } - - const relativePath = this.getRelativePath(pathOrUri, base); - return Uri.joinPath(base, relativePath); - } - - @log() - async getBestRevisionUri(repoPath: string, path: string, ref: string | undefined): Promise { - return ref ? this.createProviderUri(repoPath, ref, path) : this.createVirtualUri(repoPath, ref, path); - } - - getRelativePath(pathOrUri: string | Uri, base: string | Uri): string { - // Convert the base to a Uri if it isn't one - if (typeof base === 'string') { - // If it looks like a Uri parse it, otherwise throw - if (maybeUri(base)) { - base = Uri.parse(base, true); - } else { - debugger; - void window.showErrorMessage( - `Unable to get relative path between ${ - typeof pathOrUri === 'string' ? pathOrUri : pathOrUri.toString(false) - } and ${base}; Base path '${base}' must be a uri`, - ); - throw new Error(`Base path '${base}' must be a uri`); - } - } - - let relativePath; - - // Convert the path to a Uri if it isn't one - if (typeof pathOrUri === 'string') { - if (maybeUri(pathOrUri)) { - pathOrUri = Uri.parse(pathOrUri, true); - } else { - pathOrUri = normalizePath(pathOrUri); - relativePath = - isAbsolute(pathOrUri) && pathOrUri.startsWith(base.path) - ? pathOrUri.slice(base.path.length) - : pathOrUri; - if (relativePath.charCodeAt(0) === CharCode.Slash) { - relativePath = relativePath.slice(1); - } - return relativePath; - } - } - - relativePath = normalizePath(relative(base.path.slice(1), pathOrUri.path.slice(1))); - return relativePath; - } - - getRevisionUri(repoPath: string, path: string, ref: string): Uri { - const uri = this.createProviderUri(repoPath, ref, path); - return ref === GitRevision.deletedOrMissing ? uri.with({ query: '~' }) : uri; - } - - @log() - async getWorkingUri(repoPath: string, uri: Uri) { - return this.createVirtualUri(repoPath, undefined, uri.path); - } - - @log() - async addRemote(_repoPath: string, _name: string, _url: string): Promise {} - - @log() - async pruneRemote(_repoPath: string, _remoteName: string): Promise {} - - @log() - async applyChangesToWorkingFile(_uri: GitUri, _ref1?: string, _ref2?: string): Promise {} - - @log() - async branchContainsCommit(_repoPath: string, _name: string, _ref: string): Promise { - return false; - } - - @log() - async checkout( - _repoPath: string, - _ref: string, - _options?: { createBranch?: string } | { path?: string }, - ): Promise {} - - @log() - resetCaches( - ...affects: ('branches' | 'contributors' | 'providers' | 'remotes' | 'stashes' | 'status' | 'tags')[] - ): void { - if (affects.length === 0 || affects.includes('branches')) { - this._branchesCache.clear(); - } - - if (affects.length === 0 || affects.includes('tags')) { - this._tagsCache.clear(); - } - - if (affects.length === 0) { - this._repoInfoCache.clear(); - } - } - - @log({ args: { 1: uris => uris.length } }) - async excludeIgnoredUris(_repoPath: string, uris: Uri[]): Promise { - return uris; - } - - // @gate() - @log() - async fetch( - _repoPath: string, - _options?: { all?: boolean; branch?: GitBranchReference; prune?: boolean; pull?: boolean; remote?: string }, - ): Promise {} - - @gate() - @debug() - async findRepositoryUri(uri: Uri, _isDirectory?: boolean): Promise { - const cc = Logger.getCorrelationContext(); - - try { - const remotehub = await this.ensureRemoteHubApi(); - const rootUri = remotehub.getProviderRootUri(uri).with({ scheme: Schemes.Virtual }); - return rootUri; - } catch (ex) { - if (!(ex instanceof ExtensionNotFoundError)) { - debugger; - } - Logger.error(ex, cc); - - return undefined; - } - } - - @log({ args: { 1: refs => refs.join(',') } }) - async getAheadBehindCommitCount( - _repoPath: string, - _refs: string[], - ): Promise<{ ahead: number; behind: number } | undefined> { - return undefined; - } - - @gate() - @log() - async getBlame(uri: GitUri, document?: TextDocument | undefined): Promise { - const cc = Logger.getCorrelationContext(); - - // TODO@eamodio we need to figure out when to do this, since dirty isn't enough, we need to know if there are any uncommitted changes - if (document?.isDirty) return undefined; //this.getBlameContents(uri, document.getText()); - - let key = 'blame'; - if (uri.sha != null) { - key += `:${uri.sha}`; - } - - const doc = await this.container.tracker.getOrAdd(uri); - if (doc.state != null) { - const cachedBlame = doc.state.getBlame(key); - if (cachedBlame != null) { - Logger.debug(cc, `Cache hit: '${key}'`); - return cachedBlame.item; - } - } - - Logger.debug(cc, `Cache miss: '${key}'`); - - if (doc.state == null) { - doc.state = new GitDocumentState(doc.key); - } - - const promise = this.getBlameCore(uri, doc, key, cc); - - if (doc.state != null) { - Logger.debug(cc, `Cache add: '${key}'`); - - const value: CachedBlame = { - item: promise as Promise, - }; - doc.state.setBlame(key, value); - } - - return promise; - } - - private async getBlameCore( - uri: GitUri, - document: TrackedDocument, - key: string, - cc: LogCorrelationContext | undefined, - ): Promise { - try { - const context = await this.ensureRepositoryContext(uri.repoPath!); - if (context == null) return undefined; - const { metadata, github, remotehub, session } = context; - - const root = remotehub.getVirtualUri(remotehub.getProviderRootUri(uri)); - const relativePath = this.getRelativePath(uri, root); - - if (uri.scheme === Schemes.Virtual) { - const [working, committed] = await Promise.allSettled([ - workspace.fs.stat(uri), - workspace.fs.stat(uri.with({ scheme: Schemes.GitHub })), - ]); - if ( - working.status !== 'fulfilled' || - committed.status !== 'fulfilled' || - working.value.mtime !== committed.value.mtime - ) { - return undefined; - } - } - - const ref = !uri.sha || uri.sha === 'HEAD' ? (await metadata.getRevision()).revision : uri.sha; - const blame = await github.getBlame( - session.accessToken, - metadata.repo.owner, - metadata.repo.name, - ref, - relativePath, - ); - - const authors = new Map(); - const commits = new Map(); - const lines: GitCommitLine[] = []; - - for (const range of blame.ranges) { - const c = range.commit; - - const { viewer = session.account.label } = blame; - const authorName = viewer != null && c.author.name === viewer ? 'You' : c.author.name; - const committerName = viewer != null && c.committer.name === viewer ? 'You' : c.committer.name; - - let author = authors.get(authorName); - if (author == null) { - author = { - name: authorName, - lineCount: 0, - }; - authors.set(authorName, author); - } - - author.lineCount += range.endingLine - range.startingLine + 1; - - let commit = commits.get(c.oid); - if (commit == null) { - commit = new GitCommit( - this.container, - uri.repoPath!, - c.oid, - new GitCommitIdentity(authorName, c.author.email, new Date(c.author.date), c.author.avatarUrl), - new GitCommitIdentity(committerName, c.committer.email, new Date(c.author.date)), - c.message.split('\n', 1)[0], - c.parents.nodes[0]?.oid ? [c.parents.nodes[0]?.oid] : [], - c.message, - new GitFileChange(root.toString(), relativePath, GitFileIndexStatus.Modified), - { changedFiles: c.changedFiles ?? 0, additions: c.additions ?? 0, deletions: c.deletions ?? 0 }, - [], - ); - - commits.set(c.oid, commit); - } - - for (let i = range.startingLine; i <= range.endingLine; i++) { - // GitHub doesn't currently support returning the original line number, so we are just using the current one - const line: GitCommitLine = { sha: c.oid, originalLine: i, line: i }; - - commit.lines.push(line); - lines[i - 1] = line; - } - } - - const sortedAuthors = new Map([...authors.entries()].sort((a, b) => b[1].lineCount - a[1].lineCount)); - - return { - repoPath: uri.repoPath!, - authors: sortedAuthors, - commits: commits, - lines: lines, - }; - } catch (ex) { - debugger; - // Trap and cache expected blame errors - if (document.state != null && !/No provider registered with/.test(String(ex))) { - const msg = ex?.toString() ?? ''; - Logger.debug(cc, `Cache replace (with empty promise): '${key}'`); - - const value: CachedBlame = { - item: emptyPromise as Promise, - errorMessage: msg, - }; - document.state.setBlame(key, value); - - document.setBlameFailure(); - - return emptyPromise as Promise; - } - - return undefined; - } - } - - @log({ args: { 1: '' } }) - async getBlameContents(_uri: GitUri, _contents: string): Promise { - // TODO@eamodio figure out how to actually generate a blame given the contents (need to generate a diff) - return undefined; //this.getBlame(uri); - } - - @gate() - @log() - async getBlameForLine( - uri: GitUri, - editorLine: number, // 0-based, Git is 1-based - document?: TextDocument | undefined, - options?: { forceSingleLine?: boolean }, - ): Promise { - const cc = Logger.getCorrelationContext(); - - // TODO@eamodio we need to figure out when to do this, since dirty isn't enough, we need to know if there are any uncommitted changes - if (document?.isDirty) return undefined; //this.getBlameForLineContents(uri, editorLine, document.getText(), options); - - if (!options?.forceSingleLine) { - const blame = await this.getBlame(uri); - if (blame == null) return undefined; - - let blameLine = blame.lines[editorLine]; - if (blameLine == null) { - if (blame.lines.length !== editorLine) return undefined; - blameLine = blame.lines[editorLine - 1]; - } - - const commit = blame.commits.get(blameLine.sha); - if (commit == null) return undefined; - - const author = blame.authors.get(commit.author.name)!; - return { - author: { ...author, lineCount: commit.lines.length }, - commit: commit, - line: blameLine, - }; - } - - try { - const context = await this.ensureRepositoryContext(uri.repoPath!); - if (context == null) return undefined; - const { metadata, github, remotehub, session } = context; - - const root = remotehub.getVirtualUri(remotehub.getProviderRootUri(uri)); - const relativePath = this.getRelativePath(uri, root); - - const ref = !uri.sha || uri.sha === 'HEAD' ? (await metadata.getRevision()).revision : uri.sha; - const blame = await github.getBlame( - session.accessToken, - metadata.repo.owner, - metadata.repo.name, - ref, - relativePath, - ); - - const startingLine = editorLine + 1; - const range = blame.ranges.find(r => r.startingLine === startingLine); - if (range == null) return undefined; - - const c = range.commit; - - const { viewer = session.account.label } = blame; - const authorName = viewer != null && c.author.name === viewer ? 'You' : c.author.name; - const committerName = viewer != null && c.committer.name === viewer ? 'You' : c.committer.name; - - const commit = new GitCommit( - this.container, - uri.repoPath!, - c.oid, - new GitCommitIdentity(authorName, c.author.email, new Date(c.author.date), c.author.avatarUrl), - new GitCommitIdentity(committerName, c.committer.email, new Date(c.author.date)), - c.message.split('\n', 1)[0], - c.parents.nodes[0]?.oid ? [c.parents.nodes[0]?.oid] : [], - c.message, - new GitFileChange(root.toString(), relativePath, GitFileIndexStatus.Modified), - { changedFiles: c.changedFiles ?? 0, additions: c.additions ?? 0, deletions: c.deletions ?? 0 }, - [], - ); - - for (let i = range.startingLine; i <= range.endingLine; i++) { - // GitHub doesn't currently support returning the original line number, so we are just using the current one - const line: GitCommitLine = { sha: c.oid, originalLine: i, line: i }; - - commit.lines.push(line); - } - - return { - author: { - name: authorName, - lineCount: range.endingLine - range.startingLine + 1, - }, - commit: commit, - // GitHub doesn't currently support returning the original line number, so we are just using the current one - line: { sha: c.oid, originalLine: range.startingLine, line: range.startingLine }, - }; - } catch (ex) { - debugger; - Logger.error(cc, ex); - return undefined; - } - } - - @log({ args: { 2: '' } }) - async getBlameForLineContents( - _uri: GitUri, - _editorLine: number, // 0-based, Git is 1-based - _contents: string, - _options?: { forceSingleLine?: boolean }, - ): Promise { - // TODO@eamodio figure out how to actually generate a blame given the contents (need to generate a diff) - return undefined; //this.getBlameForLine(uri, editorLine); - } - - @log() - async getBlameForRange(uri: GitUri, range: Range): Promise { - const blame = await this.getBlame(uri); - if (blame == null) return undefined; - - return this.getBlameRange(blame, uri, range); - } - - @log({ args: { 2: '' } }) - async getBlameForRangeContents(uri: GitUri, range: Range, contents: string): Promise { - const blame = await this.getBlameContents(uri, contents); - if (blame == null) return undefined; - - return this.getBlameRange(blame, uri, range); - } - - @log({ args: { 0: '' } }) - getBlameRange(blame: GitBlame, uri: GitUri, range: Range): GitBlameLines | undefined { - if (blame.lines.length === 0) return { allLines: blame.lines, ...blame }; - - if (range.start.line === 0 && range.end.line === blame.lines.length - 1) { - return { allLines: blame.lines, ...blame }; - } - - const lines = blame.lines.slice(range.start.line, range.end.line + 1); - const shas = new Set(lines.map(l => l.sha)); - - // ranges are 0-based - const startLine = range.start.line + 1; - const endLine = range.end.line + 1; - - const authors = new Map(); - const commits = new Map(); - for (const c of blame.commits.values()) { - if (!shas.has(c.sha)) continue; - - const commit = c.with({ - lines: c.lines.filter(l => l.line >= startLine && l.line <= endLine), - }); - commits.set(c.sha, commit); - - let author = authors.get(commit.author.name); - if (author == null) { - author = { - name: commit.author.name, - lineCount: 0, - }; - authors.set(author.name, author); - } - - author.lineCount += commit.lines.length; - } - - const sortedAuthors = new Map([...authors.entries()].sort((a, b) => b[1].lineCount - a[1].lineCount)); - - return { - repoPath: uri.repoPath!, - authors: sortedAuthors, - commits: commits, - lines: lines, - allLines: blame.lines, - }; - } - - @log() - async getBranch(repoPath: string | undefined): Promise { - const { - values: [branch], - } = await this.getBranches(repoPath, { filter: b => b.current }); - return branch; - } - - @log({ args: { 1: false } }) - async getBranches( - repoPath: string | undefined, - options?: { - cursor?: string; - filter?: (b: GitBranch) => boolean; - sort?: boolean | BranchSortOptions; - }, - ): Promise> { - if (repoPath == null) return emptyPagedResult; - - const cc = Logger.getCorrelationContext(); - - let branchesPromise = options?.cursor ? undefined : this._branchesCache.get(repoPath); - if (branchesPromise == null) { - async function load(this: GitHubGitProvider): Promise> { - try { - const { metadata, github, session } = await this.ensureRepositoryContext(repoPath!); - - const revision = await metadata.getRevision(); - const current = revision.type === 0 /* HeadType.Branch */ ? revision.name : undefined; - - const branches: GitBranch[] = []; - - let cursor = options?.cursor; - const loadAll = cursor == null; - - while (true) { - const result = await github.getBranches( - session.accessToken, - metadata.repo.owner, - metadata.repo.name, - { cursor: cursor }, - ); - - for (const branch of result.values) { - const date = new Date( - this.container.config.advanced.commitOrdering === 'author-date' - ? branch.target.authoredDate - : branch.target.committedDate, - ); - const ref = branch.target.oid; - - branches.push( - new GitBranch(repoPath!, branch.name, false, branch.name === current, date, ref, { - name: `origin/${branch.name}`, - missing: false, - }), - new GitBranch(repoPath!, `origin/${branch.name}`, true, false, date, ref), - ); - } - - if (!result.paging?.more || !loadAll) return { ...result, values: branches }; - - cursor = result.paging.cursor; - } - } catch (ex) { - Logger.error(ex, cc); - debugger; - - this._branchesCache.delete(repoPath!); - return emptyPagedResult; - } - } - - branchesPromise = load.call(this); - if (options?.cursor == null) { - this._branchesCache.set(repoPath, branchesPromise); - } - } - - let result = await branchesPromise; - if (options?.filter != null) { - result = { - ...result, - values: result.values.filter(options.filter), - }; - } - - if (options?.sort != null) { - GitBranch.sort(result.values, typeof options.sort === 'boolean' ? undefined : options.sort); - } - - return result; - } - - @log() - async getChangedFilesCount(repoPath: string, ref?: string): Promise { - // TODO@eamodio if there is no ref we can't return anything, until we can get at the change store from RemoteHub - if (!ref) return undefined; - - const commit = await this.getCommit(repoPath, ref); - if (commit?.stats == null) return undefined; - - const { stats } = commit; - - const changedFiles = - typeof stats.changedFiles === 'number' - ? stats.changedFiles - : stats.changedFiles.added + stats.changedFiles.changed + stats.changedFiles.deleted; - return { additions: stats.additions, deletions: stats.deletions, changedFiles: changedFiles }; - } - - @log() - async getCommit(repoPath: string, ref: string): Promise { - if (repoPath == null) return undefined; - - const cc = Logger.getCorrelationContext(); - - try { - const { metadata, github, session } = await this.ensureRepositoryContext(repoPath); - - const commit = await github.getCommit(session.accessToken, metadata.repo.owner, metadata.repo.name, ref); - if (commit == null) return undefined; - - const { viewer = session.account.label } = commit; - const authorName = viewer != null && commit.author.name === viewer ? 'You' : commit.author.name; - const committerName = viewer != null && commit.committer.name === viewer ? 'You' : commit.committer.name; - - return new GitCommit( - this.container, - repoPath, - commit.oid, - new GitCommitIdentity( - authorName, - commit.author.email, - new Date(commit.author.date), - commit.author.avatarUrl, - ), - new GitCommitIdentity(committerName, commit.committer.email, new Date(commit.committer.date)), - commit.message.split('\n', 1)[0], - commit.parents.nodes.map(p => p.oid), - commit.message, - commit.files?.map( - f => - new GitFileChange( - repoPath, - f.filename ?? '', - fromCommitFileStatus(f.status) ?? GitFileIndexStatus.Modified, - f.previous_filename, - undefined, - { additions: f.additions ?? 0, deletions: f.deletions ?? 0, changes: f.changes ?? 0 }, - ), - ) ?? [], - { - changedFiles: commit.changedFiles ?? 0, - additions: commit.additions ?? 0, - deletions: commit.deletions ?? 0, - }, - [], - ); - } catch (ex) { - Logger.error(ex, cc); - debugger; - return undefined; - } - } - - @log() - async getCommitBranches( - repoPath: string, - ref: string, - options?: { branch?: string; commitDate?: Date; mode?: 'contains' | 'pointsAt'; remotes?: boolean }, - ): Promise { - if (repoPath == null || options?.commitDate == null) return []; - - const cc = Logger.getCorrelationContext(); - - try { - const { metadata, github, session } = await this.ensureRepositoryContext(repoPath); - - let branches; - - if (options?.branch) { - branches = await github.getCommitOnBranch( - session.accessToken, - metadata.repo.owner, - metadata.repo.name, - options?.branch, - ref, - options?.commitDate, - ); - } else { - branches = await github.getCommitBranches( - session.accessToken, - metadata.repo.owner, - metadata.repo.name, - ref, - options?.commitDate, - ); - } - - return branches; - } catch (ex) { - Logger.error(ex, cc); - debugger; - return []; - } - } - - @log() - async getCommitCount(repoPath: string, ref: string): Promise { - if (repoPath == null) return undefined; - - const cc = Logger.getCorrelationContext(); - - try { - const { metadata, github, session } = await this.ensureRepositoryContext(repoPath); - - const count = await github.getCommitCount( - session?.accessToken, - metadata.repo.owner, - metadata.repo.name, - ref, - ); - - return count; - } catch (ex) { - Logger.error(ex, cc); - debugger; - return undefined; - } - } - - @log() - async getCommitForFile( - repoPath: string | undefined, - uri: Uri, - options?: { ref?: string; firstIfNotFound?: boolean; range?: Range }, - ): Promise { - if (repoPath == null) return undefined; - - const cc = Logger.getCorrelationContext(); - - try { - const { metadata, github, remotehub, session } = await this.ensureRepositoryContext(repoPath); - - const file = this.getRelativePath(uri, remotehub.getProviderRootUri(uri)); - - const ref = !options?.ref || options.ref === 'HEAD' ? (await metadata.getRevision()).revision : options.ref; - const commit = await github.getCommitForFile( - session.accessToken, - metadata.repo.owner, - metadata.repo.name, - ref, - file, - ); - if (commit == null) return undefined; - - const { viewer = session.account.label } = commit; - const authorName = viewer != null && commit.author.name === viewer ? 'You' : commit.author.name; - const committerName = viewer != null && commit.committer.name === viewer ? 'You' : commit.committer.name; - - const files = commit.files?.map( - f => - new GitFileChange( - repoPath, - f.filename ?? '', - fromCommitFileStatus(f.status) ?? GitFileIndexStatus.Modified, - f.previous_filename, - undefined, - { additions: f.additions ?? 0, deletions: f.deletions ?? 0, changes: f.changes ?? 0 }, - ), - ); - const foundFile = files?.find(f => f.path === file); - - return new GitCommit( - this.container, - repoPath, - commit.oid, - new GitCommitIdentity( - authorName, - commit.author.email, - new Date(commit.author.date), - commit.author.avatarUrl, - ), - new GitCommitIdentity(committerName, commit.committer.email, new Date(commit.committer.date)), - commit.message.split('\n', 1)[0], - commit.parents.nodes.map(p => p.oid), - commit.message, - { file: foundFile, files: files }, - { - changedFiles: commit.changedFiles ?? 0, - additions: commit.additions ?? 0, - deletions: commit.deletions ?? 0, - }, - [], - ); - } catch (ex) { - Logger.error(ex, cc); - debugger; - return undefined; - } - } - - @log() - async getOldestUnpushedRefForFile(_repoPath: string, _uri: Uri): Promise { - // TODO@eamodio until we have access to the RemoteHub change store there isn't anything we can do here - return undefined; - } - - @log() - async getContributors( - repoPath: string, - _options?: { all?: boolean; ref?: string; stats?: boolean }, - ): Promise { - if (repoPath == null) return []; - - const cc = Logger.getCorrelationContext(); - - try { - const { metadata, github, session } = await this.ensureRepositoryContext(repoPath); - - const results = await github.getContributors(session.accessToken, metadata.repo.owner, metadata.repo.name); - const currentUser = await this.getCurrentUser(repoPath); - - const contributors = []; - for (const c of results) { - if (c.type !== 'User') continue; - - contributors.push( - new GitContributor( - repoPath, - c.name, - c.email, - c.contributions, - undefined, - isUserMatch(currentUser, c.name, c.email, c.login), - undefined, - c.login, - c.avatar_url, - c.node_id, - ), - ); - } - - return contributors; - } catch (ex) { - Logger.error(ex, cc); - debugger; - return []; - } - } - - @gate() - @log() - async getCurrentUser(repoPath: string): Promise { - if (!repoPath) return undefined; - - const cc = Logger.getCorrelationContext(); - - const repo = this._repoInfoCache.get(repoPath); - - let user = repo?.user; - if (user != null) return user; - // If we found the repo, but no user data was found just return - if (user === null) return undefined; - - try { - const { metadata, github, session } = await this.ensureRepositoryContext(repoPath); - user = await github.getCurrentUser(session.accessToken, metadata.repo.owner, metadata.repo.name); - - this._repoInfoCache.set(repoPath, { ...repo, user: user ?? null }); - return user; - } catch (ex) { - Logger.error(ex, cc); - debugger; - - // Mark it so we won't bother trying again - this._repoInfoCache.set(repoPath, { ...repo, user: null }); - return undefined; - } - } - - @log() - async getDefaultBranchName(repoPath: string | undefined, _remote?: string): Promise { - if (repoPath == null) return undefined; - - const cc = Logger.getCorrelationContext(); - - try { - const { metadata, github, session } = await this.ensureRepositoryContext(repoPath); - return await github.getDefaultBranchName(session.accessToken, metadata.repo.owner, metadata.repo.name); - } catch (ex) { - Logger.error(ex, cc); - debugger; - return undefined; - } - } - - @log() - async getDiffForFile(_uri: GitUri, _ref1: string | undefined, _ref2?: string): Promise { - return undefined; - } - - @log({ - args: { - 1: _contents => '', - }, - }) - async getDiffForFileContents(_uri: GitUri, _ref: string, _contents: string): Promise { - return undefined; - } - - @log() - async getDiffForLine( - _uri: GitUri, - _editorLine: number, // 0-based, Git is 1-based - _ref1: string | undefined, - _ref2?: string, - ): Promise { - return undefined; - } - - @log() - async getDiffStatus( - _repoPath: string, - _ref1?: string, - _ref2?: string, - _options?: { filters?: GitDiffFilter[]; similarityThreshold?: number }, - ): Promise { - return undefined; - } - - @log() - async getFileStatusForCommit(repoPath: string, uri: Uri, ref: string): Promise { - if (ref === GitRevision.deletedOrMissing || GitRevision.isUncommitted(ref)) return undefined; - - const commit = await this.getCommitForFile(repoPath, uri, { ref: ref }); - if (commit == null) return undefined; - - return commit.findFile(uri); - } - - async getLastFetchedTimestamp(_repoPath: string): Promise { - return undefined; - } - - @log() - async getLog( - repoPath: string, - options?: { - all?: boolean; - authors?: GitUser[]; - cursor?: string; - limit?: number; - merges?: boolean; - ordering?: string | null; - ref?: string; - since?: string; - }, - ): Promise { - if (repoPath == null) return undefined; - - const cc = Logger.getCorrelationContext(); - - const limit = this.getPagingLimit(options?.limit); - - try { - const { metadata, github, session } = await this.ensureRepositoryContext(repoPath); - - const ref = !options?.ref || options.ref === 'HEAD' ? (await metadata.getRevision()).revision : options.ref; - const result = await github.getCommits(session.accessToken, metadata.repo.owner, metadata.repo.name, ref, { - all: options?.all, - authors: options?.authors, - after: options?.cursor, - limit: limit, - since: options?.since ? new Date(options.since) : undefined, - }); - - const commits = new Map(); - - const { viewer = session.account.label } = result; - for (const commit of result.values) { - const authorName = viewer != null && commit.author.name === viewer ? 'You' : commit.author.name; - const committerName = - viewer != null && commit.committer.name === viewer ? 'You' : commit.committer.name; - - let c = commits.get(commit.oid); - if (c == null) { - c = new GitCommit( - this.container, - repoPath, - commit.oid, - new GitCommitIdentity( - authorName, - commit.author.email, - new Date(commit.author.date), - commit.author.avatarUrl, - ), - new GitCommitIdentity(committerName, commit.committer.email, new Date(commit.committer.date)), - commit.message.split('\n', 1)[0], - commit.parents.nodes.map(p => p.oid), - commit.message, - commit.files?.map( - f => - new GitFileChange( - repoPath, - f.filename ?? '', - fromCommitFileStatus(f.status) ?? GitFileIndexStatus.Modified, - f.previous_filename, - undefined, - { - additions: f.additions ?? 0, - deletions: f.deletions ?? 0, - changes: f.changes ?? 0, - }, - ), - ), - { - changedFiles: commit.changedFiles ?? 0, - additions: commit.additions ?? 0, - deletions: commit.deletions ?? 0, - }, - [], - ); - commits.set(commit.oid, c); - } - } - - const log: GitLog = { - repoPath: repoPath, - commits: commits, - sha: ref, - range: undefined, - count: commits.size, - limit: limit, - hasMore: result.paging?.more ?? false, - cursor: result.paging?.cursor, - query: (limit: number | undefined) => this.getLog(repoPath, { ...options, limit: limit }), - }; - - if (log.hasMore) { - log.more = this.getLogMoreFn(log, options); - } - - return log; - } catch (ex) { - Logger.error(ex, cc); - debugger; - return undefined; - } - } - - @log() - async getLogRefsOnly( - repoPath: string, - options?: { - authors?: GitUser[]; - cursor?: string; - limit?: number; - merges?: boolean; - ordering?: string | null; - ref?: string; - since?: string; - }, - ): Promise | undefined> { - // TODO@eamodio optimize this - const result = await this.getLog(repoPath, options); - if (result == null) return undefined; - - return new Set([...result.commits.values()].map(c => c.ref)); - } - - private getLogMoreFn( - log: GitLog, - options?: { - authors?: GitUser[]; - limit?: number; - merges?: boolean; - ordering?: string | null; - ref?: string; - }, - ): (limit: number | { until: string } | undefined) => Promise { - return async (limit: number | { until: string } | undefined) => { - const moreUntil = limit != null && typeof limit === 'object' ? limit.until : undefined; - let moreLimit = typeof limit === 'number' ? limit : undefined; - - if (moreUntil && some(log.commits.values(), c => c.ref === moreUntil)) { - return log; - } - - moreLimit = this.getPagingLimit(moreLimit); - - // // If the log is for a range, then just get everything prior + more - // if (GitRevision.isRange(log.sha)) { - // const moreLog = await this.getLog(log.repoPath, { - // ...options, - // limit: moreLimit === 0 ? 0 : (options?.limit ?? 0) + moreLimit, - // }); - // // If we can't find any more, assume we have everything - // if (moreLog == null) return { ...log, hasMore: false }; - - // return moreLog; - // } - - // const ref = Iterables.last(log.commits.values())?.ref; - // const moreLog = await this.getLog(log.repoPath, { - // ...options, - // limit: moreUntil == null ? moreLimit : 0, - // ref: moreUntil == null ? `${ref}^` : `${moreUntil}^..${ref}^`, - // }); - // // If we can't find any more, assume we have everything - // if (moreLog == null) return { ...log, hasMore: false }; - - const moreLog = await this.getLog(log.repoPath, { - ...options, - limit: moreLimit, - cursor: log.cursor, - }); - // If we can't find any more, assume we have everything - if (moreLog == null) return { ...log, hasMore: false }; - - const commits = new Map([...log.commits, ...moreLog.commits]); - - const mergedLog: GitLog = { - repoPath: log.repoPath, - commits: commits, - sha: log.sha, - range: undefined, - count: commits.size, - limit: moreUntil == null ? (log.limit ?? 0) + moreLimit : undefined, - hasMore: moreUntil == null ? moreLog.hasMore : true, - cursor: moreLog.cursor, - query: log.query, - }; - mergedLog.more = this.getLogMoreFn(mergedLog, options); - - return mergedLog; - }; - } - - @log() - async getLogForSearch( - repoPath: string, - search: SearchPattern, - options?: { cursor?: string; limit?: number; ordering?: 'date' | 'author-date' | 'topo' | null; skip?: number }, - ): Promise { - if (repoPath == null) return undefined; - - const cc = Logger.getCorrelationContext(); - - const operations = SearchPattern.parseSearchOperations(search.pattern); - - let op; - let values = operations.get('commit:'); - if (values != null) { - const commit = await this.getCommit(repoPath, values[0]); - if (commit == null) return undefined; - - return { - repoPath: repoPath, - commits: new Map([[commit.sha, commit]]), - sha: commit.sha, - range: undefined, - count: 1, - limit: 1, - hasMore: false, - }; - } - - const query = []; - - for ([op, values] of operations.entries()) { - switch (op) { - case 'message:': - query.push(...values.map(m => m.replace(/ /g, '+'))); - break; - - case 'author:': - query.push( - ...values.map(a => { - a = a.replace(/ /g, '+'); - if (a.startsWith('@')) return `author:${a.slice(1)}`; - if (a.startsWith('"@')) return `author:"${a.slice(2)}`; - if (a.includes('@')) return `author-email:${a}`; - return `author-name:${a}`; - }), - ); - break; - - // case 'change:': - // case 'file:': - // break; - } - } - - if (query.length === 0) return undefined; - - const limit = this.getPagingLimit(options?.limit); - - try { - const { metadata, github, session } = await this.ensureRepositoryContext(repoPath); - - const result = await github.searchCommits( - session.accessToken, - `repo:${metadata.repo.owner}/${metadata.repo.name}+${query.join('+').trim()}`, - { - cursor: options?.cursor, - limit: limit, - sort: - options?.ordering === 'date' - ? 'committer-date' - : options?.ordering === 'author-date' - ? 'author-date' - : undefined, - }, - ); - if (result == null) return undefined; - - const commits = new Map(); - - const viewer = session.account.label; - for (const commit of result.values) { - const authorName = viewer != null && commit.author.name === viewer ? 'You' : commit.author.name; - const committerName = - viewer != null && commit.committer.name === viewer ? 'You' : commit.committer.name; - - let c = commits.get(commit.oid); - if (c == null) { - c = new GitCommit( - this.container, - repoPath, - commit.oid, - new GitCommitIdentity( - authorName, - commit.author.email, - new Date(commit.author.date), - commit.author.avatarUrl, - ), - new GitCommitIdentity(committerName, commit.committer.email, new Date(commit.committer.date)), - commit.message.split('\n', 1)[0], - commit.parents.nodes.map(p => p.oid), - commit.message, - commit.files?.map( - f => - new GitFileChange( - repoPath, - f.filename ?? '', - fromCommitFileStatus(f.status) ?? GitFileIndexStatus.Modified, - f.previous_filename, - undefined, - { - additions: f.additions ?? 0, - deletions: f.deletions ?? 0, - changes: f.changes ?? 0, - }, - ), - ), - { - changedFiles: commit.changedFiles ?? 0, - additions: commit.additions ?? 0, - deletions: commit.deletions ?? 0, - }, - [], - ); - commits.set(commit.oid, c); - } - } - - const log: GitLog = { - repoPath: repoPath, - commits: commits, - sha: undefined, - range: undefined, - count: commits.size, - limit: limit, - hasMore: result.pageInfo?.hasNextPage ?? false, - cursor: result.pageInfo?.endCursor ?? undefined, - query: (limit: number | undefined) => this.getLog(repoPath, { ...options, limit: limit }), - }; - - if (log.hasMore) { - log.more = this.getLogForSearchMoreFn(log, search, options); - } - - return log; - } catch (ex) { - Logger.error(ex, cc); - debugger; - return undefined; - } - - return undefined; - } - - private getLogForSearchMoreFn( - log: GitLog, - search: SearchPattern, - options?: { limit?: number; ordering?: 'date' | 'author-date' | 'topo' | null; skip?: number }, - ): (limit: number | undefined) => Promise { - return async (limit: number | undefined) => { - limit = this.getPagingLimit(limit); - - const moreLog = await this.getLogForSearch(log.repoPath, search, { - ...options, - limit: limit, - cursor: log.cursor, - }); - // If we can't find any more, assume we have everything - if (moreLog == null) return { ...log, hasMore: false }; - - const commits = new Map([...log.commits, ...moreLog.commits]); - - const mergedLog: GitLog = { - repoPath: log.repoPath, - commits: commits, - sha: log.sha, - range: undefined, - count: commits.size, - limit: (log.limit ?? 0) + limit, - hasMore: moreLog.hasMore, - cursor: moreLog.cursor, - query: log.query, - }; - mergedLog.more = this.getLogForSearchMoreFn(mergedLog, search, options); - - return mergedLog; - }; - } - - @log() - async getLogForFile( - repoPath: string | undefined, - pathOrUri: string | Uri, - options?: { - all?: boolean; - cursor?: string; - force?: boolean | undefined; - limit?: number; - ordering?: string | null; - range?: Range; - ref?: string; - renames?: boolean; - reverse?: boolean; - since?: string; - skip?: number; - }, - ): Promise { - if (repoPath == null) return undefined; - - const cc = Logger.getCorrelationContext(); - - const relativePath = this.getRelativePath(pathOrUri, repoPath); - - if (repoPath != null && repoPath === relativePath) { - throw new Error(`File name cannot match the repository path; path=${relativePath}`); - } - - options = { reverse: false, ...options }; - - // Not currently supported - options.renames = false; - options.all = false; - - // if (options.renames == null) { - // options.renames = this.container.config.advanced.fileHistoryFollowsRenames; - // } - - let key = 'log'; - if (options.ref != null) { - key += `:${options.ref}`; - } - - // if (options.all == null) { - // options.all = this.container.config.advanced.fileHistoryShowAllBranches; - // } - // if (options.all) { - // key += ':all'; - // } - - options.limit = this.getPagingLimit(options?.limit); - if (options.limit) { - key += `:n${options.limit}`; - } - - if (options.renames) { - key += ':follow'; - } - - if (options.reverse) { - key += ':reverse'; - } - - if (options.since) { - key += `:since=${options.since}`; - } - - if (options.skip) { - key += `:skip${options.skip}`; - } - - if (options.cursor) { - key += `:cursor=${options.cursor}`; - } - - const doc = await this.container.tracker.getOrAdd(GitUri.fromFile(relativePath, repoPath, options.ref)); - if (!options.force && options.range == null) { - if (doc.state != null) { - const cachedLog = doc.state.getLog(key); - if (cachedLog != null) { - Logger.debug(cc, `Cache hit: '${key}'`); - return cachedLog.item; - } - - if (options.ref != null || options.limit != null) { - // Since we are looking for partial log, see if we have the log of the whole file - const cachedLog = doc.state.getLog( - `log${options.renames ? ':follow' : ''}${options.reverse ? ':reverse' : ''}`, - ); - if (cachedLog != null) { - if (options.ref == null) { - Logger.debug(cc, `Cache hit: ~'${key}'`); - return cachedLog.item; - } - - Logger.debug(cc, `Cache ?: '${key}'`); - let log = await cachedLog.item; - if (log != null && !log.hasMore && log.commits.has(options.ref)) { - Logger.debug(cc, `Cache hit: '${key}'`); - - // Create a copy of the log starting at the requested commit - let skip = true; - let i = 0; - const commits = new Map( - filterMap<[string, GitCommit], [string, GitCommit]>( - log.commits.entries(), - ([ref, c]) => { - if (skip) { - if (ref !== options?.ref) return undefined; - skip = false; - } - - i++; - if (options?.limit != null && i > options.limit) { - return undefined; - } - - return [ref, c]; - }, - ), - ); - - const opts = { ...options }; - log = { - ...log, - limit: options.limit, - count: commits.size, - commits: commits, - query: (limit: number | undefined) => - this.getLogForFile(repoPath, pathOrUri, { ...opts, limit: limit }), - }; - - return log; - } - } - } - } - - Logger.debug(cc, `Cache miss: '${key}'`); - - if (doc.state == null) { - doc.state = new GitDocumentState(doc.key); - } - } - - const promise = this.getLogForFileCore(repoPath, relativePath, doc, key, cc, options); - - if (doc.state != null && options.range == null) { - Logger.debug(cc, `Cache add: '${key}'`); - - const value: CachedLog = { - item: promise as Promise, - }; - doc.state.setLog(key, value); - } - - return promise; - } - - private async getLogForFileCore( - repoPath: string | undefined, - path: string, - document: TrackedDocument, - key: string, - cc: LogCorrelationContext | undefined, - options?: { - all?: boolean; - cursor?: string; - limit?: number; - ordering?: string | null; - range?: Range; - ref?: string; - renames?: boolean; - reverse?: boolean; - since?: string; - skip?: number; - }, - ): Promise { - if (repoPath == null) return undefined; - - const limit = this.getPagingLimit(options?.limit); - - try { - const context = await this.ensureRepositoryContext(repoPath); - if (context == null) return undefined; - const { metadata, github, remotehub, session } = context; - - const uri = this.getAbsoluteUri(path, repoPath); - const relativePath = this.getRelativePath(uri, remotehub.getProviderRootUri(uri)); - - // if (range != null && range.start.line > range.end.line) { - // range = new Range(range.end, range.start); - // } - - const ref = !options?.ref || options.ref === 'HEAD' ? (await metadata.getRevision()).revision : options.ref; - const result = await github.getCommits(session.accessToken, metadata.repo.owner, metadata.repo.name, ref, { - all: options?.all, - after: options?.cursor, - path: relativePath, - limit: limit, - since: options?.since ? new Date(options.since) : undefined, - }); - - const commits = new Map(); - - const { viewer = session.account.label } = result; - for (const commit of result.values) { - const authorName = viewer != null && commit.author.name === viewer ? 'You' : commit.author.name; - const committerName = - viewer != null && commit.committer.name === viewer ? 'You' : commit.committer.name; - - let c = commits.get(commit.oid); - if (c == null) { - const files = commit.files?.map( - f => - new GitFileChange( - repoPath, - f.filename ?? '', - fromCommitFileStatus(f.status) ?? GitFileIndexStatus.Modified, - f.previous_filename, - undefined, - { additions: f.additions ?? 0, deletions: f.deletions ?? 0, changes: f.changes ?? 0 }, - ), - ); - const foundFile = isFolderGlob(relativePath) - ? undefined - : files?.find(f => f.path === relativePath) ?? - new GitFileChange( - repoPath, - relativePath, - GitFileIndexStatus.Modified, - undefined, - undefined, - commit.changedFiles === 1 - ? { additions: commit.additions ?? 0, deletions: commit.deletions ?? 0, changes: 0 } - : undefined, - ); - - c = new GitCommit( - this.container, - repoPath, - commit.oid, - new GitCommitIdentity( - authorName, - commit.author.email, - new Date(commit.author.date), - commit.author.avatarUrl, - ), - new GitCommitIdentity(committerName, commit.committer.email, new Date(commit.committer.date)), - commit.message.split('\n', 1)[0], - commit.parents.nodes.map(p => p.oid), - commit.message, - { file: foundFile, files: files }, - { - changedFiles: commit.changedFiles ?? 0, - additions: commit.additions ?? 0, - deletions: commit.deletions ?? 0, - }, - [], - ); - commits.set(commit.oid, c); - } - } - - const log: GitLog = { - repoPath: repoPath, - commits: commits, - sha: ref, - range: undefined, - count: commits.size, - limit: limit, - hasMore: result.paging?.more ?? false, - cursor: result.paging?.cursor, - query: (limit: number | undefined) => this.getLogForFile(repoPath, path, { ...options, limit: limit }), - }; - - if (log.hasMore) { - log.more = this.getLogForFileMoreFn(log, path, options); - } - - return log; - } catch (ex) { - debugger; - // Trap and cache expected log errors - if (document.state != null && options?.range == null && !options?.reverse) { - const msg: string = ex?.toString() ?? ''; - Logger.debug(cc, `Cache replace (with empty promise): '${key}'`); - - const value: CachedLog = { - item: emptyPromise as Promise, - errorMessage: msg, - }; - document.state.setLog(key, value); - - return emptyPromise as Promise; - } - - return undefined; - } - } - - private getLogForFileMoreFn( - log: GitLog, - relativePath: string, - options?: { - all?: boolean; - limit?: number; - ordering?: string | null; - range?: Range; - ref?: string; - renames?: boolean; - reverse?: boolean; - }, - ): (limit: number | { until: string } | undefined) => Promise { - return async (limit: number | { until: string } | undefined) => { - const moreUntil = limit != null && typeof limit === 'object' ? limit.until : undefined; - let moreLimit = typeof limit === 'number' ? limit : undefined; - - if (moreUntil && some(log.commits.values(), c => c.ref === moreUntil)) { - return log; - } - - moreLimit = this.getPagingLimit(moreLimit); - - // const ref = Iterables.last(log.commits.values())?.ref; - const moreLog = await this.getLogForFile(log.repoPath, relativePath, { - ...options, - limit: moreUntil == null ? moreLimit : 0, - cursor: log.cursor, - // ref: options.all ? undefined : moreUntil == null ? `${ref}^` : `${moreUntil}^..${ref}^`, - // skip: options.all ? log.count : undefined, - }); - // If we can't find any more, assume we have everything - if (moreLog == null) return { ...log, hasMore: false }; - - const commits = new Map([...log.commits, ...moreLog.commits]); - - const mergedLog: GitLog = { - repoPath: log.repoPath, - commits: commits, - sha: log.sha, - range: log.range, - count: commits.size, - limit: moreUntil == null ? (log.limit ?? 0) + moreLimit : undefined, - hasMore: moreUntil == null ? moreLog.hasMore : true, - cursor: moreLog.cursor, - query: log.query, - }; - - // if (options.renames) { - // const renamed = find( - // moreLog.commits.values(), - // c => Boolean(c.file?.originalPath) && c.file?.originalPath !== fileName, - // ); - // fileName = renamed?.file?.originalPath ?? fileName; - // } - - mergedLog.more = this.getLogForFileMoreFn(mergedLog, relativePath, options); - - return mergedLog; - }; - } - - @log() - async getMergeBase( - _repoPath: string, - _ref1: string, - _ref2: string, - _options: { forkPoint?: boolean }, - ): Promise { - return undefined; - } - - // @gate() - @log() - async getMergeStatus(_repoPath: string): Promise { - return undefined; - } - - // @gate() - @log() - async getRebaseStatus(_repoPath: string): Promise { - return undefined; - } - - @log() - async getNextComparisonUris( - repoPath: string, - uri: Uri, - ref: string | undefined, - skip: number = 0, - ): Promise { - // If we have no ref there is no next commit - if (!ref) return undefined; - - const cc = Logger.getCorrelationContext(); - - try { - const context = await this.ensureRepositoryContext(repoPath); - if (context == null) return undefined; - - const { metadata, github, remotehub, session } = context; - const relativePath = this.getRelativePath(uri, remotehub.getProviderRootUri(uri)); - const revision = (await metadata.getRevision()).revision; - - if (ref === 'HEAD') { - ref = revision; - } - - const refs = await github.getNextCommitRefs( - session.accessToken, - metadata.repo.owner, - metadata.repo.name, - revision, - relativePath, - ref, - ); - - return { - current: - skip === 0 - ? GitUri.fromFile(relativePath, repoPath, ref) - : new GitUri(await this.getBestRevisionUri(repoPath, relativePath, refs[skip - 1])), - next: new GitUri(await this.getBestRevisionUri(repoPath, relativePath, refs[skip])), - }; - } catch (ex) { - Logger.error(ex, cc); - debugger; - - throw ex; - } - } - - @log() - async getPreviousComparisonUris( - repoPath: string, - uri: Uri, - ref: string | undefined, - skip: number = 0, - _firstParent: boolean = false, - ): Promise { - if (ref === GitRevision.deletedOrMissing) return undefined; - - const cc = Logger.getCorrelationContext(); - - if (ref === GitRevision.uncommitted) { - ref = undefined; - } - - try { - const context = await this.ensureRepositoryContext(repoPath); - if (context == null) return undefined; - - const { metadata, github, remotehub, session } = context; - const relativePath = this.getRelativePath(uri, remotehub.getProviderRootUri(uri)); - - const offset = ref != null ? 1 : 0; - - const result = await github.getCommitRefs( - session.accessToken, - metadata.repo.owner, - metadata.repo.name, - !ref || ref === 'HEAD' ? (await metadata.getRevision()).revision : ref, - { - path: relativePath, - first: offset + skip + 1, - }, - ); - if (result == null) return undefined; - - // If we are at a commit, diff commit with previous - const current = - skip === 0 - ? GitUri.fromFile(relativePath, repoPath, ref) - : new GitUri( - await this.getBestRevisionUri( - repoPath, - relativePath, - result.values[offset + skip - 1]?.oid ?? GitRevision.deletedOrMissing, - ), - ); - if (current == null || current.sha === GitRevision.deletedOrMissing) return undefined; - - return { - current: current, - previous: new GitUri( - await this.getBestRevisionUri( - repoPath, - relativePath, - result.values[offset + skip]?.oid ?? GitRevision.deletedOrMissing, - ), - ), - }; - } catch (ex) { - Logger.error(ex, cc); - debugger; - - throw ex; - } - } - - @log() - async getPreviousComparisonUrisForLine( - repoPath: string, - uri: Uri, - editorLine: number, // 0-based, Git is 1-based - ref: string | undefined, - skip: number = 0, - ): Promise { - if (ref === GitRevision.deletedOrMissing) return undefined; - - const cc = Logger.getCorrelationContext(); - - try { - const context = await this.ensureRepositoryContext(repoPath); - if (context == null) return undefined; - - const { remotehub } = context; - - let relativePath = this.getRelativePath(uri, remotehub.getProviderRootUri(uri)); - - // FYI, GitHub doesn't currently support returning the original line number, nor the previous sha, so this is untrustworthy - - let current = GitUri.fromFile(relativePath, repoPath, ref); - let currentLine = editorLine; - let previous; - let previousLine = editorLine; - let nextLine = editorLine; - - for (let i = 0; i < Math.max(0, skip) + 2; i++) { - const blameLine = await this.getBlameForLine(previous ?? current, nextLine, undefined, { - forceSingleLine: true, - }); - if (blameLine == null) break; - - // Diff with line ref with previous - ref = blameLine.commit.sha; - relativePath = blameLine.commit.file?.path ?? blameLine.commit.file?.originalPath ?? relativePath; - nextLine = blameLine.line.originalLine - 1; - - const gitUri = GitUri.fromFile(relativePath, repoPath, ref); - if (previous == null) { - previous = gitUri; - previousLine = nextLine; - } else { - current = previous; - currentLine = previousLine; - previous = gitUri; - previousLine = nextLine; - } - } - - if (current == null) return undefined; - - return { - current: current, - previous: previous, - line: (currentLine ?? editorLine) + 1, // 1-based - }; - } catch (ex) { - Logger.error(ex, cc); - debugger; - - throw ex; - } - } - - @log() - async getIncomingActivity( - _repoPath: string, - _options?: { all?: boolean; branch?: string; limit?: number; ordering?: string | null; skip?: number }, - ): Promise { - return undefined; - } - - @log({ args: { 1: false } }) - async getRemotes( - repoPath: string | undefined, - options?: { providers?: RemoteProviders; sort?: boolean }, - ): Promise[]> { - if (repoPath == null) return []; - - const providers = options?.providers ?? RemoteProviderFactory.loadProviders(configuration.get('remotes', null)); - - const uri = Uri.parse(repoPath, true); - const [, owner, repo] = uri.path.split('/', 3); - - const url = `https://github.com/${owner}/${repo}.git`; - const domain = 'github.com'; - const path = `${owner}/${repo}`; - - return [ - new GitRemote( - repoPath, - `${domain}/${path}`, - 'origin', - 'https', - domain, - path, - RemoteProviderFactory.factory(providers)(url, domain, path), - [ - { type: GitRemoteType.Fetch, url: url }, - { type: GitRemoteType.Push, url: url }, - ], - ), - ]; - } - - @log() - async getRevisionContent(repoPath: string, path: string, ref: string): Promise { - const uri = ref ? this.createProviderUri(repoPath, ref, path) : this.createVirtualUri(repoPath, ref, path); - return workspace.fs.readFile(uri); - } - - // @gate() - @log() - async getStash(_repoPath: string | undefined): Promise { - return undefined; - } - - @log() - async getStatusForFile(_repoPath: string, _uri: Uri): Promise { - return undefined; - } - - @log() - async getStatusForFiles(_repoPath: string, _pathOrGlob: Uri): Promise { - return undefined; - } - - @log() - async getStatusForRepo(_repoPath: string | undefined): Promise { - return undefined; - } - - @log({ args: { 1: false } }) - async getTags( - repoPath: string | undefined, - options?: { cursor?: string; filter?: (t: GitTag) => boolean; sort?: boolean | TagSortOptions }, - ): Promise> { - if (repoPath == null) return emptyPagedResult; - - const cc = Logger.getCorrelationContext(); - - let tagsPromise = options?.cursor ? undefined : this._tagsCache.get(repoPath); - if (tagsPromise == null) { - async function load(this: GitHubGitProvider): Promise> { - try { - const { metadata, github, session } = await this.ensureRepositoryContext(repoPath!); - - const tags: GitTag[] = []; - - let cursor = options?.cursor; - const loadAll = cursor == null; - - while (true) { - const result = await github.getTags( - session.accessToken, - metadata.repo.owner, - metadata.repo.name, - { cursor: cursor }, - ); - - for (const tag of result.values) { - tags.push( - new GitTag( - repoPath!, - tag.name, - tag.target.oid, - tag.target.message ?? '', - new Date(tag.target.authoredDate ?? tag.target.tagger?.date), - new Date(tag.target.committedDate ?? tag.target.tagger?.date), - ), - ); - } - - if (!result.paging?.more || !loadAll) return { ...result, values: tags }; - - cursor = result.paging.cursor; - } - } catch (ex) { - Logger.error(ex, cc); - debugger; - - this._tagsCache.delete(repoPath!); - return emptyPagedResult; - } - } - - tagsPromise = load.call(this); - if (options?.cursor == null) { - this._tagsCache.set(repoPath, tagsPromise); - } - } - - let result = await tagsPromise; - if (options?.filter != null) { - result = { - ...result, - values: result.values.filter(options.filter), - }; - } - - if (options?.sort != null) { - GitTag.sort(result.values, typeof options.sort === 'boolean' ? undefined : options.sort); - } - - return result; - } - - @log() - async getTreeEntryForRevision(repoPath: string, path: string, ref: string): Promise { - if (repoPath == null || !path) return undefined; - - if (ref === 'HEAD') { - const context = await this.ensureRepositoryContext(repoPath); - if (context == null) return undefined; - - const revision = await context.metadata.getRevision(); - ref = revision?.revision; - } - - const uri = ref ? this.createProviderUri(repoPath, ref, path) : this.createVirtualUri(repoPath, ref, path); - - const stats = await workspace.fs.stat(uri); - if (stats == null) return undefined; - - return { - path: this.getRelativePath(uri, repoPath), - commitSha: ref, - size: stats.size, - type: stats.type === FileType.Directory ? 'tree' : 'blob', - }; - } - - @log() - async getTreeForRevision(repoPath: string, ref: string): Promise { - if (repoPath == null) return []; - - if (ref === 'HEAD') { - const context = await this.ensureRepositoryContext(repoPath); - if (context == null) return []; - - const revision = await context.metadata.getRevision(); - ref = revision?.revision; - } - - const baseUri = ref ? this.createProviderUri(repoPath, ref) : this.createVirtualUri(repoPath, ref); - - const entries = await workspace.fs.readDirectory(baseUri); - if (entries == null) return []; - - const result: GitTreeEntry[] = []; - for (const [path, type] of entries) { - const uri = this.getAbsoluteUri(path, baseUri); - - // TODO:@eamodio do we care about size? - // const stats = await workspace.fs.stat(uri); - - result.push({ - path: this.getRelativePath(path, uri), - commitSha: ref, - size: 0, // stats?.size, - type: type === FileType.Directory ? 'tree' : 'blob', - }); - } - - // TODO@eamodio: Implement this - return []; - } - - @log() - async hasBranchOrTag( - repoPath: string | undefined, - options?: { - filter?: { branches?: (b: GitBranch) => boolean; tags?: (t: GitTag) => boolean }; - }, - ) { - const [{ values: branches }, { values: tags }] = await Promise.all([ - this.getBranches(repoPath, { - filter: options?.filter?.branches, - sort: false, - }), - this.getTags(repoPath, { - filter: options?.filter?.tags, - sort: false, - }), - ]); - - return branches.length !== 0 || tags.length !== 0; - } - - @log() - async hasCommitBeenPushed(_repoPath: string, _ref: string): Promise { - // In this env we can't have unpushed commits - return true; - } - - isTrackable(uri: Uri): boolean { - return this.supportedSchemes.has(uri.scheme); - } - - isTracked(uri: Uri): Promise { - return Promise.resolve(this.isTrackable(uri) && this.container.git.getRepository(uri) != null); - } - - @log() - async getDiffTool(_repoPath?: string): Promise { - return undefined; - } - - @log() - async openDiffTool( - _repoPath: string, - _uri: Uri, - _options?: { ref1?: string; ref2?: string; staged?: boolean; tool?: string }, - ): Promise {} - - @log() - async openDirectoryCompare(_repoPath: string, _ref1: string, _ref2?: string, _tool?: string): Promise {} - - @log() - async resolveReference(repoPath: string, ref: string, pathOrUri?: string | Uri, _options?: { timeout?: number }) { - if ( - !ref || - ref === GitRevision.deletedOrMissing || - (pathOrUri == null && GitRevision.isSha(ref)) || - (pathOrUri != null && GitRevision.isUncommitted(ref)) - ) { - return ref; - } - - let relativePath; - if (pathOrUri != null) { - relativePath = this.getRelativePath(pathOrUri, repoPath); - } else if (!GitRevision.isShaLike(ref) || ref.endsWith('^3')) { - // If it doesn't look like a sha at all (e.g. branch name) or is a stash ref (^3) don't try to resolve it - return ref; - } - - const context = await this.ensureRepositoryContext(repoPath); - if (context == null) return ref; - - const { metadata, github, session } = context; - - const resolved = await github.resolveReference( - session.accessToken, - metadata.repo.owner, - metadata.repo.name, - ref, - relativePath, - ); - - if (resolved != null) return resolved; - - return relativePath ? GitRevision.deletedOrMissing : ref; - } - - @log() - async validateBranchOrTagName(ref: string, _repoPath?: string): Promise { - return validBranchOrTagRegex.test(ref); - } - - @log() - async validateReference(_repoPath: string, _ref: string): Promise { - return true; - } - - @log() - async stageFile(_repoPath: string, _pathOrUri: string | Uri): Promise {} - - @log() - async stageDirectory(_repoPath: string, _directoryOrUri: string | Uri): Promise {} - - @log() - async unStageFile(_repoPath: string, _pathOrUri: string | Uri): Promise {} - - @log() - async unStageDirectory(_repoPath: string, _directoryOrUri: string | Uri): Promise {} - - @log() - async stashApply(_repoPath: string, _stashName: string, _options?: { deleteAfter?: boolean }): Promise {} - - @log() - async stashDelete(_repoPath: string, _stashName: string, _ref?: string): Promise {} - - @log({ args: { 2: uris => uris?.length } }) - async stashSave( - _repoPath: string, - _message?: string, - _uris?: Uri[], - _options?: { includeUntracked?: boolean; keepIndex?: boolean }, - ): Promise {} - - @gate() - private async ensureRepositoryContext( - repoPath: string, - open?: boolean, - ): Promise<{ github: GitHubApi; metadata: Metadata; remotehub: RemoteHubApi; session: AuthenticationSession }> { - let uri = Uri.parse(repoPath, true); - if (!/^github\+?/.test(uri.authority)) { - throw new OpenVirtualRepositoryError(repoPath, OpenVirtualRepositoryErrorReason.NotAGitHubRepository); - } - - if (!open) { - const repo = this.container.git.getRepository(uri); - if (repo == null) { - throw new OpenVirtualRepositoryError(repoPath, OpenVirtualRepositoryErrorReason.NotAGitHubRepository); - } - - uri = repo.uri; - } - - let remotehub = this._remotehub; - if (remotehub == null) { - try { - remotehub = await this.ensureRemoteHubApi(); - } catch (ex) { - if (!(ex instanceof ExtensionNotFoundError)) { - debugger; - } - throw new OpenVirtualRepositoryError( - repoPath, - OpenVirtualRepositoryErrorReason.RemoteHubApiNotFound, - ex, - ); - } - } - - const metadata = await remotehub?.getMetadata(uri); - if (metadata?.provider.id !== 'github') { - throw new OpenVirtualRepositoryError(repoPath, OpenVirtualRepositoryErrorReason.NotAGitHubRepository); - } - - let github; - let session; - try { - [github, session] = await Promise.all([this.ensureGitHub(), this.ensureSession()]); - } catch (ex) { - debugger; - if (ex instanceof AuthenticationError) { - throw new OpenVirtualRepositoryError( - repoPath, - ex.reason === AuthenticationErrorReason.UserDidNotConsent - ? OpenVirtualRepositoryErrorReason.GitHubAuthenticationDenied - : OpenVirtualRepositoryErrorReason.GitHubAuthenticationNotFound, - ex, - ); - } - - throw new OpenVirtualRepositoryError(repoPath); - } - if (github == null) { - debugger; - throw new OpenVirtualRepositoryError(repoPath); - } - - return { github: github, metadata: metadata, remotehub: remotehub, session: session }; - } - - private _github: GitHubApi | undefined; - @gate() - private async ensureGitHub() { - if (this._github == null) { - const github = await this.container.github; - if (github != null) { - this._disposables.push( - github.onDidReauthenticate(() => { - this._sessionPromise = undefined; - void this.ensureSession(true); - }), - ); - } - this._github = github; - } - return this._github; - } - - /** Only use this if you NEED non-promise access to RemoteHub */ - private _remotehub: RemoteHubApi | undefined; - private _remotehubPromise: Promise | undefined; - private async ensureRemoteHubApi(): Promise; - private async ensureRemoteHubApi(silent: false): Promise; - private async ensureRemoteHubApi(silent: boolean): Promise; - private async ensureRemoteHubApi(silent?: boolean): Promise { - if (this._remotehubPromise == null) { - this._remotehubPromise = getRemoteHubApi(); - // Not a fan of this, but we need to be able to access RemoteHub without a promise - this._remotehubPromise.then( - api => (this._remotehub = api), - () => (this._remotehub = undefined), - ); - } - - if (!silent) return this._remotehubPromise; - - try { - return await this._remotehubPromise; - } catch { - return undefined; - } - } - - private _sessionPromise: Promise | undefined; - private async ensureSession(force: boolean = false): Promise { - if (this._sessionPromise == null) { - async function getSession(): Promise { - try { - if (force) { - return await authentication.getSession('github', githubAuthenticationScopes, { - forceNewSession: true, - }); - } - - return await authentication.getSession('github', githubAuthenticationScopes, { - createIfNone: true, - }); - } catch (ex) { - if (ex instanceof Error && ex.message.includes('User did not consent')) { - throw new AuthenticationError('github', AuthenticationErrorReason.UserDidNotConsent); - } - - Logger.error(ex); - debugger; - throw new AuthenticationError('github', undefined, ex); - } - } - - this._sessionPromise = getSession(); - } - - return this._sessionPromise; - } - - private createVirtualUri(base: string | Uri, ref?: GitReference | string, path?: string): Uri { - let metadata: GitHubAuthorityMetadata | undefined; - - if (typeof ref === 'string') { - if (ref) { - if (GitRevision.isSha(ref)) { - metadata = { v: 1, ref: { id: ref, type: 2 /* RepositoryRefType.Commit */ } }; - } else { - metadata = { v: 1, ref: { id: ref, type: 4 /* RepositoryRefType.Tree */ } }; - } - } - } else { - switch (ref?.refType) { - case 'revision': - case 'stash': - metadata = { v: 1, ref: { id: ref.ref, type: 2 /* RepositoryRefType.Commit */ } }; - break; - case 'branch': - case 'tag': - metadata = { v: 1, ref: { id: ref.name, type: 4 /* RepositoryRefType.Tree */ } }; - break; - } - } - - if (typeof base === 'string') { - base = Uri.parse(base, true); - } - - if (path) { - let basePath = base.path; - if (basePath.endsWith('/')) { - basePath = basePath.slice(0, -1); - } - - path = this.getRelativePath(path, base); - path = `${basePath}/${path.startsWith('/') ? path.slice(0, -1) : path}`; - } - - return base.with({ - scheme: Schemes.Virtual, - authority: encodeAuthority('github', metadata), - path: path ?? base.path, - }); - } - - private createProviderUri(base: string | Uri, ref?: GitReference | string, path?: string): Uri { - const uri = this.createVirtualUri(base, ref, path); - if (this._remotehub == null) { - debugger; - return uri.scheme !== Schemes.Virtual ? uri : uri.with({ scheme: Schemes.GitHub }); - } - - return this._remotehub.getProviderUri(uri); - } - - private getPagingLimit(limit?: number): number { - limit = Math.min(100, limit ?? this.container.config.advanced.maxListItems ?? 100); - if (limit === 0) { - limit = 100; - } - return limit; - } - - private async resolveReferenceCore( - repoPath: string, - metadata: Metadata, - ref?: string, - ): Promise { - if (ref == null || ref === 'HEAD') { - const revision = await metadata.getRevision(); - return revision.revision; - } - - if (GitRevision.isSha(ref)) return ref; - - // TODO@eamodio need to handle ranges - if (GitRevision.isRange(ref)) return undefined; - - const [branchResults, tagResults] = await Promise.allSettled([ - this.getBranches(repoPath, { filter: b => b.name === ref }), - this.getTags(repoPath, { filter: t => t.name === ref }), - ]); - - ref = - (branchResults.status === 'fulfilled' ? branchResults.value.values[0]?.sha : undefined) ?? - (tagResults.status === 'fulfilled' ? tagResults.value.values[0]?.sha : undefined); - if (ref == null) debugger; - - return ref; - } -} - -function encodeAuthority(scheme: string, metadata?: T): string { - return `${scheme}${metadata != null ? `+${encodeUtf8Hex(JSON.stringify(metadata))}` : ''}`; -} diff --git a/src/premium/remotehub.ts b/src/premium/remotehub.ts deleted file mode 100644 index 29429c8..0000000 --- a/src/premium/remotehub.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { extensions, Uri } from 'vscode'; -import { ExtensionNotFoundError } from '../errors'; -import { Logger } from '../logger'; - -export async function getRemoteHubApi(): Promise; -export async function getRemoteHubApi(silent: false): Promise; -export async function getRemoteHubApi(silent: boolean): Promise; -export async function getRemoteHubApi(silent?: boolean): Promise { - try { - const extension = - extensions.getExtension('GitHub.remotehub') ?? - extensions.getExtension('GitHub.remotehub-insiders'); - if (extension == null) { - Logger.log('GitHub Repositories extension is not installed or enabled'); - throw new ExtensionNotFoundError('GitHub Repositories', 'GitHub.remotehub'); - } - - const api = extension.isActive ? extension.exports : await extension.activate(); - return api; - } catch (ex) { - Logger.error(ex, 'Unable to get required api from the GitHub Repositories extension'); - if (!(ex instanceof ExtensionNotFoundError)) { - debugger; - } - - if (silent) return undefined; - throw ex; - } -} - -export interface Provider { - readonly id: 'github' | 'azdo'; - readonly name: string; -} - -export enum HeadType { - Branch = 0, - RemoteBranch = 1, - Tag = 2, - Commit = 3, -} - -export interface Metadata { - readonly provider: Provider; - readonly repo: { owner: string; name: string } & Record; - getRevision(): Promise<{ type: HeadType; name: string; revision: string }>; -} - -// export type CreateUriOptions = Omit; - -export interface RemoteHubApi { - getMetadata(uri: Uri): Promise; - - // createProviderUri(provider: string, options: CreateUriOptions, path: string): Uri | undefined; - getProvider(uri: Uri): Provider | undefined; - getProviderUri(uri: Uri): Uri; - getProviderRootUri(uri: Uri): Uri; - isProviderUri(uri: Uri, provider?: string): boolean; - - // createVirtualUri(provider: string, options: CreateUriOptions, path: string): Uri | undefined; - getVirtualUri(uri: Uri): Uri; - getVirtualWorkspaceUri(uri: Uri): Uri | undefined; - - /** - * Returns whether RemoteHub has the full workspace contents for a vscode-vfs:// URI. - * This will download workspace contents if fetching full workspace contents is enabled - * for the requested URI and the contents are not already available locally. - * @param workspaceUri A vscode-vfs:// URI for a RemoteHub workspace folder. - * @returns boolean indicating whether the workspace contents were successfully loaded. - */ - loadWorkspaceContents(workspaceUri: Uri): Promise; -} - -export interface RepositoryRef { - type: RepositoryRefType; - id: string; -} - -export const enum RepositoryRefType { - Branch = 0, - Tag = 1, - Commit = 2, - PullRequest = 3, - Tree = 4, -} - -export interface GitHubAuthorityMetadata { - v: 1; - ref?: RepositoryRef; -} diff --git a/src/premium/subscription/authenticationProvider.ts b/src/premium/subscription/authenticationProvider.ts deleted file mode 100644 index 10e432b..0000000 --- a/src/premium/subscription/authenticationProvider.ts +++ /dev/null @@ -1,248 +0,0 @@ -import { v4 as uuid } from 'uuid'; -import { - authentication, - AuthenticationProvider, - AuthenticationProviderAuthenticationSessionsChangeEvent, - AuthenticationSession, - Disposable, - EventEmitter, - window, -} from 'vscode'; -import type { Container } from '../../container'; -import { Logger } from '../../logger'; -import { debug } from '../../system/decorators/log'; -import { ServerConnection } from './serverConnection'; - -interface StoredSession { - id: string; - accessToken: string; - account?: { - label?: string; - displayName?: string; - id: string; - }; - scopes: string[]; -} - -const authenticationId = 'gitlens+'; -const authenticationLabel = 'GitLens+'; -const authenticationSecretKey = `gitlens.plus.auth`; - -export class SubscriptionAuthenticationProvider implements AuthenticationProvider, Disposable { - private _onDidChangeSessions = new EventEmitter(); - get onDidChangeSessions() { - return this._onDidChangeSessions.event; - } - - private readonly _disposable: Disposable; - private _sessionsPromise: Promise; - - constructor(private readonly container: Container, private readonly server: ServerConnection) { - // Contains the current state of the sessions we have available. - this._sessionsPromise = this.getSessionsFromStorage(); - - this._disposable = Disposable.from( - authentication.registerAuthenticationProvider(authenticationId, authenticationLabel, this, { - supportsMultipleAccounts: false, - }), - this.container.storage.onDidChangeSecrets(() => this.checkForUpdates()), - ); - } - - dispose() { - this._disposable.dispose(); - } - - @debug() - public async createSession(scopes: string[]): Promise { - const cc = Logger.getCorrelationContext(); - - // Ensure that the scopes are sorted consistently (since we use them for matching and order doesn't matter) - scopes = scopes.sort(); - const scopesKey = getScopesKey(scopes); - - try { - const token = await this.server.login(scopes, scopesKey); - const session = await this.createSessionForToken(token, scopes); - - const sessions = await this._sessionsPromise; - const sessionIndex = sessions.findIndex(s => s.id === session.id || getScopesKey(s.scopes) === scopesKey); - if (sessionIndex > -1) { - sessions.splice(sessionIndex, 1, session); - } else { - sessions.push(session); - } - await this.storeSessions(sessions); - - this._onDidChangeSessions.fire({ added: [session], removed: [], changed: [] }); - - return session; - } catch (ex) { - // If login was cancelled, do not notify user. - if (ex === 'Cancelled') throw ex; - - Logger.error(ex, cc); - void window.showErrorMessage(`Unable to sign in to GitLens+: ${ex}`); - throw ex; - } - } - - @debug() - async getSessions(scopes?: string[]): Promise { - const cc = Logger.getCorrelationContext(); - - scopes = scopes?.sort(); - const scopesKey = getScopesKey(scopes); - - const sessions = await this._sessionsPromise; - const filtered = scopes != null ? sessions.filter(s => getScopesKey(s.scopes) === scopesKey) : sessions; - - if (cc != null) { - cc.exitDetails = ` \u2022 Found ${filtered.length} sessions`; - } - - return filtered; - } - - @debug() - public async removeSession(id: string) { - const cc = Logger.getCorrelationContext(); - - try { - const sessions = await this._sessionsPromise; - const sessionIndex = sessions.findIndex(session => session.id === id); - if (sessionIndex === -1) { - Logger.log(`Unable to remove session ${id}; Not found`); - return; - } - - const session = sessions[sessionIndex]; - sessions.splice(sessionIndex, 1); - - await this.storeSessions(sessions); - - this._onDidChangeSessions.fire({ added: [], removed: [session], changed: [] }); - } catch (ex) { - Logger.error(ex, cc); - void window.showErrorMessage(`Unable to sign out: ${ex}`); - throw ex; - } - } - - private async checkForUpdates() { - const previousSessions = await this._sessionsPromise; - this._sessionsPromise = this.getSessionsFromStorage(); - const storedSessions = await this._sessionsPromise; - - const added: AuthenticationSession[] = []; - const removed: AuthenticationSession[] = []; - - for (const session of storedSessions) { - if (previousSessions.some(s => s.id === session.id)) continue; - - // Another window added a session, so let our window know about it - added.push(session); - } - - for (const session of previousSessions) { - if (storedSessions.some(s => s.id === session.id)) continue; - - // Another window has removed this session (or logged out), so let our window know about it - removed.push(session); - } - - if (added.length || removed.length) { - Logger.debug(`Firing sessions changed event; added=${added.length}, removed=${removed.length}`); - this._onDidChangeSessions.fire({ added: added, removed: removed, changed: [] }); - } - } - - private async createSessionForToken(token: string, scopes: string[]): Promise { - const userInfo = await this.server.getAccountInfo(token); - return { - id: uuid(), - accessToken: token, - account: { label: userInfo.accountName, id: userInfo.id }, - scopes: scopes, - }; - } - - private async getSessionsFromStorage(): Promise { - let storedSessions: StoredSession[]; - - try { - const sessionsJSON = await this.container.storage.getSecret(authenticationSecretKey); - if (!sessionsJSON || sessionsJSON === '[]') return []; - - try { - storedSessions = JSON.parse(sessionsJSON); - } catch (ex) { - try { - await this.container.storage.deleteSecret(authenticationSecretKey); - } catch {} - - throw ex; - } - } catch (ex) { - Logger.error(ex, 'Unable to read sessions from storage'); - return []; - } - - const sessionPromises = storedSessions.map(async (session: StoredSession) => { - const scopesKey = getScopesKey(session.scopes); - - Logger.debug(`Read session from storage with scopes=${scopesKey}`); - - let userInfo: { id: string; accountName: string } | undefined; - if (session.account == null) { - try { - userInfo = await this.server.getAccountInfo(session.accessToken); - Logger.debug(`Verified session with scopes=${scopesKey}`); - } catch (ex) { - // Remove sessions that return unauthorized response - if (ex.message === 'Unauthorized') return undefined; - } - } - - return { - id: session.id, - account: { - label: - session.account != null - ? session.account.label ?? session.account.displayName ?? '' - : userInfo?.accountName ?? '', - id: session.account?.id ?? userInfo?.id ?? '', - }, - scopes: session.scopes, - accessToken: session.accessToken, - }; - }); - - const verifiedSessions = (await Promise.allSettled(sessionPromises)) - .filter(p => p.status === 'fulfilled') - .map(p => (p as PromiseFulfilledResult).value) - .filter((p?: T): p is T => Boolean(p)); - - Logger.debug(`Found ${verifiedSessions.length} verified sessions`); - if (verifiedSessions.length !== storedSessions.length) { - await this.storeSessions(verifiedSessions); - } - return verifiedSessions; - } - - private async storeSessions(sessions: AuthenticationSession[]): Promise { - try { - this._sessionsPromise = Promise.resolve(sessions); - await this.container.storage.storeSecret(authenticationSecretKey, JSON.stringify(sessions)); - } catch (ex) { - Logger.error(ex, `Unable to store ${sessions.length} sessions`); - } - } -} - -function getScopesKey(scopes: readonly string[]): string; -function getScopesKey(scopes: undefined): string | undefined; -function getScopesKey(scopes: readonly string[] | undefined): string | undefined; -function getScopesKey(scopes: readonly string[] | undefined): string | undefined { - return scopes?.join('|'); -} diff --git a/src/premium/subscription/serverConnection.ts b/src/premium/subscription/serverConnection.ts deleted file mode 100644 index cbe857f..0000000 --- a/src/premium/subscription/serverConnection.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { v4 as uuid } from 'uuid'; -import { Disposable, env, EventEmitter, StatusBarAlignment, StatusBarItem, Uri, UriHandler, window } from 'vscode'; -import { fetch, Response } from '@env/fetch'; -import { Container } from '../../container'; -import { Logger } from '../../logger'; -import { debug, log } from '../../system/decorators/log'; -import { memoize } from '../../system/decorators/memoize'; -import { DeferredEvent, DeferredEventExecutor, promisifyDeferred } from '../../system/event'; - -interface AccountInfo { - id: string; - accountName: string; -} - -export class ServerConnection implements Disposable { - private _deferredCodeExchanges = new Map>(); - private _disposable: Disposable; - private _pendingStates = new Map(); - private _statusBarItem: StatusBarItem | undefined; - private _uriHandler = new UriEventHandler(); - - constructor(private readonly container: Container) { - this._disposable = window.registerUriHandler(this._uriHandler); - } - - dispose() { - this._disposable.dispose(); - } - - @memoize() - private get baseApiUri(): Uri { - if (this.container.env === 'staging') { - return Uri.parse('https://stagingapi.gitkraken.com'); - } - - if (this.container.env === 'dev') { - return Uri.parse('https://devapi.gitkraken.com'); - } - - return Uri.parse('https://api.gitkraken.com'); - } - - @memoize() - private get baseAccountUri(): Uri { - if (this.container.env === 'staging') { - return Uri.parse('https://stagingaccount.gitkraken.com'); - } - - if (this.container.env === 'dev') { - return Uri.parse('https://devaccount.gitkraken.com'); - } - - return Uri.parse('https://account.gitkraken.com'); - } - - @debug({ args: false }) - public async getAccountInfo(token: string): Promise { - const cc = Logger.getCorrelationContext(); - - let rsp: Response; - try { - rsp = await fetch(Uri.joinPath(this.baseApiUri, 'user').toString(), { - headers: { - Authorization: `Bearer ${token}`, - // TODO: What user-agent should we use? - 'User-Agent': 'Visual-Studio-Code-GitLens', - }, - }); - } catch (ex) { - Logger.error(ex, cc); - throw ex; - } - - if (!rsp.ok) { - Logger.error(undefined, `Getting account info failed: (${rsp.status}) ${rsp.statusText}`); - throw new Error(rsp.statusText); - } - - const json: { id: string; username: string } = await rsp.json(); - return { id: json.id, accountName: json.username }; - } - - @debug() - public async login(scopes: string[], scopeKey: string): Promise { - this.updateStatusBarItem(true); - - // Include a state parameter here to prevent CSRF attacks - const gkstate = uuid(); - const existingStates = this._pendingStates.get(scopeKey) ?? []; - this._pendingStates.set(scopeKey, [...existingStates, gkstate]); - - const callbackUri = await env.asExternalUri( - Uri.parse(`${env.uriScheme}://${this.container.context.extension.id}/did-authenticate?gkstate=${gkstate}`), - ); - - const uri = Uri.joinPath(this.baseAccountUri, 'register').with({ - query: `${ - scopes.includes('gitlens') ? 'referrer=gitlens&' : '' - }pass-token=true&return-url=${encodeURIComponent(callbackUri.toString())}`, - }); - void (await env.openExternal(uri)); - - // Ensure there is only a single listener for the URI callback, in case the user starts the login process multiple times before completing it - let deferredCodeExchange = this._deferredCodeExchanges.get(scopeKey); - if (deferredCodeExchange == null) { - deferredCodeExchange = promisifyDeferred( - this._uriHandler.event, - this.getUriHandlerDeferredExecutor(scopeKey), - ); - this._deferredCodeExchanges.set(scopeKey, deferredCodeExchange); - } - - return Promise.race([ - deferredCodeExchange.promise, - new Promise((_, reject) => setTimeout(() => reject('Cancelled'), 60000)), - ]).finally(() => { - this._pendingStates.delete(scopeKey); - deferredCodeExchange?.cancel(); - this._deferredCodeExchanges.delete(scopeKey); - this.updateStatusBarItem(false); - }); - } - - private getUriHandlerDeferredExecutor(_scopeKey: string): DeferredEventExecutor { - return (uri: Uri, resolve, reject) => { - // TODO: We should really support a code to token exchange, but just return the token from the query string - // await this.exchangeCodeForToken(uri.query); - // As the backend still doesn't implement yet the code to token exchange, we just validate the state returned - const query = parseQuery(uri); - - const acceptedStates = this._pendingStates.get(_scopeKey); - - if (acceptedStates == null || !acceptedStates.includes(query.gkstate)) { - // A common scenario of this happening is if you: - // 1. Trigger a sign in with one set of scopes - // 2. Before finishing 1, you trigger a sign in with a different set of scopes - // In this scenario we should just return and wait for the next UriHandler event - // to run as we are probably still waiting on the user to hit 'Continue' - Logger.log('State not found in accepted state. Skipping this execution...'); - return; - } - - const token = query['access-token']; - if (token == null) { - reject('Token not returned'); - } else { - resolve(token); - } - }; - } - - private updateStatusBarItem(signingIn?: boolean) { - if (signingIn && this._statusBarItem == null) { - this._statusBarItem = window.createStatusBarItem( - 'gitkraken-authentication.signIn', - StatusBarAlignment.Left, - ); - this._statusBarItem.name = 'GitKraken Sign-in'; - this._statusBarItem.text = 'Signing into gitkraken.com...'; - this._statusBarItem.show(); - } - - if (!signingIn && this._statusBarItem != null) { - this._statusBarItem.dispose(); - this._statusBarItem = undefined; - } - } -} - -class UriEventHandler extends EventEmitter implements UriHandler { - @log() - public handleUri(uri: Uri) { - this.fire(uri); - } -} - -function parseQuery(uri: Uri): Record { - return uri.query.split('&').reduce((prev, current) => { - const queryString = current.split('='); - prev[queryString[0]] = queryString[1]; - return prev; - }, {} as Record); -} diff --git a/src/premium/subscription/subscriptionService.ts b/src/premium/subscription/subscriptionService.ts deleted file mode 100644 index 2504617..0000000 --- a/src/premium/subscription/subscriptionService.ts +++ /dev/null @@ -1,863 +0,0 @@ -import { - authentication, - AuthenticationSession, - AuthenticationSessionsChangeEvent, - version as codeVersion, - commands, - Disposable, - env, - Event, - EventEmitter, - MarkdownString, - MessageItem, - StatusBarAlignment, - StatusBarItem, - ThemeColor, - Uri, - window, -} from 'vscode'; -import { fetch } from '@env/fetch'; -import { getPlatform } from '@env/platform'; -import { configuration } from '../../configuration'; -import { Commands, ContextKeys } from '../../constants'; -import type { Container } from '../../container'; -import { setContext } from '../../context'; -import { AccountValidationError } from '../../errors'; -import { RepositoriesChangeEvent } from '../../git/gitProviderService'; -import { Logger } from '../../logger'; -import { StorageKeys, WorkspaceStorageKeys } from '../../storage'; -import { - computeSubscriptionState, - getSubscriptionPlan, - getSubscriptionPlanPriority, - getSubscriptionTimeRemaining, - getTimeRemaining, - isSubscriptionExpired, - isSubscriptionPaidPlan, - isSubscriptionTrial, - Subscription, - SubscriptionPlanId, - SubscriptionState, -} from '../../subscription'; -import { executeCommand } from '../../system/command'; -import { createFromDateDelta } from '../../system/date'; -import { gate } from '../../system/decorators/gate'; -import { debug, log } from '../../system/decorators/log'; -import { memoize } from '../../system/decorators/memoize'; -import { once } from '../../system/function'; -import { pluralize } from '../../system/string'; -import { openWalkthrough } from '../../system/utils'; -import { ensurePlusFeaturesEnabled } from './utils'; - -// TODO: What user-agent should we use? -const userAgent = 'Visual-Studio-Code-GitLens'; - -export interface SubscriptionChangeEvent { - readonly current: Subscription; - readonly previous: Subscription; - readonly etag: number; -} - -export class SubscriptionService implements Disposable { - private static authenticationProviderId = 'gitlens+'; - 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 = Disposable.from( - once(container.onReady)(this.onReady, this), - authentication.onDidChangeSessions(this.onAuthenticationChanged, this), - ); - - this.changeSubscription(this.getStoredSubscription(), true); - setTimeout(() => void this.ensureSession(false), 10000); - } - - dispose(): void { - this._statusBarSubscription?.dispose(); - - this._disposable.dispose(); - } - - private onAuthenticationChanged(e: AuthenticationSessionsChangeEvent): void { - if (e.provider.id !== SubscriptionService.authenticationProviderId) return; - - void this.ensureSession(false, true); - } - - @memoize() - private get baseApiUri(): Uri { - const { env } = this.container; - if (env === 'staging') { - return Uri.parse('https://stagingapi.gitkraken.com'); - } - - if (env === 'dev') { - 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') { - 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') { - return Uri.parse('https://dev.gitkraken.com'); - } - - return Uri.parse('https://gitkraken.com'); - } - - private get connectedKey(): `${WorkspaceStorageKeys.ConnectedPrefix}${string}` { - return `${WorkspaceStorageKeys.ConnectedPrefix}gitkraken`; - } - - 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(Commands.PlusLearn, openToSide => this.learn(openToSide)), - commands.registerCommand(Commands.PlusLoginOrSignUp, () => this.loginOrSignUp()), - commands.registerCommand(Commands.PlusLogout, () => this.logout()), - - commands.registerCommand(Commands.PlusStartPreviewTrial, () => this.startPreviewTrial()), - commands.registerCommand(Commands.PlusManage, () => this.manage()), - commands.registerCommand(Commands.PlusPurchase, () => this.purchase()), - - commands.registerCommand(Commands.PlusResendVerification, () => this.resendVerification()), - commands.registerCommand(Commands.PlusValidate, () => this.validate()), - - commands.registerCommand(Commands.PlusShowPlans, () => this.showPlans()), - - commands.registerCommand(Commands.PlusHide, () => - configuration.updateEffective('plusFeatures.enabled', false), - ), - commands.registerCommand(Commands.PlusRestore, () => - configuration.updateEffective('plusFeatures.enabled', true), - ), - - commands.registerCommand('gitlens.plus.reset', () => this.logout(true)), - ]; - } - - async getSubscription(): Promise { - void (await this.ensureSession(false)); - return this._subscription; - } - - @debug() - learn(openToSide: boolean = true): void { - void openWalkthrough(this.container.context.extension.id, 'gitlens.plus', undefined, openToSide); - } - - @gate() - @log() - async loginOrSignUp(): Promise { - if (!(await ensurePlusFeaturesEnabled())) return false; - - void this.showHomeView(); - - await this.container.storage.deleteWorkspace(this.connectedKey); - - const session = await this.ensureSession(true); - const loggedIn = Boolean(session); - if (loggedIn) { - const { - account, - plan: { actual, effective }, - } = this._subscription; - - if (account?.verified === false) { - const confirm: MessageItem = { title: 'Resend Verification', isCloseAffordance: true }; - const cancel: MessageItem = { title: 'Cancel' }; - const result = await window.showInformationMessage( - `Before you can access your ${actual.name} account, you must verify your email address.`, - confirm, - cancel, - ); - - if (result === confirm) { - void this.resendVerification(); - } - } else if (isSubscriptionTrial(this._subscription)) { - const remaining = getSubscriptionTimeRemaining(this._subscription, 'days'); - - const confirm: MessageItem = { title: 'OK', isCloseAffordance: true }; - const learn: MessageItem = { title: 'Learn More' }; - const result = await window.showInformationMessage( - `You are now signed in to your ${ - actual.name - } account which gives you access to GitLens+ features on public repos.\n\nYou were also granted a trial of ${ - effective.name - } for both public and private repos for ${pluralize('more day', remaining ?? 0)}.`, - { modal: true }, - confirm, - learn, - ); - - if (result === learn) { - void this.learn(); - } - } else { - void window.showInformationMessage(`You are now signed in to your ${actual.name} account.`, 'OK'); - } - } - return loggedIn; - } - - @gate() - @log() - logout(reset: boolean = false): void { - this._sessionPromise = undefined; - this._session = undefined; - void this.container.storage.storeWorkspace(this.connectedKey, false); - - if (reset && this.container.debugging) { - this.changeSubscription(undefined); - - return; - } - - this.changeSubscription({ - ...this._subscription, - plan: { - actual: getSubscriptionPlan(SubscriptionPlanId.Free), - effective: getSubscriptionPlan(SubscriptionPlanId.Free), - }, - account: undefined, - }); - } - - @log() - manage(): void { - void env.openExternal(this.baseAccountUri); - } - - @log() - async purchase(): Promise { - if (!(await ensurePlusFeaturesEnabled())) return; - - if (this._subscription.account == null) { - void this.showPlans(); - } else { - void env.openExternal( - Uri.joinPath(this.baseAccountUri, 'create-organization').with({ query: 'product=gitlens' }), - ); - } - await this.showHomeView(); - } - - @gate() - @log() - async resendVerification(): Promise { - if (this._subscription.account?.verified) return; - - const cc = Logger.getCorrelationContext(); - - void this.showHomeView(true); - - const session = await this.ensureSession(false); - if (session == null) return; - - try { - const rsp = await fetch(Uri.joinPath(this.baseApiUri, 'resend-email').toString(), { - method: 'POST', - headers: { - Authorization: `Bearer ${session.accessToken}`, - 'User-Agent': userAgent, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ id: session.account.id }), - }); - - if (!rsp.ok) { - debugger; - Logger.error('', cc, `Unable to resend verification email; status=(${rsp.status}): ${rsp.statusText}`); - - void window.showErrorMessage(`Unable to resend verification email; Status: ${rsp.statusText}`, 'OK'); - - return; - } - - const confirm = { title: 'Recheck' }; - const cancel = { title: 'Cancel' }; - const result = await window.showInformationMessage( - "Once you have verified your email address, click 'Recheck'.", - confirm, - cancel, - ); - if (result === confirm) { - await this.validate(); - } - } catch (ex) { - Logger.error(ex, cc); - debugger; - - void window.showErrorMessage('Unable to resend verification email', 'OK'); - } - } - - @log() - async showHomeView(silent: boolean = false): Promise { - if (silent && !configuration.get('plusFeatures.enabled', undefined, true)) return; - - if (!this.container.homeView.visible) { - await executeCommand(Commands.ShowHomeView); - } - } - - private showPlans(): void { - void env.openExternal(Uri.joinPath(this.baseSiteUri, 'gitlens/pricing')); - } - - @gate() - @log() - async startPreviewTrial(): Promise { - if (!(await ensurePlusFeaturesEnabled())) return; - - let { plan, previewTrial } = this._subscription; - if (previewTrial != null || plan.effective.id !== SubscriptionPlanId.Free) { - void this.showHomeView(); - - if (plan.effective.id === SubscriptionPlanId.Free) { - const confirm: MessageItem = { title: 'Sign in to GitLens+', isCloseAffordance: true }; - const cancel: MessageItem = { title: 'Cancel' }; - const result = await window.showInformationMessage( - 'Your GitLens+ features trial has ended.\nPlease sign in to use GitLens+ features on public repos and get a free 7-day trial for both public and private repos.', - { modal: true }, - confirm, - cancel, - ); - - if (result === confirm) { - void this.loginOrSignUp(); - } - } - return; - } - - const startedOn = new Date(); - - let days; - let expiresOn = new Date(startedOn); - if (!this.container.debugging) { - // Normalize the date to just before midnight on the same day - expiresOn.setHours(23, 59, 59, 999); - expiresOn = createFromDateDelta(expiresOn, { days: 3 }); - days = 3; - } else { - expiresOn = createFromDateDelta(expiresOn, { minutes: 1 }); - days = 0; - } - - previewTrial = { - startedOn: startedOn.toISOString(), - expiresOn: expiresOn.toISOString(), - }; - - this.changeSubscription({ - ...this._subscription, - plan: { - ...this._subscription.plan, - effective: getSubscriptionPlan(SubscriptionPlanId.Pro, startedOn, expiresOn), - }, - previewTrial: previewTrial, - }); - - const confirm: MessageItem = { title: 'OK', isCloseAffordance: true }; - const learn: MessageItem = { title: 'Learn More' }; - const result = await window.showInformationMessage( - `You have started a ${days} day trial of GitLens+ features for both public and private repos.`, - { modal: true }, - confirm, - learn, - ); - - if (result === learn) { - void this.learn(); - } - } - - @gate() - @log() - async validate(): Promise { - const cc = Logger.getCorrelationContext(); - - const session = await this.ensureSession(false); - if (session == null) { - this.changeSubscription(this._subscription); - return; - } - - try { - await this.checkInAndValidate(session); - } catch (ex) { - Logger.error(ex, cc); - debugger; - } - } - - private _lastCheckInDate: Date | undefined; - @debug({ args: { 0: s => s?.account.label } }) - private async checkInAndValidate(session: AuthenticationSession): Promise { - const cc = Logger.getCorrelationContext(); - - try { - const checkInData = { - id: session.account.id, - platform: getPlatform(), - gitlensVersion: this.container.version, - vscodeEdition: env.appName, - vscodeHost: env.appHost, - vscodeVersion: codeVersion, - previewStartedOn: this._subscription.previewTrial?.startedOn, - previewExpiresOn: this._subscription.previewTrial?.expiresOn, - }; - - const rsp = await fetch(Uri.joinPath(this.baseApiUri, 'gitlens/checkin').toString(), { - method: 'POST', - headers: { - Authorization: `Bearer ${session.accessToken}`, - 'User-Agent': userAgent, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(checkInData), - }); - - if (!rsp.ok) { - throw new AccountValidationError('Unable to validate account', undefined, rsp.status, rsp.statusText); - } - - const data: GKLicenseInfo = await rsp.json(); - this.validateSubscription(data); - this._lastCheckInDate = new Date(); - } catch (ex) { - Logger.error(ex, cc); - debugger; - if (ex instanceof AccountValidationError) throw ex; - - throw new AccountValidationError('Unable to validate account', ex); - } finally { - this.startDailyCheckInTimer(); - } - } - - private _dailyCheckInTimer: ReturnType | undefined; - private startDailyCheckInTimer(): void { - if (this._dailyCheckInTimer != null) { - clearInterval(this._dailyCheckInTimer); - } - - // Check twice a day to ensure we check in at least once a day - this._dailyCheckInTimer = setInterval(() => { - if (this._lastCheckInDate == null || this._lastCheckInDate.getDate() !== new Date().getDate()) { - void this.ensureSession(false, true); - } - }, 1000 * 60 * 60 * 12); - } - - @debug() - 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({ - ...this._subscription, - plan: { - actual: actual, - effective: effective, - }, - account: account, - }); - } - - private _sessionPromise: Promise | undefined; - private _session: AuthenticationSession | null | undefined; - - @gate() - @debug() - private async ensureSession(createIfNeeded: boolean, force?: boolean): Promise { - if (this._sessionPromise != null && this._session === undefined) { - this._session = await this._sessionPromise; - this._sessionPromise = undefined; - } - - if (!force && this._session != null) return this._session; - if (this._session === null && !createIfNeeded) return undefined; - - if (createIfNeeded) { - await this.container.storage.deleteWorkspace(this.connectedKey); - } else if (this.container.storage.getWorkspace(this.connectedKey) === false) { - return undefined; - } - - if (this._sessionPromise === undefined) { - this._sessionPromise = this.getOrCreateSession(createIfNeeded); - } - - this._session = await this._sessionPromise; - this._sessionPromise = undefined; - return this._session ?? undefined; - } - - @debug() - private async getOrCreateSession(createIfNeeded: boolean): Promise { - const cc = Logger.getCorrelationContext(); - - let session: AuthenticationSession | null | undefined; - - 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; - } - - Logger.error(ex, cc); - } - - if (session == null) { - this.logout(); - return session ?? null; - } - - try { - await this.checkInAndValidate(session); - } catch (ex) { - Logger.error(ex, cc); - debugger; - - const name = session.account.label; - session = null; - if (ex instanceof AccountValidationError) { - this.logout(); - - if (createIfNeeded) { - void window.showErrorMessage( - `Unable to sign in to your GitLens+ account. Please try again. If this issue persists, please contact support. Account=${name} Error=${ex.message}`, - 'OK', - ); - } - } - } - - return session; - } - - @debug() - 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.previewTrial != null && - (getTimeRemaining(subscription.previewTrial.expiresOn) ?? 0) > 0 - ) { - (subscription.plan as PickMutable).effective = getSubscriptionPlan( - SubscriptionPlanId.Pro, - new Date(subscription.previewTrial.startedOn), - new Date(subscription.previewTrial.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, etag: this._etag }); - } - } - - private getStoredSubscription(): Subscription | undefined { - const storedSubscription = this.container.storage.get>(StorageKeys.Subscription); - return storedSubscription?.data; - } - - private async storeSubscription(subscription: Subscription): Promise { - return this.container.storage.store>(StorageKeys.Subscription, { - v: 1, - data: subscription, - }); - } - - private updateContext(): void { - void this.updateStatusBar(); - - queueMicrotask(async () => { - const { allowed, subscription } = await this.container.git.access(); - const required = allowed - ? false - : subscription.required != null && isSubscriptionPaidPlan(subscription.required) - ? 'paid' - : 'free+'; - void setContext(ContextKeys.PlusAllowed, allowed); - void setContext(ContextKeys.PlusRequired, required); - }); - - const { - plan: { actual }, - state, - } = this._subscription; - - void setContext(ContextKeys.Plus, actual.id != SubscriptionPlanId.Free ? actual.id : undefined); - void setContext(ContextKeys.PlusState, state); - } - - private updateStatusBar(): void { - const { - account, - plan: { effective }, - } = this._subscription; - - if (effective.id === SubscriptionPlanId.Free) { - this._statusBarSubscription?.dispose(); - this._statusBarSubscription = undefined; - return; - } - - const trial = isSubscriptionTrial(this._subscription); - if (!trial && account?.verified !== false) { - this._statusBarSubscription?.dispose(); - this._statusBarSubscription = undefined; - return; - } - - if (this._statusBarSubscription == null) { - this._statusBarSubscription = window.createStatusBarItem( - 'gitlens.subscription', - StatusBarAlignment.Left, - 1, - ); - } - - this._statusBarSubscription.name = 'GitLens Subscription'; - this._statusBarSubscription.command = Commands.ShowHomeView; - - if (account?.verified === false) { - this._statusBarSubscription.text = `$(warning) ${effective.name} (Unverified)`; - this._statusBarSubscription.backgroundColor = new ThemeColor('statusBarItem.warningBackground'); - this._statusBarSubscription.tooltip = new MarkdownString( - trial - ? `**Please verify your email**\n\nBefore you can start your **${effective.name}** trial, please verify the email for the account you created.\n\nClick for details` - : `**Please verify your email**\n\nBefore you can use GitLens+ features, please verify the email for the account you created.\n\nClick for details`, - true, - ); - } else { - const remaining = getSubscriptionTimeRemaining(this._subscription, 'days'); - - this._statusBarSubscription.text = `${effective.name} (Trial)`; - this._statusBarSubscription.tooltip = new MarkdownString( - `You are currently trialing **${ - effective.name - }**, which gives you access to GitLens+ features on both public and private repos. You have ${pluralize( - 'day', - remaining ?? 0, - )} remaining in your trial.\n\nClick for details`, - true, - ); - } - - 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/premium/subscription/utils.ts b/src/premium/subscription/utils.ts deleted file mode 100644 index 00dcb62..0000000 --- a/src/premium/subscription/utils.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { MessageItem, window } from 'vscode'; -import { configuration } from '../../configuration'; - -export async function ensurePlusFeaturesEnabled(): Promise { - if (configuration.get('plusFeatures.enabled', undefined, true)) return true; - - const confirm: MessageItem = { title: 'Enable' }; - const cancel: MessageItem = { title: 'Cancel', isCloseAffordance: true }; - const result = await window.showInformationMessage( - 'GitLens+ features are currently disabled. Would you like to enable them?', - { modal: true }, - confirm, - cancel, - ); - - if (result !== confirm) return false; - - void (await configuration.updateEffective('plusFeatures.enabled', true)); - return true; -} diff --git a/src/premium/webviews/timeline/protocol.ts b/src/premium/webviews/timeline/protocol.ts deleted file mode 100644 index 13cd453..0000000 --- a/src/premium/webviews/timeline/protocol.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { FeatureAccess } from '../../../features'; -import { IpcCommandType, IpcNotificationType } from '../../../webviews/protocol'; - -export interface State { - dataset?: Commit[]; - period: Period; - title: string; - uri?: string; - - dateFormat: string; - access: FeatureAccess; -} - -export interface Commit { - commit: string; - author: string; - date: string; - message: string; - - additions: number | undefined; - deletions: number | undefined; - - sort: number; -} - -export type Period = `${number}|${'D' | 'M' | 'Y'}`; - -export interface DidChangeStateParams { - state: State; -} -export const DidChangeStateNotificationType = new IpcNotificationType('timeline/data/didChange'); - -export interface OpenDataPointParams { - data?: { - id: string; - selected: boolean; - }; -} -export const OpenDataPointCommandType = new IpcCommandType('timeline/point/click'); - -export interface UpdatePeriodParams { - period: Period; -} -export const UpdatePeriodCommandType = new IpcCommandType('timeline/period/update'); diff --git a/src/premium/webviews/timeline/timelineWebview.ts b/src/premium/webviews/timeline/timelineWebview.ts deleted file mode 100644 index 280c987..0000000 --- a/src/premium/webviews/timeline/timelineWebview.ts +++ /dev/null @@ -1,399 +0,0 @@ -'use strict'; -import { commands, Disposable, TextEditor, Uri, ViewColumn, window } from 'vscode'; -import type { ShowQuickCommitCommandArgs } from '../../../commands'; -import { configuration } from '../../../configuration'; -import { Commands, ContextKeys } from '../../../constants'; -import type { Container } from '../../../container'; -import { setContext } from '../../../context'; -import { PlusFeatures } from '../../../features'; -import { GitUri } from '../../../git/gitUri'; -import { RepositoryChange, RepositoryChangeComparisonMode, RepositoryChangeEvent } from '../../../git/models'; -import { createFromDateDelta } from '../../../system/date'; -import { debug } from '../../../system/decorators/log'; -import { debounce, Deferrable } from '../../../system/function'; -import { filter } from '../../../system/iterable'; -import { hasVisibleTextEditor, isTextEditor } from '../../../system/utils'; -import { IpcMessage, onIpc } from '../../../webviews/protocol'; -import { WebviewBase } from '../../../webviews/webviewBase'; -import type { SubscriptionChangeEvent } from '../../subscription/subscriptionService'; -import { ensurePlusFeaturesEnabled } from '../../subscription/utils'; -import { - Commit, - DidChangeStateNotificationType, - OpenDataPointCommandType, - Period, - State, - UpdatePeriodCommandType, -} from './protocol'; -import { generateRandomTimelineDataset } from './timelineWebviewView'; - -interface Context { - uri: Uri | undefined; - period: Period | undefined; - etagRepository: number | undefined; - etagSubscription: number | undefined; -} - -const defaultPeriod: Period = '3|M'; - -export class TimelineWebview extends WebviewBase { - private _bootstraping = true; - /** The context the webview has */ - private _context: Context; - /** The context the webview should have */ - private _pendingContext: Partial | undefined; - private _originalTitle: string; - - constructor(container: Container) { - super( - container, - 'gitlens.timeline', - 'timeline.html', - 'images/gitlens-icon.png', - 'Visual File History', - Commands.ShowTimelinePage, - ); - this._originalTitle = this.title; - this._context = { - uri: undefined, - period: defaultPeriod, - etagRepository: 0, - etagSubscription: 0, - }; - } - - override async show(column: ViewColumn = ViewColumn.Beside): Promise { - if (!(await ensurePlusFeaturesEnabled())) return; - return super.show(column); - } - - protected override onInitializing(): Disposable[] | undefined { - this._context = { - uri: undefined, - period: defaultPeriod, - etagRepository: 0, - etagSubscription: this.container.subscription.etag, - }; - - this.updatePendingEditor(window.activeTextEditor); - this._context = { ...this._context, ...this._pendingContext }; - this._pendingContext = undefined; - - return [ - this.container.subscription.onDidChange(this.onSubscriptionChanged, this), - this.container.git.onDidChangeRepository(this.onRepositoryChanged, this), - ]; - } - - protected override onShowCommand(uri?: Uri): void { - if (uri != null) { - this.updatePendingUri(uri); - } else { - this.updatePendingEditor(window.activeTextEditor); - } - this._context = { ...this._context, ...this._pendingContext }; - this._pendingContext = undefined; - - super.onShowCommand(); - } - - protected override async includeBootstrap(): Promise { - this._bootstraping = true; - - this._context = { ...this._context, ...this._pendingContext }; - this._pendingContext = undefined; - - return this.getState(this._context); - } - - protected override registerCommands(): Disposable[] { - return [commands.registerCommand(Commands.RefreshTimelinePage, () => this.refresh())]; - } - - protected override onFocusChanged(focused: boolean): void { - if (focused) { - // If we are becoming focused, delay it a bit to give the UI time to update - setTimeout(() => void setContext(ContextKeys.TimelinePageFocused, focused), 0); - return; - } - - void setContext(ContextKeys.TimelinePageFocused, focused); - } - - protected override onVisibilityChanged(visible: boolean) { - if (!visible) return; - - // Since this gets called even the first time the webview is shown, avoid sending an update, because the bootstrap has the data - if (this._bootstraping) { - this._bootstraping = false; - - // If the uri changed since bootstrap still send the update - if (this._pendingContext == null || !('uri' in this._pendingContext)) { - return; - } - } - - // Should be immediate, but it causes the bubbles to go missing on the chart, since the update happens while it still rendering - this.updateState(); - } - - protected override onMessageReceived(e: IpcMessage) { - switch (e.method) { - case OpenDataPointCommandType.method: - onIpc(OpenDataPointCommandType, e, params => { - if (params.data == null || !params.data.selected || this._context.uri == null) return; - - const repository = this.container.git.getRepository(this._context.uri); - if (repository == null) return; - - const commandArgs: ShowQuickCommitCommandArgs = { - repoPath: repository.path, - sha: params.data.id, - }; - - void commands.executeCommand(Commands.ShowQuickCommit, commandArgs); - - // const commandArgs: DiffWithPreviousCommandArgs = { - // line: 0, - // showOptions: { - // preserveFocus: true, - // preview: true, - // viewColumn: ViewColumn.Beside, - // }, - // }; - - // void commands.executeCommand( - // Commands.DiffWithPrevious, - // new GitUri(gitUri, { repoPath: gitUri.repoPath!, sha: params.data.id }), - // commandArgs, - // ); - }); - - break; - - case UpdatePeriodCommandType.method: - onIpc(UpdatePeriodCommandType, e, params => { - if (this.updatePendingContext({ period: params.period })) { - this.updateState(true); - } - }); - - break; - } - } - - @debug({ args: false }) - private onRepositoryChanged(e: RepositoryChangeEvent) { - if (!e.changed(RepositoryChange.Heads, RepositoryChange.Index, RepositoryChangeComparisonMode.Any)) { - return; - } - - if (this.updatePendingContext({ etagRepository: e.repository.etag })) { - this.updateState(); - } - } - - @debug({ args: false }) - private onSubscriptionChanged(e: SubscriptionChangeEvent) { - if (this.updatePendingContext({ etagSubscription: e.etag })) { - this.updateState(); - } - } - - @debug({ args: false }) - private async getState(current: Context): Promise { - const access = await this.container.git.access(PlusFeatures.Timeline); - const dateFormat = this.container.config.defaultDateFormat ?? 'MMMM Do, YYYY h:mma'; - const period = current.period ?? defaultPeriod; - - if (!access.allowed) { - const dataset = generateRandomTimelineDataset(); - return { - dataset: dataset.sort((a, b) => b.sort - a.sort), - period: period, - title: 'src/app/index.ts', - uri: Uri.file('src/app/index.ts').toString(), - dateFormat: dateFormat, - access: access, - }; - } - - if (current.uri == null) { - return { - period: period, - title: 'There are no editors open that can provide file history information', - dateFormat: dateFormat, - access: access, - }; - } - - const gitUri = await GitUri.fromUri(current.uri); - const repoPath = gitUri.repoPath!; - const title = gitUri.relativePath; - - this.title = `${this._originalTitle}: ${gitUri.fileName}`; - - const [currentUser, log] = await Promise.all([ - this.container.git.getCurrentUser(repoPath), - this.container.git.getLogForFile(repoPath, gitUri.fsPath, { - limit: 0, - ref: gitUri.sha, - since: this.getPeriodDate(period).toISOString(), - }), - ]); - - if (log == null) { - return { - dataset: [], - period: period, - title: 'No commits found for the specified time period', - uri: current.uri.toString(), - dateFormat: dateFormat, - access: access, - }; - } - - let queryRequiredCommits = [ - ...filter(log.commits.values(), c => c.file?.stats == null && c.stats?.changedFiles !== 1), - ]; - - if (queryRequiredCommits.length !== 0) { - const limit = configuration.get('visualHistory.queryLimit') ?? 20; - - const repository = this.container.git.getRepository(current.uri); - const name = repository?.provider.name; - - if (queryRequiredCommits.length > limit) { - void window.showWarningMessage( - `Unable able to show more than the first ${limit} commits for the specified time period because of ${ - name ? `${name} ` : '' - }rate limits.`, - ); - queryRequiredCommits = queryRequiredCommits.slice(0, 20); - } - - void (await Promise.allSettled(queryRequiredCommits.map(c => c.ensureFullDetails()))); - } - - const name = currentUser?.name ? `${currentUser.name} (you)` : 'You'; - - const dataset: Commit[] = []; - for (const commit of log.commits.values()) { - const stats = commit.file?.stats ?? (commit.stats?.changedFiles === 1 ? commit.stats : undefined); - dataset.push({ - author: commit.author.name === 'You' ? name : commit.author.name, - additions: stats?.additions, - deletions: stats?.deletions, - commit: commit.sha, - date: commit.date.toISOString(), - message: commit.message ?? commit.summary, - sort: commit.date.getTime(), - }); - } - - dataset.sort((a, b) => b.sort - a.sort); - - return { - dataset: dataset, - period: period, - title: title, - uri: current.uri.toString(), - dateFormat: dateFormat, - access: access, - }; - } - - private getPeriodDate(period: Period): Date { - const [number, unit] = period.split('|'); - - switch (unit) { - case 'D': - return createFromDateDelta(new Date(), { days: -parseInt(number, 10) }); - case 'M': - return createFromDateDelta(new Date(), { months: -parseInt(number, 10) }); - case 'Y': - return createFromDateDelta(new Date(), { years: -parseInt(number, 10) }); - default: - return createFromDateDelta(new Date(), { months: -3 }); - } - } - - private updatePendingContext(context: Partial): boolean { - let changed = false; - for (const [key, value] of Object.entries(context)) { - const current = (this._context as unknown as Record)[key]; - if ( - current === value || - ((current instanceof Uri || value instanceof Uri) && (current as any)?.toString() === value?.toString()) - ) { - continue; - } - - if (this._pendingContext == null) { - this._pendingContext = {}; - } - - (this._pendingContext as Record)[key] = value; - changed = true; - } - - return changed; - } - - private updatePendingEditor(editor: TextEditor | undefined): boolean { - if (editor == null && hasVisibleTextEditor()) return false; - if (editor != null && !isTextEditor(editor)) return false; - - return this.updatePendingUri(editor?.document.uri); - } - - private updatePendingUri(uri: Uri | undefined): boolean { - let etag; - if (uri != null) { - const repository = this.container.git.getRepository(uri); - etag = repository?.etag ?? 0; - } else { - etag = 0; - } - - return this.updatePendingContext({ uri: uri, etagRepository: etag }); - } - - private _notifyDidChangeStateDebounced: Deferrable<() => void> | undefined = undefined; - - @debug() - private updateState(immediate: boolean = false) { - if (!this.isReady || !this.visible) return; - - if (immediate) { - void this.notifyDidChangeState(); - return; - } - - if (this._notifyDidChangeStateDebounced == null) { - this._notifyDidChangeStateDebounced = debounce(this.notifyDidChangeState.bind(this), 500); - } - - this._notifyDidChangeStateDebounced(); - } - - @debug() - private async notifyDidChangeState() { - if (!this.isReady || !this.visible) return false; - - this._notifyDidChangeStateDebounced?.cancel(); - if (this._pendingContext == null) return false; - - const context = { ...this._context, ...this._pendingContext }; - - return window.withProgress({ location: { viewId: this.id } }, async () => { - const success = await this.notify(DidChangeStateNotificationType, { - state: await this.getState(context), - }); - if (success) { - this._context = context; - this._pendingContext = undefined; - } - }); - } -} diff --git a/src/premium/webviews/timeline/timelineWebviewView.ts b/src/premium/webviews/timeline/timelineWebviewView.ts deleted file mode 100644 index f4bdbf8..0000000 --- a/src/premium/webviews/timeline/timelineWebviewView.ts +++ /dev/null @@ -1,425 +0,0 @@ -'use strict'; -import { commands, Disposable, TextEditor, Uri, window } from 'vscode'; -import type { ShowQuickCommitCommandArgs } from '../../../commands'; -import { configuration } from '../../../configuration'; -import { Commands } from '../../../constants'; -import { Container } from '../../../container'; -import { PlusFeatures } from '../../../features'; -import type { RepositoriesChangeEvent } from '../../../git/gitProviderService'; -import { GitUri } from '../../../git/gitUri'; -import { RepositoryChange, RepositoryChangeComparisonMode, RepositoryChangeEvent } from '../../../git/models'; -import { createFromDateDelta } from '../../../system/date'; -import { debug } from '../../../system/decorators/log'; -import { debounce, Deferrable } from '../../../system/function'; -import { filter } from '../../../system/iterable'; -import { hasVisibleTextEditor, isTextEditor } from '../../../system/utils'; -import { IpcMessage, onIpc } from '../../../webviews/protocol'; -import { WebviewViewBase } from '../../../webviews/webviewViewBase'; -import type { SubscriptionChangeEvent } from '../../subscription/subscriptionService'; -import { ensurePlusFeaturesEnabled } from '../../subscription/utils'; -import { - Commit, - DidChangeStateNotificationType, - OpenDataPointCommandType, - Period, - State, - UpdatePeriodCommandType, -} from './protocol'; - -interface Context { - uri: Uri | undefined; - period: Period | undefined; - etagRepositories: number | undefined; - etagRepository: number | undefined; - etagSubscription: number | undefined; -} - -const defaultPeriod: Period = '3|M'; - -export class TimelineWebviewView extends WebviewViewBase { - private _bootstraping = true; - /** The context the webview has */ - private _context: Context; - /** The context the webview should have */ - private _pendingContext: Partial | undefined; - - constructor(container: Container) { - super(container, 'gitlens.views.timeline', 'timeline.html', 'Visual File History'); - - this._context = { - uri: undefined, - period: defaultPeriod, - etagRepositories: 0, - etagRepository: 0, - etagSubscription: 0, - }; - } - - override async show(options?: { preserveFocus?: boolean | undefined }): Promise { - if (!(await ensurePlusFeaturesEnabled())) return; - return super.show(options); - } - - protected override onInitializing(): Disposable[] | undefined { - this._context = { - uri: undefined, - period: defaultPeriod, - etagRepositories: this.container.git.etag, - etagRepository: 0, - etagSubscription: this.container.subscription.etag, - }; - - this.updatePendingEditor(window.activeTextEditor); - this._context = { ...this._context, ...this._pendingContext }; - this._pendingContext = undefined; - - return [ - this.container.subscription.onDidChange(this.onSubscriptionChanged, this), - window.onDidChangeActiveTextEditor(this.onActiveEditorChanged, this), - this.container.git.onDidChangeRepository(this.onRepositoryChanged, this), - this.container.git.onDidChangeRepositories(this.onRepositoriesChanged, this), - ]; - } - - protected override async includeBootstrap(): Promise { - this._bootstraping = true; - - this._context = { ...this._context, ...this._pendingContext }; - this._pendingContext = undefined; - - return this.getState(this._context); - } - - protected override registerCommands(): Disposable[] { - return [ - commands.registerCommand(`${this.id}.refresh`, () => this.refresh(), this), - commands.registerCommand(`${this.id}.openInTab`, () => this.openInTab(), this), - ]; - } - - protected override onVisibilityChanged(visible: boolean) { - if (!visible) return; - - // Since this gets called even the first time the webview is shown, avoid sending an update, because the bootstrap has the data - if (this._bootstraping) { - this._bootstraping = false; - - // If the uri changed since bootstrap still send the update - if (this._pendingContext == null || !('uri' in this._pendingContext)) { - return; - } - } - - // Should be immediate, but it causes the bubbles to go missing on the chart, since the update happens while it still rendering - this.updateState(); - } - - protected override onMessageReceived(e: IpcMessage) { - switch (e.method) { - case OpenDataPointCommandType.method: - onIpc(OpenDataPointCommandType, e, params => { - if (params.data == null || !params.data.selected || this._context.uri == null) return; - - const repository = this.container.git.getRepository(this._context.uri); - if (repository == null) return; - - const commandArgs: ShowQuickCommitCommandArgs = { - repoPath: repository.path, - sha: params.data.id, - }; - - void commands.executeCommand(Commands.ShowQuickCommit, commandArgs); - - // const commandArgs: DiffWithPreviousCommandArgs = { - // line: 0, - // showOptions: { - // preserveFocus: true, - // preview: true, - // viewColumn: ViewColumn.Beside, - // }, - // }; - - // void commands.executeCommand( - // Commands.DiffWithPrevious, - // new GitUri(gitUri, { repoPath: gitUri.repoPath!, sha: params.data.id }), - // commandArgs, - // ); - }); - - break; - - case UpdatePeriodCommandType.method: - onIpc(UpdatePeriodCommandType, e, params => { - if (this.updatePendingContext({ period: params.period })) { - this.updateState(true); - } - }); - - break; - } - } - - @debug({ args: false }) - private onActiveEditorChanged(editor: TextEditor | undefined) { - if (!this.updatePendingEditor(editor)) return; - - this.updateState(); - } - - @debug({ args: false }) - private onRepositoriesChanged(e: RepositoriesChangeEvent) { - const changed = this.updatePendingUri(this._context.uri); - - if (this.updatePendingContext({ etagRepositories: e.etag }) || changed) { - this.updateState(); - } - } - - @debug({ args: false }) - private onRepositoryChanged(e: RepositoryChangeEvent) { - if (!e.changed(RepositoryChange.Heads, RepositoryChange.Index, RepositoryChangeComparisonMode.Any)) { - return; - } - - if (this.updatePendingContext({ etagRepository: e.repository.etag })) { - this.updateState(); - } - } - - @debug({ args: false }) - private onSubscriptionChanged(e: SubscriptionChangeEvent) { - if (this.updatePendingContext({ etagSubscription: e.etag })) { - this.updateState(); - } - } - - @debug({ args: false }) - private async getState(current: Context): Promise { - const access = await this.container.git.access(PlusFeatures.Timeline); - const dateFormat = this.container.config.defaultDateFormat ?? 'MMMM Do, YYYY h:mma'; - const period = current.period ?? defaultPeriod; - - if (!access.allowed) { - const dataset = generateRandomTimelineDataset(); - return { - dataset: dataset.sort((a, b) => b.sort - a.sort), - period: period, - title: 'src/app/index.ts', - uri: Uri.file('src/app/index.ts').toString(), - dateFormat: dateFormat, - access: access, - }; - } - - if (current.uri == null) { - return { - period: period, - title: 'There are no editors open that can provide file history information', - dateFormat: dateFormat, - access: access, - }; - } - - const gitUri = await GitUri.fromUri(current.uri); - const repoPath = gitUri.repoPath!; - const title = gitUri.relativePath; - - this.description = gitUri.fileName; - - const [currentUser, log] = await Promise.all([ - this.container.git.getCurrentUser(repoPath), - this.container.git.getLogForFile(repoPath, gitUri.fsPath, { - limit: 0, - ref: gitUri.sha, - since: this.getPeriodDate(period).toISOString(), - }), - ]); - - if (log == null) { - return { - dataset: [], - period: period, - title: 'No commits found for the specified time period', - uri: current.uri.toString(), - dateFormat: dateFormat, - access: access, - }; - } - - let queryRequiredCommits = [ - ...filter(log.commits.values(), c => c.file?.stats == null && c.stats?.changedFiles !== 1), - ]; - - if (queryRequiredCommits.length !== 0) { - const limit = configuration.get('visualHistory.queryLimit') ?? 20; - - const repository = this.container.git.getRepository(current.uri); - const name = repository?.provider.name; - - if (queryRequiredCommits.length > limit) { - void window.showWarningMessage( - `Unable able to show more than the first ${limit} commits for the specified time period because of ${ - name ? `${name} ` : '' - }rate limits.`, - ); - queryRequiredCommits = queryRequiredCommits.slice(0, 20); - } - - void (await Promise.allSettled(queryRequiredCommits.map(c => c.ensureFullDetails()))); - } - - const name = currentUser?.name ? `${currentUser.name} (you)` : 'You'; - - const dataset: Commit[] = []; - for (const commit of log.commits.values()) { - const stats = commit.file?.stats ?? (commit.stats?.changedFiles === 1 ? commit.stats : undefined); - dataset.push({ - author: commit.author.name === 'You' ? name : commit.author.name, - additions: stats?.additions, - deletions: stats?.deletions, - commit: commit.sha, - date: commit.date.toISOString(), - message: commit.message ?? commit.summary, - sort: commit.date.getTime(), - }); - } - - dataset.sort((a, b) => b.sort - a.sort); - - return { - dataset: dataset, - period: period, - title: title, - uri: current.uri.toString(), - dateFormat: dateFormat, - access: access, - }; - } - - private getPeriodDate(period: Period): Date { - const [number, unit] = period.split('|'); - - switch (unit) { - case 'D': - return createFromDateDelta(new Date(), { days: -parseInt(number, 10) }); - case 'M': - return createFromDateDelta(new Date(), { months: -parseInt(number, 10) }); - case 'Y': - return createFromDateDelta(new Date(), { years: -parseInt(number, 10) }); - default: - return createFromDateDelta(new Date(), { months: -3 }); - } - } - - private openInTab() { - const uri = this._context.uri; - if (uri == null) return; - - void commands.executeCommand(Commands.ShowTimelinePage, uri); - } - - private updatePendingContext(context: Partial): boolean { - let changed = false; - for (const [key, value] of Object.entries(context)) { - const current = (this._context as unknown as Record)[key]; - if ( - current === value || - ((current instanceof Uri || value instanceof Uri) && (current as any)?.toString() === value?.toString()) - ) { - continue; - } - - if (this._pendingContext == null) { - this._pendingContext = {}; - } - - (this._pendingContext as Record)[key] = value; - changed = true; - } - - return changed; - } - - private updatePendingEditor(editor: TextEditor | undefined): boolean { - if (editor == null && hasVisibleTextEditor()) return false; - if (editor != null && !isTextEditor(editor)) return false; - - return this.updatePendingUri(editor?.document.uri); - } - - private updatePendingUri(uri: Uri | undefined): boolean { - let etag; - if (uri != null) { - const repository = this.container.git.getRepository(uri); - etag = repository?.etag ?? 0; - } else { - etag = 0; - } - - return this.updatePendingContext({ uri: uri, etagRepository: etag }); - } - - private _notifyDidChangeStateDebounced: Deferrable<() => void> | undefined = undefined; - - @debug() - private updateState(immediate: boolean = false) { - if (!this.isReady || !this.visible) return; - - this.updatePendingEditor(window.activeTextEditor); - - if (immediate) { - void this.notifyDidChangeState(); - return; - } - - if (this._notifyDidChangeStateDebounced == null) { - this._notifyDidChangeStateDebounced = debounce(this.notifyDidChangeState.bind(this), 500); - } - - this._notifyDidChangeStateDebounced(); - } - - @debug() - private async notifyDidChangeState() { - if (!this.isReady || !this.visible) return false; - - this._notifyDidChangeStateDebounced?.cancel(); - if (this._pendingContext == null) return false; - - const context = { ...this._context, ...this._pendingContext }; - - return window.withProgress({ location: { viewId: this.id } }, async () => { - const success = await this.notify(DidChangeStateNotificationType, { - state: await this.getState(context), - }); - if (success) { - this._context = context; - this._pendingContext = undefined; - } - }); - } -} - -export function generateRandomTimelineDataset(): Commit[] { - const dataset: Commit[] = []; - const authors = ['Eric Amodio', 'Justin Roberts', 'Ada Lovelace', 'Grace Hopper']; - - const count = 10; - for (let i = 0; i < count; i++) { - // Generate a random date between now and 3 months ago - const date = new Date(new Date().getTime() - Math.floor(Math.random() * (3 * 30 * 24 * 60 * 60 * 1000))); - - dataset.push({ - commit: String(i), - author: authors[Math.floor(Math.random() * authors.length)], - date: date.toISOString(), - message: '', - // Generate random additions/deletions between 1 and 20, but ensure we have a tiny and large commit - additions: i === 0 ? 2 : i === count - 1 ? 50 : Math.floor(Math.random() * 20) + 1, - deletions: i === 0 ? 1 : i === count - 1 ? 25 : Math.floor(Math.random() * 20) + 1, - sort: date.getTime(), - }); - } - - return dataset; -} diff --git a/src/views/nodes/viewNode.ts b/src/views/nodes/viewNode.ts index 48ed2ca..9af467e 100644 --- a/src/views/nodes/viewNode.ts +++ b/src/views/nodes/viewNode.ts @@ -21,7 +21,7 @@ import { RepositoryChangeEvent, } from '../../git/models'; import { Logger } from '../../logger'; -import { SubscriptionChangeEvent } from '../../premium/subscription/subscriptionService'; +import { SubscriptionChangeEvent } from '../../plus/subscription/subscriptionService'; import { gate } from '../../system/decorators/gate'; import { debug, log, logName } from '../../system/decorators/log'; import { is as isA, szudzikPairing } from '../../system/function'; diff --git a/src/views/worktreesView.ts b/src/views/worktreesView.ts index 7b1c47e..c2f6499 100644 --- a/src/views/worktreesView.ts +++ b/src/views/worktreesView.ts @@ -15,7 +15,7 @@ import { Container } from '../container'; import { PlusFeatures } from '../features'; import { GitUri } from '../git/gitUri'; import { GitWorktree, RepositoryChange, RepositoryChangeComparisonMode, RepositoryChangeEvent } from '../git/models'; -import { ensurePlusFeaturesEnabled } from '../premium/subscription/utils'; +import { ensurePlusFeaturesEnabled } from '../plus/subscription/utils'; import { getSubscriptionTimeRemaining, SubscriptionState } from '../subscription'; import { gate } from '../system/decorators/gate'; import { pluralize } from '../system/string'; diff --git a/src/webviews/apps/plus/LICENSE.plus b/src/webviews/apps/plus/LICENSE.plus new file mode 100644 index 0000000..2e551e8 --- /dev/null +++ b/src/webviews/apps/plus/LICENSE.plus @@ -0,0 +1,40 @@ +GitLens+ License + +Copyright (c) 2021-2022 Axosoft, LLC dba GitKraken ("GitKraken") + +With regard to the software set forth in or under any directory named "plus". + +This software and associated documentation files (the "Software") may be +compiled as part of the gitkraken/vscode-gitlens open source project (the +"GitLens") to the extent the Software is a required component of the GitLens; +provided, however, that the Software and its functionality may only be used if +you (and any entity that you represent) have agreed to, and are in compliance +with, the GitKraken End User License Agreement, available at +https://www.gitkraken.com/eula (the "EULA"), or other agreement governing the +use of the Software, as agreed by you and GitKraken, and otherwise have a valid +subscription for the correct number of user seats for the applicable version of +the Software (e.g., GitLens Free+, GitLens Pro, GitLens Teams, and GitLens +Enterprise). Subject to the foregoing sentence, you are free to modify this +Software and publish patches to the Software. You agree that GitKraken and/or +its licensors (as applicable) retain all right, title and interest in and to all +such modifications and/or patches, and all such modifications and/or patches may +only be used, copied, modified, displayed, distributed, or otherwise exploited +with a valid subscription for the correct number of user seats for the Software. +In furtherance of the foregoing, you hereby assign to GitKraken all such +modifications and/or patches. You are not granted any other rights beyond what +is expressly stated herein. Except as set forth above, it is forbidden to copy, +merge, publish, distribute, sublicense, and/or sell the Software. + +The full text of this GitLens+ License shall be included in all copies or +substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +For all third party components incorporated into the Software, those components +are licensed under the original license provided by the owner of the applicable +component. diff --git a/src/webviews/apps/plus/timeline/chart.scss b/src/webviews/apps/plus/timeline/chart.scss new file mode 100644 index 0000000..cabf4c3 --- /dev/null +++ b/src/webviews/apps/plus/timeline/chart.scss @@ -0,0 +1,466 @@ +.bb { + svg { + // font-size: 12px; + // line-height: 1; + + font: 10px sans-serif; + -webkit-tap-highlight-color: rgba(0, 0, 0, 0); + } + + path, + line { + fill: none; + // stroke: #000; + } + + path.domain { + .vscode-dark & { + stroke: var(--color-background--lighten-15); + } + + .vscode-light & { + stroke: var(--color-background--darken-15); + } + } + + text, + .bb-button { + user-select: none; + // fill: var(--color-foreground--75); + fill: var(--color-view-foreground); + font-size: 11px; + } + + .bb-legend-item-tile, + .bb-xgrid-focus, + .bb-ygrid-focus, + .bb-ygrid, + .bb-event-rect, + .bb-bars path { + shape-rendering: crispEdges; + } + + .bb-chart-arc { + .bb-gauge-value { + fill: #000; + } + + path { + stroke: #fff; + } + + rect { + stroke: #fff; + stroke-width: 1; + } + + text { + fill: #fff; + font-size: 13px; + } + } + + /*-- Axis --*/ + .bb-axis { + shape-rendering: crispEdges; + } + + // .bb-axis-y, + // .bb-axis-y2 { + // text { + // fill: var(--color-foreground--75); + // } + // } + + // .bb-event-rects { + // fill-opacity: 1 !important; + + // .bb-event-rect { + // fill: transparent; + + // &._active_ { + // fill: rgba(39, 201, 3, 0.05); + // } + // } + // } + + // .tick._active_ text { + // fill: #00c83c !important; + // } + + /*-- Grid --*/ + .bb-grid { + pointer-events: none; + + line { + .vscode-dark & { + stroke: var(--color-background--lighten-05); + + &.bb-ygrid { + stroke: var(--color-background--lighten-05); + } + } + + .vscode-light & { + stroke: var(--color-background--darken-05); + + &.bb-ygrid { + stroke: var(--color-background--darken-05); + } + } + } + + text { + // fill: #aaa; + fill: var(--color-view-foreground); + } + } + + // .bb-xgrid { + // stroke-dasharray: 2 2; + // } + + // .bb-ygrid { + // stroke-dasharray: 2 1; + // } + + .bb-xgrid-focus { + line { + .vscode-dark & { + stroke: var(--color-background--lighten-30); + } + .vscode-light & { + stroke: var(--color-background--darken-30); + } + } + } + + /*-- Text on Chart --*/ + .bb-text.bb-empty { + fill: #808080; + font-size: 2em; + } + + /*-- Line --*/ + .bb-line { + stroke-width: 1px; + } + + /*-- Point --*/ + .bb-circle { + &._expanded_ { + opacity: 1 !important; + fill-opacity: 1 !important; + stroke-opacity: 1 !important; + stroke-width: 3px; + } + } + + .bb-selected-circle { + opacity: 1 !important; + fill-opacity: 1 !important; + stroke-opacity: 1 !important; + stroke-width: 3px; + } + + // rect.bb-circle, + // use.bb-circle { + // &._expanded_ { + // stroke-width: 3px; + // } + // } + + // .bb-selected-circle { + // stroke-width: 2px; + + // .vscode-dark & { + // fill: rgba(255, 255, 255, 0.2); + // } + + // .vscode-light & { + // fill: rgba(0, 0, 0, 0.1); + // } + // } + + /*-- Bar --*/ + .bb-bar { + stroke-width: 0; + opacity: 0.9 !important; + fill-opacity: 0.9 !important; + + &._expanded_ { + opacity: 1 !important; + fill-opacity: 1 !important; + } + } + + /*-- Candlestick --*/ + .bb-candlestick { + stroke-width: 1px; + + &._expanded_ { + fill-opacity: 0.75; + } + } + + /*-- Focus --*/ + .bb-target.bb-focused, + .bb-circles.bb-focused { + opacity: 1; + } + + .bb-target.bb-focused path.bb-line, + .bb-target.bb-focused path.bb-step, + .bb-circles.bb-focused path.bb-line, + .bb-circles.bb-focused path.bb-step { + stroke-width: 2px; + } + + .bb-target.bb-defocused, + .bb-circles.bb-defocused { + opacity: 0.2 !important; + } + .bb-target.bb-defocused .text-overlapping, + .bb-circles.bb-defocused .text-overlapping { + opacity: 0.05 !important; + } + + /*-- Region --*/ + .bb-region { + fill: steelblue; + fill-opacity: 0.1; + } + + /*-- Zoom region --*/ + .bb-zoom-brush { + .vscode-dark & { + fill: white; + fill-opacity: 0.2; + } + + .vscode-light & { + fill: black; + fill-opacity: 0.1; + } + } + + /*-- Brush --*/ + .bb-brush { + .extent { + fill-opacity: 0.1; + } + } + + /*-- Legend --*/ + .bb-legend-item { + font-size: 12px; + user-select: none; + } + + .bb-legend-item-hidden { + opacity: 0.15; + } + + .bb-legend-background { + opacity: 0.75; + fill: white; + stroke: lightgray; + stroke-width: 1; + } + + /*-- Title --*/ + .bb-title { + font: 14px sans-serif; + } + + /*-- Tooltip --*/ + .bb-tooltip-container { + z-index: 10; + user-select: none; + } + + .bb-tooltip { + display: flex; + border-collapse: collapse; + border-spacing: 0; + background-color: var(--color-hover-background); + color: var(--color-hover-foreground); + empty-cells: show; + opacity: 1; + box-shadow: 7px 7px 12px -9px var(--color-hover-border); + + font-size: var(--font-size); + font-family: var(--font-family); + + tbody { + border: 1px solid var(--color-hover-border); + } + + th { + padding: 0.5rem 1rem; + text-align: left; + } + + tr { + &:not(.bb-tooltip-name-additions, .bb-tooltip-name-deletions) { + display: flex; + flex-direction: column-reverse; + margin-bottom: -1px; + + td { + &.name { + display: flex; + align-items: center; + font-size: 12px; + font-family: var(--editor-font-family); + background-color: var(--color-hover-statusBarBackground); + border-top: 1px solid var(--color-hover-border); + border-bottom: 1px solid var(--color-hover-border); + padding: 0.5rem; + line-height: 2rem; + + &:before { + font: normal normal normal 16px/1 codicon; + display: inline-block; + text-decoration: none; + text-rendering: auto; + text-align: center; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + user-select: none; + + vertical-align: middle; + line-height: 2rem; + padding-right: 0.25rem; + + content: '\eafc'; + } + + span { + display: none; + } + } + + &.value { + display: flex; + padding: 0.25rem 2rem 1rem 2rem; + white-space: pre-line; + word-wrap: break-word; + overflow-wrap: break-word; + max-width: 450px; + max-height: 450px; + overflow-y: scroll; + overflow-x: hidden; + } + } + } + + &.bb-tooltip-name-additions, + &.bb-tooltip-name-deletions { + display: inline-flex; + flex-direction: row-reverse; + justify-content: center; + width: calc(50% - 2rem); + margin: 0px 1rem; + + td { + &.name { + text-transform: lowercase; + } + &.value { + margin-right: 0.25rem; + } + } + } + } + + // td > span, + // td > svg { + // display: inline-block; + // width: 10px; + // height: 10px; + // margin-right: 6px; + // } + } + + /*-- Area --*/ + .bb-area { + stroke-width: 0; + opacity: 0.2; + } + + /*-- Arc --*/ + .bb-chart-arcs-title { + dominant-baseline: middle; + font-size: 1.3em; + } + + text.bb-chart-arcs-gauge-title { + dominant-baseline: middle; + font-size: 2.7em; + } + + .bb-chart-arcs .bb-chart-arcs-background { + fill: #e0e0e0; + stroke: #fff; + } + + .bb-chart-arcs .bb-chart-arcs-gauge-unit { + fill: #000; + font-size: 16px; + } + + .bb-chart-arcs .bb-chart-arcs-gauge-max { + fill: #777; + } + + .bb-chart-arcs .bb-chart-arcs-gauge-min { + fill: #777; + } + + /*-- Radar --*/ + .bb-chart-radars .bb-levels polygon { + fill: none; + stroke: #848282; + stroke-width: 0.5px; + } + + .bb-chart-radars .bb-levels text { + fill: #848282; + } + + .bb-chart-radars .bb-axis line { + stroke: #848282; + stroke-width: 0.5px; + } + + .bb-chart-radars .bb-axis text { + font-size: 1.15em; + cursor: default; + } + + .bb-chart-radars .bb-shapes polygon { + fill-opacity: 0.2; + stroke-width: 1px; + } + + /*-- Button --*/ + .bb-button { + position: absolute; + top: 0; + right: 2rem; + + border: 1px solid var(--color-button-background); + background-color: var(--color-button-background); + color: var(--color-button-foreground); + + font-size: var(--font-size); + font-family: var(--font-family); + + .bb-zoom-reset { + display: inline-block; + padding: 0.5rem 1rem; + cursor: pointer; + } + } +} diff --git a/src/webviews/apps/plus/timeline/chart.ts b/src/webviews/apps/plus/timeline/chart.ts new file mode 100644 index 0000000..1449778 --- /dev/null +++ b/src/webviews/apps/plus/timeline/chart.ts @@ -0,0 +1,426 @@ +'use strict'; +/*global*/ +import { bar, bb, bubble, Chart, ChartOptions, ChartTypes, DataItem, zoom } from 'billboard.js'; +// import BubbleCompare from 'billboard.js/dist/plugin/billboardjs-plugin-bubblecompare'; +// import { scaleSqrt } from 'd3-scale'; +import { Commit, State } from '../../../../plus/webviews/timeline/protocol'; +import { formatDate, fromNow } from '../../shared/date'; +import { Emitter, Event } from '../../shared/events'; +import { throttle } from '../../shared/utils'; + +export interface DataPointClickEvent { + data: { + id: string; + selected: boolean; + }; +} + +export class TimelineChart { + private _onDidClickDataPoint = new Emitter(); + get onDidClickDataPoint(): Event { + return this._onDidClickDataPoint.event; + } + + private readonly $container: HTMLElement; + private _chart: Chart | undefined; + private _chartDimensions: { height: number; width: number }; + private readonly _resizeObserver: ResizeObserver; + private readonly _selector: string; + + private readonly _commitsByTimestamp = new Map(); + private readonly _authorsByIndex = new Map(); + private readonly _indexByAuthors = new Map(); + + private _dateFormat: string = undefined!; + + constructor(selector: string) { + this._selector = selector; + + const fn = throttle(() => { + const dimensions = this._chartDimensions; + this._chart?.resize({ + width: dimensions.width, + height: dimensions.height - 10, + }); + }, 100); + + this._resizeObserver = new ResizeObserver(entries => { + const size = entries[0].borderBoxSize[0]; + const dimensions = { + width: Math.floor(size.inlineSize), + height: Math.floor(size.blockSize), + }; + + if ( + this._chartDimensions.height === dimensions.height && + this._chartDimensions.width === dimensions.width + ) { + return; + } + + this._chartDimensions = dimensions; + fn(); + }); + + this.$container = document.querySelector(selector)!.parentElement!; + const rect = this.$container.getBoundingClientRect(); + this._chartDimensions = { height: Math.floor(rect.height), width: Math.floor(rect.width) }; + + this._resizeObserver.observe(this.$container); + } + + reset() { + this._chart?.unselect(); + this._chart?.unzoom(); + } + + updateChart(state: State) { + this._dateFormat = state.dateFormat; + + this._commitsByTimestamp.clear(); + this._authorsByIndex.clear(); + this._indexByAuthors.clear(); + + if (state?.dataset == null || state.dataset.length === 0) { + this._chart?.destroy(); + this._chart = undefined; + + const $overlay = document.getElementById('chart-empty-overlay') as HTMLDivElement; + $overlay?.classList.toggle('hidden', false); + + const $emptyMessage = $overlay.querySelector('[data-bind="empty"]') as HTMLHeadingElement; + $emptyMessage.textContent = state.title; + + return; + } + + const $overlay = document.getElementById('chart-empty-overlay') as HTMLDivElement; + $overlay?.classList.toggle('hidden', true); + + const xs: { [key: string]: string } = {}; + const colors: { [key: string]: string } = {}; + const names: { [key: string]: string } = {}; + const axes: { [key: string]: string } = {}; + const types: { [key: string]: ChartTypes } = {}; + const groups: string[][] = []; + const series: { [key: string]: any } = {}; + const group = []; + + let index = 0; + + let commit: Commit; + let author: string; + let date: string; + let additions: number | undefined; + let deletions: number | undefined; + + // // Get the min and max additions and deletions from the dataset + // let minChanges = Infinity; + // let maxChanges = -Infinity; + + // for (const commit of state.dataset) { + // const changes = commit.additions + commit.deletions; + // if (changes < minChanges) { + // minChanges = changes; + // } + // if (changes > maxChanges) { + // maxChanges = changes; + // } + // } + + // const bubbleScale = scaleSqrt([minChanges, maxChanges], [6, 100]); + + for (commit of state.dataset) { + ({ author, date, additions, deletions } = commit); + + if (!this._indexByAuthors.has(author)) { + this._indexByAuthors.set(author, index); + this._authorsByIndex.set(index, author); + index--; + } + + const x = 'time'; + if (series[x] == null) { + series[x] = []; + + series['additions'] = []; + series['deletions'] = []; + + xs['additions'] = x; + xs['deletions'] = x; + + axes['additions'] = 'y2'; + axes['deletions'] = 'y2'; + + names['additions'] = 'Additions'; + names['deletions'] = 'Deletions'; + + colors['additions'] = 'rgba(73, 190, 71, 1)'; + colors['deletions'] = 'rgba(195, 32, 45, 1)'; + + types['additions'] = bar(); + types['deletions'] = bar(); + + group.push(x); + groups.push(['additions', 'deletions']); + } + + const authorX = `${x}.${author}`; + if (series[authorX] == null) { + series[authorX] = []; + series[author] = []; + + xs[author] = authorX; + + axes[author] = 'y'; + + names[author] = author; + + types[author] = bubble(); + + group.push(authorX); + } + + series[x].push(date); + series['additions'].push(additions ?? 0); + series['deletions'].push(deletions ?? 0); + + series[authorX].push(date); + + const z = additions == null && deletions == null ? 6 : (additions ?? 0) + (deletions ?? 0); //bubbleScale(additions + deletions); + series[author].push({ + y: this._indexByAuthors.get(author), + z: z, + }); + + this._commitsByTimestamp.set(date, commit); + } + + groups.push(group); + + const columns = Object.entries(series).map(([key, value]) => [key, ...value]); + + if (this._chart == null) { + const options = this.getChartOptions(); + + if (options.axis == null) { + options.axis = { y: { tick: {} } }; + } + if (options.axis.y == null) { + options.axis.y = { tick: {} }; + } + if (options.axis.y.tick == null) { + options.axis.y.tick = {}; + } + + options.axis.y.min = index - 2; + options.axis.y.tick.values = [...this._authorsByIndex.keys()]; + + options.data = { + ...options.data, + axes: axes, + colors: colors, + columns: columns, + groups: groups, + names: names, + types: types, + xs: xs, + }; + + this._chart = bb.generate(options); + } else { + this._chart.config('axis.y.tick.values', [...this._authorsByIndex.keys()], false); + this._chart.config('axis.y.min', index - 2, false); + this._chart.groups(groups); + + this._chart.load({ + axes: axes, + colors: colors, + columns: columns, + names: names, + types: types, + xs: xs, + unload: true, + }); + } + } + + private getChartOptions() { + const config: ChartOptions = { + bindto: this._selector, + data: { + xFormat: '%Y-%m-%dT%H:%M:%S.%LZ', + xLocaltime: false, + // selection: { + // enabled: selection(), + // draggable: false, + // grouped: false, + // multiple: false, + // }, + onclick: this.onDataPointClicked.bind(this), + }, + axis: { + x: { + type: 'timeseries', + clipPath: false, + localtime: true, + tick: { + // autorotate: true, + centered: true, + culling: false, + fit: false, + format: '%-m/%-d/%Y', + multiline: false, + // rotate: 15, + show: false, + }, + }, + y: { + max: 0, + padding: { + top: 75, + bottom: 100, + }, + show: true, + tick: { + format: (y: number) => this._authorsByIndex.get(y) ?? '', + outer: false, + }, + }, + y2: { + label: { + text: 'Lines changed', + position: 'outer-middle', + }, + // min: 0, + show: true, + // tick: { + // outer: true, + // // culling: true, + // // stepSize: 1, + // }, + }, + }, + bar: { + width: 2, + sensitivity: 4, + padding: 2, + }, + bubble: { + maxR: 100, + zerobased: true, + }, + grid: { + focus: { + edge: true, + show: true, + y: true, + }, + front: false, + lines: { + front: false, + }, + x: { + show: false, + }, + y: { + show: true, + }, + }, + legend: { + show: true, + padding: 10, + }, + resize: { + auto: false, + }, + size: { + height: this._chartDimensions.height - 10, + width: this._chartDimensions.width, + }, + tooltip: { + grouped: true, + format: { + title: this.getTooltipTitle.bind(this), + name: this.getTooltipName.bind(this), + value: this.getTooltipValue.bind(this), + }, + // linked: true, //{ name: 'time' }, + show: true, + order: 'desc', + }, + zoom: { + enabled: zoom(), + type: 'drag', + rescale: true, + resetButton: true, + extent: [1, 0.01], + x: { + min: 100, + }, + // onzoomstart: function(...args: any[]) { + // console.log('onzoomstart', args); + // }, + // onzoom: function(...args: any[]) { + // console.log('onzoom', args); + // }, + // onzoomend: function(...args: any[]) { + // console.log('onzoomend', args); + // } + }, + // plugins: [ + // new BubbleCompare({ + // minR: 6, + // maxR: 100, + // expandScale: 1.2, + // }), + // ], + }; + + return config; + } + + private getTooltipName(name: string, ratio: number, id: string, index: number) { + if (id === 'additions' || /*id === 'changes' ||*/ id === 'deletions') return name; + + const date = new Date(this._chart!.data(id)[0].values[index].x); + const commit = this._commitsByTimestamp.get(date.toISOString()); + return commit?.commit.slice(0, 8) ?? '00000000'; + } + + private getTooltipTitle(x: string) { + const date = new Date(x); + const formattedDate = `${capitalize(fromNow(date))} (${formatDate(date, this._dateFormat)})`; + + const commit = this._commitsByTimestamp.get(date.toISOString()); + if (commit == null) return formattedDate; + return `${commit.author}, ${formattedDate}`; + } + + private getTooltipValue(value: any, ratio: number, id: string, index: number) { + if (id === 'additions' || /*id === 'changes' ||*/ id === 'deletions') { + return value === 0 ? undefined! : value; + } + + const date = new Date(this._chart!.data(id)[0].values[index].x); + const commit = this._commitsByTimestamp.get(date.toISOString()); + return commit?.message ?? '???'; + } + + private onDataPointClicked(d: DataItem, _element: SVGElement) { + const commit = this._commitsByTimestamp.get(new Date(d.x).toISOString()); + if (commit == null) return; + + // const selected = this._chart!.selected(d.id) as unknown as DataItem[]; + this._onDidClickDataPoint.fire({ + data: { + id: commit.commit, + selected: true, //selected?.[0]?.id === d.id, + }, + }); + } +} + +function capitalize(s: string): string { + return s.charAt(0).toUpperCase() + s.slice(1); +} diff --git a/src/webviews/apps/plus/timeline/partials/state.free-preview-expired.html b/src/webviews/apps/plus/timeline/partials/state.free-preview-expired.html new file mode 100644 index 0000000..252fc47 --- /dev/null +++ b/src/webviews/apps/plus/timeline/partials/state.free-preview-expired.html @@ -0,0 +1,20 @@ + diff --git a/src/webviews/apps/plus/timeline/partials/state.free.html b/src/webviews/apps/plus/timeline/partials/state.free.html new file mode 100644 index 0000000..60497da --- /dev/null +++ b/src/webviews/apps/plus/timeline/partials/state.free.html @@ -0,0 +1,23 @@ + diff --git a/src/webviews/apps/plus/timeline/partials/state.plus-trial-expired.html b/src/webviews/apps/plus/timeline/partials/state.plus-trial-expired.html new file mode 100644 index 0000000..c665544 --- /dev/null +++ b/src/webviews/apps/plus/timeline/partials/state.plus-trial-expired.html @@ -0,0 +1,19 @@ + diff --git a/src/webviews/apps/plus/timeline/partials/state.verify-email.html b/src/webviews/apps/plus/timeline/partials/state.verify-email.html new file mode 100644 index 0000000..c8ea07b --- /dev/null +++ b/src/webviews/apps/plus/timeline/partials/state.verify-email.html @@ -0,0 +1,8 @@ + diff --git a/src/webviews/apps/plus/timeline/plugins.d.ts b/src/webviews/apps/plus/timeline/plugins.d.ts new file mode 100644 index 0000000..529065e --- /dev/null +++ b/src/webviews/apps/plus/timeline/plugins.d.ts @@ -0,0 +1,5 @@ +declare module 'billboard.js/dist/plugin/billboardjs-plugin-bubblecompare' { + import BubbleCompare from 'billboard.js/types/plugin/bubblecompare'; + + export = BubbleCompare; +} diff --git a/src/webviews/apps/plus/timeline/timeline.html b/src/webviews/apps/plus/timeline/timeline.html new file mode 100644 index 0000000..1dfecfc --- /dev/null +++ b/src/webviews/apps/plus/timeline/timeline.html @@ -0,0 +1,64 @@ + + + + + + + + +
+
+

+

+
+
+ + + 1 week + 1 month + 3 months + 6 months + 9 months + 1 year + 2 years + 4 years + +
+
+
+
+
+ +
+ +
+ + #{endOfBody} + + + + + <%= require('html-loader?{"esModule":false}!./partials/state.free.html') %> + <%= require('html-loader?{"esModule":false}!./partials/state.free-preview-expired.html') %> + <%= require('html-loader?{"esModule":false}!./partials/state.plus-trial-expired.html') %> + <%= require('html-loader?{"esModule":false}!./partials/state.verify-email.html') %> + diff --git a/src/webviews/apps/plus/timeline/timeline.scss b/src/webviews/apps/plus/timeline/timeline.scss new file mode 100644 index 0000000..986b7b8 --- /dev/null +++ b/src/webviews/apps/plus/timeline/timeline.scss @@ -0,0 +1,223 @@ +* { + box-sizing: border-box; +} + +html { + height: 100%; + font-size: 62.5%; +} + +body { + background-color: var(--color-background); + color: var(--color-view-foreground); + font-family: var(--font-family); + height: 100%; + line-height: 1.4; + font-size: 100% !important; + overflow: hidden; + margin: 0 20px 20px 20px; + padding: 0; + + min-width: 400px; + overflow-x: scroll; +} + +.container { + display: grid; + grid-template-rows: min-content 1fr min-content; + min-height: 100%; + overflow: hidden; +} + +section { + display: flex; + flex-direction: column; + padding: 0; +} + +h2 { + font-weight: 400; +} + +h3 { + border: none; + color: var(--color-view-header-foreground); + font-size: 1.5rem; + font-weight: 600; + margin-bottom: 0; + white-space: nowrap; +} + +h4 { + font-size: 1.5rem; + font-weight: 400; + margin: 0.5rem 0 1rem 0; +} + +a { + text-decoration: none; + + &:focus { + outline-color: var(--focus-border); + } + + &:hover { + text-decoration: underline; + } +} + +b { + font-weight: 600; +} + +p { + margin-bottom: 0; +} + +vscode-button:not([appearance='icon']) { + align-self: center; + margin-top: 1.5rem; + max-width: 300px; + width: 100%; +} + +span.button-subaction { + align-self: center; + margin-top: 0.75rem; +} + +@media (min-width: 640px) { + vscode-button:not([appearance='icon']) { + align-self: flex-start; + } + span.button-subaction { + align-self: flex-start; + } +} + +.header { + display: grid; + grid-template-columns: max-content minmax(min-content, 1fr) max-content; + align-items: baseline; + grid-template-areas: 'title description toolbox'; + justify-content: start; + margin-bottom: 1rem; + + @media all and (max-width: 500px) { + grid-template-areas: + 'title description' + 'empty toolbox'; + grid-template-columns: max-content minmax(min-content, 1fr); + } + + h2[data-bind='title'] { + grid-area: title; + margin-bottom: 0; + } + + h2[data-bind='description'] { + grid-area: description; + font-size: 1.3em; + font-weight: 200; + margin-left: 1.5rem; + opacity: 0.7; + overflow-wrap: anywhere; + } + + .toolbox { + grid-area: toolbox; + display: flex; + } +} + +.select-container { + display: flex; + align-items: center; + justify-content: flex-end; + flex: 100% 0 1; + + label { + margin: 0 1em; + font-size: var(--font-size); + } +} + +#content { + position: relative; + overflow: hidden; + width: 100%; +} + +#chart { + height: 100%; + width: 100%; + overflow: hidden; +} + +#chart-empty-overlay { + display: flex; + align-items: center; + justify-content: center; + + position: absolute; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background-color: var(--color-background); + + h1 { + font-weight: 600; + padding-bottom: 10%; + } +} + +[data-visible] { + display: none; +} + +#overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + font-size: 1.3em; + min-height: 100%; + padding: 0 2rem 2rem 2rem; + + backdrop-filter: blur(3px) saturate(0.8); + + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +.modal { + max-width: 600px; + background: var(--color-hover-background); + border: 1px solid var(--color-hover-border); + margin: 1rem; + padding: 1rem; + + > p:first-child { + margin-top: 0; + } + + vscode-button:not([appearance='icon']) { + align-self: center !important; + } +} + +.hidden { + display: none !important; +} + +@import './chart'; +@import '../../shared/codicons.scss'; + +.codicon { + position: relative; + top: -1px; +} diff --git a/src/webviews/apps/plus/timeline/timeline.ts b/src/webviews/apps/plus/timeline/timeline.ts new file mode 100644 index 0000000..d0bdb0d --- /dev/null +++ b/src/webviews/apps/plus/timeline/timeline.ts @@ -0,0 +1,177 @@ +'use strict'; +/*global*/ +import './timeline.scss'; +import { provideVSCodeDesignSystem, vsCodeButton, vsCodeDropdown, vsCodeOption } from '@vscode/webview-ui-toolkit'; +import { + DidChangeStateNotificationType, + OpenDataPointCommandType, + State, + UpdatePeriodCommandType, +} from '../../../../plus/webviews/timeline/protocol'; +import { SubscriptionPlanId, SubscriptionState } from '../../../../subscription'; +import { ExecuteCommandType, IpcMessage, onIpc } from '../../../protocol'; +import { App } from '../../shared/appBase'; +import { DOM } from '../../shared/dom'; +import { DataPointClickEvent, TimelineChart } from './chart'; + +export class TimelineApp extends App { + private _chart: TimelineChart | undefined; + + constructor() { + super('TimelineApp'); + } + + protected override onInitialize() { + provideVSCodeDesignSystem().register({ + register: function (container: any, context: any) { + vsCodeButton().register(container, context); + vsCodeDropdown().register(container, context); + vsCodeOption().register(container, context); + }, + }); + + this.updateState(); + } + + protected override onBind() { + const disposables = super.onBind?.() ?? []; + + disposables.push( + DOM.on('[data-action]', 'click', (e, target: HTMLElement) => this.onActionClicked(e, target)), + DOM.on(document, 'keydown', (e: KeyboardEvent) => this.onKeyDown(e)), + DOM.on(document.getElementById('periods')! as HTMLSelectElement, 'change', (e, target) => + this.onPeriodChanged(e, target), + ), + ); + + return disposables; + } + + protected override onMessageReceived(e: MessageEvent) { + const msg = e.data as IpcMessage; + + switch (msg.method) { + case DidChangeStateNotificationType.method: + this.log(`${this.appName}.onMessageReceived(${msg.id}): name=${msg.method}`); + + onIpc(DidChangeStateNotificationType, msg, params => { + this.state = params.state; + this.updateState(); + }); + break; + + default: + super.onMessageReceived?.(e); + } + } + + private onActionClicked(e: MouseEvent, target: HTMLElement) { + const action = target.dataset.action; + if (action?.startsWith('command:')) { + this.sendCommand(ExecuteCommandType, { command: action.slice(8) }); + } + } + + private onChartDataPointClicked(e: DataPointClickEvent) { + this.sendCommand(OpenDataPointCommandType, e); + } + + private onKeyDown(e: KeyboardEvent) { + if (e.key === 'Escape' || e.key === 'Esc') { + this._chart?.reset(); + } + } + + private onPeriodChanged(e: Event, element: HTMLSelectElement) { + const value = element.options[element.selectedIndex].value; + assertPeriod(value); + + this.log(`${this.appName}.onPeriodChanged: name=${element.name}, value=${value}`); + + this.sendCommand(UpdatePeriodCommandType, { period: value }); + } + + private updateState(): void { + const $overlay = document.getElementById('overlay') as HTMLDivElement; + $overlay.classList.toggle('hidden', this.state.access.allowed); + + const $slot = document.getElementById('overlay-slot') as HTMLDivElement; + + if (!this.state.access.allowed) { + const { current: subscription, required } = this.state.access.subscription; + + const requiresPublic = required === SubscriptionPlanId.FreePlus; + const options = { visible: { public: requiresPublic, private: !requiresPublic } }; + + if (subscription.account?.verified === false) { + DOM.insertTemplate('state:verify-email', $slot, options); + return; + } + + switch (subscription.state) { + case SubscriptionState.Free: + DOM.insertTemplate('state:free', $slot, options); + break; + case SubscriptionState.FreePreviewExpired: + DOM.insertTemplate('state:free-preview-expired', $slot, options); + break; + case SubscriptionState.FreePlusTrialExpired: + DOM.insertTemplate('state:plus-trial-expired', $slot, options); + break; + } + + if (this.state.dataset == null) return; + } else { + $slot.innerHTML = ''; + } + + if (this._chart == null) { + this._chart = new TimelineChart('#chart'); + this._chart.onDidClickDataPoint(this.onChartDataPointClicked, this); + } + + let { title } = this.state; + + const empty = this.state.dataset == null || this.state.dataset.length === 0; + if (empty) { + title = ''; + } + + let description = ''; + const index = title.lastIndexOf('/'); + if (index >= 0) { + const name = title.substring(index + 1); + description = title.substring(0, index); + title = name; + } + + for (const [key, value] of Object.entries({ title: title, description: description })) { + const $el = document.querySelector(`[data-bind="${key}"]`); + if ($el != null) { + $el.textContent = String(value); + } + } + + const $periods = document.getElementById('periods') as HTMLSelectElement; + if ($periods != null) { + const period = this.state?.period; + for (let i = 0, len = $periods.options.length; i < len; ++i) { + if ($periods.options[i].value === period) { + $periods.selectedIndex = i; + break; + } + } + } + + this._chart.updateChart(this.state); + } +} + +function assertPeriod(period: string): asserts period is `${number}|${'D' | 'M' | 'Y'}` { + const [value, unit] = period.split('|'); + if (isNaN(Number(value)) || (unit !== 'D' && unit !== 'M' && unit !== 'Y')) { + throw new Error(`Invalid period: ${period}`); + } +} + +new TimelineApp(); diff --git a/src/webviews/apps/premium/LICENSE.premium b/src/webviews/apps/premium/LICENSE.premium deleted file mode 100644 index b6045e3..0000000 --- a/src/webviews/apps/premium/LICENSE.premium +++ /dev/null @@ -1,40 +0,0 @@ -GitLens+ License - -Copyright (c) 2021-2022 Axosoft, LLC dba GitKraken ("GitKraken") - -With regard to the software set forth in or under any directory named "premium". - -This software and associated documentation files (the "Software") may be -compiled as part of the gitkraken/vscode-gitlens open source project (the -"GitLens") to the extent the Software is a required component of the GitLens; -provided, however, that the Software and its functionality may only be used if -you (and any entity that you represent) have agreed to, and are in compliance -with, the GitKraken End User License Agreement, available at -https://www.gitkraken.com/eula (the "EULA"), or other agreement governing the -use of the Software, as agreed by you and GitKraken, and otherwise have a valid -subscription for the correct number of user seats for the applicable version of -the Software (e.g., GitLens Free+, GitLens Pro, GitLens Teams, and GitLens -Enterprise). Subject to the foregoing sentence, you are free to modify this -Software and publish patches to the Software. You agree that GitKraken and/or -its licensors (as applicable) retain all right, title and interest in and to all -such modifications and/or patches, and all such modifications and/or patches may -only be used, copied, modified, displayed, distributed, or otherwise exploited -with a valid subscription for the correct number of user seats for the Software. -In furtherance of the foregoing, you hereby assign to GitKraken all such -modifications and/or patches. You are not granted any other rights beyond what -is expressly stated herein. Except as set forth above, it is forbidden to copy, -merge, publish, distribute, sublicense, and/or sell the Software. - -The full text of this GitLens+ License shall be included in all copies or -substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -For all third party components incorporated into the Software, those components -are licensed under the original license provided by the owner of the applicable -component. diff --git a/src/webviews/apps/premium/timeline/chart.scss b/src/webviews/apps/premium/timeline/chart.scss deleted file mode 100644 index cabf4c3..0000000 --- a/src/webviews/apps/premium/timeline/chart.scss +++ /dev/null @@ -1,466 +0,0 @@ -.bb { - svg { - // font-size: 12px; - // line-height: 1; - - font: 10px sans-serif; - -webkit-tap-highlight-color: rgba(0, 0, 0, 0); - } - - path, - line { - fill: none; - // stroke: #000; - } - - path.domain { - .vscode-dark & { - stroke: var(--color-background--lighten-15); - } - - .vscode-light & { - stroke: var(--color-background--darken-15); - } - } - - text, - .bb-button { - user-select: none; - // fill: var(--color-foreground--75); - fill: var(--color-view-foreground); - font-size: 11px; - } - - .bb-legend-item-tile, - .bb-xgrid-focus, - .bb-ygrid-focus, - .bb-ygrid, - .bb-event-rect, - .bb-bars path { - shape-rendering: crispEdges; - } - - .bb-chart-arc { - .bb-gauge-value { - fill: #000; - } - - path { - stroke: #fff; - } - - rect { - stroke: #fff; - stroke-width: 1; - } - - text { - fill: #fff; - font-size: 13px; - } - } - - /*-- Axis --*/ - .bb-axis { - shape-rendering: crispEdges; - } - - // .bb-axis-y, - // .bb-axis-y2 { - // text { - // fill: var(--color-foreground--75); - // } - // } - - // .bb-event-rects { - // fill-opacity: 1 !important; - - // .bb-event-rect { - // fill: transparent; - - // &._active_ { - // fill: rgba(39, 201, 3, 0.05); - // } - // } - // } - - // .tick._active_ text { - // fill: #00c83c !important; - // } - - /*-- Grid --*/ - .bb-grid { - pointer-events: none; - - line { - .vscode-dark & { - stroke: var(--color-background--lighten-05); - - &.bb-ygrid { - stroke: var(--color-background--lighten-05); - } - } - - .vscode-light & { - stroke: var(--color-background--darken-05); - - &.bb-ygrid { - stroke: var(--color-background--darken-05); - } - } - } - - text { - // fill: #aaa; - fill: var(--color-view-foreground); - } - } - - // .bb-xgrid { - // stroke-dasharray: 2 2; - // } - - // .bb-ygrid { - // stroke-dasharray: 2 1; - // } - - .bb-xgrid-focus { - line { - .vscode-dark & { - stroke: var(--color-background--lighten-30); - } - .vscode-light & { - stroke: var(--color-background--darken-30); - } - } - } - - /*-- Text on Chart --*/ - .bb-text.bb-empty { - fill: #808080; - font-size: 2em; - } - - /*-- Line --*/ - .bb-line { - stroke-width: 1px; - } - - /*-- Point --*/ - .bb-circle { - &._expanded_ { - opacity: 1 !important; - fill-opacity: 1 !important; - stroke-opacity: 1 !important; - stroke-width: 3px; - } - } - - .bb-selected-circle { - opacity: 1 !important; - fill-opacity: 1 !important; - stroke-opacity: 1 !important; - stroke-width: 3px; - } - - // rect.bb-circle, - // use.bb-circle { - // &._expanded_ { - // stroke-width: 3px; - // } - // } - - // .bb-selected-circle { - // stroke-width: 2px; - - // .vscode-dark & { - // fill: rgba(255, 255, 255, 0.2); - // } - - // .vscode-light & { - // fill: rgba(0, 0, 0, 0.1); - // } - // } - - /*-- Bar --*/ - .bb-bar { - stroke-width: 0; - opacity: 0.9 !important; - fill-opacity: 0.9 !important; - - &._expanded_ { - opacity: 1 !important; - fill-opacity: 1 !important; - } - } - - /*-- Candlestick --*/ - .bb-candlestick { - stroke-width: 1px; - - &._expanded_ { - fill-opacity: 0.75; - } - } - - /*-- Focus --*/ - .bb-target.bb-focused, - .bb-circles.bb-focused { - opacity: 1; - } - - .bb-target.bb-focused path.bb-line, - .bb-target.bb-focused path.bb-step, - .bb-circles.bb-focused path.bb-line, - .bb-circles.bb-focused path.bb-step { - stroke-width: 2px; - } - - .bb-target.bb-defocused, - .bb-circles.bb-defocused { - opacity: 0.2 !important; - } - .bb-target.bb-defocused .text-overlapping, - .bb-circles.bb-defocused .text-overlapping { - opacity: 0.05 !important; - } - - /*-- Region --*/ - .bb-region { - fill: steelblue; - fill-opacity: 0.1; - } - - /*-- Zoom region --*/ - .bb-zoom-brush { - .vscode-dark & { - fill: white; - fill-opacity: 0.2; - } - - .vscode-light & { - fill: black; - fill-opacity: 0.1; - } - } - - /*-- Brush --*/ - .bb-brush { - .extent { - fill-opacity: 0.1; - } - } - - /*-- Legend --*/ - .bb-legend-item { - font-size: 12px; - user-select: none; - } - - .bb-legend-item-hidden { - opacity: 0.15; - } - - .bb-legend-background { - opacity: 0.75; - fill: white; - stroke: lightgray; - stroke-width: 1; - } - - /*-- Title --*/ - .bb-title { - font: 14px sans-serif; - } - - /*-- Tooltip --*/ - .bb-tooltip-container { - z-index: 10; - user-select: none; - } - - .bb-tooltip { - display: flex; - border-collapse: collapse; - border-spacing: 0; - background-color: var(--color-hover-background); - color: var(--color-hover-foreground); - empty-cells: show; - opacity: 1; - box-shadow: 7px 7px 12px -9px var(--color-hover-border); - - font-size: var(--font-size); - font-family: var(--font-family); - - tbody { - border: 1px solid var(--color-hover-border); - } - - th { - padding: 0.5rem 1rem; - text-align: left; - } - - tr { - &:not(.bb-tooltip-name-additions, .bb-tooltip-name-deletions) { - display: flex; - flex-direction: column-reverse; - margin-bottom: -1px; - - td { - &.name { - display: flex; - align-items: center; - font-size: 12px; - font-family: var(--editor-font-family); - background-color: var(--color-hover-statusBarBackground); - border-top: 1px solid var(--color-hover-border); - border-bottom: 1px solid var(--color-hover-border); - padding: 0.5rem; - line-height: 2rem; - - &:before { - font: normal normal normal 16px/1 codicon; - display: inline-block; - text-decoration: none; - text-rendering: auto; - text-align: center; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - user-select: none; - - vertical-align: middle; - line-height: 2rem; - padding-right: 0.25rem; - - content: '\eafc'; - } - - span { - display: none; - } - } - - &.value { - display: flex; - padding: 0.25rem 2rem 1rem 2rem; - white-space: pre-line; - word-wrap: break-word; - overflow-wrap: break-word; - max-width: 450px; - max-height: 450px; - overflow-y: scroll; - overflow-x: hidden; - } - } - } - - &.bb-tooltip-name-additions, - &.bb-tooltip-name-deletions { - display: inline-flex; - flex-direction: row-reverse; - justify-content: center; - width: calc(50% - 2rem); - margin: 0px 1rem; - - td { - &.name { - text-transform: lowercase; - } - &.value { - margin-right: 0.25rem; - } - } - } - } - - // td > span, - // td > svg { - // display: inline-block; - // width: 10px; - // height: 10px; - // margin-right: 6px; - // } - } - - /*-- Area --*/ - .bb-area { - stroke-width: 0; - opacity: 0.2; - } - - /*-- Arc --*/ - .bb-chart-arcs-title { - dominant-baseline: middle; - font-size: 1.3em; - } - - text.bb-chart-arcs-gauge-title { - dominant-baseline: middle; - font-size: 2.7em; - } - - .bb-chart-arcs .bb-chart-arcs-background { - fill: #e0e0e0; - stroke: #fff; - } - - .bb-chart-arcs .bb-chart-arcs-gauge-unit { - fill: #000; - font-size: 16px; - } - - .bb-chart-arcs .bb-chart-arcs-gauge-max { - fill: #777; - } - - .bb-chart-arcs .bb-chart-arcs-gauge-min { - fill: #777; - } - - /*-- Radar --*/ - .bb-chart-radars .bb-levels polygon { - fill: none; - stroke: #848282; - stroke-width: 0.5px; - } - - .bb-chart-radars .bb-levels text { - fill: #848282; - } - - .bb-chart-radars .bb-axis line { - stroke: #848282; - stroke-width: 0.5px; - } - - .bb-chart-radars .bb-axis text { - font-size: 1.15em; - cursor: default; - } - - .bb-chart-radars .bb-shapes polygon { - fill-opacity: 0.2; - stroke-width: 1px; - } - - /*-- Button --*/ - .bb-button { - position: absolute; - top: 0; - right: 2rem; - - border: 1px solid var(--color-button-background); - background-color: var(--color-button-background); - color: var(--color-button-foreground); - - font-size: var(--font-size); - font-family: var(--font-family); - - .bb-zoom-reset { - display: inline-block; - padding: 0.5rem 1rem; - cursor: pointer; - } - } -} diff --git a/src/webviews/apps/premium/timeline/chart.ts b/src/webviews/apps/premium/timeline/chart.ts deleted file mode 100644 index 0c7316d..0000000 --- a/src/webviews/apps/premium/timeline/chart.ts +++ /dev/null @@ -1,426 +0,0 @@ -'use strict'; -/*global*/ -import { bar, bb, bubble, Chart, ChartOptions, ChartTypes, DataItem, zoom } from 'billboard.js'; -// import BubbleCompare from 'billboard.js/dist/plugin/billboardjs-plugin-bubblecompare'; -// import { scaleSqrt } from 'd3-scale'; -import { Commit, State } from '../../../../premium/webviews/timeline/protocol'; -import { formatDate, fromNow } from '../../shared/date'; -import { Emitter, Event } from '../../shared/events'; -import { throttle } from '../../shared/utils'; - -export interface DataPointClickEvent { - data: { - id: string; - selected: boolean; - }; -} - -export class TimelineChart { - private _onDidClickDataPoint = new Emitter(); - get onDidClickDataPoint(): Event { - return this._onDidClickDataPoint.event; - } - - private readonly $container: HTMLElement; - private _chart: Chart | undefined; - private _chartDimensions: { height: number; width: number }; - private readonly _resizeObserver: ResizeObserver; - private readonly _selector: string; - - private readonly _commitsByTimestamp = new Map(); - private readonly _authorsByIndex = new Map(); - private readonly _indexByAuthors = new Map(); - - private _dateFormat: string = undefined!; - - constructor(selector: string) { - this._selector = selector; - - const fn = throttle(() => { - const dimensions = this._chartDimensions; - this._chart?.resize({ - width: dimensions.width, - height: dimensions.height - 10, - }); - }, 100); - - this._resizeObserver = new ResizeObserver(entries => { - const size = entries[0].borderBoxSize[0]; - const dimensions = { - width: Math.floor(size.inlineSize), - height: Math.floor(size.blockSize), - }; - - if ( - this._chartDimensions.height === dimensions.height && - this._chartDimensions.width === dimensions.width - ) { - return; - } - - this._chartDimensions = dimensions; - fn(); - }); - - this.$container = document.querySelector(selector)!.parentElement!; - const rect = this.$container.getBoundingClientRect(); - this._chartDimensions = { height: Math.floor(rect.height), width: Math.floor(rect.width) }; - - this._resizeObserver.observe(this.$container); - } - - reset() { - this._chart?.unselect(); - this._chart?.unzoom(); - } - - updateChart(state: State) { - this._dateFormat = state.dateFormat; - - this._commitsByTimestamp.clear(); - this._authorsByIndex.clear(); - this._indexByAuthors.clear(); - - if (state?.dataset == null || state.dataset.length === 0) { - this._chart?.destroy(); - this._chart = undefined; - - const $overlay = document.getElementById('chart-empty-overlay') as HTMLDivElement; - $overlay?.classList.toggle('hidden', false); - - const $emptyMessage = $overlay.querySelector('[data-bind="empty"]') as HTMLHeadingElement; - $emptyMessage.textContent = state.title; - - return; - } - - const $overlay = document.getElementById('chart-empty-overlay') as HTMLDivElement; - $overlay?.classList.toggle('hidden', true); - - const xs: { [key: string]: string } = {}; - const colors: { [key: string]: string } = {}; - const names: { [key: string]: string } = {}; - const axes: { [key: string]: string } = {}; - const types: { [key: string]: ChartTypes } = {}; - const groups: string[][] = []; - const series: { [key: string]: any } = {}; - const group = []; - - let index = 0; - - let commit: Commit; - let author: string; - let date: string; - let additions: number | undefined; - let deletions: number | undefined; - - // // Get the min and max additions and deletions from the dataset - // let minChanges = Infinity; - // let maxChanges = -Infinity; - - // for (const commit of state.dataset) { - // const changes = commit.additions + commit.deletions; - // if (changes < minChanges) { - // minChanges = changes; - // } - // if (changes > maxChanges) { - // maxChanges = changes; - // } - // } - - // const bubbleScale = scaleSqrt([minChanges, maxChanges], [6, 100]); - - for (commit of state.dataset) { - ({ author, date, additions, deletions } = commit); - - if (!this._indexByAuthors.has(author)) { - this._indexByAuthors.set(author, index); - this._authorsByIndex.set(index, author); - index--; - } - - const x = 'time'; - if (series[x] == null) { - series[x] = []; - - series['additions'] = []; - series['deletions'] = []; - - xs['additions'] = x; - xs['deletions'] = x; - - axes['additions'] = 'y2'; - axes['deletions'] = 'y2'; - - names['additions'] = 'Additions'; - names['deletions'] = 'Deletions'; - - colors['additions'] = 'rgba(73, 190, 71, 1)'; - colors['deletions'] = 'rgba(195, 32, 45, 1)'; - - types['additions'] = bar(); - types['deletions'] = bar(); - - group.push(x); - groups.push(['additions', 'deletions']); - } - - const authorX = `${x}.${author}`; - if (series[authorX] == null) { - series[authorX] = []; - series[author] = []; - - xs[author] = authorX; - - axes[author] = 'y'; - - names[author] = author; - - types[author] = bubble(); - - group.push(authorX); - } - - series[x].push(date); - series['additions'].push(additions ?? 0); - series['deletions'].push(deletions ?? 0); - - series[authorX].push(date); - - const z = additions == null && deletions == null ? 6 : (additions ?? 0) + (deletions ?? 0); //bubbleScale(additions + deletions); - series[author].push({ - y: this._indexByAuthors.get(author), - z: z, - }); - - this._commitsByTimestamp.set(date, commit); - } - - groups.push(group); - - const columns = Object.entries(series).map(([key, value]) => [key, ...value]); - - if (this._chart == null) { - const options = this.getChartOptions(); - - if (options.axis == null) { - options.axis = { y: { tick: {} } }; - } - if (options.axis.y == null) { - options.axis.y = { tick: {} }; - } - if (options.axis.y.tick == null) { - options.axis.y.tick = {}; - } - - options.axis.y.min = index - 2; - options.axis.y.tick.values = [...this._authorsByIndex.keys()]; - - options.data = { - ...options.data, - axes: axes, - colors: colors, - columns: columns, - groups: groups, - names: names, - types: types, - xs: xs, - }; - - this._chart = bb.generate(options); - } else { - this._chart.config('axis.y.tick.values', [...this._authorsByIndex.keys()], false); - this._chart.config('axis.y.min', index - 2, false); - this._chart.groups(groups); - - this._chart.load({ - axes: axes, - colors: colors, - columns: columns, - names: names, - types: types, - xs: xs, - unload: true, - }); - } - } - - private getChartOptions() { - const config: ChartOptions = { - bindto: this._selector, - data: { - xFormat: '%Y-%m-%dT%H:%M:%S.%LZ', - xLocaltime: false, - // selection: { - // enabled: selection(), - // draggable: false, - // grouped: false, - // multiple: false, - // }, - onclick: this.onDataPointClicked.bind(this), - }, - axis: { - x: { - type: 'timeseries', - clipPath: false, - localtime: true, - tick: { - // autorotate: true, - centered: true, - culling: false, - fit: false, - format: '%-m/%-d/%Y', - multiline: false, - // rotate: 15, - show: false, - }, - }, - y: { - max: 0, - padding: { - top: 75, - bottom: 100, - }, - show: true, - tick: { - format: (y: number) => this._authorsByIndex.get(y) ?? '', - outer: false, - }, - }, - y2: { - label: { - text: 'Lines changed', - position: 'outer-middle', - }, - // min: 0, - show: true, - // tick: { - // outer: true, - // // culling: true, - // // stepSize: 1, - // }, - }, - }, - bar: { - width: 2, - sensitivity: 4, - padding: 2, - }, - bubble: { - maxR: 100, - zerobased: true, - }, - grid: { - focus: { - edge: true, - show: true, - y: true, - }, - front: false, - lines: { - front: false, - }, - x: { - show: false, - }, - y: { - show: true, - }, - }, - legend: { - show: true, - padding: 10, - }, - resize: { - auto: false, - }, - size: { - height: this._chartDimensions.height - 10, - width: this._chartDimensions.width, - }, - tooltip: { - grouped: true, - format: { - title: this.getTooltipTitle.bind(this), - name: this.getTooltipName.bind(this), - value: this.getTooltipValue.bind(this), - }, - // linked: true, //{ name: 'time' }, - show: true, - order: 'desc', - }, - zoom: { - enabled: zoom(), - type: 'drag', - rescale: true, - resetButton: true, - extent: [1, 0.01], - x: { - min: 100, - }, - // onzoomstart: function(...args: any[]) { - // console.log('onzoomstart', args); - // }, - // onzoom: function(...args: any[]) { - // console.log('onzoom', args); - // }, - // onzoomend: function(...args: any[]) { - // console.log('onzoomend', args); - // } - }, - // plugins: [ - // new BubbleCompare({ - // minR: 6, - // maxR: 100, - // expandScale: 1.2, - // }), - // ], - }; - - return config; - } - - private getTooltipName(name: string, ratio: number, id: string, index: number) { - if (id === 'additions' || /*id === 'changes' ||*/ id === 'deletions') return name; - - const date = new Date(this._chart!.data(id)[0].values[index].x); - const commit = this._commitsByTimestamp.get(date.toISOString()); - return commit?.commit.slice(0, 8) ?? '00000000'; - } - - private getTooltipTitle(x: string) { - const date = new Date(x); - const formattedDate = `${capitalize(fromNow(date))} (${formatDate(date, this._dateFormat)})`; - - const commit = this._commitsByTimestamp.get(date.toISOString()); - if (commit == null) return formattedDate; - return `${commit.author}, ${formattedDate}`; - } - - private getTooltipValue(value: any, ratio: number, id: string, index: number) { - if (id === 'additions' || /*id === 'changes' ||*/ id === 'deletions') { - return value === 0 ? undefined! : value; - } - - const date = new Date(this._chart!.data(id)[0].values[index].x); - const commit = this._commitsByTimestamp.get(date.toISOString()); - return commit?.message ?? '???'; - } - - private onDataPointClicked(d: DataItem, _element: SVGElement) { - const commit = this._commitsByTimestamp.get(new Date(d.x).toISOString()); - if (commit == null) return; - - // const selected = this._chart!.selected(d.id) as unknown as DataItem[]; - this._onDidClickDataPoint.fire({ - data: { - id: commit.commit, - selected: true, //selected?.[0]?.id === d.id, - }, - }); - } -} - -function capitalize(s: string): string { - return s.charAt(0).toUpperCase() + s.slice(1); -} diff --git a/src/webviews/apps/premium/timeline/partials/state.free-preview-expired.html b/src/webviews/apps/premium/timeline/partials/state.free-preview-expired.html deleted file mode 100644 index 252fc47..0000000 --- a/src/webviews/apps/premium/timeline/partials/state.free-preview-expired.html +++ /dev/null @@ -1,20 +0,0 @@ - diff --git a/src/webviews/apps/premium/timeline/partials/state.free.html b/src/webviews/apps/premium/timeline/partials/state.free.html deleted file mode 100644 index 60497da..0000000 --- a/src/webviews/apps/premium/timeline/partials/state.free.html +++ /dev/null @@ -1,23 +0,0 @@ - diff --git a/src/webviews/apps/premium/timeline/partials/state.plus-trial-expired.html b/src/webviews/apps/premium/timeline/partials/state.plus-trial-expired.html deleted file mode 100644 index c665544..0000000 --- a/src/webviews/apps/premium/timeline/partials/state.plus-trial-expired.html +++ /dev/null @@ -1,19 +0,0 @@ - diff --git a/src/webviews/apps/premium/timeline/partials/state.verify-email.html b/src/webviews/apps/premium/timeline/partials/state.verify-email.html deleted file mode 100644 index c8ea07b..0000000 --- a/src/webviews/apps/premium/timeline/partials/state.verify-email.html +++ /dev/null @@ -1,8 +0,0 @@ - diff --git a/src/webviews/apps/premium/timeline/plugins.d.ts b/src/webviews/apps/premium/timeline/plugins.d.ts deleted file mode 100644 index 529065e..0000000 --- a/src/webviews/apps/premium/timeline/plugins.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -declare module 'billboard.js/dist/plugin/billboardjs-plugin-bubblecompare' { - import BubbleCompare from 'billboard.js/types/plugin/bubblecompare'; - - export = BubbleCompare; -} diff --git a/src/webviews/apps/premium/timeline/timeline.html b/src/webviews/apps/premium/timeline/timeline.html deleted file mode 100644 index 1dfecfc..0000000 --- a/src/webviews/apps/premium/timeline/timeline.html +++ /dev/null @@ -1,64 +0,0 @@ - - - - - - - - -
-
-

-

-
-
- - - 1 week - 1 month - 3 months - 6 months - 9 months - 1 year - 2 years - 4 years - -
-
-
-
-
- -
- -
- - #{endOfBody} - - - - - <%= require('html-loader?{"esModule":false}!./partials/state.free.html') %> - <%= require('html-loader?{"esModule":false}!./partials/state.free-preview-expired.html') %> - <%= require('html-loader?{"esModule":false}!./partials/state.plus-trial-expired.html') %> - <%= require('html-loader?{"esModule":false}!./partials/state.verify-email.html') %> - diff --git a/src/webviews/apps/premium/timeline/timeline.scss b/src/webviews/apps/premium/timeline/timeline.scss deleted file mode 100644 index 986b7b8..0000000 --- a/src/webviews/apps/premium/timeline/timeline.scss +++ /dev/null @@ -1,223 +0,0 @@ -* { - box-sizing: border-box; -} - -html { - height: 100%; - font-size: 62.5%; -} - -body { - background-color: var(--color-background); - color: var(--color-view-foreground); - font-family: var(--font-family); - height: 100%; - line-height: 1.4; - font-size: 100% !important; - overflow: hidden; - margin: 0 20px 20px 20px; - padding: 0; - - min-width: 400px; - overflow-x: scroll; -} - -.container { - display: grid; - grid-template-rows: min-content 1fr min-content; - min-height: 100%; - overflow: hidden; -} - -section { - display: flex; - flex-direction: column; - padding: 0; -} - -h2 { - font-weight: 400; -} - -h3 { - border: none; - color: var(--color-view-header-foreground); - font-size: 1.5rem; - font-weight: 600; - margin-bottom: 0; - white-space: nowrap; -} - -h4 { - font-size: 1.5rem; - font-weight: 400; - margin: 0.5rem 0 1rem 0; -} - -a { - text-decoration: none; - - &:focus { - outline-color: var(--focus-border); - } - - &:hover { - text-decoration: underline; - } -} - -b { - font-weight: 600; -} - -p { - margin-bottom: 0; -} - -vscode-button:not([appearance='icon']) { - align-self: center; - margin-top: 1.5rem; - max-width: 300px; - width: 100%; -} - -span.button-subaction { - align-self: center; - margin-top: 0.75rem; -} - -@media (min-width: 640px) { - vscode-button:not([appearance='icon']) { - align-self: flex-start; - } - span.button-subaction { - align-self: flex-start; - } -} - -.header { - display: grid; - grid-template-columns: max-content minmax(min-content, 1fr) max-content; - align-items: baseline; - grid-template-areas: 'title description toolbox'; - justify-content: start; - margin-bottom: 1rem; - - @media all and (max-width: 500px) { - grid-template-areas: - 'title description' - 'empty toolbox'; - grid-template-columns: max-content minmax(min-content, 1fr); - } - - h2[data-bind='title'] { - grid-area: title; - margin-bottom: 0; - } - - h2[data-bind='description'] { - grid-area: description; - font-size: 1.3em; - font-weight: 200; - margin-left: 1.5rem; - opacity: 0.7; - overflow-wrap: anywhere; - } - - .toolbox { - grid-area: toolbox; - display: flex; - } -} - -.select-container { - display: flex; - align-items: center; - justify-content: flex-end; - flex: 100% 0 1; - - label { - margin: 0 1em; - font-size: var(--font-size); - } -} - -#content { - position: relative; - overflow: hidden; - width: 100%; -} - -#chart { - height: 100%; - width: 100%; - overflow: hidden; -} - -#chart-empty-overlay { - display: flex; - align-items: center; - justify-content: center; - - position: absolute; - top: 0; - left: 0; - width: 100vw; - height: 100vh; - background-color: var(--color-background); - - h1 { - font-weight: 600; - padding-bottom: 10%; - } -} - -[data-visible] { - display: none; -} - -#overlay { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - font-size: 1.3em; - min-height: 100%; - padding: 0 2rem 2rem 2rem; - - backdrop-filter: blur(3px) saturate(0.8); - - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; -} - -.modal { - max-width: 600px; - background: var(--color-hover-background); - border: 1px solid var(--color-hover-border); - margin: 1rem; - padding: 1rem; - - > p:first-child { - margin-top: 0; - } - - vscode-button:not([appearance='icon']) { - align-self: center !important; - } -} - -.hidden { - display: none !important; -} - -@import './chart'; -@import '../../shared/codicons.scss'; - -.codicon { - position: relative; - top: -1px; -} diff --git a/src/webviews/apps/premium/timeline/timeline.ts b/src/webviews/apps/premium/timeline/timeline.ts deleted file mode 100644 index 400a4ea..0000000 --- a/src/webviews/apps/premium/timeline/timeline.ts +++ /dev/null @@ -1,177 +0,0 @@ -'use strict'; -/*global*/ -import './timeline.scss'; -import { provideVSCodeDesignSystem, vsCodeButton, vsCodeDropdown, vsCodeOption } from '@vscode/webview-ui-toolkit'; -import { - DidChangeStateNotificationType, - OpenDataPointCommandType, - State, - UpdatePeriodCommandType, -} from '../../../../premium/webviews/timeline/protocol'; -import { SubscriptionPlanId, SubscriptionState } from '../../../../subscription'; -import { ExecuteCommandType, IpcMessage, onIpc } from '../../../protocol'; -import { App } from '../../shared/appBase'; -import { DOM } from '../../shared/dom'; -import { DataPointClickEvent, TimelineChart } from './chart'; - -export class TimelineApp extends App { - private _chart: TimelineChart | undefined; - - constructor() { - super('TimelineApp'); - } - - protected override onInitialize() { - provideVSCodeDesignSystem().register({ - register: function (container: any, context: any) { - vsCodeButton().register(container, context); - vsCodeDropdown().register(container, context); - vsCodeOption().register(container, context); - }, - }); - - this.updateState(); - } - - protected override onBind() { - const disposables = super.onBind?.() ?? []; - - disposables.push( - DOM.on('[data-action]', 'click', (e, target: HTMLElement) => this.onActionClicked(e, target)), - DOM.on(document, 'keydown', (e: KeyboardEvent) => this.onKeyDown(e)), - DOM.on(document.getElementById('periods')! as HTMLSelectElement, 'change', (e, target) => - this.onPeriodChanged(e, target), - ), - ); - - return disposables; - } - - protected override onMessageReceived(e: MessageEvent) { - const msg = e.data as IpcMessage; - - switch (msg.method) { - case DidChangeStateNotificationType.method: - this.log(`${this.appName}.onMessageReceived(${msg.id}): name=${msg.method}`); - - onIpc(DidChangeStateNotificationType, msg, params => { - this.state = params.state; - this.updateState(); - }); - break; - - default: - super.onMessageReceived?.(e); - } - } - - private onActionClicked(e: MouseEvent, target: HTMLElement) { - const action = target.dataset.action; - if (action?.startsWith('command:')) { - this.sendCommand(ExecuteCommandType, { command: action.slice(8) }); - } - } - - private onChartDataPointClicked(e: DataPointClickEvent) { - this.sendCommand(OpenDataPointCommandType, e); - } - - private onKeyDown(e: KeyboardEvent) { - if (e.key === 'Escape' || e.key === 'Esc') { - this._chart?.reset(); - } - } - - private onPeriodChanged(e: Event, element: HTMLSelectElement) { - const value = element.options[element.selectedIndex].value; - assertPeriod(value); - - this.log(`${this.appName}.onPeriodChanged: name=${element.name}, value=${value}`); - - this.sendCommand(UpdatePeriodCommandType, { period: value }); - } - - private updateState(): void { - const $overlay = document.getElementById('overlay') as HTMLDivElement; - $overlay.classList.toggle('hidden', this.state.access.allowed); - - const $slot = document.getElementById('overlay-slot') as HTMLDivElement; - - if (!this.state.access.allowed) { - const { current: subscription, required } = this.state.access.subscription; - - const requiresPublic = required === SubscriptionPlanId.FreePlus; - const options = { visible: { public: requiresPublic, private: !requiresPublic } }; - - if (subscription.account?.verified === false) { - DOM.insertTemplate('state:verify-email', $slot, options); - return; - } - - switch (subscription.state) { - case SubscriptionState.Free: - DOM.insertTemplate('state:free', $slot, options); - break; - case SubscriptionState.FreePreviewExpired: - DOM.insertTemplate('state:free-preview-expired', $slot, options); - break; - case SubscriptionState.FreePlusTrialExpired: - DOM.insertTemplate('state:plus-trial-expired', $slot, options); - break; - } - - if (this.state.dataset == null) return; - } else { - $slot.innerHTML = ''; - } - - if (this._chart == null) { - this._chart = new TimelineChart('#chart'); - this._chart.onDidClickDataPoint(this.onChartDataPointClicked, this); - } - - let { title } = this.state; - - const empty = this.state.dataset == null || this.state.dataset.length === 0; - if (empty) { - title = ''; - } - - let description = ''; - const index = title.lastIndexOf('/'); - if (index >= 0) { - const name = title.substring(index + 1); - description = title.substring(0, index); - title = name; - } - - for (const [key, value] of Object.entries({ title: title, description: description })) { - const $el = document.querySelector(`[data-bind="${key}"]`); - if ($el != null) { - $el.textContent = String(value); - } - } - - const $periods = document.getElementById('periods') as HTMLSelectElement; - if ($periods != null) { - const period = this.state?.period; - for (let i = 0, len = $periods.options.length; i < len; ++i) { - if ($periods.options[i].value === period) { - $periods.selectedIndex = i; - break; - } - } - } - - this._chart.updateChart(this.state); - } -} - -function assertPeriod(period: string): asserts period is `${number}|${'D' | 'M' | 'Y'}` { - const [value, unit] = period.split('|'); - if (isNaN(Number(value)) || (unit !== 'D' && unit !== 'M' && unit !== 'Y')) { - throw new Error(`Invalid period: ${period}`); - } -} - -new TimelineApp(); diff --git a/src/webviews/home/homeWebviewView.ts b/src/webviews/home/homeWebviewView.ts index e143f4e..85b8270 100644 --- a/src/webviews/home/homeWebviewView.ts +++ b/src/webviews/home/homeWebviewView.ts @@ -1,7 +1,7 @@ import { commands, Disposable, window } from 'vscode'; import type { Container } from '../../container'; -import type { SubscriptionChangeEvent } from '../../premium/subscription/subscriptionService'; -import { ensurePlusFeaturesEnabled } from '../../premium/subscription/utils'; +import type { SubscriptionChangeEvent } from '../../plus/subscription/subscriptionService'; +import { ensurePlusFeaturesEnabled } from '../../plus/subscription/utils'; import { SyncedStorageKeys } from '../../storage'; import type { Subscription } from '../../subscription'; import { WebviewViewBase } from '../webviewViewBase'; diff --git a/webpack.config.js b/webpack.config.js index c8e56a0..f92865c 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -303,7 +303,7 @@ function getWebviewsConfig(mode, env) { home: './home/home.ts', rebase: './rebase/rebase.ts', settings: './settings/settings.ts', - timeline: './premium/timeline/timeline.ts', + timeline: './plus/timeline/timeline.ts', welcome: './welcome/welcome.ts', }, mode: mode, @@ -502,14 +502,14 @@ function getImageMinimizerConfig(mode, env) { /** * @param { string } name - * @param { boolean } premium + * @param { boolean } plus * @param { 'production' | 'development' | 'none' } mode * @param {{ analyzeBundle?: boolean; analyzeDeps?: boolean; esbuild?: boolean; squoosh?: boolean } | undefined } env * @returns { HtmlPlugin } */ -function getHtmlPlugin(name, premium, mode, env) { +function getHtmlPlugin(name, plus, mode, env) { return new HtmlPlugin({ - template: premium ? path.join('premium', name, `${name}.html`) : path.join(name, `${name}.html`), + template: plus ? path.join('plus', name, `${name}.html`) : path.join(name, `${name}.html`), chunks: [name], filename: path.join(__dirname, 'dist', 'webviews', `${name}.html`), inject: true,