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: