Feature: Traditional menu actions in desktop application (#5892)

* chore: skeleton for building and creating menus. Need electron to renderer interface to dynamically set the menu

* chore: skeleton typing for communication between nodes and web side

* chore: more skeleton for the different roles within the menu options, need more type safety

* chore: adding more skeleton and templates of what the menus could be

* chore: implemented first pass for helpRole links

* fix: syntax issue stopped the build step

* feature: loading different menus based on your page

* feature: Home page file role implemented

* chore: handling the build workflow for the signin page

* fix: moving edit actionst to the edit menu

* chore: adding preferences to the file role

* chore: redoing help roles based on the question mark widget

* fix: auto fmt

* chore: examples of accelerator strings for Menu.MenuItems keyboard shortcuts!

* chore: oddly specific toggle API for disabling MenuItems from JS. No rules!

* fix: do not implement a custom label disable thingy, use id on menu and use the native APIga

* fix: auto fmt

* fix: adding some typechecks and auto fmt fixes

* fix: trying to fix custom type?

* fix: nvm we back, the lsp on my editor borked for a second

* fix: adding one more level to the custom type for the labels

* chore: cleaning up type definitions to read easier

* fix: resolving yarn lint errors

* chore: adding file sign out

* chore: adding more file bar actions

* chore: ready for PR draft

* fix: preemptive GC collectoin bug fix if somehow a user interacts with a menu while it is being GCed

* fix: linking the OG source

* fix: set application menu to null to avoid default electron menu

* chore: trying to add more typescript

* chore: BIG workflow changes... better typing, less IPC junk

* fix: remapping the icp functions to the cb option select...

* chore: all og events are rehooked up with new workflow pattern

* feat: adding more options to the native bar!

* fix: todo

* chore: cleaning up some menus and adding more

* fix: desktop vs browser and lint errors

* fix: typescript did not like sample electorn JS code for the basic templates with isMac conditionals...

* fix: PR clean up

* fix: more PR cleanup

* A snapshot a day keeps the bugs away! 📷🐛

* fix: added the new help menu to the default sign in and modeling page

* fix: disabled two menu actions within sign in page since they will not do anything.

* A snapshot a day keeps the bugs away! 📷🐛

* A snapshot a day keeps the bugs away! 📷🐛

* fix: mergining main, auto fixes

* A snapshot a day keeps the bugs away! 📷🐛

* A snapshot a day keeps the bugs away! 📷🐛

* A snapshot a day keeps the bugs away! 📷🐛

* A snapshot a day keeps the bugs away! 📷🐛

* fix: fixed ipc renderer off/remove listener bug

* A snapshot a day keeps the bugs away! 📷🐛

* A snapshot a day keeps the bugs away! 📷🐛

* A snapshot a day keeps the bugs away! 📷🐛

* A snapshot a day keeps the bugs away! 📷🐛

* fix: report a bug to refresha and report a bug

* fix: new type for webContents send payload that does not brick TS

* fix: removing import file from url since it is not working in the command palette for manual user input

* fix: removing old comment

* A snapshot a day keeps the bugs away! 📷🐛

* A snapshot a day keeps the bugs away! 📷🐛

* A snapshot a day keeps the bugs away! 📷🐛

* A snapshot a day keeps the bugs away! 📷🐛

* A snapshot a day keeps the bugs away! 📷🐛

* A snapshot a day keeps the bugs away! 📷🐛

* A snapshot a day keeps the bugs away! 📷🐛

* A snapshot a day keeps the bugs away! 📷🐛

* A snapshot a day keeps the bugs away! 📷🐛

* chore: adding some E2E tests.

* chore: added E2E tests for each file menu

* fix: auto fixes

* chore: adding more edit role E2E tests

* chore: e2e test

* chore: adding help role e2e test

* A snapshot a day keeps the bugs away! 📷🐛

* chore: e2e test for all the menu options you can interact with in the frontend

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Kevin Nadro
2025-03-26 13:03:44 -05:00
committed by GitHub
parent d27b8871bc
commit bb983021b1
19 changed files with 1211 additions and 23 deletions

View File

@ -0,0 +1,312 @@
import { test, expect } from './zoo-test'
/**
* Not all menu actions are tested. Some are default electron menu actions.
* Test file menu actions that trigger something in the frontend
*/
test.describe('Native file menu', { tag: ['@electron'] }, () => {
test.describe('Home page', () => {
test.describe('File role', () => {
test('File.Create project', async ({ tronApp, cmdBar, page }) => {
if (!tronApp) fail()
// Run electron snippet to find the Menu!
await tronApp.electron.evaluate(async ({ app }) => {
if (!app || !app.applicationMenu) fail()
const newProject =
app.applicationMenu.getMenuItemById('File.New project')
if (!newProject) fail()
newProject.click()
})
// Check that the command bar is opened
await expect(cmdBar.cmdBarElement).toBeVisible()
// Check the placeholder project name exists
const actualArgument = await cmdBar.cmdBarElement
.getByTestId('cmd-bar-arg-value')
.inputValue()
const expectedArgument = 'project-$nnn'
expect(actualArgument).toBe(expectedArgument)
})
test('File.Open project', async ({ tronApp, cmdBar, page }) => {
if (!tronApp) fail()
// Run electron snippet to find the Menu!
await tronApp.electron.evaluate(async ({ app }) => {
if (!app || !app.applicationMenu) fail()
const openProject =
app.applicationMenu.getMenuItemById('File.Open project')
if (!openProject) fail()
openProject.click()
})
// Check that the command bar is opened
await expect(cmdBar.cmdBarElement).toBeVisible()
// Check the placeholder project name exists
const actual = await cmdBar.cmdBarElement
.getByTestId('command-name')
.textContent()
const expected = 'Open project'
expect(actual).toBe(expected)
})
test('File.Preferences.User settings', async ({
tronApp,
cmdBar,
page,
}) => {
if (!tronApp) fail()
// Run electron snippet to find the Menu!
await tronApp.electron.evaluate(async ({ app }) => {
if (!app || !app.applicationMenu) fail()
const userSettings = app.applicationMenu.getMenuItemById(
'File.Preferences.User settings'
)
if (!userSettings) fail()
userSettings.click()
})
const settings = page.getByTestId('settings-dialog-panel')
await expect(settings).toBeVisible()
// You are viewing the user tab
const actualText = settings.getByText(
'The overall appearance of the app'
)
await expect(actualText).toBeVisible()
})
test('File.Preferences.Keybindings', async ({
tronApp,
cmdBar,
page,
}) => {
if (!tronApp) fail()
// Run electron snippet to find the Menu!
await tronApp.electron.evaluate(async ({ app }) => {
if (!app || !app.applicationMenu) fail()
const keybindings = app.applicationMenu.getMenuItemById(
'File.Preferences.Keybindings'
)
if (!keybindings) fail()
keybindings.click()
})
const settings = page.getByTestId('settings-dialog-panel')
await expect(settings).toBeVisible()
// You are viewing the keybindings tab
const enterSketchMode = settings.locator('#enter-sketch-mode')
await expect(enterSketchMode).toBeVisible()
})
test('File.Preferences.User default units', async ({
tronApp,
cmdBar,
page,
}) => {
if (!tronApp) fail()
// Run electron snippet to find the Menu!
await tronApp.electron.evaluate(async ({ app }) => {
if (!app || !app.applicationMenu) fail()
const menu = app.applicationMenu.getMenuItemById(
'File.Preferences.User default units'
)
if (!menu) fail()
menu.click()
})
const settings = page.getByTestId('settings-dialog-panel')
await expect(settings).toBeVisible()
const defaultUnit = settings.locator('#defaultUnit')
await expect(defaultUnit).toBeVisible()
})
test('File.Preferences.Theme', async ({ tronApp, cmdBar, page }) => {
if (!tronApp) fail()
// Run electron snippet to find the Menu!
await tronApp.electron.evaluate(async ({ app }) => {
if (!app || !app.applicationMenu) fail()
const menu = app.applicationMenu.getMenuItemById(
'File.Preferences.Theme'
)
if (!menu) fail()
menu.click()
})
// Check that the command bar is opened
await expect(cmdBar.cmdBarElement).toBeVisible()
// Check the placeholder project name exists
const actual = await cmdBar.cmdBarElement
.getByTestId('command-name')
.textContent()
const expected = 'Settings · app · theme'
expect(actual).toBe(expected)
})
test('File.Preferences.Theme color', async ({
tronApp,
cmdBar,
page,
}) => {
if (!tronApp) fail()
// Run electron snippet to find the Menu!
await tronApp.electron.evaluate(async ({ app }) => {
if (!app || !app.applicationMenu) fail()
const menu = app.applicationMenu.getMenuItemById(
'File.Preferences.Theme color'
)
if (!menu) fail()
menu.click()
})
const settings = page.getByTestId('settings-dialog-panel')
await expect(settings).toBeVisible()
const defaultUnit = settings.locator('#themeColor')
await expect(defaultUnit).toBeVisible()
})
test('File.Preferences.Sign out', async ({ tronApp, cmdBar, page }) => {
if (!tronApp) fail()
// Run electron snippet to find the Menu!
await tronApp.electron.evaluate(async ({ app }) => {
if (!app || !app.applicationMenu) fail()
const menu = app.applicationMenu.getMenuItemById('File.Sign out')
if (!menu) fail()
// FIXME: Add back when you can actually sign out
// menu.click()
})
// FIXME: When signing out during E2E the page is not bound correctly.
// It cannot find the button
// const signIn = page.getByTestId('sign-in-button')
// await expect(signIn).toBeVisible()
})
})
test.describe('Edit role', () => {
test('Edit.Rename project', async ({ tronApp, cmdBar, page }) => {
if (!tronApp) fail()
// Run electron snippet to find the Menu!
await tronApp.electron.evaluate(async ({ app }) => {
if (!app || !app.applicationMenu) fail()
const menu = app.applicationMenu.getMenuItemById(
'Edit.Rename project'
)
if (!menu) fail()
menu.click()
})
// Check the placeholder project name exists
const actual = await cmdBar.cmdBarElement
.getByTestId('command-name')
.textContent()
const expected = 'Rename project'
expect(actual).toBe(expected)
})
test('Edit.Delete project', async ({ tronApp, cmdBar, page }) => {
if (!tronApp) fail()
// Run electron snippet to find the Menu!
await tronApp.electron.evaluate(async ({ app }) => {
if (!app || !app.applicationMenu) fail()
const menu = app.applicationMenu.getMenuItemById(
'Edit.Delete project'
)
if (!menu) fail()
menu.click()
})
// Check the placeholder project name exists
const actual = await cmdBar.cmdBarElement
.getByTestId('command-name')
.textContent()
const expected = 'Delete project'
expect(actual).toBe(expected)
})
test('Edit.Change project directory', async ({
tronApp,
cmdBar,
page,
}) => {
if (!tronApp) fail()
// Run electron snippet to find the Menu!
await tronApp.electron.evaluate(async ({ app }) => {
if (!app || !app.applicationMenu) fail()
const menu = app.applicationMenu.getMenuItemById(
'Edit.Change project directory'
)
if (!menu) fail()
menu.click()
})
const settings = page.getByTestId('settings-dialog-panel')
await expect(settings).toBeVisible()
const projectDirectory = settings.locator('#projectDirectory')
await expect(projectDirectory).toBeVisible()
})
})
test.describe('View role', () => {
test('View.Command Palette...', async ({ tronApp, cmdBar, page }) => {
if (!tronApp) fail()
// Run electron snippet to find the Menu!
await tronApp.electron.evaluate(async ({ app }) => {
if (!app || !app.applicationMenu) fail()
const menu = app.applicationMenu.getMenuItemById(
'View.Command Palette...'
)
if (!menu) fail()
menu.click()
})
// Check the placeholder project name exists
const actual = cmdBar.cmdBarElement.getByTestId('cmd-bar-search')
await expect(actual).toBeVisible()
})
})
test.describe('Help role', () => {
test('Help.Show all commands', async ({ tronApp, cmdBar, page }) => {
if (!tronApp) fail()
// Run electron snippet to find the Menu!
await tronApp.electron.evaluate(async ({ app }) => {
if (!app || !app.applicationMenu) fail()
const menu = app.applicationMenu.getMenuItemById(
'Help.Show all commands'
)
if (!menu) fail()
menu.click()
})
// Check the placeholder project name exists
const actual = cmdBar.cmdBarElement.getByTestId('cmd-bar-search')
await expect(actual).toBeVisible()
})
test('Help.KCL code samples', async ({ tronApp, cmdBar, page }) => {
if (!tronApp) fail()
// Run electron snippet to find the Menu!
await tronApp.electron.evaluate(async ({ app }) => {
if (!app || !app.applicationMenu) fail()
const menu = app.applicationMenu.getMenuItemById(
'Help.KCL code samples'
)
if (!menu) fail()
})
})
test('Help.Refresh and report a bug', async ({
tronApp,
cmdBar,
page,
}) => {
if (!tronApp) fail()
// Run electron snippet to find the Menu!
await tronApp.electron.evaluate(async ({ app }) => {
if (!app || !app.applicationMenu) fail()
const menu = app.applicationMenu.getMenuItemById(
'Help.Refresh and report a bug'
)
if (!menu) fail()
menu.click()
})
// Core dump and refresh magic number timeout
await page.waitForTimeout(7000)
const actual = page.getByText(
'No Projects found, ready to make your first one?'
)
await expect(actual).toBeVisible()
})
test('Help.Reset onboarding', async ({ tronApp, cmdBar, page }) => {
if (!tronApp) fail()
// Run electron snippet to find the Menu!
await tronApp.electron.evaluate(async ({ app }) => {
if (!app || !app.applicationMenu) fail()
const menu = app.applicationMenu.getMenuItemById(
'Help.Reset onboarding'
)
if (!menu) fail()
menu.click()
})
const actual = page.getByText(
`This is a hardware design tool that lets you edit visually, with code, or both. It's powered by the KittyCAD Design API, the first API created for anyone to build hardware design tools.`
)
await expect(actual).toBeVisible()
})
})
})
})

20
interface.d.ts vendored
View File

@ -3,6 +3,18 @@ import fsSync from 'node:fs'
import path from 'path'
import { dialog, shell } from 'electron'
import { MachinesListing } from 'components/MachineManagerProvider'
import type { Channel } from 'src/menu/channels'
import { Menu, WebContents } from 'electron'
import { ZooLabel, ZooMenuEvents } from 'menu/roles'
import type { MenuActionIPC } from 'menu/rules'
import type { WebContentSendPayload } from 'menu/channels'
// Extend the interface with additional custom properties
declare module 'electron' {
interface Menu {
label?: ZooLabel
}
}
type EnvFn = (value?: string) => string
@ -94,6 +106,14 @@ export interface IElectronAPI {
appCheckForUpdates: () => Promise<unknown>
getArgvParsed: () => any
getAppTestProperty: (propertyName: string) => any
// Helper functions to create application Menus
createHomePageMenu: () => Promise<any>
createModelingPageMenu: () => Promise<any>
createFallbackMenu: () => Promise<any>
enableMenu(menuId: string): Promise<any>
disableMenu(menuId: string): Promise<any>
menuOn: (callback: (payload: WebContentSendPayload) => void) => any
}
declare global {

1
src/channels.ts Normal file
View File

@ -0,0 +1 @@
export type Channel = 'menu-action-clicked'

View File

@ -33,6 +33,7 @@ import { settingsActor, useSettings } from 'machines/appMachine'
import { createRouteCommands } from 'lib/commandBarConfigs/routeCommandConfig'
import { useToken } from 'machines/appMachine'
import { createNamedViewsCommand } from 'lib/commandBarConfigs/namedViewsConfig'
import { reportRejection } from 'lib/trap'
type MachineContext<T extends AnyStateMachine> = {
state: StateFrom<T>
@ -59,6 +60,13 @@ export const FileMachineProvider = ({
[]
)
// Only create the native file menus on desktop
useEffect(() => {
if (isDesktop()) {
window.electron.createModelingPageMenu().catch(reportRejection)
}
}, [])
useEffect(() => {
const {
createNamedViewCommand,

View File

@ -9,6 +9,8 @@ import { useLspContext } from './LspProvider'
import { openExternalBrowserIfDesktop } from 'lib/openWindow'
import { reportRejection } from 'lib/trap'
import { settingsActor } from 'machines/appMachine'
import type { WebContentSendPayload } from '../menu/channels'
import { useMenuListener } from 'hooks/useMenu'
const HelpMenuDivider = () => (
<div className="h-[1px] bg-chalkboard-110 dark:bg-chalkboard-80" />
@ -21,6 +23,31 @@ export function HelpMenu(props: React.PropsWithChildren) {
const isInProject = location.pathname.includes(PATHS.FILE)
const navigate = useNavigate()
const resetOnboardingWorkflow = () => {
settingsActor.send({
type: 'set.app.onboardingStatus',
data: {
value: '',
level: 'user',
},
})
if (isInProject) {
navigate(filePath + PATHS.ONBOARDING.INDEX)
} else {
createAndOpenNewTutorialProject({
onProjectOpen,
navigate,
}).catch(reportRejection)
}
}
const cb = (data: WebContentSendPayload) => {
if (data.menuLabel === 'Help.Reset onboarding') {
resetOnboardingWorkflow()
}
}
useMenuListener(cb)
return (
<Popover className="relative">
<Popover.Button
@ -102,26 +129,7 @@ export function HelpMenu(props: React.PropsWithChildren) {
>
Keyboard shortcuts
</HelpMenuItem>
<HelpMenuItem
as="button"
onClick={() => {
settingsActor.send({
type: 'set.app.onboardingStatus',
data: {
value: '',
level: 'user',
},
})
if (isInProject) {
navigate(filePath + PATHS.ONBOARDING.INDEX)
} else {
createAndOpenNewTutorialProject({
onProjectOpen,
navigate,
}).catch(reportRejection)
}
}}
>
<HelpMenuItem as="button" onClick={resetOnboardingWorkflow}>
Reset onboarding
</HelpMenuItem>
</Popover.Panel>

View File

@ -9,6 +9,8 @@ import { reportRejection } from 'lib/trap'
import { toSync } from 'lib/utils'
import { useToken } from 'machines/appMachine'
import { rustContext } from 'lib/singletons'
import type { WebContentSendPayload } from '../menu/channels'
import { useMenuListener } from 'hooks/useMenu'
export const RefreshButton = ({ children }: React.PropsWithChildren) => {
const token = useToken()
@ -61,6 +63,13 @@ export const RefreshButton = ({ children }: React.PropsWithChildren) => {
.catch(reportRejection)
}
const cb = (data: WebContentSendPayload) => {
if (data.menuLabel === 'Help.Refresh and report a bug') {
refresh().catch(reportRejection)
}
}
useMenuListener(cb)
return (
<button
onClick={toSync(refresh, reportRejection)}

23
src/hooks/useMenu.ts Normal file
View File

@ -0,0 +1,23 @@
import { useEffect } from 'react'
import type { WebContentSendPayload } from '../menu/channels'
import { isDesktop } from 'lib/isDesktop'
export function useMenuListener(
callback: (data: WebContentSendPayload) => void
) {
useEffect(() => {
const onDesktop = isDesktop()
if (!onDesktop) {
// NO OP for web
return
}
const removeListener = window.electron.menuOn(callback)
return () => {
if (!onDesktop) {
// NO OP for web
return
}
removeListener()
}
}, [])
}

View File

@ -10,6 +10,7 @@ import {
nativeTheme,
desktopCapturer,
systemPreferences,
Menu,
screen,
} from 'electron'
import path from 'path'
@ -27,11 +28,26 @@ import {
getPathOrUrlFromArgs,
parseCLIArgs,
} from './commandLineArgs'
import * as packageJSON from '../package.json'
import {
buildAndSetMenuForFallback,
buildAndSetMenuForModelingPage,
buildAndSetMenuForProjectPage,
enableMenu,
disableMenu,
} from './menu'
let mainWindow: BrowserWindow | null = null
// Preemptive code, GC may delete a menu while a user is using it as seen in VSCode
// as seen on https://github.com/microsoft/vscode/issues/55347
let oldMenus: Menu[] = []
const scheduleMenuGC = () => {
setTimeout(() => {
oldMenus = []
}, 10000)
}
// Check the command line arguments for a project path
const args = parseCLIArgs(process.argv)
@ -215,6 +231,8 @@ app.on('ready', (event, data) => {
if (mainWindow) return
// Create the mainWindow
mainWindow = createWindow()
// Set menu application to null to avoid default electron menu
Menu.setApplicationMenu(null)
})
// For now there is no good reason to separate these out to another file(s)
@ -386,6 +404,43 @@ ipcMain.handle('find_machine_api', () => {
})
})
// Given the route create the new context menu
// internal menu state will be reset since it creates a new one from
// the initial state
ipcMain.handle('create-menu', (event, data) => {
const page = data.page
if (!(page === 'project' || page === 'modeling' || page === 'fallback')) {
return
}
// Store old menu in our array to avoid GC to collect the menu and crash
const oldMenu = Menu.getApplicationMenu()
if (oldMenu) {
oldMenus.push(oldMenu)
}
if (page === 'project' && mainWindow) {
buildAndSetMenuForProjectPage(mainWindow)
} else if (page === 'modeling' && mainWindow) {
buildAndSetMenuForModelingPage(mainWindow)
} else if (page === 'fallback' && mainWindow) {
buildAndSetMenuForFallback(mainWindow)
}
scheduleMenuGC()
})
ipcMain.handle('enable-menu', (event, data) => {
const menuId = data.menuId
enableMenu(menuId)
})
ipcMain.handle('disable-menu', (event, data) => {
const menuId = data.menuId
disableMenu(menuId)
})
export function getAutoUpdater(): AppUpdater {
// Using destructuring to access autoUpdater due to the CommonJS module of 'electron-updater'.
// It is a workaround for ESM compatibility issues, see https://github.com/electron-userland/electron-builder/issues/7976.

166
src/menu.ts Normal file
View File

@ -0,0 +1,166 @@
import { app, Menu, BrowserWindow } from 'electron'
import { projectFileRole } from 'menu/fileRole'
import { projectEditRole } from 'menu/editRole'
import { helpRole } from 'menu/helpRole'
import { projectViewRole } from 'menu/viewRole'
import os from 'node:os'
import { ZooMenuItemConstructorOptions } from 'menu/roles'
const isMac = os.platform() === 'darwin'
// Default electron menu.
export function buildAndSetMenuForFallback(mainWindow: BrowserWindow) {
const templateMac: ZooMenuItemConstructorOptions[] = [
{
// @ts-ignore cannot determine this since it is dynamic. It is still a string, not a problem.
label: app.name,
submenu: [
{ role: 'about' },
{ type: 'separator' },
{ role: 'services' },
{ type: 'separator' },
{ role: 'hide' },
{ role: 'hideOthers' },
{ role: 'unhide' },
{ type: 'separator' },
{ role: 'quit' },
],
},
{
label: 'File',
submenu: [{ role: 'close' }],
},
{
label: 'Edit',
submenu: [
{ role: 'undo' },
{ role: 'redo' },
{ type: 'separator' },
{ role: 'cut' },
{ role: 'copy' },
{ role: 'paste' },
{ role: 'pasteAndMatchStyle' },
{ role: 'delete' },
{ role: 'selectAll' },
{ type: 'separator' },
{
label: 'Speech',
submenu: [{ role: 'startSpeaking' }, { role: 'stopSpeaking' }],
},
],
},
{
label: 'View',
submenu: [
{ role: 'reload' },
{ role: 'forceReload' },
{ role: 'toggleDevTools' },
{ type: 'separator' },
{ role: 'resetZoom' },
{ role: 'zoomIn' },
{ role: 'zoomOut' },
{ type: 'separator' },
{ role: 'togglefullscreen' },
],
},
{
label: 'Window',
submenu: [
{ role: 'minimize' },
{ role: 'zoom' },
{ type: 'separator' },
{ role: 'front' },
{ type: 'separator' },
{ role: 'window' },
],
},
]
const templateNotMac: ZooMenuItemConstructorOptions[] = [
{
label: 'File',
submenu: [{ role: 'quit' }],
},
{
label: 'Edit',
submenu: [
{ role: 'undo' },
{ role: 'redo' },
{ type: 'separator' },
{ role: 'cut' },
{ role: 'copy' },
{ role: 'paste' },
{ role: 'delete' },
{ type: 'separator' },
{ role: 'selectAll' },
],
},
{
label: 'View',
submenu: [
{ role: 'reload' },
{ role: 'forceReload' },
{ role: 'toggleDevTools' },
{ type: 'separator' },
{ role: 'resetZoom' },
{ role: 'zoomIn' },
{ role: 'zoomOut' },
{ type: 'separator' },
{ role: 'togglefullscreen' },
],
},
{
label: 'Window',
submenu: [{ role: 'minimize' }, { role: 'zoom' }, { role: 'close' }],
},
helpRole(mainWindow),
]
if (isMac) {
const menu = Menu.buildFromTemplate(templateMac)
Menu.setApplicationMenu(menu)
} else {
const menu = Menu.buildFromTemplate(templateNotMac)
Menu.setApplicationMenu(menu)
}
}
// This will generate a new menu from the initial state
// All state management from the previous menu is going to be lost.
export function buildAndSetMenuForModelingPage(mainWindow: BrowserWindow) {
return buildAndSetMenuForFallback(mainWindow)
}
// This will generate a new menu from the initial state
// All state management from the previous menu is going to be lost.
export function buildAndSetMenuForProjectPage(mainWindow: BrowserWindow) {
const template = [
projectFileRole(mainWindow),
projectEditRole(mainWindow),
projectViewRole(mainWindow),
// Help role is the same for all pages
helpRole(mainWindow),
]
const menu = Menu.buildFromTemplate(template)
Menu.setApplicationMenu(menu)
}
// Try to enable the menu based on the application menu
// It will not do anything if that menu cannot be found.
export function enableMenu(menuId: string) {
const applicationMenu = Menu.getApplicationMenu()
const menuItem = applicationMenu?.getMenuItemById(menuId)
if (menuItem) {
menuItem.enabled = true
}
}
// Try to disable the menu based on the application menu
// It will not do anything if that menu cannot be found.
export function disableMenu(menuId: string) {
const applicationMenu = Menu.getApplicationMenu()
const menuItem = applicationMenu?.getMenuItemById(menuId)
if (menuItem) {
menuItem.enabled = false
}
}

37
src/menu/channels.ts Normal file
View File

@ -0,0 +1,37 @@
import { BrowserWindow } from 'electron'
import type { Channel } from '../channels'
// types for knowing what menu sends what webContent payload
export type MenuLabels =
| 'Help.Command Palette...'
| 'Help.Refresh and report a bug'
| 'Help.Reset onboarding'
| 'File.New project'
| 'File.Open project'
| 'File.Import file from URL'
| 'File.Preferences.User settings'
| 'File.Preferences.Keybindings'
| 'File.Preferences.User default units'
| 'File.Preferences.Theme'
| 'File.Preferences.Theme color'
| 'File.Sign out'
| 'Edit.Rename project'
| 'Edit.Delete project'
| 'Edit.Change project directory'
| 'View.Command Palette...'
export type WebContentSendPayload = {
menuLabel: MenuLabels
}
// Unable to use declare module 'electron' with the interface of WebContents
// to update the send function. It did not work.
// Need to use a custom wrapper function for this.
// BrowserWindow.webContents instance is different from the WebContents and webContents...?
export const typeSafeWebContentsSend = (
mainWindow: BrowserWindow,
channel: Channel,
payload: WebContentSendPayload
) => {
mainWindow.webContents.send(channel, payload)
}

68
src/menu/editRole.ts Normal file
View File

@ -0,0 +1,68 @@
import { BrowserWindow } from 'electron'
import { typeSafeWebContentsSend } from './channels'
import os from 'node:os'
import { ZooMenuItemConstructorOptions } from './roles'
const isMac = os.platform() === 'darwin'
export const projectEditRole = (
mainWindow: BrowserWindow
): ZooMenuItemConstructorOptions => {
let extraBits: ZooMenuItemConstructorOptions[] = [
{ role: 'delete' },
{ type: 'separator' },
{ role: 'selectAll' },
]
if (isMac) {
extraBits = [
{ role: 'pasteAndMatchStyle' },
{ role: 'delete' },
{ role: 'selectAll' },
{ type: 'separator' },
{
label: 'Speech',
submenu: [{ role: 'startSpeaking' }, { role: 'stopSpeaking' }],
},
]
}
return {
label: 'Edit',
submenu: [
{
label: 'Rename project',
id: 'Edit.Rename project',
click: () => {
typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
menuLabel: 'Edit.Rename project',
})
},
},
{
label: 'Delete project',
id: 'Edit.Delete project',
click: () => {
typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
menuLabel: 'Edit.Delete project',
})
},
},
{ type: 'separator' },
{
label: 'Change project directory',
id: 'Edit.Change project directory',
click: () => {
typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
menuLabel: 'Edit.Change project directory',
})
},
},
{ type: 'separator' },
{ role: 'undo' },
{ role: 'redo' },
{ type: 'separator' },
{ role: 'cut' },
{ role: 'copy' },
{ role: 'paste' },
...extraBits,
],
}
}

100
src/menu/fileRole.ts Normal file
View File

@ -0,0 +1,100 @@
import { BrowserWindow } from 'electron'
import { typeSafeWebContentsSend } from './channels'
import { ZooMenuItemConstructorOptions } from './roles'
import os from 'node:os'
const isMac = os.platform() === 'darwin'
export const projectFileRole = (
mainWindow: BrowserWindow
): ZooMenuItemConstructorOptions => {
return {
label: 'File',
submenu: [
{
label: 'New project',
id: 'File.New project',
accelerator: 'CommandOrControl+N',
click: () => {
typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
menuLabel: 'File.New project',
})
},
},
{
label: 'Open project',
id: 'File.Open project',
accelerator: 'CommandOrControl+P',
click: () => {
typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
menuLabel: 'File.Open project',
})
},
},
// TODO https://www.electronjs.org/docs/latest/tutorial/recent-documents
// Appears to be only Windows and Mac OS specific. Linux does not have support
{ type: 'separator' },
{
label: 'Preferences',
submenu: [
{
label: 'User settings',
id: 'File.Preferences.User settings',
click: () => {
typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
menuLabel: 'File.Preferences.User settings',
})
},
},
{
label: 'Keybindings',
id: 'File.Preferences.Keybindings',
click: () => {
typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
menuLabel: 'File.Preferences.Keybindings',
})
},
},
{
label: 'User default units',
id: 'File.Preferences.User default units',
click: () => {
typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
menuLabel: 'File.Preferences.User default units',
})
},
},
{
label: 'Theme',
id: 'File.Preferences.Theme',
click: () => {
typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
menuLabel: 'File.Preferences.Theme',
})
},
},
{
label: 'Theme color',
id: 'File.Preferences.Theme color',
click: () => {
typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
menuLabel: 'File.Preferences.Theme color',
})
},
},
],
},
{ type: 'separator' },
// Last in list
{
label: 'Sign out',
id: 'File.Sign out',
click: () => {
typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
menuLabel: 'File.Sign out',
})
},
},
isMac ? { role: 'close' } : { role: 'quit' },
],
}
}

113
src/menu/helpRole.ts Normal file
View File

@ -0,0 +1,113 @@
import { shell, BrowserWindow } from 'electron'
import { ZooMenuItemConstructorOptions } from './roles'
import { reportRejection } from 'lib/trap'
import { typeSafeWebContentsSend } from './channels'
export const helpRole = (
mainWindow: BrowserWindow
): ZooMenuItemConstructorOptions => {
return {
label: 'Help',
submenu: [
{
id: 'Help.Show all commands',
label: 'Show all commands',
click: () => {
typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
menuLabel: 'Help.Command Palette...',
})
},
},
{
label: 'KCL code samples',
id: 'Help.KCL code samples',
click: () => {
shell
.openExternal('https://zoo.dev/docs/kcl-samples')
.catch(reportRejection)
},
},
{
label: 'KCL docs',
click: () => {
shell.openExternal('https://zoo.dev/docs/kcl').catch(reportRejection)
},
},
{
label: 'Get started with Text-to-CAD',
click: () => {
shell
.openExternal('https://text-to-cad.zoo.dev/dashboard')
.catch(reportRejection)
},
},
{ type: 'separator' },
{
label: 'Ask the community discord',
click: () => {
shell
.openExternal('https://discord.gg/JQEpHR7Nt2')
.catch(reportRejection)
},
},
{
label: 'Ask the community discourse',
click: () => {
shell
.openExternal('https://community.zoo.dev/')
.catch(reportRejection)
},
},
{ type: 'separator' },
{
label: 'Refresh and report a bug',
id: 'Help.Refresh and report a bug',
click: () => {
typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
menuLabel: 'Help.Refresh and report a bug',
})
},
},
{
label: 'Request a feature',
click: () => {
shell
.openExternal(
'https://github.com/KittyCAD/modeling-app/discussions'
)
.catch(reportRejection)
},
},
{ type: 'separator' },
{
id: 'Help.Reset onboarding',
label: 'Reset onboarding',
click: () => {
typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
menuLabel: 'Help.Reset onboarding',
})
},
},
{ type: 'separator' },
{ role: 'toggleDevTools' },
{ role: 'reload' },
{ role: 'forceReload' },
{ type: 'separator' },
{
label: 'Show release notes',
click: () => {
shell
.openExternal('https://github.com/KittyCAD/modeling-app/releases')
.catch(reportRejection)
},
},
{ type: 'separator' },
{
label: 'Manage account',
click: () => {
shell.openExternal('https://zoo.dev/account').catch(reportRejection)
},
},
],
}
}

61
src/menu/roles.ts Normal file
View File

@ -0,0 +1,61 @@
// Does not matter what labels belong to what type. I only split these into some internal types to easily parse
// what labels should belong to what grouping
import { Menu, MenuItemConstructorOptions } from 'electron'
type HeaderLabel =
| 'File'
| 'Edit'
| 'Options'
| 'Window'
| 'Utility'
| 'Help'
| 'View'
type FileRoleLabel =
| 'Open project'
| 'New project'
| 'Import file from URL'
| 'Preferences'
| 'User settings'
| 'Keybindings'
| 'Sign out'
| 'Theme'
| 'Theme color'
| 'User default units'
type EditRoleLabel =
| 'Rename project'
| 'Delete project'
| 'Change project directory'
| 'Speech'
type HelpRoleLabel =
| 'Refresh and report a bug'
| 'Request a feature'
| 'Ask the community discord'
| 'Ask the community discourse'
| 'KCL code samples'
| 'KCL docs'
| 'Reset onboarding'
| 'Show release notes'
| 'Manage account'
| 'Get started with Text-to-CAD'
| 'Show all commands'
type ViewRoleLabel = 'Command Palette...' | 'Appearance'
// Only export the union of all the internal types since they are all labels
// The internal types are only for readability within the file
export type ZooLabel =
| HeaderLabel
| FileRoleLabel
| EditRoleLabel
| HelpRoleLabel
| ViewRoleLabel
// Extend the interface with additional custom properties
export interface ZooMenuItemConstructorOptions
extends MenuItemConstructorOptions {
label?: ZooLabel
submenu?: ZooMenuItemConstructorOptions[] | Menu
}

47
src/menu/viewRole.ts Normal file
View File

@ -0,0 +1,47 @@
import { BrowserWindow } from 'electron'
import { ZooMenuItemConstructorOptions } from './roles'
import { typeSafeWebContentsSend } from './channels'
import os from 'node:os'
const isMac = os.platform() === 'darwin'
export const projectViewRole = (
mainWindow: BrowserWindow
): ZooMenuItemConstructorOptions => {
let extraBits: ZooMenuItemConstructorOptions[] = [{ role: 'close' }]
if (isMac) {
extraBits = [
{ type: 'separator' },
{ role: 'front' },
{ type: 'separator' },
{ role: 'window' },
]
}
return {
label: 'View',
submenu: [
{
label: 'Command Palette...',
id: 'View.Command Palette...',
click: () => {
typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
menuLabel: 'View.Command Palette...',
})
},
},
{
label: 'Appearance',
submenu: [
{ role: 'togglefullscreen' },
{ type: 'separator' },
{ role: 'zoomIn' },
{ role: 'zoomOut' },
{ role: 'resetZoom' },
],
},
{ type: 'separator' },
{ role: 'minimize' },
{ role: 'zoom' },
...extraBits,
],
}
}

View File

@ -1,4 +1,4 @@
import { ipcRenderer, contextBridge } from 'electron'
import { ipcRenderer, contextBridge, IpcRendererEvent } from 'electron'
import path from 'path'
import fs from 'node:fs/promises'
import os from 'node:os'
@ -6,6 +6,13 @@ import fsSync from 'node:fs'
import packageJson from '../package.json'
import { MachinesListing } from 'components/MachineManagerProvider'
import chokidar from 'chokidar'
import type { Channel } from './channels'
import type { WebContentSendPayload } from './menu/channels'
const typeSafeIpcRendererOn = (
channel: Channel,
listener: (event: IpcRendererEvent, ...args: any[]) => Promise<void> | any
) => ipcRenderer.on(channel, listener)
const resizeWindow = (width: number, height: number) =>
ipcRenderer.invoke('app.resizeWindow', [width, height])
@ -163,6 +170,58 @@ const getArgvParsed = () => {
return ipcRenderer.invoke('argv.parser')
}
// Creating a menu will refresh the state of the menu
// Anything that was enabled will be reset to the hard coded state of the original menu
const createHomePageMenu = async (): Promise<any> => {
return ipcRenderer.invoke('create-menu', { page: 'project' })
}
// Creating a menu will refresh the state of the menu
// Anything that was enabled will be reset to the hard coded state of the original menu
const createModelingPageMenu = async (): Promise<any> => {
return ipcRenderer.invoke('create-menu', { page: 'modeling' })
}
// Creating a menu will refresh the state of the menu
// Anything that was enabled will be reset to the hard coded state of the original menu
const createFallbackMenu = async (): Promise<any> => {
return ipcRenderer.invoke('create-menu', { page: 'fallback' })
}
// Given the application menu, try to enable the menu
const enableMenu = async (menuId: string): Promise<any> => {
return ipcRenderer.invoke('enable-menu', {
menuId,
})
}
// Given the application menu, try to disable the menu
const disableMenu = async (menuId: string): Promise<any> => {
return ipcRenderer.invoke('disable-menu', {
menuId,
})
}
/**
* Gotcha: Even if the callback function is the same function in JS memory
* when passing it over the IPC layer it will not map to the same function.
* this means your .on and .off with the same callback function in memory will
* not be removed.
* To remove the listener call the return value of menuOn. It builds a closure
* of the subscription on the electron side and it will let you remove the listener correctly.
*/
const menuOn = (callback: (payload: WebContentSendPayload) => void) => {
// Build a new subscription function for the closure below
const subscription = (event: IpcRendererEvent, data: WebContentSendPayload) =>
callback(data)
typeSafeIpcRendererOn('menu-action-clicked', subscription)
// This is the only way to remove the event listener from the JS side
return () => {
ipcRenderer.removeListener('menu-action-clicked', subscription)
}
}
contextBridge.exposeInMainWorld('electron', {
startDeviceFlow,
loginWithDeviceFlow,
@ -237,5 +296,11 @@ contextBridge.exposeInMainWorld('electron', {
appCheckForUpdates,
getArgvParsed,
resizeWindow,
createHomePageMenu,
createModelingPageMenu,
createFallbackMenu,
enableMenu,
disableMenu,
menuOn,
canReadWriteDirectory,
})

View File

@ -24,6 +24,9 @@ import { commandBarActor } from 'machines/commandBarMachine'
import { useCreateFileLinkQuery } from 'hooks/useCreateFileLinkQueryWatcher'
import { useSettings } from 'machines/appMachine'
import { reportRejection } from 'lib/trap'
import { authActor } from 'machines/appMachine'
import type { WebContentSendPayload } from '../menu/channels'
import { useMenuListener } from 'hooks/useMenu'
// This route only opens in the desktop context for now,
// as defined in Router.tsx, so we can use the desktop APIs and types.
@ -37,6 +40,13 @@ const Home = () => {
error: undefined,
})
// Only create the native file menus on desktop
useEffect(() => {
if (isDesktop()) {
window.electron.createHomePageMenu().catch(reportRejection)
}
}, [])
// Keep a lookout for a URL query string that invokes the 'import file from URL' command
useCreateFileLinkQuery((argDefaultValues) => {
commandBarActor.send({
@ -52,6 +62,80 @@ const Home = () => {
const navigate = useNavigate()
const settings = useSettings()
// Menu listeners
const cb = (data: WebContentSendPayload) => {
if (data.menuLabel === 'File.New project') {
commandBarActor.send({
type: 'Find and select command',
data: {
groupId: 'projects',
name: 'Create project',
argDefaultValues: {
name: settings.projects.defaultProjectName.current,
},
},
})
} else if (data.menuLabel === 'File.Open project') {
commandBarActor.send({
type: 'Find and select command',
data: {
groupId: 'projects',
name: 'Open project',
},
})
} else if (data.menuLabel === 'Edit.Rename project') {
commandBarActor.send({
type: 'Find and select command',
data: {
groupId: 'projects',
name: 'Rename project',
},
})
} else if (data.menuLabel === 'Edit.Delete project') {
commandBarActor.send({
type: 'Find and select command',
data: {
groupId: 'projects',
name: 'Delete project',
},
})
} else if (data.menuLabel === 'File.Import file from URL') {
commandBarActor.send({
type: 'Find and select command',
data: {
groupId: 'projects',
name: 'Import file from URL',
},
})
} else if (data.menuLabel === 'File.Preferences.User settings') {
navigate(PATHS.HOME + PATHS.SETTINGS)
} else if (data.menuLabel === 'File.Preferences.Keybindings') {
navigate(PATHS.HOME + PATHS.SETTINGS_KEYBINDINGS)
} else if (data.menuLabel === 'File.Preferences.User default units') {
navigate(PATHS.HOME + PATHS.SETTINGS_USER + '#defaultUnit')
} else if (data.menuLabel === 'Edit.Change project directory') {
navigate(PATHS.HOME + PATHS.SETTINGS_USER + '#projectDirectory')
} else if (data.menuLabel === 'File.Sign out') {
authActor.send({ type: 'Log out' })
} else if (
data.menuLabel === 'View.Command Palette...' ||
data.menuLabel === 'Help.Command Palette...'
) {
commandBarActor.send({ type: 'Open' })
} else if (data.menuLabel === 'File.Preferences.Theme') {
commandBarActor.send({
type: 'Find and select command',
data: {
groupId: 'settings',
name: 'app.theme',
},
})
} else if (data.menuLabel === 'File.Preferences.Theme color') {
navigate(PATHS.HOME + PATHS.SETTINGS_USER + '#themeColor')
}
}
useMenuListener(cb)
// Cancel all KCL executions while on the home page
useEffect(() => {
markOnce('code/didLoadHome')

View File

@ -98,7 +98,10 @@ export const Settings = () => {
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="rounded relative mx-auto bg-chalkboard-10 dark:bg-chalkboard-100 border dark:border-chalkboard-70 max-w-3xl w-full max-h-[66vh] shadow-lg flex flex-col gap-8">
<Dialog.Panel
data-testid="settings-dialog-panel"
className="rounded relative mx-auto bg-chalkboard-10 dark:bg-chalkboard-100 border dark:border-chalkboard-70 max-w-3xl w-full max-h-[66vh] shadow-lg flex flex-col gap-8"
>
<div className="p-5 pb-0 flex justify-between items-center">
<h1 className="text-2xl font-bold">Settings</h1>
<div className="flex gap-4 items-start">

View File

@ -21,6 +21,14 @@ const subtleBorder =
const cardArea = `${subtleBorder} rounded-lg px-6 py-3 text-chalkboard-70 dark:text-chalkboard-30`
const SignIn = () => {
// Only create the native file menus on desktop
if (isDesktop()) {
window.electron.createFallbackMenu().catch(reportRejection)
// Disable these since they cannot be accessed within the sign in page.
window.electron.disableMenu('Help.Reset onboarding').catch(reportRejection)
window.electron.disableMenu('Help.Show all commands').catch(reportRejection)
}
const [userCode, setUserCode] = useState('')
const {
app: { theme },