diff --git a/e2e/cypress/mocks/app_toaster.js b/e2e/cypress/mocks/app_toaster.js new file mode 100644 index 00000000..b2a9269a --- /dev/null +++ b/e2e/cypress/mocks/app_toaster.js @@ -0,0 +1,11 @@ +export const AppToaster = { + show: () => { + + } +} + +export const AppAlert = { + show: () => { + + } +} diff --git a/src/components/TabList.tsx b/src/components/TabList.tsx index 00ecc04b..462beeeb 100644 --- a/src/components/TabList.tsx +++ b/src/components/TabList.tsx @@ -1,31 +1,17 @@ import * as React from 'react' +import { useState, useCallback } from 'react' import { ButtonGroup, Button, Icon, IconName } from '@blueprintjs/core' -import { inject, observer } from 'mobx-react' -import { WithTranslation, withTranslation } from 'react-i18next' +import { observer } from 'mobx-react' import { MenuItemConstructorOptions, ipcRenderer } from 'electron' -import { IpcRendererEvent } from 'electron/renderer' +import { useTranslation } from 'react-i18next' -import { ViewState } from '$src/state/viewState' +import useIpcRendererListener from '$src/hooks/useIpcRendererListener' import { sendFakeCombo } from '$src/utils/keyboard' -import { SettingsState } from '$src/state/settingsState' -import { ALL_DIRS } from '$src/utils/platform' -import { UserHomeIcons } from '$src/constants/icons' import { AppAlert } from '$src/components/AppAlert' import { LocalizedError } from '$src/locale/error' - -/** - * Describes a view, the path is the path to its first tab: right now each view is created with only - * one tab: this may change in the future - */ -export interface ViewDescriptor { - viewId: number - path: string -} - -interface InjectedProps extends WithTranslation { - viewState?: ViewState - settingsState?: SettingsState -} +import { useStores } from '$src/hooks/useStores' +import { UserHomeIcons } from '$src/constants/icons' +import { ALL_DIRS } from '$src/utils/platform' /** * build a list of { regex, IconName } to match folders with an icon @@ -35,260 +21,208 @@ interface InjectedProps extends WithTranslation { * icon: 'home' * } */ -const TabIcons = Object.keys(UserHomeIcons).map((dirname: string) => ({ +export const TabIcons = Object.keys(UserHomeIcons).map((dirname: string) => ({ regex: new RegExp(`^${ALL_DIRS[dirname]}$`), icon: UserHomeIcons[dirname], })) -const TabListClass = inject( - 'viewState', - 'settingsState', -)( - observer( - class TabListClass extends React.Component { - menuIndex = 0 - - constructor(props: InjectedProps) { - super(props) - } - - componentDidMount(): void { - // add listener - ipcRenderer.on('context-menu-tab-list:click', this.contextMenuHandler) - } - - componentWillUnmount(): void { - // remove listener - ipcRenderer.removeListener('context-menu-tab-list:click', this.contextMenuHandler) - } - - contextMenuHandler = (event: IpcRendererEvent, command: string, param?: string) => { - this.onItemClick(command, param) - } - - get injected(): InjectedProps { - return this.props as InjectedProps - } - - addTab = (index: number): void => { - const { viewState, settingsState } = this.injected - - viewState.addCache(settingsState.defaultFolder, index + 1, true) - } - - selectTab(tabIndex: number): void { - const { viewState } = this.injected - viewState.setVisibleCache(tabIndex) - } - - closeTab(tabIndex: number, e?: Event): void { - const { viewState } = this.injected - - viewState.closeTab(tabIndex) - // prevent selectTab handler to be called since the tab will get closed - e && e.stopPropagation() - console.log('closetab', tabIndex) - } - - closeOthers(index: number): void { - const { viewState } = this.injected +export const getTabIcon = (path: string): IconName => { + for (const obj of TabIcons) { + if (obj.regex.test(path)) { + return obj.icon as IconName + } + } - viewState.closeOthers(index) - } - - reloadView(index: number): void { - const { viewState } = this.injected - viewState.caches[index].reload() - } - - openTerminal(): void { - const { viewState } = this.injected - sendFakeCombo('CmdOrCtrl+K', { - tabIndex: this.menuIndex, - viewId: viewState.viewId, - }) - } + return 'folder-close' +} - onContextMenu = (menuIndex: number): void => { - const tabMenuTemplate = this.getTabMenu() - this.menuIndex = menuIndex - ipcRenderer.invoke('Menu:buildFromTemplate', tabMenuTemplate) - } +const TabList = observer(() => { + const { viewState, settingsState } = useStores('viewState', 'settingsState') + const [selectedMenuIndex, setSelectedMenuIndex] = useState(-1) + const { t } = useTranslation() + + useIpcRendererListener( + 'context-menu-tab-list:click', + useCallback( + (event, command, param) => { + if (!viewState?.isActive) { + return + } - onItemClick = (id: string, param?: string): void => { - switch (id) { + switch (command) { case 'CLOSE_TAB': - this.closeTab(this.menuIndex) + closeTab(selectedMenuIndex) break case 'NEW_TAB': - this.addTab(this.menuIndex) + addTab(selectedMenuIndex) break case 'CLOSE_OTHERS': - this.closeOthers(this.menuIndex) + closeOthers(selectedMenuIndex) break case 'REFRESH': - this.reloadView(this.menuIndex) + reloadView(selectedMenuIndex) break case 'OPEN_TERMINAL': - this.openTerminal() + openTerminal() break case 'OPEN_FOLDER': - this.onFolderItemClick(param) + onFolderItemClick(param) break - // case 'OPEN_EXPLORER': - // break; - // case 'COPY_PATH': default: - console.warn('unknown tab context menu command', id) + console.warn('unknown tab context menu command', command) } - } - - onFolderItemClick = (path: string): void => { - const { viewState } = this.injected - - const cache = viewState.getVisibleCache() - if (path) { - cache - .openDirectory({ - dir: cache.path, - fullname: path, - }) - .catch((err: LocalizedError) => { - AppAlert.show(`${err.message} (${err.code})`, { - intent: 'danger', - }) - }) + }, + [selectedMenuIndex], + ), + ) + + const addTab = (index: number): void => { + viewState.addCache(settingsState.defaultFolder, index + 1, true) + } + + const selectTab = (tabIndex: number): void => viewState.setVisibleCache(tabIndex) + + const closeTab = (tabIndex: number, e?: React.MouseEvent): void => viewState.closeTab(tabIndex) + + const closeOthers = (index: number): void => viewState.closeOthers(index) + + const reloadView = (index: number): void => viewState.caches[index].reload() + + const openTerminal = (): Promise => + sendFakeCombo('CmdOrCtrl+K', { + tabIndex: selectedMenuIndex, + viewId: viewState.viewId, + }) + + const onContextMenu = (menuIndex: number): void => { + const tabMenuTemplate = getTabMenu() + setSelectedMenuIndex(menuIndex) + ipcRenderer.invoke('Menu:buildFromTemplate', tabMenuTemplate) + } + + const onFolderItemClick = (path: string): void => { + const cache = viewState.getVisibleCache() + if (path) { + cache + .openDirectory({ + dir: cache.path, + fullname: path, + }) + .catch((err: LocalizedError) => { + AppAlert.show(`${err.message} (${err.code})`, { + intent: 'danger', + }) + }) + } + } + + const onFolderContextMenu = (index: number, e: React.MouseEvent): void => { + console.log('right click') + e.preventDefault() + e.stopPropagation() + + const cacheUnderMouse = viewState.caches[index] + const tree = cacheUnderMouse.getAPI().getParentTree(cacheUnderMouse.path) + + const template: MenuItemConstructorOptions[] = tree.map( + (el: { dir: string; fullname: string; name: string }) => { + return { + label: el.name, + id: `OPEN_FOLDER///${el.fullname}`, } - } - - onFolderContextMenu = (index: number, e: React.MouseEvent): void => { - const { viewState } = this.injected - - e.preventDefault() - e.stopPropagation() - - const cacheUnderMouse = viewState.caches[index] - const tree = cacheUnderMouse.getAPI().getParentTree(cacheUnderMouse.path) - - const template: MenuItemConstructorOptions[] = tree.map( - (el: { dir: string; fullname: string; name: string }) => { - return { - label: el.name, - id: `OPEN_FOLDER///${el.fullname}`, - } - }, + }, + ) + + ipcRenderer.invoke('Menu:buildFromTemplate', template) + } + + const getTabMenu = (): MenuItemConstructorOptions[] => { + return [ + { + label: t('TABS.NEW'), + id: 'NEW_TAB', + }, + { + type: 'separator', + }, + { + label: t('TABS.REFRESH'), + id: 'REFRESH', + }, + { + type: 'separator', + }, + { + label: t('TABS.CLOSE'), + id: 'CLOSE_TAB', + }, + { + label: t('TABS.CLOSE_OTHERS'), + id: 'CLOSE_OTHERS', + }, + { + type: 'separator', + }, + { + label: t('APP_MENUS.OPEN_TERMINAL'), + id: 'OPEN_TERMINAL', + }, + ] + } + + const viewId = viewState.viewId + const caches = viewState.caches + // TODO: this will be created at each render: this should only be re-rendered + // whenever the language has changed + + return ( + + {caches.map((cache, index) => { + const closeIcon = caches.length > 1 && ( + closeTab(index, e)} + icon="cross" + > ) - - ipcRenderer.invoke('Menu:buildFromTemplate', template) - } - - getTabMenu(): MenuItemConstructorOptions[] { - const { t } = this.injected - - return [ - { - label: t('TABS.NEW'), - id: 'NEW_TAB', - }, - { - type: 'separator', - }, - { - label: t('TABS.REFRESH'), - id: 'REFRESH', - }, - { - type: 'separator', - }, - { - label: t('TABS.CLOSE'), - id: 'CLOSE_TAB', - }, - { - label: t('TABS.CLOSE_OTHERS'), - id: 'CLOSE_OTHERS', - }, - { - type: 'separator', - }, - { - label: t('APP_MENUS.OPEN_TERMINAL'), - id: 'OPEN_TERMINAL', - }, - ] - } - - getTabIcon(path: string): IconName { - for (const obj of TabIcons) { - if (obj.regex.test(path)) { - return obj.icon as IconName - } + const path = cache.path + const tabIcon = cache.error ? 'issue' : getTabIcon(path) + const tabInfo = (cache.getFS() && cache.getFS().displaypath(path)) || { + fullPath: '', + shortPath: '', } - return 'folder-close' - } - - render(): React.ReactNode { - const { viewState } = this.injected - const { t } = this.props - const viewId = viewState.viewId - const caches = viewState.caches - // TODO: this will be created at each render: this should only be re-rendered - // whenever the language has changed - return ( - - {caches.map((cache, index) => { - const closeIcon = caches.length > 1 && ( - - ) - const path = cache.path - const tabIcon = cache.error ? 'issue' : this.getTabIcon(path) - const tabInfo = (cache.getFS() && cache.getFS().displaypath(path)) || { - fullPath: '', - shortPath: '', - } - - return ( - - ) - })} - - + ) - } - }, - ), -) - -const TabList = withTranslation()(TabListClass) + })} + + + ) +}) export { TabList } diff --git a/src/components/__tests__/TabList.test.tsx b/src/components/__tests__/TabList.test.tsx new file mode 100644 index 00000000..300796e8 --- /dev/null +++ b/src/components/__tests__/TabList.test.tsx @@ -0,0 +1,144 @@ +/** + * @jest-environment jsdom + */ +import React from 'react' +import { within } from '@testing-library/dom' + +import { screen, setup, render, t } from 'rtl' +import { ViewState } from '$src/state/viewState' +import { ipcRenderer } from 'electron' +import { vol } from 'memfs' + +import { TabList } from '../TabList' +import { SettingsState } from '$src/state/settingsState' +import { Classes } from '@blueprintjs/core' + +describe('TabList', () => { + const settingsState = { + defaultFolder: '/virtual', + } + + vol.fromJSON( + { + file1: '', + file2: '', + dir1: null, + }, + '/virtual', + ) + + const options = { + providerProps: { + viewState: new ViewState(0), + settingsState: settingsState as SettingsState, + }, + } + + const isSelected = (element: HTMLElement) => element.classList.contains(Classes.INTENT_PRIMARY) + + beforeEach(async () => { + options.providerProps.viewState = new ViewState(0) + const cache = options.providerProps.viewState.addCache('/virtual', -1, true) + await cache.openDirectory({ dir: '/virtual', fullname: '' }) + jest.clearAllMocks() + }) + + it('should show tabs and "new tab" button', () => { + const { container } = render(, options) + + const tab = screen.getByRole('button', { name: 'virtual' }) + expect(tab).toBeInTheDocument() + expect(isSelected(tab)).toBe(true) + + expect(screen.getByTitle(t('TABS.NEW'))).toBeInTheDocument() + // check for presence of the tab's icon: bad habit to directly query selector + // but unfortunately there is no easy way query these elements in an accessible way :( + expect(container.querySelector('[data-icon="folder-close"]')).toBeInTheDocument() + }) + + it('should open tab context menu when right-clicking on tab', async () => { + jest.spyOn(ipcRenderer, 'invoke') + const { user } = setup(, options) + + const button = screen.getByRole('button', { name: 'virtual' }) + await user.pointer([{ target: button }, { keys: '[MouseRight]', target: button }]) + + // don't bother checking for the menu template for now, but at least check that we receive an array + expect(ipcRenderer.invoke).toHaveBeenCalledWith('Menu:buildFromTemplate', expect.any(Array)) + }) + + it('should open tab icon context menu when right-clicking on tab icon', async () => { + jest.spyOn(ipcRenderer, 'invoke') + const { user, container } = setup(, options) + + const icon = container.querySelector('[data-icon="folder-close"]') + await user.pointer([{ target: icon }, { keys: '[MouseRight]', target: icon }]) + + // don't bother checking for the menu template for now, but at least check that we receive an array + expect(ipcRenderer.invoke).toHaveBeenCalledWith('Menu:buildFromTemplate', expect.any(Array)) + }) + + describe('actions', () => { + it('should add a new tab when clicking on add button', async () => { + const { user } = setup(, options) + + await user.click(screen.getByTitle(t('TABS.NEW'))) + + // we have two items + const items = screen.getAllByRole('button', { name: 'virtual' }) + expect(items.length).toBe(2) + + // the new tab is activated and the first one became inactive + expect(isSelected(items[0])).toBe(false) + expect(isSelected(items[1])).toBe(true) + }) + + it('should close context menu when clicking on close button', async () => { + const { user } = setup(, options) + + await user.click(screen.getByTitle(t('TABS.NEW'))) + + const secondTab = screen.getAllByRole('button', { name: 'virtual' })[1] + await user.hover(secondTab) + + await user.click(within(secondTab).queryByTitle(t('TABS.CLOSE'))) + + const items = screen.getAllByRole('button', { name: 'virtual' }) + + expect(items.length).toBe(1) + }) + + it('should change tab when clicking on an inactive tab', async () => { + const { user } = setup(, options) + + await user.click(screen.getByTitle(t('TABS.NEW'))) + + const items = screen.getAllByRole('button', { name: 'virtual' }) + + expect(!isSelected(items[0])) + expect(isSelected(items[1])) + + await user.click(items[0]) + + expect(isSelected(items[0])) + expect(!isSelected(items[1])) + }) + + it('should update tab title when file path is updated', async () => { + render(, options) + + await options.providerProps.viewState + .getVisibleCache() + .openDirectory({ dir: '/virtual/dir1', fullname: '' }) + + expect(screen.getByText('dir1')).toBeInTheDocument() + }) + + it('should show an error icon when cache.error is true', () => { + options.providerProps.viewState.getVisibleCache().error = true + const { container } = render(, options) + + expect(container.querySelector('[data-icon="issue"]')).toBeInTheDocument() + }) + }) +}) diff --git a/src/constants/icons.ts b/src/constants/icons.ts index b2e34944..c5bcdcc8 100644 --- a/src/constants/icons.ts +++ b/src/constants/icons.ts @@ -1,5 +1,7 @@ import { IconName } from '@blueprintjs/core' +import { ALL_DIRS } from '$src/utils/platform' + /** * blueprint icon name for user home folders */ diff --git a/src/hooks/useIpcRendererListener.ts b/src/hooks/useIpcRendererListener.ts new file mode 100644 index 00000000..eb15fa59 --- /dev/null +++ b/src/hooks/useIpcRendererListener.ts @@ -0,0 +1,22 @@ +import * as React from 'react' +import { ipcRenderer } from 'electron' + +import type { IpcRendererEventHandler } from '$src/types' + +const useIpcRendererListener = (event: string, handler: IpcRendererEventHandler) => { + const savedHandler = React.useRef() + + React.useEffect(() => { + savedHandler.current = handler + }, [handler]) + + React.useEffect(() => { + ipcRenderer.on(event, (event, command, param) => savedHandler.current(event, command, param)) + + return () => { + ipcRenderer.removeListener(event, savedHandler.current) + } + }, [event]) +} + +export default useIpcRendererListener diff --git a/src/state/appState.tsx b/src/state/appState.tsx index fbd49650..0ab9d2f3 100644 --- a/src/state/appState.tsx +++ b/src/state/appState.tsx @@ -7,7 +7,7 @@ import { shell } from 'electron' import { File } from '$src/services/Fs' import { FileState } from '$src/state/fileState' import { TransferOptions } from '$src/state/transferState' -import { ViewDescriptor } from '$src/components/TabList' +import { ViewDescriptor } from '$src/types' import { WinState, WindowSettings } from '$src/state/winState' import { FavoritesState } from '$src/state/favoritesState' import { ViewState } from '$src/state/viewState' diff --git a/src/state/viewState.ts b/src/state/viewState.ts index b120db33..107d4123 100644 --- a/src/state/viewState.ts +++ b/src/state/viewState.ts @@ -53,7 +53,7 @@ export class ViewState { const previous = this.getVisibleCache() const next = this.caches[index] // do nothing if previous === next - if (previous !== next) { + if (next && previous !== next) { if (previous) { previous.isVisible = false } diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 00000000..3b485379 --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,12 @@ +import { IpcRendererEvent } from 'electron/renderer' + +export type IpcRendererEventHandler = (event: IpcRendererEvent, command: string, param?: string) => void + +/** + * Describes a view, the path is the path to its first tab: right now each view is created with only + * one tab: this may change in the future + */ +export interface ViewDescriptor { + viewId: number + path: string +} diff --git a/src/utils/test/rtl.tsx b/src/utils/test/rtl.tsx index 64b81042..741428c6 100644 --- a/src/utils/test/rtl.tsx +++ b/src/utils/test/rtl.tsx @@ -26,6 +26,7 @@ jest.mock('$src/locale/i18n', () => ({ jest.mock('electron', () => ({ ipcRenderer: { on: jest.fn(), + removeListener: jest.fn(), sendSync: jest.fn(), invoke: jest.fn( (command: string) =>