diff --git a/package-lock.json b/package-lock.json index 33c3533adf6..f30a6b82cb6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10477,6 +10477,10 @@ "resolved": "packages/compass-editor", "link": true }, + "node_modules/@mongodb-js/compass-electron-menu": { + "resolved": "packages/compass-electron-menu", + "link": true + }, "node_modules/@mongodb-js/compass-explain-plan": { "resolved": "packages/compass-explain-plan", "link": true @@ -47967,6 +47971,7 @@ "@mongodb-js/compass-crud": "^13.84.0", "@mongodb-js/compass-data-modeling": "^1.35.0", "@mongodb-js/compass-databases-collections": "^1.83.0", + "@mongodb-js/compass-electron-menu": "^0.1.0", "@mongodb-js/compass-explain-plan": "^6.84.0", "@mongodb-js/compass-export-to-language": "^9.60.0", "@mongodb-js/compass-field-store": "^9.59.0", @@ -48587,6 +48592,7 @@ "@mongodb-js/compass-components": "^1.56.0", "@mongodb-js/compass-connections": "^1.84.0", "@mongodb-js/compass-editor": "^0.58.0", + "@mongodb-js/compass-electron-menu": "^0.1.0", "@mongodb-js/compass-generative-ai": "^0.63.0", "@mongodb-js/compass-logging": "^1.7.22", "@mongodb-js/compass-telemetry": "^1.19.0", @@ -50609,6 +50615,125 @@ "node": ">=0.3.1" } }, + "packages/compass-electron-menu": { + "name": "@mongodb-js/compass-electron-menu", + "version": "0.1.0", + "license": "SSPL", + "dependencies": { + "bson": "^6.10.4", + "debug": "^4.3.4", + "electron": "^37.6.1", + "react": "^17.0.2" + }, + "devDependencies": { + "@mongodb-js/eslint-config-compass": "^1.4.12", + "@mongodb-js/mocha-config-compass": "^1.7.2", + "@mongodb-js/prettier-config-compass": "^1.2.9", + "@mongodb-js/testing-library-compass": "^1.3.17", + "@mongodb-js/tsconfig-compass": "^1.2.12", + "@types/chai": "^4.2.21", + "@types/chai-dom": "^0.0.10", + "@types/mocha": "^9.0.0", + "@types/react": "^17.0.5", + "@types/react-dom": "^17.0.10", + "@types/sinon-chai": "^3.2.5", + "chai": "^4.3.6", + "depcheck": "^1.4.1", + "gen-esm-wrapper": "^1.1.0", + "mocha": "^10.2.0", + "nyc": "^15.1.0", + "sinon": "^17.0.1", + "typescript": "^5.9.3" + } + }, + "packages/compass-electron-menu/node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "packages/compass-electron-menu/node_modules/@sinonjs/fake-timers": { + "version": "11.3.1", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-11.3.1.tgz", + "integrity": "sha512-EVJO7nW5M/F5Tur0Rf2z/QoMo+1Ia963RiMtapiQrEWvY0iBUvADo8Beegwjpnle5BHkyHuoxSTW3jF43H1XRA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "packages/compass-electron-menu/node_modules/@sinonjs/samsam": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.3.tgz", + "integrity": "sha512-hw6HbX+GyVZzmaYNh82Ecj1vdGZrqVIn/keDTg63IgAwiQPO+xCz99uG6Woqgb4tM0mUiFENKZ4cqd7IX94AXQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1", + "type-detect": "^4.1.0" + } + }, + "packages/compass-electron-menu/node_modules/@sinonjs/samsam/node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "packages/compass-electron-menu/node_modules/just-extend": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-6.2.0.tgz", + "integrity": "sha512-cYofQu2Xpom82S6qD778jBDpwvvy39s1l/hrYij2u9AMdQcGRpaBu6kY4mVhuno5kJVi1DAz4aiphA2WI1/OAw==", + "dev": true, + "license": "MIT" + }, + "packages/compass-electron-menu/node_modules/nise": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/nise/-/nise-5.1.9.tgz", + "integrity": "sha512-qOnoujW4SV6e40dYxJOb3uvuoPHtmLzIk4TFo+j0jPJoC+5Z9xja5qH5JZobEPsa8+YYphMrOSwnrshEhG2qww==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.0", + "@sinonjs/fake-timers": "^11.2.2", + "@sinonjs/text-encoding": "^0.7.2", + "just-extend": "^6.2.0", + "path-to-regexp": "^6.2.1" + } + }, + "packages/compass-electron-menu/node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "packages/compass-electron-menu/node_modules/sinon": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-17.0.1.tgz", + "integrity": "sha512-wmwE19Lie0MLT+ZYNpDymasPHUKTaZHUH/pKEubRXIzySv9Atnlw+BUMGCzWgV7b7wO+Hw6f1TEOr0IUnmU8/g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.0", + "@sinonjs/fake-timers": "^11.2.2", + "@sinonjs/samsam": "^8.0.0", + "diff": "^5.1.0", + "nise": "^5.1.5", + "supports-color": "^7.2.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/sinon" + } + }, "packages/compass-explain-plan": { "name": "@mongodb-js/compass-explain-plan", "version": "6.84.0", @@ -63124,6 +63249,7 @@ "@mongodb-js/compass-components": "^1.56.0", "@mongodb-js/compass-connections": "^1.84.0", "@mongodb-js/compass-editor": "^0.58.0", + "@mongodb-js/compass-electron-menu": "^0.1.0", "@mongodb-js/compass-generative-ai": "^0.63.0", "@mongodb-js/compass-logging": "^1.7.22", "@mongodb-js/compass-telemetry": "^1.19.0", @@ -64512,6 +64638,110 @@ } } }, + "@mongodb-js/compass-electron-menu": { + "version": "file:packages/compass-electron-menu", + "requires": { + "@mongodb-js/eslint-config-compass": "^1.4.12", + "@mongodb-js/mocha-config-compass": "^1.7.2", + "@mongodb-js/prettier-config-compass": "^1.2.9", + "@mongodb-js/testing-library-compass": "^1.3.17", + "@mongodb-js/tsconfig-compass": "^1.2.12", + "@types/chai": "^4.2.21", + "@types/chai-dom": "^0.0.10", + "@types/mocha": "^9.0.0", + "@types/react": "^17.0.5", + "@types/react-dom": "^17.0.10", + "@types/sinon-chai": "^3.2.5", + "bson": "^6.10.4", + "chai": "^4.3.6", + "debug": "^4.3.4", + "depcheck": "^1.4.1", + "electron": "^37.6.1", + "gen-esm-wrapper": "^1.1.0", + "mocha": "^10.2.0", + "nyc": "^15.1.0", + "react": "^17.0.2", + "sinon": "^17.0.1", + "typescript": "^5.9.3" + }, + "dependencies": { + "@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "requires": { + "type-detect": "4.0.8" + } + }, + "@sinonjs/fake-timers": { + "version": "11.3.1", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-11.3.1.tgz", + "integrity": "sha512-EVJO7nW5M/F5Tur0Rf2z/QoMo+1Ia963RiMtapiQrEWvY0iBUvADo8Beegwjpnle5BHkyHuoxSTW3jF43H1XRA==", + "dev": true, + "requires": { + "@sinonjs/commons": "^3.0.1" + } + }, + "@sinonjs/samsam": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.3.tgz", + "integrity": "sha512-hw6HbX+GyVZzmaYNh82Ecj1vdGZrqVIn/keDTg63IgAwiQPO+xCz99uG6Woqgb4tM0mUiFENKZ4cqd7IX94AXQ==", + "dev": true, + "requires": { + "@sinonjs/commons": "^3.0.1", + "type-detect": "^4.1.0" + }, + "dependencies": { + "type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true + } + } + }, + "just-extend": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-6.2.0.tgz", + "integrity": "sha512-cYofQu2Xpom82S6qD778jBDpwvvy39s1l/hrYij2u9AMdQcGRpaBu6kY4mVhuno5kJVi1DAz4aiphA2WI1/OAw==", + "dev": true + }, + "nise": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/nise/-/nise-5.1.9.tgz", + "integrity": "sha512-qOnoujW4SV6e40dYxJOb3uvuoPHtmLzIk4TFo+j0jPJoC+5Z9xja5qH5JZobEPsa8+YYphMrOSwnrshEhG2qww==", + "dev": true, + "requires": { + "@sinonjs/commons": "^3.0.0", + "@sinonjs/fake-timers": "^11.2.2", + "@sinonjs/text-encoding": "^0.7.2", + "just-extend": "^6.2.0", + "path-to-regexp": "^6.2.1" + } + }, + "path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true + }, + "sinon": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-17.0.1.tgz", + "integrity": "sha512-wmwE19Lie0MLT+ZYNpDymasPHUKTaZHUH/pKEubRXIzySv9Atnlw+BUMGCzWgV7b7wO+Hw6f1TEOr0IUnmU8/g==", + "dev": true, + "requires": { + "@sinonjs/commons": "^3.0.0", + "@sinonjs/fake-timers": "^11.2.2", + "@sinonjs/samsam": "^8.0.0", + "diff": "^5.1.0", + "nise": "^5.1.5", + "supports-color": "^7.2.0" + } + } + } + }, "@mongodb-js/compass-explain-plan": { "version": "file:packages/compass-explain-plan", "requires": { @@ -87802,6 +88032,7 @@ "@mongodb-js/compass-crud": "^13.84.0", "@mongodb-js/compass-data-modeling": "^1.35.0", "@mongodb-js/compass-databases-collections": "^1.83.0", + "@mongodb-js/compass-electron-menu": "^0.1.0", "@mongodb-js/compass-explain-plan": "^6.84.0", "@mongodb-js/compass-export-to-language": "^9.60.0", "@mongodb-js/compass-field-store": "^9.59.0", diff --git a/packages/compass-collection/package.json b/packages/compass-collection/package.json index 7dd641c3e91..4b001149ad2 100644 --- a/packages/compass-collection/package.json +++ b/packages/compass-collection/package.json @@ -54,6 +54,7 @@ "@mongodb-js/compass-components": "^1.56.0", "@mongodb-js/compass-connections": "^1.84.0", "@mongodb-js/compass-editor": "^0.58.0", + "@mongodb-js/compass-electron-menu": "^0.1.0", "@mongodb-js/compass-generative-ai": "^0.63.0", "@mongodb-js/compass-logging": "^1.7.22", "@mongodb-js/compass-telemetry": "^1.19.0", diff --git a/packages/compass-collection/src/components/collection-tab.tsx b/packages/compass-collection/src/components/collection-tab.tsx index 7d0756d113f..59c99f893e0 100644 --- a/packages/compass-collection/src/components/collection-tab.tsx +++ b/packages/compass-collection/src/components/collection-tab.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react'; +import React, { useCallback, useEffect } from 'react'; import { connect } from 'react-redux'; import { type CollectionState, selectTab } from '../modules/collection-tab'; import { css, ErrorBoundary, TabNavBar } from '@mongodb-js/compass-components'; @@ -19,6 +19,11 @@ import { useConnectionSupports, } from '@mongodb-js/compass-connections/provider'; import { usePreference } from 'compass-preferences-model/provider'; +import { useApplicationMenu } from '@mongodb-js/compass-electron-menu'; +import { + useGlobalAppRegistry, + useLocalAppRegistry, +} from '@mongodb-js/compass-app-registry'; type CollectionSubtabTrackingId = Lowercase extends infer U ? U extends string @@ -228,6 +233,80 @@ const CollectionTabWithMetadata: React.FunctionComponent< ); }; +// Setup the Electron application menu for the collection tab +function useCollectionTabApplicationMenu( + collectionMetadata: CollectionMetadata | null +) { + const localAppRegistry = useLocalAppRegistry(); + const globalAppRegistry = useGlobalAppRegistry(); + const connectionInfoRef = useConnectionInfoRef(); + const preferencesReadOnly = usePreference('readOnly'); + + const shareSchemaClick = useCallback(() => { + localAppRegistry.emit('menu-share-schema-json'); + }, [localAppRegistry]); + + const importClick = useCallback(() => { + if (!collectionMetadata) return; + globalAppRegistry.emit( + 'open-import', + { + namespace: collectionMetadata.namespace, + origin: 'menu', + }, + { + connectionId: connectionInfoRef.current.id, + }, + {} + ); + }, [collectionMetadata, globalAppRegistry, connectionInfoRef]); + + const exportClick = useCallback(() => { + if (!collectionMetadata) return; + globalAppRegistry.emit( + 'open-export', + { + exportFullCollection: true, + namespace: collectionMetadata.namespace, + origin: 'menu', + }, + { + connectionId: connectionInfoRef.current.id, + } + ); + }, [collectionMetadata, globalAppRegistry, connectionInfoRef]); + + useApplicationMenu({ + menu: collectionMetadata + ? { + label: '&Collection', + submenu: [ + { + label: '&Share Schema as JSON (Legacy)', + accelerator: 'Alt+CmdOrCtrl+S', + click: shareSchemaClick, + }, + { + type: 'separator', + }, + ...(preferencesReadOnly || collectionMetadata?.isReadonly + ? [] + : [ + { + label: '&Import Data', + click: importClick, + }, + ]), + { + label: '&Export Collection', + click: exportClick, + }, + ], + } + : undefined, + }); +} + const CollectionTab = ({ collectionMetadata, ...props @@ -235,6 +314,7 @@ const CollectionTab = ({ collectionMetadata: CollectionMetadata | null; }) => { const QueryBarPlugin = useCollectionQueryBar(); + useCollectionTabApplicationMenu(collectionMetadata); if (!collectionMetadata) { return null; diff --git a/packages/compass-electron-menu/.depcheckrc b/packages/compass-electron-menu/.depcheckrc new file mode 100644 index 00000000000..ae7c8273e41 --- /dev/null +++ b/packages/compass-electron-menu/.depcheckrc @@ -0,0 +1,11 @@ +ignores: + - '@mongodb-js/prettier-config-compass' + - '@mongodb-js/tsconfig-compass' + - '@types/chai' + - '@types/sinon-chai' + - 'sinon' + - '@types/chai-dom' + - '@types/react' + - '@types/react-dom' +ignore-patterns: + - 'dist' diff --git a/packages/compass-electron-menu/.eslintignore b/packages/compass-electron-menu/.eslintignore new file mode 100644 index 00000000000..85a8a75e68c --- /dev/null +++ b/packages/compass-electron-menu/.eslintignore @@ -0,0 +1,2 @@ +.nyc-output +dist diff --git a/packages/compass-electron-menu/.eslintrc.js b/packages/compass-electron-menu/.eslintrc.js new file mode 100644 index 00000000000..a812ac46f5d --- /dev/null +++ b/packages/compass-electron-menu/.eslintrc.js @@ -0,0 +1,8 @@ +module.exports = { + root: true, + extends: ['@mongodb-js/eslint-config-compass'], + parserOptions: { + tsconfigRootDir: __dirname, + project: ['./tsconfig.json'], + }, +}; diff --git a/packages/compass-electron-menu/.mocharc.js b/packages/compass-electron-menu/.mocharc.js new file mode 100644 index 00000000000..30aecfb78c3 --- /dev/null +++ b/packages/compass-electron-menu/.mocharc.js @@ -0,0 +1 @@ +module.exports = require('@mongodb-js/mocha-config-compass/react'); diff --git a/packages/compass-electron-menu/package.json b/packages/compass-electron-menu/package.json new file mode 100644 index 00000000000..d1e5d4f2e1a --- /dev/null +++ b/packages/compass-electron-menu/package.json @@ -0,0 +1,81 @@ +{ + "name": "@mongodb-js/compass-electron-menu", + "description": "Provide access to the Electron application menu", + "author": { + "name": "MongoDB Inc", + "email": "compass@mongodb.com" + }, + "publishConfig": { + "access": "public" + }, + "bugs": { + "url": "https://jira.mongodb.org/projects/COMPASS/issues", + "email": "compass@mongodb.com" + }, + "homepage": "https://github.com/mongodb-js/compass", + "version": "0.1.0", + "repository": { + "type": "git", + "url": "https://github.com/mongodb-js/compass.git" + }, + "files": [ + "dist" + ], + "license": "SSPL", + "main": "dist/index.js", + "compass:main": "src/index.ts", + "exports": { + ".": "./dist/index.js", + "./ipc-provider-main": "./dist/ipc-provider-main.js", + "./ipc-provider-renderer": "./dist/ipc-provider-renderer.js" + }, + "compass:exports": { + ".": "./src/index.ts", + "./ipc-provider-main": "./src/ipc-provider-main.ts", + "./ipc-provider-renderer": "./src/ipc-provider-renderer.ts" + }, + "types": "./dist/index.d.ts", + "scripts": { + "bootstrap": "npm run compile", + "prepublishOnly": "npm run compile && compass-scripts check-exports-exist", + "compile": "tsc -p tsconfig-build.json && gen-esm-wrapper . ./dist/.esm-wrapper.mjs", + "typecheck": "tsc -p tsconfig.json --noEmit", + "eslint": "eslint-compass", + "prettier": "prettier-compass", + "lint": "npm run eslint . && npm run prettier -- --check .", + "depcheck": "compass-scripts check-peer-deps && depcheck", + "check": "npm run typecheck && npm run lint && npm run depcheck", + "check-ci": "npm run check", + "test": "mocha", + "test-cov": "nyc --compact=false --produce-source-map=false -x \"**/*.spec.*\" --reporter=lcov --reporter=text --reporter=html npm run test", + "test-watch": "npm run test -- --watch", + "test-ci": "npm run test-cov", + "reformat": "npm run eslint . -- --fix && npm run prettier -- --write ." + }, + "dependencies": { + "bson": "^6.10.4", + "debug": "^4.3.4", + "electron": "^37.6.1", + "react": "^17.0.2" + }, + "devDependencies": { + "@mongodb-js/eslint-config-compass": "^1.4.12", + "@mongodb-js/mocha-config-compass": "^1.7.2", + "@mongodb-js/prettier-config-compass": "^1.2.9", + "@mongodb-js/testing-library-compass": "^1.3.17", + "@mongodb-js/tsconfig-compass": "^1.2.12", + "@types/chai": "^4.2.21", + "@types/chai-dom": "^0.0.10", + "@types/mocha": "^9.0.0", + "@types/react": "^17.0.5", + "@types/react-dom": "^17.0.10", + "@types/sinon-chai": "^3.2.5", + "chai": "^4.3.6", + "depcheck": "^1.4.1", + "gen-esm-wrapper": "^1.1.0", + "mocha": "^10.2.0", + "nyc": "^15.1.0", + "sinon": "^17.0.1", + "typescript": "^5.9.3" + } +} diff --git a/packages/compass-electron-menu/src/application-menu.spec.tsx b/packages/compass-electron-menu/src/application-menu.spec.tsx new file mode 100644 index 00000000000..8b44c86a71a --- /dev/null +++ b/packages/compass-electron-menu/src/application-menu.spec.tsx @@ -0,0 +1,175 @@ +import React from 'react'; +import { render } from '@mongodb-js/testing-library-compass'; +import { expect } from 'chai'; +import sinon from 'sinon'; +import { + ApplicationMenuContextProvider, + useApplicationMenu, +} from './application-menu'; +import type { CompassAppMenu } from './types'; + +describe('application-menu / useApplicationMenu', function () { + function createMockProvider() { + const showUnsubscribes: sinon.SinonSpy[] = []; + const roleUnsubscribes: sinon.SinonSpy[] = []; + const showApplicationMenu = sinon.stub().callsFake(() => { + const unsub = sinon.spy(); + showUnsubscribes.push(unsub); + return unsub; + }); + const handleMenuRole = sinon.stub().callsFake(() => { + const unsub = sinon.spy(); + roleUnsubscribes.push(unsub); + return unsub; + }); + return { + provider: { showApplicationMenu, handleMenuRole }, + showUnsubscribes, + roleUnsubscribes, + }; + } + + const TestComponent: React.FC<{ + menu?: CompassAppMenu; + roles?: Record void>; + }> = ({ menu, roles }) => { + useApplicationMenu({ menu, roles }); + return null; + }; + + it('subscribes to menu and roles and unsubscribes on unmount', function () { + const { provider, showUnsubscribes, roleUnsubscribes } = + createMockProvider(); + const menu: CompassAppMenu = { + label: '&File', + submenu: [{ label: 'Item', click: () => {} }], + }; + const roles = { + undo: () => {}, + redo: () => {}, + }; + + const { unmount } = render( + + + + ); + + expect(provider.showApplicationMenu.calledOnce).to.equal(true); + expect(provider.handleMenuRole.callCount).to.equal(2); + expect(showUnsubscribes).to.have.length(1); + expect(roleUnsubscribes).to.have.length(2); + for (const u of showUnsubscribes) expect(u.called).to.equal(false); + for (const u of roleUnsubscribes) expect(u.called).to.equal(false); + + unmount(); + + for (const u of showUnsubscribes) expect(u.calledOnce).to.equal(true); + for (const u of roleUnsubscribes) expect(u.calledOnce).to.equal(true); + }); + + it('does not subscribe when neither menu nor roles provided', function () { + const { provider } = createMockProvider(); + const { unmount } = render( + + + + ); + expect(provider.showApplicationMenu.called).to.equal(false); + expect(provider.handleMenuRole.called).to.equal(false); + unmount(); + // No unsubscribes expected + expect(provider.showApplicationMenu.called).to.equal(false); + }); + + it('re-subscribes when a menu handler identity changes', function () { + const { provider, showUnsubscribes } = createMockProvider(); + + const clickA = () => {}; + const menuA: CompassAppMenu = { + label: '&Edit', + submenu: [{ label: 'Action', click: clickA }], + }; + + const { rerender } = render( + + + + ); + + expect(provider.showApplicationMenu.callCount).to.equal(1); + expect(showUnsubscribes[0].called).to.equal(false); + + // Rerender with same function identity: should NOT resubscribe + rerender( + + + + ); + expect(provider.showApplicationMenu.callCount).to.equal(1); + + // Change click handler identity: should resubscribe + const menuB: CompassAppMenu = { + ...menuA, + submenu: [{ label: 'Action', click: () => {} }], + }; + rerender( + + + + ); + + expect(provider.showApplicationMenu.callCount).to.equal(2); + // First unsubscribe should have been called during effect cleanup + expect(showUnsubscribes[0].calledOnce).to.equal(true); + expect(showUnsubscribes[1].called).to.equal(false); + }); + + it('re-subscribes to roles when role handler identities change', function () { + const { provider, roleUnsubscribes } = createMockProvider(); + + const handlerUndoA = () => {}; + const handlerRedoA = () => {}; + let roles: Record void> = { + undo: handlerUndoA, + redo: handlerRedoA, + }; + + const { rerender } = render( + + + + ); + + expect(provider.handleMenuRole.callCount).to.equal(2); + expect(roleUnsubscribes[0].called).to.equal(false); + expect(roleUnsubscribes[1].called).to.equal(false); + + // Rerender with same handler identities: no resubscribe + rerender( + + + + ); + expect(provider.handleMenuRole.callCount).to.equal(2); + + // Change one handler identity + roles = { + undo: () => {}, // new identity + redo: handlerRedoA, // same identity + }; + rerender( + + + + ); + // Both roles re-subscribed because dependency array uses all handlers serialized + expect(provider.handleMenuRole.callCount).to.equal(4); + // First two unsubscribes called + expect(roleUnsubscribes[0].calledOnce).to.equal(true); + expect(roleUnsubscribes[1].calledOnce).to.equal(true); + // New unsubscribes not yet called + expect(roleUnsubscribes[2].called).to.equal(false); + expect(roleUnsubscribes[3].called).to.equal(false); + }); +}); diff --git a/packages/compass-electron-menu/src/application-menu.tsx b/packages/compass-electron-menu/src/application-menu.tsx new file mode 100644 index 00000000000..8f202d8a575 --- /dev/null +++ b/packages/compass-electron-menu/src/application-menu.tsx @@ -0,0 +1,101 @@ +import React, { useContext, useEffect } from 'react'; +import type { CompassAppMenu, MenuItemConstructorOptions } from './types'; +import { transformAppMenu } from './types'; +import { getObjectId } from './util'; + +export interface ApplicationMenuProvider { + // These functions return 'unsubscribe'-style listeners to remove + // the handlers again + showApplicationMenu(this: void, menu: CompassAppMenu): () => void; + handleMenuRole( + this: void, + role: MenuItemConstructorOptions['role'], + handler: () => void + ): () => void; +} + +const ApplicationMenuContext = React.createContext({ + showApplicationMenu: () => () => {}, + handleMenuRole: () => () => {}, +}); + +export function ApplicationMenuContextProvider({ + provider, + children, +}: { + provider: ApplicationMenuProvider; + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} + +function useApplicationMenuService(): ApplicationMenuProvider { + return useContext(ApplicationMenuContext); +} + +// Hook to set up an additional application menu, as well as +// override handlers for pre-defined Electron menu roles. +// +// Example usage: +// +// useApplicationMenu({ +// menu: { +// label: '&MyMenu', +// submenu: [ +// { +// label: 'Do Something', +// click: () => { ... } +// } +// ] +// }, +// roles: { +// undo: () => { ... }, +// redo: () => { ... } +// } +// }); +// +// You will typically want to memoize the callbacks used in these objects +// since they end up as part of the dependency array for this hook. +export function useApplicationMenu({ + menu, + roles, +}: { + menu?: CompassAppMenu; + roles?: Partial< + Record, () => void> + >; +}): void { + const { showApplicationMenu, handleMenuRole } = useApplicationMenuService(); + + useEffect(() => { + const subscriptions = [ + menu && showApplicationMenu(menu), + ...Object.entries(roles ?? {}).map(([role, handler]) => + handleMenuRole(role as MenuItemConstructorOptions['role'], handler) + ), + ]; + + return () => { + for (const unsubscribe of subscriptions) unsubscribe?.(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + showApplicationMenu, + handleMenuRole, + // eslint-disable-next-line react-hooks/exhaustive-deps + menu + ? JSON.stringify( + transformAppMenu(menu, (item) => ({ + ...item, + click: item.click && getObjectId(item.click), + })) + ) + : undefined, + // eslint-disable-next-line react-hooks/exhaustive-deps + roles ? JSON.stringify(Object.values(roles).map(getObjectId)) : undefined, + ]); +} diff --git a/packages/compass-electron-menu/src/index.ts b/packages/compass-electron-menu/src/index.ts new file mode 100644 index 00000000000..a02ab22751b --- /dev/null +++ b/packages/compass-electron-menu/src/index.ts @@ -0,0 +1,6 @@ +export { + type ApplicationMenuProvider, + ApplicationMenuContextProvider, + useApplicationMenu, +} from './application-menu'; +export type { CompassAppMenu } from './types'; diff --git a/packages/compass-electron-menu/src/integration.spec.tsx b/packages/compass-electron-menu/src/integration.spec.tsx new file mode 100644 index 00000000000..54e17300019 --- /dev/null +++ b/packages/compass-electron-menu/src/integration.spec.tsx @@ -0,0 +1,176 @@ +import React from 'react'; +import { render } from '@mongodb-js/testing-library-compass'; +import { expect } from 'chai'; +import { + ApplicationMenuContextProvider, + useApplicationMenu, +} from './application-menu'; +import type { CompassAppMenu, ModifyApplicationMenuParams } from './types'; +import type { HadronIpcRenderer } from './ipc-provider-renderer'; +import { ApplicationMenu } from './ipc-provider-renderer'; +import type { HadronIpcMain } from './ipc-provider-main'; +import { RendererDefinedMenuState } from './ipc-provider-main'; +import { EventEmitter } from 'events'; + +function serializable(obj: T): T { + try { + return JSON.parse( + JSON.stringify(obj, (_, value) => { + if (typeof value === 'function') { + return '[Function]'; + } + return value; + }) + ); + } catch { + return obj; + } +} + +const tick = () => new Promise((resolve) => setTimeout(resolve)); + +const TestComponent: React.FC<{ + menu?: CompassAppMenu; + roles?: Record void>; +}> = ({ menu, roles }) => { + useApplicationMenu({ menu, roles }); + return null; +}; + +describe('application menu integration test', function () { + let ipcRenderer: HadronIpcRenderer & EventEmitter; + let ipcMain: HadronIpcMain & EventEmitter; + + beforeEach(function () { + ipcRenderer = new (class extends EventEmitter implements HadronIpcRenderer { + // eslint-disable-next-line @typescript-eslint/require-await + async call(event: string, payload: unknown) { + queueMicrotask(() => ipcMain.emit(event, null, payload)); + } + })(); + ipcMain = new (class extends EventEmitter implements HadronIpcMain { + broadcastFocused(event: string, payload: unknown): void { + queueMicrotask(() => ipcRenderer.emit(event, null, payload)); + } + })(); + }); + + it('lets the react hook establish an application menu', async function () { + const provider = new ApplicationMenu(ipcRenderer); + const state = new RendererDefinedMenuState(ipcMain); + + ipcMain.on( + RendererDefinedMenuState.modifyApplicationMenuIpcEvent, + (_, event: ModifyApplicationMenuParams) => + state.modifyApplicationMenuHandler(event) + ); + + const clicks = { a: 0, b: 0, c: 0 }; + const clickA = () => clicks.a++; + const clickB = () => clicks.b++; + const clickC = () => clicks.c++; + const menuA: CompassAppMenu = { + label: '&Actions', + submenu: [{ label: 'Action', click: clickA }], + }; + const menuB: CompassAppMenu = { + label: 'Edit', + submenu: [ + { + label: 'Undo', + role: 'undo', + }, + { + label: 'Redo', + role: 'redo', + }, + ], + }; + + const { rerender } = render( + + {null} + + ); + + const getMenuForProps = async ( + props?: React.ComponentProps + ) => { + rerender( + + + + ); + + await tick(); + return state.translateRoles([menuB, ...state.menus()]); + }; + + let menu: CompassAppMenu[] = await getMenuForProps(); + expect(serializable(menu)).to.deep.equal([ + { + label: 'Edit', + submenu: [ + { label: 'Undo', role: 'undo' }, + { label: 'Redo', role: 'redo' }, + ], + }, + ]); + + menu = await getMenuForProps({ menu: menuA }); + expect(serializable(menu)).to.deep.equal([ + { + label: 'Edit', + submenu: [ + { label: 'Undo', role: 'undo' }, + { label: 'Redo', role: 'redo' }, + ], + }, + { + label: '&Actions', + submenu: [{ label: 'Action', click: '[Function]' }], + }, + ]); + + expect(clicks).to.deep.equal({ a: 0, b: 0, c: 0 }); + menu.find((m) => m.label === '&Actions')?.submenu?.[0].click?.(); + await tick(); + expect(clicks).to.deep.equal({ a: 1, b: 0, c: 0 }); + + menu = await getMenuForProps({ roles: { undo: clickB, redo: clickC } }); + expect(serializable(menu)).to.deep.equal([ + { + label: 'Edit', + submenu: [ + { label: 'Undo', click: '[Function]' }, + { label: 'Redo', click: '[Function]' }, + ], + }, + ]); + + expect(clicks).to.deep.equal({ a: 1, b: 0, c: 0 }); + menu + .find((m) => m.label === 'Edit') + ?.submenu?.find((i) => i.label === 'Undo') + ?.click?.(); + await tick(); + expect(clicks).to.deep.equal({ a: 1, b: 1, c: 0 }); + menu + .find((m) => m.label === 'Edit') + ?.submenu?.find((i) => i.label === 'Redo') + ?.click?.(); + await tick(); + expect(clicks).to.deep.equal({ a: 1, b: 1, c: 1 }); + + menu = await getMenuForProps(); + expect(serializable(menu)).to.deep.equal([ + { + label: 'Edit', + submenu: [ + { label: 'Undo', role: 'undo' }, + { label: 'Redo', role: 'redo' }, + ], + }, + ]); + }); +}); diff --git a/packages/compass-electron-menu/src/ipc-provider-main.ts b/packages/compass-electron-menu/src/ipc-provider-main.ts new file mode 100644 index 00000000000..23e7f979a5c --- /dev/null +++ b/packages/compass-electron-menu/src/ipc-provider-main.ts @@ -0,0 +1,91 @@ +import type { IpcEvents, ModifyApplicationMenuParams } from './types'; +import { + transformAppMenu, + type CompassAppMenu, + type MenuItemConstructorOptions, + type UUIDString, +} from './types'; + +export interface HadronIpcMain { + broadcastFocused( + channel: K, + payload: IpcEvents[K] + ): void; +} + +export class RendererDefinedMenuState { + private roleListeners: [MenuItemConstructorOptions['role'], string][] = []; + private additionalMenus: { id: string; menu: CompassAppMenu }[] = + []; + + private ipcMain: HadronIpcMain; + + constructor(ipcMain: HadronIpcMain | undefined) { + if (!ipcMain) { + throw new Error('ipcMain is required for RendererDefinedMenuState'); + } + this.ipcMain = ipcMain; + } + + menus(): CompassAppMenu[] { + return this.additionalMenus.map( + ({ menu }): CompassAppMenu => + transformAppMenu(menu, (item) => { + const id = item.click; + if (!id) return { ...item, click: undefined }; + return { + ...item, + click: () => + this.ipcMain.broadcastFocused('application-menu:invoke-handler', { + id, + }), + }; + }) + ); + } + + translateRoles(menus: CompassAppMenu[]): CompassAppMenu[] { + return menus.map((menu): CompassAppMenu => { + return transformAppMenu(menu, (item) => { + if (!item.role) return item; + + const listener = this.roleListeners.find( + ([role]) => role === item.role + ); + if (!listener) return item; + const id = listener[1]; + return { + ...item, + role: undefined, + click: () => + this.ipcMain.broadcastFocused('application-menu:invoke-handler', { + id, + }), + }; + }); + }); + } + + modifyApplicationMenuHandler = ({ + id, + menu, + role, + }: ModifyApplicationMenuParams): this => { + if (menu) { + this.additionalMenus.push({ id, menu }); + } else { + this.additionalMenus = this.additionalMenus.filter((m) => m.id !== id); + } + if (role) { + this.roleListeners.push([role, id]); + } else { + this.roleListeners = this.roleListeners.filter( + ([, listenerId]) => listenerId !== id + ); + } + return this; + }; + + static readonly modifyApplicationMenuIpcEvent = + 'application-menu:modify-application-menu' as const satisfies keyof IpcEvents; +} diff --git a/packages/compass-electron-menu/src/ipc-provider-renderer.ts b/packages/compass-electron-menu/src/ipc-provider-renderer.ts new file mode 100644 index 00000000000..e0ca65768d9 --- /dev/null +++ b/packages/compass-electron-menu/src/ipc-provider-renderer.ts @@ -0,0 +1,89 @@ +import type { ApplicationMenuProvider, CompassAppMenu } from './'; +import type { + IpcEvents, + MenuItemConstructorOptions, + UUIDString, +} from './types'; +import { transformAppMenu } from './types'; +import { uuid } from './util'; +import createDebug from 'debug'; +const debug = createDebug('compass-electron-menu:ipc-provider-renderer'); +export interface HadronIpcRenderer { + on( + event: K, + listener: (event: unknown, payload: IpcEvents[K]) => void + ): void; + call( + event: K, + payload: IpcEvents[K] + ): Promise; +} + +function translateCallsToHandlerIds( + menu: CompassAppMenu +): [CompassAppMenu, Map void>] { + const handlerIds = new Map void>(); + const transformedMenu = transformAppMenu(menu, (item) => { + if (!item.click) return { ...item, click: undefined }; + const id = uuid(); + handlerIds.set(id, item.click); + return { ...item, click: id }; + }); + return [transformedMenu, handlerIds]; +} + +export class ApplicationMenu implements ApplicationMenuProvider { + handlers = new Map void>(); + ipcRenderer: HadronIpcRenderer | undefined; + + constructor(ipcRenderer: HadronIpcRenderer | undefined) { + this.ipcRenderer = ipcRenderer; + this.ipcRenderer?.on( + 'application-menu:invoke-handler', + (event, { id }: { id: string }) => { + const handler = this.handlers.get(id); + if (!handler) debug('No handler found for menu item id', id); + handler?.(); + } + ); + } + + showApplicationMenu = (menu: CompassAppMenu): (() => void) => { + const id = uuid(); + const [translatedMenu, handlers] = translateCallsToHandlerIds(menu); + for (const [handlerId, handler] of handlers.entries()) { + this.handlers.set(handlerId, handler); + } + void this.ipcRenderer?.call('application-menu:modify-application-menu', { + id, + menu: translatedMenu, + }); + return () => { + void this.ipcRenderer?.call('application-menu:modify-application-menu', { + id, + menu: undefined, + }); + for (const handlerId of handlers.keys()) { + this.handlers.delete(handlerId); + } + }; + }; + + handleMenuRole = ( + role: MenuItemConstructorOptions['role'], + handler: () => void + ): (() => void) => { + const id = uuid(); + this.handlers.set(id, handler); + void this.ipcRenderer?.call('application-menu:modify-application-menu', { + id, + role, + }); + return () => { + void this.ipcRenderer?.call('application-menu:modify-application-menu', { + id, + }); + this.handlers.delete(id); + }; + }; +} diff --git a/packages/compass-electron-menu/src/types.spec.ts b/packages/compass-electron-menu/src/types.spec.ts new file mode 100644 index 00000000000..7230ff35796 --- /dev/null +++ b/packages/compass-electron-menu/src/types.spec.ts @@ -0,0 +1,28 @@ +import { expect } from 'chai'; +import { transformAppMenu } from './types'; + +describe('transformAppMenu', function () { + it('transforms menu items using the callback', function () { + expect( + transformAppMenu( + { + label: '42', + click: 'quux', + submenu: [ + { label: 'Item 1', click: 'foo' }, + { label: 'Item 2', click: 'bar' }, + ], + }, + (item) => ({ ...item, click: item.click?.length, extra: 'abc' }) + ) + ).to.deep.equal({ + label: '42', + click: 4, + submenu: [ + { label: 'Item 1', click: 3, extra: 'abc' }, + { label: 'Item 2', click: 3, extra: 'abc' }, + ], + extra: 'abc', + }); + }); +}); diff --git a/packages/compass-electron-menu/src/types.ts b/packages/compass-electron-menu/src/types.ts new file mode 100644 index 00000000000..56039092aeb --- /dev/null +++ b/packages/compass-electron-menu/src/types.ts @@ -0,0 +1,42 @@ +// NB: We add `electron` as a production dependency because +// of this type import. That's fine because we expect this +// package to only be used in Compass, where we know elecron +// is a dependency anyway. +import type { MenuItemConstructorOptions } from 'electron'; + +export type { MenuItemConstructorOptions }; +export type CompassAppMenu void> = Omit< + MenuItemConstructorOptions, + 'click' | 'submenu' +> & { click?: ClickHandlerType; submenu?: CompassAppMenu[] }; +export type UUIDString = string; + +// Shared helper that is useful in a few places since we need to +// translate between 'real function' click handlers and +// string identifiers for those click handlers in a few places. +export function transformAppMenu( + menu: CompassAppMenu, + transform: ( + cb: Omit, 'submenu'> + ) => Omit, 'submenu'> +): CompassAppMenu { + return { + ...transform({ ...menu }), + ...(menu.submenu + ? { + submenu: menu.submenu.map((sub) => transformAppMenu(sub, transform)), + } + : undefined), + }; +} + +export interface ModifyApplicationMenuParams { + id: string; + menu?: CompassAppMenu; + role?: MenuItemConstructorOptions['role']; +} + +export interface IpcEvents { + 'application-menu:modify-application-menu': ModifyApplicationMenuParams; + 'application-menu:invoke-handler': { id: string }; +} diff --git a/packages/compass-electron-menu/src/util.spec.ts b/packages/compass-electron-menu/src/util.spec.ts new file mode 100644 index 00000000000..947e2fa1878 --- /dev/null +++ b/packages/compass-electron-menu/src/util.spec.ts @@ -0,0 +1,31 @@ +import { expect } from 'chai'; +import { uuid, getObjectId } from './util'; + +describe('util.ts', function () { + describe('uuid()', function () { + it('returns a string matching UUID v4 format', function () { + const value = uuid(); + expect(value).to.be.a('string'); + expect(value).to.match( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + ); + }); + }); + + describe('getObjectId()', function () { + it('returns stable id for the same object', function () { + const obj = {}; + const first = getObjectId(obj); + const second = getObjectId(obj); + expect(first).to.equal(second); + }); + + it('returns incremental ids for different objects', function () { + const obj1 = {}; + const obj2 = {}; + const id1 = getObjectId(obj1); + const id2 = getObjectId(obj2); + expect(id2).to.equal(id1 + 1); + }); + }); +}); diff --git a/packages/compass-electron-menu/src/util.ts b/packages/compass-electron-menu/src/util.ts new file mode 100644 index 00000000000..7da8fd19035 --- /dev/null +++ b/packages/compass-electron-menu/src/util.ts @@ -0,0 +1,18 @@ +import { UUID } from 'bson'; +import type { UUIDString } from './types'; + +export function uuid(): UUIDString { + return new UUID().toString(); +} + +const objectIds = new WeakMap(); +let objectIdCounter = 0; + +export function getObjectId(obj: object): number { + let id = objectIds.get(obj); + if (id === undefined) { + id = ++objectIdCounter; + objectIds.set(obj, id); + } + return id; +} diff --git a/packages/compass-electron-menu/tsconfig-build.json b/packages/compass-electron-menu/tsconfig-build.json new file mode 100644 index 00000000000..737091e2e1c --- /dev/null +++ b/packages/compass-electron-menu/tsconfig-build.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*"], + "exclude": ["./src/**/*.spec.*"] +} diff --git a/packages/compass-electron-menu/tsconfig.json b/packages/compass-electron-menu/tsconfig.json new file mode 100644 index 00000000000..3495f3190e9 --- /dev/null +++ b/packages/compass-electron-menu/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@mongodb-js/tsconfig-compass/tsconfig.react.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/compass-workspaces/src/index.ts b/packages/compass-workspaces/src/index.ts index 6a3dccbd716..6afe1ecf02c 100644 --- a/packages/compass-workspaces/src/index.ts +++ b/packages/compass-workspaces/src/index.ts @@ -9,9 +9,7 @@ import workspacesReducer, { collectionRemoved, collectionRenamed, databaseRemoved, - getActiveTab, getInitialTabState, - getLocalAppRegistryForTab, cleanupLocalAppRegistries, connectionDisconnected, updateDatabaseInfo, @@ -168,46 +166,6 @@ export function activateWorkspacePlugin( } ); - on(globalAppRegistry, 'menu-share-schema-json', () => { - const activeTab = getActiveTab(store.getState()); - if (activeTab?.type === 'Collection') { - getLocalAppRegistryForTab(activeTab.id).emit('menu-share-schema-json'); - } - }); - - on(globalAppRegistry, 'open-active-namespace-export', function () { - const activeTab = getActiveTab(store.getState()); - if (activeTab?.type === 'Collection') { - globalAppRegistry.emit( - 'open-export', - { - exportFullCollection: true, - namespace: activeTab.namespace, - origin: 'menu', - }, - { - connectionId: activeTab.connectionId, - } - ); - } - }); - - on(globalAppRegistry, 'open-active-namespace-import', function () { - const activeTab = getActiveTab(store.getState()); - if (activeTab?.type === 'Collection') { - globalAppRegistry.emit( - 'open-import', - { - namespace: activeTab.namespace, - origin: 'menu', - }, - { - connectionId: activeTab.connectionId, - } - ); - } - }); - onBeforeUnloadCallbackRequest?.(() => { return store.dispatch(beforeUnloading()); }); diff --git a/packages/compass/package.json b/packages/compass/package.json index 2850f6632e2..e503fc2410f 100644 --- a/packages/compass/package.json +++ b/packages/compass/package.json @@ -206,6 +206,7 @@ "@mongodb-js/compass-assistant": "^1.15.0", "@mongodb-js/compass-data-modeling": "^1.35.0", "@mongodb-js/compass-databases-collections": "^1.83.0", + "@mongodb-js/compass-electron-menu": "^0.1.0", "@mongodb-js/compass-explain-plan": "^6.84.0", "@mongodb-js/compass-export-to-language": "^9.60.0", "@mongodb-js/compass-field-store": "^9.59.0", diff --git a/packages/compass/src/app/application.tsx b/packages/compass/src/app/application.tsx index cdda3a3f485..f6f75efdad2 100644 --- a/packages/compass/src/app/application.tsx +++ b/packages/compass/src/app/application.tsx @@ -38,12 +38,18 @@ const track = createIpcTrack(); import './index.less'; import 'source-code-pro/source-code-pro.css'; +import { + ApplicationMenuContextProvider, + type ApplicationMenuProvider, +} from '@mongodb-js/compass-electron-menu'; +import { ApplicationMenu } from '@mongodb-js/compass-electron-menu/ipc-provider-renderer'; const DEFAULT_APP_VERSION = '0.0.0'; class Application { private static instance: Application | null = null; + private menuProvider: ApplicationMenuProvider; version: string; previousVersion: string; highestInstalledVersion: string; @@ -52,6 +58,7 @@ class Application { this.version = remote.app.getVersion() || ''; this.previousVersion = DEFAULT_APP_VERSION; this.highestInstalledVersion = this.version; + this.menuProvider = new ApplicationMenu(ipcRenderer); } public static getInstance(): Application { @@ -173,27 +180,27 @@ class Application { ReactDOM.render( - { - return connectionStorage.getAutoConnectInfo( - initialAutoConnectPreferences - ); - } - : undefined - } - /> + + { + return connectionStorage.getAutoConnectInfo( + initialAutoConnectPreferences + ); + } + : undefined + } + /> + , elem.querySelector('[data-hook="layout-container"]') ); @@ -216,21 +223,6 @@ class Application { ); } - private setupSchemaSharingListener() { - ipcRenderer?.on('window:menu-share-schema-json', () => { - globalAppRegistry.emit('menu-share-schema-json'); - }); - } - - private setupImportExportListeners() { - ipcRenderer?.on('compass:open-export', () => { - globalAppRegistry.emit('open-active-namespace-export'); - }); - ipcRenderer?.on('compass:open-import', () => { - globalAppRegistry.emit('open-active-namespace-import'); - }); - } - private setupDownloadStatusListeners() { const fileDownloadCompleteToastId = 'file-download-complete'; ipcRenderer?.on('download-finished', (event, { path }) => { @@ -263,8 +255,6 @@ class Application { private setupIpcListeners() { this.setupDataRefreshListener(); - this.setupSchemaSharingListener(); - this.setupImportExportListeners(); this.setupDownloadStatusListeners(); } @@ -457,16 +447,6 @@ class Application { document.addEventListener('drop', (evt) => evt.preventDefault()); } - private showCollectionSubMenu({ isReadOnly }: { isReadOnly: boolean }) { - void ipcRenderer?.call('window:show-collection-submenu', { - isReadOnly, - }); - } - - private hideCollectionSubMenu() { - void ipcRenderer?.call('window:hide-collection-submenu'); - } - private showSettingsModal(tab?: SettingsTabId) { globalAppRegistry?.emit('open-compass-settings', tab); } diff --git a/packages/compass/src/app/components/home.tsx b/packages/compass/src/app/components/home.tsx index 798bf5ca975..db15bedbbb5 100644 --- a/packages/compass/src/app/components/home.tsx +++ b/packages/compass/src/app/components/home.tsx @@ -29,7 +29,6 @@ import { CompassInstanceStorePlugin } from '@mongodb-js/compass-app-stores'; import FieldStorePlugin from '@mongodb-js/compass-field-store'; import { AtlasAuthPlugin } from '@mongodb-js/atlas-service/renderer'; import { CompassGenerativeAIPlugin } from '@mongodb-js/compass-generative-ai'; -import type { WorkspaceTab } from '@mongodb-js/compass-workspaces'; import { ConnectionStorageProvider } from '@mongodb-js/connection-storage/provider'; import { ConnectionImportExportProvider } from '@mongodb-js/compass-connection-import-export'; import { useTelemetry } from '@mongodb-js/compass-telemetry/provider'; @@ -62,8 +61,6 @@ const globalDarkThemeStyles = css({ export type HomeProps = { appName: string; showWelcomeModal?: boolean; - showCollectionSubMenu: (args: { isReadOnly: boolean }) => void; - hideCollectionSubMenu: () => void; showSettings: (tab?: SettingsTabId) => void; }; @@ -76,24 +73,13 @@ const verticalSplitStyles = css({ overflow: 'hidden', }); +function noop() {} + function Home({ appName, showWelcomeModal = false, - showCollectionSubMenu, - hideCollectionSubMenu, showSettings, }: HomeProps): React.ReactElement | null { - const onWorkspaceChange = useCallback( - (ws: WorkspaceTab | null, collectionInfo) => { - if (ws?.type === 'Collection') { - showCollectionSubMenu({ isReadOnly: !!collectionInfo?.isReadonly }); - } else { - hideCollectionSubMenu(); - } - }, - [showCollectionSubMenu, hideCollectionSubMenu] - ); - const [isWelcomeOpen, setIsWelcomeOpen] = useState(showWelcomeModal); const closeWelcomeModal = useCallback( @@ -112,10 +98,7 @@ function Home({
- +
diff --git a/packages/compass/src/main/menu.spec.ts b/packages/compass/src/main/menu.spec.ts index c514e8a1f63..8f3813c7d05 100644 --- a/packages/compass/src/main/menu.spec.ts +++ b/packages/compass/src/main/menu.spec.ts @@ -9,6 +9,7 @@ import type { CompassApplication } from './application'; import type { CompassMenu as _CompassMenu } from './menu'; import { quitItem } from './menu'; import { AutoUpdateManagerState } from './auto-update-manager'; +import { RendererDefinedMenuState } from '@mongodb-js/compass-electron-menu/ipc-provider-main'; function serializable(obj: T): T { try { @@ -46,11 +47,12 @@ describe('CompassMenu', function () { const bw = new BrowserWindow({ show: false }); App.emit('new-window', bw); expect(CompassMenu['windowState']).to.have.property('size', 1); - expect(CompassMenu['windowState'].get(bw.id)).to.deep.eq({ - showCollection: false, - isReadOnly: false, - updateManagerState: 'idle', - }); + expect(serializable(CompassMenu['windowState'].get(bw.id))).to.deep.eq( + serializable({ + rendererState: new RendererDefinedMenuState(ipcMain as any), + updateManagerState: 'idle', + }) + ); }); it('should remove window from state when window is closed', function () { @@ -75,61 +77,33 @@ describe('CompassMenu', function () { expect(CompassMenu).to.have.property('currentWindowMenuLoaded', bw1.id); }); - it('should change window state when window emits show-collection-submenu event', function () { - const bw = new BrowserWindow({ show: false }); - App.emit('new-window', bw); - ipcMain.emit( - 'window:show-collection-submenu', - { sender: bw.webContents }, - { isReadOnly: false } - ); - expect(CompassMenu['windowState'].get(bw.id)).to.deep.eq({ - showCollection: true, - isReadOnly: false, - updateManagerState: 'idle', - }); - ipcMain.emit('window:hide-collection-submenu', { sender: bw.webContents }); - expect(CompassMenu['windowState'].get(bw.id)).to.deep.eq({ - showCollection: false, - isReadOnly: false, - updateManagerState: 'idle', - }); - ipcMain.emit( - 'window:show-collection-submenu', - { sender: bw.webContents }, - { isReadOnly: true } - ); - expect(CompassMenu['windowState'].get(bw.id)).to.deep.eq({ - showCollection: true, - isReadOnly: true, - updateManagerState: 'idle', - }); - }); - it('should change window state when window emits update-manager:new-state event', function () { const bw = new BrowserWindow({ show: false }); App.emit('new-window', bw); - expect(CompassMenu['windowState'].get(bw.id)).to.deep.eq({ - showCollection: false, - isReadOnly: false, - updateManagerState: 'idle', - }); + expect(serializable(CompassMenu['windowState'].get(bw.id))).to.deep.eq( + serializable({ + rendererState: new RendererDefinedMenuState(ipcMain as any), + updateManagerState: 'idle', + }) + ); App.emit('auto-updater:new-state', AutoUpdateManagerState.PromptForRestart); - expect(CompassMenu['windowState'].get(bw.id)).to.deep.eq({ - showCollection: false, - isReadOnly: false, - updateManagerState: 'ready to restart', - }); + expect(serializable(CompassMenu['windowState'].get(bw.id))).to.deep.eq( + serializable({ + rendererState: new RendererDefinedMenuState(ipcMain as any), + updateManagerState: 'ready to restart', + }) + ); App.emit( 'auto-updater:new-state', AutoUpdateManagerState.DownloadingUpdate ); - expect(CompassMenu['windowState'].get(bw.id)).to.deep.eq({ - showCollection: false, - isReadOnly: false, - updateManagerState: 'installing updates', - }); + expect(serializable(CompassMenu['windowState'].get(bw.id))).to.deep.eq( + serializable({ + rendererState: new RendererDefinedMenuState(ipcMain as any), + updateManagerState: 'installing updates', + }) + ); }); describe('getTemplate', function () { @@ -388,73 +362,100 @@ describe('CompassMenu', function () { } }); - it('should generate a menu template without collection submenu if `showCollection` is `false`', function () { - expect( - CompassMenu.getTemplate(0).find((item) => item.label === '&Collection') - ).to.be.undefined; - }); - - it('should generate a menu template with collection submenu if `showCollection` is `true`', function () { + it('should add menus requested from browser windows', function () { + const rendererState = new RendererDefinedMenuState({} as any); + Object.assign(rendererState, { + additionalMenus: [ + { + id: 'menu0', + menu: { + label: '&MyMenu', + submenu: [{ label: 'Do Something', click: 'id2' }], + }, + }, + ], + roleListeners: [ + ['undo', 'id0'], + ['redo', 'id1'], + ], + }); CompassMenu['windowState'].set(0, { - showCollection: true, - isReadOnly: false, + rendererState, updateManagerState: 'idle', }); expect( // Contains functions, so we can't easily deep equal it without // converting to serializable format serializable( - CompassMenu.getTemplate(0).find( - (item) => item.label === '&Collection' - ) + CompassMenu.getTemplate(0).find((item) => item.label === '&MyMenu') ) ).to.deep.eq({ - label: '&Collection', + label: '&MyMenu', submenu: [ { - accelerator: 'Alt+CmdOrCtrl+S', - label: '&Share Schema as JSON (Legacy)', - }, - { - type: 'separator', - }, - { - label: '&Import Data', - }, - { - label: '&Export Collection', + label: 'Do Something', }, ], }); - }); - - it('should generate a menu template with import collection action hidden if `isReadOnly` is `true`', function () { - CompassMenu['windowState'].set(0, { - showCollection: true, - isReadOnly: true, - updateManagerState: 'idle', - }); expect( // Contains functions, so we can't easily deep equal it without // converting to serializable format serializable( - CompassMenu.getTemplate(0).find( - (item) => item.label === '&Collection' - ) + CompassMenu.getTemplate(0).find((item) => item.label === 'Edit') ) ).to.deep.eq({ - label: '&Collection', + label: 'Edit', submenu: [ { - accelerator: 'Alt+CmdOrCtrl+S', - label: '&Share Schema as JSON (Legacy)', + // note the missing 'role' property here + accelerator: 'Command+Z', + label: 'Undo', + }, + { + accelerator: 'Shift+Command+Z', + label: 'Redo', + }, + { + type: 'separator', + }, + { + accelerator: 'Command+X', + label: 'Cut', + role: 'cut', + }, + { + accelerator: 'Command+C', + label: 'Copy', + role: 'copy', + }, + { + accelerator: 'Command+V', + label: 'Paste', + role: 'paste', + }, + { + accelerator: 'Command+A', + label: 'Select All', + role: 'selectAll', }, { type: 'separator', }, { - label: '&Export Collection', + accelerator: 'CmdOrCtrl+F', + label: 'Find', }, + ...(process.platform === 'darwin' + ? [] + : [ + { + type: 'separator', + }, + { + accelerator: 'CmdOrCtrl+,', + label: '&Settings', + }, + ]), ], }); }); diff --git a/packages/compass/src/main/menu.ts b/packages/compass/src/main/menu.ts index e3e3662b284..0df2f637c98 100644 --- a/packages/compass/src/main/menu.ts +++ b/packages/compass/src/main/menu.ts @@ -1,4 +1,5 @@ -import type { MenuItemConstructorOptions } from 'electron'; +import { RendererDefinedMenuState } from '@mongodb-js/compass-electron-menu/ipc-provider-main'; +import { type CompassAppMenu } from '@mongodb-js/compass-electron-menu'; import { BrowserWindow, Menu, @@ -19,7 +20,8 @@ import { createIpcTrack } from '@mongodb-js/compass-telemetry'; const track = createIpcTrack(); -type MenuTemplate = MenuItemConstructorOptions | MenuItemConstructorOptions[]; +type MenuItemConstructorOptions = CompassAppMenu; // Alias to reduce diff complexity +type MenuTemplate = CompassAppMenu | CompassAppMenu[]; const debug = createDebug('mongodb-compass:menu'); @@ -321,39 +323,6 @@ function helpSubMenu( }; } -function collectionSubMenu( - menuReadOnly: boolean, - app: typeof CompassApplication -): MenuItemConstructorOptions { - const subMenu = []; - subMenu.push({ - label: '&Share Schema as JSON (Legacy)', - accelerator: 'Alt+CmdOrCtrl+S', - click() { - ipcMain?.broadcastFocused('window:menu-share-schema-json'); - }, - }); - subMenu.push(separator()); - if (!app.preferences.getPreferences().readOnly && !menuReadOnly) { - subMenu.push({ - label: '&Import Data', - click() { - ipcMain?.broadcastFocused('compass:open-import'); - }, - }); - } - subMenu.push({ - label: '&Export Collection', - click() { - ipcMain?.broadcastFocused('compass:open-export'); - }, - }); - return { - label: '&Collection', - submenu: subMenu, - }; -} - function viewSubMenu( app: typeof CompassApplication ): MenuItemConstructorOptions { @@ -452,42 +421,36 @@ function darwinMenu( menuState: WindowMenuState, app: typeof CompassApplication ): MenuItemConstructorOptions[] { - const menu: MenuTemplate = [darwinCompassSubMenu(menuState, app)]; - - menu.push(connectSubMenu(false, app)); - menu.push(editSubMenu()); - menu.push(viewSubMenu(app)); - - if (menuState.showCollection) { - menu.push(collectionSubMenu(menuState.isReadOnly, app)); - } - - menu.push(windowSubMenu(app)); - menu.push(helpSubMenu(menuState, app)); - - return menu; + return menuState.rendererState.translateRoles([ + darwinCompassSubMenu(menuState, app), + connectSubMenu(false, app), + editSubMenu(), + viewSubMenu(app), + ...menuState.rendererState.menus(), + windowSubMenu(app), + helpSubMenu(menuState, app), + ]); } function nonDarwinMenu( menuState: WindowMenuState, app: typeof CompassApplication ): MenuItemConstructorOptions[] { - const menu = [connectSubMenu(true, app), editSubMenu(), viewSubMenu(app)]; - - if (menuState.showCollection) { - menu.push(collectionSubMenu(menuState.isReadOnly, app)); - } - - menu.push(helpSubMenu(menuState, app)); - - return menu; + return menuState.rendererState.translateRoles([ + connectSubMenu(true, app), + editSubMenu(), + viewSubMenu(app), + ...menuState.rendererState.menus(), + helpSubMenu(menuState, app), + ]); } type UpdateManagerState = 'idle' | 'installing updates' | 'ready to restart'; class WindowMenuState { - showCollection = false; - isReadOnly = false; + rendererState: RendererDefinedMenuState = new RendererDefinedMenuState( + ipcMain + ); updateManagerState: UpdateManagerState = 'idle'; } @@ -527,12 +490,15 @@ class CompassMenu { return 'idle'; } })(); - this.updateMenu({ updateManagerState }); + this.updateMenu(() => ({ updateManagerState })); }); ipcMain?.respondTo({ - 'window:show-collection-submenu': this.showCollection.bind(this), - 'window:hide-collection-submenu': this.hideCollection.bind(this), + [RendererDefinedMenuState.modifyApplicationMenuIpcEvent]: (ev, params) => + this.updateMenu((state) => ({ + rendererState: + state.rendererState.modifyApplicationMenuHandler(params), + })), }); preferences.onPreferenceValueChanged('theme', (newTheme: THEMES) => { @@ -652,10 +618,11 @@ class CompassMenu { menuState = new WindowMenuState(); } - if (process.platform === 'darwin') { - return darwinMenu(menuState, this.app); - } - return nonDarwinMenu(menuState, this.app); + const menu = + process.platform === 'darwin' + ? darwinMenu(menuState, this.app) + : nonDarwinMenu(menuState, this.app); + return menuState.rendererState.translateRoles(menu); } private static refreshMenu = () => { @@ -674,19 +641,8 @@ class CompassMenu { Menu.setApplicationMenu(menu); }; - private static showCollection( - evt: unknown, - { isReadOnly }: { isReadOnly: boolean } - ) { - this.updateMenu({ showCollection: true, isReadOnly }); - } - - private static hideCollection() { - this.updateMenu({ showCollection: false }); - } - private static updateMenu( - newValues: Partial, + newValues: (state: WindowMenuState) => Partial, bw: BrowserWindow | null = this.lastFocusedWindow ) { debug(`updateMenu() set menu state to ${JSON.stringify(newValues)}`); @@ -699,7 +655,7 @@ class CompassMenu { const menuState = this.windowState.get(bw.id); if (menuState) { - Object.assign(menuState, newValues); + Object.assign(menuState, newValues(menuState)); this.windowState.set(bw.id, menuState); this.setTemplate(bw.id); }