BISYNC (alpha)

Signed-off-by: FTCHD <144691102+FTCHD@users.noreply.github.com>
This commit is contained in:
FTCHD
2025-11-03 04:13:45 +01:00
parent 385ec98316
commit 3c614d11c6
7 changed files with 815 additions and 0 deletions
+15
View File
@@ -518,6 +518,7 @@ export async function buildMenu() {
const allowsAdditional = const allowsAdditional =
!persistedStoreState.disabledActions?.includes('tray-sync') || !persistedStoreState.disabledActions?.includes('tray-sync') ||
!persistedStoreState.disabledActions?.includes('tray-move') || !persistedStoreState.disabledActions?.includes('tray-move') ||
!persistedStoreState.disabledActions?.includes('tray-bisync') ||
!persistedStoreState.disabledActions?.includes('tray-serve') || !persistedStoreState.disabledActions?.includes('tray-serve') ||
!persistedStoreState.disabledActions?.includes('tray-purge') || !persistedStoreState.disabledActions?.includes('tray-purge') ||
!persistedStoreState.disabledActions?.includes('tray-delete') !persistedStoreState.disabledActions?.includes('tray-delete')
@@ -553,6 +554,20 @@ export async function buildMenu() {
commandsSubmenuItems.push(moveMenuItem) commandsSubmenuItems.push(moveMenuItem)
} }
if (!persistedStoreState.disabledActions?.includes('tray-bisync')) {
const bisyncMenuItem = await MenuItem.new({
id: 'bisync',
text: 'Bisync',
action: async () => {
await openWindow({
name: 'Bisync',
url: '/bisync',
})
},
})
commandsSubmenuItems.push(bisyncMenuItem)
}
const rcloneVersion = await getRcloneVersion() const rcloneVersion = await getRcloneVersion()
if ( if (
+56
View File
@@ -586,6 +586,62 @@ export async function startMove({
return r.jobid return r.jobid
} }
export async function startBisync({
path1,
path2,
_config,
_filter,
remoteOptions,
outerOptions,
}: {
path1: string
path2: string
_config?: Record<string, FlagValue>
_filter?: Record<string, FlagValue>
remoteOptions?: Record<string, FlagValue>
outerOptions?: Record<string, FlagValue>
}) {
console.log('[startBisync]', path1, path2)
const params = new URLSearchParams()
params.set('path1', path1)
params.set('path2', path2)
params.set('_async', 'true')
if (_config && Object.keys(_config).length > 0) {
params.set('_config', JSON.stringify(parseRcloneOptions(_config)))
}
if (_filter && Object.keys(_filter).length > 0) {
params.set('_filter', JSON.stringify(parseRcloneOptions(_filter)))
}
if (remoteOptions && Object.keys(remoteOptions).length > 0) {
for (const [key, value] of Object.entries(remoteOptions)) {
params.set(key, value.toString())
}
}
if (outerOptions && Object.keys(outerOptions).length > 0) {
for (const [key, value] of Object.entries(outerOptions)) {
params.set(key, value.toString())
}
}
const r = await fetch(`http://localhost:5572/sync/bisync?${params.toString()}`, {
method: 'POST',
headers: getAuthHeader(),
}).then((res) => res.json() as Promise<{ jobid: string }>)
console.log('[startBisync] operation started:', r)
if (!r.jobid) {
throw new Error('Failed to start bisync job')
}
return r.jobid
}
export async function startSync({ export async function startSync({
srcFs, srcFs,
dstFs, dstFs,
+2
View File
@@ -25,6 +25,7 @@ export interface RemoteConfig {
syncDefaults?: Record<string, any> syncDefaults?: Record<string, any>
configDefaults?: Record<string, any> configDefaults?: Record<string, any>
serveDefaults?: Record<(typeof SERVE_TYPES)[number], Record<string, any>> serveDefaults?: Record<(typeof SERVE_TYPES)[number], Record<string, any>>
bisyncDefaults?: Record<string, any>
remoteDefaults?: Record<string, any> remoteDefaults?: Record<string, any>
} }
@@ -58,6 +59,7 @@ type SupportedAction =
| 'tray-copy' | 'tray-copy'
| 'tray-serve' | 'tray-serve'
| 'tray-move' | 'tray-move'
| 'tray-bisync'
| 'tray-delete' | 'tray-delete'
| 'tray-purge' | 'tray-purge'
| 'tray-download' | 'tray-download'
+1
View File
@@ -14,6 +14,7 @@
"Jobs", "Jobs",
"Cron", "Cron",
"Serve", "Serve",
"Bisync",
"Download", "Download",
"Browse", "Browse",
"Startup", "Startup",
+5
View File
@@ -5,6 +5,7 @@ import Home from './pages/Home'
import './global.css' import './global.css'
import { HeroUIProvider } from '@heroui/react' import { HeroUIProvider } from '@heroui/react'
import { debug, error, info, trace, warn } from '@tauri-apps/plugin-log' import { debug, error, info, trace, warn } from '@tauri-apps/plugin-log'
import Bisync from './pages/Bisync'
import Copy from './pages/Copy' import Copy from './pages/Copy'
import Cron from './pages/Cron' import Cron from './pages/Cron'
import Delete from './pages/Delete' import Delete from './pages/Delete'
@@ -79,6 +80,10 @@ const router = createBrowserRouter([
path: '/serve', path: '/serve',
element: <Serve />, element: <Serve />,
}, },
{
path: '/bisync',
element: <Bisync />,
},
{ {
path: '/mount', path: '/mount',
element: <Mount />, element: <Mount />,
+728
View File
@@ -0,0 +1,728 @@
import { Accordion, AccordionItem, Avatar, Button, Switch } from '@heroui/react'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { message } from '@tauri-apps/plugin-dialog'
import { exists } from '@tauri-apps/plugin-fs'
import { fetch } from '@tauri-apps/plugin-http'
import {
AlertOctagonIcon,
DiamondPercentIcon,
FilterIcon,
FoldersIcon,
PlayIcon,
ServerIcon,
WrenchIcon,
XIcon,
} from 'lucide-react'
import { startTransition, useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { getRemoteName } from '../../lib/format'
import { isRemotePath } from '../../lib/fs'
import {
getConfigFlags,
getCopyFlags,
getCurrentGlobalFlags,
getFilterFlags,
startBisync,
} from '../../lib/rclone/api'
import { RCLONE_CONFIG_DEFAULTS } from '../../lib/rclone/constants'
import { usePersistedStore } from '../../lib/store'
import { openWindow } from '../../lib/window'
import type { FlagValue } from '../../types/rclone'
import CommandInfo from '../components/CommandInfo'
import OptionsSection from '../components/OptionsSection'
import { MultiPathFinder } from '../components/PathFinder'
import RemoteOptionsSection from '../components/RemoteOptionsSection'
export default function Bisync() {
const [searchParams] = useSearchParams()
const [sources, setSources] = useState<string[] | undefined>(
searchParams.get('initialSource') ? [searchParams.get('initialSource')!] : undefined
)
const [dest, setDest] = useState<string | undefined>(undefined)
const [isStarted, setIsStarted] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [jsonError, setJsonError] = useState<'bisync' | 'filter' | 'config' | 'remote' | null>(
null
)
const [bisyncOptionsLocked, setBisyncOptionsLocked] = useState(false)
const [bisyncOptions, setBisyncOptions] = useState<Record<string, FlagValue>>({})
const [bisyncOptionsJson, setBisyncOptionsJson] = useState<string>('{}')
const [outerBisyncOptions, setOuterBisyncOptions] = useState<Record<string, boolean>>({})
const [filterOptionsLocked, setFilterOptionsLocked] = useState(false)
const [filterOptions, setFilterOptions] = useState<Record<string, FlagValue>>({})
const [filterOptionsJson, setFilterOptionsJson] = useState<string>('{}')
const [configOptionsLocked, setConfigOptionsLocked] = useState(false)
const [configOptions, setConfigOptions] = useState<Record<string, FlagValue>>({})
const [configOptionsJson, setConfigOptionsJson] = useState<string>('{}')
const [remoteOptionsLocked, setRemoteOptionsLocked] = useState(false)
const [remoteOptions, setRemoteOptions] = useState<Record<string, FlagValue>>({})
const [remoteOptionsJson, setRemoteOptionsJson] = useState<string>('{}')
const [currentGlobalOptions, setCurrentGlobalOptions] = useState<any[]>([])
const selectedRemotes = (() => {
return [...(sources || []), dest].filter(Boolean) as string[]
})()
useEffect(() => {
getCurrentGlobalFlags().then((flags) => {
startTransition(() => {
setCurrentGlobalOptions(flags)
})
})
}, [])
useEffect(() => {
getCurrentGlobalFlags().then((flags) =>
startTransition(() => setCurrentGlobalOptions(flags))
)
}, [])
// biome-ignore lint/correctness/useExhaustiveDependencies: when unlocking, we don't want to re-run the effect
useEffect(() => {
const storeData = usePersistedStore.getState()
const sourceRemote = getRemoteName(sources?.[0])
const destRemote = getRemoteName(dest)
let mergedBisyncDefaults = {}
let mergedFilterDefaults = {}
let mergedConfigDefaults = {}
// Helper function to merge defaults from a remote
const mergeRemoteDefaults = (remote: string | null) => {
if (!remote) return
const remoteConfig = storeData.remoteConfigList?.[remote] || {}
if (remoteConfig.bisyncDefaults) {
mergedBisyncDefaults = {
...mergedBisyncDefaults,
...remoteConfig.bisyncDefaults,
}
}
if (remoteConfig.filterDefaults) {
mergedFilterDefaults = {
...mergedFilterDefaults,
...remoteConfig.filterDefaults,
}
}
if (remoteConfig.configDefaults) {
mergedConfigDefaults = {
...mergedConfigDefaults,
...remoteConfig.configDefaults,
}
} else {
mergedConfigDefaults = {
...mergedConfigDefaults,
...RCLONE_CONFIG_DEFAULTS,
}
}
}
// Only merge defaults for remote paths
if (sourceRemote) mergeRemoteDefaults(sourceRemote)
if (destRemote) mergeRemoteDefaults(destRemote)
if (Object.keys(mergedBisyncDefaults).length > 0 && !bisyncOptionsLocked) {
setBisyncOptionsJson(JSON.stringify(mergedBisyncDefaults, null, 2))
}
if (Object.keys(mergedFilterDefaults).length > 0 && !filterOptionsLocked) {
setFilterOptionsJson(JSON.stringify(mergedFilterDefaults, null, 2))
}
if (Object.keys(mergedConfigDefaults).length > 0 && !configOptionsLocked) {
setConfigOptionsJson(JSON.stringify(mergedConfigDefaults, null, 2))
}
}, [sources, dest])
useEffect(() => {
let step: 'bisync' | 'filter' | 'config' | 'remote' = 'bisync'
try {
setBisyncOptions(JSON.parse(bisyncOptionsJson))
step = 'filter'
setFilterOptions(JSON.parse(filterOptionsJson))
step = 'config'
setConfigOptions(JSON.parse(configOptionsJson))
step = 'remote'
setRemoteOptions(JSON.parse(remoteOptionsJson))
setJsonError(null)
} catch (error) {
setJsonError(step)
console.error(`Error parsing ${step} options:`, error)
}
}, [bisyncOptionsJson, filterOptionsJson, configOptionsJson, remoteOptionsJson])
async function handleStartBisync() {
setIsLoading(true)
if (!sources || sources.length === 0 || !dest) {
await message('Please select both a source and destination path', {
title: 'Error',
kind: 'error',
})
setIsLoading(false)
return
}
// check local paths exists
for (const source of sources) {
try {
if (!isRemotePath(source)) {
const sourceExists = await exists(source)
if (sourceExists) {
continue
}
await message(`Source path does not exist, ${source} is missing`, {
title: 'Error',
kind: 'error',
})
setIsLoading(false)
return
}
} catch {}
}
if (!isRemotePath(dest)) {
const destExists = await exists(dest)
if (!destExists) {
await message('Destination path does not exist', {
title: 'Error',
kind: 'error',
})
setIsLoading(false)
return
}
}
if (
sources.length > 1 &&
filterOptions &&
('IncludeRule' in filterOptions || 'IncludeFrom' in filterOptions)
) {
await message('Include rules are not supported with multiple sources', {
title: 'Error',
kind: 'error',
})
setIsLoading(false)
return
}
const mergedConfig = {
...configOptions,
...bisyncOptions,
}
const failedPaths: Record<string, string> = {}
// Group files by their parent folder to build a single IncludeRule per group
const folderSources = sources.filter((path) => path.endsWith('/'))
const fileSources = sources.filter((path) => !path.endsWith('/'))
const parentToFilesMap: Record<string, string[]> = {}
for (const fileSource of fileSources) {
const parentFolder = fileSource.split('/').slice(0, -1).join('/')
const fileName = fileSource.split('/').pop()!
if (!parentToFilesMap[parentFolder]) parentToFilesMap[parentFolder] = []
parentToFilesMap[parentFolder].push(fileName)
}
const fileGroups = Object.entries(parentToFilesMap)
// Start move for each file group (per parent folder)
for (const [parentFolder, fileNames] of fileGroups) {
const customFilterOptions = {
...filterOptions,
IncludeRule: fileNames,
}
console.log('[Bisync] customFilterOptions for group', parentFolder, customFilterOptions)
const customSource = parentFolder
console.log('[Bisync] customSource for group', parentFolder, customSource)
const destination = dest
console.log('[Bisync] destination for group', parentFolder, destination)
try {
const jobId = await startBisync({
path1: customSource,
path2: destination,
_config: mergedConfig,
_filter: customFilterOptions,
remoteOptions,
outerOptions: outerBisyncOptions,
})
await new Promise((resolve) => setTimeout(resolve, 500))
const statusRes = await fetch(`http://localhost:5572/job/status?jobid=${jobId}`, {
method: 'POST',
})
.then((res) => {
return res.json() as Promise<{
duration: number
endTime?: string
error?: string
finished: boolean
group: string
id: number
output?: Record<string, any>
startTime: string
success: boolean
}>
})
.catch(() => {
return { error: null }
})
console.log('statusRes', JSON.stringify(statusRes, null, 2))
if (statusRes.error) {
failedPaths[`[group] ${parentFolder}`] = statusRes.error
}
} catch (error) {
console.log('error', error)
console.error('Failed to start move for group:', parentFolder, error)
if (!failedPaths[`[group] ${parentFolder}`]) {
if (error instanceof Error) {
failedPaths[`[group] ${parentFolder}`] = error.message
} else {
failedPaths[`[group] ${parentFolder}`] = 'Unknown error'
}
}
}
}
// Start move for each full folder source (preserve existing behavior)
for (const source of folderSources) {
const isFolder = true
const customFilterOptions = filterOptions
console.log('[Bisync] customFilterOptions for', source, customFilterOptions)
const customSource = source
console.log('[Bisync] customSource for', source, customSource)
const destination =
isFolder && sources.length > 1
? `${dest}/${source.split('/').filter(Boolean).pop()!}`
: dest
console.log('[Bisync] destination for', source, destination)
try {
const jobId = await startBisync({
path1: customSource,
path2: destination,
_config: mergedConfig,
_filter: customFilterOptions,
remoteOptions,
outerOptions: outerBisyncOptions,
})
await new Promise((resolve) => setTimeout(resolve, 500))
const statusRes = await fetch(`http://localhost:5572/job/status?jobid=${jobId}`, {
method: 'POST',
})
.then((res) => {
return res.json() as Promise<{
duration: number
endTime?: string
error?: string
finished: boolean
group: string
id: number
output?: Record<string, any>
startTime: string
success: boolean
}>
})
.catch(() => {
return { error: null }
})
console.log('statusRes', JSON.stringify(statusRes, null, 2))
if (statusRes.error) {
failedPaths[source] = statusRes.error
}
} catch (error) {
console.log('error', error)
console.error('Failed to start move for path:', source, error)
if (!failedPaths[source]) {
if (error instanceof Error) {
failedPaths[source] = error.message
} else {
failedPaths[source] = 'Unknown error'
}
}
}
}
console.log('[Bisync] failedPaths', failedPaths)
// dummy delay to avoid waiting when opening the Jobs page
await new Promise((resolve) => setTimeout(resolve, 1000))
const failedPathsKeys = Object.keys(failedPaths)
console.log('[Bisync] failedPathsKeys', failedPathsKeys)
const expectedJobsFinal = (() => {
const folderSourcesFinal = (sources || []).filter((path) => path.endsWith('/'))
const fileSourcesFinal = (sources || []).filter((path) => !path.endsWith('/'))
const parentToFilesMapFinal: Record<string, true> = {}
for (const fileSource of fileSourcesFinal) {
const parentFolder = fileSource.split('/').slice(0, -1).join('/')
parentToFilesMapFinal[parentFolder] = true
}
return folderSourcesFinal.length + Object.keys(parentToFilesMapFinal).length
})()
if (expectedJobsFinal !== failedPathsKeys.length) {
setIsStarted(true)
}
if (failedPathsKeys.length > 0) {
if (expectedJobsFinal === failedPathsKeys.length) {
await message(`${failedPathsKeys[0]} ${failedPaths[failedPathsKeys[0]]}`, {
title: 'Failed to start move',
kind: 'error',
})
} else {
await message(
`${failedPathsKeys.map((source) => `${source} ${failedPaths[source]}`).join(',')}`,
{
title: 'Failed to start move',
kind: 'error',
}
)
}
}
setIsLoading(false)
}
const buttonText = (() => {
if (isLoading) return 'STARTING...'
if (!sources || sources.length === 0) return 'Please select a source path'
if (!dest) return 'Please select a destination path'
if (sources[0] === dest) return 'Source and destination cannot be the same'
if (jsonError) return 'Invalid JSON for ' + jsonError.toUpperCase() + ' options'
return 'START BISYNC'
})()
const buttonIcon = (() => {
if (isLoading) return
if (!sources || sources.length === 0 || !dest || sources[0] === dest)
return <FoldersIcon className="w-5 h-5" />
if (jsonError) return <AlertOctagonIcon className="w-5 h-5" />
return <PlayIcon className="w-5 h-5 fill-current" />
})()
return (
<div className="flex flex-col h-screen gap-10">
<CommandInfo
content={`Bisync provides a bidirectional cloud sync solution in rclone.
It retains the Path1 and Path2 filesystem listings from the prior run. On each successive run it will:
- List files on Path1 and Path2, and check for changes on each side (new, newer, older, and deleted files).
- Propagate changes on Path1 to Path2, and vice-versa.
Bisync is considered an advanced command, so use with care. Make sure you have read and understood the entire manual (especially the Limitations section) before using, or data loss can result.`}
/>
{/* Main Content */}
<div className="flex flex-col flex-1 w-full max-w-3xl gap-6 mx-auto">
{/* Paths Display */}
<MultiPathFinder
sourcePaths={sources}
setSourcePaths={setSources}
destPath={dest}
setDestPath={setDest}
/>
<Accordion>
<AccordionItem
key="bisync"
startContent={
<Avatar
className="bg-lime-500"
radius="lg"
fallback={
<DiamondPercentIcon className="text-success-foreground" />
}
/>
}
indicator={<DiamondPercentIcon />}
subtitle="Tap to toggle bisync options for this operation"
title="Bisync"
>
<div className="flex flex-row flex-wrap gap-2 pb-5">
<Switch
isSelected={outerBisyncOptions?.resync}
onValueChange={(value) =>
setOuterBisyncOptions({
...outerBisyncOptions,
resync: value,
})
}
size="sm"
>
resync
</Switch>
<Switch
isSelected={outerBisyncOptions?.checkAccess}
onValueChange={(value) =>
setOuterBisyncOptions({
...outerBisyncOptions,
checkAccess: value,
})
}
size="sm"
>
checkAccess
</Switch>
<Switch
isSelected={outerBisyncOptions?.force}
onValueChange={(value) =>
setOuterBisyncOptions({
...outerBisyncOptions,
force: value,
})
}
size="sm"
>
force
</Switch>
<Switch
isSelected={outerBisyncOptions?.createEmptySrcDirs}
onValueChange={(value) =>
setOuterBisyncOptions({
...outerBisyncOptions,
createEmptySrcDirs: value,
})
}
size="sm"
>
createEmptySrcDirs
</Switch>
<Switch
isSelected={outerBisyncOptions?.removeEmptyDirs}
onValueChange={(value) =>
setOuterBisyncOptions({
...outerBisyncOptions,
removeEmptyDirs: value,
})
}
size="sm"
>
removeEmptyDirs
</Switch>
<Switch
isSelected={outerBisyncOptions?.ignoreListingChecksum}
onValueChange={(value) =>
setOuterBisyncOptions({
...outerBisyncOptions,
ignoreListingChecksum: value,
})
}
size="sm"
>
ignoreListingChecksum
</Switch>
<Switch
isSelected={outerBisyncOptions?.resilient}
onValueChange={(value) =>
setOuterBisyncOptions({
...outerBisyncOptions,
resilient: value,
})
}
size="sm"
>
resilient
</Switch>
<Switch
isSelected={outerBisyncOptions?.noCleanup}
onValueChange={(value) =>
setOuterBisyncOptions({
...outerBisyncOptions,
noCleanup: value,
})
}
size="sm"
>
noCleanup
</Switch>
</div>
<OptionsSection
globalOptions={
currentGlobalOptions['main' as keyof typeof currentGlobalOptions]
}
optionsJson={bisyncOptionsJson}
setOptionsJson={setBisyncOptionsJson}
getAvailableOptions={getCopyFlags}
isLocked={bisyncOptionsLocked}
setIsLocked={setBisyncOptionsLocked}
/>
</AccordionItem>
<AccordionItem
key="filters"
startContent={
<Avatar color="danger" radius="lg" fallback={<FilterIcon />} />
}
indicator={<FilterIcon />}
subtitle="Tap to toggle filtering options for this operation"
title="Filters"
>
<OptionsSection
globalOptions={
currentGlobalOptions['filter' as keyof typeof currentGlobalOptions]
}
optionsJson={filterOptionsJson}
setOptionsJson={setFilterOptionsJson}
getAvailableOptions={getFilterFlags}
isLocked={filterOptionsLocked}
setIsLocked={setFilterOptionsLocked}
/>
</AccordionItem>
<AccordionItem
key="config"
startContent={
<Avatar color="default" radius="lg" fallback={<WrenchIcon />} />
}
indicator={<WrenchIcon />}
subtitle="Tap to toggle config options for this operation"
title="Config"
>
<OptionsSection
globalOptions={
currentGlobalOptions['main' as keyof typeof currentGlobalOptions]
}
optionsJson={configOptionsJson}
setOptionsJson={setConfigOptionsJson}
getAvailableOptions={getConfigFlags}
isLocked={configOptionsLocked}
setIsLocked={setConfigOptionsLocked}
/>
</AccordionItem>
{selectedRemotes.length > 0 ? (
<AccordionItem
key={'remotes'}
startContent={
<Avatar
className="bg-fuchsia-500"
radius="lg"
fallback={<ServerIcon />}
/>
}
indicator={<ServerIcon />}
subtitle="Tap to toggle remote options for this operation"
title={'Remotes'}
>
<RemoteOptionsSection
selectedRemotes={selectedRemotes}
remoteOptionsJson={remoteOptionsJson}
setRemoteOptionsJson={setRemoteOptionsJson}
setRemoteOptionsLocked={setRemoteOptionsLocked}
remoteOptionsLocked={remoteOptionsLocked}
/>
</AccordionItem>
) : null}
</Accordion>
</div>
<div className="sticky bottom-0 z-50 flex items-center justify-center flex-none gap-2 p-4 border-t border-neutral-500/20 bg-neutral-900/50 backdrop-blur-lg">
{isStarted ? (
<>
<Button
fullWidth={true}
color="primary"
size="lg"
onPress={() => {
setBisyncOptionsJson('{}')
setFilterOptionsJson('{}')
setSources(undefined)
setDest(undefined)
setIsStarted(false)
}}
data-focus-visible="false"
>
RESET
</Button>
<Button
fullWidth={true}
size="lg"
color="secondary"
onPress={async () => {
const createdWindow = await openWindow({
name: 'Jobs',
url: '/jobs',
})
await createdWindow.setFocus()
}}
data-focus-visible="false"
>
JOBS
</Button>
<Button
size="lg"
isIconOnly={true}
onPress={async () => {
await getCurrentWindow().hide()
await getCurrentWindow().destroy()
}}
data-focus-visible="false"
>
<XIcon />
</Button>
</>
) : (
<Button
onPress={handleStartBisync}
size="lg"
fullWidth={true}
type="button"
color="primary"
isDisabled={
isLoading ||
!!jsonError ||
!sources ||
sources.length === 0 ||
!dest ||
sources[0] === dest
}
isLoading={isLoading}
endContent={buttonIcon}
className="max-w-2xl gap-2"
data-focus-visible="false"
>
{buttonText}
</Button>
)}
</div>
</div>
)
}
+8
View File
@@ -559,6 +559,14 @@ function GeneralSection() {
> >
Show <span className="font-mono text-blue-300">Copy</span> option Show <span className="font-mono text-blue-300">Copy</span> option
</Checkbox> </Checkbox>
<Checkbox
isSelected={!disabledActions?.includes('tray-bisync')}
onValueChange={(value) =>
updateDisabledActions({ name: 'tray-bisync', value })
}
>
Show <span className="font-mono text-blue-300">Bisync</span> option
</Checkbox>
<Checkbox <Checkbox
isSelected={!disabledActions?.includes('tray-move')} isSelected={!disabledActions?.includes('tray-move')}
onValueChange={(value) => onValueChange={(value) =>