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:
312
e2e/playwright/native-file-menu.spec.ts
Normal file
312
e2e/playwright/native-file-menu.spec.ts
Normal 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
20
interface.d.ts
vendored
@ -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
1
src/channels.ts
Normal file
@ -0,0 +1 @@
|
||||
export type Channel = 'menu-action-clicked'
|
@ -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,
|
||||
|
@ -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>
|
||||
|
@ -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
23
src/hooks/useMenu.ts
Normal 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()
|
||||
}
|
||||
}, [])
|
||||
}
|
57
src/main.ts
57
src/main.ts
@ -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
166
src/menu.ts
Normal 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
37
src/menu/channels.ts
Normal 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
68
src/menu/editRole.ts
Normal 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
100
src/menu/fileRole.ts
Normal 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
113
src/menu/helpRole.ts
Normal 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
61
src/menu/roles.ts
Normal 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
47
src/menu/viewRole.ts
Normal 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,
|
||||
],
|
||||
}
|
||||
}
|
@ -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,
|
||||
})
|
||||
|
@ -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')
|
||||
|
@ -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">
|
||||
|
@ -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 },
|
||||
|
Reference in New Issue
Block a user