From ce0128453165d9a9493a7a958fcfaa4b0a2c6e9e Mon Sep 17 00:00:00 2001 From: Mark Sujew Date: Tue, 21 Jun 2022 14:29:07 +0200 Subject: [PATCH] Update `Configure Display Language` command (#11289) --- dev-packages/ovsx-client/src/ovsx-types.ts | 1 + .../browser/common-frontend-contribution.ts | 29 ++-- .../src/browser/i18n/i18n-frontend-module.ts | 2 + .../i18n/language-quick-pick-service.ts | 128 ++++++++++++++++++ packages/core/src/common/nls.ts | 11 ++ .../src/browser/style/notifications.css | 16 +-- .../src/browser/monaco-quick-input-service.ts | 26 +++- packages/monaco/src/browser/style/index.css | 7 + .../src/common/plugin-vscode-uri.ts | 10 +- .../vsx-language-quick-pick-service.ts | 97 +++++++++++++ .../browser/vsx-registry-frontend-module.ts | 6 +- 11 files changed, 293 insertions(+), 40 deletions(-) create mode 100644 packages/core/src/browser/i18n/language-quick-pick-service.ts create mode 100644 packages/vsx-registry/src/browser/vsx-language-quick-pick-service.ts diff --git a/dev-packages/ovsx-client/src/ovsx-types.ts b/dev-packages/ovsx-client/src/ovsx-types.ts index 6de67c364eb2d..17e9d596fa4a4 100644 --- a/dev-packages/ovsx-client/src/ovsx-types.ts +++ b/dev-packages/ovsx-client/src/ovsx-types.ts @@ -76,6 +76,7 @@ export interface VSXSearchEntry { readonly url: string; readonly files: { download: string + manifest?: string readme?: string license?: string icon?: string diff --git a/packages/core/src/browser/common-frontend-contribution.ts b/packages/core/src/browser/common-frontend-contribution.ts index 0c328367829d9..767d900287ca8 100644 --- a/packages/core/src/browser/common-frontend-contribution.ts +++ b/packages/core/src/browser/common-frontend-contribution.ts @@ -64,6 +64,7 @@ import { isPinned, Title, togglePinned, Widget } from './widgets'; import { SaveResourceService } from './save-resource-service'; import { UserWorkingDirectoryProvider } from './user-working-directory-provider'; import { createUntitledURI } from '../common'; +import { LanguageQuickPickService } from './i18n/language-quick-pick-service'; export namespace CommonMenus { @@ -400,6 +401,9 @@ export class CommonFrontendContribution implements FrontendApplicationContributi @inject(UserWorkingDirectoryProvider) protected readonly workingDirProvider: UserWorkingDirectoryProvider; + @inject(LanguageQuickPickService) + protected readonly languageQuickPickService: LanguageQuickPickService; + protected pinnedKey: ContextKey; async configure(app: FrontendApplication): Promise { @@ -1142,27 +1146,12 @@ export class CommonFrontendContribution implements FrontendApplicationContributi } protected async configureDisplayLanguage(): Promise { - const availableLanguages = await this.localizationProvider.getAvailableLanguages(); - const items: QuickPickItem[] = []; - for (const languageId of ['en', ...availableLanguages.map(e => e.languageId)]) { - if (typeof languageId === 'string') { - items.push({ - label: languageId, - execute: async () => { - if (languageId !== nls.locale && await this.confirmRestart()) { - this.windowService.setSafeToShutDown(); - window.localStorage.setItem(nls.localeId, languageId); - this.windowService.reload(); - } - } - }); - } + const languageId = await this.languageQuickPickService.pickDisplayLanguage(); + if (languageId && !nls.isSelectedLocale(languageId) && await this.confirmRestart()) { + nls.setLocale(languageId); + this.windowService.setSafeToShutDown(); + this.windowService.reload(); } - this.quickInputService?.showQuickPick(items, - { - placeholder: CommonCommands.CONFIGURE_DISPLAY_LANGUAGE.label, - activeItem: items.find(item => item.label === (nls.locale || 'en')) - }); } protected async confirmRestart(): Promise { diff --git a/packages/core/src/browser/i18n/i18n-frontend-module.ts b/packages/core/src/browser/i18n/i18n-frontend-module.ts index 9a06aafcd7979..7f8ba9f6f69db 100644 --- a/packages/core/src/browser/i18n/i18n-frontend-module.ts +++ b/packages/core/src/browser/i18n/i18n-frontend-module.ts @@ -17,9 +17,11 @@ import { ContainerModule } from 'inversify'; import { AsyncLocalizationProvider, localizationPath } from '../../common/i18n/localization'; import { WebSocketConnectionProvider } from '../messaging/ws-connection-provider'; +import { LanguageQuickPickService } from './language-quick-pick-service'; export default new ContainerModule(bind => { bind(AsyncLocalizationProvider).toDynamicValue( ctx => ctx.container.get(WebSocketConnectionProvider).createProxy(localizationPath) ).inSingletonScope(); + bind(LanguageQuickPickService).toSelf().inSingletonScope(); }); diff --git a/packages/core/src/browser/i18n/language-quick-pick-service.ts b/packages/core/src/browser/i18n/language-quick-pick-service.ts new file mode 100644 index 0000000000000..fed5f81dac0bc --- /dev/null +++ b/packages/core/src/browser/i18n/language-quick-pick-service.ts @@ -0,0 +1,128 @@ +// ***************************************************************************** +// Copyright (C) 2022 TypeFox and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { inject, injectable } from 'inversify'; +import { nls } from '../../common/nls'; +import { AsyncLocalizationProvider, LanguageInfo } from '../../common/i18n/localization'; +import { QuickInputService, QuickPickItem, QuickPickSeparator } from '../quick-input'; +import { WindowService } from '../window/window-service'; + +export interface LanguageQuickPickItem extends QuickPickItem { + languageId: string + execute?(): Promise +} + +@injectable() +export class LanguageQuickPickService { + + @inject(QuickInputService) protected readonly quickInputService: QuickInputService; + @inject(AsyncLocalizationProvider) protected readonly localizationProvider: AsyncLocalizationProvider; + @inject(WindowService) protected readonly windowService: WindowService; + + async pickDisplayLanguage(): Promise { + const quickInput = this.quickInputService.createQuickPick(); + const installedItems = await this.getInstalledLanguages(); + const quickInputItems: (LanguageQuickPickItem | QuickPickSeparator)[] = [ + { + type: 'separator', + label: nls.localize('theia/core/installedLanguages', 'Installed languages') + }, + ...installedItems + ]; + quickInput.items = quickInputItems; + quickInput.busy = true; + const selected = installedItems.find(item => nls.isSelectedLocale(item.languageId)); + if (selected) { + quickInput.activeItems = [selected]; + } + quickInput.placeholder = nls.localizeByDefault('Configure Display Language'); + quickInput.show(); + + this.getAvailableLanguages().then(availableItems => { + if (availableItems.length > 0) { + quickInputItems.push({ + type: 'separator', + label: nls.localize('theia/core/availableLanguages', 'Available languages') + }); + const installed = new Set(installedItems.map(e => e.languageId)); + for (const available of availableItems) { + // Exclude already installed languages + if (!installed.has(available.languageId)) { + quickInputItems.push(available); + } + } + quickInput.items = quickInputItems; + } + }).finally(() => { + quickInput.busy = false; + }); + + return new Promise(resolve => { + quickInput.onDidAccept(async () => { + const selectedItem = quickInput.selectedItems[0]; + if (selectedItem) { + // Some language quick pick items want to install additional languages + // We have to await that before returning the selected locale + await selectedItem.execute?.(); + resolve(selectedItem.languageId); + } else { + resolve(undefined); + } + }); + quickInput.onDidHide(() => { + resolve(undefined); + }); + }); + } + + protected async getInstalledLanguages(): Promise { + const languageInfos = await this.localizationProvider.getAvailableLanguages(); + const items: LanguageQuickPickItem[] = []; + const en: LanguageInfo = { + languageId: 'en', + languageName: 'English', + localizedLanguageName: 'English' + }; + languageInfos.push(en); + for (const language of languageInfos.filter(e => !!e.languageId)) { + items.push(this.createLanguageQuickPickItem(language)); + } + return items; + } + + protected async getAvailableLanguages(): Promise { + return []; + } + + protected createLanguageQuickPickItem(language: LanguageInfo): LanguageQuickPickItem { + let label: string; + let description: string | undefined; + const languageName = language.localizedLanguageName || language.languageName; + const id = language.languageId; + const idLabel = id + (nls.isSelectedLocale(id) ? ` (${nls.localizeByDefault('Current')})` : ''); + if (languageName) { + label = languageName; + description = idLabel; + } else { + label = idLabel; + } + return { + label, + description, + languageId: id + }; + } +} diff --git a/packages/core/src/common/nls.ts b/packages/core/src/common/nls.ts index 8ad3cd9c468f9..04d71764624e6 100644 --- a/packages/core/src/common/nls.ts +++ b/packages/core/src/common/nls.ts @@ -55,6 +55,17 @@ export namespace nls { export function localize(key: string, defaultValue: string, ...args: FormatType[]): string { return Localization.localize(localization, key, defaultValue, ...args); } + + export function isSelectedLocale(id: string): boolean { + if (locale === undefined && id === 'en') { + return true; + } + return locale === id; + } + + export function setLocale(id: string): void { + window.localStorage.setItem(localeId, id); + } } interface NlsKeys { diff --git a/packages/messages/src/browser/style/notifications.css b/packages/messages/src/browser/style/notifications.css index fb4b784aa04c8..e7ff0283af241 100644 --- a/packages/messages/src/browser/style/notifications.css +++ b/packages/messages/src/browser/style/notifications.css @@ -242,24 +242,10 @@ } .theia-notification-item-progressbar.indeterminate { + /* `progress-animation` is defined in `packages/core/src/browser/style/progress-bar.css` */ animation: progress-animation 1.3s 0s infinite cubic-bezier(0.645, 0.045, 0.355, 1); } -@keyframes progress-animation { - 0% { - margin-left: 0%; - width: 3%; - } - 60% { - margin-left: 45%; - width: 20%; - } - 100% { - margin-left: 99%; - width: 1%; - } -} - /* Perfect scrollbar */ .theia-notification-list-scroll-container .ps__rail-y { diff --git a/packages/monaco/src/browser/monaco-quick-input-service.ts b/packages/monaco/src/browser/monaco-quick-input-service.ts index 691f5a9d71fab..595fa969f3798 100644 --- a/packages/monaco/src/browser/monaco-quick-input-service.ts +++ b/packages/monaco/src/browser/monaco-quick-input-service.ts @@ -488,11 +488,17 @@ class MonacoQuickPick extends MonacoQuickInput implemen } set items(itms: readonly (T | QuickPickSeparator)[]) { + // We need to store and apply the currently selected active items. + // Since monaco compares these items by reference equality, creating new wrapped items will unmark any active items. + // Assigning the `activeItems` again will restore all active items even after the items array has changed. + // See also the `findMonacoItemReferences` method. + const active = this.activeItems; this.wrapped.items = itms.map(item => QuickPickSeparator.is(item) ? item : new MonacoQuickPickItem(item, this.keybindingRegistry)); + this.activeItems = active; } set activeItems(itms: readonly T[]) { - this.wrapped.activeItems = itms.map(item => new MonacoQuickPickItem(item, this.keybindingRegistry)); + this.wrapped.activeItems = this.findMonacoItemReferences(this.wrapped.items, itms); } get activeItems(): readonly (T)[] { @@ -500,7 +506,7 @@ class MonacoQuickPick extends MonacoQuickInput implemen } set selectedItems(itms: readonly T[]) { - this.wrapped.selectedItems = itms.map(item => new MonacoQuickPickItem(item, this.keybindingRegistry)); + this.wrapped.selectedItems = this.findMonacoItemReferences(this.wrapped.items, itms); } get selectedItems(): readonly (T)[] { @@ -520,6 +526,22 @@ class MonacoQuickPick extends MonacoQuickInput implemen (items: MonacoQuickPickItem[]) => items.map(item => item.item)); readonly onDidChangeSelection: Event = Event.map( this.wrapped.onDidChangeSelection, (items: MonacoQuickPickItem[]) => items.map(item => item.item)); + + /** + * Monaco doesn't check for deep equality when setting the `activeItems` or `selectedItems`. + * Instead we have to find the references of the monaco wrappers that contain the selected/active items + */ + protected findMonacoItemReferences(source: readonly (MonacoQuickPickItem | IQuickPickSeparator)[], items: readonly QuickPickItem[]): MonacoQuickPickItem[] { + const monacoReferences: MonacoQuickPickItem[] = []; + for (const item of items) { + for (const wrappedItem of source) { + if (!QuickPickSeparator.is(wrappedItem) && wrappedItem.item === item) { + monacoReferences.push(wrappedItem); + } + } + } + return monacoReferences; + } } export class MonacoQuickPickItem implements IQuickPickItem { diff --git a/packages/monaco/src/browser/style/index.css b/packages/monaco/src/browser/style/index.css index c1af474c3e5d0..81a40d6b86773 100644 --- a/packages/monaco/src/browser/style/index.css +++ b/packages/monaco/src/browser/style/index.css @@ -227,6 +227,13 @@ cursor: pointer !important; } +.quick-input-progress.active.infinite { + background-color: var(--theia-progressBar-background); + width: 3%; + /* `progress-animation` is defined in `packages/core/src/browser/style/progress-bar.css` */ + animation: progress-animation 1.3s 0s infinite cubic-bezier(0.645, 0.045, 0.355, 1); +} + .monaco-list:not(.drop-target) .monaco-list-row:hover:not(.selected):not(.focused) { background: var(--theia-list-hoverBackground); } diff --git a/packages/plugin-ext-vscode/src/common/plugin-vscode-uri.ts b/packages/plugin-ext-vscode/src/common/plugin-vscode-uri.ts index ddcbb76c4cb93..1ab790576024d 100644 --- a/packages/plugin-ext-vscode/src/common/plugin-vscode-uri.ts +++ b/packages/plugin-ext-vscode/src/common/plugin-vscode-uri.ts @@ -29,8 +29,14 @@ export namespace VSCodeExtensionUri { export function toVsxExtensionUriString(id: string): string { return `${VSCODE_PREFIX}${id}`; } - export function toUri(id: string): URI { - return new URI(toVsxExtensionUriString(id)); + export function toUri(name: string, namespace: string): URI; + export function toUri(id: string): URI; + export function toUri(idOrName: string, namespace?: string): URI { + if (typeof namespace === 'string') { + return new URI(toVsxExtensionUriString(`${namespace}.${idOrName}`)); + } else { + return new URI(toVsxExtensionUriString(idOrName)); + } } export function toId(uri: URI): string | undefined { if (uri.scheme === 'vscode' && uri.path.dir.toString() === 'extension') { diff --git a/packages/vsx-registry/src/browser/vsx-language-quick-pick-service.ts b/packages/vsx-registry/src/browser/vsx-language-quick-pick-service.ts new file mode 100644 index 0000000000000..0780aa3370c7c --- /dev/null +++ b/packages/vsx-registry/src/browser/vsx-language-quick-pick-service.ts @@ -0,0 +1,97 @@ +// ***************************************************************************** +// Copyright (C) 2022 TypeFox and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { LanguageQuickPickItem, LanguageQuickPickService } from '@theia/core/lib/browser/i18n/language-quick-pick-service'; +import { RequestContext, RequestService } from '@theia/core/shared/@theia/request'; +import { inject, injectable } from '@theia/core/shared/inversify'; +import { LanguageInfo } from '@theia/core/lib/common/i18n/localization'; +import { PluginPackage, PluginServer } from '@theia/plugin-ext'; +import { OVSXClientProvider } from '../common/ovsx-client-provider'; +import { VSXSearchEntry } from '@theia/ovsx-client'; +import { VSCodeExtensionUri } from '@theia/plugin-ext-vscode/lib/common/plugin-vscode-uri'; + +@injectable() +export class VSXLanguageQuickPickService extends LanguageQuickPickService { + + @inject(OVSXClientProvider) + protected readonly clientProvider: OVSXClientProvider; + + @inject(RequestService) + protected readonly requestService: RequestService; + + @inject(PluginServer) + protected readonly pluginServer: PluginServer; + + protected override async getAvailableLanguages(): Promise { + const client = await this.clientProvider(); + const searchResult = await client.search({ + category: 'Language Packs', + sortBy: 'downloadCount', + sortOrder: 'desc', + size: 20 + }); + if (searchResult.error) { + throw new Error('Error while loading available languages: ' + searchResult.error); + } + + const extensionLanguages = await Promise.all( + searchResult.extensions.map(async extension => ({ + extension, + languages: await this.loadExtensionLanguages(extension) + })) + ); + + const languages = new Map(); + + for (const extension of extensionLanguages) { + for (const localizationContribution of extension.languages) { + if (!languages.has(localizationContribution.languageId)) { + languages.set(localizationContribution.languageId, { + ...this.createLanguageQuickPickItem(localizationContribution), + execute: async () => { + const extensionUri = VSCodeExtensionUri.toUri(extension.extension.name, extension.extension.namespace).toString(); + await this.pluginServer.deploy(extensionUri); + } + }); + } + } + } + return Array.from(languages.values()); + } + + protected async loadExtensionLanguages(extension: VSXSearchEntry): Promise { + // When searching for extensions on ovsx, we don't receive the `manifest` property. + // This property is only set when querying a specific extension. + // To improve performance, we assume that a manifest exists at `/package.json`. + const downloadUrl = extension.files.download; + const parentUrl = downloadUrl.substring(0, downloadUrl.lastIndexOf('/')); + const manifestUrl = parentUrl + '/package.json'; + try { + const manifestRequest = await this.requestService.request({ url: manifestUrl }); + const manifestContent = RequestContext.asJson(manifestRequest); + const localizations = manifestContent.contributes?.localizations ?? []; + return localizations.map(e => ({ + languageId: e.languageId, + languageName: e.languageName, + localizedLanguageName: e.localizedLanguageName, + languagePack: true + })); + } catch { + // The `package.json` file might not actually exist, simply return an empty array + return []; + } + } +} diff --git a/packages/vsx-registry/src/browser/vsx-registry-frontend-module.ts b/packages/vsx-registry/src/browser/vsx-registry-frontend-module.ts index 4331e9c9a9f92..59e71c2fef2fb 100644 --- a/packages/vsx-registry/src/browser/vsx-registry-frontend-module.ts +++ b/packages/vsx-registry/src/browser/vsx-registry-frontend-module.ts @@ -36,8 +36,10 @@ import { bindPreferenceProviderOverrides } from './recommended-extensions/prefer import { OVSXClientProvider, createOVSXClient } from '../common/ovsx-client-provider'; import { VSXEnvironment, VSX_ENVIRONMENT_PATH } from '../common/vsx-environment'; import { RequestService } from '@theia/core/shared/@theia/request'; +import { LanguageQuickPickService } from '@theia/core/lib/browser/i18n/language-quick-pick-service'; +import { VSXLanguageQuickPickService } from './vsx-language-quick-pick-service'; -export default new ContainerModule((bind, unbind) => { +export default new ContainerModule((bind, unbind, _, rebind) => { bind(OVSXClientProvider).toDynamicValue(ctx => { const clientPromise = createOVSXClient(ctx.container.get(VSXEnvironment), ctx.container.get(RequestService)); return () => clientPromise; @@ -100,6 +102,8 @@ export default new ContainerModule((bind, unbind) => { bind(VSXExtensionsSearchModel).toSelf().inSingletonScope(); bind(VSXExtensionsSearchBar).toSelf().inSingletonScope(); + rebind(LanguageQuickPickService).to(VSXLanguageQuickPickService).inSingletonScope(); + bindViewContribution(bind, VSXExtensionsContribution); bind(FrontendApplicationContribution).toService(VSXExtensionsContribution); bind(ColorContribution).toService(VSXExtensionsContribution);