From 5271e14eb2710fbc36b2e320be8c546ea9dc1722 Mon Sep 17 00:00:00 2001 From: Eric Amodio Date: Sun, 2 Aug 2020 18:16:28 -0400 Subject: [PATCH] Adds open file from remote command --- package.json | 5 +++ src/commands.ts | 1 + src/commands/common.ts | 1 + src/commands/openFileFromRemote.ts | 68 ++++++++++++++++++++++++++++++++++ src/git/gitService.ts | 14 +++++++ src/git/models/repository.ts | 5 +++ src/git/remotes/azure-devops.ts | 43 ++++++++++++++++++++-- src/git/remotes/bitbucket-server.ts | 73 +++++++++++++++++++++++++++++++++++-- src/git/remotes/bitbucket.ts | 73 +++++++++++++++++++++++++++++++++++-- src/git/remotes/custom.ts | 12 +++++- src/git/remotes/github.ts | 66 +++++++++++++++++++++++++++++++++ src/git/remotes/gitlab.ts | 73 +++++++++++++++++++++++++++++++++++-- src/git/remotes/provider.ts | 7 ++++ 13 files changed, 427 insertions(+), 14 deletions(-) create mode 100644 src/commands/openFileFromRemote.ts diff --git a/package.json b/package.json index ad701ff..b1bc8af 100644 --- a/package.json +++ b/package.json @@ -2528,6 +2528,11 @@ "icon": "$(globe)" }, { + "command": "gitlens.openFileFromRemote", + "title": "Open File From Remote", + "category": "GitLens" + }, + { "command": "gitlens.openFileInRemote", "title": "Open File on Remote", "category": "GitLens", diff --git a/src/commands.ts b/src/commands.ts index e389a6a..e3d1629 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -24,6 +24,7 @@ export * from './commands/openBranchesOnRemote'; export * from './commands/openBranchOnRemote'; export * from './commands/openChangedFiles'; export * from './commands/openCommitOnRemote'; +export * from './commands/openFileFromRemote'; export * from './commands/openFileOnRemote'; export * from './commands/openFileAtRevision'; export * from './commands/openFileAtRevisionFrom'; diff --git a/src/commands/common.ts b/src/commands/common.ts index 08157c2..8a4768f 100644 --- a/src/commands/common.ts +++ b/src/commands/common.ts @@ -59,6 +59,7 @@ export enum Commands { OpenBranchesInRemote = 'gitlens.openBranchesInRemote', OpenBranchInRemote = 'gitlens.openBranchInRemote', OpenCommitInRemote = 'gitlens.openCommitInRemote', + OpenFileFromRemote = 'gitlens.openFileFromRemote', OpenFileInRemote = 'gitlens.openFileInRemote', OpenFileAtRevision = 'gitlens.openFileRevision', OpenFileAtRevisionFrom = 'gitlens.openFileRevisionFrom', diff --git a/src/commands/openFileFromRemote.ts b/src/commands/openFileFromRemote.ts new file mode 100644 index 0000000..a27275a --- /dev/null +++ b/src/commands/openFileFromRemote.ts @@ -0,0 +1,68 @@ +'use strict'; +import { env, Range, Uri, window } from 'vscode'; +import { command, Command, Commands, openEditor } from './common'; +import { Container } from '../container'; + +@command() +export class OpenFileFromRemoteCommand extends Command { + constructor() { + super(Commands.OpenFileFromRemote); + } + + async execute() { + let clipboard: string | undefined = await env.clipboard.readText(); + try { + Uri.parse(clipboard, true); + } catch { + clipboard = undefined; + } + + const url = await window.showInputBox({ + prompt: 'Enter a remote file url to open', + placeHolder: 'Remote file url', + value: clipboard, + ignoreFocusOut: true, + }); + if (url == null || url.length === 0) return; + + let local = await Container.git.getLocalInfoFromRemoteUri(Uri.parse(url)); + if (local == null) { + local = await Container.git.getLocalInfoFromRemoteUri(Uri.parse(url), { validate: false }); + if (local == null) { + void window.showWarningMessage('Unable to parse the provided remote url.'); + + return; + } + + const confirm = 'Open File...'; + const pick = await window.showWarningMessage( + 'Unable to find a workspace folder that matches the provided remote url.', + confirm, + ); + if (pick !== confirm) return; + } + + let selection; + if (local.startLine) { + if (local.endLine) { + selection = new Range(local.startLine - 1, 0, local.endLine, 0); + } else { + selection = new Range(local.startLine - 1, 0, local.startLine - 1, 0); + } + } + + try { + await openEditor(local.uri, { selection: selection, rethrow: true }); + } catch { + const uris = await window.showOpenDialog({ + title: 'Open local file', + defaultUri: local.uri, + canSelectMany: false, + canSelectFolders: false, + }); + if (uris == null || uris.length === 0) return; + + await openEditor(uris[0]); + } + } +} diff --git a/src/git/gitService.ts b/src/git/gitService.ts index 9d668bf..15d1002 100644 --- a/src/git/gitService.ts +++ b/src/git/gitService.ts @@ -2843,6 +2843,20 @@ export class GitService implements Disposable { return repo; } + async getLocalInfoFromRemoteUri( + uri: Uri, + options?: { validate?: boolean }, + ): Promise<{ uri: Uri; startLine?: number; endLine?: number } | undefined> { + for (const repo of await this.getRepositories()) { + for (const remote of await repo.getRemotes()) { + const local = await remote?.provider?.getLocalInfoFromRemoteUri(repo, uri, options); + if (local != null) return local; + } + } + + return undefined; + } + async getRepositoryCount(): Promise { const repositoryTree = await this.getRepositoryTree(); return repositoryTree.count(); diff --git a/src/git/models/repository.ts b/src/git/models/repository.ts index 2c398ee..52fcac3 100644 --- a/src/git/models/repository.ts +++ b/src/git/models/repository.ts @@ -580,6 +580,11 @@ export class Repository implements Disposable { } } + toAbsoluteUri(path: string, options?: { validate?: boolean }): Uri | undefined { + const uri = Uri.joinPath(GitUri.file(this.path), path); + return !(options?.validate ?? true) || this.containsUri(uri) ? uri : undefined; + } + unstar() { return this.updateStarred(false); } diff --git a/src/git/remotes/azure-devops.ts b/src/git/remotes/azure-devops.ts index 6a30785..e6ac1f9 100644 --- a/src/git/remotes/azure-devops.ts +++ b/src/git/remotes/azure-devops.ts @@ -1,8 +1,9 @@ 'use strict'; -import { Range } from 'vscode'; -import { RemoteProvider } from './provider'; -import { AutolinkReference } from '../../config'; +import { Range, Uri } from 'vscode'; import { DynamicAutolinkReference } from '../../annotations/autolinks'; +import { AutolinkReference } from '../../config'; +import { Repository } from '../models/repository'; +import { RemoteProvider } from './provider'; const gitRegex = /\/_git\/?/i; const legacyDefaultCollectionRegex = /^DefaultCollection\//i; @@ -10,6 +11,9 @@ const orgAndProjectRegex = /^(.*?)\/(.*?)\/(.*)/; const sshDomainRegex = /^(ssh|vs-ssh)\./i; const sshPathRegex = /^\/?v\d\//i; +const fileRegex = /path=([^&]+)/i; +const rangeRegex = /line=(\d+)(?:&lineEnd=(\d+))?/; + export class AzureDevOpsRemote extends RemoteProvider { constructor(domain: string, path: string, protocol?: string, name?: string, legacy: boolean = false) { if (sshDomainRegex.test(domain)) { @@ -66,6 +70,39 @@ export class AzureDevOpsRemote extends RemoteProvider { return this._displayPath; } + // eslint-disable-next-line @typescript-eslint/require-await + async getLocalInfoFromRemoteUri( + repository: Repository, + uri: Uri, + options?: { validate?: boolean }, + ): Promise<{ uri: Uri; startLine?: number; endLine?: number } | undefined> { + if (uri.authority !== this.domain) return undefined; + // if ((options?.validate ?? true) && !uri.path.startsWith(`/${this.path}/`)) return undefined; + + let startLine; + let endLine; + if (uri.query) { + const match = rangeRegex.exec(uri.query); + if (match != null) { + const [, start, end] = match; + if (start) { + startLine = parseInt(start, 10); + if (end) { + endLine = parseInt(end, 10); + } + } + } + } + + const match = fileRegex.exec(uri.query); + if (match == null) return undefined; + + const [, path] = match; + + const absoluteUri = repository.toAbsoluteUri(path, { validate: options?.validate }); + return absoluteUri != null ? { uri: absoluteUri, startLine: startLine, endLine: endLine } : undefined; + } + protected getUrlForBranches(): string { return `${this.baseUrl}/branches`; } diff --git a/src/git/remotes/bitbucket-server.ts b/src/git/remotes/bitbucket-server.ts index a181dfc..22add58 100644 --- a/src/git/remotes/bitbucket-server.ts +++ b/src/git/remotes/bitbucket-server.ts @@ -1,8 +1,13 @@ 'use strict'; -import { Range } from 'vscode'; -import { RemoteProvider } from './provider'; -import { AutolinkReference } from '../../config'; +import { Range, Uri } from 'vscode'; import { DynamicAutolinkReference } from '../../annotations/autolinks'; +import { AutolinkReference } from '../../config'; +import { GitRevision } from '../models/models'; +import { Repository } from '../models/repository'; +import { RemoteProvider } from './provider'; + +const fileRegex = /^\/([^/]+)\/([^/]+?)\/src(.+)$/i; +const rangeRegex = /^lines-(\d+)(?::(\d+))?$/; export class BitbucketServerRemote extends RemoteProvider { constructor(domain: string, path: string, protocol?: string, name?: string, custom: boolean = false) { @@ -43,6 +48,68 @@ export class BitbucketServerRemote extends RemoteProvider { return this.formatName('Bitbucket Server'); } + async getLocalInfoFromRemoteUri( + repository: Repository, + uri: Uri, + options?: { validate?: boolean }, + ): Promise<{ uri: Uri; startLine?: number; endLine?: number } | undefined> { + if (uri.authority !== this.domain) return undefined; + if ((options?.validate ?? true) && !uri.path.startsWith(`/${this.path}/`)) return undefined; + + let startLine; + let endLine; + if (uri.fragment) { + const match = rangeRegex.exec(uri.fragment); + if (match != null) { + const [, start, end] = match; + if (start) { + startLine = parseInt(start, 10); + if (end) { + endLine = parseInt(end, 10); + } + } + } + } + + const match = fileRegex.exec(uri.path); + if (match == null) return undefined; + + const [, , , path] = match; + + // Check for a permalink + let index = path.indexOf('/', 1); + if (index !== -1) { + const sha = path.substring(1, index); + if (GitRevision.isSha(sha)) { + const uri = repository.toAbsoluteUri(path.substr(index), { validate: options?.validate }); + if (uri != null) return { uri: uri, startLine: startLine, endLine: endLine }; + } + } + + const branches = new Set( + ( + await repository.getBranches({ + filter: b => b.remote, + }) + ).map(b => b.getNameWithoutRemote()), + ); + + // Check for a link with branch (and deal with branch names with /) + let branch; + index = path.length; + do { + index = path.lastIndexOf('/', index - 1); + branch = path.substring(1, index); + + if (branches.has(branch)) { + const uri = repository.toAbsoluteUri(path.substr(index), { validate: options?.validate }); + if (uri != null) return { uri: uri, startLine: startLine, endLine: endLine }; + } + } while (index > 0); + + return undefined; + } + protected getUrlForBranches(): string { return `${this.baseUrl}/branches`; } diff --git a/src/git/remotes/bitbucket.ts b/src/git/remotes/bitbucket.ts index 5d0db0a..cccabc8 100644 --- a/src/git/remotes/bitbucket.ts +++ b/src/git/remotes/bitbucket.ts @@ -1,8 +1,13 @@ 'use strict'; -import { Range } from 'vscode'; -import { RemoteProvider } from './provider'; -import { AutolinkReference } from '../../config'; +import { Range, Uri } from 'vscode'; import { DynamicAutolinkReference } from '../../annotations/autolinks'; +import { AutolinkReference } from '../../config'; +import { GitRevision } from '../models/models'; +import { Repository } from '../models/repository'; +import { RemoteProvider } from './provider'; + +const fileRegex = /^\/([^/]+)\/([^/]+?)\/src(.+)$/i; +const rangeRegex = /^lines-(\d+)(?::(\d+))?$/; export class BitbucketRemote extends RemoteProvider { constructor(domain: string, path: string, protocol?: string, name?: string, custom: boolean = false) { @@ -36,6 +41,68 @@ export class BitbucketRemote extends RemoteProvider { return this.formatName('Bitbucket'); } + async getLocalInfoFromRemoteUri( + repository: Repository, + uri: Uri, + options?: { validate?: boolean }, + ): Promise<{ uri: Uri; startLine?: number; endLine?: number } | undefined> { + if (uri.authority !== this.domain) return undefined; + if ((options?.validate ?? true) && !uri.path.startsWith(`/${this.path}/`)) return undefined; + + let startLine; + let endLine; + if (uri.fragment) { + const match = rangeRegex.exec(uri.fragment); + if (match != null) { + const [, start, end] = match; + if (start) { + startLine = parseInt(start, 10); + if (end) { + endLine = parseInt(end, 10); + } + } + } + } + + const match = fileRegex.exec(uri.path); + if (match == null) return undefined; + + const [, , , path] = match; + + // Check for a permalink + let index = path.indexOf('/', 1); + if (index !== -1) { + const sha = path.substring(1, index); + if (GitRevision.isSha(sha)) { + const uri = repository.toAbsoluteUri(path.substr(index), { validate: options?.validate }); + if (uri != null) return { uri: uri, startLine: startLine, endLine: endLine }; + } + } + + const branches = new Set( + ( + await repository.getBranches({ + filter: b => b.remote, + }) + ).map(b => b.getNameWithoutRemote()), + ); + + // Check for a link with branch (and deal with branch names with /) + let branch; + index = path.length; + do { + index = path.lastIndexOf('/', index - 1); + branch = path.substring(1, index); + + if (branches.has(branch)) { + const uri = repository.toAbsoluteUri(path.substr(index), { validate: options?.validate }); + if (uri != null) return { uri: uri, startLine: startLine, endLine: endLine }; + } + } while (index > 0); + + return undefined; + } + protected getUrlForBranches(): string { return `${this.baseUrl}/branches`; } diff --git a/src/git/remotes/custom.ts b/src/git/remotes/custom.ts index a644900..e86ee6b 100644 --- a/src/git/remotes/custom.ts +++ b/src/git/remotes/custom.ts @@ -1,8 +1,9 @@ 'use strict'; -import { Range } from 'vscode'; +import { Range, Uri } from 'vscode'; import { RemotesUrlsConfig } from '../../configuration'; -import { Strings } from '../../system'; +import { Repository } from '../models/repository'; import { RemoteProvider } from './provider'; +import { Strings } from '../../system'; export class CustomRemote extends RemoteProvider { private readonly urls: RemotesUrlsConfig; @@ -16,6 +17,13 @@ export class CustomRemote extends RemoteProvider { return this.formatName('Custom'); } + getLocalInfoFromRemoteUri( + _repository: Repository, + _uri: Uri, + ): Promise<{ uri: Uri; startLine?: number; endLine?: number } | undefined> { + return Promise.resolve(undefined); + } + protected getUrlForRepository(): string { return Strings.interpolate(this.urls.repository, this.getContext()); } diff --git a/src/git/remotes/github.ts b/src/git/remotes/github.ts index 22e21f3..43a5653 100644 --- a/src/git/remotes/github.ts +++ b/src/git/remotes/github.ts @@ -4,10 +4,14 @@ import { DynamicAutolinkReference } from '../../annotations/autolinks'; import { AutolinkReference } from '../../config'; import { Container } from '../../container'; import { IssueOrPullRequest } from '../models/issue'; +import { GitRevision } from '../models/models'; import { PullRequest } from '../models/pullRequest'; +import { Repository } from '../models/repository'; import { RemoteProviderWithApi } from './provider'; const issueEnricher3rdParyRegex = /\b(\w+\\?-?\w+(?!\\?-)\/\w+\\?-?\w+(?!\\?-))\\?#([0-9]+)\b/g; +const fileRegex = /^\/([^/]+)\/([^/]+?)\/blob(.+)$/i; +const rangeRegex = /^L(\d+)(?:-L(\d+))?$/; export class GitHubRemote extends RemoteProviderWithApi<{ token: string }> { private readonly Buttons = class { @@ -115,6 +119,68 @@ export class GitHubRemote extends RemoteProviderWithApi<{ token: string }> { return true; } + async getLocalInfoFromRemoteUri( + repository: Repository, + uri: Uri, + options?: { validate?: boolean }, + ): Promise<{ uri: Uri; startLine?: number; endLine?: number } | undefined> { + if (uri.authority !== this.domain) return undefined; + if ((options?.validate ?? true) && !uri.path.startsWith(`/${this.path}/`)) return undefined; + + let startLine; + let endLine; + if (uri.fragment) { + const match = rangeRegex.exec(uri.fragment); + if (match != null) { + const [, start, end] = match; + if (start) { + startLine = parseInt(start, 10); + if (end) { + endLine = parseInt(end, 10); + } + } + } + } + + const match = fileRegex.exec(uri.path); + if (match == null) return undefined; + + const [, , , path] = match; + + // Check for a permalink + let index = path.indexOf('/', 1); + if (index !== -1) { + const sha = path.substring(1, index); + if (GitRevision.isSha(sha)) { + const uri = repository.toAbsoluteUri(path.substr(index), { validate: options?.validate }); + if (uri != null) return { uri: uri, startLine: startLine, endLine: endLine }; + } + } + + const branches = new Set( + ( + await repository.getBranches({ + filter: b => b.remote, + }) + ).map(b => b.getNameWithoutRemote()), + ); + + // Check for a link with branch (and deal with branch names with /) + let branch; + index = path.length; + do { + index = path.lastIndexOf('/', index - 1); + branch = path.substring(1, index); + + if (branches.has(branch)) { + const uri = repository.toAbsoluteUri(path.substr(index), { validate: options?.validate }); + if (uri != null) return { uri: uri, startLine: startLine, endLine: endLine }; + } + } while (index > 0); + + return undefined; + } + protected getUrlForBranches(): string { return `${this.baseUrl}/branches`; } diff --git a/src/git/remotes/gitlab.ts b/src/git/remotes/gitlab.ts index 8d58665..a098408 100644 --- a/src/git/remotes/gitlab.ts +++ b/src/git/remotes/gitlab.ts @@ -1,8 +1,13 @@ 'use strict'; -import { Range } from 'vscode'; -import { RemoteProvider } from './provider'; -import { AutolinkReference } from '../../config'; +import { Range, Uri } from 'vscode'; import { DynamicAutolinkReference } from '../../annotations/autolinks'; +import { AutolinkReference } from '../../config'; +import { GitRevision } from '../models/models'; +import { Repository } from '../models/repository'; +import { RemoteProvider } from './provider'; + +const fileRegex = /^\/([^/]+)\/([^/]+?)\/-\/blob(.+)$/i; +const rangeRegex = /^L(\d+)(?:-(\d+))?$/; export class GitLabRemote extends RemoteProvider { constructor(domain: string, path: string, protocol?: string, name?: string, custom: boolean = false) { @@ -31,6 +36,68 @@ export class GitLabRemote extends RemoteProvider { return this.formatName('GitLab'); } + async getLocalInfoFromRemoteUri( + repository: Repository, + uri: Uri, + options?: { validate?: boolean }, + ): Promise<{ uri: Uri; startLine?: number; endLine?: number } | undefined> { + if (uri.authority !== this.domain) return undefined; + if ((options?.validate ?? true) && !uri.path.startsWith(`/${this.path}/`)) return undefined; + + let startLine; + let endLine; + if (uri.fragment) { + const match = rangeRegex.exec(uri.fragment); + if (match != null) { + const [, start, end] = match; + if (start) { + startLine = parseInt(start, 10); + if (end) { + endLine = parseInt(end, 10); + } + } + } + } + + const match = fileRegex.exec(uri.path); + if (match == null) return undefined; + + const [, , , path] = match; + + // Check for a permalink + let index = path.indexOf('/', 1); + if (index !== -1) { + const sha = path.substring(1, index); + if (GitRevision.isSha(sha)) { + const uri = repository.toAbsoluteUri(path.substr(index), { validate: options?.validate }); + if (uri != null) return { uri: uri, startLine: startLine, endLine: endLine }; + } + } + + const branches = new Set( + ( + await repository.getBranches({ + filter: b => b.remote, + }) + ).map(b => b.getNameWithoutRemote()), + ); + + // Check for a link with branch (and deal with branch names with /) + let branch; + index = path.length; + do { + index = path.lastIndexOf('/', index - 1); + branch = path.substring(1, index); + + if (branches.has(branch)) { + const uri = repository.toAbsoluteUri(path.substr(index), { validate: options?.validate }); + if (uri != null) return { uri: uri, startLine: startLine, endLine: endLine }; + } + } while (index > 0); + + return undefined; + } + protected getUrlForBranches(): string { return `${this.baseUrl}/branches`; } diff --git a/src/git/remotes/provider.ts b/src/git/remotes/provider.ts index c7fe88c..6c4e78d 100644 --- a/src/git/remotes/provider.ts +++ b/src/git/remotes/provider.ts @@ -9,6 +9,7 @@ import { Messages } from '../../messages'; import { IssueOrPullRequest } from '../models/issue'; import { GitLogCommit } from '../models/logCommit'; import { PullRequest } from '../models/pullRequest'; +import { Repository } from '../models/repository'; import { debug, gate, Promises } from '../../system'; export class CredentialError extends Error { @@ -129,6 +130,12 @@ export abstract class RemoteProvider { return RemoteProviderWithApi.is(this); } + abstract getLocalInfoFromRemoteUri( + repository: Repository, + uri: Uri, + options?: { validate?: boolean }, + ): Promise<{ uri: Uri; startLine?: number; endLine?: number } | undefined>; + open(resource: RemoteResource): Promise { return this.openUrl(this.url(resource)); }