diff --git a/packages/desktop-client/src/browser-preload.browser.js b/packages/desktop-client/src/browser-preload.browser.js index 2522fce9065..a65e4b09a24 100644 --- a/packages/desktop-client/src/browser-preload.browser.js +++ b/packages/desktop-client/src/browser-preload.browser.js @@ -151,6 +151,8 @@ global.Actual = { setTheme: theme => { window.__actionsForMenu.saveGlobalPrefs({ theme }); }, + + moveBudgetDirectory: () => {}, }; function inputFocused(e) { diff --git a/packages/desktop-client/src/components/Modals.tsx b/packages/desktop-client/src/components/Modals.tsx index 19ca95755e5..59af5cd24a2 100644 --- a/packages/desktop-client/src/components/Modals.tsx +++ b/packages/desktop-client/src/components/Modals.tsx @@ -41,7 +41,9 @@ import { HoldBufferModal } from './modals/HoldBufferModal'; import { ImportTransactionsModal } from './modals/ImportTransactionsModal'; import { KeyboardShortcutModal } from './modals/KeyboardShortcutModal'; import { LoadBackupModal } from './modals/LoadBackupModal'; +import { ConfirmChangeDocumentDirModal } from './modals/manager/ConfirmChangeDocumentDir'; import { DeleteFileModal } from './modals/manager/DeleteFileModal'; +import { FilesSettingsModal } from './modals/manager/FilesSettingsModal'; import { ImportActualModal } from './modals/manager/ImportActualModal'; import { ImportModal } from './modals/manager/ImportModal'; import { ImportYNAB4Modal } from './modals/manager/ImportYNAB4Modal'; @@ -573,6 +575,16 @@ export function Modals() { return ; case 'import': return ; + case 'files-settings': + return ; + case 'confirm-change-document-dir': + return ( + + ); case 'import-ynab4': return ; case 'import-ynab5': diff --git a/packages/desktop-client/src/components/alerts.tsx b/packages/desktop-client/src/components/alerts.tsx index 831dc52a68f..7cb177b8f78 100644 --- a/packages/desktop-client/src/components/alerts.tsx +++ b/packages/desktop-client/src/components/alerts.tsx @@ -66,9 +66,9 @@ export const Information = ({ style, children }: ScopedAlertProps) => { color={theme.pageTextLight} backgroundColor="transparent" style={{ - ...style, boxShadow: 'none', padding: 5, + ...style, }} > {children} diff --git a/packages/desktop-client/src/components/manager/BudgetList.tsx b/packages/desktop-client/src/components/manager/BudgetList.tsx index 37f3f839384..1cd0f5e3c96 100644 --- a/packages/desktop-client/src/components/manager/BudgetList.tsx +++ b/packages/desktop-client/src/components/manager/BudgetList.tsx @@ -12,7 +12,10 @@ import { loadBudget, pushModal, } from 'loot-core/client/actions'; -import { isNonProductionEnvironment } from 'loot-core/src/shared/environment'; +import { + isElectron, + isNonProductionEnvironment, +} from 'loot-core/src/shared/environment'; import { type File, type LocalFile, @@ -26,6 +29,7 @@ import { AnimatedLoading } from '../../icons/AnimatedLoading'; import { SvgCloudCheck, SvgCloudDownload, + SvgCog, SvgDotsHorizontalTriple, SvgFileDouble, } from '../../icons/v1'; @@ -324,12 +328,33 @@ function RefreshButton({ ); } +function SettingsButton({ onOpenSettings }: { onOpenSettings: () => void }) { + const { t } = useTranslation(); + + return ( + + + + ); +} + function BudgetListHeader({ quickSwitchMode, onRefresh, + onOpenSettings, }: { quickSwitchMode: boolean; onRefresh: () => void; + onOpenSettings: () => void; }) { return ( Files - {!quickSwitchMode && } + {!quickSwitchMode && ( + + + {isElectron() && } + + )} ); } @@ -425,6 +460,7 @@ export function BudgetList({ showHeader = true, quickSwitchMode = false }) { dispatch(pushModal('files-settings'))} /> )} + + {directory} + + + ); +} + +export function ConfirmChangeDocumentDirModal({ + currentBudgetDirectory, + newDirectory, +}: { + currentBudgetDirectory: string; + newDirectory: string; +}) { + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + const [moveFiles, setMoveFiles] = useState(false); + const { t } = useTranslation(); + const dispatch = useDispatch(); + + const restartElectronServer = useCallback(() => { + globalThis.window.Actual?.restartElectronServer(); + }, []); + + const [_documentDir, setDocumentDirPref] = useGlobalPref( + 'documentDir', + restartElectronServer, + ); + + const moveDirectory = async (close: () => void) => { + setError(''); + setLoading(true); + try { + if (moveFiles) { + await globalThis.window.Actual?.moveBudgetDirectory( + currentBudgetDirectory, + newDirectory, + ); + } + + setDocumentDirPref(newDirectory); + + dispatch( + addNotification({ + type: 'message', + message: t('Actual’s data directory successfully changed.'), + }), + ); + close(); + } catch (error) { + console.error('There was an error changing your directory', error); + setError( + t( + 'There was an error changing your directory, please check the directory and try again.', + ), + ); + } finally { + setLoading(false); + } + }; + + return ( + + {({ state: { close } }) => ( + <> + } + /> + + + + + You are about to change Actual’s data directory from: + + + + + To: + + + + {moveFiles && ( + + + Files in the destination folder with the same name will be + overwritten. + + + )} + + {!moveFiles && ( + + + Your files won’t be moved. You can manually move them to the + folder. + + + )} + + {error && {error}} + + + + moveDirectory(close)} + > + Change Directory + + + + + )} + + ); +} diff --git a/packages/desktop-client/src/components/modals/manager/FilesSettingsModal.tsx b/packages/desktop-client/src/components/modals/manager/FilesSettingsModal.tsx new file mode 100644 index 00000000000..55d1e7bee68 --- /dev/null +++ b/packages/desktop-client/src/components/modals/manager/FilesSettingsModal.tsx @@ -0,0 +1,190 @@ +import React, { useState } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; +import { useDispatch } from 'react-redux'; + +import { loadAllFiles, pushModal } from 'loot-core/client/actions'; + +import { useGlobalPref } from '../../../hooks/useGlobalPref'; +import { SvgPencil1 } from '../../../icons/v2'; +import { theme, styles } from '../../../style'; +import { Button } from '../../common/Button2'; +import { Modal, ModalCloseButton, ModalHeader } from '../../common/Modal'; +import { Text } from '../../common/Text'; +import { View } from '../../common/View'; + +function FileLocationSettings() { + const [documentDir, _setDocumentDirPref] = useGlobalPref('documentDir'); + const [_documentDirChanged, setDirChanged] = useState(false); + const { t } = useTranslation(); + const dispatch = useDispatch(); + + async function onChooseDocumentDir() { + const chosenDirectory = await window.Actual?.openFileDialog({ + properties: ['openDirectory'], + }); + + if (chosenDirectory && documentDir && chosenDirectory[0] !== documentDir) { + setDirChanged(true); + + dispatch( + pushModal('confirm-change-document-dir', { + currentBudgetDirectory: documentDir, + newDirectory: chosenDirectory[0], + }), + ); + } + } + + return ( + + + + Actual’s data directory{' '} + + where your files are stored + + + + + + {documentDir} + + + + + ); +} + +function SelfSignedCertLocationSettings() { + const [serverSelfSignedCertPref, _setServerSelfSignedCertPref] = + useGlobalPref('serverSelfSignedCert'); + + if (!serverSelfSignedCertPref) { + return null; + } + + return ( + + + + Server self-signed certificate + {' '} + + + enables a secure connection + + + + + + {serverSelfSignedCertPref} + + + + ); +} + +export function FilesSettingsModal() { + const dispatch = useDispatch(); + + function closeModal(close: () => void) { + dispatch(loadAllFiles()); + close(); + } + + return ( + + {({ state: { close } }) => ( + <> + closeModal(close)} /> + } + /> + + + + + + + )} + + ); +} diff --git a/packages/desktop-client/src/components/settings/Backups.tsx b/packages/desktop-client/src/components/settings/Backups.tsx new file mode 100644 index 00000000000..e31d2293cd5 --- /dev/null +++ b/packages/desktop-client/src/components/settings/Backups.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { Trans } from 'react-i18next'; + +import { Text } from '../common/Text'; + +import { Setting } from './UI'; + +export function Backups() { + const BACKUP_FREQUENCY_MINS = 15; + const MAX_BACKUPS = 10; + + return ( + + + + Backups + +

+ + Backups are taken every {{ BACKUP_FREQUENCY_MINS }} minutes and + stored in{' '} + + Actual’s data directory + + . Actual retains a maximum of {{ MAX_BACKUPS }} backups at any time. + +

+
+
+ ); +} diff --git a/packages/desktop-client/src/components/settings/Global.tsx b/packages/desktop-client/src/components/settings/Global.tsx deleted file mode 100644 index 86cedba10ec..00000000000 --- a/packages/desktop-client/src/components/settings/Global.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import React, { useState, useEffect, useRef } from 'react'; - -import { useGlobalPref } from '../../hooks/useGlobalPref'; -import { theme } from '../../style'; -import { Information } from '../alerts'; -import { Button } from '../common/Button2'; -import { Text } from '../common/Text'; -import { View } from '../common/View'; - -import { Setting } from './UI'; - -export function GlobalSettings() { - const [documentDir, setDocumentDirPref] = useGlobalPref('documentDir'); - - const [documentDirChanged, setDirChanged] = useState(false); - const dirScrolled = useRef(null); - - useEffect(() => { - if (dirScrolled.current) { - dirScrolled.current.scrollTo(10000, 0); - } - }, []); - - async function onChooseDocumentDir() { - const res = await window.Actual?.openFileDialog({ - properties: ['openDirectory'], - }); - if (res) { - setDocumentDirPref(res[0]); - setDirChanged(true); - } - } - - return ( - - - - {documentDir} - - - - - {documentDirChanged && ( - - Remember to copy your budget(s) into the new - folder.
A restart is required for this change to take - effect. -
- )} - - } - > - - Actual’s files are stored in a folder on your computer. - Currently, that’s: - -
- ); -} diff --git a/packages/desktop-client/src/components/settings/index.tsx b/packages/desktop-client/src/components/settings/index.tsx index e2439ab98a5..04380ee782a 100644 --- a/packages/desktop-client/src/components/settings/index.tsx +++ b/packages/desktop-client/src/components/settings/index.tsx @@ -2,8 +2,8 @@ import React, { type ReactNode, useEffect } from 'react'; import { media } from 'glamor'; +import { isElectron } from 'loot-core/shared/environment'; import { listen } from 'loot-core/src/platform/client/fetch'; -import { isElectron } from 'loot-core/src/shared/environment'; import { useActions } from '../../hooks/useActions'; import { useFeatureFlag } from '../../hooks/useFeatureFlag'; @@ -23,13 +23,13 @@ import { MOBILE_NAV_HEIGHT } from '../mobile/MobileNavTabs'; import { Page } from '../Page'; import { useServerVersion } from '../ServerContext'; +import { Backups } from './Backups'; import { BudgetTypeSettings } from './BudgetTypeSettings'; import { EncryptionSettings } from './Encryption'; import { ExperimentalFeatures } from './Experimental'; import { ExportBudget } from './Export'; import { FixSplits } from './FixSplits'; import { FormatSettings } from './Format'; -import { GlobalSettings } from './Global'; import { ResetCache, ResetSync } from './Reset'; import { ThemeSettings } from './Themes'; import { AdvancedToggle, Setting } from './UI'; @@ -171,11 +171,11 @@ export function Settings() { )} - {isElectron() && } {useFeatureFlag('reportBudget') && } + {isElectron() && } diff --git a/packages/desktop-electron/index.ts b/packages/desktop-electron/index.ts index ac3618ed314..92649a96c1f 100644 --- a/packages/desktop-electron/index.ts +++ b/packages/desktop-electron/index.ts @@ -16,6 +16,7 @@ import { OpenDialogSyncOptions, SaveDialogOptions, } from 'electron'; +import { copy, exists, remove } from 'fs-extra'; import promiseRetry from 'promise-retry'; import { getMenu } from './menu'; @@ -403,3 +404,32 @@ ipcMain.on('set-theme', (_event, theme: string) => { ); } }); + +ipcMain.handle( + 'move-budget-directory', + async (_event, currentBudgetDirectory: string, newDirectory: string) => { + try { + if (!currentBudgetDirectory || !newDirectory) { + throw new Error('The from and to directories must be provided'); + } + + if (newDirectory.startsWith(currentBudgetDirectory)) { + throw new Error( + 'The destination must not be a subdirectory of the current directory', + ); + } + + if (!(await exists(newDirectory))) { + throw new Error('The destination directory does not exist'); + } + + await copy(currentBudgetDirectory, newDirectory, { + overwrite: true, + }); + await remove(currentBudgetDirectory); + } catch (error) { + console.error('There was an error moving your directory', error); + throw error; + } + }, +); diff --git a/packages/desktop-electron/package.json b/packages/desktop-electron/package.json index 1c504ac9e66..cfee1fa3c86 100644 --- a/packages/desktop-electron/package.json +++ b/packages/desktop-electron/package.json @@ -87,6 +87,7 @@ "dependencies": { "better-sqlite3": "^9.6.0", "electron-log": "4.4.8", + "fs-extra": "^11.2.0", "node-fetch": "^2.7.0", "promise-retry": "^2.0.1" }, @@ -94,6 +95,7 @@ "@electron/notarize": "2.4.0", "@electron/rebuild": "3.6.0", "@types/copyfiles": "^2", + "@types/fs-extra": "^11", "copyfiles": "^2.4.1", "cross-env": "^7.0.3", "electron": "30.0.6", diff --git a/packages/desktop-electron/preload.ts b/packages/desktop-electron/preload.ts index 7ede4442910..c6f8327ebf5 100644 --- a/packages/desktop-electron/preload.ts +++ b/packages/desktop-electron/preload.ts @@ -73,4 +73,15 @@ contextBridge.exposeInMainWorld('Actual', { setTheme: (theme: string) => { ipcRenderer.send('set-theme', theme); }, + + moveBudgetDirectory: ( + currentBudgetDirectory: string, + newDirectory: string, + ) => { + return ipcRenderer.invoke( + 'move-budget-directory', + currentBudgetDirectory, + newDirectory, + ); + }, }); diff --git a/packages/loot-core/src/client/state-types/modals.d.ts b/packages/loot-core/src/client/state-types/modals.d.ts index c1c5405ffde..c7ccf14f232 100644 --- a/packages/loot-core/src/client/state-types/modals.d.ts +++ b/packages/loot-core/src/client/state-types/modals.d.ts @@ -86,6 +86,13 @@ type FinanceModals = { 'import-actual': null; + 'files-settings': null; + + 'confirm-change-document-dir': { + currentBudgetDirectory: string; + newDirectory: string; + }; + 'create-encryption-key': { recreate?: boolean }; 'fix-encryption-key': { hasExistingKey?: boolean; diff --git a/packages/loot-core/src/server/main.ts b/packages/loot-core/src/server/main.ts index c510854948f..a2a547173af 100644 --- a/packages/loot-core/src/server/main.ts +++ b/packages/loot-core/src/server/main.ts @@ -1273,6 +1273,7 @@ handlers['load-global-prefs'] = async function () { [, encryptKey], [, theme], [, preferredDarkTheme], + [, serverSelfSignedCert], ] = await asyncStorage.multiGet([ 'floating-sidebar', 'max-months', @@ -1280,6 +1281,7 @@ handlers['load-global-prefs'] = async function () { 'encrypt-key', 'theme', 'preferred-dark-theme', + 'server-self-signed-cert', ]); return { floatingSidebar: floatingSidebar === 'true' ? true : false, @@ -1298,6 +1300,7 @@ handlers['load-global-prefs'] = async function () { preferredDarkTheme === 'dark' || preferredDarkTheme === 'midnight' ? preferredDarkTheme : 'dark', + serverSelfSignedCert: serverSelfSignedCert || undefined, }; }; diff --git a/packages/loot-core/typings/window.d.ts b/packages/loot-core/typings/window.d.ts index 9dde1c2b45b..1a56a14fb20 100644 --- a/packages/loot-core/typings/window.d.ts +++ b/packages/loot-core/typings/window.d.ts @@ -17,6 +17,10 @@ declare global { relaunch: () => void; reload: (() => Promise) | undefined; restartElectronServer: () => void; + moveBudgetDirectory: ( + currentBudgetDirectory: string, + newDirectory: string, + ) => Promise; }; __navigate?: import('react-router').NavigateFunction; diff --git a/upcoming-release-notes/3584.md b/upcoming-release-notes/3584.md new file mode 100644 index 00000000000..120b29c8154 --- /dev/null +++ b/upcoming-release-notes/3584.md @@ -0,0 +1,6 @@ +--- +category: Enhancements +authors: [MikesGlitch] +--- + +Moving file settings to the management page and enabling budget file relocation diff --git a/yarn.lock b/yarn.lock index 5da1e170baf..c73d98bd2ca 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5145,6 +5145,16 @@ __metadata: languageName: node linkType: hard +"@types/fs-extra@npm:^11": + version: 11.0.4 + resolution: "@types/fs-extra@npm:11.0.4" + dependencies: + "@types/jsonfile": "npm:*" + "@types/node": "npm:*" + checksum: 10/acc4c1eb0cde7b1f23f3fe6eb080a14832d8fa9dc1761aa444c5e2f0f6b6fa657ed46ebae32fb580a6700fc921b6165ce8ac3e3ba030c3dd15f10ad4dd4cae98 + languageName: node + linkType: hard + "@types/graceful-fs@npm:^4.1.2, @types/graceful-fs@npm:^4.1.3": version: 4.1.6 resolution: "@types/graceful-fs@npm:4.1.6" @@ -5239,6 +5249,15 @@ __metadata: languageName: node linkType: hard +"@types/jsonfile@npm:*": + version: 6.1.4 + resolution: "@types/jsonfile@npm:6.1.4" + dependencies: + "@types/node": "npm:*" + checksum: 10/309fda20eb5f1cf68f2df28931afdf189c5e7e6bec64ac783ce737bb98908d57f6f58757ad5da9be37b815645a6f914e2d4f3ac66c574b8fe1ba6616284d0e97 + languageName: node + linkType: hard + "@types/keyv@npm:^3.1.1, @types/keyv@npm:^3.1.4": version: 3.1.4 resolution: "@types/keyv@npm:3.1.4" @@ -8412,12 +8431,14 @@ __metadata: "@electron/notarize": "npm:2.4.0" "@electron/rebuild": "npm:3.6.0" "@types/copyfiles": "npm:^2" + "@types/fs-extra": "npm:^11" better-sqlite3: "npm:^9.6.0" copyfiles: "npm:^2.4.1" cross-env: "npm:^7.0.3" electron: "npm:30.0.6" electron-builder: "npm:24.13.3" electron-log: "npm:4.4.8" + fs-extra: "npm:^11.2.0" node-fetch: "npm:^2.7.0" promise-retry: "npm:^2.0.1" typescript: "npm:^5.5.4" @@ -10141,7 +10162,7 @@ __metadata: languageName: node linkType: hard -"fs-extra@npm:^11.1.0": +"fs-extra@npm:^11.1.0, fs-extra@npm:^11.2.0": version: 11.2.0 resolution: "fs-extra@npm:11.2.0" dependencies: