build status: failing

This commit is contained in:
FTCHD
2025-01-22 14:47:24 +02:00
commit af044bca82
93 changed files with 18425 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
# TAURI_DEV_HOST="0.0.0.0" # needed on macOS
APPLE_SIGNING_IDENTITY=
APPLE_ID=
APPLE_PASSWORD=
APPLE_TEAM_ID=
+32
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"]
}
+64
View File
@@ -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
+60
View File
@@ -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"]
}
}
+29
View File
@@ -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>
+28
View File
@@ -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])
}
+15
View File
@@ -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('/')
}
+358
View File
@@ -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,
})
}
+514
View File
@@ -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
}
+126
View File
@@ -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()
})
+82
View File
@@ -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)
}
+125
View File
@@ -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)))
}
+100
View File
@@ -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()
+6458
View File
File diff suppressed because it is too large Load Diff
+58
View File
@@ -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"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+495
View File
@@ -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>
+3
View File
File diff suppressed because one or more lines are too long
+6
View File
@@ -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

+9
View File
@@ -0,0 +1,9 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<script type="module" src="/out.js"></script>
</head>
<body> </body>
</html>
+1
View File
@@ -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

+60
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
# Generated by Cargo
# will have compiled files and executables
/target/
/gen/schemas
+6430
View File
File diff suppressed because it is too large Load Diff
+43
View File
@@ -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"
+8
View File
@@ -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>
+12
View File
@@ -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>
+16
View File
@@ -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>
+16
View File
@@ -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>
View File
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+222
View File
@@ -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"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 381 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 382 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 383 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 382 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 385 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 381 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 380 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 379 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 379 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 380 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 B

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+106
View File
@@ -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| {})
}
+7
View File
@@ -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();
}
+84
View File
@@ -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"
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

+1
View File
@@ -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

+151
View File
@@ -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>
)
}
+311
View File
@@ -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>
)
}
+316
View File
@@ -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>
)
}
+539
View File
@@ -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>
)
}
+282
View File
@@ -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>
)
}
+6
View File
@@ -0,0 +1,6 @@
// import { useStore } from '../../lib/store'
export default function TauriWatcher() {
// const count = useStore((state) => state.count)
return <div>TauriWatcher</div>
}
+22
View File
@@ -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; */
}
+72
View File
@@ -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>
)
+204
View File
@@ -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>
)
}
+11
View File
@@ -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
+159
View File
@@ -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>
)
}
+253
View File
@@ -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>
)
}
+156
View File
@@ -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
+189
View File
@@ -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>
)
}
+52
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+14
View File
@@ -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()],
}
+25
View File
@@ -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" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+26
View File
@@ -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
}
+37
View File
@@ -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/**'],
},
},
}))