From a1b198dbcfb03d90912287b451de0ed29a76bb34 Mon Sep 17 00:00:00 2001 From: Esteban Galvis Date: Mon, 16 Mar 2026 13:36:14 -0500 Subject: [PATCH 1/8] Feat/unify sync status in a context manager --- src/apps/main/interface.d.ts | 3 ++ src/apps/main/preload.d.ts | 4 --- src/apps/main/preload.js | 15 -------- src/apps/renderer/App.tsx | 11 +++--- src/apps/renderer/context/SyncContext.tsx | 36 +++++++++++++++++++ src/apps/renderer/hooks/useOnSyncRunning.tsx | 18 +++++----- src/apps/renderer/hooks/useSyncStatus.tsx | 32 ----------------- src/apps/renderer/pages/Widget/SyncAction.tsx | 4 +-- .../renderer/pages/Widget/SyncErrorBanner.tsx | 17 +++++---- src/apps/renderer/pages/Widget/index.tsx | 4 +-- 10 files changed, 65 insertions(+), 79 deletions(-) create mode 100644 src/apps/renderer/context/SyncContext.tsx delete mode 100644 src/apps/renderer/hooks/useSyncStatus.tsx diff --git a/src/apps/main/interface.d.ts b/src/apps/main/interface.d.ts index e8c662943e..da1fab8498 100644 --- a/src/apps/main/interface.d.ts +++ b/src/apps/main/interface.d.ts @@ -125,12 +125,15 @@ export interface IElectronAPI { removeInfectedFiles: (infectedFiles: string[]) => Promise; cancelScan: () => Promise; }; + chooseSyncRootWithDialog(): Promise; getBackupErrorByFolder(folderId: number): Promise; getLastBackupHadIssues(): Promise; onBackupFatalErrorsChanged(fn: (backupErrors: Array) => void): () => void; getBackupFatalErrors(): Promise>; onBackupProgress(func: (value: number) => void): () => void; startRemoteSync(): Promise; + getRemoteSyncStatus(): Promise; + onRemoteSyncStatusChange(callback: (status: import('./remote-sync/helpers').RemoteSyncStatus) => void): () => void; } declare global { diff --git a/src/apps/main/preload.d.ts b/src/apps/main/preload.d.ts index f69c261930..aa31638115 100644 --- a/src/apps/main/preload.d.ts +++ b/src/apps/main/preload.d.ts @@ -55,10 +55,6 @@ declare interface Window { getUser(): Promise>; - startSyncProcess(): void; - - stopSyncProcess(): void; - openProcessIssuesWindow(): void; openLogs(): void; diff --git a/src/apps/main/preload.js b/src/apps/main/preload.js index 704b016a05..0dbfb2a9dc 100644 --- a/src/apps/main/preload.js +++ b/src/apps/main/preload.js @@ -50,21 +50,6 @@ contextBridge.exposeInMainWorld('electron', { getUser() { return ipcRenderer.invoke('get-user'); }, - startSyncProcess() { - return ipcRenderer.send('start-sync-process'); - }, - stopSyncProcess() { - return ipcRenderer.send('stop-sync-process'); - }, - getSyncStatus() { - return ipcRenderer.invoke('get-sync-status'); - }, - onSyncStatusChanged(func) { - const eventName = 'sync-status-changed'; - const callback = (_, v) => func(v); - ipcRenderer.on(eventName, callback); - return () => ipcRenderer.removeListener(eventName, callback); - }, onSyncStopped(func) { const eventName = 'sync-stopped'; const callback = (_, v) => func(v); diff --git a/src/apps/renderer/App.tsx b/src/apps/renderer/App.tsx index 7d8777b97f..201eaee1ec 100644 --- a/src/apps/renderer/App.tsx +++ b/src/apps/renderer/App.tsx @@ -3,6 +3,7 @@ import './localize/i18n.service'; import { Suspense, useEffect, useRef } from 'react'; import { HashRouter as Router, Route, Routes, useLocation, useNavigate } from 'react-router-dom'; import { TranslationProvider } from './context/LocalContext'; +import { SyncProvider } from './context/SyncContext'; import useLanguageChangedListener from './hooks/useLanguage'; import Login from './pages/Login'; import Onboarding from './pages/Onboarding'; @@ -55,8 +56,9 @@ export default function App() { }> - - + + + } /> } /> @@ -64,8 +66,9 @@ export default function App() { } /> } /> - - + + + diff --git a/src/apps/renderer/context/SyncContext.tsx b/src/apps/renderer/context/SyncContext.tsx new file mode 100644 index 0000000000..4b7e25c753 --- /dev/null +++ b/src/apps/renderer/context/SyncContext.tsx @@ -0,0 +1,36 @@ +import { createContext, ReactNode, useCallback, useContext, useEffect, useState } from 'react'; +import { SyncStatus } from '../../../context/desktop/sync/domain/SyncStatus'; +import { RemoteSyncStatus } from '../../main/remote-sync/helpers'; + +const statusesMap: Record = { + SYNCING: 'RUNNING', + IDLE: 'STANDBY', + SYNCED: 'STANDBY', + SYNC_FAILED: 'FAILED', +}; + +interface SyncContextValue { + syncStatus: SyncStatus; +} + +const SyncContext = createContext({ syncStatus: 'STANDBY' }); + +export function SyncProvider({ children }: { children: ReactNode }) { + const [syncStatus, setSyncStatus] = useState('STANDBY'); + + const setSyncStatusFromRemote = useCallback((remote: RemoteSyncStatus): void => { + setSyncStatus(statusesMap[remote]); + }, []); + + useEffect(() => { + window.electron.getRemoteSyncStatus().then(setSyncStatusFromRemote); + const removeListener = window.electron.onRemoteSyncStatusChange(setSyncStatusFromRemote); + return removeListener; + }, [setSyncStatusFromRemote]); + + return {children}; +} + +export function useSyncContext(): SyncContextValue { + return useContext(SyncContext); +} diff --git a/src/apps/renderer/hooks/useOnSyncRunning.tsx b/src/apps/renderer/hooks/useOnSyncRunning.tsx index 288d1743bd..dfde001a52 100644 --- a/src/apps/renderer/hooks/useOnSyncRunning.tsx +++ b/src/apps/renderer/hooks/useOnSyncRunning.tsx @@ -1,14 +1,12 @@ -import { SyncStatus } from '../../../context/desktop/sync/domain/SyncStatus'; -import useSyncStatus from './useSyncStatus'; +import { useEffect } from 'react'; +import { useSyncContext } from '../context/SyncContext'; export function useOnSyncRunning(fn: () => void) { - function isRunning(status: SyncStatus) { - return status === 'RUNNING'; - } + const { syncStatus } = useSyncContext(); - useSyncStatus((status) => { - if (!isRunning(status)) return; - - fn(); - }); + useEffect(() => { + if (syncStatus === 'RUNNING') { + fn(); + } + }, [syncStatus]); } diff --git a/src/apps/renderer/hooks/useSyncStatus.tsx b/src/apps/renderer/hooks/useSyncStatus.tsx deleted file mode 100644 index 21e4504939..0000000000 --- a/src/apps/renderer/hooks/useSyncStatus.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { useEffect, useState } from 'react'; -import { SyncStatus } from '../../../context/desktop/sync/domain/SyncStatus'; -import { RemoteSyncStatus } from '../../main/remote-sync/helpers'; - -const statusesMap: Record = { - SYNCING: 'RUNNING', - IDLE: 'STANDBY', - SYNCED: 'STANDBY', - SYNC_FAILED: 'FAILED', -}; - -export default function useSyncStatus(onChange?: (currentState: SyncStatus) => void) { - const [syncStatus, setSyncStatus] = useState('RUNNING'); - - const setSyncStatusFromRemote = (remote: RemoteSyncStatus): void => { - setSyncStatus(statusesMap[remote]); - }; - - useEffect(() => { - window.electron.getRemoteSyncStatus().then(setSyncStatusFromRemote); - - const removeListener = window.electron.onRemoteSyncStatusChange(setSyncStatusFromRemote); - - return removeListener; - }, []); - - useEffect(() => { - if (onChange) onChange(syncStatus); - }, [syncStatus]); - - return { syncStatus }; -} diff --git a/src/apps/renderer/pages/Widget/SyncAction.tsx b/src/apps/renderer/pages/Widget/SyncAction.tsx index 8451f2af55..ab10bd42c8 100644 --- a/src/apps/renderer/pages/Widget/SyncAction.tsx +++ b/src/apps/renderer/pages/Widget/SyncAction.tsx @@ -5,7 +5,6 @@ import Spinner from '../../assets/spinner.svg'; import Button from '../../components/Button'; import { useTranslationContext } from '../../context/LocalContext'; import useVirtualDriveStatus from '../../hooks/useVirtualDriveStatus'; -import useSyncStatus from '../../hooks/useSyncStatus'; import { useOnlineStatus } from '../../hooks/useOnlineStatus/useOnlineStatus'; import { useUsage } from '../../context/UsageContext/useUsage'; @@ -14,9 +13,8 @@ export default function SyncAction(props: { syncStatus: SyncStatus }) { const isOnLine = useOnlineStatus(); const { usage, status } = useUsage(); const { virtualDriveStatus } = useVirtualDriveStatus(); - const { syncStatus } = useSyncStatus(); - const isSyncStopped = virtualDriveStatus && syncStatus && syncStatus === 'FAILED'; + const isSyncStopped = virtualDriveStatus && props.syncStatus && props.syncStatus === 'FAILED'; const handleOpenUpgrade = async () => { try { diff --git a/src/apps/renderer/pages/Widget/SyncErrorBanner.tsx b/src/apps/renderer/pages/Widget/SyncErrorBanner.tsx index 66b76497c0..ca1b4d8517 100644 --- a/src/apps/renderer/pages/Widget/SyncErrorBanner.tsx +++ b/src/apps/renderer/pages/Widget/SyncErrorBanner.tsx @@ -1,9 +1,9 @@ -import { SyncStatus } from '../../../../context/desktop/sync/domain/SyncStatus'; +import { useEffect } from 'react'; import { FatalError } from '../../../../shared/issues/FatalError'; import Error from '../../assets/error.svg'; import Warn from '../../assets/warn.svg'; import { useTranslationContext } from '../../context/LocalContext'; -import useSyncStatus from '../../hooks/useSyncStatus'; +import { useSyncContext } from '../../context/SyncContext'; import useSyncStopped from '../../hooks/useSyncStopped'; import SyncFatalErrorMessages from '../../messages/fatal-error'; @@ -15,7 +15,7 @@ const fatalErrorActionMap: Record void } func: async () => { const result = await window.electron.chooseSyncRootWithDialog(); if (result) { - window.electron.startSyncProcess(); + window.electron.startRemoteSync(); } }, }, @@ -24,7 +24,7 @@ const fatalErrorActionMap: Record void } func: async () => { const result = await window.electron.chooseSyncRootWithDialog(); if (result) { - window.electron.startSyncProcess(); + window.electron.startRemoteSync(); } }, }, @@ -39,14 +39,13 @@ const fatalErrorActionMap: Record void } export default function SyncErrorBanner() { const [stopReason, setStopReason] = useSyncStopped(); const { translate } = useTranslationContext(); + const { syncStatus } = useSyncContext(); - function onSyncStatusChanged(value: SyncStatus) { - if (value === 'RUNNING') { + useEffect(() => { + if (syncStatus === 'RUNNING') { setStopReason(null); } - } - - useSyncStatus(onSyncStatusChanged); + }, [syncStatus]); const severity = 'FATAL' as ErrorSeverity; const show = stopReason !== null && stopReason?.reason !== 'STOPPED_BY_USER'; diff --git a/src/apps/renderer/pages/Widget/index.tsx b/src/apps/renderer/pages/Widget/index.tsx index 03e08cb576..e49df69d51 100644 --- a/src/apps/renderer/pages/Widget/index.tsx +++ b/src/apps/renderer/pages/Widget/index.tsx @@ -2,7 +2,7 @@ import Header from './Header'; import SyncAction from './SyncAction'; import SyncErrorBanner from './SyncErrorBanner'; import SyncInfo from './SyncInfo'; -import useSyncStatus from '../../hooks/useSyncStatus'; +import { useSyncContext } from '../../context/SyncContext'; import { SyncFailed } from './SyncFailed'; import { useEffect, useState } from 'react'; import useVirtualDriveStatus from '../../hooks/useVirtualDriveStatus'; @@ -16,7 +16,7 @@ const handleRetrySync = () => { }; export default function Widget() { - const { syncStatus } = useSyncStatus(); + const { syncStatus } = useSyncContext(); const [displayErrorInWidget, setDisplayErrorInWidget] = useState(false); const { virtualDriveStatus } = useVirtualDriveStatus(); From 6c127064f484a8eabcfcc794e12adb54cdcc1d59 Mon Sep 17 00:00:00 2001 From: Esteban Galvis Date: Mon, 16 Mar 2026 13:38:17 -0500 Subject: [PATCH 2/8] style: format Routes component for better readability --- src/apps/renderer/App.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/apps/renderer/App.tsx b/src/apps/renderer/App.tsx index 201eaee1ec..bff1c47cbb 100644 --- a/src/apps/renderer/App.tsx +++ b/src/apps/renderer/App.tsx @@ -59,13 +59,13 @@ export default function App() { - - } /> - } /> - } /> - } /> - } /> - + + } /> + } /> + } /> + } /> + } /> + From e0551869c4143a21a2807de3eaf202581acfbf5c Mon Sep 17 00:00:00 2001 From: Esteban Galvis Date: Mon, 16 Mar 2026 14:28:45 -0500 Subject: [PATCH 3/8] Feat: add sync status and functionality to Header component and localize new strings --- src/apps/renderer/localize/locales/en.json | 3 ++- src/apps/renderer/localize/locales/es.json | 3 ++- src/apps/renderer/localize/locales/fr.json | 3 ++- src/apps/renderer/pages/Widget/Header.tsx | 27 ++++++++++++++++++++-- 4 files changed, 31 insertions(+), 5 deletions(-) diff --git a/src/apps/renderer/localize/locales/en.json b/src/apps/renderer/localize/locales/en.json index 0e47b6080b..84158b5ab4 100644 --- a/src/apps/renderer/localize/locales/en.json +++ b/src/apps/renderer/localize/locales/en.json @@ -152,7 +152,8 @@ "quit": "Quit", "antivirus": "Antivirus", "cleaner": "Cleaner", - "new": "New" + "new": "New", + "sync": "Sync" } }, "body": { diff --git a/src/apps/renderer/localize/locales/es.json b/src/apps/renderer/localize/locales/es.json index 929ffc4ba6..33fd79029f 100644 --- a/src/apps/renderer/localize/locales/es.json +++ b/src/apps/renderer/localize/locales/es.json @@ -152,7 +152,8 @@ "logout": "Cerrar sesión", "quit": "Salir", "cleaner": "Cleaner", - "new": "Nuevo" + "new": "Nuevo", + "sync": "Sincronizar" } }, "body": { diff --git a/src/apps/renderer/localize/locales/fr.json b/src/apps/renderer/localize/locales/fr.json index 15292d6bfd..e716199a21 100644 --- a/src/apps/renderer/localize/locales/fr.json +++ b/src/apps/renderer/localize/locales/fr.json @@ -151,7 +151,8 @@ "logout": "Déconnecter", "quit": "Fermer", "cleaner": "Cleaner", - "new": "Nouveau" + "new": "Nouveau", + "sync": "Synchroniser" } }, "body": { diff --git a/src/apps/renderer/pages/Widget/Header.tsx b/src/apps/renderer/pages/Widget/Header.tsx index 95b380e27c..5e7a6e57a9 100644 --- a/src/apps/renderer/pages/Widget/Header.tsx +++ b/src/apps/renderer/pages/Widget/Header.tsx @@ -5,6 +5,7 @@ import bytes from 'bytes'; import { User } from '../../../main/types'; import { useTranslationContext } from '../../context/LocalContext'; +import { useSyncContext } from '../../context/SyncContext'; import useBackupErrors from '../../hooks/backups/useBackupErrors'; import useGeneralIssues from '../../hooks/GeneralIssues'; import useVirtualDriveIssues from '../../hooks/ProcessIssues'; @@ -117,17 +118,20 @@ export default function Header() { children, active, onClick, + disabled, }: { children: JSX.Element; active?: boolean; onClick?: () => void; + disabled?: boolean; }) => { return ( + ); +} diff --git a/src/apps/renderer/pages/Widget/Header.tsx b/src/apps/renderer/pages/Widget/Header.tsx index e7ab1b9329..ff010b430a 100644 --- a/src/apps/renderer/pages/Widget/Header.tsx +++ b/src/apps/renderer/pages/Widget/Header.tsx @@ -1,43 +1,17 @@ -import { useEffect, useRef, useState } from 'react'; -import { FolderSimple, Gear, Globe } from '@phosphor-icons/react'; -import { Menu, Transition } from '@headlessui/react'; -import bytes from 'bytes'; - -import { User } from '../../../main/types'; -import { useTranslationContext } from '../../context/LocalContext'; -import { useSyncContext } from '../../context/SyncContext'; import useBackupErrors from '../../hooks/backups/useBackupErrors'; import useGeneralIssues from '../../hooks/GeneralIssues'; import useVirtualDriveIssues from '../../hooks/ProcessIssues'; -import { useUsage } from '../../context/UsageContext/useUsage'; +import { AccountSection } from './AccountSection'; +import { ItemsSection } from './ItemsSection'; export default function Header() { - const { translate } = useTranslationContext(); const processIssues = useVirtualDriveIssues(); const { generalIssues } = useGeneralIssues(); const { backupErrors } = useBackupErrors(); const numberOfIssues: number = processIssues.length + backupErrors.length + generalIssues.length; - const numberOfIssuesDisplay = numberOfIssues > 99 ? '99+' : numberOfIssues; - /* Electron on MacOS kept focusing the first focusable - element on start so we had to create a dummy element - to get that focus, remove it and make itself - non-focusable */ - const dummyRef = useRef(null); - - useEffect(() => { - if (process.env.platform === 'darwin') { - const listener = () => { - dummyRef.current?.blur(); - dummyRef.current?.removeEventListener('focus', listener); - dummyRef.current?.setAttribute('tabindex', '-1'); - }; - dummyRef.current?.addEventListener('focus', listener); - } - }, []); - function onQuitClick() { window.electron.quit(); } @@ -50,234 +24,15 @@ export default function Header() { } }; - const AccountSection = () => { - const { translate } = useTranslationContext(); - const [user, setUser] = useState(null); - - useEffect(() => { - window.electron.getUser().then(setUser); - }, []); - - const { usage, status } = useUsage(); - console.log('usage in header', usage); - console.log('status in header', status); - - let displayUsage: string; - - if (status === 'loading') { - displayUsage = 'Loading...'; - } else if (status === 'error') { - displayUsage = ''; - } else if (usage) { - displayUsage = `${bytes.format(usage.usageInBytes)} ${translate( - 'widget.header.usage.of', - )} ${usage.isInfinite ? '∞' : bytes.format(usage.limitInBytes)}`; - } else { - displayUsage = ''; - } - console.log('displayUsage', displayUsage); - return ( -
-
- {`${user?.name.charAt(0) ?? ''}${user?.lastname.charAt(0) ?? ''}`} -
- -
-

- {user?.email} -

-

{displayUsage}

-
-
- ); - }; - - const HeaderItemWrapper = ({ - children, - active = false, - onClick, - disabled, - }: { - children: JSX.Element; - active?: boolean; - onClick?: any; - disabled?: boolean; - }) => { - return ( -
- {children} -
- ); - }; - - const DropdownItem = ({ - children, - active, - onClick, - disabled, - }: { - children: JSX.Element; - active?: boolean; - onClick?: () => void; - disabled?: boolean; - }) => { - return ( - - ); - }; - - const ItemsSection = () => { - const { syncStatus } = useSyncContext(); - const isSyncing = syncStatus === 'RUNNING'; - - const handleManualSync = () => { - if (isSyncing) return; - window.electron.startRemoteSync().catch(reportError); - }; - - return ( -
- {process.env.platform === 'darwin' &&
} - handleOpenURL('https://drive.internxt.com')}> - - - window.electron.openVirtualDriveFolder()} - data-automation-id="openVirtualDriveFolder"> - - - - - {({ open }) => ( - <> - - - - - - - - - - {({ active }) => ( -
- window.electron.openSettingsWindow()}> - {translate('widget.header.dropdown.preferences')} - -
- )} -
- - {({ active }) => ( -
- - {translate('widget.header.dropdown.sync')} - -
- )} -
- - {({ active }) => ( -
- -
-

{translate('widget.header.dropdown.issues')}

- {numberOfIssues > 0 && ( -

{numberOfIssuesDisplay}

- )} -
-
-
- )} -
- - {({ active }) => ( -
- handleOpenURL('https://help.internxt.com')} - data-automation-id="menuItemSupport"> - {translate('widget.header.dropdown.support')} - -
- )} -
- {true && ( - - {({ active }) => ( -
- window.electron.openSettingsWindow('CLEANER')} - data-automation-id="menuItemCleaner"> -
- {translate('widget.header.dropdown.cleaner')} -
- {translate('widget.header.dropdown.new')} -
-
-
-
- )} -
- )} - - {({ active }) => ( -
- - {translate('widget.header.dropdown.logout')} - -
- )} -
- - {({ active }) => ( -
- - {translate('widget.header.dropdown.quit')} - -
- )} -
-
-
- - )} -
-
- ); - }; - return (
- +
); } diff --git a/src/apps/renderer/pages/Widget/HeaderItemWrapper.test.tsx b/src/apps/renderer/pages/Widget/HeaderItemWrapper.test.tsx new file mode 100644 index 0000000000..a54fc35152 --- /dev/null +++ b/src/apps/renderer/pages/Widget/HeaderItemWrapper.test.tsx @@ -0,0 +1,59 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { HeaderItemWrapper } from './HeaderItemWrapper'; + +describe('HeaderItemWrapper', () => { + it('renders children', () => { + render( + + Icon + , + ); + + expect(screen.getByText('Icon')).toBeInTheDocument(); + }); + + it('calls onClick when clicked', () => { + const onClick = vi.fn(); + + render( + + Click + , + ); + + fireEvent.click(screen.getByText('Click').parentElement!); + + expect(onClick).toHaveBeenCalledOnce(); + }); + + it('applies active styles when active is true', () => { + const { container } = render( + + Active + , + ); + + expect(container.firstChild).toHaveClass('bg-surface'); + }); + + it('does not apply active styles when active is false', () => { + const { container } = render( + + Inactive + , + ); + + expect(container.firstChild).not.toHaveClass('ring-1'); + }); + + it('applies disabled styles when disabled is true', () => { + const { container } = render( + + Disabled + , + ); + + expect(container.firstChild).toHaveClass('pointer-events-none'); + expect(container.firstChild).toHaveClass('text-gray-40'); + }); +}); diff --git a/src/apps/renderer/pages/Widget/HeaderItemWrapper.tsx b/src/apps/renderer/pages/Widget/HeaderItemWrapper.tsx new file mode 100644 index 0000000000..f009b52c24 --- /dev/null +++ b/src/apps/renderer/pages/Widget/HeaderItemWrapper.tsx @@ -0,0 +1,21 @@ +export function HeaderItemWrapper({ + children, + active = false, + onClick, + disabled, +}: { + children: JSX.Element; + active?: boolean; + onClick?: any; + disabled?: boolean; +}) { + return ( +
+ {children} +
+ ); +} diff --git a/src/apps/renderer/pages/Widget/ItemsSection.test.tsx b/src/apps/renderer/pages/Widget/ItemsSection.test.tsx new file mode 100644 index 0000000000..9dad346add --- /dev/null +++ b/src/apps/renderer/pages/Widget/ItemsSection.test.tsx @@ -0,0 +1,139 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import React from 'react'; +import { type Mock } from 'vitest'; +import { useTranslationContext } from '../../context/LocalContext'; +import { useSyncContext } from '../../context/SyncContext'; +import { ItemsSection } from './ItemsSection'; + +vi.mock('../../context/LocalContext'); +vi.mock('../../context/SyncContext'); + +vi.mock('@headlessui/react', () => ({ + Menu: Object.assign( + ({ children, as: Tag = 'div', className }: any) => { + const content = typeof children === 'function' ? children({ open: false }) : children; + return {content}; + }, + { + Button: ({ children, className }: any) => , + Items: ({ children, className }: any) =>
{children}
, + Item: ({ children }: any) => ( +
{typeof children === 'function' ? children({ active: false }) : children}
+ ), + }, + ), + Transition: ({ children }: any) => <>{children}, +})); + +const defaultProps = { + numberOfIssues: 0, + numberOfIssuesDisplay: 0, + dummyRef: { current: null } as React.RefObject, + onQuitClick: vi.fn(), + onOpenURL: vi.fn(), +}; + +describe('ItemsSection', () => { + beforeEach(() => { + vi.clearAllMocks(); + (useTranslationContext as Mock).mockReturnValue({ translate: (key: string) => key }); + (useSyncContext as Mock).mockReturnValue({ syncStatus: 'STANDBY' }); + }); + + it('calls onOpenURL with drive URL when globe icon wrapper is clicked', () => { + const onOpenURL = vi.fn(); + const { container } = render(); + + const wrappers = container.querySelectorAll('.cursor-pointer'); + fireEvent.click(wrappers[0]); + + expect(onOpenURL).toHaveBeenCalledWith('https://drive.internxt.com'); + }); + + it('opens virtual drive folder when folder icon wrapper is clicked', () => { + const { container } = render(); + + const wrappers = container.querySelectorAll('.cursor-pointer'); + fireEvent.click(wrappers[1]); + + expect(window.electron.openVirtualDriveFolder).toHaveBeenCalledOnce(); + }); + + it('shows issues count when numberOfIssues is greater than 0', () => { + render(); + + expect(screen.getByText('3')).toBeInTheDocument(); + }); + + it('does not show issues count when numberOfIssues is 0', () => { + render(); + + expect(screen.queryByText('0')).not.toBeInTheDocument(); + }); + + it('disables sync menu item when syncing', () => { + (useSyncContext as Mock).mockReturnValue({ syncStatus: 'RUNNING' }); + + render(); + + const syncButton = screen.getByText('widget.header.dropdown.sync').closest('button'); + expect(syncButton).toBeDisabled(); + }); + + it('enables sync menu item when not syncing', () => { + render(); + + const syncButton = screen.getByText('widget.header.dropdown.sync').closest('button'); + expect(syncButton).not.toBeDisabled(); + }); + + it('calls onQuitClick when quit is clicked', () => { + const onQuitClick = vi.fn(); + + render(); + + const quitButton = screen.getByText('widget.header.dropdown.quit').closest('button')!; + fireEvent.click(quitButton); + + expect(onQuitClick).toHaveBeenCalledOnce(); + }); + + it('opens settings window when preferences is clicked', () => { + render(); + + const prefsButton = screen.getByText('widget.header.dropdown.preferences').closest('button')!; + fireEvent.click(prefsButton); + + expect(window.electron.openSettingsWindow).toHaveBeenCalledOnce(); + }); + + it('opens process issues window when issues is clicked', () => { + render(); + + const issuesButton = screen.getByText('widget.header.dropdown.issues').closest('button')!; + fireEvent.click(issuesButton); + + expect(window.electron.openProcessIssuesWindow).toHaveBeenCalledOnce(); + }); + + it('calls onOpenURL with support URL when support is clicked', () => { + const onOpenURL = vi.fn(); + + render(); + + const supportButton = screen.getByText('widget.header.dropdown.support').closest('button')!; + fireEvent.click(supportButton); + + expect(onOpenURL).toHaveBeenCalledWith('https://help.internxt.com'); + }); + + it('calls logout when logout is clicked', () => { + render(); + + const logoutButton = screen.getByText('widget.header.dropdown.logout').closest('button')!; + fireEvent.click(logoutButton); + + expect(window.electron.logout).toHaveBeenCalledOnce(); + }); +}); + diff --git a/src/apps/renderer/pages/Widget/ItemsSection.tsx b/src/apps/renderer/pages/Widget/ItemsSection.tsx new file mode 100644 index 0000000000..c28fc4244a --- /dev/null +++ b/src/apps/renderer/pages/Widget/ItemsSection.tsx @@ -0,0 +1,149 @@ +import { FolderSimple, Gear, Globe } from '@phosphor-icons/react'; +import { Menu, Transition } from '@headlessui/react'; + +import { useTranslationContext } from '../../context/LocalContext'; +import { useSyncContext } from '../../context/SyncContext'; +import { DropdownItem } from './DropdownItem'; +import { HeaderItemWrapper } from './HeaderItemWrapper'; + +type Props = { + numberOfIssues: number; + numberOfIssuesDisplay: number | string; + onQuitClick: () => void; + onOpenURL: (url: string) => void; +}; + +export function ItemsSection({ numberOfIssues, numberOfIssuesDisplay, onQuitClick, onOpenURL }: Props) { + const { translate } = useTranslationContext(); + const { syncStatus } = useSyncContext(); + const isSyncing = syncStatus === 'RUNNING'; + + const handleManualSync = () => { + if (isSyncing) return; + window.electron.startRemoteSync().catch(reportError); + }; + + return ( +
+ onOpenURL('https://drive.internxt.com')}> + + + window.electron.openVirtualDriveFolder()} + data-automation-id="openVirtualDriveFolder"> + + + + + {({ open }) => ( + <> + + + + + + + + + + {({ active }) => ( +
+ window.electron.openSettingsWindow()}> + {translate('widget.header.dropdown.preferences')} + +
+ )} +
+ + {({ active }) => ( +
+ + {translate('widget.header.dropdown.sync')} + +
+ )} +
+ + {({ active }) => ( +
+ +
+

{translate('widget.header.dropdown.issues')}

+ {numberOfIssues > 0 && ( +

{numberOfIssuesDisplay}

+ )} +
+
+
+ )} +
+ + {({ active }) => ( +
+ onOpenURL('https://help.internxt.com')} + data-automation-id="menuItemSupport"> + {translate('widget.header.dropdown.support')} + +
+ )} +
+ {true && ( + + {({ active }) => ( +
+ window.electron.openSettingsWindow('CLEANER')} + data-automation-id="menuItemCleaner"> +
+ {translate('widget.header.dropdown.cleaner')} +
+ {translate('widget.header.dropdown.new')} +
+
+
+
+ )} +
+ )} + + {({ active }) => ( +
+ + {translate('widget.header.dropdown.logout')} + +
+ )} +
+ + {({ active }) => ( +
+ + {translate('widget.header.dropdown.quit')} + +
+ )} +
+
+
+ + )} +
+
+ ); +} diff --git a/src/apps/renderer/pages/Widget/SyncErrorBanner.tsx b/src/apps/renderer/pages/Widget/SyncErrorBanner.tsx index ca1b4d8517..4f1a6d78ee 100644 --- a/src/apps/renderer/pages/Widget/SyncErrorBanner.tsx +++ b/src/apps/renderer/pages/Widget/SyncErrorBanner.tsx @@ -1,9 +1,8 @@ -import { useEffect } from 'react'; import { FatalError } from '../../../../shared/issues/FatalError'; import Error from '../../assets/error.svg'; import Warn from '../../assets/warn.svg'; import { useTranslationContext } from '../../context/LocalContext'; -import { useSyncContext } from '../../context/SyncContext'; +import { useOnSyncRunning } from '../../hooks/useOnSyncRunning'; import useSyncStopped from '../../hooks/useSyncStopped'; import SyncFatalErrorMessages from '../../messages/fatal-error'; @@ -39,13 +38,8 @@ const fatalErrorActionMap: Record void } export default function SyncErrorBanner() { const [stopReason, setStopReason] = useSyncStopped(); const { translate } = useTranslationContext(); - const { syncStatus } = useSyncContext(); - useEffect(() => { - if (syncStatus === 'RUNNING') { - setStopReason(null); - } - }, [syncStatus]); + useOnSyncRunning(() => setStopReason(null)); const severity = 'FATAL' as ErrorSeverity; const show = stopReason !== null && stopReason?.reason !== 'STOPPED_BY_USER'; From a58b01b497cafd52e6dc06e5accbdabfa1b6fa68 Mon Sep 17 00:00:00 2001 From: Esteban Galvis Date: Mon, 23 Mar 2026 15:26:32 -0500 Subject: [PATCH 7/8] refactor: improve code formatting in AccountSection and ItemsSection tests --- src/apps/renderer/pages/Widget/AccountSection.test.tsx | 4 +++- src/apps/renderer/pages/Widget/ItemsSection.test.tsx | 5 +---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/apps/renderer/pages/Widget/AccountSection.test.tsx b/src/apps/renderer/pages/Widget/AccountSection.test.tsx index bee281eda5..c7c4aaefa1 100644 --- a/src/apps/renderer/pages/Widget/AccountSection.test.tsx +++ b/src/apps/renderer/pages/Widget/AccountSection.test.tsx @@ -55,7 +55,9 @@ describe('AccountSection', () => { render(); - const usageParagraph = document.querySelector('[data-automation-id="headerAccountSection"]')?.querySelector('p:last-child'); + const usageParagraph = document + .querySelector('[data-automation-id="headerAccountSection"]') + ?.querySelector('p:last-child'); expect(usageParagraph?.textContent).toBe(''); }); diff --git a/src/apps/renderer/pages/Widget/ItemsSection.test.tsx b/src/apps/renderer/pages/Widget/ItemsSection.test.tsx index 9dad346add..12491310c3 100644 --- a/src/apps/renderer/pages/Widget/ItemsSection.test.tsx +++ b/src/apps/renderer/pages/Widget/ItemsSection.test.tsx @@ -17,9 +17,7 @@ vi.mock('@headlessui/react', () => ({ { Button: ({ children, className }: any) => , Items: ({ children, className }: any) =>
{children}
, - Item: ({ children }: any) => ( -
{typeof children === 'function' ? children({ active: false }) : children}
- ), + Item: ({ children }: any) =>
{typeof children === 'function' ? children({ active: false }) : children}
, }, ), Transition: ({ children }: any) => <>{children}, @@ -136,4 +134,3 @@ describe('ItemsSection', () => { expect(window.electron.logout).toHaveBeenCalledOnce(); }); }); - From 2b52e28d5a68cec01c265d42261a1dfd8e40f413 Mon Sep 17 00:00:00 2001 From: Esteban Galvis Date: Mon, 23 Mar 2026 15:45:12 -0500 Subject: [PATCH 8/8] refactor: update linting rules and improve type definitions in components --- package.json | 2 +- src/apps/renderer/App.tsx | 1 - .../renderer/pages/Widget/DropdownItem.tsx | 17 ++++----- .../pages/Widget/HeaderItemWrapper.tsx | 31 ++++++++------- .../pages/Widget/ItemsSection.test.tsx | 28 ++++++++++---- .../renderer/pages/Widget/ItemsSection.tsx | 38 +++++++++---------- 6 files changed, 61 insertions(+), 56 deletions(-) diff --git a/package.json b/package.json index 083fa9a279..f04fb71b7b 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "build:renderer": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.renderer.prod.ts", "rebuild": "electron-rebuild --parallel --types prod,dev,optional --module-dir .", "reinstall:nautilus-extension": "NODE_ENV=development ts-node src/apps/nautilus-extension/reload.ts", - "lint": "cross-env NODE_ENV=development eslint . --ext .ts,.tsx --max-warnings=220", + "lint": "cross-env NODE_ENV=development eslint . --ext .ts,.tsx --max-warnings=210", "lint:fix": "npm run lint --fix", "format": "prettier src --check", "format:fix": "prettier src --write", diff --git a/src/apps/renderer/App.tsx b/src/apps/renderer/App.tsx index f3b04c1913..495529abd9 100644 --- a/src/apps/renderer/App.tsx +++ b/src/apps/renderer/App.tsx @@ -4,7 +4,6 @@ import { Suspense, useEffect, useRef } from 'react'; import { HashRouter as Router, Route, Routes, useLocation, useNavigate } from 'react-router-dom'; import { TranslationProvider } from './context/LocalContext'; import { SyncProvider } from './context/SyncContext'; -import useLanguageChangedListener from './hooks/useLanguage'; import Login from './pages/Login'; import Onboarding from './pages/Onboarding'; import IssuesPage from './pages/Issues/IssuesPage'; diff --git a/src/apps/renderer/pages/Widget/DropdownItem.tsx b/src/apps/renderer/pages/Widget/DropdownItem.tsx index c5bf61d1fa..c75e81807f 100644 --- a/src/apps/renderer/pages/Widget/DropdownItem.tsx +++ b/src/apps/renderer/pages/Widget/DropdownItem.tsx @@ -1,14 +1,11 @@ -export function DropdownItem({ - children, - active, - onClick, - disabled, -}: { - children: JSX.Element; - active?: boolean; +type Props = { + readonly children: React.ReactNode; + readonly active?: boolean; onClick?: () => void; - disabled?: boolean; -}) { + readonly disabled?: boolean; +}; + +export function DropdownItem({ children, active, onClick, disabled }: Props) { return ( ); } diff --git a/src/apps/renderer/pages/Widget/ItemsSection.test.tsx b/src/apps/renderer/pages/Widget/ItemsSection.test.tsx index 12491310c3..71a246cebf 100644 --- a/src/apps/renderer/pages/Widget/ItemsSection.test.tsx +++ b/src/apps/renderer/pages/Widget/ItemsSection.test.tsx @@ -1,5 +1,5 @@ import { render, screen, fireEvent } from '@testing-library/react'; -import React from 'react'; +import { ElementType, ReactNode } from 'react'; import { type Mock } from 'vitest'; import { useTranslationContext } from '../../context/LocalContext'; import { useSyncContext } from '../../context/SyncContext'; @@ -10,30 +10,42 @@ vi.mock('../../context/SyncContext'); vi.mock('@headlessui/react', () => ({ Menu: Object.assign( - ({ children, as: Tag = 'div', className }: any) => { + ({ + children, + as: Tag = 'div', + className, + }: { + children: ReactNode | ((bag: { open: boolean }) => ReactNode); + as?: ElementType; + className?: string; + }) => { const content = typeof children === 'function' ? children({ open: false }) : children; return {content}; }, { - Button: ({ children, className }: any) => , - Items: ({ children, className }: any) =>
{children}
, - Item: ({ children }: any) =>
{typeof children === 'function' ? children({ active: false }) : children}
, + Button: ({ children, className }: { children: ReactNode; className?: string }) => ( + + ), + Items: ({ children, className }: { children: ReactNode; className?: string }) => ( +
{children}
+ ), + Item: ({ children }: { children: ReactNode | ((bag: { active: boolean }) => ReactNode) }) => ( +
{typeof children === 'function' ? children({ active: false }) : children}
+ ), }, ), - Transition: ({ children }: any) => <>{children}, + Transition: ({ children }: { children: ReactNode }) => <>{children}, })); const defaultProps = { numberOfIssues: 0, numberOfIssuesDisplay: 0, - dummyRef: { current: null } as React.RefObject, onQuitClick: vi.fn(), onOpenURL: vi.fn(), }; describe('ItemsSection', () => { beforeEach(() => { - vi.clearAllMocks(); (useTranslationContext as Mock).mockReturnValue({ translate: (key: string) => key }); (useSyncContext as Mock).mockReturnValue({ syncStatus: 'STANDBY' }); }); diff --git a/src/apps/renderer/pages/Widget/ItemsSection.tsx b/src/apps/renderer/pages/Widget/ItemsSection.tsx index c28fc4244a..2a84b21dab 100644 --- a/src/apps/renderer/pages/Widget/ItemsSection.tsx +++ b/src/apps/renderer/pages/Widget/ItemsSection.tsx @@ -7,8 +7,8 @@ import { DropdownItem } from './DropdownItem'; import { HeaderItemWrapper } from './HeaderItemWrapper'; type Props = { - numberOfIssues: number; - numberOfIssuesDisplay: number | string; + readonly numberOfIssues: number; + readonly numberOfIssuesDisplay: number | string; onQuitClick: () => void; onOpenURL: (url: string) => void; }; @@ -99,25 +99,23 @@ export function ItemsSection({ numberOfIssues, numberOfIssuesDisplay, onQuitClic
)} - {true && ( - - {({ active }) => ( -
- window.electron.openSettingsWindow('CLEANER')} - data-automation-id="menuItemCleaner"> -
- {translate('widget.header.dropdown.cleaner')} -
- {translate('widget.header.dropdown.new')} -
+ + {({ active }) => ( +
+ window.electron.openSettingsWindow('CLEANER')} + data-automation-id="menuItemCleaner"> +
+ {translate('widget.header.dropdown.cleaner')} +
+ {translate('widget.header.dropdown.new')}
- -
- )} - - )} +
+ +
+ )} + {({ active }) => (