Skip to content

Commit ffe53e8

Browse files
authored
Add a Share menu and a share vscode.dev command (microsoft#152765)
* Add a share menu Fixes microsoft#146309 * Add vscod.dev command in github extension * Make share menu proposed * Add share submenu into editor context * Add proposed to editor share menu
1 parent e71b610 commit ffe53e8

File tree

13 files changed

+191
-18
lines changed

13 files changed

+191
-18
lines changed

extensions/github/package.json

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,18 +25,41 @@
2525
"supported": true
2626
}
2727
},
28+
"enabledApiProposals": [
29+
"contribShareMenu"
30+
],
2831
"contributes": {
2932
"commands": [
3033
{
3134
"command": "github.publish",
3235
"title": "Publish to GitHub"
36+
},
37+
{
38+
"command": "github.copyVscodeDevLink",
39+
"title": "Copy vscode.dev Link"
3340
}
3441
],
3542
"menus": {
3643
"commandPalette": [
3744
{
3845
"command": "github.publish",
3946
"when": "git-base.gitEnabled"
47+
},
48+
{
49+
"command": "github.copyVscodeDevLink",
50+
"when": "false"
51+
}
52+
],
53+
"file/share": [
54+
{
55+
"command": "github.copyVscodeDevLink",
56+
"when": "github.hasGitHubRepo"
57+
}
58+
],
59+
"editor/context/share": [
60+
{
61+
"command": "github.copyVscodeDevLink",
62+
"when": "github.hasGitHubRepo"
4063
}
4164
]
4265
},

extensions/github/src/commands.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import * as vscode from 'vscode';
77
import { API as GitAPI } from './typings/git';
88
import { publishRepository } from './publish';
99
import { DisposableStore } from './util';
10+
import { getPermalink } from './links';
1011

1112
export function registerCommands(gitAPI: GitAPI): vscode.Disposable {
1213
const disposables = new DisposableStore();
@@ -19,5 +20,16 @@ export function registerCommands(gitAPI: GitAPI): vscode.Disposable {
1920
}
2021
}));
2122

23+
disposables.add(vscode.commands.registerCommand('github.copyVscodeDevLink', async () => {
24+
try {
25+
const permalink = getPermalink(gitAPI, 'https://vscode.dev/github');
26+
if (permalink) {
27+
vscode.env.clipboard.writeText(permalink);
28+
}
29+
} catch (err) {
30+
vscode.window.showErrorMessage(err.message);
31+
}
32+
}));
33+
2234
return disposables;
2335
}

extensions/github/src/extension.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@
55

66
import { commands, Disposable, ExtensionContext, extensions } from 'vscode';
77
import { GithubRemoteSourceProvider } from './remoteSourceProvider';
8-
import { GitExtension } from './typings/git';
8+
import { API, GitExtension } from './typings/git';
99
import { registerCommands } from './commands';
1010
import { GithubCredentialProviderManager } from './credentialProvider';
11-
import { DisposableStore } from './util';
11+
import { DisposableStore, repositoryHasGitHubRemote } from './util';
1212
import { GithubPushErrorHandler } from './pushErrorHandler';
1313
import { GitBaseExtension } from './typings/git-base';
1414
import { GithubRemoteSourcePublisher } from './remoteSourcePublisher';
@@ -48,6 +48,21 @@ function initializeGitBaseExtension(): Disposable {
4848
return disposables;
4949
}
5050

51+
function setGitHubContext(gitAPI: API, disposables: DisposableStore) {
52+
if (gitAPI.repositories.find(repo => repositoryHasGitHubRemote(repo))) {
53+
commands.executeCommand('setContext', 'github.hasGitHubRepo', true);
54+
} else {
55+
const openRepoDisposable = gitAPI.onDidOpenRepository(async e => {
56+
await e.status();
57+
if (repositoryHasGitHubRemote(e)) {
58+
commands.executeCommand('setContext', 'github.hasGitHubRepo', true);
59+
openRepoDisposable.dispose();
60+
}
61+
});
62+
disposables.add(openRepoDisposable);
63+
}
64+
}
65+
5166
function initializeGitExtension(): Disposable {
5267
const disposables = new DisposableStore();
5368

@@ -64,6 +79,7 @@ function initializeGitExtension(): Disposable {
6479
disposables.add(new GithubCredentialProviderManager(gitAPI));
6580
disposables.add(gitAPI.registerPushErrorHandler(new GithubPushErrorHandler()));
6681
disposables.add(gitAPI.registerRemoteSourcePublisher(new GithubRemoteSourcePublisher(gitAPI)));
82+
setGitHubContext(gitAPI, disposables);
6783

6884
commands.executeCommand('setContext', 'git-base.gitEnabled', true);
6985
} else {

extensions/github/src/links.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import * as vscode from 'vscode';
7+
import { API as GitAPI, Repository } from './typings/git';
8+
import { getRepositoryFromUrl } from './util';
9+
10+
export function isFileInRepo(repository: Repository, file: vscode.Uri): boolean {
11+
return file.path.toLowerCase() === repository.rootUri.path.toLowerCase() ||
12+
(file.path.toLowerCase().startsWith(repository.rootUri.path.toLowerCase()) &&
13+
file.path.substring(repository.rootUri.path.length).startsWith('/'));
14+
}
15+
16+
export function getRepositoryForFile(gitAPI: GitAPI, file: vscode.Uri): Repository | undefined {
17+
for (const repository of gitAPI.repositories) {
18+
if (isFileInRepo(repository, file)) {
19+
return repository;
20+
}
21+
}
22+
return undefined;
23+
}
24+
25+
function getFileAndPosition(): { uri: vscode.Uri | undefined; range: vscode.Range | undefined } {
26+
let uri: vscode.Uri | undefined;
27+
let range: vscode.Range | undefined;
28+
if (vscode.window.activeTextEditor) {
29+
uri = vscode.window.activeTextEditor.document.uri;
30+
range = vscode.window.activeTextEditor.selection;
31+
}
32+
return { uri, range };
33+
}
34+
35+
function rangeString(range: vscode.Range | undefined) {
36+
if (!range) {
37+
return '';
38+
}
39+
let hash = `#L${range.start.line + 1}`;
40+
if (range.start.line !== range.end.line) {
41+
hash += `-L${range.end.line + 1}`;
42+
}
43+
return hash;
44+
}
45+
46+
export function getPermalink(gitAPI: GitAPI, hostPrefix?: string): string | undefined {
47+
hostPrefix = hostPrefix ?? 'https://github.com';
48+
const { uri, range } = getFileAndPosition();
49+
if (!uri) {
50+
return;
51+
}
52+
const gitRepo = getRepositoryForFile(gitAPI, uri);
53+
if (!gitRepo) {
54+
return;
55+
}
56+
let repo: { owner: string; repo: string } | undefined;
57+
gitRepo.state.remotes.find(remote => {
58+
if (remote.fetchUrl) {
59+
const foundRepo = getRepositoryFromUrl(remote.fetchUrl);
60+
if (foundRepo && (remote.name === gitRepo.state.HEAD?.upstream?.remote)) {
61+
repo = foundRepo;
62+
return;
63+
} else if (foundRepo && !repo) {
64+
repo = foundRepo;
65+
}
66+
}
67+
return;
68+
});
69+
if (!repo) {
70+
return;
71+
}
72+
73+
const commitHash = gitRepo.state.HEAD?.commit;
74+
const pathSegment = uri.path.substring(gitRepo.rootUri.path.length);
75+
76+
return `${hostPrefix}/${repo.owner}/${repo.repo}/blob/${commitHash
77+
}${pathSegment}${rangeString(range)}`;
78+
}

extensions/github/src/remoteSourceProvider.ts

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,7 @@ import { workspace } from 'vscode';
77
import { RemoteSourceProvider, RemoteSource } from './typings/git-base';
88
import { getOctokit } from './auth';
99
import { Octokit } from '@octokit/rest';
10-
11-
function getRepositoryFromUrl(url: string): { owner: string; repo: string } | undefined {
12-
const match = /^https:\/\/github\.com\/([^/]+)\/([^/]+)\.git/i.exec(url)
13-
|| /^git@github\.com:([^/]+)\/([^/]+)\.git/i.exec(url);
14-
return match ? { owner: match[1], repo: match[2] } : undefined;
15-
}
16-
17-
function getRepositoryFromQuery(query: string): { owner: string; repo: string } | undefined {
18-
const match = /^([^/]+)\/([^/]+)$/i.exec(query);
19-
return match ? { owner: match[1], repo: match[2] } : undefined;
20-
}
10+
import { getRepositoryFromQuery, getRepositoryFromUrl } from './util';
2111

2212
function asRemoteSource(raw: any): RemoteSource {
2313
const protocol = workspace.getConfiguration('github').get<'https' | 'ssh'>('gitProtocol');

extensions/github/src/util.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
*--------------------------------------------------------------------------------------------*/
55

66
import * as vscode from 'vscode';
7+
import { Repository } from './typings/git';
78

89
export class DisposableStore {
910

@@ -21,3 +22,18 @@ export class DisposableStore {
2122
this.disposables.clear();
2223
}
2324
}
25+
26+
export function getRepositoryFromUrl(url: string): { owner: string; repo: string } | undefined {
27+
const match = /^https:\/\/github\.com\/([^/]+)\/([^/]+?)(\.git)?$/i.exec(url)
28+
|| /^git@github\.com:([^/]+)\/([^/]+?)(\.git)?$/i.exec(url);
29+
return match ? { owner: match[1], repo: match[2] } : undefined;
30+
}
31+
32+
export function getRepositoryFromQuery(query: string): { owner: string; repo: string } | undefined {
33+
const match = /^([^/]+)\/([^/]+)$/i.exec(query);
34+
return match ? { owner: match[1], repo: match[2] } : undefined;
35+
}
36+
37+
export function repositoryHasGitHubRemote(repository: Repository) {
38+
return !!repository.state.remotes.find(remote => remote.fetchUrl ? getRepositoryFromUrl(remote.fetchUrl) : undefined);
39+
}

src/vs/editor/contrib/clipboard/browser/clipboard.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ export const CopyAction = supportsCopy ? registerCommand(new MultiCommand({
107107

108108
MenuRegistry.appendMenuItem(MenuId.MenubarEditMenu, { submenu: MenuId.MenubarCopy, title: { value: nls.localize('copy as', "Copy As"), original: 'Copy As', }, group: '2_ccp', order: 3 });
109109
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 });
110+
MenuRegistry.appendMenuItem(MenuId.EditorContext, { submenu: MenuId.EditorContextShare, title: { value: nls.localize('share', "Share"), original: 'Share', }, group: '11_share', order: -1 });
110111

111112
export const PasteAction = supportsPaste ? registerCommand(new MultiCommand({
112113
id: 'editor.action.clipboardPasteAction',

src/vs/platform/actions/common/actions.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ export class MenuId {
5959
static readonly SimpleEditorContext = new MenuId('SimpleEditorContext');
6060
static readonly EditorContextCopy = new MenuId('EditorContextCopy');
6161
static readonly EditorContextPeek = new MenuId('EditorContextPeek');
62+
static readonly EditorContextShare = new MenuId('EditorContextShare');
6263
static readonly EditorTitle = new MenuId('EditorTitle');
6364
static readonly EditorTitleRun = new MenuId('EditorTitleRun');
6465
static readonly EditorTitleContext = new MenuId('EditorTitleContext');
@@ -85,6 +86,7 @@ export class MenuId {
8586
static readonly MenubarPreferencesMenu = new MenuId('MenubarPreferencesMenu');
8687
static readonly MenubarRecentMenu = new MenuId('MenubarRecentMenu');
8788
static readonly MenubarSelectionMenu = new MenuId('MenubarSelectionMenu');
89+
static readonly MenubarShare = new MenuId('MenubarShare');
8890
static readonly MenubarSwitchEditorMenu = new MenuId('MenubarSwitchEditorMenu');
8991
static readonly MenubarSwitchGroupMenu = new MenuId('MenubarSwitchGroupMenu');
9092
static readonly MenubarTerminalMenu = new MenuId('MenubarTerminalMenu');

src/vs/platform/actions/common/menuService.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -150,11 +150,20 @@ class Menu implements IMenu {
150150
const activeActions: Array<MenuItemAction | SubmenuItemAction> = [];
151151
for (const item of items) {
152152
if (this._contextKeyService.contextMatchesRules(item.when)) {
153-
const action = isIMenuItem(item)
154-
? new MenuItemAction(item.command, item.alt, options, this._contextKeyService, this._commandService)
155-
: new SubmenuItemAction(item, this._menuService, this._contextKeyService, options);
156-
157-
activeActions.push(action);
153+
let action: MenuItemAction | SubmenuItemAction | undefined;
154+
if (isIMenuItem(item)) {
155+
action = new MenuItemAction(item.command, item.alt, options, this._contextKeyService, this._commandService);
156+
} else {
157+
action = new SubmenuItemAction(item, this._menuService, this._contextKeyService, options);
158+
if (action.actions.length === 0) {
159+
action.dispose();
160+
action = undefined;
161+
}
162+
}
163+
164+
if (action) {
165+
activeActions.push(action);
166+
}
158167
}
159168
}
160169
if (activeActions.length > 0) {

src/vs/workbench/browser/parts/editor/editor.contribution.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -579,6 +579,13 @@ MenuRegistry.appendMenuItem(MenuId.MenubarRecentMenu, {
579579
order: 1
580580
});
581581

582+
MenuRegistry.appendMenuItem(MenuId.MenubarFileMenu, {
583+
title: localize('miShare', "Share"),
584+
submenu: MenuId.MenubarShare,
585+
group: '45_share',
586+
order: 1,
587+
});
588+
582589
// Layout menu
583590
MenuRegistry.appendMenuItem(MenuId.MenubarViewMenu, {
584591
group: '2_appearance',

0 commit comments

Comments
 (0)