Move command

Signed-off-by: FTCHD <144691102+FTCHD@users.noreply.github.com>
This commit is contained in:
FTCHD
2025-08-13 00:08:42 +02:00
parent 8aef98807f
commit bc8607eb72
8 changed files with 586 additions and 6 deletions
+14
View File
@@ -368,6 +368,20 @@ export async function buildMenu() {
menuItems.push(copyMenuItem)
}
if (!persistedStoreState.disabledActions?.includes('tray-move')) {
const moveMenuItem = await MenuItem.new({
id: 'move',
text: 'Move',
action: async () => {
await openWindow({
name: 'Move',
url: '/move',
})
},
})
menuItems.push(moveMenuItem)
}
if (!persistedStoreState.disabledActions?.includes('tray-sync')) {
const syncMenuItem = await MenuItem.new({
id: 'sync',
+54
View File
@@ -423,6 +423,60 @@ export async function startCopy({
return r.jobid
}
export async function startMove({
srcFs,
dstFs,
createEmptySrcDirs,
deleteEmptyDstDirs,
_config,
_filter,
}: {
srcFs: string
dstFs: string
createEmptySrcDirs?: boolean // create empty src directories on destination if set
deleteEmptyDstDirs?: boolean // delete empty src directories if set
_config?: Record<string, string | number | boolean | string[]>
_filter?: Record<string, string | number | boolean | string[]>
}) {
console.log('[startMove]', srcFs, dstFs, createEmptySrcDirs, deleteEmptyDstDirs)
const params = new URLSearchParams()
params.set('srcFs', srcFs)
params.set('dstFs', dstFs)
if (createEmptySrcDirs) {
params.set('createEmptySrcDirs', 'true')
}
if (deleteEmptyDstDirs) {
params.set('deleteEmptyDstDirs', 'true')
}
// params.set('b2_disable_checksum', 'true')
params.set('_async', 'true')
if (_config && Object.keys(_config).length > 0) {
params.set('_config', JSON.stringify(_config))
}
if (_filter && Object.keys(_filter).length > 0) {
params.set('_filter', JSON.stringify(_filter))
}
const r = await fetch(`http://localhost:5572/sync/move?${params.toString()}`, {
method: 'POST',
headers: getAuthHeader(),
}).then((res) => res.json() as Promise<{ jobid: string }>)
console.log('[startMove] operation started:', r)
if (!r.jobid) {
throw new Error('Failed to start move job')
}
return r.jobid
}
export async function startSync({
srcFs,
dstFs,
+22 -3
View File
@@ -19,6 +19,7 @@ export interface RemoteConfig {
vfsDefaults?: Record<string, any>
filterDefaults?: Record<string, any>
copyDefaults?: Record<string, any>
moveDefaults?: Record<string, any>
syncDefaults?: Record<string, any>
}
@@ -46,9 +47,21 @@ interface PersistedState {
setRemoteConfig: (remote: string, config: RemoteConfig) => void
mergeRemoteConfig: (remote: string, config: RemoteConfig) => void
disabledActions: ('tray-mount' | 'tray-sync' | 'tray-copy' | 'tray-serve')[]
disabledActions: (
| 'tray-mount'
| 'tray-sync'
| 'tray-copy'
| 'tray-serve'
| 'tray-move'
)[]
setDisabledActions: (
actions: ('tray-mount' | 'tray-sync' | 'tray-copy' | 'tray-serve')[]
actions: (
| 'tray-mount'
| 'tray-sync'
| 'tray-copy'
| 'tray-serve'
| 'tray-move'
)[]
) => void
settingsPass: string | undefined
@@ -143,7 +156,13 @@ export const usePersistedStore = create<PersistedState>()(
disabledActions: [],
setDisabledActions: (
actions: ('tray-mount' | 'tray-sync' | 'tray-copy' | 'tray-serve')[]
actions: (
| 'tray-mount'
| 'tray-sync'
| 'tray-copy'
| 'tray-serve'
| 'tray-move'
)[]
) => set((_) => ({ disabledActions: actions })),
settingsPass: undefined,
+26 -2
View File
@@ -5,7 +5,14 @@ import { platform } from '@tauri-apps/plugin-os'
import { exit } from '@tauri-apps/plugin-process'
import { CronExpressionParser } from 'cron-parser'
import { validateLicense } from './lib/license'
import { listRemotes, mountRemote, startCopy, startSync, unmountAllRemotes } from './lib/rclone/api'
import {
listRemotes,
mountRemote,
startCopy,
startMove,
startSync,
unmountAllRemotes,
} from './lib/rclone/api'
import { initRclone } from './lib/rclone/init'
import { usePersistedStore, useStore } from './lib/store'
import { initLoadingTray, initTray, rebuildTrayMenu } from './lib/tray'
@@ -308,7 +315,14 @@ async function handleTask(task: ScheduledTask) {
}
try {
const { srcFs, dstFs, _config, _filter } = task.args
const {
srcFs,
dstFs,
_config,
_filter,
createEmptySrcDirs,
deleteEmptyDstDirs,
} = task.args
switch (task.type) {
case 'delete':
@@ -321,6 +335,16 @@ async function handleTask(task: ScheduledTask) {
_filter,
})
break
case 'move':
await startMove({
srcFs,
dstFs,
createEmptySrcDirs,
deleteEmptyDstDirs,
_config,
_filter,
})
break
case 'sync':
await startSync({
srcFs,
+1
View File
@@ -5,6 +5,7 @@
"windows": [
"main",
"Copy",
"Move",
"Sync",
"Mount",
"Settings",
+5
View File
@@ -9,6 +9,7 @@ import Copy from './pages/Copy'
import Cron from './pages/Cron'
import Jobs from './pages/Jobs'
import Mount from './pages/Mount'
import Move from './pages/Move'
import Settings from './pages/Settings'
import Sync from './pages/Sync'
import Test from './pages/Test'
@@ -49,6 +50,10 @@ const router = createBrowserRouter([
path: '/copy',
element: <Copy />,
},
{
path: '/move',
element: <Move />,
},
{
path: '/mount',
element: <Mount />,
+463
View File
@@ -0,0 +1,463 @@
import { Accordion, AccordionItem, Avatar, Button, Switch } from '@heroui/react'
import { message } from '@tauri-apps/plugin-dialog'
import { exists, readDir } from '@tauri-apps/plugin-fs'
import { fetch } from '@tauri-apps/plugin-http'
import cronstrue from 'cronstrue'
import {
AlertOctagonIcon,
ClockIcon,
FilterIcon,
FoldersIcon,
MoveIcon,
PlayIcon,
} from 'lucide-react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { getRemoteName } from '../../lib/format'
import { isRemotePath } from '../../lib/fs'
import { getCopyFlags, getFilterFlags, getGlobalFlags, startMove } from '../../lib/rclone/api'
import { usePersistedStore } from '../../lib/store'
import { openWindow } from '../../lib/window'
import CronEditor from '../components/CronEditor'
import OptionsSection from '../components/OptionsSection'
import { MultiPathFinder } from '../components/PathFinder'
export default function Move() {
const [searchParams] = useSearchParams()
const [sources, setSources] = useState<string[] | undefined>(
searchParams.get('initialSource') ? [searchParams.get('initialSource')!] : undefined
)
const [dest, setDest] = useState<string | undefined>(undefined)
const [createEmptySrcDirs, setCreateEmptySrcDirs] = useState(false)
const [deleteEmptyDstDirs, setDeleteEmptyDstDirs] = useState(false)
const [isStarted, setIsStarted] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [jsonError, setJsonError] = useState<'move' | 'filter' | null>(null)
const [moveOptionsLocked, setMoveOptionsLocked] = useState(false)
const [moveOptions, setMoveOptions] = useState<Record<string, string>>({})
const [moveOptionsJson, setMoveOptionsJson] = useState<string>('{}')
const [filterOptionsLocked, setFilterOptionsLocked] = useState(false)
const [filterOptions, setFilterOptions] = useState<Record<string, string>>({})
const [filterOptionsJson, setFilterOptionsJson] = useState<string>('{}')
const [cronExpression, setCronExpression] = useState<string | null>(null)
const [globalOptions, setGlobalOptions] = useState<any[]>([])
// 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 mergedMoveDefaults = {}
let mergedFilterDefaults = {}
// Helper function to merge defaults from a remote
const mergeRemoteDefaults = (remote: string | null) => {
if (!remote || !(remote in storeData.remoteConfigList)) return
const remoteConfig = storeData.remoteConfigList[remote]
if (remoteConfig.moveDefaults) {
mergedMoveDefaults = {
...mergedMoveDefaults,
...remoteConfig.moveDefaults,
}
}
if (remoteConfig.filterDefaults) {
mergedFilterDefaults = {
...mergedFilterDefaults,
...remoteConfig.filterDefaults,
}
}
}
// Only merge defaults for remote paths
if (sourceRemote) mergeRemoteDefaults(sourceRemote)
if (destRemote) mergeRemoteDefaults(destRemote)
if (Object.keys(mergedMoveDefaults).length > 0 && !moveOptionsLocked) {
setMoveOptionsJson(JSON.stringify(mergedMoveDefaults, null, 2))
}
if (Object.keys(mergedFilterDefaults).length > 0 && !filterOptionsLocked) {
setFilterOptionsJson(JSON.stringify(mergedFilterDefaults, null, 2))
}
}, [sources, dest])
useEffect(() => {
getGlobalFlags().then((flags) => setGlobalOptions(flags))
}, [])
useEffect(() => {
let step: 'move' | 'filter' = 'move'
try {
setMoveOptions(JSON.parse(moveOptionsJson))
step = 'filter'
setFilterOptions(JSON.parse(filterOptionsJson))
setJsonError(null)
} catch (error) {
setJsonError(step)
console.error(`Error parsing ${step} options:`, error)
}
}, [moveOptionsJson, filterOptionsJson])
const handleStartMove = useCallback(async () => {
setIsLoading(true)
if (!sources || sources.length === 0 || !dest) {
await message('Please select both a source and destination path', {
title: 'Error',
kind: 'error',
})
return
}
try {
// check local paths exists
for (const source of sources) {
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
}
}
let isFolder = true
try {
await readDir(sources[0])
} catch {
console.log('not a folder')
isFolder = false
}
if (
!isFolder &&
filterOptions &&
('IncludeRule' in filterOptions || 'IncludeFrom' in filterOptions)
) {
throw new Error(
'Include rules are not supported when the input is one or multiple files'
)
}
if (cronExpression) {
if (sources.length > 1) {
await message(
'Cron is not supported for multiple sources, please use a single source',
{
title: 'Error',
kind: 'error',
}
)
setIsLoading(false)
return
}
try {
cronstrue.toString(cronExpression)
} catch {
await message('Invalid cron expression', {
title: 'Error',
kind: 'error',
})
setIsLoading(false)
return
}
usePersistedStore.getState().addScheduledTask({
'type': 'move',
'cron': cronExpression,
'args': {
'srcFs': sources[0],
'dstFs': dest,
'createEmptySrcDirs': createEmptySrcDirs,
'deleteEmptyDstDirs': deleteEmptyDstDirs,
'_config': moveOptions,
'_filter': filterOptions,
},
})
}
const failedPaths: Record<string, string> = {}
for (const source of sources) {
try {
const customFilterOptions = isFolder
? filterOptions
: {
...filterOptions,
IncludeRule: [source.split('/').pop()!],
}
// Use parent folder path if the input is a file
const customSource = isFolder ? source : source.split('/').slice(0, -1).join('/')
const jobId = await startMove({
srcFs: customSource,
dstFs: dest,
createEmptySrcDirs,
deleteEmptyDstDirs,
_config: moveOptions,
_filter: customFilterOptions,
})
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('failedPaths', failedPaths)
// dummy delay to avoid waiting when opening the Jobs page
await new Promise((resolve) => setTimeout(resolve, 1500))
if (sources.length !== Object.keys(failedPaths).length) {
setIsStarted(true)
}
const failedPathsKeys = Object.keys(failedPaths)
if (failedPathsKeys.length > 0) {
if (sources.length === 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)
}, [
sources,
dest,
moveOptions,
filterOptions,
cronExpression,
createEmptySrcDirs,
deleteEmptyDstDirs,
])
const buttonText = useMemo(() => {
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 MOVE'
}, [isLoading, jsonError, sources, dest])
const buttonIcon = useMemo(() => {
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" />
}, [isLoading, jsonError, sources, dest])
return (
<div className="flex flex-col h-screen gap-10 pt-10">
{/* 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}
/>
<div className="flex flex-col gap-2 pt-2 -mb-5">
<Switch isSelected={createEmptySrcDirs} onValueChange={setCreateEmptySrcDirs}>
Create empty source directories on destination after move
</Switch>
<Switch isSelected={deleteEmptyDstDirs} onValueChange={setDeleteEmptyDstDirs}>
Delete empty source directories after move
</Switch>
</div>
<Accordion>
<AccordionItem
key="move"
startContent={
<Avatar color="primary" radius="lg" fallback={<MoveIcon />} />
}
indicator={<MoveIcon />}
subtitle="Tap to toggle move options for this operation"
title="Move"
>
<OptionsSection
globalOptions={globalOptions['main' as keyof typeof globalOptions]}
optionsJson={moveOptionsJson}
setOptionsJson={setMoveOptionsJson}
optionsFetcher={getCopyFlags}
rows={18}
isLocked={moveOptionsLocked}
setIsLocked={setMoveOptionsLocked}
/>
</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={globalOptions['filter' as keyof typeof globalOptions]}
optionsJson={filterOptionsJson}
setOptionsJson={setFilterOptionsJson}
optionsFetcher={getFilterFlags}
rows={4}
isLocked={filterOptionsLocked}
setIsLocked={setFilterOptionsLocked}
/>
</AccordionItem>
<AccordionItem
key="cron"
startContent={
<Avatar color="warning" radius="lg" fallback={<ClockIcon />} />
}
indicator={<ClockIcon />}
subtitle="Tap to toggle cron options for this operation"
title="Cron"
>
<CronEditor expression={cronExpression} onChange={setCronExpression} />
</AccordionItem>
</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}
size="lg"
onPress={() => {
setMoveOptionsJson('{}')
setFilterOptionsJson('{}')
setSources(undefined)
setDest(undefined)
setIsStarted(false)
}}
data-focus-visible="false"
>
New Move
</Button>
<Button
fullWidth={true}
size="lg"
color="primary"
onPress={async () => {
await openWindow({ name: 'Jobs', url: '/jobs' })
// await getCurrentWindow().hide()
// await getCurrentWindow().destroy()
}}
data-focus-visible="false"
>
Show Jobs
</Button>
</>
) : (
<Button
onPress={handleStartMove}
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"
data-focus-visible="false"
>
{buttonText}
</Button>
)}
</div>
</div>
)
}
+1 -1
View File
@@ -1,6 +1,6 @@
export interface ScheduledTask {
id: string
type: 'delete' | 'sync' | 'copy'
type: 'delete' | 'sync' | 'copy' | 'move'
cron: string
isRunning: boolean
isEnabled: boolean