diff --git a/benchmark/package.json b/benchmark/package.json index 645ea60b8f..78f38c7944 100644 --- a/benchmark/package.json +++ b/benchmark/package.json @@ -8,18 +8,18 @@ "@foxglove/den": "workspace:*", "@foxglove/log": "workspace:*", "@foxglove/rostime": "1.1.2", - "@foxglove/schemas": "1.6.0", + "@foxglove/schemas": "1.6.2", "@foxglove/studio": "workspace:*", "@foxglove/tsconfig": "2.0.0", "@pmmmwh/react-refresh-webpack-plugin": "0.5.11", "@types/react": "18.2.23", "@types/react-dom": "18.2.7", "clean-webpack-plugin": "4.0.0", - "html-webpack-plugin": "5.5.3", + "html-webpack-plugin": "5.6.0", "mathjs": "11.11.1", "react": "18.2.0", "react-dom": "18.2.0", - "webpack": "5.88.2", + "webpack": "5.90.1", "webpack-dev-server": "4.15.1" } } diff --git a/benchmark/src/Root.tsx b/benchmark/src/Root.tsx index 456722c028..eb135ed29e 100644 --- a/benchmark/src/Root.tsx +++ b/benchmark/src/Root.tsx @@ -4,7 +4,13 @@ import { useMemo, useState } from "react"; -import { App, IDataSourceFactory, AppSetting, LaunchPreferenceValue } from "@foxglove/studio-base"; +import { + SharedRoot, + IDataSourceFactory, + AppSetting, + LaunchPreferenceValue, + StudioApp, +} from "@foxglove/studio-base"; import { McapLocalBenchmarkDataSourceFactory, SyntheticDataSourceFactory } from "./dataSources"; import { LAYOUTS } from "./layouts"; @@ -47,17 +53,16 @@ export function Root(): JSX.Element { return sources; }, []); - const [extensionLoaders] = useState(() => []); - const url = new URL(window.location.href); return ( - + > + + ); } diff --git a/benchmark/src/dataSources/McapLocalBenchmarkDataSourceFactory.ts b/benchmark/src/dataSources/McapLocalBenchmarkDataSourceFactory.ts index 49ee7055bd..0dfde4f23c 100644 --- a/benchmark/src/dataSources/McapLocalBenchmarkDataSourceFactory.ts +++ b/benchmark/src/dataSources/McapLocalBenchmarkDataSourceFactory.ts @@ -6,6 +6,7 @@ import { IDataSourceFactory, DataSourceFactoryInitializeArgs, } from "@foxglove/studio-base/context/PlayerSelectionContext"; +import { DeserializingIterableSource } from "@foxglove/studio-base/players/IterablePlayer/DeserializingIterableSource"; import { McapIterableSource } from "@foxglove/studio-base/players/IterablePlayer/Mcap/McapIterableSource"; import { Player } from "@foxglove/studio-base/players/types"; @@ -25,7 +26,8 @@ class McapLocalBenchmarkDataSourceFactory implements IDataSourceFactory { } const mcapProvider = new McapIterableSource({ type: "file", file }); - return new BenchmarkPlayer(file.name, mcapProvider); + const source = new DeserializingIterableSource(mcapProvider); + return new BenchmarkPlayer(file.name, source); } } diff --git a/benchmark/src/layouts.ts b/benchmark/src/layouts.ts index 54351e3e1a..471c2b1fd6 100644 --- a/benchmark/src/layouts.ts +++ b/benchmark/src/layouts.ts @@ -16,7 +16,6 @@ function makeLayoutData(partialData: Pick): LayoutData configById: partialData.configById, globalVariables: {}, userNodes: {}, - playbackConfig: { speed: 1 }, }; } diff --git a/benchmark/src/players/BenchmarkPlayer.ts b/benchmark/src/players/BenchmarkPlayer.ts index 9790e627b2..941fe9a43a 100644 --- a/benchmark/src/players/BenchmarkPlayer.ts +++ b/benchmark/src/players/BenchmarkPlayer.ts @@ -8,7 +8,7 @@ import { toRFC3339String } from "@foxglove/rostime"; import { MessageEvent } from "@foxglove/studio"; import { GlobalVariables } from "@foxglove/studio-base/hooks/useGlobalVariables"; import { BlockLoader } from "@foxglove/studio-base/players/IterablePlayer/BlockLoader"; -import { IIterableSource } from "@foxglove/studio-base/players/IterablePlayer/IIterableSource"; +import { IDeserializedIterableSource } from "@foxglove/studio-base/players/IterablePlayer/IIterableSource"; import PlayerProblemManager from "@foxglove/studio-base/players/PlayerProblemManager"; import { AdvertiseOptions, @@ -30,14 +30,14 @@ const MAX_BLOCKS = 400; const CAPABILITIES: string[] = [PlayerCapabilities.playbackControl]; class BenchmarkPlayer implements Player { - #source: IIterableSource; + #source: IDeserializedIterableSource; #name: string; #listener?: (state: PlayerState) => Promise; #subscriptions: SubscribePayload[] = []; #blockLoader?: BlockLoader; #problemManager = new PlayerProblemManager(); - public constructor(name: string, source: IIterableSource) { + public constructor(name: string, source: IDeserializedIterableSource) { this.#name = name; this.#source = source; } @@ -96,7 +96,7 @@ class BenchmarkPlayer implements Player { } do { - log.info("Waiting for topic subscriptions..."); + log.info("Waiting for topic subscriptions…"); // Allow the layout to subscribe to any messages it needs await delay(500); @@ -114,6 +114,7 @@ class BenchmarkPlayer implements Player { currentTime: startTime, startTime, isPlaying: false, + repeatEnabled: false, speed: 1, lastSeekTime: 1, endTime, @@ -214,6 +215,7 @@ class BenchmarkPlayer implements Player { endTime, currentTime: msgEvent.receiveTime, isPlaying: true, + repeatEnabled: false, speed: 1, lastSeekTime: 1, topics, @@ -263,6 +265,7 @@ class BenchmarkPlayer implements Player { endTime, currentTime: seekToMessage.receiveTime, isPlaying: false, + repeatEnabled: false, speed: 1, lastSeekTime: Date.now(), topics, diff --git a/benchmark/src/players/PointcloudPlayer.ts b/benchmark/src/players/PointcloudPlayer.ts index 405bc87f19..12e3417f9c 100644 --- a/benchmark/src/players/PointcloudPlayer.ts +++ b/benchmark/src/players/PointcloudPlayer.ts @@ -247,6 +247,7 @@ class PointcloudPlayer implements Player { currentTime: now, startTime: this.#startTime, isPlaying: true, + repeatEnabled: false, speed: 1, lastSeekTime: 1, endTime: now, diff --git a/benchmark/src/players/SinewavePlayer.ts b/benchmark/src/players/SinewavePlayer.ts index 495549d880..7657dab721 100644 --- a/benchmark/src/players/SinewavePlayer.ts +++ b/benchmark/src/players/SinewavePlayer.ts @@ -141,6 +141,7 @@ class SinewavePlayer implements Player { currentTime: now, startTime: this.#startTime, isPlaying: true, + repeatEnabled: false, speed: 1, lastSeekTime: 1, endTime: now, diff --git a/benchmark/src/players/TransformPlayer.ts b/benchmark/src/players/TransformPlayer.ts index fc95474c39..09f1205320 100644 --- a/benchmark/src/players/TransformPlayer.ts +++ b/benchmark/src/players/TransformPlayer.ts @@ -166,6 +166,7 @@ class TransformPlayer implements Player { currentTime: timestamp, startTime, isPlaying: true, + repeatEnabled: false, speed: 1, lastSeekTime: 1, endTime: timestamp, diff --git a/benchmark/src/players/TransformPreloadingPlayer.ts b/benchmark/src/players/TransformPreloadingPlayer.ts index fe6199b494..eb7cfe83b9 100644 --- a/benchmark/src/players/TransformPreloadingPlayer.ts +++ b/benchmark/src/players/TransformPreloadingPlayer.ts @@ -148,6 +148,7 @@ class TransformPreloadingPlayer implements Player { currentTime: this.#startTime, startTime: this.#startTime, isPlaying: false, + repeatEnabled: false, speed: 1, lastSeekTime: 1, endTime: this.#endTime, @@ -236,6 +237,7 @@ class TransformPreloadingPlayer implements Player { endTime: this.#endTime, currentTime: seekToMessage.receiveTime, isPlaying: false, + repeatEnabled: false, speed: 1, lastSeekTime: Date.now(), topics: this.#topics, @@ -281,6 +283,7 @@ class TransformPreloadingPlayer implements Player { endTime: this.#endTime, currentTime: seekToMessage.receiveTime, isPlaying: false, + repeatEnabled: false, speed: 1, lastSeekTime: Date.now(), topics: this.#topics, diff --git a/benchmark/src/tsconfig.json b/benchmark/src/tsconfig.json index cea0d79d32..205a994255 100644 --- a/benchmark/src/tsconfig.json +++ b/benchmark/src/tsconfig.json @@ -4,7 +4,7 @@ "compilerOptions": { "noEmit": true, "jsx": "react-jsx", - "lib": ["dom", "dom.iterable", "es2022", "webworker"], + "lib": ["dom", "dom.iterable", "es2022", "webworker", "ESNext.Disposable"], "experimentalDecorators": true, "useUnknownInCatchVariables": false, "paths": { diff --git a/benchmark/webpack.config.ts b/benchmark/webpack.config.ts index 63fe7d4c0c..8d820d5969 100644 --- a/benchmark/webpack.config.ts +++ b/benchmark/webpack.config.ts @@ -37,6 +37,11 @@ const devServerConfig: WebpackConfiguration = { // "[WDS] Disconnected!" // Since we are only connecting to localhost, DNS rebinding attacks are not a concern during dev allowedHosts: "all", + headers: { + // Enable cross-origin isolation: https://resourcepolicy.fyi + "cross-origin-opener-policy": "same-origin", + "cross-origin-embedder-policy": "credentialless", + }, }, plugins: [new CleanWebpackPlugin()], diff --git a/ci/lint-unused-exports.ts b/ci/lint-unused-exports.ts index 45782da18b..79cf2c81da 100644 --- a/ci/lint-unused-exports.ts +++ b/ci/lint-unused-exports.ts @@ -23,7 +23,6 @@ async function main(): Promise { String.raw`packages/studio-base/src/index\.ts`, String.raw`packages/studio-base/src/panels/ThreeDeeRender/transforms/index\.ts`, // `export *` is not correctly analyzed String.raw`packages/studio-base/src/test/`, - String.raw`packages/studio-base/src/players/UserNodePlayer/nodeTransformerWorker/typescript/userUtils/`, ].join("|"), ); diff --git a/ci/vercel-ignore-build.sh b/ci/vercel-ignore-build.sh deleted file mode 100644 index 6bf6d44dd2..0000000000 --- a/ci/vercel-ignore-build.sh +++ /dev/null @@ -1,21 +0,0 @@ -#!/bin/bash - -# Since vercel does not any any way to disable the preview build feature, we use the ignore build -# step feature to only deploy the main branch. -# https://vercel.com/support/articles/how-do-i-use-the-ignored-build-step-field-on-vercel#with-environment-variables - -# To deploy only on the main branch, remove this line and uncomment the code below. -exit 1 - -# echo "VERCEL_GIT_COMMIT_REF: $VERCEL_GIT_COMMIT_REF" -# -# if [[ "$VERCEL_GIT_COMMIT_REF" == "main" ]] ; then -# # Proceed with the build -# echo "✅ - Deploy" -# exit 1; -# -# else -# # Don't build -# echo "🛑 - Build cancelled" -# exit 0; -# fi diff --git a/demo/compose.yaml b/demo/compose.yaml new file mode 100644 index 0000000000..2652b4575b --- /dev/null +++ b/demo/compose.yaml @@ -0,0 +1,26 @@ +x-common-config: &common-config + network_mode: host + ipc: host + environment: + - RMW_IMPLEMENTATION=rmw_cyclonedds_cpp + - ROS_DOMAIN_ID=${ROS_DOMAIN_ID:-0} + +services: + foxglove: + image: husarion/foxglove:improvements-nightly + <<: *common-config + ports: + - 8080:8080 + volumes: + - ./foxglove-layout.json:/foxglove/default-layout.json + - ../Caddyfile:/etc/caddy/Caddyfile + environment: + - DISABLE_CACHE=true + - DISABLE_INTERACTION=false + + foxglove-ds: + image: husarion/foxglove-bridge:humble-0.7.7-20240708 + <<: *common-config + ports: + - 8765:8765 + command: ros2 launch foxglove_bridge foxglove_bridge_launch.xml port:=8765 capabilities:=[clientPublish,parameters,parametersSubscribe,services,connectionGraph,assets] diff --git a/demo/foxglove-layout.json b/demo/foxglove-layout.json new file mode 100644 index 0000000000..fce7798915 --- /dev/null +++ b/demo/foxglove-layout.json @@ -0,0 +1,181 @@ +{ + "configById": { + "DiagnosticSummary!47zasr6": { + "minLevel": 0, + "pinnedIds": [], + "hardwareIdFilter": "", + "topicToRender": "/diagnostics", + "sortByLevel": true + }, + "Plot!dg5ynj": { + "paths": [ + { + "timestampMethod": "receiveTime", + "value": "/imu/data.linear_acceleration.x", + "enabled": true, + "label": "x", + "showLine": true + }, + { + "timestampMethod": "receiveTime", + "value": "/imu/data.linear_acceleration.y", + "enabled": true, + "label": "y" + }, + { + "timestampMethod": "receiveTime", + "value": "/imu/data.linear_acceleration.z", + "enabled": true, + "label": "z" + } + ], + "showXAxisLabels": true, + "showYAxisLabels": true, + "showLegend": true, + "legendDisplay": "floating", + "showPlotValuesInLegend": false, + "isSynced": true, + "xAxisVal": "timestamp", + "sidebarDimension": 0, + "foxglovePanelTitle": "Acceleration", + "followingViewWidth": 60 + }, + "Bar!3t52ye7": { + "path": "/joint_states.effort[0]", + "maxValue": 34.52, + "colorMode": "colormap", + "gradient": ["#0000ff", "#ff00ff"], + "reverse": false, + "foxglovePanelTitle": "FL" + }, + "Bar!461hl59": { + "path": "/joint_states.effort[1]", + "maxValue": 34.52, + "colorMode": "colormap", + "gradient": ["#0000ff", "#ff00ff"], + "reverse": true, + "foxglovePanelTitle": "FR" + }, + "Bar!1fzrnqw": { + "path": "/joint_states.effort[2]", + "maxValue": 34.52, + "colorMode": "colormap", + "gradient": ["#0000ff", "#ff00ff"], + "reverse": false, + "foxglovePanelTitle": "RL" + }, + "Bar!1q5qffy": { + "path": "/joint_states.effort[3]", + "maxValue": 34.52, + "colorMode": "colormap", + "gradient": ["#0000ff", "#ff00ff"], + "reverse": true, + "foxglovePanelTitle": "RR" + }, + "Battery!wppv5y": { + "path": "/battery/battery_status.percentage", + "minValue": 0, + "maxValue": 1, + "colorMap": "red-yellow-green", + "colorMode": "colormap", + "gradient": ["#0000ff", "#ff00ff"], + "reverse": false, + "foxglovePanelTitle": "Battery" + }, + "EStop!1ejvisy": { + "requestPayload": "{}", + "layout": "vertical", + "advancedView": false, + "serviceName": "", + "goServiceName": "/hardware/e_stop_reset", + "stopServiceName": "/hardware/e_stop_trigger", + "statusTopicName": "/hardware/e_stop.data", + "foxglovePanelTitle": "E-stop" + }, + "Joy!3fmstz6": { + "topic": "/cmd_vel", + "publishRate": 5, + "upButton": { + "field": "linear-x", + "value": 1 + }, + "downButton": { + "field": "linear-x", + "value": -1 + }, + "leftButton": { + "field": "angular-z", + "value": 1 + }, + "rightButton": { + "field": "angular-z", + "value": 0 + }, + "xValue": { + "field": "linear-x", + "value": 1 + }, + "yValue": { + "field": "angular-z", + "value": -1 + }, + "xAxis": { + "field": "linear-x", + "limit": 2 + }, + "yAxis": { + "field": "angular-z", + "limit": 3 + } + }, + "Tab!1plmth0": { + "activeTabIdx": 2, + "tabs": [ + { + "title": "Diagnostic", + "layout": "DiagnosticSummary!47zasr6" + }, + { + "title": "IMU", + "layout": "Plot!dg5ynj" + }, + { + "title": "Motors", + "layout": { + "first": { + "first": "Bar!3t52ye7", + "second": "Bar!461hl59", + "direction": "row" + }, + "second": { + "first": "Bar!1fzrnqw", + "second": "Bar!1q5qffy", + "direction": "row" + }, + "direction": "column" + } + } + ] + } + }, + "globalVariables": { + "": "\"\"" + }, + "userNodes": {}, + "layout": { + "first": { + "first": "Battery!wppv5y", + "second": "EStop!1ejvisy", + "direction": "row", + "splitPercentage": 45.78378378378378 + }, + "second": { + "first": "Joy!3fmstz6", + "second": "Tab!1plmth0", + "direction": "column", + "splitPercentage": 57.41007194244605 + }, + "direction": "column", + "splitPercentage": 23.626373626373624 + } +} diff --git a/desktop/electronBuilderConfig.js b/desktop/electronBuilderConfig.js deleted file mode 100644 index 289f420bbc..0000000000 --- a/desktop/electronBuilderConfig.js +++ /dev/null @@ -1,11 +0,0 @@ -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/ - -const path = require("path"); - -const { makeElectronBuilderConfig } = require("@foxglove/studio-desktop/src/electronBuilderConfig"); - -module.exports = makeElectronBuilderConfig({ - appPath: path.resolve(__dirname, ".webpack"), -}); diff --git a/desktop/integration-test/build.ts b/desktop/integration-test/build.ts deleted file mode 100644 index 969457e49d..0000000000 --- a/desktop/integration-test/build.ts +++ /dev/null @@ -1,47 +0,0 @@ -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/ -import path from "path"; -import webpack from "webpack"; - -import webpackConfig from "../webpack.config"; - -const appPath = path.join(__dirname, "..", ".webpack"); -export { appPath }; - -// global jest test setup builds the webpack build before running any integration tests -export default async (): Promise => { - if ((process.env.INTEGRATION_SKIP_BUILD ?? "") !== "") { - return; - } - - const compiler = webpack( - webpackConfig.map((config) => { - if (typeof config === "function") { - return config(undefined, { mode: "production" }); - } - - return config; - }), - ); - - await new Promise((resolve, reject) => { - // eslint-disable-next-line no-restricted-syntax - console.info("Building Webpack. To skip, set INTEGRATION_SKIP_BUILD=1"); - compiler.run((err, result) => { - compiler.close(() => {}); - if (err) { - reject(err); - return; - } - if (!result || result.hasErrors()) { - console.error(result?.toString()); - reject(new Error("webpack build failed")); - return; - } - // eslint-disable-next-line no-restricted-syntax - console.info("Webpack build complete"); - resolve(); - }); - }); -}; diff --git a/desktop/integration-test/buildSize.test.ts b/desktop/integration-test/buildSize.test.ts deleted file mode 100644 index 062d89bf76..0000000000 --- a/desktop/integration-test/buildSize.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/ - -import { readdir, stat } from "fs/promises"; -import * as path from "path"; - -// Adjust this byte size as needed if the app is growing for a valid reason. -// This check is here to catch unexpected ballooning of the build size -const MAX_BUILD_SIZE = 160_000_000; - -async function getAllFiles(dirPath: string, arrayOfFiles: string[] = []): Promise { - const files = await readdir(dirPath, { withFileTypes: true }); - - for (const file of files) { - if (file.isDirectory()) { - // eslint-disable-next-line no-param-reassign - arrayOfFiles = await getAllFiles(path.join(dirPath, file.name), arrayOfFiles); - } else { - arrayOfFiles.push(path.join(dirPath, file.name)); - } - } - - return arrayOfFiles; -} - -async function getTotalSize(directoryPath: string): Promise { - const arrayOfFiles = await getAllFiles(directoryPath); - let totalSize = 0; - for (const filename of arrayOfFiles) { - totalSize += (await stat(filename)).size; - } - return totalSize; -} - -it("build size should not grow significantly", async () => { - const buildDir = path.join(__dirname, "..", ".webpack"); - const buildSizeInBytes = await getTotalSize(buildDir); - // eslint-disable-next-line no-restricted-syntax - console.info(`Total production build size: ${buildSizeInBytes} bytes`); - expect(buildSizeInBytes).toBeLessThan(MAX_BUILD_SIZE); -}); diff --git a/desktop/integration-test/jest.config.json b/desktop/integration-test/jest.config.json deleted file mode 100644 index 655f393c95..0000000000 --- a/desktop/integration-test/jest.config.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "globalSetup": "/build.ts", - "transform": { - "\\.[jt]sx?$": ["babel-jest", { "rootMode": "upward" }] - }, - "//": "Native find is slow because it does not exclude files: https://github.com/facebook/jest/pull/11264#issuecomment-825377579", - "haste": { "forceNodeFilesystemAPI": true } -} diff --git a/desktop/integration-test/launchApp.ts b/desktop/integration-test/launchApp.ts deleted file mode 100644 index 6adc68cfbd..0000000000 --- a/desktop/integration-test/launchApp.ts +++ /dev/null @@ -1,70 +0,0 @@ -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/ - -import electronPath from "electron"; -import { mkdtemp } from "fs/promises"; -import * as os from "os"; -import * as path from "path"; -import { ConsoleMessage, _electron as electron, ElectronApplication, Page } from "playwright"; - -import { signal } from "@foxglove/den/async"; -import Logger from "@foxglove/log"; - -import { appPath } from "./build"; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -(Symbol as any).dispose ??= Symbol("Symbol.dispose"); -// eslint-disable-next-line @typescript-eslint/no-explicit-any -(Symbol as any).asyncDispose ??= Symbol("Symbol.asyncDispose"); - -const log = Logger.getLogger(__filename); - -/** - * Launch the app and wait for initial render. - * - * @returns An AsyncDisposable which automatically closes the app when it goes out of scope. - */ -export async function launchApp(): Promise< - { - main: ElectronApplication; - renderer: Page; - } & AsyncDisposable -> { - // Create a new user data directory for each test, which bypasses the `app.requestSingleInstanceLock()` - const userDataDir = await mkdtemp(path.join(os.tmpdir(), "integration-test-")); - const electronApp = await electron.launch({ - args: [appPath, `--user-data-dir=${userDataDir}`], - // In node.js the electron import gives us the path to the electron binary - // Our type definitions don't realize this so cast the variable to a string - executablePath: electronPath as unknown as string, - }); - - const electronWindow = await electronApp.firstWindow(); - const appRendered = signal(); - electronWindow.on("console", (message: ConsoleMessage) => { - if (message.type() === "error") { - throw new Error(message.text()); - } - log.info(message.text()); - - if (message.text().includes("App rendered")) { - // Wait for a few seconds for the app to render more components and detect if - // there are any errors after the initial app render - setTimeout(() => { - appRendered.resolve(); - }, 2_000); - } - }); - - await appRendered; - - return { - main: electronApp, - renderer: electronWindow, - - async [Symbol.asyncDispose]() { - await electronApp.close(); - }, - }; -} diff --git a/desktop/integration-test/menus.test.ts b/desktop/integration-test/menus.test.ts deleted file mode 100644 index 0b4c64b7f1..0000000000 --- a/desktop/integration-test/menus.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/ - -import { signal } from "@foxglove/den/async"; - -import { launchApp } from "./launchApp"; - -describe("menus", () => { - it("should display the data source dialog when clicking File > Open", async () => { - await using app = await launchApp(); - // The Open dialog shows up automatically; close it - await expect(app.renderer.getByTestId("DataSourceDialog").isVisible()).resolves.toBe(true); - await app.renderer.getByTestId("DataSourceDialog").getByTestId("CloseIcon").click(); - await expect(app.renderer.getByTestId("DataSourceDialog").count()).resolves.toBe(0); - - // Click "File > Open" and the dialog should appear again - await app.main.evaluate(async ({ Menu }) => { - const menu = Menu.getApplicationMenu(); - menu?.getMenuItemById("fileMenu")?.submenu?.getMenuItemById("open")?.click(); - }); - await app.renderer.waitForSelector('[data-testid="DataSourceDialog"]', { state: "visible" }); - }, 15_000); - - it("should display the Open Connection screen when clicking File > Open Connection", async () => { - await using app = await launchApp(); - // The Open dialog shows up automatically; close it - await expect(app.renderer.getByTestId("DataSourceDialog").isVisible()).resolves.toBe(true); - await expect(app.renderer.getByTestId("OpenConnection").count()).resolves.toBe(0); - await app.renderer.getByTestId("DataSourceDialog").getByTestId("CloseIcon").click(); - await expect(app.renderer.getByTestId("DataSourceDialog").count()).resolves.toBe(0); - - // Click "File > Open Connection" and the Open Connection screen should appear - await app.main.evaluate(async ({ Menu }) => { - const menu = Menu.getApplicationMenu(); - menu?.getMenuItemById("fileMenu")?.submenu?.getMenuItemById("openConnection")?.click(); - }); - await app.renderer.waitForSelector('[data-testid="OpenConnection"]', { state: "visible" }); - }, 15_000); - - it("should open the file chooser when clicking File > Open Local File", async () => { - await using app = await launchApp(); - - // The page is loaded as a file:// URL in the test, so showOpenFilePicker is not available and we need to mock it. - // If it were available we could use `app.renderer.waitForEvent("filechooser")` instead. - const openFilePickerCalled = signal(); - await app.renderer.exposeFunction("showOpenFilePicker", async () => { - openFilePickerCalled.resolve(true); - return []; - }); - - // Click "File > Open Connection" and the Open Connection screen should appear - await app.main.evaluate(async ({ Menu }) => { - const menu = Menu.getApplicationMenu(); - menu?.getMenuItemById("fileMenu")?.submenu?.getMenuItemById("openLocalFile")?.click(); - }); - - await expect(openFilePickerCalled).resolves.toBe(true); - }, 15_000); -}); diff --git a/desktop/integration-test/startup.test.ts b/desktop/integration-test/startup.test.ts deleted file mode 100644 index 5040d05d91..0000000000 --- a/desktop/integration-test/startup.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/ - -import { launchApp } from "./launchApp"; - -describe("startup", () => { - it("should start the application", async () => { - expect.assertions(0); // just needs to complete without error - await using app = await launchApp(); - void app; - }, 10_000); -}); diff --git a/desktop/jest.config.json b/desktop/jest.config.json deleted file mode 100644 index 4bc793de6e..0000000000 --- a/desktop/jest.config.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "transform": { - "\\.[jt]sx?$": ["babel-jest", { "rootMode": "upward" }] - }, - "testPathIgnorePatterns": ["integration-test"], - "//": "Native find is slow because it does not exclude files: https://github.com/facebook/jest/pull/11264#issuecomment-825377579", - "haste": { "forceNodeFilesystemAPI": true } -} diff --git a/desktop/main/tsconfig.json b/desktop/main/tsconfig.json deleted file mode 100644 index 48095d7f16..0000000000 --- a/desktop/main/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "@foxglove/tsconfig/base", - "include": ["./**/*", "../common/*", "../../package.json"], - "compilerOptions": { - "jsx": "react-jsx", - "rootDir": "../../", - "noEmit": true - } -} diff --git a/desktop/package.json b/desktop/package.json deleted file mode 100644 index 81484eff3a..0000000000 --- a/desktop/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "desktop", - "private": true, - "devDependencies": { - "@foxglove/den": "workspace:*", - "@foxglove/log": "workspace:*", - "@foxglove/studio-base": "workspace:*", - "@foxglove/studio-desktop": "workspace:*", - "@foxglove/tsconfig": "2.0.0", - "electron": "25.5.0", - "playwright": "1.37.1", - "webpack": "5.88.2" - } -} diff --git a/desktop/preload/tsconfig.json b/desktop/preload/tsconfig.json deleted file mode 100644 index 9cd3f348ad..0000000000 --- a/desktop/preload/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "@foxglove/tsconfig/base", - "include": ["./**/*", "../common/*", "../../package.json"], - "compilerOptions": { - "rootDir": "../../", - "noEmit": true, - "lib": ["dom", "es2022"], - "useUnknownInCatchVariables": false - } -} diff --git a/desktop/quicklook/tsconfig.json b/desktop/quicklook/tsconfig.json deleted file mode 100644 index 533aee1f39..0000000000 --- a/desktop/quicklook/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "@foxglove/tsconfig/base", - "include": ["./**/*"], - "compilerOptions": { - "rootDir": "../../", - "noEmit": true, - "jsx": "react-jsx", - "lib": ["dom", "dom.iterable", "es2022"] - } -} diff --git a/desktop/renderer/index.ts b/desktop/renderer/index.ts deleted file mode 100644 index 5c941a7d5c..0000000000 --- a/desktop/renderer/index.ts +++ /dev/null @@ -1,25 +0,0 @@ -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/ - -import { AppSetting } from "@foxglove/studio-base"; -import { Storage } from "@foxglove/studio-desktop/src/common/types"; -import { main as rendererMain } from "@foxglove/studio-desktop/src/renderer/index"; -import NativeStorageAppConfiguration from "@foxglove/studio-desktop/src/renderer/services/NativeStorageAppConfiguration"; - -const isDevelopment = process.env.NODE_ENV === "development"; - -async function main() { - const appConfiguration = await NativeStorageAppConfiguration.Initialize( - (global as { storageBridge?: Storage }).storageBridge!, - { - defaults: { - [AppSetting.SHOW_DEBUG_PANELS]: isDevelopment, - }, - }, - ); - - await rendererMain({ appConfiguration }); -} - -void main(); diff --git a/desktop/renderer/tsconfig.json b/desktop/renderer/tsconfig.json deleted file mode 100644 index 5d4476b0ba..0000000000 --- a/desktop/renderer/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "extends": "@foxglove/tsconfig/base", - "include": ["./**/*", "../common/**/*", "../../package.json"], - "compilerOptions": { - "rootDir": "../../", - "noEmit": true, - "jsx": "react-jsx", - "experimentalDecorators": true, - "useUnknownInCatchVariables": false, - "lib": ["dom", "dom.iterable", "es2022", "webworker"], - "paths": { - "@foxglove/studio-base/*": ["../../packages/studio-base/src/*"] - } - } -} diff --git a/desktop/tsconfig.json b/desktop/tsconfig.json deleted file mode 100644 index d1eb189658..0000000000 --- a/desktop/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "@foxglove/tsconfig/base", - "include": ["*.ts", "common/**/*", "integration-test/**/*.ts", "../package.json"], - "compilerOptions": { - "lib": ["dom", "es2022", "esnext.disposable"], - "module": "commonjs", - "noEmit": true - } -} diff --git a/desktop/webpack.config.ts b/desktop/webpack.config.ts deleted file mode 100644 index 47a38fc0c8..0000000000 --- a/desktop/webpack.config.ts +++ /dev/null @@ -1,36 +0,0 @@ -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/ - -import path from "path"; - -import { WebpackConfigParams } from "@foxglove/studio-desktop/src/WebpackConfigParams"; -import { webpackDevServerConfig } from "@foxglove/studio-desktop/src/webpackDevServerConfig"; -import { webpackMainConfig } from "@foxglove/studio-desktop/src/webpackMainConfig"; -import { webpackPreloadConfig } from "@foxglove/studio-desktop/src/webpackPreloadConfig"; -import { webpackQuicklookConfig } from "@foxglove/studio-desktop/src/webpackQuicklookConfig"; -import { webpackRendererConfig } from "@foxglove/studio-desktop/src/webpackRendererConfig"; - -import packageJson from "../package.json"; - -const params: WebpackConfigParams = { - packageJson, - outputPath: path.resolve(__dirname, ".webpack"), - prodSourceMap: "source-map", - rendererContext: path.resolve(__dirname, "renderer"), - rendererEntrypoint: "./index.ts", - mainContext: path.resolve(__dirname, "main"), - mainEntrypoint: "./index.ts", - quicklookContext: path.resolve(__dirname, "quicklook"), - quicklookEntrypoint: "./index.ts", - preloadContext: path.resolve(__dirname, "preload"), - preloadEntrypoint: "./index.ts", -}; - -export default [ - webpackDevServerConfig(params), - webpackMainConfig(params), - webpackPreloadConfig(params), - webpackRendererConfig(params), - webpackQuicklookConfig(params), -]; diff --git a/packages/@types/foxglove__web/index.d.ts b/packages/@types/foxglove__web/index.d.ts index f173dd8034..101d60453b 100644 --- a/packages/@types/foxglove__web/index.d.ts +++ b/packages/@types/foxglove__web/index.d.ts @@ -17,8 +17,14 @@ type MemoryInfo = { usedJSHeapSize: number; }; +/** https://developer.mozilla.org/en-US/docs/Web/API/Performance/measureUserAgentSpecificMemory */ +type UserAgentSpecificMemory = { + bytes: number; +}; + // Our DOM types don't have types for performance.memory since this is a chrome feature // We make our own version of Performance which optionally has MemoryInfo interface Performance { memory?: MemoryInfo; + measureUserAgentSpecificMemory?(): Promise; } diff --git a/packages/comlink-transfer-handlers/package.json b/packages/comlink-transfer-handlers/package.json index c43ef51439..41807c12c9 100644 --- a/packages/comlink-transfer-handlers/package.json +++ b/packages/comlink-transfer-handlers/package.json @@ -21,7 +21,7 @@ }, "devDependencies": { "@foxglove/tsconfig": "2.0.0", - "comlink": "4.4.1", - "typescript": "5.2.2" + "comlink": "github:foxglove/comlink#9181fa505671b35b1e66e0a8361a6fc1bdd03307", + "typescript": "5.3.3" } } diff --git a/packages/comlink-transfer-handlers/src/abortSignalTransferHandler.ts b/packages/comlink-transfer-handlers/src/abortSignalTransferHandler.ts index c8a79c072e..3a43230f3a 100644 --- a/packages/comlink-transfer-handlers/src/abortSignalTransferHandler.ts +++ b/packages/comlink-transfer-handlers/src/abortSignalTransferHandler.ts @@ -2,7 +2,7 @@ // License, v2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/ -import { TransferHandler } from "comlink"; +import type { TransferHandler } from "comlink"; const isAbortSignal = (val: unknown): val is AbortSignal => val instanceof AbortSignal; diff --git a/packages/den/async/MutexLocked.ts b/packages/den/async/MutexLocked.ts index 430a4d9784..f95db88c6b 100644 --- a/packages/den/async/MutexLocked.ts +++ b/packages/den/async/MutexLocked.ts @@ -17,4 +17,11 @@ export default class MutexLocked { public async runExclusive(body: (value: T) => Promise): Promise { return await this.#mutex.runExclusive(async () => await body(this.value)); } + + /** + * @returns a boolean indicating if the mutex is currently locked. Does not block or wait. + */ + public isLocked(): boolean { + return this.#mutex.isLocked(); + } } diff --git a/packages/den/collection/ArrayMap.test.ts b/packages/den/collection/ArrayMap.test.ts index fd24159303..1023fb17a0 100644 --- a/packages/den/collection/ArrayMap.test.ts +++ b/packages/den/collection/ArrayMap.test.ts @@ -154,4 +154,25 @@ describe("ArrayMap", () => { expect(list.binarySearch(8)).toBe(7); expect(list.binarySearch(9)).toBe(8); }); + + it("iterates properly", () => { + const list = new ArrayMap(); + const data = [...Array(10).keys()]; + data.forEach((val) => { + list.set(val, String(val)); + }); + let i = 0; + for (const [k, v] of list) { + expect(k).toBe(data[i]); + expect(v).toBe(data[i]!.toString()); + i++; + } + }); + + it("does not return referentially the same object from set when replacing", () => { + const list = new ArrayMap(); + list.set(1, { a: 1 }); + const prev = list.set(1, { a: 2 }); + expect(prev).not.toBe(list.at(list.binarySearch(1))![1]); + }); }); diff --git a/packages/den/collection/ArrayMap.ts b/packages/den/collection/ArrayMap.ts index 1e95b9b904..d206fd0f6e 100644 --- a/packages/den/collection/ArrayMap.ts +++ b/packages/den/collection/ArrayMap.ts @@ -15,8 +15,13 @@ export class ArrayMap { return this.#list.length; } - public clear(): void { - this.#list.length = 0; + /** Clears array and returns removed elements */ + public clear(): [K, V][] { + return this.#list.splice(0); + } + + public [Symbol.iterator](): IterableIterator<[K, V]> { + return this.#list[Symbol.iterator](); } /** Retrieve the key/value tuple at the given index, if it exists. */ @@ -27,20 +32,24 @@ export class ArrayMap { /** * Store a key/value tuple in the sorted list. If the key already exists, the * previous entry is overwritten. + * Returns replaced value if it exists. */ - public set(key: K, value: V): void { + public set(key: K, value: V): V | undefined { const index = this.binarySearch(key); if (index >= 0) { + const existingEntry = this.#list[index]![1]; this.#list[index]![1] = value; + return existingEntry; } else { - const greaterThanIndex = ~index; const newEntry: [K, V] = [key, value]; + const greaterThanIndex = ~index; if (greaterThanIndex >= this.#list.length) { this.#list.push(newEntry); } else { this.#list.splice(greaterThanIndex, 0, newEntry); } } + return undefined; } /** Removes the first element and returns it, if available. */ @@ -53,26 +62,34 @@ export class ArrayMap { return this.#list.pop(); } - /** Removes the element with the given key, if it exists */ - public remove(key: K): void { + /** Removes the element with the given key, if it exists. + * Returns element removed. + */ + public remove(key: K): [K, V] | undefined { const index = this.binarySearch(key); if (index >= 0) { - this.#list.splice(index, 1); + return this.#list.splice(index, 1)[0]; } + return undefined; } - /** Removes all elements with keys greater than the given key. */ - public removeAfter(key: K): void { + /** Removes all elements with keys greater than the given key. + * Returns elements removed. + */ + public removeAfter(key: K): [K, V][] { const index = this.binarySearch(key); const greaterThanIndex = index >= 0 ? index + 1 : ~index; - this.#list.length = greaterThanIndex; + const removed = this.#list.splice(greaterThanIndex); + return removed; } - /** Removes all elements with keys less than the given key. */ - public removeBefore(key: K): void { + /** Removes all elements with keys less than the given key. + * Returns elements removed. + */ + public removeBefore(key: K): [K, V][] { const index = this.binarySearch(key); const lessThanIndex = index >= 0 ? index : ~index; - this.#list.splice(0, lessThanIndex); + return this.#list.splice(0, lessThanIndex); } /** Access the first key/value tuple in the list, without modifying the list. */ diff --git a/packages/den/collection/ObjectPool.test.ts b/packages/den/collection/ObjectPool.test.ts new file mode 100644 index 0000000000..1d43513de8 --- /dev/null +++ b/packages/den/collection/ObjectPool.test.ts @@ -0,0 +1,41 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/ + +import { ObjectPool } from "./ObjectPool"; + +describe("ObjectPool", () => { + it("creates a new object when the pool is empty", () => { + const objectPool = new ObjectPool(() => ({ a: 1 })); + const obj = objectPool.acquire(); + expect(obj).toEqual({ a: 1 }); + }); + it("uses released objects if it has some", () => { + const objectPool = new ObjectPool(() => ({ a: 1 })); + const obj1 = { a: 1 }; + objectPool.release(obj1); + const acq = objectPool.acquire(); + // should have same object reference + expect(acq === obj1).toBe(true); + }); + it("does not release past the maximum capacity", () => { + const list = [{ a: 1 }, { a: 1 }]; + const objectPool = new ObjectPool(() => ({ a: 1 }), { maxCapacity: 1 }); + for (const obj of list) { + objectPool.release(obj); + } + // first object should be released, second should be dropped + expect(objectPool.acquire() === list[0]).toBe(true); + expect(objectPool.acquire() === list[1]).toBe(false); + }); + it("returns all elements on clear", () => { + const list = [{ a: 1 }, { a: 1 }]; + const objectPool = new ObjectPool(() => ({ a: 1 })); + for (const obj of list) { + objectPool.release(obj); + } + expect(objectPool.clear()).toEqual(list); + //second clear should be empty + expect(objectPool.clear()).toEqual([]); + }); +}); diff --git a/packages/den/collection/ObjectPool.ts b/packages/den/collection/ObjectPool.ts new file mode 100644 index 0000000000..8ccb3651b2 --- /dev/null +++ b/packages/den/collection/ObjectPool.ts @@ -0,0 +1,59 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/ + +type ObjectPoolOptions = { + /** + * Limits the number of elements in the pool at a given time. + * After the limit is reached, releasing an object will not add it to the pool. + */ + maxCapacity?: number; +}; +/** + * An object pool for reusing objects. + * Can be helpful for reusing objects that are either expensive to create or + * frequently used and discarded to avoid garbage collection. + * + * Options can be passed to it to limit the number of elements it has at once. + * + */ +export class ObjectPool { + #init: () => T; + #maxCapacity?: number; + #objects: T[] = []; + + /** + * + * @param init - A function that returns a new object. + * @param options.maxCapacity - Limits the number of elements in the pool at a given time. + */ + public constructor(init: () => T, options: ObjectPoolOptions = {}) { + this.#init = init; + this.#maxCapacity = options.maxCapacity; + } + + /** Returns an object from the pool or instantiates and returns a new one if + * there are none. + */ + public acquire(): T { + return this.#objects.pop() ?? this.#init(); + } + + /** Release a object back to the pool to be reused. + * If the maxCapacity is defined and has been reached it will be dropped. + */ + public release(obj: T): void { + if (this.#maxCapacity == undefined || this.#objects.length < this.#maxCapacity) { + this.#objects.push(obj); + } + } + + /** + * Clears all objects in the pool. + * Returns the objects that were cleared; this can be helpful if they have + * custom dispose logic. + */ + public clear(): T[] { + return this.#objects.splice(0); + } +} diff --git a/packages/den/collection/index.ts b/packages/den/collection/index.ts index 314eb6c2ec..822eb090b0 100644 --- a/packages/den/collection/index.ts +++ b/packages/den/collection/index.ts @@ -7,4 +7,5 @@ export * from "./ArrayMap"; export * from "./binarySearch"; export * from "./minIndexBy"; export * from "./MultiMap"; +export * from "./ObjectPool"; export * from "./VecQueue"; diff --git a/packages/den/disposable/defer.test.ts b/packages/den/disposable/defer.test.ts new file mode 100644 index 0000000000..e3956687b5 --- /dev/null +++ b/packages/den/disposable/defer.test.ts @@ -0,0 +1,38 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/ + +import { defer } from "./defer"; + +describe("defer", () => { + it("should call the provided function when disposed", () => { + let called = false; + + { + // eslint-disable-next-line no-underscore-dangle + using _disposable = defer(() => { + called = true; + }); + + expect(called).toBe(false); + } + expect(called).toBe(true); + }); + + it("should call the provided function with throw in scope", () => { + let called = false; + + function foo() { + // eslint-disable-next-line no-underscore-dangle + using _disposable = defer(() => { + called = true; + }); + + expect(called).toBe(false); + throw new Error("some error"); + } + + expect(foo).toThrow(); + expect(called).toBe(true); + }); +}); diff --git a/packages/den/disposable/defer.ts b/packages/den/disposable/defer.ts new file mode 100644 index 0000000000..f0a42650e7 --- /dev/null +++ b/packages/den/disposable/defer.ts @@ -0,0 +1,37 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/ + +// Ensure Symbol.dispose and Symbol.asyncDispose are defined +// https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-2.html +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(Symbol as any).dispose ??= Symbol("Symbol.dispose"); +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(Symbol as any).asyncDispose ??= Symbol("Symbol.asyncDispose"); + +/** + * defer runs a function when returned disposable is disposed + * + * ``` + * { + * const resource = Resource.open(); + * using def = defer(() => resource.close()); + * + * // do some other stuff // + * + * // end of scope, the deferred function will run + * } + * ``` + * + * See also: https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-2.html + * + * @param fn The function to run when the returned disposable is disposed + * @returns a disposable + */ +export function defer(fn: () => void): Disposable { + return { + [Symbol.dispose]() { + fn(); + }, + }; +} diff --git a/desktop/preload/index.ts b/packages/den/disposable/index.ts similarity index 74% rename from desktop/preload/index.ts rename to packages/den/disposable/index.ts index 97e8c140e5..902245a299 100644 --- a/desktop/preload/index.ts +++ b/packages/den/disposable/index.ts @@ -2,6 +2,4 @@ // License, v2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/ -import { main } from "@foxglove/studio-desktop/src/preload"; - -main(); +export * from "./defer"; diff --git a/packages/studio-desktop/src/quicklook/formatByteSize.test.ts b/packages/den/format/formatByteSize.test.ts similarity index 95% rename from packages/studio-desktop/src/quicklook/formatByteSize.test.ts rename to packages/den/format/formatByteSize.test.ts index b09830a188..80a4275b88 100644 --- a/packages/studio-desktop/src/quicklook/formatByteSize.test.ts +++ b/packages/den/format/formatByteSize.test.ts @@ -2,7 +2,7 @@ // License, v2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/ -import formatByteSize from "./formatByteSize"; +import { formatByteSize } from "./formatByteSize"; describe("formatByteSize", () => { it.each([ diff --git a/packages/studio-desktop/src/quicklook/formatByteSize.ts b/packages/den/format/formatByteSize.ts similarity index 88% rename from packages/studio-desktop/src/quicklook/formatByteSize.ts rename to packages/den/format/formatByteSize.ts index e5a90bdf00..7bf337fade 100644 --- a/packages/studio-desktop/src/quicklook/formatByteSize.ts +++ b/packages/den/format/formatByteSize.ts @@ -2,7 +2,7 @@ // License, v2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/ -export default function formatByteSize(size: number): string { +export function formatByteSize(size: number): string { const suffixes = ["Bytes", "KiB", "MiB", "GiB", "TiB", "PiB"]; let value = size; let suffix = 0; diff --git a/desktop/quicklook/index.ts b/packages/den/format/index.ts similarity index 73% rename from desktop/quicklook/index.ts rename to packages/den/format/index.ts index 4eaf889fa7..34d20322e6 100644 --- a/desktop/quicklook/index.ts +++ b/packages/den/format/index.ts @@ -2,6 +2,4 @@ // License, v2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/ -import { main } from "@foxglove/studio-desktop/src/quicklook"; - -main(); +export * from "./formatByteSize"; diff --git a/packages/den/package.json b/packages/den/package.json index 6d9e01dc59..19b805f7ca 100644 --- a/packages/den/package.json +++ b/packages/den/package.json @@ -13,7 +13,8 @@ }, "homepage": "https://foxglove.dev/", "dependencies": { - "async-mutex": "0.4.0", + "async-mutex": "0.4.1", + "comlink": "github:foxglove/comlink#9181fa505671b35b1e66e0a8361a6fc1bdd03307", "xacro-parser": "0.3.9" }, "devDependencies": { diff --git a/desktop/main/index.ts b/packages/den/testing/index.ts similarity index 73% rename from desktop/main/index.ts rename to packages/den/testing/index.ts index 28d6af70de..bac041dace 100644 --- a/desktop/main/index.ts +++ b/packages/den/testing/index.ts @@ -2,6 +2,4 @@ // License, v2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/ -import { main } from "@foxglove/studio-desktop/src/main"; - -void main(); +export * from "./makeComlinkWorkerMock"; diff --git a/packages/den/testing/makeComlinkWorkerMock.ts b/packages/den/testing/makeComlinkWorkerMock.ts new file mode 100644 index 0000000000..d1580c42c9 --- /dev/null +++ b/packages/den/testing/makeComlinkWorkerMock.ts @@ -0,0 +1,75 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/ + +import * as Comlink from "comlink"; +import { EventEmitter } from "node:events"; + +type ComlinkWorkerConstructor = new () => Comlink.Endpoint; + +/** + * makeComlinkWorkerMock provides a way to mock the global Worker class to _expose_ the worker + * side of a comlink connection. The instance can be passed to a `Comlink.wrap()` call to + * invoke methods on the underling class. + * + * ``` + * Object.defineProperty(global, "Worker", { + * writable: true, + * value: makeComlinkWorkerMock(() => new SomeClassThatYouExpose()) + * }); + * ``` + */ +export function makeComlinkWorkerMock(makeInstance: () => unknown): ComlinkWorkerConstructor { + class WorkerEndpoint extends EventEmitter { + #client: WorkerClient; + + public constructor(client: WorkerClient) { + super(); + this.#client = client; + } + + public postMessage(msg: unknown): void { + this.#client.emit("message", { + data: msg, + }); + } + + public addEventListener(event: string, fn: () => void): void { + this.on(event, fn); + } + + public removeEventListener(event: string, fn: () => void): void { + this.off(event, fn); + } + } + + class WorkerClient extends EventEmitter implements Comlink.Endpoint { + #server: WorkerEndpoint; + public constructor() { + super(); + + this.#server = new WorkerEndpoint(this); + Comlink.expose(makeInstance(), this.#server); + } + + public postMessage(msg: unknown): void { + this.#server.emit("message", { + data: msg, + }); + } + + public addEventListener(event: string, fn: () => void): void { + this.on(event, fn); + } + + public removeEventListener(event: string, fn: () => void): void { + this.off(event, fn); + } + + public terminate(): void { + // no-op + } + } + + return WorkerClient; +} diff --git a/packages/den/worker/ComlinkWrap.ts b/packages/den/worker/ComlinkWrap.ts new file mode 100644 index 0000000000..76dd3b202b --- /dev/null +++ b/packages/den/worker/ComlinkWrap.ts @@ -0,0 +1,26 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/ + +import * as Comlink from "comlink"; + +/** + * Wraps an instantiated `Worker` and exposes its API in the same way that `Comlink.wrap` does + * but it also provides a `dispose` function to terminate the worker and release the comlink proxy. + * This can help prevent memory leaks when the comlink proxy is unable to garbage collect itself due to + * unresolved promises which can occur if the worker is terminated while processing a request. + * This should be used instead of `Comlink.wrap` where possible. + * + * @param worker - worker to be wrapped by comlink + * @returns remote - API for worker wrapped by comlink. What is normally received from Comlink.wrap + * @returns dispose - function to release the comlink proxy and to terminate the worker + */ +export function ComlinkWrap(worker: Worker): { remote: Comlink.Remote; dispose: () => void } { + const remote = Comlink.wrap(worker); + + const dispose = () => { + remote[Comlink.releaseProxy](); + worker.terminate(); + }; + return { remote, dispose }; +} diff --git a/packages/studio-desktop/src/quicklook/extensions.d.ts b/packages/den/worker/index.ts similarity index 73% rename from packages/studio-desktop/src/quicklook/extensions.d.ts rename to packages/den/worker/index.ts index 143533f502..e2ddc50250 100644 --- a/packages/studio-desktop/src/quicklook/extensions.d.ts +++ b/packages/den/worker/index.ts @@ -2,7 +2,4 @@ // License, v2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/ -declare module "*.png" { - const path: string; - export default path; -} +export * from "./ComlinkWrap"; diff --git a/packages/eslint-plugin-studio/index.js b/packages/eslint-plugin-studio/index.js index 569db9d56f..8b91aa3efe 100644 --- a/packages/eslint-plugin-studio/index.js +++ b/packages/eslint-plugin-studio/index.js @@ -7,6 +7,7 @@ module.exports = { "link-target": require("./link-target"), "lodash-ramda-imports": require("./lodash-ramda-imports"), "ramda-usage": require("./ramda-usage"), + "no-map-type-argument": require("./no-map-type-argument"), }, configs: { @@ -16,6 +17,7 @@ module.exports = { "@foxglove/studio/link-target": "error", "@foxglove/studio/lodash-ramda-imports": "error", "@foxglove/studio/ramda-usage": "error", + "@foxglove/studio/no-map-type-argument": "error", }, }, }, diff --git a/packages/eslint-plugin-studio/no-map-type-argument.js b/packages/eslint-plugin-studio/no-map-type-argument.js new file mode 100644 index 0000000000..84d7c1448e --- /dev/null +++ b/packages/eslint-plugin-studio/no-map-type-argument.js @@ -0,0 +1,67 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/ + +const { ESLintUtils } = require("@typescript-eslint/utils"); + +/** + * @type {import("eslint").Rule.RuleModule} + */ +module.exports = { + meta: { + type: "problem", + fixable: "code", + messages: { + preferReturnTypeAnnotation: `Annotate the function return type explicitly instead of passing generic arguments to Array#map, to avoid return type widening (https://github.com/microsoft/TypeScript/issues/241)`, + }, + }, + create: (context) => { + return { + [`CallExpression[arguments.length>=1][typeArguments.params.length=1][arguments.0.type=ArrowFunctionExpression]:not([arguments.0.returnType]) > MemberExpression.callee[property.name="map"]`]: + (/** @type {import("estree").MemberExpression} */ node) => { + /** @type {import("estree").CallExpression} */ + const callExpr = node.parent; + + const { esTreeNodeToTSNodeMap, program } = ESLintUtils.getParserServices(context); + const sourceCode = context.getSourceCode(); + const checker = program.getTypeChecker(); + const objectTsNode = esTreeNodeToTSNodeMap.get(node.object); + const objectType = checker.getTypeAtLocation(objectTsNode); + if (!checker.isArrayType(objectType) && !checker.isTupleType(objectType)) { + return; + } + + const arrowToken = sourceCode.getTokenBefore( + callExpr.arguments[0].body, + (token) => token.type === "Punctuator" && token.value === "=>", + ); + if (!arrowToken) { + return; + } + const maybeCloseParenToken = sourceCode.getTokenBefore(arrowToken); + const closeParenToken = + maybeCloseParenToken.type === "Punctuator" && maybeCloseParenToken.value === ")" + ? maybeCloseParenToken + : undefined; + + context.report({ + node: callExpr.typeArguments, + messageId: "preferReturnTypeAnnotation", + *fix(fixer) { + const returnType = sourceCode.getText(callExpr.typeArguments.params[0]); + yield fixer.remove(callExpr.typeArguments); + if (closeParenToken) { + yield fixer.insertTextAfter(closeParenToken, `: ${returnType}`); + } else { + yield fixer.insertTextBefore(callExpr.arguments[0], "("); + yield fixer.insertTextAfter( + callExpr.arguments[0].params[callExpr.arguments[0].params.length - 1], + `): ${returnType}`, + ); + } + }, + }); + }, + }; + }, +}; diff --git a/packages/eslint-plugin-studio/no-map-type-argument.test.ts b/packages/eslint-plugin-studio/no-map-type-argument.test.ts new file mode 100644 index 0000000000..b2792a3546 --- /dev/null +++ b/packages/eslint-plugin-studio/no-map-type-argument.test.ts @@ -0,0 +1,48 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/ + +import { RuleTester } from "@typescript-eslint/rule-tester"; +import { TSESLint } from "@typescript-eslint/utils"; +import path from "path"; + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const rule = require("./no-map-type-argument") as TSESLint.RuleModule<"preferReturnTypeAnnotation">; + +const ruleTester = new RuleTester({ + parser: "@typescript-eslint/parser", + parserOptions: { + ecmaVersion: 2020, + tsconfigRootDir: path.join(__dirname, "fixture"), + project: "tsconfig.json", + }, +}); + +ruleTester.run("no-map-type-argument", rule, { + valid: [ + /* ts */ ` + [1, 2].map((x) => x + 1); + [1, 2].map((x): number => x + 1); + [1, 2].map((x): number => x + 1); + [1, 2].map((x) => x + 1); + ({ x: 1 }).map((x) => x + 1); + `, + ], + + invalid: [ + { + code: /* ts */ ` + [1, 2].map(x => x + 1); + [1, 2].map((x) => x + 1); + `, + errors: [ + { messageId: "preferReturnTypeAnnotation", line: 2 }, + { messageId: "preferReturnTypeAnnotation", line: 3 }, + ], + output: /* ts */ ` + [1, 2].map((x): number => x + 1); + [1, 2].map((x): number => x + 1); + `, + }, + ], +}); diff --git a/packages/eslint-plugin-studio/package.json b/packages/eslint-plugin-studio/package.json index d622cf43df..c15210b798 100644 --- a/packages/eslint-plugin-studio/package.json +++ b/packages/eslint-plugin-studio/package.json @@ -17,7 +17,7 @@ ], "devDependencies": { "@foxglove/tsconfig": "2.0.0", - "@typescript-eslint/rule-tester": "6.7.3", - "@typescript-eslint/utils": "6.7.3" + "@typescript-eslint/rule-tester": "6.10.0", + "@typescript-eslint/utils": "6.10.0" } } diff --git a/packages/eslint-plugin-studio/ramda-usage.js b/packages/eslint-plugin-studio/ramda-usage.js index 334b5ccbd3..04b9183683 100644 --- a/packages/eslint-plugin-studio/ramda-usage.js +++ b/packages/eslint-plugin-studio/ramda-usage.js @@ -12,8 +12,9 @@ module.exports = { type: "problem", fixable: "code", messages: { - useMath: `Use built-in Math.{{name}} instead of R.{{name}} when applying arguments directly`, - useArrayMap: `Use built-in Array#map instead of R.map`, + useMath: `Use built-in Math.{{name}} instead of R.{{name}}`, + useObject: `Use built-in Object.{{name}} instead of R.{{name}}`, + useArrayMethod: `Use built-in Array#{{arrayName}} instead of R.{{ramdaName}}`, }, }, create: (context) => { @@ -34,9 +35,24 @@ module.exports = { }, /** - * Transform `R.map(fn, array)` to `array.map(fn)` + * Transform `R.keys/values(a)` to `Object.keys/values(a)` */ - [`CallExpression[arguments.length=2] > MemberExpression.callee[object.name="R"][property.name="map"]`]: + [`CallExpression[arguments.length=1] > MemberExpression.callee[object.name="R"]:matches([property.name="keys"], [property.name="values"])`]: + (/** @type {import("estree").MemberExpression} */ node) => { + context.report({ + node, + messageId: "useObject", + data: { name: node.property.name }, + fix(fixer) { + return fixer.replaceText(node.object, "Object"); + }, + }); + }, + + /** + * Transform `R.all/any(fn, array)` to `array.every/some(fn)` + */ + [`CallExpression[arguments.length=2] > MemberExpression.callee[object.name="R"]:matches([property.name="all"], [property.name="any"])`]: (/** @type {import("estree").MemberExpression} */ node) => { /** @type {import("estree").CallExpression} */ const callExpr = node.parent; @@ -48,20 +64,59 @@ module.exports = { if (!checker.isArrayType(type) && !checker.isTupleType(type)) { return; } + const arrayName = node.property.name === "all" ? "every" : "some"; context.report({ node: callExpr, - messageId: "useArrayMap", + messageId: "useArrayMethod", + data: { + arrayName, + ramdaName: node.property.name, + }, fix(fixer) { return fixer.replaceText( callExpr, // Add parentheses indiscriminately, leave it to prettier to clean up - `(${sourceCode.getText(callExpr.arguments[1])}).map(${sourceCode.getText( + `(${sourceCode.getText(callExpr.arguments[1])}).${arrayName}(${sourceCode.getText( callExpr.arguments[0], )})`, ); }, }); }, + + /** + * Transform `R.map/find(fn, array)` to `array.map/find(fn)` + */ + [`CallExpression[arguments.length=2] > MemberExpression.callee[object.name="R"]:matches([property.name="map"], [property.name="find"])`]: + (/** @type {import("estree").MemberExpression} */ node) => { + /** @type {import("estree").CallExpression} */ + const callExpr = node.parent; + const { esTreeNodeToTSNodeMap, program } = ESLintUtils.getParserServices(context); + const sourceCode = context.getSourceCode(); + const checker = program.getTypeChecker(); + const tsNode = esTreeNodeToTSNodeMap.get(callExpr.arguments[1]); + const type = checker.getTypeAtLocation(tsNode); + if (!checker.isArrayType(type) && !checker.isTupleType(type)) { + return; + } + context.report({ + node: callExpr, + messageId: "useArrayMethod", + data: { + arrayName: node.property.name, + ramdaName: node.property.name, + }, + fix(fixer) { + return fixer.replaceText( + callExpr, + // Add parentheses indiscriminately, leave it to prettier to clean up + `(${sourceCode.getText(callExpr.arguments[1])}).${ + node.property.name + }(${sourceCode.getText(callExpr.arguments[0])})`, + ); + }, + }); + }, }; }, }; diff --git a/packages/eslint-plugin-studio/ramda-usage.test.ts b/packages/eslint-plugin-studio/ramda-usage.test.ts index 68ad3f4247..edfc5ef59e 100644 --- a/packages/eslint-plugin-studio/ramda-usage.test.ts +++ b/packages/eslint-plugin-studio/ramda-usage.test.ts @@ -7,7 +7,9 @@ import { TSESLint } from "@typescript-eslint/utils"; import path from "path"; // eslint-disable-next-line @typescript-eslint/no-var-requires -const rule = require("./ramda-usage") as TSESLint.RuleModule<"useMath" | "useArrayMap">; +const rule = require("./ramda-usage") as TSESLint.RuleModule< + "useMath" | "useObject" | "useArrayMethod" +>; const ruleTester = new RuleTester({ parser: "@typescript-eslint/parser", @@ -24,8 +26,12 @@ ruleTester.run("ramda-usage", rule, { import * as R from "ramda"; R.max(1)(2); R.max; + R.keys; + R.values; R.map((x) => x + 1); R.map((x) => x + 1, {a: 1, b: 2}); + R.all((x) => x === 1); + R.any((x) => x === 1); `, ], @@ -47,6 +53,23 @@ ruleTester.run("ramda-usage", rule, { `, }, + { + code: /* ts */ ` + import * as R from "ramda"; + R.keys({a: 1, b: 2}); + R.values({a: 1, b: 2}); + `, + errors: [ + { messageId: "useObject", data: { name: "keys" }, line: 3 }, + { messageId: "useObject", data: { name: "values" }, line: 4 }, + ], + output: /* ts */ ` + import * as R from "ramda"; + Object.keys({a: 1, b: 2}); + Object.values({a: 1, b: 2}); + `, + }, + { code: /* ts */ ` import * as R from "ramda"; @@ -56,10 +79,10 @@ ruleTester.run("ramda-usage", rule, { foo("bar", R.map((x) => x + 1, [1])); `, errors: [ - { messageId: "useArrayMap", line: 3 }, - { messageId: "useArrayMap", line: 4 }, - { messageId: "useArrayMap", line: 5 }, - { messageId: "useArrayMap", line: 6 }, + { messageId: "useArrayMethod", line: 3, data: { arrayName: "map", ramdaName: "map" } }, + { messageId: "useArrayMethod", line: 4, data: { arrayName: "map", ramdaName: "map" } }, + { messageId: "useArrayMethod", line: 5, data: { arrayName: "map", ramdaName: "map" } }, + { messageId: "useArrayMethod", line: 6, data: { arrayName: "map", ramdaName: "map" } }, ], output: /* ts */ ` import * as R from "ramda"; @@ -69,5 +92,63 @@ ruleTester.run("ramda-usage", rule, { foo("bar", ([1]).map((x) => x + 1)); `, }, + + { + code: /* ts */ ` + import * as R from "ramda"; + R.find((x) => x === 1, [1, 2]); + R.find((x) => x === 1, [1, 2].reverse(/*hi*/)); + R.find((x) => x === 1, [1] as const); + foo("bar", R.find((x) => x === 1, [1])); + `, + errors: [ + { messageId: "useArrayMethod", line: 3, data: { arrayName: "find", ramdaName: "find" } }, + { messageId: "useArrayMethod", line: 4, data: { arrayName: "find", ramdaName: "find" } }, + { messageId: "useArrayMethod", line: 5, data: { arrayName: "find", ramdaName: "find" } }, + { messageId: "useArrayMethod", line: 6, data: { arrayName: "find", ramdaName: "find" } }, + ], + output: /* ts */ ` + import * as R from "ramda"; + ([1, 2]).find((x) => x === 1); + ([1, 2].reverse(/*hi*/)).find((x) => x === 1); + ([1] as const).find((x) => x === 1); + foo("bar", ([1]).find((x) => x === 1)); + `, + }, + + { + code: /* ts */ ` + import * as R from "ramda"; + R.all((x) => x === 1, [1, 2]); + R.all((x) => x === 1, [1, 2].reverse(/*hi*/)); + R.all((x) => x === 1, [1] as const); + R.any((x) => x === 1, [1, 2]); + R.any((x) => x === 1, [1, 2].reverse(/*hi*/)); + R.any((x) => x === 1, [1] as const); + foo("bar", R.all((x) => x === 1, [1])); + foo("bar", R.any((x) => x === 1, [1])); + `, + errors: [ + { messageId: "useArrayMethod", line: 3, data: { ramdaName: "all", arrayName: "every" } }, + { messageId: "useArrayMethod", line: 4, data: { ramdaName: "all", arrayName: "every" } }, + { messageId: "useArrayMethod", line: 5, data: { ramdaName: "all", arrayName: "every" } }, + { messageId: "useArrayMethod", line: 6, data: { ramdaName: "any", arrayName: "some" } }, + { messageId: "useArrayMethod", line: 7, data: { ramdaName: "any", arrayName: "some" } }, + { messageId: "useArrayMethod", line: 8, data: { ramdaName: "any", arrayName: "some" } }, + { messageId: "useArrayMethod", line: 9, data: { ramdaName: "all", arrayName: "every" } }, + { messageId: "useArrayMethod", line: 10, data: { ramdaName: "any", arrayName: "some" } }, + ], + output: /* ts */ ` + import * as R from "ramda"; + ([1, 2]).every((x) => x === 1); + ([1, 2].reverse(/*hi*/)).every((x) => x === 1); + ([1] as const).every((x) => x === 1); + ([1, 2]).some((x) => x === 1); + ([1, 2].reverse(/*hi*/)).some((x) => x === 1); + ([1] as const).some((x) => x === 1); + foo("bar", ([1]).every((x) => x === 1)); + foo("bar", ([1]).some((x) => x === 1)); + `, + }, ], }); diff --git a/packages/hooks/package.json b/packages/hooks/package.json index edc83d8d94..4ee690ff00 100644 --- a/packages/hooks/package.json +++ b/packages/hooks/package.json @@ -21,10 +21,10 @@ }, "devDependencies": { "@foxglove/tsconfig": "2.0.0", - "@testing-library/react": "14.0.0", + "@testing-library/react": "14.2.1", "@types/foxglove__web": "workspace:*", "@types/lodash-es": "^4", - "typescript": "5.2.2" + "typescript": "5.3.3" }, "dependencies": { "@foxglove/log": "workspace:*", diff --git a/packages/log/package.json b/packages/log/package.json index 390589ee33..e283836701 100644 --- a/packages/log/package.json +++ b/packages/log/package.json @@ -21,6 +21,6 @@ }, "devDependencies": { "@foxglove/tsconfig": "2.0.0", - "typescript": "5.2.2" + "typescript": "5.3.3" } } diff --git a/packages/mcap-support/package.json b/packages/mcap-support/package.json index d70c977881..293ba0bf0b 100644 --- a/packages/mcap-support/package.json +++ b/packages/mcap-support/package.json @@ -23,21 +23,21 @@ "devDependencies": { "@foxglove/tsconfig": "2.0.0", "@types/protobufjs": "workspace:*", - "typescript": "5.2.2" + "typescript": "5.3.3" }, "dependencies": { - "@foxglove/message-definition": "0.3.0", - "@foxglove/omgidl-parser": "1.0.1", - "@foxglove/omgidl-serialization": "1.0.1", - "@foxglove/ros2idl-parser": "0.3.1", + "@foxglove/message-definition": "0.3.1", + "@foxglove/omgidl-parser": "1.0.3", + "@foxglove/omgidl-serialization": "1.0.4", + "@foxglove/ros2idl-parser": "0.3.2", "@foxglove/rosmsg": "4.2.2", - "@foxglove/rosmsg-serialization": "2.0.2", - "@foxglove/rosmsg2-serialization": "2.0.2", - "@foxglove/schemas": "1.6.0", + "@foxglove/rosmsg-serialization": "2.0.3", + "@foxglove/rosmsg2-serialization": "2.0.3", + "@foxglove/schemas": "1.6.2", "@foxglove/wasm-bz2": "0.1.1", "@foxglove/wasm-lz4": "1.0.2", "@foxglove/wasm-zstd": "1.0.1", - "@mcap/core": "1.3.0", + "@mcap/core": "2.0.2", "@protobufjs/base64": "1.1.2", "flatbuffers": "23.5.26", "flatbuffers_reflection": "0.0.7", diff --git a/packages/mcap-support/src/parseChannel.ts b/packages/mcap-support/src/parseChannel.ts index e58c9c7422..ff637339fd 100644 --- a/packages/mcap-support/src/parseChannel.ts +++ b/packages/mcap-support/src/parseChannel.ts @@ -25,6 +25,8 @@ export type ParsedChannel = { datatypes: MessageDefinitionMap; }; +const KNOWN_EMPTY_SCHEMA_NAMES = ["std_msgs/Empty", "std_msgs/msg/Empty"]; + function parseIDLDefinitionsToDatatypes( parsedDefinitions: IDLMessageDefinition[], rootName?: string, @@ -72,11 +74,28 @@ function parsedDefinitionsToDatatypes( * Process a channel/schema and extract information that can be used to deserialize messages on the * channel, and schemas in the format expected by Studio's RosDatatypes. * + * Empty ROS schemas (except std_msgs/[msg/]Empty) are treated as errors. If you want to allow empty + * schemas then use the `allowEmptySchema` option. + * * See: * - https://github.com/foxglove/mcap/blob/main/docs/specification/well-known-message-encodings.md * - https://github.com/foxglove/mcap/blob/main/docs/specification/well-known-schema-encodings.md */ -export function parseChannel(channel: Channel): ParsedChannel { +export function parseChannel( + channel: Channel, + options?: { allowEmptySchema: boolean }, +): ParsedChannel { + // For ROS schemas, we expect the schema to be non-empty unless the + // schema name is one of the well-known empty schema names. + if ( + options?.allowEmptySchema !== true && + ["ros1msg", "ros2msg", "ros2idl"].includes(channel.schema?.encoding ?? "") && + channel.schema?.data.length === 0 && + !KNOWN_EMPTY_SCHEMA_NAMES.includes(channel.schema.name) + ) { + throw new Error(`Schema for ${channel.schema.name} is empty`); + } + if (channel.messageEncoding === "json") { if (channel.schema != undefined && channel.schema.encoding !== "jsonschema") { throw new Error( diff --git a/packages/mcap-support/src/parseFlatbufferSchema.ts b/packages/mcap-support/src/parseFlatbufferSchema.ts index 9a54ede08a..aaae23bd2f 100644 --- a/packages/mcap-support/src/parseFlatbufferSchema.ts +++ b/packages/mcap-support/src/parseFlatbufferSchema.ts @@ -151,9 +151,6 @@ function typeForField(schema: SchemaT, field: FieldT): MessageDefinitionField[] /** * Parse a flatbuffer binary schema and produce datatypes and a deserializer function. - * - * Note: Currently this does not support "lazy" message reading in the style of the ros1 message - * reader, and so will relatively inefficiently deserialize the entire flatbuffer message. */ export function parseFlatbufferSchema( schemaName: string, diff --git a/packages/mcap-support/src/parseJsonSchema.test.ts b/packages/mcap-support/src/parseJsonSchema.test.ts index ca1952f7bc..0164bbb691 100644 --- a/packages/mcap-support/src/parseJsonSchema.test.ts +++ b/packages/mcap-support/src/parseJsonSchema.test.ts @@ -285,4 +285,18 @@ describe("parseJsonSchema", () => { it.each(Object.values(foxgloveMessageSchemas))("handles Foxglove schema '$name'", (schema) => { expect(() => parseJsonSchema(generateJsonSchema(schema), schema.name)).not.toThrow(); }); + + it("tolerates an 'any object' schema", () => { + const { datatypes } = parseJsonSchema({ type: "object" }, "Any object"); + expect(datatypes).toEqual( + new Map([ + [ + "Any object", + { + definitions: [], + }, + ], + ]), + ); + }); }); diff --git a/packages/mcap-support/src/parseJsonSchema.ts b/packages/mcap-support/src/parseJsonSchema.ts index 2e1e78ea36..a79375a6ad 100644 --- a/packages/mcap-support/src/parseJsonSchema.ts +++ b/packages/mcap-support/src/parseJsonSchema.ts @@ -37,9 +37,8 @@ export function parseJsonSchema( `Expected "type": "object" for schema ${typeName}, got ${JSON.stringify(schema.type)}`, ); } - for (const [fieldName, fieldSchema] of Object.entries( - schema.properties as Record>, - )) { + const properties = (schema.properties ?? {}) as Record>; + for (const [fieldName, fieldSchema] of Object.entries(properties)) { if (Array.isArray(fieldSchema.oneOf)) { if (fieldSchema.oneOf.every((alternative) => typeof alternative.const === "number")) { for (const alternative of fieldSchema.oneOf) { diff --git a/packages/message-path/package.json b/packages/message-path/package.json new file mode 100644 index 0000000000..6a9f59b150 --- /dev/null +++ b/packages/message-path/package.json @@ -0,0 +1,30 @@ +{ + "name": "@foxglove/message-path", + "license": "MPL-2.0", + "private": true, + "repository": { + "type": "git", + "url": "https://github.com/foxglove/studio.git" + }, + "author": { + "name": "Foxglove Technologies", + "email": "support@foxglove.dev" + }, + "homepage": "https://foxglove.dev/", + "main": "./src/index.ts", + "files": [ + "dist", + "src" + ], + "scripts": { + "prepack": "tsc -b" + }, + "dependencies": { + "nearley": "2.20.1" + }, + "devDependencies": { + "@foxglove/tsconfig": "2.0.0", + "@types/nearley": "2.11.5", + "typescript": "5.3.3" + } +} diff --git a/packages/studio-base/src/components/MessagePathSyntax/grammar.ne b/packages/message-path/src/grammar.ne similarity index 100% rename from packages/studio-base/src/components/MessagePathSyntax/grammar.ne rename to packages/message-path/src/grammar.ne diff --git a/packages/message-path/src/index.ts b/packages/message-path/src/index.ts new file mode 100644 index 0000000000..7cf53ee82e --- /dev/null +++ b/packages/message-path/src/index.ts @@ -0,0 +1,6 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/ + +export * from "./parseMessagePath"; +export * from "./types"; diff --git a/packages/studio-base/src/components/MessagePathSyntax/parseRosPath.test.ts b/packages/message-path/src/parseMessagePath.test.ts similarity index 80% rename from packages/studio-base/src/components/MessagePathSyntax/parseRosPath.test.ts rename to packages/message-path/src/parseMessagePath.test.ts index 8fc5163eaa..437a7a117b 100644 --- a/packages/studio-base/src/components/MessagePathSyntax/parseRosPath.test.ts +++ b/packages/message-path/src/parseMessagePath.test.ts @@ -11,7 +11,7 @@ // found at http://www.apache.org/licenses/LICENSE-2.0 // You may not use this file except in compliance with the License. -import parseRosPath from "./parseRosPath"; +import { parseMessagePath } from "./parseMessagePath"; // Nearley parser returns nulls // eslint-disable-next-line no-restricted-syntax @@ -19,7 +19,7 @@ const MISSING = null; describe("parseRosPath", () => { it("parses valid strings", () => { - expect(parseRosPath("/some0/nice_topic.with[99].stuff[0]")).toEqual({ + expect(parseMessagePath("/some0/nice_topic.with[99].stuff[0]")).toEqual({ topicName: "/some0/nice_topic", topicNameRepr: "/some0/nice_topic", messagePath: [ @@ -30,7 +30,7 @@ describe("parseRosPath", () => { ], modifier: MISSING, }); - expect(parseRosPath("/some0/nice_topic.with[99].stuff[0].@derivative")).toEqual({ + expect(parseMessagePath("/some0/nice_topic.with[99].stuff[0].@derivative")).toEqual({ topicName: "/some0/nice_topic", topicNameRepr: "/some0/nice_topic", messagePath: [ @@ -41,7 +41,7 @@ describe("parseRosPath", () => { ], modifier: "derivative", }); - expect(parseRosPath("some0/nice_topic.with[99].stuff[0]")).toEqual({ + expect(parseMessagePath("some0/nice_topic.with[99].stuff[0]")).toEqual({ topicName: "some0/nice_topic", topicNameRepr: "some0/nice_topic", messagePath: [ @@ -52,7 +52,7 @@ describe("parseRosPath", () => { ], modifier: MISSING, }); - expect(parseRosPath("some_nice_topic")).toEqual({ + expect(parseMessagePath("some_nice_topic")).toEqual({ topicName: "some_nice_topic", topicNameRepr: "some_nice_topic", messagePath: [], @@ -61,37 +61,37 @@ describe("parseRosPath", () => { }); it("parses quoted topic and field names with escapes", () => { - expect(parseRosPath(String.raw`"/foo/bar".baz`)).toEqual({ + expect(parseMessagePath(String.raw`"/foo/bar".baz`)).toEqual({ topicName: "/foo/bar", topicNameRepr: String.raw`"/foo/bar"`, messagePath: [{ type: "name", name: "baz", repr: "baz" }], modifier: MISSING, }); - expect(parseRosPath(String.raw`"\"/foo/bar\"".baz`)).toEqual({ + expect(parseMessagePath(String.raw`"\"/foo/bar\"".baz`)).toEqual({ topicName: `"/foo/bar"`, topicNameRepr: String.raw`"\"/foo/bar\""`, messagePath: [{ type: "name", name: "baz", repr: "baz" }], modifier: MISSING, }); - expect(parseRosPath(String.raw`"\"".baz`)).toEqual({ + expect(parseMessagePath(String.raw`"\"".baz`)).toEqual({ topicName: `"`, topicNameRepr: String.raw`"\""`, messagePath: [{ type: "name", name: "baz", repr: "baz" }], modifier: MISSING, }); - expect(parseRosPath(String.raw`"\\".baz`)).toEqual({ + expect(parseMessagePath(String.raw`"\\".baz`)).toEqual({ topicName: "\\", topicNameRepr: String.raw`"\\"`, messagePath: [{ type: "name", name: "baz", repr: "baz" }], modifier: MISSING, }); - expect(parseRosPath(String.raw`"\\a".baz`)).toEqual({ + expect(parseMessagePath(String.raw`"\\a".baz`)).toEqual({ topicName: "\\a", topicNameRepr: String.raw`"\\a"`, messagePath: [{ type: "name", name: "baz", repr: "baz" }], modifier: MISSING, }); - expect(parseRosPath(String.raw`/foo."/foo/bar".baz`)).toEqual({ + expect(parseMessagePath(String.raw`/foo."/foo/bar".baz`)).toEqual({ topicName: "/foo", topicNameRepr: "/foo", messagePath: [ @@ -100,7 +100,7 @@ describe("parseRosPath", () => { ], modifier: MISSING, }); - expect(parseRosPath(String.raw`/foo."\"/foo/bar\"".baz`)).toEqual({ + expect(parseMessagePath(String.raw`/foo."\"/foo/bar\"".baz`)).toEqual({ topicName: "/foo", topicNameRepr: "/foo", messagePath: [ @@ -109,7 +109,7 @@ describe("parseRosPath", () => { ], modifier: MISSING, }); - expect(parseRosPath(String.raw`/foo."\"".baz`)).toEqual({ + expect(parseMessagePath(String.raw`/foo."\"".baz`)).toEqual({ topicName: "/foo", topicNameRepr: "/foo", messagePath: [ @@ -118,7 +118,7 @@ describe("parseRosPath", () => { ], modifier: MISSING, }); - expect(parseRosPath(String.raw`/foo."\\".baz`)).toEqual({ + expect(parseMessagePath(String.raw`/foo."\\".baz`)).toEqual({ topicName: "/foo", topicNameRepr: "/foo", messagePath: [ @@ -127,7 +127,7 @@ describe("parseRosPath", () => { ], modifier: MISSING, }); - expect(parseRosPath(String.raw`/foo."\\a".baz`)).toEqual({ + expect(parseMessagePath(String.raw`/foo."\\a".baz`)).toEqual({ topicName: "/foo", topicNameRepr: "/foo", messagePath: [ @@ -136,18 +136,18 @@ describe("parseRosPath", () => { ], modifier: MISSING, }); - expect(parseRosPath(String.raw`""".baz`)).toBeUndefined(); - expect(parseRosPath(String.raw`"\a".baz`)).toBeUndefined(); - expect(parseRosPath(String.raw`"\".baz`)).toBeUndefined(); - expect(parseRosPath(String.raw`"x.baz`)).toBeUndefined(); - expect(parseRosPath(String.raw`/foo.""".baz`)).toBeUndefined(); - expect(parseRosPath(String.raw`/foo."\a".baz`)).toBeUndefined(); - expect(parseRosPath(String.raw`/foo."\".baz`)).toBeUndefined(); - expect(parseRosPath(String.raw`/foo."x.baz`)).toBeUndefined(); + expect(parseMessagePath(String.raw`""".baz`)).toBeUndefined(); + expect(parseMessagePath(String.raw`"\a".baz`)).toBeUndefined(); + expect(parseMessagePath(String.raw`"\".baz`)).toBeUndefined(); + expect(parseMessagePath(String.raw`"x.baz`)).toBeUndefined(); + expect(parseMessagePath(String.raw`/foo.""".baz`)).toBeUndefined(); + expect(parseMessagePath(String.raw`/foo."\a".baz`)).toBeUndefined(); + expect(parseMessagePath(String.raw`/foo."\".baz`)).toBeUndefined(); + expect(parseMessagePath(String.raw`/foo."x.baz`)).toBeUndefined(); }); it("parses slices", () => { - expect(parseRosPath("/topic.foo[0].bar")).toEqual({ + expect(parseMessagePath("/topic.foo[0].bar")).toEqual({ topicName: "/topic", topicNameRepr: "/topic", messagePath: [ @@ -157,7 +157,7 @@ describe("parseRosPath", () => { ], modifier: MISSING, }); - expect(parseRosPath("/topic.foo[1:3].bar")).toEqual({ + expect(parseMessagePath("/topic.foo[1:3].bar")).toEqual({ topicName: "/topic", topicNameRepr: "/topic", messagePath: [ @@ -167,7 +167,7 @@ describe("parseRosPath", () => { ], modifier: MISSING, }); - expect(parseRosPath("/topic.foo[1:].bar")).toEqual({ + expect(parseMessagePath("/topic.foo[1:].bar")).toEqual({ topicName: "/topic", topicNameRepr: "/topic", messagePath: [ @@ -177,7 +177,7 @@ describe("parseRosPath", () => { ], modifier: MISSING, }); - expect(parseRosPath("/topic.foo[:10].bar")).toEqual({ + expect(parseMessagePath("/topic.foo[:10].bar")).toEqual({ topicName: "/topic", topicNameRepr: "/topic", messagePath: [ @@ -187,7 +187,7 @@ describe("parseRosPath", () => { ], modifier: MISSING, }); - expect(parseRosPath("/topic.foo[:].bar")).toEqual({ + expect(parseMessagePath("/topic.foo[:].bar")).toEqual({ topicName: "/topic", topicNameRepr: "/topic", messagePath: [ @@ -197,7 +197,7 @@ describe("parseRosPath", () => { ], modifier: MISSING, }); - expect(parseRosPath("/topic.foo[$a].bar")).toEqual({ + expect(parseMessagePath("/topic.foo[$a].bar")).toEqual({ topicName: "/topic", topicNameRepr: "/topic", messagePath: [ @@ -211,7 +211,7 @@ describe("parseRosPath", () => { ], modifier: MISSING, }); - expect(parseRosPath("/topic.foo[$a:$b].bar")).toEqual({ + expect(parseMessagePath("/topic.foo[$a:$b].bar")).toEqual({ topicName: "/topic", topicNameRepr: "/topic", messagePath: [ @@ -225,7 +225,7 @@ describe("parseRosPath", () => { ], modifier: MISSING, }); - expect(parseRosPath("/topic.foo[$a:].bar")).toEqual({ + expect(parseMessagePath("/topic.foo[$a:].bar")).toEqual({ topicName: "/topic", topicNameRepr: "/topic", messagePath: [ @@ -239,7 +239,7 @@ describe("parseRosPath", () => { ], modifier: MISSING, }); - expect(parseRosPath("/topic.foo[$a:5].bar")).toEqual({ + expect(parseMessagePath("/topic.foo[$a:5].bar")).toEqual({ topicName: "/topic", topicNameRepr: "/topic", messagePath: [ @@ -249,7 +249,7 @@ describe("parseRosPath", () => { ], modifier: MISSING, }); - expect(parseRosPath("/topic.foo[:$b].bar")).toEqual({ + expect(parseMessagePath("/topic.foo[:$b].bar")).toEqual({ topicName: "/topic", topicNameRepr: "/topic", messagePath: [ @@ -259,7 +259,7 @@ describe("parseRosPath", () => { ], modifier: MISSING, }); - expect(parseRosPath("/topic.foo[2:$b].bar")).toEqual({ + expect(parseMessagePath("/topic.foo[2:$b].bar")).toEqual({ topicName: "/topic", topicNameRepr: "/topic", messagePath: [ @@ -273,7 +273,7 @@ describe("parseRosPath", () => { it("parses filters", () => { expect( - parseRosPath( + parseMessagePath( "/topic.foo{bar=='baz'}.a{bar==\"baz\"}.b{bar==3}.c{bar==-1}.d{bar==false}.e[:]{bar.baz==true}", ), ).toEqual({ @@ -344,7 +344,7 @@ describe("parseRosPath", () => { }); it("parses filters on top level topic", () => { - expect(parseRosPath("/topic{foo=='bar'}{baz==2}.a[3].b{x=='y'}")).toEqual({ + expect(parseMessagePath("/topic{foo=='bar'}{baz==2}.a[3].b{x=='y'}")).toEqual({ topicName: "/topic", topicNameRepr: "/topic", messagePath: [ @@ -381,7 +381,7 @@ describe("parseRosPath", () => { }); it("parses filters with global variables", () => { - expect(parseRosPath("/topic.foo{bar==$}.a{bar==$my_var_1}")).toEqual({ + expect(parseMessagePath("/topic.foo{bar==$}.a{bar==$my_var_1}")).toEqual({ topicName: "/topic", topicNameRepr: "/topic", messagePath: [ @@ -409,19 +409,19 @@ describe("parseRosPath", () => { }); it("parses unfinished strings", () => { - expect(parseRosPath("/")).toEqual({ + expect(parseMessagePath("/")).toEqual({ topicName: "/", topicNameRepr: "/", messagePath: [], modifier: MISSING, }); - expect(parseRosPath("/topic.")).toEqual({ + expect(parseMessagePath("/topic.")).toEqual({ topicName: "/topic", topicNameRepr: "/topic", messagePath: [{ type: "name", name: "", repr: "" }], modifier: MISSING, }); - expect(parseRosPath("/topic.hi.")).toEqual({ + expect(parseMessagePath("/topic.hi.")).toEqual({ topicName: "/topic", topicNameRepr: "/topic", messagePath: [ @@ -430,13 +430,13 @@ describe("parseRosPath", () => { ], modifier: MISSING, }); - expect(parseRosPath("/topic.hi.@")).toEqual({ + expect(parseMessagePath("/topic.hi.@")).toEqual({ topicName: "/topic", topicNameRepr: "/topic", messagePath: [{ type: "name", name: "hi", repr: "hi" }], modifier: "", }); - expect(parseRosPath("/topic.foo{}")).toEqual({ + expect(parseMessagePath("/topic.foo{}")).toEqual({ topicName: "/topic", topicNameRepr: "/topic", messagePath: [ @@ -452,7 +452,7 @@ describe("parseRosPath", () => { ], modifier: MISSING, }); - expect(parseRosPath("/topic.foo{bar}")).toEqual({ + expect(parseMessagePath("/topic.foo{bar}")).toEqual({ topicName: "/topic", topicNameRepr: "/topic", messagePath: [ @@ -468,7 +468,7 @@ describe("parseRosPath", () => { ], modifier: MISSING, }); - expect(parseRosPath("/topic.foo{==1}")).toEqual({ + expect(parseMessagePath("/topic.foo{==1}")).toEqual({ topicName: "/topic", topicNameRepr: "/topic", messagePath: [ @@ -484,7 +484,7 @@ describe("parseRosPath", () => { ], modifier: MISSING, }); - expect(parseRosPath("/topic.foo{==-3}")).toEqual({ + expect(parseMessagePath("/topic.foo{==-3}")).toEqual({ topicName: "/topic", topicNameRepr: "/topic", messagePath: [ @@ -503,18 +503,18 @@ describe("parseRosPath", () => { }); it("parses simple valid strings", () => { - expect(parseRosPath("blah")).toBeDefined(); - expect(parseRosPath("100")).toBeDefined(); - expect(parseRosPath("blah.blah")).toBeDefined(); + expect(parseMessagePath("blah")).toBeDefined(); + expect(parseMessagePath("100")).toBeDefined(); + expect(parseMessagePath("blah.blah")).toBeDefined(); }); it("returns undefined for invalid strings", () => { - expect(parseRosPath("[100]")).toBeUndefined(); - expect(parseRosPath("[-100]")).toBeUndefined(); - expect(parseRosPath("/topic.no.2d.arrays[0][1]")).toBeUndefined(); - expect(parseRosPath("/topic.foo[].bar")).toBeUndefined(); - expect(parseRosPath("/topic.foo[bar]")).toBeUndefined(); - expect(parseRosPath("/topic.foo{bar==}")).toBeUndefined(); - expect(parseRosPath("/topic.foo{bar==baz}")).toBeUndefined(); + expect(parseMessagePath("[100]")).toBeUndefined(); + expect(parseMessagePath("[-100]")).toBeUndefined(); + expect(parseMessagePath("/topic.no.2d.arrays[0][1]")).toBeUndefined(); + expect(parseMessagePath("/topic.foo[].bar")).toBeUndefined(); + expect(parseMessagePath("/topic.foo[bar]")).toBeUndefined(); + expect(parseMessagePath("/topic.foo{bar==}")).toBeUndefined(); + expect(parseMessagePath("/topic.foo{bar==baz}")).toBeUndefined(); }); }); diff --git a/packages/studio-base/src/components/MessagePathSyntax/parseRosPath.ts b/packages/message-path/src/parseMessagePath.ts similarity index 89% rename from packages/studio-base/src/components/MessagePathSyntax/parseRosPath.ts rename to packages/message-path/src/parseMessagePath.ts index dd635f41a8..1cedaef144 100644 --- a/packages/studio-base/src/components/MessagePathSyntax/parseRosPath.ts +++ b/packages/message-path/src/parseMessagePath.ts @@ -11,11 +11,10 @@ // found at http://www.apache.org/licenses/LICENSE-2.0 // You may not use this file except in compliance with the License. -import * as _ from "lodash-es"; import { Grammar, Parser } from "nearley"; -import { RosPath } from "./constants"; import grammar from "./grammar.ne"; +import { MessagePath } from "./types"; const grammarObj = Grammar.fromCompiled(grammar); @@ -37,7 +36,7 @@ export function quoteFieldNameIfNeeded(name: string): string { return `"${name.replace(/[\\"]/g, (char) => `\\${char}`)}"`; } -const parseRosPath = _.memoize((path: string): RosPath | undefined => { +const parseMessagePath = (path: string): MessagePath | undefined => { // Need to create a new Parser object for every new string to parse (should be cheap). const parser = new Parser(grammarObj); try { @@ -45,6 +44,6 @@ const parseRosPath = _.memoize((path: string): RosPath | undefined => { } catch (_err) { return undefined; } -}); +}; -export default parseRosPath; +export { parseMessagePath }; diff --git a/packages/studio-base/src/components/MessagePathSyntax/constants.ts b/packages/message-path/src/types.ts similarity index 83% rename from packages/studio-base/src/components/MessagePathSyntax/constants.ts rename to packages/message-path/src/types.ts index 9b9e3a15de..35d671c5d1 100644 --- a/packages/studio-base/src/components/MessagePathSyntax/constants.ts +++ b/packages/message-path/src/types.ts @@ -11,23 +11,19 @@ // found at http://www.apache.org/licenses/LICENSE-2.0 // You may not use this file except in compliance with the License. -const RosPrimitives = { - bool: undefined, - int8: undefined, - uint8: undefined, - int16: undefined, - uint16: undefined, - int32: undefined, - uint32: undefined, - int64: undefined, - uint64: undefined, - float32: undefined, - float64: undefined, - string: undefined, -}; - -export type RosPrimitive = keyof typeof RosPrimitives; -export const rosPrimitives = Object.keys(RosPrimitives) as RosPrimitive[]; +export type PrimitiveType = + | "bool" + | "int8" + | "uint8" + | "int16" + | "uint16" + | "int32" + | "uint32" + | "int64" + | "uint64" + | "float32" + | "float64" + | "string"; export type MessagePathFilter = { type: "filter"; @@ -57,7 +53,7 @@ export type MessagePathPart = } | MessagePathFilter; -export type RosPath = { +export type MessagePath = { /** Referenced topic name */ topicName: string; /** @@ -85,7 +81,7 @@ type MessagePathStructureItemArray = { }; type MessagePathStructureItemPrimitive = { structureType: "primitive"; - primitiveType: RosPrimitive; + primitiveType: PrimitiveType; datatype: string; }; export type MessagePathStructureItem = diff --git a/packages/message-path/src/typings/extensions.ts b/packages/message-path/src/typings/extensions.ts new file mode 100644 index 0000000000..e86912ab19 --- /dev/null +++ b/packages/message-path/src/typings/extensions.ts @@ -0,0 +1,10 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/ + +declare module "*.ne" { + import type { CompiledRules } from "nearley"; + + const compiledRules: CompiledRules; + export default compiledRules; +} diff --git a/packages/message-path/tsconfig.json b/packages/message-path/tsconfig.json new file mode 100644 index 0000000000..b716be0d06 --- /dev/null +++ b/packages/message-path/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@foxglove/tsconfig/base", + "include": ["./src/**/*"], + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist" + } +} diff --git a/packages/studio-base/.eslintrc.yaml b/packages/studio-base/.eslintrc.yaml index e7464582e7..59d97148d3 100644 --- a/packages/studio-base/.eslintrc.yaml +++ b/packages/studio-base/.eslintrc.yaml @@ -1,5 +1,5 @@ settings: - # UserNodePlayer uses `?raw` import syntax to invoke the webpack raw loader + # We use the `?raw` import syntax to invoke the webpack raw loader. # By default, eslint doesn't understand how to resolve this import and fails. The webpack # resolver helps eslint understand this import syntax. import/resolver: diff --git a/packages/studio-base/README.md b/packages/studio-base/README.md index 5e52db306e..e476452a59 100644 --- a/packages/studio-base/README.md +++ b/packages/studio-base/README.md @@ -15,4 +15,4 @@ For a full list of the package's exports, reference its [`index.ts` file](https: ## Stay in touch -Join us in [Slack](https://foxglove.dev/join-slack) to ask questions, share feedback, and stay up to date on what our team is working on. +Join us in [Slack](https://foxglove.dev/slack) to ask questions, share feedback, and stay up to date on what our team is working on. diff --git a/packages/studio-base/jest.config.json b/packages/studio-base/jest.config.json index 1ed76598cf..04ab9a0aa3 100644 --- a/packages/studio-base/jest.config.json +++ b/packages/studio-base/jest.config.json @@ -19,7 +19,6 @@ "moduleNameMapper": { "\\.svg$": "/src/test/mocks/MockSvg.tsx", "\\.css$": "/src/test/mocks/MockCss.ts", - "react-monaco-editor": "/src/test/stubs/MonacoEditor.tsx", "\\.(glb|md|png)$": "/src/test/mocks/fileMock.ts", "@foxglove/studio-base/(.*)": "/src/$1" }, diff --git a/packages/studio-base/package.json b/packages/studio-base/package.json index 509dff209f..ff60fd5eca 100644 --- a/packages/studio-base/package.json +++ b/packages/studio-base/package.json @@ -15,29 +15,28 @@ "main": "src/index", "devDependencies": { "@emotion/cache": "11.11.0", - "@emotion/react": "11.11.1", + "@emotion/react": "11.11.3", "@emotion/styled": "11.11.0", - "@fluentui/react-icons": "2.0.218", + "@fluentui/react-icons": "2.0.227", "@foxglove/avl": "1.0.0", "@foxglove/chartjs-plugin-zoom": "2.0.4", "@foxglove/comlink-transfer-handlers": "workspace:*", "@foxglove/crc": "0.0.3", "@foxglove/den": "workspace:*", - "@foxglove/electron-socket": "2.1.1", "@foxglove/hooks": "workspace:*", "@foxglove/log": "workspace:*", "@foxglove/mcap-support": "workspace:*", - "@foxglove/message-definition": "0.3.0", - "@foxglove/ros1": "2.0.0", + "@foxglove/message-definition": "0.3.1", + "@foxglove/message-path": "workspace:*", "@foxglove/rosbag": "0.4.0", "@foxglove/rosbag2-web": "4.1.1", "@foxglove/roslibjs": "0.0.3", "@foxglove/rosmsg": "4.2.2", "@foxglove/rosmsg-msgs-common": "3.1.0", - "@foxglove/rosmsg-serialization": "2.0.2", - "@foxglove/rosmsg2-serialization": "2.0.2", + "@foxglove/rosmsg-serialization": "2.0.3", + "@foxglove/rosmsg2-serialization": "2.0.3", "@foxglove/rostime": "1.1.2", - "@foxglove/schemas": "1.6.0", + "@foxglove/schemas": "1.6.2", "@foxglove/studio": "workspace:*", "@foxglove/theme": "workspace:*", "@foxglove/three-text": "0.2.2", @@ -47,82 +46,73 @@ "@foxglove/velodyne-cloud": "1.0.1", "@foxglove/wasm-bz2": "0.1.1", "@foxglove/wasm-lz4": "1.0.2", - "@foxglove/ws-protocol": "0.7.1", - "@foxglove/xmlrpc": "1.3.0", - "@mcap/core": "1.3.0", - "@mui/icons-material": "5.14.9", - "@mui/material": "5.14.10", - "@popperjs/core": "2.11.8", + "@foxglove/ws-protocol": "0.7.2", + "@mcap/core": "2.0.2", + "@mui/icons-material": "5.15.5", + "@mui/material": "5.15.6", + "@popperjs/core": "^2.11.8", "@protobufjs/base64": "1.1.2", - "@storybook/addon-actions": "7.4.5", - "@storybook/jest": "0.2.2", - "@storybook/react": "7.4.5", - "@storybook/testing-library": "0.2.1", + "@storybook/addon-actions": "7.6.15", + "@storybook/jest": "0.2.3", + "@storybook/react": "7.6.15", + "@storybook/testing-library": "0.2.2", "@svgr/webpack": "8.1.0", - "@tanstack/react-table": "8.10.3", - "@testing-library/react": "14.0.0", - "@types/base16": "^1.0.3", - "@types/cytoscape": "^3.19.11", + "@tanstack/react-table": "8.11.7", + "@testing-library/react": "14.2.1", + "@types/base16": "^1.0.5", + "@types/cytoscape": "^3.19.16", "@types/foxglove__roslibjs": "workspace:*", - "@types/foxglove__web": "workspace:*", - "@types/geojson": "7946.0.11", + "@types/geojson": "7946.0.14", "@types/gl-matrix": "3.2.0", - "@types/hammerjs": "2.0.42", - "@types/leaflet": "1.9.6", + "@types/hammerjs": "2.0.45", + "@types/leaflet": "1.9.7", "@types/memoize-weak": "workspace:*", - "@types/moment-duration-format": "2.2.3", - "@types/nearley": "2.11.2", - "@types/prettier": "3.0.0", - "@types/ramda": "0.29.4", + "@types/moment-duration-format": "2.2.6", + "@types/nearley": "2.11.5", + "@types/ramda": "0.29.10", "@types/react": "18.2.23", "@types/react-dom": "18.2.7", "@types/react-hover-observer": "workspace:*", - "@types/react-transition-group": "^4.4.6", - "@types/react-virtualized": "9.21.22", + "@types/react-transition-group": "^4.4.8", + "@types/react-virtualized": "9.21.29", "@types/react-window": "^1.8.5", - "@types/sanitize-html": "2.9.0", - "@types/seedrandom": "3.0.5", - "@types/shallowequal": "1.1.1", - "@types/string-hash": "1.1.1", + "@types/sanitize-html": "2.9.3", + "@types/seedrandom": "3.0.8", + "@types/shallowequal": "1.1.5", + "@types/string-hash": "1.1.3", "@types/text-metrics": "workspace:*", "@types/three": "0.156.0", - "@types/tinycolor2": "1.4.4", - "@types/use-sync-external-store": "0.0.4", - "@types/uuid": "9.0.4", + "@types/tinycolor2": "1.4.6", + "@types/uuid": "9.0.7", "@types/wicg-file-system-access": "2020.9.6", - "@uiw/react-textarea-code-editor": "2.1.9", - "async-mutex": "0.4.0", + "@uiw/react-textarea-code-editor": "3.0.2", "babel-jest": "29.7.0", "babel-plugin-transform-import-meta": "2.2.1", "browserify-zlib": "0.2.0", "chart.js": "4.4.0", "chartjs-plugin-annotation": "3.0.1", "chartjs-plugin-datalabels": "2.2.0", - "chromatic": "7.2.0", - "comlink": "4.4.1", + "chromatic": "10.6.1", + "comlink": "github:foxglove/comlink#9181fa505671b35b1e66e0a8361a6fc1bdd03307", "crypto-browserify": "3.12.0", - "css-loader": "6.8.1", - "cytoscape": "3.26.0", + "css-loader": "6.10.0", + "cytoscape": "3.28.1", "cytoscape-dagre": "2.5.0", "esbuild-loader": "2.21.0", "eventemitter3": "5.0.1", "fake-indexeddb": "4.0.2", - "fetch-mock": "9.11.0", - "fork-ts-checker-webpack-plugin": "8.0.0", + "fork-ts-checker-webpack-plugin": "9.0.2", "fuzzysort": "2.0.4", "fzf": "0.5.2", "geojson": "0.5.0", "gl-matrix": "3.4.3", "hammerjs": "2.0.8", - "i18next": "23.5.1", + "i18next": "23.8.2", "i18next-browser-languagedetector": "7.1.0", - "idb": "7.1.1", "idb-keyval": "6.2.1", - "immer": "10.0.2", + "immer": "10.0.3", "intervals-fn": "3.0.3", "jest-canvas-mock": "2.5.2", - "jest-diff": "29.7.0", - "jszip": "3.10.1", "leaflet": "1.9.4", "leaflet-ellipse": "0.9.1", "lodash-es": "4.17.21", @@ -130,58 +120,52 @@ "memoize-weak": "1.0.2", "meshoptimizer": "0.19.0", "moize": "6.1.6", - "moment": "2.29.4", + "moment": "2.30.1", "moment-duration-format": "2.3.2", - "moment-timezone": "0.5.43", - "monaco-editor": "0.40.0", - "monaco-editor-webpack-plugin": "7.1.0", + "moment-timezone": "0.5.45", "natsort": "2.0.3", "nearley": "2.20.1", "nearley-loader": "2.0.0", "notistack": "3.0.1", "path-browserify": "1.0.1", - "prettier": "3.0.3", - "ramda": "0.29.0", + "ramda": "0.29.1", "react": "18.2.0", "react-colorful": "5.6.1", "react-dnd": "16.0.1", "react-dnd-html5-backend": "16.0.1", "react-dom": "18.2.0", "react-hover-observer": "2.1.1", - "react-i18next": "13.2.2", + "react-i18next": "14.0.5", "react-json-tree": "patch:react-json-tree@npm:0.15.1#../../patches/react-json-tree.patch", "react-markdown": "8.0.7", - "react-monaco-editor": "0.54.0", "react-mosaic-component": "6.1.0", "react-refresh-typescript": "2.0.9", - "react-resizable-panels": "0.0.55", - "react-resize-detector": "9.1.0", + "react-resize-detector": "10.0.1", "react-transition-group": "4.4.5", - "react-use": "17.4.0", + "react-use": "17.4.2", "react-virtualized": "9.22.5", "react-window": "1.8.9", - "readable-stream": "4.4.2", - "rehype-raw": "6.1.1", - "reselect": "4.1.8", + "readable-stream": "4.5.2", + "remixicon": "4.3.0", + "reselect": "5.1.0", "sanitize-html": "2.11.0", "seedrandom": "3.0.5", "shallowequal": "1.1.0", "string-hash": "1.1.3", "string-replace-loader": "3.1.0", - "style-loader": "3.3.3", + "style-loader": "3.3.4", "text-metrics": "3.0.0", "three": "patch:three@0.156.1#../../patches/three.patch", "tinycolor2": "1.6.0", - "ts-essentials": "9.4.0", + "ts-essentials": "9.4.1", "ts-key-enum": "2.0.12", - "ts-loader": "9.4.4", - "tss-react": "4.9.2", - "typescript": "5.2.2", - "use-debounce": "9.0.4", + "ts-loader": "9.5.1", + "tss-react": "4.9.3", + "typescript": "5.3.3", + "use-debounce": "10.0.0", "use-immer": "0.9.0", - "use-sync-external-store": "1.2.0", "uuid": "9.0.1", - "webpack": "5.88.2", - "zustand": "4.4.1" + "webpack": "5.90.1", + "zustand": "4.5.0" } } diff --git a/packages/studio-base/src/App.tsx b/packages/studio-base/src/App.tsx deleted file mode 100644 index bcf0d82bc9..0000000000 --- a/packages/studio-base/src/App.tsx +++ /dev/null @@ -1,148 +0,0 @@ -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/ - -import { Fragment, Suspense, useEffect } from "react"; -import { DndProvider } from "react-dnd"; -import { HTML5Backend } from "react-dnd-html5-backend"; - -import GlobalCss from "@foxglove/studio-base/components/GlobalCss"; -import EventsProvider from "@foxglove/studio-base/providers/EventsProvider"; -import ProblemsContextProvider from "@foxglove/studio-base/providers/ProblemsContextProvider"; -import { StudioLogsSettingsProvider } from "@foxglove/studio-base/providers/StudioLogsSettingsProvider"; -import TimelineInteractionStateProvider from "@foxglove/studio-base/providers/TimelineInteractionStateProvider"; - -import Workspace from "./Workspace"; -import { CustomWindowControlsProps } from "./components/AppBar/CustomWindowControls"; -import { ColorSchemeThemeProvider } from "./components/ColorSchemeThemeProvider"; -import CssBaseline from "./components/CssBaseline"; -import DocumentTitleAdapter from "./components/DocumentTitleAdapter"; -import ErrorBoundary from "./components/ErrorBoundary"; -import MultiProvider from "./components/MultiProvider"; -import PlayerManager from "./components/PlayerManager"; -import SendNotificationToastAdapter from "./components/SendNotificationToastAdapter"; -import StudioToastProvider from "./components/StudioToastProvider"; -import AppConfigurationContext, { IAppConfiguration } from "./context/AppConfigurationContext"; -import NativeAppMenuContext, { INativeAppMenu } from "./context/NativeAppMenuContext"; -import NativeWindowContext, { INativeWindow } from "./context/NativeWindowContext"; -import { IDataSourceFactory } from "./context/PlayerSelectionContext"; -import { UserNodeStateProvider } from "./context/UserNodeStateContext"; -import CurrentLayoutProvider from "./providers/CurrentLayoutProvider"; -import ExtensionCatalogProvider from "./providers/ExtensionCatalogProvider"; -import ExtensionMarketplaceProvider from "./providers/ExtensionMarketplaceProvider"; -import PanelCatalogProvider from "./providers/PanelCatalogProvider"; -import { LaunchPreference } from "./screens/LaunchPreference"; -import { ExtensionLoader } from "./services/ExtensionLoader"; - -type AppProps = CustomWindowControlsProps & { - deepLinks: string[]; - appConfiguration: IAppConfiguration; - dataSources: IDataSourceFactory[]; - extensionLoaders: readonly ExtensionLoader[]; - nativeAppMenu?: INativeAppMenu; - nativeWindow?: INativeWindow; - enableLaunchPreferenceScreen?: boolean; - enableGlobalCss?: boolean; - appBarLeftInset?: number; - extraProviders?: JSX.Element[]; - onAppBarDoubleClick?: () => void; -}; - -// Suppress context menu for the entire app except on inputs & textareas. -function contextMenuHandler(event: MouseEvent) { - if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) { - return; - } - - event.preventDefault(); - return false; -} - -export function App(props: AppProps): JSX.Element { - const { - appConfiguration, - dataSources, - extensionLoaders, - nativeAppMenu, - nativeWindow, - deepLinks, - enableLaunchPreferenceScreen, - enableGlobalCss = false, - extraProviders, - } = props; - - const providers = [ - /* eslint-disable react/jsx-key */ - , - , - , - , - , - , - , - /* eslint-enable react/jsx-key */ - ]; - - if (nativeAppMenu) { - providers.push(); - } - - if (nativeWindow) { - providers.push(); - } - - if (extraProviders) { - providers.unshift(...extraProviders); - } - - // The toast and logs provider comes first so they are available to all downstream providers - providers.unshift(); - providers.unshift(); - - // Problems provider also must come before other, depdendent contexts. - providers.unshift(); - - const MaybeLaunchPreference = enableLaunchPreferenceScreen === true ? LaunchPreference : Fragment; - - useEffect(() => { - document.addEventListener("contextmenu", contextMenuHandler); - return () => { - document.removeEventListener("contextmenu", contextMenuHandler); - }; - }, []); - - return ( - - - {enableGlobalCss && } - - - - - - - - }> - - - - - - - - - - - - ); -} diff --git a/packages/studio-base/src/AppSetting.ts b/packages/studio-base/src/AppSetting.ts index 7616c61e5b..5c3dd1021a 100644 --- a/packages/studio-base/src/AppSetting.ts +++ b/packages/studio-base/src/AppSetting.ts @@ -14,10 +14,6 @@ export enum AppSetting { // ROS ROS_PACKAGE_PATH = "ros.ros_package_path", - // Privacy - TELEMETRY_ENABLED = "telemetry.telemetryEnabled", - CRASH_REPORTING_ENABLED = "telemetry.crashReportingEnabled", - // Experimental features SHOW_DEBUG_PANELS = "showDebugPanels", @@ -29,5 +25,4 @@ export enum AppSetting { // Dev only ENABLE_LAYOUT_DEBUGGING = "enableLayoutDebugging", - ENABLE_MEMORY_USE_INDICATOR = "dev.memory-use-indicator", } diff --git a/packages/studio-base/src/PanelAPI/useMessageReducer.test.tsx b/packages/studio-base/src/PanelAPI/useMessageReducer.test.tsx index fe6fe07085..12828a6556 100644 --- a/packages/studio-base/src/PanelAPI/useMessageReducer.test.tsx +++ b/packages/studio-base/src/PanelAPI/useMessageReducer.test.tsx @@ -25,10 +25,14 @@ import { Topic, MessageEvent, } from "@foxglove/studio-base/players/types"; +import MockCurrentLayoutProvider from "@foxglove/studio-base/providers/CurrentLayoutProvider/MockCurrentLayoutProvider"; import { makeMockAppConfiguration } from "@foxglove/studio-base/util/makeMockAppConfiguration"; import * as PanelAPI from "."; +// MockMessagePipeline initial restore call arguments from initial, then backfill seek after getting subscriptions for initial messages +const initialRestoreCallArguments = [[undefined], [undefined]]; + describe("useMessageReducer", () => { it("calls restore to initialize without messages", async () => { const addMessage = jest.fn(); @@ -47,7 +51,7 @@ describe("useMessageReducer", () => { }, ); - expect(restore.mock.calls).toEqual([[undefined]]); + expect(restore.mock.calls).toEqual(initialRestoreCallArguments); expect(addMessage.mock.calls).toEqual([]); expect(result.current).toEqual(1); }); @@ -96,7 +100,7 @@ describe("useMessageReducer", () => { }, ); - expect(restore.mock.calls).toEqual([[undefined]]); + expect(restore.mock.calls).toEqual(initialRestoreCallArguments); expect(addMessage.mock.calls).toEqual([[1, message]]); expect(result.current).toEqual(2); }); @@ -128,20 +132,20 @@ describe("useMessageReducer", () => { }, ); - expect(restore.mock.calls).toEqual([[undefined]]); + expect(restore.mock.calls).toEqual(initialRestoreCallArguments); expect(addMessages.mock.calls).toEqual([[1, [message]]]); expect(result.current).toEqual(2); }); it("calls addMessage for messages added later", async () => { - const message1: MessageEvent = { + const messageFoo: MessageEvent = { topic: "/foo", receiveTime: { sec: 0, nsec: 0 }, message: { value: 2 }, schemaName: "foo", sizeInBytes: 0, }; - const message2: MessageEvent = { + const messageBar: MessageEvent = { topic: "/bar", receiveTime: { sec: 0, nsec: 0 }, message: { value: 3 }, @@ -152,7 +156,7 @@ describe("useMessageReducer", () => { const restore = jest.fn().mockReturnValue(1); const addMessage = jest.fn().mockImplementation((_, msg) => msg.message.value); - let messages: (typeof message1)[] = []; + let messages: (typeof messageFoo)[] = []; const { result, rerender } = renderHook( ({ topics }) => PanelAPI.useMessageReducer({ @@ -168,27 +172,27 @@ describe("useMessageReducer", () => { }, ); - messages = [message1]; - rerender({ topics: ["/foo"] }); + messages = [messageFoo]; + rerender({ topics: ["/foo"] }); // subscriptions unchanged - expect(restore.mock.calls).toEqual([[undefined]]); - expect(addMessage.mock.calls).toEqual([[1, message1]]); + expect(restore.mock.calls).toEqual(initialRestoreCallArguments); + expect(addMessage.mock.calls).toEqual([[1, messageFoo]]); expect(result.current).toEqual(2); // Subscribe to a new topic, then receive a message on that topic rerender({ topics: ["/foo", "/bar"] }); - expect(restore.mock.calls).toEqual([[undefined]]); - expect(addMessage.mock.calls).toEqual([[1, message1]]); + expect(restore.mock.calls).toEqual(initialRestoreCallArguments); + expect(addMessage.mock.calls).toEqual([[1, messageFoo]]); expect(result.current).toEqual(2); - messages = [message2]; + messages = [messageBar]; rerender({ topics: ["/foo", "/bar"] }); - expect(restore.mock.calls).toEqual([[undefined]]); + expect(restore.mock.calls).toEqual(initialRestoreCallArguments); expect(addMessage.mock.calls).toEqual([ - [1, message1], - [2, message2], + [1, messageFoo], + [2, messageBar], ]); expect(result.current).toEqual(3); }); @@ -240,21 +244,21 @@ describe("useMessageReducer", () => { messages = [message1]; rerender({ topics: ["/foo"] }); - expect(restore.mock.calls).toEqual([[undefined]]); + expect(restore.mock.calls).toEqual(initialRestoreCallArguments); expect(addMessages.mock.calls).toEqual([[1, [message1]]]); expect(result.current).toEqual(2); // Subscribe to a new topic, then receive a message on that topic rerender({ topics: ["/foo", "/bar"] }); - expect(restore.mock.calls).toEqual([[undefined]]); + expect(restore.mock.calls).toEqual(initialRestoreCallArguments); expect(addMessages.mock.calls).toEqual([[1, [message1]]]); expect(result.current).toEqual(2); messages = [message2, message3]; rerender({ topics: ["/foo", "/bar"] }); - expect(restore.mock.calls).toEqual([[undefined]]); + expect(restore.mock.calls).toEqual(initialRestoreCallArguments); expect(addMessages.mock.calls).toEqual([ [1, [message1]], [2, [message2, message3]], @@ -334,7 +338,7 @@ describe("useMessageReducer", () => { messages = [message1]; rerender(); - expect(restore.mock.calls).toEqual([[undefined]]); + expect(restore.mock.calls).toEqual(initialRestoreCallArguments); expect(addMessage.mock.calls).toEqual([[1, message1]]); expect(result.current).toEqual(2); @@ -342,7 +346,7 @@ describe("useMessageReducer", () => { activeData = { lastSeekTime: 1 }; rerender(); - expect(restore.mock.calls).toEqual([[undefined], [undefined]]); + expect(restore.mock.calls).toEqual([...initialRestoreCallArguments, [undefined]]); expect(addMessage.mock.calls).toEqual([[1, message1]]); expect(result.current).toEqual(1); }); @@ -352,30 +356,30 @@ describe("useMessageReducer", () => { const [config] = useState(() => makeMockAppConfiguration()); return ( - - {children} - + + {children} + ); } return Wrapper; } - it("doesn't call addMessage when requested topics change player", async () => { + it("calls add message for messages from newly subscribed topic, given that topic has emitted messages previously", async () => { const restore = jest.fn(); const addMessage = jest.fn(); restore.mockReturnValue(0); addMessage.mockImplementation((_, msg) => msg.message.value); - const message1: MessageEvent = { + const messageFoo: MessageEvent = { topic: "/foo", receiveTime: { sec: 0, nsec: 0 }, message: { value: 1 }, schemaName: "foo", sizeInBytes: 0, }; - const message2: MessageEvent = { + const messageBar: MessageEvent = { topic: "/bar", receiveTime: { sec: 0, nsec: 0 }, message: { value: 2 }, @@ -410,11 +414,12 @@ describe("useMessageReducer", () => { () => void (promise = player.emit({ activeData: { - messages: [message1, message2], + messages: [messageFoo, messageBar], // foo message being emitted here currentTime: { sec: 0, nsec: 0 }, startTime: { sec: 0, nsec: 0 }, endTime: { sec: 1, nsec: 0 }, isPlaying: true, + repeatEnabled: false, speed: 0.2, lastSeekTime: 1234, topics: [ @@ -435,53 +440,18 @@ describe("useMessageReducer", () => { // restore call with undefined, then add message called with our subscribed message expect(restore.mock.calls).toEqual([[undefined], [undefined]]); - expect(addMessage.mock.calls).toEqual([[0, message2]]); + expect(addMessage.mock.calls).toEqual([[0, messageBar]]); expect(result.current).toEqual(2); rerender({ topics: ["/bar", "/foo"] }); - // no additional calls - expect(restore.mock.calls).toEqual([[undefined], [undefined]]); - expect(addMessage.mock.calls).toEqual([[0, message2]]); - // the same result is repeated - expect(result.current).toEqual(2); - - let promise2: Promise; - act( - () => - void (promise2 = player.emit({ - activeData: { - messages: [message1, message2], - currentTime: { sec: 0, nsec: 0 }, - startTime: { sec: 0, nsec: 0 }, - endTime: { sec: 1, nsec: 0 }, - isPlaying: true, - speed: 0.2, - lastSeekTime: 1234, - topics: [ - { name: "/foo", schemaName: "foo" }, - { name: "/bar", schemaName: "foo" }, - ], - topicStats: new Map(), - datatypes: new Map( - Object.entries({ foo: { definitions: [] }, bar: { definitions: [] } }), - ), - totalBytesReceived: 1234, - }, - })), - ); - await act(async () => { - await promise2; - }); - expect(restore.mock.calls).toEqual([[undefined], [undefined]]); expect(addMessage.mock.calls).toEqual([ - [0, message2], - [2, message1], - [1, message2], + [0, messageBar], + [2, messageFoo], ]); - // the same result is repeated - expect(result.current).toEqual(2); + // foo message after subscribing to that topic + expect(result.current).toEqual(1); }); it("doesn't re-render when player topics or other playerState changes", async () => { @@ -514,7 +484,7 @@ describe("useMessageReducer", () => { }, ); - expect(restore.mock.calls).toEqual([[undefined]]); + expect(restore.mock.calls).toEqual(initialRestoreCallArguments); expect(addMessage.mock.calls).toEqual([[1, message]]); expect(result.current).toEqual(2); @@ -523,7 +493,7 @@ describe("useMessageReducer", () => { capabilities = ["some_capability"]; rerender(); - expect(restore.mock.calls).toEqual([[undefined]]); + expect(restore.mock.calls).toEqual(initialRestoreCallArguments); expect(addMessage.mock.calls).toEqual([[1, message]]); expect(result.current).toEqual(2); }); @@ -581,9 +551,9 @@ describe("useMessageReducer", () => { }, ); - expect(restore.mock.calls).toEqual([[undefined]]); + expect(restore.mock.calls).toEqual(initialRestoreCallArguments); expect(result.current).toEqual(2); rerender({ addMessages: jest.fn() }); - expect(restore.mock.calls).toEqual([[undefined], [2]]); + expect(restore.mock.calls).toEqual([...initialRestoreCallArguments, [2]]); }); }); diff --git a/packages/studio-base/src/PanelAPI/useMessageReducer.ts b/packages/studio-base/src/PanelAPI/useMessageReducer.ts index 2a6ffcc054..7720c9fac1 100644 --- a/packages/studio-base/src/PanelAPI/useMessageReducer.ts +++ b/packages/studio-base/src/PanelAPI/useMessageReducer.ts @@ -30,18 +30,37 @@ import { const log = Log.getLogger(__filename); -type MessageReducer = (arg0: T, message: MessageEvent) => T; -type MessagesReducer = (arg0: T, messages: readonly MessageEvent[]) => T; +type MessageReducer = (state: T, message: MessageEvent) => T; +type MessagesReducer = (state: T, messages: readonly MessageEvent[]) => T; type Params = { + /** + * Topics to subscribe to. Can be a list of topic strings or `SubscribePayload` objects. + */ topics: readonly string[] | SubscribePayload[]; + /** + * Preload type to be used for topic string subscriptions. + * Has no effect on `SubscribePayload` topic subscriptions. + * @default "partial" + */ preloadType?: SubscriptionPreloadType; - // Functions called when the reducers change and for each newly received message. - // The object is assumed to be immutable, so in order to trigger a re-render, the reducers must - // return a new object. - restore: (arg: T | undefined) => T; + /** + * Called on intialization, seek, and when reducers change. + * @param state - Immutable. `undefined` when called on initialization or seek. Otherwise, the current state. + * @returns - New state. Must be new reference to trigger rerender. + */ + restore: (state: T | undefined) => T; + + /** + * Called for each new message with the current state (Immutable). + * Return new reference to trigger rerender. + */ addMessage?: MessageReducer; + /** + * Called for all new messages with the current state (Immutable). + * Return new reference to trigger rerender. + */ addMessages?: MessagesReducer; }; diff --git a/packages/studio-base/src/SharedRoot.tsx b/packages/studio-base/src/SharedRoot.tsx new file mode 100644 index 0000000000..6fc60bfb21 --- /dev/null +++ b/packages/studio-base/src/SharedRoot.tsx @@ -0,0 +1,57 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/ + +import GlobalCss from "@foxglove/studio-base/components/GlobalCss"; +import { + ISharedRootContext, + SharedRootContext, +} from "@foxglove/studio-base/context/SharedRootContext"; + +import { ColorSchemeThemeProvider } from "./components/ColorSchemeThemeProvider"; +import CssBaseline from "./components/CssBaseline"; +import ErrorBoundary from "./components/ErrorBoundary"; +import AppConfigurationContext from "./context/AppConfigurationContext"; + +export function SharedRoot(props: ISharedRootContext & { children: JSX.Element }): JSX.Element { + const { + appBarLeftInset, + appConfiguration, + onAppBarDoubleClick, + AppBarComponent, + children, + customWindowControlProps, + dataSources, + deepLinks, + enableGlobalCss = false, + enableLaunchPreferenceScreen, + extraProviders, + } = props; + + return ( + + + {enableGlobalCss && } + + + + {children} + + + + + + ); +} diff --git a/packages/studio-base/src/StudioApp.tsx b/packages/studio-base/src/StudioApp.tsx new file mode 100644 index 0000000000..413f9c4435 --- /dev/null +++ b/packages/studio-base/src/StudioApp.tsx @@ -0,0 +1,103 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/ + +import { Fragment, Suspense, useEffect } from "react"; +import { DndProvider } from "react-dnd"; +import { HTML5Backend } from "react-dnd-html5-backend"; + +import { useSharedRootContext } from "@foxglove/studio-base/context/SharedRootContext"; +import EventsProvider from "@foxglove/studio-base/providers/EventsProvider"; +import ProblemsContextProvider from "@foxglove/studio-base/providers/ProblemsContextProvider"; +import { StudioLogsSettingsProvider } from "@foxglove/studio-base/providers/StudioLogsSettingsProvider"; +import TimelineInteractionStateProvider from "@foxglove/studio-base/providers/TimelineInteractionStateProvider"; + +import Workspace from "./Workspace"; +import DocumentTitleAdapter from "./components/DocumentTitleAdapter"; +import MultiProvider from "./components/MultiProvider"; +import PlayerManager from "./components/PlayerManager"; +import SendNotificationToastAdapter from "./components/SendNotificationToastAdapter"; +import StudioToastProvider from "./components/StudioToastProvider"; +import CurrentLayoutProvider from "./providers/CurrentLayoutProvider"; +import PanelCatalogProvider from "./providers/PanelCatalogProvider"; +import { LaunchPreference } from "./screens/LaunchPreference"; + +// Suppress context menu for the entire app except on inputs & textareas. +function contextMenuHandler(event: MouseEvent) { + if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) { + return; + } + + event.preventDefault(); + return false; +} + +export function StudioApp(): JSX.Element { + const { + dataSources, + deepLinks, + enableLaunchPreferenceScreen, + extraProviders, + appBarLeftInset, + customWindowControlProps, + onAppBarDoubleClick, + AppBarComponent, + } = useSharedRootContext(); + + const providers = [ + /* eslint-disable react/jsx-key */ + , + , + , + , + /* eslint-enable react/jsx-key */ + ]; + + if (extraProviders) { + providers.unshift(...extraProviders); + } + + // The toast and logs provider comes first so they are available to all downstream providers + providers.unshift(); + providers.unshift(); + + // Problems provider also must come before other, depdendent contexts. + providers.unshift(); + + const MaybeLaunchPreference = enableLaunchPreferenceScreen === true ? LaunchPreference : Fragment; + + useEffect(() => { + document.addEventListener("contextmenu", contextMenuHandler); + return () => { + document.removeEventListener("contextmenu", contextMenuHandler); + }; + }, []); + + return ( + + + + + + }> + + + + + + + + ); +} diff --git a/packages/studio-base/src/Workspace.stories.tsx b/packages/studio-base/src/Workspace.stories.tsx index c73d9b1d77..8dfc41daa1 100644 --- a/packages/studio-base/src/Workspace.stories.tsx +++ b/packages/studio-base/src/Workspace.stories.tsx @@ -6,10 +6,10 @@ import { StoryObj } from "@storybook/react"; import { fireEvent, screen, waitFor } from "@storybook/testing-library"; import { useEffect, useState } from "react"; -import { DraggedMessagePath } from "@foxglove/studio"; import MultiProvider from "@foxglove/studio-base/components/MultiProvider"; import Panel from "@foxglove/studio-base/components/Panel"; import { usePanelContext } from "@foxglove/studio-base/components/PanelContext"; +import { DraggedMessagePath } from "@foxglove/studio-base/components/PanelExtensionAdapter"; import PanelToolbar from "@foxglove/studio-base/components/PanelToolbar"; import { LayoutData } from "@foxglove/studio-base/context/CurrentLayoutContext"; import PanelCatalogContext, { @@ -107,14 +107,14 @@ export const Basic: StoryObj<{ initialLayoutState: Partial }> = { }, render: (args) => { const fixture: Fixture = { - topics: [{ name: "foo", schemaName: "test.Foo" }], + topics: [{ name: "foo topic", schemaName: "test.Foo" }], datatypes: new Map([ [ "test.Foo", { definitions: [ - { name: "bar", type: "string" }, - { name: "baz", type: "string" }, + { name: "bar field", type: "string" }, + { name: "baz field", type: "string" }, ], }, ], @@ -236,12 +236,14 @@ export const DragMultipleItems: typeof Basic = { }); fireEvent.click( await screen.findByText( - (_content, element) => element instanceof HTMLSpanElement && element.textContent === ".bar", + (_content, element) => + element instanceof HTMLSpanElement && element.textContent === '."bar field"', ), ); fireEvent.click( await screen.findByText( - (_content, element) => element instanceof HTMLSpanElement && element.textContent === ".baz", + (_content, element) => + element instanceof HTMLSpanElement && element.textContent === '."baz field"', ), { metaKey: true }, ); diff --git a/packages/studio-base/src/Workspace.tsx b/packages/studio-base/src/Workspace.tsx index 943232c9ab..1e74021556 100644 --- a/packages/studio-base/src/Workspace.tsx +++ b/packages/studio-base/src/Workspace.tsx @@ -18,7 +18,7 @@ import { makeStyles } from "tss-react/mui"; import Logger from "@foxglove/log"; import { AppSetting } from "@foxglove/studio-base/AppSetting"; -import { AppBar } from "@foxglove/studio-base/components/AppBar"; +import { AppBarProps, AppBar } from "@foxglove/studio-base/components/AppBar"; import { CustomWindowControlsProps } from "@foxglove/studio-base/components/AppBar/CustomWindowControls"; import { DataSourceDialog, @@ -45,7 +45,7 @@ import { TopicList } from "@foxglove/studio-base/components/TopicList"; import VariablesList from "@foxglove/studio-base/components/VariablesList"; import { WorkspaceDialogs } from "@foxglove/studio-base/components/WorkspaceDialogs"; import { useAppContext } from "@foxglove/studio-base/context/AppContext"; -import { useCurrentUser } from "@foxglove/studio-base/context/CurrentUserContext"; +import { useCurrentUser } from "@foxglove/studio-base/context/BaseUserContext"; import { EventsStore, useEvents } from "@foxglove/studio-base/context/EventsContext"; import { useExtensionCatalog } from "@foxglove/studio-base/context/ExtensionCatalogContext"; import { usePlayerSelection } from "@foxglove/studio-base/context/PlayerSelectionContext"; @@ -58,7 +58,6 @@ import { import { useAppConfigurationValue } from "@foxglove/studio-base/hooks"; import { useDefaultWebLaunchPreference } from "@foxglove/studio-base/hooks/useDefaultWebLaunchPreference"; import useElectronFilesToOpen from "@foxglove/studio-base/hooks/useElectronFilesToOpen"; -import useNativeAppMenuEvent from "@foxglove/studio-base/hooks/useNativeAppMenuEvent"; import { PlayerPresence } from "@foxglove/studio-base/players/types"; import { PanelStateContextProvider } from "@foxglove/studio-base/providers/PanelStateContextProvider"; import WorkspaceContextProvider from "@foxglove/studio-base/providers/WorkspaceContextProvider"; @@ -82,11 +81,12 @@ const useStyles = makeStyles()({ }); type WorkspaceProps = CustomWindowControlsProps & { - deepLinks?: string[]; + deepLinks?: readonly string[]; appBarLeftInset?: number; onAppBarDoubleClick?: () => void; // eslint-disable-next-line react/no-unused-prop-types disablePersistenceForStorybook?: boolean; + AppBarComponent?: (props: AppBarProps) => JSX.Element; }; const selectPlayerPresence = ({ playerState }: MessagePipelineContext) => playerState.presence; @@ -95,9 +95,12 @@ const selectPlayerIsPresent = ({ playerState }: MessagePipelineContext) => const selectPlayerProblems = ({ playerState }: MessagePipelineContext) => playerState.problems; const selectIsPlaying = (ctx: MessagePipelineContext) => ctx.playerState.activeData?.isPlaying === true; +const selectRepeatEnabled = (ctx: MessagePipelineContext) => + ctx.playerState.activeData?.repeatEnabled === true; const selectPause = (ctx: MessagePipelineContext) => ctx.pausePlayback; const selectPlay = (ctx: MessagePipelineContext) => ctx.startPlayback; const selectSeek = (ctx: MessagePipelineContext) => ctx.seekPlayback; +const selectEnableRepeat = (ctx: MessagePipelineContext) => ctx.enableRepeatPlayback; const selectPlayUntil = (ctx: MessagePipelineContext) => ctx.playUntil; const selectPlayerId = (ctx: MessagePipelineContext) => ctx.playerState.playerId; const selectEventsSupported = (store: EventsStore) => store.eventsSupported; @@ -112,6 +115,7 @@ const selectWorkspaceRightSidebarOpen = (store: WorkspaceContextStore) => store. const selectWorkspaceRightSidebarSize = (store: WorkspaceContextStore) => store.sidebars.right.size; function WorkspaceContent(props: WorkspaceProps): JSX.Element { + const { PerformanceSidebarComponent } = useAppContext(); const { classes } = useStyles(); const containerRef = useRef(ReactNull); const { availableSources, selectSource } = usePlayerSelection(); @@ -126,6 +130,7 @@ function WorkspaceContent(props: WorkspaceProps): JSX.Element { const rightSidebarOpen = useWorkspaceStore(selectWorkspaceRightSidebarOpen); const rightSidebarSize = useWorkspaceStore(selectWorkspaceRightSidebarSize); const { t } = useTranslation("workspace"); + const { AppBarComponent = AppBar } = props; const { dialogActions, sidebarActions } = useWorkspaceActions(); @@ -144,15 +149,13 @@ function WorkspaceContent(props: WorkspaceProps): JSX.Element { // see comment below above the RemountOnValueChange component const playerId = useMessagePipeline(selectPlayerId); - const { currentUser } = useCurrentUser(); + const { currentUserType } = useCurrentUser(); useDefaultWebLaunchPreference(); - const [enableStudioLogsSidebar = false] = useAppConfigurationValue( - AppSetting.SHOW_DEBUG_PANELS, - ); + const [enableDebugMode = false] = useAppConfigurationValue(AppSetting.SHOW_DEBUG_PANELS); - const { workspaceExtensions } = useAppContext(); + const { workspaceExtensions = [] } = useAppContext(); // When a player is activated, hide the open dialog. useLayoutEffect(() => { @@ -171,56 +174,6 @@ function WorkspaceContent(props: WorkspaceProps): JSX.Element { } }, []); - useNativeAppMenuEvent( - "open", - useCallback(async () => { - dialogActions.dataSource.open("start"); - }, [dialogActions.dataSource]), - ); - - useNativeAppMenuEvent( - "open-file", - useCallback(async () => { - await dialogActions.openFile.open(); - }, [dialogActions.openFile]), - ); - - useNativeAppMenuEvent( - "open-connection", - useCallback(() => { - dialogActions.dataSource.open("connection"); - }, [dialogActions.dataSource]), - ); - - useNativeAppMenuEvent( - "open-demo", - useCallback(() => { - dialogActions.dataSource.open("demo"); - }, [dialogActions.dataSource]), - ); - - useNativeAppMenuEvent( - "open-help-about", - useCallback(() => { - dialogActions.preferences.open("about"); - }, [dialogActions.preferences]), - ); - - useNativeAppMenuEvent( - "open-help-general", - useCallback(() => { - dialogActions.preferences.open("general"); - }, [dialogActions.preferences]), - ); - - useNativeAppMenuEvent("open-help-docs", () => { - window.open("https://foxglove.dev/docs", "_blank"); - }); - - useNativeAppMenuEvent("open-help-slack", () => { - window.open("https://foxglove.dev/slack", "_blank"); - }); - const { enqueueSnackbar } = useSnackbar(); const installExtension = useExtensionCatalog((state) => state.installExtension); @@ -326,7 +279,7 @@ function WorkspaceContent(props: WorkspaceProps): JSX.Element { ); const eventsSupported = useEvents(selectEventsSupported); - const showEventsTab = currentUser != undefined && eventsSupported; + const showEventsTab = currentUserType !== "unauthenticated" && eventsSupported; const leftSidebarItems = useMemo(() => { const items = new Map([ @@ -354,14 +307,20 @@ function WorkspaceContent(props: WorkspaceProps): JSX.Element { const items = new Map([ ["variables", { title: t("variables"), component: VariablesList }], ]); - if (enableStudioLogsSidebar) { + if (enableDebugMode) { + if (PerformanceSidebarComponent) { + items.set("performance", { + title: t("performance"), + component: PerformanceSidebarComponent, + }); + } items.set("studio-logs-settings", { title: t("studioLogs"), component: StudioLogsSettings }); } if (showEventsTab) { items.set("events", { title: t("events"), component: EventsList }); } return items; - }, [enableStudioLogsSidebar, showEventsTab, t]); + }, [enableDebugMode, showEventsTab, t, PerformanceSidebarComponent]); const keyboardEventHasModifier = (event: KeyboardEvent) => navigator.userAgent.includes("Mac") ? event.metaKey : event.ctrlKey; @@ -392,6 +351,8 @@ function WorkspaceContent(props: WorkspaceProps): JSX.Element { const playUntil = useMessagePipeline(selectPlayUntil); const pause = useMessagePipeline(selectPause); const seek = useMessagePipeline(selectSeek); + const enableRepeat = useMessagePipeline(selectEnableRepeat); + const repeatEnabled = useMessagePipeline(selectRepeatEnabled); const isPlaying = useMessagePipeline(selectIsPlaying); const getMessagePipeline = useMessagePipelineGetter(); const getTimeInfo = useCallback( @@ -415,7 +376,7 @@ function WorkspaceContent(props: WorkspaceProps): JSX.Element { return; } - // Apply any available datasource args + // Apply any available data source args if (unappliedSourceArgs.ds) { log.debug("Initialising source from url", unappliedSourceArgs); selectSource(unappliedSourceArgs.ds, { @@ -446,6 +407,34 @@ function WorkspaceContent(props: WorkspaceProps): JSX.Element { setUnappliedTime({ time: undefined }); }, [playerPresence, seek, unappliedTime]); + const appBar = useMemo( + () => ( + + ), + [ + AppBarComponent, + props.appBarLeftInset, + props.isMaximized, + props.initialZoomFactor, + props.onAppBarDoubleClick, + props.onCloseWindow, + props.onMaximizeWindow, + props.onMinimizeWindow, + props.onUnmaximizeWindow, + props.showCustomWindowControls, + ], + ); + return ( {dataSourceDialog.open && } @@ -453,16 +442,7 @@ function WorkspaceContent(props: WorkspaceProps): JSX.Element {
- + {appBar} - {play && pause && seek && ( + {play && pause && seek && enableRepeat && (
)}
- {workspaceExtensions} + {/* Splat to avoid requiring unique a `key` on each item in workspaceExtensions */} + {...workspaceExtensions}
); diff --git a/packages/studio-base/src/components/AppBar/AppBarDropdownButton.tsx b/packages/studio-base/src/components/AppBar/AppBarDropdownButton.tsx index 8cf76b614d..f74cde7113 100644 --- a/packages/studio-base/src/components/AppBar/AppBarDropdownButton.tsx +++ b/packages/studio-base/src/components/AppBar/AppBarDropdownButton.tsx @@ -8,10 +8,11 @@ import { forwardRef } from "react"; import tinycolor2 from "tinycolor2"; import { makeStyles } from "tss-react/mui"; -import { APP_BAR_HEIGHT } from "@foxglove/studio-base/components/AppBar/constants"; import Stack from "@foxglove/studio-base/components/Stack"; import TextMiddleTruncate from "@foxglove/studio-base/components/TextMiddleTruncate"; +import { APP_BAR_HEIGHT } from "./constants"; + const useStyles = makeStyles()((theme) => ({ textTruncate: { maxWidth: "18vw", diff --git a/packages/studio-base/src/components/AppBar/AppBarIconButton.tsx b/packages/studio-base/src/components/AppBar/AppBarIconButton.tsx index ad7bf3dd56..30511b261a 100644 --- a/packages/studio-base/src/components/AppBar/AppBarIconButton.tsx +++ b/packages/studio-base/src/components/AppBar/AppBarIconButton.tsx @@ -47,9 +47,17 @@ export const AppBarIconButton = forwardRef - - {children} - + {/* Extra div to avoid issues with wrapping Tooltip around disabled buttons */} +
+ + {children} + +
); }, diff --git a/packages/studio-base/src/components/AppBar/AppMenu.stories.tsx b/packages/studio-base/src/components/AppBar/AppMenu.stories.tsx index 1e16e955a8..4b7f823b76 100644 --- a/packages/studio-base/src/components/AppBar/AppMenu.stories.tsx +++ b/packages/studio-base/src/components/AppBar/AppMenu.stories.tsx @@ -7,8 +7,6 @@ import { Meta, StoryObj } from "@storybook/react"; import { userEvent, within } from "@storybook/testing-library"; import * as _ from "lodash-es"; -import { AppBarMenuItem } from "@foxglove/studio-base/components/AppBar/types"; -import { AppContext } from "@foxglove/studio-base/context/AppContext"; import PlayerSelectionContext, { PlayerSelection, } from "@foxglove/studio-base/context/PlayerSelectionContext"; @@ -19,7 +17,6 @@ import { AppMenu } from "./AppMenu"; type StoryArgs = { handleClose: () => void; - appBarMenuItems?: AppBarMenuItem[]; anchorEl?: HTMLElement; anchorReference?: PopoverReference; anchorPosition?: PopoverPosition; @@ -40,15 +37,13 @@ export default { }, decorators: [ (Story, { args: { testId: _testId, ...args } }): JSX.Element => ( - - - - - - - - - + + + + + + + ), ], play: async ({ canvasElement, args }) => { @@ -70,7 +65,7 @@ const playerSelection: PlayerSelection = { { id: "2222", title: "http://localhost:11311", label: "ROS 1" }, { id: "3333", title: "ws://localhost:9090/", label: "Rosbridge (ROS 1 & 2)" }, { id: "4444", title: "ws://localhost:8765", label: "Foxglove WebSocket" }, - { id: "5555", title: "2369", label: "Velodyne Lidar" }, + { id: "5555", title: "ws://1.2.3.4:8765", label: "Foxglove WebSocket" }, { id: "6666", title: "THIS ITEM SHOULD BE HIDDEN IN STORYBOOKS", label: "!!!!!!!!!!!!" }, ], availableSources: [], @@ -80,17 +75,6 @@ type Story = StoryObj; export const Default: Story = {}; -export const WithAppContextMenuItens: Story = { - args: { - appBarMenuItems: [ - { type: "item", key: "item1", label: "App Context Item 1" }, - { type: "divider" }, - { type: "item", key: "item2", label: "App Context Item 2" }, - { type: "divider" }, - ], - }, -}; - export const FileMenuDark: Story = { args: { testId: "app-menu-file" }, parameters: { colorScheme: "dark" }, diff --git a/packages/studio-base/src/components/AppBar/AppMenu.tsx b/packages/studio-base/src/components/AppBar/AppMenu.tsx index 4a2d2bac3a..95d3c83be3 100644 --- a/packages/studio-base/src/components/AppBar/AppMenu.tsx +++ b/packages/studio-base/src/components/AppBar/AppMenu.tsx @@ -2,35 +2,23 @@ // License, v2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/ -import { - Divider, - ListSubheader, - Menu, - MenuItem, - PaperProps, - PopoverPosition, - PopoverReference, - Typography, -} from "@mui/material"; +import { Menu, PaperProps, PopoverPosition, PopoverReference } from "@mui/material"; import { useCallback, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { makeStyles } from "tss-react/mui"; -import { NestedMenuItem } from "@foxglove/studio-base/components/AppBar/NestedMenuItem"; -import { AppBarMenuItem } from "@foxglove/studio-base/components/AppBar/types"; import TextMiddleTruncate from "@foxglove/studio-base/components/TextMiddleTruncate"; -import { useAnalytics } from "@foxglove/studio-base/context/AnalyticsContext"; -import { useAppContext } from "@foxglove/studio-base/context/AppContext"; -import { useCurrentUserType } from "@foxglove/studio-base/context/CurrentUserContext"; import { usePlayerSelection } from "@foxglove/studio-base/context/PlayerSelectionContext"; import { WorkspaceContextStore, - useWorkspaceStoreWithShallowSelector, + useWorkspaceStore, } from "@foxglove/studio-base/context/Workspace/WorkspaceContext"; import { useWorkspaceActions } from "@foxglove/studio-base/context/Workspace/useWorkspaceActions"; -import { AppEvent } from "@foxglove/studio-base/services/IAnalytics"; -type AppMenuProps = { +import { NestedMenuItem } from "./NestedMenuItem"; +import { AppBarMenuItem } from "./types"; + +export type AppMenuProps = { handleClose: () => void; anchorEl?: HTMLElement; anchorReference?: PopoverReference; @@ -49,27 +37,20 @@ const useStyles = makeStyles()({ }, }); -const selectWorkspace = (store: WorkspaceContextStore) => store; +const selectLeftSidebarOpen = (store: WorkspaceContextStore) => store.sidebars.left.open; +const selectRightSidebarOpen = (store: WorkspaceContextStore) => store.sidebars.right.open; export function AppMenu(props: AppMenuProps): JSX.Element { const { open, handleClose, anchorEl, anchorReference, anchorPosition, disablePortal } = props; const { classes } = useStyles(); const { t } = useTranslation("appBar"); - const { appBarMenuItems } = useAppContext(); - const [nestedMenu, setNestedMenu] = useState(); - const currentUserType = useCurrentUserType(); - const analytics = useAnalytics(); - const { recentSources, selectRecent } = usePlayerSelection(); - const { - sidebars: { - left: { open: leftSidebarOpen }, - right: { open: rightSidebarOpen }, - }, - } = useWorkspaceStoreWithShallowSelector(selectWorkspace); + + const leftSidebarOpen = useWorkspaceStore(selectLeftSidebarOpen); + const rightSidebarOpen = useWorkspaceStore(selectRightSidebarOpen); const { sidebarActions, dialogActions, layoutActions } = useWorkspaceActions(); const handleNestedMenuClose = useCallback(() => { @@ -81,16 +62,6 @@ export function AppMenu(props: AppMenuProps): JSX.Element { setNestedMenu(id); }, []); - const handleAnalytics = useCallback( - (cta: string) => { - void analytics.logEvent(AppEvent.APP_MENU_CLICK, { - user: currentUserType, - cta, - }); - }, - [analytics, currentUserType], - ); - // FILE const fileItems = useMemo(() => { @@ -101,7 +72,6 @@ export function AppMenu(props: AppMenuProps): JSX.Element { key: "open", onClick: () => { dialogActions.dataSource.open("start"); - handleAnalytics("open-data-source-dialog"); handleNestedMenuClose(); }, }, @@ -110,7 +80,6 @@ export function AppMenu(props: AppMenuProps): JSX.Element { label: t("openLocalFile"), key: "open-file", onClick: () => { - handleAnalytics("open-file"); handleNestedMenuClose(); dialogActions.openFile.open().catch(console.error); }, @@ -121,7 +90,6 @@ export function AppMenu(props: AppMenuProps): JSX.Element { key: "open-connection", onClick: () => { dialogActions.dataSource.open("connection"); - handleAnalytics("open-connection"); handleNestedMenuClose(); }, }, @@ -134,7 +102,6 @@ export function AppMenu(props: AppMenuProps): JSX.Element { type: "item", key: recent.id, onClick: () => { - handleAnalytics("open-recent"); selectRecent(recent.id); handleNestedMenuClose(); }, @@ -147,7 +114,6 @@ export function AppMenu(props: AppMenuProps): JSX.Element { classes.truncate, dialogActions.dataSource, dialogActions.openFile, - handleAnalytics, handleNestedMenuClose, recentSources, selectRecent, @@ -215,27 +181,23 @@ export function AppMenu(props: AppMenuProps): JSX.Element { const onAboutClick = useCallback(() => { dialogActions.preferences.open("about"); - handleAnalytics("about"); handleNestedMenuClose(); - }, [dialogActions.preferences, handleAnalytics, handleNestedMenuClose]); + }, [dialogActions.preferences, handleNestedMenuClose]); const onDocsClick = useCallback(() => { - handleAnalytics("docs"); - window.open("https://foxglove.dev/docs", "_blank"); + window.open("https://docs.foxglove.dev/docs", "_blank"); handleNestedMenuClose(); - }, [handleAnalytics, handleNestedMenuClose]); + }, [handleNestedMenuClose]); const onSlackClick = useCallback(() => { - handleAnalytics("join-slack"); window.open("https://foxglove.dev/slack", "_blank"); handleNestedMenuClose(); - }, [handleAnalytics, handleNestedMenuClose]); + }, [handleNestedMenuClose]); const onDemoClick = useCallback(() => { dialogActions.dataSource.open("demo"); - handleAnalytics("demo"); handleNestedMenuClose(); - }, [dialogActions.dataSource, handleAnalytics, handleNestedMenuClose]); + }, [dialogActions.dataSource, handleNestedMenuClose]); const helpItems = useMemo( () => [ @@ -277,31 +239,6 @@ export function AppMenu(props: AppMenuProps): JSX.Element { } as Partial } > - {(appBarMenuItems ?? []).map((item, idx) => { - switch (item.type) { - case "item": - return ( - { - item.onClick?.(event); - handleClose(); - }} - > - {item.label} - {item.shortcut && {item.shortcut}} - - ); - case "divider": - return ; - case "subheader": - return ( - - {item.label} - - ); - } - })} void; onMaximizeWindow?: () => void; onUnmaximizeWindow?: () => void; diff --git a/packages/studio-base/src/components/AppBar/DataSource.tsx b/packages/studio-base/src/components/AppBar/DataSource.tsx index bb00767fb6..fcec42ec68 100644 --- a/packages/studio-base/src/components/AppBar/DataSource.tsx +++ b/packages/studio-base/src/components/AppBar/DataSource.tsx @@ -96,8 +96,7 @@ export function DataSource(): JSX.Element { playerProblems.some((problem) => problem.severity === "error"); const loading = reconnecting || initializing; - const playerDisplayName = - initializing && playerName == undefined ? "Initializing..." : playerName; + const playerDisplayName = initializing && playerName == undefined ? "Initializing…" : playerName; if (playerPresence === PlayerPresence.NOT_PRESENT) { return
{t("noDataSource")}
; diff --git a/packages/studio-base/src/components/AppBar/EndTimestamp.stories.tsx b/packages/studio-base/src/components/AppBar/EndTimestamp.stories.tsx index 2678fba3aa..7e6fadb14d 100644 --- a/packages/studio-base/src/components/AppBar/EndTimestamp.stories.tsx +++ b/packages/studio-base/src/components/AppBar/EndTimestamp.stories.tsx @@ -7,12 +7,13 @@ import { useState } from "react"; import { Time } from "@foxglove/rostime"; import { AppSetting } from "@foxglove/studio-base/AppSetting"; -import { EndTimestamp } from "@foxglove/studio-base/components/AppBar/EndTimestamp"; import MockMessagePipelineProvider from "@foxglove/studio-base/components/MessagePipeline/MockMessagePipelineProvider"; import AppConfigurationContext from "@foxglove/studio-base/context/AppConfigurationContext"; import { PlayerPresence } from "@foxglove/studio-base/players/types"; import { makeMockAppConfiguration } from "@foxglove/studio-base/util/makeMockAppConfiguration"; +import { EndTimestamp } from "./EndTimestamp"; + type StoryArgs = { time?: Time; timezone?: string; diff --git a/packages/studio-base/src/components/AppBar/NestedMenuItem.tsx b/packages/studio-base/src/components/AppBar/NestedMenuItem.tsx index 1e9aac5a4f..2ae5c80a88 100644 --- a/packages/studio-base/src/components/AppBar/NestedMenuItem.tsx +++ b/packages/studio-base/src/components/AppBar/NestedMenuItem.tsx @@ -35,7 +35,7 @@ const useStyles = makeStyles()((theme, _params, classes) => ({ }, menuList: { minWidth: 180, - maxWidth: 220, + maxWidth: 280, }, endIcon: { marginRight: theme.spacing(-0.75), diff --git a/packages/studio-base/src/components/AppBar/SettingsMenu.tsx b/packages/studio-base/src/components/AppBar/SettingsMenu.tsx new file mode 100644 index 0000000000..50f02d1ea2 --- /dev/null +++ b/packages/studio-base/src/components/AppBar/SettingsMenu.tsx @@ -0,0 +1,105 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/ + +import { + Divider, + Menu, + MenuItem, + PaperProps, + PopoverPosition, + PopoverReference, +} from "@mui/material"; +import { useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { makeStyles } from "tss-react/mui"; + +import { AppSettingsTab } from "@foxglove/studio-base/components/AppSettingsDialog/AppSettingsDialog"; +import { useAppContext } from "@foxglove/studio-base/context/AppContext"; +import { useWorkspaceActions } from "@foxglove/studio-base/context/Workspace/useWorkspaceActions"; + +const useStyles = makeStyles()({ + menuList: { + minWidth: 200, + }, +}); + +type SettingsMenuProps = { + handleClose: () => void; + anchorEl?: HTMLElement; + anchorReference?: PopoverReference; + anchorPosition?: PopoverPosition; + disablePortal?: boolean; + open: boolean; +}; + +export function SettingsMenu({ + anchorEl, + anchorReference, + anchorPosition, + disablePortal, + handleClose, + open, +}: SettingsMenuProps): JSX.Element { + const { classes } = useStyles(); + const { t } = useTranslation("appBar"); + + const { extensionSettings } = useAppContext(); + const { dialogActions } = useWorkspaceActions(); + + const onSettingsClick = useCallback( + (tab?: AppSettingsTab) => { + dialogActions.preferences.open(tab); + }, + [dialogActions.preferences], + ); + + const onDocsClick = useCallback(() => { + window.open("https://docs.foxglove.dev/docs", "_blank"); + }, []); + + const onSlackClick = useCallback(() => { + window.open("https://foxglove.dev/slack", "_blank"); + }, []); + + return ( + <> + + } + > + { + onSettingsClick(); + }} + > + {t("settings")} + + {extensionSettings && ( + { + onSettingsClick("extensions"); + }} + > + {t("extensions")} + + )} + + {t("documentation")} + {t("joinSlackCommunity")} + + + ); +} diff --git a/packages/studio-base/src/components/AppBar/UserMenu.stories.tsx b/packages/studio-base/src/components/AppBar/UserMenu.stories.tsx deleted file mode 100644 index 7771e5da34..0000000000 --- a/packages/studio-base/src/components/AppBar/UserMenu.stories.tsx +++ /dev/null @@ -1,95 +0,0 @@ -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/ - -import { StoryObj } from "@storybook/react"; - -import { AppBar } from "@foxglove/studio-base/components/AppBar"; -import { StorybookDecorator } from "@foxglove/studio-base/components/AppBar/StorybookDecorator.stories"; -import { UserMenu } from "@foxglove/studio-base/components/AppBar/UserMenu"; -import Stack from "@foxglove/studio-base/components/Stack"; -import CurrentUserContext, { User } from "@foxglove/studio-base/context/CurrentUserContext"; - -export default { - title: "components/AppBar/UserMenu", - component: AppBar, - decorators: [StorybookDecorator], -}; - -function MenuStory({ top, left }: { top: number; left: number }) { - return ( - { - // no-op - }} - /> - ); -} - -function SignInStates(): JSX.Element { - const currentUser: User = { - id: "user-1", - email: "user@example.com", - orgId: "org_id", - orgDisplayName: "Orgalorg", - orgSlug: "org", - orgPaid: false, - org: { - id: "org_id", - slug: "org", - displayName: "Orgalorg", - isEnterprise: false, - allowsUploads: true, - supportsEdgeSites: false, - }, - }; - - return ( - -
sign in undefined
-
no user present
-
user present
- - undefined, - signOut: async () => undefined, - }} - > - - - undefined, - signOut: async () => undefined, - }} - > - - -
- ); -} - -export const Dark: StoryObj = { - render: () => , - parameters: { colorScheme: "dark" }, -}; - -export const Light: StoryObj = { - render: () => , - parameters: { colorScheme: "light" }, -}; - -export const Chinese: StoryObj = { - ...Light, - parameters: { colorScheme: "light", forceLanguage: "zh" }, -}; -export const Japanese: StoryObj = { - ...Light, - parameters: { colorScheme: "light", forceLanguage: "ja" }, -}; diff --git a/packages/studio-base/src/components/AppBar/UserMenu.tsx b/packages/studio-base/src/components/AppBar/UserMenu.tsx deleted file mode 100644 index 6a89bbcb09..0000000000 --- a/packages/studio-base/src/components/AppBar/UserMenu.tsx +++ /dev/null @@ -1,174 +0,0 @@ -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/ - -import { - Divider, - Menu, - MenuItem, - PaperProps, - PopoverPosition, - PopoverReference, -} from "@mui/material"; -import { useSnackbar } from "notistack"; -import { useCallback } from "react"; -import { useTranslation } from "react-i18next"; -import { makeStyles } from "tss-react/mui"; - -import Logger from "@foxglove/log"; -import { AppSettingsTab } from "@foxglove/studio-base/components/AppSettingsDialog/AppSettingsDialog"; -import { useAnalytics } from "@foxglove/studio-base/context/AnalyticsContext"; -import { - useCurrentUser, - useCurrentUserType, -} from "@foxglove/studio-base/context/CurrentUserContext"; -import { useWorkspaceActions } from "@foxglove/studio-base/context/Workspace/useWorkspaceActions"; -import { useConfirm } from "@foxglove/studio-base/hooks/useConfirm"; -import { AppEvent } from "@foxglove/studio-base/services/IAnalytics"; - -const log = Logger.getLogger(__filename); - -const useStyles = makeStyles()({ - menuList: { - minWidth: 200, - }, -}); - -type UserMenuProps = { - handleClose: () => void; - anchorEl?: HTMLElement; - anchorReference?: PopoverReference; - anchorPosition?: PopoverPosition; - disablePortal?: boolean; - open: boolean; -}; - -export function UserMenu({ - anchorEl, - anchorReference, - anchorPosition, - disablePortal, - handleClose, - open, -}: UserMenuProps): JSX.Element { - const { classes } = useStyles(); - const { currentUser, signIn, signOut } = useCurrentUser(); - const currentUserType = useCurrentUserType(); - const analytics = useAnalytics(); - const { enqueueSnackbar } = useSnackbar(); - const [confirm, confirmModal] = useConfirm(); - const { t } = useTranslation("appBar"); - - const { dialogActions } = useWorkspaceActions(); - - const beginSignOut = useCallback(async () => { - try { - await signOut?.(); - } catch (error) { - log.error(error); - enqueueSnackbar((error as Error).toString(), { variant: "error" }); - } - }, [enqueueSnackbar, signOut]); - - const onSignoutClick = useCallback(() => { - void confirm({ - title: "Are you sure you want to sign out?", - ok: "Sign out", - }).then((response) => { - if (response === "ok") { - void beginSignOut(); - } - }); - }, [beginSignOut, confirm]); - - const onSignInClick = useCallback(() => { - void analytics.logEvent(AppEvent.APP_BAR_CLICK_CTA, { - user: currentUserType, - cta: "sign-in", - }); - signIn?.(); - }, [analytics, currentUserType, signIn]); - - const onSettingsClick = useCallback( - (tab?: AppSettingsTab) => { - void analytics.logEvent(AppEvent.APP_BAR_CLICK_CTA, { - user: currentUserType, - cta: "app-settings-dialog", - }); - dialogActions.preferences.open(tab); - }, - [analytics, currentUserType, dialogActions.preferences], - ); - - const onProfileClick = useCallback(() => { - void analytics.logEvent(AppEvent.APP_BAR_CLICK_CTA, { - user: currentUserType, - cta: "profile", - }); - window.open(process.env.FOXGLOVE_ACCOUNT_PROFILE_URL, "_blank"); - }, [analytics, currentUserType]); - - const onDocsClick = useCallback(() => { - void analytics.logEvent(AppEvent.APP_BAR_CLICK_CTA, { - user: currentUserType, - cta: "docs", - }); - window.open("https://foxglove.dev/docs", "_blank"); - }, [analytics, currentUserType]); - - const onSlackClick = useCallback(() => { - void analytics.logEvent(AppEvent.APP_BAR_CLICK_CTA, { - user: currentUserType, - cta: "join-slack", - }); - window.open("https://foxglove.dev/slack", "_blank"); - }, [analytics, currentUserType]); - - return ( - <> - - } - > - {currentUser && {currentUser.email}} - { - onSettingsClick(); - }} - > - {t("settings")} - - { - onSettingsClick("extensions"); - }} - > - {t("extensions")} - - {currentUser && {t("userProfile")}} - - {t("documentation")} - {t("joinSlackCommunity")} - {signIn != undefined && } - {currentUser ? ( - {t("signOut")} - ) : signIn != undefined ? ( - {t("signIn")} - ) : undefined} - - {confirmModal} - - ); -} diff --git a/packages/studio-base/src/components/AppBar/index.stories.tsx b/packages/studio-base/src/components/AppBar/index.stories.tsx index 13ee4756c1..561a99c5ce 100644 --- a/packages/studio-base/src/components/AppBar/index.stories.tsx +++ b/packages/studio-base/src/components/AppBar/index.stories.tsx @@ -5,15 +5,15 @@ import { action } from "@storybook/addon-actions"; import { Meta, StoryFn, StoryObj } from "@storybook/react"; -import { AppBar } from "@foxglove/studio-base/components/AppBar"; -import { StorybookDecorator } from "@foxglove/studio-base/components/AppBar/StorybookDecorator.stories"; import MockMessagePipelineProvider, { MockMessagePipelineProps, } from "@foxglove/studio-base/components/MessagePipeline/MockMessagePipelineProvider"; import Stack from "@foxglove/studio-base/components/Stack"; -import CurrentUserContext, { User } from "@foxglove/studio-base/context/CurrentUserContext"; import { PlayerPresence } from "@foxglove/studio-base/players/types"; +import { AppBar } from "."; +import { StorybookDecorator } from "./StorybookDecorator.stories"; + export default { title: "components/AppBar", component: AppBar, @@ -53,82 +53,6 @@ const Grid = (Story: StoryFn): JSX.Element => ( ); -const currentUser: User = { - id: "user-1", - email: "user@example.com", - orgId: "org_id", - orgDisplayName: "Orgalorg", - orgSlug: "org", - orgPaid: false, - org: { - id: "org_id", - slug: "org", - displayName: "Orgalorg", - isEnterprise: false, - allowsUploads: true, - supportsEdgeSites: false, - }, -}; - -export const SignInStates: Story = { - decorators: [ - (Story: StoryFn): JSX.Element => { - return ( - <> -
sign in undefined
-
- -
- {[ - { label: "no user present", currentUser: undefined }, - { label: "user present", currentUser }, - { - label: "user present with avatar", - currentUser: { - ...currentUser, - avatarImageUrl: - "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAASABIAAD/4QCARXhpZgAATU0AKgAAAAgABAESAAMAAAABAAEAAAEaAAUAAAABAAAAPgEbAAUAAAABAAAARodpAAQAAAABAAAATgAAAAAAAABIAAAAAQAAAEgAAAABAAOgAQADAAAAAQABAACgAgAEAAAAAQAAAECgAwAEAAAAAQAAAEAAAAAA/8AAEQgAQABAAwEiAAIRAQMRAf/EAB8AAAEFAQEBAQEBAAAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/EAB8BAAMBAQEBAQEBAQEAAAAAAAABAgMEBQYHCAkKC//EALURAAIBAgQEAwQHBQQEAAECdwABAgMRBAUhMQYSQVEHYXETIjKBCBRCkaGxwQkjM1LwFWJy0QoWJDThJfEXGBkaJicoKSo1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoKDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uLj5OXm5+jp6vLz9PX29/j5+v/bAEMAAgICAgICAwICAwUDAwMFBgUFBQUGCAYGBgYGCAoICAgICAgKCgoKCgoKCgwMDAwMDA4ODg4ODw8PDw8PDw8PD//bAEMBAgICBAQEBwQEBxALCQsQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEP/dAAQABP/aAAwDAQACEQMRAD8A8VIAoApT0pNwr89SPyC9wwDwOtchoes3Gp+Ib2NpFFts3W8X8XkxOYTMRgHEswcL1BWMEHmutlLRQPc+WzpHjJUdN2cfyP0Aya8Vkh8VeF/FD+JvEGlzaZbXNjFpNlHIo3tIjGbeQOikH5CerELgMQD3YXD80Zd+h72V5fKpSqT5emmnnqex3uqabp2wXtykTSyLCinJd5XBKRoigszsASqqCxAJAxVt7fVbe4li1LTptPRSBH9owksuQCW8rJdFGQP3m1ic/Ljk6vgO8Om+JIb3SJYb/UdCt5baKGA+YYL6/wBsl5cSNgjzAgSG3Tl1jBZ2AfAk1lrybUp5L/mfcdwzuK+xPc9ye5ya5asOV8tjtzHLsNhsOlJ81V/cvu6/8OY+31pJU+Q/Spcc0kn3GPsaxPmGj//Q8UPWjy3lZUTJPovU1MVBr6//AGcPhVpGvbPFmuRCYW82YlLkAFPVOh57mvzqtiFSi5s/M8kyyrjK6oUt3+CPju18RaRo+jPb/EDwpqkN5odpdB7tYGkt7xtQlMcEqxRFpmRUwBmIAOOGwOflrUvF3hf4r3uo6Zq2o3ujaujS3qaktpLcMJbWDy4tyofNEjxt5e0ABcB8qRz+qf7XfjfWtL8RT6V4T8JXWrXek2USRzWvkxhZZgZIxKzukixAjhow4J3DAIBr8jNV8C+KE0HVNc13xWtg+oySRy29sPLtmj3FVQkbSqsCQfm+YcEt1r28kqxqQ9pLRvbW9r66fn2P1TG0YYblpbpaaK17eh+3H7KvwT8Bah8I/B/jGGB4k1vTLa9+z7fLCCdQ20nqec/Nn5vvHrXrXxD/AGevCuu2Mp0eNNPndf8AWZBAIOeFx1PrXwp+xB+2vpv9mQfCD4362LTWIZ1t9G1G8Ty47m2YYignlGFWRSpETsqCRCoGXDZ/X10MybiBzivhs2+s0MRL2l97rs1+p9BhMnwFXDqEYJp797+u5+Mfj7wHq3gHWW0nVCjk/MkkZyGUk49Oa4J8BGB6YNfdP7VvhyGKGDWYQ0spb5ztGEQcZZjzgk4AHevhO4JEbfQ16uEre0pqR+L8Q5YsJi50I7Lb0Z//0fHU5Ir1jR/jh8SvC+g6x4T+GWkwR6TpcbTz+KrsvJGsGzzG+zQCMq0gG7CbmaMKWkUZUN5MDg8cV9zfBbR/BPjr4dReC/GmnWmu2Yka4+yXkazIrZIztPHIzkdwcHOa/Nq9anCPNUhzL+tfP56dz5XgVc2JlTi+WTWj/T5999ND8otb+L2uaHod/rWq3niXX4fE0rhNbvUnihvNm4+TaXMgUGHOSFR/UgBBtHzxrfxHk1XQLPTYUYuS/nb8lVIOEKZ4OVPPHBBI61/TH8Qfhh4P+Jfw31H4X6/B5OjX9v5EYtlRGtSuDHJANpVHiYBkwMDFfmW/7FXw8s5tQ0ERFdT0mcv9oWdpVnwNyBwWGF5BZOAOnANeplvE2EabnHld/XTo+h+g5jw5WUlyvm/zPzn8H/DUeLLafWNWvircgqjhpGO35g4cHjGPlyeMZ44r9Kf2Bf2tvFCeK4/2fvipqU2oWV2zQeHb26VjPDLErMLOWU8vFJGpaBnyysChZgY6+eb74b638ONYuIPGJt9YaeN2E8RUrgj5xtbLLwPuEnjuRivln4q3LeF/EcfiDw/ftbahp9zHcWhDfPEbUeZAyJyP3cgUqcdBjpwfWxVCnjqcqbd01o+x4mAxVTDYi34H9A/7VEUj+D/MVJXw6jhlCAZySQeSR6DtX5v3J/dPj0NfYHxP+Kw8dfCXQ9ccoo8UabYX0alTvBuI0lbb3BPIPtXx/ccwvj0NfF5dRlTpckt0fMccV4VMe5Q7I//S8aPBFeseDfjHH4J02axt0c3KxuqkFVAY4PXGTjrz9K8owetc1faCLuWSQy4VyTjbyMjBwQa/P6dOlP3ar0PzDK8dKhU54Ssz7i079qawjjeS6BiKFjvbmNVGSCemAABnPOe1fHf7LnjXTYdW8VTapeFPFmuanJNfvPN+7n3yPKJNjHCgq4JwM7QqEkKK8Y8d/D/xb43vreZdUtdMigyrmCGVWlT5Nm5fM2AoEwpA7sepNWPD/wAOT4RKT6FY6fJe9JL26M0ty+ep3kEjPoOO1b08BhYUZwhLWVvlb9D7+HFzjKLnU57bdDM+K3xMu7DxJrum6pG63cV480N1tMiy20x3B/YnlMD05x0r4d8QatFqupzXLDYk5YH+JyGBBPJHQHIXOPpkmvq34z6Rda/riL4b0q/1G6dBvu7eGe2MYBH7uQSkRSlhkkqPl6ZJrg/DXwn8Y+TqVrqvhgvBqVqkALSxQyxyLJ5iz7hu+df7uMMOG4r6nC1qUKSd0n6pCni6UZObkr+q/wAz768C/tJeF/jRrN98PdO0JNI8J+HdAX+yvtSxtfm4tkRHld4iyRqAQoRWPqTlsDMlLeQxPXb/AEr5++E3w017wFc3V35SJPeJ5Ukry78RZzsVEAHJ5JJzX0PMAYmwe1fKY2jRpytRenr+PzPm+JcfTxFSEotNq92vlZfI/9k=", - }, - }, - { - label: "user present with invalid avatar url", - currentUser: { - ...currentUser, - avatarImageUrl: "data:image/jpeg;base64,/9j/4AAQSkZJR/9k=", - }, - }, - ].map((state) => ( - undefined, - signOut: async () => undefined, - }} - > -
{state.label}
-
- -
-
- ))} - - ); - }, - Grid, - ], -}; - -export const SignInStatesChinese: Story = { - ...SignInStates, - parameters: { forceLanguage: "zh" }, -}; - -export const SignInStatesJapanese: Story = { - ...SignInStates, - parameters: { forceLanguage: "ja" }, -}; - const problems: MockMessagePipelineProps["problems"] = [ { severity: "error", message: "example error" }, { severity: "warn", message: "example warn" }, @@ -212,11 +136,8 @@ const fileSources: MockMessagePipelineProps[] = [ })); const remoteSources: MockMessagePipelineProps[] = [ - "ros1-socket", - "ros2-socket", "rosbridge-websocket", "foxglove-websocket", - "velodyne-device", "some other source type", ].map((sourceId) => ({ name: "https://longexampleurlwith_specialcharaters-and-portnumber:3030", diff --git a/packages/studio-base/src/components/AppBar/index.test.tsx b/packages/studio-base/src/components/AppBar/index.test.tsx index 1ac5a32d7e..3858bf407c 100644 --- a/packages/studio-base/src/components/AppBar/index.test.tsx +++ b/packages/studio-base/src/components/AppBar/index.test.tsx @@ -9,7 +9,6 @@ import MockMessagePipelineProvider from "@foxglove/studio-base/components/Messag import MultiProvider from "@foxglove/studio-base/components/MultiProvider"; import StudioToastProvider from "@foxglove/studio-base/components/StudioToastProvider"; import AppConfigurationContext from "@foxglove/studio-base/context/AppConfigurationContext"; -import { UserNodeStateProvider } from "@foxglove/studio-base/context/UserNodeStateContext"; import MockCurrentLayoutProvider from "@foxglove/studio-base/providers/CurrentLayoutProvider/MockCurrentLayoutProvider"; import TimelineInteractionStateProvider from "@foxglove/studio-base/providers/TimelineInteractionStateProvider"; import WorkspaceContextProvider from "@foxglove/studio-base/providers/WorkspaceContextProvider"; @@ -26,7 +25,6 @@ function Wrapper({ children }: React.PropsWithChildren): JSX.Element { , , , - , , , , @@ -72,6 +70,7 @@ describe("", () => { onUnmaximizeWindow={mockUnmaximize} onCloseWindow={mockClose} isMaximized + initialZoomFactor={1} /> , ); diff --git a/packages/studio-base/src/components/AppBar/index.tsx b/packages/studio-base/src/components/AppBar/index.tsx index 3cc84dc51c..5a979e74e1 100644 --- a/packages/studio-base/src/components/AppBar/index.tsx +++ b/packages/studio-base/src/components/AppBar/index.tsx @@ -10,42 +10,32 @@ import { PanelRight24Regular, SlideAdd24Regular, } from "@fluentui/react-icons"; -import { Avatar, Button, IconButton, Tooltip } from "@mui/material"; +import { Avatar, IconButton, Tooltip } from "@mui/material"; import { useState } from "react"; import { useTranslation } from "react-i18next"; import tc from "tinycolor2"; import { makeStyles } from "tss-react/mui"; -import { AppSetting } from "@foxglove/studio-base/AppSetting"; -import { AppBarIconButton } from "@foxglove/studio-base/components/AppBar/AppBarIconButton"; -import { AppMenu } from "@foxglove/studio-base/components/AppBar/AppMenu"; -import { - CustomWindowControls, - CustomWindowControlsProps, -} from "@foxglove/studio-base/components/AppBar/CustomWindowControls"; -import { BetaAppMenu } from "@foxglove/studio-base/components/BetaAppMenu"; import { FoxgloveLogo } from "@foxglove/studio-base/components/FoxgloveLogo"; -import { MemoryUseIndicator } from "@foxglove/studio-base/components/MemoryUseIndicator"; import Stack from "@foxglove/studio-base/components/Stack"; -import { useAnalytics } from "@foxglove/studio-base/context/AnalyticsContext"; import { useAppContext } from "@foxglove/studio-base/context/AppContext"; import { LayoutState, useCurrentLayoutSelector, } from "@foxglove/studio-base/context/CurrentLayoutContext"; -import { useCurrentUser } from "@foxglove/studio-base/context/CurrentUserContext"; import { WorkspaceContextStore, - useWorkspaceStoreWithShallowSelector, + useWorkspaceStore, } from "@foxglove/studio-base/context/Workspace/WorkspaceContext"; import { useWorkspaceActions } from "@foxglove/studio-base/context/Workspace/useWorkspaceActions"; -import { useAppConfigurationValue } from "@foxglove/studio-base/hooks"; -import { AppEvent } from "@foxglove/studio-base/services/IAnalytics"; import { AddPanelMenu } from "./AddPanelMenu"; import { AppBarContainer } from "./AppBarContainer"; +import { AppBarIconButton } from "./AppBarIconButton"; +import { AppMenu } from "./AppMenu"; +import { CustomWindowControls, CustomWindowControlsProps } from "./CustomWindowControls"; import { DataSource } from "./DataSource"; -import { UserMenu } from "./UserMenu"; +import { SettingsMenu } from "./SettingsMenu"; const useStyles = makeStyles<{ debugDragRegion?: boolean }, "avatar">()(( theme, @@ -153,33 +143,22 @@ const useStyles = makeStyles<{ debugDragRegion?: boolean }, "avatar">()(( }, }, }, - button: { - marginInline: theme.spacing(1), - backgroundColor: theme.palette.appBar.primary, - - "&:hover": { - backgroundColor: theme.palette.augmentColor({ - color: { main: theme.palette.appBar.primary as string }, - }).dark, - }, - }, }; }); -type AppBarProps = CustomWindowControlsProps & { +export type AppBarProps = CustomWindowControlsProps & { leftInset?: number; onDoubleClick?: () => void; debugDragRegion?: boolean; - disableSignIn?: boolean; }; const selectHasCurrentLayout = (state: LayoutState) => state.selectedLayout != undefined; -const selectWorkspace = (store: WorkspaceContextStore) => store; +const selectLeftSidebarOpen = (store: WorkspaceContextStore) => store.sidebars.left.open; +const selectRightSidebarOpen = (store: WorkspaceContextStore) => store.sidebars.right.open; export function AppBar(props: AppBarProps): JSX.Element { const { debugDragRegion, - disableSignIn = false, isMaximized, leftInset, onCloseWindow, @@ -190,27 +169,15 @@ export function AppBar(props: AppBarProps): JSX.Element { showCustomWindowControls = false, } = props; const { classes, cx, theme } = useStyles({ debugDragRegion }); - const { currentUser, signIn } = useCurrentUser(); const { t } = useTranslation("appBar"); const { appBarLayoutButton } = useAppContext(); - const analytics = useAnalytics(); - const [enableMemoryUseIndicator = false] = useAppConfigurationValue( - AppSetting.ENABLE_MEMORY_USE_INDICATOR, - ); - const [enableUnifiedNavigation = false] = useAppConfigurationValue( - AppSetting.ENABLE_UNIFIED_NAVIGATION, - ); - const hasCurrentLayout = useCurrentLayoutSelector(selectHasCurrentLayout); - const { - sidebars: { - left: { open: leftSidebarOpen }, - right: { open: rightSidebarOpen }, - }, - } = useWorkspaceStoreWithShallowSelector(selectWorkspace); + const leftSidebarOpen = useWorkspaceStore(selectLeftSidebarOpen); + const rightSidebarOpen = useWorkspaceStore(selectRightSidebarOpen); + const { sidebarActions } = useWorkspaceActions(); const [appMenuEl, setAppMenuEl] = useState(undefined); @@ -246,23 +213,13 @@ export function AppBar(props: AppBarProps): JSX.Element { primaryFill={theme.palette.common.white} /> - {enableUnifiedNavigation ? ( - { - setAppMenuEl(undefined); - }} - /> - ) : ( - { - setAppMenuEl(undefined); - }} - /> - )} + { + setAppMenuEl(undefined); + }} + />
- {enableMemoryUseIndicator && } {appBarLayoutButton} : } - {!disableSignIn && !currentUser && signIn != undefined && ( - - )} - + - + {showCustomWindowControls && ( @@ -386,7 +317,7 @@ export function AppBar(props: AppBarProps): JSX.Element { setPanelAnchorEl(undefined); }} /> - { diff --git a/packages/studio-base/src/components/AppSettingsDialog/AppSettingsDialog.stories.tsx b/packages/studio-base/src/components/AppSettingsDialog/AppSettingsDialog.stories.tsx index a3cb170ee3..262f57d5e9 100644 --- a/packages/studio-base/src/components/AppSettingsDialog/AppSettingsDialog.stories.tsx +++ b/packages/studio-base/src/components/AppSettingsDialog/AppSettingsDialog.stories.tsx @@ -4,70 +4,15 @@ import { StoryFn, StoryObj } from "@storybook/react"; import { screen, userEvent } from "@storybook/testing-library"; -import * as _ from "lodash-es"; -import { ExtensionInfo, ExtensionLoader } from "@foxglove/studio-base"; -import ExtensionMarketplaceContext, { - ExtensionMarketplace, -} from "@foxglove/studio-base/context/ExtensionMarketplaceContext"; -import ExtensionCatalogProvider from "@foxglove/studio-base/providers/ExtensionCatalogProvider"; import WorkspaceContextProvider from "@foxglove/studio-base/providers/WorkspaceContextProvider"; import { AppSettingsDialog } from "./AppSettingsDialog"; -const installedExtensions: ExtensionInfo[] = _.range(1, 10).map((index) => ({ - id: "publisher.storyextension", - name: "privatestoryextension", - qualifiedName: "storyextension", - displayName: `Private Extension Name ${index + 1}`, - description: "Private extension sample description", - publisher: "Private Publisher", - homepage: "https://foxglove.dev/", - license: "MIT", - version: `1.${index}`, - keywords: ["storybook", "testing"], - namespace: index % 2 === 0 ? "local" : "org", -})); - -const marketplaceExtensions: ExtensionInfo[] = [ - { - id: "publisher.storyextension", - name: "storyextension", - qualifiedName: "storyextension", - displayName: "Extension Name", - description: "Extension sample description", - publisher: "Publisher", - homepage: "https://foxglove.dev/", - license: "MIT", - version: "1.2.10", - keywords: ["storybook", "testing"], - }, -]; - -const MockExtensionLoader: ExtensionLoader = { - namespace: "local", - getExtensions: async () => installedExtensions, - loadExtension: async (_id: string) => "", - installExtension: async (_foxeFileData: Uint8Array) => { - throw new Error("MockExtensionLoader cannot install extensions"); - }, - uninstallExtension: async (_id: string) => undefined, -}; - -const MockExtensionMarketplace: ExtensionMarketplace = { - getAvailableExtensions: async () => marketplaceExtensions, - getMarkdown: async (url: string) => `# Markdown -Mock markdown rendering for URL [${url}](${url}).`, -}; - function Wrapper(StoryComponent: StoryFn): JSX.Element { return ( - - - - - + ); } @@ -127,38 +72,6 @@ export const GeneralJapanese: StoryObj = { parameters: { forceLanguage: "ja" }, }; -export const Privacy: StoryObj = { - render: () => { - return ; - }, -}; - -export const PrivacyChinese: StoryObj = { - ...Privacy, - parameters: { forceLanguage: "zh" }, -}; - -export const PrivacyJapanese: StoryObj = { - ...Privacy, - parameters: { forceLanguage: "ja" }, -}; - -export const Extensions: StoryObj = { - render: () => { - return ; - }, -}; - -export const ExtensionsChinese: StoryObj = { - ...Extensions, - parameters: { forceLanguage: "zh" }, -}; - -export const ExtensionsJapanese: StoryObj = { - ...Extensions, - parameters: { forceLanguage: "ja" }, -}; - export const Experimental: StoryObj = { render: () => { return ; diff --git a/packages/studio-base/src/components/AppSettingsDialog/AppSettingsDialog.tsx b/packages/studio-base/src/components/AppSettingsDialog/AppSettingsDialog.tsx index bdd700c145..41faa5aabb 100644 --- a/packages/studio-base/src/components/AppSettingsDialog/AppSettingsDialog.tsx +++ b/packages/studio-base/src/components/AppSettingsDialog/AppSettingsDialog.tsx @@ -3,7 +3,6 @@ // file, You can obtain one at http://mozilla.org/MPL/2.0/ import CloseIcon from "@mui/icons-material/Close"; -import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; import WarningAmberIcon from "@mui/icons-material/WarningAmber"; import { Alert, @@ -12,7 +11,9 @@ import { Dialog, DialogActions, DialogProps, + DialogTitle, FormControlLabel, + FormLabel, IconButton, Link, Tab, @@ -24,13 +25,13 @@ import { MouseEvent, SyntheticEvent, useState } from "react"; import { useTranslation } from "react-i18next"; import { makeStyles } from "tss-react/mui"; -import { AppSetting } from "@foxglove/studio-base/AppSetting"; +import { AppSetting } from "@foxglove/studio-base"; import OsContextSingleton from "@foxglove/studio-base/OsContextSingleton"; import CopyButton from "@foxglove/studio-base/components/CopyButton"; import { ExperimentalFeatureSettings } from "@foxglove/studio-base/components/ExperimentalFeatureSettings"; -import ExtensionsSettings from "@foxglove/studio-base/components/ExtensionsSettings"; import FoxgloveLogoText from "@foxglove/studio-base/components/FoxgloveLogoText"; import Stack from "@foxglove/studio-base/components/Stack"; +import { useAppContext } from "@foxglove/studio-base/context/AppContext"; import { useWorkspaceStore, WorkspaceContextStore, @@ -120,6 +121,12 @@ const useStyles = makeStyles()((theme) => ({ borderRadius: theme.shape.borderRadius, }, }, + dialogTitle: { + display: "flex", + justifyContent: "space-between", + alignItems: "center", + fontSize: theme.typography.h3.fontSize, + }, })); type SectionKey = "resources" | "products" | "contact" | "legal"; @@ -137,7 +144,7 @@ const aboutItems = new Map< subheader: "External resources", links: [ ...(isDesktopApp() ? [] : [{ title: "Desktop app", url: "https://foxglove.dev/download" }]), - { title: "Browse docs", url: "https://foxglove.dev/docs" }, + { title: "Browse docs", url: "https://docs.foxglove.dev/docs" }, { title: "Join our community", url: "https://foxglove.dev/community" }, ], }, @@ -174,12 +181,7 @@ const aboutItems = new Map< ], ]); -export type AppSettingsTab = - | "general" - | "privacy" - | "extensions" - | "experimental-features" - | "about"; +export type AppSettingsTab = "general" | "extensions" | "experimental-features" | "about"; const selectWorkspaceInitialActiveTab = (store: WorkspaceContextStore) => store.dialogs.preferences.initialTab; @@ -193,15 +195,14 @@ export function AppSettingsDialog( const [activeTab, setActiveTab] = useState( _activeTab ?? initialActiveTab ?? "general", ); - const [crashReportingEnabled, setCrashReportingEnabled] = useAppConfigurationValue( - AppSetting.CRASH_REPORTING_ENABLED, - ); - const [telemetryEnabled, setTelemetryEnabled] = useAppConfigurationValue( - AppSetting.TELEMETRY_ENABLED, + const [debugModeEnabled = false, setDebugModeEnabled] = useAppConfigurationValue( + AppSetting.SHOW_DEBUG_PANELS, ); const { classes, cx, theme } = useStyles(); const smUp = useMediaQuery(theme.breakpoints.up("sm")); + const { extensionSettings } = useAppContext(); + // automatic updates are a desktop-only setting // // electron-updater does not provide a way to detect if we are on a supported update platform @@ -220,21 +221,13 @@ export function AppSettingsDialog( }; return ( - - - - {t("settings")} - + + + {t("settings")} - +
- - + {extensionSettings && ( + + )} } {!isDesktopApp() && } {isDesktopApp() && } - - - -
- - }> - {t("privacyDescription")} - - - void setTelemetryEnabled(checked)} - /> - } - label={t("sendAnonymizedUsageData")} - /> + + {t("advanced")}: void setCrashReportingEnabled(checked)} + checked={debugModeEnabled} + onChange={(_, checked) => { + void setDebugModeEnabled(checked); + }} /> } - label={t("sendAnonymizedCrashReports")} + label={t("debugModeDescription")} />
-
- - - -
+ {extensionSettings && ( +
+ {extensionSettings} +
+ )}
{ - const timeOutID = setTimeout(() => { - if (changeSize) { - setWidth(150); - } - if (changePixelRatio) { - setPixelRatio(2); - } - }, 10); - - return () => { - clearTimeout(timeOutID); - }; - }, [changePixelRatio, changeSize]); - - return ( -
- { - ctx.fillStyle = "white"; - ctx.fillRect(0, 0, drawWidth, drawHeight); - ctx.strokeStyle = "red"; - ctx.lineWidth = 2; - ctx.font = "24px Arial"; - ctx.strokeRect(0, 0, drawWidth, drawHeight); - - const text = `hello ${ctx.getTransform().a}`; - const size = ctx.measureText(text); - ctx.fillStyle = "black"; - ctx.textBaseline = "middle"; - ctx.fillText(text, drawWidth / 2 - size.width / 2, drawHeight / 2); - }} - /> -
- ); -} - -export default { - title: "components/AutoSizingCanvas", -}; - -export const Static: StoryObj = { - render: () => , - name: "static", -}; - -export const ChangingSize: StoryObj = { - render: () => , - name: "changing size", -}; - -export const PixelRatio2: StoryObj = { - render: () => , - name: "pixel ratio 2", -}; - -export const ChangingPixelRatio: StoryObj = { - render: () => , - name: "changing pixel ratio", -}; diff --git a/packages/studio-base/src/components/AutoSizingCanvas/index.tsx b/packages/studio-base/src/components/AutoSizingCanvas/index.tsx deleted file mode 100644 index 9b1ea8fe88..0000000000 --- a/packages/studio-base/src/components/AutoSizingCanvas/index.tsx +++ /dev/null @@ -1,80 +0,0 @@ -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/ -// -// This file incorporates work covered by the following copyright and -// permission notice: -// -// Copyright 2018-2021 Cruise LLC -// -// This source code is licensed under the Apache License, Version 2.0, -// found at http://www.apache.org/licenses/LICENSE-2.0 -// You may not use this file except in compliance with the License. - -import { useLayoutEffect, useState } from "react"; -import { useResizeDetector } from "react-resize-detector"; - -type Draw = (context: CanvasRenderingContext2D, width: number, height: number) => void; - -type AutoSizingCanvasProps = { - draw: Draw; - overrideDevicePixelRatioForTest?: number; -}; - -const AutoSizingCanvas = ({ - draw, - overrideDevicePixelRatioForTest, -}: AutoSizingCanvasProps): JSX.Element => { - // Use a debounce and 0 refresh rate to avoid triggering a resize observation while handling - // an existing resize observation. - // https://github.com/maslianok/react-resize-detector/issues/45 - const { - width, - height, - ref: canvasRef, - } = useResizeDetector({ - refreshRate: 0, - refreshMode: "debounce", - }); - - const [pixelRatio, setPixelRatio] = useState(window.devicePixelRatio); - useLayoutEffect(() => { - const listener = () => { - setPixelRatio(window.devicePixelRatio); - }; - const query = window.matchMedia(`(resolution: ${pixelRatio}dppx)`); - query.addEventListener("change", listener, { once: true }); - return () => { - query.removeEventListener("change", listener); - }; - }, [pixelRatio]); - - const ratio = overrideDevicePixelRatioForTest ?? pixelRatio; - - const actualWidth = ratio * (width ?? 0); - const actualHeight = ratio * (height ?? 0); - - useLayoutEffect(() => { - const canvas = canvasRef.current; - if (!canvas || width == undefined || height == undefined) { - return; - } - const ctx = canvas.getContext("2d"); - if (!ctx) { - return; - } - ctx.setTransform(ratio, 0, 0, ratio, 0, 0); - draw(ctx, width, height); - }); - - return ( - - ); -}; - -export default AutoSizingCanvas; diff --git a/packages/studio-base/src/components/Autocomplete.stories.tsx b/packages/studio-base/src/components/Autocomplete/Autocomplete.stories.tsx similarity index 62% rename from packages/studio-base/src/components/Autocomplete.stories.tsx rename to packages/studio-base/src/components/Autocomplete/Autocomplete.stories.tsx index 6483e897bb..db45af73d3 100644 --- a/packages/studio-base/src/components/Autocomplete.stories.tsx +++ b/packages/studio-base/src/components/Autocomplete/Autocomplete.stories.tsx @@ -15,9 +15,10 @@ import { Meta, StoryFn, StoryObj } from "@storybook/react"; import { fireEvent, within } from "@storybook/testing-library"; import * as _ from "lodash-es"; -import Autocomplete from "@foxglove/studio-base/components/Autocomplete"; import Stack from "@foxglove/studio-base/components/Stack"; +import { Autocomplete } from "./Autocomplete"; + export default { title: "components/Autocomplete", component: Autocomplete, @@ -60,25 +61,9 @@ export const FilteringToOLight: Story = { parameters: { colorScheme: "light" }, }; -export const WithNonStringItemsAndLeadingWhitespace: Story = { - args: { - items: [ - { value: "one", text: "ONE" }, - { value: "two", text: " TWO" }, - { value: "three", text: "THREE" }, - ], - getItemText: ({ text }: any) => text, - filterText: "o", - value: "o", - }, - name: "with non-string items and leading whitespace", - play: clickInput, -}; - export const UncontrolledValue: Story = { args: { - items: [{ value: "one" }, { value: "two" }, { value: "three" }], - getItemText: ({ value }: any) => `item: ${value.toUpperCase()}`, + items: ["one", "two", "three"], filterText: "h", value: "h", }, @@ -90,30 +75,9 @@ export const UncontrolledValueLight: Story = { parameters: { colorScheme: "light" }, }; -export const UncontrolledValueWithSelectedItem: Story = { - args: { - items: [{ value: "one" }, { value: "two" }, { value: "three" }], - getItemText: ({ value }: any) => `item: ${value.toUpperCase()}`, - selectedItem: { value: "two" }, - }, - play: clickInput, -}; - -export const UncontrolledValueWithSelectedItemAndClearOnFocus: Story = { - args: { - items: [{ value: "one" }, { value: "two" }, { value: "three" }], - getItemText: ({ value }: any) => `item: ${value.toUpperCase()}`, - selectedItem: { value: "two" }, - selectOnFocus: true, - }, - name: "uncontrolled value with selected item and clearOnFocus", - play: clickInput, -}; - export const SortWhenFilteringFalse: Story = { args: { - items: [{ value: "bab" }, { value: "bb" }, { value: "a2" }, { value: "a1" }], - getItemText: ({ value }: any) => `item: ${value.toUpperCase()}`, + items: ["bab", "bb", "a2", "a1"], sortWhenFiltering: false, value: "b", filterText: "b", @@ -122,25 +86,27 @@ export const SortWhenFilteringFalse: Story = { play: clickInput, }; -export const WithALongTruncatedPathAndAutoSize: Story = { +export const ManyItems: Story = { + args: { + items: _.range(1, 1000).map((i) => `item_${i}`), + }, + play: clickInput, +}; + +export const LongPathInPopup: Story = { render: (args): JSX.Element => (
), args: { - items: [], + items: [ + "/abcdefghi_jklmnop.abcdefghi_jklmnop[:]{some_id==1297193}.isSomething", + "/abcdefghi_jklmnop.abcdefghi_jklmnop[:]{some_id==1297194}.isSomething", + "/abcdefghi_jklmnop.abcdefghi_jklmnop[:]{some_id==1297195}.isSomething", + ], value: "/abcdefghi_jklmnop.abcdefghi_jklmnop[:]{some_id==1297193}.isSomething", - autoSize: true, - }, - name: "with a long truncated path (and autoSize)", - play: clickInput, -}; - -export const ManyItems: Story = { - args: { - items: _.range(1, 1000).map((i) => `item_${i}`), - autoSize: true, + filterText: "/abcdefghi_jklmnop.abcdefghi_jklmnop[:]{", }, play: clickInput, }; diff --git a/packages/studio-base/src/components/Autocomplete.tsx b/packages/studio-base/src/components/Autocomplete/Autocomplete.tsx similarity index 56% rename from packages/studio-base/src/components/Autocomplete.tsx rename to packages/studio-base/src/components/Autocomplete/Autocomplete.tsx index 81938eba7e..9b915a5000 100644 --- a/packages/studio-base/src/components/Autocomplete.tsx +++ b/packages/studio-base/src/components/Autocomplete/Autocomplete.tsx @@ -12,11 +12,11 @@ // You may not use this file except in compliance with the License. import { - MenuItem, Autocomplete as MuiAutocomplete, + Popper, + PopperProps, TextField, TextFieldProps, - alpha, } from "@mui/material"; import { Fzf, FzfResultItem } from "fzf"; import * as React from "react"; @@ -29,36 +29,30 @@ import { useRef, useState, } from "react"; -import { useResizeDetector } from "react-resize-detector"; import { makeStyles } from "tss-react/mui"; -import { HighlightChars } from "@foxglove/studio-base/components/HighlightChars"; -import { ReactWindowListboxAdapter } from "@foxglove/studio-base/components/ReactWindowListboxAdapter"; +import { ListboxAdapterChild, ReactWindowListboxAdapter } from "./ReactWindowListboxAdapter"; const MAX_FZF_MATCHES = 200; // Above this number of items we fall back to the faster fuzzy find algorithm. const FAST_FIND_ITEM_CUTOFF = 1_000; -type AutocompleteProps = { +type AutocompleteProps = { className?: string; - autoSize?: boolean; disableAutoSelect?: boolean; disabled?: boolean; filterText?: string; - getItemText?: (item: T) => string; - getItemValue?: (item: T) => string; hasError?: boolean; inputStyle?: CSSProperties; - items: readonly T[]; + items: readonly string[]; menuStyle?: CSSProperties; minWidth?: number; onBlur?: () => void; onChange?: (event: React.SyntheticEvent, text: string) => void; - onSelect: (value: string | T, autocomplete: IAutocomplete) => void; + onSelect: (value: string, autocomplete: IAutocomplete) => void; placeholder?: string; readOnly?: boolean; - selectedItem?: T; selectOnFocus?: boolean; sortWhenFiltering?: boolean; value?: string; @@ -77,47 +71,8 @@ const useStyles = makeStyles()((theme) => ({ color: theme.palette.error.main, }, }, - item: { - padding: 6, - cursor: "pointer", - minHeight: "100%", - lineHeight: "calc(100% - 10px)", - overflowWrap: "break-word", - color: theme.palette.text.primary, - - // re-establish the styles because the autocomplete is in a Portal - mark: { - backgroundColor: "transparent", - color: theme.palette.info.main, - fontWeight: 700, - }, - }, - itemSelected: { - backgroundColor: alpha( - theme.palette.primary.main, - theme.palette.action.selectedOpacity + theme.palette.action.hoverOpacity, - ), - }, - itemHighlighted: { - backgroundColor: theme.palette.action.hover, - }, })); -function defaultGetText(name: string): (item: unknown) => string { - return function (item: unknown) { - if (typeof item === "string") { - return item; - } else if ( - item != undefined && - typeof item === "object" && - typeof (item as { value?: string }).value === "string" - ) { - return (item as { value: string }).value; - } - throw new Error(`you need to provide an implementation of ${name}`); - }; -} - const EMPTY_SET = new Set(); function itemToFzfResult(item: T): FzfResultItem { @@ -130,13 +85,31 @@ function itemToFzfResult(item: T): FzfResultItem { }; } +// We use fzf to filter the input items to make autocompleteItems so we don't need the +// MuiAutocomplete to also filter the items. Using a passthrough function for filterOptions +// disables the internal filtering of MuiAutocomplete +// +// https://mui.com/material-ui/react-autocomplete/#search-as-you-type +const filterOptions = (options: FzfResultItem[]) => options; + +const getOptionLabel = (item: string | FzfResultItem) => + typeof item === "string" ? item : item.item; + +// The builtin Popper in MuiAutocomplete uses the width hint from the parent Autocomplete to set +// the width. We want to set the minWidth to allow the popper to grow wider than the input field width, +// so we can show long topic paths and autocomplete entries. +const CustomPopper = function (props: PopperProps) { + const width = props.style?.width ?? 0; + return ; +}; + /** * is a Studio-specific wrapper of MUI autocomplete with support * for things like multiple autocompletes that seamlessly transition into each * other, e.g. when building more complex strings like in the Plot panel. */ -export default React.forwardRef(function Autocomplete( - props: AutocompleteProps, +export const Autocomplete = React.forwardRef(function Autocomplete( + props: AutocompleteProps, ref: React.ForwardedRef, ): JSX.Element { const inputRef = useRef(ReactNull); @@ -145,22 +118,9 @@ export default React.forwardRef(function Autocomplete( const [stateValue, setValue] = useState(undefined); - const getItemText = useMemo( - () => props.getItemText ?? defaultGetText("getItemText"), - [props.getItemText], - ); - - const getItemValue = useMemo( - () => props.getItemValue ?? defaultGetText("getItemValue"), - [props.getItemValue], - ); - - // Props const { className, - selectedItem, - // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions - value = stateValue ?? (selectedItem ? getItemText(selectedItem) : undefined), + value = stateValue, disabled, filterText = value ?? "", items, @@ -172,30 +132,27 @@ export default React.forwardRef(function Autocomplete( selectOnFocus, sortWhenFiltering = true, variant = "filled", - }: AutocompleteProps = props; + }: AutocompleteProps = props; const fzfUnfiltered = useMemo(() => { return items.map((item) => itemToFzfResult(item)); }, [items]); const fzf = useMemo(() => { - // @ts-expect-error Fzf selector TS type seems to be wrong? return new Fzf(items, { // v1 algorithm is significantly faster on long lists of items. fuzzy: items.length > FAST_FIND_ITEM_CUTOFF ? "v1" : "v2", sort: sortWhenFiltering, limit: MAX_FZF_MATCHES, - selector: getItemText, }); - }, [getItemText, items, sortWhenFiltering]); + }, [items, sortWhenFiltering]); - const autocompleteItems: FzfResultItem[] = useMemo(() => { + const autocompleteItems = useMemo(() => { return filterText ? fzf.find(filterText) : fzfUnfiltered; }, [filterText, fzf, fzfUnfiltered]); - const hasError = Boolean(props.hasError ?? (autocompleteItems.length === 0 && value?.length)); + const hasError = props.hasError ?? (autocompleteItems.length === 0 && value?.length !== 0); - const selectedItemValue = selectedItem != undefined ? getItemValue(selectedItem) : undefined; const setSelectionRange = useCallback((selectionStart: number, selectionEnd: number): void => { inputRef.current?.focus(); inputRef.current?.setSelectionRange(selectionStart, selectionEnd); @@ -233,7 +190,7 @@ export default React.forwardRef(function Autocomplete( // To allow multiple completions in sequence, it's up to the parent component // to manually blur the input to finish a completion. const onSelect = useCallback( - (_event: SyntheticEvent, selectedValue: ReactNull | string | FzfResultItem): void => { + (_event: SyntheticEvent, selectedValue: ReactNull | string | FzfResultItem): void => { if (selectedValue != undefined && typeof selectedValue !== "string") { setValue(undefined); onSelectCallback(selectedValue.item, { setSelectionRange, focus, blur }); @@ -242,37 +199,18 @@ export default React.forwardRef(function Autocomplete( [onSelectCallback, blur, focus, setSelectionRange], ); - // Blur the input on resize to prevent misalignment of the input field and the - // autocomplete listbox. Debounce to prevent resize observer loop limit errors. - useResizeDetector({ - handleHeight: false, - onResize: () => inputRef.current?.blur(), - refreshMode: "debounce", - refreshRate: 0, - skipOnMount: true, - targetRef: inputRef, - }); - - // Don't filter out options here because we assume that the parent - // component has already filtered them. This allows completing fragments. - const filterOptions = useCallback((options: FzfResultItem[]) => options, []); - return ( ) => { - if (typeof item === "string") { - return item; - } - return getItemValue(item.item); - }} + PopperComponent={CustomPopper} filterOptions={filterOptions} ListboxComponent={ReactWindowListboxAdapter} onChange={onSelect} @@ -291,29 +229,18 @@ export default React.forwardRef(function Autocomplete( size="small" /> )} - renderOption={(optProps, item: FzfResultItem, { selected }) => { - const itemValue = getItemValue(item.item); - return ( - - - - ); + renderOption={(optProps, option: FzfResultItem, state) => { + // The return values of renderOption are passed as the _child_ argument to the ListboxComponent. + // Our ReactWindowListboxAdapter expects a tuple for each item in the list and will itself manage + // when and which items to render using virtualization. + // + // The final as ReactNode cast is to appease the expected return type of renderOption because + // it does not understand that our ListboxAdapter needs a tuple and not a ReactNode + return [optProps, option, state] satisfies ListboxAdapterChild as React.ReactNode; }} selectOnFocus={selectOnFocus} size="small" value={value ?? ReactNull} /> ); -}) as (props: AutocompleteProps & React.RefAttributes) => JSX.Element; // https://stackoverflow.com/a/58473012/23649 +}); diff --git a/packages/studio-base/src/components/Autocomplete/ReactWindowListboxAdapter.tsx b/packages/studio-base/src/components/Autocomplete/ReactWindowListboxAdapter.tsx new file mode 100644 index 0000000000..3f9a947abe --- /dev/null +++ b/packages/studio-base/src/components/Autocomplete/ReactWindowListboxAdapter.tsx @@ -0,0 +1,132 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/ + +import { AutocompleteRenderOptionState, MenuItem } from "@mui/material"; +import { FzfResultItem } from "fzf"; +import { useMemo } from "react"; +import { FixedSizeList, ListChildComponentProps } from "react-window"; +import { makeStyles } from "tss-react/mui"; + +import { HighlightChars } from "@foxglove/studio-base/components/HighlightChars"; + +const Constants = Object.freeze({ + LISTBOX_PADDING: 8, + ROW_HEIGHT: 26, +}); + +const useStyles = makeStyles()((theme) => ({ + item: { + padding: 6, + cursor: "pointer", + minHeight: "100%", + lineHeight: "calc(100% - 10px)", + overflowWrap: "break-word", + color: theme.palette.text.primary, + + // re-establish the styles because the autocomplete is in a Portal + mark: { + backgroundColor: "transparent", + color: theme.palette.info.main, + fontWeight: 700, + }, + }, + itemHighlighted: { + backgroundColor: theme.palette.action.hover, + }, +})); + +/** The type of each child component from the Autocomplete */ +export type ListboxAdapterChild = [ + React.HTMLAttributes, + FzfResultItem, + AutocompleteRenderOptionState, +]; + +/** + * React-window adapter to use a virtualized list as the autocomplete ListboxComponent to support + * lists with thousands of elements without rendering all of them to the DOM. + * + * From the Autocomplete parent it receives a list of children (which must conform to the + * ListboxAdapterChild type), and props to apply to the outer listbox element. + */ +export const ReactWindowListboxAdapter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(function ListboxComponent(props, ref) { + const { children, ...other } = props; + const { className, ...rest } = other; + + const options = children as ListboxAdapterChild[]; + + const longestChild = useMemo( + () => + options.reduce((prev, item) => { + if (item[1].item.length > prev.length) { + return item[1].item; + } + return prev; + }, ""), + [options], + ); + + const totalHeight = + 2 * Constants.LISTBOX_PADDING + Constants.ROW_HEIGHT * Math.min(options.length, 16); + + // The hidden div is a trick to cause the parent div to expand to the width of the longest child + // in the list. Without this, the parent div would have a width of 0 because the FixedSizeList + // places items using position absolute which means they do not impact the size of their parent. + return ( +
+
{longestChild}
+ + height={totalHeight} + itemCount={options.length} + itemData={options} + itemSize={Constants.ROW_HEIGHT} + className={className} + width="100%" + > + {FixedSizeListRenderRow} + +
+ ); +}); + +/** Render an individual row for the FixedSizeList */ +function FixedSizeListRenderRow(props: ListChildComponentProps) { + // data is the array of all items, index is the index of the current row (item), and style + // is the position style for the specific item + const { data, index, style } = props; + const { classes, cx } = useStyles(); + + const dataSet = data[index]; + if (!dataSet) { + return ReactNull; + } + + const inlineStyle = { + ...style, + top: (style.top as number) + Constants.LISTBOX_PADDING, + }; + + const [optProps, item, opt] = dataSet; + const itemValue = item.item; + + return ( +
+ + + +
+ ); +} diff --git a/packages/studio-base/src/components/Autocomplete/index.ts b/packages/studio-base/src/components/Autocomplete/index.ts new file mode 100644 index 0000000000..91ab9f7cab --- /dev/null +++ b/packages/studio-base/src/components/Autocomplete/index.ts @@ -0,0 +1,5 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/ + +export * from "./Autocomplete"; diff --git a/packages/studio-base/src/components/BetaAppMenu.stories.tsx b/packages/studio-base/src/components/BetaAppMenu.stories.tsx deleted file mode 100644 index 5feff53abf..0000000000 --- a/packages/studio-base/src/components/BetaAppMenu.stories.tsx +++ /dev/null @@ -1,135 +0,0 @@ -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/ - -import { - Home20Regular, - Grid20Regular, - RecordStop20Regular, - LineStyle20Regular, - BookStar20Regular, -} from "@fluentui/react-icons"; -import { Meta, StoryObj } from "@storybook/react"; -import * as _ from "lodash-es"; - -import { AppBarMenuItem } from "@foxglove/studio-base/components/AppBar/types"; -import { AppContext } from "@foxglove/studio-base/context/AppContext"; -import PlayerSelectionContext, { - PlayerSelection, -} from "@foxglove/studio-base/context/PlayerSelectionContext"; -import MockCurrentLayoutProvider from "@foxglove/studio-base/providers/CurrentLayoutProvider/MockCurrentLayoutProvider"; -import WorkspaceContextProvider from "@foxglove/studio-base/providers/WorkspaceContextProvider"; - -import { BetaAppMenu, BetaAppMenuProps } from "./BetaAppMenu"; - -type StoryArgs = { - appBarMenuItems?: AppBarMenuItem[]; - recentSources?: PlayerSelection["recentSources"]; -} & BetaAppMenuProps; - -type Story = StoryObj; - -// Connection -const playerSelection: PlayerSelection = { - selectSource: () => {}, - selectRecent: () => {}, - recentSources: [], - availableSources: [], -}; - -export default { - title: "beta/components/AppMenu", - component: BetaAppMenu, - args: { - open: true, - anchorPosition: { top: 0, left: 0 }, - anchorReference: "anchorPosition", - disablePortal: true, - disableAutoFocus: true, - disableAutoFocusItem: true, - handleClose: _.noop, - }, - decorators: [ - (Story, { args: { appBarMenuItems, recentSources = [], ...args } }): JSX.Element => ( - - - - - - - - - - ), - ], -} satisfies Meta; - -export const Default: Story = {}; - -export const DefaultChinese: Story = { - parameters: { forceLanguage: "zh" }, -}; - -export const DefaultJapanese: Story = { - parameters: { forceLanguage: "ja" }, -}; - -const mockSources = [ - // prettier-ignore - { id: "1111", title: "NuScenes-v1.0-mini-scene-0655-reallllllllly-long-name-8829908290831091.mcap", }, - { id: "2222", title: "http://localhost:11311", label: "ROS 1" }, - { id: "3333", title: "ws://localhost:9090/", label: "Rosbridge (ROS 1 & 2)" }, - { id: "4444", title: "ws://localhost:8765", label: "Foxglove WebSocket" }, - { id: "5555", title: "2369", label: "Velodyne Lidar" }, - { id: "6666", title: "THIS ITEM SHOULD BE HIDDEN IN STORYBOOKS", label: "!!!!!!!!!!!!" }, -]; - -export const WithRecents: Story = { - args: { - recentSources: mockSources, - }, -}; - -export const WithRecentsChinese: Story = { - ...WithRecents, - parameters: { forceLanguage: "zh" }, -}; - -export const WithRecentsJapanese: Story = { - ...WithRecents, - parameters: { forceLanguage: "ja" }, -}; - -export const WithAppContextMenuItems: Story = { - args: { - appBarMenuItems: [ - { type: "subheader", key: "recent", label: "Browse" }, - { external: true, type: "item", key: "home", icon: , label: "Home" }, - { external: true, type: "item", key: "devices", icon: , label: "Devices" }, - { - external: true, - type: "item", - key: "recordings", - icon: , - label: "Recordings", - }, - { - external: true, - type: "item", - key: "timeline", - icon: , - label: "Timeline", - }, - { - type: "divider", - }, - { - type: "item", - key: "demo", - icon: , - label: "Explore Sample Data", - }, - ], - recentSources: mockSources, - }, -}; diff --git a/packages/studio-base/src/components/BetaAppMenu.tsx b/packages/studio-base/src/components/BetaAppMenu.tsx deleted file mode 100644 index e0b99d6a16..0000000000 --- a/packages/studio-base/src/components/BetaAppMenu.tsx +++ /dev/null @@ -1,177 +0,0 @@ -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/ - -import { Document20Regular, Flow20Regular, FolderOpen20Regular } from "@fluentui/react-icons"; -import { - Divider, - ListSubheader, - Menu, - MenuItem, - PopoverPosition, - PopoverReference, - Typography, -} from "@mui/material"; -import { useCallback } from "react"; -import { useTranslation } from "react-i18next"; -import { makeStyles } from "tss-react/mui"; - -import TextMiddleTruncate from "@foxglove/studio-base/components/TextMiddleTruncate"; -import { useAnalytics } from "@foxglove/studio-base/context/AnalyticsContext"; -import { useAppContext } from "@foxglove/studio-base/context/AppContext"; -import { useCurrentUserType } from "@foxglove/studio-base/context/CurrentUserContext"; -import { usePlayerSelection } from "@foxglove/studio-base/context/PlayerSelectionContext"; -import { useWorkspaceActions } from "@foxglove/studio-base/context/Workspace/useWorkspaceActions"; -import { AppEvent } from "@foxglove/studio-base/services/IAnalytics"; -import { formatKeyboardShortcut } from "@foxglove/studio-base/util/formatKeyboardShortcut"; - -const useStyles = makeStyles()((theme) => ({ - menuItem: { - gap: theme.spacing(1), - - svg: { - color: theme.palette.primary.main, - flex: "none", - }, - kbd: { - font: "inherit", - color: theme.palette.text.disabled, - }, - }, - menuText: { - display: "flex", - flex: "auto", - overflow: "hidden", - maxWidth: "100%", - alignItems: "center", - gap: theme.spacing(1), - }, - paper: { - minWidth: 240, - maxWidth: 280, - }, -})); - -export type BetaAppMenuProps = { - handleClose: () => void; - anchorEl?: HTMLElement; - anchorReference?: PopoverReference; - anchorPosition?: PopoverPosition; - disablePortal?: boolean; - disableAutoFocus?: boolean; - disableAutoFocusItem?: boolean; - open: boolean; -}; - -export function BetaAppMenu(props: BetaAppMenuProps): JSX.Element { - const { handleClose } = props; - const { classes } = useStyles(); - const { appBarMenuItems } = useAppContext(); - const { recentSources, selectRecent } = usePlayerSelection(); - const { t } = useTranslation("appBar"); - const { dialogActions } = useWorkspaceActions(); - const analytics = useAnalytics(); - const user = useCurrentUserType(); - - const handleAnalytics = useCallback( - (cta: string) => void analytics.logEvent(AppEvent.APP_MENU_CLICK, { user, cta }), - [analytics, user], - ); - - return ( - - - {t("viewData")} - - { - handleAnalytics("open-file"); - dialogActions.openFile.open().catch(console.error); - handleClose(); - }} - > -
- - {t("openLocalFile")} -
- {formatKeyboardShortcut("O", ["Meta"])} -
- { - dialogActions.dataSource.open("connection"); - handleAnalytics("open-connection"); - handleClose(); - }} - > -
- - {t("openConnection")} -
- {formatKeyboardShortcut("O", ["Shift", "Meta"])} -
- {recentSources.length > 0 && } - {recentSources.length > 0 && ( - - {t("recentlyViewed")} - - )} - {recentSources.slice(0, 5).map((source) => ( - { - handleAnalytics("open-recent"); - selectRecent(source.id); - handleClose(); - }} - > -
- - -
-
- ))} - {appBarMenuItems && } - {(appBarMenuItems ?? []).map((item, idx) => { - switch (item.type) { - case "item": - return ( - { - item.onClick?.(event); - handleClose(); - }} - key={item.key} - className={classes.menuItem} - > - {item.icon} - {item.label} - {item.shortcut && {item.shortcut}} - - ); - case "divider": - return ; - case "subheader": - return ( - - {item.label} - - ); - } - })} -
- ); -} diff --git a/packages/studio-base/src/components/Chart/datasets.ts b/packages/studio-base/src/components/Chart/datasets.ts index 82e18d206a..35240ecd61 100644 --- a/packages/studio-base/src/components/Chart/datasets.ts +++ b/packages/studio-base/src/components/Chart/datasets.ts @@ -6,15 +6,7 @@ import * as R from "ramda"; import { TypedData, ObjectData } from "./types"; -export type Point = { index: number; x: number; y: number; label: string | undefined }; - -// Get the length of a typed dataset. -export function getTypedLength(data: TypedData[]): number { - return R.pipe( - R.map((v: TypedData) => v.x.length), - R.sum, - )(data); -} +export type Point = { index: number; x: number; y: number; label?: string | undefined }; /** * iterateObjects iterates over ObjectData, yielding a `Point` for each entry. @@ -85,13 +77,13 @@ export function* iterateTyped | Float32Arr let index = 0; for (const slice of dataset) { // Find a property for which we can check the length - const first = R.head(R.values(slice)); + const first = R.head(Object.values(slice)); if (first == undefined) { continue; } for (let j = 0; j < first.length; j++) { - for (const key of R.keys(slice)) { + for (const key of Object.keys(slice) as (keyof typeof slice)[]) { point[key] = slice[key]?.[j]; } @@ -102,14 +94,12 @@ export function* iterateTyped | Float32Arr } } +export type Indices = [slice: number, offset: number]; /** * Given a dataset and an index inside of that dataset, return the index of the * slice and offset inside of that slice. */ -export function findIndices( - dataset: TypedData[], - index: number, -): [slice: number, offset: number] | undefined { +export function findIndices(dataset: TypedData[], index: number): Indices | undefined { let offset = index; for (let i = 0; i < dataset.length; i++) { const slice = dataset[i]; diff --git a/packages/studio-base/src/components/Chart/index.stories.tsx b/packages/studio-base/src/components/Chart/index.stories.tsx index 8e8e5c8ed8..2377e1677b 100644 --- a/packages/studio-base/src/components/Chart/index.stories.tsx +++ b/packages/studio-base/src/components/Chart/index.stories.tsx @@ -187,6 +187,12 @@ export const AllowsClickingOnDatalabels: StoryObj = { } }, [clickedDatalabel, readySignal]); + const debouncedOnFinish = React.useMemo(() => { + return _.debounce(() => { + doClick(); + }, 3000); + }, [doClick]); + return (
@@ -196,7 +202,11 @@ export const AllowsClickingOnDatalabels: StoryObj = { )}` : "Have not clicked datalabel"}
- +
); }, diff --git a/packages/studio-base/src/components/Chart/index.tsx b/packages/studio-base/src/components/Chart/index.tsx index b487e083b1..268537fea0 100644 --- a/packages/studio-base/src/components/Chart/index.tsx +++ b/packages/studio-base/src/components/Chart/index.tsx @@ -10,6 +10,7 @@ import { ChartOptions } from "chart.js"; import Hammer from "hammerjs"; +import * as R from "ramda"; import { useCallback, useLayoutEffect, useRef, useState } from "react"; import { useMountedState } from "react-use"; import { assert } from "ts-essentials"; @@ -26,6 +27,8 @@ import { mightActuallyBePartial } from "@foxglove/studio-base/util/mightActually import { TypedChartData, ChartData, RpcElement, RpcScales } from "./types"; +type PartialUpdate = Partial; + const log = Logger.getLogger(__filename); function makeChartJSWorker() { @@ -127,7 +130,8 @@ function Chart(props: Props): JSX.Element { const rpcSendRef = useRef(); const hasPannedSinceMouseDown = useRef(false); - const queuedUpdates = useRef[]>([]); + const queuedUpdates = useRef([]); + const isSending = useRef(false); const previousUpdateMessage = useRef>({}); useLayoutEffect(() => { @@ -199,7 +203,7 @@ function Chart(props: Props): JSX.Element { // if they are unchanged from a previous initialization or update. const getNewUpdateMessage = useCallback(() => { const prev = previousUpdateMessage.current; - const out: Partial = {}; + const out: PartialUpdate = {}; // NOTE(Roman): I don't know why this happens but when I initialize a chart using some data // and width/height of 0. Even when I later send an update for correct width/height the chart @@ -237,76 +241,101 @@ function Chart(props: Props): JSX.Element { return out; }, [data, typedData, height, options, isBoundsReset, width]); - // Update the chart with a new set of data - const updateChart = useCallback( - async (update: Partial) => { - // first time initialization - if (!initialized.current) { - assert(canvasRef.current == undefined, "Canvas has already been initialized"); - assert(containerRef.current, "No container ref"); - assert(sendWrapperRef.current, "No RPC"); - - const canvas = document.createElement("canvas"); - canvas.style.width = "100%"; - canvas.style.height = "100%"; - canvas.width = update.width ?? 0; - canvas.height = update.height ?? 0; - containerRef.current.appendChild(canvas); + // Flush all new updates to the worker, coalescing them together if there is + // more than one. + const flushUpdates = useCallback( + async (send: RpcSend | undefined) => { + if (send == undefined || isSending.current) { + return; + } - canvasRef.current = canvas; - initialized.current = true; + isSending.current = true; - onStartRender?.(); - const offscreenCanvas = - typeof canvas.transferControlToOffscreen === "function" - ? canvas.transferControlToOffscreen() - : canvas; - const scales = await sendWrapperRef.current( - "initialize", - { - node: offscreenCanvas, - type, - data: update.data, - typedData: update.typedData, - options: update.options, - devicePixelRatio, - width: update.width, - height: update.height, - }, - [ - // If this is actually a HTMLCanvasElement then it will not be transferred because we - // don't use a worker - offscreenCanvas as OffscreenCanvas, - ], - ); - - // Flush any updates that occurred before the worker was initialized - const { current: queued } = queuedUpdates; - queuedUpdates.current = []; - for (const queuedUpdate of queued) { - const newScales = await sendWrapperRef.current("update", queuedUpdate); - maybeUpdateScales(newScales); + while (queuedUpdates.current.length > 0) { + const { current: updates } = queuedUpdates; + if (updates.length === 0) { + break; } - // once we are initialized, we can allow other handlers to send to the rpc endpoint - rpcSendRef.current = sendWrapperRef.current; - + // We merge all of the pending updates together to do as few renders as + // possible when we fall behind + const coalesced = R.mergeAll(updates); + onStartRender?.(); + const scales = await send("update", coalesced); maybeUpdateScales(scales); onFinishRender?.(); - return; + queuedUpdates.current = queuedUpdates.current.slice(updates.length); } - if (!rpcSendRef.current) { + isSending.current = false; + }, + [maybeUpdateScales, onFinishRender, onStartRender], + ); + + // Update the chart with a new set of data + const updateChart = useCallback( + async (update: PartialUpdate) => { + if (initialized.current) { queuedUpdates.current = [...queuedUpdates.current, update]; + await flushUpdates(rpcSendRef.current); return; } + // first time initialization + assert(canvasRef.current == undefined, "Canvas has already been initialized"); + assert(containerRef.current, "No container ref"); + assert(sendWrapperRef.current, "No RPC"); + + const canvas = document.createElement("canvas"); + canvas.style.width = "100%"; + canvas.style.height = "100%"; + canvas.width = update.width ?? 0; + canvas.height = update.height ?? 0; + containerRef.current.appendChild(canvas); + + canvasRef.current = canvas; + initialized.current = true; + onStartRender?.(); - const scales = await rpcSendRef.current("update", update); + const offscreenCanvas = + typeof canvas.transferControlToOffscreen === "function" + ? canvas.transferControlToOffscreen() + : canvas; + const scales = await sendWrapperRef.current( + "initialize", + { + node: offscreenCanvas, + type, + data: update.data, + typedData: update.typedData, + options: update.options, + devicePixelRatio, + width: update.width, + height: update.height, + }, + [ + // If this is actually a HTMLCanvasElement then it will not be transferred because we + // don't use a worker + offscreenCanvas as OffscreenCanvas, + ], + ); maybeUpdateScales(scales); onFinishRender?.(); + + // We cannot rely solely on the call to `initialize`, since it doesn't + // actually produce the first frame. However, if we append this update to + // the end, it will overwrite updates that have been queued _since we + // started initializing_. This is incorrect behavior and can set the + // scales incorrectly on weak devices. + // + // To prevent this from happening, we put this update at the beginning of + // the queue so that it gets coalesced properly. + queuedUpdates.current = [update, ...queuedUpdates.current]; + await flushUpdates(sendWrapperRef.current); + // once we are initialized, we can allow other handlers to send to the rpc endpoint + rpcSendRef.current = sendWrapperRef.current; }, - [maybeUpdateScales, onFinishRender, onStartRender, type], + [maybeUpdateScales, onFinishRender, onStartRender, type, flushUpdates], ); const [updateError, setUpdateError] = useState(); diff --git a/packages/studio-base/src/components/Chart/types.ts b/packages/studio-base/src/components/Chart/types.ts index a5830ac757..87cff55aaf 100644 --- a/packages/studio-base/src/components/Chart/types.ts +++ b/packages/studio-base/src/components/Chart/types.ts @@ -18,6 +18,10 @@ type Datum = ScatterDataPoint & { value?: string | number | bigint | boolean; // Constant name for the datum (used by state transitions) constantName?: string | undefined; + + // Contains all of the distinct states present in this line segment. Only for + // state transition data. + states?: string[]; }; export type ObjectData = (Datum | typeof ChartNull)[]; export type ChartData = ChartJsChartData<"scatter", ObjectData>; diff --git a/packages/studio-base/src/components/Chart/worker/ChartJSManager.ts b/packages/studio-base/src/components/Chart/worker/ChartJSManager.ts index 4f5f75047e..4a971a2d1a 100644 --- a/packages/studio-base/src/components/Chart/worker/ChartJSManager.ts +++ b/packages/studio-base/src/components/Chart/worker/ChartJSManager.ts @@ -11,7 +11,16 @@ // found at http://www.apache.org/licenses/LICENSE-2.0 // You may not use this file except in compliance with the License. -import { Chart, ChartData, ChartOptions, ChartType } from "chart.js"; +import { + Chart, + ChartData, + ChartOptions, + ChartType, + Interaction, + InteractionModeFunction, + InteractionItem, +} from "chart.js"; +import { getRelativePosition } from "chart.js/helpers"; import type { Context as DatalabelContext } from "chartjs-plugin-datalabels"; import DatalabelPlugin from "chartjs-plugin-datalabels"; import { type Options as DatalabelsPluginOptions } from "chartjs-plugin-datalabels/types/options"; @@ -69,6 +78,41 @@ type ZoomableChart = Chart & { }; }; +declare module "chart.js" { + interface InteractionModeMap { + lastX: InteractionModeFunction; + } +} + +// A custom interaction mode that returns the items before an x cursor position. This mode is +// used by the state transition panel to show a tooltip of the current state "between" state datapoints. +// +// Built-in chartjs interaction of nearest is not sufficient because it snaps forward whereas we only +// want to look backwards at the state we are currently in. +// +// See: https://www.chartjs.org/docs/latest/configuration/interactions.html#custom-interaction-modes +const lastX: InteractionModeFunction = (chart, event, _options, useFinalPosition) => { + // Suppress the type error on the _chart_ type. Chartjs types are broken for the + // `getRelativePosition` function which seems to use a different declaration of the Chart type + // than what is exported from chart.js. + // + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any + const position = getRelativePosition(event, chart as any); + + // Create a sparse array to track the last datum for each dataset + const datasetIndexToLastItem: InteractionItem[] = []; + Interaction.evaluateInteractionItems(chart, "x", position, (element, datasetIndex, index) => { + const center = element.getCenterPoint(useFinalPosition); + if (center.x <= position.x) { + datasetIndexToLastItem[datasetIndex] = { element, datasetIndex, index }; + } + }); + // Filter unused entries from the sparse array + return datasetIndexToLastItem.filter(Boolean); +}; + +Interaction.modes.lastX = lastX; + export default class ChartJSManager { #chartInstance?: Chart; #fakeNodeEvents = new EventEmitter(); @@ -273,9 +317,7 @@ export default class ChartJSManager { if (data != undefined) { instance.data = data; - } - - if (typedData != undefined) { + } else if (typedData != undefined) { instance.data = proxyTyped(typedData); } diff --git a/packages/studio-base/src/components/Chart/worker/ChartJsMux.ts b/packages/studio-base/src/components/Chart/worker/ChartJsMux.ts index 63a0bdd376..a2261ee094 100644 --- a/packages/studio-base/src/components/Chart/worker/ChartJsMux.ts +++ b/packages/studio-base/src/components/Chart/worker/ChartJsMux.ts @@ -59,7 +59,10 @@ type RpcUpdateEvent = { // crash in skia code related to DirectWrite font loading when the system display scaling is set // >100%. For more info on this crash, see util/waitForFonts.ts. async function loadDefaultFont(): Promise { - const fontFace = new FontFace("IBM Plex Mono", `url(${PlexMono}) format('woff2')`); + // Passing a `url(data:...) format('woff2')` string does not work in Safari, which complains it + // cannot load the data url due to it being cross-origin. + // https://bugs.webkit.org/show_bug.cgi?id=265000 + const fontFace = new FontFace("IBM Plex Mono", await (await fetch(PlexMono)).arrayBuffer()); if (typeof WorkerGlobalScope !== "undefined" && self instanceof WorkerGlobalScope) { (self as unknown as WorkerGlobalScope).fonts.add(fontFace); } else { diff --git a/packages/studio-base/src/components/CreateEventDialog.tsx b/packages/studio-base/src/components/CreateEventDialog.tsx index 234c3b30bb..781171e8c8 100644 --- a/packages/studio-base/src/components/CreateEventDialog.tsx +++ b/packages/studio-base/src/components/CreateEventDialog.tsx @@ -7,17 +7,19 @@ import RemoveIcon from "@mui/icons-material/Remove"; import { Alert, Button, + ButtonGroup, CircularProgress, Dialog, DialogActions, + DialogContent, + DialogTitle, + FormControl, + FormLabel, + IconButton, TextField, ToggleButton, ToggleButtonGroup, Typography, - FormLabel, - FormControl, - IconButton, - ButtonGroup, } from "@mui/material"; import * as _ from "lodash-es"; import { KeyboardEvent, useCallback } from "react"; @@ -196,10 +198,8 @@ export function CreateEventDialog(props: { onClose: () => void }): JSX.Element { return ( - - Create event - - + Create event +
Start Time @@ -249,59 +249,71 @@ export function CreateEventDialog(props: { onClose: () => void }): JSX.Element {
-
- - Metadata -
- {event.metadataEntries.map(({ key, value }, index) => { - const hasDuplicate = ((key.length > 0 ? countedMetadata[key] : undefined) ?? 0) > 1; - return ( -
- { - updateMetadata(index, "key", evt.currentTarget.value); - }} - /> - { - updateMetadata(index, "value", evt.currentTarget.value); - }} - /> - - { - addRow(index); +
+ Metadata +
+ {event.metadataEntries.map(({ key, value }, index) => { + const hasDuplicate = ((key.length > 0 ? countedMetadata[key] : undefined) ?? 0) > 1; + return ( +
+ { + updateMetadata(index, "key", evt.currentTarget.value); }} - > - - - { - removeRow(index); + /> + { + updateMetadata(index, "value", evt.currentTarget.value); }} - style={{ visibility: event.metadataEntries.length > 1 ? "visible" : "hidden" }} - > - - - -
- ); - })} + /> + + { + addRow(index); + }} + > + + + { + removeRow(index); + }} + style={{ + visibility: event.metadataEntries.length > 1 ? "visible" : "hidden", + }} + > + + + +
+ ); + })} +
- + + {duplicateKey && ( + + Duplicate key {duplicateKey[0]} + + )} + {createdEvent.error?.message && ( + + {createdEvent.error.message} + + )} - {duplicateKey && Duplicate key {duplicateKey[0]}} - {createdEvent.error?.message && {createdEvent.error.message}}
); } diff --git a/packages/studio-base/src/components/CssBaseline.stories.tsx b/packages/studio-base/src/components/CssBaseline.stories.tsx index 0e4ac64ff8..0b1c2bcd4b 100644 --- a/packages/studio-base/src/components/CssBaseline.stories.tsx +++ b/packages/studio-base/src/components/CssBaseline.stories.tsx @@ -27,28 +27,3 @@ export const Scrollbars: StoryObj = { ); }, }; - -export const FontFeatureSettings: StoryObj = { - render: () => { - // See https://github.com/foxglove/studio/pull/5113#discussion_r1106619194 - return ( - - cv08: I -
- cv10: G -
- 显示时间戳在 -
- ); - }, -}; - -export const FontFeatureSettingsChinese: StoryObj = { - ...FontFeatureSettings, - parameters: { forceLanguage: "zh" }, -}; - -export const FontFeatureSettingsJapanese: StoryObj = { - ...FontFeatureSettings, - parameters: { forceLanguage: "ja" }, -}; diff --git a/packages/studio-base/src/components/CssBaseline.tsx b/packages/studio-base/src/components/CssBaseline.tsx index 8bb37d4878..d9a56e21e9 100644 --- a/packages/studio-base/src/components/CssBaseline.tsx +++ b/packages/studio-base/src/components/CssBaseline.tsx @@ -76,6 +76,9 @@ const useStyles = makeStyles()(({ palette, typography }) => ({ canvas: { outline: "none", }, + th: { + textAlign: "inherit", + }, // mosaic styling ".mosaic": { diff --git a/packages/studio-base/src/components/CurrentLayoutLocalStorageSyncAdapter.tsx b/packages/studio-base/src/components/CurrentLayoutLocalStorageSyncAdapter.tsx index 516725764d..4cfce99515 100644 --- a/packages/studio-base/src/components/CurrentLayoutLocalStorageSyncAdapter.tsx +++ b/packages/studio-base/src/components/CurrentLayoutLocalStorageSyncAdapter.tsx @@ -7,6 +7,7 @@ import { useEffect } from "react"; import { useDebounce } from "use-debounce"; import Log from "@foxglove/log"; +import { LOCAL_STORAGE_STUDIO_LAYOUT_KEY } from "@foxglove/studio-base/constants/localStorageKeys"; import { LayoutState, useCurrentLayoutActions, @@ -23,8 +24,6 @@ function selectLayoutData(state: LayoutState) { const log = Log.getLogger(__filename); -const KEY = "studio.layout"; - export function CurrentLayoutLocalStorageSyncAdapter(): JSX.Element { const { selectedSource } = usePlayerSelection(); @@ -46,13 +45,13 @@ export function CurrentLayoutLocalStorageSyncAdapter(): JSX.Element { const serializedLayoutData = JSON.stringify(debouncedLayoutData); assert(serializedLayoutData); - localStorage.setItem(KEY, serializedLayoutData); + localStorage.setItem(LOCAL_STORAGE_STUDIO_LAYOUT_KEY, serializedLayoutData); }, [debouncedLayoutData]); useEffect(() => { - log.debug(`Reading layout from local storage: ${KEY}`); + log.debug(`Reading layout from local storage: ${LOCAL_STORAGE_STUDIO_LAYOUT_KEY}`); - const serializedLayoutData = localStorage.getItem(KEY); + const serializedLayoutData = localStorage.getItem(LOCAL_STORAGE_STUDIO_LAYOUT_KEY); if (serializedLayoutData) { log.debug("Restoring layout from local storage"); diff --git a/packages/studio-base/src/components/DataSourceDialog/DataSourceDialog.tsx b/packages/studio-base/src/components/DataSourceDialog/DataSourceDialog.tsx index 118a82ef54..934c6fe3cd 100644 --- a/packages/studio-base/src/components/DataSourceDialog/DataSourceDialog.tsx +++ b/packages/studio-base/src/components/DataSourceDialog/DataSourceDialog.tsx @@ -4,7 +4,7 @@ import CloseIcon from "@mui/icons-material/Close"; import { Dialog, IconButton } from "@mui/material"; -import { useCallback, useLayoutEffect, useMemo } from "react"; +import { useCallback, useLayoutEffect, useMemo, useRef } from "react"; import { useMountedState } from "react-use"; import { makeStyles } from "tss-react/mui"; @@ -63,7 +63,13 @@ export function DataSourceDialog(props: DataSourceDialogProps): JSX.Element { dialogActions.dataSource.close(); }, [analytics, activeDataSource, dialogActions.dataSource]); + const prevActiveViewRef = useRef(); useLayoutEffect(() => { + if (activeView === prevActiveViewRef.current) { + // Only run actions below when the active view actually changed + return; + } + prevActiveViewRef.current = activeView; if (activeView === "file") { dialogActions.openFile .open() diff --git a/packages/studio-base/src/components/DataSourceDialog/Start.stories.tsx b/packages/studio-base/src/components/DataSourceDialog/Start.stories.tsx index 9567f66975..8f14eceb4a 100644 --- a/packages/studio-base/src/components/DataSourceDialog/Start.stories.tsx +++ b/packages/studio-base/src/components/DataSourceDialog/Start.stories.tsx @@ -5,10 +5,10 @@ import { StoryFn, StoryObj } from "@storybook/react"; import { ReactNode } from "react"; -import CurrentUserContext, { +import BaseUserContext, { CurrentUser, - User, -} from "@foxglove/studio-base/context/CurrentUserContext"; + UserType, +} from "@foxglove/studio-base/context/BaseUserContext"; import PlayerSelectionContext, { PlayerSelection, } from "@foxglove/studio-base/context/PlayerSelectionContext"; @@ -48,25 +48,6 @@ export default { decorators: [Wrapper], }; -function fakeUser(type: "free" | "paid" | "enterprise"): User { - return { - id: "user-1", - email: "user@example.com", - orgId: "org_id", - orgDisplayName: "Orgalorg", - orgSlug: "org", - orgPaid: type === "paid" || type === "enterprise", - org: { - id: "org_id", - slug: "org", - displayName: "Orgalorg", - isEnterprise: type === "enterprise", - allowsUploads: true, - supportsEdgeSites: type === "enterprise", - }, - }; -} - // Connection const playerSelection: PlayerSelection = { selectSource: () => {}, @@ -93,8 +74,8 @@ const playerSelection: PlayerSelection = { }, { id: "5555", - title: "2369", - label: "Velodyne Lidar", + title: "ws://1.2.3.4:8765", + label: "Foxglove WebSocket", }, { id: "6666", @@ -122,13 +103,16 @@ const playerSelection: PlayerSelection = { ], }; -function CurrentUserWrapper(props: { children: ReactNode; user?: User | undefined }): JSX.Element { +function CurrentUserWrapper(props: { + children: ReactNode; + userType?: UserType | undefined; +}): JSX.Element { const value: CurrentUser = { - currentUser: props.user, + currentUserType: props.userType ?? "unauthenticated", signIn: () => undefined, signOut: async () => undefined, }; - return {props.children}; + return {props.children}; } const Default = (): JSX.Element => ; @@ -195,10 +179,8 @@ export const UserPrivateJapanese: StoryObj = { export const UserAuthedFree: StoryObj = { render: () => { - const freeUser = fakeUser("free"); - return ( - + @@ -222,10 +204,8 @@ export const UserAuthedFreeJapanese: StoryObj = { export const UserAuthedPaid: StoryObj = { render: () => { - const freeUser = fakeUser("paid"); - return ( - + diff --git a/packages/studio-base/src/components/DataSourceDialog/Start.tsx b/packages/studio-base/src/components/DataSourceDialog/Start.tsx index 8e99d4f076..866a823dcc 100644 --- a/packages/studio-base/src/components/DataSourceDialog/Start.tsx +++ b/packages/studio-base/src/components/DataSourceDialog/Start.tsx @@ -13,10 +13,7 @@ import FoxgloveLogoText from "@foxglove/studio-base/components/FoxgloveLogoText" import Stack from "@foxglove/studio-base/components/Stack"; import TextMiddleTruncate from "@foxglove/studio-base/components/TextMiddleTruncate"; import { useAnalytics } from "@foxglove/studio-base/context/AnalyticsContext"; -import { - useCurrentUser, - useCurrentUserType, -} from "@foxglove/studio-base/context/CurrentUserContext"; +import { useCurrentUser } from "@foxglove/studio-base/context/BaseUserContext"; import { usePlayerSelection } from "@foxglove/studio-base/context/PlayerSelectionContext"; import { useWorkspaceActions } from "@foxglove/studio-base/context/Workspace/useWorkspaceActions"; import { AppEvent } from "@foxglove/studio-base/services/IAnalytics"; @@ -170,8 +167,7 @@ function SidebarItems(props: { onSelectView: (newValue: DataSourceDialogItem) => void; }): JSX.Element { const { onSelectView } = props; - const { signIn } = useCurrentUser(); - const currentUserType = useCurrentUserType(); + const { currentUserType, signIn } = useCurrentUser(); const analytics = useAnalytics(); const { classes } = useStyles(); const { t } = useTranslation("openDialog"); @@ -198,7 +194,7 @@ function SidebarItems(props: { {t("exploreSampleData")} - - {extension.name} - -
- - - - - - {extension.id} - - {`v${extension.version}`} - - {extension.license} - - - - {extension.publisher} - - - {extension.description} - - - {isInstalled && canUninstall ? ( - - ) : ( - canInstall && ( - - ) - )} - - - - { - setActiveTab(newValue); - }} - > - - - - - - - - {activeTab === 0 && {readmeContent}} - {activeTab === 1 && {changelogContent}} - - - ); -} diff --git a/packages/studio-base/src/components/ExtensionsSettings/index.stories.tsx b/packages/studio-base/src/components/ExtensionsSettings/index.stories.tsx deleted file mode 100644 index ba1e6c2515..0000000000 --- a/packages/studio-base/src/components/ExtensionsSettings/index.stories.tsx +++ /dev/null @@ -1,128 +0,0 @@ -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/ -// -// This file incorporates work covered by the following copyright and -// permission notice: -// -// Copyright 2019-2021 Cruise LLC -// -// This source code is licensed under the Apache License, Version 2.0, -// found at http://www.apache.org/licenses/LICENSE-2.0 -// You may not use this file except in compliance with the License. - -import { StoryObj } from "@storybook/react"; -import { useState } from "react"; - -import { ExtensionInfo, ExtensionLoader } from "@foxglove/studio-base"; -import ExtensionsSettings from "@foxglove/studio-base/components/ExtensionsSettings"; -import AppConfigurationContext from "@foxglove/studio-base/context/AppConfigurationContext"; -import ExtensionMarketplaceContext, { - ExtensionMarketplace, -} from "@foxglove/studio-base/context/ExtensionMarketplaceContext"; -import ExtensionCatalogProvider from "@foxglove/studio-base/providers/ExtensionCatalogProvider"; -import { makeMockAppConfiguration } from "@foxglove/studio-base/util/makeMockAppConfiguration"; - -export default { - title: "components/ExtensionsSettings", - component: ExtensionsSettings, -}; - -const installedExtensions: ExtensionInfo[] = [ - { - id: "publisher.storyextension", - name: "privatestoryextension", - qualifiedName: "storyextension", - displayName: "Private Extension Name", - description: "Private extension sample description", - publisher: "Private Publisher", - homepage: "https://foxglove.dev/", - license: "MIT", - version: "1.2.10", - keywords: ["storybook", "testing"], - namespace: "org", - }, - { - id: "publisher.storyextension", - name: "storyextension", - qualifiedName: "storyextension", - displayName: "Extension Name", - description: "Extension sample description", - publisher: "Publisher", - homepage: "https://foxglove.dev/", - license: "MIT", - version: "1.2.10", - keywords: ["storybook", "testing"], - namespace: "local", - }, -]; - -const marketplaceExtensions: ExtensionInfo[] = [ - { - id: "publisher.storyextension", - name: "storyextension", - qualifiedName: "storyextension", - displayName: "Extension Name", - description: "Extension sample description", - publisher: "Publisher", - homepage: "https://foxglove.dev/", - license: "MIT", - version: "1.2.10", - keywords: ["storybook", "testing"], - }, -]; - -const MockExtensionLoader: ExtensionLoader = { - namespace: "local", - getExtensions: async () => installedExtensions, - loadExtension: async (_id: string) => "", - installExtension: async (_foxeFileData: Uint8Array) => { - throw new Error("MockExtensionLoader cannot install extensions"); - }, - uninstallExtension: async (_id: string) => undefined, -}; - -const MockExtensionMarketplace: ExtensionMarketplace = { - getAvailableExtensions: async () => marketplaceExtensions, - getMarkdown: async (url: string) => `# Markdown -Mock markdown rendering for URL [${url}](${url}).`, -}; - -export const Sidebar: StoryObj = { - render: function Story() { - const [config] = useState(() => makeMockAppConfiguration()); - - return ( - - - - - - - - ); - }, -}; - -export const WithoutNetwork: StoryObj = { - render: function Story() { - const [config] = useState(() => makeMockAppConfiguration()); - - const marketPlace = { - ...MockExtensionMarketplace, - getAvailableExtensions: () => { - throw new Error("offline"); - }, - }; - - return ( - - - - - - - - ); - }, -}; diff --git a/packages/studio-base/src/components/ExtensionsSettings/index.tsx b/packages/studio-base/src/components/ExtensionsSettings/index.tsx deleted file mode 100644 index 95bfe5348b..0000000000 --- a/packages/studio-base/src/components/ExtensionsSettings/index.tsx +++ /dev/null @@ -1,227 +0,0 @@ -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/ - -import { - Alert, - AlertTitle, - Button, - List, - ListItem, - ListItemButton, - ListItemText, - Typography, -} from "@mui/material"; -import * as _ from "lodash-es"; -import { useEffect, useMemo, useState } from "react"; -import { useAsyncFn } from "react-use"; -import { makeStyles } from "tss-react/mui"; - -import Log from "@foxglove/log"; -import { Immutable } from "@foxglove/studio"; -import { ExtensionDetails } from "@foxglove/studio-base/components/ExtensionDetails"; -import Stack from "@foxglove/studio-base/components/Stack"; -import { useExtensionCatalog } from "@foxglove/studio-base/context/ExtensionCatalogContext"; -import { - ExtensionMarketplaceDetail, - useExtensionMarketplace, -} from "@foxglove/studio-base/context/ExtensionMarketplaceContext"; - -const log = Log.getLogger(__filename); - -const useStyles = makeStyles()((theme) => ({ - listItemButton: { - "&:hover": { color: theme.palette.primary.main }, - }, -})); - -function displayNameForNamespace(namespace: string): string { - switch (namespace) { - case "org": - return "Organization"; - default: - return namespace; - } -} - -function ExtensionListEntry(props: { - entry: Immutable; - onClick: () => void; -}): JSX.Element { - const { - entry: { id, description, name, publisher, version }, - onClick, - } = props; - const { classes } = useStyles(); - return ( - - - - - {name} - - - {version} - - - } - secondary={ - - - {description} - - - {publisher} - - - } - /> - - - ); -} - -export default function ExtensionsSettings(): React.ReactElement { - const [focusedExtension, setFocusedExtension] = useState< - | { - installed: boolean; - entry: Immutable; - } - | undefined - >(undefined); - const installed = useExtensionCatalog((state) => state.installedExtensions); - const marketplace = useExtensionMarketplace(); - - const [marketplaceEntries, refreshMarketplaceEntries] = useAsyncFn( - async () => await marketplace.getAvailableExtensions(), - [marketplace], - ); - - const marketplaceMap = useMemo( - () => _.keyBy(marketplaceEntries.value ?? [], (entry) => entry.id), - [marketplaceEntries], - ); - - const installedEntries = useMemo( - () => - (installed ?? []).map((entry) => { - const marketplaceEntry = marketplaceMap[entry.id]; - if (marketplaceEntry != undefined) { - return { ...marketplaceEntry, namespace: entry.namespace }; - } - - return { - id: entry.id, - installed: true, - name: entry.displayName, - displayName: entry.displayName, - description: entry.description, - publisher: entry.publisher, - homepage: entry.homepage, - license: entry.license, - version: entry.version, - keywords: entry.keywords, - namespace: entry.namespace, - qualifiedName: entry.qualifiedName, - }; - }), - [installed, marketplaceMap], - ); - - const namespacedEntries = useMemo( - () => _.groupBy(installedEntries, (entry) => entry.namespace), - [installedEntries], - ); - - // Hide installed extensions from the list of available extensions - const filteredMarketplaceEntries = useMemo( - () => - _.differenceWith( - marketplaceEntries.value ?? [], - installed ?? [], - (a, b) => a.id === b.id && a.namespace === b.namespace, - ), - [marketplaceEntries, installed], - ); - - useEffect(() => { - refreshMarketplaceEntries().catch((error) => { - log.error(error); - }); - }, [refreshMarketplaceEntries]); - - if (focusedExtension != undefined) { - return ( - { - setFocusedExtension(undefined); - }} - /> - ); - } - - return ( - - {marketplaceEntries.error && ( - await refreshMarketplaceEntries()}> - Retry - - } - > - Failed to retrieve the list of available marketplace extensions - Check your internet connection and try again. - - )} - {!_.isEmpty(namespacedEntries) ? ( - Object.entries(namespacedEntries).map(([namespace, entries]) => ( - - - - {displayNameForNamespace(namespace)} - - - {entries.map((entry) => ( - { - setFocusedExtension({ installed: true, entry }); - }} - /> - ))} - - )) - ) : ( - - - - - - )} - - - - Available - - - {filteredMarketplaceEntries.map((entry) => ( - { - setFocusedExtension({ installed: false, entry }); - }} - /> - ))} - - - ); -} diff --git a/packages/studio-base/src/components/GlobalCss.tsx b/packages/studio-base/src/components/GlobalCss.tsx index 60f7559612..ba2f2170c4 100644 --- a/packages/studio-base/src/components/GlobalCss.tsx +++ b/packages/studio-base/src/components/GlobalCss.tsx @@ -37,6 +37,7 @@ export default function GlobalCss(): JSX.Element { // scrollable elements to be scrolled without the whole page moving (even if they don't // preventDefault on scroll events). overscrollBehavior: "none", + overflow: "hidden", }, "#root": { height: "100%", diff --git a/packages/studio-base/src/components/MemoryUseIndicator.tsx b/packages/studio-base/src/components/MemoryUseIndicator.tsx deleted file mode 100644 index 3c8d6d94ad..0000000000 --- a/packages/studio-base/src/components/MemoryUseIndicator.tsx +++ /dev/null @@ -1,61 +0,0 @@ -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/ - -import { Tooltip, Typography } from "@mui/material"; -import { makeStyles } from "tss-react/mui"; - -import { useMemoryInfo } from "@foxglove/hooks"; - -const useStyles = makeStyles()((theme) => ({ - root: { - display: "flex", - alignItems: "center", - justifyContent: "center", - flexDirection: "column", - position: "relative", - width: 50, - flex: "1 0 50px", - fontSize: theme.typography.caption.fontSize, - overflow: "hidden", - }, - label: { - fontWeight: "bold", - }, - text: { - lineHeight: 1.1, - }, -})); - -function toMB(bytes: number): number { - return bytes / 1024 / 1024; -} - -function MemoryUseIndicator(): JSX.Element { - const memoryInfo = useMemoryInfo({ refreshIntervalMs: 5000 }); - const { classes, cx } = useStyles(); - - // If we can't load memory info (maybe not supported) then we don't show any indicator - if (!memoryInfo) { - return <>; - } - - const usedPercent = (memoryInfo.usedJSHeapSize / memoryInfo.jsHeapSizeLimit) * 100; - const usedMb = toMB(memoryInfo.usedJSHeapSize).toLocaleString(); - const limitMb = toMB(memoryInfo.jsHeapSizeLimit).toLocaleString(); - - return ( - -
- - MEM - - - {usedPercent.toFixed(2)}% - -
-
- ); -} - -export { MemoryUseIndicator }; diff --git a/packages/studio-base/src/components/MessagePathSyntax/MessagePathInput.stories.tsx b/packages/studio-base/src/components/MessagePathSyntax/MessagePathInput.stories.tsx index 0bdfcee9f9..1f5c9d9dc6 100644 --- a/packages/studio-base/src/components/MessagePathSyntax/MessagePathInput.stories.tsx +++ b/packages/studio-base/src/components/MessagePathSyntax/MessagePathInput.stories.tsx @@ -56,7 +56,6 @@ function MessagePathInputStory(props: { { it("correctly returns true/false depending on whether a global variable has a default", () => { @@ -26,7 +27,7 @@ describe("tryToSetDefaultGlobalVar", () => { describe("getFirstInvalidVariableFromRosPath", () => { it("returns all possible message paths when not passing in `validTypes`", () => { const setGlobalVars = jest.fn(); - const rosPath: RosPath = { + const rosPath: MessagePath = { topicName: "/some_topic", topicNameRepr: "/some_topic", messagePath: [ diff --git a/packages/studio-base/src/components/MessagePathSyntax/MessagePathInput.tsx b/packages/studio-base/src/components/MessagePathSyntax/MessagePathInput.tsx index f91b278f85..ce33193bec 100644 --- a/packages/studio-base/src/components/MessagePathSyntax/MessagePathInput.tsx +++ b/packages/studio-base/src/components/MessagePathSyntax/MessagePathInput.tsx @@ -16,17 +16,19 @@ import * as _ from "lodash-es"; import { CSSProperties, useCallback, useMemo } from "react"; import { makeStyles } from "tss-react/mui"; -import { MessageDefinitionField } from "@foxglove/message-definition"; -import { Immutable } from "@foxglove/studio"; +import { filterMap } from "@foxglove/den/collection"; +import { + quoteTopicNameIfNeeded, + parseMessagePath, + MessagePath, + PrimitiveType, +} from "@foxglove/message-path"; import * as PanelAPI from "@foxglove/studio-base/PanelAPI"; -import Autocomplete, { IAutocomplete } from "@foxglove/studio-base/components/Autocomplete"; +import { Autocomplete, IAutocomplete } from "@foxglove/studio-base/components/Autocomplete"; import useGlobalVariables, { GlobalVariables, } from "@foxglove/studio-base/hooks/useGlobalVariables"; -import { Topic } from "@foxglove/studio-base/players/types"; -import { RosDatatypes } from "@foxglove/studio-base/types/RosDatatypes"; -import { RosPath, RosPrimitive } from "./constants"; import { traverseStructure, messagePathStructures, @@ -34,75 +36,6 @@ import { validTerminatingStructureItem, StructureTraversalResult, } from "./messagePathsForDatatype"; -import parseRosPath, { quoteFieldNameIfNeeded, quoteTopicNameIfNeeded } from "./parseRosPath"; - -// To show an input field with an autocomplete so the user can enter message paths, use: -// -// this.setState({ path })} /> -// -// To limit the autocomplete items to only certain types of values, you can use -// -// -// -// Or use actual ROS primitive types: -// -// -// -// If you are rendering many input fields, you might want to use ``, -// which gets passed down to `` as the second parameter, so you can -// avoid creating anonymous functions on every render (which will prevent the component from -// rendering unnecessarily). - -// Get a list of Message Path strings for all of the fields (recursively) in a list of topics -function getFieldPaths( - topics: Immutable, - datatypes: Immutable, -): Map { - const output = new Map(); - for (const topic of topics) { - if (topic.schemaName == undefined) { - continue; - } - addFieldPathsForType( - quoteTopicNameIfNeeded(topic.name), - topic.schemaName, - datatypes, - [], - output, - ); - } - return output; -} - -function addFieldPathsForType( - curPath: string, - typeName: string, - datatypes: Immutable, - seenTypes: string[], - output: Map>, -): void { - const msgdef = datatypes.get(typeName); - if (msgdef) { - for (const field of msgdef.definitions) { - if (seenTypes.includes(field.type)) { - continue; - } - if (field.isConstant !== true) { - const fieldPath = `${curPath}.${quoteFieldNameIfNeeded(field.name)}`; - output.set(fieldPath, field); - if (field.isComplex === true) { - addFieldPathsForType( - fieldPath, - field.type, - datatypes, - [...seenTypes, field.type], - output, - ); - } - } - } - } -} export function tryToSetDefaultGlobalVar( variableName: string, @@ -118,7 +51,7 @@ export function tryToSetDefaultGlobalVar( } export function getFirstInvalidVariableFromRosPath( - rosPath: RosPath, + rosPath: MessagePath, globalVariables: GlobalVariables, setGlobalVariables: (arg0: GlobalVariables) => void, ): { variableName: string; loc: number } | undefined { @@ -147,7 +80,7 @@ export function getFirstInvalidVariableFromRosPath( }).filter(({ variableName }) => !tryToSetDefaultGlobalVar(variableName, setGlobalVariables))[0]; } -function getExamplePrimitive(primitiveType: RosPrimitive) { +function getExamplePrimitive(primitiveType: PrimitiveType) { switch (primitiveType) { case "string": return '""'; @@ -174,7 +107,6 @@ type MessagePathInputBaseProps = { onChange: (value: string, index?: number) => void; validTypes?: readonly string[]; // Valid types, like "message", "array", or "primitive", or a ROS primitive like "float64" noMultiSlices?: boolean; // Don't suggest slices with multiple values `[:]`, only single values like `[0]`. - autoSize?: boolean; placeholder?: string; inputStyle?: CSSProperties; disabled?: boolean; @@ -188,6 +120,24 @@ const useStyles = makeStyles()({ root: { flexGrow: 1 }, }); +/** + * To show an input field with an autocomplete so the user can enter message paths, use: + * + * this.setState({ path })} /> + * + * To limit the autocomplete items to only certain types of values, you can use + * + * + * + * Or use actual ROS primitive types: + * + * + * + * If you are rendering many input fields, you might want to use ``, + * which gets passed down to `` as the second parameter, so you can + * avoid creating anonymous functions on every render (which will prevent the component from + * rendering unnecessarily). + */ export default React.memo(function MessagePathInput( props: MessagePathInputBaseProps, ) { @@ -199,7 +149,6 @@ export default React.memo(function MessagePathInput( path, prioritizedDatatype, validTypes, - autoSize, placeholder, noMultiSlices, inputStyle, @@ -207,7 +156,38 @@ export default React.memo(function MessagePathInput( variant = "standard", } = props; const { classes } = useStyles(); - const topicFields = useMemo(() => getFieldPaths(topics, datatypes), [datatypes, topics]); + + const messagePathStructuresForDataype = useMemo( + () => messagePathStructures(datatypes), + [datatypes], + ); + /** A map from each possible message path to the corresponding MessagePathStructureItem */ + const allStructureItemsByPath = useMemo( + () => + new Map( + topics.flatMap((topic) => { + if (topic.schemaName == undefined) { + return []; + } + const structureItem = messagePathStructuresForDataype[topic.schemaName]; + if (structureItem == undefined) { + return []; + } + const allPaths = messagePathsForStructure(structureItem, { + validTypes, + noMultiSlices, + }); + return filterMap(allPaths, (item) => { + if (item.path === "") { + // Plain topic items will be added via `topicNamesAutocompleteItems` + return undefined; + } + return [quoteTopicNameIfNeeded(topic.name) + item.path, item.terminatingStructureItem]; + }); + }), + ), + [messagePathStructuresForDataype, noMultiSlices, topics, validTypes], + ); const onChangeProp = props.onChange; const onChange = useCallback( @@ -240,8 +220,9 @@ export default React.memo(function MessagePathInput( // Check if accepting this completion would result in a path to a non-complex field. const completedPath = completeStart + rawValue + completeEnd; - const completedField = topicFields.get(completedPath); - const isSimpleField = completedField != undefined && completedField.isComplex !== true; + const completedField = allStructureItemsByPath.get(completedPath); + const isSimpleField = + completedField != undefined && completedField.structureType === "primitive"; // If we're dealing with a topic name, and we cannot validly end in a message type, // add a "." so the user can keep typing to autocomplete the message path. @@ -265,10 +246,10 @@ export default React.memo(function MessagePathInput( autocomplete.blur(); } }, - [onChangeProp, path, props.index, topicFields, validTypes], + [onChangeProp, path, props.index, allStructureItemsByPath, validTypes], ); - const rosPath = useMemo(() => parseRosPath(path), [path]); + const rosPath = useMemo(() => parseMessagePath(path), [path]); const topic = useMemo(() => { if (!rosPath) { @@ -279,11 +260,6 @@ export default React.memo(function MessagePathInput( return topics.find(({ name }) => name === topicName); }, [rosPath, topics]); - const messagePathStructuresForDataype = useMemo( - () => messagePathStructures(datatypes), - [datatypes], - ); - const structureTraversalResult = useMemo((): StructureTraversalResult | undefined => { if (!topic || !rosPath?.messagePath) { return undefined; @@ -319,8 +295,8 @@ export default React.memo(function MessagePathInput( ); const topicNamesAndFieldsAutocompleteItems = useMemo( - () => topicNamesAutocompleteItems.concat(Array.from(topicFields.keys())), - [topicFields, topicNamesAutocompleteItems], + () => topicNamesAutocompleteItems.concat(Array.from(allStructureItemsByPath.keys())), + [allStructureItemsByPath, topicNamesAutocompleteItems], ); const autocompleteType = useMemo(() => { @@ -404,13 +380,13 @@ export default React.memo(function MessagePathInput( autocompleteItems: structure == undefined ? [] - : messagePathsForStructure(structure, { - validTypes, - noMultiSlices, - messagePath: rosPath.messagePath, - }).filter( - // .header.seq is pretty useless but shows up everryyywhere. - (msgPath) => msgPath !== "" && !msgPath.endsWith(".header.seq"), + : filterMap( + messagePathsForStructure(structure, { + validTypes, + noMultiSlices, + messagePath: rosPath.messagePath, + }), + (item) => item.path, ), autocompleteRange: { @@ -501,7 +477,6 @@ export default React.memo(function MessagePathInput( placeholder={ placeholder != undefined && placeholder !== "" ? placeholder : "/some/topic.msgs[0].field" } - autoSize={autoSize} inputStyle={inputStyle} // Disable autoselect since people often construct complex queries, and it's very annoying // to have the entire input selected whenever you want to make a change to a part it. disableAutoSelect diff --git a/packages/studio-base/src/components/MessagePathSyntax/filterMatches.ts b/packages/studio-base/src/components/MessagePathSyntax/filterMatches.ts index 8f6ba7622f..dbb3098968 100644 --- a/packages/studio-base/src/components/MessagePathSyntax/filterMatches.ts +++ b/packages/studio-base/src/components/MessagePathSyntax/filterMatches.ts @@ -2,9 +2,10 @@ // License, v2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/ -import { MessagePathFilter } from "./constants"; +import { MessagePathFilter } from "@foxglove/message-path"; +import { Immutable } from "@foxglove/studio"; -export function filterMatches(filter: MessagePathFilter, value: unknown): boolean { +export function filterMatches(filter: Immutable, value: unknown): boolean { if (typeof filter.value === "object") { throw new Error("filterMatches only works on paths where global variables have been filled in"); } diff --git a/packages/studio-base/src/components/MessagePathSyntax/fixture.ts b/packages/studio-base/src/components/MessagePathSyntax/fixture.ts index e2d23cc544..87bbc4443c 100644 --- a/packages/studio-base/src/components/MessagePathSyntax/fixture.ts +++ b/packages/studio-base/src/components/MessagePathSyntax/fixture.ts @@ -72,12 +72,16 @@ export const MessagePathInputStoryFixture: Fixture = { { name: "foo_id", type: "uint32", isArray: false }, ], }, + "msgs/StateData": { + definitions: [{ name: "value", type: "float32", isArray: false }], + }, "msgs/OtherState": { definitions: [ { name: "id", type: "int32", isArray: false }, { name: "speed", type: "float32", isArray: false }, { name: "name", type: "string", isArray: false }, { name: "valid", type: "bool", isArray: false }, + { name: "data", type: "msgs/StateData", isArray: true }, ], }, "msgs/Log": { diff --git a/packages/studio-base/src/components/MessagePathSyntax/messagePathsForDatatype.test.ts b/packages/studio-base/src/components/MessagePathSyntax/messagePathsForDatatype.test.ts index 5c14050a9f..044d28672f 100644 --- a/packages/studio-base/src/components/MessagePathSyntax/messagePathsForDatatype.test.ts +++ b/packages/studio-base/src/components/MessagePathSyntax/messagePathsForDatatype.test.ts @@ -12,6 +12,7 @@ // You may not use this file except in compliance with the License. import { unwrap } from "@foxglove/den/monads"; +import { parseMessagePath } from "@foxglove/message-path"; import { RosDatatypes } from "@foxglove/studio-base/types/RosDatatypes"; import { @@ -428,7 +429,9 @@ describe("messagePathsForStructure", () => { const structures = messagePathStructures(datatypes); it("returns all possible message paths when not passing in `validTypes`", () => { - expect(messagePathsForStructure(unwrap(structures["pose_msgs/PoseDebug"]))).toEqual([ + expect( + messagePathsForStructure(unwrap(structures["pose_msgs/PoseDebug"])).map(({ path }) => path), + ).toEqual([ "", ".header", ".header.frame_id", @@ -447,37 +450,40 @@ describe("messagePathsForStructure", () => { ".some_pose.header.stamp.sec", ".some_pose.x", ]); - expect(messagePathsForStructure(unwrap(structures["msgs/Log"]))).toEqual(["", ".id"]); + expect( + messagePathsForStructure(unwrap(structures["msgs/Log"])).map(({ path }) => path), + ).toEqual(["", ".id"]); - expect(messagePathsForStructure(unwrap(structures["tf/tfMessage"]))).toEqual([ + expect( + messagePathsForStructure(unwrap(structures["tf/tfMessage"])).map(({ path }) => path), + ).toEqual([ "", ".transforms", - ".transforms[0]", - ".transforms[0].child_frame_id", - ".transforms[0].header", - ".transforms[0].header.frame_id", - ".transforms[0].header.seq", - ".transforms[0].header.stamp", - ".transforms[0].header.stamp.nsec", - ".transforms[0].header.stamp.sec", - ".transforms[0].transform", - ".transforms[0].transform.rotation", - ".transforms[0].transform.translation", + '.transforms[:]{child_frame_id==""}', + '.transforms[:]{child_frame_id==""}.child_frame_id', + '.transforms[:]{child_frame_id==""}.header', + '.transforms[:]{child_frame_id==""}.header.frame_id', + '.transforms[:]{child_frame_id==""}.header.seq', + '.transforms[:]{child_frame_id==""}.header.stamp', + '.transforms[:]{child_frame_id==""}.header.stamp.nsec', + '.transforms[:]{child_frame_id==""}.header.stamp.sec', + '.transforms[:]{child_frame_id==""}.transform', + '.transforms[:]{child_frame_id==""}.transform.rotation', + '.transforms[:]{child_frame_id==""}.transform.translation', ]); - expect(messagePathsForStructure(unwrap(structures["visualization_msgs/MarkerArray"]))).toEqual([ - "", - ".markers", - ".markers[:]{id==0}", - ".markers[:]{id==0}.id", - ]); + expect( + messagePathsForStructure(unwrap(structures["visualization_msgs/MarkerArray"])).map( + ({ path }) => path, + ), + ).toEqual(["", ".markers", ".markers[:]{id==0}", ".markers[:]{id==0}.id"]); }); it("returns an array of possible message paths for the given `validTypes`", () => { expect( messagePathsForStructure(unwrap(structures["pose_msgs/PoseDebug"]), { validTypes: ["float64"], - }), + }).map(({ path }) => path), ).toEqual([".some_pose.dummy_array[:]", ".some_pose.x"]); }); @@ -486,9 +492,51 @@ describe("messagePathsForStructure", () => { messagePathsForStructure(unwrap(structures["pose_msgs/PoseDebug"]), { validTypes: ["float64"], noMultiSlices: true, - }), + }).map(({ path }) => path), ).toEqual([".some_pose.dummy_array[0]", ".some_pose.x"]); }); + + it("preserves existing filters matching isTypicalFilterName", () => { + expect( + messagePathsForStructure(unwrap(structures["tf/tfMessage"]), { + messagePath: parseMessagePath('/tf.transforms[:]{child_frame_id=="foo"}')!.messagePath, + }).map(({ path }) => path), + ).toEqual([ + "", + ".transforms", + '.transforms[:]{child_frame_id=="foo"}', + '.transforms[:]{child_frame_id=="foo"}.child_frame_id', + '.transforms[:]{child_frame_id=="foo"}.header', + '.transforms[:]{child_frame_id=="foo"}.header.frame_id', + '.transforms[:]{child_frame_id=="foo"}.header.seq', + '.transforms[:]{child_frame_id=="foo"}.header.stamp', + '.transforms[:]{child_frame_id=="foo"}.header.stamp.nsec', + '.transforms[:]{child_frame_id=="foo"}.header.stamp.sec', + '.transforms[:]{child_frame_id=="foo"}.transform', + '.transforms[:]{child_frame_id=="foo"}.transform.rotation', + '.transforms[:]{child_frame_id=="foo"}.transform.translation', + ]); + + expect( + messagePathsForStructure(unwrap(structures["tf/tfMessage"]), { + messagePath: parseMessagePath("/tf.transforms[:]{header.stamp.sec==0}")!.messagePath, + }).map(({ path }) => path), + ).toEqual([ + "", + ".transforms", + '.transforms[:]{child_frame_id==""}', + '.transforms[:]{child_frame_id==""}.child_frame_id', + '.transforms[:]{child_frame_id==""}.header', + '.transforms[:]{child_frame_id==""}.header.frame_id', + '.transforms[:]{child_frame_id==""}.header.seq', + '.transforms[:]{child_frame_id==""}.header.stamp', + '.transforms[:]{child_frame_id==""}.header.stamp.nsec', + '.transforms[:]{child_frame_id==""}.header.stamp.sec', + '.transforms[:]{child_frame_id==""}.transform', + '.transforms[:]{child_frame_id==""}.transform.rotation', + '.transforms[:]{child_frame_id==""}.transform.translation', + ]); + }); }); describe("validTerminatingStructureItem", () => { diff --git a/packages/studio-base/src/components/MessagePathSyntax/messagePathsForDatatype.ts b/packages/studio-base/src/components/MessagePathSyntax/messagePathsForDatatype.ts index 93ed4c5624..0620aad249 100644 --- a/packages/studio-base/src/components/MessagePathSyntax/messagePathsForDatatype.ts +++ b/packages/studio-base/src/components/MessagePathSyntax/messagePathsForDatatype.ts @@ -13,22 +13,20 @@ import * as _ from "lodash-es"; +import { + MessagePathFilter, + quoteFieldNameIfNeeded, + MessagePathPart, + PrimitiveType, + MessagePathStructureItem, + MessagePathStructureItemMessage, +} from "@foxglove/message-path"; import { Immutable } from "@foxglove/studio"; -import { MessagePathFilter } from "@foxglove/studio-base/components/MessagePathSyntax/constants"; import { isTypicalFilterName } from "@foxglove/studio-base/components/MessagePathSyntax/isTypicalFilterName"; -import { quoteFieldNameIfNeeded } from "@foxglove/studio-base/components/MessagePathSyntax/parseRosPath"; import { RosDatatypes } from "@foxglove/studio-base/types/RosDatatypes"; import { assertNever } from "@foxglove/studio-base/util/assertNever"; import naturalSort from "@foxglove/studio-base/util/naturalSort"; -import { - MessagePathPart, - rosPrimitives, - RosPrimitive, - MessagePathStructureItem, - MessagePathStructureItemMessage, -} from "./constants"; - const STRUCTURE_ITEM_INTEGER_TYPES = [ "int8", "uint8", @@ -40,8 +38,25 @@ const STRUCTURE_ITEM_INTEGER_TYPES = [ "uint64", ]; -function isRosPrimitive(type: string): type is RosPrimitive { - return rosPrimitives.includes(type as RosPrimitive); +function isPrimitiveType(type: string): type is PrimitiveType { + // casting _as_ PrimitiveType here to have typescript error if add a case to the union + switch (type as PrimitiveType) { + case "bool": + case "int8": + case "uint8": + case "int16": + case "uint16": + case "int32": + case "uint32": + case "int64": + case "uint64": + case "float32": + case "float64": + case "string": + return true; + } + + return false; } function structureItemIsIntegerPrimitive(item: MessagePathStructureItem) { @@ -112,7 +127,7 @@ export function messagePathStructures( continue; } - const next: MessagePathStructureItem = isRosPrimitive(msgField.type) + const next: MessagePathStructureItem = isPrimitiveType(msgField.type) ? { structureType: "primitive", primitiveType: msgField.type, @@ -151,8 +166,10 @@ export function validTerminatingStructureItem( ); } -// Given a datatype, the array of datatypes, and a list of valid types, -// list out all valid strings for a MessagePathStructure. +/** + * Given a datatype, the array of datatypes, and a list of valid types, list out all valid strings + * for a MessagePathStructure and its corresponding structure item. + */ export function messagePathsForStructure( structure: MessagePathStructureItemMessage, { @@ -164,12 +181,12 @@ export function messagePathsForStructure( noMultiSlices?: boolean; messagePath?: MessagePathPart[]; } = {}, -): string[] { +): { path: string; terminatingStructureItem: MessagePathStructureItem }[] { let clonedMessagePath = [...messagePath]; - const messagePaths: string[] = []; + const messagePaths: { path: string; terminatingStructureItem: MessagePathStructureItem }[] = []; function traverse(structureItem: MessagePathStructureItem, builtString: string) { if (validTerminatingStructureItem(structureItem, validTypes)) { - messagePaths.push(builtString); + messagePaths.push({ path: builtString, terminatingStructureItem: structureItem }); } if (structureItem.structureType === "message") { for (const [name, item] of Object.entries(structureItem.nextByName)) { @@ -198,16 +215,14 @@ export function messagePathsForStructure( clonedMessagePath = clonedMessagePath.filter( (pathPart) => pathPart !== matchingFilterPart, ); - traverse( - structureItem.next, - `${builtString}[:]{${typicalFilterName}==${ - typeof matchingFilterPart.value === "object" - ? `$${matchingFilterPart.value.variableName}` - : matchingFilterPart.value - }}`, - ); + traverse(structureItem.next, `${builtString}[:]{${matchingFilterPart.repr}}`); } else if (structureItemIsIntegerPrimitive(typicalFilterValue)) { traverse(structureItem.next, `${builtString}[:]{${typicalFilterName}==0}`); + } else if ( + typicalFilterValue.structureType === "primitive" && + typicalFilterValue.primitiveType === "string" + ) { + traverse(structureItem.next, `${builtString}[:]{${typicalFilterName}==""}`); } else { traverse(structureItem.next, `${builtString}[0]`); } @@ -225,7 +240,7 @@ export function messagePathsForStructure( } traverse(structure, ""); - return messagePaths.sort(naturalSort()); + return messagePaths.sort(naturalSort("path")); } export type StructureTraversalResult = { diff --git a/packages/studio-base/src/components/MessagePathSyntax/simpleGetMessagePathDataItems.test.ts b/packages/studio-base/src/components/MessagePathSyntax/simpleGetMessagePathDataItems.test.ts index 8b3eb21499..1d7e4adbb5 100644 --- a/packages/studio-base/src/components/MessagePathSyntax/simpleGetMessagePathDataItems.test.ts +++ b/packages/studio-base/src/components/MessagePathSyntax/simpleGetMessagePathDataItems.test.ts @@ -2,9 +2,9 @@ // License, v2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/ +import { parseMessagePath } from "@foxglove/message-path"; import { MessageEvent } from "@foxglove/studio"; -import parseRosPath from "./parseRosPath"; import { simpleGetMessagePathDataItems } from "./simpleGetMessagePathDataItems"; describe("simpleGetMessagePathDataItems", () => { @@ -16,8 +16,10 @@ describe("simpleGetMessagePathDataItems", () => { schemaName: "datatype", message: { foo: 42 }, }; - expect(simpleGetMessagePathDataItems(message, parseRosPath("/foo")!)).toEqual([{ foo: 42 }]); - expect(simpleGetMessagePathDataItems(message, parseRosPath("/bar")!)).toEqual([]); + expect(simpleGetMessagePathDataItems(message, parseMessagePath("/foo")!)).toEqual([ + { foo: 42 }, + ]); + expect(simpleGetMessagePathDataItems(message, parseMessagePath("/bar")!)).toEqual([]); }); it("supports TypedArray messages", () => { @@ -30,10 +32,10 @@ describe("simpleGetMessagePathDataItems", () => { bar: new Uint32Array([3, 4, 5]), }, }; - expect(simpleGetMessagePathDataItems(message, parseRosPath("/foo.bar")!)).toEqual([ + expect(simpleGetMessagePathDataItems(message, parseMessagePath("/foo.bar")!)).toEqual([ new Uint32Array([3, 4, 5]), ]); - expect(simpleGetMessagePathDataItems(message, parseRosPath("/foo.bar[0]")!)).toEqual([3]); + expect(simpleGetMessagePathDataItems(message, parseMessagePath("/foo.bar[0]")!)).toEqual([3]); }); it("returns correct nested values", () => { @@ -54,19 +56,19 @@ describe("simpleGetMessagePathDataItems", () => { }; expect( - simpleGetMessagePathDataItems(message, parseRosPath("/foo.foo.bars[:]{id==1}")!), + simpleGetMessagePathDataItems(message, parseMessagePath("/foo.foo.bars[:]{id==1}")!), ).toEqual([ { id: 1, name: "bar1" }, { id: 1, name: "bar1-2" }, ]); expect( - simpleGetMessagePathDataItems(message, parseRosPath("/foo.foo.bars[:]{id==1}.name")!), + simpleGetMessagePathDataItems(message, parseMessagePath("/foo.foo.bars[:]{id==1}.name")!), ).toEqual(["bar1", "bar1-2"]); expect( - simpleGetMessagePathDataItems(message, parseRosPath("/foo.foo.bars[:]{id==2}")!), + simpleGetMessagePathDataItems(message, parseMessagePath("/foo.foo.bars[:]{id==2}")!), ).toEqual([{ id: 2, name: "bar2" }]); expect( - simpleGetMessagePathDataItems(message, parseRosPath("/foo.foo.bars[:]{id==2}.name")!), + simpleGetMessagePathDataItems(message, parseMessagePath("/foo.foo.bars[:]{id==2}.name")!), ).toEqual(["bar2"]); }); @@ -78,7 +80,9 @@ describe("simpleGetMessagePathDataItems", () => { schemaName: "datatype", message: { foo: 1 }, }; - expect(simpleGetMessagePathDataItems(message, parseRosPath("/foo.foo.baz.hello")!)).toEqual([]); + expect(simpleGetMessagePathDataItems(message, parseMessagePath("/foo.foo.baz.hello")!)).toEqual( + [], + ); }); it("throws for unsupported paths", () => { @@ -99,10 +103,10 @@ describe("simpleGetMessagePathDataItems", () => { }; expect(() => - simpleGetMessagePathDataItems(message, parseRosPath("/foo.foo.bars[:]{id==$id}")!), + simpleGetMessagePathDataItems(message, parseMessagePath("/foo.foo.bars[:]{id==$id}")!), ).toThrow("filterMatches only works on paths where global variables have been filled in"); expect(() => - simpleGetMessagePathDataItems(message, parseRosPath("/foo.foo.bars[$id]")!), + simpleGetMessagePathDataItems(message, parseMessagePath("/foo.foo.bars[$id]")!), ).toThrow("Variables in slices are not supported"); }); }); diff --git a/packages/studio-base/src/components/MessagePathSyntax/simpleGetMessagePathDataItems.ts b/packages/studio-base/src/components/MessagePathSyntax/simpleGetMessagePathDataItems.ts index c1100f65b1..9760bdaa4f 100644 --- a/packages/studio-base/src/components/MessagePathSyntax/simpleGetMessagePathDataItems.ts +++ b/packages/studio-base/src/components/MessagePathSyntax/simpleGetMessagePathDataItems.ts @@ -2,18 +2,19 @@ // License, v2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/ +import { MessagePath } from "@foxglove/message-path"; +import { Immutable } from "@foxglove/studio"; import { MessageEvent } from "@foxglove/studio-base/players/types"; import { isTypedArray } from "@foxglove/studio-base/types/isTypedArray"; -import { RosPath } from "./constants"; import { filterMatches } from "./filterMatches"; /** * Execute the given message path to extract item(s) from the message. */ export function simpleGetMessagePathDataItems( - message: MessageEvent, - filledInPath: RosPath, + message: Immutable, + filledInPath: Immutable, ): unknown[] { // We don't care about messages that don't match the topic we're looking for. if (message.topic !== filledInPath.topicName) { diff --git a/packages/studio-base/src/components/MessagePathSyntax/stringifyRosPath.test.ts b/packages/studio-base/src/components/MessagePathSyntax/stringifyRosPath.test.ts new file mode 100644 index 0000000000..01c8ccb64c --- /dev/null +++ b/packages/studio-base/src/components/MessagePathSyntax/stringifyRosPath.test.ts @@ -0,0 +1,47 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/ + +import { parseMessagePath } from "@foxglove/message-path"; +import { fillInGlobalVariablesInPath } from "@foxglove/studio-base/components/MessagePathSyntax/useCachedGetMessagePathDataItems"; + +import { stringifyMessagePath } from "./stringifyRosPath"; + +describe("stringifyRosPath", () => { + const paths = [ + "/some0/nice_topic.with[99].stuff[0]", + "/some0/nice_topic.with[99].stuff[0].@derivative", + "some0/nice_topic.with[99].stuff[0]", + "some_nice_topic", + String.raw`"/foo/bar".baz`, + String.raw`"\"".baz`, + "/topic.foo[0].bar", + "/topic.foo[1:3].bar", + "/topic.foo[1:].bar", + "/topic.foo[:10].bar", + "/topic.foo[:].bar", + "/topic.foo[$a].bar", + "/topic.foo[$a:$b].bar", + "/topic.foo[$a:5].bar", + "/topic.foo[$a:].bar", + '/topic.foo{bar=="baz"}.a{bar=="baz"}.b{bar==3}.c{bar==-1}.d{bar==false}.e[:]{bar.baz==true}', + '/topic{foo=="bar"}{baz==2}.a[3].b{x=="y"}', + "/topic.foo{bar==$}.a{bar.baz==$my_var_1}", + ]; + it.each(paths)("returns original string for: %s", (str) => { + expect(stringifyMessagePath(parseMessagePath(str)!)).toEqual(str); + }); + + it.each([ + { path: "/topic.foo[$num1].bar", expected: "/topic.foo[1].bar" }, + { path: "/topic.foo[$num1:$num2].bar", expected: "/topic.foo[1:2].bar" }, + { path: "/topic.foo{bar==$num1}.baz", expected: "/topic.foo{bar==1}.baz" }, + { path: "/topic.foo{bar==$str}.baz", expected: '/topic.foo{bar=="foo"}.baz' }, + ])("turns $path with variables into $expected", ({ path, expected }) => { + // note: only string and number are currently supported by fillInGlobalVariablesInPath + const globalVariables = { str: "foo", num1: 1, num2: 2 }; + expect( + stringifyMessagePath(fillInGlobalVariablesInPath(parseMessagePath(path)!, globalVariables)), + ).toEqual(expected); + }); +}); diff --git a/packages/studio-base/src/components/MessagePathSyntax/stringifyRosPath.ts b/packages/studio-base/src/components/MessagePathSyntax/stringifyRosPath.ts new file mode 100644 index 0000000000..888d5498ca --- /dev/null +++ b/packages/studio-base/src/components/MessagePathSyntax/stringifyRosPath.ts @@ -0,0 +1,79 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/ + +import { MessagePathFilter, MessagePathPart, MessagePath } from "@foxglove/message-path"; +import { Immutable } from "@foxglove/studio"; + +type SlicePart = number | { variableName: string; startLoc: number }; + +type Slice = { + start: SlicePart; + end: SlicePart; +}; + +/** + * Return the string representation of the ros path + */ +export function stringifyMessagePath(path: Immutable): string { + return ( + path.topicNameRepr + + path.messagePath.map(stringifyMessagePathPart).join("") + + (path.modifier ? `.@${path.modifier}` : "") + ); +} + +function stringifyMessagePathPart(part: Immutable): string { + switch (part.type) { + case "name": + return `.${part.repr}`; + case "filter": + return filterToString(part); + case "slice": + return sliceToString(part); + } + return ""; +} + +function sliceToString(slice: Immutable): string { + if (typeof slice.start === "number" && typeof slice.end === "number") { + if (slice.start === slice.end) { + return `[${slice.start}]`; + } + if (slice.start === 0) { + return `[:${slice.end === Infinity ? "" : slice.end}]`; + } + return `[${slice.start === Infinity ? "" : slice.start}:${ + slice.end === Infinity ? "" : slice.end + }]`; + } + + const startStr = slicePartToString(slice.start); + const endStr = slicePartToString(slice.end); + if (startStr === endStr) { + return `[${startStr}]`; + } + + return `[${startStr}:${endStr}]`; +} + +function slicePartToString(slicePart: Immutable): string { + if (typeof slicePart === "number") { + if (slicePart === Infinity) { + return ""; + } + return String(slicePart); + } + + return `$${slicePart.variableName}`; +} + +function filterToString(filter: Immutable): string { + if (typeof filter.value === "object") { + return `{${filter.repr}}`; + } + + return `{${filter.path.join(".")}==${ + typeof filter.value === "bigint" ? filter.value.toString() : JSON.stringify(filter.value) + }}`; +} diff --git a/packages/studio-base/src/components/MessagePathSyntax/useCachedGetMessagePathDataItems.test.tsx b/packages/studio-base/src/components/MessagePathSyntax/useCachedGetMessagePathDataItems.test.tsx index 3b16e01580..bbf6ecff9f 100644 --- a/packages/studio-base/src/components/MessagePathSyntax/useCachedGetMessagePathDataItems.test.tsx +++ b/packages/studio-base/src/components/MessagePathSyntax/useCachedGetMessagePathDataItems.test.tsx @@ -15,8 +15,8 @@ import { renderHook } from "@testing-library/react"; import * as _ from "lodash-es"; +import { parseMessagePath } from "@foxglove/message-path"; import { messagePathStructures } from "@foxglove/studio-base/components/MessagePathSyntax/messagePathsForDatatype"; -import parseRosPath from "@foxglove/studio-base/components/MessagePathSyntax/parseRosPath"; import MockMessagePipelineProvider from "@foxglove/studio-base/components/MessagePipeline/MockMessagePipelineProvider"; import { MessageEvent, Topic } from "@foxglove/studio-base/players/types"; import MockCurrentLayoutProvider from "@foxglove/studio-base/providers/CurrentLayoutProvider/MockCurrentLayoutProvider"; @@ -37,7 +37,7 @@ function addValuesWithPathsToItems( datatypes: RosDatatypes, ) { return messages.map((message) => { - const rosPath = parseRosPath(messagePath); + const rosPath = parseMessagePath(messagePath); if (!rosPath) { return undefined; } @@ -173,6 +173,80 @@ describe("useCachedGetMessagePathDataItems", () => { ]); }); + it("returns items even when a schema is not available", () => { + const messages: MessageEvent[] = [ + { + topic: "/some/topic", + receiveTime: { sec: 0, nsec: 0 }, + message: { some_num: 1, some_array: [{ some_id: 10, some_message: { x: 10, y: 20 } }] }, + schemaName: "datatype", + sizeInBytes: 0, + }, + { + topic: "/some/topic", + receiveTime: { sec: 0, nsec: 0 }, + message: { + some_num: 2, + some_array: [ + { some_id: 10, some_message: { x: 10, y: 20 } }, + { some_id: 50, some_message: { x: 50, y: 60 } }, + ], + }, + schemaName: "datatype", + sizeInBytes: 0, + }, + ]; + const topics: Topic[] = [{ name: "/some/topic", schemaName: undefined }]; + const datatypes: RosDatatypes = new Map(); + + expect( + addValuesWithPathsToItems(messages, "/some/topic.some_num", topics, datatypes), + ).toEqual([ + [ + { + value: 1, + path: "/some/topic.some_num", + constantName: undefined, + }, + ], + [ + { + value: 2, + path: "/some/topic.some_num", + constantName: undefined, + }, + ], + ]); + expect( + addValuesWithPathsToItems( + messages, + "/some/topic.some_array[:].some_message", + topics, + datatypes, + ), + ).toEqual([ + [ + { + value: { x: 10, y: 20 }, + path: "/some/topic.some_array[0].some_message", + constantName: undefined, + }, + ], + [ + { + value: { x: 10, y: 20 }, + path: "/some/topic.some_array[0].some_message", + constantName: undefined, + }, + { + value: { x: 50, y: 60 }, + path: "/some/topic.some_array[1].some_message", + constantName: undefined, + }, + ], + ]); + }); + it("works with negative slices", () => { const messages: MessageEvent[] = [ { @@ -624,6 +698,36 @@ describe("fillInGlobalVariablesInPath", () => { ], }); }); + + // This test captures current behavior, but in the future we might want to add support for boolean values. + it("does not fill in boolean values", () => { + expect( + fillInGlobalVariablesInPath( + { + topicName: "/foo", + topicNameRepr: "/foo", + messagePath: [ + { + type: "filter", + path: ["bar"], + value: { variableName: "var", startLoc: 0 }, + nameLoc: 0, + valueLoc: 0, + repr: "", + }, + ], + modifier: undefined, + }, + { var: true }, + ), + ).toEqual({ + topicName: "/foo", + topicNameRepr: "/foo", + messagePath: [ + { type: "filter", path: ["bar"], value: undefined, nameLoc: 0, valueLoc: 0, repr: "" }, + ], + }); + }); }); describe("useDecodeMessagePathsForMessagesByTopic", () => { diff --git a/packages/studio-base/src/components/MessagePathSyntax/useCachedGetMessagePathDataItems.ts b/packages/studio-base/src/components/MessagePathSyntax/useCachedGetMessagePathDataItems.ts index 4c46624592..cd7424d2aa 100644 --- a/packages/studio-base/src/components/MessagePathSyntax/useCachedGetMessagePathDataItems.ts +++ b/packages/studio-base/src/components/MessagePathSyntax/useCachedGetMessagePathDataItems.ts @@ -16,6 +16,13 @@ import { useCallback, useMemo } from "react"; import { filterMap } from "@foxglove/den/collection"; import { useDeepMemo, useShallowMemo } from "@foxglove/hooks"; +import { + quoteTopicNameIfNeeded, + parseMessagePath, + MessagePathStructureItem, + MessagePathStructureItemMessage, + MessagePath, +} from "@foxglove/message-path"; import { Immutable } from "@foxglove/studio"; import * as PanelAPI from "@foxglove/studio-base/PanelAPI"; import useGlobalVariables, { @@ -28,11 +35,9 @@ import { extractTypeFromStudioEnumAnnotation, } from "@foxglove/studio-base/util/enums"; -import { MessagePathStructureItem, MessagePathStructureItemMessage, RosPath } from "./constants"; import { filterMatches } from "./filterMatches"; import { TypicalFilterNames } from "./isTypicalFilterName"; import { messagePathStructures } from "./messagePathsForDatatype"; -import parseRosPath, { quoteTopicNameIfNeeded } from "./parseRosPath"; type ValueInMapRecord = T extends Map ? I : never; @@ -54,15 +59,15 @@ export function useCachedGetMessagePathDataItems( const parsedPaths = useMemo(() => { return filterMap(memoizedPaths, (path) => { - const rosPath = parseRosPath(path); - return rosPath ? ([path, rosPath] satisfies [string, RosPath]) : undefined; + const rosPath = parseMessagePath(path); + return rosPath ? ([path, rosPath] satisfies [string, MessagePath]) : undefined; }); }, [memoizedPaths]); // We first fill in global variables in the paths, so we can later see which paths have really // changed when the global variables have changed. const unmemoizedFilledInPaths = useMemo(() => { - const filledInPaths: Record = {}; + const filledInPaths: Record = {}; for (const [path, parsedPath] of parsedPaths) { filledInPaths[path] = fillInGlobalVariablesInPath(parsedPath, globalVariables); } @@ -152,9 +157,9 @@ export function useCachedGetMessagePathDataItems( } export function fillInGlobalVariablesInPath( - rosPath: RosPath, + rosPath: MessagePath, globalVariables: GlobalVariables, -): RosPath { +): MessagePath { return { ...rosPath, messagePath: rosPath.messagePath.map((messagePathPart) => { @@ -191,7 +196,7 @@ export function fillInGlobalVariablesInPath( // Exported for tests. export function getMessagePathDataItems( message: MessageEvent, - filledInPath: RosPath, + filledInPath: MessagePath, topicsByName: Record, structures: Record, enumValues: ReturnType, @@ -225,7 +230,7 @@ export function getMessagePathDataItems( path: string, structureItem: MessagePathStructureItem | undefined, ) { - if (value == undefined || structureItem == undefined) { + if (value == undefined) { return; } const pathItem = filledInPath.messagePath[pathIndex]; @@ -236,15 +241,21 @@ export function getMessagePathDataItems( const prevPathItem = filledInPath.messagePath[pathIndex - 1]; if (prevPathItem && prevPathItem.type === "name") { const fieldName = prevPathItem.name; - const enumMap = enumValues[structureItem.datatype]; + const enumMap = structureItem != undefined ? enumValues[structureItem.datatype] : undefined; constantName = enumMap?.[fieldName]?.[value]; } queriedData.push({ value, path, constantName }); - } else if (pathItem.type === "name" && structureItem.structureType === "message") { + } else if ( + pathItem.type === "name" && + (structureItem == undefined || structureItem.structureType === "message") + ) { // If the `pathItem` is a name, we're traversing down using that name. - const next = structureItem.nextByName[pathItem.name]; + const next = structureItem?.nextByName[pathItem.name]; traverse(value[pathItem.name], pathIndex + 1, `${path}.${pathItem.repr}`, next); - } else if (pathItem.type === "slice" && structureItem.structureType === "array") { + } else if ( + pathItem.type === "slice" && + (structureItem == undefined || structureItem.structureType === "array") + ) { const { start, end } = pathItem; if (typeof start === "object" || typeof end === "object") { throw new Error( @@ -291,7 +302,7 @@ export function getMessagePathDataItems( // (otherwise they wouldn't have chosen a negative slice). newPath = `${path}[${i}]`; } - traverse(arrayElement, pathIndex + 1, newPath, structureItem.next); + traverse(arrayElement, pathIndex + 1, newPath, structureItem?.next); } } else if (pathItem.type === "filter") { if (filterMatches(pathItem, value)) { @@ -299,7 +310,7 @@ export function getMessagePathDataItems( } } else { console.warn( - `Unknown pathItem.type ${pathItem.type} for structureType: ${structureItem.structureType}`, + `Unknown pathItem.type ${pathItem.type} for structureType: ${structureItem?.structureType}`, ); } } @@ -335,7 +346,7 @@ export function useDecodeMessagePathsForMessagesByTopic( const obj: { [path: string]: MessageAndData[] } = {}; for (const path of memoizedPaths) { // Create an array for invalid paths, and valid paths with entries in messagesByTopic - const rosPath = parseRosPath(path); + const rosPath = parseMessagePath(path); if (!rosPath) { obj[path] = []; continue; diff --git a/packages/studio-base/src/components/MessagePathSyntax/useMessagesByPath.test.tsx b/packages/studio-base/src/components/MessagePathSyntax/useMessagesByPath.test.tsx index 6b01fd51b4..8dab051d99 100644 --- a/packages/studio-base/src/components/MessagePathSyntax/useMessagesByPath.test.tsx +++ b/packages/studio-base/src/components/MessagePathSyntax/useMessagesByPath.test.tsx @@ -51,7 +51,7 @@ type WrapperProps = { datatypes?: RosDatatypes; activeData?: Partial; globalVariables?: GlobalVariables; -}; +} & PropsWithChildren; function makeMessagePipelineWrapper(initialProps?: WrapperProps) { const setSubscriptions = jest.fn(); @@ -204,6 +204,7 @@ describe("useMessagesByPath", () => { wrapper, initialProps, }); + rerender(initialProps); expect(result.current.messagesByPath).toEqual({ "/some/topic": [queriedMessage(0), queriedMessage(1)], }); @@ -245,17 +246,18 @@ describe("useMessagesByPath", () => { datatypes: fixture.datatypes, messages: [fixture.messages[0]!, fixture.messages[1]!], }); - const { result: result1 } = renderHook(Hooks, { + const { result, rerender } = renderHook(Hooks, { wrapper, initialProps: { paths: ["/some/topic"] }, }); - const { result: result2 } = renderHook(Hooks, { - wrapper, - initialProps: { paths: ["/some/topic", "/some/topic"] }, - }); - expect(result1.current.messagesByPath["/some/topic"]).toHaveLength(2); - expect(result1.current.messagesByPath).toEqual(result2.current.messagesByPath); + const result1 = result.current.messagesByPath; + + rerender({ paths: ["/some/topic", "/some/topic"] }); + const result2 = result.current.messagesByPath; + + expect(result1["/some/topic"]).toHaveLength(2); + expect(result1).toEqual(result2); }); it("lets you drill down in a path", () => { diff --git a/packages/studio-base/src/components/MessagePipeline/FakePlayer.ts b/packages/studio-base/src/components/MessagePipeline/FakePlayer.ts index 0d377b4368..b3afa33262 100644 --- a/packages/studio-base/src/components/MessagePipeline/FakePlayer.ts +++ b/packages/studio-base/src/components/MessagePipeline/FakePlayer.ts @@ -94,6 +94,9 @@ export default class FakePlayer implements Player { public startPlayback = (): void => { // no-op }; + public enableRepeatPlayback = (): void => { + // no-op + }; public seekPlayback = (): void => { // no-op }; diff --git a/packages/studio-base/src/components/MessagePipeline/MessageOrderTracker.test.ts b/packages/studio-base/src/components/MessagePipeline/MessageOrderTracker.test.ts index 81f3e0ee1f..f1028476c0 100644 --- a/packages/studio-base/src/components/MessagePipeline/MessageOrderTracker.test.ts +++ b/packages/studio-base/src/components/MessagePipeline/MessageOrderTracker.test.ts @@ -43,6 +43,7 @@ const playerStateWithMessages = (messages: any): PlayerState => ({ startTime: { sec: 0, nsec: 0 }, endTime: { sec: 2, nsec: 0 }, isPlaying: false, + repeatEnabled: false, messages, totalBytesReceived: 1234, }, diff --git a/packages/studio-base/src/components/MessagePipeline/MockMessagePipelineProvider.tsx b/packages/studio-base/src/components/MessagePipeline/MockMessagePipelineProvider.tsx index c2d632ad75..a78bef2fc7 100644 --- a/packages/studio-base/src/components/MessagePipeline/MockMessagePipelineProvider.tsx +++ b/packages/studio-base/src/components/MessagePipeline/MockMessagePipelineProvider.tsx @@ -13,13 +13,18 @@ import { Immutable } from "immer"; import * as _ from "lodash-es"; -import { useEffect, useRef, useState } from "react"; +import { MutableRefObject, useEffect, useMemo, useRef, useState } from "react"; import shallowequal from "shallowequal"; import { Writable } from "ts-essentials"; import { createStore } from "zustand"; +import { Condvar } from "@foxglove/den/async"; import { Time, isLessThan } from "@foxglove/rostime"; import { ParameterValue } from "@foxglove/studio"; +import { + FramePromise, + pauseFrameForPromises, +} from "@foxglove/studio-base/components/MessagePipeline/pauseFrameForPromise"; import { BuiltinPanelExtensionContext } from "@foxglove/studio-base/components/PanelExtensionAdapter"; import { AdvertiseOptions, @@ -67,10 +72,12 @@ export type MockMessagePipelineProps = { startPlayback?: () => void; pausePlayback?: () => void; seekPlayback?: (arg0: Time) => void; + enableRepeat?: () => void; currentTime?: Time; startTime?: Time; endTime?: Time; isPlaying?: boolean; + repeatEnabled?: boolean; pauseFrame?: (arg0: string) => () => void; playerId?: string; progress?: Progress; @@ -90,6 +97,7 @@ function getPublicState( prevState: MockMessagePipelineState | undefined, props: MockMessagePipelineProps, dispatch: MockMessagePipelineState["dispatch"], + promisesToWaitForRef: MutableRefObject, ): Omit { let startTime = prevState?.public.playerState.activeData?.startTime; let currentTime = props.currentTime; @@ -126,6 +134,7 @@ function getPublicState( currentTime: currentTime ?? { sec: 100, nsec: 0 }, endTime: props.endTime ?? currentTime ?? { sec: 100, nsec: 0 }, isPlaying: props.isPlaying ?? false, + repeatEnabled: props.repeatEnabled ?? false, speed: 0.2, lastSeekTime: 0, totalBytesReceived: 0, @@ -167,17 +176,27 @@ function getPublicState( startPlayback: props.startPlayback, playUntil: noop, pausePlayback: props.pausePlayback, + enableRepeatPlayback: props.enableRepeat ?? noop, setPlaybackSpeed: props.capabilities?.includes(PlayerCapabilities.setSpeed) === true ? noop : undefined, seekPlayback: props.seekPlayback, - pauseFrame: props.pauseFrame ?? (() => noop), + pauseFrame: + props.pauseFrame ?? + function (name) { + const condvar = new Condvar(); + promisesToWaitForRef.current.push({ name, promise: condvar.wait() }); + return () => { + condvar.notifyAll(); + }; + }, }; } export default function MockMessagePipelineProvider( props: React.PropsWithChildren, ): React.ReactElement { + const promisesToWaitForRef = useRef([]); const startTime = useRef