From ffe53e8d710a6fce5e2bdc67f25914aea3c40d42 Mon Sep 17 00:00:00 2001 From: Alex Ross Date: Mon, 27 Jun 2022 09:56:36 +0200 Subject: [PATCH] Add a Share menu and a share vscode.dev command (#152765) * Add a share menu Fixes #146309 * Add vscod.dev command in github extension * Make share menu proposed * Add share submenu into editor context * Add proposed to editor share menu --- extensions/github/package.json | 23 ++++++ extensions/github/src/commands.ts | 12 +++ extensions/github/src/extension.ts | 20 ++++- extensions/github/src/links.ts | 78 +++++++++++++++++++ extensions/github/src/remoteSourceProvider.ts | 12 +-- extensions/github/src/util.ts | 16 ++++ .../contrib/clipboard/browser/clipboard.ts | 1 + src/vs/platform/actions/common/actions.ts | 2 + src/vs/platform/actions/common/menuService.ts | 19 +++-- .../parts/editor/editor.contribution.ts | 7 ++ .../actions/common/menusExtensionPoint.ts | 12 +++ .../common/extensionsApiProposals.ts | 1 + .../vscode.proposed.contribShareMenu.d.ts | 6 ++ 13 files changed, 191 insertions(+), 18 deletions(-) create mode 100644 extensions/github/src/links.ts create mode 100644 src/vscode-dts/vscode.proposed.contribShareMenu.d.ts diff --git a/extensions/github/package.json b/extensions/github/package.json index af7b544f59677..fd5b8a9dfca91 100644 --- a/extensions/github/package.json +++ b/extensions/github/package.json @@ -25,11 +25,18 @@ "supported": true } }, + "enabledApiProposals": [ + "contribShareMenu" + ], "contributes": { "commands": [ { "command": "github.publish", "title": "Publish to GitHub" + }, + { + "command": "github.copyVscodeDevLink", + "title": "Copy vscode.dev Link" } ], "menus": { @@ -37,6 +44,22 @@ { "command": "github.publish", "when": "git-base.gitEnabled" + }, + { + "command": "github.copyVscodeDevLink", + "when": "false" + } + ], + "file/share": [ + { + "command": "github.copyVscodeDevLink", + "when": "github.hasGitHubRepo" + } + ], + "editor/context/share": [ + { + "command": "github.copyVscodeDevLink", + "when": "github.hasGitHubRepo" } ] }, diff --git a/extensions/github/src/commands.ts b/extensions/github/src/commands.ts index 42ed8198287b0..d2d4fb814f79c 100644 --- a/extensions/github/src/commands.ts +++ b/extensions/github/src/commands.ts @@ -7,6 +7,7 @@ import * as vscode from 'vscode'; import { API as GitAPI } from './typings/git'; import { publishRepository } from './publish'; import { DisposableStore } from './util'; +import { getPermalink } from './links'; export function registerCommands(gitAPI: GitAPI): vscode.Disposable { const disposables = new DisposableStore(); @@ -19,5 +20,16 @@ export function registerCommands(gitAPI: GitAPI): vscode.Disposable { } })); + disposables.add(vscode.commands.registerCommand('github.copyVscodeDevLink', async () => { + try { + const permalink = getPermalink(gitAPI, 'https://vscode.dev/github'); + if (permalink) { + vscode.env.clipboard.writeText(permalink); + } + } catch (err) { + vscode.window.showErrorMessage(err.message); + } + })); + return disposables; } diff --git a/extensions/github/src/extension.ts b/extensions/github/src/extension.ts index 2fbe1d597fd76..a3a84b033dd86 100644 --- a/extensions/github/src/extension.ts +++ b/extensions/github/src/extension.ts @@ -5,10 +5,10 @@ import { commands, Disposable, ExtensionContext, extensions } from 'vscode'; import { GithubRemoteSourceProvider } from './remoteSourceProvider'; -import { GitExtension } from './typings/git'; +import { API, GitExtension } from './typings/git'; import { registerCommands } from './commands'; import { GithubCredentialProviderManager } from './credentialProvider'; -import { DisposableStore } from './util'; +import { DisposableStore, repositoryHasGitHubRemote } from './util'; import { GithubPushErrorHandler } from './pushErrorHandler'; import { GitBaseExtension } from './typings/git-base'; import { GithubRemoteSourcePublisher } from './remoteSourcePublisher'; @@ -48,6 +48,21 @@ function initializeGitBaseExtension(): Disposable { return disposables; } +function setGitHubContext(gitAPI: API, disposables: DisposableStore) { + if (gitAPI.repositories.find(repo => repositoryHasGitHubRemote(repo))) { + commands.executeCommand('setContext', 'github.hasGitHubRepo', true); + } else { + const openRepoDisposable = gitAPI.onDidOpenRepository(async e => { + await e.status(); + if (repositoryHasGitHubRemote(e)) { + commands.executeCommand('setContext', 'github.hasGitHubRepo', true); + openRepoDisposable.dispose(); + } + }); + disposables.add(openRepoDisposable); + } +} + function initializeGitExtension(): Disposable { const disposables = new DisposableStore(); @@ -64,6 +79,7 @@ function initializeGitExtension(): Disposable { disposables.add(new GithubCredentialProviderManager(gitAPI)); disposables.add(gitAPI.registerPushErrorHandler(new GithubPushErrorHandler())); disposables.add(gitAPI.registerRemoteSourcePublisher(new GithubRemoteSourcePublisher(gitAPI))); + setGitHubContext(gitAPI, disposables); commands.executeCommand('setContext', 'git-base.gitEnabled', true); } else { diff --git a/extensions/github/src/links.ts b/extensions/github/src/links.ts new file mode 100644 index 0000000000000..3b0bc3ce8aac7 --- /dev/null +++ b/extensions/github/src/links.ts @@ -0,0 +1,78 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { API as GitAPI, Repository } from './typings/git'; +import { getRepositoryFromUrl } from './util'; + +export function isFileInRepo(repository: Repository, file: vscode.Uri): boolean { + return file.path.toLowerCase() === repository.rootUri.path.toLowerCase() || + (file.path.toLowerCase().startsWith(repository.rootUri.path.toLowerCase()) && + file.path.substring(repository.rootUri.path.length).startsWith('/')); +} + +export function getRepositoryForFile(gitAPI: GitAPI, file: vscode.Uri): Repository | undefined { + for (const repository of gitAPI.repositories) { + if (isFileInRepo(repository, file)) { + return repository; + } + } + return undefined; +} + +function getFileAndPosition(): { uri: vscode.Uri | undefined; range: vscode.Range | undefined } { + let uri: vscode.Uri | undefined; + let range: vscode.Range | undefined; + if (vscode.window.activeTextEditor) { + uri = vscode.window.activeTextEditor.document.uri; + range = vscode.window.activeTextEditor.selection; + } + return { uri, range }; +} + +function rangeString(range: vscode.Range | undefined) { + if (!range) { + return ''; + } + let hash = `#L${range.start.line + 1}`; + if (range.start.line !== range.end.line) { + hash += `-L${range.end.line + 1}`; + } + return hash; +} + +export function getPermalink(gitAPI: GitAPI, hostPrefix?: string): string | undefined { + hostPrefix = hostPrefix ?? 'https://github.com'; + const { uri, range } = getFileAndPosition(); + if (!uri) { + return; + } + const gitRepo = getRepositoryForFile(gitAPI, uri); + if (!gitRepo) { + return; + } + let repo: { owner: string; repo: string } | undefined; + gitRepo.state.remotes.find(remote => { + if (remote.fetchUrl) { + const foundRepo = getRepositoryFromUrl(remote.fetchUrl); + if (foundRepo && (remote.name === gitRepo.state.HEAD?.upstream?.remote)) { + repo = foundRepo; + return; + } else if (foundRepo && !repo) { + repo = foundRepo; + } + } + return; + }); + if (!repo) { + return; + } + + const commitHash = gitRepo.state.HEAD?.commit; + const pathSegment = uri.path.substring(gitRepo.rootUri.path.length); + + return `${hostPrefix}/${repo.owner}/${repo.repo}/blob/${commitHash + }${pathSegment}${rangeString(range)}`; +} diff --git a/extensions/github/src/remoteSourceProvider.ts b/extensions/github/src/remoteSourceProvider.ts index e365a7172b718..e8eeb851549fd 100644 --- a/extensions/github/src/remoteSourceProvider.ts +++ b/extensions/github/src/remoteSourceProvider.ts @@ -7,17 +7,7 @@ import { workspace } from 'vscode'; import { RemoteSourceProvider, RemoteSource } from './typings/git-base'; import { getOctokit } from './auth'; import { Octokit } from '@octokit/rest'; - -function getRepositoryFromUrl(url: string): { owner: string; repo: string } | undefined { - const match = /^https:\/\/github\.com\/([^/]+)\/([^/]+)\.git/i.exec(url) - || /^git@github\.com:([^/]+)\/([^/]+)\.git/i.exec(url); - return match ? { owner: match[1], repo: match[2] } : undefined; -} - -function getRepositoryFromQuery(query: string): { owner: string; repo: string } | undefined { - const match = /^([^/]+)\/([^/]+)$/i.exec(query); - return match ? { owner: match[1], repo: match[2] } : undefined; -} +import { getRepositoryFromQuery, getRepositoryFromUrl } from './util'; function asRemoteSource(raw: any): RemoteSource { const protocol = workspace.getConfiguration('github').get<'https' | 'ssh'>('gitProtocol'); diff --git a/extensions/github/src/util.ts b/extensions/github/src/util.ts index c7d23a8230108..3d8bf4a40bef5 100644 --- a/extensions/github/src/util.ts +++ b/extensions/github/src/util.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; +import { Repository } from './typings/git'; export class DisposableStore { @@ -21,3 +22,18 @@ export class DisposableStore { this.disposables.clear(); } } + +export function getRepositoryFromUrl(url: string): { owner: string; repo: string } | undefined { + const match = /^https:\/\/github\.com\/([^/]+)\/([^/]+?)(\.git)?$/i.exec(url) + || /^git@github\.com:([^/]+)\/([^/]+?)(\.git)?$/i.exec(url); + return match ? { owner: match[1], repo: match[2] } : undefined; +} + +export function getRepositoryFromQuery(query: string): { owner: string; repo: string } | undefined { + const match = /^([^/]+)\/([^/]+)$/i.exec(query); + return match ? { owner: match[1], repo: match[2] } : undefined; +} + +export function repositoryHasGitHubRemote(repository: Repository) { + return !!repository.state.remotes.find(remote => remote.fetchUrl ? getRepositoryFromUrl(remote.fetchUrl) : undefined); +} diff --git a/src/vs/editor/contrib/clipboard/browser/clipboard.ts b/src/vs/editor/contrib/clipboard/browser/clipboard.ts index e9a71900cf12c..488fc8a605673 100644 --- a/src/vs/editor/contrib/clipboard/browser/clipboard.ts +++ b/src/vs/editor/contrib/clipboard/browser/clipboard.ts @@ -107,6 +107,7 @@ export const CopyAction = supportsCopy ? registerCommand(new MultiCommand({ MenuRegistry.appendMenuItem(MenuId.MenubarEditMenu, { submenu: MenuId.MenubarCopy, title: { value: nls.localize('copy as', "Copy As"), original: 'Copy As', }, group: '2_ccp', order: 3 }); MenuRegistry.appendMenuItem(MenuId.EditorContext, { submenu: MenuId.EditorContextCopy, title: { value: nls.localize('copy as', "Copy As"), original: 'Copy As', }, group: CLIPBOARD_CONTEXT_MENU_GROUP, order: 3 }); +MenuRegistry.appendMenuItem(MenuId.EditorContext, { submenu: MenuId.EditorContextShare, title: { value: nls.localize('share', "Share"), original: 'Share', }, group: '11_share', order: -1 }); export const PasteAction = supportsPaste ? registerCommand(new MultiCommand({ id: 'editor.action.clipboardPasteAction', diff --git a/src/vs/platform/actions/common/actions.ts b/src/vs/platform/actions/common/actions.ts index 1f9da3b627ac2..d1cd1197ccebb 100644 --- a/src/vs/platform/actions/common/actions.ts +++ b/src/vs/platform/actions/common/actions.ts @@ -59,6 +59,7 @@ export class MenuId { static readonly SimpleEditorContext = new MenuId('SimpleEditorContext'); static readonly EditorContextCopy = new MenuId('EditorContextCopy'); static readonly EditorContextPeek = new MenuId('EditorContextPeek'); + static readonly EditorContextShare = new MenuId('EditorContextShare'); static readonly EditorTitle = new MenuId('EditorTitle'); static readonly EditorTitleRun = new MenuId('EditorTitleRun'); static readonly EditorTitleContext = new MenuId('EditorTitleContext'); @@ -85,6 +86,7 @@ export class MenuId { static readonly MenubarPreferencesMenu = new MenuId('MenubarPreferencesMenu'); static readonly MenubarRecentMenu = new MenuId('MenubarRecentMenu'); static readonly MenubarSelectionMenu = new MenuId('MenubarSelectionMenu'); + static readonly MenubarShare = new MenuId('MenubarShare'); static readonly MenubarSwitchEditorMenu = new MenuId('MenubarSwitchEditorMenu'); static readonly MenubarSwitchGroupMenu = new MenuId('MenubarSwitchGroupMenu'); static readonly MenubarTerminalMenu = new MenuId('MenubarTerminalMenu'); diff --git a/src/vs/platform/actions/common/menuService.ts b/src/vs/platform/actions/common/menuService.ts index 4b8f39a184007..e0f60280e3350 100644 --- a/src/vs/platform/actions/common/menuService.ts +++ b/src/vs/platform/actions/common/menuService.ts @@ -150,11 +150,20 @@ class Menu implements IMenu { const activeActions: Array = []; for (const item of items) { if (this._contextKeyService.contextMatchesRules(item.when)) { - const action = isIMenuItem(item) - ? new MenuItemAction(item.command, item.alt, options, this._contextKeyService, this._commandService) - : new SubmenuItemAction(item, this._menuService, this._contextKeyService, options); - - activeActions.push(action); + let action: MenuItemAction | SubmenuItemAction | undefined; + if (isIMenuItem(item)) { + action = new MenuItemAction(item.command, item.alt, options, this._contextKeyService, this._commandService); + } else { + action = new SubmenuItemAction(item, this._menuService, this._contextKeyService, options); + if (action.actions.length === 0) { + action.dispose(); + action = undefined; + } + } + + if (action) { + activeActions.push(action); + } } } if (activeActions.length > 0) { diff --git a/src/vs/workbench/browser/parts/editor/editor.contribution.ts b/src/vs/workbench/browser/parts/editor/editor.contribution.ts index 18bc56414ed6b..399f58c4f6aea 100644 --- a/src/vs/workbench/browser/parts/editor/editor.contribution.ts +++ b/src/vs/workbench/browser/parts/editor/editor.contribution.ts @@ -579,6 +579,13 @@ MenuRegistry.appendMenuItem(MenuId.MenubarRecentMenu, { order: 1 }); +MenuRegistry.appendMenuItem(MenuId.MenubarFileMenu, { + title: localize('miShare', "Share"), + submenu: MenuId.MenubarShare, + group: '45_share', + order: 1, +}); + // Layout menu MenuRegistry.appendMenuItem(MenuId.MenubarViewMenu, { group: '2_appearance', diff --git a/src/vs/workbench/services/actions/common/menusExtensionPoint.ts b/src/vs/workbench/services/actions/common/menusExtensionPoint.ts index 6bb3eca341a72..c78225da3203d 100644 --- a/src/vs/workbench/services/actions/common/menusExtensionPoint.ts +++ b/src/vs/workbench/services/actions/common/menusExtensionPoint.ts @@ -61,6 +61,12 @@ const apiMenus: IAPIMenu[] = [ id: MenuId.EditorContextCopy, description: localize('menus.editorContextCopyAs', "'Copy as' submenu in the editor context menu") }, + { + key: 'editor/context/share', + id: MenuId.EditorContextShare, + description: localize('menus.editorContextShare', "'Share' submenu in the editor context menu"), + proposed: 'contribShareMenu' + }, { key: 'explorer/context', id: MenuId.ExplorerContext, @@ -249,6 +255,12 @@ const apiMenus: IAPIMenu[] = [ description: localize('file.newFile', "The 'New File...' quick pick, shown on welcome page and File menu."), supportsSubmenus: false, }, + { + key: 'file/share', + id: MenuId.MenubarShare, + description: localize('menus.share', "Share submenu shown in the top level File menu."), + proposed: 'contribShareMenu' + }, { key: 'editor/inlineCompletions/actions', id: MenuId.InlineCompletionsActions, diff --git a/src/vs/workbench/services/extensions/common/extensionsApiProposals.ts b/src/vs/workbench/services/extensions/common/extensionsApiProposals.ts index b6d9980d7efa0..f735af27335a8 100644 --- a/src/vs/workbench/services/extensions/common/extensionsApiProposals.ts +++ b/src/vs/workbench/services/extensions/common/extensionsApiProposals.ts @@ -14,6 +14,7 @@ export const allApiProposals = Object.freeze({ contribMenuBarHome: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.contribMenuBarHome.d.ts', contribMergeEditorToolbar: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.contribMergeEditorToolbar.d.ts', contribRemoteHelp: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.contribRemoteHelp.d.ts', + contribShareMenu: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.contribShareMenu.d.ts', contribViewsRemote: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.contribViewsRemote.d.ts', contribViewsWelcome: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.contribViewsWelcome.d.ts', customEditorMove: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.customEditorMove.d.ts', diff --git a/src/vscode-dts/vscode.proposed.contribShareMenu.d.ts b/src/vscode-dts/vscode.proposed.contribShareMenu.d.ts new file mode 100644 index 0000000000000..a38d03f4fde54 --- /dev/null +++ b/src/vscode-dts/vscode.proposed.contribShareMenu.d.ts @@ -0,0 +1,6 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// empty placeholder declaration for the `file/share`-submenu contribution point