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 5a0b5221b..fdab829f3 100644 --- a/arduino-ide-extension/src/browser/arduino-ide-frontend-module.ts +++ b/arduino-ide-extension/src/browser/arduino-ide-frontend-module.ts @@ -302,6 +302,7 @@ import { CoreErrorHandler } from './contributions/core-error-handler'; import { CompilerErrors } from './contributions/compiler-errors'; import { WidgetManager } from './theia/core/widget-manager'; import { WidgetManager as TheiaWidgetManager } from '@theia/core/lib/browser/widget-manager'; +import { StartupTask } from './widgets/sketchbook/startup-task'; MonacoThemingService.register({ id: 'arduino-theme', @@ -698,6 +699,7 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { Contribution.configure(bind, PlotterFrontendContribution); Contribution.configure(bind, Format); Contribution.configure(bind, CompilerErrors); + Contribution.configure(bind, StartupTask); // Disabled the quick-pick customization from Theia when multiple formatters are available. // Use the default VS Code behavior, and pick the first one. In the IDE2, clang-format has `exclusive` selectors. diff --git a/arduino-ide-extension/src/browser/theia/workspace/workspace-service.ts b/arduino-ide-extension/src/browser/theia/workspace/workspace-service.ts index e49da551b..3ba955c7c 100644 --- a/arduino-ide-extension/src/browser/theia/workspace/workspace-service.ts +++ b/arduino-ide-extension/src/browser/theia/workspace/workspace-service.ts @@ -9,7 +9,10 @@ import { FrontendApplication } from '@theia/core/lib/browser/frontend-applicatio import { FocusTracker, Widget } from '@theia/core/lib/browser'; import { DEFAULT_WINDOW_HASH } from '@theia/core/lib/common/window'; import { FrontendApplicationStateService } from '@theia/core/lib/browser/frontend-application-state'; -import { WorkspaceService as TheiaWorkspaceService } from '@theia/workspace/lib/browser/workspace-service'; +import { + WorkspaceInput, + WorkspaceService as TheiaWorkspaceService, +} from '@theia/workspace/lib/browser/workspace-service'; import { ConfigService } from '../../../common/protocol/config-service'; import { SketchesService, @@ -17,6 +20,8 @@ import { } from '../../../common/protocol/sketches-service'; import { BoardsServiceProvider } from '../../boards/boards-service-provider'; import { BoardsConfig } from '../../boards/boards-config'; +import { FileStat } from '@theia/filesystem/lib/common/files'; +import { StartupTask } from '../../widgets/sketchbook/startup-task'; @injectable() export class WorkspaceService extends TheiaWorkspaceService { @@ -82,13 +87,75 @@ export class WorkspaceService extends TheiaWorkspaceService { } } - protected override openNewWindow(workspacePath: string): void { + /** + * Copied from Theia as-is to be able to pass the original `options` down. + */ + protected override async doOpen( + uri: URI, + options?: WorkspaceInput + ): Promise { + const stat = await this.toFileStat(uri); + if (stat) { + if (!stat.isDirectory && !this.isWorkspaceFile(stat)) { + const message = `Not a valid workspace: ${uri.path.toString()}`; + this.messageService.error(message); + throw new Error(message); + } + // The same window has to be preserved too (instead of opening a new one), if the workspace root is not yet available and we are setting it for the first time. + // Option passed as parameter has the highest priority (for api developers), then the preference, then the default. + await this.roots; + const { preserveWindow } = { + preserveWindow: + this.preferences['workspace.preserveWindow'] || !this.opened, + ...options, + }; + await this.server.setMostRecentlyUsedWorkspace(uri.toString()); + if (preserveWindow) { + this._workspace = stat; + } + this.openWindow(stat, Object.assign(options ?? {}, { preserveWindow })); // Unlike Theia, IDE2 passes the whole `input` downstream and not only { preserveWindow } + return; + } + throw new Error( + 'Invalid workspace root URI. Expected an existing directory or workspace file.' + ); + } + + /** + * Copied from Theia. Can pass the `options` further down the chain. + */ + protected override openWindow(uri: FileStat, options?: WorkspaceInput): void { + const workspacePath = uri.resource.path.toString(); + if (this.shouldPreserveWindow(options)) { + this.reloadWindow(); + } else { + try { + this.openNewWindow(workspacePath, options); // Unlike Theia, IDE2 passes the `input` downstream. + } catch (error) { + // Fall back to reloading the current window in case the browser has blocked the new window + this._workspace = uri; + this.logger.error(error.toString()).then(() => this.reloadWindow()); + } + } + } + + protected override openNewWindow( + workspacePath: string, + options?: WorkspaceInput + ): void { const { boardsConfig } = this.boardsServiceProvider; const url = BoardsConfig.Config.setConfig( boardsConfig, new URL(window.location.href) ); // Set the current boards config for the new browser window. url.hash = workspacePath; + if (StartupTask.WorkspaceInput.is(options)) { + url.searchParams.set( + StartupTask.QUERY_STRING, + encodeURIComponent(JSON.stringify(options.tasks)) + ); + } + this.windowService.openNewWindow(url.toString()); } diff --git a/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-contributions.ts b/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-contributions.ts index 85e703554..54594b2dc 100644 --- a/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-contributions.ts +++ b/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-contributions.ts @@ -23,7 +23,10 @@ import { } from '@theia/core/lib/browser/preferences/preference-service'; import { ArduinoMenus, PlaceholderMenuNode } from '../../menu/arduino-menus'; import { SketchbookCommands } from '../sketchbook/sketchbook-commands'; -import { CurrentSketch, SketchesServiceClientImpl } from '../../../common/protocol/sketches-service-client-impl'; +import { + CurrentSketch, + SketchesServiceClientImpl, +} from '../../../common/protocol/sketches-service-client-impl'; import { Contribution } from '../../contributions/contribution'; import { ArduinoPreferences } from '../../arduino-preferences'; import { MainMenuManager } from '../../../common/main-menu-manager'; diff --git a/arduino-ide-extension/src/browser/widgets/sketchbook/sketchbook-commands.ts b/arduino-ide-extension/src/browser/widgets/sketchbook/sketchbook-commands.ts index f8e360650..50b5f9008 100644 --- a/arduino-ide-extension/src/browser/widgets/sketchbook/sketchbook-commands.ts +++ b/arduino-ide-extension/src/browser/widgets/sketchbook/sketchbook-commands.ts @@ -1,6 +1,14 @@ import { Command } from '@theia/core/lib/common/command'; export namespace SketchbookCommands { + export const TOGGLE_SKETCHBOOK_WIDGET: Command = { + id: 'arduino-sketchbook-widget:toggle', + }; + + export const REVEAL_SKETCH_NODE: Command = { + id: 'arduino-sketchbook--reveal-sketch-node', + }; + export const OPEN_NEW_WINDOW = Command.toLocalizedCommand( { id: 'arduino-sketchbook--open-sketch-new-window', diff --git a/arduino-ide-extension/src/browser/widgets/sketchbook/sketchbook-widget-contribution.ts b/arduino-ide-extension/src/browser/widgets/sketchbook/sketchbook-widget-contribution.ts index 16b66a26a..74b782887 100644 --- a/arduino-ide-extension/src/browser/widgets/sketchbook/sketchbook-widget-contribution.ts +++ b/arduino-ide-extension/src/browser/widgets/sketchbook/sketchbook-widget-contribution.ts @@ -29,6 +29,7 @@ import { } from '../../../common/protocol/sketches-service-client-impl'; import { FileService } from '@theia/filesystem/lib/browser/file-service'; import { URI } from '../../contributions/contribution'; +import { WorkspaceInput } from '@theia/workspace/lib/browser'; export const SKETCHBOOK__CONTEXT = ['arduino-sketchbook--context']; @@ -77,7 +78,7 @@ export class SketchbookWidgetContribution area: 'left', rank: 1, }, - toggleCommandId: 'arduino-sketchbook-widget:toggle', + toggleCommandId: SketchbookCommands.TOGGLE_SKETCHBOOK_WIDGET.id, toggleKeybinding: 'CtrlCmd+Shift+B', }); } @@ -100,11 +101,12 @@ export class SketchbookWidgetContribution override registerCommands(registry: CommandRegistry): void { super.registerCommands(registry); - + registry.registerCommand(SketchbookCommands.REVEAL_SKETCH_NODE, { + execute: (treeWidgetId: string, nodeUri: string) => + this.revealSketchNode(treeWidgetId, nodeUri), + }); registry.registerCommand(SketchbookCommands.OPEN_NEW_WINDOW, { - execute: async (arg) => { - return this.workspaceService.open(arg.node.uri); - }, + execute: (arg) => this.openNewWindow(arg.node), isEnabled: (arg) => !!arg && 'node' in arg && SketchbookTree.SketchDirNode.is(arg.node), isVisible: (arg) => @@ -197,7 +199,7 @@ export class SketchbookWidgetContribution // unregister main menu action registry.unregisterMenuAction({ - commandId: 'arduino-sketchbook-widget:toggle', + commandId: SketchbookCommands.TOGGLE_SKETCHBOOK_WIDGET.id, }); registry.registerMenuAction(SKETCHBOOK__CONTEXT__MAIN_GROUP, { @@ -207,6 +209,28 @@ export class SketchbookWidgetContribution }); } + private openNewWindow(node: SketchbookTree.SketchDirNode): void { + const widget = this.tryGetWidget(); + if (widget) { + const treeWidgetId = widget.activeTreeWidgetId(); + if (!treeWidgetId) { + console.warn(`Could not retrieve active sketchbook tree ID.`); + return; + } + const nodeUri = node.uri.toString(); + const options: WorkspaceInput = {}; + Object.assign(options, { + tasks: [ + { + command: SketchbookCommands.REVEAL_SKETCH_NODE.id, + args: [treeWidgetId, nodeUri], + }, + ], + }); + return this.workspaceService.open(node.uri, options); + } + } + /** * Reveals and selects node in the file navigator to which given widget is related. * Does nothing if given widget undefined or doesn't have related resource. @@ -230,4 +254,17 @@ export class SketchbookWidgetContribution protected onCurrentWidgetChangedHandler(): void { this.selectWidgetFileNode(this.shell.currentWidget); } + + private async revealSketchNode( + treeWidgetId: string, + nodeUIri: string + ): Promise { + return this.widget + .then((widget) => this.shell.activateWidget(widget.id)) + .then((widget) => { + if (widget instanceof SketchbookWidget) { + return widget.revealSketchNode(treeWidgetId, nodeUIri); + } + }); + } } diff --git a/arduino-ide-extension/src/browser/widgets/sketchbook/sketchbook-widget.tsx b/arduino-ide-extension/src/browser/widgets/sketchbook/sketchbook-widget.tsx index f0a427de7..0b7b920a1 100644 --- a/arduino-ide-extension/src/browser/widgets/sketchbook/sketchbook-widget.tsx +++ b/arduino-ide-extension/src/browser/widgets/sketchbook/sketchbook-widget.tsx @@ -1,4 +1,8 @@ -import { inject, injectable, postConstruct } from '@theia/core/shared/inversify'; +import { + inject, + injectable, + postConstruct, +} from '@theia/core/shared/inversify'; import { toArray } from '@theia/core/shared/@phosphor/algorithm'; import { IDragEvent } from '@theia/core/shared/@phosphor/dragdrop'; import { DockPanel, Widget } from '@theia/core/shared/@phosphor/widgets'; @@ -7,6 +11,8 @@ import { Disposable } from '@theia/core/lib/common/disposable'; import { BaseWidget } from '@theia/core/lib/browser/widgets/widget'; import { SketchbookTreeWidget } from './sketchbook-tree-widget'; import { nls } from '@theia/core/lib/common'; +import { CloudSketchbookCompositeWidget } from '../cloud-sketchbook/cloud-sketchbook-composite-widget'; +import { URI } from '../../contributions/contribution'; @injectable() export class SketchbookWidget extends BaseWidget { @@ -45,6 +51,57 @@ export class SketchbookWidget extends BaseWidget { return this.localSketchbookTreeWidget; } + activeTreeWidgetId(): string | undefined { + const selectedTreeWidgets = toArray( + this.sketchbookTreesContainer.selectedWidgets() + ).map(({ id }) => id); + if (selectedTreeWidgets.length > 1) { + console.warn( + `Found multiple selected tree widgets: ${JSON.stringify( + selectedTreeWidgets + )}. Expected only one.` + ); + } + return selectedTreeWidgets.shift(); + } + + async revealSketchNode(treeWidgetId: string, nodeUri: string): Promise { + const widget = toArray(this.sketchbookTreesContainer.widgets()) + .filter(({ id }) => id === treeWidgetId) + .shift(); + if (!widget) { + console.warn(`Could not find tree widget with ID: ${widget}`); + return; + } + // TODO: remove this when the remote/local sketchbooks and their widgets are cleaned up. + const findTreeWidget = ( + widget: Widget | undefined + ): SketchbookTreeWidget | undefined => { + if (widget instanceof SketchbookTreeWidget) { + return widget; + } + if (widget instanceof CloudSketchbookCompositeWidget) { + return widget.getTreeWidget(); + } + return undefined; + }; + const treeWidget = findTreeWidget( + toArray(this.sketchbookTreesContainer.widgets()) + .filter(({ id }) => id === treeWidgetId) + .shift() + ); + if (!treeWidget) { + console.warn(`Could not find tree widget with ID: ${treeWidget}`); + return; + } + this.sketchbookTreesContainer.activateWidget(widget); + + const treeNode = await treeWidget.model.revealFile(new URI(nodeUri)); + if (!treeNode) { + console.warn(`Could not find tree node with URI: ${nodeUri}`); + } + } + protected override onActivateRequest(message: Message): void { super.onActivateRequest(message); diff --git a/arduino-ide-extension/src/browser/widgets/sketchbook/startup-task.ts b/arduino-ide-extension/src/browser/widgets/sketchbook/startup-task.ts new file mode 100644 index 000000000..47ab60dbc --- /dev/null +++ b/arduino-ide-extension/src/browser/widgets/sketchbook/startup-task.ts @@ -0,0 +1,42 @@ +import { injectable } from '@theia/core/shared/inversify'; +import { WorkspaceInput as TheiaWorkspaceInput } from '@theia/workspace/lib/browser'; +import { Contribution } from '../../contributions/contribution'; + +export interface Task { + command: string; + /** + * This must be JSON serializable. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + args?: any[]; +} + +@injectable() +export class StartupTask extends Contribution { + override onReady(): void { + const params = new URLSearchParams(window.location.search); + const encoded = params.get(StartupTask.QUERY_STRING); + if (!encoded) return; + + const commands = JSON.parse(decodeURIComponent(encoded)); + + if (Array.isArray(commands)) { + commands.forEach(({ command, args }) => { + this.commandService.executeCommand(command, ...args); + }); + } + } +} +export namespace StartupTask { + export const QUERY_STRING = 'startupTasks'; + export interface WorkspaceInput extends TheiaWorkspaceInput { + tasks: Task[]; + } + export namespace WorkspaceInput { + export function is( + input: (TheiaWorkspaceInput & Partial) | undefined + ): input is WorkspaceInput { + return !!input && !!input.tasks; + } + } +}