can now sync with config files that are updated externally (not imported)

Signed-off-by: FTCHD <144691102+FTCHD@users.noreply.github.com>
This commit is contained in:
FTCHD
2025-08-13 01:30:36 +02:00
parent 6137965d22
commit d31687cc01
7 changed files with 479 additions and 65 deletions
+16 -5
View File
@@ -718,7 +718,16 @@ export async function getConfigPath({ id, validate = true }: { id: string; valid
const appLocalDataDirPath = await appLocalDataDir()
console.log('[getConfigPath] appLocalDataDirPath', appLocalDataDirPath)
let configPath = `${appLocalDataDirPath}/configs/${id}/rclone.conf`
const slashSymbol = platform() === 'windows' ? '\\' : '/'
let configPath =
appLocalDataDirPath +
slashSymbol +
'configs' +
slashSymbol +
id +
slashSymbol +
'rclone.conf'
if (id == 'default') {
const defaultPaths = await getDefaultPaths()
@@ -731,10 +740,12 @@ export async function getConfigPath({ id, validate = true }: { id: string; valid
configPath = defaultPaths.config
}
const configExists = await exists(configPath)
if (validate && !configExists) {
console.error('[getConfigPath] config file does not exist')
throw new Error('Config file does not exist')
if (validate) {
const configExists = await exists(configPath)
if (!configExists) {
console.error('[getConfigPath] config file does not exist')
throw new Error('Config file does not exist')
}
}
return configPath
+25 -3
View File
@@ -77,6 +77,7 @@ export async function initRclone(args: string[]) {
sync: undefined,
isEncrypted: false,
pass: undefined,
passCommand: undefined,
})
usePersistedStore.setState({ configFiles })
@@ -89,6 +90,29 @@ export async function initRclone(args: string[]) {
usePersistedStore.setState({ activeConfigFile })
}
let configFolderPath = activeConfigFile.sync
? activeConfigFile.sync
: (await getConfigPath({ id: activeConfigFile.id!, validate: true })).replace(
/\/rclone\.conf$/,
''
)
if (activeConfigFile.sync) {
const slashSymbol = platform() === 'windows' ? '\\' : '/'
if (!(await exists(configFolderPath + slashSymbol + 'rclone.conf'))) {
await message('The config file could not be found. Switching to the default config.', {
title: 'Invalid synced config',
kind: 'error',
okLabel: 'OK',
})
configFolderPath = (await getConfigPath({ id: 'default', validate: true })).replace(
/\/rclone\.conf$/,
''
)
usePersistedStore.setState({ activeConfigFile: configFiles[0] })
}
}
const extraParams =
activeConfigFile.id === 'default'
? undefined
@@ -97,9 +121,7 @@ export async function initRclone(args: string[]) {
...(activeConfigFile.isEncrypted
? { RCLONE_CONFIG_PASS: activeConfigFile.pass }
: {}),
RCLONE_CONFIG_DIR: (
await getConfigPath({ id: activeConfigFile.id!, validate: true })
).replace(/\/rclone\.conf$/, ''),
RCLONE_CONFIG_DIR: configFolderPath,
},
}
+20 -9
View File
@@ -8,8 +8,9 @@ import {
Textarea,
} from '@heroui/react'
import { Button } from '@heroui/react'
import { ask, open } from '@tauri-apps/plugin-dialog'
import { message, open } from '@tauri-apps/plugin-dialog'
import { mkdir, readTextFile, writeTextFile } from '@tauri-apps/plugin-fs'
import { platform } from '@tauri-apps/plugin-os'
import { UploadIcon } from 'lucide-react'
import { useCallback, useMemo, useState } from 'react'
import { getConfigPath } from '../../lib/rclone/api'
@@ -58,7 +59,11 @@ export default function ConfigCreateDrawer({
const configPath = await getConfigPath({ id: generatedId, validate: false })
await mkdir(configPath.replace('/rclone.conf', ''), { recursive: true })
const slashSymbol = platform() === 'windows' ? '\\' : '/'
await mkdir(configPath.replace(slashSymbol + 'rclone.conf', ''), {
recursive: true,
})
await writeTextFile(configPath, content)
console.log('[handleCreate] saved config to', configPath)
@@ -68,17 +73,20 @@ export default function ConfigCreateDrawer({
pass,
isEncrypted: content.includes('RCLONE_ENCRYPT_V0:'),
sync: undefined,
passCommand: undefined,
})
onClose()
} catch (error) {
console.error('[handleCreate] failed to save config', error)
await ask('Failed to save config', {
title: 'Error',
kind: 'error',
okLabel: 'OK',
cancelLabel: '',
})
await message(
error instanceof Error ? error.message : 'An unknown error occurred',
{
title: 'Failed to save config',
kind: 'error',
okLabel: 'OK',
}
)
} finally {
setIsSaving(false)
}
@@ -194,12 +202,15 @@ export default function ConfigCreateDrawer({
}, '')
}
const slashSymbol =
platform() === 'windows' ? '\\' : '/'
setConfigContent(content)
setConfig({
...config,
label:
config.label ||
selectedFile.split('/').pop() ||
selectedFile.split(slashSymbol).pop() ||
'New Config',
isEncrypted:
content.includes('RCLONE_ENCRYPT_V0:'),
+10 -1
View File
@@ -8,6 +8,7 @@ import {
Textarea,
} from '@heroui/react'
import { Button } from '@heroui/react'
import { message } from '@tauri-apps/plugin-dialog'
import { readTextFile, writeTextFile } from '@tauri-apps/plugin-fs'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { getConfigPath } from '../../lib/rclone/api'
@@ -65,7 +66,15 @@ export default function ConfigEditDrawer({
onClose()
} catch (error) {
console.error(error)
console.error('[handleUpdate] failed to save config', error)
await message(
error instanceof Error ? error.message : 'An unknown error occurred',
{
title: 'Failed to save config',
kind: 'error',
okLabel: 'OK',
}
)
} finally {
setIsSaving(false)
}
+338
View File
@@ -0,0 +1,338 @@
import {
Drawer,
DrawerBody,
DrawerContent,
DrawerFooter,
DrawerHeader,
Input,
Switch,
} from '@heroui/react'
import { Button } from '@heroui/react'
import { message, open } from '@tauri-apps/plugin-dialog'
import { exists, readTextFile } from '@tauri-apps/plugin-fs'
import { platform } from '@tauri-apps/plugin-os'
import { UploadIcon } from 'lucide-react'
import { useCallback, useState } from 'react'
import { usePersistedStore } from '../../lib/store'
import type { ConfigFile } from '../../types/config'
export default function ConfigSyncDrawer({
onClose,
isOpen,
}: {
onClose: () => void
isOpen: boolean
}) {
const [config, setConfig] = useState<Partial<ConfigFile>>({
label: 'New Config',
})
const [isPasswordCommand, setIsPasswordCommand] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const handleCreate = useCallback(
async ({
label,
pass,
isEncrypted,
passCommand,
sync,
}: {
label?: string
sync?: string
isEncrypted?: boolean
pass?: string
passCommand?: string
}) => {
try {
if (!label) {
throw new Error('Label is required')
}
if (!sync) {
throw new Error('Path is required')
}
if (isEncrypted) {
if (!pass && !passCommand) {
throw new Error('Password is required for encrypted configs')
}
}
setIsSaving(true)
const generatedId = crypto.randomUUID()
usePersistedStore.getState().addConfigFile({
id: generatedId,
label,
isEncrypted: isEncrypted || false,
pass,
passCommand,
sync,
})
onClose()
} catch (error) {
console.error('[handleCreate] failed to save config', error)
await message(
error instanceof Error ? error.message : 'An unknown error occurred',
{
title: 'Failed to save config',
kind: 'error',
okLabel: 'OK',
}
)
} finally {
setIsSaving(false)
}
},
[onClose]
)
return (
<Drawer
isOpen={isOpen}
placement={'bottom'}
size="full"
onClose={onClose}
hideCloseButton={true}
>
<DrawerContent>
{(close) => (
<>
<DrawerHeader className="flex flex-col gap-1">Sync Config</DrawerHeader>
<DrawerBody>
<form
id="config-form"
className="flex flex-col gap-5"
onSubmit={(e) => {
e.preventDefault()
handleCreate({
label: config.label,
pass: isPasswordCommand ? undefined : config.pass,
isEncrypted: config.isEncrypted,
passCommand: isPasswordCommand
? config.passCommand
: undefined,
sync: config.sync,
})
}}
>
<Switch
size="lg"
isSelected={config.isEncrypted}
onValueChange={() =>
setConfig({ ...config, isEncrypted: !config.isEncrypted })
}
color="primary"
>
Encrypted
</Switch>
<Input
label="Name"
labelPlacement="outside"
placeholder="Enter a name for your config"
type="text"
value={config.label}
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
spellCheck="false"
onValueChange={(value) => {
setConfig({ ...config, label: value })
}}
isClearable={true}
onClear={() => {
setConfig({ ...config, label: '' })
}}
size="lg"
/>
{config.isEncrypted && (
<Input
// label={
// <div className="flex items-center gap-1.5">
// <p className="text-medium">Password</p>
// <Switch
// size="sm"
// isSelected={isPasswordCommand}
// onValueChange={() =>
// setIsPasswordCommand(!isPasswordCommand)
// }
// color="primary"
// >
// Command
// </Switch>
// </div>
// }
label="Password"
labelPlacement="outside"
placeholder={
isPasswordCommand
? 'Enter the password command for your config file'
: 'Enter the password for your config file'
}
type={isPasswordCommand ? 'text' : 'password'}
value={isPasswordCommand ? config.passCommand : config.pass}
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
spellCheck="false"
onValueChange={(value) => {
setConfig({
...config,
...(isPasswordCommand
? { passCommand: value }
: { pass: value }),
})
}}
isClearable={true}
onClear={() => {
setConfig({
...config,
...(isPasswordCommand
? { passCommand: '' }
: { pass: '' }),
})
}}
size="lg"
/>
)}
<Input
label={
<div className="flex items-center gap-1.5">
<p className="text-medium">Config</p>
<Button
isIconOnly={true}
variant="light"
size="sm"
onPress={async () => {
try {
let selectedFolder = await open({
directory: true,
multiple: false,
title: 'Select the root directory of your config file',
})
if (!selectedFolder) {
return
}
if (
selectedFolder.endsWith('/') ||
selectedFolder.endsWith('\\')
) {
selectedFolder = selectedFolder.slice(
0,
-1
)
}
if (!(await exists(selectedFolder))) {
throw new Error(
'The selected path does not exist'
)
}
const slashSymbol =
platform() === 'windows' ? '\\' : '/'
const configPath =
selectedFolder +
slashSymbol +
'rclone.conf'
let content: string | null = null
try {
content = await readTextFile(configPath)
} catch {
throw new Error(
'Could not find an rclone.conf file in the selected folder'
)
}
if (!content) {
throw new Error(
'Empty rclone.conf file'
)
}
setConfig({
...config,
label:
config.label ||
configPath
.split(slashSymbol)
.pop() ||
'New Config',
sync: selectedFolder,
})
} catch (error) {
await message(
error instanceof Error
? error.message
: 'An unknown error occurred',
{
title: 'Failed to sync config',
kind: 'error',
okLabel: 'OK',
}
)
}
}}
>
<UploadIcon className="w-4 h-4" />
</Button>
</div>
}
labelPlacement="outside"
placeholder="Select the folder containing your rclone.conf file"
value={config.sync || ''}
onValueChange={(value) => {
console.log(value)
setConfig({ ...config, sync: value })
}}
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
spellCheck="false"
type="textarea"
size="lg"
isClearable={true}
onClear={() => {
setConfig({ ...config, sync: '' })
}}
data-focus-visible="false"
/>
</form>
</DrawerBody>
<DrawerFooter>
<Button
color="danger"
variant="light"
onPress={close}
data-focus-visible="false"
>
Close
</Button>
<Button
color="primary"
type="submit"
form="config-form"
isDisabled={isSaving}
data-focus-visible="false"
>
{isSaving ? 'Saving...' : 'Sync'}
</Button>
</DrawerFooter>
</>
)}
</DrawerContent>
</Drawer>
)
}
+69 -47
View File
@@ -1,4 +1,17 @@
import { Button, Card, CardBody, Checkbox, Chip, Input, Tab, Tabs } from '@heroui/react'
import {
Button,
Card,
CardBody,
Checkbox,
Chip,
Dropdown,
DropdownItem,
DropdownMenu,
DropdownTrigger,
Input,
Tab,
Tabs,
} from '@heroui/react'
import { getVersion as getUiVersion } from '@tauri-apps/api/app'
import { ask, message } from '@tauri-apps/plugin-dialog'
import { remove } from '@tauri-apps/plugin-fs'
@@ -10,6 +23,7 @@ import {
CodeIcon,
CogIcon,
EyeIcon,
ImportIcon,
MedalIcon,
PencilIcon,
PlusIcon,
@@ -23,6 +37,7 @@ import { usePersistedStore, useStore } from '../../lib/store'
import { triggerTrayRebuild } from '../../lib/tray'
import ConfigCreateDrawer from '../components/ConfigCreateDrawer'
import ConfigEditDrawer from '../components/ConfigEditDrawer'
import ConfigSyncDrawer from '../components/ConfigSyncDrawer'
import RemoteCreateDrawer from '../components/RemoteCreateDrawer'
import RemoteDefaultsDrawer from '../components/RemoteDefaultsDrawer'
import RemoteEditDrawer from '../components/RemoteEditDrawer'
@@ -870,6 +885,7 @@ function ConfigSection() {
const activeConfigFile = usePersistedStore((state) => state.activeConfigFile)
const [isCreateDrawerOpen, setIsCreateDrawerOpen] = useState(false)
const [isSyncDrawerOpen, setIsSyncDrawerOpen] = useState(false)
const [isEditDrawerOpen, setIsEditDrawerOpen] = useState(false)
const [focusedConfigId, setFocusedConfigId] = useState<string | null>(null)
@@ -878,44 +894,40 @@ function ConfigSection() {
<BaseHeader
title="Config"
endContent={
<Button
onPress={async () => {
// setFocusedConfig({
// id: undefined,
// label: filePath.split('/').pop() || 'New Config',
// isEncrypted: configText.includes('RCLONE_ENCRYPT_V0:'),
// pass: undefined,
// sync: undefined,
// })
setIsCreateDrawerOpen(true)
// await ask(
// 'Config loaded successfully, you can now remove the file.',
// {
// title: 'Success',
// kind: 'info',
// okLabel: 'OK',
// cancelLabel: '',
// }
// )
// } catch (error) {
// console.error(error)
// await ask('Failed to load config file', {
// title: 'Error',
// kind: 'error',
// okLabel: 'OK',
// cancelLabel: '',
// })
// }
}}
isIconOnly={true}
variant="faded"
color="primary"
data-focus-visible="false"
size="sm"
>
<PlusIcon className="w-4 h-4" />
</Button>
<Dropdown>
<DropdownTrigger>
<Button variant="faded" color="primary" data-focus-visible="false">
Add Config
</Button>
</DropdownTrigger>
<DropdownMenu
onAction={(key) => {
setTimeout(() => {
if (key === 'import') {
setIsCreateDrawerOpen(true)
} else {
setIsSyncDrawerOpen(true)
}
}, 100)
}}
variant="faded"
>
<DropdownItem
key="import"
description="Edit using the CLI or UI"
startContent={<PlusIcon />}
>
Import Config
</DropdownItem>
<DropdownItem
key="sync"
description="Update using Git or similar"
startContent={<ImportIcon />}
>
Sync Config
</DropdownItem>
</DropdownMenu>
</Dropdown>
}
/>
<div className="flex flex-col gap-2 p-4">
@@ -964,7 +976,8 @@ function ConfigSection() {
}}
isDisabled={
configFile.id === 'default' ||
configFile.id === activeConfigFile?.id
configFile.id === activeConfigFile?.id ||
Boolean(configFile.sync)
}
>
<PencilIcon className="w-4 h-4" />
@@ -999,14 +1012,16 @@ function ConfigSection() {
return
}
const path = await getConfigPath({
id: configFile.id!,
validate: true,
})
if (!configFile.sync) {
const path = await getConfigPath({
id: configFile.id!,
validate: true,
})
await remove(path.replace('rclone.conf', ''), {
recursive: true,
})
await remove(path.replace('rclone.conf', ''), {
recursive: true,
})
}
if (activeConfigFile?.id === configFile.id) {
usePersistedStore
@@ -1043,6 +1058,13 @@ function ConfigSection() {
}}
id={focusedConfigId}
/>
<ConfigSyncDrawer
isOpen={isSyncDrawerOpen}
onClose={() => {
setIsSyncDrawerOpen(false)
}}
/>
</div>
)
}
+1
View File
@@ -4,4 +4,5 @@ export interface ConfigFile {
sync: string | undefined
isEncrypted: boolean
pass: string | undefined
passCommand: string | undefined
}