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
+48
View File
@@ -1,3 +1,5 @@
import { appLocalDataDir } from '@tauri-apps/api/path'
import { exists } from '@tauri-apps/plugin-fs'
import { fetch } from '@tauri-apps/plugin-http'
import { platform } from '@tauri-apps/plugin-os'
import { useStore } from '../store'
@@ -561,3 +563,49 @@ export async function getMountFlags() {
return filteredFlags
}
export async function getConfigPath({ id, validate = true }: { id: string; validate?: boolean }) {
console.log('[getConfigPath]', id, validate)
const appLocalDataDirPath = await appLocalDataDir()
console.log('[getConfigPath] appLocalDataDirPath', appLocalDataDirPath)
let configPath = `${appLocalDataDirPath}/configs/${id}/rclone.conf`
if (id == 'default') {
const defaultPaths = await getDefaultPaths()
if (typeof defaultPaths?.config === 'undefined') {
console.error('[getConfigPath] failed to fetch config path')
throw new Error('Failed to fetch config path')
}
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')
}
return configPath
}
export async function getDefaultPaths() {
console.log('[getDefaultPaths]')
const r = await fetch('http://localhost:5572/config/paths', {
method: 'POST',
})
if (!r.ok) {
throw new Error('Failed to make request to config/paths')
}
const defaultPaths = (await r.json()) as { cache: string; config: string; temp: string }
console.log('[getDefaultPaths] json', JSON.stringify(defaultPaths, null, 2))
return defaultPaths
}
+155 -16
View File
@@ -1,36 +1,174 @@
import { invoke } from '@tauri-apps/api/core'
import { BaseDirectory, appLocalDataDir } from '@tauri-apps/api/path'
import { tempDir } from '@tauri-apps/api/path'
import { message } from '@tauri-apps/plugin-dialog'
import { copyFile, exists, mkdir, remove } from '@tauri-apps/plugin-fs'
import { ask, message } from '@tauri-apps/plugin-dialog'
import { copyFile, exists, mkdir, readTextFile, remove } from '@tauri-apps/plugin-fs'
import { writeFile } from '@tauri-apps/plugin-fs'
import { fetch } from '@tauri-apps/plugin-http'
import { platform } from '@tauri-apps/plugin-os'
import { exit } from '@tauri-apps/plugin-process'
import { Command } from '@tauri-apps/plugin-shell'
import { usePersistedStore } from '../store'
import { getConfigPath, getDefaultPaths } from './api'
export async function initRclone(args: string[]) {
console.log('[initRclone]')
export async function initRclone() {
const system = await isSystemRcloneInstalled()
let internal = await isInternalRcloneInstalled()
// rclone not available, let's download it
if (!system && !internal) {
await provisionRclone()
const success = await provisionRclone()
if (!success) {
await new Promise((resolve) => setTimeout(resolve, 1000))
await exit(0)
return
}
internal = true
}
return {
system: system
? async (args: string[]) => {
console.log('running system rclone')
return Command.create('rclone-system', args)
const state = usePersistedStore.getState()
let configFiles = state.configFiles
let activeConfigFile = state.activeConfigFile
const defaultPath = await getDefaultPath(system ? 'system' : 'internal')
if (configFiles.length === 0) {
if (system) {
let isEncrypted = false
// Detect if the config is encrypted
try {
const configContent = await readTextFile(defaultPath)
isEncrypted = configContent.includes('RCLONE_ENCRYPT_V0:')
} catch (error) {
console.log('[initRclone] could not read config file, asking user:', error)
isEncrypted = await ask(
'Is your configuration encrypted? Press "No" if you\'re unsure or using the default config file.',
{
title: 'Config file found',
kind: 'info',
okLabel: 'Yes',
cancelLabel: 'No',
}
)
}
if (isEncrypted) {
await ask(
'Encrypted config files cannot be imported during the initial setup. Use a blank conf file and import the encrypted configuration later in Settings.',
{
title: 'Not supported yet',
kind: 'error',
okLabel: 'OK',
cancelLabel: '',
}
)
await exit(0)
return
}
}
}
configFiles = configFiles.filter((config) => config.id !== 'default')
configFiles.unshift({
id: 'default',
label: 'Default config',
sync: undefined,
isEncrypted: false,
pass: undefined,
})
usePersistedStore.setState({ configFiles })
if (!activeConfigFile) {
activeConfigFile = configFiles[0]
if (!activeConfigFile) {
throw new Error('Failed to get active config file')
}
usePersistedStore.setState({ activeConfigFile })
}
const extraParams =
activeConfigFile.id === 'default'
? undefined
: {
env: {
...(activeConfigFile.isEncrypted
? { RCLONE_CONFIG_PASS: activeConfigFile.pass }
: {}),
RCLONE_CONFIG_DIR: (
await getConfigPath({ id: activeConfigFile.id!, validate: true })
).replace(/\/rclone\.conf$/, ''),
},
}
: null,
internal: internal
? async (args: string[]) => {
console.log('running internal rclone')
return Command.create('rclone-internal', args)
}
: null,
if (system) {
console.log('[initRclone] running system rclone')
const instance = Command.create('rclone-system', args, extraParams)
return { system: instance }
}
if (internal) {
console.log('[initRclone] running internal rclone')
const instance = Command.create('rclone-internal', args, extraParams)
return { internal: instance }
}
throw new Error('Failed to initialize rclone, please try again later.')
}
async function getDefaultPath(type: 'system' | 'internal') {
console.log('[getDefaultPath]', type)
let instance = null
if (type === 'system') {
console.log('[getDefaultPath] running system rclone')
instance = Command.create('rclone-system', [
'rcd',
'--rc-no-auth',
'--rc-serve',
// '-rc-addr',
// ':5572',
])
}
if (type === 'internal') {
console.log('[getDefaultPath] running internal rclone')
instance = Command.create('rclone-internal', [
'rcd',
'--rc-no-auth',
'--rc-serve',
// '-rc-addr',
// ':5572',
])
}
if (!instance) {
console.error('[getDefaultPath] failed to create rclone instance')
throw new Error('Failed to create rclone instance, please try again later.')
}
const output = await instance.spawn()
console.log('[getDefaultPath] spawned rclone')
await new Promise((resolve) => setTimeout(resolve, 200))
try {
const defaultPaths = await getDefaultPaths()
if (typeof defaultPaths?.config === 'undefined') {
throw new Error('Failed to fetch config path')
}
return defaultPaths.config
} catch (error) {
console.error('getDefaultPath error', error)
if (error instanceof Error) {
throw error
}
throw new Error('Failed to get default path, please try again later.')
} finally {
await output.kill()
}
}
@@ -223,4 +361,5 @@ export async function provisionRclone() {
console.log('[provisionRclone] rclone has been installed')
return true
}
+29
View File
@@ -2,6 +2,7 @@ import { LazyStore } from '@tauri-apps/plugin-store'
import { shared } from 'use-broadcast-ts'
import { create } from 'zustand'
import { type StateStorage, createJSONStorage, persist } from 'zustand/middleware'
import type { ConfigFile } from '../types/config'
// const { LazyStore } = window.__TAURI__.store
const store = new LazyStore('store.json')
@@ -59,6 +60,13 @@ interface PersistedState {
isFirstOpen: boolean
setIsFirstOpen: (isFirstOpen: boolean) => void
configFiles: ConfigFile[]
addConfigFile: (configFile: ConfigFile) => void
removeConfigFile: (id: string) => void
activeConfigFile: ConfigFile | null
setActiveConfigFile: (configFile: string) => void
updateConfigFile: (id: string, configFile: Partial<ConfigFile>) => void
}
const getStorage = (store: LazyStore): StateStorage => ({
@@ -137,6 +145,27 @@ export const usePersistedStore = create<PersistedState>()(
isFirstOpen: true,
setIsFirstOpen: (isFirstOpen: boolean) => set((_) => ({ isFirstOpen })),
configFiles: [],
addConfigFile: (configFile: ConfigFile) =>
set((state) => ({
configFiles: [...state.configFiles, configFile],
})),
removeConfigFile: (id: string) =>
set((state) => ({
configFiles: state.configFiles.filter((f) => f.id !== id),
})),
activeConfigFile: null,
setActiveConfigFile: (id: string) =>
set((state) => ({
activeConfigFile: state.configFiles.find((f) => f.id === id) || null,
})),
updateConfigFile: (id: string, configFile: Partial<ConfigFile>) =>
set((state) => ({
configFiles: state.configFiles.map((f) =>
f.id === id ? { ...f, ...configFile } : f
),
})),
}),
{
name: 'store',
+19 -19
View File
@@ -96,10 +96,24 @@ async function startRclone() {
return
} catch {}
let rclone
let rclone: Awaited<ReturnType<typeof initRclone>> | null = null
try {
rclone = await initRclone()
const sessionPassword = Math.random().toString(36).substring(2, 15)
useStore.setState({ rcloneAuth: sessionPassword })
useStore.setState({ rcloneAuthHeader: 'Basic ' + btoa(`admin:${sessionPassword}`) })
rclone = await initRclone([
'rcd',
// ...(platform() === 'macos'
// ? ['--rc-no-auth'] // webkit doesn't allow for credentials in the url
// : ['--rc-user', 'admin', '--rc-pass', sessionPassword]),
'--rc-no-auth',
'--rc-serve',
// defaults
// '-rc-addr',
// ':5572',
])
} catch (error) {
await ask(error.message || 'Failed to start rclone, please try again later.', {
title: 'Error',
@@ -110,23 +124,9 @@ async function startRclone() {
return await exit(0)
}
const sessionPassword = Math.random().toString(36).substring(2, 15)
useStore.setState({ rcloneAuth: sessionPassword })
useStore.setState({ rcloneAuthHeader: 'Basic ' + btoa(`admin:${sessionPassword}`) })
const rcloneCommandFn = rclone?.system || rclone?.internal
const rcloneCommandFn = rclone.system || rclone.internal
const command = (await rcloneCommandFn([
'rcd',
// ...(platform() === 'macos'
// ? ['--rc-no-auth'] // webkit doesn't allow for credentials in the url
// : ['--rc-user', 'admin', '--rc-pass', sessionPassword]),
'--rc-no-auth',
'--rc-serve',
// defaults
// '-rc-addr',
// ':5572',
])) as Command<string>
const command = rcloneCommandFn!
// command.stdout.on('data', (line) => {
// console.log('stdout ' + line)
@@ -158,7 +158,7 @@ async function startRclone() {
getCurrentWindow().listen('close-app', async (e) => {
console.log('[startRclone] (main) window close-app requested')
if (rclone.system) {
if (rclone?.system) {
const answer = await ask('Unmount all remotes before exiting?', {
title: 'Exit',
kind: 'info',
+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">
+7
View File
@@ -0,0 +1,7 @@
export interface ConfigFile {
id: string | undefined
label: string
sync: string | undefined
isEncrypted: boolean
pass: string | undefined
}