From 627373402f46080a8fb572e0265df078e5567355 Mon Sep 17 00:00:00 2001 From: Akos Kitta Date: Wed, 14 Dec 2022 15:14:43 +0100 Subject: [PATCH] feat: configure sketchbook location without restart Closes #1764 Closes #796 Closes #569 Closes #655 Signed-off-by: Akos Kitta --- .../browser/arduino-ide-frontend-module.ts | 3 + .../browser/config/config-service-client.ts | 106 ++++++++ .../browser/contributions/add-zip-library.ts | 4 - .../browser/contributions/archive-sketch.ts | 12 +- .../browser/contributions/board-selection.ts | 5 +- .../src/browser/contributions/contribution.ts | 29 ++- .../src/browser/contributions/examples.ts | 20 +- .../browser/contributions/include-library.ts | 1 + .../src/browser/contributions/open-sketch.ts | 5 +- .../browser/contributions/save-as-sketch.ts | 7 +- .../src/browser/contributions/sketchbook.ts | 1 + .../src/browser/dialogs/settings/settings.ts | 28 ++- .../local-cache/local-cache-fs-provider.ts | 21 +- .../src/browser/menu/arduino-menus.ts | 5 + .../src/browser/notification-center.ts | 15 +- .../browser/theia/core/tab-bar-decorator.ts | 33 +-- .../browser/theia/markers/problem-manager.ts | 20 +- .../sketchbook/sketchbook-tree-model.ts | 26 +- .../src/common/protocol/config-service.ts | 28 ++- .../common/protocol/notification-service.ts | 9 +- .../protocol/sketches-service-client-impl.ts | 238 +++++++++--------- .../theia/core/electron-main-menu-factory.ts | 40 ++- .../src/node/clang-formatter.ts | 17 +- .../src/node/config-service-impl.ts | 166 ++++++++---- .../src/node/core-client-provider.ts | 25 +- .../src/node/notification-service-server.ts | 8 +- .../src/node/sketches-service-impl.ts | 35 ++- i18n/en.json | 6 + 28 files changed, 651 insertions(+), 262 deletions(-) create mode 100644 arduino-ide-extension/src/browser/config/config-service-client.ts diff --git a/arduino-ide-extension/src/browser/arduino-ide-frontend-module.ts b/arduino-ide-extension/src/browser/arduino-ide-frontend-module.ts index 22e791c56..b4f11b72a 100644 --- a/arduino-ide-extension/src/browser/arduino-ide-frontend-module.ts +++ b/arduino-ide-extension/src/browser/arduino-ide-frontend-module.ts @@ -343,6 +343,7 @@ import { DebugWidget } from '@theia/debug/lib/browser/view/debug-widget'; import { DebugViewModel } from '@theia/debug/lib/browser/view/debug-view-model'; import { DebugSessionWidget } from '@theia/debug/lib/browser/view/debug-session-widget'; import { DebugConfigurationWidget } from '@theia/debug/lib/browser/view/debug-configuration-widget'; +import { ConfigServiceClient } from './config/config-service-client'; export default new ContainerModule((bind, unbind, isBound, rebind) => { // Commands and toolbar items @@ -404,6 +405,8 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { ) ) .inSingletonScope(); + bind(ConfigServiceClient).toSelf().inSingletonScope(); + bind(FrontendApplicationContribution).toService(ConfigServiceClient); // Boards service bind(BoardsService) diff --git a/arduino-ide-extension/src/browser/config/config-service-client.ts b/arduino-ide-extension/src/browser/config/config-service-client.ts new file mode 100644 index 000000000..b0ff79fd8 --- /dev/null +++ b/arduino-ide-extension/src/browser/config/config-service-client.ts @@ -0,0 +1,106 @@ +import { FrontendApplicationContribution } from '@theia/core/lib/browser/frontend-application'; +import { FrontendApplicationStateService } from '@theia/core/lib/browser/frontend-application-state'; +import { DisposableCollection } from '@theia/core/lib/common/disposable'; +import { Emitter, Event } from '@theia/core/lib/common/event'; +import { MessageService } from '@theia/core/lib/common/message-service'; +import { deepClone } from '@theia/core/lib/common/objects'; +import URI from '@theia/core/lib/common/uri'; +import { + inject, + injectable, + postConstruct, +} from '@theia/core/shared/inversify'; +import { ConfigService, ConfigState } from '../../common/protocol'; +import { NotificationCenter } from '../notification-center'; + +@injectable() +export class ConfigServiceClient implements FrontendApplicationContribution { + @inject(ConfigService) + private readonly delegate: ConfigService; + @inject(NotificationCenter) + private readonly notificationCenter: NotificationCenter; + @inject(FrontendApplicationStateService) + private readonly appStateService: FrontendApplicationStateService; + @inject(MessageService) + private readonly messageService: MessageService; + + private readonly didChangeSketchDirUriEmitter = new Emitter< + URI | undefined + >(); + private readonly didChangeDataDirUriEmitter = new Emitter(); + private readonly toDispose = new DisposableCollection( + this.didChangeSketchDirUriEmitter, + this.didChangeDataDirUriEmitter + ); + + private config: ConfigState | undefined; + + @postConstruct() + protected init(): void { + this.appStateService.reachedState('ready').then(async () => { + const config = await this.fetchConfig(); + this.use(config); + }); + } + + onStart(): void { + this.notificationCenter.onConfigDidChange((config) => this.use(config)); + } + + onStop(): void { + this.toDispose.dispose(); + } + + get onDidChangeSketchDirUri(): Event { + return this.didChangeSketchDirUriEmitter.event; + } + + get onDidChangeDataDirUri(): Event { + return this.didChangeDataDirUriEmitter.event; + } + + async fetchConfig(): Promise { + return this.delegate.getConfiguration(); + } + + /** + * CLI config related error messages if any. + */ + tryGetMessages(): string[] | undefined { + return this.config?.messages; + } + + /** + * `directories.user` + */ + tryGetSketchDirUri(): URI | undefined { + return this.config?.config?.sketchDirUri + ? new URI(this.config?.config?.sketchDirUri) + : undefined; + } + + /** + * `directories.data` + */ + tryGetDataDirUri(): URI | undefined { + return this.config?.config?.dataDirUri + ? new URI(this.config?.config?.dataDirUri) + : undefined; + } + + private use(config: ConfigState): void { + const oldConfig = deepClone(this.config); + this.config = config; + if (oldConfig?.config?.sketchDirUri !== this.config?.config?.sketchDirUri) { + this.didChangeSketchDirUriEmitter.fire(this.tryGetSketchDirUri()); + } + if (oldConfig?.config?.dataDirUri !== this.config?.config?.dataDirUri) { + this.didChangeDataDirUriEmitter.fire(this.tryGetDataDirUri()); + } + if (this.config.messages?.length) { + const message = this.config.messages.join(' '); + // toast the error later otherwise it might not show up in IDE2 + setTimeout(() => this.messageService.error(message), 1_000); + } + } +} diff --git a/arduino-ide-extension/src/browser/contributions/add-zip-library.ts b/arduino-ide-extension/src/browser/contributions/add-zip-library.ts index 13edb03f0..0fdc27121 100644 --- a/arduino-ide-extension/src/browser/contributions/add-zip-library.ts +++ b/arduino-ide-extension/src/browser/contributions/add-zip-library.ts @@ -2,7 +2,6 @@ import { inject, injectable } from '@theia/core/shared/inversify'; import * as remote from '@theia/core/electron-shared/@electron/remote'; import URI from '@theia/core/lib/common/uri'; import { ConfirmDialog } from '@theia/core/lib/browser/dialogs'; -import { EnvVariablesServer } from '@theia/core/lib/common/env-variables'; import { ArduinoMenus } from '../menu/arduino-menus'; import { LibraryService, ResponseServiceClient } from '../../common/protocol'; import { ExecuteWithProgress } from '../../common/protocol/progressible'; @@ -16,9 +15,6 @@ import { nls } from '@theia/core/lib/common'; @injectable() export class AddZipLibrary extends SketchContribution { - @inject(EnvVariablesServer) - private readonly envVariableServer: EnvVariablesServer; - @inject(ResponseServiceClient) private readonly responseService: ResponseServiceClient; diff --git a/arduino-ide-extension/src/browser/contributions/archive-sketch.ts b/arduino-ide-extension/src/browser/contributions/archive-sketch.ts index 80da956e6..e1d11e507 100644 --- a/arduino-ide-extension/src/browser/contributions/archive-sketch.ts +++ b/arduino-ide-extension/src/browser/contributions/archive-sketch.ts @@ -1,7 +1,6 @@ import { injectable } from '@theia/core/shared/inversify'; import * as remote from '@theia/core/electron-shared/@electron/remote'; import * as dateFormat from 'dateformat'; -import URI from '@theia/core/lib/common/uri'; import { ArduinoMenus } from '../menu/arduino-menus'; import { SketchContribution, @@ -29,10 +28,7 @@ export class ArchiveSketch extends SketchContribution { } private async archiveSketch(): Promise { - const [sketch, config] = await Promise.all([ - this.sketchServiceClient.currentSketch(), - this.configService.getConfiguration(), - ]); + const sketch = await this.sketchServiceClient.currentSketch(); if (!CurrentSketch.isValid(sketch)) { return; } @@ -40,9 +36,9 @@ export class ArchiveSketch extends SketchContribution { new Date(), 'yymmdd' )}a.zip`; - const defaultPath = await this.fileService.fsPath( - new URI(config.sketchDirUri).resolve(archiveBasename) - ); + const defaultContainerUri = await this.defaultUri(); + const defaultUri = defaultContainerUri.resolve(archiveBasename); + const defaultPath = await this.fileService.fsPath(defaultUri); const { filePath, canceled } = await remote.dialog.showSaveDialog( remote.getCurrentWindow(), { diff --git a/arduino-ide-extension/src/browser/contributions/board-selection.ts b/arduino-ide-extension/src/browser/contributions/board-selection.ts index fb3af4d5b..010f18164 100644 --- a/arduino-ide-extension/src/browser/contributions/board-selection.ts +++ b/arduino-ide-extension/src/browser/contributions/board-selection.ts @@ -155,10 +155,7 @@ PID: ${PID}`; ); // Ports submenu - const portsSubmenuPath = [ - ...ArduinoMenus.TOOLS__BOARD_SELECTION_GROUP, - '2_ports', - ]; + const portsSubmenuPath = ArduinoMenus.TOOLS__PORTS_SUBMENU; const portsSubmenuLabel = config.selectedPort?.address; this.menuModelRegistry.registerSubmenu( portsSubmenuPath, diff --git a/arduino-ide-extension/src/browser/contributions/contribution.ts b/arduino-ide-extension/src/browser/contributions/contribution.ts index 0a3f93d92..8c646254e 100644 --- a/arduino-ide-extension/src/browser/contributions/contribution.ts +++ b/arduino-ide-extension/src/browser/contributions/contribution.ts @@ -12,6 +12,7 @@ import { MaybePromise } from '@theia/core/lib/common/types'; import { LabelProvider } from '@theia/core/lib/browser/label-provider'; import { EditorManager } from '@theia/editor/lib/browser/editor-manager'; import { MessageService } from '@theia/core/lib/common/message-service'; +import { EnvVariablesServer } from '@theia/core/lib/common/env-variables'; import { open, OpenerService } from '@theia/core/lib/browser/opener-service'; import { @@ -43,7 +44,6 @@ import { } from '../../common/protocol/sketches-service-client-impl'; import { SketchesService, - ConfigService, FileSystemExt, Sketch, CoreService, @@ -62,6 +62,7 @@ import { NotificationManager } from '../theia/messages/notifications-manager'; import { MessageType } from '@theia/core/lib/common/message-service-protocol'; import { WorkspaceService } from '../theia/workspace/workspace-service'; import { MainMenuManager } from '../../common/main-menu-manager'; +import { ConfigServiceClient } from '../config/config-service-client'; export { Command, @@ -142,8 +143,8 @@ export abstract class SketchContribution extends Contribution { @inject(FileSystemExt) protected readonly fileSystemExt: FileSystemExt; - @inject(ConfigService) - protected readonly configService: ConfigService; + @inject(ConfigServiceClient) + protected readonly configService: ConfigServiceClient; @inject(SketchesService) protected readonly sketchService: SketchesService; @@ -160,6 +161,9 @@ export abstract class SketchContribution extends Contribution { @inject(OutputChannelManager) protected readonly outputChannelManager: OutputChannelManager; + @inject(EnvVariablesServer) + protected readonly envVariableServer: EnvVariablesServer; + protected async sourceOverride(): Promise> { const override: Record = {}; const sketch = await this.sketchServiceClient.currentSketch(); @@ -173,6 +177,25 @@ export abstract class SketchContribution extends Contribution { } return override; } + + /** + * Defaults to `directories.user` if defined and not CLI config errors were detected. + * Otherwise, the URI of the user home directory. + */ + protected async defaultUri(): Promise { + const errors = this.configService.tryGetMessages(); + let defaultUri = this.configService.tryGetSketchDirUri(); + if (!defaultUri || errors?.length) { + // Fall back to user home when the `directories.user` is not available or there are known CLI config errors + defaultUri = new URI(await this.envVariableServer.getHomeDirUri()); + } + return defaultUri; + } + + protected async defaultPath(): Promise { + const defaultUri = await this.defaultUri(); + return this.fileService.fsPath(defaultUri); + } } @injectable() diff --git a/arduino-ide-extension/src/browser/contributions/examples.ts b/arduino-ide-extension/src/browser/contributions/examples.ts index b51dddf8c..3d93ecb11 100644 --- a/arduino-ide-extension/src/browser/contributions/examples.ts +++ b/arduino-ide-extension/src/browser/contributions/examples.ts @@ -29,6 +29,7 @@ import { CoreService, } from '../../common/protocol'; import { nls } from '@theia/core/lib/common'; +import { unregisterSubmenu } from '../menu/arduino-menus'; @injectable() export abstract class Examples extends SketchContribution { @@ -36,7 +37,7 @@ export abstract class Examples extends SketchContribution { private readonly commandRegistry: CommandRegistry; @inject(MenuModelRegistry) - private readonly menuRegistry: MenuModelRegistry; + protected readonly menuRegistry: MenuModelRegistry; @inject(ExamplesService) protected readonly examplesService: ExamplesService; @@ -47,6 +48,9 @@ export abstract class Examples extends SketchContribution { @inject(BoardsServiceProvider) protected readonly boardsServiceClient: BoardsServiceProvider; + @inject(NotificationCenter) + protected readonly notificationCenter: NotificationCenter; + protected readonly toDispose = new DisposableCollection(); protected override init(): void { @@ -54,6 +58,12 @@ export abstract class Examples extends SketchContribution { this.boardsServiceClient.onBoardsConfigChanged(({ selectedBoard }) => this.handleBoardChanged(selectedBoard) ); + this.notificationCenter.onDidReinitialize(() => + this.update({ + board: this.boardsServiceClient.boardsConfig.selectedBoard, + // No force refresh. The core client was already refreshed. + }) + ); } // eslint-disable-next-line @typescript-eslint/no-unused-vars, unused-imports/no-unused-vars @@ -120,6 +130,11 @@ export abstract class Examples extends SketchContribution { const { label } = sketchContainerOrPlaceholder; submenuPath = [...menuPath, label]; this.menuRegistry.registerSubmenu(submenuPath, label, subMenuOptions); + this.toDispose.push( + Disposable.create(() => + unregisterSubmenu(submenuPath, this.menuRegistry) + ) + ); sketches.push(...sketchContainerOrPlaceholder.sketches); children.push(...sketchContainerOrPlaceholder.children); } else { @@ -239,9 +254,6 @@ export class BuiltInExamples extends Examples { @injectable() export class LibraryExamples extends Examples { - @inject(NotificationCenter) - private readonly notificationCenter: NotificationCenter; - private readonly queue = new PQueue({ autoStart: true, concurrency: 1 }); override onStart(): void { diff --git a/arduino-ide-extension/src/browser/contributions/include-library.ts b/arduino-ide-extension/src/browser/contributions/include-library.ts index 19853c7ff..0efe114d7 100644 --- a/arduino-ide-extension/src/browser/contributions/include-library.ts +++ b/arduino-ide-extension/src/browser/contributions/include-library.ts @@ -53,6 +53,7 @@ export class IncludeLibrary extends SketchContribution { this.notificationCenter.onLibraryDidUninstall(() => this.updateMenuActions() ); + this.notificationCenter.onDidReinitialize(() => this.updateMenuActions()); } override async onReady(): Promise { diff --git a/arduino-ide-extension/src/browser/contributions/open-sketch.ts b/arduino-ide-extension/src/browser/contributions/open-sketch.ts index 5e41001c3..af2ecad67 100644 --- a/arduino-ide-extension/src/browser/contributions/open-sketch.ts +++ b/arduino-ide-extension/src/browser/contributions/open-sketch.ts @@ -82,10 +82,7 @@ export class OpenSketch extends SketchContribution { } private async selectSketch(): Promise { - const config = await this.configService.getConfiguration(); - const defaultPath = await this.fileService.fsPath( - new URI(config.sketchDirUri) - ); + const defaultPath = await this.defaultPath(); const { filePaths } = await remote.dialog.showOpenDialog( remote.getCurrentWindow(), { diff --git a/arduino-ide-extension/src/browser/contributions/save-as-sketch.ts b/arduino-ide-extension/src/browser/contributions/save-as-sketch.ts index 3fee8491e..6802e400f 100644 --- a/arduino-ide-extension/src/browser/contributions/save-as-sketch.ts +++ b/arduino-ide-extension/src/browser/contributions/save-as-sketch.ts @@ -58,10 +58,7 @@ export class SaveAsSketch extends SketchContribution { markAsRecentlyOpened, }: SaveAsSketch.Options = SaveAsSketch.Options.DEFAULT ): Promise { - const [sketch, configuration] = await Promise.all([ - this.sketchServiceClient.currentSketch(), - this.configService.getConfiguration(), - ]); + const sketch = await this.sketchServiceClient.currentSketch(); if (!CurrentSketch.isValid(sketch)) { return false; } @@ -72,7 +69,7 @@ export class SaveAsSketch extends SketchContribution { } const sketchUri = new URI(sketch.uri); - const sketchbookDirUri = new URI(configuration.sketchDirUri); + const sketchbookDirUri = await this.defaultUri(); // If the sketch is temp, IDE2 proposes the default sketchbook folder URI. // If the sketch is not temp, but not contained in the default sketchbook folder, IDE2 proposes the default location. // Otherwise, it proposes the parent folder of the current sketch. diff --git a/arduino-ide-extension/src/browser/contributions/sketchbook.ts b/arduino-ide-extension/src/browser/contributions/sketchbook.ts index 8c01cd46a..e76b64bc1 100644 --- a/arduino-ide-extension/src/browser/contributions/sketchbook.ts +++ b/arduino-ide-extension/src/browser/contributions/sketchbook.ts @@ -11,6 +11,7 @@ import { nls } from '@theia/core/lib/common/nls'; export class Sketchbook extends Examples { override onStart(): void { this.sketchServiceClient.onSketchbookDidChange(() => this.update()); + this.configService.onDidChangeSketchDirUri(() => this.update()); } override async onReady(): Promise { diff --git a/arduino-ide-extension/src/browser/dialogs/settings/settings.ts b/arduino-ide-extension/src/browser/dialogs/settings/settings.ts index 73afccb5b..e4923c760 100644 --- a/arduino-ide-extension/src/browser/dialogs/settings/settings.ts +++ b/arduino-ide-extension/src/browser/dialogs/settings/settings.ts @@ -27,6 +27,7 @@ import { import { ElectronCommands } from '@theia/core/lib/electron-browser/menu/electron-menu-contribution'; import { DefaultTheme } from '@theia/application-package/lib/application-props'; import { FrontendApplicationConfigProvider } from '@theia/core/lib/browser/frontend-application-config-provider'; +import type { FileStat } from '@theia/filesystem/lib/common/files'; export const WINDOW_SETTING = 'window'; export const EDITOR_SETTING = 'editor'; @@ -171,7 +172,15 @@ export class SettingsService { this.preferenceService.get(SHOW_ALL_FILES_SETTING, false), this.configService.getConfiguration(), ]); - const { additionalUrls, sketchDirUri, network } = cliConfig; + const { + config = { + additionalUrls: [], + sketchDirUri: '', + network: Network.Default(), + }, + } = cliConfig; + const { additionalUrls, sketchDirUri, network } = config; + const sketchbookPath = await this.fileService.fsPath(new URI(sketchDirUri)); return { editorFontSize, @@ -223,7 +232,11 @@ export class SettingsService { try { const { sketchbookPath, editorFontSize, themeId } = await settings; const sketchbookDir = await this.fileSystemExt.getUri(sketchbookPath); - if (!(await this.fileService.exists(new URI(sketchbookDir)))) { + let sketchbookStat: FileStat | undefined = undefined; + try { + sketchbookStat = await this.fileService.resolve(new URI(sketchbookDir)); + } catch {} + if (!sketchbookStat || !sketchbookStat.isDirectory) { return nls.localize( 'arduino/preferences/invalid.sketchbook.location', 'Invalid sketchbook location: {0}', @@ -274,10 +287,19 @@ export class SettingsService { network, sketchbookShowAllFiles, } = this._settings; - const [config, sketchDirUri] = await Promise.all([ + const [cliConfig, sketchDirUri] = await Promise.all([ this.configService.getConfiguration(), this.fileSystemExt.getUri(sketchbookPath), ]); + const { config } = cliConfig; + if (!config) { + // Do not check for any error messages. The config might has errors (such as invalid directories.user) right before saving the new values. + return nls.localize( + 'arduino/preferences/noCliConfig', + 'Could not load the CLI configuration' + ); + } + (config as any).additionalUrls = additionalUrls; (config as any).sketchDirUri = sketchDirUri; (config as any).network = network; diff --git a/arduino-ide-extension/src/browser/local-cache/local-cache-fs-provider.ts b/arduino-ide-extension/src/browser/local-cache/local-cache-fs-provider.ts index ee2aebf99..583da52ef 100644 --- a/arduino-ide-extension/src/browser/local-cache/local-cache-fs-provider.ts +++ b/arduino-ide-extension/src/browser/local-cache/local-cache-fs-provider.ts @@ -88,8 +88,25 @@ export class LocalCacheFsProvider } protected async init(fileService: FileService): Promise { - const config = await this.configService.getConfiguration(); - this._localCacheRoot = new URI(config.dataDirUri); + const { config } = await this.configService.getConfiguration(); + // Any possible CLI config errors are ignored here. IDE2 does not verify the `directories.data` folder. + // If the data dir is accessible, IDE2 creates the cache folder for the remote sketches. Otherwise, it does not. + // The data folder can be configured outside of the IDE2, and the new data folder will be picked up with a + // subsequent IDE2 start. + if (!config?.dataDirUri) { + return; // the deferred promise will never resolve + } + const localCacheUri = new URI(config.dataDirUri); + try { + await fileService.access(localCacheUri); + } catch (err) { + console.error( + `'directories.data' location is inaccessible at ${config.dataDirUri}`, + err + ); + return; + } + this._localCacheRoot = localCacheUri; for (const segment of ['RemoteSketchbook', 'ArduinoCloud']) { this._localCacheRoot = this._localCacheRoot.resolve(segment); await fileService.createFolder(this._localCacheRoot); diff --git a/arduino-ide-extension/src/browser/menu/arduino-menus.ts b/arduino-ide-extension/src/browser/menu/arduino-menus.ts index 500bdf124..9ac323aa0 100644 --- a/arduino-ide-extension/src/browser/menu/arduino-menus.ts +++ b/arduino-ide-extension/src/browser/menu/arduino-menus.ts @@ -97,6 +97,11 @@ export namespace ArduinoMenus { export const TOOLS__BOARD_SELECTION_GROUP = [...TOOLS, '2_board_selection']; // Core settings, such as `Processor` and `Programmers` for the board and `Burn Bootloader` export const TOOLS__BOARD_SETTINGS_GROUP = [...TOOLS, '3_board_settings']; + // `Tool` > `Ports` (always visible https://github.com/arduino/arduino-ide/issues/655) + export const TOOLS__PORTS_SUBMENU = [ + ...ArduinoMenus.TOOLS__BOARD_SELECTION_GROUP, + '2_ports', + ]; // -- Help // `Getting Started`, `Environment`, `Troubleshooting`, etc. diff --git a/arduino-ide-extension/src/browser/notification-center.ts b/arduino-ide-extension/src/browser/notification-center.ts index 091ad8cdb..0ed0a2645 100644 --- a/arduino-ide-extension/src/browser/notification-center.ts +++ b/arduino-ide-extension/src/browser/notification-center.ts @@ -18,7 +18,7 @@ import { AttachedBoardsChangeEvent, BoardsPackage, LibraryPackage, - Config, + ConfigState, Sketch, ProgressMessage, } from '../common/protocol'; @@ -37,6 +37,7 @@ export class NotificationCenter @inject(FrontendApplicationStateService) private readonly appStateService: FrontendApplicationStateService; + private readonly didReinitializeEmitter = new Emitter(); private readonly indexUpdateDidCompleteEmitter = new Emitter(); private readonly indexUpdateWillStartEmitter = @@ -47,9 +48,7 @@ export class NotificationCenter new Emitter(); private readonly daemonDidStartEmitter = new Emitter(); private readonly daemonDidStopEmitter = new Emitter(); - private readonly configDidChangeEmitter = new Emitter<{ - config: Config | undefined; - }>(); + private readonly configDidChangeEmitter = new Emitter(); private readonly platformDidInstallEmitter = new Emitter<{ item: BoardsPackage; }>(); @@ -71,6 +70,7 @@ export class NotificationCenter new Emitter(); private readonly toDispose = new DisposableCollection( + this.didReinitializeEmitter, this.indexUpdateWillStartEmitter, this.indexUpdateDidProgressEmitter, this.indexUpdateDidCompleteEmitter, @@ -85,6 +85,7 @@ export class NotificationCenter this.attachedBoardsDidChangeEmitter ); + readonly onDidReinitialize = this.didReinitializeEmitter.event; readonly onIndexUpdateDidComplete = this.indexUpdateDidCompleteEmitter.event; readonly onIndexUpdateWillStart = this.indexUpdateWillStartEmitter.event; readonly onIndexUpdateDidProgress = this.indexUpdateDidProgressEmitter.event; @@ -115,6 +116,10 @@ export class NotificationCenter this.toDispose.dispose(); } + notifyDidReinitialize(): void { + this.didReinitializeEmitter.fire(); + } + notifyIndexUpdateWillStart(params: IndexUpdateWillStartParams): void { this.indexUpdateWillStartEmitter.fire(params); } @@ -139,7 +144,7 @@ export class NotificationCenter this.daemonDidStopEmitter.fire(); } - notifyConfigDidChange(event: { config: Config | undefined }): void { + notifyConfigDidChange(event: ConfigState): void { this.configDidChangeEmitter.fire(event); } diff --git a/arduino-ide-extension/src/browser/theia/core/tab-bar-decorator.ts b/arduino-ide-extension/src/browser/theia/core/tab-bar-decorator.ts index ea1f29eed..0ecf1a373 100644 --- a/arduino-ide-extension/src/browser/theia/core/tab-bar-decorator.ts +++ b/arduino-ide-extension/src/browser/theia/core/tab-bar-decorator.ts @@ -1,30 +1,35 @@ import { inject, injectable, postConstruct } from '@theia/core/shared/inversify'; import URI from '@theia/core/lib/common/uri'; import { Title, Widget } from '@theia/core/shared/@phosphor/widgets'; -import { ILogger } from '@theia/core/lib/common/logger'; import { EditorWidget } from '@theia/editor/lib/browser'; import { WidgetDecoration } from '@theia/core/lib/browser/widget-decoration'; import { TabBarDecoratorService as TheiaTabBarDecoratorService } from '@theia/core/lib/browser/shell/tab-bar-decorator'; -import { ConfigService } from '../../../common/protocol/config-service'; +import { ConfigServiceClient } from '../../config/config-service-client'; +import { FrontendApplicationStateService } from '@theia/core/lib/browser/frontend-application-state'; @injectable() export class TabBarDecoratorService extends TheiaTabBarDecoratorService { - @inject(ConfigService) - protected readonly configService: ConfigService; + @inject(ConfigServiceClient) + private readonly configService: ConfigServiceClient; + @inject(FrontendApplicationStateService) + private readonly appStateService: FrontendApplicationStateService; - @inject(ILogger) - protected readonly logger: ILogger; - - protected dataDirUri: URI | undefined; + private dataDirUri: URI | undefined; @postConstruct() protected init(): void { - this.configService - .getConfiguration() - .then(({ dataDirUri }) => (this.dataDirUri = new URI(dataDirUri))) - .catch((err) => - this.logger.error(`Failed to determine the data directory: ${err}`) - ); + const fireDidChange = () => + this.appStateService + .reachedState('ready') + .then(() => this.fireDidChangeDecorations()); + this.dataDirUri = this.configService.tryGetDataDirUri(); + this.configService.onDidChangeDataDirUri((dataDirUri) => { + this.dataDirUri = dataDirUri; + fireDidChange(); + }); + if (this.dataDirUri) { + fireDidChange(); + } } override getDecorations(title: Title): WidgetDecoration.Data[] { diff --git a/arduino-ide-extension/src/browser/theia/markers/problem-manager.ts b/arduino-ide-extension/src/browser/theia/markers/problem-manager.ts index b218dde3e..4d1e0d084 100644 --- a/arduino-ide-extension/src/browser/theia/markers/problem-manager.ts +++ b/arduino-ide-extension/src/browser/theia/markers/problem-manager.ts @@ -5,31 +5,23 @@ import { } from '@theia/core/shared/inversify'; import { Diagnostic } from '@theia/core/shared/vscode-languageserver-types'; import URI from '@theia/core/lib/common/uri'; -import { ILogger } from '@theia/core'; import { Marker } from '@theia/markers/lib/common/marker'; import { ProblemManager as TheiaProblemManager } from '@theia/markers/lib/browser/problem/problem-manager'; -import { ConfigService } from '../../../common/protocol/config-service'; +import { ConfigServiceClient } from '../../config/config-service-client'; import debounce = require('lodash.debounce'); @injectable() export class ProblemManager extends TheiaProblemManager { - @inject(ConfigService) - protected readonly configService: ConfigService; + @inject(ConfigServiceClient) + private readonly configService: ConfigServiceClient; - @inject(ILogger) - protected readonly logger: ILogger; - - protected dataDirUri: URI | undefined; + private dataDirUri: URI | undefined; @postConstruct() protected override init(): void { super.init(); - this.configService - .getConfiguration() - .then(({ dataDirUri }) => (this.dataDirUri = new URI(dataDirUri))) - .catch((err) => - this.logger.error(`Failed to determine the data directory: ${err}`) - ); + this.dataDirUri = this.configService.tryGetDataDirUri(); + this.configService.onDidChangeDataDirUri((uri) => (this.dataDirUri = uri)); } override setMarkers( diff --git a/arduino-ide-extension/src/browser/widgets/sketchbook/sketchbook-tree-model.ts b/arduino-ide-extension/src/browser/widgets/sketchbook/sketchbook-tree-model.ts index 5b567d0be..34263df8a 100644 --- a/arduino-ide-extension/src/browser/widgets/sketchbook/sketchbook-tree-model.ts +++ b/arduino-ide-extension/src/browser/widgets/sketchbook/sketchbook-tree-model.ts @@ -2,7 +2,7 @@ import { inject, injectable, postConstruct } from '@theia/core/shared/inversify' import URI from '@theia/core/lib/common/uri'; import { FileNode, FileTreeModel } from '@theia/filesystem/lib/browser'; import { FileService } from '@theia/filesystem/lib/browser/file-service'; -import { ConfigService } from '../../../common/protocol'; +import { ConfigServiceClient } from './../../config/config-service-client'; import { SketchbookTree } from './sketchbook-tree'; import { ArduinoPreferences } from '../../arduino-preferences'; import { @@ -36,8 +36,8 @@ export class SketchbookTreeModel extends FileTreeModel { @inject(CommandRegistry) public readonly commandRegistry: CommandRegistry; - @inject(ConfigService) - protected readonly configService: ConfigService; + @inject(ConfigServiceClient) + protected readonly configService: ConfigServiceClient; @inject(OpenerService) protected readonly openerService: OpenerService; @@ -59,6 +59,12 @@ export class SketchbookTreeModel extends FileTreeModel { super.init(); this.reportBusyProgress(); this.initializeRoot(); + this.toDispose.push( + this.configService.onDidChangeSketchDirUri(async () => { + await this.updateRoot(); + this.selectRoot(this.root); + }) + ); } protected readonly pendingBusyProgress = new Map>(); @@ -121,6 +127,10 @@ export class SketchbookTreeModel extends FileTreeModel { return; } const root = this.root; + this.selectRoot(root); + } + + private selectRoot(root: TreeNode | undefined) { if (CompositeTreeNode.is(root) && root.children.length === 1) { const child = root.children[0]; if ( @@ -161,10 +171,12 @@ export class SketchbookTreeModel extends FileTreeModel { } protected async createRoot(): Promise { - const config = await this.configService.getConfiguration(); - const rootFileStats = await this.fileService.resolve( - new URI(config.sketchDirUri) - ); + const sketchDirUri = this.configService.tryGetSketchDirUri(); + const errors = this.configService.tryGetMessages(); + if (!sketchDirUri || errors?.length) { + return undefined; + } + const rootFileStats = await this.fileService.resolve(sketchDirUri); if (this.workspaceService.opened && rootFileStats.children) { // filter out libraries and hardware diff --git a/arduino-ide-extension/src/common/protocol/config-service.ts b/arduino-ide-extension/src/common/protocol/config-service.ts index d02f92770..feaf6a1e1 100644 --- a/arduino-ide-extension/src/common/protocol/config-service.ts +++ b/arduino-ide-extension/src/common/protocol/config-service.ts @@ -6,10 +6,12 @@ export interface ConfigService { getVersion(): Promise< Readonly<{ version: string; commit: string; status?: string }> >; - getCliConfigFileUri(): Promise; - getConfiguration(): Promise; + getConfiguration(): Promise; setConfiguration(config: Config): Promise; } +export type ConfigState = + | { config: undefined; messages: string[] } + | { config: Config; messages?: string[] }; export interface Daemon { readonly port: string | number; @@ -119,7 +121,16 @@ export interface Config { readonly network: Network; } export namespace Config { - export function sameAs(left: Config, right: Config): boolean { + export function sameAs( + left: Config | undefined, + right: Config | undefined + ): boolean { + if (!left) { + return !right; + } + if (!right) { + return false; + } const leftUrls = left.additionalUrls.sort(); const rightUrls = right.additionalUrls.sort(); if (leftUrls.length !== rightUrls.length) { @@ -150,7 +161,16 @@ export namespace AdditionalUrls { export function stringify(additionalUrls: AdditionalUrls): string { return additionalUrls.join(','); } - export function sameAs(left: AdditionalUrls, right: AdditionalUrls): boolean { + export function sameAs( + left: AdditionalUrls | undefined, + right: AdditionalUrls | undefined + ): boolean { + if (!left) { + return !right; + } + if (!right) { + return false; + } if (left.length !== right.length) { return false; } diff --git a/arduino-ide-extension/src/common/protocol/notification-service.ts b/arduino-ide-extension/src/common/protocol/notification-service.ts index cbea74b60..fa951a961 100644 --- a/arduino-ide-extension/src/common/protocol/notification-service.ts +++ b/arduino-ide-extension/src/common/protocol/notification-service.ts @@ -2,7 +2,7 @@ import type { JsonRpcServer } from '@theia/core/lib/common/messaging/proxy-facto import type { AttachedBoardsChangeEvent, BoardsPackage, - Config, + ConfigState, ProgressMessage, Sketch, IndexType, @@ -39,6 +39,11 @@ export interface IndexUpdateDidFailParams extends IndexUpdateParams { } export interface NotificationServiceClient { + // The cached state of the core client. Libraries, examples, etc. has been updated. + // This can happen without an index update. For example, changing the `directories.user` location. + // An index update always implicitly involves a re-initialization without notifying via this method. + notifyDidReinitialize(): void; + // Index notifyIndexUpdateWillStart(params: IndexUpdateWillStartParams): void; notifyIndexUpdateDidProgress(progressMessage: ProgressMessage): void; @@ -50,7 +55,7 @@ export interface NotificationServiceClient { notifyDaemonDidStop(): void; // CLI config - notifyConfigDidChange(event: { config: Config | undefined }): void; + notifyConfigDidChange(event: ConfigState): void; // Platforms notifyPlatformDidInstall(event: { item: BoardsPackage }): void; diff --git a/arduino-ide-extension/src/common/protocol/sketches-service-client-impl.ts b/arduino-ide-extension/src/common/protocol/sketches-service-client-impl.ts index 595761be7..59e88d740 100644 --- a/arduino-ide-extension/src/common/protocol/sketches-service-client-impl.ts +++ b/arduino-ide-extension/src/common/protocol/sketches-service-client-impl.ts @@ -3,13 +3,15 @@ import URI from '@theia/core/lib/common/uri'; import { Emitter } from '@theia/core/lib/common/event'; import { notEmpty } from '@theia/core/lib/common/objects'; import { FileService } from '@theia/filesystem/lib/browser/file-service'; -import { MessageService } from '@theia/core/lib/common/message-service'; import { FileChangeType } from '@theia/filesystem/lib/common/files'; import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service'; -import { DisposableCollection } from '@theia/core/lib/common/disposable'; +import { + Disposable, + DisposableCollection, +} from '@theia/core/lib/common/disposable'; import { FrontendApplicationContribution } from '@theia/core/lib/browser/frontend-application'; -import { Sketch, SketchesService } from '../../common/protocol'; -import { ConfigService } from './config-service'; +import { Sketch, SketchesService } from '.'; +import { ConfigServiceClient } from '../../browser/config/config-service-client'; import { SketchContainer, SketchesError, SketchRef } from './sketches-service'; import { ARDUINO_CLOUD_FOLDER, @@ -34,139 +36,143 @@ export class SketchesServiceClientImpl implements FrontendApplicationContribution { @inject(FileService) - protected readonly fileService: FileService; - - @inject(MessageService) - protected readonly messageService: MessageService; - + private readonly fileService: FileService; @inject(SketchesService) - protected readonly sketchService: SketchesService; - + private readonly sketchService: SketchesService; @inject(WorkspaceService) - protected readonly workspaceService: WorkspaceService; - - @inject(ConfigService) - protected readonly configService: ConfigService; - + private readonly workspaceService: WorkspaceService; + @inject(ConfigServiceClient) + private readonly configService: ConfigServiceClient; @inject(FrontendApplicationStateService) private readonly appStateService: FrontendApplicationStateService; - protected sketches = new Map(); - // TODO: rename this + event to the `onBlabla` pattern - protected sketchbookDidChangeEmitter = new Emitter<{ + private sketches = new Map(); + private onSketchbookDidChangeEmitter = new Emitter<{ created: SketchRef[]; removed: SketchRef[]; }>(); - readonly onSketchbookDidChange = this.sketchbookDidChangeEmitter.event; - protected currentSketchDidChangeEmitter = new Emitter(); + readonly onSketchbookDidChange = this.onSketchbookDidChangeEmitter.event; + private currentSketchDidChangeEmitter = new Emitter(); readonly onCurrentSketchDidChange = this.currentSketchDidChangeEmitter.event; - protected toDispose = new DisposableCollection( - this.sketchbookDidChangeEmitter, - this.currentSketchDidChangeEmitter + private toDisposeBeforeWatchSketchbookDir = new DisposableCollection(); + private toDispose = new DisposableCollection( + this.onSketchbookDidChangeEmitter, + this.currentSketchDidChangeEmitter, + this.toDisposeBeforeWatchSketchbookDir ); private _currentSketch: CurrentSketch | undefined; private currentSketchLoaded = new Deferred(); onStart(): void { - this.configService.getConfiguration().then(({ sketchDirUri }) => { - this.sketchService - .getSketches({ uri: sketchDirUri }) - .then((container) => { - const sketchbookUri = new URI(sketchDirUri); - for (const sketch of SketchContainer.toArray(container)) { - this.sketches.set(sketch.uri, sketch); - } - this.toDispose.push( - // Watch changes in the sketchbook to update `File` > `Sketchbook` menu items. - this.fileService.watch(new URI(sketchDirUri), { - recursive: true, - excludes: [], - }) - ); - this.toDispose.push( - this.fileService.onDidFilesChange(async (event) => { - for (const { type, resource } of event.changes) { - // The file change events have higher precedence in the current sketch over the sketchbook. - if ( - CurrentSketch.isValid(this._currentSketch) && - new URI(this._currentSketch.uri).isEqualOrParent(resource) - ) { - // https://github.com/arduino/arduino-ide/pull/1351#pullrequestreview-1086666656 - // On a sketch file rename, the FS watcher will contain two changes: - // - Deletion of the original file, - // - Update of the new file, - // Hence, `UPDATE` events must be processed but only and if only there is a `DELETED` change in the same event. - // Otherwise, IDE2 would ask CLI to reload the sketch content on every save event in IDE2. - if ( - type === FileChangeType.UPDATED && - event.changes.length === 1 - ) { - // If the event contains only one `UPDATE` change, it cannot be a rename. - return; - } + const sketchDirUri = this.configService.tryGetSketchDirUri(); + this.watchSketchbookDir(sketchDirUri); + const refreshCurrentSketch = async () => { + const currentSketch = await this.loadCurrentSketch(); + this.useCurrentSketch(currentSketch); + }; + this.toDispose.push( + this.configService.onDidChangeSketchDirUri((sketchDirUri) => { + this.watchSketchbookDir(sketchDirUri); + refreshCurrentSketch(); + }) + ); + this.appStateService + .reachedState('started_contributions') + .then(refreshCurrentSketch); + } - let reloadedSketch: Sketch | undefined = undefined; - try { - reloadedSketch = await this.sketchService.loadSketch( - this._currentSketch.uri - ); - } catch (err) { - if (!SketchesError.NotFound.is(err)) { - throw err; - } - } + private async watchSketchbookDir( + sketchDirUri: URI | undefined + ): Promise { + this.toDisposeBeforeWatchSketchbookDir.dispose(); + if (!sketchDirUri) { + return; + } + const container = await this.sketchService.getSketches({ + uri: sketchDirUri.toString(), + }); + for (const sketch of SketchContainer.toArray(container)) { + this.sketches.set(sketch.uri, sketch); + } + this.toDisposeBeforeWatchSketchbookDir.pushAll([ + Disposable.create(() => this.sketches.clear()), + // Watch changes in the sketchbook to update `File` > `Sketchbook` menu items. + this.fileService.watch(sketchDirUri, { + recursive: true, + excludes: [], + }), + this.fileService.onDidFilesChange(async (event) => { + for (const { type, resource } of event.changes) { + // The file change events have higher precedence in the current sketch over the sketchbook. + if ( + CurrentSketch.isValid(this._currentSketch) && + new URI(this._currentSketch.uri).isEqualOrParent(resource) + ) { + // https://github.com/arduino/arduino-ide/pull/1351#pullrequestreview-1086666656 + // On a sketch file rename, the FS watcher will contain two changes: + // - Deletion of the original file, + // - Update of the new file, + // Hence, `UPDATE` events must be processed but only and if only there is a `DELETED` change in the same event. + // Otherwise, IDE2 would ask CLI to reload the sketch content on every save event in IDE2. + if (type === FileChangeType.UPDATED && event.changes.length === 1) { + // If the event contains only one `UPDATE` change, it cannot be a rename. + return; + } - if (!reloadedSketch) { - return; - } + let reloadedSketch: Sketch | undefined = undefined; + try { + reloadedSketch = await this.sketchService.loadSketch( + this._currentSketch.uri + ); + } catch (err) { + if (!SketchesError.NotFound.is(err)) { + throw err; + } + } - if (!Sketch.sameAs(this._currentSketch, reloadedSketch)) { - this.useCurrentSketch(reloadedSketch, true); - } - return; - } - // We track main sketch files changes only. // TODO: check sketch folder changes. One can rename the folder without renaming the `.ino` file. - if (sketchbookUri.isEqualOrParent(resource)) { - if (Sketch.isSketchFile(resource)) { - if (type === FileChangeType.ADDED) { - try { - const toAdd = await this.sketchService.loadSketch( - resource.parent.toString() - ); - if (!this.sketches.has(toAdd.uri)) { - console.log( - `New sketch '${toAdd.name}' was created in sketchbook '${sketchDirUri}'.` - ); - this.sketches.set(toAdd.uri, toAdd); - this.fireSoon(toAdd, 'created'); - } - } catch {} - } else if (type === FileChangeType.DELETED) { - const uri = resource.parent.toString(); - const toDelete = this.sketches.get(uri); - if (toDelete) { - console.log( - `Sketch '${toDelete.name}' was removed from sketchbook '${sketchbookUri}'.` - ); - this.sketches.delete(uri); - this.fireSoon(toDelete, 'removed'); - } - } + if (!reloadedSketch) { + return; + } + + if (!Sketch.sameAs(this._currentSketch, reloadedSketch)) { + this.useCurrentSketch(reloadedSketch, true); + } + return; + } + // We track main sketch files changes only. // TODO: check sketch folder changes. One can rename the folder without renaming the `.ino` file. + if (sketchDirUri.isEqualOrParent(resource)) { + if (Sketch.isSketchFile(resource)) { + if (type === FileChangeType.ADDED) { + try { + const toAdd = await this.sketchService.loadSketch( + resource.parent.toString() + ); + if (!this.sketches.has(toAdd.uri)) { + console.log( + `New sketch '${toAdd.name}' was created in sketchbook '${sketchDirUri}'.` + ); + this.sketches.set(toAdd.uri, toAdd); + this.fireSoon(toAdd, 'created'); } + } catch {} + } else if (type === FileChangeType.DELETED) { + const uri = resource.parent.toString(); + const toDelete = this.sketches.get(uri); + if (toDelete) { + console.log( + `Sketch '${toDelete.name}' was removed from sketchbook '${sketchDirUri}'.` + ); + this.sketches.delete(uri); + this.fireSoon(toDelete, 'removed'); } } - }) - ); - }); - }); - this.appStateService - .reachedState('started_contributions') - .then(async () => { - const currentSketch = await this.loadCurrentSketch(); - this.useCurrentSketch(currentSketch); - }); + } + } + } + }), + ]); } private useCurrentSketch( @@ -249,7 +255,7 @@ export class SketchesServiceClientImpl event.removed.push(sketch); } } - this.sketchbookDidChangeEmitter.fire(event); + this.onSketchbookDidChangeEmitter.fire(event); this.bufferedSketchbookEvents.length = 0; }, 100); } diff --git a/arduino-ide-extension/src/electron-browser/theia/core/electron-main-menu-factory.ts b/arduino-ide-extension/src/electron-browser/theia/core/electron-main-menu-factory.ts index 0e192e4e7..6baa5901a 100644 --- a/arduino-ide-extension/src/electron-browser/theia/core/electron-main-menu-factory.ts +++ b/arduino-ide-extension/src/electron-browser/theia/core/electron-main-menu-factory.ts @@ -180,7 +180,7 @@ export class ElectronMainMenuFactory extends TheiaElectronMainMenuFactory { if ( CompoundMenuNode.is(menu) && - menu.children.length && + this.visibleSubmenu(menu) && // customization for #569 and #655 this.undefinedOrMatch(menu.when, options.context) ) { const role = CompoundMenuNode.getRole(menu); @@ -193,10 +193,17 @@ export class ElectronMainMenuFactory extends TheiaElectronMainMenuFactory { this.fillMenuTemplate(myItems, child, args, options) ); if (myItems.length === 0) { - return parentItems; + // customization for #569 and #655 + if (!this.visibleLeafSubmenu(menu)) { + return parentItems; + } } if (role === CompoundMenuNodeRole.Submenu) { - parentItems.push({ label: menu.label, submenu: myItems }); + parentItems.push({ + label: menu.label, + submenu: myItems, + enabled: !this.visibleLeafSubmenu(menu), // customization for #569 and #655 + }); } else if (role === CompoundMenuNodeRole.Group && menu.id !== 'inline') { if ( parentItems.length && @@ -278,4 +285,31 @@ export class ElectronMainMenuFactory extends TheiaElectronMainMenuFactory { } return parentItems; } + + /** + * `true` if either has at least `children`, or was forced to be visible. + */ + private visibleSubmenu(node: MenuNode & CompoundMenuNode): boolean { + return node.children.length > 0 || this.visibleLeafSubmenu(node); + } + + /** + * The node is a visible submenu if is a compound node but has zero children. + */ + private visibleLeafSubmenu(node: MenuNode): boolean { + if (CompoundMenuNode.is(node)) { + return ( + node.children.length === 0 && + AlwaysVisibleSubmenus.findIndex( + (menuPath) => menuPath[menuPath.length - 1] === node.id + ) >= 0 + ); + } + return false; + } } + +const AlwaysVisibleSubmenus: MenuPath[] = [ + ArduinoMenus.TOOLS__PORTS_SUBMENU, // #655 + ArduinoMenus.FILE__SKETCHBOOK_SUBMENU, // #569 +]; diff --git a/arduino-ide-extension/src/node/clang-formatter.ts b/arduino-ide-extension/src/node/clang-formatter.ts index a1ba2345e..20e10bc21 100644 --- a/arduino-ide-extension/src/node/clang-formatter.ts +++ b/arduino-ide-extension/src/node/clang-formatter.ts @@ -79,9 +79,12 @@ export class ClangFormatter implements Formatter { return `-style="${style(toClangOptions(options))}"`; } - private async dataDirPath(): Promise { - const { dataDirUri } = await this.configService.getConfiguration(); - return FileUri.fsPath(dataDirUri); + private async dataDirPath(): Promise { + const { config } = await this.configService.getConfiguration(); + if (!config?.dataDirUri) { + return undefined; + } + return FileUri.fsPath(config.dataDirUri); } private async configDirPath(): Promise { @@ -90,9 +93,13 @@ export class ClangFormatter implements Formatter { } private async clangConfigPath( - folderUri: MaybePromise + folderUri: MaybePromise ): Promise { - const folderPath = FileUri.fsPath(await folderUri); + const uri = await folderUri; + if (!uri) { + return undefined; + } + const folderPath = FileUri.fsPath(uri); const clangFormatPath = join(folderPath, ClangFormatFile); try { await fs.access(clangFormatPath, constants.R_OK); diff --git a/arduino-ide-extension/src/node/config-service-impl.ts b/arduino-ide-extension/src/node/config-service-impl.ts index 2efc4e774..ceea2a1db 100644 --- a/arduino-ide-extension/src/node/config-service-impl.ts +++ b/arduino-ide-extension/src/node/config-service-impl.ts @@ -13,6 +13,7 @@ import { Config, NotificationServiceServer, Network, + ConfigState, } from '../common/protocol'; import { spawnCommand } from './exec-util'; import { @@ -25,7 +26,7 @@ import { ArduinoDaemonImpl } from './arduino-daemon-impl'; import { DefaultCliConfig, CLI_CONFIG } from './cli-config'; import { Deferred } from '@theia/core/lib/common/promise-util'; import { EnvVariablesServer } from '@theia/core/lib/common/env-variables'; -import { deepClone } from '@theia/core'; +import { deepClone, nls } from '@theia/core'; import { ErrnoException } from './utils/errors'; const deepmerge = require('deepmerge'); @@ -36,46 +37,38 @@ export class ConfigServiceImpl { @inject(ILogger) @named('config') - protected readonly logger: ILogger; + private readonly logger: ILogger; @inject(EnvVariablesServer) - protected readonly envVariablesServer: EnvVariablesServer; + private readonly envVariablesServer: EnvVariablesServer; @inject(ArduinoDaemonImpl) - protected readonly daemon: ArduinoDaemonImpl; + private readonly daemon: ArduinoDaemonImpl; @inject(NotificationServiceServer) - protected readonly notificationService: NotificationServiceServer; + private readonly notificationService: NotificationServiceServer; - protected config: Config; - protected cliConfig: DefaultCliConfig | undefined; - protected ready = new Deferred(); - protected readonly configChangeEmitter = new Emitter(); + private config: ConfigState = { + config: undefined, + messages: ['uninitialized'], + }; + private cliConfig: DefaultCliConfig | undefined; + private ready = new Deferred(); + private readonly configChangeEmitter = new Emitter<{ + oldState: ConfigState; + newState: ConfigState; + }>(); onStart(): void { - this.loadCliConfig().then(async (cliConfig) => { - this.cliConfig = cliConfig; - if (this.cliConfig) { - const [config] = await Promise.all([ - this.mapCliConfigToAppConfig(this.cliConfig), - this.ensureUserDirExists(this.cliConfig), - ]); - if (config) { - this.config = config; - this.ready.resolve(); - return; - } - } - this.fireInvalidConfig(); - }); + this.initConfig(); } - async getCliConfigFileUri(): Promise { + private async getCliConfigFileUri(): Promise { const configDirUri = await this.envVariablesServer.getConfigDirUri(); return new URI(configDirUri).resolve(CLI_CONFIG).toString(); } - async getConfiguration(): Promise { + async getConfiguration(): Promise { await this.ready.promise; return { ...this.config }; } @@ -83,9 +76,10 @@ export class ConfigServiceImpl // Used by frontend to update the config. async setConfiguration(config: Config): Promise { await this.ready.promise; - if (Config.sameAs(this.config, config)) { + if (Config.sameAs(this.config.config, config)) { return; } + const oldConfigState = deepClone(this.config); let copyDefaultCliConfig: DefaultCliConfig | undefined = deepClone( this.cliConfig ); @@ -110,16 +104,30 @@ export class ConfigServiceImpl await this.updateDaemon(port, copyDefaultCliConfig); await this.writeDaemonState(port); - this.config = deepClone(config); + this.config.config = deepClone(config); this.cliConfig = copyDefaultCliConfig; - this.fireConfigChanged(this.config); + try { + await this.validateCliConfig(this.cliConfig); + delete this.config.messages; + this.fireConfigChanged(oldConfigState, this.config); + } catch (err) { + if (err instanceof InvalidConfigError) { + this.config.messages = err.errors; + this.fireConfigChanged(oldConfigState, this.config); + } else { + throw err; + } + } } get cliConfiguration(): DefaultCliConfig | undefined { return this.cliConfig; } - get onConfigChange(): Event { + get onConfigChange(): Event<{ + oldState: ConfigState; + newState: ConfigState; + }> { return this.configChangeEmitter.event; } @@ -129,9 +137,42 @@ export class ConfigServiceImpl return this.daemon.getVersion(); } - protected async loadCliConfig( + private async initConfig(): Promise { + try { + const cliConfig = await this.loadCliConfig(); + this.cliConfig = cliConfig; + const [config] = await Promise.all([ + this.mapCliConfigToAppConfig(this.cliConfig), + this.ensureUserDirExists(this.cliConfig).catch((reason) => { + if (reason instanceof Error) { + this.logger.warn( + `Could not ensure user directory existence: ${this.cliConfig?.directories.user}`, + reason + ); + } + // NOOP. Try to create the folder if missing but swallow any errors. + // The validation will take care of the missing location handling. + }), + ]); + this.config.config = config; + await this.validateCliConfig(this.cliConfig); + delete this.config.messages; + if (config) { + this.ready.resolve(); + return; + } + } catch (err: unknown) { + this.logger.error('Failed to initialize the CLI configuration.', err); + if (err instanceof InvalidConfigError) { + this.config.messages = err.errors; + this.ready.resolve(); + } + } + } + + private async loadCliConfig( initializeIfAbsent = true - ): Promise { + ): Promise { const cliConfigFileUri = await this.getCliConfigFileUri(); const cliConfigPath = FileUri.fsPath(cliConfigFileUri); try { @@ -157,7 +198,7 @@ export class ConfigServiceImpl } } - protected async getFallbackCliConfig(): Promise { + private async getFallbackCliConfig(): Promise { const cliPath = await this.daemon.getExecPath(); const rawJson = await spawnCommand(`"${cliPath}"`, [ 'config', @@ -168,7 +209,7 @@ export class ConfigServiceImpl return JSON.parse(rawJson); } - protected async initCliConfigTo(fsPathToDir: string): Promise { + private async initCliConfigTo(fsPathToDir: string): Promise { const cliPath = await this.daemon.getExecPath(); await spawnCommand(`"${cliPath}"`, [ 'config', @@ -178,7 +219,7 @@ export class ConfigServiceImpl ]); } - protected async mapCliConfigToAppConfig( + private async mapCliConfigToAppConfig( cliConfig: DefaultCliConfig ): Promise { const { directories, locale = 'en' } = cliConfig; @@ -199,16 +240,45 @@ export class ConfigServiceImpl }; } - protected fireConfigChanged(config: Config): void { - this.configChangeEmitter.fire(config); - this.notificationService.notifyConfigDidChange({ config }); + private fireConfigChanged( + oldState: ConfigState, + newState: ConfigState + ): void { + this.configChangeEmitter.fire({ oldState, newState }); + this.notificationService.notifyConfigDidChange(newState); + } + + private async validateCliConfig(config: DefaultCliConfig): Promise { + const errors: string[] = []; + errors.push(...(await this.checkAccessible(config))); + if (errors.length) { + throw new InvalidConfigError(errors); + } } - protected fireInvalidConfig(): void { - this.notificationService.notifyConfigDidChange({ config: undefined }); + private async checkAccessible({ + directories, + }: DefaultCliConfig): Promise { + try { + await fs.readdir(directories.user); + return []; + } catch (err) { + console.error( + `Check accessible failed for input: ${directories.user}`, + err + ); + return [ + nls.localize( + 'arduino/configuration/cli/inaccessibleDirectory', + "Could not access the sketchbook location at '{0}': {1}", + directories.user, + String(err) + ), + ]; + } } - protected async updateDaemon( + private async updateDaemon( port: string | number, config: DefaultCliConfig ): Promise { @@ -216,7 +286,7 @@ export class ConfigServiceImpl const req = new MergeRequest(); const json = JSON.stringify(config, null, 2); req.setJsonData(json); - console.log(`Updating daemon with 'data': ${json}`); + this.logger.info(`Updating daemon with 'data': ${json}`); return new Promise((resolve, reject) => { client.merge(req, (error) => { try { @@ -232,7 +302,7 @@ export class ConfigServiceImpl }); } - protected async writeDaemonState(port: string | number): Promise { + private async writeDaemonState(port: string | number): Promise { const client = this.createClient(port); const req = new WriteRequest(); const cliConfigUri = await this.getCliConfigFileUri(); @@ -273,3 +343,13 @@ export class ConfigServiceImpl await fs.mkdir(cliConfig.directories.user, { recursive: true }); } } + +class InvalidConfigError extends Error { + constructor(readonly errors: string[]) { + super('InvalidConfigError:\n - ' + errors.join('\n - ')); + if (!errors.length) { + throw new Error("Illegal argument: 'messages'. It must not be empty."); + } + Object.setPrototypeOf(this, InvalidConfigError.prototype); + } +} diff --git a/arduino-ide-extension/src/node/core-client-provider.ts b/arduino-ide-extension/src/node/core-client-provider.ts index f039f6594..0864d669e 100644 --- a/arduino-ide-extension/src/node/core-client-provider.ts +++ b/arduino-ide-extension/src/node/core-client-provider.ts @@ -25,6 +25,7 @@ import { IndexUpdateDidFailParams, IndexUpdateWillStartParams, NotificationServiceServer, + AdditionalUrls, } from '../common/protocol'; import { Deferred } from '@theia/core/lib/common/promise-util'; import { @@ -75,9 +76,27 @@ export class CoreClientProvider { }); this.daemon.onDaemonStarted((port) => this.create(port)); this.daemon.onDaemonStopped(() => this.closeClient()); - this.configService.onConfigChange( - () => this.client.then((client) => this.updateIndex(client, ['platform'])) // Assuming 3rd party URL changes. No library index update is required. - ); + this.configService.onConfigChange(async ({ oldState, newState }) => { + if ( + !AdditionalUrls.sameAs( + oldState.config?.additionalUrls, + newState.config?.additionalUrls + ) + ) { + const client = await this.client; + this.updateIndex(client, ['platform']); + } else if ( + !!newState.config?.sketchDirUri && + oldState.config?.sketchDirUri !== newState.config.sketchDirUri + ) { + // If the sketchbook location has changed, the custom libraries has changed. + // Reinitialize the core client and fire an event so that the frontend can refresh. + // https://github.com/arduino/arduino-ide/issues/796 (see the file > examples and sketch > include examples) + const client = await this.client; + await this.initInstance(client); + this.notificationService.notifyDidReinitialize(); + } + }); } get tryGetClient(): CoreClientProvider.Client | undefined { diff --git a/arduino-ide-extension/src/node/notification-service-server.ts b/arduino-ide-extension/src/node/notification-service-server.ts index 3dc3280d7..5fcb023d2 100644 --- a/arduino-ide-extension/src/node/notification-service-server.ts +++ b/arduino-ide-extension/src/node/notification-service-server.ts @@ -5,7 +5,7 @@ import type { AttachedBoardsChangeEvent, BoardsPackage, LibraryPackage, - Config, + ConfigState, Sketch, ProgressMessage, IndexUpdateWillStartParams, @@ -19,6 +19,10 @@ export class NotificationServiceServerImpl { private readonly clients: NotificationServiceClient[] = []; + notifyDidReinitialize(): void { + this.clients.forEach((client) => client.notifyDidReinitialize()); + } + notifyIndexUpdateWillStart(params: IndexUpdateWillStartParams): void { this.clients.forEach((client) => client.notifyIndexUpdateWillStart(params)); } @@ -69,7 +73,7 @@ export class NotificationServiceServerImpl ); } - notifyConfigDidChange(event: { config: Config | undefined }): void { + notifyConfigDidChange(event: ConfigState): void { this.clients.forEach((client) => client.notifyConfigDidChange(event)); } diff --git a/arduino-ide-extension/src/node/sketches-service-impl.ts b/arduino-ide-extension/src/node/sketches-service-impl.ts index f20723f01..dc7553d33 100644 --- a/arduino-ide-extension/src/node/sketches-service-impl.ts +++ b/arduino-ide-extension/src/node/sketches-service-impl.ts @@ -80,6 +80,15 @@ export class SketchesServiceImpl async getSketches({ uri }: { uri?: string }): Promise { const root = await this.root(uri); + if (!root) { + this.logger.warn(`Could not derive sketchbook root from ${uri}.`); + return SketchContainer.create(''); + } + const exists = await this.exists(root); + if (!exists) { + this.logger.warn(`Sketchbook root ${root} does not exist.`); + return SketchContainer.create(''); + } const pathToAllSketchFiles = await new Promise( (resolve, reject) => { glob( @@ -179,13 +188,23 @@ export class SketchesServiceImpl return container; } - private async root(uri?: string | undefined): Promise { - return FileUri.fsPath(uri ?? (await this.sketchbookUri())); + private async root(uri?: string | undefined): Promise { + if (uri) { + return FileUri.fsPath(uri); + } + const sketchbookUri = await this.sketchbookUri(); + if (sketchbookUri) { + return FileUri.fsPath(sketchbookUri); + } + return undefined; } - private async sketchbookUri(): Promise { - const { sketchDirUri } = await this.configService.getConfiguration(); - return sketchDirUri; + private async sketchbookUri(): Promise { + const { config, messages } = await this.configService.getConfiguration(); + if (!config?.sketchDirUri || messages?.length) { + return undefined; + } + return config.sketchDirUri; } async loadSketch(uri: string): Promise { @@ -454,8 +473,10 @@ export class SketchesServiceImpl const sketchBaseName = `sketch_${ monthNames[today.getMonth()] }${today.getDate()}`; - const config = await this.configService.getConfiguration(); - const sketchbookPath = FileUri.fsPath(config.sketchDirUri); + const { config } = await this.configService.getConfiguration(); + const sketchbookPath = config?.sketchDirUri + ? FileUri.fsPath(config?.sketchDirUri) + : os.homedir(); let sketchName: string | undefined; // If it's another day, reset the count of sketches created today diff --git a/i18n/en.json b/i18n/en.json index 778914aa6..2b2f8c674 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -157,6 +157,11 @@ "uninstallMsg": "Do you want to uninstall {0}?", "version": "Version {0}" }, + "configuration": { + "cli": { + "inaccessibleDirectory": "Could not access the sketchbook location at '{0}': {1}" + } + }, "contributions": { "addFile": "Add File", "fileAdded": "One file added to the sketch.", @@ -352,6 +357,7 @@ "manualProxy": "Manual proxy configuration", "network": "Network", "newSketchbookLocation": "Select new sketchbook location", + "noCliConfig": "Could not load the CLI configuration", "noProxy": "No proxy", "proxySettings": { "hostname": "Host name",