diff --git a/build/lib/i18n.resources.json b/build/lib/i18n.resources.json index 58617f9b13c..5a1e2dc4d7a 100644 --- a/build/lib/i18n.resources.json +++ b/build/lib/i18n.resources.json @@ -510,6 +510,10 @@ "name": "vs/workbench/services/localization", "project": "vscode-workbench" }, + { + "name": "vs/workbench/contrib/share", + "project": "vscode-workbench" + }, { "name": "vs/workbench/contrib/accessibility", "project": "vscode-workbench" diff --git a/build/lib/stylelint/vscode-known-variables.json b/build/lib/stylelint/vscode-known-variables.json index 730e6bdf6ba..87239d2c19b 100644 --- a/build/lib/stylelint/vscode-known-variables.json +++ b/build/lib/stylelint/vscode-known-variables.json @@ -457,6 +457,7 @@ "--vscode-problemsErrorIcon-foreground", "--vscode-problemsInfoIcon-foreground", "--vscode-problemsWarningIcon-foreground", + "--vscode-problemsSuccessIcon-foreground", "--vscode-profileBadge-background", "--vscode-profileBadge-foreground", "--vscode-progressBar-background", diff --git a/extensions/github/package.json b/extensions/github/package.json index 305742e8c11..64916932d37 100644 --- a/extensions/github/package.json +++ b/extensions/github/package.json @@ -28,7 +28,8 @@ "enabledApiProposals": [ "contribShareMenu", "contribEditSessions", - "canonicalUriProvider" + "canonicalUriProvider", + "shareProvider" ], "contributes": { "commands": [ diff --git a/extensions/github/src/extension.ts b/extensions/github/src/extension.ts index 57fdc4a938a..95cb3535d3c 100644 --- a/extensions/github/src/extension.ts +++ b/extensions/github/src/extension.ts @@ -14,6 +14,7 @@ import { GitBaseExtension } from './typings/git-base'; import { GithubRemoteSourcePublisher } from './remoteSourcePublisher'; import { GithubBranchProtectionProviderManager } from './branchProtection'; import { GitHubCanonicalUriProvider } from './canonicalUriProvider'; +import { VscodeDevShareProvider } from './shareProviders'; export function activate(context: ExtensionContext): void { const disposables: Disposable[] = []; @@ -95,6 +96,7 @@ function initializeGitExtension(context: ExtensionContext, logger: LogOutputChan disposables.add(gitAPI.registerPushErrorHandler(new GithubPushErrorHandler())); disposables.add(gitAPI.registerRemoteSourcePublisher(new GithubRemoteSourcePublisher(gitAPI))); disposables.add(new GitHubCanonicalUriProvider(gitAPI)); + disposables.add(new VscodeDevShareProvider(gitAPI)); setGitHubContext(gitAPI, disposables); commands.executeCommand('setContext', 'git-base.gitEnabled', true); diff --git a/extensions/github/src/links.ts b/extensions/github/src/links.ts index 455662baf18..223da3712fe 100644 --- a/extensions/github/src/links.ts +++ b/extensions/github/src/links.ts @@ -92,7 +92,7 @@ function getRangeOrSelection(lineNumber: number | undefined) { : vscode.window.activeTextEditor?.selection; } -function rangeString(range: vscode.Range | undefined) { +export function rangeString(range: vscode.Range | undefined) { if (!range) { return ''; } @@ -119,7 +119,7 @@ export function notebookCellRangeString(index: number | undefined, range: vscode return hash; } -function encodeURIComponentExceptSlashes(path: string) { +export function encodeURIComponentExceptSlashes(path: string) { // There may be special characters like # and whitespace in the path. // These characters are not escaped by encodeURI(), so it is not sufficient to // feed the full URI to encodeURI(). diff --git a/extensions/github/src/shareProviders.ts b/extensions/github/src/shareProviders.ts new file mode 100644 index 00000000000..1188a002a3d --- /dev/null +++ b/extensions/github/src/shareProviders.ts @@ -0,0 +1,111 @@ +/*--------------------------------------------------------------------------------------------- + * 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 } from './typings/git'; +import { getRepositoryFromUrl, repositoryHasGitHubRemote } from './util'; +import { encodeURIComponentExceptSlashes, getRepositoryForFile, notebookCellRangeString, rangeString } from './links'; + +export class VscodeDevShareProvider implements vscode.ShareProvider, vscode.Disposable { + readonly id: string = 'copyVscodeDevLink'; + readonly label: string = vscode.l10n.t('Copy vscode.dev Link'); + readonly priority: number = 10; + + + private _hasGitHubRepositories: boolean = false; + private set hasGitHubRepositories(value: boolean) { + vscode.commands.executeCommand('setContext', 'github.hasGitHubRepo', value); + this._hasGitHubRepositories = value; + this.ensureShareProviderRegistration(); + } + + private shareProviderRegistration: vscode.Disposable | undefined; + private disposables: vscode.Disposable[] = []; + + constructor(private readonly gitAPI: API) { + this.initializeGitHubRepoContext(); + } + + dispose() { + this.disposables.forEach(d => d.dispose()); + } + + private initializeGitHubRepoContext() { + if (this.gitAPI.repositories.find(repo => repositoryHasGitHubRemote(repo))) { + this.hasGitHubRepositories = true; + vscode.commands.executeCommand('setContext', 'github.hasGitHubRepo', true); + } else { + this.disposables.push(this.gitAPI.onDidOpenRepository(async e => { + await e.status(); + if (repositoryHasGitHubRemote(e)) { + vscode.commands.executeCommand('setContext', 'github.hasGitHubRepo', true); + this.hasGitHubRepositories = true; + } + })); + } + this.disposables.push(this.gitAPI.onDidCloseRepository(() => { + if (!this.gitAPI.repositories.find(repo => repositoryHasGitHubRemote(repo))) { + this.hasGitHubRepositories = false; + } + })); + } + + private ensureShareProviderRegistration() { + if (vscode.env.appHost !== 'codespaces' && !this.shareProviderRegistration && this._hasGitHubRepositories) { + const shareProviderRegistration = vscode.window.registerShareProvider({ scheme: 'file' }, this); + this.shareProviderRegistration = shareProviderRegistration; + this.disposables.push(shareProviderRegistration); + } else if (this.shareProviderRegistration && !this._hasGitHubRepositories) { + this.shareProviderRegistration.dispose(); + this.shareProviderRegistration = undefined; + } + } + + provideShare(item: vscode.ShareableItem, _token: vscode.CancellationToken): vscode.ProviderResult { + const repository = getRepositoryForFile(this.gitAPI, item.resourceUri); + if (!repository) { + return; + } + + let repo: { owner: string; repo: string } | undefined; + repository.state.remotes.find(remote => { + if (remote.fetchUrl) { + const foundRepo = getRepositoryFromUrl(remote.fetchUrl); + if (foundRepo && (remote.name === repository.state.HEAD?.upstream?.remote)) { + repo = foundRepo; + return; + } else if (foundRepo && !repo) { + repo = foundRepo; + } + } + return; + }); + + if (!repo) { + return; + } + + const blobSegment = repository?.state.HEAD?.name ? encodeURIComponentExceptSlashes(repository.state.HEAD?.name) : repository?.state.HEAD?.commit; + const filepathSegment = encodeURIComponentExceptSlashes(item.resourceUri.path.substring(repository?.rootUri.path.length)); + const rangeSegment = getRangeSegment(item); + return vscode.Uri.parse(`${this.getVscodeDevHost()}/${repo.owner}/${repo.repo}/blob/${blobSegment}${filepathSegment}${rangeSegment}${rangeSegment}`); + + } + + private getVscodeDevHost(): string { + return `https://${vscode.env.appName.toLowerCase().includes('insiders') ? 'insiders.' : ''}vscode.dev/github`; + } +} + +function getRangeSegment(item: vscode.ShareableItem) { + if (item.resourceUri.scheme === 'vscode-notebook-cell') { + const notebookEditor = vscode.window.visibleNotebookEditors.find(editor => editor.notebook.uri.fsPath === item.resourceUri.fsPath); + const cell = notebookEditor?.notebook.getCells().find(cell => cell.document.uri.fragment === item.resourceUri?.fragment); + const cellIndex = cell?.index ?? notebookEditor?.selection.start; + return notebookCellRangeString(cellIndex, item.selection); + } + + return rangeString(item.selection); +} diff --git a/extensions/github/src/typings/vscode.proposed.shareProvider.d.ts b/extensions/github/src/typings/vscode.proposed.shareProvider.d.ts new file mode 100644 index 00000000000..6470557cac1 --- /dev/null +++ b/extensions/github/src/typings/vscode.proposed.shareProvider.d.ts @@ -0,0 +1,29 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// https://github.com/microsoft/vscode/issues/176316 + +declare module 'vscode' { + export interface TreeItem { + shareableItem?: ShareableItem; + } + + export interface ShareableItem { + resourceUri: Uri; + selection?: Range; + } + + export interface ShareProvider { + readonly id: string; + readonly label: string; + readonly priority: number; + + provideShare(item: ShareableItem, token: CancellationToken): ProviderResult; + } + + export namespace window { + export function registerShareProvider(selector: DocumentSelector, provider: ShareProvider): Disposable; + } +} diff --git a/src/vs/platform/actions/common/actions.ts b/src/vs/platform/actions/common/actions.ts index f031b294c9f..18e0c99f060 100644 --- a/src/vs/platform/actions/common/actions.ts +++ b/src/vs/platform/actions/common/actions.ts @@ -98,9 +98,11 @@ export class MenuId { static readonly MenubarViewMenu = new MenuId('MenubarViewMenu'); static readonly MenubarHomeMenu = new MenuId('MenubarHomeMenu'); static readonly OpenEditorsContext = new MenuId('OpenEditorsContext'); + static readonly OpenEditorsContextShare = new MenuId('OpenEditorsContextShare'); static readonly ProblemsPanelContext = new MenuId('ProblemsPanelContext'); static readonly SCMChangeContext = new MenuId('SCMChangeContext'); static readonly SCMResourceContext = new MenuId('SCMResourceContext'); + static readonly SCMResourceContextShare = new MenuId('SCMResourceContextShare'); static readonly SCMResourceFolderContext = new MenuId('SCMResourceFolderContext'); static readonly SCMResourceGroupContext = new MenuId('SCMResourceGroupContext'); static readonly SCMSourceControl = new MenuId('SCMSourceControl'); diff --git a/src/vs/workbench/api/browser/extensionHost.contribution.ts b/src/vs/workbench/api/browser/extensionHost.contribution.ts index 617a3971e05..b007b92473c 100644 --- a/src/vs/workbench/api/browser/extensionHost.contribution.ts +++ b/src/vs/workbench/api/browser/extensionHost.contribution.ts @@ -82,6 +82,7 @@ import './mainThreadAuthentication'; import './mainThreadTimeline'; import './mainThreadTesting'; import './mainThreadSecretState'; +import './mainThreadShare'; import './mainThreadProfilContentHandlers'; import './mainThreadSemanticSimilarity'; import './mainThreadIssueReporter'; diff --git a/src/vs/workbench/api/browser/mainThreadShare.ts b/src/vs/workbench/api/browser/mainThreadShare.ts new file mode 100644 index 00000000000..60f86f96ed0 --- /dev/null +++ b/src/vs/workbench/api/browser/mainThreadShare.ts @@ -0,0 +1,55 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationTokenSource } from 'vs/base/common/cancellation'; +import { IDisposable, dispose } from 'vs/base/common/lifecycle'; +import { URI } from 'vs/base/common/uri'; +import { ExtHostContext, ExtHostShareShape, IDocumentFilterDto, MainContext, MainThreadShareShape } from 'vs/workbench/api/common/extHost.protocol'; +import { IShareProvider, IShareService, IShareableItem } from 'vs/workbench/contrib/share/common/share'; +import { IExtHostContext, extHostNamedCustomer } from 'vs/workbench/services/extensions/common/extHostCustomers'; + +@extHostNamedCustomer(MainContext.MainThreadShare) +export class MainThreadShare implements MainThreadShareShape { + + private readonly proxy: ExtHostShareShape; + private providers = new Map(); + private providerDisposables = new Map(); + + constructor( + extHostContext: IExtHostContext, + @IShareService private readonly shareService: IShareService + ) { + this.proxy = extHostContext.getProxy(ExtHostContext.ExtHostShare); + } + + $registerShareProvider(handle: number, selector: IDocumentFilterDto[], id: string, label: string): void { + const provider: IShareProvider = { + id, + label, + selector, + provideShare: async (item: IShareableItem) => { + return URI.revive(await this.proxy.$provideShare(handle, item, new CancellationTokenSource().token)); + } + }; + this.providers.set(handle, provider); + const disposable = this.shareService.registerShareProvider(provider); + this.providerDisposables.set(handle, disposable); + } + + $unregisterShareProvider(handle: number): void { + if (this.providers.has(handle)) { + this.providers.delete(handle); + } + if (this.providerDisposables.has(handle)) { + this.providerDisposables.delete(handle); + } + } + + dispose(): void { + this.providers.clear(); + dispose(this.providerDisposables.values()); + this.providerDisposables.clear(); + } +} diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index a690fa7d58e..4e7a417c3b5 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -103,6 +103,7 @@ import { ExtHostNotebookDocumentSaveParticipant } from 'vs/workbench/api/common/ import { ExtHostSemanticSimilarity } from 'vs/workbench/api/common/extHostSemanticSimilarity'; import { ExtHostIssueReporter } from 'vs/workbench/api/common/extHostIssueReporter'; import { IExtHostManagedSockets } from 'vs/workbench/api/common/extHostManagedSockets'; +import { ExtHostShare } from 'vs/workbench/api/common/extHostShare'; export interface IExtensionRegistries { mine: ExtensionDescriptionRegistry; @@ -186,6 +187,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I const extHostQuickOpen = rpcProtocol.set(ExtHostContext.ExtHostQuickOpen, createExtHostQuickOpen(rpcProtocol, extHostWorkspace, extHostCommands)); const extHostSCM = rpcProtocol.set(ExtHostContext.ExtHostSCM, new ExtHostSCM(rpcProtocol, extHostCommands, extHostDocuments, extHostLogService)); const extHostQuickDiff = rpcProtocol.set(ExtHostContext.ExtHostQuickDiff, new ExtHostQuickDiff(rpcProtocol, uriTransformer)); + const extHostShare = rpcProtocol.set(ExtHostContext.ExtHostShare, new ExtHostShare(rpcProtocol, uriTransformer)); const extHostComment = rpcProtocol.set(ExtHostContext.ExtHostComments, createExtHostComments(rpcProtocol, extHostCommands, extHostDocuments)); const extHostProgress = rpcProtocol.set(ExtHostContext.ExtHostProgress, new ExtHostProgress(rpcProtocol.getProxy(MainContext.MainThreadProgress))); const extHostLabelService = rpcProtocol.set(ExtHostContext.ExtHosLabelService, new ExtHostLabelService(rpcProtocol)); @@ -842,6 +844,10 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I }, get tabGroups(): vscode.TabGroups { return extHostEditorTabs.tabGroups; + }, + registerShareProvider(selector: vscode.DocumentSelector, provider: vscode.ShareProvider): vscode.Disposable { + checkProposedApiEnabled(extension, 'shareProvider'); + return extHostShare.registerShareProvider(checkSelector(selector), provider); } }; diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 97d6f8a2741..70ead644b72 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -362,6 +362,11 @@ export interface IDocumentFilterDto { isBuiltin?: boolean; } +export interface IShareableItemDto { + resourceUri: UriComponents; + range?: IRange; +} + export interface ISignatureHelpProviderMetadataDto { readonly triggerCharacters: readonly string[]; readonly retriggerCharacters: readonly string[]; @@ -1247,6 +1252,11 @@ export interface MainThreadSearchShape extends IDisposable { $handleTelemetry(eventName: string, data: any): void; } +export interface MainThreadShareShape extends IDisposable { + $registerShareProvider(handle: number, selector: IDocumentFilterDto[], id: string, label: string): void; + $unregisterShareProvider(handle: number): void; +} + export interface MainThreadTaskShape extends IDisposable { $createTaskId(task: tasks.ITaskDTO): Promise; $registerTaskProvider(handle: number, type: string): Promise; @@ -2009,6 +2019,10 @@ export interface ExtHostQuickDiffShape { $provideOriginalResource(sourceControlHandle: number, uri: UriComponents, token: CancellationToken): Promise; } +export interface ExtHostShareShape { + $provideShare(handle: number, shareableItem: IShareableItemDto, token: CancellationToken): Promise; +} + export interface ExtHostTaskShape { $provideTasks(handle: number, validTypes: { [key: string]: boolean }): Promise; $resolveTask(handle: number, taskDTO: tasks.ITaskDTO): Promise; @@ -2525,6 +2539,7 @@ export const MainContext = { MainThreadExtensionService: createProxyIdentifier('MainThreadExtensionService'), MainThreadSCM: createProxyIdentifier('MainThreadSCM'), MainThreadSearch: createProxyIdentifier('MainThreadSearch'), + MainThreadShare: createProxyIdentifier('MainThreadShare'), MainThreadTask: createProxyIdentifier('MainThreadTask'), MainThreadWindow: createProxyIdentifier('MainThreadWindow'), MainThreadLabelService: createProxyIdentifier('MainThreadLabelService'), @@ -2565,6 +2580,7 @@ export const ExtHostContext = { ExtHostLanguageFeatures: createProxyIdentifier('ExtHostLanguageFeatures'), ExtHostQuickOpen: createProxyIdentifier('ExtHostQuickOpen'), ExtHostQuickDiff: createProxyIdentifier('ExtHostQuickDiff'), + ExtHostShare: createProxyIdentifier('ExtHostShare'), ExtHostExtensionService: createProxyIdentifier('ExtHostExtensionService'), ExtHostLogLevelServiceShape: createProxyIdentifier('ExtHostLogLevelServiceShape'), ExtHostTerminalService: createProxyIdentifier('ExtHostTerminalService'), diff --git a/src/vs/workbench/api/common/extHostShare.ts b/src/vs/workbench/api/common/extHostShare.ts new file mode 100644 index 00000000000..15acba45c47 --- /dev/null +++ b/src/vs/workbench/api/common/extHostShare.ts @@ -0,0 +1,43 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type * as vscode from 'vscode'; +import { ExtHostShareShape, IMainContext, IShareableItemDto, MainContext, MainThreadShareShape } from 'vs/workbench/api/common/extHost.protocol'; +import { DocumentSelector, Range } from 'vs/workbench/api/common/extHostTypeConverters'; +import { IURITransformer } from 'vs/base/common/uriIpc'; +import { CancellationToken } from 'vs/base/common/cancellation'; +import { URI, UriComponents } from 'vs/base/common/uri'; + +export class ExtHostShare implements ExtHostShareShape { + private static handlePool: number = 0; + + private proxy: MainThreadShareShape; + private providers: Map = new Map(); + + constructor( + mainContext: IMainContext, + private readonly uriTransformer: IURITransformer | undefined + ) { + this.proxy = mainContext.getProxy(MainContext.MainThreadShare); + } + + async $provideShare(handle: number, shareableItem: IShareableItemDto, token: CancellationToken): Promise { + const provider = this.providers.get(handle); + const result = await provider?.provideShare({ selection: Range.to(shareableItem.range), resourceUri: URI.revive(shareableItem.resourceUri) }, token); + return result ?? undefined; + } + + registerShareProvider(selector: vscode.DocumentSelector, provider: vscode.ShareProvider): vscode.Disposable { + const handle = ExtHostShare.handlePool++; + this.providers.set(handle, provider); + this.proxy.$registerShareProvider(handle, DocumentSelector.from(selector, this.uriTransformer), provider.id, provider.label); + return { + dispose: () => { + this.proxy.$unregisterShareProvider(handle); + this.providers.delete(handle); + } + }; + } +} diff --git a/src/vs/workbench/contrib/files/electron-sandbox/fileActions.contribution.ts b/src/vs/workbench/contrib/files/electron-sandbox/fileActions.contribution.ts index 189d94255c9..7c0c2ac1757 100644 --- a/src/vs/workbench/contrib/files/electron-sandbox/fileActions.contribution.ts +++ b/src/vs/workbench/contrib/files/electron-sandbox/fileActions.contribution.ts @@ -71,6 +71,12 @@ MenuRegistry.appendMenuItem(MenuId.OpenEditorsContext, { command: revealInOsCommand, when: REVEAL_IN_OS_WHEN_CONTEXT }); +MenuRegistry.appendMenuItem(MenuId.OpenEditorsContextShare, { + title: nls.localize('miShare', "Share"), + submenu: MenuId.MenubarShare, + group: 'share', + order: 3, +}); // Menu registration - explorer diff --git a/src/vs/workbench/contrib/scm/browser/menus.ts b/src/vs/workbench/contrib/scm/browser/menus.ts index 3f3648102d0..f1a041e1a7a 100644 --- a/src/vs/workbench/contrib/scm/browser/menus.ts +++ b/src/vs/workbench/contrib/scm/browser/menus.ts @@ -7,7 +7,7 @@ import 'vs/css!./media/scm'; import { Emitter } from 'vs/base/common/event'; import { IDisposable, DisposableStore, dispose } from 'vs/base/common/lifecycle'; import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; -import { IMenuService, MenuId, IMenu } from 'vs/platform/actions/common/actions'; +import { IMenuService, MenuId, IMenu, MenuRegistry } from 'vs/platform/actions/common/actions'; import { IAction } from 'vs/base/common/actions'; import { createAndFillInActionBarActions } from 'vs/platform/actions/browser/menuEntryActionViewItem'; import { ISCMResource, ISCMResourceGroup, ISCMProvider, ISCMRepository, ISCMService, ISCMMenus, ISCMRepositoryMenus } from 'vs/workbench/contrib/scm/common/scm'; @@ -15,6 +15,7 @@ import { equals } from 'vs/base/common/arrays'; import { ISplice } from 'vs/base/common/sequence'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { ServiceCollection } from 'vs/platform/instantiation/common/serviceCollection'; +import { localize } from 'vs/nls'; function actionEquals(a: IAction, b: IAction): boolean { return a.id === b.id; @@ -266,3 +267,10 @@ export class SCMMenus implements ISCMMenus, IDisposable { this.disposables.dispose(); } } + +MenuRegistry.appendMenuItem(MenuId.SCMResourceContext, { + title: localize('miShare', "Share"), + submenu: MenuId.SCMResourceContextShare, + group: '45_share', + order: 3, +}); diff --git a/src/vs/workbench/contrib/share/browser/share.contribution.ts b/src/vs/workbench/contrib/share/browser/share.contribution.ts new file mode 100644 index 00000000000..7b8386eb3ed --- /dev/null +++ b/src/vs/workbench/contrib/share/browser/share.contribution.ts @@ -0,0 +1,110 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationTokenSource } from 'vs/base/common/cancellation'; +import { Codicon } from 'vs/base/common/codicons'; +import { KeyCode, KeyMod } from 'vs/base/common/keyCodes'; +import { localize } from 'vs/nls'; +import { Action2, MenuId, MenuRegistry, registerAction2 } from 'vs/platform/actions/common/actions'; +import { IClipboardService } from 'vs/platform/clipboard/common/clipboardService'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { ContextKeyExpr } from 'vs/platform/contextkey/common/contextkey'; +import { IDialogService } from 'vs/platform/dialogs/common/dialogs'; +import { InstantiationType, registerSingleton } from 'vs/platform/instantiation/common/extensions'; +import { ServicesAccessor } from 'vs/platform/instantiation/common/instantiation'; +import { KeybindingWeight } from 'vs/platform/keybinding/common/keybindingsRegistry'; +import { Severity } from 'vs/platform/notification/common/notification'; +import { IOpenerService } from 'vs/platform/opener/common/opener'; +import { Registry } from 'vs/platform/registry/common/platform'; +import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace'; +import { WorkspaceFolderCountContext } from 'vs/workbench/common/contextkeys'; +import { Extensions, IWorkbenchContributionsRegistry } from 'vs/workbench/common/contributions'; +import { ShareProviderCountContext, ShareService } from 'vs/workbench/contrib/share/browser/shareService'; +import { IShareService } from 'vs/workbench/contrib/share/common/share'; +import { LifecyclePhase } from 'vs/workbench/services/lifecycle/common/lifecycle'; + +const targetMenus = [ + MenuId.EditorContextShare, + MenuId.SCMResourceContextShare, + MenuId.OpenEditorsContextShare, + MenuId.EditorTitleContextShare, + MenuId.MenubarShare, + // MenuId.EditorLineNumberContext, // todo@joyceerhl add share + MenuId.ExplorerContextShare +]; + +class ShareWorkbenchContribution { + private static SHARE_ENABLED_SETTING = 'workbench.experimental.share.enabled'; + + constructor( + @IShareService private readonly shareService: IShareService, + @IConfigurationService private readonly configurationService: IConfigurationService + ) { + if (this.configurationService.getValue(ShareWorkbenchContribution.SHARE_ENABLED_SETTING)) { + this.registerActions(); + } + } + + private registerActions() { + registerAction2(class ShareAction extends Action2 { + static readonly ID = 'workbench.action.share'; + static readonly LABEL = localize('share', 'Share...'); + + constructor() { + super({ + id: ShareAction.ID, + title: { value: ShareAction.LABEL, original: 'Share...' }, + f1: true, + icon: Codicon.linkExternal, + precondition: ContextKeyExpr.and(ShareProviderCountContext.notEqualsTo(0), WorkspaceFolderCountContext.notEqualsTo(0)), + keybinding: { + weight: KeybindingWeight.WorkbenchContrib, + primary: KeyMod.Alt | KeyMod.CtrlCmd | KeyCode.KeyS, + }, + menu: [ + { id: MenuId.CommandCenter, order: 1000 } + ] + }); + } + + override async run(accessor: ServicesAccessor, ...args: any[]): Promise { + const shareService = accessor.get(IShareService); + const resourceUri = accessor.get(IWorkspaceContextService).getWorkspace().folders[0].uri; + const clipboardService = accessor.get(IClipboardService); + const dialogService = accessor.get(IDialogService); + const urlService = accessor.get(IOpenerService); + + const uri = await shareService.provideShare({ resourceUri }, new CancellationTokenSource().token); + if (uri) { + await clipboardService.writeText(uri.toString()); + const result = await dialogService.input( + { + type: Severity.Info, + inputs: [{ type: 'text', value: uri.toString() }], + message: localize('shareSuccess', 'Copied link to clipboard!'), + custom: { icon: Codicon.check }, + primaryButton: localize('open link', 'Open Link') + } + ); + if (result.confirmed) { + urlService.open(uri, { openExternal: true }); + } + } + } + }); + + const actions = this.shareService.getShareActions(); + for (const menuId of targetMenus) { + for (const action of actions) { + // todo@joyceerhl avoid duplicates + MenuRegistry.appendMenuItem(menuId, action); + } + } + } +} + +registerSingleton(IShareService, ShareService, InstantiationType.Delayed); +const workbenchContributionsRegistry = Registry.as(Extensions.Workbench); +workbenchContributionsRegistry.registerWorkbenchContribution(ShareWorkbenchContribution, LifecyclePhase.Eventually); diff --git a/src/vs/workbench/contrib/share/browser/shareService.ts b/src/vs/workbench/contrib/share/browser/shareService.ts new file mode 100644 index 00000000000..8e471a1593f --- /dev/null +++ b/src/vs/workbench/contrib/share/browser/shareService.ts @@ -0,0 +1,63 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from 'vs/base/common/cancellation'; +import { IDisposable } from 'vs/base/common/lifecycle'; +import { URI } from 'vs/base/common/uri'; +import { localize } from 'vs/nls'; +import { ISubmenuItem } from 'vs/platform/actions/common/actions'; +import { IContextKey, IContextKeyService, RawContextKey } from 'vs/platform/contextkey/common/contextkey'; +import { ILabelService } from 'vs/platform/label/common/label'; +import { IQuickInputService, IQuickPickItem } from 'vs/platform/quickinput/common/quickInput'; +import { IShareProvider, IShareService, IShareableItem } from 'vs/workbench/contrib/share/common/share'; + +export const ShareProviderCountContext = new RawContextKey('shareProviderCount', 0, localize('shareProviderCount', "The number of available share providers")); + +export class ShareService implements IShareService { + readonly _serviceBrand: undefined; + + readonly providerCount: IContextKey; + private readonly _providers = new Set(); + + constructor( + @IContextKeyService private contextKeyService: IContextKeyService, + @ILabelService private readonly labelService: ILabelService, + @IQuickInputService private quickInputService: IQuickInputService + ) { + this.providerCount = ShareProviderCountContext.bindTo(this.contextKeyService); + } + + registerShareProvider(provider: IShareProvider): IDisposable { + this._providers.add(provider); + this.providerCount.set(this._providers.size); + return { + dispose: () => { + this._providers.delete(provider); + this.providerCount.set(this._providers.size); + } + }; + } + + getShareActions(): ISubmenuItem[] { + // todo@joyceerhl return share actions + return []; + } + + async provideShare(item: IShareableItem, token: CancellationToken): Promise { + const providers = [...this._providers.values()]; + + if (providers.length === 0) { + return undefined; + } + + if (providers.length === 1) { + return providers[0].provideShare(item, token); + } + + const items: (IQuickPickItem & { provider: IShareProvider })[] = providers.map((p) => ({ label: p.label, provider: p })); + const selected = await this.quickInputService.pick(items, { canPickMany: false, placeHolder: localize('type to filter', 'Choose how to share {0}', this.labelService.getUriLabel(item.resourceUri)) }, token); + return selected?.provider.provideShare(item, token); + } +} diff --git a/src/vs/workbench/contrib/share/common/share.ts b/src/vs/workbench/contrib/share/common/share.ts new file mode 100644 index 00000000000..dcde5d780ad --- /dev/null +++ b/src/vs/workbench/contrib/share/common/share.ts @@ -0,0 +1,34 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from 'vs/base/common/cancellation'; +import { IDisposable } from 'vs/base/common/lifecycle'; +import { IRange } from 'vs/base/common/range'; +import { URI } from 'vs/base/common/uri'; +import { LanguageSelector } from 'vs/editor/common/languageSelector'; +import { ISubmenuItem } from 'vs/platform/actions/common/actions'; +import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; + +export interface IShareableItem { + resourceUri: URI; + location?: IRange; +} + +export interface IShareProvider { + readonly id: string; + readonly label: string; + readonly selector: LanguageSelector; + prepareShare?(item: IShareableItem, token: CancellationToken): Thenable; + provideShare(item: IShareableItem, token: CancellationToken): Thenable; +} + +export const IShareService = createDecorator('shareService'); +export interface IShareService { + _serviceBrand: undefined; + + registerShareProvider(provider: IShareProvider): IDisposable; + getShareActions(): ISubmenuItem[]; + provideShare(item: IShareableItem, token: CancellationToken): Thenable; +} diff --git a/src/vs/workbench/services/extensions/common/extensionsApiProposals.ts b/src/vs/workbench/services/extensions/common/extensionsApiProposals.ts index 8e56592a04c..58dac72b165 100644 --- a/src/vs/workbench/services/extensions/common/extensionsApiProposals.ts +++ b/src/vs/workbench/services/extensions/common/extensionsApiProposals.ts @@ -73,6 +73,7 @@ export const allApiProposals = Object.freeze({ scmTextDocument: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.scmTextDocument.d.ts', scmValidation: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.scmValidation.d.ts', semanticSimilarity: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.semanticSimilarity.d.ts', + shareProvider: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.shareProvider.d.ts', showLocal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.showLocal.d.ts', tabInputTextMerge: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.tabInputTextMerge.d.ts', taskPresentationGroup: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.taskPresentationGroup.d.ts', diff --git a/src/vs/workbench/workbench.common.main.ts b/src/vs/workbench/workbench.common.main.ts index 7561fbf1fb9..e1de203f9cf 100644 --- a/src/vs/workbench/workbench.common.main.ts +++ b/src/vs/workbench/workbench.common.main.ts @@ -368,4 +368,7 @@ import 'vs/workbench/contrib/bracketPairColorizer2Telemetry/browser/bracketPairC // Accessibility import 'vs/workbench/contrib/accessibility/browser/accessibility.contribution'; +// Share +import 'vs/workbench/contrib/share/browser/share.contribution'; + //#endregion diff --git a/src/vscode-dts/vscode.proposed.shareProvider.d.ts b/src/vscode-dts/vscode.proposed.shareProvider.d.ts new file mode 100644 index 00000000000..6470557cac1 --- /dev/null +++ b/src/vscode-dts/vscode.proposed.shareProvider.d.ts @@ -0,0 +1,29 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// https://github.com/microsoft/vscode/issues/176316 + +declare module 'vscode' { + export interface TreeItem { + shareableItem?: ShareableItem; + } + + export interface ShareableItem { + resourceUri: Uri; + selection?: Range; + } + + export interface ShareProvider { + readonly id: string; + readonly label: string; + readonly priority: number; + + provideShare(item: ShareableItem, token: CancellationToken): ProviderResult; + } + + export namespace window { + export function registerShareProvider(selector: DocumentSelector, provider: ShareProvider): Disposable; + } +}