From bb983021b100da9efceae54825cb797ccdea5eaa Mon Sep 17 00:00:00 2001 From: Kevin Nadro Date: Wed, 26 Mar 2025 13:03:44 -0500 Subject: [PATCH] Feature: Traditional menu actions in desktop application (#5892) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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] --- e2e/playwright/native-file-menu.spec.ts | 312 ++++++++++++++++++++++++ interface.d.ts | 20 ++ src/channels.ts | 1 + src/components/FileMachineProvider.tsx | 8 + src/components/HelpMenu.tsx | 48 ++-- src/components/RefreshButton.tsx | 9 + src/hooks/useMenu.ts | 23 ++ src/main.ts | 57 ++++- src/menu.ts | 166 +++++++++++++ src/menu/channels.ts | 37 +++ src/menu/editRole.ts | 68 ++++++ src/menu/fileRole.ts | 100 ++++++++ src/menu/helpRole.ts | 113 +++++++++ src/menu/roles.ts | 61 +++++ src/menu/viewRole.ts | 47 ++++ src/preload.ts | 67 ++++- src/routes/Home.tsx | 84 +++++++ src/routes/Settings.tsx | 5 +- src/routes/SignIn.tsx | 8 + 19 files changed, 1211 insertions(+), 23 deletions(-) create mode 100644 e2e/playwright/native-file-menu.spec.ts create mode 100644 src/channels.ts create mode 100644 src/hooks/useMenu.ts create mode 100644 src/menu.ts create mode 100644 src/menu/channels.ts create mode 100644 src/menu/editRole.ts create mode 100644 src/menu/fileRole.ts create mode 100644 src/menu/helpRole.ts create mode 100644 src/menu/roles.ts create mode 100644 src/menu/viewRole.ts diff --git a/e2e/playwright/native-file-menu.spec.ts b/e2e/playwright/native-file-menu.spec.ts new file mode 100644 index 000000000..798013e24 --- /dev/null +++ b/e2e/playwright/native-file-menu.spec.ts @@ -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() + }) + }) + }) +}) diff --git a/interface.d.ts b/interface.d.ts index b4ebeb511..27c97f3ac 100644 --- a/interface.d.ts +++ b/interface.d.ts @@ -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 getArgvParsed: () => any getAppTestProperty: (propertyName: string) => any + + // Helper functions to create application Menus + createHomePageMenu: () => Promise + createModelingPageMenu: () => Promise + createFallbackMenu: () => Promise + enableMenu(menuId: string): Promise + disableMenu(menuId: string): Promise + menuOn: (callback: (payload: WebContentSendPayload) => void) => any } declare global { diff --git a/src/channels.ts b/src/channels.ts new file mode 100644 index 000000000..6f10a405e --- /dev/null +++ b/src/channels.ts @@ -0,0 +1 @@ +export type Channel = 'menu-action-clicked' diff --git a/src/components/FileMachineProvider.tsx b/src/components/FileMachineProvider.tsx index 2d9263198..3d73b0ee4 100644 --- a/src/components/FileMachineProvider.tsx +++ b/src/components/FileMachineProvider.tsx @@ -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 = { state: StateFrom @@ -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, diff --git a/src/components/HelpMenu.tsx b/src/components/HelpMenu.tsx index 7363dddd1..56a186f22 100644 --- a/src/components/HelpMenu.tsx +++ b/src/components/HelpMenu.tsx @@ -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 = () => (
@@ -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 ( Keyboard shortcuts - { - settingsActor.send({ - type: 'set.app.onboardingStatus', - data: { - value: '', - level: 'user', - }, - }) - if (isInProject) { - navigate(filePath + PATHS.ONBOARDING.INDEX) - } else { - createAndOpenNewTutorialProject({ - onProjectOpen, - navigate, - }).catch(reportRejection) - } - }} - > + Reset onboarding diff --git a/src/components/RefreshButton.tsx b/src/components/RefreshButton.tsx index e93bdf088..65aefb741 100644 --- a/src/components/RefreshButton.tsx +++ b/src/components/RefreshButton.tsx @@ -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 (