manage multiple configs, spawn updates

This commit is contained in:
FTCHD
2025-08-05 05:19:40 +03:00
parent db89d3a103
commit 6ac7a2bf15
8 changed files with 977 additions and 36 deletions
+275
View File
@@ -0,0 +1,275 @@
import {
Drawer,
DrawerBody,
DrawerContent,
DrawerFooter,
DrawerHeader,
Input,
Textarea,
} from '@heroui/react'
import { Button } from '@heroui/react'
import { ask, open } from '@tauri-apps/plugin-dialog'
import { mkdir, readTextFile, writeTextFile } from '@tauri-apps/plugin-fs'
import { UploadIcon } from 'lucide-react'
import { useCallback, useMemo, useState } from 'react'
import { getConfigPath } from '../../lib/rclone/api'
import { usePersistedStore } from '../../lib/store'
import type { ConfigFile } from '../../types/config'
export default function ConfigCreateDrawer({
onClose,
isOpen,
}: {
onClose: () => void
isOpen: boolean
}) {
const [config, setConfig] = useState<Partial<ConfigFile>>({
label: 'New Config',
})
const [configContent, setConfigContent] = useState<string | null>(null)
const [isSaving, setIsSaving] = useState(false)
const isEncrypted = useMemo(() => {
return configContent?.includes('RCLONE_ENCRYPT_V0:')
}, [configContent])
const handleCreate = useCallback(
async ({
label,
pass,
content,
}: { label?: string; pass?: string; content: string | null }) => {
try {
if (!label) {
throw new Error('Label is required')
}
if (!content) {
throw new Error('Content is required')
}
if (!pass && content.includes('RCLONE_ENCRYPT_V0:')) {
throw new Error('Password is required for encrypted configs')
}
setIsSaving(true)
const generatedId = crypto.randomUUID()
const configPath = await getConfigPath({ id: generatedId, validate: false })
await mkdir(configPath.replace('/rclone.conf', ''), { recursive: true })
await writeTextFile(configPath, content)
console.log('[handleCreate] saved config to', configPath)
usePersistedStore.getState().addConfigFile({
id: generatedId,
label,
pass,
isEncrypted: content.includes('RCLONE_ENCRYPT_V0:'),
sync: 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: '',
})
} 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">Import Config</DrawerHeader>
<DrawerBody>
<form
id="config-form"
className="flex flex-col gap-4"
onSubmit={(e) => {
e.preventDefault()
handleCreate({
label: config.label,
pass: config.pass,
content: configContent,
})
}}
>
<Input
label="Name"
labelPlacement="outside"
placeholder="Enter a name for your config"
type="text"
value={config.label}
autoComplete="off"
autoCapitalize="off"
autoCorrect="off"
onValueChange={(value) => {
setConfig({ ...config, label: value })
}}
isClearable={true}
onClear={() => {
setConfig({ ...config, label: '' })
}}
size="lg"
/>
{isEncrypted && (
<Input
label="Password"
labelPlacement="outside"
placeholder="Enter the password for your config file"
type="password"
value={config.pass}
autoComplete="off"
autoCapitalize="off"
autoCorrect="off"
onValueChange={(value) => {
setConfig({ ...config, pass: value })
}}
isClearable={true}
onClear={() => {
setConfig({ ...config, pass: '' })
}}
size="lg"
/>
)}
<Textarea
className="w-full"
label={
<div className="flex items-center gap-1.5">
<p className="text-medium">Config</p>
<Button
isIconOnly={true}
variant="light"
size="sm"
onPress={async () => {
const selectedFile = await open({
directory: false,
multiple: false,
title: 'Select a config file',
})
if (!selectedFile) {
return
}
let content = await readTextFile(selectedFile)
if (selectedFile.endsWith('.json')) {
const importedRemotes = JSON.parse(
content
) as Record<string, object>
content = Object.entries(importedRemotes)
.map(([name, remote]) => {
const remoteName = `[${name}]`
const remoteConfig = Object.entries(
remote
)
.map(([key, value]) => {
return `${key} = ${value}`
})
.join('\n')
return `${remoteName}\n${remoteConfig}`
})
.reduce((acc, curr) => {
return `${curr}\n\n${acc}`
}, '')
}
setConfigContent(content)
setConfig({
...config,
label:
config.label ||
selectedFile.split('/').pop() ||
'New Config',
isEncrypted:
content.includes('RCLONE_ENCRYPT_V0:'),
})
}}
>
<UploadIcon className="w-4 h-4" />
</Button>
</div>
}
labelPlacement="outside"
placeholder="Paste your config here or import an existing file"
value={configContent || ''}
onValueChange={(value) => {
console.log(value)
setConfigContent(value)
}}
onKeyDown={(e) => {
//if it's tab key, add 2 spaces at the current text cursor position
if (e.key === 'Tab') {
e.preventDefault()
const text = e.currentTarget.value
const cursorPosition = e.currentTarget.selectionStart
const newText =
text.slice(0, cursorPosition) +
' ' +
text.slice(cursorPosition)
e.currentTarget.value = newText
e.currentTarget.selectionStart = cursorPosition + 2
e.currentTarget.selectionEnd = cursorPosition + 2
}
}}
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
spellCheck="false"
minRows={14}
rows={14}
maxRows={14}
disableAutosize={true}
size="lg"
onClear={() => {
setConfigContent(null)
}}
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...' : 'Import'}
</Button>
</DrawerFooter>
</>
)}
</DrawerContent>
</Drawer>
)
}
+243
View File
@@ -0,0 +1,243 @@
import {
Drawer,
DrawerBody,
DrawerContent,
DrawerFooter,
DrawerHeader,
Input,
Textarea,
} from '@heroui/react'
import { Button } from '@heroui/react'
import { readTextFile, writeTextFile } from '@tauri-apps/plugin-fs'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { getConfigPath } from '../../lib/rclone/api'
import { usePersistedStore } from '../../lib/store'
export default function ConfigEditDrawer({
id,
onClose,
isOpen,
}: {
id?: string | null
onClose: () => void
isOpen: boolean
}) {
const configFiles = usePersistedStore((state) => state.configFiles)
const initialConfig = useMemo(() => configFiles.find((c) => c.id === id), [configFiles, id])
const [configLabel, setConfigLabel] = useState<string | null>(null)
const [configPass, setConfigPass] = useState<string | null>(null)
const [configContent, setConfigContent] = useState<string | null>(null)
const [isSaving, setIsSaving] = useState(false)
const isEncrypted = useMemo(() => {
return configContent?.includes('RCLONE_ENCRYPT_V0:')
}, [configContent])
const handleUpdate = useCallback(
async ({ label, pass, content }: { label?: string; pass?: string; content?: string }) => {
if (!id) return
try {
if (!label) {
throw new Error('Label is required')
}
if (!content) {
throw new Error('Content is required')
}
if (content.includes('RCLONE_ENCRYPT_V0:') && !pass) {
throw new Error('Password is required for encrypted configs')
}
setIsSaving(true)
const configPath = await getConfigPath({ id: id, validate: true })
await writeTextFile(configPath, content)
usePersistedStore.getState().updateConfigFile(id, {
label,
pass: pass || undefined,
isEncrypted: content.includes('RCLONE_ENCRYPT_V0:'),
})
onClose()
} catch (error) {
console.error(error)
} finally {
setIsSaving(false)
}
},
[id, onClose]
)
const initializeConfig = useCallback(async () => {
if (!initialConfig) {
return
}
const configPath = await getConfigPath({ id: initialConfig.id!, validate: true })
const text = await readTextFile(configPath)
setConfigContent(text)
setConfigLabel(initialConfig.label)
setConfigPass(initialConfig.pass || null)
}, [initialConfig])
useEffect(() => {
if (isOpen && configContent === null && configLabel === null) {
initializeConfig()
}
if (!isOpen) {
setConfigContent(null)
setConfigLabel(null)
setConfigPass(null)
}
}, [isOpen, initializeConfig, configLabel, configContent])
if (!id) {
return null
}
return (
<Drawer
isOpen={isOpen}
placement={'bottom'}
size="full"
onClose={onClose}
hideCloseButton={true}
>
<DrawerContent>
{(close) => (
<>
<DrawerHeader className="flex flex-col gap-1">
Edit {configLabel}
</DrawerHeader>
<DrawerBody>
<form
id="config-form"
className="flex flex-col gap-4"
onSubmit={(e) => {
e.preventDefault()
handleUpdate({
label: configLabel || undefined,
pass: configPass || undefined,
content: configContent || undefined,
})
}}
>
<Input
name="label"
label="Name"
labelPlacement="outside"
placeholder="Enter a name for your config"
type="text"
value={configLabel || ''}
autoComplete="off"
autoCapitalize="off"
autoCorrect="off"
onValueChange={(value) => {
setConfigLabel(value)
}}
isClearable={true}
onClear={() => {
setConfigLabel(null)
}}
size="lg"
/>
{isEncrypted && (
<Input
name="label"
label="Password"
labelPlacement="outside"
placeholder="Enter the password for your config file"
type="password"
value={configPass || ''}
autoComplete="off"
autoCapitalize="off"
autoCorrect="off"
onValueChange={(value) => {
setConfigPass(value)
}}
isClearable={true}
onClear={() => {
setConfigPass(null)
}}
size="lg"
/>
)}
<Textarea
className="w-full"
name="content"
label={
<div className="flex items-center gap-1.5">
<p className="text-medium">Config</p>
</div>
}
labelPlacement="outside"
placeholder="Update your config here"
value={configContent || ''}
onValueChange={(value) => {
console.log(value)
setConfigContent(value)
}}
onKeyDown={(e) => {
//if it's tab key, add 2 spaces at the current text cursor position
if (e.key === 'Tab') {
e.preventDefault()
const text = e.currentTarget.value
const cursorPosition = e.currentTarget.selectionStart
const newText =
text.slice(0, cursorPosition) +
' ' +
text.slice(cursorPosition)
e.currentTarget.value = newText
e.currentTarget.selectionStart = cursorPosition + 2
e.currentTarget.selectionEnd = cursorPosition + 2
}
}}
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
spellCheck="false"
minRows={14}
rows={14}
maxRows={14}
disableAutosize={true}
size="lg"
onClear={() => {
setConfigContent(null)
}}
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...' : 'Save Changes'}
</Button>
</DrawerFooter>
</>
)}
</DrawerContent>
</Drawer>
)
}
+201 -1
View File
@@ -1,22 +1,27 @@
import { Button, Card, CardBody, Checkbox, Chip, Input, Tab, Tabs } from '@heroui/react'
import { ask, message } from '@tauri-apps/plugin-dialog'
import { remove } from '@tauri-apps/plugin-fs'
import { openUrl } from '@tauri-apps/plugin-opener'
import { relaunch } from '@tauri-apps/plugin-process'
import { type Update, check } from '@tauri-apps/plugin-updater'
import {
CheckIcon,
CodeIcon,
CogIcon,
EyeIcon,
MedalIcon,
PencilIcon,
PlusIcon,
ServerIcon,
Trash2Icon,
} from 'lucide-react'
import { useCallback, useEffect, useState } from 'react'
import { revokeLicense, validateLicense } from '../../lib/license'
import { deleteRemote } from '../../lib/rclone/api'
import { deleteRemote, getConfigPath } from '../../lib/rclone/api'
import { usePersistedStore, useStore } from '../../lib/store'
import { triggerTrayRebuild } from '../../lib/tray'
import ConfigCreateDrawer from '../components/ConfigCreateDrawer'
import ConfigEditDrawer from '../components/ConfigEditDrawer'
import RemoteCreateDrawer from '../components/RemoteCreateDrawer'
import RemoteDefaultsDrawer from '../components/RemoteDefaultsDrawer'
import RemoteEditDrawer from '../components/RemoteEditDrawer'
@@ -131,6 +136,19 @@ function Settings() {
>
<LicenseSection />
</Tab>
<Tab
key="config"
title={
<div className="flex items-center space-x-2">
<CodeIcon className="w-5 h-5" />
<span>Config</span>
</div>
}
data-focus-visible="false"
className="w-full max-h-screen p-0 overflow-scroll overscroll-none"
>
<ConfigSection />
</Tab>
{/* <Tab
key="hosts"
title={
@@ -798,6 +816,188 @@ function RemotesSection() {
)
}
function ConfigSection() {
const configFiles = usePersistedStore((state) => state.configFiles)
const activeConfigFile = usePersistedStore((state) => state.activeConfigFile)
const [isCreateDrawerOpen, setIsCreateDrawerOpen] = useState(false)
const [isEditDrawerOpen, setIsEditDrawerOpen] = useState(false)
const [focusedConfigId, setFocusedConfigId] = useState<string | null>(null)
return (
<div className="flex flex-col gap-4">
<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>
}
/>
<div className="flex flex-col gap-2 p-4">
{configFiles.map((configFile) => (
<Button
key={configFile.id}
className="flex flex-row justify-start w-full gap-2"
color={configFile.id === activeConfigFile?.id ? 'primary' : 'default'}
onPress={() => {
if (configFile.id === activeConfigFile?.id) {
return
}
setTimeout(async () => {
const confirmed = await ask(
'This will cancel any active jobs or transfers and restart the app',
{
title: `Switch to config ${configFile.label}?`,
kind: 'info',
okLabel: 'OK',
cancelLabel: 'Cancel',
}
)
if (!confirmed) {
return
}
usePersistedStore.getState().setActiveConfigFile(configFile.id!)
await new Promise((resolve) => setTimeout(resolve, 500))
await relaunch()
}, 100)
}}
>
<p>{configFile.label}</p>
<div className="flex-1" />
<div className="flex flex-row gap-2">
<Button
isIconOnly={true}
variant="light"
size="sm"
onPress={() => {
setFocusedConfigId(configFile.id!)
setIsEditDrawerOpen(true)
}}
isDisabled={
configFile.id === 'default' ||
configFile.id === activeConfigFile?.id
}
>
<PencilIcon className="w-4 h-4" />
</Button>
<Button
isIconOnly={true}
variant="light"
size="sm"
onPress={() => {
setTimeout(async () => {
const confirmed = await ask(
`Are you sure you want to delete config ${configFile.label}?`,
{
title: 'Delete Config',
kind: 'warning',
okLabel: 'Delete',
cancelLabel: 'Cancel',
}
)
if (!confirmed) {
return
}
if (configFile.id === 'default') {
await ask('Default config cannot be deleted', {
title: 'Error',
kind: 'warning',
okLabel: 'OK',
cancelLabel: '',
})
return
}
const path = await getConfigPath({
id: configFile.id!,
validate: true,
})
await remove(path.replace('rclone.conf', ''), {
recursive: true,
})
if (activeConfigFile?.id === configFile.id) {
usePersistedStore
.getState()
.setActiveConfigFile('default')
}
usePersistedStore
.getState()
.removeConfigFile(configFile.id!)
}, 100)
}}
isDisabled={configFile.id === 'default'}
>
<Trash2Icon className="w-4 h-4" />
</Button>
</div>
</Button>
))}
</div>
<ConfigCreateDrawer
isOpen={isCreateDrawerOpen}
onClose={() => {
setIsCreateDrawerOpen(false)
}}
/>
<ConfigEditDrawer
isOpen={isEditDrawerOpen}
onClose={() => {
setIsEditDrawerOpen(false)
setFocusedConfigId(null)
}}
id={focusedConfigId}
/>
</div>
)
}
function BaseHeader({ title, endContent }: { title: string; endContent?: React.ReactNode }) {
return (
<div className="sticky top-0 z-50 flex items-center justify-between p-4 h-14 bg-neutral-900/50 backdrop-blur-lg">