diff --git a/package-lock.json b/package-lock.json index 4ad7345186f..fbec065a1c8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7951,6 +7951,10 @@ "prettier": "^2.3.2" } }, + "node_modules/@mongodb-js/react-electron-menu": { + "resolved": "packages/react-electron-menu", + "link": true + }, "node_modules/@mongodb-js/saslprep": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.1.7.tgz", @@ -19621,6 +19625,15 @@ "integrity": "sha512-OKvTvu9n3swmgYshvsyVHYX0+aPzCoYUnyXUacfQMmFtBtBKewV/gT4I9jkAbpTqtTi2E4S9MXLlvzBDUlqg0Q==", "dev": true }, + "node_modules/diff": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz", + "integrity": "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==", + "dev": true, + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/diff-match-patch": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz", @@ -37339,7 +37352,6 @@ "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz", "integrity": "sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==", - "dev": true, "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", @@ -37353,7 +37365,6 @@ "version": "0.20.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.20.2.tgz", "integrity": "sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==", - "dev": true, "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1" @@ -43752,6 +43763,7 @@ "@mongodb-js/mongodb-downloader": "^0.3.0", "@mongodb-js/my-queries-storage": "^0.8.0", "@mongodb-js/prettier-config-compass": "^1.0.2", + "@mongodb-js/react-electron-menu": "^1.0.0", "@mongodb-js/sbom-tools": "^0.5.3", "@mongodb-js/tsconfig-compass": "^1.0.4", "@mongodb-js/webpack-config-compass": "^1.3.8", @@ -45580,15 +45592,6 @@ "type-detect": "4.0.8" } }, - "packages/compass-intercom/node_modules/diff": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz", - "integrity": "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==", - "dev": true, - "engines": { - "node": ">=0.3.1" - } - }, "packages/compass-intercom/node_modules/just-extend": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-6.2.0.tgz", @@ -46699,15 +46702,6 @@ "ieee754": "^1.2.1" } }, - "packages/compass-web/node_modules/diff": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-5.1.0.tgz", - "integrity": "sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==", - "dev": true, - "engines": { - "node": ">=0.3.1" - } - }, "packages/compass-web/node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -47041,15 +47035,6 @@ } } }, - "packages/compass-workspaces/node_modules/diff": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-5.1.0.tgz", - "integrity": "sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==", - "dev": true, - "engines": { - "node": ">=0.3.1" - } - }, "packages/compass-workspaces/node_modules/nise": { "version": "5.1.5", "resolved": "https://registry.npmjs.org/nise/-/nise-5.1.5.tgz", @@ -47268,15 +47253,6 @@ "type-detect": "4.0.8" } }, - "packages/connection-info/node_modules/diff": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-5.1.0.tgz", - "integrity": "sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==", - "dev": true, - "engines": { - "node": ">=0.3.1" - } - }, "packages/connection-info/node_modules/nise": { "version": "5.1.5", "resolved": "https://registry.npmjs.org/nise/-/nise-5.1.5.tgz", @@ -48628,15 +48604,6 @@ "type-detect": "4.0.8" } }, - "packages/hadron-document/node_modules/diff": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-5.1.0.tgz", - "integrity": "sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==", - "dev": true, - "engines": { - "node": ">=0.3.1" - } - }, "packages/hadron-document/node_modules/nise": { "version": "5.1.5", "resolved": "https://registry.npmjs.org/nise/-/nise-5.1.5.tgz", @@ -49120,6 +49087,118 @@ "mocha": "^10.2.0" } }, + "packages/react-electron-menu": { + "name": "@mongodb-js/react-electron-menu", + "version": "1.0.0", + "license": "SSPL", + "dependencies": { + "react": "^17.0.2", + "react-dom": "^17.0.2" + }, + "devDependencies": { + "@mongodb-js/eslint-config-compass": "^1.1.1", + "@mongodb-js/mocha-config-compass": "^1.3.9", + "@mongodb-js/prettier-config-compass": "^1.0.2", + "@mongodb-js/tsconfig-compass": "^1.0.4", + "@testing-library/react": "^12.1.5", + "@testing-library/user-event": "^13.5.0", + "@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", + "eslint": "^7.25.0", + "mocha": "^10.2.0", + "nyc": "^15.1.0", + "prettier": "^2.7.1", + "sinon": "^17.0.1", + "typescript": "^5.0.4" + } + }, + "packages/react-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, + "dependencies": { + "type-detect": "4.0.8" + } + }, + "packages/react-electron-menu/node_modules/@sinonjs/fake-timers": { + "version": "11.2.2", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-11.2.2.tgz", + "integrity": "sha512-G2piCSxQ7oWOxwGSAyFHfPIsyeJGXYtc6mFbnFA+kRXkiEnTl8c/8jul2S329iFBnDI9HGoeWWAZvuvOkZccgw==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "packages/react-electron-menu/node_modules/@sinonjs/samsam": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.0.tgz", + "integrity": "sha512-Bp8KUVlLp8ibJZrnvq2foVhP0IVX2CIprMJPK0vqGqgrDa0OHVKeZyBykqskkrdxV6yKBPmGasO8LVjAKR3Gew==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^2.0.0", + "lodash.get": "^4.4.2", + "type-detect": "^4.0.8" + } + }, + "packages/react-electron-menu/node_modules/@sinonjs/samsam/node_modules/@sinonjs/commons": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-2.0.0.tgz", + "integrity": "sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg==", + "dev": true, + "dependencies": { + "type-detect": "4.0.8" + } + }, + "packages/react-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 + }, + "packages/react-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, + "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/react-electron-menu/node_modules/path-to-regexp": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.2.tgz", + "integrity": "sha512-GQX3SSMokngb36+whdpRXE+3f9V8UzyAorlYvOGx87ufGHehNTn5lCxrKtLyZ4Yl/wEKnNnr98ZzOwwDZV5ogw==", + "dev": true + }, + "packages/react-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, + "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/reflux-store/node_modules/acorn": { "version": "5.7.4", "resolved": "https://registry.npmjs.org/acorn/-/acorn-5.7.4.tgz", @@ -56311,12 +56390,6 @@ } } }, - "diff": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz", - "integrity": "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==", - "dev": true - }, "just-extend": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-6.2.0.tgz", @@ -57469,12 +57542,6 @@ "ieee754": "^1.2.1" } }, - "diff": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-5.1.0.tgz", - "integrity": "sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==", - "dev": true - }, "events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -57750,12 +57817,6 @@ "react-error-boundary": "^3.1.0" } }, - "diff": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-5.1.0.tgz", - "integrity": "sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==", - "dev": true - }, "nise": { "version": "5.1.5", "resolved": "https://registry.npmjs.org/nise/-/nise-5.1.5.tgz", @@ -57947,12 +58008,6 @@ } } }, - "diff": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-5.1.0.tgz", - "integrity": "sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==", - "dev": true - }, "nise": { "version": "5.1.5", "resolved": "https://registry.npmjs.org/nise/-/nise-5.1.5.tgz", @@ -58787,6 +58842,114 @@ "integrity": "sha512-Zaw/H/QUzwnIpThiD8IYxTurC7sv7OLwVXx9msgMkBIB6ebYXLeSNVZ25Q+gDah/t8mRFtBbDhq/Uledg7dPSQ==", "dev": true }, + "@mongodb-js/react-electron-menu": { + "version": "file:packages/react-electron-menu", + "requires": { + "@mongodb-js/eslint-config-compass": "^1.1.1", + "@mongodb-js/mocha-config-compass": "^1.3.9", + "@mongodb-js/prettier-config-compass": "^1.0.2", + "@mongodb-js/tsconfig-compass": "^1.0.4", + "@testing-library/react": "^12.1.5", + "@testing-library/user-event": "^13.5.0", + "@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", + "eslint": "^7.25.0", + "mocha": "^10.2.0", + "nyc": "^15.1.0", + "prettier": "^2.7.1", + "react": "^17.0.2", + "react-dom": "^17.0.2", + "sinon": "^17.0.1", + "typescript": "^5.0.4" + }, + "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.2.2", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-11.2.2.tgz", + "integrity": "sha512-G2piCSxQ7oWOxwGSAyFHfPIsyeJGXYtc6mFbnFA+kRXkiEnTl8c/8jul2S329iFBnDI9HGoeWWAZvuvOkZccgw==", + "dev": true, + "requires": { + "@sinonjs/commons": "^3.0.0" + } + }, + "@sinonjs/samsam": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.0.tgz", + "integrity": "sha512-Bp8KUVlLp8ibJZrnvq2foVhP0IVX2CIprMJPK0vqGqgrDa0OHVKeZyBykqskkrdxV6yKBPmGasO8LVjAKR3Gew==", + "dev": true, + "requires": { + "@sinonjs/commons": "^2.0.0", + "lodash.get": "^4.4.2", + "type-detect": "^4.0.8" + }, + "dependencies": { + "@sinonjs/commons": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-2.0.0.tgz", + "integrity": "sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg==", + "dev": true, + "requires": { + "type-detect": "4.0.8" + } + } + } + }, + "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.2.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.2.tgz", + "integrity": "sha512-GQX3SSMokngb36+whdpRXE+3f9V8UzyAorlYvOGx87ufGHehNTn5lCxrKtLyZ4Yl/wEKnNnr98ZzOwwDZV5ogw==", + "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/saslprep": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.1.7.tgz", @@ -68864,6 +69027,12 @@ "integrity": "sha512-OKvTvu9n3swmgYshvsyVHYX0+aPzCoYUnyXUacfQMmFtBtBKewV/gT4I9jkAbpTqtTi2E4S9MXLlvzBDUlqg0Q==", "dev": true }, + "diff": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz", + "integrity": "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==", + "dev": true + }, "diff-match-patch": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz", @@ -74021,12 +74190,6 @@ } } }, - "diff": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-5.1.0.tgz", - "integrity": "sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==", - "dev": true - }, "nise": { "version": "5.1.5", "resolved": "https://registry.npmjs.org/nise/-/nise-5.1.5.tgz", @@ -79250,6 +79413,7 @@ "@mongodb-js/mongodb-downloader": "^0.3.0", "@mongodb-js/my-queries-storage": "^0.8.0", "@mongodb-js/prettier-config-compass": "^1.0.2", + "@mongodb-js/react-electron-menu": "^1.0.0", "@mongodb-js/sbom-tools": "^0.5.3", "@mongodb-js/tsconfig-compass": "^1.0.4", "@mongodb-js/webpack-config-compass": "^1.3.8", @@ -83780,7 +83944,6 @@ "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz", "integrity": "sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==", - "dev": true, "requires": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", @@ -83791,7 +83954,6 @@ "version": "0.20.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.20.2.tgz", "integrity": "sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==", - "dev": true, "requires": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1" diff --git a/packages/compass-collection/src/components/collection-tab.tsx b/packages/compass-collection/src/components/collection-tab.tsx index 92db2669e43..1f04ce701cb 100644 --- a/packages/compass-collection/src/components/collection-tab.tsx +++ b/packages/compass-collection/src/components/collection-tab.tsx @@ -21,6 +21,12 @@ import { CollectionIndexesStats, } from './collection-tab-stats'; import type { CollectionSubtab } from '@mongodb-js/compass-workspaces'; +import { + ElectronMenuItem, + ElectronMenuSeparator, + ElectronSubMenu, +} from '@mongodb-js/react-electron-menu'; +import { globalAppRegistry } from 'hadron-app-registry'; function trackingIdForTabName(name: string) { return name.toLowerCase().replace(/ /g, '_'); @@ -242,10 +248,38 @@ const CollectionTab = ({ } return ( - + <> + + + { + globalAppRegistry.emit('menu-share-schema-json'); + }} + > + + {!collectionMetadata.isReadonly && ( + { + globalAppRegistry.emit('open-active-namespace-import'); + }} + > + )} + { + globalAppRegistry.emit('open-active-namespace-export'); + }} + > + + + + ); }; diff --git a/packages/compass-connections-navigation/src/collection-item.tsx b/packages/compass-connections-navigation/src/collection-item.tsx index 85b8fe4ec41..2e4d9d98048 100644 --- a/packages/compass-connections-navigation/src/collection-item.tsx +++ b/packages/compass-connections-navigation/src/collection-item.tsx @@ -22,6 +22,10 @@ import type { import type { Actions } from './constants'; import { usePreference } from 'compass-preferences-model/provider'; import { getItemPaddingStyles } from './utils'; +import { + ElectronMenu, + ElectronMenuItem, +} from '@mongodb-js/react-electron-menu'; const CollectionIcon: React.FunctionComponent<{ type: string; @@ -156,37 +160,57 @@ export const CollectionItem: React.FunctionComponent< }, [type, isReadOnly, isRenameCollectionEnabled]); return ( - { + return ( + + + + + + {name} + + + + onAction={onAction} + data-testid="sidebar-collection-item-actions" + iconSize="small" + isVisible={isActive || isHovered} + actions={actions} + > + + + ); + }} > - - - - - {name} - - - - onAction={onAction} - data-testid="sidebar-collection-item-actions" - iconSize="small" - isVisible={isActive || isHovered} - actions={actions} - > - - + {actions.map((action) => { + return ( + { + onAction(action.action); + }} + > + ); + })} + ); }; diff --git a/packages/compass-connections-navigation/src/tree-item.tsx b/packages/compass-connections-navigation/src/tree-item.tsx index f9727151f3d..e7d9df0e249 100644 --- a/packages/compass-connections-navigation/src/tree-item.tsx +++ b/packages/compass-connections-navigation/src/tree-item.tsx @@ -217,6 +217,7 @@ export const ItemContainer: React.FunctionComponent< | React.KeyboardEvent | React.MouseEvent ): void; + containerRef?: React.LegacyRef; } & React.HTMLProps > = ({ id, @@ -229,6 +230,7 @@ export const ItemContainer: React.FunctionComponent< onDefaultAction, children, className, + containerRef, ...props }) => { const isMultipleConnection = usePreference( @@ -258,7 +260,8 @@ export const ItemContainer: React.FunctionComponent< }, props, defaultActionProps, - focusRingProps + focusRingProps, + { ref: containerRef } ); return ( diff --git a/packages/compass-connections/src/components/connections-provider.tsx b/packages/compass-connections/src/components/connections-provider.tsx index 83b3f9a4cb4..dc7261ef7b4 100644 --- a/packages/compass-connections/src/components/connections-provider.tsx +++ b/packages/compass-connections/src/components/connections-provider.tsx @@ -1,7 +1,15 @@ import React, { useContext, useEffect, useRef } from 'react'; -import { useConnectionsManagerContext } from '../provider'; +import { + useActiveConnections, + useConnectionsManagerContext, +} from '../provider'; import { useConnections as useConnectionsStore } from '../stores/connections-store'; import { useConnectionRepository as useConnectionsRepositoryState } from '../hooks/use-connection-repository'; +import { + ElectronMenuItem, + ElectronSubMenu, +} from '@mongodb-js/react-electron-menu'; +import { getConnectionTitle } from '@mongodb-js/connection-info'; const ConnectionsStoreContext = React.createContext = ({ children, ...useConnectionsParams }) => { const connectionsStore = useConnectionsStore(useConnectionsParams); + const activeConnections = useActiveConnections(); + return ( - - {children} - + <> + {activeConnections.length > 0 && ( + + {activeConnections.map((connectionInfo) => { + return ( + + { + void connectionsStore.closeConnection(connectionInfo.id); + }} + > + { + void navigator.clipboard.writeText( + connectionInfo.connectionOptions.connectionString + ); + }} + > + + ); + })} + + )} + + {children} + + ); }; diff --git a/packages/compass-crud/src/components/document-list-view.tsx b/packages/compass-crud/src/components/document-list-view.tsx index 938ecc6fd1b..61802d1751f 100644 --- a/packages/compass-crud/src/components/document-list-view.tsx +++ b/packages/compass-crud/src/components/document-list-view.tsx @@ -6,6 +6,10 @@ import type { DocumentProps } from './document'; import Document from './document'; import type HadronDocument from 'hadron-document'; import type { BSONObject } from '../stores/crud-store'; +import { + ElectronMenu, + ElectronMenuItem, +} from '@mongodb-js/react-electron-menu'; const listStyles = css({ listStyle: 'none', @@ -51,24 +55,45 @@ class DocumentListView extends React.Component { renderDocuments() { return this.props.docs.map((doc, index) => { return ( -
  • { + return ( +
  • + + + +
  • + ); + }} > - - - - + { + const str = + typeof doc.toEJSON === 'function' + ? doc.toEJSON() + : JSON.stringify(doc); + void navigator.clipboard.writeText(str); + }} + > + ); }); } diff --git a/packages/compass/package.json b/packages/compass/package.json index 68477136adb..ecbeb8e4909 100644 --- a/packages/compass/package.json +++ b/packages/compass/package.json @@ -183,8 +183,8 @@ "@mongodb-js/compass-app-stores": "^7.13.0", "@mongodb-js/compass-collection": "^4.26.0", "@mongodb-js/compass-components": "^1.25.0", - "@mongodb-js/compass-connections": "^1.28.0", "@mongodb-js/compass-connection-import-export": "^0.24.0", + "@mongodb-js/compass-connections": "^1.28.0", "@mongodb-js/compass-crud": "^13.27.0", "@mongodb-js/compass-databases-collections": "^1.26.0", "@mongodb-js/compass-explain-plan": "^6.27.0", @@ -215,6 +215,7 @@ "@mongodb-js/mongodb-downloader": "^0.3.0", "@mongodb-js/my-queries-storage": "^0.8.0", "@mongodb-js/prettier-config-compass": "^1.0.2", + "@mongodb-js/react-electron-menu": "^1.0.0", "@mongodb-js/sbom-tools": "^0.5.3", "@mongodb-js/tsconfig-compass": "^1.0.4", "@mongodb-js/webpack-config-compass": "^1.3.8", diff --git a/packages/compass/src/app/components/home.tsx b/packages/compass/src/app/components/home.tsx index c242039e697..b06b47139e3 100644 --- a/packages/compass/src/app/components/home.tsx +++ b/packages/compass/src/app/components/home.tsx @@ -57,6 +57,10 @@ import { ExportConnectionsModal, } from '@mongodb-js/compass-connection-import-export'; import { usePreference } from 'compass-preferences-model/provider'; +import { + ElectronMenuItem, + ElectronSubMenu, +} from '@mongodb-js/react-electron-menu'; resetGlobalCSS(); diff --git a/packages/compass/src/app/index.tsx b/packages/compass/src/app/index.tsx index c7c8ceefdc1..e11670c2b60 100644 --- a/packages/compass/src/app/index.tsx +++ b/packages/compass/src/app/index.tsx @@ -9,6 +9,11 @@ import { globalAppRegistry } from 'hadron-app-registry'; import { defaultPreferencesInstance } from 'compass-preferences-model'; import semver from 'semver'; import { CompassElectron } from './components/entrypoint'; +import { + ElectronMenuIpcProvider, + ElectronMenu, + ElectronMenuItem, +} from '@mongodb-js/react-electron-menu'; // https://github.com/nodejs/node/issues/40537 dns.setDefaultResultOrder('ipv4first'); @@ -83,6 +88,7 @@ import { CompassRendererConnectionStorage, type AutoConnectPreferences, } from '@mongodb-js/connection-storage/renderer'; +import Menu from './menu'; const { log, mongoLogId, track } = createLoggerAndTelemetry('COMPASS-APP'); // Lets us call `setShowDevFeatureFlags(true | false)` from DevTools. @@ -235,18 +241,36 @@ const Application = View.extend({ await defaultPreferencesInstance.ensureDefaultConfigurableUserPreferences(); } + const appName = remote.app.getName(); + + const openNewWindow = () => { + void ipcRenderer?.call('test:show-connect-window'); + }; + ReactDOM.render( - + + + + + + + + + + , this.queryByHook('layout-container') ); diff --git a/packages/compass/src/app/menu.tsx b/packages/compass/src/app/menu.tsx new file mode 100644 index 00000000000..54e7ce84c5b --- /dev/null +++ b/packages/compass/src/app/menu.tsx @@ -0,0 +1,56 @@ +/* eslint-disable jsx-a11y/aria-role */ +import React from 'react'; +import { + ElectronMenuItem, + ElectronSubMenu, +} from '@mongodb-js/react-electron-menu'; + +function Menu({ + appName, + onNewWindowClick, +}: { + appName: string; + onNewWindowClick(): void; +}) { + return ( + <> + {/* MacOS-only Compass sub menu */} + {process.platform === 'darwin' && ( + + + + )} + + + + + + + + + + + + ); +} + +export default Menu; diff --git a/packages/compass/src/main/application.ts b/packages/compass/src/main/application.ts index 34d87b5f808..38380fa5d5b 100644 --- a/packages/compass/src/main/application.ts +++ b/packages/compass/src/main/application.ts @@ -24,6 +24,7 @@ import { initCompassMainConnectionStorage, getCompassMainConnectionStorage, } from '@mongodb-js/connection-storage/main'; +import { initialize as initializeReactElectronMenu } from '@mongodb-js/react-electron-menu/main'; const { debug, log, track, mongoLogId } = createLoggerAndTelemetry('COMPASS-MAIN'); @@ -136,7 +137,7 @@ class CompassApplication { setupTheme(this); this.setupJavaScriptArguments(); this.setupLifecycleListeners(); - this.setupApplicationMenu(); + initializeReactElectronMenu(); this.setupWindowManager(); this.trackApplicationLaunched(globalPreferences); } diff --git a/packages/compass/src/main/index.ts b/packages/compass/src/main/index.ts index 474b75a0c22..4a807915007 100644 --- a/packages/compass/src/main/index.ts +++ b/packages/compass/src/main/index.ts @@ -1,7 +1,7 @@ // THIS IMPORT SHOULD ALWAYS BE THE FIRST ONE FOR THE APPLICATION ENTRY POINT import '../setup-hadron-distribution'; -import { app, dialog, crashReporter } from 'electron'; +import { app, dialog, crashReporter, Menu } from 'electron'; import { handleUncaughtException } from './handle-uncaught-exception'; import { handleUnhandledRejection } from './handle-unhandled-rejection'; import { initialize as initializeElectronRemote } from '@electron/remote/main'; @@ -18,6 +18,8 @@ import chalk from 'chalk'; import { installEarlyLoggingListener } from './logging'; import { installEarlyOpenUrlListener } from './window-manager'; +Menu.setApplicationMenu(null); + crashReporter.start({ uploadToServer: false }); initializeElectronRemote(); diff --git a/packages/react-electron-menu/.depcheckrc b/packages/react-electron-menu/.depcheckrc new file mode 100644 index 00000000000..ae7c8273e41 --- /dev/null +++ b/packages/react-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/react-electron-menu/.eslintignore b/packages/react-electron-menu/.eslintignore new file mode 100644 index 00000000000..85a8a75e68c --- /dev/null +++ b/packages/react-electron-menu/.eslintignore @@ -0,0 +1,2 @@ +.nyc-output +dist diff --git a/packages/react-electron-menu/.eslintrc.js b/packages/react-electron-menu/.eslintrc.js new file mode 100644 index 00000000000..e4cf824b6ac --- /dev/null +++ b/packages/react-electron-menu/.eslintrc.js @@ -0,0 +1,8 @@ +module.exports = { + root: true, + extends: ['@mongodb-js/eslint-config-compass'], + parserOptions: { + tsconfigRootDir: __dirname, + project: ['./tsconfig-lint.json'], + }, +}; diff --git a/packages/react-electron-menu/.mocharc.js b/packages/react-electron-menu/.mocharc.js new file mode 100644 index 00000000000..30aecfb78c3 --- /dev/null +++ b/packages/react-electron-menu/.mocharc.js @@ -0,0 +1 @@ +module.exports = require('@mongodb-js/mocha-config-compass/react'); diff --git a/packages/react-electron-menu/.prettierignore b/packages/react-electron-menu/.prettierignore new file mode 100644 index 00000000000..4d28df6603a --- /dev/null +++ b/packages/react-electron-menu/.prettierignore @@ -0,0 +1,3 @@ +.nyc_output +dist +coverage diff --git a/packages/react-electron-menu/.prettierrc.json b/packages/react-electron-menu/.prettierrc.json new file mode 100644 index 00000000000..18853d1532e --- /dev/null +++ b/packages/react-electron-menu/.prettierrc.json @@ -0,0 +1 @@ +"@mongodb-js/prettier-config-compass" diff --git a/packages/react-electron-menu/main.d.ts b/packages/react-electron-menu/main.d.ts new file mode 100644 index 00000000000..2288fec21d1 --- /dev/null +++ b/packages/react-electron-menu/main.d.ts @@ -0,0 +1 @@ +export * from './dist/main.d'; diff --git a/packages/react-electron-menu/main.js b/packages/react-electron-menu/main.js new file mode 100644 index 00000000000..bf8a3d0b439 --- /dev/null +++ b/packages/react-electron-menu/main.js @@ -0,0 +1,2 @@ +'use strict'; +module.exports = require('./dist/main'); diff --git a/packages/react-electron-menu/package.json b/packages/react-electron-menu/package.json new file mode 100644 index 00000000000..80dfd0c5b60 --- /dev/null +++ b/packages/react-electron-menu/package.json @@ -0,0 +1,79 @@ +{ + "name": "@mongodb-js/react-electron-menu", + "description": "Yet another electron menu renderer using React", + "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": "1.0.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", + "./main": "./main.js" + }, + "compass:exports": { + ".": "./src/index.tsx", + "./main": "./src/main.ts" + }, + "types": "./dist/index.d.ts", + "scripts": { + "bootstrap": "npm run compile", + "prepublishOnly": "npm run compile && compass-scripts check-exports-exist", + "compile": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig-lint.json --noEmit", + "eslint": "eslint", + "prettier": "prettier", + "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": { + "react": "^17.0.2", + "react-dom": "^17.0.2" + }, + "devDependencies": { + "@mongodb-js/eslint-config-compass": "^1.1.1", + "@mongodb-js/mocha-config-compass": "^1.3.9", + "@mongodb-js/prettier-config-compass": "^1.0.2", + "@mongodb-js/tsconfig-compass": "^1.0.4", + "@testing-library/react": "^12.1.5", + "@testing-library/user-event": "^13.5.0", + "@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", + "eslint": "^7.25.0", + "mocha": "^10.2.0", + "nyc": "^15.1.0", + "prettier": "^2.7.1", + "sinon": "^17.0.1", + "typescript": "^5.0.4" + } +} diff --git a/packages/react-electron-menu/src/index.spec.tsx b/packages/react-electron-menu/src/index.spec.tsx new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/react-electron-menu/src/index.tsx b/packages/react-electron-menu/src/index.tsx new file mode 100644 index 00000000000..b774d0953bc --- /dev/null +++ b/packages/react-electron-menu/src/index.tsx @@ -0,0 +1,445 @@ +import React, { useContext, useEffect, useRef, useState } from 'react'; +import type { + MenuItemConstructorOptions, + IpcRenderer, + KeyboardEvent as ElectronKeyboardEvent, +} from 'electron'; +import debounce from 'lodash/debounce'; + +const ElectronMenuIpcContext = React.createContext(null); + +export const ElectronMenuIpcProvider = ElectronMenuIpcContext.Provider; + +type ElectronMenuItemOptions = Omit< + MenuItemConstructorOptions, + 'id' | 'label' | 'submenu' +> & { + id: string; + label: string; + submenu?: ElectronMenuItemOptions[]; +}; + +type MenuItemContentProps = Pick< + ElectronMenuItemOptions, + 'label' | 'sublabel' | 'toolTip' +>; + +type MenuItemStateProps = Pick< + ElectronMenuItemOptions, + 'enabled' | 'visible' | 'checked' | 'acceleratorWorksWhenHidden' +>; + +type MenuItemTypeProps = Pick< + ElectronMenuItemOptions, + 'role' | 'accelerator' +> & { + type?: Extract< + ElectronMenuItemOptions['type'], + 'normal' | 'checkbox' | 'radio' | 'separator' + >; +}; + +interface ElectronMenuTemplateBuilder { + updateItem( + prevItem: ElectronMenuItemOptions | null, + newItem: ElectronMenuItemOptions | null + ): void; + getTemplate(): ElectronMenuItemOptions | ElectronMenuItemOptions[]; + subscribe( + fn: (template: ElectronMenuItemOptions | ElectronMenuItemOptions[]) => void + ): () => void; +} + +class TemplateBuilder implements ElectronMenuTemplateBuilder { + private items = new Map< + string, + ElectronMenuItemOptions | { type: 'submenuRef'; items: Set } + >(); + private submenus = new Map(); + private subscriptions = new Set< + (template: ElectronMenuItemOptions | ElectronMenuItemOptions[]) => void + >(); + constructor(private type: 'menu' | 'submenu') {} + getTemplate(): ElectronMenuItemOptions | ElectronMenuItemOptions[] { + const menuItems = Array.from(this.items.values()).flatMap((item) => { + return item.type === 'submenuRef' + ? (() => { + let submenuItem = { + id: '', + type: 'submenu', + submenu: [] as ElectronMenuItemOptions[], + }; + for (const submenuId of item.items.values()) { + const { submenu = [], ...item } = this.submenus.get(submenuId)!; + const menuItems = submenuItem.submenu.concat(submenu); + submenuItem = { + ...submenuItem, + ...item, + submenu: menuItems, + }; + } + return submenuItem as ElectronMenuItemOptions; + })() + : item; + }); + return this.type === 'menu' + ? menuItems + : { + id: '', + label: '', + type: 'submenu', + submenu: menuItems, + }; + } + updateItem( + prevItem: ElectronMenuItemOptions | null, + newItem: ElectronMenuItemOptions | null + ): void { + const itemType = prevItem?.type ?? newItem?.type; + if (itemType === 'submenu') { + if (newItem) { + this.submenus.set(newItem.id, newItem); + const item = + this.items.get(newItem.label) ?? + this.items + .set(newItem.label, { + type: 'submenuRef', + items: new Set(), + }) + .get(newItem.label); + if (item?.type === 'submenuRef') { + item.items.add(newItem.id); + } + } else if (prevItem) { + this.submenus.delete(prevItem.id); + const item = this.items.get(prevItem.label); + if (item?.type === 'submenuRef') { + item.items.delete(prevItem.id); + if (item.items.size === 0) { + this.items.delete(prevItem.label); + } + } + } + } else { + if (newItem) { + this.items.set(newItem.label, newItem); + } else if (prevItem) { + this.items.delete(prevItem.label); + } + } + const template = this.getTemplate(); + this.subscriptions.forEach((fn) => { + fn(template); + }); + } + subscribe( + fn: (template: ElectronMenuItemOptions | ElectronMenuItemOptions[]) => void + ): () => void { + this.subscriptions.add(fn); + return () => { + this.subscriptions.delete(fn); + }; + } +} + +const ElectronMenuTemplateBuilderContext = + React.createContext({ + updateItem() { + // noop + }, + getTemplate() { + return []; + }, + subscribe() { + return () => { + // noop + }; + }, + }); + +type ElectronMenuClickEventHandler = (event: ElectronKeyboardEvent) => void; + +interface ElectronMenuClickHandler { + subscribe(id: string, fn: ElectronMenuClickEventHandler): () => void; +} + +class IpcClickHandler implements ElectronMenuClickHandler { + private subscriptions = new Map(); + constructor(private ipc?: IpcRenderer) { + ipc?.on('react-electron-menu-on-click', this.onClickHandler); + } + private onClickHandler = ( + _ipcEvent: unknown, + id: string, + event: ElectronKeyboardEvent + ) => { + this.subscriptions.get(id)?.(event); + }; + subscribe(id: string, fn: ElectronMenuClickEventHandler): () => void { + this.subscriptions.set(id, fn); + return () => { + this.subscriptions.delete(id); + }; + } + cleanup() { + this.ipc?.off('react-electron-menu-on-click', this.onClickHandler); + } +} + +const ElectronMenuClickHandlerContext = + React.createContext({ + subscribe() { + return () => { + // noop + }; + }, + }); + +function useCurrectRef(val: T): { current: T } { + const ref = useRef(val); + ref.current = val; + return ref; +} + +function useClickHandler(ipc?: IpcRenderer) { + const ref = useRef(); + if (!ref.current) { + ref.current = new IpcClickHandler(ipc); + } + return ref.current; +} + +function useTemplateBuilder(type: 'menu' | 'submenu') { + const ref = useRef(); + if (!ref.current) { + ref.current = new TemplateBuilder(type); + } + return ref.current; +} + +export function ElectronMenu({ + children, + type, + trigger, +}: { children: React.ReactNode } & ( + | { type?: never; trigger?: never } + | { type: 'dock'; trigger?: never } + | { + type: 'context'; + trigger: >(props: { + ref: T; + }) => React.ReactNode; + } +)) { + const id = useId(); + const ipcRef = useRef(useContext(ElectronMenuIpcContext)); + const triggerRef = useRef(null); + const typeRef = useRef(type); + const idRef = useCurrectRef(id); + const clickHandler = useClickHandler(ipcRef.current!); + const templateBuilder = useTemplateBuilder('menu'); + + const emitUpdateRef = useRef( + debounce( + (template: ElectronMenuItemOptions | ElectronMenuItemOptions[]) => { + ipcRef.current?.send('react-electron-menu-update', { + id: idRef.current, + type: typeRef.current ?? 'menu', + template, + }); + } + ) + ); + + useEffect(() => { + if (typeRef.current !== 'context') { + return; + } + + const trigger = triggerRef.current; + const onContextMenu = () => { + ipcRef.current?.send('react-electron-menu-contextmenu-click', { + id: idRef.current, + }); + }; + trigger?.addEventListener('contextmenu', onContextMenu); + return () => { + trigger?.removeEventListener('contextmenu', onContextMenu); + }; + }); + + useEffect(() => { + emitUpdateRef.current(templateBuilder.getTemplate()); + return templateBuilder.subscribe((template) => { + emitUpdateRef.current(template); + }); + }, [templateBuilder]); + + useEffect(() => { + return () => { + clickHandler.cleanup(); + }; + }, [clickHandler]); + + return ( + + + {trigger?.({ ref: triggerRef })} + {children} + + + ); +} + +function useId(defaultId?: string) { + const [id] = useState(() => { + return defaultId ?? crypto.randomUUID(); + }); + return id; +} + +type MenuGroupProps = MenuItemContentProps & { + id?: string; + children: React.ReactNode; +}; + +export function ElectronSubMenu({ + id: _id, + label, + sublabel, + toolTip, + children, +}: MenuGroupProps) { + const id = useId(_id); + const parentTemplateBuilder = useContext(ElectronMenuTemplateBuilderContext); + const submenuTemplateBuilder = useTemplateBuilder('submenu'); + const currentSubmenuOptionsRef = useCurrectRef({ + id, + label, + sublabel, + toolTip, + }); + const previousTemplateRef = useRef(null); + const updateTemplateRef = useRef(function ( + template: ElectronMenuItemOptions | ElectronMenuItemOptions[] + ) { + if (Array.isArray(template)) { + throw new Error('Incorrect state'); + } + const newTemplate: ElectronMenuItemOptions = { + ...currentSubmenuOptionsRef.current, + type: 'submenu', + submenu: template.submenu, + }; + parentTemplateBuilder.updateItem(previousTemplateRef.current, newTemplate); + previousTemplateRef.current = newTemplate; + }); + + useEffect(() => { + updateTemplateRef.current(submenuTemplateBuilder.getTemplate()); + return () => { + parentTemplateBuilder.updateItem(previousTemplateRef.current, null); + }; + }, [parentTemplateBuilder, submenuTemplateBuilder]); + + useEffect(() => { + return submenuTemplateBuilder.subscribe((template) => { + updateTemplateRef.current(template); + }); + }, [submenuTemplateBuilder]); + + return ( + + {children} + + ); +} + +type MenuItemProps = { + id?: string; + onClick?: (event: ElectronKeyboardEvent) => void; +} & MenuItemContentProps & + MenuItemStateProps & + MenuItemTypeProps; + +export function ElectronMenuItem({ + id: _id, + label, + sublabel, + toolTip, + enabled, + visible, + checked, + acceleratorWorksWhenHidden, + role, + type, + accelerator, + onClick, +}: MenuItemProps) { + const id = useId(_id); + const parentTemplateBuilderRef = useRef( + useContext(ElectronMenuTemplateBuilderContext) + ); + const onClickRef = useCurrectRef(onClick); + const clickHandlerRef = useRef(useContext(ElectronMenuClickHandlerContext)); + const previousTemplateRef = useRef(null); + + useEffect(() => { + const clickHandler = clickHandlerRef.current; + return clickHandler.subscribe(id, (event) => { + onClickRef.current?.(event); + }); + }, [id, onClickRef]); + + useEffect(() => { + const template = Object.fromEntries( + Object.entries({ + id, + label, + sublabel, + toolTip, + enabled, + visible, + checked, + acceleratorWorksWhenHidden, + role, + type, + accelerator, + }).filter(([, val]) => { + return typeof val !== 'undefined'; + }) + ) as unknown as ElectronMenuItemOptions; + + parentTemplateBuilderRef.current.updateItem( + previousTemplateRef.current, + template + ); + previousTemplateRef.current = template; + }, [ + id, + accelerator, + acceleratorWorksWhenHidden, + checked, + enabled, + label, + role, + sublabel, + toolTip, + type, + visible, + ]); + + useEffect(() => { + const builder = parentTemplateBuilderRef.current; + return () => { + builder.updateItem(previousTemplateRef.current, null); + }; + }, []); + + return null; +} + +export function ElectronMenuSeparator() { + const id = useId(); + return ; +} diff --git a/packages/react-electron-menu/src/main.ts b/packages/react-electron-menu/src/main.ts new file mode 100644 index 00000000000..ca1d12ae3c4 --- /dev/null +++ b/packages/react-electron-menu/src/main.ts @@ -0,0 +1,94 @@ +import type { MenuItemConstructorOptions, WebContents } from 'electron'; +import { app, ipcMain, Menu, BrowserWindow } from 'electron'; + +type BrowserWindowId = string; + +const MENU_MAP: Record> = {}; + +function traverseTemplate( + template: MenuItemConstructorOptions[], + fn: (item: MenuItemConstructorOptions) => MenuItemConstructorOptions +) { + return template.map((item) => { + item = fn(item); + if (Array.isArray(item.submenu)) { + item.submenu = traverseTemplate(item.submenu, fn); + } + return item; + }); +} + +function buildMenuFromTemplate( + webContents: WebContents, + template: MenuItemConstructorOptions | MenuItemConstructorOptions[] +) { + template = Array.isArray(template) ? template : [template]; + + return Menu.buildFromTemplate( + traverseTemplate(template, (item) => { + return { + ...item, + click(menuItem, _browserWindow, event) { + webContents.send('react-electron-menu-on-click', menuItem.id, event); + }, + }; + }) + ); +} + +export function initialize() { + ipcMain.on( + 'react-electron-menu-update', + (event, { id: menuId, type, template }) => { + const id = String(event.sender.id); + const menu = buildMenuFromTemplate(event.sender, template); + + // Only context menu can have multiple definitions per window + menuId = type === 'context' ? menuId : type; + + MENU_MAP[id] ??= {}; + MENU_MAP[id][menuId] = menu; + + if (type === 'dock' && process.platform === 'darwin') { + app.dock.setMenu(menu); + return; + } + + if (type === 'context') { + // TODO: should do nothing here, but show menu on context click event + return; + } + + Menu.setApplicationMenu(menu); + } + ); + + ipcMain.on( + 'react-electron-menu-contextmenu-click', + (event, { id: menuId }) => { + const id = String(event.sender.id); + const menu = MENU_MAP[id][menuId]; + + if (!menu) { + return; + } + + const window = BrowserWindow.fromWebContents(event.sender); + + if (!window) { + return; + } + + menu.popup({ window }); + } + ); + + ipcMain.on('react-electron-menu-unmount', () => { + // TODO: cleanup + }); + + app.on('browser-window-focus', (_event, window) => { + const menu = MENU_MAP[String(window.webContents.id)]?.menu; + Menu.setApplicationMenu(menu ?? null); + }); +} diff --git a/packages/react-electron-menu/tsconfig-lint.json b/packages/react-electron-menu/tsconfig-lint.json new file mode 100644 index 00000000000..6bdef84f322 --- /dev/null +++ b/packages/react-electron-menu/tsconfig-lint.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "include": ["**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/react-electron-menu/tsconfig.json b/packages/react-electron-menu/tsconfig.json new file mode 100644 index 00000000000..79bc84584ce --- /dev/null +++ b/packages/react-electron-menu/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@mongodb-js/tsconfig-compass/tsconfig.react.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src/**/*"], + "exclude": ["./src/**/*.spec.*"] +}