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.*"]
+}