From e17508219275f1d1c8e4270a0d133c9f7e10c211 Mon Sep 17 00:00:00 2001 From: Eric Amodio Date: Thu, 27 Jan 2022 00:53:20 -0500 Subject: [PATCH] Add basic virtual repository support (wip) --- .vscode/queries.github-graphql-nb | 1 + package.json | 14 + src/codelens/codeLensProvider.ts | 40 +- src/commands/showQuickCommitFile.ts | 2 +- src/config.ts | 5 + src/container.ts | 4 +- src/env/browser/git.ts | 10 +- src/env/node/git.ts | 8 +- src/env/node/git/localGitProvider.ts | 383 ++---- src/errors.ts | 64 + src/git/gitProvider.ts | 46 +- src/git/gitProviderService.ts | 228 +++- src/git/gitUri.ts | 49 +- src/git/models/repository.ts | 68 +- src/git/remotes/github.ts | 2 +- src/github/github.ts | 648 --------- src/premium/github/githubGitProvider.ts | 2083 +++++++++++++++++++++++++++++ src/premium/remotehub.ts | 88 ++ src/repositories.ts | 7 +- src/system/path.ts | 10 +- src/views/nodes/fileHistoryNode.ts | 18 +- src/views/nodes/fileHistoryTrackerNode.ts | 27 +- src/views/nodes/lineHistoryNode.ts | 23 +- src/views/nodes/repositoriesNode.ts | 28 +- src/vsls/guest.ts | 4 +- src/vsls/host.ts | 6 +- src/vsls/protocol.ts | 2 +- 27 files changed, 2724 insertions(+), 1144 deletions(-) create mode 100644 .vscode/queries.github-graphql-nb delete mode 100644 src/github/github.ts create mode 100644 src/premium/github/githubGitProvider.ts create mode 100644 src/premium/remotehub.ts diff --git a/.vscode/queries.github-graphql-nb b/.vscode/queries.github-graphql-nb new file mode 100644 index 0000000..5d04e1c --- /dev/null +++ b/.vscode/queries.github-graphql-nb @@ -0,0 +1 @@ +{"cells":[{"code":"### Get Default Branch & Tip","kind":"markdown"},{"code":"query getDefaultBranchAndTip(\n\t$owner: String!\n\t$repo: String!\n) {\n\trepository(owner: $owner, name: $repo) {\n\t\tdefaultBranchRef {\n\t\t\tname\n\t\t\ttarget { oid }\n\t\t}\n\t}\n}\n\nvariables {\n\t\"owner\": \"eamodio\",\n\t\"repo\": \"vscode-gitlens\"\n}","kind":"code"},{"code":"### Get Branches","kind":"markdown"},{"code":"query getBranches(\n\t$owner: String!\n\t$repo: String!\n\t$branchQuery: String\n\t$cursor: String\n\t$limit: Int = 100\n) {\n\trepository(owner: $owner, name: $repo) {\n\t\trefs(query: $branchQuery, refPrefix: \"refs/heads/\", first: $limit, after: $cursor, orderBy: { field: TAG_COMMIT_DATE, direction: DESC }) {\n\t\t\tpageInfo {\n\t\t\t\tendCursor\n\t\t\t\thasNextPage\n\t\t\t}\n\t\t\tnodes {\n\t\t\t\tname\n\t\t\t\ttarget {\n\t\t\t\t\toid\n\t\t\t\t\tcommitUrl\n\t\t\t\t\t...on Commit {\n\t\t\t\t\t\tauthoredDate\n\t\t\t\t\t\tcommittedDate\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nvariables {\n\t\"owner\": \"eamodio\",\n\t\"repo\": \"vscode-gitlens\"\n}","kind":"code"},{"code":"### Get Blame","kind":"markdown"},{"code":"query getBlame(\n\t$owner: String!\n\t$repo: String!\n\t$ref: GitObjectID!\n\t$path: String!\n) {\n\tviewer { name }\n\trepository(owner: $owner, name: $repo) {\n\t\tobject(oid: $ref) {\n\t\t\t...on Commit {\n\t\t\t\tblame(path: $path) {\n\t\t\t\t\tranges {\n\t\t\t\t\t\tstartingLine\n\t\t\t\t\t\tendingLine\n\t\t\t\t\t\tage\n\t\t\t\t\t\tcommit {\n\t\t\t\t\t\t\toid\n\t\t\t\t\t\t\tparents(first: 3) { nodes { oid } }\n\t\t\t\t\t\t\tmessage\n\t\t\t\t\t\t\tauthor {\n\t\t\t\t\t\t\t\tavatarUrl\n\t\t\t\t\t\t\t\tdate\n\t\t\t\t\t\t\t\temail\n\t\t\t\t\t\t\t\tname\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tcommitter { date }\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nvariables {\n\t\"owner\": \"eamodio\",\n\t\"repo\": \"vscode-gitlens\",\n\t\"ref\": \"54f28933055124d6ba3808a787f6947c929f9db0\",\n\t\"path\": \"src/keyboard.ts\"\n}","kind":"code"},{"code":"### Get Commit for File","kind":"markdown"},{"code":"query getCommitForFile(\n\t$owner: String!\n\t$repo: String!\n\t$ref: String!\n\t$path: String!\n) {\n\trepository(owner: $owner, name: $repo) {\n\t\tref(qualifiedName: $ref) {\n\t\t\ttarget {\n\t\t\t\t... on Commit {\n\t\t\t\t\thistory(first: 1, path: $path) {\n\t\t\t\t\t\tnodes {\n\t\t\t\t\t\t\toid\n\t\t\t\t\t\t\tparents(first: 3) { nodes { oid } }\n\t\t\t\t\t\t\tmessage\n\t\t\t\t\t\t\tadditions\n\t\t\t\t\t\t\tchangedFiles\n\t\t\t\t\t\t\tdeletions\n\t\t\t\t\t\t\tauthor {\n\t\t\t\t\t\t\t\tdate\n\t\t\t\t\t\t\t\temail\n\t\t\t\t\t\t\t\tname\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tcommitter { date }\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nvariables {\n\t\"owner\": \"eamodio\",\n\t\"repo\": \"vscode-gitlens\",\n\t\"ref\": \"refs/heads/main\",\n\t\"path\": \"src/extension.ts\"\n}","kind":"code"},{"code":"### Get Current User","kind":"markdown"},{"code":"query getCurrentUser(\n\t$owner: String!\n\t$repo: String!\n) {\n\tviewer { name }\n\trepository(name: $repo owner: $owner) {\n\t\tviewerPermission\n\t}\n}\n\nvariables {\n\t\"owner\": \"eamodio\",\n\t\"repo\": \"vscode-gitlens\"\n}","kind":"code"},{"code":"### Get Commit","kind":"markdown"},{"code":"query getCommit(\n\t$owner: String!\n\t$repo: String!\n\t$ref: GitObjectID!\n) {\n\trepository(name: $repo owner: $owner) {\n\t\tobject(oid: $ref) {\n\t\t\t...on Commit {\n\t\t\t\toid\n\t\t\t\tparents(first: 3) { nodes { oid } }\n\t\t\t\tmessage\n\t\t\t\tadditions\n\t\t\t\tchangedFiles\n\t\t\t\tdeletions\n\t\t\t\tauthor {\n\t\t\t\t\tdate\n\t\t\t\t\temail\n\t\t\t\t\tname\n\t\t\t\t}\n\t\t\t\tcommitter { date }\n\t\t\t}\n\t\t}\n\t}\n}\n\nvariables {\n\t\"owner\": \"eamodio\",\n\t\"repo\": \"vscode-gitlens\",\n\t\"ref\": \"54f28933055124d6ba3808a787f6947c929f9db0\"\n}","kind":"code"},{"code":"### Get Commits","kind":"markdown"},{"code":"query getCommits(\n\t$owner: String!\n\t$repo: String!\n\t$ref: GitObjectID!\n) {\n\trepository(name: $repo owner: $owner) {\n\t\tobject(oid: $ref) {\n\t\t\t...on Commit {\n\t\t\t\toid\n\t\t\t\tparents(first: 3) { nodes { oid } }\n\t\t\t\tmessage\n\t\t\t\tadditions\n\t\t\t\tchangedFiles\n\t\t\t\tdeletions\n\t\t\t\tauthor {\n\t\t\t\t\tdate\n\t\t\t\t\temail\n\t\t\t\t\tname\n\t\t\t\t}\n\t\t\t\tcommitter { date }\n\t\t\t}\n\t\t}\n\t}\n}\n\nvariables {\n\t\"owner\": \"eamodio\",\n\t\"repo\": \"vscode-gitlens\",\n\t\"ref\": \"54f28933055124d6ba3808a787f6947c929f9db0\"\n}","kind":"code"},{"code":"### Get Tags","kind":"markdown"},{"code":"query getTags(\n\t$owner: String!\n\t$repo: String!\n\t$tagQuery: String\n\t$cursor: String\n\t$limit: Int = 100\n) {\n\trepository(owner: $owner, name: $repo) {\n\t\trefs(query: $tagQuery, refPrefix: \"refs/tags/\", first: $limit, after: $cursor, orderBy: { field: TAG_COMMIT_DATE, direction: DESC }) {\n\t\t\tpageInfo {\n\t\t\t\tendCursor\n\t\t\t\thasNextPage\n\t\t\t}\n\t\t\tnodes {\n\t\t\t\tname\n\t\t\t\ttarget {\n\t\t\t\t\toid\n\t\t\t\t\tcommitUrl\n\t\t\t\t\t...on Commit {\n\t\t\t\t\t\tauthoredDate\n\t\t\t\t\t\tcommittedDate\n\t\t\t\t\t\tmessage\n\t\t\t\t\t}\n\t\t\t\t\t...on Tag {\n\t\t\t\t\t\tmessage\n\t\t\t\t\t\ttagger { date }\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nvariables {\n\t\"owner\": \"eamodio\",\n\t\"repo\": \"vscode-gitlens\"\n}","kind":"code"},{"code":"### Get Contributors","kind":"markdown"},{"code":"query getContributors(\n\t$owner: String!\n\t$repo: String!\n\t$cursor: String\n\t$limit: Int = 100\n) {\n\trepository(owner: $owner, name: $repo) {\n\t\tcollaborators(affiliation: ALL, first: $limit, after: $cursor) {\n\t\t\tpageInfo {\n\t\t\t\tendCursor\n\t\t\t\thasNextPage\n\t\t\t}\n\t\t\tnodes {\n\t\t\t\tname\n\t\t\t}\n\t\t}\n\t}\n}\n\nvariables {\n\t\"owner\": \"eamodio\",\n\t\"repo\": \"vscode-gitlens\"\n}","kind":"code"},{"code":"### Resolve reference","kind":"markdown"},{"code":"query resolveReference(\n\t$owner: String!\n\t$repo: String!\n\t$ref: String!\n\t$path: String!\n) {\n\trepository(owner: $owner, name: $repo) {\n\t\tobject(expression: $ref) {\n\t\t\t... on Commit {\n\t\t\t\thistory(first: 1, path: $path) {\n\t\t\t\t\tnodes { oid }\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nvariables {\n\t\"owner\": \"eamodio\",\n\t\"repo\": \"vscode-gitlens\",\n\t\"ref\": \"d790e9db047769de079f6838c3578f3a47bf5930^\",\n\t\"path\": \"CODE_OF_CONDUCT.md\"\n}","kind":"code"}]} \ No newline at end of file diff --git a/package.json b/package.json index de1a888..48e6941 100644 --- a/package.json +++ b/package.json @@ -3117,6 +3117,20 @@ "markdownDeprecationMessage": "Deprecated. Use the [Insiders edition](https://marketplace.visualstudio.com/items?itemName=eamodio.gitlens-insiders) of GitLens instead" } } + }, + { + "id": "experimental", + "title": "Experimental", + "order": 9999, + "properties": { + "gitlens.experimental.virtualRepositories.enabled": { + "type": "boolean", + "default": false, + "markdownDescription": "Specifies whether to enable the experimental virtual repositories support", + "scope": "window", + "order": 0 + } + } } ], "configurationDefaults": { diff --git a/src/codelens/codeLensProvider.ts b/src/codelens/codeLensProvider.ts index b7fa1e9..bf49f7b 100644 --- a/src/codelens/codeLensProvider.ts +++ b/src/codelens/codeLensProvider.ts @@ -37,11 +37,12 @@ import { } from '../configuration'; import { BuiltInCommands, DocumentSchemes } from '../constants'; import { Container } from '../container'; -import { GitUri } from '../git/gitUri'; +import type { GitUri } from '../git/gitUri'; import { GitBlame, GitBlameLines, GitCommit } from '../git/models'; import { RemoteResourceType } from '../git/remotes/provider'; import { Logger } from '../logger'; -import { Functions, Iterables } from '../system'; +import { is, once } from '../system/function'; +import { filterMap, find, first, join, map } from '../system/iterable'; export class GitRecentChangeCodeLens extends CodeLens { constructor( @@ -90,6 +91,8 @@ export class GitCodeLensProvider implements CodeLensProvider { { scheme: DocumentSchemes.GitLens }, { scheme: DocumentSchemes.PRs }, { scheme: DocumentSchemes.Vsls }, + { scheme: DocumentSchemes.Virtual }, + { scheme: DocumentSchemes.GitHub }, ]; private _onDidChangeCodeLenses = new EventEmitter(); @@ -175,7 +178,7 @@ export class GitCodeLensProvider implements CodeLensProvider { if (token.isCancellationRequested) return lenses; - const documentRangeFn = Functions.once(() => document.validateRange(new Range(0, 0, 1000000, 1000000))); + const documentRangeFn = once(() => document.validateRange(new Range(0, 0, 1000000, 1000000))); // Since blame information isn't valid when there are unsaved changes -- update the lenses appropriately const dirtyCommand: Command | undefined = dirty @@ -211,9 +214,7 @@ export class GitCodeLensProvider implements CodeLensProvider { let blameForRangeFn: (() => GitBlameLines | undefined) | undefined = undefined; if (dirty || cfg.recentChange.enabled) { if (!dirty) { - blameForRangeFn = Functions.once(() => - this.container.git.getBlameForRangeSync(blame!, gitUri, blameRange), - ); + blameForRangeFn = once(() => this.container.git.getBlameRange(blame!, gitUri, blameRange)); } const fileSymbol = new SymbolInformation( @@ -239,9 +240,7 @@ export class GitCodeLensProvider implements CodeLensProvider { } if (!dirty && cfg.authors.enabled) { if (blameForRangeFn === undefined) { - blameForRangeFn = Functions.once(() => - this.container.git.getBlameForRangeSync(blame!, gitUri, blameRange), - ); + blameForRangeFn = once(() => this.container.git.getBlameRange(blame!, gitUri, blameRange)); } const fileSymbol = new SymbolInformation( @@ -400,9 +399,7 @@ export class GitCodeLensProvider implements CodeLensProvider { let blameForRangeFn: (() => GitBlameLines | undefined) | undefined; if (dirty || cfg.recentChange.enabled) { if (!dirty) { - blameForRangeFn = Functions.once(() => - this.container.git.getBlameForRangeSync(blame!, gitUri!, blameRange), - ); + blameForRangeFn = once(() => this.container.git.getBlameRange(blame!, gitUri!, blameRange)); } lenses.push( new GitRecentChangeCodeLens( @@ -444,9 +441,7 @@ export class GitCodeLensProvider implements CodeLensProvider { if (multiline && !dirty) { if (blameForRangeFn === undefined) { - blameForRangeFn = Functions.once(() => - this.container.git.getBlameForRangeSync(blame!, gitUri!, blameRange), - ); + blameForRangeFn = once(() => this.container.git.getBlameRange(blame!, gitUri!, blameRange)); } lenses.push( new GitAuthorsCodeLens( @@ -492,7 +487,7 @@ export class GitCodeLensProvider implements CodeLensProvider { const blame = lens.getBlame(); if (blame === undefined) return lens; - const recentCommit: GitCommit = Iterables.first(blame.commits.values()); + const recentCommit: GitCommit = first(blame.commits.values()); // TODO@eamodio This is FAR too expensive, but this accounts for commits that delete lines -- is there another way? // if (lens.uri != null) { // const commit = await this.container.git.getCommitForFile(lens.uri.repoPath, lens.uri.fsPath, { @@ -578,7 +573,7 @@ export class GitCodeLensProvider implements CodeLensProvider { const count = blame.authors.size; - const author = Iterables.first(blame.authors.values()).name; + const author = first(blame.authors.values()).name; let title = `${count} ${count > 1 ? 'authors' : 'author'} (${author}${count > 1 ? ' and others' : ''})`; if (this.container.config.debug) { @@ -588,8 +583,8 @@ export class GitCodeLensProvider implements CodeLensProvider { (lens.symbol as SymbolInformation).containerName ? `|${(lens.symbol as SymbolInformation).containerName}` : '' - }), Lines (${lens.blameRange.start.line + 1}-${lens.blameRange.end.line + 1}), Authors (${Iterables.join( - Iterables.map(blame.authors.values(), a => a.name), + }), Lines (${lens.blameRange.start.line + 1}-${lens.blameRange.end.line + 1}), Authors (${join( + map(blame.authors.values(), a => a.name), ', ', )})]`; } @@ -598,8 +593,7 @@ export class GitCodeLensProvider implements CodeLensProvider { return this.applyCommandWithNoClickAction(title, lens); } - const commit = - Iterables.find(blame.commits.values(), c => c.author === author) ?? Iterables.first(blame.commits.values()); + const commit = find(blame.commits.values(), c => c.author === author) ?? first(blame.commits.values()); switch (lens.desiredCommand) { case CodeLensCommand.CopyRemoteCommitUrl: @@ -730,7 +724,7 @@ export class GitCodeLensProvider implements CodeLensProvider { ): T { let refs; if (commit === undefined) { - refs = [...Iterables.filterMap(blame.commits.values(), c => (c.isUncommitted ? undefined : c.ref))]; + refs = [...filterMap(blame.commits.values(), c => (c.isUncommitted ? undefined : c.ref))]; } else { refs = [commit.ref]; } @@ -884,5 +878,5 @@ function getRangeFromSymbol(symbol: DocumentSymbol | SymbolInformation) { } function isDocumentSymbol(symbol: DocumentSymbol | SymbolInformation): symbol is DocumentSymbol { - return Functions.is(symbol, 'children'); + return is(symbol, 'children'); } diff --git a/src/commands/showQuickCommitFile.ts b/src/commands/showQuickCommitFile.ts index a3cbddb..f626f3e 100644 --- a/src/commands/showQuickCommitFile.ts +++ b/src/commands/showQuickCommitFile.ts @@ -64,7 +64,7 @@ export class ShowQuickCommitFileCommand extends ActiveEditorCachedCommand { let gitUri; if (args.revisionUri !== undefined) { - gitUri = GitUri.fromRevisionUri(Uri.parse(args.revisionUri)); + gitUri = GitUri.fromRevisionUri(Uri.parse(args.revisionUri, true)); args.sha = gitUri.sha; } else { gitUri = await GitUri.fromUri(uri); diff --git a/src/config.ts b/src/config.ts index 41d695c..e9a19ce 100644 --- a/src/config.ts +++ b/src/config.ts @@ -48,6 +48,11 @@ export interface Config { defaultGravatarsStyle: GravatarDefaultStyle; defaultTimeFormat: DateTimeFormat | string | null; detectNestedRepositories: boolean; + experimental: { + virtualRepositories: { + enabled: boolean; + }; + }; fileAnnotations: { command: string | null; }; diff --git a/src/container.ts b/src/container.ts index 2e00d6d..da4eb9d 100644 --- a/src/container.ts +++ b/src/container.ts @@ -253,7 +253,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(); @@ -264,7 +264,7 @@ export class Container { private async _loadGitHubApi() { try { - return new (await import(/* webpackChunkName: "github" */ './github/github')).GitHubApi(); + return new (await import(/* webpackChunkName: "github" */ './premium/github/github')).GitHubApi(); } catch (ex) { Logger.error(ex); return undefined; diff --git a/src/env/browser/git.ts b/src/env/browser/git.ts index a0e76e8..c0d2508 100644 --- a/src/env/browser/git.ts +++ b/src/env/browser/git.ts @@ -1,11 +1,17 @@ import { Container } from '../../container'; import { GitCommandOptions } from '../../git/commandOptions'; +import { GitHubGitProvider } from '../../premium/github/githubGitProvider'; import { GitProvider } from '../../git/gitProvider'; +// Force import of GitHub since dynamic imports are not supported in the WebWorker ExtensionHost +import * as GitHub from '../../premium/github/github'; export function git(_options: GitCommandOptions, ..._args: any[]): Promise { return Promise.resolve(''); } -export function getSupportedGitProviders(_container: Container): GitProvider[] { - return []; +export function getSupportedGitProviders(container: Container): GitProvider[] { + if (!container.config.experimental.virtualRepositories.enabled) return []; + + GitHub.GitHubApi; + return [new GitHubGitProvider(container)]; } diff --git a/src/env/node/git.ts b/src/env/node/git.ts index fa496da..d60c09e 100644 --- a/src/env/node/git.ts +++ b/src/env/node/git.ts @@ -1,12 +1,12 @@ import { Container } from '../../container'; import { GitProvider } from '../../git/gitProvider'; +import { GitHubGitProvider } from '../../premium/github/githubGitProvider'; import { LocalGitProvider } from './git/localGitProvider'; -import { isWeb } from './platform'; export { git } from './git/git'; export function getSupportedGitProviders(container: Container): GitProvider[] { - if (isWeb) return []; - - return [new LocalGitProvider(container)]; + return container.config.experimental.virtualRepositories.enabled + ? [new LocalGitProvider(container), new GitHubGitProvider(container)] + : [new LocalGitProvider(container)]; } diff --git a/src/env/node/git/localGitProvider.ts b/src/env/node/git/localGitProvider.ts index 9400847..ef2738e 100644 --- a/src/env/node/git/localGitProvider.ts +++ b/src/env/node/git/localGitProvider.ts @@ -9,7 +9,6 @@ import { extensions, FileType, Range, - TextEditor, Uri, window, workspace, @@ -158,7 +157,6 @@ export class LocalGitProvider implements GitProvider, Disposable { private readonly _contributorsCache = new Map>(); private readonly _mergeStatusCache = new Map(); private readonly _rebaseStatusCache = new Map(); - private readonly _remotesWithApiProviderCache = new Map | null>(); private readonly _repoInfoCache = new Map(); private readonly _stashesCache = new Map(); private readonly _tagsCache = new Map>>(); @@ -189,10 +187,6 @@ export class LocalGitProvider implements GitProvider, Disposable { this._contributorsCache.delete(`stats|${repo.path}`); } - if (e.changed(RepositoryChange.Remotes, RepositoryChange.RemoteProviders, RepositoryChangeComparisonMode.Any)) { - this._remotesWithApiProviderCache.clear(); - } - if (e.changed(RepositoryChange.Index, RepositoryChange.Unknown, RepositoryChangeComparisonMode.Any)) { this._trackedPaths.clear(); } @@ -345,7 +339,7 @@ export class LocalGitProvider implements GitProvider, Disposable { } openRepository( - folder: WorkspaceFolder, + folder: WorkspaceFolder | undefined, uri: Uri, root: boolean, suspended?: boolean, @@ -357,7 +351,7 @@ export class LocalGitProvider implements GitProvider, Disposable { this.onRepositoryChanged.bind(this), this.descriptor, folder, - uri.fsPath, + uri, root, suspended ?? !window.state.focused, closed, @@ -496,12 +490,27 @@ export class LocalGitProvider implements GitProvider, Disposable { }); } + canHandlePathOrUri(pathOrUri: string | Uri): string | undefined { + let scheme; + if (typeof pathOrUri === 'string') { + const match = isUriRegex.exec(pathOrUri); + if (match == null) return pathOrUri; + + [, scheme] = match; + } else { + ({ scheme } = pathOrUri); + } + + if (!this.supportedSchemes.includes(scheme)) return undefined; + return typeof pathOrUri === 'string' ? pathOrUri : pathOrUri.fsPath; + } + 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 if (isUriRegex.test(base)) { - base = Uri.parse(base); + base = Uri.parse(base, true); } else { if (!isAbsolute(base)) { debugger; @@ -554,7 +563,7 @@ export class LocalGitProvider implements GitProvider, Disposable { if (typeof base === 'string') { // If it looks like a Uri parse it if (isUriRegex.test(base)) { - base = Uri.parse(base); + base = Uri.parse(base, true); } else { if (!isAbsolute(base)) { debugger; @@ -568,7 +577,7 @@ export class LocalGitProvider implements GitProvider, Disposable { // Convert the path to a Uri if it isn't one if (typeof pathOrUri === 'string') { if (isUriRegex.test(pathOrUri)) { - pathOrUri = Uri.parse(pathOrUri); + pathOrUri = Uri.parse(pathOrUri, true); } else { if (!isAbsolute(pathOrUri)) return normalizePath(pathOrUri); @@ -733,33 +742,29 @@ export class LocalGitProvider implements GitProvider, Disposable { } @log() - resetCaches(...cache: ('branches' | 'contributors' | 'providers' | 'remotes' | 'stashes' | 'status' | 'tags')[]) { - if (cache.length === 0 || cache.includes('branches')) { + resetCaches(...affects: ('branches' | 'contributors' | 'providers' | 'remotes' | 'stashes' | 'status' | 'tags')[]) { + if (affects.length === 0 || affects.includes('branches')) { this._branchesCache.clear(); } - if (cache.length === 0 || cache.includes('contributors')) { + if (affects.length === 0 || affects.includes('contributors')) { this._contributorsCache.clear(); } - if (cache.length === 0 || cache.includes('providers')) { - this._remotesWithApiProviderCache.clear(); - } - - if (cache.length === 0 || cache.includes('stashes')) { + if (affects.length === 0 || affects.includes('stashes')) { this._stashesCache.clear(); } - if (cache.length === 0 || cache.includes('status')) { + if (affects.length === 0 || affects.includes('status')) { this._mergeStatusCache.clear(); this._rebaseStatusCache.clear(); } - if (cache.length === 0 || cache.includes('tags')) { + if (affects.length === 0 || affects.includes('tags')) { this._tagsCache.clear(); } - if (cache.length === 0) { + if (affects.length === 0) { this._trackedPaths.clear(); this._repoInfoCache.clear(); } @@ -1166,7 +1171,7 @@ export class LocalGitProvider implements GitProvider, Disposable { const blame = await this.getBlameForFile(uri); if (blame == null) return undefined; - return this.getBlameForRangeSync(blame, uri, range); + return this.getBlameRange(blame, uri, range); } @log({ args: { 2: '' } }) @@ -1174,11 +1179,11 @@ export class LocalGitProvider implements GitProvider, Disposable { const blame = await this.getBlameForFileContents(uri, contents); if (blame == null) return undefined; - return this.getBlameForRangeSync(blame, uri, range); + return this.getBlameRange(blame, uri, range); } - @log({ args: { 0: '' } }) - getBlameForRangeSync(blame: GitBlame, uri: GitUri, range: Range): GitBlameLines | undefined { + @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) { @@ -1260,42 +1265,6 @@ export class LocalGitProvider implements GitProvider, Disposable { return branch; } - // @log({ - // args: { - // 0: b => b.name, - // }, - // }) - // async getBranchAheadRange(branch: GitBranch) { - // if (branch.state.ahead > 0) { - // return GitRevision.createRange(branch.upstream?.name, branch.ref); - // } - - // if (branch.upstream == null) { - // // If we have no upstream branch, try to find a best guess branch to use as the "base" - // const { values: branches } = await this.getBranches(branch.repoPath, { - // filter: b => weightedDefaultBranches.has(b.name), - // }); - // if (branches.length > 0) { - // let weightedBranch: { weight: number; branch: GitBranch } | undefined; - // for (const branch of branches) { - // const weight = weightedDefaultBranches.get(branch.name)!; - // if (weightedBranch == null || weightedBranch.weight < weight) { - // weightedBranch = { weight: weight, branch: branch }; - // } - - // if (weightedBranch.weight === maxDefaultBranchWeight) break; - // } - - // const possibleBranch = weightedBranch!.branch.upstream?.name ?? weightedBranch!.branch.ref; - // if (possibleBranch !== branch.ref) { - // return GitRevision.createRange(possibleBranch, branch.ref); - // } - // } - // } - - // return undefined; - // } - @log({ args: { 1: false } }) async getBranches( repoPath: string | undefined, @@ -1378,58 +1347,6 @@ export class LocalGitProvider implements GitProvider, Disposable { return result; } - // @log() - // async getBranchesAndTagsTipsFn(repoPath: string | undefined, currentName?: string) { - // const [{ values: branches }, { values: tags }] = await Promise.all([ - // this.getBranches(repoPath), - // this.getTags(repoPath), - // ]); - - // const branchesAndTagsBySha = Arrays.groupByFilterMap( - // (branches as (GitBranch | GitTag)[]).concat(tags as (GitBranch | GitTag)[]), - // bt => bt.sha, - // bt => { - // if (currentName) { - // if (bt.name === currentName) return undefined; - // if (bt.refType === 'branch' && bt.getNameWithoutRemote() === currentName) { - // return { name: bt.name, compactName: bt.getRemoteName(), type: bt.refType }; - // } - // } - - // return { name: bt.name, compactName: undefined, type: bt.refType }; - // }, - // ); - - // return (sha: string, options?: { compact?: boolean; icons?: boolean }): string | undefined => { - // const branchesAndTags = branchesAndTagsBySha.get(sha); - // if (branchesAndTags == null || branchesAndTags.length === 0) return undefined; - - // if (!options?.compact) { - // return branchesAndTags - // .map( - // bt => `${options?.icons ? `${bt.type === 'tag' ? '$(tag)' : '$(git-branch)'} ` : ''}${bt.name}`, - // ) - // .join(', '); - // } - - // if (branchesAndTags.length > 1) { - // const [bt] = branchesAndTags; - // return `${options?.icons ? `${bt.type === 'tag' ? '$(tag)' : '$(git-branch)'} ` : ''}${ - // bt.compactName ?? bt.name - // }, ${GlyphChars.Ellipsis}`; - // } - - // return branchesAndTags - // .map( - // bt => - // `${options?.icons ? `${bt.type === 'tag' ? '$(tag)' : '$(git-branch)'} ` : ''}${ - // bt.compactName ?? bt.name - // }`, - // ) - // .join(', '); - // }; - // } - @log() async getChangedFilesCount(repoPath: string, ref?: string): Promise { const data = await Git.diff__shortstat(repoPath, ref); @@ -1550,6 +1467,10 @@ export class LocalGitProvider implements GitProvider, Disposable { @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; @@ -1559,48 +1480,57 @@ export class LocalGitProvider implements GitProvider, Disposable { user = { name: undefined, email: undefined }; - const data = await Git.config__get_regex('^user\\.', repoPath, { local: true }); - if (data) { - let key: string; - let value: string; + try { + const data = await Git.config__get_regex('^user\\.', repoPath, { local: true }); + if (data) { + let key: string; + let value: string; + + let match; + do { + match = userConfigRegex.exec(data); + if (match == null) break; + + [, key, value] = match; + // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 + user[key as 'name' | 'email'] = ` ${value}`.substr(1); + } while (true); + } else { + user.name = + process.env.GIT_AUTHOR_NAME || process.env.GIT_COMMITTER_NAME || userInfo()?.username || undefined; + if (!user.name) { + // If we found no user data, mark it so we won't bother trying again + this._repoInfoCache.set(repoPath, { ...repo, user: null }); + return undefined; + } - let match; - do { - match = userConfigRegex.exec(data); - if (match == null) break; + user.email = + process.env.GIT_AUTHOR_EMAIL || + process.env.GIT_COMMITTER_EMAIL || + process.env.EMAIL || + `${user.name}@${hostname()}`; + } - [, key, value] = match; - // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 - user[key as 'name' | 'email'] = ` ${value}`.substr(1); - } while (true); - } else { - user.name = - process.env.GIT_AUTHOR_NAME || process.env.GIT_COMMITTER_NAME || userInfo()?.username || undefined; - if (!user.name) { - // If we found no user data, mark it so we won't bother trying again - this._repoInfoCache.set(repoPath, { ...repo, user: null }); - return undefined; + const author = `${user.name} <${user.email}>`; + // Check if there is a mailmap for the current user + const mappedAuthor = await Git.check_mailmap(repoPath, author); + if (mappedAuthor != null && mappedAuthor.length !== 0 && author !== mappedAuthor) { + const match = mappedAuthorRegex.exec(mappedAuthor); + if (match != null) { + [, user.name, user.email] = match; + } } - user.email = - process.env.GIT_AUTHOR_EMAIL || - process.env.GIT_COMMITTER_EMAIL || - process.env.EMAIL || - `${user.name}@${hostname()}`; - } + this._repoInfoCache.set(repoPath, { ...repo, user: user }); + return user; + } catch (ex) { + Logger.error(ex, cc); + debugger; - const author = `${user.name} <${user.email}>`; - // Check if there is a mailmap for the current user - const mappedAuthor = await Git.check_mailmap(repoPath, author); - if (mappedAuthor != null && mappedAuthor.length !== 0 && author !== mappedAuthor) { - const match = mappedAuthorRegex.exec(mappedAuthor); - if (match != null) { - [, user.name, user.email] = match; - } + // Mark it so we won't bother trying again + this._repoInfoCache.set(repoPath, { ...repo, user: null }); + return undefined; } - - this._repoInfoCache.set(repoPath, { ...repo, user: user }); - return user; } @log() @@ -1900,6 +1830,8 @@ export class LocalGitProvider implements GitProvider, Disposable { since?: string; }, ): Promise { + const cc = Logger.getCorrelationContext(); + const limit = options?.limit ?? this.container.config.advanced.maxListItems ?? 0; try { @@ -1931,6 +1863,8 @@ export class LocalGitProvider implements GitProvider, Disposable { return log; } catch (ex) { + Logger.error(ex, cc); + debugger; return undefined; } } @@ -1949,6 +1883,8 @@ export class LocalGitProvider implements GitProvider, Disposable { since?: string; }, ): Promise | undefined> { + const cc = Logger.getCorrelationContext(); + const limit = options?.limit ?? this.container.config.advanced.maxListItems ?? 0; try { @@ -1965,6 +1901,8 @@ export class LocalGitProvider implements GitProvider, Disposable { const commits = GitLogParser.parseRefsOnly(data); return new Set(commits); } catch (ex) { + Logger.error(ex, cc); + debugger; return undefined; } } @@ -3062,91 +3000,6 @@ export class LocalGitProvider implements GitProvider, Disposable { }; } - @gate( - (repoPath, remotes, options) => - `${repoPath}|${remotes?.map(r => r.id).join(',') ?? ''}|${options?.includeDisconnected ?? false}`, - ) - @log({ args: { 1: remotes => remotes?.map(r => r.name).join(',') } }) - async getRichRemoteProvider( - repoPath: string, - remotes: GitRemote[] | undefined, - options?: { includeDisconnected?: boolean | undefined }, - ): Promise | undefined> { - if (repoPath == null) return undefined; - - const cacheKey = repoPath; - - let richRemote = this._remotesWithApiProviderCache.get(cacheKey); - if (richRemote != null) return richRemote; - if (richRemote === null && !options?.includeDisconnected) return undefined; - - if (options?.includeDisconnected) { - richRemote = this._remotesWithApiProviderCache.get(`disconnected|${cacheKey}`); - if (richRemote !== undefined) return richRemote ?? undefined; - } - - remotes = (remotes ?? (await this.getRemotesWithProviders(repoPath))).filter( - ( - r: GitRemote, - ): r is GitRemote => r.provider != null, - ); - - if (remotes.length === 0) return undefined; - - let remote; - if (remotes.length === 1) { - remote = remotes[0]; - } else { - const weightedRemotes = new Map([ - ['upstream', 15], - ['origin', 10], - ]); - - const branch = await this.getBranch(remotes[0].repoPath); - const branchRemote = branch?.getRemoteName(); - - if (branchRemote != null) { - weightedRemotes.set(branchRemote, 100); - } - - let bestRemote; - let weight = 0; - for (const r of remotes) { - if (r.default) { - bestRemote = r; - break; - } - - // Don't choose a remote unless its weighted above - const matchedWeight = weightedRemotes.get(r.name) ?? -1; - if (matchedWeight > weight) { - bestRemote = r; - weight = matchedWeight; - } - } - - remote = bestRemote ?? null; - } - - if (!remote?.hasRichProvider()) { - this._remotesWithApiProviderCache.set(cacheKey, null); - return undefined; - } - - const { provider } = remote; - const connected = provider.maybeConnected ?? (await provider.isConnected()); - if (connected) { - this._remotesWithApiProviderCache.set(cacheKey, remote); - } else { - this._remotesWithApiProviderCache.set(cacheKey, null); - this._remotesWithApiProviderCache.set(`disconnected|${cacheKey}`, remote); - - if (!options?.includeDisconnected) return undefined; - } - - return remote; - } - @log({ args: { 1: false } }) async getRemotes( repoPath: string | undefined, @@ -3172,21 +3025,6 @@ export class LocalGitProvider implements GitProvider, Disposable { } } - @log() - async getRemotesWithProviders( - repoPath: string | undefined, - options?: { sort?: boolean }, - ): Promise[]> { - if (repoPath == null) return []; - - const repository = this.container.git.getRepository(repoPath); - const remotes = await (repository != null - ? repository.getRemotes(options) - : this.getRemotes(repoPath, options)); - - return remotes.filter(r => r.provider != null) as GitRemote[]; - } - @gate() @log() getRevisionContent(repoPath: string, path: string, ref: string): Promise { @@ -3214,10 +3052,10 @@ export class LocalGitProvider implements GitProvider, Disposable { } @log() - async getStatusForFile(repoPath: string, fileName: string): Promise { + async getStatusForFile(repoPath: string, path: string): Promise { const porcelainVersion = (await Git.isAtLeastVersion('2.11')) ? 2 : 1; - const data = await Git.status__file(repoPath, fileName, porcelainVersion, { + const data = await Git.status__file(repoPath, path, porcelainVersion, { similarityThreshold: this.container.config.advanced.similarityThreshold, }); const status = GitStatusParser.parse(data, repoPath, porcelainVersion); @@ -3227,10 +3065,10 @@ export class LocalGitProvider implements GitProvider, Disposable { } @log() - async getStatusForFiles(repoPath: string, path: string): Promise { + async getStatusForFiles(repoPath: string, pathOrGlob: string): Promise { const porcelainVersion = (await Git.isAtLeastVersion('2.11')) ? 2 : 1; - const data = await Git.status__file(repoPath, path, porcelainVersion, { + const data = await Git.status__file(repoPath, pathOrGlob, porcelainVersion, { similarityThreshold: this.container.config.advanced.similarityThreshold, }); const status = GitStatusParser.parse(data, repoPath, porcelainVersion); @@ -3270,7 +3108,7 @@ export class LocalGitProvider implements GitProvider, Disposable { @log({ args: { 1: false } }) async getTags( repoPath: string | undefined, - options?: { filter?: (t: GitTag) => boolean; sort?: boolean | TagSortOptions }, + options?: { cursor?: string; filter?: (t: GitTag) => boolean; sort?: boolean | TagSortOptions }, ): Promise> { if (repoPath == null) return emptyPagedResult; @@ -3348,19 +3186,6 @@ export class LocalGitProvider implements GitProvider, Disposable { return branches.length !== 0 || tags.length !== 0; } - @log({ - args: { 1: e => (e != null ? `TextEditor(${Logger.toLoggable(e.document.uri)})` : undefined) }, - }) - async isActiveRepoPath(repoPath: string | undefined, editor?: TextEditor): Promise { - if (repoPath == null) return false; - - editor = editor ?? window.activeTextEditor; - if (editor == null) return false; - - const doc = await this.container.tracker.getOrAdd(editor.document.uri); - return repoPath === doc?.uri.repoPath; - } - isTrackable(uri: Uri): boolean { return this.supportedSchemes.includes(uri.scheme); } @@ -3610,18 +3435,11 @@ export class LocalGitProvider implements GitProvider, Disposable { return (await Git.rev_parse__verify(repoPath, ref)) != null; } - stageFile(repoPath: string, fileName: string): Promise; - stageFile(repoPath: string, uri: Uri): Promise; @log() - async stageFile(repoPath: string, fileNameOrUri: string | Uri): Promise { - await Git.add( - repoPath, - typeof fileNameOrUri === 'string' ? fileNameOrUri : splitPath(fileNameOrUri.fsPath, repoPath)[0], - ); + async stageFile(repoPath: string, pathOrUri: string | Uri): Promise { + await Git.add(repoPath, typeof pathOrUri === 'string' ? pathOrUri : splitPath(pathOrUri.fsPath, repoPath)[0]); } - stageDirectory(repoPath: string, directory: string): Promise; - stageDirectory(repoPath: string, uri: Uri): Promise; @log() async stageDirectory(repoPath: string, directoryOrUri: string | Uri): Promise { await Git.add( @@ -3630,18 +3448,11 @@ export class LocalGitProvider implements GitProvider, Disposable { ); } - unStageFile(repoPath: string, fileName: string): Promise; - unStageFile(repoPath: string, uri: Uri): Promise; @log() - async unStageFile(repoPath: string, fileNameOrUri: string | Uri): Promise { - await Git.reset( - repoPath, - typeof fileNameOrUri === 'string' ? fileNameOrUri : splitPath(fileNameOrUri.fsPath, repoPath)[0], - ); + async unStageFile(repoPath: string, pathOrUri: string | Uri): Promise { + await Git.reset(repoPath, typeof pathOrUri === 'string' ? pathOrUri : splitPath(pathOrUri.fsPath, repoPath)[0]); } - unStageDirectory(repoPath: string, directory: string): Promise; - unStageDirectory(repoPath: string, uri: Uri): Promise; @log() async unStageDirectory(repoPath: string, directoryOrUri: string | Uri): Promise { await Git.reset( diff --git a/src/errors.ts b/src/errors.ts index 85ae225..52c08cd 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -44,6 +44,70 @@ export class AuthenticationError extends Error { } } +export class ExtensionNotFoundError extends Error { + constructor(public readonly extensionId: string, public readonly extensionName: string) { + super( + `Unable to find the ${extensionName} extension (${extensionId}). Please ensure it is installed and enabled.`, + ); + + Error.captureStackTrace?.(this, ExtensionNotFoundError); + } +} + +export const enum OpenVirtualRepositoryErrorReason { + RemoteHubApiNotFound = 1, + NotAGitHubRepository = 2, + GitHubAuthenticationNotFound = 3, + GitHubAuthenticationDenied = 4, +} + +export class OpenVirtualRepositoryError extends Error { + readonly original?: Error; + readonly reason: OpenVirtualRepositoryErrorReason | undefined; + readonly repoPath: string; + + constructor(repoPath: string, reason?: OpenVirtualRepositoryErrorReason, original?: Error); + constructor(repoPath: string, message?: string, original?: Error); + constructor( + repoPath: string, + messageOrReason: string | OpenVirtualRepositoryErrorReason | undefined, + original?: Error, + ) { + let message; + let reason: OpenVirtualRepositoryErrorReason | undefined; + if (messageOrReason == null) { + message = `Unable to open the virtual repository: ${repoPath}`; + } else if (typeof messageOrReason === 'string') { + message = messageOrReason; + reason = undefined; + } else { + reason = messageOrReason; + message = `Unable to open the virtual repository: ${repoPath}; `; + switch (reason) { + case OpenVirtualRepositoryErrorReason.RemoteHubApiNotFound: + message += + 'Unable to get required api from the GitHub Repositories extension. Please ensure that the GitHub Repositories extension is installed and enabled'; + break; + case OpenVirtualRepositoryErrorReason.NotAGitHubRepository: + message += 'Only GitHub repositories are supported currently'; + break; + case OpenVirtualRepositoryErrorReason.GitHubAuthenticationNotFound: + message += 'Unable to get required GitHub authentication'; + break; + case OpenVirtualRepositoryErrorReason.GitHubAuthenticationDenied: + message += 'GitHub authentication is required'; + break; + } + } + super(message); + + this.original = original; + this.reason = reason; + this.repoPath = repoPath; + Error.captureStackTrace?.(this, OpenVirtualRepositoryError); + } +} + export class ProviderNotFoundError extends Error { constructor(pathOrUri: string | Uri | undefined) { super( diff --git a/src/git/gitProvider.ts b/src/git/gitProvider.ts index 7733923..48d4107 100644 --- a/src/git/gitProvider.ts +++ b/src/git/gitProvider.ts @@ -1,4 +1,4 @@ -import { Disposable, Event, Range, TextEditor, Uri, WorkspaceFolder } from 'vscode'; +import { Disposable, Event, Range, Uri, WorkspaceFolder } from 'vscode'; import { Commit, InputBox } from '../@types/vscode.git'; import { GitUri } from './gitUri'; import { @@ -82,15 +82,23 @@ export interface GitProvider extends Disposable { discoverRepositories(uri: Uri): Promise; updateContext?(): void; - openRepository(folder: WorkspaceFolder, uri: Uri, root: boolean, suspended?: boolean, closed?: boolean): Repository; + openRepository( + folder: WorkspaceFolder | undefined, + uri: Uri, + root: boolean, + suspended?: boolean, + closed?: boolean, + ): Repository; openRepositoryInitWatcher?(): RepositoryInitWatcher; getOpenScmRepositories(): Promise; getOrOpenScmRepository(repoPath: string): Promise; + canHandlePathOrUri(pathOrUri: string | Uri): string | undefined; getAbsoluteUri(pathOrUri: string | Uri, base?: string | Uri): Uri; getBestRevisionUri(repoPath: string, path: string, ref: string | undefined): Promise; getRelativePath(pathOrUri: string | Uri, base: string | Uri): string; getRevisionUri(repoPath: string, path: string, ref: string): Uri; + // getRootUri(pathOrUri: string | Uri): Uri; getWorkingUri(repoPath: string, uri: Uri): Promise; addRemote(repoPath: string, name: string, url: string): Promise; @@ -103,7 +111,7 @@ export interface GitProvider extends Disposable { options?: { createBranch?: string | undefined } | { fileName?: string | undefined }, ): Promise; resetCaches( - ...cache: ('branches' | 'contributors' | 'providers' | 'remotes' | 'stashes' | 'status' | 'tags')[] + ...affects: ('branches' | 'contributors' | 'providers' | 'remotes' | 'stashes' | 'status' | 'tags')[] ): void; excludeIgnoredUris(repoPath: string, uris: Uri[]): Promise; fetch( @@ -154,7 +162,7 @@ export interface GitProvider extends Disposable { ): Promise; getBlameForRange(uri: GitUri, range: Range): Promise; getBlameForRangeContents(uri: GitUri, range: Range, contents: string): Promise; - getBlameForRangeSync(blame: GitBlame, uri: GitUri, range: Range): GitBlameLines | undefined; + getBlameRange(blame: GitBlame, uri: GitUri, range: Range): GitBlameLines | undefined; getBranch(repoPath: string): Promise; getBranches( repoPath: string, @@ -319,27 +327,22 @@ export interface GitProvider extends Disposable { skip?: number | undefined; }, ): Promise; - getRichRemoteProvider( - repoPath: string, - remotes: GitRemote[] | undefined, - options?: { includeDisconnected?: boolean | undefined }, - ): Promise | undefined>; getRemotes( repoPath: string | undefined, options?: { providers?: RemoteProviders; sort?: boolean }, ): Promise[]>; - getRemotesWithProviders( - repoPath: string | undefined, - options?: { force?: boolean; providers?: RemoteProviders; sort?: boolean | undefined }, - ): Promise[]>; getRevisionContent(repoPath: string, path: string, ref: string): Promise; getStash(repoPath: string | undefined): Promise; - getStatusForFile(repoPath: string, fileName: string): Promise; + getStatusForFile(repoPath: string, path: string): Promise; getStatusForFiles(repoPath: string, pathOrGlob: string): Promise; getStatusForRepo(repoPath: string | undefined): Promise; getTags( repoPath: string | undefined, - options?: { filter?: ((t: GitTag) => boolean) | undefined; sort?: boolean | TagSortOptions | undefined }, + options?: { + cursor?: string; + filter?: ((t: GitTag) => boolean) | undefined; + sort?: boolean | TagSortOptions | undefined; + }, ): Promise>; getTreeEntryForRevision(repoPath: string, path: string, ref: string): Promise; getTreeForRevision(repoPath: string, ref: string): Promise; @@ -352,7 +355,6 @@ export interface GitProvider extends Disposable { | undefined; }, ): Promise; - isActiveRepoPath(repoPath: string | undefined, editor?: TextEditor): Promise; isTrackable(uri: Uri): boolean; @@ -378,17 +380,9 @@ export interface GitProvider extends Disposable { validateBranchOrTagName(repoPath: string, ref: string): Promise; validateReference(repoPath: string, ref: string): Promise; - stageFile(repoPath: string, fileName: string): Promise; - stageFile(repoPath: string, uri: Uri): Promise; - stageFile(repoPath: string, fileNameOrUri: string | Uri): Promise; - stageDirectory(repoPath: string, directory: string): Promise; - stageDirectory(repoPath: string, uri: Uri): Promise; + stageFile(repoPath: string, pathOrUri: string | Uri): Promise; stageDirectory(repoPath: string, directoryOrUri: string | Uri): Promise; - unStageFile(repoPath: string, fileName: string): Promise; - unStageFile(repoPath: string, uri: Uri): Promise; - unStageFile(repoPath: string, fileNameOrUri: string | Uri): Promise; - unStageDirectory(repoPath: string, directory: string): Promise; - unStageDirectory(repoPath: string, uri: Uri): Promise; + unStageFile(repoPath: string, pathOrUri: string | Uri): Promise; unStageDirectory(repoPath: string, directoryOrUri: string | Uri): Promise; stashApply(repoPath: string, stashName: string, options?: { deleteAfter?: boolean | undefined }): Promise; diff --git a/src/git/gitProviderService.ts b/src/git/gitProviderService.ts index 0e55849..8ce9a32 100644 --- a/src/git/gitProviderService.ts +++ b/src/git/gitProviderService.ts @@ -14,7 +14,6 @@ import { WorkspaceFolder, WorkspaceFoldersChangeEvent, } from 'vscode'; -import { isWeb } from '@env/platform'; import { resetAvatarCache } from '../avatars'; import { configuration } from '../configuration'; import { @@ -33,7 +32,7 @@ import { groupByFilterMap, groupByMap } from '../system/array'; import { gate } from '../system/decorators/gate'; import { debug, log } from '../system/decorators/log'; import { count, filter, first, flatMap, map } from '../system/iterable'; -import { basename, dirname, isAbsolute, normalizePath } from '../system/path'; +import { dirname, getBestPath, isAbsolute, normalizePath } from '../system/path'; import { cancellable, isPromise, PromiseCancelledError } from '../system/promise'; import { CharCode } from '../system/string'; import { VisitedPathsTrie } from '../system/trie'; @@ -137,8 +136,9 @@ export class GitProviderService implements Disposable { private readonly _disposable: Disposable; private readonly _providers = new Map(); private readonly _repositories = new Repositories(); + private readonly _richRemotesCache = new Map | null>(); private readonly _supportedSchemes = new Set(); - private _visitedPaths = new VisitedPathsTrie(); + private readonly _visitedPaths = new VisitedPathsTrie(); constructor(private readonly container: Container) { this._disposable = Disposable.from( @@ -312,6 +312,16 @@ export class GitProviderService implements Disposable { provider, ...disposables, provider.onDidChangeRepository(e => { + if ( + e.changed( + RepositoryChange.Remotes, + RepositoryChange.RemoteProviders, + RepositoryChangeComparisonMode.Any, + ) + ) { + this._richRemotesCache.clear(); + } + if (e.changed(RepositoryChange.Closed, RepositoryChangeComparisonMode.Any)) { this.updateContext(); @@ -556,47 +566,62 @@ export class GitProviderService implements Disposable { this._providers.forEach(p => p.updateContext?.()); } + // private _pathToProvider = new Map(); + private getProvider(repoPath: string | Uri): GitProviderResult { if (repoPath == null || (typeof repoPath !== 'string' && !this._supportedSchemes.has(repoPath.scheme))) { debugger; throw new ProviderNotFoundError(repoPath); } - let id = !isWeb ? GitProviderId.Git : undefined; - if (typeof repoPath !== 'string' && repoPath.scheme === DocumentSchemes.Virtual) { - if (repoPath.authority.startsWith('github')) { - id = GitProviderId.GitHub; - } else { - throw new ProviderNotFoundError(repoPath); - } - } - if (id == null) throw new ProviderNotFoundError(repoPath); - - const provider = this._providers.get(id); - if (provider == null) throw new ProviderNotFoundError(repoPath); - - switch (id) { - case GitProviderId.Git: - return { - provider: provider, - path: typeof repoPath === 'string' ? repoPath : repoPath.fsPath, - }; - - default: - return { - provider: provider, - path: typeof repoPath === 'string' ? repoPath : repoPath.toString(), - }; + // const key = repoPath.toString(); + // let providerResult = this._pathToProvider.get(key); + // if (providerResult != null) return providerResult; + + for (const provider of this._providers.values()) { + const path = provider.canHandlePathOrUri(repoPath); + if (path == null) continue; + + const providerResult: GitProviderResult = { provider: provider, path: path }; + // this._pathToProvider.set(key, providerResult); + return providerResult; } + + debugger; + throw new ProviderNotFoundError(repoPath); + + // let id = !isWeb ? GitProviderId.Git : undefined; + // if (typeof repoPath !== 'string' && repoPath.scheme === DocumentSchemes.Virtual) { + // if (repoPath.authority.startsWith('github')) { + // id = GitProviderId.GitHub; + // } else { + // throw new ProviderNotFoundError(repoPath); + // } + // } + // if (id == null) throw new ProviderNotFoundError(repoPath); + + // const provider = this._providers.get(id); + // if (provider == null) throw new ProviderNotFoundError(repoPath); + + // switch (id) { + // case GitProviderId.Git: + // return { + // provider: provider, + // path: typeof repoPath === 'string' ? repoPath : repoPath.fsPath, + // }; + + // default: + // return { + // provider: provider, + // path: typeof repoPath === 'string' ? repoPath : repoPath.toString(), + // }; + // } } getAbsoluteUri(pathOrUri: string | Uri, base?: string | Uri): Uri { if (base == null) { if (typeof pathOrUri === 'string') { - if (isUriRegex.test(pathOrUri)) { - debugger; - return Uri.parse(pathOrUri, true); - } + if (isUriRegex.test(pathOrUri)) return Uri.parse(pathOrUri, true); // I think it is safe to assume this should be file:// return Uri.file(pathOrUri); @@ -651,7 +676,7 @@ export class GitProviderService implements Disposable { ref = refOrUri.sha; repoPath = refOrUri.repoPath!; - path = refOrUri.scheme === DocumentSchemes.File ? refOrUri.fsPath : refOrUri.path; + path = getBestPath(refOrUri); } const { provider, path: rp } = this.getProvider(repoPath!); @@ -700,17 +725,23 @@ export class GitProviderService implements Disposable { @log() resetCaches( - ...cache: ('branches' | 'contributors' | 'providers' | 'remotes' | 'stashes' | 'status' | 'tags')[] + ...affects: ('branches' | 'contributors' | 'providers' | 'remotes' | 'stashes' | 'status' | 'tags')[] ): void { - const repoCache = cache.filter((c): c is 'branches' | 'remotes' => c === 'branches' || c === 'remotes'); + if (affects.length === 0 || affects.includes('providers')) { + this._richRemotesCache.clear(); + } + + const repoAffects = affects.filter((c): c is 'branches' | 'remotes' => c === 'branches' || c === 'remotes'); // Delegate to the repos, if we are clearing everything or one of the per-repo caches - if (cache.length === 0 || repoCache.length > 0) { + if (affects.length === 0 || repoAffects.length > 0) { for (const repo of this.repositories) { - repo.resetCaches(...repoCache); + repo.resetCaches(...repoAffects); } } - void Promise.allSettled([...this._providers.values()].map(provider => provider.resetCaches(...cache))); + for (const provider of this._providers.values()) { + provider.resetCaches(...affects); + } } @log({ args: { 1: uris => uris.length } }) @@ -878,10 +909,10 @@ export class GitProviderService implements Disposable { return provider.getBlameForRangeContents(uri, range, contents); } - @log({ args: { 0: '' } }) - getBlameForRangeSync(blame: GitBlame, uri: GitUri, range: Range): GitBlameLines | undefined { + @log({ args: { 0: '' } }) + getBlameRange(blame: GitBlame, uri: GitUri, range: Range): GitBlameLines | undefined { const { provider } = this.getProvider(uri); - return provider.getBlameForRangeSync(blame, uri, range); + return provider.getBlameRange(blame, uri, range); } @log() @@ -1457,8 +1488,81 @@ export class GitProviderService implements Disposable { remotesOrRepoPath = remotesOrRepoPath[0].repoPath; } - const { provider, path } = this.getProvider(remotesOrRepoPath); - return provider.getRichRemoteProvider(path, remotes, options); + if (typeof remotesOrRepoPath === 'string') { + remotesOrRepoPath = this.getAbsoluteUri(remotesOrRepoPath); + } + + const cacheKey = asRepoComparisonKey(remotesOrRepoPath); + + let richRemote = this._richRemotesCache.get(cacheKey); + if (richRemote != null) return richRemote; + if (richRemote === null && !options?.includeDisconnected) return undefined; + + if (options?.includeDisconnected) { + richRemote = this._richRemotesCache.get(`disconnected|${cacheKey}`); + if (richRemote !== undefined) return richRemote ?? undefined; + } + + remotes = (remotes ?? (await this.getRemotesWithProviders(remotesOrRepoPath))).filter( + ( + r: GitRemote, + ): r is GitRemote => r.provider != null, + ); + + if (remotes.length === 0) return undefined; + + let remote; + if (remotes.length === 1) { + remote = remotes[0]; + } else { + const weightedRemotes = new Map([ + ['upstream', 15], + ['origin', 10], + ]); + + const branch = await this.getBranch(remotes[0].repoPath); + const branchRemote = branch?.getRemoteName(); + + if (branchRemote != null) { + weightedRemotes.set(branchRemote, 100); + } + + let bestRemote; + let weight = 0; + for (const r of remotes) { + if (r.default) { + bestRemote = r; + break; + } + + // Don't choose a remote unless its weighted above + const matchedWeight = weightedRemotes.get(r.name) ?? -1; + if (matchedWeight > weight) { + bestRemote = r; + weight = matchedWeight; + } + } + + remote = bestRemote ?? null; + } + + if (!remote?.hasRichProvider()) { + this._richRemotesCache.set(cacheKey, null); + return undefined; + } + + const { provider } = remote; + const connected = provider.maybeConnected ?? (await provider.isConnected()); + if (connected) { + this._richRemotesCache.set(cacheKey, remote); + } else { + this._richRemotesCache.set(cacheKey, null); + this._richRemotesCache.set(`disconnected|${cacheKey}`, remote); + + if (!options?.includeDisconnected) return undefined; + } + + return remote; } @log({ args: { 1: false } }) @@ -1479,8 +1583,12 @@ export class GitProviderService implements Disposable { ): Promise[]> { if (repoPath == null) return []; - const { provider, path } = this.getProvider(repoPath); - return provider.getRemotesWithProviders(path, options); + const repository = this.container.git.getRepository(repoPath); + const remotes = await (repository != null + ? repository.getRemotes(options) + : this.getRemotes(repoPath, options)); + + return remotes.filter(r => r.provider != null) as GitRemote[]; } getBestRepository(): Repository | undefined; @@ -1546,22 +1654,8 @@ export class GitProviderService implements Disposable { } } - let folder: WorkspaceFolder | undefined; - if (root != null) { - folder = root.folder; - } else { - folder = workspace.getWorkspaceFolder(repoUri); - if (folder == null) { - folder = { - uri: uri, - name: basename(normalizePath(repoUri.path)), - index: this.repositoryCount, - }; - } - } - Logger.log(cc, `Repository found in '${repoUri.toString(false)}'`); - repository = provider.openRepository(folder, repoUri, false); + repository = provider.openRepository(root?.folder, repoUri, false); this._repositories.add(repository); this._pendingRepositories.delete(key); @@ -1654,7 +1748,7 @@ export class GitProviderService implements Disposable { @log({ args: { 1: false } }) async getTags( repoPath: string | Uri | undefined, - options?: { filter?: (t: GitTag) => boolean; sort?: boolean | TagSortOptions }, + options?: { cursor?: string; filter?: (t: GitTag) => boolean; sort?: boolean | TagSortOptions }, ): Promise> { if (repoPath == null) return { values: [] }; @@ -1803,12 +1897,12 @@ export class GitProviderService implements Disposable { return provider.validateReference(path, ref); } - stageFile(repoPath: string | Uri, fileName: string): Promise; + stageFile(repoPath: string | Uri, path: string): Promise; stageFile(repoPath: string | Uri, uri: Uri): Promise; @log() - stageFile(repoPath: string | Uri, fileNameOrUri: string | Uri): Promise { + stageFile(repoPath: string | Uri, pathOrUri: string | Uri): Promise { const { provider, path } = this.getProvider(repoPath); - return provider.stageFile(path, fileNameOrUri); + return provider.stageFile(path, pathOrUri); } stageDirectory(repoPath: string | Uri, directory: string): Promise; @@ -1819,12 +1913,12 @@ export class GitProviderService implements Disposable { return provider.stageDirectory(path, directoryOrUri); } - unStageFile(repoPath: string | Uri, fileName: string): Promise; + unStageFile(repoPath: string | Uri, path: string): Promise; unStageFile(repoPath: string | Uri, uri: Uri): Promise; @log() - unStageFile(repoPath: string | Uri, fileNameOrUri: string | Uri): Promise { + unStageFile(repoPath: string | Uri, pathOrUri: string | Uri): Promise { const { provider, path } = this.getProvider(repoPath); - return provider.unStageFile(path, fileNameOrUri); + return provider.unStageFile(path, pathOrUri); } unStageDirectory(repoPath: string | Uri, directory: string): Promise; diff --git a/src/git/gitUri.ts b/src/git/gitUri.ts index d4f350d..b56ae37 100644 --- a/src/git/gitUri.ts +++ b/src/git/gitUri.ts @@ -4,9 +4,10 @@ import { UriComparer } from '../comparers'; import { DocumentSchemes } from '../constants'; import { Container } from '../container'; import { Logger } from '../logger'; +import { GitHubAuthorityMetadata } from '../premium/remotehub'; import { debug } from '../system/decorators/log'; import { memoize } from '../system/decorators/memoize'; -import { basename, dirname, isAbsolute, normalizePath, relative } from '../system/path'; +import { basename, dirname, getBestPath, isAbsolute, normalizePath, relative } from '../system/path'; import { CharCode, truncateLeft, truncateMiddle } from '../system/string'; import { RevisionUriData } from './gitProvider'; import { GitCommit, GitFile, GitRevision } from './models'; @@ -66,8 +67,34 @@ export class GitUri extends (Uri as any as UriEx) { const metadata = decodeGitLensRevisionUriAuthority(uri.authority); this.repoPath = metadata.repoPath; - if (GitRevision.isUncommittedStaged(metadata.ref) || !GitRevision.isUncommitted(metadata.ref)) { - this.sha = metadata.ref; + + let ref = metadata.ref; + if (commitOrRepoPath != null && typeof commitOrRepoPath !== 'string') { + ref = commitOrRepoPath.sha; + } + + if (GitRevision.isUncommittedStaged(ref) || !GitRevision.isUncommitted(ref)) { + this.sha = ref; + } + + return; + } + + if (uri.scheme === DocumentSchemes.Virtual || uri.scheme === DocumentSchemes.GitHub) { + super(uri); + + const [, owner, repo] = uri.path.split('/', 3); + this.repoPath = uri.with({ path: `/${owner}/${repo}` }).toString(); + + const data = decodeRemoteHubAuthority(uri); + + let ref = data.metadata?.ref?.id; + if (commitOrRepoPath != null && typeof commitOrRepoPath !== 'string') { + ref = commitOrRepoPath.sha; + } + + if (ref && (GitRevision.isUncommittedStaged(ref) || !GitRevision.isUncommitted(ref))) { + this.sha = ref; } return; @@ -429,7 +456,7 @@ export class GitUri extends (Uri as any as UriEx) { path: uri.path, query: JSON.stringify({ // Ensure we use the fsPath here, otherwise the url won't open properly - path: uri.scheme === DocumentSchemes.File ? uri.fsPath : uri.path, + path: getBestPath(uri), ref: '~', }), }); @@ -454,3 +481,17 @@ export function decodeGitLensRevisionUriAuthority(authority: string): T { export function encodeGitLensRevisionUriAuthority(metadata: T): string { return encodeUtf8Hex(JSON.stringify(metadata)); } + +function decodeRemoteHubAuthority(uri: Uri): { scheme: string; metadata: T | undefined } { + const [scheme, encoded] = uri.authority.split('+'); + + let metadata: T | undefined; + if (encoded) { + try { + const data = JSON.parse(decodeUtf8Hex(encoded)); + metadata = data as T; + } catch {} + } + + return { scheme: scheme, metadata: metadata }; +} diff --git a/src/git/models/repository.ts b/src/git/models/repository.ts index 79066e8..a503b4f 100644 --- a/src/git/models/repository.ts +++ b/src/git/models/repository.ts @@ -14,21 +14,20 @@ import { import type { CreatePullRequestActionContext } from '../../api/gitlens'; import { executeActionCommand } from '../../commands'; import { configuration } from '../../configuration'; -import { BuiltInGitCommands, BuiltInGitConfiguration, Starred, WorkspaceState } from '../../constants'; +import { BuiltInGitCommands, BuiltInGitConfiguration, DocumentSchemes, Starred, WorkspaceState } from '../../constants'; import { Container } from '../../container'; import { Logger } from '../../logger'; import { Messages } from '../../messages'; +import { asRepoComparisonKey } from '../../repositories'; import { filterMap, groupByMap } from '../../system/array'; import { getFormatter } from '../../system/date'; import { gate } from '../../system/decorators/gate'; import { debug, log, logName } from '../../system/decorators/log'; -import { memoize } from '../../system/decorators/memoize'; import { debounce } from '../../system/function'; import { filter, join, some } from '../../system/iterable'; import { basename } from '../../system/path'; import { runGitCommandInTerminal } from '../../terminal'; import { GitProviderDescriptor } from '../gitProvider'; -import { GitUri } from '../gitUri'; import { RemoteProviderFactory, RemoteProviders } from '../remotes/factory'; import { RichRemoteProvider } from '../remotes/provider'; import { SearchPattern } from '../search'; @@ -183,7 +182,6 @@ export class Repository implements Disposable { readonly id: string; readonly index: number; readonly name: string; - readonly normalizedPath: string; private _branch: Promise | undefined; private readonly _disposable: Disposable; @@ -203,29 +201,36 @@ export class Repository implements Disposable { private readonly container: Container, private readonly onDidRepositoryChange: (repo: Repository, e: RepositoryChangeEvent) => void, public readonly provider: GitProviderDescriptor, - public readonly folder: WorkspaceFolder, - public readonly path: string, + public readonly folder: WorkspaceFolder | undefined, + public readonly uri: Uri, public readonly root: boolean, suspended: boolean, closed: boolean = false, ) { - const relativePath = container.git.getRelativePath(folder.uri, path); - if (root) { - // Check if the repository is not contained by a workspace folder - const repoFolder = workspace.getWorkspaceFolder(GitUri.fromRepoPath(path)); - if (repoFolder == null) { - this.formattedName = this.name = basename(path); + folder = workspace.getWorkspaceFolder(uri) ?? folder; + if (folder != null) { + this.name = folder.name; + + if (root) { + this.formattedName = this.name; } else { - this.formattedName = this.name = folder.name; + const relativePath = container.git.getRelativePath(folder.uri, uri); + this.formattedName = relativePath ? `${this.name} (${relativePath})` : this.name; } } else { - this.formattedName = relativePath ? `${folder.name} (${relativePath})` : folder.name; - this.name = folder.name; + this.name = basename(uri.path); + this.formattedName = this.name; + + // TODO@eamodio should we create a fake workspace folder? + // folder = { + // uri: uri, + // name: this.name, + // index: container.git.repositoryCount, + // }; } - this.index = folder.index; + this.index = folder?.index ?? container.git.repositoryCount; - this.normalizedPath = (path.endsWith('/') ? path : `${path}/`).toLowerCase(); - this.id = this.normalizedPath; + this.id = asRepoComparisonKey(uri); this._suspended = suspended; this._closed = closed; @@ -264,9 +269,8 @@ export class Repository implements Disposable { this._disposable.dispose(); } - @memoize() - get uri(): Uri { - return this.container.git.getAbsoluteUri(this.path); + get path(): string { + return this.uri.scheme === DocumentSchemes.File ? this.uri.fsPath : this.uri.toString(); } get etag(): number { @@ -279,8 +283,8 @@ export class Repository implements Disposable { } private onConfigurationChanged(e?: ConfigurationChangeEvent) { - if (configuration.changed(e, 'remotes', this.folder.uri)) { - this._providers = RemoteProviderFactory.loadProviders(configuration.get('remotes', this.folder.uri)); + if (configuration.changed(e, 'remotes', this.folder?.uri)) { + this._providers = RemoteProviderFactory.loadProviders(configuration.get('remotes', this.folder?.uri)); if (e != null) { this.resetCaches('remotes'); @@ -442,11 +446,7 @@ export class Repository implements Disposable { } containsUri(uri: Uri) { - if (GitUri.is(uri)) { - uri = uri.repoPath != null ? this.container.git.getAbsoluteUri(uri.repoPath) : uri.documentUri(); - } - - return this.folder === workspace.getWorkspaceFolder(uri); + return this === this.container.git.getRepository(uri); } @gate() @@ -513,8 +513,8 @@ export class Repository implements Disposable { return this.container.git.getBranches(this.path, options); } - getChangedFilesCount(sha?: string): Promise { - return this.container.git.getChangedFilesCount(this.path, sha); + getChangedFilesCount(ref?: string): Promise { + return this.container.git.getChangedFilesCount(this.path, ref); } getCommit(ref: string): Promise { @@ -561,7 +561,7 @@ export class Repository implements Disposable { async getRemotes(options: { filter?: (remote: GitRemote) => boolean; sort?: boolean } = {}): Promise { if (this._remotes == null) { if (this._providers == null) { - const remotesCfg = configuration.get('remotes', this.folder.uri); + const remotesCfg = configuration.get('remotes', this.folder?.uri); this._providers = RemoteProviderFactory.loadProviders(remotesCfg); } @@ -781,12 +781,12 @@ export class Repository implements Disposable { this.runTerminalCommand('reset', ...args); } - resetCaches(...cache: ('branches' | 'remotes')[]) { - if (cache.length === 0 || cache.includes('branches')) { + resetCaches(...affects: ('branches' | 'remotes')[]) { + if (affects.length === 0 || affects.includes('branches')) { this._branch = undefined; } - if (cache.length === 0 || cache.includes('remotes')) { + if (affects.length === 0 || affects.includes('remotes')) { this._remotes = undefined; this._remotesDisposable?.dispose(); this._remotesDisposable = undefined; diff --git a/src/git/remotes/github.ts b/src/git/remotes/github.ts index a814664..c7b3d7f 100644 --- a/src/git/remotes/github.ts +++ b/src/git/remotes/github.ts @@ -238,7 +238,7 @@ export class GitHubRemote extends RichRemoteProvider { const [owner, repo] = this.splitPath(); const { include, ...opts } = options ?? {}; - const GitHubPullRequest = (await import('../../github/github')).GitHubPullRequest; + const GitHubPullRequest = (await import('../../premium/github/github')).GitHubPullRequest; return (await Container.instance.github)?.getPullRequestForBranch(this, accessToken, owner, repo, branch, { ...opts, include: include?.map(s => GitHubPullRequest.toState(s)), diff --git a/src/github/github.ts b/src/github/github.ts deleted file mode 100644 index b13d70b..0000000 --- a/src/github/github.ts +++ /dev/null @@ -1,648 +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 fetch from '@env/fetch'; -import { isWeb } from '@env/platform'; -import { - AuthenticationError, - AuthenticationErrorReason, - ProviderRequestClientError, - ProviderRequestNotFoundError, -} from '../errors'; -import { - type DefaultBranch, - 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, Stopwatch } from '../system'; - -export class GitHubApi { - @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) { - return this.handleRequestError(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) { - return this.handleRequestError(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; - } | null; - } - - 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) { - return this.handleRequestError(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) { - return this.handleRequestError(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) { - return this.handleRequestError(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) { - return this.handleRequestError(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); - } - } - - 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) { - switch (ex.status) { - case 404: // Not found - case 410: // Gone - 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) { - // TODO@eamodio: Handle GitHub down errors - } - break; - default: - if (ex.status >= 400 && ex.status < 500) throw new ProviderRequestClientError(ex); - break; - } - } - - throw ex; - } - } - - private handleRequestError(ex: unknown | Error, cc: LogCorrelationContext | undefined, defaultValue: T): T { - if (ex instanceof ProviderRequestNotFoundError) return defaultValue; - - Logger.error(ex, cc); - debugger; - throw ex; - } -} - -interface GitHubIssueOrPullRequest { - type: IssueOrPullRequestType; - number: number; - createdAt: string; - closed: boolean; - closedAt: string | null; - title: string; - url: string; -} - -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'; - } -} diff --git a/src/premium/github/githubGitProvider.ts b/src/premium/github/githubGitProvider.ts new file mode 100644 index 0000000..c67b81d --- /dev/null +++ b/src/premium/github/githubGitProvider.ts @@ -0,0 +1,2083 @@ +/* eslint-disable @typescript-eslint/require-await */ +import { + authentication, + AuthenticationSession, + Disposable, + Event, + EventEmitter, + FileType, + Range, + Uri, + window, + workspace, + WorkspaceFolder, +} from 'vscode'; +import { encodeUtf8Hex } from '@env/hex'; +import { configuration } from '../../configuration'; +import { DocumentSchemes } from '../../constants'; +import type { Container } from '../../container'; +import { + AuthenticationError, + AuthenticationErrorReason, + OpenVirtualRepositoryError, + OpenVirtualRepositoryErrorReason, +} from '../../errors'; +import { + GitProvider, + GitProviderId, + PagedResult, + RepositoryCloseEvent, + RepositoryOpenEvent, + ScmRepository, +} from '../../git/gitProvider'; +import { isUriRegex } from '../../git/gitProviderService'; +import { GitUri } from '../../git/gitUri'; +import { + BranchSortOptions, + GitAuthor, + GitBlame, + GitBlameCommit, + GitBlameLine, + GitBlameLines, + GitBranch, + GitBranchReference, + GitCommitLine, + GitCommitType, + GitContributor, + GitDiff, + GitDiffFilter, + GitDiffHunkLine, + GitDiffShortStat, + GitFile, + GitFileIndexStatus, + GitLog, + GitLogCommit, + GitMergeStatus, + GitRebaseStatus, + GitReference, + GitReflog, + GitRemote, + GitRemoteType, + GitRevision, + GitStash, + GitStatus, + GitStatusFile, + GitTag, + GitTreeEntry, + GitUser, + 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 { debug, gate, Iterables, log } from '../../system'; +import { isAbsolute, isFolderGlob, normalizePath, relative } from '../../system/path'; +import { CharCode } from '../../system/string'; +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); + +// Since negative lookbehind isn't supported in all browsers, this leaves out the negative lookbehind condition `(?(); + 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>>(); + + constructor(private readonly container: Container) {} + + 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 (uri.scheme !== DocumentSchemes.Virtual) return []; + + try { + void (await this.ensureRepositoryContext(uri.toString())); + return [this.openRepository(undefined, uri, true)]; + } catch { + return []; + } + } + + 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, + ); + } + + async getOpenScmRepositories(): Promise { + return []; + } + + async getOrOpenScmRepository(_repoPath: string): Promise { + return undefined; + } + + canHandlePathOrUri(pathOrUri: string | Uri): string | undefined { + let scheme; + if (typeof pathOrUri === 'string') { + const match = isUriRegex.exec(pathOrUri); + if (match == null) return undefined; + + [, scheme] = match; + } else { + ({ scheme } = pathOrUri); + } + + if (!this.supportedSchemes.includes(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 (isUriRegex.test(base)) { + base = Uri.parse(base, true); + } else { + debugger; + throw new Error(`Base path '${base}' must be an uri string`); + } + } + + if (typeof pathOrUri === 'string' && !isUriRegex.test(pathOrUri) && !isAbsolute(pathOrUri)) { + return Uri.joinPath(base, 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 (isUriRegex.test(base)) { + base = Uri.parse(base, true); + } else { + debugger; + throw new Error(`Base path '${base}' must be an uri string`); + } + } + + let relativePath; + + // Convert the path to a Uri if it isn't one + if (typeof pathOrUri === 'string') { + if (isUriRegex.test(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 = relative(base.path.slice(1), pathOrUri.path.slice(1)); + return normalizePath(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 } | { fileName?: 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: DocumentSchemes.Virtual }); + return rootUri; + } catch (ex) { + 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 getBlameForFile(uri: GitUri): Promise { + const cc = Logger.getCorrelationContext(); + + 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.get(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.getBlameForFileCore(uri, doc, key, cc); + + if (doc.state != null) { + Logger.debug(cc, `Cache add: '${key}'`); + + const value: CachedBlame = { + item: promise as Promise, + }; + doc.state.set(key, value); + } + + return promise; + } + + private async getBlameForFileCore( + 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 file = this.getRelativePath(uri, remotehub.getProviderRootUri(uri)); + + // const sha = await this.resolveReferenceCore(uri.repoPath!, metadata, uri.sha); + // if (sha == null) return undefined; + + const ref = uri.sha ?? 'HEAD'; + const blame = await github.getBlame( + session?.accessToken, + metadata.repo.owner, + metadata.repo.name, + ref, + file, + ); + + const authors = new Map(); + const commits = new Map(); + const lines: GitCommitLine[] = []; + + for (const range of blame.ranges) { + const { viewer = session.account.label } = blame; + const name = viewer != null && range.commit.author.name === viewer ? 'You' : range.commit.author.name; + + let author = authors.get(range.commit.author.email); + if (author == null) { + author = { + name: range.commit.author.name, + lineCount: 0, + // email: range.commit.author.email, + // date: range.commit.author.date, + }; + authors.set(name, author); + } + + author.lineCount += range.endingLine - range.startingLine + 1; + + let commit = commits.get(range.commit.oid); + if (commit == null) { + commit = new GitBlameCommit( + uri.repoPath!, + range.commit.oid, + author.name, + range.commit.author.email, + new Date(range.commit.author.date), + new Date(range.commit.committer.date), + range.commit.message, + file, + undefined, + range.commit.parents.nodes[0]?.oid, + undefined, + [], + ); + + commits.set(range.commit.oid, commit); + } + + for (let i = range.startingLine; i <= range.endingLine; i++) { + const line: GitCommitLine = { + sha: range.commit.oid, + line: i, + originalLine: 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.set(key, value); + + document.setBlameFailure(); + + return emptyPromise as Promise; + } + + return undefined; + } + } + + @log({ args: { 1: '' } }) + async getBlameForFileContents(uri: GitUri, _contents: string): Promise { + return this.getBlameForFile(uri); + } + + @gate() + @log() + async getBlameForLine( + uri: GitUri, + editorLine: number, + _options?: { forceSingleLine?: boolean }, + ): Promise { + const blame = await this.getBlameForFile(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)!; + return { + author: { ...author, lineCount: commit.lines.length }, + commit: commit, + line: blameLine, + }; + } + + @log({ args: { 2: '' } }) + async getBlameForLineContents( + uri: GitUri, + editorLine: number, + _contents: string, + _options?: { forceSingleLine?: boolean }, + ): Promise { + return this.getBlameForLine(uri, editorLine); + } + + @log() + async getBlameForRange(uri: GitUri, range: Range): Promise { + const blame = await this.getBlameForFile(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.getBlameForFileContents(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); + if (author == null) { + author = { + name: commit.author, + 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 { + return undefined; + } + + @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 name = viewer != null && commit.author.name === viewer ? 'You' : commit.author.name; + + const { files } = commit; + + return new GitLogCommit( + GitCommitType.Log, + repoPath, + commit.oid, + name, + commit.author.email, + new Date(commit.author.date), + new Date(commit.committer.date), + commit.message, + '', + files?.map(f => ({ + status: fromCommitFileStatus(f.status) ?? GitFileIndexStatus.Modified, + repoPath: repoPath, + fileName: f.filename ?? '', + originalFileName: f.previous_filename, + })) ?? [], + undefined, + undefined, + commit.parents.nodes[0]?.oid, + undefined, + undefined, + commit.parents.nodes.map(p => p.oid), + undefined, + ); + } catch (ex) { + Logger.error(ex, cc); + debugger; + return undefined; + } + } + + @log() + async getCommitBranches( + _repoPath: string, + _ref: string, + _options?: { mode?: 'contains' | 'pointsAt'; remotes?: boolean }, + ): Promise { + return []; + } + + @log() + async getCommitCount(_repoPath: string, _ref: string): Promise { + return undefined; + } + + @log() + async getCommitForFile( + repoPath: string | undefined, + uri: Uri, + options?: { ref?: string; firstIfNotFound?: boolean; range?: Range; reverse?: boolean }, + ): 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 commit = await github.getCommitForFile( + session?.accessToken, + metadata.repo.owner, + metadata.repo.name, + options?.ref ?? 'HEAD', + file, + ); + if (commit == null) return undefined; + + const { viewer = session.account.label } = commit; + const name = viewer != null && commit.author.name === viewer ? 'You' : commit.author.name; + + return new GitLogCommit( + GitCommitType.LogFile, + repoPath, + commit.oid, + name, + commit.author.email, + new Date(commit.author.date), + new Date(commit.committer.date), + commit.message, + file, + commit.files?.map(f => ({ + status: fromCommitFileStatus(f.status) ?? GitFileIndexStatus.Modified, + repoPath: repoPath, + fileName: f.filename ?? '', + originalFileName: f.previous_filename, + })) ?? [ + { + fileName: file, + status: GitFileIndexStatus.Modified, + repoPath: repoPath, + }, + ], + GitFileIndexStatus.Modified, + undefined, + commit.parents.nodes[0]?.oid, + undefined, + undefined, + commit.parents.nodes.map(p => p.oid), + undefined, + ); + } catch (ex) { + Logger.error(ex, cc); + debugger; + return undefined; + } + } + + @log() + async getOldestUnpushedRefForFile(_repoPath: string, _uri: Uri): Promise { + return undefined; + } + + @log() + async getContributors( + _repoPath: string, + _options?: { all?: boolean; ref?: string; stats?: boolean }, + ): Promise { + 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, + _originalFileName?: string, + ): Promise { + return undefined; + } + + @log({ + args: { + 1: _contents => '', + }, + }) + async getDiffForFileContents( + _uri: GitUri, + _ref: string, + _contents: string, + _originalFileName?: string, + ): Promise { + return undefined; + } + + @log() + async getDiffForLine( + _uri: GitUri, + _editorLine: number, + _ref1: string | undefined, + _ref2?: string, + _originalFileName?: 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(this.getRelativePath(uri, repoPath)); + } + + async getLastFetchedTimestamp(_repoPath: string): Promise { + return undefined; + } + + @log() + async getLog( + repoPath: string, + options?: { + all?: boolean; + authors?: string[]; + cursor?: string; + limit?: number; + merges?: boolean; + ordering?: string | null; + ref?: string; + reverse?: boolean; + since?: string; + }, + ): Promise { + if (repoPath == null) return undefined; + + const cc = Logger.getCorrelationContext(); + + const limit = options?.limit ?? this.container.config.advanced.maxListItems ?? 0; + + try { + const { metadata, github, session } = await this.ensureRepositoryContext(repoPath); + + const ref = options?.ref ?? 'HEAD'; + const result = await github.getCommits(session?.accessToken, metadata.repo.owner, metadata.repo.name, ref, { + limit: limit, + // TODO@eamodio this isn't quite right + cursor: options?.cursor ?? options?.since, + }); + + const authors = new Map(); + const commits = new Map(); + + const { viewer = session.account.label } = result; + for (const commit of result.values) { + const name = viewer != null && commit.author.name === viewer ? 'You' : commit.author.name; + + let author = authors.get(commit.author.email!); + if (author == null) { + author = { + name: commit.author.name, + lineCount: 0, + }; + authors.set(name, author); + } + + let c = commits.get(commit.oid); + if (c == null) { + c = new GitLogCommit( + GitCommitType.Log, + repoPath, + commit.oid, + name, + commit.author.email, + new Date(commit.author.date), + new Date(commit.committer.date), + commit.message, + '', + [], + undefined, + undefined, + commit.parents.nodes[0]?.oid, + undefined, + undefined, + commit.parents.nodes.map(p => p.oid), + undefined, + ); + commits.set(commit.oid, c); + } + } + + const log: GitLog = { + repoPath: repoPath, + authors: authors, + 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?: string[]; + cursor?: string; + limit?: number; + merges?: boolean; + ordering?: string | null; + ref?: string; + reverse?: boolean; + 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?: string[]; + limit?: number; + merges?: boolean; + ordering?: string | null; + ref?: string; + 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 && Iterables.some(log.commits.values(), c => c.ref === moreUntil)) { + return log; + } + + moreLimit = moreLimit ?? this.container.config.advanced.maxSearchItems ?? 0; + + // // 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 }; + + // Merge authors + const authors = new Map([...log.authors]); + for (const [key, addAuthor] of moreLog.authors) { + const author = authors.get(key); + if (author == null) { + authors.set(key, addAuthor); + } else { + author.lineCount += addAuthor.lineCount; + } + } + + const commits = new Map([...log.commits, ...moreLog.commits]); + + const mergedLog: GitLog = { + repoPath: log.repoPath, + authors: authors, + 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: moreLog.query, + }; + mergedLog.more = this.getLogMoreFn(mergedLog, options); + + return mergedLog; + }; + } + + @log() + async getLogForSearch( + _repoPath: string, + _search: SearchPattern, + _options?: { limit?: number; ordering?: string | null; skip?: number }, + ): Promise { + return undefined; + } + + @log() + async getLogForFile( + repoPath: string | undefined, + fileName: string, + 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 && repoPath === normalizePath(fileName)) { + throw new Error(`File name cannot match the repository path; fileName=${fileName}`); + } + + const cc = Logger.getCorrelationContext(); + + options = { reverse: false, ...options }; + + 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 = options.limit == null ? this.container.config.advanced.maxListItems || 0 : 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}`; + } + + const doc = await this.container.tracker.getOrAdd(GitUri.fromFile(fileName, repoPath!, options.ref)); + if (options.range == null) { + if (doc.state != null) { + const cachedLog = doc.state.get(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.get( + `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 authors = new Map(); + const commits = new Map( + Iterables.filterMap<[string, GitLogCommit], [string, GitLogCommit]>( + 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; + } + + authors.set(c.author, log.authors.get(c.author)!); + return [ref, c]; + }, + ), + ); + + const opts = { ...options }; + log = { + ...log, + limit: options.limit, + count: commits.size, + commits: commits, + authors: authors, + query: (limit: number | undefined) => + this.getLogForFile(repoPath, fileName, { ...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, fileName, 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.set(key, value); + } + + return promise; + } + + private async getLogForFileCore( + repoPath: string | undefined, + fileName: 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 = options?.limit ?? this.container.config.advanced.maxListItems ?? 0; + + try { + const context = await this.ensureRepositoryContext(repoPath); + if (context == null) return undefined; + const { metadata, github, remotehub, session } = context; + + const uri = this.getAbsoluteUri(fileName, repoPath); + const file = 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 ?? 'HEAD'; + const result = await github.getCommits(session?.accessToken, metadata.repo.owner, metadata.repo.name, ref, { + path: file, + limit: limit, + // TODO@eamodio this isn't quite right + cursor: options?.cursor ?? options?.since, + }); + + const authors = new Map(); + const commits = new Map(); + + const { viewer = session.account.label } = result; + for (const commit of result.values) { + const name = viewer != null && commit.author.name === viewer ? 'You' : commit.author.name; + + let author = authors.get(commit.author.email!); + if (author == null) { + author = { + name: commit.author.name, + lineCount: 0, + }; + authors.set(name, author); + } + + let c = commits.get(commit.oid); + if (c == null) { + c = new GitLogCommit( + isFolderGlob(file) ? GitCommitType.Log : GitCommitType.LogFile, + repoPath, + commit.oid, + name, + commit.author.email, + new Date(commit.author.date), + new Date(commit.committer.date), + commit.message, + file, + [ + { + fileName: file, + status: GitFileIndexStatus.Modified, + repoPath: repoPath, + }, + ], + GitFileIndexStatus.Modified, + undefined, + commit.parents.nodes[0]?.oid, + undefined, + undefined, + commit.parents.nodes.map(p => p.oid), + undefined, + ); + commits.set(commit.oid, c); + } + } + + const log: GitLog = { + repoPath: repoPath, + authors: authors, + 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, fileName, { ...options, limit: limit }), + }; + + if (log.hasMore) { + log.more = this.getLogForFileMoreFn(log, fileName, 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.set(key, value); + + return emptyPromise as Promise; + } + + return undefined; + } + } + + private getLogForFileMoreFn( + log: GitLog, + fileName: 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 && Iterables.some(log.commits.values(), c => c.ref === moreUntil)) { + return log; + } + + moreLimit = moreLimit ?? this.container.config.advanced.maxSearchItems ?? 0; + + // const ref = Iterables.last(log.commits.values())?.ref; + const moreLog = await this.getLogForFile(log.repoPath, fileName, { + ...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 }; + + // Merge authors + const authors = new Map([...log.authors]); + for (const [key, addAuthor] of moreLog.authors) { + const author = authors.get(key); + if (author == null) { + authors.set(key, addAuthor); + } else { + author.lineCount += addAuthor.lineCount; + } + } + + const commits = new Map([...log.commits, ...moreLog.commits]); + + const mergedLog: GitLog = { + repoPath: log.repoPath, + authors: authors, + 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: moreLog.query, + }; + + // if (options.renames) { + // const renamed = Iterables.find( + // moreLog.commits.values(), + // c => Boolean(c.originalFileName) && c.originalFileName !== fileName, + // ); + // if (renamed != null) { + // fileName = renamed.originalFileName!; + // } + // } + + mergedLog.more = this.getLogForFileMoreFn(mergedLog, fileName, 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 getNextDiffUris( + _repoPath: string, + _uri: Uri, + _ref: string | undefined, + _skip: number = 0, + ): Promise<{ current: GitUri; next: GitUri | undefined; deleted?: boolean } | undefined> { + return undefined; + } + + @log() + async getNextUri( + _repoPath: string, + _uri: Uri, + _ref?: string, + _skip: number = 0, + // editorLine?: number + ): Promise { + return undefined; + } + + @log() + async getPreviousDiffUris( + repoPath: string, + uri: Uri, + ref: string | undefined, + _skip: number = 0, + firstParent: boolean = false, + ): Promise<{ current: GitUri; previous: GitUri | undefined } | undefined> { + if (ref === GitRevision.deletedOrMissing) return undefined; + + const path = this.getRelativePath(uri, repoPath); + return { + current: GitUri.fromFile(path, repoPath, undefined), + previous: await this.getPreviousUri(repoPath, uri, ref, 0, undefined, firstParent), + }; + // return undefined; + } + + @log() + async getPreviousLineDiffUris( + _repoPath: string, + _uri: Uri, + _editorLine: number, + _ref: string | undefined, + _skip: number = 0, + ): Promise<{ current: GitUri; previous: GitUri | undefined; line: number } | undefined> { + return undefined; + } + + @log() + async getPreviousUri( + repoPath: string, + uri: Uri, + ref?: string, + _skip: number = 0, + _editorLine?: number, + _firstParent: boolean = false, + ): Promise { + if (ref === GitRevision.deletedOrMissing) return undefined; + + const commit = await this.getCommitForFile(repoPath, uri, { ref: `${ref ?? 'HEAD'}^` }); + if (commit == null) return undefined; + + return GitUri.fromCommit(commit); + } + + @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, _path: string): Promise { + return undefined; + } + + @log() + async getStatusForFiles(_repoPath: string, _pathOrGlob: string): 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; + } + + isTrackable(uri: Uri): boolean { + return this.supportedSchemes.includes(uri.scheme); + } + + @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 || GitRevision.isUncommitted(ref)) { + return ref; + } + + let path; + if (pathOrUri == null) { + if (GitRevision.isSha(ref) || !GitRevision.isShaLike(ref) || ref.endsWith('^3')) return ref; + } else { + path = normalizePath(this.getRelativePath(pathOrUri, repoPath)); + } + + 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, + path, + ); + + if (resolved != null) return resolved; + + return path ? 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, + ): Promise<{ github: GitHubApi; metadata: Metadata; remotehub: RemoteHubApi; session: AuthenticationSession }> { + const uri = Uri.parse(repoPath, true); + if (!/^github\+?/.test(uri.authority)) { + throw new OpenVirtualRepositoryError(repoPath, OpenVirtualRepositoryErrorReason.NotAGitHubRepository); + } + + let remotehub = this._remotehub; + if (remotehub == null) { + try { + remotehub = await this.ensureRemoteHubApi(); + } catch (ex) { + 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.container.github, 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 }; + } + + /** 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(): Promise { + if (this._sessionPromise == null) { + async function getSession(): Promise { + try { + return await authentication.getSession('github', ['repo'], { + 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: DocumentSchemes.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 !== DocumentSchemes.Virtual ? uri : uri.with({ scheme: DocumentSchemes.GitHub }); + } + + return this._remotehub.getProviderUri(uri); + } + + 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 new file mode 100644 index 0000000..f6aa5a7 --- /dev/null +++ b/src/premium/remotehub.ts @@ -0,0 +1,88 @@ +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'); + 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/repositories.ts b/src/repositories.ts index 3195467..f6093c3 100644 --- a/src/repositories.ts +++ b/src/repositories.ts @@ -34,13 +34,16 @@ export function normalizeRepoUri(uri: Uri): { path: string; ignoreCase: boolean return { path: path, ignoreCase: !isLinux }; case DocumentSchemes.Virtual: - case DocumentSchemes.GitHub: + case DocumentSchemes.GitHub: { path = uri.path; if (path.charCodeAt(path.length - 1) === slash) { path = path.slice(0, -1); } - return { path: uri.authority ? `${uri.authority}${path}` : path.slice(1), ignoreCase: false }; + // TODO@eamodio Revisit this, as we can't strip off the authority details (e.g. metadata) ultimately (since you in theory could have a workspace with more than 1 virtual repo which are the same except for the authority) + const authority = uri.authority?.split('+', 1)[0]; + return { path: authority ? `${authority}${path}` : path.slice(1), ignoreCase: false }; + } default: path = uri.path; if (path.charCodeAt(path.length - 1) === slash) { diff --git a/src/system/path.ts b/src/system/path.ts index 7890ea9..48aa18c 100644 --- a/src/system/path.ts +++ b/src/system/path.ts @@ -1,6 +1,7 @@ import { basename, dirname } from 'path'; import { Uri } from 'vscode'; import { isLinux, isWindows } from '@env/platform'; +import { DocumentSchemes } from '../constants'; // TODO@eamodio don't import from string here since it will break the tests because of ESM dependencies // import { CharCode } from './string'; @@ -9,6 +10,7 @@ export { basename, dirname, extname, isAbsolute, join as joinPaths } from 'path' const driveLetterNormalizeRegex = /(?<=^\/?)([A-Z])(?=:\/)/; const pathNormalizeRegex = /\\/g; const slash = 47; //slash; +const uriSchemeRegex = /^(\w[\w\d+.-]{1,}?):\/\//; export function commonBase(s1: string, s2: string, delimiter: string, ignoreCase?: boolean): string | undefined { const index = commonBaseIndex(s1, s2, delimiter, ignoreCase); @@ -37,6 +39,10 @@ export function commonBaseIndex(s1: string, s2: string, delimiter: string, ignor return index; } +export function getBestPath(uri: Uri): string { + return uri.scheme === DocumentSchemes.File ? uri.fsPath : uri.path; +} + export function isChild(path: string, base: string | Uri): boolean; export function isChild(uri: Uri, base: string | Uri): boolean; export function isChild(pathOrUri: string | Uri, base: string | Uri): boolean { @@ -120,8 +126,8 @@ export function normalizePath(path: string): string { } export function relative(from: string, to: string, ignoreCase?: boolean): string { - from = normalizePath(from); - to = normalizePath(to); + from = uriSchemeRegex.test(from) ? Uri.parse(from, true).path : normalizePath(from); + to = uriSchemeRegex.test(to) ? Uri.parse(to, true).path : normalizePath(to); const index = commonBaseIndex(`${to}/`, `${from}/`, '/', ignoreCase); return index > 0 ? to.substring(index + 1) : to; diff --git a/src/views/nodes/fileHistoryNode.ts b/src/views/nodes/fileHistoryNode.ts index 84e9c00..ea3f85b 100644 --- a/src/views/nodes/fileHistoryNode.ts +++ b/src/views/nodes/fileHistoryNode.ts @@ -11,7 +11,11 @@ import { RepositoryFileSystemChangeEvent, } from '../../git/models'; import { Logger } from '../../logger'; -import { Arrays, debug, gate, Iterables, memoize } from '../../system'; +import { uniqueBy } from '../../system/array'; +import { gate } from '../../system/decorators/gate'; +import { debug } from '../../system/decorators/log'; +import { memoize } from '../../system/decorators/memoize'; +import { filterMap, flatMap } from '../../system/iterable'; import { basename, joinPaths } from '../../system/path'; import { FileHistoryView } from '../fileHistoryView'; import { CommitNode } from './commitNode'; @@ -75,8 +79,8 @@ export class FileHistoryNode extends SubscribeableViewNode impl if (fileStatuses?.length) { if (this.folder) { - const commits = Arrays.uniqueBy( - [...Iterables.flatMap(fileStatuses, f => f.toPsuedoCommits(currentUser))], + const commits = uniqueBy( + [...flatMap(fileStatuses, f => f.toPsuedoCommits(currentUser))], c => c.sha, (original, c) => void original.files.push(...c.files), ); @@ -97,7 +101,7 @@ export class FileHistoryNode extends SubscribeableViewNode impl if (log != null) { children.push( ...insertDateMarkers( - Iterables.map(log.commits.values(), c => + filterMap(log.commits.values(), c => this.folder ? new CommitNode( this.view as any, @@ -110,11 +114,13 @@ export class FileHistoryNode extends SubscribeableViewNode impl expand: false, }, ) - : new FileRevisionAsCommitNode(this.view, this, c.files[0], c, { + : c.files.length + ? new FileRevisionAsCommitNode(this.view, this, c.files[0], c, { branch: this.branch, getBranchAndTagTips: getBranchAndTagTips, unpublished: unpublishedCommits?.has(c.ref), - }), + }) + : undefined, ), this, ), diff --git a/src/views/nodes/fileHistoryTrackerNode.ts b/src/views/nodes/fileHistoryTrackerNode.ts index a48bc3d..f74570e 100644 --- a/src/views/nodes/fileHistoryTrackerNode.ts +++ b/src/views/nodes/fileHistoryTrackerNode.ts @@ -1,11 +1,13 @@ import { Disposable, FileType, TextEditor, TreeItem, TreeItemCollapsibleState, window, workspace } from 'vscode'; import { UriComparer } from '../../comparers'; -import { ContextKeys, setContext } from '../../constants'; +import { ContextKeys, DocumentSchemes, setContext } from '../../constants'; import { GitCommitish, GitUri } from '../../git/gitUri'; import { GitReference, GitRevision } from '../../git/models'; import { Logger } from '../../logger'; import { ReferencePicker } from '../../quickpicks'; -import { debug, Functions, gate, log } from '../../system'; +import { gate } from '../../system/decorators/gate'; +import { debug, log } from '../../system/decorators/log'; +import { debounce, Deferrable } from '../../system/function'; import { FileHistoryView } from '../fileHistoryView'; import { FileHistoryNode } from './fileHistoryNode'; import { ContextValues, SubscribeableViewNode, ViewNode } from './viewNode'; @@ -219,17 +221,30 @@ export class FileHistoryTrackerNode extends SubscribeableViewNode Promise> | undefined; @debug({ args: false }) - private onActiveEditorChanged(_editor: TextEditor | undefined) { + private onActiveEditorChanged(editor: TextEditor | undefined) { + // If we are losing the active editor, give more time before assuming its really gone + // For virtual repositories the active editor event takes a while to fire + // Ultimately we need to be using the upcoming Tabs api to avoid this + if ( + editor == null && + (this._uri?.scheme === DocumentSchemes.Virtual || this._uri?.scheme === DocumentSchemes.GitHub) + ) { + if (this._triggerChangeDebounced == null) { + this._triggerChangeDebounced = debounce(() => this.triggerChange(), 1500); + } + + void this._triggerChangeDebounced(); + return; + } void this.triggerChange(); } diff --git a/src/views/nodes/lineHistoryNode.ts b/src/views/nodes/lineHistoryNode.ts index f7c73de..5ebfe8c 100644 --- a/src/views/nodes/lineHistoryNode.ts +++ b/src/views/nodes/lineHistoryNode.ts @@ -14,7 +14,10 @@ import { RepositoryFileSystemChangeEvent, } from '../../git/models'; import { Logger } from '../../logger'; -import { debug, gate, Iterables, memoize } from '../../system'; +import { gate } from '../../system/decorators/gate'; +import { debug } from '../../system/decorators/log'; +import { memoize } from '../../system/decorators/memoize'; +import { filterMap } from '../../system/iterable'; import { FileHistoryView } from '../fileHistoryView'; import { LineHistoryView } from '../lineHistoryView'; import { LoadMoreNode, MessageNode } from './common'; @@ -203,15 +206,15 @@ export class LineHistoryNode if (log != null) { children.push( ...insertDateMarkers( - Iterables.filterMap( - log.commits.values(), - c => - new FileRevisionAsCommitNode(this.view, this, c.files[0], c, { - branch: this.branch, - getBranchAndTagTips: getBranchAndTagTips, - selection: selection, - unpublished: unpublishedCommits?.has(c.ref), - }), + filterMap(log.commits.values(), c => + c.files.length + ? new FileRevisionAsCommitNode(this.view, this, c.files[0], c, { + branch: this.branch, + getBranchAndTagTips: getBranchAndTagTips, + selection: selection, + unpublished: unpublishedCommits?.has(c.ref), + }) + : undefined, ), this, ), diff --git a/src/views/nodes/repositoriesNode.ts b/src/views/nodes/repositoriesNode.ts index cfc9e93..fe28c9a 100644 --- a/src/views/nodes/repositoriesNode.ts +++ b/src/views/nodes/repositoriesNode.ts @@ -2,7 +2,9 @@ import { Disposable, TextEditor, TreeItem, TreeItemCollapsibleState, window } fr import { RepositoriesChangeEvent } from '../../git/gitProviderService'; import { GitUri } from '../../git/gitUri'; import { Logger } from '../../logger'; -import { debug, Functions, gate } from '../../system'; +import { gate } from '../../system/decorators/gate'; +import { debug } from '../../system/decorators/log'; +import { debounce } from '../../system/function'; import { RepositoriesView } from '../repositoriesView'; import { MessageNode } from './common'; import { RepositoryNode } from './repositoryNode'; @@ -23,7 +25,7 @@ export class RepositoriesNode extends SubscribeableViewNode { @debug() private resetChildren() { - if (this._children === undefined) return; + if (this._children == null) return; for (const child of this._children) { if (child instanceof RepositoryNode) { @@ -34,7 +36,7 @@ export class RepositoriesNode extends SubscribeableViewNode { } getChildren(): ViewNode[] { - if (this._children === undefined) { + if (this._children == null) { const repositories = this.view.container.git.openRepositories; if (repositories.length === 0) return [new MessageNode(this.view, this, 'No repositories could be found.')]; @@ -54,7 +56,7 @@ export class RepositoriesNode extends SubscribeableViewNode { @gate() @debug() override async refresh(reset: boolean = false) { - if (this._children === undefined) return; + if (this._children == null) return; if (reset) { this.resetChildren(); @@ -65,7 +67,7 @@ export class RepositoriesNode extends SubscribeableViewNode { } const repositories = this.view.container.git.openRepositories; - if (repositories.length === 0 && (this._children === undefined || this._children.length === 0)) return; + if (repositories.length === 0 && (this._children == null || this._children.length === 0)) return; if (repositories.length === 0) { this._children = [new MessageNode(this.view, this, 'No repositories could be found.')]; @@ -74,9 +76,9 @@ export class RepositoriesNode extends SubscribeableViewNode { const children = []; for (const repo of repositories) { - const normalizedPath = repo.normalizedPath; - const child = (this._children as RepositoryNode[]).find(c => c.repo.normalizedPath === normalizedPath); - if (child !== undefined) { + const id = repo.id; + const child = (this._children as RepositoryNode[]).find(c => c.repo.id === id); + if (child != null) { children.push(child); void child.refresh(); } else { @@ -100,9 +102,7 @@ export class RepositoriesNode extends SubscribeableViewNode { const subscriptions = [this.view.container.git.onDidChangeRepositories(this.onRepositoriesChanged, this)]; if (this.view.config.autoReveal) { - subscriptions.push( - window.onDidChangeActiveTextEditor(Functions.debounce(this.onActiveEditorChanged, 500), this), - ); + subscriptions.push(window.onDidChangeActiveTextEditor(debounce(this.onActiveEditorChanged, 500), this)); } return Disposable.from(...subscriptions); @@ -114,7 +114,7 @@ export class RepositoriesNode extends SubscribeableViewNode { @debug({ args: false }) private onActiveEditorChanged(editor: TextEditor | undefined) { - if (editor == null || this._children === undefined || this._children.length === 1) { + if (editor == null || this._children == null || this._children.length === 1) { return; } @@ -123,11 +123,11 @@ export class RepositoriesNode extends SubscribeableViewNode { const node = this._children.find(n => n instanceof RepositoryNode && n.repo.containsUri(uri)) as | RepositoryNode | undefined; - if (node === undefined) return; + if (node == null) return; // Check to see if this repo has a descendent that is already selected let parent = this.view.selection.length === 0 ? undefined : this.view.selection[0]; - while (parent !== undefined) { + while (parent != null) { if (parent === node) return; parent = parent.getParent(); diff --git a/src/vsls/guest.ts b/src/vsls/guest.ts index 265bf5f..94b940e 100644 --- a/src/vsls/guest.ts +++ b/src/vsls/guest.ts @@ -1,4 +1,4 @@ -import { CancellationToken, Disposable, window, WorkspaceFolder } from 'vscode'; +import { CancellationToken, Disposable, Uri, window, WorkspaceFolder } from 'vscode'; import type { LiveShare, SharedServiceProxy } from '../@types/vsls'; import { Container } from '../container'; import { GitCommandOptions } from '../git/commandOptions'; @@ -80,7 +80,7 @@ export class VslsGuestService implements Disposable { // TODO@eamodio add live share provider undefined!, folder, - r.path, + Uri.parse(r.uri), r.root, !window.state.focused, r.closed, diff --git a/src/vsls/host.ts b/src/vsls/host.ts index c47e1ed..8da5e3d 100644 --- a/src/vsls/host.ts +++ b/src/vsls/host.ts @@ -216,12 +216,12 @@ export class VslsHostService implements Disposable { const repos = [ ...filterMap(this.container.git.repositories, r => { - if (!r.normalizedPath.startsWith(normalized)) return undefined; + if (!r.id.startsWith(normalized)) return undefined; - const vslsUri = this.convertLocalUriToShared(r.folder.uri); + const vslsUri = this.convertLocalUriToShared(r.folder?.uri ?? r.uri); return { folderUri: vslsUri.toString(true), - path: vslsUri.path, + uri: vslsUri.toString(), root: r.root, closed: r.closed, }; diff --git a/src/vsls/protocol.ts b/src/vsls/protocol.ts index da9227e..ba606cd 100644 --- a/src/vsls/protocol.ts +++ b/src/vsls/protocol.ts @@ -19,7 +19,7 @@ export const GitCommandRequestType = new RequestType