From 23e87080b4bf7fa389e1531621b4835d746465ee Mon Sep 17 00:00:00 2001 From: Maxim Stykow Date: Mon, 17 Mar 2025 11:06:23 +0100 Subject: [PATCH] feat: introduce open recent This PR allows users to chose from up to 10 recently opened files. It also creates a central method for creating/rebuilding the electron menu and uses it to simplify some code around QA mode. Signed-off-by: Maxim Stykow --- .../main/__tests__/iconHelpers.test.ts | 47 -------- .../main/__tests__/menu.test.ts | 1 + src/ElectronBackend/main/iconHelpers.ts | 17 +-- src/ElectronBackend/main/listeners.ts | 21 ++++ src/ElectronBackend/main/main.ts | 4 +- src/ElectronBackend/main/menu.ts | 24 +++-- src/ElectronBackend/main/menu/aboutMenu.ts | 10 +- src/ElectronBackend/main/menu/editMenu.ts | 16 ++- src/ElectronBackend/main/menu/fileMenu.ts | 102 +++++++++++++++--- src/ElectronBackend/main/menu/helpMenu.ts | 16 +-- src/ElectronBackend/main/menu/viewMenu.ts | 65 +++++------ src/e2e-tests/__tests__/import-dialog.test.ts | 19 ++-- src/e2e-tests/__tests__/merge-dialog.test.ts | 6 +- src/e2e-tests/__tests__/open-files.test.ts | 43 ++++++++ src/e2e-tests/page-objects/ImportDialog.ts | 12 +-- src/e2e-tests/page-objects/MenuBar.ts | 40 +++++-- src/e2e-tests/page-objects/MergeDialog.ts | 12 +-- src/e2e-tests/utils/fixtures.ts | 72 ++++++++----- src/shared/shared-types.ts | 1 + src/shared/text.ts | 2 + 20 files changed, 315 insertions(+), 215 deletions(-) delete mode 100644 src/ElectronBackend/main/__tests__/iconHelpers.test.ts create mode 100644 src/e2e-tests/__tests__/open-files.test.ts diff --git a/src/ElectronBackend/main/__tests__/iconHelpers.test.ts b/src/ElectronBackend/main/__tests__/iconHelpers.test.ts deleted file mode 100644 index 5eb52d0d8..000000000 --- a/src/ElectronBackend/main/__tests__/iconHelpers.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -// SPDX-FileCopyrightText: Meta Platforms, Inc. and its affiliates -// SPDX-FileCopyrightText: TNG Technology Consulting GmbH -// -// SPDX-License-Identifier: Apache-2.0 -import { Menu, MenuItem } from 'electron'; - -import { makeFirstIconVisibleAndSecondHidden } from '../iconHelpers'; - -jest.mock('electron', () => ({ - app: { - isPackaged: true, - }, - Menu: { - setApplicationMenu: jest.fn(), - buildFromTemplate: jest.fn(), - getApplicationMenu: jest.fn(), - }, -})); - -describe('makeFirstIconVisibleAndSecondHidden', () => { - let toBeVisibleMenuItem: Partial; - let toBeHiddenMenuItem: Partial; - - it('should make the first item visible and the second item hidden', () => { - toBeVisibleMenuItem = { visible: false }; - toBeHiddenMenuItem = { visible: true }; - (Menu.getApplicationMenu as jest.Mock).mockImplementation(() => ({ - getMenuItemById: jest.fn().mockImplementation((id) => { - if (id === 'toBeVisibleMenuItem') { - return toBeVisibleMenuItem; - } - if (id === 'toBeHiddenMenuItem') { - return toBeHiddenMenuItem; - } - throw Error('unexpected ID'); - }), - })); - - makeFirstIconVisibleAndSecondHidden( - 'toBeVisibleMenuItem', - 'toBeHiddenMenuItem', - ); - - expect(toBeVisibleMenuItem.visible).toBe(true); - expect(toBeHiddenMenuItem.visible).toBe(false); - }); -}); diff --git a/src/ElectronBackend/main/__tests__/menu.test.ts b/src/ElectronBackend/main/__tests__/menu.test.ts index 3c49f4314..9765376d3 100644 --- a/src/ElectronBackend/main/__tests__/menu.test.ts +++ b/src/ElectronBackend/main/__tests__/menu.test.ts @@ -14,6 +14,7 @@ jest.mock('electron', () => ({ }, Menu: { buildFromTemplate: jest.fn(), + setApplicationMenu: jest.fn(), }, })); diff --git a/src/ElectronBackend/main/iconHelpers.ts b/src/ElectronBackend/main/iconHelpers.ts index 6005a8183..43310d00b 100644 --- a/src/ElectronBackend/main/iconHelpers.ts +++ b/src/ElectronBackend/main/iconHelpers.ts @@ -2,7 +2,7 @@ // SPDX-FileCopyrightText: TNG Technology Consulting GmbH // // SPDX-License-Identifier: Apache-2.0 -import electron, { app, Menu } from 'electron'; +import electron, { app } from 'electron'; import path from 'path'; import upath from 'upath'; @@ -23,18 +23,3 @@ export function getIconBasedOnTheme( ? path.join(getBasePathOfAssets(), white_icon) : path.join(getBasePathOfAssets(), black_icon); } - -export function makeFirstIconVisibleAndSecondHidden( - firstItemId: string, - secondItemId: string, -): void { - const itemToMakeVisible = - Menu.getApplicationMenu()?.getMenuItemById(firstItemId); - if (itemToMakeVisible) { - itemToMakeVisible.visible = true; - } - const itemToHide = Menu.getApplicationMenu()?.getMenuItemById(secondItemId); - if (itemToHide) { - itemToHide.visible = false; - } -} diff --git a/src/ElectronBackend/main/listeners.ts b/src/ElectronBackend/main/listeners.ts index 7ba50ad22..0a7ae6156 100644 --- a/src/ElectronBackend/main/listeners.ts +++ b/src/ElectronBackend/main/listeners.ts @@ -5,6 +5,7 @@ // SPDX-License-Identifier: Apache-2.0 import { BrowserWindow, shell, WebContents } from 'electron'; import fs from 'fs'; +import { uniq } from 'lodash'; import path from 'path'; import upath from 'upath'; @@ -53,6 +54,10 @@ import { setGlobalBackendState, } from './globalBackendState'; import logger from './logger'; +import { createMenu } from './menu'; +import { UserSettings } from './user-settings'; + +const MAX_NUMBER_OF_RECENTLY_OPENED_PATHS = 10; export const saveFileListener = (mainWindow: BrowserWindow) => @@ -130,6 +135,10 @@ export async function handleOpeningFile( await openFile(mainWindow, filePath, onOpen); + await updateRecentlyOpenedPaths(filePath); + + await createMenu(mainWindow); + setLoadingState(mainWindow.webContents, false); } @@ -351,6 +360,18 @@ export async function openFile( onOpen(); } +async function updateRecentlyOpenedPaths(filePath: string): Promise { + const recentlyOpenedPaths = await UserSettings.get('recentlyOpenedPaths'); + await UserSettings.set( + 'recentlyOpenedPaths', + uniq([filePath, ...(recentlyOpenedPaths ?? [])]).slice( + 0, + MAX_NUMBER_OF_RECENTLY_OPENED_PATHS, + ), + { skipNotification: true }, + ); +} + function setTitle(mainWindow: BrowserWindow, filePath: string): void { const defaultTitle = 'OpossumUI'; diff --git a/src/ElectronBackend/main/main.ts b/src/ElectronBackend/main/main.ts index 11ba82a90..a6a43d0aa 100644 --- a/src/ElectronBackend/main/main.ts +++ b/src/ElectronBackend/main/main.ts @@ -2,7 +2,7 @@ // SPDX-FileCopyrightText: TNG Technology Consulting GmbH // // SPDX-License-Identifier: Apache-2.0 -import { dialog, ipcMain, Menu, systemPreferences } from 'electron'; +import { dialog, ipcMain, systemPreferences } from 'electron'; import os from 'os'; import { AllowedFrontendChannels, IpcChannel } from '../../shared/ipc-channels'; @@ -36,7 +36,7 @@ export async function main(): Promise { const mainWindow = await createWindow(); await UserSettings.init(); - Menu.setApplicationMenu(await createMenu(mainWindow)); + await createMenu(mainWindow); mainWindow.webContents.session.webRequest.onBeforeSendHeaders( (details, callback) => { diff --git a/src/ElectronBackend/main/menu.ts b/src/ElectronBackend/main/menu.ts index 5d1107374..8008aa9a9 100644 --- a/src/ElectronBackend/main/menu.ts +++ b/src/ElectronBackend/main/menu.ts @@ -3,7 +3,7 @@ // SPDX-FileCopyrightText: Nico Carl // // SPDX-License-Identifier: Apache-2.0 -import { BrowserWindow, Menu, MenuItem } from 'electron'; +import { BrowserWindow, Menu, MenuItemConstructorOptions } from 'electron'; import os from 'os'; import { getAboutMenu } from './menu/aboutMenu'; @@ -12,15 +12,19 @@ import { getFileMenu } from './menu/fileMenu'; import { getHelpMenu } from './menu/helpMenu'; import { getViewMenu } from './menu/viewMenu'; -export async function createMenu(mainWindow: BrowserWindow): Promise { +export async function createMenu(mainWindow: BrowserWindow): Promise { const webContents = mainWindow.webContents; - return Menu.buildFromTemplate([ - ...(os.platform() === 'darwin' ? [{ role: 'appMenu' } as MenuItem] : []), - getFileMenu(mainWindow), - getEditMenu(webContents), - await getViewMenu(), - getAboutMenu(), - getHelpMenu(webContents), - ]); + return Menu.setApplicationMenu( + Menu.buildFromTemplate([ + ...(os.platform() === 'darwin' + ? [{ role: 'appMenu' } satisfies MenuItemConstructorOptions] + : []), + await getFileMenu(mainWindow), + getEditMenu(webContents), + await getViewMenu(mainWindow), + getAboutMenu(), + getHelpMenu(webContents), + ]), + ); } diff --git a/src/ElectronBackend/main/menu/aboutMenu.ts b/src/ElectronBackend/main/menu/aboutMenu.ts index 426f200e8..c1e25d102 100644 --- a/src/ElectronBackend/main/menu/aboutMenu.ts +++ b/src/ElectronBackend/main/menu/aboutMenu.ts @@ -3,7 +3,7 @@ // SPDX-FileCopyrightText: Nico Carl // // SPDX-License-Identifier: Apache-2.0 -import { shell } from 'electron'; +import { MenuItemConstructorOptions, shell } from 'electron'; import { text } from '../../../shared/text'; import { getIconBasedOnTheme } from '../iconHelpers'; @@ -12,7 +12,7 @@ import { getPathOfNoticeDocument, } from '../notice-document-helpers'; -function getOpenOnGithub() { +function getOpenOnGithub(): MenuItemConstructorOptions { return { icon: getIconBasedOnTheme( 'icons/github-white.png', @@ -24,7 +24,7 @@ function getOpenOnGithub() { }; } -function getOpossumUiNotices() { +function getOpossumUiNotices(): MenuItemConstructorOptions { return { icon: getIconBasedOnTheme( 'icons/notice-white.png', @@ -35,7 +35,7 @@ function getOpossumUiNotices() { }; } -function getChromiumNotices() { +function getChromiumNotices(): MenuItemConstructorOptions { return { icon: getIconBasedOnTheme( 'icons/chromium-white.png', @@ -46,7 +46,7 @@ function getChromiumNotices() { }; } -export function getAboutMenu() { +export function getAboutMenu(): MenuItemConstructorOptions { return { label: text.menu.about, submenu: [getOpenOnGithub(), getOpossumUiNotices(), getChromiumNotices()], diff --git a/src/ElectronBackend/main/menu/editMenu.ts b/src/ElectronBackend/main/menu/editMenu.ts index 73a0e7940..b839cbede 100644 --- a/src/ElectronBackend/main/menu/editMenu.ts +++ b/src/ElectronBackend/main/menu/editMenu.ts @@ -71,7 +71,9 @@ function getSelectAll(): MenuItemConstructorOptions { }; } -function getSearchAttributions(webContents: Electron.WebContents) { +function getSearchAttributions( + webContents: Electron.WebContents, +): MenuItemConstructorOptions { return { icon: getIconBasedOnTheme( 'icons/magnifying-glass-white.png', @@ -89,7 +91,9 @@ function getSearchAttributions(webContents: Electron.WebContents) { }; } -function getSearchSignals(webContents: Electron.WebContents) { +function getSearchSignals( + webContents: Electron.WebContents, +): MenuItemConstructorOptions { return { icon: getIconBasedOnTheme( 'icons/magnifying-glass-white.png', @@ -107,7 +111,9 @@ function getSearchSignals(webContents: Electron.WebContents) { }; } -function getSearchResources(webContents: Electron.WebContents) { +function getSearchResources( + webContents: Electron.WebContents, +): MenuItemConstructorOptions { return { icon: getIconBasedOnTheme( 'icons/search-white.png', @@ -125,7 +131,9 @@ function getSearchResources(webContents: Electron.WebContents) { }; } -function getSearchLinkedResources(webContents: Electron.WebContents) { +function getSearchLinkedResources( + webContents: Electron.WebContents, +): MenuItemConstructorOptions { return { icon: getIconBasedOnTheme( 'icons/search-white.png', diff --git a/src/ElectronBackend/main/menu/fileMenu.ts b/src/ElectronBackend/main/menu/fileMenu.ts index 152a6cbfb..7383defe3 100644 --- a/src/ElectronBackend/main/menu/fileMenu.ts +++ b/src/ElectronBackend/main/menu/fileMenu.ts @@ -3,8 +3,14 @@ // SPDX-FileCopyrightText: Nico Carl // // SPDX-License-Identifier: Apache-2.0 -import { app, BrowserWindow } from 'electron'; +import { + app, + BrowserWindow, + MenuItemConstructorOptions, + WebContents, +} from 'electron'; import os from 'os'; +import path from 'path'; import { AllowedFrontendChannels } from '../../../shared/ipc-channels'; import { @@ -18,11 +24,14 @@ import { getGlobalBackendState } from '../globalBackendState'; import { getIconBasedOnTheme } from '../iconHelpers'; import { getMergeListener, + handleOpeningFile, importFileListener, selectBaseURLListener, setLoadingState, } from '../listeners'; import logger from '../logger'; +import { createMenu } from '../menu'; +import { UserSettings } from '../user-settings'; import { DisabledMenuItemHandler } from './DisabledMenuItemHandler'; export const importFileFormats: Array = [ @@ -43,7 +52,7 @@ export const importFileFormats: Array = [ }, ]; -function getOpenFile(mainWindow: Electron.CrossProcessExports.BrowserWindow) { +function getOpenFile(mainWindow: BrowserWindow): MenuItemConstructorOptions { return { icon: getIconBasedOnTheme('icons/open-white.png', 'icons/open-black.png'), label: text.menu.fileSubmenu.open, @@ -55,7 +64,51 @@ function getOpenFile(mainWindow: Electron.CrossProcessExports.BrowserWindow) { }; } -function getImportFile(mainWindow: Electron.CrossProcessExports.BrowserWindow) { +async function getOpenRecent( + mainWindow: BrowserWindow, +): Promise { + const recentlyOpenedPaths = await UserSettings.get('recentlyOpenedPaths'); + + return { + icon: getIconBasedOnTheme('icons/open-white.png', 'icons/open-black.png'), + label: text.menu.fileSubmenu.openRecent, + submenu: getOpenRecentSubmenu(mainWindow, recentlyOpenedPaths), + enabled: !!recentlyOpenedPaths?.length, + }; +} + +function getOpenRecentSubmenu( + mainWindow: BrowserWindow, + recentlyOpenedPaths: Array | null, +): MenuItemConstructorOptions['submenu'] { + if (!recentlyOpenedPaths?.length) { + return undefined; + } + + return [ + ...recentlyOpenedPaths.map((recentPath) => ({ + label: path.basename(recentPath, path.extname(recentPath)), + click: ({ id }) => + handleOpeningFile( + mainWindow, + id, + DisabledMenuItemHandler.activateMenuItems, + ), + id: recentPath, + })), + { type: 'separator' }, + { + id: 'clear-recent', + label: text.menu.fileSubmenu.clearRecent, + click: async () => { + await UserSettings.set('recentlyOpenedPaths', []); + await createMenu(mainWindow); + }, + }, + ]; +} + +function getImportFile(mainWindow: BrowserWindow): MenuItemConstructorOptions { return { icon: getIconBasedOnTheme( 'icons/import-white.png', @@ -70,7 +123,7 @@ function getImportFile(mainWindow: Electron.CrossProcessExports.BrowserWindow) { }; } -function getMerge(mainWindow: Electron.CrossProcessExports.BrowserWindow) { +function getMerge(mainWindow: BrowserWindow): MenuItemConstructorOptions { return { icon: getIconBasedOnTheme('icons/merge-white.png', 'icons/merge-black.png'), label: text.menu.fileSubmenu.merge, @@ -83,7 +136,7 @@ function getMerge(mainWindow: Electron.CrossProcessExports.BrowserWindow) { }; } -function getSaveFile(webContents: Electron.WebContents) { +function getSaveFile(webContents: WebContents): MenuItemConstructorOptions { return { icon: getIconBasedOnTheme('icons/save-white.png', 'icons/save-black.png'), label: text.menu.fileSubmenu.save, @@ -98,7 +151,9 @@ function getSaveFile(webContents: Electron.WebContents) { }; } -function getProjectMetadata(webContents: Electron.WebContents) { +function getProjectMetadata( + webContents: WebContents, +): MenuItemConstructorOptions { return { icon: getIconBasedOnTheme('icons/about-white.png', 'icons/about-black.png'), label: text.menu.fileSubmenu.projectMetadata, @@ -114,7 +169,9 @@ function getProjectMetadata(webContents: Electron.WebContents) { }; } -function getProjectStatistics(webContents: Electron.WebContents) { +function getProjectStatistics( + webContents: WebContents, +): MenuItemConstructorOptions { return { icon: getIconBasedOnTheme( 'icons/statictics-white.png', @@ -133,7 +190,7 @@ function getProjectStatistics(webContents: Electron.WebContents) { }; } -function getSetBaseUrl(mainWindow: Electron.CrossProcessExports.BrowserWindow) { +function getSetBaseUrl(mainWindow: BrowserWindow): MenuItemConstructorOptions { return { icon: getIconBasedOnTheme( 'icons/restore-white.png', @@ -155,7 +212,9 @@ function getQuit() { }; } -function getExportFollowUp(webContents: Electron.WebContents) { +function getExportFollowUp( + webContents: WebContents, +): MenuItemConstructorOptions { return { label: text.menu.fileSubmenu.exportSubmenu.followUp, icon: getIconBasedOnTheme( @@ -175,7 +234,9 @@ function getExportFollowUp(webContents: Electron.WebContents) { }; } -function getExportCompactBom(webContents: Electron.WebContents) { +function getExportCompactBom( + webContents: WebContents, +): MenuItemConstructorOptions { return { icon: getIconBasedOnTheme( 'icons/com-list-white.png', @@ -195,7 +256,9 @@ function getExportCompactBom(webContents: Electron.WebContents) { }; } -function getExportDetailedBom(webContents: Electron.WebContents) { +function getExportDetailedBom( + webContents: WebContents, +): MenuItemConstructorOptions { return { icon: getIconBasedOnTheme( 'icons/det-list-white.png', @@ -215,7 +278,9 @@ function getExportDetailedBom(webContents: Electron.WebContents) { }; } -function getExportSpdxYaml(webContents: Electron.WebContents) { +function getExportSpdxYaml( + webContents: WebContents, +): MenuItemConstructorOptions { return { icon: getIconBasedOnTheme('icons/yaml-white.png', 'icons/yaml-black.png'), label: text.menu.fileSubmenu.exportSubmenu.spdxYAML, @@ -232,7 +297,9 @@ function getExportSpdxYaml(webContents: Electron.WebContents) { }; } -function getExportSpdxJson(webContents: Electron.WebContents) { +function getExportSpdxJson( + webContents: WebContents, +): MenuItemConstructorOptions { return { icon: getIconBasedOnTheme('icons/json-white.png', 'icons/json-black.png'), label: text.menu.fileSubmenu.exportSubmenu.spdxJSON, @@ -249,7 +316,9 @@ function getExportSpdxJson(webContents: Electron.WebContents) { }; } -function getExportSubMenu(webContents: Electron.WebContents) { +function getExportSubMenu( + webContents: WebContents, +): MenuItemConstructorOptions { return { label: text.menu.fileSubmenu.export, icon: getIconBasedOnTheme( @@ -266,12 +335,15 @@ function getExportSubMenu(webContents: Electron.WebContents) { }; } -export function getFileMenu(mainWindow: BrowserWindow) { +export async function getFileMenu( + mainWindow: BrowserWindow, +): Promise { const webContents = mainWindow.webContents; return { label: text.menu.file, submenu: [ getOpenFile(mainWindow), + await getOpenRecent(mainWindow), getImportFile(mainWindow), getMerge(mainWindow), getSaveFile(webContents), diff --git a/src/ElectronBackend/main/menu/helpMenu.ts b/src/ElectronBackend/main/menu/helpMenu.ts index 4dd78ec7c..ee5397021 100644 --- a/src/ElectronBackend/main/menu/helpMenu.ts +++ b/src/ElectronBackend/main/menu/helpMenu.ts @@ -3,13 +3,13 @@ // SPDX-FileCopyrightText: Nico Carl // // SPDX-License-Identifier: Apache-2.0 -import { app, shell } from 'electron'; +import { app, MenuItemConstructorOptions, shell } from 'electron'; import { AllowedFrontendChannels } from '../../../shared/ipc-channels'; import { text } from '../../../shared/text'; import { getIconBasedOnTheme } from '../iconHelpers'; -function getOpenlogfiles() { +function getOpenLogFiles(): MenuItemConstructorOptions { return { icon: getIconBasedOnTheme('icons/log-white.png', 'icons/log-black.png'), label: text.menu.helpSubmenu.openLogFiles, @@ -17,7 +17,9 @@ function getOpenlogfiles() { }; } -function getCheckForUpdates(webContents: Electron.WebContents) { +function getCheckForUpdates( + webContents: Electron.WebContents, +): MenuItemConstructorOptions { return { icon: getIconBasedOnTheme( 'icons/update-white.png', @@ -32,7 +34,7 @@ function getCheckForUpdates(webContents: Electron.WebContents) { }; } -function getUsersGuide() { +function getUsersGuide(): MenuItemConstructorOptions { return { icon: getIconBasedOnTheme( 'icons/user-guide-white.png', @@ -46,12 +48,14 @@ function getUsersGuide() { }; } -export function getHelpMenu(webContents: Electron.WebContents) { +export function getHelpMenu( + webContents: Electron.WebContents, +): MenuItemConstructorOptions { return { label: text.menu.help, submenu: [ getUsersGuide(), - getOpenlogfiles(), + getOpenLogFiles(), getCheckForUpdates(webContents), ], }; diff --git a/src/ElectronBackend/main/menu/viewMenu.ts b/src/ElectronBackend/main/menu/viewMenu.ts index 59077837f..444e90b0f 100644 --- a/src/ElectronBackend/main/menu/viewMenu.ts +++ b/src/ElectronBackend/main/menu/viewMenu.ts @@ -3,13 +3,11 @@ // SPDX-FileCopyrightText: Nico Carl // // SPDX-License-Identifier: Apache-2.0 -import { MenuItemConstructorOptions } from 'electron'; +import { BrowserWindow, MenuItemConstructorOptions } from 'electron'; import { text } from '../../../shared/text'; -import { - getIconBasedOnTheme, - makeFirstIconVisibleAndSecondHidden, -} from '../iconHelpers'; +import { getIconBasedOnTheme } from '../iconHelpers'; +import { createMenu } from '../menu'; import { UserSettings } from '../user-settings'; function getShowDevTools(): MenuItemConstructorOptions { @@ -56,47 +54,33 @@ function getZoomOut(): MenuItemConstructorOptions { }; } -function getEnableQaMode(qaMode: null | boolean) { - return { - icon: getIconBasedOnTheme( - 'icons/check-box-blank-white.png', - 'icons/check-box-blank-black.png', - ), - label: text.menu.viewSubmenu.qaMode, - id: 'disabled-qa-mode', - click: () => { - makeFirstIconVisibleAndSecondHidden( - 'enabled-qa-mode', - 'disabled-qa-mode', - ); - void UserSettings.set('qaMode', true); - }, - visible: !qaMode, - }; -} +async function getQaMode( + mainWindow: BrowserWindow, +): Promise { + const qaMode = (await UserSettings.get('qaMode')) ?? false; -function getDisableQaMode(qaMode: null | boolean) { return { - icon: getIconBasedOnTheme( - 'icons/check-box-white.png', - 'icons/check-box-black.png', - ), + icon: qaMode + ? getIconBasedOnTheme( + 'icons/check-box-white.png', + 'icons/check-box-black.png', + ) + : getIconBasedOnTheme( + 'icons/check-box-blank-white.png', + 'icons/check-box-blank-black.png', + ), label: text.menu.viewSubmenu.qaMode, - id: 'enabled-qa-mode', - click: () => { - makeFirstIconVisibleAndSecondHidden( - 'disabled-qa-mode', - 'enabled-qa-mode', - ); - void UserSettings.set('qaMode', false); + id: qaMode ? 'enabled-qa-mode' : 'disabled-qa-mode', + click: async () => { + await UserSettings.set('qaMode', !qaMode); + await createMenu(mainWindow); }, - visible: !!qaMode, }; } -export async function getViewMenu(): Promise { - const qaMode = await UserSettings.get('qaMode'); - +export async function getViewMenu( + mainWindow: BrowserWindow, +): Promise { return { label: text.menu.view, submenu: [ @@ -104,8 +88,7 @@ export async function getViewMenu(): Promise { getToggleFullScreen(), getZoomIn(), getZoomOut(), - getEnableQaMode(qaMode), - getDisableQaMode(qaMode), + await getQaMode(mainWindow), ], }; } diff --git a/src/e2e-tests/__tests__/import-dialog.test.ts b/src/e2e-tests/__tests__/import-dialog.test.ts index 6b04034c0..33422e970 100644 --- a/src/e2e-tests/__tests__/import-dialog.test.ts +++ b/src/e2e-tests/__tests__/import-dialog.test.ts @@ -4,7 +4,6 @@ // SPDX-License-Identifier: Apache-2.0 import { stubDialog } from 'electron-playwright-helpers'; -import { getDotOpossumFilePath } from '../../shared/write-file'; import { faker, test } from '../utils'; const [resourceName] = faker.opossum.resourceName(); @@ -20,7 +19,6 @@ test.use({ }), }), outputData: faker.opossum.outputData({}), - provideImportFiles: true, }, openFromCLI: false, }); @@ -42,14 +40,13 @@ test('imports legacy opossum file', async ({ importDialog, resourcesTree, window, -}) => { - await stubDialog(window.app, 'showOpenDialogSync', [ - importDialog.legacyFilePath, - ]); + filePaths, +}, testInfo) => { + await stubDialog(window.app, 'showOpenDialogSync', [filePaths!.json]); await stubDialog( window.app, 'showSaveDialogSync', - getDotOpossumFilePath(importDialog.legacyFilePath, ['json', 'json.gz']), + testInfo.outputPath('report.opossum'), ); await menuBar.importLegacyOpossumFile(); @@ -68,14 +65,14 @@ test('imports scancode file', async ({ importDialog, resourcesTree, window, -}) => { +}, testInfo) => { await stubDialog(window.app, 'showOpenDialogSync', [ importDialog.scancodeFilePath, ]); await stubDialog( window.app, 'showSaveDialogSync', - getDotOpossumFilePath(importDialog.scancodeFilePath, ['json']), + testInfo.outputPath('scancode-report.opossum'), ); await menuBar.importScanCodeFile(); @@ -94,14 +91,14 @@ test('imports OWASP file', async ({ importDialog, resourcesTree, window, -}) => { +}, testInfo) => { await stubDialog(window.app, 'showOpenDialogSync', [ importDialog.owaspFilePath, ]); await stubDialog( window.app, 'showSaveDialogSync', - getDotOpossumFilePath(importDialog.owaspFilePath, ['json']), + testInfo.outputPath('owasp-dependency-check-report.opossum'), ); await menuBar.importOwaspDependencyScanFile(); diff --git a/src/e2e-tests/__tests__/merge-dialog.test.ts b/src/e2e-tests/__tests__/merge-dialog.test.ts index 8f2cf95b9..f6d78b0ea 100644 --- a/src/e2e-tests/__tests__/merge-dialog.test.ts +++ b/src/e2e-tests/__tests__/merge-dialog.test.ts @@ -20,7 +20,6 @@ test.use({ }), }), outputData: faker.opossum.outputData({}), - provideImportFiles: true, }, }); @@ -41,10 +40,9 @@ test('merges legacy opossum file', async ({ mergeDialog, resourcesTree, window, + filePaths, }) => { - await stubDialog(window.app, 'showOpenDialogSync', [ - mergeDialog.legacyFilePath, - ]); + await stubDialog(window.app, 'showOpenDialogSync', [filePaths!.json]); await menuBar.mergeLegacyOpossumFile(); await mergeDialog.assert.titleIsVisible(); diff --git a/src/e2e-tests/__tests__/open-files.test.ts b/src/e2e-tests/__tests__/open-files.test.ts new file mode 100644 index 000000000..1f8a92676 --- /dev/null +++ b/src/e2e-tests/__tests__/open-files.test.ts @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: Meta Platforms, Inc. and its affiliates +// SPDX-FileCopyrightText: TNG Technology Consulting GmbH +// +// SPDX-License-Identifier: Apache-2.0 +import { stubDialog } from 'electron-playwright-helpers'; + +import { faker, test } from '../utils'; + +const [resourceName] = faker.opossum.resourceName(); + +test.use({ + data: { + inputData: faker.opossum.inputData({ + resources: faker.opossum.resources({ + [resourceName]: 1, + }), + metadata: faker.opossum.metadata({ + projectId: 'test_project', + }), + }), + outputData: faker.opossum.outputData({}), + }, + openFromCLI: false, +}); + +test('opens Opossum file and shows project as recently opened', async ({ + menuBar, + resourcesTree, + window, + filePaths, + data, +}) => { + await menuBar.assert.openRecentIsDisabled(); + + await stubDialog(window.app, 'showOpenDialogSync', [filePaths!.opossum]); + await menuBar.openFile(); + + await resourcesTree.assert.resourceIsVisible(resourceName); + await menuBar.assert.openRecentIsEnabled(); + await menuBar.assert.hasRecentlyOpenedProject( + data!.inputData.metadata.projectId, + ); +}); diff --git a/src/e2e-tests/page-objects/ImportDialog.ts b/src/e2e-tests/page-objects/ImportDialog.ts index 5f43c8a47..8204e5b21 100644 --- a/src/e2e-tests/page-objects/ImportDialog.ts +++ b/src/e2e-tests/page-objects/ImportDialog.ts @@ -2,7 +2,7 @@ // SPDX-FileCopyrightText: TNG Technology Consulting GmbH // // SPDX-License-Identifier: Apache-2.0 -import { expect, type Locator, type Page, TestInfo } from '@playwright/test'; +import { expect, type Locator, type Page } from '@playwright/test'; import * as path from 'path'; export class ImportDialog { @@ -14,15 +14,10 @@ export class ImportDialog { readonly cancelButton: Locator; readonly errorIcon: Locator; - readonly legacyFilePath: string; readonly scancodeFilePath: string; readonly owaspFilePath: string; - constructor( - window: Page, - legacyFilename: string | undefined, - info: TestInfo, - ) { + constructor(window: Page) { this.node = window.getByLabel('import dialog'); this.title = this.node.getByRole('heading').getByText('Import'); this.inputFileSelection = this.node @@ -35,7 +30,6 @@ export class ImportDialog { this.cancelButton = this.node.getByRole('button', { name: 'Cancel' }); this.errorIcon = this.node.getByTestId('ErrorIcon').locator('path'); - this.legacyFilePath = info.outputPath(`${legacyFilename}.json`); this.scancodeFilePath = path.resolve(__dirname, '..', 'scancode.json'); this.owaspFilePath = path.resolve( __dirname, @@ -49,7 +43,7 @@ export class ImportDialog { await expect(this.title).toBeVisible(); }, titleIsHidden: async (): Promise => { - await expect(this.title).toBeHidden({ timeout: 10000 }); + await expect(this.title).toBeHidden({ timeout: 30000 }); }, showsError: async (): Promise => { await expect(this.errorIcon).toBeVisible(); diff --git a/src/e2e-tests/page-objects/MenuBar.ts b/src/e2e-tests/page-objects/MenuBar.ts index 00981dcf5..5e41c1113 100644 --- a/src/e2e-tests/page-objects/MenuBar.ts +++ b/src/e2e-tests/page-objects/MenuBar.ts @@ -20,6 +20,34 @@ export class MenuBar { hasTitle: async (title: string): Promise => { expect(await this.window.title()).toBe(title); }, + openRecentIsEnabled: async (): Promise => { + const menuItem = await findMenuItem( + this.window.app, + 'label', + 'Open Recent', + ); + expect(menuItem!.enabled).toBe(true); + }, + openRecentIsDisabled: async (): Promise => { + const menuItem = await findMenuItem( + this.window.app, + 'label', + 'Open Recent', + ); + expect(menuItem!.enabled).toBe(false); + }, + hasRecentlyOpenedProject: async (projectName: string): Promise => { + const submenu = ( + await findMenuItem(this.window.app, 'label', 'Open Recent') + )?.submenu; + const menuItem = await findMenuItem( + this.window.app, + 'label', + projectName, + submenu, + ); + expect(menuItem).toBeDefined(); + }, }; async openProjectMetadata(): Promise { @@ -31,20 +59,18 @@ export class MenuBar { } private async clickSubmenuItem( + menuLabel: string, submenuLabel: string, - itemLabel: string, ): Promise { - const submenu = (await findMenuItem(this.window.app, 'label', submenuLabel)) - ?.submenu; + const submenu = (await findMenuItem(this.window.app, 'label', menuLabel))! + .submenu; const menuItem = await findMenuItem( this.window.app, 'label', - itemLabel, + submenuLabel, submenu, ); - if (menuItem?.id) { - await clickMenuItemById(this.window.app, menuItem.id); - } + await clickMenuItemById(this.window.app, menuItem!.id); } async openProjectStatistics(): Promise { diff --git a/src/e2e-tests/page-objects/MergeDialog.ts b/src/e2e-tests/page-objects/MergeDialog.ts index 83dc91db8..2b8100f93 100644 --- a/src/e2e-tests/page-objects/MergeDialog.ts +++ b/src/e2e-tests/page-objects/MergeDialog.ts @@ -2,7 +2,7 @@ // SPDX-FileCopyrightText: TNG Technology Consulting GmbH // // SPDX-License-Identifier: Apache-2.0 -import { expect, Locator, Page, TestInfo } from '@playwright/test'; +import { expect, Locator, Page } from '@playwright/test'; import path from 'path'; export class MergeDialog { @@ -13,15 +13,10 @@ export class MergeDialog { readonly cancelButton: Locator; readonly errorIcon: Locator; - readonly legacyFilePath: string; readonly scancodeFilePath: string; readonly owaspFilePath: string; - constructor( - window: Page, - legacyFilename: string | undefined, - info: TestInfo, - ) { + constructor(window: Page) { this.node = window.getByLabel('merge dialog'); this.title = this.node.getByRole('heading').getByText('Merge'); this.inputFileSelection = this.node @@ -31,7 +26,6 @@ export class MergeDialog { this.cancelButton = this.node.getByRole('button', { name: 'Cancel' }); this.errorIcon = this.node.getByTestId('ErrorIcon').locator('path'); - this.legacyFilePath = info.outputPath(`${legacyFilename}.json`); this.scancodeFilePath = path.resolve(__dirname, '..', 'scancode.json'); this.owaspFilePath = path.resolve( __dirname, @@ -45,7 +39,7 @@ export class MergeDialog { await expect(this.title).toBeVisible(); }, titleIsHidden: async (): Promise => { - await expect(this.title).toBeHidden({ timeout: 10000 }); + await expect(this.title).toBeHidden({ timeout: 30000 }); }, showsError: async (): Promise => { await expect(this.errorIcon).toBeVisible(); diff --git a/src/e2e-tests/utils/fixtures.ts b/src/e2e-tests/utils/fixtures.ts index 60f4dcb2c..220fe0cd8 100644 --- a/src/e2e-tests/utils/fixtures.ts +++ b/src/e2e-tests/utils/fixtures.ts @@ -7,7 +7,6 @@ import { _electron as electron, ElectronApplication, Page, - TestInfo, } from '@playwright/test'; import { parseElectronApp } from 'electron-playwright-helpers'; import * as os from 'os'; @@ -45,7 +44,11 @@ const LOAD_TIMEOUT = 15000; interface OpossumData { inputData: ParsedOpossumInputFile; outputData?: ParsedOpossumOutputFile; - provideImportFiles?: boolean; +} + +interface FilePaths { + opossum: string; + json: string; } export const test = base.extend<{ @@ -63,6 +66,7 @@ export const test = base.extend<{ confirmationDialog: ConfirmationDialog; diffPopup: DiffPopup; errorPopup: ErrorPopup; + filePaths: FilePaths | null; fileSupportPopup: FileSupportPopup; importDialog: ImportDialog; mergeDialog: MergeDialog; @@ -80,8 +84,21 @@ export const test = base.extend<{ }>({ data: undefined, openFromCLI: true, - window: async ({ data, openFromCLI }, use, info) => { - const filePath = data && (await createTestFile({ data, info })); + filePaths: async ({ data }, use, info) => { + if (!data) { + return use(null); + } + + const filename = data.inputData.metadata.projectId; + + await use({ + opossum: info.outputPath(`${filename}.opossum`), + json: info.outputPath(`${filename}.json`), + }); + }, + window: async ({ data, openFromCLI, filePaths }, use, info) => { + const opossumFilePath = + data && filePaths && (await createTestFiles({ data, filePaths })); const [executablePath, main] = getLaunchProps(); const args = ['--reset']; @@ -92,7 +109,9 @@ export const test = base.extend<{ const app = await electron.launch({ args: [ main, - ...(!filePath || !openFromCLI ? args : args.concat([filePath])), + ...(!opossumFilePath || !openFromCLI + ? args + : args.concat([opossumFilePath])), ], executablePath, }); @@ -183,15 +202,11 @@ export const test = base.extend<{ reportView: async ({ window }, use) => { await use(new ReportView(window)); }, - importDialog: async ({ window, data }, use, info) => { - await use( - new ImportDialog(window, data?.inputData.metadata.projectId, info), - ); + importDialog: async ({ window }, use) => { + await use(new ImportDialog(window)); }, - mergeDialog: async ({ window, data }, use, info) => { - await use( - new MergeDialog(window, data?.inputData.metadata.projectId, info), - ); + mergeDialog: async ({ window }, use) => { + await use(new MergeDialog(window)); }, }); @@ -219,25 +234,24 @@ function getReleasePath(): string { throw new Error('Unsupported platform'); } -async function createTestFile({ - data: { inputData, outputData, provideImportFiles }, - info, +async function createTestFiles({ + data: { inputData, outputData }, + filePaths: { json, opossum }, }: { data: OpossumData; - info: TestInfo; + filePaths: FilePaths; }): Promise { - const filename = inputData.metadata.projectId; - - if (provideImportFiles) { - await writeFile({ - path: info.outputPath(`${filename}.json`), + await Promise.all([ + writeFile({ + path: json, content: inputData, - }); - } + }), + writeOpossumFile({ + input: inputData, + path: opossum, + output: outputData, + }), + ]); - return writeOpossumFile({ - input: inputData, - path: info.outputPath(`${filename}.opossum`), - output: outputData, - }); + return opossum; } diff --git a/src/shared/shared-types.ts b/src/shared/shared-types.ts index 753c75898..35f74eae7 100644 --- a/src/shared/shared-types.ts +++ b/src/shared/shared-types.ts @@ -316,4 +316,5 @@ export interface UserSettings { linkedResourcesPanelHeight: number | null; signalsPanelHeight: number | null; } | null; + recentlyOpenedPaths: Array | null; } diff --git a/src/shared/text.ts b/src/shared/text.ts index 14e90f0dd..569f4b628 100644 --- a/src/shared/text.ts +++ b/src/shared/text.ts @@ -13,6 +13,8 @@ export const text = { file: 'File', fileSubmenu: { open: 'Open...', + openRecent: 'Open Recent', + clearRecent: 'Clear Recent', import: 'Import', importSubmenu: menuLabelForFileFormat, merge: 'Merge',