diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a8bf0f..4d111de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,8 @@ ## Release Notes ### 2.13.0 -- Adds an update notification (for feature releases) -- please file an issue if this it too much +- Adds experimental support for `Open in GitHub` to the relevant quick picks -- need to enable it via `"gitlens.experimental.openInHostingProvider": true` +- Adds an update notification for feature releases - Adds `Show Branch History` command (`gitlens.showQuickBranchHistory`) to show the history of the selected branch - Adds `Show Last Opened Quick Pick` command (`gitlens.showLastQuickPick`) to re-open the previously opened quick pick - helps to get back to previous context - Adds `alt+-` shortcut for the `Show Last Opened Quick Pick` command (`gitlens.showLastQuickPick`) diff --git a/package.json b/package.json index 18b2ecf..955067d 100644 --- a/package.json +++ b/package.json @@ -350,6 +350,11 @@ "type": "boolean", "default": false, "description": "Specifies whether or not to toggle whitespace off then showing blame annotations (*may* be required by certain fonts/themes)" + }, + "gitlens.experimental.openInHostingProvider": { + "type": "boolean", + "default": false, + "description": "(experimental) Specifies whether or not to show Open X in Hosting Provider commands in quick picks" } } }, diff --git a/src/commands/showQuickBranchHistory.ts b/src/commands/showQuickBranchHistory.ts index 808bf3b..8b2c74e 100644 --- a/src/commands/showQuickBranchHistory.ts +++ b/src/commands/showQuickBranchHistory.ts @@ -50,7 +50,7 @@ export class ShowQuickBranchHistoryCommand extends ActiveEditorCachedCommand { if (progressCancellation.token.isCancellationRequested) return undefined; - const pick = await BranchHistoryQuickPick.show(log, gitUri, branch, progressCancellation, goBackCommand, nextPageCommand); + const pick = await BranchHistoryQuickPick.show(this.git, log, gitUri, branch, progressCancellation, goBackCommand, nextPageCommand); if (!pick) return undefined; if (pick instanceof CommandQuickPickItem) { diff --git a/src/commands/showQuickCommitDetails.ts b/src/commands/showQuickCommitDetails.ts index 7f7e729..4f6f3e4 100644 --- a/src/commands/showQuickCommitDetails.ts +++ b/src/commands/showQuickCommitDetails.ts @@ -75,19 +75,20 @@ export class ShowQuickCommitDetailsCommand extends ActiveEditorCachedCommand { }, Commands.ShowQuickCurrentBranchHistory, [new GitUri(commit.uri, commit)]); } - const pick = await CommitDetailsQuickPick.show(this.git, commit as GitLogCommit, uri, goBackCommand, repoLog); + // Create a command to get back to where we are right now + const currentCommand = new CommandQuickPickItem({ + label: `go back \u21A9`, + description: `\u00a0 \u2014 \u00a0\u00a0 to details of \u00a0$(git-commit) ${commit.shortSha}` + }, Commands.ShowQuickCommitDetails, [new GitUri(commit.uri, commit), sha, commit, goBackCommand, repoLog]); + + const pick = await CommitDetailsQuickPick.show(this.git, commit as GitLogCommit, uri, goBackCommand, currentCommand, repoLog); if (!pick) return undefined; if (!(pick instanceof CommitWithFileStatusQuickPickItem)) { return pick.execute(); } - return commands.executeCommand(Commands.ShowQuickCommitFileDetails, pick.gitUri, pick.sha, undefined, - // Create a command to get back to where we are right now - new CommandQuickPickItem({ - label: `go back \u21A9`, - description: `\u00a0 \u2014 \u00a0\u00a0 to details of \u00a0$(git-commit) ${pick.shortSha}` - }, Commands.ShowQuickCommitDetails, [new GitUri(commit.uri, commit), sha, commit, goBackCommand, repoLog])); + return commands.executeCommand(Commands.ShowQuickCommitFileDetails, pick.gitUri, pick.sha, undefined, currentCommand); } catch (ex) { Logger.error('[GitLens.ShowQuickCommitDetailsCommand]', ex); diff --git a/src/configuration.ts b/src/configuration.ts index 4828e52..91fa767 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -123,9 +123,14 @@ export interface IAdvancedConfig { }; } +export interface IExperimentalConfig { + openInHostingProvider: boolean; +} + export interface IConfig { blame: IBlameConfig; codeLens: ICodeLensesConfig; + experimental: IExperimentalConfig; statusBar: IStatusBarConfig; advanced: IAdvancedConfig; } \ No newline at end of file diff --git a/src/git/git.ts b/src/git/git.ts index dbc25e2..d359b95 100644 --- a/src/git/git.ts +++ b/src/git/git.ts @@ -10,6 +10,7 @@ export * from './models/models'; export * from './parsers/blameParser'; export * from './parsers/logParser'; export * from './parsers/statusParser'; +export * from './hosting/hostingProvider'; let git: IGit; @@ -210,6 +211,16 @@ export class Git { return gitCommand(root, ...params); } + static remote(repoPath: string): Promise { + const params = ['remote', '-v']; + return gitCommand(repoPath, ...params); + } + + static remote_url(repoPath: string, remote: string): Promise { + const params = ['remote', 'get-url', remote]; + return gitCommand(repoPath, ...params); + } + static status(repoPath: string): Promise { const params = ['status', '--porcelain=v2', '--branch']; return gitCommand(repoPath, ...params); diff --git a/src/git/gitUri.ts b/src/git/gitUri.ts index 886f2b6..9a5929d 100644 --- a/src/git/gitUri.ts +++ b/src/git/gitUri.ts @@ -71,6 +71,10 @@ export class GitUri extends Uri { : `${path.basename(this.fsPath)}${separator}${directory}`; } + getRelativePath(): string { + return Git.normalizePath(path.relative(this.repoPath, this.fsPath)); + } + static async fromUri(uri: Uri, git: GitService) { if (uri instanceof GitUri) return uri; diff --git a/src/git/hosting/factory.ts b/src/git/hosting/factory.ts new file mode 100644 index 0000000..e4910cb --- /dev/null +++ b/src/git/hosting/factory.ts @@ -0,0 +1,32 @@ +'use strict'; +import { HostingProvider } from './hostingProvider'; +import { GitHubService } from './github'; +import { Logger } from '../../logger'; + +export { HostingProvider }; + +const serviceMap = new Map HostingProvider>([ + ['github.com', (domain: string, path: string) => new GitHubService(domain, path)] +]); + +const UrlRegex = /^(?:git:\/\/(.*?)\/|https:\/\/(.*?)\/|http:\/\/(.*?)\/|git@(.*):\/\/|ssh:\/\/git@(.*?)\/)(.*)$/; + +export class HostingProviderFactory { + + static getHostingProvider(url: string): HostingProvider { + try { + const match = UrlRegex.exec(url); + const domain = match[1] || match[2] || match[3] || match[4] || match[5]; + const path = match[6].replace(/\.git/, ''); + + const serviceCreator = serviceMap.get(domain); + if (!serviceCreator) return undefined; + + return serviceCreator(domain, path); + } + catch (ex) { + Logger.error(ex); + return undefined; + } + } +} \ No newline at end of file diff --git a/src/git/hosting/github.ts b/src/git/hosting/github.ts new file mode 100644 index 0000000..ac345aa --- /dev/null +++ b/src/git/hosting/github.ts @@ -0,0 +1,26 @@ +'use strict'; +import { HostingProvider } from './hostingProvider'; + +export class GitHubService extends HostingProvider { + + constructor(public domain: string, public path: string) { + super(domain, path); + } + + get name() { + return 'GitHub'; + } + + protected getUrlForBranch(branch: string): string { + return `${this.baseUrl}/tree/${branch}`; + } + + protected getUrlForCommit(sha: string): string { + return `${this.baseUrl}/commit/${sha}`; + } + + protected getUrlForFile(fileName: string, sha?: string): string { + if (sha) return `${this.baseUrl}/blob/${sha}/${fileName}`; + return `${this.baseUrl}?path=${fileName}`; + } +} \ No newline at end of file diff --git a/src/git/hosting/hostingProvider.ts b/src/git/hosting/hostingProvider.ts new file mode 100644 index 0000000..d281e39 --- /dev/null +++ b/src/git/hosting/hostingProvider.ts @@ -0,0 +1,48 @@ +'use strict'; +import { commands, Uri } from 'vscode'; +import { BuiltInCommands } from '../../constants'; + +export type HostingProviderOpenType = 'branch' | 'commit' | 'file'; + +export abstract class HostingProvider { + + constructor(public domain: string, public path: string) { } + + abstract get name(): string; + + protected get baseUrl() { + return `https://${this.domain}/${this.path}`; + } + + protected abstract getUrlForBranch(branch: string): string; + protected abstract getUrlForCommit(sha: string): string; + protected abstract getUrlForFile(fileName: string, sha?: string): string; + + private async _openUrl(url: string): Promise<{}> { + return url && commands.executeCommand(BuiltInCommands.Open, Uri.parse(url)); + } + + open(type: 'branch', branch: string): Promise<{}>; + open(type: 'commit', sha: string): Promise<{}>; + open(type: 'file', fileName: string, sha?: string): Promise<{}>; + open(type: HostingProviderOpenType, ...args: string[]): Promise<{}>; + open(type: HostingProviderOpenType, branchOrShaOrFileName: string, sha?: string): Promise<{}> { + switch (type) { + case 'branch': return this.openBranch(branchOrShaOrFileName); + case 'commit': return this.openCommit(branchOrShaOrFileName); + case 'file': return this.openFile(branchOrShaOrFileName, sha); + } + } + + openBranch(branch: string) { + return this._openUrl(this.getUrlForBranch(branch)); + } + + openCommit(sha: string) { + return this._openUrl(this.getUrlForCommit(sha)); + } + + openFile(fileName: string, sha?: string) { + return this._openUrl(this.getUrlForFile(fileName, sha)); + } +} \ No newline at end of file diff --git a/src/git/models/models.ts b/src/git/models/models.ts index bc25b8b..847d2c7 100644 --- a/src/git/models/models.ts +++ b/src/git/models/models.ts @@ -4,4 +4,5 @@ export * from './branch'; export * from './commit'; export * from './log'; export * from './logCommit'; +export * from './remote'; export * from './status'; \ No newline at end of file diff --git a/src/git/models/remote.ts b/src/git/models/remote.ts new file mode 100644 index 0000000..7650538 --- /dev/null +++ b/src/git/models/remote.ts @@ -0,0 +1,27 @@ +'use strict'; +import { HostingProvider, HostingProviderFactory } from '../hosting/factory'; + +export type GitRemoteType = 'fetch' | 'push'; + +export class GitRemote { + + name: string; + url: string; + type: GitRemoteType; + + provider?: HostingProvider; + + constructor(remote: string) { + remote = remote.trim(); + + const [name, info] = remote.split('\t'); + this.name = name; + + const [url, typeInfo] = info.split(' '); + this.url = url; + + this.type = typeInfo.substring(1, typeInfo.length - 1) as GitRemoteType; + + this.provider = HostingProviderFactory.getHostingProvider(this.url); + } +} \ No newline at end of file diff --git a/src/gitService.ts b/src/gitService.ts index ad0b11f..2f3fd3f 100644 --- a/src/gitService.ts +++ b/src/gitService.ts @@ -4,7 +4,7 @@ import { Disposable, Event, EventEmitter, ExtensionContext, FileSystemWatcher, l import { CommandContext, setCommandContext } from './commands'; import { CodeLensVisibility, IConfig } from './configuration'; import { DocumentSchemes, WorkspaceState } from './constants'; -import { Git, GitBlameParser, GitBranch, GitCommit, GitStatusFile, GitLogParser, GitStatusParser, IGitAuthor, IGitBlame, IGitBlameLine, IGitBlameLines, IGitLog, IGitStatus } from './git/git'; +import { Git, GitBlameParser, GitBranch, GitCommit, GitLogParser, GitRemote, GitStatusFile, GitStatusParser, IGitAuthor, IGitBlame, IGitBlameLine, IGitBlameLines, IGitLog, IGitStatus } from './git/git'; import { IGitUriData, GitUri } from './git/gitUri'; import { GitCodeLensProvider } from './gitCodeLensProvider'; import { Logger } from './logger'; @@ -63,6 +63,7 @@ export class GitService extends Disposable { public repoPath: string; private _gitCache: Map | undefined; + private _remotesCache: GitRemote[]; private _cacheDisposable: Disposable | undefined; private _uriCache: Map | undefined; @@ -585,6 +586,21 @@ export class GitService extends Disposable { return locations; } + async getRemotes(repoPath: string): Promise { + if (!this.config.experimental || !this.config.experimental.openInHostingProvider) return Promise.resolve([]); + + Logger.log(`getRemotes('${repoPath}')`); + + if (this.UseGitCaching && this._remotesCache) return this._remotesCache; + + const data = await Git.remote(repoPath); + const remotes = data.split('\n').filter(_ => !!_).map(_ => new GitRemote(_)); + if (this.UseGitCaching) { + this._remotesCache = remotes; + } + return remotes; + } + getRepoPath(cwd: string): Promise { return Git.getRepoPath(cwd); } @@ -618,13 +634,6 @@ export class GitService extends Disposable { return GitStatusParser.parse(data, repoPath); } - async isFileUncommitted(uri: GitUri): Promise { - Logger.log(`isFileUncommitted('${uri.repoPath}', '${uri.fsPath}')`); - - const status = await this.getStatusForFile(uri.repoPath, uri.fsPath); - return !!status; - } - async getVersionedFile(repoPath: string, fileName: string, sha: string) { Logger.log(`getVersionedFile('${repoPath}', '${fileName}', ${sha})`); @@ -664,6 +673,13 @@ export class GitService extends Disposable { this.hasGitUriForFile(editor)); } + async isFileUncommitted(uri: GitUri): Promise { + Logger.log(`isFileUncommitted('${uri.repoPath}', '${uri.fsPath}')`); + + const status = await this.getStatusForFile(uri.repoPath, uri.fsPath); + return !!status; + } + openDirectoryDiff(repoPath: string, sha1: string, sha2?: string) { Logger.log(`openDirectoryDiff('${repoPath}', ${sha1}, ${sha2})`); diff --git a/src/quickPicks.ts b/src/quickPicks.ts index e39b2f5..461b6d8 100644 --- a/src/quickPicks.ts +++ b/src/quickPicks.ts @@ -6,4 +6,5 @@ export * from './quickPicks/commitDetails'; export * from './quickPicks/commitFileDetails'; export * from './quickPicks/branchHistory'; export * from './quickPicks/fileHistory'; +export * from './quickPicks/remotes'; export * from './quickPicks/repoStatus'; \ No newline at end of file diff --git a/src/quickPicks/branchHistory.ts b/src/quickPicks/branchHistory.ts index 19b8fb3..646fd1b 100644 --- a/src/quickPicks/branchHistory.ts +++ b/src/quickPicks/branchHistory.ts @@ -1,10 +1,9 @@ 'use strict'; -import { Iterables } from '../system'; +import { Arrays, Iterables } from '../system'; import { CancellationTokenSource, QuickPickOptions, Uri, window } from 'vscode'; import { Commands, Keyboard, KeyNoopCommand } from '../commands'; -import { GitUri, IGitLog } from '../gitService'; -import { CommitQuickPickItem } from './gitQuickPicks'; -import { CommandQuickPickItem, getQuickPickIgnoreFocusOut, showQuickPickProgress } from './quickPicks'; +import { GitService, GitUri, IGitLog } from '../gitService'; +import { CommandQuickPickItem, CommitQuickPickItem, getQuickPickIgnoreFocusOut, OpenRemotesCommandQuickPickItem, showQuickPickProgress } from '../quickPicks'; export class BranchHistoryQuickPick { @@ -17,9 +16,19 @@ export class BranchHistoryQuickPick { }); } - static async show(log: IGitLog, uri: GitUri, branch: string, progressCancellation: CancellationTokenSource, goBackCommand?: CommandQuickPickItem, nextPageCommand?: CommandQuickPickItem): Promise { + static async show(git: GitService, log: IGitLog, uri: GitUri, branch: string, progressCancellation: CancellationTokenSource, goBackCommand?: CommandQuickPickItem, nextPageCommand?: CommandQuickPickItem): Promise { const items = Array.from(Iterables.map(log.commits.values(), c => new CommitQuickPickItem(c, ` \u2014 ${c.fileNames}`))) as (CommitQuickPickItem | CommandQuickPickItem)[]; + const currentCommand = new CommandQuickPickItem({ + label: `go back \u21A9`, + description: `\u00a0 \u2014 \u00a0\u00a0 to \u00a0$(git-branch) ${branch} history` + }, Commands.ShowQuickBranchHistory, [uri, branch, log.maxCount, goBackCommand, log]); + + const remotes = Arrays.uniqueBy(await git.getRemotes(git.repoPath), _ => _.url, _ => !!_.provider); + if (remotes.length) { + items.splice(0, 0, new OpenRemotesCommandQuickPickItem(remotes, 'branch', branch, currentCommand)); + } + let previousPageCommand: CommandQuickPickItem; if ((log.truncated || (uri && uri.sha))) { @@ -42,10 +51,7 @@ export class BranchHistoryQuickPick { new GitUri(Uri.file(log.repoPath), { fileName: '', repoPath: log.repoPath }), branch, undefined, - new CommandQuickPickItem({ - label: `go back \u21A9`, - description: `\u00a0 \u2014 \u00a0\u00a0 to \u00a0$(git-branch) ${branch} history` - }, Commands.ShowQuickBranchHistory, [uri, branch, log.maxCount, goBackCommand, log]) + currentCommand ])); } diff --git a/src/quickPicks/commitDetails.ts b/src/quickPicks/commitDetails.ts index f3266af..d92275c 100644 --- a/src/quickPicks/commitDetails.ts +++ b/src/quickPicks/commitDetails.ts @@ -1,10 +1,9 @@ 'use strict'; -import { Iterables } from '../system'; +import { Arrays, Iterables } from '../system'; import { QuickPickItem, QuickPickOptions, Uri, window } from 'vscode'; import { Commands, Keyboard, KeyNoopCommand } from '../commands'; import { GitLogCommit, GitService, IGitLog } from '../gitService'; -import { CommitWithFileStatusQuickPickItem } from './gitQuickPicks'; -import { CommandQuickPickItem, getQuickPickIgnoreFocusOut, KeyCommandQuickPickItem, OpenFilesCommandQuickPickItem } from './quickPicks'; +import { CommandQuickPickItem, CommitWithFileStatusQuickPickItem, getQuickPickIgnoreFocusOut, KeyCommandQuickPickItem, OpenFilesCommandQuickPickItem, OpenRemotesCommandQuickPickItem } from '../quickPicks'; import * as moment from 'moment'; import * as path from 'path'; @@ -36,7 +35,7 @@ export class OpenCommitWorkingTreeFilesCommandQuickPickItem extends OpenFilesCom export class CommitDetailsQuickPick { - static async show(git: GitService, commit: GitLogCommit, uri: Uri, goBackCommand?: CommandQuickPickItem, repoLog?: IGitLog): Promise { + static async show(git: GitService, commit: GitLogCommit, uri: Uri, goBackCommand?: CommandQuickPickItem, currentCommand?: CommandQuickPickItem, repoLog?: IGitLog): Promise { const items: (CommitWithFileStatusQuickPickItem | CommandQuickPickItem)[] = commit.fileStatuses.map(fs => new CommitWithFileStatusQuickPickItem(commit, fs.fileName, fs.status)); let index = 0; @@ -51,6 +50,11 @@ export class CommitDetailsQuickPick { description: `\u00a0 \u2014 \u00a0\u00a0 ${commit.message}` }, Commands.CopyMessageToClipboard, [uri, commit.sha, commit.message])); + const remotes = Arrays.uniqueBy(await git.getRemotes(git.repoPath), _ => _.url, _ => !!_.provider); + if (remotes.length) { + items.splice(index++, 0, new OpenRemotesCommandQuickPickItem(remotes, 'commit', commit.sha, currentCommand)); + } + items.splice(index++, 0, new CommandQuickPickItem({ label: `$(git-compare) Directory Compare with Previous Commit`, description: `\u00a0 \u2014 \u00a0\u00a0 $(git-commit) ${commit.previousShortSha || `${commit.shortSha}^`} \u00a0 $(git-compare) \u00a0 $(git-commit) ${commit.shortSha}` @@ -61,7 +65,6 @@ export class CommitDetailsQuickPick { description: `\u00a0 \u2014 \u00a0\u00a0 $(git-commit) ${commit.shortSha} \u00a0 $(git-compare) \u00a0 $(file-directory) Working Tree` }, Commands.DiffDirectory, [uri, commit.sha])); - const added = commit.fileStatuses.filter(_ => _.status === 'A' || _.status === '?').length; const deleted = commit.fileStatuses.filter(_ => _.status === 'D').length; const changed = commit.fileStatuses.filter(_ => _.status !== 'A' && _.status !== '?' && _.status !== 'D').length; diff --git a/src/quickPicks/commitFileDetails.ts b/src/quickPicks/commitFileDetails.ts index 3c8606d..455ba62 100644 --- a/src/quickPicks/commitFileDetails.ts +++ b/src/quickPicks/commitFileDetails.ts @@ -1,9 +1,9 @@ 'use strict'; -import { Iterables } from '../system'; +import { Arrays, Iterables } from '../system'; import { QuickPickItem, QuickPickOptions, Uri, window } from 'vscode'; import { Commands, Keyboard, KeyNoopCommand } from '../commands'; import { GitCommit, GitLogCommit, GitService, GitUri, IGitLog } from '../gitService'; -import { CommandQuickPickItem, getQuickPickIgnoreFocusOut, KeyCommandQuickPickItem, OpenFileCommandQuickPickItem } from './quickPicks'; +import { CommandQuickPickItem, getQuickPickIgnoreFocusOut, KeyCommandQuickPickItem, OpenFileCommandQuickPickItem, OpenRemotesCommandQuickPickItem } from '../quickPicks'; import * as moment from 'moment'; import * as path from 'path'; @@ -75,7 +75,17 @@ export class CommitFileDetailsQuickPick { }, Commands.CopyMessageToClipboard, [uri, commit.sha, commit.message])); items.push(new OpenCommitFileCommandQuickPickItem(commit)); - items.push(new OpenCommitWorkingTreeFileCommandQuickPickItem(commit)); + if (commit.workingFileName) { + items.push(new OpenCommitWorkingTreeFileCommandQuickPickItem(commit)); + } + + const remotes = Arrays.uniqueBy(await git.getRemotes(git.repoPath), _ => _.url, _ => !!_.provider); + if (remotes.length) { + items.push(new OpenRemotesCommandQuickPickItem(remotes, 'file', commit.fileName, commit.sha, undefined, currentCommand)); + if (commit.workingFileName) { + items.push(new OpenRemotesCommandQuickPickItem(remotes, 'file', commit.workingFileName, undefined, 'Working File', currentCommand)); + } + } if (commit.workingFileName) { items.push(new CommandQuickPickItem({ diff --git a/src/quickPicks/fileHistory.ts b/src/quickPicks/fileHistory.ts index 3786212..7b1a37e 100644 --- a/src/quickPicks/fileHistory.ts +++ b/src/quickPicks/fileHistory.ts @@ -1,10 +1,9 @@ 'use strict'; -import { Iterables } from '../system'; +import { Arrays, Iterables } from '../system'; import { CancellationTokenSource, QuickPickOptions, Uri, window } from 'vscode'; import { Commands, Keyboard, KeyNoopCommand } from '../commands'; import { GitService, GitUri, IGitLog } from '../gitService'; -import { CommitQuickPickItem } from './gitQuickPicks'; -import { CommandQuickPickItem, getQuickPickIgnoreFocusOut, showQuickPickProgress } from './quickPicks'; +import { CommandQuickPickItem, CommitQuickPickItem, getQuickPickIgnoreFocusOut, OpenRemotesCommandQuickPickItem, showQuickPickProgress } from '../quickPicks'; import * as path from 'path'; export class FileHistoryQuickPick { @@ -74,21 +73,28 @@ export class FileHistoryQuickPick { } } + const currentCommand = new CommandQuickPickItem({ + label: `go back \u21A9`, + description: `\u00a0 \u2014 \u00a0\u00a0 to history of \u00a0$(file-text) ${path.basename(uri.fsPath)}${uri.sha ? ` from \u00a0$(git-commit) ${uri.shortSha}` : ''}` + }, Commands.ShowQuickFileHistory, [uri, log.range, log.maxCount, undefined, log]); + // Only show the full repo option if we are the root if (!goBackCommand) { - items.splice(index, 0, new CommandQuickPickItem({ + items.splice(index++, 0, new CommandQuickPickItem({ label: `$(history) Show Branch History`, description: `\u00a0 \u2014 \u00a0\u00a0 shows the current branch history` }, Commands.ShowQuickCurrentBranchHistory, [ undefined, - new CommandQuickPickItem({ - label: `go back \u21A9`, - description: `\u00a0 \u2014 \u00a0\u00a0 to history of \u00a0$(file-text) ${path.basename(uri.fsPath)}${uri.sha ? ` from \u00a0$(git-commit) ${uri.shortSha}` : ''}` - }, Commands.ShowQuickFileHistory, [uri, log.range, log.maxCount, undefined, log]) + currentCommand ])); } + const remotes = Arrays.uniqueBy(await git.getRemotes(git.repoPath), _ => _.url, _ => !!_.provider); + if (remotes.length) { + items.splice(index++, 0, new OpenRemotesCommandQuickPickItem(remotes, 'file', uri.getRelativePath(), uri.sha, undefined, currentCommand)); + } + if (goBackCommand) { items.splice(0, 0, goBackCommand); } diff --git a/src/quickPicks/remotes.ts b/src/quickPicks/remotes.ts new file mode 100644 index 0000000..aa282ae --- /dev/null +++ b/src/quickPicks/remotes.ts @@ -0,0 +1,139 @@ +'use strict'; +import { QuickPickOptions, window } from 'vscode'; +import { GitRemote, HostingProviderOpenType } from '../gitService'; +import { CommandQuickPickItem, getQuickPickIgnoreFocusOut } from './quickPicks'; +import * as path from 'path'; + +export class OpenRemoteCommandQuickPickItem extends CommandQuickPickItem { + + private type: HostingProviderOpenType; + private remote: GitRemote; + + constructor(remote: GitRemote, type: HostingProviderOpenType, ...args: string[]); + constructor(remote: GitRemote, type: HostingProviderOpenType, branchOrShaOrFileName: string, fileSha?: string, name?: string) { + if (!name) { + name = `${type[0].toUpperCase()}${type.substring(1)}`; + } + + super({ + label: `$(link-external) Open ${name} in ${remote.provider.name}`, + description: `\u00a0 \u2014 \u00a0\u00a0 $(repo) ${remote.provider.path}` + }, undefined, undefined); + + this.type = type; + this.remote = remote; + this.args = [branchOrShaOrFileName, fileSha]; + } + + async execute(): Promise<{}> { + return this.remote.provider.open(this.type, ...this.args); + } +} + +export class OpenRemotesCommandQuickPickItem extends CommandQuickPickItem { + + private goBackCommand: CommandQuickPickItem; + private name: string; + private placeHolder: string; + private remotes: GitRemote[]; + private type: HostingProviderOpenType; + + constructor(remotes: GitRemote[], type: 'branch', branch: string, goBackCommand?: CommandQuickPickItem); + constructor(remotes: GitRemote[], type: 'commit', sha: string, goBackCommand?: CommandQuickPickItem); + constructor(remotes: GitRemote[], type: 'file', fileName: string, sha?: string, name?: string, goBackCommand?: CommandQuickPickItem); + constructor(remotes: GitRemote[], type: HostingProviderOpenType, branchOrShaOrFileName: string, shaOrGoBackCommand?: string | CommandQuickPickItem, name?: string, goBackCommand?: CommandQuickPickItem) { + let fileSha: string; + if (typeof shaOrGoBackCommand === 'string') { + fileSha = shaOrGoBackCommand; + } + else if (!goBackCommand) { + goBackCommand = shaOrGoBackCommand; + } + + let description: string; + let placeHolder: string; + switch (type) { + case 'branch': + name = name || 'Branch'; + description = `$(git-branch) ${branchOrShaOrFileName}`; + placeHolder = `open ${branchOrShaOrFileName} ${name.toLowerCase()} in\u2026`; + break; + case 'commit': + const shortSha = branchOrShaOrFileName.substring(0, 8); + + name = name || 'Commit'; + description = `$(git-commit) ${shortSha}`; + placeHolder = `open ${name.toLowerCase()} ${shortSha} in\u2026`; + break; + case 'file': + const fileName = path.basename(branchOrShaOrFileName); + const shortFileSha = (fileSha && fileSha.substring(0, 8)) || ''; + const shaSuffix = shortFileSha ? ` \u00a0\u2022\u00a0 ${shortFileSha}` : ''; + + name = name || 'File'; + description = `$(file-text) ${fileName}${shortFileSha ? ` in \u00a0$(git-commit) ${shortFileSha}` : ''}`; + placeHolder = `open ${branchOrShaOrFileName}${shaSuffix} in\u2026`; + break; + } + + const remote = remotes[0]; + if (remotes.length === 1) { + super({ + label: `$(link-external) Open ${name} in ${remote.provider.name}`, + description: `\u00a0 \u2014 \u00a0\u00a0 $(repo) ${remote.provider.path} \u00a0\u2022\u00a0 ${description}` + }, undefined, undefined); + } + else { + const provider = remotes.every(_ => _.provider.name === remote.provider.name) + ? remote.provider.name + : 'Hosting Provider'; + + super({ + label: `$(link-external) Open ${name} in ${provider}\u2026`, + description: `\u00a0 \u2014 \u00a0\u00a0 ${description}` + }, undefined, undefined); + } + + this.goBackCommand = goBackCommand; + this.name = name; + this.placeHolder = placeHolder; + this.remotes = remotes; + this.type = type; + this.args = [branchOrShaOrFileName, fileSha]; + } + + async execute(): Promise<{}> { + if (this.remotes.length === 1) { + const command = new OpenRemoteCommandQuickPickItem(this.remotes[0], this.type, ...this.args); + return command.execute(); + } + + const pick = await RemotesQuickPick.show(this.remotes, this.placeHolder, this.type, this.args, this.name, this.goBackCommand); + return pick && pick.execute(); + } +} + +export class RemotesQuickPick { + + static async show(remotes: GitRemote[], placeHolder: string, type: HostingProviderOpenType, args: string[], name: string, goBackCommand?: CommandQuickPickItem): Promise { + + const items = remotes.map(_ => new OpenRemoteCommandQuickPickItem(_, type, ...args, name)) as (OpenRemoteCommandQuickPickItem | CommandQuickPickItem)[]; + + if (goBackCommand) { + items.splice(0, 0, goBackCommand); + } + + // const scope = await Keyboard.instance.beginScope({ left: goBackCommand }); + + const pick = await window.showQuickPick(items, + { + placeHolder: placeHolder, + ignoreFocusOut: getQuickPickIgnoreFocusOut() + } as QuickPickOptions); + if (!pick) return undefined; + + // await scope.dispose(); + + return pick; + } +} \ No newline at end of file diff --git a/src/system.ts b/src/system.ts index 767b840..fa321ab 100644 --- a/src/system.ts +++ b/src/system.ts @@ -1,5 +1,5 @@ 'use strict'; -// export * from './system/array'; +export * from './system/array'; // export * from './system/disposable'; // export * from './system/element'; // export * from './system/event'; diff --git a/src/system/array.ts b/src/system/array.ts new file mode 100644 index 0000000..8ceb1db --- /dev/null +++ b/src/system/array.ts @@ -0,0 +1,14 @@ +'use strict'; + +export namespace Arrays { + export function uniqueBy(array: T[], accessor: (item: T) => any, predicate?: (item: T) => boolean): T[] { + const uniqueValues = Object.create(null); + return array.filter(_ => { + const value = accessor(_); + if (uniqueValues[value]) return false; + + uniqueValues[value] = accessor; + return predicate ? predicate(_) : true; + }); + } +} \ No newline at end of file