diff --git a/src/apps/main/backups/add-backup.test.ts b/src/apps/main/backups/add-backup.test.ts index 2dbab8d95..a9300526a 100644 --- a/src/apps/main/backups/add-backup.test.ts +++ b/src/apps/main/backups/add-backup.test.ts @@ -1,4 +1,4 @@ -import * as getPathFromDialogModule from '../device/service'; +import * as getPathFromDialogModule from '../../../backend/features/backup/get-path-from-dialog'; import * as createBackupModule from './create-backup'; import * as DeviceModuleModule from './../../../backend/features/device/device.module'; import * as enableExistingBackupModule from './enable-existing-backup'; diff --git a/src/apps/main/backups/add-backup.ts b/src/apps/main/backups/add-backup.ts index bd58531ee..ac7f87cd7 100644 --- a/src/apps/main/backups/add-backup.ts +++ b/src/apps/main/backups/add-backup.ts @@ -1,9 +1,9 @@ -import { getPathFromDialog } from '../device/service'; import configStore from '../config'; import { createBackup } from './create-backup'; import { DeviceModule } from '../../../backend/features/device/device.module'; import { logger } from '@internxt/drive-desktop-core/build/backend'; import { enableExistingBackup } from './enable-existing-backup'; +import { getPathFromDialog } from '../../../backend/features/backup/get-path-from-dialog'; export async function addBackup() { const { error, data } = await DeviceModule.getOrCreateDevice(); diff --git a/src/apps/main/device/handlers.ts b/src/apps/main/device/handlers.ts index 7602c8584..de087976d 100644 --- a/src/apps/main/device/handlers.ts +++ b/src/apps/main/device/handlers.ts @@ -7,10 +7,10 @@ import { disableBackup, downloadBackup, getDevices, - getPathFromDialog, } from './service'; import { DeviceModule } from '../../../backend/features/device/device.module'; import { addBackup } from '../backups/add-backup'; +import { getPathFromDialog } from '../../../backend/features/backup/get-path-from-dialog'; ipcMain.handle('devices.get-all', () => getDevices()); diff --git a/src/apps/main/device/service.ts b/src/apps/main/device/service.ts index 55cb8d2e5..6e7e53a9c 100644 --- a/src/apps/main/device/service.ts +++ b/src/apps/main/device/service.ts @@ -21,6 +21,7 @@ import { getBackupFolderUuid } from '../../../infra/drive-server/services/backup import { updateBackupFolderName } from '../../../infra/drive-server/services/backup/services/update-backup-folder-metadata'; import { migrateBackupEntryIfNeeded } from './migrate-backup-entry-if-needed'; import { createBackup } from '../backups/create-backup'; +import { getPathFromDialog } from '../../../backend/features/backup/get-path-from-dialog'; export type Device = { id: number; @@ -314,30 +315,6 @@ export type PathInfo = { isDirectory?: boolean; }; -export async function getPathFromDialog(): Promise<{ - path: string; - itemName: string; -} | null> { - const result = await dialog.showOpenDialog({ - properties: ['openDirectory'], - }); - - if (result.canceled) { - return null; - } - - const chosenPath = result.filePaths[0]; - - const itemPath = chosenPath + (chosenPath[chosenPath.length - 1] === path.sep ? '' : path.sep); - - const itemName = path.basename(itemPath); - - return { - path: itemPath, - itemName, - }; -} - export async function getMultiplePathsFromDialog(allowFiles = false): Promise { const result = await dialog.showOpenDialog({ properties: ['multiSelections' as const, ...(allowFiles ? (['openFile'] as const) : ['openDirectory' as const])], diff --git a/src/apps/main/preload.d.ts b/src/apps/main/preload.d.ts index b60cf0efa..0f8b3ce56 100644 --- a/src/apps/main/preload.d.ts +++ b/src/apps/main/preload.d.ts @@ -145,7 +145,7 @@ declare interface Window { changeBackupPath: typeof import('../main/device/service').changeBackupPath; - getFolderPath: typeof import('../main/device/service').getPathFromDialog; + getFolderPath: typeof import('../../backend/features/backup/get-path-from-dialog').getPathFromDialog; onRemoteChanges(func: (value: import('../main/realtime').EventPayload) => void): () => void; diff --git a/src/backend/features/backup/get-path-from-dialog.test.ts b/src/backend/features/backup/get-path-from-dialog.test.ts new file mode 100644 index 000000000..47855c542 --- /dev/null +++ b/src/backend/features/backup/get-path-from-dialog.test.ts @@ -0,0 +1,107 @@ +import { BrowserWindow, dialog } from 'electron'; +import { call, partialSpyOn } from 'tests/vitest/utils.helper'; +import { getPathFromDialog } from './get-path-from-dialog'; +import path from 'node:path'; +import { mockDeep } from 'vitest-mock-extended'; +describe('getPathFromDialog', () => { + const mockWindow = mockDeep(); + const mockedDialog = partialSpyOn(dialog, 'showOpenDialog'); + const mockedGetFocusedWindow = partialSpyOn(BrowserWindow, 'getFocusedWindow'); + const mockedGetAllWindows = partialSpyOn(BrowserWindow, 'getAllWindows'); + + beforeEach(() => { + mockedGetFocusedWindow.mockReturnValue(mockWindow as unknown as BrowserWindow); + mockedGetAllWindows.mockReturnValue([mockWindow] as unknown as BrowserWindow[]); + mockWindow.isVisible.mockReturnValue(true); + mockWindow.isDestroyed.mockReturnValue(false); + }); + + it('should hide the focused window before opening the dialog', async () => { + mockedDialog.mockResolvedValue({ canceled: true, filePaths: [] }); + + await getPathFromDialog(); + + expect(mockWindow.hide).toHaveBeenCalled(); + }); + + it('should show the window after the dialog closes', async () => { + mockedDialog.mockResolvedValue({ canceled: true, filePaths: [] }); + + await getPathFromDialog(); + + expect(mockWindow.show).toBeCalled(); + }); + + it('should not show the window if it was destroyed', async () => { + mockWindow.isDestroyed.mockReturnValue(true); + mockedDialog.mockResolvedValue({ canceled: true, filePaths: [] }); + + await getPathFromDialog(); + + expect(mockWindow.show).not.toBeCalled(); + }); + + it('should use a visible window when no focused window exists', async () => { + mockedGetFocusedWindow.mockReturnValue(null); + + mockedDialog.mockResolvedValue({ canceled: true, filePaths: [] }); + + await getPathFromDialog(); + + expect(mockWindow.hide).toBeCalled(); + }); + + it('should return null when the dialog is canceled', async () => { + mockedDialog.mockResolvedValue({ canceled: true, filePaths: [] }); + + const result = await getPathFromDialog(); + + expect(result).toBe(null); + }); + + it('should return the path with a trailing separator and the item name', async () => { + mockedDialog.mockResolvedValue({ canceled: false, filePaths: ['/home/user/Documents'] }); + + const result = await getPathFromDialog(); + + expect(result).toStrictEqual({ + path: `/home/user/Documents${path.sep}`, + itemName: 'Documents', + }); + }); + + it('should not duplicate the separator if the path already ends with one', async () => { + mockedDialog.mockResolvedValue({ canceled: false, filePaths: [`/home/user/Documents${path.sep}`] }); + + const result = await getPathFromDialog(); + + expect(result).toStrictEqual({ + path: `/home/user/Documents${path.sep}`, + itemName: 'Documents', + }); + }); + + it('should open the dialog with openDirectory property', async () => { + mockedDialog.mockResolvedValue({ canceled: true, filePaths: [] }); + + await getPathFromDialog(); + + call(mockedDialog).toMatchObject({ properties: ['openDirectory'] }); + }); + + it('should skip hide and show and still return the selected path when no BackupFolderSelector window exists', async () => { + mockedGetFocusedWindow.mockReturnValue(null); + mockedGetAllWindows.mockReturnValue([]); + + mockedDialog.mockResolvedValue({ canceled: false, filePaths: ['/home/user/folder'] }); + + const result = await getPathFromDialog(); + + expect(mockWindow.hide).not.toHaveBeenCalled(); + expect(mockWindow.show).not.toHaveBeenCalled(); + expect(result).toStrictEqual({ + path: `/home/user/folder${path.sep}`, + itemName: 'folder', + }); + }); +}); diff --git a/src/backend/features/backup/get-path-from-dialog.ts b/src/backend/features/backup/get-path-from-dialog.ts new file mode 100644 index 000000000..bfa2cdfd3 --- /dev/null +++ b/src/backend/features/backup/get-path-from-dialog.ts @@ -0,0 +1,34 @@ +import { BrowserWindow, dialog } from 'electron'; +import { PathInfo } from '../../../apps/main/device/service'; +import path from 'node:path'; + +export async function getPathFromDialog(): Promise | null> { + const parentWindow = BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows().find((w) => w.isVisible()); + + if (parentWindow) { + parentWindow.hide(); + } + + const result = await dialog.showOpenDialog({ + properties: ['openDirectory'], + }); + + if (parentWindow && !parentWindow.isDestroyed()) { + parentWindow.show(); + } + + if (result.canceled) { + return null; + } + + const chosenPath = result.filePaths[0]; + + const itemPath = `${chosenPath}${chosenPath.endsWith(path.sep) ? '' : path.sep}`; + + const itemName = path.basename(itemPath); + + return { + path: itemPath, + itemName, + }; +} diff --git a/vitest.setup.main.ts b/vitest.setup.main.ts index a41d9b463..4b56cae09 100644 --- a/vitest.setup.main.ts +++ b/vitest.setup.main.ts @@ -10,7 +10,14 @@ vi.mock('electron', () => ({ }, ipcMain: { on: vi.fn(), - handle: vi.fn() + handle: vi.fn(), + }, + dialog: { + showOpenDialog: vi.fn(), + }, + BrowserWindow: { + getFocusedWindow: vi.fn(), + getAllWindows: vi.fn(), }, }));