From 506446c62b9398b4f3a7ef8f295b22d929f7ef87 Mon Sep 17 00:00:00 2001 From: Matt Hillsdon <44397098+microbit-matt-hillsdon@users.noreply.github.com> Date: Tue, 9 Jul 2024 17:38:34 +0100 Subject: [PATCH] Use EventTarget rather than the Node "events" module (#1188) This should amount no meaningful change but is a useful preliminary to extracting the device code for reuse. Incorporates the only meaningful file from https://github.com/DerZade/typescript-event-target/ (with licence details) to save on the dependency. --- package-lock.json | 9 -- package.json | 1 - src/common/events.ts | 122 ++++++++++++++++++ src/device/device-hooks.tsx | 48 ++++--- src/device/device.ts | 65 ++++++++-- src/device/mock.ts | 21 ++- src/device/simulator.ts | 95 ++++++++++---- src/device/webusb.test.ts | 8 +- src/device/webusb.ts | 34 ++--- src/e2e/simulator.test.ts | 21 ++- src/editor/codemirror/language-server/view.ts | 19 +-- src/fs/fs.test.ts | 5 +- src/fs/fs.ts | 32 ++++- src/fs/host-iframe.test.ts | 2 +- src/fs/host.ts | 12 +- src/language-server/client-fs.ts | 13 +- src/language-server/client.ts | 27 ++-- src/project/project-actions.tsx | 7 +- src/project/project-hooks.tsx | 6 +- src/serial/XTerm.tsx | 14 +- src/simulator/SimulatorActionBar.tsx | 5 +- src/simulator/SimulatorModules.tsx | 9 +- src/simulator/data-logging-hooks.tsx | 9 +- src/simulator/radio-hooks.tsx | 13 +- src/workbench/Workbench.tsx | 30 +++-- src/workbench/connect-dialogs/Overlay.tsx | 12 +- 26 files changed, 436 insertions(+), 203 deletions(-) create mode 100644 src/common/events.ts diff --git a/package-lock.json b/package-lock.json index 4277d15e4..4c85a5423 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,7 +38,6 @@ "crelt": "^1.0.5", "dapjs": "2.2.0", "dompurify": "^2.3.3", - "events": "^3.3.0", "file-saver": "^2.0.5", "framer-motion": "^10.2.4", "lodash.debounce": "^4.0.8", @@ -7493,14 +7492,6 @@ "node": ">=0.10.0" } }, - "node_modules/events": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "engines": { - "node": ">=0.8.x" - } - }, "node_modules/execa": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", diff --git a/package.json b/package.json index e3e07e491..defc4f33d 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,6 @@ "crelt": "^1.0.5", "dapjs": "2.2.0", "dompurify": "^2.3.3", - "events": "^3.3.0", "file-saver": "^2.0.5", "framer-motion": "^10.2.4", "lodash.debounce": "^4.0.8", diff --git a/src/common/events.ts b/src/common/events.ts new file mode 100644 index 000000000..df7a78659 --- /dev/null +++ b/src/common/events.ts @@ -0,0 +1,122 @@ +/** + * Copyright (c) 2022 Jonas "DerZade" Schade + * + * SPDX-License-Identifier: MIT + * + * https://github.com/DerZade/typescript-event-target/blob/master/src/TypedEventTarget.ts + */ + +/** + * A function that can be passed to the `listener` parameter of {@link TypedEventTarget.addEventListener} and {@link TypedEventTarget.removeEventListener}. + * + * @template M A map of event types to their respective event classes. + * @template T The type of event to listen for (has to be keyof `M`). + */ +export type TypedEventListener = ( + evt: M[T] +) => void | Promise; + +/** + * An object that can be passed to the `listener` parameter of {@link TypedEventTarget.addEventListener} and {@link TypedEventTarget.removeEventListener}. + * + * @template M A map of event types to their respective event classes. + * @template T The type of event to listen for (has to be keyof `M`). + */ +export interface TypedEventListenerObject { + handleEvent: (evt: M[T]) => void | Promise; +} + +/** + * Type of parameter `listener` in {@link TypedEventTarget.addEventListener} and {@link TypedEventTarget.removeEventListener}. + * + * The object that receives a notification (an object that implements the Event interface) when an event of the specified type occurs. + * + * Can be either an object with a handleEvent() method, or a JavaScript function. + * + * @template M A map of event types to their respective event classes. + * @template T The type of event to listen for (has to be keyof `M`). + */ +export type TypedEventListenerOrEventListenerObject = + | TypedEventListener + | TypedEventListenerObject; + +type ValueIsEvent = { + [key in keyof T]: Event; +}; + +/** + * Typescript friendly version of {@link EventTarget} + * + * @template M A map of event types to their respective event classes. + * + * @example + * ```typescript + * interface MyEventMap { + * hello: Event; + * time: CustomEvent; + * } + * + * const eventTarget = new TypedEventTarget(); + * + * eventTarget.addEventListener('time', (event) => { + * // event is of type CustomEvent + * }); + * ``` + */ +export interface TypedEventTarget> { + /** Appends an event listener for events whose type attribute value is type. + * The callback argument sets the callback that will be invoked when the event + * is dispatched. + * + * The options argument sets listener-specific options. For compatibility this + * can be a boolean, in which case the method behaves exactly as if the value + * was specified as options's capture. + * + * When set to true, options's capture prevents callback from being invoked + * when the event's eventPhase attribute value is BUBBLING_PHASE. When false + * (or not present), callback will not be invoked when event's eventPhase + * attribute value is CAPTURING_PHASE. Either way, callback will be invoked if + * event's eventPhase attribute value is AT_TARGET. + * + * When set to true, options's passive indicates that the callback will not + * cancel the event by invoking preventDefault(). This is used to enable + * performance optimizations described in ยง 2.8 Observing event listeners. + * + * When set to true, options's once indicates that the callback will only be + * invoked once after which the event listener will be removed. + * + * The event listener is appended to target's event listener list and is not + * appended if it has the same type, callback, and capture. */ + addEventListener: ( + type: T, + listener: TypedEventListenerOrEventListenerObject | null, + options?: boolean | AddEventListenerOptions + ) => void; + + /** Removes the event listener in target's event listener list with the same + * type, callback, and options. */ + removeEventListener: ( + type: T, + callback: TypedEventListenerOrEventListenerObject | null, + options?: EventListenerOptions | boolean + ) => void; + + /** + * Dispatches a synthetic event event to target and returns true if either + * event's cancelable attribute value is false or its preventDefault() method + * was not invoked, and false otherwise. + * @deprecated To ensure type safety use `dispatchTypedEvent` instead. + */ + dispatchEvent: (event: Event) => boolean; +} +// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging +export class TypedEventTarget> extends EventTarget { + /** + * Dispatches a synthetic event event to target and returns true if either + * event's cancelable attribute value is false or its preventDefault() method + * was not invoked, and false otherwise. + */ + public dispatchTypedEvent(_type: T, event: M[T]): boolean { + return super.dispatchEvent(event); + } +} diff --git a/src/device/device-hooks.tsx b/src/device/device-hooks.tsx index 4059b4126..ef6bc8907 100644 --- a/src/device/device-hooks.tsx +++ b/src/device/device-hooks.tsx @@ -11,17 +11,13 @@ import React, { useEffect, useState, } from "react"; -import { EVENT_PROJECT_UPDATED, EVENT_TEXT_EDIT } from "../fs/fs"; import { useFileSystem } from "../fs/fs-hooks"; import { useLogging } from "../logging/logging-hooks"; import { ConnectionStatus, DeviceConnection, - EVENT_FLASH, - EVENT_SERIAL_DATA, - EVENT_SERIAL_ERROR, - EVENT_SERIAL_RESET, - EVENT_STATUS, + SerialDataEvent, + ConnectionStatusEvent, } from "./device"; import { SimulatorDeviceConnection } from "./simulator"; @@ -60,12 +56,12 @@ export const useConnectionStatus = () => { const device = useDevice(); const [status, setStatus] = useState(device.status); useEffect(() => { - const statusListener = (status: ConnectionStatus) => { - setStatus(status); + const statusListener = (event: ConnectionStatusEvent) => { + setStatus(event.status); }; - device.on(EVENT_STATUS, statusListener); + device.addEventListener("status", statusListener); return () => { - device.removeListener(EVENT_STATUS, statusListener); + device.removeEventListener("status", statusListener); }; }, [device, setStatus]); @@ -189,8 +185,8 @@ export const useDeviceTraceback = () => { useEffect(() => { const buffer = new TracebackScrollback(); - const dataListener = (data: string) => { - const latest = buffer.push(data); + const dataListener = (event: SerialDataEvent) => { + const latest = buffer.push(event.data); setRuntimeError((current) => { if (!current && latest) { logging.event({ @@ -204,13 +200,13 @@ export const useDeviceTraceback = () => { buffer.clear(); setRuntimeError(undefined); }; - device.addListener(EVENT_SERIAL_DATA, dataListener); - device.addListener(EVENT_SERIAL_RESET, clearListener); - device.addListener(EVENT_SERIAL_ERROR, clearListener); + device.addEventListener("serial_data", dataListener); + device.addEventListener("serial_reset", clearListener); + device.addEventListener("serial_error", clearListener); return () => { - device.removeListener(EVENT_SERIAL_ERROR, clearListener); - device.removeListener(EVENT_SERIAL_RESET, clearListener); - device.removeListener(EVENT_SERIAL_DATA, dataListener); + device.removeEventListener("serial_error", clearListener); + device.removeEventListener("serial_reset", clearListener); + device.removeEventListener("serial_data", dataListener); }; }, [device, setRuntimeError, logging]); @@ -247,15 +243,15 @@ export const DeviceContextProvider = ({ useEffect(() => { const moveToOutOfSync = () => setSyncStatus(SyncStatus.OUT_OF_SYNC); const moveToInSync = () => setSyncStatus(SyncStatus.IN_SYNC); - fs.on(EVENT_TEXT_EDIT, moveToOutOfSync); - fs.on(EVENT_PROJECT_UPDATED, moveToOutOfSync); - device.on(EVENT_FLASH, moveToInSync); - device.on(EVENT_STATUS, moveToOutOfSync); + fs.addEventListener("file_text_updated", moveToOutOfSync); + fs.addEventListener("project_updated", moveToOutOfSync); + device.addEventListener("flash", moveToInSync); + device.addEventListener("status", moveToOutOfSync); return () => { - fs.removeListener(EVENT_TEXT_EDIT, moveToOutOfSync); - fs.removeListener(EVENT_PROJECT_UPDATED, moveToOutOfSync); - device.removeListener(EVENT_STATUS, moveToOutOfSync); - device.removeListener(EVENT_FLASH, moveToInSync); + fs.removeEventListener("file_text_updated", moveToOutOfSync); + fs.removeEventListener("project_updated", moveToOutOfSync); + device.removeEventListener("status", moveToOutOfSync); + device.removeEventListener("flash", moveToInSync); }; }, [fs, device, setSyncStatus]); return ( diff --git a/src/device/device.ts b/src/device/device.ts index 9e9396b25..a432501b7 100644 --- a/src/device/device.ts +++ b/src/device/device.ts @@ -3,7 +3,7 @@ * * SPDX-License-Identifier: MIT */ -import EventEmitter from "events"; +import { TypedEventTarget } from "../common/events"; import { Logging } from "../logging/logging"; import { BoardId } from "./board-id"; @@ -96,14 +96,6 @@ export enum ConnectionAction { DISCONNECT = "DISCONNECT", } -export const EVENT_STATUS = "status"; -export const EVENT_SERIAL_DATA = "serial_data"; -export const EVENT_SERIAL_RESET = "serial_reset"; -export const EVENT_SERIAL_ERROR = "serial_error"; -export const EVENT_FLASH = "flash"; -export const EVENT_START_USB_SELECT = "start_usb_select"; -export const EVENT_END_USB_SELECT = "end_usb_select"; - export class HexGenerationError extends Error {} export interface FlashDataSource { @@ -135,7 +127,60 @@ export interface ConnectOptions { export type BoardVersion = "V1" | "V2"; -export interface DeviceConnection extends EventEmitter { +export class ConnectionStatusEvent extends Event { + constructor(public readonly status: ConnectionStatus) { + super("status"); + } +} + +export class SerialDataEvent extends Event { + constructor(public readonly data: string) { + super("serial_data"); + } +} + +export class SerialResetEvent extends Event { + constructor() { + super("serial_reset"); + } +} + +export class SerialErrorEvent extends Event { + constructor(public readonly error: unknown) { + super("serial_error"); + } +} + +export class FlashEvent extends Event { + constructor() { + super("flash"); + } +} + +export class StartUSBSelect extends Event { + constructor() { + super("start_usb_select"); + } +} + +export class EndUSBSelect extends Event { + constructor() { + super("end_usb_select"); + } +} + +export class DeviceConnectionEventMap { + "status": ConnectionStatusEvent; + "serial_data": SerialDataEvent; + "serial_reset": Event; + "serial_error": Event; + "flash": Event; + "start_usb_select": Event; + "end_usb_select": Event; +} + +export interface DeviceConnection + extends TypedEventTarget { status: ConnectionStatus; /** diff --git a/src/device/mock.ts b/src/device/mock.ts index 5854ea616..824374a21 100644 --- a/src/device/mock.ts +++ b/src/device/mock.ts @@ -3,18 +3,19 @@ * * SPDX-License-Identifier: MIT */ +import { TypedEventTarget } from "../common/events"; import { BoardVersion, ConnectionStatus, DeviceConnection, - EVENT_FLASH, - EVENT_SERIAL_DATA, - EVENT_STATUS, + DeviceConnectionEventMap, FlashDataSource, + FlashEvent, + SerialDataEvent, + ConnectionStatusEvent, WebUSBError, WebUSBErrorCode, } from "./device"; -import EventEmitter from "events"; /** * A mock device used during end-to-end testing. @@ -24,7 +25,7 @@ import EventEmitter from "events"; * the connected state without a real device. */ export class MockDeviceConnection - extends EventEmitter + extends TypedEventTarget implements DeviceConnection { status: ConnectionStatus = navigator.usb @@ -40,7 +41,7 @@ export class MockDeviceConnection } mockSerialWrite(data: string) { - this.emit(EVENT_SERIAL_DATA, data); + this.dispatchTypedEvent("serial_data", new SerialDataEvent(data)); } mockConnect(code: WebUSBErrorCode) { @@ -49,9 +50,7 @@ export class MockDeviceConnection async initialize(): Promise {} - dispose() { - this.removeAllListeners(); - } + dispose() {} async connect(): Promise { const next = this.connectResults.shift(); @@ -90,7 +89,7 @@ export class MockDeviceConnection options.progress(0.5); await new Promise((resolve) => setTimeout(resolve, 100)); options.progress(undefined); - this.emit(EVENT_FLASH); + this.dispatchTypedEvent("flash", new FlashEvent()); } async disconnect(): Promise { @@ -103,7 +102,7 @@ export class MockDeviceConnection private setStatus(newStatus: ConnectionStatus) { this.status = newStatus; - this.emit(EVENT_STATUS, this.status); + this.dispatchTypedEvent("status", new ConnectionStatusEvent(this.status)); } clearDevice(): void { diff --git a/src/device/simulator.ts b/src/device/simulator.ts index 6304a232b..b9f8b5f21 100644 --- a/src/device/simulator.ts +++ b/src/device/simulator.ts @@ -3,26 +3,57 @@ * * SPDX-License-Identifier: MIT */ -import EventEmitter from "events"; +import { TypedEventTarget } from "../common/events"; import { Logging } from "../logging/logging"; import { BoardVersion, ConnectionStatus, DeviceConnection, - EVENT_FLASH, - EVENT_SERIAL_DATA, - EVENT_SERIAL_RESET, - EVENT_STATUS, + DeviceConnectionEventMap, FlashDataSource, + FlashEvent, + SerialDataEvent, + SerialResetEvent, + ConnectionStatusEvent, } from "./device"; // Simulator-only events. -export const EVENT_LOG_DATA = "log_data"; -export const EVENT_RADIO_DATA = "radio_data"; -export const EVENT_RADIO_GROUP = "radio_group"; -export const EVENT_RADIO_RESET = "radio_reset"; -export const EVENT_STATE_CHANGE = "state_change"; -export const EVENT_REQUEST_FLASH = "request_flash"; + +export class LogDataEvent extends Event { + constructor(public readonly log: DataLog) { + super("log_data"); + } +} + +export class RadioDataEvent extends Event { + constructor(public readonly text: string) { + super("radio_data"); + } +} + +export class RadioGroupEvent extends Event { + constructor(public readonly group: number) { + super("radio_group"); + } +} + +export class RadioResetEvent extends Event { + constructor() { + super("radio_reset"); + } +} + +export class StateChangeEvent extends Event { + constructor(public readonly state: SimulatorState) { + super("state_change"); + } +} + +export class RequestFlashEvent extends Event { + constructor() { + super("request_flash"); + } +} // It'd be nice to publish these types from the simulator project. @@ -133,13 +164,22 @@ const initialDataLog = (): DataLog => ({ data: [], }); +class SimulatorEventMap extends DeviceConnectionEventMap { + "log_data": LogDataEvent; + "radio_data": RadioDataEvent; + "radio_group": RadioGroupEvent; + "radio_reset": RadioResetEvent; + "state_change": StateChangeEvent; + "request_flash": RequestFlashEvent; +} + /** * A simulated device. * * This communicates with the iframe that is used to embed the simulator. */ export class SimulatorDeviceConnection - extends EventEmitter + extends TypedEventTarget implements DeviceConnection { status: ConnectionStatus = ConnectionStatus.NO_AUTHORIZED_DEVICE; @@ -155,26 +195,28 @@ export class SimulatorDeviceConnection } switch (event.data.kind) { case "ready": { - this.state = event.data.state; - this.emit(EVENT_STATE_CHANGE, this.state); + const newState = event.data.state; + this.state = newState; + this.dispatchTypedEvent("state_change", new StateChangeEvent(newState)); if (this.status !== ConnectionStatus.CONNECTED) { this.setStatus(ConnectionStatus.CONNECTED); } break; } case "request_flash": { - this.emit(EVENT_REQUEST_FLASH); + this.dispatchTypedEvent("request_flash", new RequestFlashEvent()); this.logging.event({ type: "sim-user-start", }); break; } case "state_change": { - this.state = { + const updated = { ...this.state, ...event.data.change, }; - this.emit(EVENT_STATE_CHANGE, this.state); + this.state = updated; + this.dispatchTypedEvent("state_change", new StateChangeEvent(updated)); break; } case "radio_output": { @@ -187,7 +229,7 @@ export class SimulatorDeviceConnection // eslint-disable-next-line no-control-regex .replace(/^\x01\x00\x01/, ""); if (message instanceof Uint8Array) { - this.emit(EVENT_RADIO_DATA, text); + this.dispatchTypedEvent("radio_data", new RadioDataEvent(text)); } break; } @@ -205,18 +247,18 @@ export class SimulatorDeviceConnection result.data.push({ data: entry.data }); } this.log = result; - this.emit(EVENT_LOG_DATA, this.log); + this.dispatchTypedEvent("log_data", new LogDataEvent(this.log)); break; } case "log_delete": { this.log = initialDataLog(); - this.emit(EVENT_LOG_DATA, this.log); + this.dispatchTypedEvent("log_data", new LogDataEvent(this.log)); break; } case "serial_output": { const text = event.data.data; if (typeof text === "string") { - this.emit(EVENT_SERIAL_DATA, text); + this.dispatchTypedEvent("serial_data", new SerialDataEvent(text)); } break; } @@ -254,7 +296,6 @@ export class SimulatorDeviceConnection } dispose() { - this.removeAllListeners(); window.removeEventListener("message", this.messageListener); } @@ -279,7 +320,7 @@ export class SimulatorDeviceConnection }); this.notifyResetComms(); options.progress(undefined); - this.emit(EVENT_FLASH); + this.dispatchTypedEvent("flash", new FlashEvent()); } configure(config: Config): void { @@ -288,8 +329,8 @@ export class SimulatorDeviceConnection private notifyResetComms() { // Might be nice to rework so this was all about connection state changes. - this.emit(EVENT_SERIAL_RESET, {}); - this.emit(EVENT_RADIO_RESET, {}); + this.dispatchTypedEvent("serial_reset", new SerialResetEvent()); + this.dispatchTypedEvent("radio_reset", new RadioResetEvent()); } async disconnect(): Promise { @@ -329,7 +370,7 @@ export class SimulatorDeviceConnection value, }, }; - this.emit(EVENT_STATE_CHANGE, this.state); + this.dispatchTypedEvent("state_change", new StateChangeEvent(this.state)); this.postMessage("set_value", { id, value, @@ -365,7 +406,7 @@ export class SimulatorDeviceConnection private setStatus(newStatus: ConnectionStatus) { this.status = newStatus; - this.emit(EVENT_STATUS, this.status); + this.dispatchTypedEvent("status", new ConnectionStatusEvent(newStatus)); } clearDevice(): void { diff --git a/src/device/webusb.test.ts b/src/device/webusb.test.ts index 5332f3660..6d5374aab 100644 --- a/src/device/webusb.test.ts +++ b/src/device/webusb.test.ts @@ -9,7 +9,7 @@ * It might be we could create a custom environment that was web but * with a tweak to Buffer. */ -import { ConnectionStatus, EVENT_STATUS } from "./device"; +import { ConnectionStatus, ConnectionStatusEvent } from "./device"; import { NullLogging } from "../deployment/default/logging"; import { MicrobitWebUSBConnection } from "./webusb"; import { vi } from "vitest"; @@ -59,9 +59,9 @@ describeDeviceOnly("MicrobitWebUSBConnection (WebUSB supported)", () => { it("connects and disconnects updating status and events", async () => { const events: ConnectionStatus[] = []; const connection = new MicrobitWebUSBConnection(); - connection.on(EVENT_STATUS, (status: ConnectionStatus) => - events.push(status) - ); + connection.addEventListener("status", (event: ConnectionStatusEvent) => { + events.push(event.status); + }); await connection.connect(); diff --git a/src/device/webusb.ts b/src/device/webusb.ts index bd6e37fac..02ebc23f8 100644 --- a/src/device/webusb.ts +++ b/src/device/webusb.ts @@ -3,7 +3,6 @@ * * SPDX-License-Identifier: MIT */ -import EventEmitter from "events"; import { Logging } from "../logging/logging"; import { NullLogging } from "../deployment/default/logging"; import { withTimeout, TimeoutError } from "./async-util"; @@ -14,18 +13,20 @@ import { ConnectionStatus, ConnectOptions, DeviceConnection, - EVENT_END_USB_SELECT, - EVENT_FLASH, - EVENT_SERIAL_DATA, - EVENT_SERIAL_ERROR, - EVENT_SERIAL_RESET, - EVENT_START_USB_SELECT, - EVENT_STATUS, + DeviceConnectionEventMap, + EndUSBSelect, FlashDataSource, + FlashEvent, HexGenerationError, MicrobitWebUSBConnectionOptions, + SerialDataEvent, + SerialErrorEvent, + SerialResetEvent, + StartUSBSelect, + ConnectionStatusEvent, WebUSBError, } from "./device"; +import { TypedEventTarget } from "../common/events"; // Temporary workaround for ChromeOS 105 bug. // See https://bugs.chromium.org/p/chromium/issues/detail?id=1363712&q=usb&can=2 @@ -38,7 +39,7 @@ export const isChromeOS105 = (): boolean => { * A WebUSB connection to a micro:bit device. */ export class MicrobitWebUSBConnection - extends EventEmitter + extends TypedEventTarget implements DeviceConnection { status: ConnectionStatus = @@ -63,7 +64,7 @@ export class MicrobitWebUSBConnection private serialReadInProgress: Promise | undefined; private serialListener = (data: string) => { - this.emit(EVENT_SERIAL_DATA, data); + this.dispatchTypedEvent("serial_data", new SerialDataEvent(data)); }; private flashing: boolean = false; @@ -151,7 +152,6 @@ export class MicrobitWebUSBConnection } dispose() { - this.removeAllListeners(); if (navigator.usb) { navigator.usb.removeEventListener("disconnect", this.handleDisconnect); } @@ -200,7 +200,7 @@ export class MicrobitWebUSBConnection await this.withEnrichedErrors(() => this.flashInternal(dataSource, options) ); - this.emit(EVENT_FLASH); + this.dispatchTypedEvent("flash", new FlashEvent()); const flashTime = new Date().getTime() - startTime; this.logging.event({ @@ -278,7 +278,7 @@ export class MicrobitWebUSBConnection .startSerial(this.serialListener) .then(() => this.log("Finished listening for serial data")) .catch((e) => { - this.emit(EVENT_SERIAL_ERROR, e); + this.dispatchTypedEvent("serial_error", new SerialErrorEvent(e)); }); } @@ -287,7 +287,7 @@ export class MicrobitWebUSBConnection this.connection.stopSerial(this.serialListener); await this.serialReadInProgress; this.serialReadInProgress = undefined; - this.emit(EVENT_SERIAL_RESET, {}); + this.dispatchTypedEvent("serial_reset", new SerialResetEvent()); } } @@ -318,7 +318,7 @@ export class MicrobitWebUSBConnection this.status = newStatus; this.visibilityReconnect = false; this.log("Device status " + newStatus); - this.emit(EVENT_STATUS, this.status); + this.dispatchTypedEvent("status", new ConnectionStatusEvent(newStatus)); } private async withEnrichedErrors(f: () => Promise): Promise { @@ -402,11 +402,11 @@ export class MicrobitWebUSBConnection if (this.device) { return this.device; } - this.emit(EVENT_START_USB_SELECT); + this.dispatchTypedEvent("start_usb_select", new StartUSBSelect()); this.device = await navigator.usb.requestDevice({ filters: [{ vendorId: 0x0d28, productId: 0x0204 }], }); - this.emit(EVENT_END_USB_SELECT); + this.dispatchTypedEvent("end_usb_select", new EndUSBSelect()); return this.device; } } diff --git a/src/e2e/simulator.test.ts b/src/e2e/simulator.test.ts index a49b6ac1a..32645a0d1 100644 --- a/src/e2e/simulator.test.ts +++ b/src/e2e/simulator.test.ts @@ -5,16 +5,23 @@ */ import { test } from "./app-test-fixtures.js"; -const basicTest = "from microbit import *\ndisplay.show(Image.NO)"; +const basicTest = `from microbit import * +display.show(Image.NO)`; -const buttonTest = - "from microbit import *\nwhile True:\nif button_a.was_pressed():\ndisplay.show(Image.NO)"; +const buttonTest = `from microbit import * +while True: + if button_a.was_pressed(): + display.show(Image.NO)`; -const gestureTest = - "from microbit import *\nwhile True:\nif accelerometer.was_gesture('freefall'):\ndisplay.show(Image.NO)"; +const gestureTest = `from microbit import * +while True: + if accelerometer.was_gesture('freefall'): + display.show(Image.NO)`; -const sliderTest = - "from microbit import *\nwhile True:\nif temperature() == -5:\ndisplay.show(Image.NO)"; +const sliderTest = `from microbit import * +while True: + if temperature() == -5: + display.show(Image.NO)`; test.describe("simulator", () => { test("responds to a sent gesture", async ({ app }) => { diff --git a/src/editor/codemirror/language-server/view.ts b/src/editor/codemirror/language-server/view.ts index a500db581..35acc4fbc 100644 --- a/src/editor/codemirror/language-server/view.ts +++ b/src/editor/codemirror/language-server/view.ts @@ -6,16 +6,18 @@ import type { PluginValue, ViewUpdate } from "@codemirror/view"; import { EditorView, ViewPlugin } from "@codemirror/view"; import { IntlShape } from "react-intl"; -import * as LSP from "vscode-languageserver-protocol"; import { ApiReferenceMap } from "../../../documentation/mapping/content"; -import { LanguageServerClient } from "../../../language-server/client"; +import { + DiagnosticsEvent, + LanguageServerClient, +} from "../../../language-server/client"; import { Logging } from "../../../logging/logging"; import { Action, setDiagnostics } from "../lint/lint"; import { autocompletion } from "./autocompletion"; import { BaseLanguageServerView, clientFacet, uriFacet } from "./common"; import { diagnosticsMapping } from "./diagnostics"; import { signatureHelp } from "./signatureHelp"; -import { DeviceConnection, EVENT_STATUS } from "../../../device/device"; +import { DeviceConnection } from "../../../device/device"; /** * The main extension. This synchronises the diagnostics between the client @@ -23,7 +25,8 @@ import { DeviceConnection, EVENT_STATUS } from "../../../device/device"; * the language server when the document changes. */ class LanguageServerView extends BaseLanguageServerView implements PluginValue { - private diagnosticsListener = (params: LSP.PublishDiagnosticsParams) => { + private diagnosticsListener = (event: DiagnosticsEvent) => { + const params = event.detail; if (params.uri === this.uri) { const diagnostics = diagnosticsMapping( this.view.state.doc, @@ -64,8 +67,8 @@ class LanguageServerView extends BaseLanguageServerView implements PluginValue { ) { super(view); - this.client.on("diagnostics", this.diagnosticsListener); - this.device.on(EVENT_STATUS, this.onDeviceStatusChanged); + this.client.addEventListener("diagnostics", this.diagnosticsListener); + this.device.addEventListener("status", this.onDeviceStatusChanged); // Is there a better way to do this? We can 't dispatch at this point. // It would be best to do this with initial state and avoid the dispatch. @@ -95,8 +98,8 @@ class LanguageServerView extends BaseLanguageServerView implements PluginValue { destroy() { this.destroyed = true; - this.client.removeListener("diagnostics", this.diagnosticsListener); - this.device.removeListener(EVENT_STATUS, this.onDeviceStatusChanged); + this.client.removeEventListener("diagnostics", this.diagnosticsListener); + this.device.removeEventListener("status", this.onDeviceStatusChanged); // We don't own the client/connection which might outlive us, just our notifications. } } diff --git a/src/fs/fs.test.ts b/src/fs/fs.test.ts index d3f2647fd..ab49a3aba 100644 --- a/src/fs/fs.test.ts +++ b/src/fs/fs.test.ts @@ -12,7 +12,6 @@ import { NullLogging } from "../deployment/default/logging"; import { BoardId } from "../device/board-id"; import { diff, - EVENT_PROJECT_UPDATED, FileSystem, MAIN_FILE, Project, @@ -55,7 +54,9 @@ describe("Filesystem", () => { beforeEach(() => { events = []; ufs = new FileSystem(logging, host, fsMicroPythonSource); - ufs.addListener(EVENT_PROJECT_UPDATED, events.push.bind(events)); + ufs.addEventListener("project_updated", (e) => { + events.push(e.project); + }); }); it("has an initial blank project", async () => { diff --git a/src/fs/fs.ts b/src/fs/fs.ts index 159ce94d5..c11a8025e 100644 --- a/src/fs/fs.ts +++ b/src/fs/fs.ts @@ -8,7 +8,6 @@ import { MicropythonFsHex, } from "@microbit/microbit-fs"; import { fromByteArray, toByteArray } from "base64-js"; -import EventEmitter from "events"; import sortBy from "lodash.sortby"; import { lineNumFromUint8Array } from "../common/text-util"; import { BoardId } from "../device/board-id"; @@ -19,6 +18,7 @@ import { asciiToBytes, extractModuleData, generateId } from "./fs-util"; import { Host } from "./host"; import { PythonProject } from "./initial-project"; import { FSStorage } from "./storage"; +import { TypedEventTarget } from "../common/events"; const commonFsSize = 20 * 1024; @@ -133,8 +133,22 @@ export const diff = (before: Project, after: Project): FileChange[] => { return result; }; -export const EVENT_PROJECT_UPDATED = "project_updated"; -export const EVENT_TEXT_EDIT = "file_text_updated"; +export class ProjectUpdatedEvent extends Event { + constructor(public readonly project: Project) { + super("project_updated"); + } +} +export class TextEditEvent extends Event { + constructor() { + super("file_text_updated"); + } +} + +class EventMap { + "project_updated": ProjectUpdatedEvent; + "file_text_updated": TextEditEvent; +} + export const MAIN_FILE = "main.py"; export const isNameLengthValid = (filename: string): boolean => @@ -153,7 +167,10 @@ export const isNameLengthValid = (filename: string): boolean => * or fire any events. This plays well with uncontrolled embeddings of * third-party text editors. */ -export class FileSystem extends EventEmitter implements FlashDataSource { +export class FileSystem + extends TypedEventTarget + implements FlashDataSource +{ private initializing: Promise | undefined; private storage: FSStorage; private fileVersions: Map = new Map(); @@ -303,7 +320,7 @@ export class FileSystem extends EventEmitter implements FlashDataSource { this.incrementFileVersion(filename); return this.notify(); } else { - this.emit(EVENT_TEXT_EDIT); + this.dispatchTypedEvent("file_text_updated", new TextEditEvent()); // Nothing can have changed, don't needlessly change the identity of our file objects. return this.markDirty(); } @@ -427,7 +444,10 @@ export class FileSystem extends EventEmitter implements FlashDataSource { name: await this.storage.projectName(), files: filesSorted, }; - this.emit(EVENT_PROJECT_UPDATED, this.project); + this.dispatchTypedEvent( + "project_updated", + new ProjectUpdatedEvent(this.project) + ); } async toHexForSave(): Promise { diff --git a/src/fs/host-iframe.test.ts b/src/fs/host-iframe.test.ts index aea454d1a..aa30a789b 100644 --- a/src/fs/host-iframe.test.ts +++ b/src/fs/host-iframe.test.ts @@ -14,7 +14,7 @@ describe("IframeHost", () => { const fs = { read: () => new TextEncoder().encode("Code read!"), write: mockWrite, - addListener: mockAddListener, + addEventListener: mockAddListener, getPythonProject: () => "", } as any; diff --git a/src/fs/host.ts b/src/fs/host.ts index 6b58fe9e7..b49485cf6 100644 --- a/src/fs/host.ts +++ b/src/fs/host.ts @@ -4,13 +4,7 @@ * SPDX-License-Identifier: MIT */ import debounce from "lodash.debounce"; -import { - FileSystem, - VersionAction, - EVENT_PROJECT_UPDATED, - EVENT_TEXT_EDIT, - MAIN_FILE, -} from "./fs"; +import { FileSystem, VersionAction, MAIN_FILE } from "./fs"; import { Logging } from "../logging/logging"; import { defaultInitialProject, @@ -135,8 +129,8 @@ export class IframeHost implements Host { const debounceCodeChange = debounce(() => { notifyWorkspaceSave(fs, this.parent); }, this.debounceDelay); - fs.addListener(EVENT_PROJECT_UPDATED, debounceCodeChange); - fs.addListener(EVENT_TEXT_EDIT, debounceCodeChange); + fs.addEventListener("project_updated", debounceCodeChange); + fs.addEventListener("file_text_updated", debounceCodeChange); this.window.addEventListener("message", (event) => { if (event?.data.type === messages.type) { diff --git a/src/language-server/client-fs.ts b/src/language-server/client-fs.ts index 020e71b6d..a380bdddd 100644 --- a/src/language-server/client-fs.ts +++ b/src/language-server/client-fs.ts @@ -4,12 +4,12 @@ * SPDX-License-Identifier: MIT */ import { CreateFile, DeleteFile } from "vscode-languageserver-protocol"; -import { EVENT_PROJECT_UPDATED, FileSystem, Project, diff } from "../fs/fs"; +import { FileSystem, Project, ProjectUpdatedEvent, diff } from "../fs/fs"; import { isPythonFile } from "../project/project-utils"; import { LanguageServerClient, createUri } from "./client"; import { isErrorDueToDispose } from "./error-util"; -export type FsChangesListener = (current: Project) => any; +export type FsChangesListener = (event: ProjectUpdatedEvent) => any; /** * Updates the language server open files as the file system @@ -30,7 +30,8 @@ export const trackFsChanges = ( }; const documentText = async (name: string) => new TextDecoder().decode((await fs.read(name)).data); - const diffAndUpdateClient = async (current: Project) => { + const diffAndUpdateClient = async (event: ProjectUpdatedEvent) => { + const current = event.project; const changes = diff(previous, current).filter((c) => isPythonFile(c.name)); previous = current; try { @@ -86,8 +87,8 @@ export const trackFsChanges = ( } } }; - fs.addListener(EVENT_PROJECT_UPDATED, diffAndUpdateClient); - diffAndUpdateClient(fs.project); + fs.addEventListener("project_updated", diffAndUpdateClient); + diffAndUpdateClient(new ProjectUpdatedEvent(fs.project)); return diffAndUpdateClient; }; @@ -95,5 +96,5 @@ export const removeTrackFsChangesListener = ( fs: FileSystem, listener: FsChangesListener ): void => { - fs.removeListener(EVENT_PROJECT_UPDATED, listener); + fs.removeEventListener("project_updated", listener); }; diff --git a/src/language-server/client.ts b/src/language-server/client.ts index f8427d52f..701d03833 100644 --- a/src/language-server/client.ts +++ b/src/language-server/client.ts @@ -3,7 +3,6 @@ * * SPDX-License-Identifier: MIT */ -import EventEmitter from "events"; import { CompletionItem, CompletionList, @@ -37,12 +36,23 @@ import { } from "./error-util"; import { fallbackLocale } from "../settings/settings"; import { CreateToastFnReturn } from "@chakra-ui/react"; +import { TypedEventTarget } from "../common/events"; /** * Create a URI for a source document under the default root of file:///src/. */ export const createUri = (name: string) => `file:///src/${name}`; +export class DiagnosticsEvent extends Event { + constructor(public readonly detail: PublishDiagnosticsParams) { + super("diagnostics"); + } +} + +class EventMap { + "diagnostics": DiagnosticsEvent; +} + /** * Owns the connection. * @@ -51,7 +61,7 @@ export const createUri = (name: string) => `file:///src/${name}`; * * Tracks and exposes the diagnostics. */ -export class LanguageServerClient extends EventEmitter { +export class LanguageServerClient extends TypedEventTarget { /** * The capabilities of the server we're connected to. * Populated after initialize. @@ -70,14 +80,6 @@ export class LanguageServerClient extends EventEmitter { super(); } - on( - event: "diagnostics", - listener: (params: PublishDiagnosticsParams) => void - ): this { - super.on(event, listener); - return this; - } - currentDiagnostics(uri: string): Diagnostic[] { return this.diagnostics.get(uri) ?? []; } @@ -110,7 +112,10 @@ export class LanguageServerClient extends EventEmitter { (params) => { this.diagnostics.set(params.uri, params.diagnostics); // Republish as you can't listen twice. - this.emit("diagnostics", params); + this.dispatchTypedEvent( + "diagnostics", + new DiagnosticsEvent(params) + ); } ); this.connection.onRequest(RegistrationRequest.type, () => { diff --git a/src/project/project-actions.tsx b/src/project/project-actions.tsx index f370e1f60..ef9941443 100644 --- a/src/project/project-actions.tsx +++ b/src/project/project-actions.tsx @@ -22,7 +22,7 @@ import { ConnectionStatus, ConnectOptions, DeviceConnection, - EVENT_END_USB_SELECT, + EndUSBSelect as RequestDeviceEndEvent, HexGenerationError, WebUSBError, WebUSBErrorCode, @@ -830,7 +830,10 @@ export class ProjectActions { finalFocusRef: FinalFocusRef ) { if (e instanceof WebUSBError) { - this.device.emit(EVENT_END_USB_SELECT); + this.device.dispatchTypedEvent( + "end_usb_select", + new RequestDeviceEndEvent() + ); switch (e.code) { case "no-device-selected": { // User just cancelled the browser dialog, perhaps because there diff --git a/src/project/project-hooks.tsx b/src/project/project-hooks.tsx index fccc6aa4d..3d3449ad9 100644 --- a/src/project/project-hooks.tsx +++ b/src/project/project-hooks.tsx @@ -9,7 +9,7 @@ import useActionFeedback from "../common/use-action-feedback"; import { useDialogs } from "../common/use-dialogs"; import useIsUnmounted from "../common/use-is-unmounted"; import { useDevice } from "../device/device-hooks"; -import { EVENT_PROJECT_UPDATED, Project, VersionAction } from "../fs/fs"; +import { Project, VersionAction } from "../fs/fs"; import { useFileSystem } from "../fs/fs-hooks"; import { extractModuleData, @@ -92,9 +92,9 @@ export const useProject = (): DefaultedProject => { setState(defaultedProject(fs, intl)); } }; - fs.on(EVENT_PROJECT_UPDATED, listener); + fs.addEventListener("project_updated", listener); return () => { - fs.removeListener(EVENT_PROJECT_UPDATED, listener); + fs.removeEventListener("project_updated", listener); }; }, [fs, isUnmounted, intl]); return state; diff --git a/src/serial/XTerm.tsx b/src/serial/XTerm.tsx index 166c78281..0c1f61b50 100644 --- a/src/serial/XTerm.tsx +++ b/src/serial/XTerm.tsx @@ -12,7 +12,7 @@ import "xterm/css/xterm.css"; import useActionFeedback from "../common/use-action-feedback"; import useIsUnmounted from "../common/use-is-unmounted"; import { backgroundColorTerm } from "../deployment/misc"; -import { EVENT_SERIAL_DATA, EVENT_SERIAL_RESET } from "../device/device"; +import { SerialDataEvent } from "../device/device"; import { parseTraceLine, useDevice } from "../device/device-hooks"; import { useSelection } from "../workbench/use-selection"; import { WebLinkProvider } from "./link-provider"; @@ -96,9 +96,9 @@ const useManagedTermimal = ( customKeyEventHandler(e, tabOutRef) ); - const serialListener = (data: string) => { + const serialListener = (event: SerialDataEvent) => { if (!isUnmounted()) { - terminal.write(data); + terminal.write(event.data); } }; const resetListener = () => { @@ -106,8 +106,8 @@ const useManagedTermimal = ( terminal.reset(); } }; - device.on(EVENT_SERIAL_DATA, serialListener); - device.on(EVENT_SERIAL_RESET, resetListener); + device.addEventListener("serial_data", serialListener); + device.addEventListener("serial_reset", resetListener); terminal.onData((data: string) => { if (!isUnmounted()) { // Async for internal error handling, we don't need to wait. @@ -177,8 +177,8 @@ const useManagedTermimal = ( return () => { currentTerminalRef.current = undefined; - device.removeListener(EVENT_SERIAL_RESET, resetListener); - device.removeListener(EVENT_SERIAL_DATA, serialListener); + device.removeEventListener("serial_reset", resetListener); + device.removeEventListener("serial_data", serialListener); resizeObserver.disconnect(); terminal.dispose(); }; diff --git a/src/simulator/SimulatorActionBar.tsx b/src/simulator/SimulatorActionBar.tsx index 7951e4683..85e9e883f 100644 --- a/src/simulator/SimulatorActionBar.tsx +++ b/src/simulator/SimulatorActionBar.tsx @@ -17,7 +17,6 @@ import { useSimulator, useSyncStatus, } from "../device/device-hooks"; -import { EVENT_REQUEST_FLASH } from "../device/simulator"; import { useFileSystem } from "../fs/fs-hooks"; import { useLogging } from "../logging/logging-hooks"; import { RunningStatus } from "./Simulator"; @@ -71,9 +70,9 @@ const SimulatorActionBar = ({ setIsMuted(!isMuted); }, [device, isMuted, setIsMuted]); useEffect(() => { - device.on(EVENT_REQUEST_FLASH, handlePlay); + device.addEventListener("request_flash", handlePlay); return () => { - device.removeListener(EVENT_REQUEST_FLASH, handlePlay); + device.removeEventListener("request_flash", handlePlay); }; }, [device, handlePlay]); const size = "md"; diff --git a/src/simulator/SimulatorModules.tsx b/src/simulator/SimulatorModules.tsx index 130b7a0bd..e268e9a92 100644 --- a/src/simulator/SimulatorModules.tsx +++ b/src/simulator/SimulatorModules.tsx @@ -20,10 +20,10 @@ import { useIntl } from "react-intl"; import ExpandCollapseIcon from "../common/ExpandCollapseIcon"; import useRafState from "../common/use-raf-state"; import { - EVENT_STATE_CHANGE, RangeSensor as RangeSensorType, SensorStateKey, SimulatorState, + StateChangeEvent, } from "../device/simulator"; import { useRouterState } from "../router-hooks"; import ButtonsModule from "./ButtonsModule"; @@ -110,9 +110,12 @@ const SimulatorModules = ({ running, ...props }: SimulatorModulesProps) => { ); const intl = useIntl(); useEffect(() => { - device.on(EVENT_STATE_CHANGE, setState); + const listener = (event: StateChangeEvent) => { + setState(event.state); + }; + device.addEventListener("state_change", listener); return () => { - device.removeListener(EVENT_STATE_CHANGE, setState); + device.removeEventListener("state_change", listener); }; }, [device, setState]); const handleSensorChange = useCallback( diff --git a/src/simulator/data-logging-hooks.tsx b/src/simulator/data-logging-hooks.tsx index e66d34283..3ea7729b3 100644 --- a/src/simulator/data-logging-hooks.tsx +++ b/src/simulator/data-logging-hooks.tsx @@ -1,15 +1,18 @@ import React, { ReactNode, useContext, useEffect } from "react"; import useRafState from "../common/use-raf-state"; import { useSimulator } from "../device/device-hooks"; -import { DataLog, EVENT_LOG_DATA } from "../device/simulator"; +import { DataLog, LogDataEvent } from "../device/simulator"; const useDataLogInternal = (): DataLog => { const simulator = useSimulator(); const [value, setValue] = useRafState(simulator.log); useEffect(() => { - simulator.on(EVENT_LOG_DATA, setValue); + const listener = (event: LogDataEvent) => { + setValue(event.log); + }; + simulator.addEventListener("log_data", listener); return () => { - simulator.removeListener(EVENT_LOG_DATA, setValue); + simulator.removeEventListener("log_data", listener); }; }, [simulator, setValue]); return value; diff --git a/src/simulator/radio-hooks.tsx b/src/simulator/radio-hooks.tsx index 155fb9c4f..ea69a65b7 100644 --- a/src/simulator/radio-hooks.tsx +++ b/src/simulator/radio-hooks.tsx @@ -7,7 +7,7 @@ import React, { useState, } from "react"; import { useSimulator } from "../device/device-hooks"; -import { EVENT_RADIO_DATA, EVENT_RADIO_RESET } from "../device/simulator"; +import { RadioDataEvent } from "../device/simulator"; const messageLimit = 100; let idSeq = 0; @@ -52,7 +52,8 @@ const useRadioChatItemsInternal = ( }, [group, prevGroup]); useEffect(() => { - const handleReceive = (message: string) => { + const handleReceive = (event: RadioDataEvent) => { + const message = event.text; setItems((items) => cappedMessages([ ...items, @@ -63,11 +64,11 @@ const useRadioChatItemsInternal = ( const handleReset = () => { setItems([{ type: "groupChange", group, id: idSeq++ }]); }; - device.on(EVENT_RADIO_DATA, handleReceive); - device.on(EVENT_RADIO_RESET, handleReset); + device.addEventListener("radio_data", handleReceive); + device.addEventListener("radio_reset", handleReset); return () => { - device.removeListener(EVENT_RADIO_RESET, handleReset); - device.removeListener(EVENT_RADIO_DATA, handleReceive); + device.removeEventListener("radio_reset", handleReset); + device.removeEventListener("radio_data", handleReceive); }; }, [device, group]); const handleSend = useCallback( diff --git a/src/workbench/Workbench.tsx b/src/workbench/Workbench.tsx index c9c685e36..cfa91055e 100644 --- a/src/workbench/Workbench.tsx +++ b/src/workbench/Workbench.tsx @@ -23,7 +23,7 @@ import { SizedMode } from "../common/SplitView/SplitView"; import { ConnectionStatus } from "../device/device"; import { useConnectionStatus } from "../device/device-hooks"; import EditorArea from "../editor/EditorArea"; -import { MAIN_FILE } from "../fs/fs"; +import { FileVersion, MAIN_FILE } from "../fs/fs"; import { useProject } from "../project/project-hooks"; import ProjectActionBar from "../project/ProjectActionBar"; import SerialArea from "../serial/SerialArea"; @@ -31,35 +31,39 @@ import { useSettings } from "../settings/settings"; import Simulator from "../simulator/Simulator"; import Overlay from "./connect-dialogs/Overlay"; import SideBar from "./SideBar"; -import { useSelection } from "./use-selection"; +import { WorkbenchSelection, useSelection } from "./use-selection"; import { flags } from "../flags"; const minimums: [number, number] = [380, 580]; const simulatorMinimums: [number, number] = [275, 0]; +const defaultSelection = ( + selection: WorkbenchSelection, + files: FileVersion[] +) => { + // Selected file deleted? Default it. + if (!files.find((x) => x.name === selection.file) && files.length > 0) { + const defaultFile = files.find((x) => x.name === MAIN_FILE) ?? files[0]; + return { file: defaultFile.name, location: { line: undefined } }; + } + return selection; +}; + /** * The main app layout with resizable panels. */ const Workbench = () => { - const [selection, setSelection] = useSelection(); const intl = useIntl(); + + const [maybeInvalidSelection, setSelection] = useSelection(); const { files } = useProject(); + const selection = defaultSelection(maybeInvalidSelection, files); const setSelectedFile = useCallback( (file: string) => { setSelection({ file, location: { line: undefined } }); }, [setSelection] ); - useEffect(() => { - // No file yet or selected file deleted? Default it. - if ( - (!selection || !files.find((x) => x.name === selection.file)) && - files.length > 0 - ) { - const defaultFile = files.find((x) => x.name === MAIN_FILE) || files[0]; - setSelectedFile(defaultFile.name); - } - }, [selection, setSelectedFile, files]); useEffect(() => { const scriptId = "crowdin-jipt"; diff --git a/src/workbench/connect-dialogs/Overlay.tsx b/src/workbench/connect-dialogs/Overlay.tsx index 7fd28bde6..5d92f2e0d 100644 --- a/src/workbench/connect-dialogs/Overlay.tsx +++ b/src/workbench/connect-dialogs/Overlay.tsx @@ -6,10 +6,6 @@ import { Box, useDisclosure } from "@chakra-ui/react"; import { useCallback, useEffect } from "react"; import { zIndexOverlay } from "../../common/zIndex"; -import { - EVENT_END_USB_SELECT, - EVENT_START_USB_SELECT, -} from "../../device/device"; import { useDevice } from "../../device/device-hooks"; const Overlay = () => { @@ -22,11 +18,11 @@ const Overlay = () => { selectingDevice.onClose(); }, [selectingDevice]); useEffect(() => { - device.on(EVENT_START_USB_SELECT, showOverlay); - device.on(EVENT_END_USB_SELECT, hideOverlay); + device.addEventListener("start_usb_select", showOverlay); + device.addEventListener("end_usb_select", hideOverlay); return () => { - device.removeListener(EVENT_START_USB_SELECT, showOverlay); - device.removeListener(EVENT_END_USB_SELECT, hideOverlay); + device.removeEventListener("start_usb_select", showOverlay); + device.removeEventListener("end_usb_select", hideOverlay); }; }, [device, showOverlay, hideOverlay]); return (