build status: failing
@@ -0,0 +1,5 @@
|
|||||||
|
# TAURI_DEV_HOST="0.0.0.0" # needed on macOS
|
||||||
|
APPLE_SIGNING_IDENTITY=
|
||||||
|
APPLE_ID=
|
||||||
|
APPLE_PASSWORD=
|
||||||
|
APPLE_TEAM_ID=
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
.env*
|
||||||
|
!.env.example
|
||||||
|
.cursorignore
|
||||||
|
.work
|
||||||
|
.work/*
|
||||||
|
src-tauri/binaries/*
|
||||||
|
!src-tauri/binaries/.gitkeep
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# Issue & Goal
|
||||||
|
- opening the app in release mode does not show the tray icon or do anything
|
||||||
|
- tauri looks to be working and sending the `applicationDidFinishLaunching` event but nothing happens
|
||||||
|
- the goal is to have this run on macOS (no sandboxing needed)
|
||||||
|
|
||||||
|
|
||||||
|
# Notes
|
||||||
|
### Overall
|
||||||
|
- you will have to download the `rclone` binary from the [rclone website](https://rclone.org/downloads/) and put it in the `src-tauri/binaries` directory. if you already have rclone installed, it will be used instead of the binary.
|
||||||
|
- the main window runs the `main.ts` file that's at the project root, it is not related to the vite app. it gets compiled by the `buildExternal.js` script and added to the vite bundle through the `public` directory.
|
||||||
|
- sometimes `rclone` binary does not start correctly and the loading tray icons is stuck forever, i'd like to solve that too but this is more of a notice to restart the app if that happens
|
||||||
|
|
||||||
|
### Tauri Info
|
||||||
|
[✔] Environment
|
||||||
|
- OS: Mac OS 14.7.2 arm64 (X64)
|
||||||
|
✔ Xcode Command Line Tools: installed
|
||||||
|
✔ rustc: 1.82.0 (f6e511eec 2024-10-15)
|
||||||
|
✔ cargo: 1.82.0 (8f40fc59f 2024-08-21)
|
||||||
|
✔ rustup: 1.27.1 (54dd3d00f 2024-04-24)
|
||||||
|
✔ Rust toolchain: stable-aarch64-apple-darwin (default)
|
||||||
|
- node: 20.8.1
|
||||||
|
- npm: 10.1.0
|
||||||
|
|
||||||
|
[-] Packages
|
||||||
|
- tauri 🦀: 2.2.0
|
||||||
|
- tauri-build 🦀: 2.0.4
|
||||||
|
- wry 🦀: 0.48.0
|
||||||
|
- tao 🦀: 0.31.1
|
||||||
|
- @tauri-apps/api : 2.2.0
|
||||||
|
- @tauri-apps/cli : 2.2.5
|
||||||
|
|
||||||
|
[-] Plugins
|
||||||
|
- tauri-plugin-log 🦀: 2.2.0
|
||||||
|
- @tauri-apps/plugin-log : 2.2.0
|
||||||
|
- tauri-plugin-notification 🦀: 2.2.0
|
||||||
|
- @tauri-apps/plugin-notification : 2.2.1
|
||||||
|
- tauri-plugin-process 🦀: 2.2.0
|
||||||
|
- @tauri-apps/plugin-process : 2.2.0
|
||||||
|
- tauri-plugin-http 🦀: 2.2.0
|
||||||
|
- @tauri-apps/plugin-http : 2.2.0
|
||||||
|
- tauri-plugin-store 🦀: 2.2.0
|
||||||
|
- @tauri-apps/plugin-store : 2.2.0
|
||||||
|
- tauri-plugin-os 🦀: 2.2.0
|
||||||
|
- @tauri-apps/plugin-os : 2.2.0
|
||||||
|
- tauri-plugin-shell 🦀: 2.2.0
|
||||||
|
- @tauri-apps/plugin-shell : 2.2.0
|
||||||
|
- tauri-plugin-single-instance 🦀: 2.2.0
|
||||||
|
- @tauri-apps/plugin-single-instance : not installed!
|
||||||
|
- tauri-plugin-dialog 🦀: 2.2.0
|
||||||
|
- @tauri-apps/plugin-dialog : 2.2.0
|
||||||
|
- tauri-plugin-fs 🦀: 2.2.0
|
||||||
|
- @tauri-apps/plugin-fs : 2.2.0
|
||||||
|
- tauri-plugin-opener 🦀: 2.2.3
|
||||||
|
- @tauri-apps/plugin-opener : 2.2.5
|
||||||
|
- tauri-plugin-positioner 🦀: 2.2.0
|
||||||
|
- @tauri-apps/plugin-positioner : 2.2.0
|
||||||
|
|
||||||
|
[-] App
|
||||||
|
- build-type: bundle
|
||||||
|
- CSP: unset
|
||||||
|
- frontendDist: ../dist
|
||||||
|
- devUrl: http://localhost:1420/
|
||||||
|
- framework: React
|
||||||
|
- bundler: Vite
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
||||||
|
"organizeImports": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"linter": {
|
||||||
|
"enabled": true,
|
||||||
|
"rules": {
|
||||||
|
"a11y": {
|
||||||
|
"all": true,
|
||||||
|
"noSvgWithoutTitle": "off"
|
||||||
|
},
|
||||||
|
"complexity": {
|
||||||
|
"all": true,
|
||||||
|
"noUselessTypeConstraint": "off",
|
||||||
|
"useSimplifiedLogicExpression": "off"
|
||||||
|
},
|
||||||
|
"correctness": {
|
||||||
|
"all": true
|
||||||
|
},
|
||||||
|
"performance": {
|
||||||
|
"all": true
|
||||||
|
},
|
||||||
|
"security": {
|
||||||
|
"all": true
|
||||||
|
},
|
||||||
|
"style": {
|
||||||
|
"all": true,
|
||||||
|
"useTemplate": "off",
|
||||||
|
"noNonNullAssertion": "off",
|
||||||
|
"useBlockStatements": "off",
|
||||||
|
"noInferrableTypes": "off",
|
||||||
|
"noDefaultExport": "off",
|
||||||
|
"useNamingConvention": "off",
|
||||||
|
"useForOf": "off"
|
||||||
|
},
|
||||||
|
"suspicious": {
|
||||||
|
"all": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ignore": ["node_modules/*", "dist/*", ".work/*", "*.js"]
|
||||||
|
},
|
||||||
|
"formatter": {
|
||||||
|
"enabled": true,
|
||||||
|
"formatWithErrors": true,
|
||||||
|
"indentStyle": "space",
|
||||||
|
"indentWidth": 4,
|
||||||
|
"lineWidth": 100,
|
||||||
|
"ignore": ["node_modules/*", "dist/*", ".work/*"]
|
||||||
|
},
|
||||||
|
"javascript": {
|
||||||
|
"formatter": {
|
||||||
|
"quoteStyle": "single",
|
||||||
|
"quoteProperties": "preserve",
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"semicolons": "asNeeded"
|
||||||
|
},
|
||||||
|
"globals": ["it", "describe", "expect", "test"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" class="bg-neutral-900 dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Rclone UI</title>
|
||||||
|
<style>
|
||||||
|
#drag-region {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
/* pointer-events: none; */
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
/* #drag-region.draggable {
|
||||||
|
pointer-events: auto;
|
||||||
|
} */
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body class="min-h-screen">
|
||||||
|
<!-- <div id="drag-region" data-tauri-drag-region></div> -->
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
export function formatBytes(bytes: number) {
|
||||||
|
// format bytes in to a readable format like (MB, GB, etc), depending on how big the number is
|
||||||
|
if (bytes < 1024) {
|
||||||
|
return `${bytes} B`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bytes < 1024 * 1024) {
|
||||||
|
return `${(bytes / 1024).toFixed(2)} KB`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bytes < 1024 * 1024 * 1024) {
|
||||||
|
return `${(bytes / 1024 / 1024).toFixed(2)} MB`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function replaceSmartQuotes(value: string) {
|
||||||
|
const replacements: { [key: string]: string } = {
|
||||||
|
'‘': "'",
|
||||||
|
'’': "'",
|
||||||
|
'‚': "'",
|
||||||
|
'“': '"',
|
||||||
|
'”': '"',
|
||||||
|
'„': '"',
|
||||||
|
}
|
||||||
|
return value.replace(/[‘’‚“”„]/g, (match) => replacements[match])
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { readDir } from '@tauri-apps/plugin-fs'
|
||||||
|
|
||||||
|
export async function isDirectoryEmpty(path: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const entries = await readDir(path)
|
||||||
|
return entries.length === 0
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error checking directory:', err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRemotePath(path: string): boolean {
|
||||||
|
return path.includes(':/') && !path.startsWith('/')
|
||||||
|
}
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
import { Menu, MenuItem, PredefinedMenuItem, Submenu } from '@tauri-apps/api/menu'
|
||||||
|
import { ask, confirm, message, open } from '@tauri-apps/plugin-dialog'
|
||||||
|
import { isPermissionGranted, requestPermission } from '@tauri-apps/plugin-notification'
|
||||||
|
import { sendNotification } from '@tauri-apps/plugin-notification'
|
||||||
|
import { revealItemInDir } from '@tauri-apps/plugin-opener'
|
||||||
|
import { exit } from '@tauri-apps/plugin-process'
|
||||||
|
import { isDirectoryEmpty } from './fs'
|
||||||
|
import { deleteRemote, mountRemote, unmountRemote } from './rclone'
|
||||||
|
import { usePersistedStore, useStore } from './store'
|
||||||
|
import { getLoadingTray, getMainTray } from './tray'
|
||||||
|
import { lockWindows, openFullWindow, openTrayWindow, openWindow, unlockWindows } from './window'
|
||||||
|
|
||||||
|
// Function to rebuild and update the menu
|
||||||
|
export async function buildMenu() {
|
||||||
|
const storeState = useStore.getState()
|
||||||
|
|
||||||
|
const persistedStoreState = usePersistedStore.getState()
|
||||||
|
|
||||||
|
const remotes = storeState.remotes
|
||||||
|
|
||||||
|
const menuItems: (MenuItem | Submenu | PredefinedMenuItem)[] = []
|
||||||
|
|
||||||
|
// Add remote submenus
|
||||||
|
for (const remote of remotes) {
|
||||||
|
const remoteConfig = persistedStoreState.remoteConfigList?.[remote]
|
||||||
|
if (remoteConfig?.hideTray) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const submenuItems: (MenuItem | Submenu | PredefinedMenuItem)[] = []
|
||||||
|
|
||||||
|
const alreadyMounted = storeState.mountedRemotes[remote]
|
||||||
|
|
||||||
|
if (alreadyMounted) {
|
||||||
|
const unmountMenuItem = await MenuItem.new({
|
||||||
|
id: `unmount-${remote}`,
|
||||||
|
text: 'Unmount',
|
||||||
|
action: async () => {
|
||||||
|
try {
|
||||||
|
const mountPoint = storeState.mountedRemotes[remote]
|
||||||
|
if (!mountPoint) {
|
||||||
|
console.error(`No mount point found for remote ${remote}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await unmountRemote(mountPoint)
|
||||||
|
delete storeState.mountedRemotes[remote]
|
||||||
|
// await rebuildTrayMenu()
|
||||||
|
await message(`Successfully unmounted ${remote} from ${mountPoint}`, {
|
||||||
|
title: 'Unmount Success',
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Unmount operation failed:', err)
|
||||||
|
await message(`Failed to unmount ${remote}: ${err}`, {
|
||||||
|
title: 'Unmount Error',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
submenuItems.push(unmountMenuItem)
|
||||||
|
|
||||||
|
// Add "Open in Finder" option for mounted remotes
|
||||||
|
const mountPoint = storeState.mountedRemotes[remote]
|
||||||
|
console.log('Adding Open in Finder', mountPoint)
|
||||||
|
const openInFinderItem = await MenuItem.new({
|
||||||
|
id: `open-${remote}`,
|
||||||
|
|
||||||
|
text: 'Open in Finder',
|
||||||
|
action: async () => {
|
||||||
|
console.log('Open in Finder')
|
||||||
|
console.log(mountPoint)
|
||||||
|
if (mountPoint) {
|
||||||
|
console.log('Opening in Finder')
|
||||||
|
await revealItemInDir(mountPoint)
|
||||||
|
console.log('Opened in Finder')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
submenuItems.push(openInFinderItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!alreadyMounted && !remoteConfig?.disabledActions?.includes('mount')) {
|
||||||
|
const mountMenuItem = await MenuItem.new({
|
||||||
|
id: `mount-${remote}`,
|
||||||
|
text: 'Quick Mount',
|
||||||
|
action: async () => {
|
||||||
|
await getMainTray().then((t) => t?.setVisible(false))
|
||||||
|
|
||||||
|
await getLoadingTray().then((t) => t?.setVisible(true))
|
||||||
|
|
||||||
|
try {
|
||||||
|
await lockWindows()
|
||||||
|
|
||||||
|
let selectedPath = remoteConfig.defaultMountPoint || null
|
||||||
|
|
||||||
|
if (!selectedPath) {
|
||||||
|
selectedPath = await open({
|
||||||
|
title: `Select a directory to mount "${remote}"`,
|
||||||
|
multiple: false,
|
||||||
|
directory: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedPath) {
|
||||||
|
// await resetMainWindow()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('selectedPath', selectedPath)
|
||||||
|
|
||||||
|
// Check if directory is empty
|
||||||
|
const isEmpty = await isDirectoryEmpty(selectedPath)
|
||||||
|
if (!isEmpty) {
|
||||||
|
// await resetMainWindow()
|
||||||
|
|
||||||
|
await message(
|
||||||
|
'The selected directory must be empty to mount a remote.',
|
||||||
|
{
|
||||||
|
title: 'Mount Error',
|
||||||
|
kind: 'error',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mount the remote
|
||||||
|
await mountRemote({
|
||||||
|
remotePath: `${remote}:${remoteConfig.defaultRemotePath || ''}`,
|
||||||
|
mountPoint: selectedPath,
|
||||||
|
mountOptions: remoteConfig.mountDefaults,
|
||||||
|
vfsOptions: remoteConfig.vfsDefaults,
|
||||||
|
})
|
||||||
|
storeState.mountedRemotes[remote] = selectedPath
|
||||||
|
|
||||||
|
// await rebuildTrayMenu()
|
||||||
|
|
||||||
|
let permissionGranted = await isPermissionGranted()
|
||||||
|
|
||||||
|
if (!permissionGranted) {
|
||||||
|
const permission = await requestPermission()
|
||||||
|
permissionGranted = permission === 'granted'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (permissionGranted) {
|
||||||
|
sendNotification({
|
||||||
|
title: 'Mounted',
|
||||||
|
body: `Successfully mounted ${remote} to ${selectedPath}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!remoteConfig.defaultMountPoint) {
|
||||||
|
const answer = await ask(
|
||||||
|
`Mount successful! Do you want to set ${selectedPath} as the default mount point for ${remote}? You can always change it later in Remote settings.`,
|
||||||
|
{
|
||||||
|
title: 'Set Default?',
|
||||||
|
okLabel: 'Set',
|
||||||
|
cancelLabel: 'Cancel',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if (answer) {
|
||||||
|
usePersistedStore.setState((state) => ({
|
||||||
|
remoteConfigList: {
|
||||||
|
...state.remoteConfigList,
|
||||||
|
[remote]: {
|
||||||
|
...state.remoteConfigList[remote],
|
||||||
|
defaultMountPoint: selectedPath,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// await resetMainWindow()
|
||||||
|
console.error('Mount operation failed:', err)
|
||||||
|
await message(`Failed to mount ${remote}: ${err}`, {
|
||||||
|
title: 'Mount Error',
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
await unlockWindows()
|
||||||
|
await getLoadingTray().then((t) => t?.setVisible(false))
|
||||||
|
await getMainTray().then((t) => t?.setVisible(true))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
submenuItems.push(mountMenuItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!remoteConfig?.disabledActions?.includes('browse')) {
|
||||||
|
const browseMenuItem = await MenuItem.new({
|
||||||
|
id: `browse-${remote}`,
|
||||||
|
text: 'Browse',
|
||||||
|
action: async () => {
|
||||||
|
// await openBrowser(`http://localhost:5572/[${remote}:]/`)
|
||||||
|
await openFullWindow({
|
||||||
|
name: 'Browse',
|
||||||
|
// url: 'browse.html?url=https%3A%2F%2Fwww.google.com%2F',
|
||||||
|
url:
|
||||||
|
'browse.html?url=' +
|
||||||
|
encodeURIComponent(`http://localhost:5572/[${remote}:]/`),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
submenuItems.push(browseMenuItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!remoteConfig?.disabledActions?.includes('remove')) {
|
||||||
|
const removeMenuItem = await MenuItem.new({
|
||||||
|
id: `remove-${remote}`,
|
||||||
|
text: 'Remove',
|
||||||
|
action: async () => {
|
||||||
|
const confirmation = await confirm(
|
||||||
|
`Are you sure you want to remove ${remote}? This action cannot be reverted.`,
|
||||||
|
{ title: `Removing ${remote}`, kind: 'warning' }
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!confirmation) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteRemote(remote)
|
||||||
|
// await rebuildTrayMenu()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
submenuItems.push(removeMenuItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sub = await Submenu.new({
|
||||||
|
items: submenuItems,
|
||||||
|
text: remote,
|
||||||
|
})
|
||||||
|
|
||||||
|
menuItems.push(sub)
|
||||||
|
}
|
||||||
|
|
||||||
|
await PredefinedMenuItem.new({
|
||||||
|
item: 'Separator',
|
||||||
|
}).then((item) => {
|
||||||
|
menuItems.push(item)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!persistedStoreState.disabledActions?.includes('mount')) {
|
||||||
|
const mountToMenuItem = await MenuItem.new({
|
||||||
|
id: 'mount',
|
||||||
|
text: 'Mount',
|
||||||
|
action: async () => {
|
||||||
|
await openWindow({
|
||||||
|
name: 'Mount',
|
||||||
|
url: '/mount',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
menuItems.push(mountToMenuItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!persistedStoreState.disabledActions?.includes('copy')) {
|
||||||
|
const copyMenuItem = await MenuItem.new({
|
||||||
|
id: 'copy',
|
||||||
|
text: 'Copy',
|
||||||
|
action: async () => {
|
||||||
|
await openWindow({
|
||||||
|
name: 'Copy',
|
||||||
|
url: '/copy',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
menuItems.push(copyMenuItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!persistedStoreState.disabledActions?.includes('sync')) {
|
||||||
|
const syncMenuItem = await MenuItem.new({
|
||||||
|
id: 'sync',
|
||||||
|
text: 'Sync',
|
||||||
|
action: async () => {
|
||||||
|
await openWindow({
|
||||||
|
name: 'Sync',
|
||||||
|
url: '/sync',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
menuItems.push(syncMenuItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
const jobsMenuItem = await MenuItem.new({
|
||||||
|
id: 'jobs',
|
||||||
|
text: 'Jobs',
|
||||||
|
action: async () => {
|
||||||
|
await openTrayWindow({
|
||||||
|
name: 'Jobs',
|
||||||
|
url: '/jobs',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
menuItems.push(jobsMenuItem)
|
||||||
|
|
||||||
|
await PredefinedMenuItem.new({
|
||||||
|
item: 'Separator',
|
||||||
|
}).then((item) => {
|
||||||
|
menuItems.push(item)
|
||||||
|
})
|
||||||
|
|
||||||
|
const settingsItem = await MenuItem.new({
|
||||||
|
id: 'settings',
|
||||||
|
text: 'Settings',
|
||||||
|
action: async () => {
|
||||||
|
await openWindow({
|
||||||
|
name: 'Settings',
|
||||||
|
url: '/settings',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
menuItems.push(settingsItem)
|
||||||
|
|
||||||
|
const quitItem = await MenuItem.new({
|
||||||
|
id: 'quit',
|
||||||
|
text: 'Quit',
|
||||||
|
action: async () => {
|
||||||
|
await exit(0)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
menuItems.push(quitItem)
|
||||||
|
|
||||||
|
const testItem = await MenuItem.new({
|
||||||
|
id: 'test',
|
||||||
|
text: 'Test',
|
||||||
|
action: async () => {
|
||||||
|
await openWindow({
|
||||||
|
name: 'Test',
|
||||||
|
url: '/test',
|
||||||
|
width: 400,
|
||||||
|
height: 400,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
menuItems.push(testItem)
|
||||||
|
|
||||||
|
const test2Item = await MenuItem.new({
|
||||||
|
id: 'test2',
|
||||||
|
text: 'Test2',
|
||||||
|
action: async () => {
|
||||||
|
await openWindow({
|
||||||
|
name: 'Test2',
|
||||||
|
url: '/test',
|
||||||
|
width: 400,
|
||||||
|
height: 400,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
menuItems.push(test2Item)
|
||||||
|
|
||||||
|
return await Menu.new({
|
||||||
|
id: 'main-menu',
|
||||||
|
|
||||||
|
items: menuItems,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,514 @@
|
|||||||
|
import { ask } from '@tauri-apps/plugin-dialog'
|
||||||
|
import { fetch } from '@tauri-apps/plugin-http'
|
||||||
|
import { Command } from '@tauri-apps/plugin-shell'
|
||||||
|
|
||||||
|
/* DATA */
|
||||||
|
|
||||||
|
export async function listRemotes() {
|
||||||
|
const r = await fetch('http://localhost:5572/config/listremotes', {
|
||||||
|
method: 'POST',
|
||||||
|
}).then((res) => res.json() as Promise<{ remotes: string[] }>)
|
||||||
|
// .catch((e) => {
|
||||||
|
// console.log("error", e);
|
||||||
|
// throw e;
|
||||||
|
// });
|
||||||
|
|
||||||
|
if (typeof r?.remotes === 'undefined') {
|
||||||
|
throw new Error('Failed to fetch remotes')
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.remotes
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRemote(remote: string) {
|
||||||
|
const r = await fetch(`http://localhost:5572/config/get?name=${remote}`, {
|
||||||
|
method: 'POST',
|
||||||
|
}).then(
|
||||||
|
(res) => res.json() as Promise<{ type: string } & Record<string, string | number | boolean>>
|
||||||
|
)
|
||||||
|
// .catch((e) => {
|
||||||
|
// console.log("error", e);
|
||||||
|
// throw e;
|
||||||
|
// });
|
||||||
|
|
||||||
|
console.log(JSON.stringify(r, null, 2))
|
||||||
|
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateRemote(
|
||||||
|
remote: string,
|
||||||
|
parameters: Record<string, string | number | boolean>
|
||||||
|
) {
|
||||||
|
console.log('updateRemote', remote, parameters)
|
||||||
|
|
||||||
|
const options = new URLSearchParams()
|
||||||
|
options.set('name', remote)
|
||||||
|
options.set('parameters', JSON.stringify(parameters))
|
||||||
|
|
||||||
|
await fetch(`http://localhost:5572/config/update?${options.toString()}`, {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
|
||||||
|
// console.log(JSON.stringify(r, null, 2))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createRemote(
|
||||||
|
name: string,
|
||||||
|
type: string,
|
||||||
|
parameters: Record<string, string | number | boolean>
|
||||||
|
) {
|
||||||
|
console.log('createRemote', name, type, parameters)
|
||||||
|
|
||||||
|
const options = new URLSearchParams()
|
||||||
|
options.set('name', name)
|
||||||
|
options.set('type', type)
|
||||||
|
options.set('parameters', JSON.stringify(parameters))
|
||||||
|
|
||||||
|
await fetch(`http://localhost:5572/config/create?${options.toString()}`, {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
|
||||||
|
// console.log(JSON.stringify(r, null, 2))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteRemote(remote: string) {
|
||||||
|
await fetch(`http://localhost:5572/config/delete?name=${remote}`, {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
|
||||||
|
// console.log(JSON.stringify(r, null, 2))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMountPoints() {
|
||||||
|
const r = await fetch('http://localhost:5572/mount/listmounts', {
|
||||||
|
method: 'POST',
|
||||||
|
}).then(
|
||||||
|
(res) =>
|
||||||
|
res.json() as Promise<{
|
||||||
|
mountPoints: string[]
|
||||||
|
}>
|
||||||
|
)
|
||||||
|
|
||||||
|
// console.log('Mount points:', r)
|
||||||
|
|
||||||
|
if (!Array.isArray(r?.mountPoints)) {
|
||||||
|
throw new Error('Failed to get mount points')
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.mountPoints
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUPPORRTED_BACKENDS = ['sftp', 's3', 'b2', 'drive']
|
||||||
|
|
||||||
|
export async function getBackends() {
|
||||||
|
const providers = await fetch('http://localhost:5572/config/providers', {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
.then((res) => res.json() as Promise<any>)
|
||||||
|
.then((r) => r.providers)
|
||||||
|
|
||||||
|
return providers.filter((b: any) => SUPPORRTED_BACKENDS.includes(b.Name))
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListOptions {
|
||||||
|
recurse?: boolean
|
||||||
|
noModTime?: boolean
|
||||||
|
showEncrypted?: boolean
|
||||||
|
showOrigIDs?: boolean
|
||||||
|
showHash?: boolean
|
||||||
|
noMimeType?: boolean
|
||||||
|
dirsOnly?: boolean
|
||||||
|
filesOnly?: boolean
|
||||||
|
metadata?: boolean
|
||||||
|
hashTypes?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listPath(remote: string, path: string = '', options: ListOptions = {}) {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
params.set('fs', `${remote}:`)
|
||||||
|
params.set('remote', path)
|
||||||
|
|
||||||
|
// Add optional parameters
|
||||||
|
for (const [key, value] of Object.entries(options)) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
for (const v of value) {
|
||||||
|
params.append(key, v)
|
||||||
|
}
|
||||||
|
} else if (value !== undefined) {
|
||||||
|
params.set(key, value.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`http://localhost:5572/operations/list?${params.toString()}`, {
|
||||||
|
method: 'POST',
|
||||||
|
}).then(
|
||||||
|
(res) =>
|
||||||
|
res.json() as Promise<{
|
||||||
|
list: {
|
||||||
|
Hashes?: Record<string, string>
|
||||||
|
ID?: string
|
||||||
|
OrigID?: string
|
||||||
|
IsBucket?: boolean
|
||||||
|
IsDir: boolean
|
||||||
|
MimeType?: string
|
||||||
|
ModTime?: string
|
||||||
|
Name: string
|
||||||
|
Encrypted?: string
|
||||||
|
EncryptedPath?: string
|
||||||
|
Path: string
|
||||||
|
Size?: number
|
||||||
|
Tier?: string
|
||||||
|
}[]
|
||||||
|
}>
|
||||||
|
)
|
||||||
|
|
||||||
|
return response?.list || []
|
||||||
|
}
|
||||||
|
/* JOBS */
|
||||||
|
|
||||||
|
export async function listJobs() {
|
||||||
|
const allStats = await fetch('http://localhost:5572/core/stats', {
|
||||||
|
method: 'POST',
|
||||||
|
}).then((res) => res.json() as any)
|
||||||
|
|
||||||
|
const transferring = allStats?.transferring
|
||||||
|
|
||||||
|
const transferredStats = await fetch('http://localhost:5572/core/transferred', {
|
||||||
|
method: 'POST',
|
||||||
|
}).then((res) => res.json() as any)
|
||||||
|
|
||||||
|
const transferred = transferredStats?.transferred
|
||||||
|
|
||||||
|
const jobs = {
|
||||||
|
active: [] as any[],
|
||||||
|
inactive: [] as any[],
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeJobIds = new Set(
|
||||||
|
transferring
|
||||||
|
?.filter((t: any) => t.group.startsWith('job/'))
|
||||||
|
.map((t: any) => Number(t.group.split('/')[1]))
|
||||||
|
.sort((a: number, b: number) => a - b)
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const jobId of activeJobIds) {
|
||||||
|
const job = await fetch(`http://localhost:5572/core/stats?group=job/${jobId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
}).then((res) => res.json() as Promise<any>)
|
||||||
|
|
||||||
|
jobs.active.push({
|
||||||
|
id: jobId,
|
||||||
|
bytes: job.bytes,
|
||||||
|
totalBytes: job.totalBytes,
|
||||||
|
speed: job.speed,
|
||||||
|
|
||||||
|
done: job.bytes === job.totalBytes,
|
||||||
|
progress: Math.round((job.bytes / job.totalBytes) * 100),
|
||||||
|
fatal: job.fatalError,
|
||||||
|
|
||||||
|
srcFs: transferred.find((t: any) => t.group === `job/${jobId}`)?.srcFs,
|
||||||
|
dstFs: transferred.find((t: any) => t.group === `job/${jobId}`)?.dstFs,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const inactiveJobIds = new Set(
|
||||||
|
transferred
|
||||||
|
?.filter((t: any) => t.group.startsWith('job/'))
|
||||||
|
.map((t: any) => Number(t.group.split('/')[1]))
|
||||||
|
.filter((id: number) => !activeJobIds.has(id))
|
||||||
|
.sort((a: number, b: number) => a - b)
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const jobId of inactiveJobIds) {
|
||||||
|
const job = await fetch(`http://localhost:5572/core/stats?group=job/${jobId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
}).then((res) => res.json() as Promise<any>)
|
||||||
|
|
||||||
|
jobs.inactive.push({
|
||||||
|
id: jobId,
|
||||||
|
bytes: job.bytes,
|
||||||
|
totalBytes: job.totalBytes,
|
||||||
|
speed: 0,
|
||||||
|
|
||||||
|
done: job.bytes === job.totalBytes,
|
||||||
|
progress: Math.round((job.bytes / job.totalBytes) * 100),
|
||||||
|
fatal: job.fatalError,
|
||||||
|
|
||||||
|
srcFs: transferred.find((t: any) => t.group === `job/${jobId}`)?.srcFs,
|
||||||
|
dstFs: transferred.find((t: any) => t.group === `job/${jobId}`)?.dstFs,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return jobs
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stopJob(jobId: number) {
|
||||||
|
await fetch(`http://localhost:5572/job/stopgroup?group=job/${jobId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/* OPERATIONS */
|
||||||
|
|
||||||
|
export async function mountRemote({
|
||||||
|
remotePath,
|
||||||
|
mountPoint,
|
||||||
|
mountOptions,
|
||||||
|
vfsOptions,
|
||||||
|
}: {
|
||||||
|
remotePath: string
|
||||||
|
mountPoint: string
|
||||||
|
mountOptions?: Record<string, string | number | boolean>
|
||||||
|
vfsOptions?: Record<string, string | number | boolean>
|
||||||
|
}) {
|
||||||
|
const options = new URLSearchParams()
|
||||||
|
options.set('fs', remotePath)
|
||||||
|
options.set('mountPoint', mountPoint)
|
||||||
|
|
||||||
|
if (mountOptions && Object.keys(mountOptions).length > 0) {
|
||||||
|
options.set('mountOpt', JSON.stringify(mountOptions))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vfsOptions && Object.keys(vfsOptions).length > 0) {
|
||||||
|
options.set('vfsOpt', JSON.stringify(vfsOptions))
|
||||||
|
}
|
||||||
|
|
||||||
|
const r = await fetch(`http://localhost:5572/mount/mount?${options.toString()}`, {
|
||||||
|
method: 'POST',
|
||||||
|
}).then((res) => res.json() as Promise<{ remotes: string[] } | Promise<{ error: string }>>)
|
||||||
|
|
||||||
|
if ('error' in r) {
|
||||||
|
throw new Error(r.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unmountRemote(mountPoint: string, force = false) {
|
||||||
|
const command = Command.create('umount', [force ? '-f' : '', mountPoint])
|
||||||
|
|
||||||
|
const output = await command.execute()
|
||||||
|
|
||||||
|
// console.log('output')
|
||||||
|
// console.log(JSON.stringify(output, null, 2))
|
||||||
|
|
||||||
|
if (output.code !== 0) {
|
||||||
|
if (output.stderr.toLowerCase().includes('busy')) {
|
||||||
|
const answer = await ask('This resource is busy, do you want to force unmount?', {
|
||||||
|
title: 'Could not unmount',
|
||||||
|
kind: 'warning',
|
||||||
|
})
|
||||||
|
if (answer) {
|
||||||
|
return await unmountRemote(mountPoint, true)
|
||||||
|
}
|
||||||
|
throw new Error(output.stderr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (output.stderr.toLowerCase().includes('not currently mounted')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(output.stderr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startCopy({
|
||||||
|
source,
|
||||||
|
dest,
|
||||||
|
copyOptions,
|
||||||
|
filterOptions,
|
||||||
|
}: {
|
||||||
|
source: string
|
||||||
|
dest: string
|
||||||
|
copyOptions: Record<string, string | number | boolean> | undefined
|
||||||
|
filterOptions: Record<string, string | number | boolean> | undefined
|
||||||
|
}) {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
params.set('srcFs', source)
|
||||||
|
params.set('dstFs', dest)
|
||||||
|
// params.set('b2_disable_checksum', 'true')
|
||||||
|
params.set('_async', 'true')
|
||||||
|
|
||||||
|
console.log('params', params.toString())
|
||||||
|
|
||||||
|
if (copyOptions && Object.keys(copyOptions).length > 0) {
|
||||||
|
params.set('_config', JSON.stringify(copyOptions))
|
||||||
|
}
|
||||||
|
console.log('copyOptions', copyOptions)
|
||||||
|
|
||||||
|
if (filterOptions && Object.keys(filterOptions).length > 0) {
|
||||||
|
params.set('_filter', JSON.stringify(filterOptions))
|
||||||
|
}
|
||||||
|
console.log('filterOptions', filterOptions)
|
||||||
|
|
||||||
|
const r = await fetch(`http://localhost:5572/sync/copy?${params.toString()}`, {
|
||||||
|
method: 'POST',
|
||||||
|
}).then((res) => res.json() as Promise<{ jobid: string }>)
|
||||||
|
|
||||||
|
console.log('Copy operation started:', r)
|
||||||
|
return
|
||||||
|
|
||||||
|
// if (!r.jobid) {
|
||||||
|
// throw new Error("Failed to start copy job");
|
||||||
|
// }
|
||||||
|
|
||||||
|
// Monitor job status
|
||||||
|
// while (true) {
|
||||||
|
// const status = await fetch(
|
||||||
|
// `http://localhost:5572/job/status/${r.jobid}`,
|
||||||
|
// {
|
||||||
|
// method: "POST",
|
||||||
|
// },
|
||||||
|
// ).then((res) => res.json() as Promise<RcloneJobStatus>);
|
||||||
|
|
||||||
|
// if (status.finished) {
|
||||||
|
// return status.success;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // Wait a bit before checking again
|
||||||
|
// await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startSync({
|
||||||
|
source,
|
||||||
|
dest,
|
||||||
|
syncOptions,
|
||||||
|
filterOptions,
|
||||||
|
}: {
|
||||||
|
source: string
|
||||||
|
dest: string
|
||||||
|
syncOptions: Record<string, string | number | boolean> | undefined
|
||||||
|
filterOptions: Record<string, string | number | boolean> | undefined
|
||||||
|
}) {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
params.set('srcFs', source)
|
||||||
|
params.set('dstFs', dest)
|
||||||
|
// params.set('b2_disable_checksum', 'true')
|
||||||
|
params.set('_async', 'true')
|
||||||
|
|
||||||
|
console.log('params', params.toString())
|
||||||
|
|
||||||
|
if (syncOptions && Object.keys(syncOptions).length > 0) {
|
||||||
|
params.set('_config', JSON.stringify(syncOptions))
|
||||||
|
}
|
||||||
|
console.log('syncOptions', syncOptions)
|
||||||
|
|
||||||
|
if (filterOptions && Object.keys(filterOptions).length > 0) {
|
||||||
|
params.set('_filter', JSON.stringify(filterOptions))
|
||||||
|
}
|
||||||
|
console.log('filterOptions', filterOptions)
|
||||||
|
|
||||||
|
const r = await fetch(`http://localhost:5572/sync/sync?${params.toString()}`, {
|
||||||
|
method: 'POST',
|
||||||
|
}).then((res) => res.json() as Promise<{ jobid: string }>)
|
||||||
|
|
||||||
|
console.log('Sync operation started:', r)
|
||||||
|
return
|
||||||
|
|
||||||
|
// if (!r.jobid) {
|
||||||
|
// throw new Error("Failed to start copy job");
|
||||||
|
// }
|
||||||
|
|
||||||
|
// Monitor job status
|
||||||
|
// while (true) {
|
||||||
|
// const status = await fetch(
|
||||||
|
// `http://localhost:5572/job/status/${r.jobid}`,
|
||||||
|
// {
|
||||||
|
// method: "POST",
|
||||||
|
// },
|
||||||
|
// ).then((res) => res.json() as Promise<RcloneJobStatus>);
|
||||||
|
|
||||||
|
// if (status.finished) {
|
||||||
|
// return status.success;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // Wait a bit before checking again
|
||||||
|
// await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* FLAGS */
|
||||||
|
|
||||||
|
export async function getGlobalFlags() {
|
||||||
|
const r = await fetch('http://localhost:5572/options/get', {
|
||||||
|
method: 'POST',
|
||||||
|
}).then((res) => res.json() as Promise<any>)
|
||||||
|
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCopyFlags() {
|
||||||
|
const r = await fetch('http://localhost:5572/options/info', {
|
||||||
|
method: 'POST',
|
||||||
|
}).then((res) => res.json() as Promise<any>)
|
||||||
|
|
||||||
|
const mainFlags = r.main
|
||||||
|
|
||||||
|
const copyFlags = mainFlags.filter(
|
||||||
|
(flag: any) => flag?.Groups?.includes('Copy') || flag?.Groups?.includes('Performance')
|
||||||
|
)
|
||||||
|
|
||||||
|
return copyFlags
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSyncFlags() {
|
||||||
|
const r = await fetch('http://localhost:5572/options/info', {
|
||||||
|
method: 'POST',
|
||||||
|
}).then((res) => res.json() as Promise<any>)
|
||||||
|
|
||||||
|
const mainFlags = r.main
|
||||||
|
|
||||||
|
const syncFlags = mainFlags.filter(
|
||||||
|
(flag: any) =>
|
||||||
|
flag?.Groups?.includes('Copy') ||
|
||||||
|
flag?.Groups?.includes('Sync') ||
|
||||||
|
flag?.Groups?.includes('Performance')
|
||||||
|
)
|
||||||
|
|
||||||
|
return syncFlags
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFilterFlags() {
|
||||||
|
const r = await fetch('http://localhost:5572/options/info', {
|
||||||
|
method: 'POST',
|
||||||
|
}).then((res) => res.json() as Promise<any>)
|
||||||
|
|
||||||
|
const filterFlags = r.filter
|
||||||
|
|
||||||
|
// ignore "Metadata" fields as they have the same FieldNames as the normal non-metadata filters
|
||||||
|
const filteredFlags = filterFlags.filter((flag: any) => !flag.Groups.includes('Metadata'))
|
||||||
|
|
||||||
|
return filteredFlags
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getVfsFlags() {
|
||||||
|
const r = await fetch('http://localhost:5572/options/info', {
|
||||||
|
method: 'POST',
|
||||||
|
}).then((res) => res.json() as Promise<any>)
|
||||||
|
|
||||||
|
const vfsFlags = r.vfs
|
||||||
|
|
||||||
|
const IGNORED_FLAGS = ['NONE']
|
||||||
|
|
||||||
|
const filteredFlags = vfsFlags.filter((flag: any) => !IGNORED_FLAGS.includes(flag.Name))
|
||||||
|
|
||||||
|
return filteredFlags
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMountFlags() {
|
||||||
|
const r = await fetch('http://localhost:5572/options/info', {
|
||||||
|
method: 'POST',
|
||||||
|
}).then((res) => res.json() as Promise<any>)
|
||||||
|
|
||||||
|
const mountFlags = r.mount
|
||||||
|
|
||||||
|
const IGNORED_FLAGS = ['debug_fuse', 'daemon', 'daemon_timeout']
|
||||||
|
|
||||||
|
const filteredFlags = mountFlags.filter((flag: any) => !IGNORED_FLAGS.includes(flag.Name))
|
||||||
|
|
||||||
|
return filteredFlags
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
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'
|
||||||
|
|
||||||
|
// const { LazyStore } = window.__TAURI__.store
|
||||||
|
const store = new LazyStore('store.json')
|
||||||
|
|
||||||
|
export interface RemoteConfig {
|
||||||
|
hideTray?: boolean
|
||||||
|
|
||||||
|
disabledActions?: ('mount' | 'browse' | 'remove')[]
|
||||||
|
|
||||||
|
defaultRemotePath?: string
|
||||||
|
defaultMountPoint?: string
|
||||||
|
mountOnStart?: boolean
|
||||||
|
|
||||||
|
mountDefaults?: Record<string, any>
|
||||||
|
vfsDefaults?: Record<string, any>
|
||||||
|
filterDefaults?: Record<string, any>
|
||||||
|
copyDefaults?: Record<string, any>
|
||||||
|
syncDefaults?: Record<string, any>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
count: number
|
||||||
|
anotherCount: number
|
||||||
|
increment: () => void
|
||||||
|
|
||||||
|
rcloneLoaded: boolean
|
||||||
|
mountedRemotes: Record<string, string>
|
||||||
|
|
||||||
|
serveList: { pid: number; protocol: string; remote: string }[]
|
||||||
|
setServeList: (serve: { pid: number; protocol: string; remote: string }) => void
|
||||||
|
removeServeList: (pid: number) => void
|
||||||
|
|
||||||
|
remotes: string[]
|
||||||
|
setRemotes: (remotes: string[]) => void
|
||||||
|
addRemote: (remote: string) => void
|
||||||
|
removeRemote: (remote: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PersistedState {
|
||||||
|
remoteConfigList: Record<string, RemoteConfig>
|
||||||
|
setRemoteConfig: (remote: string, config: RemoteConfig) => void
|
||||||
|
mergeRemoteConfig: (remote: string, config: RemoteConfig) => void
|
||||||
|
|
||||||
|
disabledActions: ('mount' | 'sync' | 'copy' | 'serve')[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStorage = (store: LazyStore): StateStorage => ({
|
||||||
|
getItem: async (name: string): Promise<string | null> => {
|
||||||
|
console.log('getItem', { name })
|
||||||
|
return (await store.get(name)) || null
|
||||||
|
},
|
||||||
|
setItem: async (name: string, value: string): Promise<void> => {
|
||||||
|
console.log('setItem', { name, value })
|
||||||
|
await store.set(name, value)
|
||||||
|
await store.save()
|
||||||
|
},
|
||||||
|
removeItem: async (name: string): Promise<void> => {
|
||||||
|
console.log('removeItem', { name })
|
||||||
|
await store.delete(name)
|
||||||
|
await store.save()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const useStore = create<State>()(
|
||||||
|
shared(
|
||||||
|
(set) => ({
|
||||||
|
count: 0,
|
||||||
|
anotherCount: 0,
|
||||||
|
increment: () => set((state) => ({ count: state.count + 1 })),
|
||||||
|
|
||||||
|
rcloneLoaded: false,
|
||||||
|
mountedRemotes: {},
|
||||||
|
|
||||||
|
serveList: [],
|
||||||
|
setServeList: (serve: { pid: number; protocol: string; remote: string }) =>
|
||||||
|
set((state) => ({ serveList: [...state.serveList, serve] })),
|
||||||
|
removeServeList: (pid: number) =>
|
||||||
|
set((state) => ({ serveList: state.serveList.filter((s) => s.pid !== pid) })),
|
||||||
|
|
||||||
|
remotes: [],
|
||||||
|
setRemotes: (remotes: string[]) => set((_) => ({ remotes })),
|
||||||
|
addRemote: (remote: string) =>
|
||||||
|
set((state) => ({ remotes: [...state.remotes, remote] })),
|
||||||
|
removeRemote: (remote: string) =>
|
||||||
|
set((state) => ({ remotes: state.remotes.filter((r) => r !== remote) })),
|
||||||
|
}),
|
||||||
|
{ name: 'shared-store' }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
export const usePersistedStore = create<PersistedState>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
remoteConfigList: {},
|
||||||
|
setRemoteConfig: (remote: string, config: Record<string, any>) =>
|
||||||
|
set((state) => ({
|
||||||
|
remoteConfigList: { ...state.remoteConfigList, [remote]: config },
|
||||||
|
})),
|
||||||
|
mergeRemoteConfig: (remote: string, config: Record<string, any>) =>
|
||||||
|
set((state) => ({
|
||||||
|
remoteConfigList: {
|
||||||
|
...state.remoteConfigList,
|
||||||
|
[remote]: { ...state.remoteConfigList[remote], ...config },
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
|
||||||
|
disabledActions: [],
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'store',
|
||||||
|
storage: createJSONStorage(() => getStorage(store)),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// useStore.persist.onFinishHydration(() => {
|
||||||
|
// console.log('onFinishHydration')
|
||||||
|
// })
|
||||||
|
|
||||||
|
store.onKeyChange('store', async (_) => {
|
||||||
|
await usePersistedStore.persist.rehydrate()
|
||||||
|
})
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import type { TrayIconEvent } from '@tauri-apps/api/tray'
|
||||||
|
import { TrayIcon } from '@tauri-apps/api/tray'
|
||||||
|
import { getAllWindows } from '@tauri-apps/api/window'
|
||||||
|
import { handleIconState } from '@tauri-apps/plugin-positioner'
|
||||||
|
import { buildMenu } from './menu'
|
||||||
|
import { resetMainWindow } from './window'
|
||||||
|
|
||||||
|
export async function getMainTray() {
|
||||||
|
return await TrayIcon.getById('main-tray')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLoadingTray() {
|
||||||
|
return await TrayIcon.getById('loading-tray')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function triggerTrayRebuild() {
|
||||||
|
return getAllWindows().then((windows) => {
|
||||||
|
windows.find((w) => w.label === 'main')?.emit('rebuild-tray')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to update the tray menu
|
||||||
|
export async function rebuildTrayMenu() {
|
||||||
|
const tray = await getMainTray()
|
||||||
|
if (!tray) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const newMenu = await buildMenu()
|
||||||
|
await tray.setMenu(newMenu)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onTrayAction(event: TrayIconEvent) {
|
||||||
|
await handleIconState(event)
|
||||||
|
|
||||||
|
if (event.type === 'Click') {
|
||||||
|
console.log('Tray clicked:', event)
|
||||||
|
|
||||||
|
await resetMainWindow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize the tray
|
||||||
|
export async function initTray(): Promise<void> {
|
||||||
|
try {
|
||||||
|
console.log('initTray')
|
||||||
|
const menu = await buildMenu()
|
||||||
|
console.log('built menu')
|
||||||
|
|
||||||
|
await TrayIcon.getById('loading-tray').then((t) => t?.setVisible(false))
|
||||||
|
console.log('set loading tray to false')
|
||||||
|
|
||||||
|
await TrayIcon.new({
|
||||||
|
id: 'main-tray',
|
||||||
|
icon: 'icons/icon.png',
|
||||||
|
tooltip: 'S-Tray App',
|
||||||
|
menu,
|
||||||
|
menuOnLeftClick: true,
|
||||||
|
action: onTrayAction,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create tray:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function initLoadingTray() {
|
||||||
|
const loadingTray = await TrayIcon.new({
|
||||||
|
id: 'loading-tray',
|
||||||
|
icon: 'icons/favicon/frame_00_delay-0.1s.png',
|
||||||
|
})
|
||||||
|
|
||||||
|
let currentIcon = 1
|
||||||
|
|
||||||
|
setInterval(async () => {
|
||||||
|
if (currentIcon > 17) {
|
||||||
|
currentIcon = 1
|
||||||
|
}
|
||||||
|
await loadingTray?.setIcon(
|
||||||
|
`icons/favicon/frame_${currentIcon < 10 ? '0' : ''}${currentIcon}_delay-0.1s.png`
|
||||||
|
)
|
||||||
|
currentIcon = currentIcon + 1
|
||||||
|
}, 200)
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { WebviewWindow } from '@tauri-apps/api/webviewWindow'
|
||||||
|
import { LogicalSize, PhysicalSize, currentMonitor, getAllWindows } from '@tauri-apps/api/window'
|
||||||
|
import { Position, moveWindow } from '@tauri-apps/plugin-positioner'
|
||||||
|
|
||||||
|
export async function resetMainWindow() {
|
||||||
|
const window = await getAllWindows().then((w) => w.find((w) => w.label === 'main'))
|
||||||
|
if (!window) return
|
||||||
|
|
||||||
|
await window.setSize(new PhysicalSize(0, 0))
|
||||||
|
await window.center()
|
||||||
|
await window.hide()
|
||||||
|
await window.setAlwaysOnTop(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openFullWindow({
|
||||||
|
name,
|
||||||
|
url,
|
||||||
|
}: {
|
||||||
|
name: string
|
||||||
|
url: string
|
||||||
|
}) {
|
||||||
|
const w = new WebviewWindow(name, {
|
||||||
|
height: 0,
|
||||||
|
width: 0,
|
||||||
|
visibleOnAllWorkspaces: false,
|
||||||
|
alwaysOnTop: false,
|
||||||
|
resizable: true,
|
||||||
|
visible: true,
|
||||||
|
focus: true,
|
||||||
|
title: name,
|
||||||
|
decorations: true,
|
||||||
|
url: url,
|
||||||
|
})
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||||
|
|
||||||
|
const size = await currentMonitor().then((m) => m?.size)
|
||||||
|
|
||||||
|
if (!size) return
|
||||||
|
|
||||||
|
await w.hide()
|
||||||
|
await w.setSize(size)
|
||||||
|
await w.center()
|
||||||
|
await w.show()
|
||||||
|
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openWindow({
|
||||||
|
name,
|
||||||
|
url,
|
||||||
|
width = 740,
|
||||||
|
height = 600,
|
||||||
|
}: {
|
||||||
|
name: string
|
||||||
|
url: string
|
||||||
|
width?: number
|
||||||
|
height?: number
|
||||||
|
}) {
|
||||||
|
const w = new WebviewWindow(name, {
|
||||||
|
height: 0,
|
||||||
|
width: 0,
|
||||||
|
resizable: false,
|
||||||
|
visibleOnAllWorkspaces: false,
|
||||||
|
alwaysOnTop: true,
|
||||||
|
visible: true,
|
||||||
|
focus: true,
|
||||||
|
title: name,
|
||||||
|
decorations: true,
|
||||||
|
url: url,
|
||||||
|
// parent: 'main',
|
||||||
|
})
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||||
|
|
||||||
|
await w.hide()
|
||||||
|
await w.setSize(new LogicalSize(width, height))
|
||||||
|
await w.center()
|
||||||
|
await w.show()
|
||||||
|
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openTrayWindow({
|
||||||
|
name,
|
||||||
|
url,
|
||||||
|
}: {
|
||||||
|
name: string
|
||||||
|
url: string
|
||||||
|
}) {
|
||||||
|
const w = new WebviewWindow(name, {
|
||||||
|
height: 0,
|
||||||
|
width: 0,
|
||||||
|
resizable: false,
|
||||||
|
visibleOnAllWorkspaces: true,
|
||||||
|
alwaysOnTop: true,
|
||||||
|
visible: true,
|
||||||
|
focus: true,
|
||||||
|
title: name,
|
||||||
|
decorations: false,
|
||||||
|
url: url,
|
||||||
|
})
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1500))
|
||||||
|
|
||||||
|
await w.hide()
|
||||||
|
await w.setSize(new LogicalSize(400, 600))
|
||||||
|
// await w.center()
|
||||||
|
await w.show()
|
||||||
|
await moveWindow(Position.TopRight)
|
||||||
|
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function lockWindows(ids?: string[]) {
|
||||||
|
const windows = await getAllWindows()
|
||||||
|
const lockedWindows = ids ? windows.filter((w) => ids.includes(w.label)) : windows
|
||||||
|
await Promise.all(lockedWindows.map((w) => w.setClosable(false)))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unlockWindows(ids?: string[]) {
|
||||||
|
const windows = await getAllWindows()
|
||||||
|
const unlockedWindows = ids ? windows.filter((w) => ids.includes(w.label)) : windows
|
||||||
|
await Promise.all(unlockedWindows.map((w) => w.setClosable(true)))
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { getCurrentWindow } from '@tauri-apps/api/window'
|
||||||
|
import { message } from '@tauri-apps/plugin-dialog'
|
||||||
|
import { debug, error, info, trace, warn } from '@tauri-apps/plugin-log'
|
||||||
|
import { exit } from '@tauri-apps/plugin-process'
|
||||||
|
import { Command } from '@tauri-apps/plugin-shell'
|
||||||
|
import { listRemotes } from './lib/rclone'
|
||||||
|
import { useStore } from './lib/store'
|
||||||
|
import { initLoadingTray, initTray, rebuildTrayMenu } from './lib/tray'
|
||||||
|
|
||||||
|
// forward console logs in webviews to the tauri logger, so they show up in the console
|
||||||
|
function forwardConsole(
|
||||||
|
fnName: 'log' | 'debug' | 'info' | 'warn' | 'error',
|
||||||
|
logger: (message: string) => Promise<void>
|
||||||
|
) {
|
||||||
|
const original = console[fnName]
|
||||||
|
console[fnName] = (message, ...args) => {
|
||||||
|
original(message, ...args)
|
||||||
|
logger(
|
||||||
|
`${message} ${args?.map((arg) => (typeof arg === 'string' ? arg : JSON.stringify(arg))).join(' ')}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
forwardConsole('log', trace)
|
||||||
|
forwardConsole('debug', debug)
|
||||||
|
forwardConsole('info', info)
|
||||||
|
forwardConsole('warn', warn)
|
||||||
|
forwardConsole('error', error)
|
||||||
|
|
||||||
|
console.log('main')
|
||||||
|
console.error('main')
|
||||||
|
|
||||||
|
async function startRclone() {
|
||||||
|
try {
|
||||||
|
const remotes = await listRemotes()
|
||||||
|
console.log('rclone already running', remotes)
|
||||||
|
useStore.setState({ rcloneLoaded: true })
|
||||||
|
useStore.setState({ remotes: remotes })
|
||||||
|
return
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to start rclone', e)
|
||||||
|
}
|
||||||
|
|
||||||
|
const command = Command.sidecar('binaries/rclone', [
|
||||||
|
'rcd',
|
||||||
|
'--rc-no-auth',
|
||||||
|
'--rc-serve',
|
||||||
|
// '-rc-addr',
|
||||||
|
// ':5572',
|
||||||
|
])
|
||||||
|
|
||||||
|
command.addListener('close', async (event) => {
|
||||||
|
console.log('close', event)
|
||||||
|
|
||||||
|
if (event.code === 1 || event.code === 143) {
|
||||||
|
await message('Rclone has crashed', {
|
||||||
|
title: 'Error',
|
||||||
|
kind: 'error',
|
||||||
|
})
|
||||||
|
await exit(0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
command.addListener('error', (event) => {
|
||||||
|
console.log('error', event)
|
||||||
|
})
|
||||||
|
|
||||||
|
// console.log('command', command)
|
||||||
|
|
||||||
|
const childProcess = await command.spawn()
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||||
|
|
||||||
|
useStore.setState({ rcloneLoaded: true })
|
||||||
|
|
||||||
|
const remotes = await listRemotes()
|
||||||
|
console.log('remotes', remotes)
|
||||||
|
useStore.setState({ remotes: remotes })
|
||||||
|
|
||||||
|
// console.log('childProcess', JSON.stringify(childProcess)) // prints `pid`
|
||||||
|
|
||||||
|
// console.log('command', JSON.stringify(command))
|
||||||
|
}
|
||||||
|
|
||||||
|
getCurrentWindow().listen('tauri://close-requested', async (e) => {
|
||||||
|
console.log('MAIN window close requested')
|
||||||
|
})
|
||||||
|
|
||||||
|
getCurrentWindow().listen('rebuild-tray', async (e) => {
|
||||||
|
console.log('MAIN window rebuild-tray requested')
|
||||||
|
await rebuildTrayMenu()
|
||||||
|
})
|
||||||
|
|
||||||
|
initLoadingTray()
|
||||||
|
.then(() => startRclone())
|
||||||
|
.then(() => initTray())
|
||||||
|
|
||||||
|
// await initLoadingTray()
|
||||||
|
// await startRclone()
|
||||||
|
// await initTray()
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
{
|
||||||
|
"name": "s-tray",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "node scripts/buildExternal.js && vite",
|
||||||
|
"build": "node scripts/buildExternal.js && tsc && vite build",
|
||||||
|
"preview": "node scripts/buildExternal.js && vite preview",
|
||||||
|
"tauri": "tauri",
|
||||||
|
"info": "tauri info",
|
||||||
|
"build:mac": "tauri build --target aarch64-apple-darwin --debug",
|
||||||
|
"build:mac:universal": "tauri build --target universal-apple-darwin",
|
||||||
|
"build:mac:intel": "tauri build --target x86_64-apple-darwin",
|
||||||
|
"build:windows": "tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc",
|
||||||
|
"below targets do not work": "echo 'you heard me'",
|
||||||
|
"build:windows:arm": "tauri build --runner cargo-xwin --target aarch64-pc-windows-msvc",
|
||||||
|
"build:linux:arm": "tauri build --target aarch64-unknown-linux-gnu",
|
||||||
|
"build:wasm": "tauri build --target wasm32-unknown-unknown"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nextui-org/react": "^2.6.11",
|
||||||
|
"@tauri-apps/api": "~2.2.0",
|
||||||
|
"@tauri-apps/plugin-dialog": "^2.2.0",
|
||||||
|
"@tauri-apps/plugin-fs": "^2.2.0",
|
||||||
|
"@tauri-apps/plugin-http": "^2.2.0",
|
||||||
|
"@tauri-apps/plugin-log": "^2.2.0",
|
||||||
|
"@tauri-apps/plugin-notification": "^2.2.1",
|
||||||
|
"@tauri-apps/plugin-opener": "^2.2.5",
|
||||||
|
"@tauri-apps/plugin-os": "^2.2.0",
|
||||||
|
"@tauri-apps/plugin-positioner": "^2.2.0",
|
||||||
|
"@tauri-apps/plugin-process": "^2.2.0",
|
||||||
|
"@tauri-apps/plugin-shell": "^2.2.0",
|
||||||
|
"@tauri-apps/plugin-store": "^2.2.0",
|
||||||
|
"@tauri-apps/plugin-window-state": "^2.2.0",
|
||||||
|
"framer-motion": "^11.17.0",
|
||||||
|
"lucide-react": "^0.471.0",
|
||||||
|
"p-retry": "^6.2.1",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^6.28.1",
|
||||||
|
"use-broadcast-ts": "^2.0.0",
|
||||||
|
"zustand": "^5.0.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@biomejs/biome": "^1.9.4",
|
||||||
|
"@tauri-apps/cli": "~2.2.5",
|
||||||
|
"@types/node": "^22.10.7",
|
||||||
|
"@types/react": "^18.3.1",
|
||||||
|
"@types/react-dom": "^18.3.1",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"postcss": "^8.4.49",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
|
"typescript": "~5.6.2",
|
||||||
|
"vite": "^6.0.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,495 @@
|
|||||||
|
<!--
|
||||||
|
This file is needed in order to add dark mode to the default Rclone browser.
|
||||||
|
It's not pretty, but at the same time Adobe's .PSD implementation exists.
|
||||||
|
-->
|
||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<style>
|
||||||
|
html {
|
||||||
|
overscroll-behavior: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header positioning */
|
||||||
|
header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
padding: 10px;
|
||||||
|
background-color: #f2f2f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Meta div positioning */
|
||||||
|
.meta {
|
||||||
|
position: sticky;
|
||||||
|
top: 63px; /* Adjust based on header height + padding */
|
||||||
|
z-index: 99;
|
||||||
|
/* padding: 10px; */
|
||||||
|
background-color: #f2f2f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner {
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
border: 3px solid #f3f3f3;
|
||||||
|
border-top: 3px solid #3498db;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-spinner {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 20px;
|
||||||
|
right: 20px;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border: 2px solid #f3f3f3;
|
||||||
|
border-top: 2px solid #3498db;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
display: none;
|
||||||
|
z-index: 9999;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: translate(-50%, -50%) rotate(0deg); }
|
||||||
|
100% { transform: translate(-50%, -50%) rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode styles */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
color-scheme: dark !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
background-color: #1d1d1d !important;
|
||||||
|
min-height: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: #1d1d1d !important;
|
||||||
|
color: #e0e0e0 !important ;
|
||||||
|
min-height: 100vh !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header styles */
|
||||||
|
header {
|
||||||
|
background-color: #242424 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 a {
|
||||||
|
color: #e0e0e0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table styles */
|
||||||
|
table {
|
||||||
|
background-color: #242424 !important;
|
||||||
|
border-color: #404040 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr {
|
||||||
|
border-color: #404040 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:hover {
|
||||||
|
background-color: #2a2a2a !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background-color: #2c2c2c !important;
|
||||||
|
color: #e0e0e0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
border-color: #404040 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Links */
|
||||||
|
a {
|
||||||
|
color: #66b3ff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:visited {
|
||||||
|
color: #b366ff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: #99ccff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Filter input */
|
||||||
|
#filter {
|
||||||
|
background-color: #2c2c2c !important;
|
||||||
|
color: #e0e0e0 !important;
|
||||||
|
border: 1px solid #404040 !important;
|
||||||
|
padding: 5px 10px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#filter:focus {
|
||||||
|
outline: 1px solid #66b3ff !important;
|
||||||
|
border-color: #66b3ff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* SVG icons */
|
||||||
|
svg {
|
||||||
|
filter: invert(0.8) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Go up link */
|
||||||
|
.goup {
|
||||||
|
color: #e0e0e0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Time display */
|
||||||
|
time {
|
||||||
|
color: #b0b0b0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sort icons */
|
||||||
|
.icon.sort {
|
||||||
|
opacity: 0.8 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Meta section */
|
||||||
|
.meta {
|
||||||
|
border-color: #404040 !important;
|
||||||
|
background-color: #242424 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure text remains readable */
|
||||||
|
td[data-order="-1"],
|
||||||
|
.hideable {
|
||||||
|
color: #b0b0b0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-link {
|
||||||
|
background-color: #2e7d32 !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-link:hover {
|
||||||
|
background-color: #1b5e20 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
thead {
|
||||||
|
background-color: #242424 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Download button styles */
|
||||||
|
.download-cell {
|
||||||
|
width: 30px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-link {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-decoration: none;
|
||||||
|
background-color: #4CAF50;
|
||||||
|
color: white !important;
|
||||||
|
font-size: 0.8em;
|
||||||
|
margin-left: 8px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-link:hover {
|
||||||
|
background-color: #45a049;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Adjust the name span to align with download button */
|
||||||
|
.name {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table header positioning */
|
||||||
|
thead {
|
||||||
|
position: sticky;
|
||||||
|
top: 109px; /* header + meta */
|
||||||
|
z-index: 98;
|
||||||
|
background-color: #f2f2f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure table layout works with sticky header */
|
||||||
|
table {
|
||||||
|
border-collapse: separate;
|
||||||
|
border-spacing: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<script>
|
||||||
|
|
||||||
|
console.log(window.__TAURI__)
|
||||||
|
|
||||||
|
// Create a persistent spinner element outside the body
|
||||||
|
const navSpinner = document.createElement('div');
|
||||||
|
navSpinner.id = 'nav-spinner';
|
||||||
|
navSpinner.className = 'nav-spinner';
|
||||||
|
|
||||||
|
// Function to show/hide navigation spinner
|
||||||
|
function toggleNavSpinner(show) {
|
||||||
|
if (!document.getElementById('nav-spinner')) {
|
||||||
|
document.body.appendChild(navSpinner);
|
||||||
|
}
|
||||||
|
navSpinner.style.display = show ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to check if we're at root URL
|
||||||
|
function isRootUrl(url) {
|
||||||
|
try {
|
||||||
|
const parsedUrl = new URL(url);
|
||||||
|
// Remove trailing slash for consistent comparison
|
||||||
|
const path = parsedUrl.pathname.replace(/\/$/, '');
|
||||||
|
// Count segments (excluding empty strings from leading/trailing slashes)
|
||||||
|
const segments = path.split('/').filter(Boolean);
|
||||||
|
return segments.length <= 1;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing URL:', e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to hide "Go up" row if at root
|
||||||
|
function hideGoUpIfRoot(url) {
|
||||||
|
const goUpRow = document.querySelector('tr:has(span.goup)');
|
||||||
|
if (goUpRow) {
|
||||||
|
goUpRow.style.display = isRootUrl(url) ? 'none' : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to fetch and modify webpage content
|
||||||
|
async function fetchAndInjectContent(url, isInitialLoad = false) {
|
||||||
|
try {
|
||||||
|
// Only show navigation spinner for non-initial loads
|
||||||
|
if (!isInitialLoad) {
|
||||||
|
toggleNavSpinner(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetch = window.__TAURI__.http.fetch
|
||||||
|
// Fetch the webpage content
|
||||||
|
const response = await fetch(url);
|
||||||
|
const html = await response.text();
|
||||||
|
|
||||||
|
// Create a temporary DOM parser
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const doc = parser.parseFromString(html, 'text/html');
|
||||||
|
|
||||||
|
// Modify the title if it exists
|
||||||
|
const title = doc.querySelector('title');
|
||||||
|
if (title) {
|
||||||
|
title.textContent = 'Modified: ' + title.textContent;
|
||||||
|
// Update current page title
|
||||||
|
document.title = title.textContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove existing injected styles
|
||||||
|
const existingStyles = document.querySelectorAll('style[data-injected="true"]');
|
||||||
|
existingStyles.forEach(style => style.remove());
|
||||||
|
|
||||||
|
// Copy over all style elements from the fetched document
|
||||||
|
const newStyles = doc.getElementsByTagName('style');
|
||||||
|
Array.from(newStyles).forEach(style => {
|
||||||
|
const clonedStyle = style.cloneNode(true);
|
||||||
|
clonedStyle.setAttribute('data-injected', 'true');
|
||||||
|
document.head.appendChild(clonedStyle);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store the spinner temporarily
|
||||||
|
const spinnerParent = navSpinner.parentElement;
|
||||||
|
if (spinnerParent) {
|
||||||
|
spinnerParent.removeChild(navSpinner);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace only the body content
|
||||||
|
document.body.innerHTML = doc.body.innerHTML;
|
||||||
|
|
||||||
|
// Hide "Go up" row if at root
|
||||||
|
hideGoUpIfRoot(url);
|
||||||
|
|
||||||
|
// Add download buttons to the table
|
||||||
|
addDownloadButtons();
|
||||||
|
|
||||||
|
// Re-add the spinner
|
||||||
|
document.body.appendChild(navSpinner);
|
||||||
|
|
||||||
|
// Update the URL in the address bar without reloading
|
||||||
|
window.history.pushState({}, '', `?url=${encodeURIComponent(url)}`);
|
||||||
|
|
||||||
|
// Re-add our event handlers
|
||||||
|
setupEventHandlers();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching webpage:', error);
|
||||||
|
document.body.innerHTML = `<div>Error loading webpage: ${error.message}</div>`;
|
||||||
|
// Make sure spinner is still available in error case
|
||||||
|
document.body.appendChild(navSpinner);
|
||||||
|
} finally {
|
||||||
|
toggleNavSpinner(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to handle clicks on links
|
||||||
|
function handleLinkClick(event) {
|
||||||
|
const link = event.target.closest('a');
|
||||||
|
if (!link) return;
|
||||||
|
|
||||||
|
// Don't interfere with download links
|
||||||
|
if (link.hasAttribute('download')) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const save = window.__TAURI__.dialog.save;
|
||||||
|
const writeTextFile = window.__TAURI__.fs.writeTextFile;
|
||||||
|
const writeFile = window.__TAURI__.fs.writeFile;
|
||||||
|
const http = window.__TAURI__.http;
|
||||||
|
|
||||||
|
// Get the full URL for the download
|
||||||
|
const href = link.getAttribute('href');
|
||||||
|
const currentUrl = new URLSearchParams(window.location.search).get('url');
|
||||||
|
const baseUrl = new URL(currentUrl).href;
|
||||||
|
const downloadUrl = new URL(href, baseUrl).href;
|
||||||
|
|
||||||
|
// Get the filename from the download attribute or URL
|
||||||
|
const filename = link.getAttribute('download') || href.split('/').pop();
|
||||||
|
|
||||||
|
save({
|
||||||
|
title: 'Save File',
|
||||||
|
defaultPath: filename
|
||||||
|
}).then(async (result) => {
|
||||||
|
if (result) {
|
||||||
|
try {
|
||||||
|
console.log('downloading', downloadUrl)
|
||||||
|
// Fetch the file contents
|
||||||
|
const response = await http.fetch(downloadUrl, {
|
||||||
|
method: 'GET',
|
||||||
|
responseType: 2 // ResponseType.Binary
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.arrayBuffer();
|
||||||
|
|
||||||
|
// Write the file contents
|
||||||
|
await writeFile(result, data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to download file:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const href = link.getAttribute('href');
|
||||||
|
if (!href || href === '') {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle different types of URLs
|
||||||
|
let fullUrl;
|
||||||
|
if (href.startsWith('http')) {
|
||||||
|
fullUrl = href;
|
||||||
|
} else if (href.startsWith('//')) {
|
||||||
|
fullUrl = 'https:' + href;
|
||||||
|
} else if (href.startsWith('/')) {
|
||||||
|
// Get the base URL from the current URL parameter
|
||||||
|
const currentUrl = new URLSearchParams(window.location.search).get('url');
|
||||||
|
const baseUrl = new URL(currentUrl).origin;
|
||||||
|
fullUrl = baseUrl + href;
|
||||||
|
} else {
|
||||||
|
// Relative URL
|
||||||
|
const currentUrl = new URLSearchParams(window.location.search).get('url');
|
||||||
|
const baseUrl = new URL(currentUrl).href;
|
||||||
|
fullUrl = new URL(href, baseUrl).href;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the target URL is the same as the current one
|
||||||
|
const currentPageUrl = new URLSearchParams(window.location.search).get('url');
|
||||||
|
console.log('fullUrl', fullUrl)
|
||||||
|
console.log('currentPageUrl', currentPageUrl)
|
||||||
|
if (fullUrl === currentPageUrl) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent default navigation
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
// Fetch and inject the new content
|
||||||
|
fetchAndInjectContent(fullUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to set up event handlers
|
||||||
|
function setupEventHandlers() {
|
||||||
|
// Handle all click events at the document level
|
||||||
|
document.addEventListener('click', handleLinkClick);
|
||||||
|
|
||||||
|
// Prevent form submissions and handle them manually
|
||||||
|
document.addEventListener('submit', (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
// You can add form handling logic here if needed
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to modify the table structure after content load
|
||||||
|
function addDownloadButtons() {
|
||||||
|
// Find all file rows
|
||||||
|
const fileRows = document.querySelectorAll('tr.file');
|
||||||
|
fileRows.forEach(row => {
|
||||||
|
// Check if this is a file (not a folder) by looking for the file icon
|
||||||
|
const isFolder = row.querySelector('svg use').getAttribute('xlink:href') === '#folder';
|
||||||
|
|
||||||
|
if (!isFolder) {
|
||||||
|
// Get the file link container
|
||||||
|
const nameSpan = row.querySelector('.name');
|
||||||
|
if (nameSpan) {
|
||||||
|
// Create download link
|
||||||
|
const downloadLink = document.createElement('a');
|
||||||
|
const fileLink = nameSpan.querySelector('a');
|
||||||
|
// keep only the last part of the url, otherwise it uses the wrong root url
|
||||||
|
downloadLink.href = fileLink.href.split('/').pop();
|
||||||
|
downloadLink.className = 'download-link';
|
||||||
|
downloadLink.setAttribute('download', '');
|
||||||
|
downloadLink.textContent = '⇩';
|
||||||
|
nameSpan.appendChild(downloadLink);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for DOM to be ready before initializing
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// Initial setup
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const url = urlParams.get('url');
|
||||||
|
if (url) {
|
||||||
|
// Pass true to indicate this is the initial load
|
||||||
|
fetchAndInjectContent(url, true);
|
||||||
|
} else {
|
||||||
|
document.body.innerHTML = '<div>Error: No URL provided. Use ?url= parameter.</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up initial event handlers
|
||||||
|
setupEventHandlers();
|
||||||
|
|
||||||
|
// Handle browser back/forward buttons
|
||||||
|
window.addEventListener('popstate', () => {
|
||||||
|
const newUrl = new URLSearchParams(window.location.search).get('url');
|
||||||
|
if (newUrl) {
|
||||||
|
fetchAndInjectContent(newUrl);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="loading-spinner"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
|
||||||
|
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1,9 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<script type="module" src="/out.js"></script>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body> </body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,60 @@
|
|||||||
|
import { execSync } from 'child_process'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { fileURLToPath } from 'url'
|
||||||
|
import { unlink } from 'fs/promises'
|
||||||
|
|
||||||
|
// Get the project root directory
|
||||||
|
const __dirname = fileURLToPath(new URL('.', import.meta.url))
|
||||||
|
const projectRoot = join(__dirname, '..')
|
||||||
|
|
||||||
|
async function buildExternal() {
|
||||||
|
try {
|
||||||
|
// Compile TypeScript using local tsc
|
||||||
|
console.log('Compiling TypeScript...')
|
||||||
|
execSync(
|
||||||
|
'npx tsc main.ts --target es2020 --module esnext --moduleResolution bundler --listEmittedFiles',
|
||||||
|
{
|
||||||
|
stdio: 'inherit',
|
||||||
|
cwd: projectRoot,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Bundle with esbuild
|
||||||
|
console.log('Bundling with esbuild...')
|
||||||
|
execSync(
|
||||||
|
'npx esbuild main.js --bundle --format=esm --target=esnext --outfile=out.js --minify --legal-comments=none',
|
||||||
|
{
|
||||||
|
stdio: 'inherit',
|
||||||
|
cwd: projectRoot,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Move output file to public directory
|
||||||
|
console.log('Moving output file to public directory...')
|
||||||
|
execSync('mv ./out.js ./public/out.js', {
|
||||||
|
stdio: 'inherit',
|
||||||
|
cwd: projectRoot,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Clean up temporary files
|
||||||
|
console.log('Cleaning up...')
|
||||||
|
await Promise.all([
|
||||||
|
// Remove lib/*.js files
|
||||||
|
execSync('rm -rf ./lib/**.js', { stdio: 'inherit', cwd: projectRoot }),
|
||||||
|
// Remove main.js
|
||||||
|
unlink(join(projectRoot, 'main.js')).catch(() => {}), // Ignore if file doesn't exist
|
||||||
|
])
|
||||||
|
|
||||||
|
console.log('Build completed successfully!')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Build failed:', error)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If script is run directly (not imported)
|
||||||
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||||
|
buildExternal()
|
||||||
|
}
|
||||||
|
|
||||||
|
export default buildExternal
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Generated by Cargo
|
||||||
|
# will have compiled files and executables
|
||||||
|
/target/
|
||||||
|
/gen/schemas
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
[package]
|
||||||
|
name = "app"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "A Tauri App"
|
||||||
|
authors = ["you"]
|
||||||
|
license = ""
|
||||||
|
repository = ""
|
||||||
|
edition = "2021"
|
||||||
|
rust-version = "1.77.2"
|
||||||
|
|
||||||
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "app_lib"
|
||||||
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "2.0.4", features = [] }
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde_json = "1.0"
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
log = "0.4"
|
||||||
|
tauri = { version = "2.2.0", features = [ "tray-icon", "image-ico",
|
||||||
|
"image-png", "config-json5" ] }
|
||||||
|
tauri-plugin-log = "2.2.0"
|
||||||
|
tauri-plugin-shell = "2.2.0"
|
||||||
|
tauri-plugin-dialog = "2.2.0"
|
||||||
|
tauri-plugin-fs = "2.2.0"
|
||||||
|
tauri-plugin-opener = "2.2.3"
|
||||||
|
tauri-plugin-positioner = { version = "2.2.0", features = ["tray-icon"] }
|
||||||
|
tauri-plugin-http = "2.2.0"
|
||||||
|
tauri-plugin-store = "2.2.0"
|
||||||
|
tauri-plugin-process = "2"
|
||||||
|
tauri-plugin-notification = { version = "2.0.0", features = [ "windows7-compat" ] }
|
||||||
|
tauri-plugin-os = "2"
|
||||||
|
fix-path-env = { git = "https://github.com/tauri-apps/fix-path-env-rs" }
|
||||||
|
|
||||||
|
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||||
|
tauri-plugin-single-instance = "2.2.0"
|
||||||
|
|
||||||
|
[target."cfg(target_os = \"macos\")".dependencies]
|
||||||
|
cocoa = "0.26"
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.security.automation.apple-events</key><true/>
|
||||||
|
<key>com.apple.security.device.audio-input</key><true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>NSCameraUsageDescription</key>
|
||||||
|
<string>Please allow camera</string>
|
||||||
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
<string>Please allow microphone</string>
|
||||||
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
|
<false/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.security.app-sandbox</key><true/>
|
||||||
|
<key>com.apple.security.network.client</key><true/>
|
||||||
|
<key>com.apple.security.automation.apple-events</key><true/>
|
||||||
|
<key>com.apple.security.device.microphone</key><true/>
|
||||||
|
<key>com.apple.security.device.audio-input</key><true/>
|
||||||
|
|
||||||
|
<key>com.apple.application-identifier</key>
|
||||||
|
<string>{TEAM_ID}.{BUNDLE_ID}</string>
|
||||||
|
<key>com.apple.developer.team-identifier</key>
|
||||||
|
<string>{TEAM_ID}</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>NSCameraUsageDescription</key>
|
||||||
|
<string>Please allow camera</string>
|
||||||
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
<string>Please allow microphone</string>
|
||||||
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
|
<false/>
|
||||||
|
<key>NSStatusItem</key>
|
||||||
|
<true/>
|
||||||
|
<key>LSUIElement</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "enables the default permissions",
|
||||||
|
"windows": [
|
||||||
|
"main",
|
||||||
|
"Copy",
|
||||||
|
"Sync",
|
||||||
|
"Mount",
|
||||||
|
"Settings",
|
||||||
|
"Jobs",
|
||||||
|
"Browse",
|
||||||
|
"Test",
|
||||||
|
"Test2",
|
||||||
|
"Test3"
|
||||||
|
],
|
||||||
|
"permissions": [
|
||||||
|
"core:default",
|
||||||
|
"core:app:allow-app-hide",
|
||||||
|
"core:app:allow-app-show",
|
||||||
|
"core:app:allow-default-window-icon",
|
||||||
|
"core:app:allow-name",
|
||||||
|
"core:app:allow-set-app-theme",
|
||||||
|
"core:app:allow-tauri-version",
|
||||||
|
"core:app:allow-version",
|
||||||
|
"core:menu:default",
|
||||||
|
"core:menu:allow-append",
|
||||||
|
"core:menu:allow-prepend",
|
||||||
|
"core:menu:allow-insert",
|
||||||
|
"core:menu:allow-remove",
|
||||||
|
"core:menu:allow-set-icon",
|
||||||
|
"core:menu:allow-create-default",
|
||||||
|
"core:menu:allow-get",
|
||||||
|
"core:menu:allow-is-checked",
|
||||||
|
"core:menu:allow-is-enabled",
|
||||||
|
"core:menu:allow-items",
|
||||||
|
"core:menu:allow-new",
|
||||||
|
"core:menu:allow-popup",
|
||||||
|
"core:menu:allow-remove-at",
|
||||||
|
"core:menu:allow-set-accelerator",
|
||||||
|
"core:menu:allow-set-checked",
|
||||||
|
"core:menu:allow-set-enabled",
|
||||||
|
"core:menu:allow-set-as-app-menu",
|
||||||
|
"core:menu:allow-set-as-help-menu-for-nsapp",
|
||||||
|
"core:menu:allow-set-as-window-menu",
|
||||||
|
"core:menu:allow-set-as-windows-menu-for-nsapp",
|
||||||
|
"core:menu:allow-set-text",
|
||||||
|
"core:menu:allow-text",
|
||||||
|
"core:tray:default",
|
||||||
|
"core:tray:allow-get-by-id",
|
||||||
|
"core:tray:allow-remove-by-id",
|
||||||
|
"core:tray:allow-set-icon-as-template",
|
||||||
|
"core:tray:allow-set-show-menu-on-left-click",
|
||||||
|
"core:tray:allow-new",
|
||||||
|
"core:tray:allow-set-icon",
|
||||||
|
"core:tray:allow-set-menu",
|
||||||
|
"core:tray:allow-set-tooltip",
|
||||||
|
"core:tray:allow-set-title",
|
||||||
|
"core:tray:allow-set-visible",
|
||||||
|
"core:tray:allow-set-temp-dir-path",
|
||||||
|
"core:resources:default",
|
||||||
|
"core:path:default",
|
||||||
|
"core:event:default",
|
||||||
|
"core:event:allow-emit",
|
||||||
|
"core:event:allow-emit-to",
|
||||||
|
"core:event:allow-listen",
|
||||||
|
"core:event:allow-unlisten",
|
||||||
|
"core:image:default",
|
||||||
|
"core:window:default",
|
||||||
|
"core:window:allow-set-focus",
|
||||||
|
"core:window:allow-show",
|
||||||
|
"core:window:allow-hide",
|
||||||
|
"core:window:allow-set-always-on-top",
|
||||||
|
"core:window:allow-set-size",
|
||||||
|
"core:window:allow-set-position",
|
||||||
|
"core:window:allow-set-enabled",
|
||||||
|
"core:window:allow-start-dragging",
|
||||||
|
"core:window:allow-start-resize-dragging",
|
||||||
|
"core:window:allow-set-decorations",
|
||||||
|
"core:window:allow-set-title",
|
||||||
|
"core:window:allow-set-visible-on-all-workspaces",
|
||||||
|
"core:window:allow-request-user-attention",
|
||||||
|
"core:window:allow-set-fullscreen",
|
||||||
|
"core:window:allow-create",
|
||||||
|
"core:window:allow-title",
|
||||||
|
"core:window:allow-center",
|
||||||
|
"core:window:allow-close",
|
||||||
|
"core:window:allow-destroy",
|
||||||
|
"core:window:allow-get-all-windows",
|
||||||
|
"core:window:allow-maximize",
|
||||||
|
"core:window:allow-minimize",
|
||||||
|
"core:window:allow-unminimize",
|
||||||
|
"core:window:allow-is-minimized",
|
||||||
|
"core:window:allow-is-closable",
|
||||||
|
"core:window:allow-is-decorated",
|
||||||
|
"core:window:allow-is-enabled",
|
||||||
|
"core:window:allow-is-focused",
|
||||||
|
"core:window:allow-is-fullscreen",
|
||||||
|
"core:window:allow-is-maximizable",
|
||||||
|
"core:window:allow-is-minimizable",
|
||||||
|
"core:window:allow-is-visible",
|
||||||
|
"core:window:allow-is-maximized",
|
||||||
|
"core:window:allow-is-resizable",
|
||||||
|
"core:window:allow-cursor-position",
|
||||||
|
"core:window:allow-inner-size",
|
||||||
|
"core:window:allow-inner-position",
|
||||||
|
"core:window:allow-internal-toggle-maximize",
|
||||||
|
"core:window:allow-set-background-color",
|
||||||
|
"core:window:allow-set-effects",
|
||||||
|
"core:window:allow-set-icon",
|
||||||
|
"core:window:allow-set-max-size",
|
||||||
|
"core:window:allow-set-min-size",
|
||||||
|
"core:window:allow-set-cursor-grab",
|
||||||
|
"core:window:allow-set-cursor-icon",
|
||||||
|
"core:window:allow-set-cursor-position",
|
||||||
|
"core:window:allow-set-always-on-bottom",
|
||||||
|
"core:window:allow-set-maximizable",
|
||||||
|
"core:window:allow-set-theme",
|
||||||
|
"core:window:allow-set-badge-count",
|
||||||
|
"core:window:allow-set-badge-label",
|
||||||
|
"core:window:allow-set-closable",
|
||||||
|
"core:window:allow-set-content-protected",
|
||||||
|
"core:window:allow-set-cursor-visible",
|
||||||
|
"core:window:allow-set-ignore-cursor-events",
|
||||||
|
"core:window:allow-set-minimizable",
|
||||||
|
"core:window:allow-set-overlay-icon",
|
||||||
|
"core:window:allow-set-progress-bar",
|
||||||
|
"core:window:allow-set-resizable",
|
||||||
|
"core:window:allow-set-shadow",
|
||||||
|
"core:window:allow-set-size-constraints",
|
||||||
|
"core:window:allow-set-skip-taskbar",
|
||||||
|
"core:window:allow-set-title-bar-style",
|
||||||
|
"core:window:allow-available-monitors",
|
||||||
|
"core:window:allow-current-monitor",
|
||||||
|
"core:window:allow-primary-monitor",
|
||||||
|
"core:window:allow-monitor-from-point",
|
||||||
|
"core:window:allow-outer-position",
|
||||||
|
"core:window:allow-outer-size",
|
||||||
|
"core:window:allow-scale-factor",
|
||||||
|
"core:window:allow-theme",
|
||||||
|
"core:window:allow-toggle-maximize",
|
||||||
|
"core:window:allow-unmaximize",
|
||||||
|
"core:webview:default",
|
||||||
|
"core:webview:allow-create-webview",
|
||||||
|
"core:webview:allow-create-webview-window",
|
||||||
|
"core:webview:allow-set-webview-background-color",
|
||||||
|
"core:webview:allow-set-webview-focus",
|
||||||
|
"core:webview:allow-set-webview-position",
|
||||||
|
"core:webview:allow-set-webview-size",
|
||||||
|
"core:webview:allow-set-webview-zoom",
|
||||||
|
"core:webview:allow-webview-size",
|
||||||
|
"core:webview:allow-webview-close",
|
||||||
|
"core:webview:allow-print",
|
||||||
|
"core:webview:allow-webview-hide",
|
||||||
|
"core:webview:allow-webview-position",
|
||||||
|
"core:webview:allow-webview-show",
|
||||||
|
"core:webview:allow-clear-all-browsing-data",
|
||||||
|
"core:webview:allow-reparent",
|
||||||
|
"core:webview:allow-internal-toggle-devtools",
|
||||||
|
"core:webview:allow-get-all-webviews",
|
||||||
|
"shell:default",
|
||||||
|
"shell:allow-kill",
|
||||||
|
"shell:allow-spawn",
|
||||||
|
"shell:allow-stdin-write",
|
||||||
|
"shell:allow-open",
|
||||||
|
{
|
||||||
|
"identifier": "shell:allow-execute",
|
||||||
|
"allow": [
|
||||||
|
{
|
||||||
|
"name": "rclone",
|
||||||
|
"cmd": "rclone",
|
||||||
|
"args": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "umount",
|
||||||
|
"cmd": "umount",
|
||||||
|
"args": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "qjs",
|
||||||
|
"cmd": "qjs",
|
||||||
|
"args": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "shell:allow-spawn",
|
||||||
|
"allow": [
|
||||||
|
{
|
||||||
|
"name": "binaries/rclone",
|
||||||
|
"args": true,
|
||||||
|
"sidecar": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"log:default",
|
||||||
|
"dialog:default",
|
||||||
|
"fs:read-all",
|
||||||
|
"fs:write-all",
|
||||||
|
"fs:allow-open",
|
||||||
|
"fs:allow-download-write-recursive",
|
||||||
|
"fs:allow-download-read-recursive",
|
||||||
|
"fs:allow-download-meta-recursive",
|
||||||
|
"opener:default",
|
||||||
|
"positioner:default",
|
||||||
|
{
|
||||||
|
"identifier": "http:default",
|
||||||
|
"allow": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:5572/**"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://www.google.com"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"store:default",
|
||||||
|
"process:default",
|
||||||
|
"notification:default",
|
||||||
|
"os:default"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 384 B |
|
After Width: | Height: | Size: 381 B |
|
After Width: | Height: | Size: 382 B |
|
After Width: | Height: | Size: 383 B |
|
After Width: | Height: | Size: 382 B |
|
After Width: | Height: | Size: 378 B |
|
After Width: | Height: | Size: 385 B |
|
After Width: | Height: | Size: 384 B |
|
After Width: | Height: | Size: 381 B |
|
After Width: | Height: | Size: 380 B |
|
After Width: | Height: | Size: 379 B |
|
After Width: | Height: | Size: 378 B |
|
After Width: | Height: | Size: 378 B |
|
After Width: | Height: | Size: 378 B |
|
After Width: | Height: | Size: 379 B |
|
After Width: | Height: | Size: 378 B |
|
After Width: | Height: | Size: 380 B |
|
After Width: | Height: | Size: 384 B |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,106 @@
|
|||||||
|
// use tauri::Manager;
|
||||||
|
|
||||||
|
// #[derive(Clone, serde::Serialize)]
|
||||||
|
// struct Payload {
|
||||||
|
// args: Vec<String>,
|
||||||
|
// cwd: String,
|
||||||
|
// }
|
||||||
|
|
||||||
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
|
pub fn run() {
|
||||||
|
let mut app = tauri::Builder::default()
|
||||||
|
.plugin(tauri_plugin_os::init())
|
||||||
|
.plugin(tauri_plugin_notification::init())
|
||||||
|
.plugin(tauri_plugin_process::init())
|
||||||
|
// .plugin(tauri_plugin_single_instance::init(|app, argv, cwd| {
|
||||||
|
// println!("{}, {argv:?}, {cwd}", app.package_info().name);
|
||||||
|
// // app.emit("single-instance", Payload { args: argv, cwd }).unwrap();
|
||||||
|
// }))
|
||||||
|
// .plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| {
|
||||||
|
// let _ = app
|
||||||
|
// .get_webview_window("main")
|
||||||
|
// .expect("no main window")
|
||||||
|
// .set_focus();
|
||||||
|
// }))
|
||||||
|
.plugin(tauri_plugin_store::Builder::new().build())
|
||||||
|
.plugin(tauri_plugin_http::init())
|
||||||
|
.plugin(tauri_plugin_positioner::init())
|
||||||
|
.plugin(tauri_plugin_fs::init())
|
||||||
|
.plugin(tauri_plugin_dialog::init())
|
||||||
|
.plugin(tauri_plugin_log::Builder::new().build())
|
||||||
|
.plugin(tauri_plugin_shell::init())
|
||||||
|
.plugin(tauri_plugin_opener::init())
|
||||||
|
.setup(|_app| Ok(()))
|
||||||
|
// .setup(|app| {
|
||||||
|
// if cfg!(debug_assertions) {
|
||||||
|
// app.handle().plugin(
|
||||||
|
// tauri_plugin_log::Builder::default()
|
||||||
|
// .level(log::LevelFilter::Info)
|
||||||
|
// .build(),
|
||||||
|
// )?;
|
||||||
|
// }
|
||||||
|
// Ok(())
|
||||||
|
// })
|
||||||
|
// .setup(|app| {
|
||||||
|
// let win_builder =
|
||||||
|
// tauri::WebviewWindowBuilder::new(app, "main", tauri::WebviewUrl::default())
|
||||||
|
// .title("Transparent Titlebar Window")
|
||||||
|
// .inner_size(800.0, 600.0);
|
||||||
|
// // set transparent title bar only when building for macOS
|
||||||
|
// #[cfg(target_os = "macos")]
|
||||||
|
// let win_builder = win_builder.title_bar_style(tauri::TitleBarStyle::Transparent);
|
||||||
|
// let window = win_builder.build().unwrap();
|
||||||
|
// // set background color only when building for macOS
|
||||||
|
// #[cfg(target_os = "macos")]
|
||||||
|
// {
|
||||||
|
// use cocoa::appkit::{NSColor, NSWindow};
|
||||||
|
// use cocoa::base::{id, nil};
|
||||||
|
// let ns_window = window.ns_window().unwrap() as id;
|
||||||
|
// unsafe {
|
||||||
|
// let bg_color = NSColor::colorWithRed_green_blue_alpha_(
|
||||||
|
// nil,
|
||||||
|
// 138.0 / 255.0,
|
||||||
|
// 43.0 / 255.0,
|
||||||
|
// 226.0 / 255.0,
|
||||||
|
// 1.0,
|
||||||
|
// );
|
||||||
|
// ns_window.setBackgroundColor_(bg_color);
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// Ok(())
|
||||||
|
// })
|
||||||
|
.build(tauri::generate_context!())
|
||||||
|
.expect("error while running tauri application");
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
app.set_activation_policy(tauri::ActivationPolicy::Accessory);
|
||||||
|
|
||||||
|
// prevents only app close
|
||||||
|
// app.run(|app_handle, e| {
|
||||||
|
// if let tauri::RunEvent::ExitRequested { api, .. } = &e {
|
||||||
|
// // Keep the event loop running even if all windows are closed
|
||||||
|
// // This allow us to catch system tray events when there is no window
|
||||||
|
// api.prevent_exit();
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
|
||||||
|
// also prevents window close
|
||||||
|
// RS: https://github.com/tauri-apps/tauri/issues/5500#issuecomment-1300258861
|
||||||
|
// JS: https://github.com/tauri-apps/tauri/blob/4cbdf0fb1c0de5004eab51c36d5843a9816f18af/examples/api/src/App.svelte#L26
|
||||||
|
// app.run(|app, event| match event {
|
||||||
|
// tauri::RunEvent::WindowEvent {
|
||||||
|
// label,
|
||||||
|
// event: win_event,
|
||||||
|
// ..
|
||||||
|
// } => match win_event {
|
||||||
|
// tauri::WindowEvent::CloseRequested { api, .. } => {
|
||||||
|
// let win = app.get_webview_window(label.as_str()).unwrap();
|
||||||
|
// win.hide().unwrap();
|
||||||
|
// api.prevent_close();
|
||||||
|
// }
|
||||||
|
// _ => {}
|
||||||
|
// },
|
||||||
|
// _ => {}
|
||||||
|
// })
|
||||||
|
app.run(|_app, _event| {})
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let _ = fix_path_env::fix();
|
||||||
|
app_lib::run();
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
|
"productName": "s-tray",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"identifier": "com.stray.app",
|
||||||
|
"build": {
|
||||||
|
"frontendDist": "../dist",
|
||||||
|
"devUrl": "http://localhost:1420",
|
||||||
|
"beforeDevCommand": "npm run dev",
|
||||||
|
"beforeBuildCommand": "npm run build"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"title": "STray",
|
||||||
|
"width": 0,
|
||||||
|
"height": 0,
|
||||||
|
"center": true,
|
||||||
|
"hiddenTitle": false,
|
||||||
|
"resizable": false,
|
||||||
|
"fullscreen": false,
|
||||||
|
"visible": false,
|
||||||
|
"decorations": false,
|
||||||
|
"focus": true,
|
||||||
|
"url": "tray.html"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"security": {
|
||||||
|
"csp": null
|
||||||
|
},
|
||||||
|
"withGlobalTauri": true
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"active": true,
|
||||||
|
"targets": "all",
|
||||||
|
"icon": [
|
||||||
|
"icons/32x32.png",
|
||||||
|
"icons/128x128.png",
|
||||||
|
"icons/128x128@2x.png",
|
||||||
|
"icons/icon.icns",
|
||||||
|
"icons/icon.ico",
|
||||||
|
"icons/favicon/frame_00_delay-0.1s.png",
|
||||||
|
"icons/favicon/frame_01_delay-0.1s.png",
|
||||||
|
"icons/favicon/frame_02_delay-0.1s.png",
|
||||||
|
"icons/favicon/frame_03_delay-0.1s.png",
|
||||||
|
"icons/favicon/frame_04_delay-0.1s.png",
|
||||||
|
"icons/favicon/frame_05_delay-0.1s.png",
|
||||||
|
"icons/favicon/frame_06_delay-0.1s.png",
|
||||||
|
"icons/favicon/frame_07_delay-0.1s.png",
|
||||||
|
"icons/favicon/frame_08_delay-0.1s.png",
|
||||||
|
"icons/favicon/frame_09_delay-0.1s.png",
|
||||||
|
"icons/favicon/frame_10_delay-0.1s.png",
|
||||||
|
"icons/favicon/frame_11_delay-0.1s.png",
|
||||||
|
"icons/favicon/frame_12_delay-0.1s.png",
|
||||||
|
"icons/favicon/frame_13_delay-0.1s.png",
|
||||||
|
"icons/favicon/frame_14_delay-0.1s.png",
|
||||||
|
"icons/favicon/frame_15_delay-0.1s.png",
|
||||||
|
"icons/favicon/frame_16_delay-0.1s.png",
|
||||||
|
"icons/favicon/frame_17_delay-0.1s.png"
|
||||||
|
],
|
||||||
|
"externalBin": ["binaries/rclone"],
|
||||||
|
"category": "Utility",
|
||||||
|
"copyright": "FarFetched",
|
||||||
|
"macOS": {
|
||||||
|
"dmg": {
|
||||||
|
"appPosition": {
|
||||||
|
"x": 180,
|
||||||
|
"y": 170
|
||||||
|
},
|
||||||
|
"applicationFolderPosition": {
|
||||||
|
"x": 480,
|
||||||
|
"y": 170
|
||||||
|
},
|
||||||
|
"windowSize": {
|
||||||
|
"height": 400,
|
||||||
|
"width": 660
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"hardenedRuntime": true,
|
||||||
|
"entitlements": "Entitlements.plist",
|
||||||
|
"minimumSystemVersion": "10.13"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 4.5 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1,151 @@
|
|||||||
|
import { Chip, Textarea, Tooltip } from '@nextui-org/react'
|
||||||
|
import { XIcon } from 'lucide-react'
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { replaceSmartQuotes } from '../../lib/format'
|
||||||
|
|
||||||
|
export default function OptionsSection({
|
||||||
|
optionsJson,
|
||||||
|
setOptionsJson,
|
||||||
|
globalOptions,
|
||||||
|
optionsFetcher,
|
||||||
|
rows = 14,
|
||||||
|
}: {
|
||||||
|
optionsJson: string
|
||||||
|
setOptionsJson: (value: string) => void
|
||||||
|
globalOptions: any[]
|
||||||
|
optionsFetcher: () => Promise<any>
|
||||||
|
rows?: number
|
||||||
|
}) {
|
||||||
|
const [copyAvailableOptions, setCopyAvailableOptions] = useState<any[]>([])
|
||||||
|
|
||||||
|
const [options, setOptions] = useState<any>({})
|
||||||
|
const [isJsonValid, setIsJsonValid] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
optionsFetcher()
|
||||||
|
.then((flags) => {
|
||||||
|
console.log(JSON.stringify(flags, null, 2))
|
||||||
|
return flags
|
||||||
|
})
|
||||||
|
.then((flags) => setCopyAvailableOptions(flags))
|
||||||
|
}, [optionsFetcher])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const parsedOptions = JSON.parse(optionsJson)
|
||||||
|
setOptions(parsedOptions)
|
||||||
|
setIsJsonValid(true)
|
||||||
|
} catch {
|
||||||
|
setIsJsonValid(false)
|
||||||
|
}
|
||||||
|
}, [optionsJson])
|
||||||
|
|
||||||
|
const isOptionAdded = useCallback(
|
||||||
|
(option: string) => {
|
||||||
|
return options[option] !== undefined
|
||||||
|
},
|
||||||
|
[options]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-row gap-2">
|
||||||
|
<Textarea
|
||||||
|
className="w-1/2"
|
||||||
|
label="Custom Options"
|
||||||
|
description="Tap on an option to add it to the config. Hover to see info."
|
||||||
|
value={optionsJson}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
console.log(value)
|
||||||
|
//weird curly apostrophe alternatives on macos, replace to normal apostrophe
|
||||||
|
const cleanedJson = replaceSmartQuotes(value)
|
||||||
|
setOptionsJson(cleanedJson)
|
||||||
|
}}
|
||||||
|
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"
|
||||||
|
isInvalid={!isJsonValid}
|
||||||
|
errorMessage={isJsonValid ? '' : 'Invalid JSON'}
|
||||||
|
minRows={rows}
|
||||||
|
rows={rows}
|
||||||
|
maxRows={rows}
|
||||||
|
disableAutosize={true}
|
||||||
|
size="lg"
|
||||||
|
onClear={() => {
|
||||||
|
setOptionsJson('{}')
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap w-1/2 gap-2">
|
||||||
|
{copyAvailableOptions.map((option) => {
|
||||||
|
const alreadyAdded = isOptionAdded(option.FieldName)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
key={option.FieldName}
|
||||||
|
delay={500}
|
||||||
|
content={
|
||||||
|
copyAvailableOptions.find((o) => o.FieldName === option.FieldName)
|
||||||
|
?.Help
|
||||||
|
}
|
||||||
|
closeDelay={0}
|
||||||
|
>
|
||||||
|
<Chip
|
||||||
|
isDisabled={!isJsonValid}
|
||||||
|
variant={alreadyAdded ? 'flat' : 'solid'}
|
||||||
|
onClick={() => {
|
||||||
|
if (alreadyAdded) {
|
||||||
|
const newOptions = {
|
||||||
|
...options,
|
||||||
|
}
|
||||||
|
delete newOptions[option.FieldName]
|
||||||
|
setOptionsJson(JSON.stringify(newOptions, null, 2))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultGlobalValue =
|
||||||
|
globalOptions[
|
||||||
|
option.FieldName as keyof typeof globalOptions
|
||||||
|
]
|
||||||
|
|
||||||
|
const defaultValue =
|
||||||
|
copyAvailableOptions.find(
|
||||||
|
(o) => o.FieldName === option.FieldName
|
||||||
|
)?.DefaultStr || ''
|
||||||
|
|
||||||
|
const newOptions = {
|
||||||
|
...options,
|
||||||
|
[option.FieldName]: defaultGlobalValue || defaultValue,
|
||||||
|
}
|
||||||
|
|
||||||
|
setOptionsJson(JSON.stringify(newOptions, null, 2))
|
||||||
|
}}
|
||||||
|
className="cursor-pointer"
|
||||||
|
size="sm"
|
||||||
|
endContent={
|
||||||
|
alreadyAdded ? <XIcon className="w-4 h-4 mr-1" /> : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{option.FieldName}
|
||||||
|
</Chip>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
import { Autocomplete } from '@nextui-org/autocomplete'
|
||||||
|
import { AutocompleteItem, Button } from '@nextui-org/react'
|
||||||
|
import { open } from '@tauri-apps/plugin-dialog'
|
||||||
|
import { ArrowDownUp, FolderOpen } from 'lucide-react'
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { isRemotePath } from '../../lib/fs'
|
||||||
|
import { listPath } from '../../lib/rclone'
|
||||||
|
import { useStore } from '../../lib/store'
|
||||||
|
import { lockWindows, unlockWindows } from '../../lib/window'
|
||||||
|
|
||||||
|
export default function PathFinder({
|
||||||
|
sourcePath = '',
|
||||||
|
setSourcePath,
|
||||||
|
destPath = '',
|
||||||
|
setDestPath,
|
||||||
|
switchable = true,
|
||||||
|
sourceOptions = {
|
||||||
|
label: 'Source',
|
||||||
|
folderPicker: true,
|
||||||
|
placeholder: 'Enter a remote:/path or local path',
|
||||||
|
remoteSuggestions: true,
|
||||||
|
clearable: true,
|
||||||
|
},
|
||||||
|
destOptions = {
|
||||||
|
label: 'Destination',
|
||||||
|
folderPicker: true,
|
||||||
|
placeholder: 'Enter a remote:/path or local path',
|
||||||
|
remoteSuggestions: true,
|
||||||
|
clearable: true,
|
||||||
|
},
|
||||||
|
}: {
|
||||||
|
sourcePath?: string
|
||||||
|
setSourcePath: (path: string | undefined) => void
|
||||||
|
destPath?: string
|
||||||
|
setDestPath: (path: string | undefined) => void
|
||||||
|
switchable?: boolean
|
||||||
|
sourceOptions?: {
|
||||||
|
label: string
|
||||||
|
placeholder: string
|
||||||
|
folderPicker: boolean
|
||||||
|
remoteSuggestions: boolean
|
||||||
|
clearable: boolean
|
||||||
|
}
|
||||||
|
destOptions?: {
|
||||||
|
label: string
|
||||||
|
placeholder: string
|
||||||
|
folderPicker: boolean
|
||||||
|
remoteSuggestions: boolean
|
||||||
|
clearable: boolean
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
const remotes = useStore((state) => state.remotes)
|
||||||
|
|
||||||
|
const [suggestions, setSuggestions] = useState<
|
||||||
|
Record<
|
||||||
|
'source' | 'dest',
|
||||||
|
{
|
||||||
|
IsDir: boolean
|
||||||
|
Name: string
|
||||||
|
Path: string
|
||||||
|
}[]
|
||||||
|
>
|
||||||
|
>({
|
||||||
|
source: [],
|
||||||
|
dest: [],
|
||||||
|
})
|
||||||
|
const [activeSuggestionField, setActiveSuggestionField] = useState<'source' | 'dest' | null>(
|
||||||
|
null
|
||||||
|
)
|
||||||
|
const [isLoading, setIsLoading] = useState<{ source: boolean; dest: boolean }>({
|
||||||
|
source: false,
|
||||||
|
dest: false,
|
||||||
|
})
|
||||||
|
const [error, setError] = useState<{
|
||||||
|
source: string | null
|
||||||
|
dest: string | null
|
||||||
|
}>({
|
||||||
|
source: null,
|
||||||
|
dest: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSwap = () => {
|
||||||
|
const temp = sourcePath
|
||||||
|
setSourcePath(destPath)
|
||||||
|
setDestPath(temp)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBrowse = useCallback(
|
||||||
|
async (field: 'source' | 'dest') => {
|
||||||
|
try {
|
||||||
|
await lockWindows()
|
||||||
|
const selected = await open({
|
||||||
|
directory: true,
|
||||||
|
multiple: false,
|
||||||
|
defaultPath: field === 'source' ? sourcePath : destPath,
|
||||||
|
})
|
||||||
|
await unlockWindows()
|
||||||
|
if (selected) {
|
||||||
|
if (field === 'source') {
|
||||||
|
setSourcePath(selected as string)
|
||||||
|
} else {
|
||||||
|
setDestPath(selected as string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to open folder picker:', err)
|
||||||
|
setError((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[field]: 'Failed to open folder picker',
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[destPath, sourcePath, setDestPath, setSourcePath]
|
||||||
|
)
|
||||||
|
|
||||||
|
const fetchSuggestions = useCallback(
|
||||||
|
async (path: string, field: 'source' | 'dest') => {
|
||||||
|
setIsLoading((prev) => ({ ...prev, [field]: true }))
|
||||||
|
setError((prev) => ({ ...prev, [field]: null }))
|
||||||
|
|
||||||
|
console.log('fetching suggestions for', path, field)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// If path is empty, show list of remotes
|
||||||
|
if (!path) {
|
||||||
|
const remoteItems = remotes.map((remote) => ({
|
||||||
|
IsDir: true,
|
||||||
|
Name: remote + ':/',
|
||||||
|
Path: remote + ':/',
|
||||||
|
}))
|
||||||
|
setSuggestions((prev) => ({ ...prev, [field]: remoteItems }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only fetch suggestions for remote paths
|
||||||
|
if (!isRemotePath(path)) {
|
||||||
|
setSuggestions((prev) => ({ ...prev, [field]: [] }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split the path into remote and path parts
|
||||||
|
const [remote, ...pathParts] = path.split(':/')
|
||||||
|
if (!remote) {
|
||||||
|
throw new Error('Invalid remote path format')
|
||||||
|
}
|
||||||
|
|
||||||
|
let remotePath = pathParts.join('/')
|
||||||
|
if (remotePath.endsWith('/')) {
|
||||||
|
remotePath = remotePath.slice(0, -1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = await listPath(remote, remotePath, {
|
||||||
|
noModTime: true,
|
||||||
|
noMimeType: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const suggestionsWithRemote = items.map((item) => ({
|
||||||
|
IsDir: item.IsDir,
|
||||||
|
Name: item.Path,
|
||||||
|
Path: `${remote}:/${item.Path}`,
|
||||||
|
}))
|
||||||
|
|
||||||
|
setSuggestions((prev) => ({ ...prev, [field]: suggestionsWithRemote }))
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch suggestions:', err)
|
||||||
|
const errorMessage =
|
||||||
|
err instanceof Error ? err.message : 'Failed to fetch suggestions'
|
||||||
|
setError((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[field]: errorMessage,
|
||||||
|
}))
|
||||||
|
setSuggestions((prev) => ({ ...prev, [field]: [] }))
|
||||||
|
} finally {
|
||||||
|
setIsLoading((prev) => ({ ...prev, [field]: false }))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[remotes]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handlePathChange = useCallback(
|
||||||
|
(value: string, field: 'source' | 'dest') => {
|
||||||
|
if (field === 'source') {
|
||||||
|
setSourcePath(value)
|
||||||
|
} else {
|
||||||
|
setDestPath(value)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setDestPath, setSourcePath]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeSuggestionField) {
|
||||||
|
const path = activeSuggestionField === 'source' ? sourcePath : destPath
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
fetchSuggestions(path, activeSuggestionField)
|
||||||
|
}, 300)
|
||||||
|
|
||||||
|
return () => clearTimeout(timeoutId)
|
||||||
|
}
|
||||||
|
}, [sourcePath, destPath, activeSuggestionField, fetchSuggestions])
|
||||||
|
|
||||||
|
//! suggestions close because component re-renders
|
||||||
|
const renderField = useCallback(
|
||||||
|
(field: 'source' | 'dest') => {
|
||||||
|
const isSource = field === 'source'
|
||||||
|
|
||||||
|
const value = isSource ? sourcePath : destPath
|
||||||
|
const setValue = (newValue: string) => handlePathChange(newValue, field)
|
||||||
|
const isFieldLoading = isLoading[field]
|
||||||
|
const fieldError = error[field]
|
||||||
|
|
||||||
|
const remoteSuggestionsEnabled = isSource
|
||||||
|
? sourceOptions.remoteSuggestions
|
||||||
|
: destOptions.remoteSuggestions
|
||||||
|
|
||||||
|
const fieldSuggestions = remoteSuggestionsEnabled ? suggestions[field] : []
|
||||||
|
|
||||||
|
const pickerEnabled = isSource ? sourceOptions.folderPicker : destOptions.folderPicker
|
||||||
|
|
||||||
|
const isClearable = isSource ? sourceOptions.clearable : destOptions.clearable
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Autocomplete
|
||||||
|
size="lg"
|
||||||
|
label={isSource ? sourceOptions.label : destOptions.label}
|
||||||
|
allowsCustomValue={true}
|
||||||
|
inputValue={value}
|
||||||
|
onInputChange={(e) => setValue(e)}
|
||||||
|
onFocus={() => {
|
||||||
|
setActiveSuggestionField(field)
|
||||||
|
if (!value) {
|
||||||
|
fetchSuggestions('', field)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
setActiveSuggestionField(null)
|
||||||
|
}}
|
||||||
|
shouldCloseOnBlur={false}
|
||||||
|
placeholder={
|
||||||
|
isSource ? sourceOptions.placeholder : destOptions.placeholder
|
||||||
|
}
|
||||||
|
isInvalid={!!fieldError}
|
||||||
|
errorMessage={fieldError}
|
||||||
|
isLoading={isFieldLoading}
|
||||||
|
selectorIcon={remoteSuggestionsEnabled ? undefined : null}
|
||||||
|
isClearable={isClearable}
|
||||||
|
>
|
||||||
|
{fieldSuggestions.map((item, index) => (
|
||||||
|
<AutocompleteItem
|
||||||
|
startContent={item.IsDir ? '📁' : '📄'}
|
||||||
|
key={index}
|
||||||
|
textValue={item.Path}
|
||||||
|
title={item.Name}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Autocomplete>
|
||||||
|
</div>
|
||||||
|
{pickerEnabled && (
|
||||||
|
<Button
|
||||||
|
onPress={() => handleBrowse(field)}
|
||||||
|
type="button"
|
||||||
|
isIconOnly={true}
|
||||||
|
size="lg"
|
||||||
|
className="w-16 h-18"
|
||||||
|
data-focus-visible="false"
|
||||||
|
>
|
||||||
|
<FolderOpen className="w-6 h-6" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[
|
||||||
|
sourcePath,
|
||||||
|
destPath,
|
||||||
|
suggestions,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
fetchSuggestions,
|
||||||
|
handleBrowse,
|
||||||
|
handlePathChange,
|
||||||
|
sourceOptions,
|
||||||
|
destOptions,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
|
{renderField('source')}
|
||||||
|
|
||||||
|
{switchable && (
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Button
|
||||||
|
onPress={handleSwap}
|
||||||
|
type="button"
|
||||||
|
isIconOnly={true}
|
||||||
|
size="lg"
|
||||||
|
isDisabled={!sourcePath && !destPath}
|
||||||
|
data-focus-visible="false"
|
||||||
|
>
|
||||||
|
<ArrowDownUp className="w-6 h-6" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{renderField('dest')}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
import { Drawer, DrawerBody, DrawerFooter, DrawerHeader } from '@nextui-org/drawer'
|
||||||
|
import { Button, DrawerContent } from '@nextui-org/react'
|
||||||
|
import { message } from '@tauri-apps/plugin-dialog'
|
||||||
|
import { ChevronDown, ChevronUp } from 'lucide-react'
|
||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { createRemote } from '../../lib/rclone'
|
||||||
|
import { getBackends } from '../../lib/rclone'
|
||||||
|
import { useStore } from '../../lib/store'
|
||||||
|
import { triggerTrayRebuild } from '../../lib/tray'
|
||||||
|
import type { Backend, BackendOption } from '../../types/rclone'
|
||||||
|
|
||||||
|
export default function RemoteCreateDrawer({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
}: { isOpen: boolean; onClose: () => void }) {
|
||||||
|
const addRemote = useStore((state) => state.addRemote)
|
||||||
|
const [config, setConfig] = useState<Record<string, any>>({})
|
||||||
|
const [showMoreOptions, setShowMoreOptions] = useState(false)
|
||||||
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
|
const [backends, setBackends] = useState<Backend[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getBackends().then((b) => {
|
||||||
|
setBackends(b)
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleTypeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
|
const newType = e.target.value
|
||||||
|
setConfig({ type: newType })
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderField = (option: BackendOption) => {
|
||||||
|
// Skip rendering if the field should be hidden
|
||||||
|
if (option.Hide !== 0) return null
|
||||||
|
|
||||||
|
// For S3 type, only show fields that match the current provider or have no provider specified
|
||||||
|
if (config.type === 's3' && option.Provider && option.Provider !== config.provider) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldId = `field-${option.Name}`
|
||||||
|
const fieldValue = config[option.Name] || option.DefaultStr
|
||||||
|
|
||||||
|
switch (option.Type) {
|
||||||
|
case 'bool':
|
||||||
|
return (
|
||||||
|
<div key={option.Name} className="space-y-2">
|
||||||
|
<label className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id={fieldId}
|
||||||
|
name={option.Name}
|
||||||
|
className="form-checkbox"
|
||||||
|
defaultChecked={fieldValue === 'true'}
|
||||||
|
onChange={(e) =>
|
||||||
|
setConfig({ ...config, [option.Name]: e.target.checked })
|
||||||
|
}
|
||||||
|
autoComplete="off"
|
||||||
|
autoCapitalize="off"
|
||||||
|
autoCorrect="off"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{option.Help.split('\n')[0]}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{option.Help.includes('\n') && (
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{option.Help.split('\n').slice(1).join('\n')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
case 'string': {
|
||||||
|
if (option.Examples && option.Examples.length > 0) {
|
||||||
|
return (
|
||||||
|
<div key={option.Name} className="space-y-2">
|
||||||
|
<label htmlFor={fieldId} className="block text-sm font-medium">
|
||||||
|
{option.Help.split('\n')[0]}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id={fieldId}
|
||||||
|
name={option.Name}
|
||||||
|
className="w-full p-2 border rounded dark:bg-gray-800"
|
||||||
|
value={fieldValue}
|
||||||
|
onChange={(e) =>
|
||||||
|
setConfig({ ...config, [option.Name]: e.target.value })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="">Select {option.Name}</option>
|
||||||
|
{option.Examples.map((example) => (
|
||||||
|
<option key={example.Value} value={example.Value}>
|
||||||
|
{example.Help || example.Value}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{option.Help.includes('\n') && (
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{option.Help.split('\n').slice(1).join('\n')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div key={option.Name} className="space-y-2">
|
||||||
|
<label htmlFor={fieldId} className="block text-sm font-medium">
|
||||||
|
{option.Help.split('\n')[0]}
|
||||||
|
{option.Required && <span className="ml-1 text-red-500">*</span>}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={fieldId}
|
||||||
|
name={option.Name}
|
||||||
|
type={option.IsPassword ? 'password' : 'text'}
|
||||||
|
className="w-full p-2 border rounded dark:bg-gray-800"
|
||||||
|
value={fieldValue || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setConfig({ ...config, [option.Name]: e.target.value })
|
||||||
|
}
|
||||||
|
required={option.Required}
|
||||||
|
autoComplete="off"
|
||||||
|
autoCapitalize="off"
|
||||||
|
autoCorrect="off"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
{option.Help.includes('\n') && (
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{option.Help.split('\n').slice(1).join('\n')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setIsSaving(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData(e.currentTarget)
|
||||||
|
const data: Record<string, string | boolean> = {}
|
||||||
|
|
||||||
|
// First collect all form values
|
||||||
|
for (const [key, value] of formData.entries()) {
|
||||||
|
if (value.toString().trim() === '') continue
|
||||||
|
if (
|
||||||
|
e.currentTarget[key] instanceof HTMLInputElement &&
|
||||||
|
e.currentTarget[key].type === 'checkbox'
|
||||||
|
) {
|
||||||
|
data[key] = (e.currentTarget[key] as HTMLInputElement).checked
|
||||||
|
} else {
|
||||||
|
data[key] = value.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = data.name as string
|
||||||
|
const type = data.type as string
|
||||||
|
const parameters = Object.fromEntries(
|
||||||
|
Object.entries(data).filter(([key]) => key !== 'name' && key !== 'type')
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create the remote
|
||||||
|
await createRemote(name, type, parameters)
|
||||||
|
addRemote(name)
|
||||||
|
onClose()
|
||||||
|
await triggerTrayRebuild()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create remote:', error)
|
||||||
|
await message(error instanceof Error ? error.message : 'Unknown error occurred', {
|
||||||
|
title: 'Could not create remote',
|
||||||
|
kind: 'error',
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentBackend = useMemo(() => {
|
||||||
|
if (!config.type) return null
|
||||||
|
return backends.find((b) => b.Name === config.type)
|
||||||
|
}, [config, backends])
|
||||||
|
|
||||||
|
const currentBackendFields = useMemo(() => {
|
||||||
|
if (!currentBackend) return []
|
||||||
|
const options =
|
||||||
|
(currentBackend?.Options as BackendOption[]).filter(
|
||||||
|
(opt) =>
|
||||||
|
!opt.Provider ||
|
||||||
|
(opt.Provider.includes(config.provider) && !opt.Provider.startsWith('!'))
|
||||||
|
) || []
|
||||||
|
|
||||||
|
return options
|
||||||
|
}, [config.provider, currentBackend])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
isOpen={isOpen}
|
||||||
|
placement={'bottom'}
|
||||||
|
size="full"
|
||||||
|
onClose={onClose}
|
||||||
|
hideCloseButton={true}
|
||||||
|
>
|
||||||
|
<DrawerContent>
|
||||||
|
{(close) => (
|
||||||
|
<>
|
||||||
|
<DrawerHeader className="flex flex-col gap-1">Create Remote</DrawerHeader>
|
||||||
|
<DrawerBody>
|
||||||
|
<form className="space-y-6" onSubmit={handleSubmit} id="create-form">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="name" className="block text-sm font-medium">
|
||||||
|
Remote Name <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
className="w-full p-2 border rounded dark:bg-gray-800"
|
||||||
|
value={config.name || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setConfig({ ...config, name: e.target.value })
|
||||||
|
}
|
||||||
|
required={true}
|
||||||
|
autoComplete="off"
|
||||||
|
autoCapitalize="off"
|
||||||
|
autoCorrect="off"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label
|
||||||
|
htmlFor="remote-type"
|
||||||
|
className="block text-sm font-medium"
|
||||||
|
>
|
||||||
|
Type <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="remote-type"
|
||||||
|
name="type"
|
||||||
|
className="w-full p-2 border rounded dark:bg-gray-800"
|
||||||
|
value={config.type || ''}
|
||||||
|
onChange={handleTypeChange}
|
||||||
|
required={true}
|
||||||
|
>
|
||||||
|
<option value="">Select Type</option>
|
||||||
|
{backends.map((backend) => (
|
||||||
|
<option key={backend.Name} value={backend.Name}>
|
||||||
|
{backend.Description.includes('Compliant')
|
||||||
|
? `${backend.Description.split('Compliant')[0]} Compliant`
|
||||||
|
: backend.Description || backend.Name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Normal Fields */}
|
||||||
|
{currentBackendFields
|
||||||
|
.filter((opt) => !opt.Advanced)
|
||||||
|
.map(renderField)}
|
||||||
|
|
||||||
|
{/* Advanced Fields */}
|
||||||
|
{currentBackendFields.some((opt) => opt.Advanced) && (
|
||||||
|
<div className="pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowMoreOptions(!showMoreOptions)}
|
||||||
|
className="flex items-center space-x-2 text-sm font-medium text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100"
|
||||||
|
>
|
||||||
|
{showMoreOptions ? (
|
||||||
|
<ChevronUp className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
<span>More Options</span>
|
||||||
|
</button>
|
||||||
|
{showMoreOptions && (
|
||||||
|
<div className="pt-4 mt-4 space-y-6 border-t dark:border-gray-700">
|
||||||
|
{currentBackendFields
|
||||||
|
.filter((opt) => opt.Advanced)
|
||||||
|
.map(renderField)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</DrawerBody>
|
||||||
|
<DrawerFooter>
|
||||||
|
<Button
|
||||||
|
color="danger"
|
||||||
|
variant="light"
|
||||||
|
onPress={close}
|
||||||
|
data-focus-visible="false"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="primary"
|
||||||
|
type="submit"
|
||||||
|
form="create-form"
|
||||||
|
isLoading={isSaving}
|
||||||
|
data-focus-visible="false"
|
||||||
|
>
|
||||||
|
{isSaving ? 'Creating...' : 'Create Remote'}
|
||||||
|
</Button>
|
||||||
|
</DrawerFooter>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,539 @@
|
|||||||
|
import { Checkbox } from '@nextui-org/checkbox'
|
||||||
|
import { Drawer, DrawerBody, DrawerContent, DrawerFooter, DrawerHeader } from '@nextui-org/drawer'
|
||||||
|
import { Accordion, AccordionItem, Avatar, Button, Input } from '@nextui-org/react'
|
||||||
|
import { homeDir } from '@tauri-apps/api/path'
|
||||||
|
import { message, open } from '@tauri-apps/plugin-dialog'
|
||||||
|
import {
|
||||||
|
CogIcon,
|
||||||
|
CopyIcon,
|
||||||
|
FilterIcon,
|
||||||
|
FolderOpen,
|
||||||
|
FolderSyncIcon,
|
||||||
|
HardDriveIcon,
|
||||||
|
WavesLadderIcon,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import {
|
||||||
|
getCopyFlags,
|
||||||
|
getFilterFlags,
|
||||||
|
getGlobalFlags,
|
||||||
|
getMountFlags,
|
||||||
|
getSyncFlags,
|
||||||
|
getVfsFlags,
|
||||||
|
} from '../../lib/rclone'
|
||||||
|
import { type RemoteConfig, usePersistedStore } from '../../lib/store'
|
||||||
|
import { triggerTrayRebuild } from '../../lib/tray'
|
||||||
|
import { lockWindows, unlockWindows } from '../../lib/window'
|
||||||
|
import OptionsSection from './OptionsSection'
|
||||||
|
|
||||||
|
export default function RemoteDefaultsDrawer({
|
||||||
|
remoteName,
|
||||||
|
onClose,
|
||||||
|
isOpen,
|
||||||
|
}: {
|
||||||
|
remoteName: string
|
||||||
|
onClose: () => void
|
||||||
|
isOpen: boolean
|
||||||
|
}) {
|
||||||
|
const remoteConfigList = usePersistedStore((state) => state.remoteConfigList)
|
||||||
|
const mergeRemoteConfig = usePersistedStore((state) => state.mergeRemoteConfig)
|
||||||
|
|
||||||
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
|
|
||||||
|
const [config, setConfig] = useState<RemoteConfig | null>(null)
|
||||||
|
|
||||||
|
const [copyOptionsJson, setCopyOptionsJson] = useState<string>('{}')
|
||||||
|
const [syncOptionsJson, setSyncOptionsJson] = useState<string>('{}')
|
||||||
|
const [filterOptionsJson, setFilterOptionsJson] = useState<string>('{}')
|
||||||
|
const [mountOptionsJson, setMountOptionsJson] = useState<string>('{}')
|
||||||
|
const [vfsOptionsJson, setVfsOptionsJson] = useState<string>('{}')
|
||||||
|
|
||||||
|
const [globalOptions, setGlobalOptions] = useState<any[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getGlobalFlags().then((flags) => setGlobalOptions(flags))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (config?.disabledActions?.length === 3) {
|
||||||
|
setConfig((prev) => ({
|
||||||
|
...prev,
|
||||||
|
hideTray: true,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}, [config?.disabledActions?.length])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// console.log(JSON.stringify(remoteConfigList, null, 2))
|
||||||
|
|
||||||
|
const remoteConfig = remoteConfigList[remoteName] || {}
|
||||||
|
setConfig(remoteConfig)
|
||||||
|
|
||||||
|
console.log('remoteName', remoteName)
|
||||||
|
console.log(JSON.stringify(remoteConfig, null, 2))
|
||||||
|
|
||||||
|
setCopyOptionsJson(JSON.stringify(remoteConfig?.copyDefaults, null, 2) || '{}')
|
||||||
|
setSyncOptionsJson(JSON.stringify(remoteConfig?.syncDefaults, null, 2) || '{}')
|
||||||
|
setFilterOptionsJson(JSON.stringify(remoteConfig?.filterDefaults, null, 2) || '{}')
|
||||||
|
setMountOptionsJson(JSON.stringify(remoteConfig?.mountDefaults, null, 2) || '{}')
|
||||||
|
setVfsOptionsJson(JSON.stringify(remoteConfig?.vfsDefaults, null, 2) || '{}')
|
||||||
|
}, [remoteConfigList, remoteName])
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(async () => {
|
||||||
|
if (!config) {
|
||||||
|
console.log('No config')
|
||||||
|
console.log(JSON.stringify(config, null, 2))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSaving(true)
|
||||||
|
|
||||||
|
const newConfig = {
|
||||||
|
...config,
|
||||||
|
}
|
||||||
|
|
||||||
|
let step = 'Copy'
|
||||||
|
|
||||||
|
try {
|
||||||
|
const copyOptions = JSON.parse(copyOptionsJson)
|
||||||
|
newConfig.copyDefaults = Object.keys(copyOptions).length > 0 ? copyOptions : undefined
|
||||||
|
|
||||||
|
step = 'Mount'
|
||||||
|
const mountOptions = JSON.parse(mountOptionsJson)
|
||||||
|
newConfig.mountDefaults =
|
||||||
|
Object.keys(mountOptions).length > 0 ? mountOptions : undefined
|
||||||
|
|
||||||
|
step = 'Sync'
|
||||||
|
const syncOptions = JSON.parse(syncOptionsJson)
|
||||||
|
newConfig.syncDefaults = Object.keys(syncOptions).length > 0 ? syncOptions : undefined
|
||||||
|
|
||||||
|
step = 'Filter'
|
||||||
|
const filterOptions = JSON.parse(filterOptionsJson)
|
||||||
|
newConfig.filterDefaults =
|
||||||
|
Object.keys(filterOptions).length > 0 ? filterOptions : undefined
|
||||||
|
|
||||||
|
step = 'VFS'
|
||||||
|
const vfsOptions = JSON.parse(vfsOptionsJson)
|
||||||
|
newConfig.vfsDefaults = Object.keys(vfsOptions).length > 0 ? vfsOptions : undefined
|
||||||
|
} catch {
|
||||||
|
await message(`Could not update remote, error parsing ${step} options`, {
|
||||||
|
title: 'Invalid JSON',
|
||||||
|
kind: 'error',
|
||||||
|
})
|
||||||
|
setIsSaving(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
mergeRemoteConfig(remoteName, newConfig)
|
||||||
|
await triggerTrayRebuild()
|
||||||
|
setConfig(newConfig)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update remote:', error)
|
||||||
|
await message(error instanceof Error ? error.message : 'Unknown error occurred', {
|
||||||
|
title: 'Could not update remote',
|
||||||
|
kind: 'error',
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false)
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
config,
|
||||||
|
copyOptionsJson,
|
||||||
|
filterOptionsJson,
|
||||||
|
syncOptionsJson,
|
||||||
|
vfsOptionsJson,
|
||||||
|
mountOptionsJson,
|
||||||
|
remoteName,
|
||||||
|
mergeRemoteConfig,
|
||||||
|
])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
isOpen={isOpen}
|
||||||
|
placement={'bottom'}
|
||||||
|
size="full"
|
||||||
|
onClose={onClose}
|
||||||
|
hideCloseButton={true}
|
||||||
|
>
|
||||||
|
<DrawerContent>
|
||||||
|
{(close) => (
|
||||||
|
<>
|
||||||
|
<DrawerHeader className="flex flex-col gap-1">Defaults</DrawerHeader>
|
||||||
|
<DrawerBody>
|
||||||
|
<Accordion selectionMode="multiple" defaultExpandedKeys={['general']}>
|
||||||
|
<AccordionItem
|
||||||
|
key="general"
|
||||||
|
startContent={
|
||||||
|
<Avatar
|
||||||
|
color="default"
|
||||||
|
radius="lg"
|
||||||
|
fallback={<CogIcon />}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
indicator={<CogIcon />}
|
||||||
|
subtitle={`General defaults for ${remoteName}`}
|
||||||
|
title="UI & General"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Checkbox
|
||||||
|
isSelected={!config?.hideTray || false}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (value) {
|
||||||
|
setConfig((prev) => ({
|
||||||
|
...prev,
|
||||||
|
hideTray: undefined,
|
||||||
|
}))
|
||||||
|
} else {
|
||||||
|
setConfig((prev) => ({
|
||||||
|
...prev,
|
||||||
|
hideTray: true,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Show in tray menu
|
||||||
|
</Checkbox>
|
||||||
|
<Checkbox
|
||||||
|
isSelected={!config?.disabledActions?.includes('mount')}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (value) {
|
||||||
|
setConfig((prev) => ({
|
||||||
|
...prev,
|
||||||
|
disabledActions:
|
||||||
|
prev?.disabledActions?.filter(
|
||||||
|
(action) => action !== 'mount'
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
} else {
|
||||||
|
setConfig((prev) => ({
|
||||||
|
...prev,
|
||||||
|
disabledActions: [
|
||||||
|
...(prev?.disabledActions || []),
|
||||||
|
'mount',
|
||||||
|
],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Show{' '}
|
||||||
|
<span className="font-mono text-blue-300">Mount</span>{' '}
|
||||||
|
option
|
||||||
|
</Checkbox>
|
||||||
|
<Checkbox
|
||||||
|
isSelected={
|
||||||
|
!config?.disabledActions?.includes('browse')
|
||||||
|
}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (value) {
|
||||||
|
setConfig((prev) => ({
|
||||||
|
...prev,
|
||||||
|
disabledActions:
|
||||||
|
prev?.disabledActions?.filter(
|
||||||
|
(action) => action !== 'browse'
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
} else {
|
||||||
|
setConfig((prev) => ({
|
||||||
|
...prev,
|
||||||
|
disabledActions: [
|
||||||
|
...(prev?.disabledActions || []),
|
||||||
|
'browse',
|
||||||
|
],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Show{' '}
|
||||||
|
<span className="font-mono text-blue-300">Browse</span>{' '}
|
||||||
|
option
|
||||||
|
</Checkbox>
|
||||||
|
<Checkbox
|
||||||
|
isSelected={
|
||||||
|
!config?.disabledActions?.includes('remove')
|
||||||
|
}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (value) {
|
||||||
|
setConfig((prev) => ({
|
||||||
|
...prev,
|
||||||
|
disabledActions:
|
||||||
|
prev?.disabledActions?.filter(
|
||||||
|
(action) => action !== 'remove'
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
} else {
|
||||||
|
setConfig((prev) => ({
|
||||||
|
...prev,
|
||||||
|
disabledActions: [
|
||||||
|
...(prev?.disabledActions || []),
|
||||||
|
'remove',
|
||||||
|
],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Show{' '}
|
||||||
|
<span className="font-mono text-blue-300">Remove</span>{' '}
|
||||||
|
option
|
||||||
|
</Checkbox>
|
||||||
|
</div>
|
||||||
|
</AccordionItem>
|
||||||
|
|
||||||
|
<AccordionItem
|
||||||
|
key="mount"
|
||||||
|
startContent={
|
||||||
|
<Avatar
|
||||||
|
color="secondary"
|
||||||
|
radius="lg"
|
||||||
|
fallback={<HardDriveIcon />}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
indicator={<HardDriveIcon />}
|
||||||
|
subtitle={`Mount defaults for ${remoteName}`}
|
||||||
|
title="Mount"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Input
|
||||||
|
placeholder="Default Remote Path (starting with bucket name: bucket/path/to/folder)"
|
||||||
|
type="text"
|
||||||
|
value={config?.defaultRemotePath || ''}
|
||||||
|
size="lg"
|
||||||
|
autoComplete="off"
|
||||||
|
autoCapitalize="off"
|
||||||
|
autoCorrect="off"
|
||||||
|
spellCheck={false}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setConfig((prev) => ({
|
||||||
|
...prev,
|
||||||
|
defaultRemotePath: value
|
||||||
|
? value.startsWith('/')
|
||||||
|
? value.slice(1)
|
||||||
|
: value
|
||||||
|
: undefined,
|
||||||
|
}))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
placeholder="Default Mount Point"
|
||||||
|
type="text"
|
||||||
|
value={config?.defaultMountPoint || ''}
|
||||||
|
size="lg"
|
||||||
|
autoComplete="off"
|
||||||
|
autoCapitalize="off"
|
||||||
|
autoCorrect="off"
|
||||||
|
spellCheck={false}
|
||||||
|
startContent={
|
||||||
|
<Button
|
||||||
|
onPress={async () => {
|
||||||
|
try {
|
||||||
|
await lockWindows()
|
||||||
|
const selected = await open({
|
||||||
|
directory: true,
|
||||||
|
multiple: false,
|
||||||
|
defaultPath: await homeDir(),
|
||||||
|
})
|
||||||
|
await unlockWindows()
|
||||||
|
if (selected) {
|
||||||
|
setConfig((prev) => ({
|
||||||
|
...prev,
|
||||||
|
defaultMountPoint:
|
||||||
|
selected as string,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
'Failed to open folder picker:',
|
||||||
|
err
|
||||||
|
)
|
||||||
|
await message(
|
||||||
|
'Failed to open folder picker',
|
||||||
|
{
|
||||||
|
title: 'Error',
|
||||||
|
kind: 'error',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
isIconOnly={true}
|
||||||
|
data-focus-visible="false"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<FolderOpen className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
endContent={
|
||||||
|
<Checkbox
|
||||||
|
isSelected={config?.mountOnStart || false}
|
||||||
|
onValueChange={async (value) => {
|
||||||
|
if (
|
||||||
|
!config?.defaultMountPoint ||
|
||||||
|
!config?.defaultRemotePath
|
||||||
|
) {
|
||||||
|
await message(
|
||||||
|
'Please set a default mount point and remote path before enabling this option',
|
||||||
|
{
|
||||||
|
title: 'Missing Information',
|
||||||
|
kind: 'error',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setConfig((prev) => ({
|
||||||
|
...prev,
|
||||||
|
mountOnStart: value || undefined,
|
||||||
|
}))
|
||||||
|
}}
|
||||||
|
size="sm"
|
||||||
|
data-focus-visible="false"
|
||||||
|
className="h-full m-0 min-w-fit"
|
||||||
|
>
|
||||||
|
Mount on startup
|
||||||
|
</Checkbox>
|
||||||
|
}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setConfig((prev) => ({
|
||||||
|
...prev,
|
||||||
|
defaultMountPoint: value || undefined,
|
||||||
|
}))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<OptionsSection
|
||||||
|
optionsJson={mountOptionsJson}
|
||||||
|
setOptionsJson={setMountOptionsJson}
|
||||||
|
globalOptions={
|
||||||
|
globalOptions['mount' as keyof typeof globalOptions]
|
||||||
|
}
|
||||||
|
optionsFetcher={getMountFlags}
|
||||||
|
rows={5}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</AccordionItem>
|
||||||
|
<AccordionItem
|
||||||
|
key="vfs"
|
||||||
|
startContent={
|
||||||
|
<Avatar
|
||||||
|
color="warning"
|
||||||
|
radius="lg"
|
||||||
|
fallback={<WavesLadderIcon />}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
indicator={<WavesLadderIcon />}
|
||||||
|
subtitle={`VFS defaults for ${remoteName}`}
|
||||||
|
title="VFS"
|
||||||
|
>
|
||||||
|
<OptionsSection
|
||||||
|
optionsJson={vfsOptionsJson}
|
||||||
|
setOptionsJson={setVfsOptionsJson}
|
||||||
|
globalOptions={
|
||||||
|
globalOptions['vfs' as keyof typeof globalOptions]
|
||||||
|
}
|
||||||
|
optionsFetcher={getVfsFlags}
|
||||||
|
rows={10}
|
||||||
|
/>
|
||||||
|
</AccordionItem>
|
||||||
|
<AccordionItem
|
||||||
|
key="filters"
|
||||||
|
startContent={
|
||||||
|
<Avatar
|
||||||
|
color="danger"
|
||||||
|
radius="lg"
|
||||||
|
fallback={<FilterIcon />}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
indicator={<FilterIcon />}
|
||||||
|
subtitle={`Filtering defaults for ${remoteName}`}
|
||||||
|
title="Filters"
|
||||||
|
>
|
||||||
|
<OptionsSection
|
||||||
|
optionsJson={filterOptionsJson}
|
||||||
|
setOptionsJson={setFilterOptionsJson}
|
||||||
|
globalOptions={
|
||||||
|
globalOptions['filter' as keyof typeof globalOptions]
|
||||||
|
}
|
||||||
|
optionsFetcher={getFilterFlags}
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
</AccordionItem>
|
||||||
|
<AccordionItem
|
||||||
|
key="copy"
|
||||||
|
startContent={
|
||||||
|
<Avatar
|
||||||
|
color="primary"
|
||||||
|
radius="lg"
|
||||||
|
fallback={<CopyIcon />}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
indicator={<CopyIcon />}
|
||||||
|
subtitle={`Default copy flags for ${remoteName}`}
|
||||||
|
title="Copy"
|
||||||
|
>
|
||||||
|
<OptionsSection
|
||||||
|
optionsJson={copyOptionsJson}
|
||||||
|
setOptionsJson={setCopyOptionsJson}
|
||||||
|
globalOptions={
|
||||||
|
globalOptions['main' as keyof typeof globalOptions]
|
||||||
|
}
|
||||||
|
optionsFetcher={getCopyFlags}
|
||||||
|
/>
|
||||||
|
</AccordionItem>
|
||||||
|
|
||||||
|
<AccordionItem
|
||||||
|
key="sync"
|
||||||
|
startContent={
|
||||||
|
<Avatar
|
||||||
|
color="success"
|
||||||
|
radius="lg"
|
||||||
|
fallback={<FolderSyncIcon />}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
indicator={<FolderSyncIcon />}
|
||||||
|
subtitle={`Sync defaults for ${remoteName}`}
|
||||||
|
title="Sync"
|
||||||
|
>
|
||||||
|
<OptionsSection
|
||||||
|
optionsJson={syncOptionsJson}
|
||||||
|
setOptionsJson={setSyncOptionsJson}
|
||||||
|
globalOptions={
|
||||||
|
globalOptions['main' as keyof typeof globalOptions]
|
||||||
|
}
|
||||||
|
optionsFetcher={getSyncFlags}
|
||||||
|
rows={20}
|
||||||
|
/>
|
||||||
|
</AccordionItem>
|
||||||
|
{/* <SyncSection
|
||||||
|
remoteName={remoteName}
|
||||||
|
syncOptionsJson={syncOptionsJson}
|
||||||
|
setSyncOptionsJson={setSyncOptionsJson}
|
||||||
|
globalOptions={
|
||||||
|
globalOptions['main' as keyof typeof globalOptions]
|
||||||
|
}
|
||||||
|
/> */}
|
||||||
|
</Accordion>
|
||||||
|
</DrawerBody>
|
||||||
|
<DrawerFooter>
|
||||||
|
<Button
|
||||||
|
color="danger"
|
||||||
|
variant="light"
|
||||||
|
onPress={close}
|
||||||
|
data-focus-visible="false"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="primary"
|
||||||
|
isLoading={isSaving}
|
||||||
|
onPress={handleSubmit}
|
||||||
|
data-focus-visible="false"
|
||||||
|
>
|
||||||
|
{isSaving ? 'Saving...' : 'Save Changes'}
|
||||||
|
</Button>
|
||||||
|
</DrawerFooter>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
import { Checkbox } from '@nextui-org/checkbox'
|
||||||
|
import { Drawer, DrawerBody, DrawerContent, DrawerFooter, DrawerHeader } from '@nextui-org/drawer'
|
||||||
|
import { Button, Input } from '@nextui-org/react'
|
||||||
|
import { message } from '@tauri-apps/plugin-dialog'
|
||||||
|
import { ChevronDown, ChevronUp } from 'lucide-react'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { getBackends, getRemote, updateRemote } from '../../lib/rclone'
|
||||||
|
import type { Backend, BackendOption } from '../../types/rclone'
|
||||||
|
|
||||||
|
export default function RemoteEditDrawer({
|
||||||
|
remoteName,
|
||||||
|
onClose,
|
||||||
|
isOpen,
|
||||||
|
}: {
|
||||||
|
remoteName: string
|
||||||
|
onClose: () => void
|
||||||
|
isOpen: boolean
|
||||||
|
}) {
|
||||||
|
const [config, setConfig] = useState<any>({})
|
||||||
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
|
const [currentBackend, setCurrentBackend] = useState<Backend | null>(null)
|
||||||
|
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||||
|
|
||||||
|
const [backends, setBackends] = useState<Backend[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getBackends().then((b) => {
|
||||||
|
setBackends(b)
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadRemoteConfig = async () => {
|
||||||
|
try {
|
||||||
|
const remoteInfo = await getRemote(remoteName)
|
||||||
|
setConfig(remoteInfo)
|
||||||
|
// Find the current backend based on the type
|
||||||
|
const backend = backends.find((b) => b.Name === remoteInfo.type)
|
||||||
|
setCurrentBackend(backend || null)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load remote config:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadRemoteConfig()
|
||||||
|
}, [remoteName, backends])
|
||||||
|
|
||||||
|
const renderField = (option: BackendOption) => {
|
||||||
|
// Skip rendering if the field should be hidden
|
||||||
|
if (option.Hide !== 0) return null
|
||||||
|
|
||||||
|
// For S3 type, only show fields that match the current provider or have no provider specified
|
||||||
|
if (config.type === 's3' && option.Provider && option.Provider !== config.provider) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldId = `field-${option.Name}`
|
||||||
|
const fieldValue = config[option.Name] || option.DefaultStr
|
||||||
|
|
||||||
|
switch (option.Type) {
|
||||||
|
case 'bool':
|
||||||
|
return (
|
||||||
|
<div key={option.Name} className="space-y-2">
|
||||||
|
<Checkbox
|
||||||
|
defaultChecked={fieldValue === 'true'}
|
||||||
|
name={option.Name}
|
||||||
|
radius="sm"
|
||||||
|
>
|
||||||
|
{option.Name}
|
||||||
|
</Checkbox>
|
||||||
|
{option.Help.includes('\n') && (
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
{option.Help.split('\n').slice(1).join('\n')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
case 'string': {
|
||||||
|
if (option.Examples && option.Examples.length > 0) {
|
||||||
|
return (
|
||||||
|
<div key={option.Name} className="space-y-2">
|
||||||
|
<label htmlFor={fieldId} className="block text-sm font-medium">
|
||||||
|
{option.Help.split('\n')[0]}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id={fieldId}
|
||||||
|
name={option.Name}
|
||||||
|
className="w-full p-2 border rounded dark:bg-gray-800"
|
||||||
|
defaultValue={fieldValue}
|
||||||
|
>
|
||||||
|
<option value="">Select {option.Name}</option>
|
||||||
|
{option.Examples.map((example) => (
|
||||||
|
<option key={example.Value} value={example.Value}>
|
||||||
|
{example.Help || example.Value}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{option.Help.includes('\n') && (
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{option.Help.split('\n').slice(1).join('\n')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
// form='remote-form' might not be needed
|
||||||
|
key={option.Name}
|
||||||
|
id={fieldId}
|
||||||
|
name={option.Name}
|
||||||
|
label={option.Name}
|
||||||
|
labelPlacement="outside"
|
||||||
|
placeholder={option.Help.split('\n')[0]}
|
||||||
|
type={option.IsPassword ? 'password' : 'text'}
|
||||||
|
defaultValue={fieldValue}
|
||||||
|
autoComplete="off"
|
||||||
|
autoCapitalize="off"
|
||||||
|
autoCorrect="off"
|
||||||
|
spellCheck={false}
|
||||||
|
description={option.Help.split('\n').slice(1).join('\n')}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setIsSaving(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData(e.currentTarget)
|
||||||
|
const data: Record<string, string | boolean> = {}
|
||||||
|
const changedValues: Record<string, string | boolean> = {}
|
||||||
|
|
||||||
|
// First collect all form values
|
||||||
|
for (const [key, value] of formData.entries()) {
|
||||||
|
if (
|
||||||
|
e.currentTarget[key] instanceof HTMLInputElement &&
|
||||||
|
e.currentTarget[key].type === 'checkbox'
|
||||||
|
) {
|
||||||
|
data[key] = (e.currentTarget[key] as HTMLInputElement).checked
|
||||||
|
} else {
|
||||||
|
data[key] = value.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare with original config and only include changed values
|
||||||
|
for (const [key, value] of Object.entries(data)) {
|
||||||
|
// if the value is empty and the key is not in the config, skip it
|
||||||
|
if (!config?.[key] && value.toString().trim() === '') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (config[key] !== value) {
|
||||||
|
changedValues[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only update if there are changes
|
||||||
|
if (Object.keys(changedValues).length > 0) {
|
||||||
|
await updateRemote(remoteName, changedValues)
|
||||||
|
onClose()
|
||||||
|
} else {
|
||||||
|
// No changes, just go back
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update remote:', error)
|
||||||
|
await message(error instanceof Error ? error.message : 'Unknown error occurred', {
|
||||||
|
title: 'Could not update remote',
|
||||||
|
kind: 'error',
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
isOpen={isOpen}
|
||||||
|
placement={'bottom'}
|
||||||
|
size="full"
|
||||||
|
onClose={onClose}
|
||||||
|
hideCloseButton={true}
|
||||||
|
>
|
||||||
|
<DrawerContent>
|
||||||
|
{(close) => (
|
||||||
|
<>
|
||||||
|
<DrawerHeader className="flex flex-col gap-1">
|
||||||
|
Edit {remoteName}
|
||||||
|
</DrawerHeader>
|
||||||
|
<DrawerBody>
|
||||||
|
<form
|
||||||
|
id="remote-form"
|
||||||
|
className="flex flex-col gap-4"
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label
|
||||||
|
htmlFor="edit-remote-type"
|
||||||
|
className="block text-sm font-medium"
|
||||||
|
>
|
||||||
|
Type
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="edit-remote-type"
|
||||||
|
name="type"
|
||||||
|
value={config.type}
|
||||||
|
className="w-full h-10"
|
||||||
|
onChange={(e) =>
|
||||||
|
setConfig({ ...config, type: e.target.value })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="">Select Type</option>
|
||||||
|
{backends.map((backend) => (
|
||||||
|
<option key={backend.Name} value={backend.Name}>
|
||||||
|
{backend.Description.includes('Compliant')
|
||||||
|
? `${backend.Description.split('Compliant')[0]} Compliant`
|
||||||
|
: backend.Description || backend.Name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Basic Options */}
|
||||||
|
{currentBackend?.Options.filter((opt) => !opt.Advanced).map(
|
||||||
|
renderField
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Advanced Options */}
|
||||||
|
{currentBackend?.Options.some((opt) => opt.Advanced) && (
|
||||||
|
<div className="pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||||
|
className="flex items-center space-x-2 text-sm font-medium text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100"
|
||||||
|
>
|
||||||
|
{showAdvanced ? (
|
||||||
|
<ChevronUp className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
<span>Advanced Options</span>
|
||||||
|
</button>
|
||||||
|
{showAdvanced && (
|
||||||
|
<div className="flex flex-col gap-4 pt-4 mt-4">
|
||||||
|
{currentBackend.Options.filter(
|
||||||
|
(opt) => opt.Advanced
|
||||||
|
).map(renderField)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</DrawerBody>
|
||||||
|
<DrawerFooter>
|
||||||
|
<Button
|
||||||
|
color="danger"
|
||||||
|
variant="light"
|
||||||
|
onPress={close}
|
||||||
|
data-focus-visible="false"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="primary"
|
||||||
|
type="submit"
|
||||||
|
form="remote-form"
|
||||||
|
isLoading={isSaving}
|
||||||
|
data-focus-visible="false"
|
||||||
|
>
|
||||||
|
{isSaving ? 'Saving...' : 'Save Changes'}
|
||||||
|
</Button>
|
||||||
|
</DrawerFooter>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// import { useStore } from '../../lib/store'
|
||||||
|
|
||||||
|
export default function TauriWatcher() {
|
||||||
|
// const count = useStore((state) => state.count)
|
||||||
|
return <div>TauriWatcher</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide scrollbar for IE, Edge and Firefox */
|
||||||
|
* {
|
||||||
|
-ms-overflow-style: none; /* IE and Edge */
|
||||||
|
scrollbar-width: none; /* Firefox */
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
overscroll-behavior: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
/* background-color: red; */
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import { RouterProvider, createBrowserRouter } from 'react-router-dom'
|
||||||
|
import Home from './pages/Home'
|
||||||
|
import './global.css'
|
||||||
|
import { NextUIProvider } from '@nextui-org/react'
|
||||||
|
import { debug, error, info, trace, warn } from '@tauri-apps/plugin-log'
|
||||||
|
import Copy from './pages/Copy'
|
||||||
|
import Jobs from './pages/Jobs'
|
||||||
|
import Mount from './pages/Mount'
|
||||||
|
import Settings from './pages/Settings'
|
||||||
|
import Sync from './pages/Sync'
|
||||||
|
import Test from './pages/Test'
|
||||||
|
|
||||||
|
function forwardConsole(
|
||||||
|
fnName: 'log' | 'debug' | 'info' | 'warn' | 'error',
|
||||||
|
logger: (message: string) => Promise<void>
|
||||||
|
) {
|
||||||
|
const original = console[fnName]
|
||||||
|
console[fnName] = (message, ...args) => {
|
||||||
|
original(message, ...args)
|
||||||
|
logger(
|
||||||
|
`${message} ${args?.map((arg) => (typeof arg === 'string' ? arg : JSON.stringify(arg))).join(' ')}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
forwardConsole('log', trace)
|
||||||
|
forwardConsole('debug', debug)
|
||||||
|
forwardConsole('info', info)
|
||||||
|
forwardConsole('warn', warn)
|
||||||
|
forwardConsole('error', error)
|
||||||
|
|
||||||
|
const router = createBrowserRouter([
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
element: <Home />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/settings',
|
||||||
|
element: <Settings />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/sync',
|
||||||
|
element: <Sync />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/copy',
|
||||||
|
element: <Copy />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/mount',
|
||||||
|
element: <Mount />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/jobs',
|
||||||
|
element: <Jobs />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/test',
|
||||||
|
element: <Test />,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<NextUIProvider>
|
||||||
|
{/* <TauriWatcher /> */}
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</NextUIProvider>
|
||||||
|
</React.StrictMode>
|
||||||
|
)
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import { Accordion, AccordionItem, Avatar, Button } from '@nextui-org/react'
|
||||||
|
import { getCurrentWindow } from '@tauri-apps/api/window'
|
||||||
|
import { message } from '@tauri-apps/plugin-dialog'
|
||||||
|
import { AlertOctagonIcon, CopyIcon, FilterIcon, FoldersIcon, PlayIcon } from 'lucide-react'
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useSearchParams } from 'react-router-dom'
|
||||||
|
import { getCopyFlags, getFilterFlags, getGlobalFlags, startCopy } from '../../lib/rclone'
|
||||||
|
import { usePersistedStore } from '../../lib/store'
|
||||||
|
import { getLoadingTray, getMainTray } from '../../lib/tray'
|
||||||
|
import { openTrayWindow } from '../../lib/window'
|
||||||
|
import OptionsSection from '../components/OptionsSection'
|
||||||
|
import PathFinder from '../components/PathFinder'
|
||||||
|
|
||||||
|
export default function Copy() {
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
|
|
||||||
|
const [source, setSource] = useState<string | undefined>(
|
||||||
|
searchParams.get('initialSource') || undefined
|
||||||
|
)
|
||||||
|
const [dest, setDest] = useState<string | undefined>(undefined)
|
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const [jsonError, setJsonError] = useState<'copy' | 'filter' | null>(null)
|
||||||
|
|
||||||
|
const [copyOptions, setCopyOptions] = useState<Record<string, string>>({})
|
||||||
|
const [copyOptionsJson, setCopyOptionsJson] = useState<string>('{}')
|
||||||
|
|
||||||
|
const [filterOptions, setFilterOptions] = useState<Record<string, string>>({})
|
||||||
|
const [filterOptionsJson, setFilterOptionsJson] = useState<string>('{}')
|
||||||
|
|
||||||
|
const [globalOptions, setGlobalOptions] = useState<any[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const storeData = usePersistedStore.getState()
|
||||||
|
|
||||||
|
const remote = source?.split(':')[0]
|
||||||
|
|
||||||
|
if (!remote) return
|
||||||
|
|
||||||
|
if (!(remote in storeData.remoteConfigList)) return
|
||||||
|
|
||||||
|
if (
|
||||||
|
storeData.remoteConfigList[remote].copyDefaults &&
|
||||||
|
Object.keys(storeData.remoteConfigList[remote].copyDefaults).length > 0
|
||||||
|
) {
|
||||||
|
setCopyOptionsJson(
|
||||||
|
JSON.stringify(storeData.remoteConfigList[remote].copyDefaults, null, 2)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
storeData.remoteConfigList[remote].filterDefaults &&
|
||||||
|
Object.keys(storeData.remoteConfigList[remote].filterDefaults).length > 0
|
||||||
|
) {
|
||||||
|
setFilterOptionsJson(
|
||||||
|
JSON.stringify(storeData.remoteConfigList[remote].filterDefaults, null, 2)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}, [source])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getGlobalFlags().then((flags) => setGlobalOptions(flags))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let step: 'copy' | 'filter' = 'copy'
|
||||||
|
try {
|
||||||
|
setCopyOptions(JSON.parse(copyOptionsJson))
|
||||||
|
|
||||||
|
step = 'filter'
|
||||||
|
setFilterOptions(JSON.parse(filterOptionsJson))
|
||||||
|
|
||||||
|
setJsonError(null)
|
||||||
|
} catch (error) {
|
||||||
|
setJsonError(step)
|
||||||
|
console.error(`Error parsing ${step} options:`, error)
|
||||||
|
}
|
||||||
|
}, [copyOptionsJson, filterOptionsJson])
|
||||||
|
|
||||||
|
const handleStartCopy = useCallback(async () => {
|
||||||
|
setIsLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await getMainTray().then((tray) => tray?.setVisible(false))
|
||||||
|
await getLoadingTray().then((tray) => tray?.setVisible(true))
|
||||||
|
|
||||||
|
await startCopy({
|
||||||
|
source: source!,
|
||||||
|
dest: dest!,
|
||||||
|
copyOptions,
|
||||||
|
filterOptions,
|
||||||
|
})
|
||||||
|
|
||||||
|
// delay for the job to appear in the API
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 2500))
|
||||||
|
|
||||||
|
await openTrayWindow({ name: 'Jobs', url: '/jobs' })
|
||||||
|
await getCurrentWindow().hide()
|
||||||
|
|
||||||
|
await getMainTray().then((tray) => tray?.setVisible(true))
|
||||||
|
await getLoadingTray().then((tray) => tray?.setVisible(false))
|
||||||
|
|
||||||
|
await getCurrentWindow().destroy()
|
||||||
|
} catch (err) {
|
||||||
|
await getMainTray().then((tray) => tray?.setVisible(true))
|
||||||
|
await getLoadingTray().then((tray) => tray?.setVisible(false))
|
||||||
|
|
||||||
|
console.error('Failed to start copy:', err)
|
||||||
|
const errorMessage =
|
||||||
|
err instanceof Error ? err.message : 'Failed to start copy operation'
|
||||||
|
await message(errorMessage, {
|
||||||
|
title: 'Error',
|
||||||
|
kind: 'error',
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}, [source, dest, copyOptions, filterOptions])
|
||||||
|
|
||||||
|
const buttonText = useMemo(() => {
|
||||||
|
if (isLoading) return 'STARTING...'
|
||||||
|
if (!source) return 'Please select a source path'
|
||||||
|
if (!dest) return 'Please select a destination path'
|
||||||
|
if (source === dest) return 'Source and destination cannot be the same'
|
||||||
|
if (jsonError) return 'Invalid JSON for ' + jsonError.toUpperCase() + ' options'
|
||||||
|
return 'START COPY'
|
||||||
|
}, [isLoading, jsonError, source, dest])
|
||||||
|
|
||||||
|
const buttonIcon = useMemo(() => {
|
||||||
|
if (isLoading) return
|
||||||
|
if (!source || !dest || source === 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, source, dest])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col min-h-screen gap-10 pt-10">
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="flex flex-col flex-1 w-full max-w-xl gap-6 mx-auto">
|
||||||
|
{/* Paths Display */}
|
||||||
|
<PathFinder
|
||||||
|
sourcePath={source}
|
||||||
|
setSourcePath={setSource}
|
||||||
|
destPath={dest}
|
||||||
|
setDestPath={setDest}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Accordion>
|
||||||
|
<AccordionItem
|
||||||
|
key="copy"
|
||||||
|
startContent={
|
||||||
|
<Avatar color="primary" radius="lg" fallback={<CopyIcon />} />
|
||||||
|
}
|
||||||
|
indicator={<CopyIcon />}
|
||||||
|
subtitle="Tap to toggle copy options for this operation"
|
||||||
|
title="Copy"
|
||||||
|
>
|
||||||
|
<OptionsSection
|
||||||
|
globalOptions={globalOptions['main' as keyof typeof globalOptions]}
|
||||||
|
optionsJson={copyOptionsJson}
|
||||||
|
setOptionsJson={setCopyOptionsJson}
|
||||||
|
optionsFetcher={getCopyFlags}
|
||||||
|
rows={20}
|
||||||
|
/>
|
||||||
|
</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}
|
||||||
|
/>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sticky bottom-0 z-50 flex items-center justify-center flex-none p-4 border-t border-neutral-500/20 bg-neutral-900/50 backdrop-blur-lg">
|
||||||
|
<Button
|
||||||
|
onPress={handleStartCopy}
|
||||||
|
size="lg"
|
||||||
|
fullWidth={true}
|
||||||
|
type="button"
|
||||||
|
color="primary"
|
||||||
|
isDisabled={isLoading || !!jsonError || !source || !dest || source === dest}
|
||||||
|
isLoading={isLoading}
|
||||||
|
endContent={buttonIcon}
|
||||||
|
className="max-w-2xl"
|
||||||
|
data-focus-visible="false"
|
||||||
|
>
|
||||||
|
{buttonText}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
function Home() {
|
||||||
|
return (
|
||||||
|
<main className="container bg-blue-500">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<h1>Rclone UI</h1>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Home
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { Card, CardBody, CardFooter, CardHeader } from '@nextui-org/card'
|
||||||
|
import { Button, Chip, Divider, Progress, Spinner } from '@nextui-org/react'
|
||||||
|
import { listen } from '@tauri-apps/api/event'
|
||||||
|
import { ask } from '@tauri-apps/plugin-dialog'
|
||||||
|
import { Trash2Icon } from 'lucide-react'
|
||||||
|
import { useCallback, useEffect } from 'react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { formatBytes } from '../../lib/format'
|
||||||
|
import { listJobs, stopJob } from '../../lib/rclone'
|
||||||
|
|
||||||
|
export default function Jobs() {
|
||||||
|
const [isInitialLoad, setIsInitialLoad] = useState(true)
|
||||||
|
const [jobs, setJobs] = useState<{
|
||||||
|
active: any[]
|
||||||
|
inactive: any[]
|
||||||
|
}>({
|
||||||
|
active: [],
|
||||||
|
inactive: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
const [busyIds, setBusyIds] = useState<number[]>([])
|
||||||
|
|
||||||
|
const fetchJobs = useCallback(async () => {
|
||||||
|
const jobs = await listJobs()
|
||||||
|
|
||||||
|
console.log('jobs', JSON.stringify(jobs, null, 2))
|
||||||
|
|
||||||
|
setJobs(jobs)
|
||||||
|
setIsInitialLoad(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const buildReadablePath = useCallback((path: string) => {
|
||||||
|
return path.split(':')?.[1]
|
||||||
|
? `${path.split(':')[0]}:/.../${path.split(':')[1]?.split('/').slice(-1).join('')}`
|
||||||
|
: path.split(':')?.[0]?.split('/').slice(-1).join('')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchJobs()
|
||||||
|
|
||||||
|
const interval = setInterval(async () => {
|
||||||
|
await fetchJobs()
|
||||||
|
}, 2000)
|
||||||
|
|
||||||
|
//! prevents jobs being refreshed after "X" was pressed on the window
|
||||||
|
const unlisten = listen('tauri://close-requested', () => {
|
||||||
|
clearInterval(interval)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(interval)
|
||||||
|
unlisten.then((unlisten) => unlisten())
|
||||||
|
}
|
||||||
|
}, [fetchJobs])
|
||||||
|
|
||||||
|
if (isInitialLoad) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-screen">
|
||||||
|
<Spinner size="lg" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jobs.active.length === 0 && jobs.inactive.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-screen">
|
||||||
|
<h1 className="text-2xl font-bold">No jobs found</h1>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col overflow-scroll">
|
||||||
|
{jobs.active.map((job) => (
|
||||||
|
<Card key={job.id} radius="none">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex flex-row items-center justify-between w-full">
|
||||||
|
<Chip isCloseable={false} size="sm" variant="bordered">
|
||||||
|
#{job.id}
|
||||||
|
</Chip>
|
||||||
|
<Button
|
||||||
|
isIconOnly={true}
|
||||||
|
color="danger"
|
||||||
|
isLoading={busyIds.includes(job.id)}
|
||||||
|
size="sm"
|
||||||
|
onPress={async () => {
|
||||||
|
setBusyIds([...busyIds, job.id])
|
||||||
|
const answer = await ask(
|
||||||
|
'Are you sure you want to stop this job?'
|
||||||
|
)
|
||||||
|
if (answer) {
|
||||||
|
await stopJob(job.id)
|
||||||
|
await fetchJobs()
|
||||||
|
}
|
||||||
|
setBusyIds(busyIds.filter((id) => id !== job.id))
|
||||||
|
}}
|
||||||
|
data-focus-visible="false"
|
||||||
|
>
|
||||||
|
<Trash2Icon className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody>
|
||||||
|
<div className="flex flex-col w-full gap-0">
|
||||||
|
<div className="font-bold">{buildReadablePath(job.srcFs)}</div>
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
{buildReadablePath(job.dstFs)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
<CardFooter>
|
||||||
|
<div className="flex flex-col items-center justify-center w-full gap-2">
|
||||||
|
<div className="flex flex-row items-center justify-between w-full">
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
{formatBytes(job.bytes)}/{formatBytes(job.totalBytes)} (
|
||||||
|
{job.progress}%)
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
{formatBytes(job.speed)}/s
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Progress value={job.progress} isStriped={true} />
|
||||||
|
</div>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{jobs.inactive.length > 0 && jobs.active.length > 0 && <Divider className="h-1" />}
|
||||||
|
|
||||||
|
{jobs.inactive.map((job) => (
|
||||||
|
<Card key={job.id} radius="none" isDisabled={true}>
|
||||||
|
<CardHeader>
|
||||||
|
<Chip
|
||||||
|
isCloseable={false}
|
||||||
|
size="sm"
|
||||||
|
variant="bordered"
|
||||||
|
color={job.progress === 100 ? 'success' : 'warning'}
|
||||||
|
>
|
||||||
|
#{job.id}
|
||||||
|
</Chip>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody>
|
||||||
|
<div className="flex flex-col w-full gap-0">
|
||||||
|
<div className="font-bold">{buildReadablePath(job.srcFs)}</div>
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
{buildReadablePath(job.dstFs)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
<CardFooter>
|
||||||
|
{job.progress === 100
|
||||||
|
? 'Finished successfully'
|
||||||
|
: `Stopped at ${job.progress}%`}
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
import { Accordion, AccordionItem, Avatar, Button } from '@nextui-org/react'
|
||||||
|
import { getCurrentWindow } from '@tauri-apps/api/window'
|
||||||
|
import { message } from '@tauri-apps/plugin-dialog'
|
||||||
|
import { revealItemInDir } from '@tauri-apps/plugin-opener'
|
||||||
|
import { platform } from '@tauri-apps/plugin-os'
|
||||||
|
import {
|
||||||
|
AlertOctagonIcon,
|
||||||
|
FoldersIcon,
|
||||||
|
HardDriveIcon,
|
||||||
|
PlayIcon,
|
||||||
|
WavesLadderIcon,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useSearchParams } from 'react-router-dom'
|
||||||
|
import { getGlobalFlags, getMountFlags, getVfsFlags, mountRemote } from '../../lib/rclone'
|
||||||
|
import { usePersistedStore } from '../../lib/store'
|
||||||
|
import OptionsSection from '../components/OptionsSection'
|
||||||
|
import PathFinder from '../components/PathFinder'
|
||||||
|
|
||||||
|
export default function Mount() {
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
|
|
||||||
|
const [source, setSource] = useState<string | undefined>(
|
||||||
|
searchParams.get('initialSource') || undefined
|
||||||
|
)
|
||||||
|
const [dest, setDest] = useState<string | undefined>(undefined)
|
||||||
|
|
||||||
|
const [isMounted, setIsMounted] = useState(false)
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const [jsonError, setJsonError] = useState<'mount' | 'vfs' | null>(null)
|
||||||
|
|
||||||
|
const [mountOptions, setMountOptions] = useState<Record<string, string>>({})
|
||||||
|
const [mountOptionsJson, setMountOptionsJson] = useState<string>('{}')
|
||||||
|
|
||||||
|
const [vfsOptions, setVfsOptions] = useState<Record<string, string>>({})
|
||||||
|
const [vfsOptionsJson, setVfsOptionsJson] = useState<string>('{}')
|
||||||
|
|
||||||
|
const [globalOptions, setGlobalOptions] = useState<any[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const storeData = usePersistedStore.getState()
|
||||||
|
|
||||||
|
const remote = source?.split(':')[0]
|
||||||
|
|
||||||
|
if (!remote) return
|
||||||
|
|
||||||
|
if (!(remote in storeData.remoteConfigList)) return
|
||||||
|
|
||||||
|
if (
|
||||||
|
storeData.remoteConfigList[remote].mountDefaults &&
|
||||||
|
Object.keys(storeData.remoteConfigList[remote].mountDefaults).length > 0
|
||||||
|
) {
|
||||||
|
setMountOptionsJson(
|
||||||
|
JSON.stringify(storeData.remoteConfigList[remote].mountDefaults, null, 2)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
storeData.remoteConfigList[remote].vfsDefaults &&
|
||||||
|
Object.keys(storeData.remoteConfigList[remote].vfsDefaults).length > 0
|
||||||
|
) {
|
||||||
|
setVfsOptionsJson(
|
||||||
|
JSON.stringify(storeData.remoteConfigList[remote].vfsDefaults, null, 2)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}, [source])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getGlobalFlags().then((flags) => setGlobalOptions(flags))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let step: 'mount' | 'vfs' = 'mount'
|
||||||
|
try {
|
||||||
|
setMountOptions(JSON.parse(mountOptionsJson))
|
||||||
|
|
||||||
|
step = 'vfs'
|
||||||
|
setVfsOptions(JSON.parse(vfsOptionsJson))
|
||||||
|
|
||||||
|
setJsonError(null)
|
||||||
|
} catch (error) {
|
||||||
|
setJsonError(step)
|
||||||
|
console.error(`Error parsing ${step} options:`, error)
|
||||||
|
}
|
||||||
|
}, [mountOptionsJson, vfsOptionsJson])
|
||||||
|
|
||||||
|
const handleStartMount = useCallback(async () => {
|
||||||
|
setIsLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const _mountOptions = { ...mountOptions }
|
||||||
|
|
||||||
|
if (!('VolumeName' in _mountOptions) && ['windows', 'macos'].includes(platform())) {
|
||||||
|
_mountOptions.VolumeName = source!.split('/').pop()!
|
||||||
|
}
|
||||||
|
|
||||||
|
await mountRemote({
|
||||||
|
remotePath: source!,
|
||||||
|
mountPoint: dest!,
|
||||||
|
mountOptions: _mountOptions,
|
||||||
|
vfsOptions,
|
||||||
|
})
|
||||||
|
|
||||||
|
setIsMounted(true)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to start mount:', err)
|
||||||
|
const errorMessage =
|
||||||
|
err instanceof Error ? err.message : 'Failed to start mount operation'
|
||||||
|
await message(errorMessage, {
|
||||||
|
title: 'Error',
|
||||||
|
kind: 'error',
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}, [source, dest, mountOptions, vfsOptions])
|
||||||
|
|
||||||
|
const buttonText = useMemo(() => {
|
||||||
|
if (isLoading) return 'MOUNTING...'
|
||||||
|
if (isMounted) return 'MOUNTED'
|
||||||
|
if (!source) return 'Please select a source path'
|
||||||
|
if (!dest) return 'Please select a destination path'
|
||||||
|
if (source === dest) return 'Source and destination cannot be the same'
|
||||||
|
if (jsonError) return 'Invalid JSON for ' + jsonError.toUpperCase() + ' options'
|
||||||
|
return 'START MOUNT'
|
||||||
|
}, [isLoading, jsonError, source, dest, isMounted])
|
||||||
|
|
||||||
|
const buttonIcon = useMemo(() => {
|
||||||
|
if (isLoading || isMounted) return
|
||||||
|
if (!source || !dest || source === 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, source, dest, isMounted])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col min-h-screen gap-10 pt-10">
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="flex flex-col flex-1 w-full max-w-xl gap-6 mx-auto">
|
||||||
|
{/* Paths Display */}
|
||||||
|
<PathFinder
|
||||||
|
sourcePath={source}
|
||||||
|
setSourcePath={setSource}
|
||||||
|
destPath={dest}
|
||||||
|
setDestPath={setDest}
|
||||||
|
switchable={false}
|
||||||
|
sourceOptions={{
|
||||||
|
label: 'Remote Path',
|
||||||
|
folderPicker: false,
|
||||||
|
placeholder: 'Root path inside the remote',
|
||||||
|
remoteSuggestions: true,
|
||||||
|
clearable: true,
|
||||||
|
}}
|
||||||
|
destOptions={{
|
||||||
|
label: 'Mount Point',
|
||||||
|
folderPicker: true,
|
||||||
|
placeholder: 'The local path to mount the remote to',
|
||||||
|
remoteSuggestions: false,
|
||||||
|
clearable: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Accordion>
|
||||||
|
<AccordionItem
|
||||||
|
key="mount"
|
||||||
|
startContent={
|
||||||
|
<Avatar color="secondary" radius="lg" fallback={<HardDriveIcon />} />
|
||||||
|
}
|
||||||
|
indicator={<HardDriveIcon />}
|
||||||
|
subtitle="Tap to see Mount options for the current operation"
|
||||||
|
title="Mount"
|
||||||
|
>
|
||||||
|
<OptionsSection
|
||||||
|
optionsJson={mountOptionsJson}
|
||||||
|
setOptionsJson={setMountOptionsJson}
|
||||||
|
globalOptions={globalOptions['mount' as keyof typeof globalOptions]}
|
||||||
|
optionsFetcher={getMountFlags}
|
||||||
|
rows={5}
|
||||||
|
/>
|
||||||
|
</AccordionItem>
|
||||||
|
<AccordionItem
|
||||||
|
key="vfs"
|
||||||
|
startContent={
|
||||||
|
<Avatar color="warning" radius="lg" fallback={<WavesLadderIcon />} />
|
||||||
|
}
|
||||||
|
indicator={<WavesLadderIcon />}
|
||||||
|
subtitle="Tap to see VFS options for the current operation"
|
||||||
|
title="VFS"
|
||||||
|
>
|
||||||
|
<OptionsSection
|
||||||
|
optionsJson={vfsOptionsJson}
|
||||||
|
setOptionsJson={setVfsOptionsJson}
|
||||||
|
globalOptions={globalOptions['vfs' as keyof typeof globalOptions]}
|
||||||
|
optionsFetcher={getVfsFlags}
|
||||||
|
rows={10}
|
||||||
|
/>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sticky bottom-0 z-50 flex items-center justify-center flex-none gap-5 p-4 border-t border-neutral-500/20 bg-neutral-900/50 backdrop-blur-lg">
|
||||||
|
{isMounted ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
fullWidth={true}
|
||||||
|
size="lg"
|
||||||
|
onPress={() => {
|
||||||
|
setDest(undefined)
|
||||||
|
setIsMounted(false)
|
||||||
|
}}
|
||||||
|
data-focus-visible="false"
|
||||||
|
>
|
||||||
|
New Mount
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
fullWidth={true}
|
||||||
|
size="lg"
|
||||||
|
color="primary"
|
||||||
|
onPress={async () => {
|
||||||
|
await revealItemInDir(dest!)
|
||||||
|
await getCurrentWindow().destroy()
|
||||||
|
}}
|
||||||
|
data-focus-visible="false"
|
||||||
|
>
|
||||||
|
Open
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
onPress={handleStartMount}
|
||||||
|
size="lg"
|
||||||
|
fullWidth={true}
|
||||||
|
color="primary"
|
||||||
|
isDisabled={
|
||||||
|
isLoading ||
|
||||||
|
!!jsonError ||
|
||||||
|
!source ||
|
||||||
|
!dest ||
|
||||||
|
source === dest ||
|
||||||
|
isMounted
|
||||||
|
}
|
||||||
|
isLoading={isLoading}
|
||||||
|
endContent={buttonIcon}
|
||||||
|
className="max-w-2xl"
|
||||||
|
data-focus-visible="false"
|
||||||
|
>
|
||||||
|
{buttonText}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { Button, Card, CardBody } from '@nextui-org/react'
|
||||||
|
import { confirm } from '@tauri-apps/plugin-dialog'
|
||||||
|
import { CableIcon, PencilIcon, Plus, Trash2Icon } from 'lucide-react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { deleteRemote } from '../../lib/rclone'
|
||||||
|
import { useStore } from '../../lib/store'
|
||||||
|
import { triggerTrayRebuild } from '../../lib/tray'
|
||||||
|
import RemoteCreateDrawer from '../components/RemoteCreateDrawer'
|
||||||
|
import RemoteDefaultsDrawer from '../components/RemoteDefaultsDrawer'
|
||||||
|
import RemoteEditDrawer from '../components/RemoteEditDrawer'
|
||||||
|
|
||||||
|
function Settings() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<RemotesSection />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const RemotesSection = () => {
|
||||||
|
const remotes = useStore((state) => state.remotes)
|
||||||
|
const removeRemote = useStore((state) => state.removeRemote)
|
||||||
|
|
||||||
|
const [pickedRemote, setPickedRemote] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const [editingDrawerOpen, setEditingDrawerOpen] = useState(false)
|
||||||
|
const [creatingDrawerOpen, setCreatingDrawerOpen] = useState(false)
|
||||||
|
const [defaultsDrawerOpen, setDefaultsDrawerOpen] = useState(false)
|
||||||
|
|
||||||
|
if (remotes.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-screen gap-4">
|
||||||
|
<h1 className="text-2xl font-bold">No remotes found</h1>
|
||||||
|
<Button
|
||||||
|
onPress={() => setCreatingDrawerOpen(true)}
|
||||||
|
color="primary"
|
||||||
|
data-focus-visible="false"
|
||||||
|
>
|
||||||
|
Create Remote
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="sticky top-0 z-50 flex items-center justify-between p-4 bg-neutral-900/50 backdrop-blur-lg">
|
||||||
|
<h2 className="text-xl font-semibold">Remotes</h2>
|
||||||
|
<Button
|
||||||
|
onPress={() => setCreatingDrawerOpen(true)}
|
||||||
|
isIconOnly={true}
|
||||||
|
variant="light"
|
||||||
|
data-focus-visible="false"
|
||||||
|
>
|
||||||
|
<Plus className="w-5 h-5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2 p-4">
|
||||||
|
{remotes.map((remote) => (
|
||||||
|
<Card key={remote} shadow="sm">
|
||||||
|
<CardBody>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span>{remote}</span>
|
||||||
|
<div className="space-x-2">
|
||||||
|
<Button
|
||||||
|
onPress={() => {
|
||||||
|
setPickedRemote(remote)
|
||||||
|
setDefaultsDrawerOpen(true)
|
||||||
|
}}
|
||||||
|
isIconOnly={true}
|
||||||
|
color="primary"
|
||||||
|
variant="light"
|
||||||
|
data-focus-visible="false"
|
||||||
|
>
|
||||||
|
<CableIcon className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onPress={() => {
|
||||||
|
setPickedRemote(remote)
|
||||||
|
setEditingDrawerOpen(true)
|
||||||
|
}}
|
||||||
|
isIconOnly={true}
|
||||||
|
color="primary"
|
||||||
|
variant="light"
|
||||||
|
data-focus-visible="false"
|
||||||
|
>
|
||||||
|
<PencilIcon className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
isIconOnly={true}
|
||||||
|
color="danger"
|
||||||
|
variant="light"
|
||||||
|
onPress={async () => {
|
||||||
|
const confirmation = await confirm(
|
||||||
|
`Are you sure you want to remove ${remote}? This action cannot be reverted.`,
|
||||||
|
{ title: `Removing ${remote}`, kind: 'warning' }
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!confirmation) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteRemote(remote)
|
||||||
|
removeRemote(remote)
|
||||||
|
await triggerTrayRebuild()
|
||||||
|
}}
|
||||||
|
data-focus-visible="false"
|
||||||
|
>
|
||||||
|
<Trash2Icon className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pickedRemote && (
|
||||||
|
<RemoteEditDrawer
|
||||||
|
isOpen={editingDrawerOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setEditingDrawerOpen(false)
|
||||||
|
setTimeout(() => {
|
||||||
|
// allow for drawer effect to happen
|
||||||
|
setPickedRemote(null)
|
||||||
|
}, 100)
|
||||||
|
}}
|
||||||
|
remoteName={pickedRemote}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<RemoteCreateDrawer
|
||||||
|
isOpen={creatingDrawerOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setCreatingDrawerOpen(false)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{pickedRemote && (
|
||||||
|
<RemoteDefaultsDrawer
|
||||||
|
isOpen={defaultsDrawerOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setDefaultsDrawerOpen(false)
|
||||||
|
setTimeout(() => {
|
||||||
|
// allow for drawer effect to happen
|
||||||
|
setPickedRemote(null)
|
||||||
|
}, 100)
|
||||||
|
}}
|
||||||
|
remoteName={pickedRemote}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Settings
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import { Accordion, AccordionItem, Avatar, Button } from '@nextui-org/react'
|
||||||
|
import { Window } from '@tauri-apps/api/window'
|
||||||
|
import { message } from '@tauri-apps/plugin-dialog'
|
||||||
|
import { AlertOctagonIcon, FilterIcon, FolderSyncIcon, FoldersIcon, PlayIcon } from 'lucide-react'
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
|
import { getFilterFlags, getGlobalFlags, getSyncFlags, startSync } from '../../lib/rclone'
|
||||||
|
import { usePersistedStore } from '../../lib/store'
|
||||||
|
import OptionsSection from '../components/OptionsSection'
|
||||||
|
import PathFinder from '../components/PathFinder'
|
||||||
|
|
||||||
|
export default function Sync() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
|
|
||||||
|
const [source, setSource] = useState<string | undefined>(
|
||||||
|
searchParams.get('initialSource') || undefined
|
||||||
|
)
|
||||||
|
const [dest, setDest] = useState<string | undefined>(undefined)
|
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const [jsonError, setJsonError] = useState<'sync' | 'filter' | null>(null)
|
||||||
|
|
||||||
|
const [syncOptions, setSyncOptions] = useState<Record<string, string>>({})
|
||||||
|
const [syncOptionsJson, setSyncOptionsJson] = useState<string>('{}')
|
||||||
|
|
||||||
|
const [filterOptions, setFilterOptions] = useState<Record<string, string>>({})
|
||||||
|
const [filterOptionsJson, setFilterOptionsJson] = useState<string>('{}')
|
||||||
|
|
||||||
|
const [globalOptions, setGlobalOptions] = useState<any[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const storeData = usePersistedStore.getState()
|
||||||
|
|
||||||
|
const remote = source?.split(':')[0]
|
||||||
|
|
||||||
|
if (!remote) return
|
||||||
|
|
||||||
|
if (!(remote in storeData.remoteConfigList)) return
|
||||||
|
|
||||||
|
if (
|
||||||
|
storeData.remoteConfigList[remote].syncDefaults &&
|
||||||
|
Object.keys(storeData.remoteConfigList[remote].syncDefaults).length > 0
|
||||||
|
) {
|
||||||
|
setSyncOptionsJson(
|
||||||
|
JSON.stringify(storeData.remoteConfigList[remote].syncDefaults, null, 2)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
storeData.remoteConfigList[remote].filterDefaults &&
|
||||||
|
Object.keys(storeData.remoteConfigList[remote].filterDefaults).length > 0
|
||||||
|
) {
|
||||||
|
setFilterOptionsJson(
|
||||||
|
JSON.stringify(storeData.remoteConfigList[remote].filterDefaults, null, 2)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}, [source])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getGlobalFlags().then((flags) => setGlobalOptions(flags))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let step: 'sync' | 'filter' = 'sync'
|
||||||
|
try {
|
||||||
|
setSyncOptions(JSON.parse(syncOptionsJson))
|
||||||
|
|
||||||
|
step = 'filter'
|
||||||
|
setFilterOptions(JSON.parse(filterOptionsJson))
|
||||||
|
|
||||||
|
setJsonError(null)
|
||||||
|
} catch (error) {
|
||||||
|
setJsonError(step)
|
||||||
|
console.error(`Error parsing ${step} options:`, error)
|
||||||
|
}
|
||||||
|
}, [syncOptionsJson, filterOptionsJson])
|
||||||
|
|
||||||
|
const handleStartSync = useCallback(async () => {
|
||||||
|
setIsLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await startSync({
|
||||||
|
source: source!,
|
||||||
|
dest: dest!,
|
||||||
|
syncOptions,
|
||||||
|
filterOptions,
|
||||||
|
})
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||||
|
navigate('/jobs')
|
||||||
|
await Window.getCurrent().setTitle('Jobs')
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to start sync:', err)
|
||||||
|
const errorMessage =
|
||||||
|
err instanceof Error ? err.message : 'Failed to start sync operation'
|
||||||
|
await message(errorMessage, {
|
||||||
|
title: 'Error',
|
||||||
|
kind: 'error',
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}, [source, dest, syncOptions, filterOptions, navigate])
|
||||||
|
|
||||||
|
const buttonText = useMemo(() => {
|
||||||
|
if (isLoading) return 'STARTING...'
|
||||||
|
if (!source) return 'Please select a source path'
|
||||||
|
if (!dest) return 'Please select a destination path'
|
||||||
|
if (source === dest) return 'Source and destination cannot be the same'
|
||||||
|
if (jsonError) return 'Invalid JSON for ' + jsonError.toUpperCase() + ' options'
|
||||||
|
return 'START SYNC'
|
||||||
|
}, [isLoading, jsonError, source, dest])
|
||||||
|
|
||||||
|
const buttonIcon = useMemo(() => {
|
||||||
|
if (isLoading) return
|
||||||
|
if (!source || !dest || source === 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, source, dest])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col min-h-screen gap-10 pt-10">
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="flex flex-col flex-1 w-full max-w-xl gap-6 mx-auto">
|
||||||
|
{/* Paths Display */}
|
||||||
|
<PathFinder
|
||||||
|
sourcePath={source}
|
||||||
|
setSourcePath={setSource}
|
||||||
|
destPath={dest}
|
||||||
|
setDestPath={setDest}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Accordion>
|
||||||
|
<AccordionItem
|
||||||
|
key="sync"
|
||||||
|
startContent={
|
||||||
|
<Avatar color="success" radius="lg" fallback={<FolderSyncIcon />} />
|
||||||
|
}
|
||||||
|
indicator={<FolderSyncIcon />}
|
||||||
|
subtitle="Tap to toggle sync options for this operation"
|
||||||
|
title="Sync"
|
||||||
|
>
|
||||||
|
<OptionsSection
|
||||||
|
optionsJson={syncOptionsJson}
|
||||||
|
setOptionsJson={setSyncOptionsJson}
|
||||||
|
globalOptions={globalOptions['main' as keyof typeof globalOptions]}
|
||||||
|
optionsFetcher={getSyncFlags}
|
||||||
|
rows={20}
|
||||||
|
/>
|
||||||
|
</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}
|
||||||
|
/>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sticky bottom-0 z-50 flex items-center justify-center flex-none p-4 border-t border-neutral-500/20 bg-neutral-900/50 backdrop-blur-lg">
|
||||||
|
<Button
|
||||||
|
onPress={handleStartSync}
|
||||||
|
size="lg"
|
||||||
|
fullWidth={true}
|
||||||
|
type="button"
|
||||||
|
color="primary"
|
||||||
|
isDisabled={isLoading || !!jsonError || !source || !dest || source === dest}
|
||||||
|
isLoading={isLoading}
|
||||||
|
endContent={buttonIcon}
|
||||||
|
className="max-w-2xl"
|
||||||
|
data-focus-visible="false"
|
||||||
|
>
|
||||||
|
{buttonText}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { useStore } from '../../lib/store'
|
||||||
|
|
||||||
|
// const store = new LazyStore('store.json')
|
||||||
|
|
||||||
|
function Test() {
|
||||||
|
const count = useStore((state) => state.count)
|
||||||
|
const increment = useStore((state) => state.increment)
|
||||||
|
|
||||||
|
// useEffect(() => {
|
||||||
|
// console.log('Test')
|
||||||
|
|
||||||
|
// let unsubscribe: () => void
|
||||||
|
|
||||||
|
// store
|
||||||
|
// .onChange(async (key, value) => {
|
||||||
|
// console.log('key', key)
|
||||||
|
// console.log('value', value)
|
||||||
|
|
||||||
|
// const v = await store.entries()
|
||||||
|
// console.log('v', v)
|
||||||
|
// })
|
||||||
|
// .then((uFn) => {
|
||||||
|
// unsubscribe = uFn
|
||||||
|
// })
|
||||||
|
|
||||||
|
// return () => {
|
||||||
|
// if (unsubscribe) {
|
||||||
|
// unsubscribe()
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="container bg-blue-500">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<h1>Rclone UI</h1>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
increment()
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Increment Zustand
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p>{count}</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Test
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
const { nextui } = require('@nextui-org/react')
|
||||||
|
|
||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
'./index.html',
|
||||||
|
'./src/**/*.{js,ts,jsx,tsx}',
|
||||||
|
'./node_modules/@nextui-org/theme/dist/**/*.{js,ts,jsx,tsx}',
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [nextui()],
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src", "lib"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
export interface BackendOption {
|
||||||
|
Name: string
|
||||||
|
FieldName: string
|
||||||
|
Help: string
|
||||||
|
Provider?: string
|
||||||
|
Default: any
|
||||||
|
Value: any
|
||||||
|
Examples?: Array<{ Value: string; Help: string }>
|
||||||
|
Hide: number
|
||||||
|
Required: boolean
|
||||||
|
IsPassword: boolean
|
||||||
|
NoPrefix: boolean
|
||||||
|
Advanced: boolean
|
||||||
|
Exclusive: boolean
|
||||||
|
Sensitive: boolean
|
||||||
|
DefaultStr: string
|
||||||
|
ValueStr: string
|
||||||
|
Type: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Backend {
|
||||||
|
Name: string
|
||||||
|
Description: string
|
||||||
|
Options: BackendOption[]
|
||||||
|
Prefix: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
|
||||||
|
const host = process.env.TAURI_DEV_HOST
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig(async () => ({
|
||||||
|
plugins: [react()],
|
||||||
|
|
||||||
|
esbuild: {
|
||||||
|
supported: {
|
||||||
|
'top-level-await': false, //browsers can handle top-level-await features
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
||||||
|
//
|
||||||
|
// 1. prevent vite from obscuring rust errors
|
||||||
|
clearScreen: false,
|
||||||
|
// 2. tauri expects a fixed port, fail if that port is not available
|
||||||
|
server: {
|
||||||
|
port: 1420,
|
||||||
|
strictPort: true,
|
||||||
|
host: host || false,
|
||||||
|
hmr: host
|
||||||
|
? {
|
||||||
|
protocol: 'ws',
|
||||||
|
host,
|
||||||
|
port: 1421,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
watch: {
|
||||||
|
// 3. tell vite to ignore watching `src-tauri`
|
||||||
|
ignored: ['**/src-tauri/**'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||