Commander (in chief)
This commit is contained in:
Generated
+483
-5
@@ -12,6 +12,7 @@
|
||||
"@formkit/auto-animate": "^0.9.0",
|
||||
"@heroui/react": "^2.7.11",
|
||||
"@mux/mux-player-react": "^3.8.0",
|
||||
"@react-pdf/renderer": "^4.3.2",
|
||||
"@sentry/browser": "^10.8.0",
|
||||
"@tanstack/query-persist-client-core": "^5.91.6",
|
||||
"@tanstack/query-sync-storage-persister": "^5.90.9",
|
||||
@@ -44,6 +45,7 @@
|
||||
"rclone-sdk": "^1.72.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-resizable-panels": "^4.5.5",
|
||||
"react-router-dom": "^6.28.1",
|
||||
"tauri-plugin-sentry-api": "^0.5.0",
|
||||
"use-broadcast-ts": "^2.0.1",
|
||||
@@ -4002,6 +4004,180 @@
|
||||
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-pdf/fns": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/fns/-/fns-3.1.2.tgz",
|
||||
"integrity": "sha512-qTKGUf0iAMGg2+OsUcp9ffKnKi41RukM/zYIWMDJ4hRVYSr89Q7e3wSDW/Koqx3ea3Uy/z3h2y3wPX6Bdfxk6g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@react-pdf/font": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/font/-/font-4.0.4.tgz",
|
||||
"integrity": "sha512-8YtgGtL511txIEc9AjiilpZ7yjid8uCd8OGUl6jaL3LIHnrToUupSN4IzsMQpVTCMYiDLFnDNQzpZsOYtRS/Pg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@react-pdf/pdfkit": "^4.1.0",
|
||||
"@react-pdf/types": "^2.9.2",
|
||||
"fontkit": "^2.0.2",
|
||||
"is-url": "^1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-pdf/image": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/image/-/image-3.0.4.tgz",
|
||||
"integrity": "sha512-z0ogVQE0bKqgXQ5smgzIU857rLV7bMgVdrYsu3UfXDDLSzI7QPvzf6MFTFllX6Dx2rcsF13E01dqKPtJEM799g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@react-pdf/png-js": "^3.0.0",
|
||||
"jay-peg": "^1.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-pdf/layout": {
|
||||
"version": "4.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/layout/-/layout-4.4.2.tgz",
|
||||
"integrity": "sha512-gNu2oh8MiGR+NJZYTJ4c4q0nWCESBI6rKFiodVhE7OeVAjtzZzd6l65wsN7HXdWJqOZD3ttD97iE+tf5SOd/Yg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@react-pdf/fns": "3.1.2",
|
||||
"@react-pdf/image": "^3.0.4",
|
||||
"@react-pdf/primitives": "^4.1.1",
|
||||
"@react-pdf/stylesheet": "^6.1.2",
|
||||
"@react-pdf/textkit": "^6.1.0",
|
||||
"@react-pdf/types": "^2.9.2",
|
||||
"emoji-regex-xs": "^1.0.0",
|
||||
"queue": "^6.0.1",
|
||||
"yoga-layout": "^3.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-pdf/pdfkit": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/pdfkit/-/pdfkit-4.1.0.tgz",
|
||||
"integrity": "sha512-Wm/IOAv0h/U5Ra94c/PltFJGcpTUd/fwVMVeFD6X9tTTPCttIwg0teRG1Lqq617J8K4W7jpL/B0HTH0mjp3QpQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.13",
|
||||
"@react-pdf/png-js": "^3.0.0",
|
||||
"browserify-zlib": "^0.2.0",
|
||||
"crypto-js": "^4.2.0",
|
||||
"fontkit": "^2.0.2",
|
||||
"jay-peg": "^1.1.1",
|
||||
"linebreak": "^1.1.0",
|
||||
"vite-compatible-readable-stream": "^3.6.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-pdf/png-js": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/png-js/-/png-js-3.0.0.tgz",
|
||||
"integrity": "sha512-eSJnEItZ37WPt6Qv5pncQDxLJRK15eaRwPT+gZoujP548CodenOVp49GST8XJvKMFt9YqIBzGBV/j9AgrOQzVA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"browserify-zlib": "^0.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-pdf/primitives": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/primitives/-/primitives-4.1.1.tgz",
|
||||
"integrity": "sha512-IuhxYls1luJb7NUWy6q5avb1XrNaVj9bTNI40U9qGRuS6n7Hje/8H8Qi99Z9UKFV74bBP3DOf3L1wV2qZVgVrQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@react-pdf/reconciler": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/reconciler/-/reconciler-2.0.0.tgz",
|
||||
"integrity": "sha512-7zaPRujpbHSmCpIrZ+b9HSTJHthcVZzX0Wx7RzvQGsGBUbHP4p6s5itXrAIOuQuPvDepoHGNOvf6xUuMVvdoyw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"object-assign": "^4.1.1",
|
||||
"scheduler": "0.25.0-rc-603e6108-20241029"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-pdf/reconciler/node_modules/scheduler": {
|
||||
"version": "0.25.0-rc-603e6108-20241029",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0-rc-603e6108-20241029.tgz",
|
||||
"integrity": "sha512-pFwF6H1XrSdYYNLfOcGlM28/j8CGLu8IvdrxqhjWULe2bPcKiKW4CV+OWqR/9fT52mywx65l7ysNkjLKBda7eA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@react-pdf/render": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/render/-/render-4.3.2.tgz",
|
||||
"integrity": "sha512-el5KYM1sH/PKcO4tRCIm8/AIEmhtraaONbwCrBhFdehoGv6JtgnXiMxHGAvZbI5kEg051GbyP+XIU6f6YbOu6Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.13",
|
||||
"@react-pdf/fns": "3.1.2",
|
||||
"@react-pdf/primitives": "^4.1.1",
|
||||
"@react-pdf/textkit": "^6.1.0",
|
||||
"@react-pdf/types": "^2.9.2",
|
||||
"abs-svg-path": "^0.1.1",
|
||||
"color-string": "^1.9.1",
|
||||
"normalize-svg-path": "^1.1.0",
|
||||
"parse-svg-path": "^0.1.2",
|
||||
"svg-arc-to-cubic-bezier": "^3.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-pdf/renderer": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/renderer/-/renderer-4.3.2.tgz",
|
||||
"integrity": "sha512-EhPkj35gO9rXIyyx29W3j3axemvVY5RigMmlK4/6Ku0pXB8z9PEE/sz4ZBOShu2uot6V4xiCR3aG+t9IjJJlBQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.13",
|
||||
"@react-pdf/fns": "3.1.2",
|
||||
"@react-pdf/font": "^4.0.4",
|
||||
"@react-pdf/layout": "^4.4.2",
|
||||
"@react-pdf/pdfkit": "^4.1.0",
|
||||
"@react-pdf/primitives": "^4.1.1",
|
||||
"@react-pdf/reconciler": "^2.0.0",
|
||||
"@react-pdf/render": "^4.3.2",
|
||||
"@react-pdf/types": "^2.9.2",
|
||||
"events": "^3.3.0",
|
||||
"object-assign": "^4.1.1",
|
||||
"prop-types": "^15.6.2",
|
||||
"queue": "^6.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-pdf/stylesheet": {
|
||||
"version": "6.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/stylesheet/-/stylesheet-6.1.2.tgz",
|
||||
"integrity": "sha512-E3ftGRYUQGKiN3JOgtGsLDo0hGekA6dmkmi/MYACytmPTKxQRBSO3126MebmCq+t1rgU9uRlREIEawJ+8nzSbw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@react-pdf/fns": "3.1.2",
|
||||
"@react-pdf/types": "^2.9.2",
|
||||
"color-string": "^1.9.1",
|
||||
"hsl-to-hex": "^1.0.0",
|
||||
"media-engine": "^1.0.3",
|
||||
"postcss-value-parser": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-pdf/textkit": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/textkit/-/textkit-6.1.0.tgz",
|
||||
"integrity": "sha512-sFlzDC9CDFrJsnL3B/+NHrk9+Advqk7iJZIStiYQDdskbow8GF/AGYrpIk+vWSnh35YxaGbHkqXq53XOxnyrjQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@react-pdf/fns": "3.1.2",
|
||||
"bidi-js": "^1.0.2",
|
||||
"hyphen": "^1.6.4",
|
||||
"unicode-properties": "^1.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-pdf/types": {
|
||||
"version": "2.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/types/-/types-2.9.2.tgz",
|
||||
"integrity": "sha512-dufvpKId9OajLLbgn9q7VLUmyo1Jf+iyGk2ZHmCL8nIDtL8N1Ejh9TH7+pXXrR0tdie1nmnEb5Bz9U7g4hI4/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@react-pdf/font": "^4.0.4",
|
||||
"@react-pdf/primitives": "^4.1.1",
|
||||
"@react-pdf/stylesheet": "^6.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-stately/calendar": {
|
||||
"version": "3.8.2",
|
||||
"resolved": "https://registry.npmjs.org/@react-stately/calendar/-/calendar-3.8.2.tgz",
|
||||
@@ -5908,6 +6084,12 @@
|
||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/abs-svg-path": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/abs-svg-path/-/abs-svg-path-0.1.1.tgz",
|
||||
"integrity": "sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.14.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz",
|
||||
@@ -6049,6 +6231,35 @@
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bidi-js": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
|
||||
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"require-from-string": "^2.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/binary-extensions": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||
@@ -6079,6 +6290,24 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/brotli": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
|
||||
"integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/browserify-zlib": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz",
|
||||
"integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pako": "~1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.25.1",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz",
|
||||
@@ -6219,6 +6448,15 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/clone": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
|
||||
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
|
||||
@@ -6336,6 +6574,12 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/crypto-js": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
|
||||
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cssesc": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||
@@ -6412,6 +6656,12 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/dfa": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
|
||||
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/didyoumean": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
|
||||
@@ -6439,6 +6689,12 @@
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
|
||||
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="
|
||||
},
|
||||
"node_modules/emoji-regex-xs": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-1.0.0.tgz",
|
||||
"integrity": "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
||||
@@ -6491,13 +6747,20 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/events": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
||||
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.8.x"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-glob": {
|
||||
"version": "3.3.2",
|
||||
@@ -6553,6 +6816,23 @@
|
||||
"flat": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/fontkit": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
|
||||
"integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.5.12",
|
||||
"brotli": "^1.3.2",
|
||||
"clone": "^2.1.2",
|
||||
"dfa": "^1.2.0",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"restructure": "^3.0.0",
|
||||
"tiny-inflate": "^1.0.3",
|
||||
"unicode-properties": "^1.4.0",
|
||||
"unicode-trie": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/foreground-child": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz",
|
||||
@@ -6695,6 +6975,21 @@
|
||||
"integrity": "sha512-CSpT2aXsv71HST8C5ETeVo+6YybqCpHBiYrCRQSn3U5QUZuLTSsvtq/bj+zuvjLVADeKxoebzo16OkH8m1+65Q==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/hsl-to-hex": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/hsl-to-hex/-/hsl-to-hex-1.0.0.tgz",
|
||||
"integrity": "sha512-K6GVpucS5wFf44X0h2bLVRDsycgJmf9FF2elg+CrqD8GcFU8c6vYhgXn8NjUkFCwj+xDFb70qgLbTUm6sxwPmA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"hsl-to-rgb-for-reals": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/hsl-to-rgb-for-reals": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/hsl-to-rgb-for-reals/-/hsl-to-rgb-for-reals-1.1.1.tgz",
|
||||
"integrity": "sha512-LgOWAkrN0rFaQpfdWBQlv/VhkOxb5AsBjk6NQVx4yEzWS923T07X0M1Y0VNko2H52HeSpZrZNNMJ0aFqsdVzQg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
@@ -6710,6 +7005,12 @@
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/hyphen": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/hyphen/-/hyphen-1.14.1.tgz",
|
||||
"integrity": "sha512-kvL8xYl5QMTh+LwohVN72ciOxC0OEV79IPdJSTwEXok9y9QHebXGdFgrED4sWfiax/ODx++CAMk3hMy4XPJPOw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/index-to-position": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz",
|
||||
@@ -6724,6 +7025,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/input-otp": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/input-otp/-/input-otp-1.4.1.tgz",
|
||||
@@ -6823,6 +7130,12 @@
|
||||
"node": ">=0.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-url": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz",
|
||||
"integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
@@ -6842,6 +7155,15 @@
|
||||
"@pkgjs/parseargs": "^0.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jay-peg": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/jay-peg/-/jay-peg-1.1.1.tgz",
|
||||
"integrity": "sha512-D62KEuBxz/ip2gQKOEhk/mx14o7eiFRaU+VNNSP4MOiIkwb/D6B3G1Mfas7C/Fit8EsSV2/IWjZElx/Gs6A4ww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"restructure": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jiti": {
|
||||
"version": "1.21.7",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
|
||||
@@ -6925,6 +7247,25 @@
|
||||
"url": "https://github.com/sponsors/antonk52"
|
||||
}
|
||||
},
|
||||
"node_modules/linebreak": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
|
||||
"integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "0.0.8",
|
||||
"unicode-trie": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/linebreak/node_modules/base64-js": {
|
||||
"version": "0.0.8",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
|
||||
"integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/lines-and-columns": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
||||
@@ -6978,6 +7319,12 @@
|
||||
"ce-la-react": "^0.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/media-engine": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/media-engine/-/media-engine-1.0.3.tgz",
|
||||
"integrity": "sha512-aa5tG6sDoK+k70B9iEX1NeyfT8ObCKhNDs6lJVpwF6r8vhUfuKMslIcirq6HIUYuuUYLefcEQOn9bSBOvawtwg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/media-tracks": {
|
||||
"version": "0.3.3",
|
||||
"resolved": "https://registry.npmjs.org/media-tracks/-/media-tracks-0.3.3.tgz",
|
||||
@@ -7106,6 +7453,15 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/normalize-svg-path": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-svg-path/-/normalize-svg-path-1.1.0.tgz",
|
||||
"integrity": "sha512-r9KHKG2UUeB5LoTouwDzBy2VxXlHsiM6fyLQvnJa0S5hrhzqElH/CH7TUGhT1fVvIYBIKf3OpY4YJ4CK+iaqHg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"svg-arc-to-cubic-bezier": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
@@ -7170,6 +7526,12 @@
|
||||
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
||||
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/parse-json": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz",
|
||||
@@ -7189,6 +7551,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/parse-svg-path": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.1.2.tgz",
|
||||
"integrity": "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/path-key": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
@@ -7447,6 +7815,15 @@
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/queue": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
|
||||
"integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "~2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
@@ -7575,6 +7952,16 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-resizable-panels": {
|
||||
"version": "4.5.5",
|
||||
"resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-4.5.5.tgz",
|
||||
"integrity": "sha512-C1N+mcHF5lOkELEx8QpFsiHCibOCu82LVqULp2q00/88Mcofqp1oy4tZv0H4eArzWJsCo6RZ0jeTb0/4tpVlNg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "6.28.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.28.1.tgz",
|
||||
@@ -7646,8 +8033,6 @@
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -7671,6 +8056,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/restructure": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
|
||||
"integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/reusify": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
|
||||
@@ -7742,6 +8133,26 @@
|
||||
"queue-microtask": "^1.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.23.2",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||
@@ -7839,6 +8250,15 @@
|
||||
"source-map": "^0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
|
||||
@@ -7973,6 +8393,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/svg-arc-to-cubic-bezier": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/svg-arc-to-cubic-bezier/-/svg-arc-to-cubic-bezier-3.2.0.tgz",
|
||||
"integrity": "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/swr": {
|
||||
"version": "2.3.6",
|
||||
"resolved": "https://registry.npmjs.org/swr/-/swr-2.3.6.tgz",
|
||||
@@ -8121,6 +8547,12 @@
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/tiny-inflate": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
|
||||
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.14",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",
|
||||
@@ -8218,6 +8650,32 @@
|
||||
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/unicode-properties": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
|
||||
"integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.0",
|
||||
"unicode-trie": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unicode-trie": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
|
||||
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pako": "^0.2.5",
|
||||
"tiny-inflate": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unicode-trie/node_modules/pako": {
|
||||
"version": "0.2.9",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
|
||||
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
|
||||
@@ -8413,6 +8871,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vite-compatible-readable-stream": {
|
||||
"version": "3.6.1",
|
||||
"resolved": "https://registry.npmjs.org/vite-compatible-readable-stream/-/vite-compatible-readable-stream-3.6.1.tgz",
|
||||
"integrity": "sha512-t20zYkrSf868+j/p31cRIGN28Phrjm3nRSLR2fyc2tiWi4cZGVdv68yNlwnIINTkMTmPoMiSlc0OadaO7DXZaQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
"util-deprecate": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/fdir": {
|
||||
"version": "6.4.6",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz",
|
||||
@@ -8577,6 +9049,12 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/yoga-layout": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz",
|
||||
"integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/zustand": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz",
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"@formkit/auto-animate": "^0.9.0",
|
||||
"@heroui/react": "^2.7.11",
|
||||
"@mux/mux-player-react": "^3.8.0",
|
||||
"@react-pdf/renderer": "^4.3.2",
|
||||
"@sentry/browser": "^10.8.0",
|
||||
"@tanstack/query-persist-client-core": "^5.91.6",
|
||||
"@tanstack/query-sync-storage-persister": "^5.90.9",
|
||||
@@ -67,6 +68,7 @@
|
||||
"rclone-sdk": "^1.72.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-resizable-panels": "^4.5.5",
|
||||
"react-router-dom": "^6.28.1",
|
||||
"tauri-plugin-sentry-api": "^0.5.0",
|
||||
"use-broadcast-ts": "^2.0.1",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"Bisync",
|
||||
"Download",
|
||||
"Browse",
|
||||
"Commander",
|
||||
"Startup",
|
||||
"Toolbar",
|
||||
"Onboarding",
|
||||
|
||||
@@ -270,6 +270,7 @@ export function PathField({
|
||||
setPath(item.path)
|
||||
}}
|
||||
isOpen={isOpen}
|
||||
initialPaths={path ? [path] : []}
|
||||
allowedKeys={allowedKeys}
|
||||
allowFiles={showFiles}
|
||||
allowMultiple={false}
|
||||
@@ -399,6 +400,7 @@ export function MultiPathField({
|
||||
setPaths(Array.from(newPaths))
|
||||
}}
|
||||
isOpen={isOpen}
|
||||
initialPaths={paths}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
+169
-1450
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,139 @@
|
||||
import {
|
||||
FileIcon as FileIconLucide,
|
||||
FileTextIcon,
|
||||
FileImageIcon,
|
||||
FileVideoIcon,
|
||||
FileAudioIcon,
|
||||
FileArchiveIcon,
|
||||
FileCodeIcon,
|
||||
FileSpreadsheetIcon,
|
||||
FolderIcon,
|
||||
FileJson2Icon,
|
||||
FileTypeIcon,
|
||||
} from 'lucide-react'
|
||||
import { getFileExtension } from './utils'
|
||||
import type { Entry } from './types'
|
||||
|
||||
const IMAGE_EXTENSIONS = new Set([
|
||||
'jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'ico', 'tiff', 'tif', 'heic', 'heif', 'avif'
|
||||
])
|
||||
|
||||
const VIDEO_EXTENSIONS = new Set([
|
||||
'mp4', 'webm', 'mkv', 'avi', 'mov', 'wmv', 'flv', 'm4v', 'mpg', 'mpeg', '3gp', 'ogv'
|
||||
])
|
||||
|
||||
const AUDIO_EXTENSIONS = new Set([
|
||||
'mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a', 'wma', 'opus', 'aiff', 'mid', 'midi'
|
||||
])
|
||||
|
||||
const ARCHIVE_EXTENSIONS = new Set([
|
||||
'zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz', 'tgz', 'tbz2', 'lz', 'lzma', 'cab', 'iso', 'dmg'
|
||||
])
|
||||
|
||||
const CODE_EXTENSIONS = new Set([
|
||||
'js', 'ts', 'jsx', 'tsx', 'py', 'rb', 'java', 'c', 'cpp', 'h', 'hpp', 'cs', 'go', 'rs',
|
||||
'php', 'swift', 'kt', 'scala', 'sh', 'bash', 'zsh', 'ps1', 'bat', 'cmd', 'lua', 'r',
|
||||
'pl', 'pm', 'ex', 'exs', 'erl', 'hrl', 'clj', 'cljs', 'hs', 'ml', 'fs', 'vb', 'asm', 's'
|
||||
])
|
||||
|
||||
const CONFIG_EXTENSIONS = new Set([
|
||||
'json', 'yaml', 'yml', 'toml', 'ini', 'cfg', 'conf', 'config', 'env', 'properties'
|
||||
])
|
||||
|
||||
const DOCUMENT_EXTENSIONS = new Set([
|
||||
'pdf', 'doc', 'docx', 'odt', 'rtf', 'pages'
|
||||
])
|
||||
|
||||
const SPREADSHEET_EXTENSIONS = new Set([
|
||||
'xls', 'xlsx', 'csv', 'ods', 'numbers'
|
||||
])
|
||||
|
||||
const PRESENTATION_EXTENSIONS = new Set([
|
||||
'ppt', 'pptx', 'odp', 'key'
|
||||
])
|
||||
|
||||
const TEXT_EXTENSIONS = new Set([
|
||||
'txt', 'md', 'markdown', 'rst', 'log', 'readme', 'license', 'changelog', 'todo'
|
||||
])
|
||||
|
||||
export type FileType =
|
||||
| 'folder'
|
||||
| 'image'
|
||||
| 'video'
|
||||
| 'audio'
|
||||
| 'archive'
|
||||
| 'code'
|
||||
| 'config'
|
||||
| 'document'
|
||||
| 'spreadsheet'
|
||||
| 'presentation'
|
||||
| 'text'
|
||||
| 'unknown'
|
||||
|
||||
export function getFileType(entry: Entry): FileType {
|
||||
if (entry.isDir) return 'folder'
|
||||
|
||||
const ext = getFileExtension(entry.name)
|
||||
|
||||
if (IMAGE_EXTENSIONS.has(ext)) return 'image'
|
||||
if (VIDEO_EXTENSIONS.has(ext)) return 'video'
|
||||
if (AUDIO_EXTENSIONS.has(ext)) return 'audio'
|
||||
if (ARCHIVE_EXTENSIONS.has(ext)) return 'archive'
|
||||
if (CODE_EXTENSIONS.has(ext)) return 'code'
|
||||
if (CONFIG_EXTENSIONS.has(ext)) return 'config'
|
||||
if (DOCUMENT_EXTENSIONS.has(ext)) return 'document'
|
||||
if (SPREADSHEET_EXTENSIONS.has(ext)) return 'spreadsheet'
|
||||
if (PRESENTATION_EXTENSIONS.has(ext)) return 'presentation'
|
||||
if (TEXT_EXTENSIONS.has(ext)) return 'text'
|
||||
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
export function isPreviewable(entry: Entry): boolean {
|
||||
const type = getFileType(entry)
|
||||
return type === 'image' || type === 'video' || type === 'audio' || type === 'text' || type === 'document'
|
||||
}
|
||||
|
||||
const ICON_SIZE_MAP = {
|
||||
sm: 'size-4',
|
||||
md: 'size-5',
|
||||
lg: 'size-6',
|
||||
} as const
|
||||
|
||||
export default function FileIcon({
|
||||
entry,
|
||||
size = 'md',
|
||||
}: {
|
||||
entry: Entry
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
}) {
|
||||
const sizeClass = ICON_SIZE_MAP[size]
|
||||
const type = getFileType(entry)
|
||||
|
||||
switch (type) {
|
||||
case 'folder':
|
||||
return <FolderIcon className={`${sizeClass} text-warning`} />
|
||||
case 'image':
|
||||
return <FileImageIcon className={`${sizeClass} text-success`} />
|
||||
case 'video':
|
||||
return <FileVideoIcon className={`${sizeClass} text-danger`} />
|
||||
case 'audio':
|
||||
return <FileAudioIcon className={`${sizeClass} text-secondary`} />
|
||||
case 'archive':
|
||||
return <FileArchiveIcon className={`${sizeClass} text-warning`} />
|
||||
case 'code':
|
||||
return <FileCodeIcon className={`${sizeClass} text-primary`} />
|
||||
case 'config':
|
||||
return <FileJson2Icon className={`${sizeClass} text-default-500`} />
|
||||
case 'document':
|
||||
return <FileTypeIcon className={`${sizeClass} text-danger`} />
|
||||
case 'spreadsheet':
|
||||
return <FileSpreadsheetIcon className={`${sizeClass} text-success`} />
|
||||
case 'presentation':
|
||||
return <FileTypeIcon className={`${sizeClass} text-warning`} />
|
||||
case 'text':
|
||||
return <FileTextIcon className={`${sizeClass} text-default-600`} />
|
||||
default:
|
||||
return <FileIconLucide className={`${sizeClass} text-default-400`} />
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
import { Button, Checkbox, Listbox, ListboxItem, cn } from '@heroui/react'
|
||||
import { platform } from '@tauri-apps/plugin-os'
|
||||
import { DownloadIcon, EyeIcon, StarIcon } from 'lucide-react'
|
||||
import { useCallback } from 'react'
|
||||
import { formatBytes } from '../../../lib/format.ts'
|
||||
import FileIcon from './FileIcon'
|
||||
import type { Entry, PaddingItem, VirtualizedEntry } from './types'
|
||||
import { dragStateRef, dropTargetsRef, formatModTime } from './utils'
|
||||
|
||||
export default function FileList({
|
||||
items,
|
||||
isLoading,
|
||||
error,
|
||||
selectedKeys,
|
||||
onToggleSelect,
|
||||
onNavigate,
|
||||
selectionMode,
|
||||
allowMultiple,
|
||||
showPreviewColumn = false,
|
||||
onPreviewClick,
|
||||
draggable = false,
|
||||
onDragStart,
|
||||
onContextMenu,
|
||||
favoritedKeys,
|
||||
onToggleFavorite,
|
||||
onDownload,
|
||||
listHeight,
|
||||
}: {
|
||||
items: (VirtualizedEntry | PaddingItem)[]
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
selectedKeys: Set<string>
|
||||
onToggleSelect: (entry: Entry) => void
|
||||
onNavigate: (entry: Entry) => void
|
||||
selectionMode: 'checkbox' | 'drag' | 'both'
|
||||
allowMultiple: boolean
|
||||
showPreviewColumn?: boolean
|
||||
onPreviewClick?: (entry: Entry) => void
|
||||
draggable?: boolean
|
||||
onDragStart?: (items: Entry[]) => void
|
||||
onContextMenu?: (entry: Entry, event: React.MouseEvent) => void
|
||||
favoritedKeys?: Record<string, boolean>
|
||||
onToggleFavorite?: (entry: Entry, isFavorited: boolean) => void
|
||||
onDownload?: (entry: Entry) => void
|
||||
listHeight: number
|
||||
}) {
|
||||
const showCheckbox = selectionMode === 'checkbox' || selectionMode === 'both'
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(entry: VirtualizedEntry, event: React.DragEvent) => {
|
||||
if (!draggable || !onDragStart) return
|
||||
|
||||
const selectedEntries: Entry[] = []
|
||||
if (selectedKeys.has(entry.key)) {
|
||||
for (const item of items) {
|
||||
if ('padding' in item) continue
|
||||
if (selectedKeys.has(item.key)) {
|
||||
selectedEntries.push(item)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
selectedEntries.push(entry)
|
||||
}
|
||||
|
||||
const paths = selectedEntries.map((e) => e.fullPath)
|
||||
dragStateRef.current = selectedEntries.map((e) => ({
|
||||
path: e.fullPath,
|
||||
type: e.isDir ? 'folder' : 'file',
|
||||
}))
|
||||
event.dataTransfer.setData('application/json', JSON.stringify(paths))
|
||||
event.dataTransfer.effectAllowed = 'copyMove'
|
||||
|
||||
const count = selectedEntries.length
|
||||
const dragPreview = document.createElement('div')
|
||||
dragPreview.style.cssText = `
|
||||
position: fixed;
|
||||
top: -1000px;
|
||||
left: -1000px;
|
||||
padding: 8px 16px;
|
||||
background: hsl(var(--heroui-primary-500));
|
||||
color: white;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
`
|
||||
dragPreview.textContent = count === 1 ? selectedEntries[0].name : `${count} items`
|
||||
document.body.appendChild(dragPreview)
|
||||
event.dataTransfer.setDragImage(
|
||||
dragPreview,
|
||||
dragPreview.offsetWidth / 2,
|
||||
dragPreview.offsetHeight / 2
|
||||
)
|
||||
requestAnimationFrame(() => document.body.removeChild(dragPreview))
|
||||
|
||||
onDragStart(selectedEntries)
|
||||
},
|
||||
[draggable, onDragStart, selectedKeys, items]
|
||||
)
|
||||
|
||||
const handleDragEnd = useCallback((event: React.DragEvent) => {
|
||||
if (!dragStateRef.current || dragStateRef.current.length === 0) return
|
||||
|
||||
const mouseX = event.clientX
|
||||
const mouseY = event.clientY
|
||||
|
||||
for (const [_id, target] of dropTargetsRef.current) {
|
||||
const rect = target.element.getBoundingClientRect()
|
||||
if (
|
||||
mouseX >= rect.left &&
|
||||
mouseX <= rect.right &&
|
||||
mouseY >= rect.top &&
|
||||
mouseY <= rect.bottom
|
||||
) {
|
||||
const destination = target.getDestination()
|
||||
target.onDrop(dragStateRef.current, destination)
|
||||
break
|
||||
}
|
||||
}
|
||||
dragStateRef.current = null
|
||||
}, [])
|
||||
|
||||
const handleContextMenu = useCallback(
|
||||
(entry: VirtualizedEntry, event: React.MouseEvent) => {
|
||||
if (!onContextMenu) return
|
||||
event.preventDefault()
|
||||
onContextMenu(entry, event)
|
||||
},
|
||||
[onContextMenu]
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center flex-1 w-full h-full">
|
||||
<div className="animate-blink">Loading...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-danger">
|
||||
{error}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const visibleItems = items.filter((item) => !('padding' in item)) as VirtualizedEntry[]
|
||||
|
||||
if (visibleItems.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-default-500">
|
||||
No items
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const gridCols = showPreviewColumn
|
||||
? 'grid-cols-[2.5rem_1fr_6rem_9rem_5rem]'
|
||||
: 'grid-cols-[2.5rem_1fr_6rem_9rem_2.5rem]'
|
||||
|
||||
return (
|
||||
<Listbox
|
||||
items={items}
|
||||
isVirtualized={true}
|
||||
virtualization={{
|
||||
maxListboxHeight: listHeight,
|
||||
itemHeight: 50,
|
||||
}}
|
||||
classNames={{
|
||||
base: 'w-full p-0 m-0',
|
||||
list: 'w-full p-0 m-0 gap-0',
|
||||
}}
|
||||
// @ts-ignore
|
||||
scrollShadowProps={{
|
||||
className: platform() !== 'macos' ? 'show-scrollbar' : undefined,
|
||||
}}
|
||||
selectionMode="none"
|
||||
hideSelectedIcon={true}
|
||||
selectedKeys={[]}
|
||||
shouldHighlightOnFocus={false}
|
||||
autoFocus={false}
|
||||
disallowEmptySelection={false}
|
||||
>
|
||||
{(item) => {
|
||||
if ('padding' in item && item.padding) {
|
||||
return (
|
||||
<ListboxItem
|
||||
key={item.key}
|
||||
isDisabled={true}
|
||||
className="p-0 m-0 pointer-events-none"
|
||||
>
|
||||
<div className="h-[50px]" aria-hidden="true" />
|
||||
</ListboxItem>
|
||||
)
|
||||
}
|
||||
const entry = item as VirtualizedEntry
|
||||
const isSelected = entry.isSelected
|
||||
const isDisabled = !allowMultiple && selectedKeys.size > 0 && !isSelected
|
||||
const isFavorited = favoritedKeys?.[entry.key] ?? false
|
||||
|
||||
return (
|
||||
<ListboxItem
|
||||
key={entry.key}
|
||||
textValue={entry.name}
|
||||
classNames={{
|
||||
base: 'p-0 m-0 rounded-none !outline-none data-[focus-visible=true]:!outline-none focus:!outline-none',
|
||||
title: 'h-full justify-center flex',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
`grid ${gridCols} items-center hover:bg-content2 py-2 border-b border-divider group transition-colors w-full h-full`,
|
||||
isSelected ? 'bg-primary-50 hover:bg-primary-100' : ''
|
||||
)}
|
||||
draggable={draggable}
|
||||
onDragStart={(e) => handleDragStart(entry, e)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onContextMenu={(e) => handleContextMenu(entry, e)}
|
||||
>
|
||||
{showCheckbox && (
|
||||
<div
|
||||
className="flex items-center justify-end"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Checkbox
|
||||
isSelected={isSelected}
|
||||
isDisabled={isDisabled}
|
||||
onValueChange={() => onToggleSelect(entry)}
|
||||
aria-label="Select item"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!showCheckbox && <div />}
|
||||
|
||||
<div
|
||||
className="flex items-center h-full gap-2 pl-2 overflow-hidden cursor-pointer"
|
||||
onClick={() => onNavigate(entry)}
|
||||
>
|
||||
<FileIcon entry={entry} size="md" />
|
||||
<span className="truncate !cursor-pointer">{entry.name}</span>
|
||||
</div>
|
||||
|
||||
<div className="truncate text-small text-default-500">
|
||||
{!entry.isDir && typeof entry.size === 'number'
|
||||
? formatBytes(entry.size)
|
||||
: '—'}
|
||||
</div>
|
||||
|
||||
<div className="truncate text-small text-default-500">
|
||||
{formatModTime(entry.modTime)}
|
||||
</div>
|
||||
|
||||
{showPreviewColumn ? (
|
||||
<div className="flex items-center justify-end gap-1 pr-2">
|
||||
{entry.isDir && onToggleFavorite && (
|
||||
<Button
|
||||
isIconOnly={true}
|
||||
size="sm"
|
||||
variant="light"
|
||||
color="warning"
|
||||
className="transition-opacity duration-300 opacity-0 group-hover:opacity-100"
|
||||
onPress={() => onToggleFavorite(entry, isFavorited)}
|
||||
>
|
||||
<StarIcon
|
||||
className={cn(
|
||||
'size-5',
|
||||
isFavorited && 'fill-warning'
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
{!entry.isDir && onPreviewClick && (
|
||||
<Button
|
||||
isIconOnly={true}
|
||||
size="sm"
|
||||
variant="light"
|
||||
className="transition-opacity duration-300 opacity-0 group-hover:opacity-100"
|
||||
onPress={() => onPreviewClick(entry)}
|
||||
>
|
||||
<EyeIcon className="size-5" />
|
||||
</Button>
|
||||
)}
|
||||
{onDownload && (
|
||||
<Button
|
||||
isIconOnly={true}
|
||||
size="sm"
|
||||
variant="light"
|
||||
className="transition-opacity duration-300 opacity-0 group-hover:opacity-100"
|
||||
onPress={() => onDownload(entry)}
|
||||
>
|
||||
<DownloadIcon className="size-5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center w-10 shrink-0">
|
||||
{entry.isDir && onToggleFavorite && (
|
||||
<Button
|
||||
isIconOnly={true}
|
||||
size="sm"
|
||||
variant="light"
|
||||
color="warning"
|
||||
className="transition-opacity duration-300 opacity-0 group-hover:opacity-100"
|
||||
onPress={() => onToggleFavorite(entry, isFavorited)}
|
||||
>
|
||||
<StarIcon
|
||||
className={cn(
|
||||
'size-5',
|
||||
isFavorited && 'fill-warning'
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ListboxItem>
|
||||
)
|
||||
}}
|
||||
</Listbox>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
import { Divider } from '@heroui/react'
|
||||
import {
|
||||
forwardRef,
|
||||
startTransition,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useHostStore } from '../../../store/host.ts'
|
||||
import FileList from './FileList'
|
||||
import PanelToolbar, { type ToolbarButtons } from './PanelToolbar'
|
||||
import PathBreadcrumb from './PathBreadcrumb'
|
||||
import PreviewDrawer from './PreviewDrawer'
|
||||
import RemoteSidebar from './RemoteSidebar'
|
||||
import type { AllowedKey, ContextMenuItem, Entry, FilePanelHandle, SelectItem } from './types'
|
||||
import useFileNavigation from './useFileNavigation'
|
||||
import { RE_LEADING_SLASH, dragStateRef, dropTargetsRef, serializeRemotePath } from './utils'
|
||||
|
||||
export type { FilePanelHandle } from './types'
|
||||
|
||||
const FilePanel = forwardRef<
|
||||
FilePanelHandle,
|
||||
{
|
||||
sidebarPosition?: 'left' | 'right' | 'none'
|
||||
initialRemote?: string | 'UI_LOCAL_FS'
|
||||
initialPath?: string
|
||||
selectionMode?: 'checkbox' | 'drag' | 'both'
|
||||
allowFiles?: boolean
|
||||
allowMultiple?: boolean
|
||||
onSelectionChange?: (selected: SelectItem[]) => void
|
||||
onNavigate?: (remote: string, path: string) => void
|
||||
onDragStart?: (items: SelectItem[]) => void
|
||||
onDrop?: (items: SelectItem[], destination: string) => void
|
||||
showPreviewColumn?: boolean
|
||||
onPreviewRequest?: (item: Entry) => void
|
||||
onDownload?: (item: Entry) => void
|
||||
contextMenuItems?: ContextMenuItem[]
|
||||
allowedKeys?: AllowedKey[]
|
||||
renderToolbar?: (buttons: ToolbarButtons) => React.ReactNode[][]
|
||||
toolbarVisible?: boolean
|
||||
isActive?: boolean
|
||||
}
|
||||
>(function FilePanel(
|
||||
{
|
||||
sidebarPosition = 'left',
|
||||
initialRemote,
|
||||
initialPath,
|
||||
selectionMode = 'checkbox',
|
||||
allowFiles = true,
|
||||
allowMultiple = true,
|
||||
onSelectionChange,
|
||||
onNavigate,
|
||||
onDragStart,
|
||||
onDrop,
|
||||
showPreviewColumn = true,
|
||||
onPreviewRequest,
|
||||
onDownload,
|
||||
contextMenuItems,
|
||||
allowedKeys = ['REMOTES', 'LOCAL_FS'],
|
||||
renderToolbar,
|
||||
toolbarVisible = true,
|
||||
isActive = true,
|
||||
},
|
||||
ref
|
||||
) {
|
||||
const favoritePaths = useHostStore((state) => state.favoritePaths)
|
||||
const [previewItem, setPreviewItem] = useState<Entry | null>(null)
|
||||
|
||||
const nav = useFileNavigation({
|
||||
initialRemote,
|
||||
initialPath,
|
||||
allowedKeys,
|
||||
allowFiles,
|
||||
allowMultiple,
|
||||
isActive,
|
||||
})
|
||||
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const panelIdRef = useRef(`panel-${Math.random().toString(36).slice(2)}`)
|
||||
const [listHeight, setListHeight] = useState(400)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive || !listRef.current) return
|
||||
|
||||
const measureInitial = () => {
|
||||
if (listRef.current) {
|
||||
const height = listRef.current.getBoundingClientRect().height
|
||||
if (height > 0) {
|
||||
startTransition(() => setListHeight(height))
|
||||
}
|
||||
}
|
||||
}
|
||||
measureInitial()
|
||||
const timeoutId = setTimeout(measureInitial, 100)
|
||||
|
||||
const obs = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
startTransition(() => setListHeight(entry.contentRect.height))
|
||||
}
|
||||
})
|
||||
obs.observe(listRef.current)
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId)
|
||||
obs.disconnect()
|
||||
}
|
||||
}, [isActive])
|
||||
|
||||
useEffect(() => {
|
||||
if (!onDrop || !panelRef.current) return
|
||||
const panelId = panelIdRef.current
|
||||
const getDestination = () =>
|
||||
nav.selectedRemote === 'UI_LOCAL_FS'
|
||||
? nav.cwd
|
||||
: serializeRemotePath(nav.selectedRemote as string, nav.cwd)
|
||||
|
||||
dropTargetsRef.current.set(panelId, {
|
||||
element: panelRef.current,
|
||||
onDrop,
|
||||
getDestination,
|
||||
})
|
||||
return () => {
|
||||
dropTargetsRef.current.delete(panelId)
|
||||
}
|
||||
}, [onDrop, nav.selectedRemote, nav.cwd])
|
||||
|
||||
useEffect(() => {
|
||||
if (onSelectionChange) {
|
||||
onSelectionChange(nav.getSelection())
|
||||
}
|
||||
}, [nav.selectedPaths, onSelectionChange, nav.getSelection])
|
||||
|
||||
useEffect(() => {
|
||||
if (onNavigate && nav.selectedRemote) {
|
||||
onNavigate(nav.selectedRemote, nav.cwd)
|
||||
}
|
||||
}, [nav.selectedRemote, nav.cwd, onNavigate])
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
refresh: nav.refresh,
|
||||
getSelection: nav.getSelection,
|
||||
clearSelection: nav.clearSelection,
|
||||
selectAll: nav.selectAll,
|
||||
navigate: (remote: string, path: string) => {
|
||||
nav.setSelectedRemote(remote as any)
|
||||
nav.setCwd(path)
|
||||
},
|
||||
getCurrentPath: () => ({
|
||||
remote: nav.selectedRemote,
|
||||
path: nav.cwd,
|
||||
}),
|
||||
}),
|
||||
[nav]
|
||||
)
|
||||
|
||||
const handleToggleFavorite = useCallback(
|
||||
(entry: Entry, isFavorited: boolean) => {
|
||||
if (isFavorited) {
|
||||
useHostStore.setState({
|
||||
favoritePaths: (favoritePaths || []).filter((it) => {
|
||||
const remote = (it as any).remote as string | undefined
|
||||
const rawPath = (it as any).path as string
|
||||
let fullKey = rawPath
|
||||
if (rawPath?.includes(':/')) {
|
||||
fullKey = rawPath
|
||||
} else if (remote && remote !== 'UI_LOCAL_FS') {
|
||||
const rel = (rawPath || '').replace(RE_LEADING_SLASH, '')
|
||||
fullKey = serializeRemotePath(remote, rel)
|
||||
} else {
|
||||
fullKey = rawPath
|
||||
}
|
||||
return fullKey !== entry.fullPath
|
||||
}),
|
||||
})
|
||||
} else {
|
||||
const storedPath = entry.fullPath.includes(':/')
|
||||
? entry.fullPath.split(':/').slice(1).join('/')
|
||||
: entry.fullPath
|
||||
useHostStore.setState({
|
||||
favoritePaths: [
|
||||
...(favoritePaths || []),
|
||||
{
|
||||
remote: entry.remote!,
|
||||
path: storedPath,
|
||||
added: Date.now(),
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
},
|
||||
[favoritePaths]
|
||||
)
|
||||
|
||||
const handleDragStartInternal = useCallback(
|
||||
(items: Entry[]) => {
|
||||
if (onDragStart) {
|
||||
onDragStart(
|
||||
items.map((e) => ({ path: e.fullPath, type: e.isDir ? 'folder' : 'file' }))
|
||||
)
|
||||
}
|
||||
},
|
||||
[onDragStart]
|
||||
)
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(event: React.DragEvent) => {
|
||||
if (!onDrop) return
|
||||
event.preventDefault()
|
||||
|
||||
let items: SelectItem[] | null = null
|
||||
|
||||
const data = event.dataTransfer.getData('application/json')
|
||||
if (data) {
|
||||
try {
|
||||
const paths = JSON.parse(data) as string[]
|
||||
items = paths.map((p) => ({
|
||||
path: p,
|
||||
type: p.endsWith('/') ? 'folder' : 'file',
|
||||
}))
|
||||
} catch {
|
||||
// Invalid JSON data
|
||||
}
|
||||
}
|
||||
|
||||
if (!items && dragStateRef.current) {
|
||||
items = dragStateRef.current
|
||||
}
|
||||
|
||||
if (!items || items.length === 0) return
|
||||
|
||||
const destination =
|
||||
nav.selectedRemote === 'UI_LOCAL_FS'
|
||||
? nav.cwd
|
||||
: serializeRemotePath(nav.selectedRemote as string, nav.cwd)
|
||||
onDrop(items, destination)
|
||||
dragStateRef.current = null
|
||||
},
|
||||
[onDrop, nav.selectedRemote, nav.cwd]
|
||||
)
|
||||
|
||||
const handleDragOver = useCallback(
|
||||
(event: React.DragEvent) => {
|
||||
if (onDrop) {
|
||||
event.preventDefault()
|
||||
event.dataTransfer.dropEffect = 'copy'
|
||||
}
|
||||
},
|
||||
[onDrop]
|
||||
)
|
||||
|
||||
const handleContextMenu = useCallback((entry: Entry, event: React.MouseEvent) => {
|
||||
// Context menu is handled at the Browser level
|
||||
}, [])
|
||||
|
||||
const handlePreviewClick = useCallback(
|
||||
(entry: Entry) => {
|
||||
if (entry.isDir) return
|
||||
if (onPreviewRequest) {
|
||||
onPreviewRequest(entry)
|
||||
} else {
|
||||
setPreviewItem(entry)
|
||||
}
|
||||
},
|
||||
[onPreviewRequest]
|
||||
)
|
||||
|
||||
const showSidebar = sidebarPosition !== 'none'
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="flex flex-row w-full h-full"
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
>
|
||||
{showSidebar && sidebarPosition === 'left' && (
|
||||
<>
|
||||
<RemoteSidebar
|
||||
position="left"
|
||||
selectedRemote={nav.selectedRemote}
|
||||
onRemoteSelect={nav.selectRemote}
|
||||
allowedKeys={allowedKeys}
|
||||
remotes={nav.remotes}
|
||||
/>
|
||||
<Divider orientation="vertical" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col w-full h-full overflow-y-hidden ">
|
||||
<PathBreadcrumb
|
||||
remote={nav.selectedRemote}
|
||||
path={nav.cwd}
|
||||
pathInput={nav.pathInput}
|
||||
onNavigate={nav.navigateTo}
|
||||
onPathInputChange={nav.setPathInput}
|
||||
isReadOnly={nav.selectedRemote === 'UI_FAVORITES'}
|
||||
/>
|
||||
<Divider />
|
||||
|
||||
<div className="relative flex flex-col w-full h-full overflow-hidden">
|
||||
<div
|
||||
className={`sticky top-0 z-10 grid ${showPreviewColumn ? 'grid-cols-[2.5rem_1fr_6rem_9rem_5rem]' : 'grid-cols-[2.5rem_1fr_6rem_9rem_2.5rem]'} items-center py-2 bg-default-100`}
|
||||
>
|
||||
<div />
|
||||
<div className="pl-2 font-semibold text-small">Name</div>
|
||||
<div className="font-semibold text-small">Size</div>
|
||||
<div className="font-semibold text-small">Last Modified</div>
|
||||
<div />
|
||||
</div>
|
||||
|
||||
<div ref={listRef} className="relative flex-1 w-full overflow-hidden">
|
||||
<FileList
|
||||
items={nav.virtualizedItems}
|
||||
isLoading={nav.isLoading}
|
||||
error={nav.error}
|
||||
selectedKeys={nav.selectedPaths}
|
||||
onToggleSelect={nav.handleToggleSelect}
|
||||
onNavigate={nav.handleNavigate}
|
||||
selectionMode={selectionMode}
|
||||
allowMultiple={allowMultiple}
|
||||
showPreviewColumn={showPreviewColumn}
|
||||
onPreviewClick={handlePreviewClick}
|
||||
onDownload={onDownload}
|
||||
draggable={selectionMode === 'drag' || selectionMode === 'both'}
|
||||
onDragStart={handleDragStartInternal}
|
||||
onContextMenu={contextMenuItems ? handleContextMenu : undefined}
|
||||
favoritedKeys={nav.favoritedKeys}
|
||||
onToggleFavorite={handleToggleFavorite}
|
||||
listHeight={listHeight}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PanelToolbar
|
||||
onBack={nav.navigateUp}
|
||||
onRefresh={nav.refresh}
|
||||
isBackDisabled={nav.isUpDisabled}
|
||||
isLoading={nav.isLoading}
|
||||
searchTerm={nav.searchTerm}
|
||||
onSearchChange={nav.setSearchTerm}
|
||||
renderToolbar={renderToolbar}
|
||||
visible={toolbarVisible && nav.selectedRemote !== 'UI_FAVORITES'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showSidebar && sidebarPosition === 'right' && (
|
||||
<>
|
||||
<Divider orientation="vertical" />
|
||||
<RemoteSidebar
|
||||
position="right"
|
||||
selectedRemote={nav.selectedRemote}
|
||||
onRemoteSelect={nav.selectRemote}
|
||||
allowedKeys={allowedKeys}
|
||||
remotes={nav.remotes}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!onPreviewRequest && (
|
||||
<PreviewDrawer item={previewItem} onClose={() => setPreviewItem(null)} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export default FilePanel
|
||||
@@ -0,0 +1,118 @@
|
||||
import { Button, Input, Tooltip } from '@heroui/react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { ArrowLeftIcon, RefreshCwIcon } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
export type ToolbarButtons = {
|
||||
BackButton: ReactNode
|
||||
RefreshButton: ReactNode
|
||||
SearchInput: ReactNode
|
||||
}
|
||||
|
||||
export default function PanelToolbar({
|
||||
onBack,
|
||||
onRefresh,
|
||||
isBackDisabled,
|
||||
isLoading,
|
||||
searchTerm,
|
||||
onSearchChange,
|
||||
renderToolbar,
|
||||
visible = true,
|
||||
}: {
|
||||
onBack: () => void
|
||||
onRefresh: () => void
|
||||
isBackDisabled: boolean
|
||||
isLoading: boolean
|
||||
searchTerm: string
|
||||
onSearchChange: (term: string) => void
|
||||
renderToolbar?: (buttons: ToolbarButtons) => ReactNode[][]
|
||||
visible?: boolean
|
||||
}) {
|
||||
const BackButton = (
|
||||
<Tooltip content="Go to parent directory" size="lg" color="foreground">
|
||||
<Button
|
||||
color="primary"
|
||||
size="sm"
|
||||
onPress={onBack}
|
||||
isDisabled={isBackDisabled || isLoading}
|
||||
radius="full"
|
||||
startContent={<ArrowLeftIcon className="size-5" />}
|
||||
className="gap-1 min-w-fit"
|
||||
>
|
||||
BACK
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)
|
||||
|
||||
const RefreshButton = (
|
||||
<Tooltip content="Refresh directory" size="lg" color="foreground">
|
||||
<Button
|
||||
color="primary"
|
||||
size="sm"
|
||||
onPress={onRefresh}
|
||||
isDisabled={isLoading}
|
||||
radius="full"
|
||||
isIconOnly={true}
|
||||
>
|
||||
<RefreshCwIcon className={`size-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)
|
||||
|
||||
const SearchInput = (
|
||||
<Input
|
||||
size="sm"
|
||||
radius="full"
|
||||
placeholder="Type here to search"
|
||||
value={searchTerm}
|
||||
onValueChange={onSearchChange}
|
||||
isClearable={true}
|
||||
onClear={() => onSearchChange('')}
|
||||
autoCapitalize="off"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
spellCheck="false"
|
||||
classNames={{
|
||||
base: 'w-48',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
const buttons: ToolbarButtons = { BackButton, RefreshButton, SearchInput }
|
||||
const groups = renderToolbar
|
||||
? renderToolbar(buttons)
|
||||
: [[BackButton, RefreshButton], [SearchInput]]
|
||||
|
||||
const motionTransition = {
|
||||
enter: {
|
||||
type: 'spring',
|
||||
stiffness: 300,
|
||||
damping: 20,
|
||||
delay: 0.69,
|
||||
},
|
||||
exit: {
|
||||
duration: 0.2,
|
||||
delay: 0,
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute left-0 right-0 flex justify-center w-full gap-4 bottom-5">
|
||||
<AnimatePresence>
|
||||
{visible &&
|
||||
groups.map((group, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ y: 100, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: 100, opacity: 0 }}
|
||||
transition={motionTransition}
|
||||
className="flex flex-row items-center gap-2.5 px-2 py-1.5 rounded-full bg-content2"
|
||||
>
|
||||
{group}
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { Button, Input, Tooltip, cn } from '@heroui/react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { platform } from '@tauri-apps/plugin-os'
|
||||
import { CheckIcon, ChevronRightIcon, LaptopIcon, PencilIcon, StarIcon } from 'lucide-react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import rclone from '../../../lib/rclone/client.ts'
|
||||
import type { RemoteString } from './types'
|
||||
import { getPathSegments } from './utils'
|
||||
|
||||
export default function PathBreadcrumb({
|
||||
remote,
|
||||
path,
|
||||
pathInput,
|
||||
onNavigate,
|
||||
onPathInputChange,
|
||||
isReadOnly = false,
|
||||
}: {
|
||||
remote: RemoteString
|
||||
path: string
|
||||
pathInput: string
|
||||
onNavigate: (path: string) => void
|
||||
onPathInputChange: (value: string) => void
|
||||
isReadOnly?: boolean
|
||||
}) {
|
||||
const [isInputMode, setIsInputMode] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const remoteConfigQuery = useQuery({
|
||||
queryKey: ['remote', remote, 'config'],
|
||||
queryFn: async () => {
|
||||
if (!remote || remote === 'UI_LOCAL_FS' || remote === 'UI_FAVORITES') return null
|
||||
return await rclone('/config/get', {
|
||||
params: { query: { name: remote } },
|
||||
})
|
||||
},
|
||||
enabled: !!remote && remote !== 'UI_LOCAL_FS' && remote !== 'UI_FAVORITES',
|
||||
})
|
||||
|
||||
const remoteType = remoteConfigQuery.data?.type
|
||||
|
||||
const segments = getPathSegments(path)
|
||||
|
||||
const handleSegmentClick = useCallback(
|
||||
(index: number) => {
|
||||
if (index < 0) {
|
||||
onNavigate(remote === 'UI_LOCAL_FS' ? '/' : `${remote}:/`)
|
||||
} else {
|
||||
const newPath = segments.slice(0, index + 1).join('/')
|
||||
if (remote === 'UI_LOCAL_FS') {
|
||||
onNavigate('/' + newPath)
|
||||
} else {
|
||||
onNavigate(`${remote}:/${newPath}`)
|
||||
}
|
||||
}
|
||||
},
|
||||
[segments, onNavigate, remote]
|
||||
)
|
||||
|
||||
const handleInputKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
const value = pathInput.trim()
|
||||
if (value) {
|
||||
onNavigate(value)
|
||||
}
|
||||
setIsInputMode(false)
|
||||
} else if (e.key === 'Escape') {
|
||||
setIsInputMode(false)
|
||||
}
|
||||
},
|
||||
[pathInput, onNavigate]
|
||||
)
|
||||
|
||||
const toggleInputMode = useCallback(() => {
|
||||
if (isReadOnly) return
|
||||
setIsInputMode((prev) => !prev)
|
||||
}, [isReadOnly])
|
||||
|
||||
const handleConfirmInput = useCallback(() => {
|
||||
const value = pathInput.trim()
|
||||
if (value) {
|
||||
onNavigate(value)
|
||||
}
|
||||
setIsInputMode(false)
|
||||
}, [pathInput, onNavigate])
|
||||
|
||||
useEffect(() => {
|
||||
if (isInputMode && inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
inputRef.current.select()
|
||||
}
|
||||
}, [isInputMode])
|
||||
|
||||
const renderRemoteIcon = () => {
|
||||
if (remote === 'UI_LOCAL_FS') {
|
||||
return <LaptopIcon className="size-4" />
|
||||
}
|
||||
if (remote === 'UI_FAVORITES') {
|
||||
return <StarIcon className="stroke-warning fill-warning size-4" />
|
||||
}
|
||||
if (remoteType) {
|
||||
return (
|
||||
<img
|
||||
src={`/icons/backends/${remoteType}.png`}
|
||||
className="object-contain size-4"
|
||||
alt={remoteType}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const remoteLabel =
|
||||
remote === 'UI_LOCAL_FS' ? 'Local' : remote === 'UI_FAVORITES' ? 'Favorites' : remote
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group flex items-center w-full h-12 px-3 gap-1 bg-default-100 border-b border-divider',
|
||||
platform() === 'macos' && 'pt-5 h-16'
|
||||
)}
|
||||
>
|
||||
{isInputMode ? (
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={pathInput}
|
||||
onChange={(e) => onPathInputChange(e.target.value)}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
onBlur={() => setIsInputMode(false)}
|
||||
placeholder="Enter path"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
radius="sm"
|
||||
classNames={{
|
||||
base: 'flex-1',
|
||||
input: 'text-sm',
|
||||
inputWrapper: 'h-8',
|
||||
}}
|
||||
autoCapitalize="off"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
spellCheck="false"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center flex-1 gap-1 overflow-x-auto">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="light"
|
||||
className="gap-1.5 min-w-fit px-2 shrink-0"
|
||||
onPress={() => handleSegmentClick(-1)}
|
||||
isDisabled={isReadOnly}
|
||||
>
|
||||
{renderRemoteIcon()}
|
||||
<span className="text-sm font-medium">{remoteLabel}</span>
|
||||
</Button>
|
||||
|
||||
{segments.map((segment, index) => (
|
||||
<div
|
||||
key={`${segment}-${index}`}
|
||||
className="flex items-center gap-1 shrink-0"
|
||||
>
|
||||
<ChevronRightIcon className="text-default-400 size-4 shrink-0" />
|
||||
<Button
|
||||
size="sm"
|
||||
variant="light"
|
||||
className="px-2 min-w-fit"
|
||||
onPress={() => handleSegmentClick(index)}
|
||||
isDisabled={isReadOnly}
|
||||
>
|
||||
<span className="text-sm">{segment}</span>
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isReadOnly && (
|
||||
<Tooltip
|
||||
content={isInputMode ? 'Confirm' : 'Edit path'}
|
||||
size="sm"
|
||||
color="foreground"
|
||||
>
|
||||
<Button
|
||||
isIconOnly={true}
|
||||
size="sm"
|
||||
variant="light"
|
||||
onPress={isInputMode ? handleConfirmInput : toggleInputMode}
|
||||
onMouseDown={isInputMode ? (e) => e.preventDefault() : undefined}
|
||||
className="shrink-0"
|
||||
>
|
||||
{isInputMode ? (
|
||||
<CheckIcon className="text-success size-4" />
|
||||
) : (
|
||||
<PencilIcon className="transition-opacity duration-300 opacity-0 size-4 group-hover:opacity-100" />
|
||||
)}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
import {
|
||||
Button,
|
||||
Divider,
|
||||
Drawer,
|
||||
DrawerBody,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
Spinner,
|
||||
} from '@heroui/react'
|
||||
import { DownloadIcon, FileIcon as FileIconLucide, XIcon } from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { formatBytes } from '../../../lib/format.ts'
|
||||
import { usePersistedStore } from '../../../store/persisted.ts'
|
||||
import FileIcon, { getFileType, isPreviewable } from './FileIcon'
|
||||
import type { Entry } from './types'
|
||||
|
||||
const TRAILING_SLASH_RE = /\/$/
|
||||
const MAX_PREVIEW_SIZE = 500_000_000
|
||||
|
||||
function buildPreviewUrl(entry: Entry, hostUrl: string, auth?: string): string {
|
||||
const normalizedHost = hostUrl.replace(TRAILING_SLASH_RE, '')
|
||||
|
||||
if (entry.remote === 'UI_LOCAL_FS') {
|
||||
return `${normalizedHost}/[local]/${entry.fullPath}`
|
||||
}
|
||||
|
||||
const pathPart = entry.fullPath.includes(':/')
|
||||
? entry.fullPath.split(':/').slice(1).join('/')
|
||||
: entry.fullPath
|
||||
|
||||
const url = `${normalizedHost}/[${entry.remote}:]/${pathPart}`
|
||||
|
||||
if (auth) {
|
||||
return `${url}?auth=${encodeURIComponent(auth)}`
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
export default function PreviewDrawer({
|
||||
item,
|
||||
onClose,
|
||||
}: {
|
||||
item: Entry | null
|
||||
onClose: () => void
|
||||
}) {
|
||||
const currentHost = usePersistedStore((state) => state.currentHost)
|
||||
const hostUrl = currentHost?.url
|
||||
const authUser = currentHost?.authUser
|
||||
const authPassword = currentHost?.authPassword
|
||||
|
||||
const [textContent, setTextContent] = useState<string | null>(null)
|
||||
const [isLoadingText, setIsLoadingText] = useState(false)
|
||||
const [textError, setTextError] = useState<string | null>(null)
|
||||
|
||||
const auth = useMemo(() => {
|
||||
if (authUser) {
|
||||
return btoa(`${authUser}:${authPassword ?? ''}`)
|
||||
}
|
||||
return undefined
|
||||
}, [authUser, authPassword])
|
||||
|
||||
const fileType = item ? getFileType(item) : 'unknown'
|
||||
const canPreview = item ? isPreviewable(item) : false
|
||||
|
||||
const isTooLarge =
|
||||
item?.size !== undefined && MAX_PREVIEW_SIZE > 0 && item.size > MAX_PREVIEW_SIZE
|
||||
|
||||
const previewUrl = useMemo(() => {
|
||||
if (!item || !hostUrl) return null
|
||||
return buildPreviewUrl(item, hostUrl, auth)
|
||||
}, [item, hostUrl, auth])
|
||||
|
||||
useEffect(() => {
|
||||
if (!item || fileType !== 'text' || !previewUrl || isTooLarge) {
|
||||
setTextContent(null)
|
||||
setTextError(null)
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoadingText(true)
|
||||
setTextError(null)
|
||||
|
||||
fetch(previewUrl, {
|
||||
headers: auth ? { Authorization: `Basic ${auth}` } : undefined,
|
||||
})
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return res.text()
|
||||
})
|
||||
.then((text) => {
|
||||
setTextContent(text)
|
||||
setIsLoadingText(false)
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
setTextError(err.message)
|
||||
setIsLoadingText(false)
|
||||
})
|
||||
}, [item, fileType, previewUrl, auth, isTooLarge])
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
if (!previewUrl) return
|
||||
window.open(previewUrl, '_blank')
|
||||
}, [previewUrl])
|
||||
|
||||
const renderPreview = () => {
|
||||
if (!item || !previewUrl) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4 text-default-500">
|
||||
<FileIconLucide className="size-16" />
|
||||
<p>Select a file to preview</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isTooLarge) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4 text-default-500">
|
||||
<FileIcon entry={item} size="lg" />
|
||||
<p>File is too large for preview</p>
|
||||
<p className="text-sm">
|
||||
{formatBytes(item.size ?? 0)} (max: {formatBytes(MAX_PREVIEW_SIZE)})
|
||||
</p>
|
||||
<Button
|
||||
color="primary"
|
||||
onPress={handleDownload}
|
||||
startContent={<DownloadIcon className="size-4" />}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!canPreview) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4 text-default-500">
|
||||
<FileIcon entry={item} size="lg" />
|
||||
<p>Preview not available for this file type</p>
|
||||
<Button
|
||||
color="primary"
|
||||
onPress={handleDownload}
|
||||
startContent={<DownloadIcon className="size-4" />}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
switch (fileType) {
|
||||
case 'image':
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full p-4">
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt={item.name}
|
||||
className="object-contain max-w-full max-h-full rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'video':
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full p-4">
|
||||
{/* biome-ignore lint/a11y/useMediaCaption: <explanation> */}
|
||||
<video
|
||||
src={previewUrl}
|
||||
controls={true}
|
||||
className="max-w-full max-h-full rounded-lg"
|
||||
autoPlay={false}
|
||||
>
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'audio':
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full gap-4 p-4">
|
||||
<FileIcon entry={item} size="lg" />
|
||||
<p className="text-lg font-medium">{item.name}</p>
|
||||
{/* biome-ignore lint/a11y/useMediaCaption: <explanation> */}
|
||||
<audio src={previewUrl} controls={true} className="w-full max-w-md">
|
||||
Your browser does not support the audio tag.
|
||||
</audio>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'text': {
|
||||
if (isLoadingText) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (textError) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4 text-danger">
|
||||
<p>Error loading file: {textError}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="w-full h-full p-4 overflow-auto">
|
||||
<pre className="p-4 font-mono text-sm break-words whitespace-pre-wrap rounded-lg bg-default-100">
|
||||
{textContent}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
case 'document':
|
||||
return (
|
||||
<div className="flex flex-col items-center w-full h-full p-4 overflow-auto">
|
||||
{/* PDF preview disabled - uncomment when react-pdf is configured */}
|
||||
</div>
|
||||
)
|
||||
|
||||
default:
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4 text-default-500">
|
||||
<FileIcon entry={item} size="lg" />
|
||||
<p>Preview not available</p>
|
||||
<Button
|
||||
color="primary"
|
||||
onPress={handleDownload}
|
||||
startContent={<DownloadIcon className="size-4" />}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
isOpen={!!item}
|
||||
placement="right"
|
||||
size="md"
|
||||
onClose={onClose}
|
||||
hideCloseButton={true}
|
||||
>
|
||||
<DrawerContent>
|
||||
<DrawerHeader className="flex items-center justify-between mt-4">
|
||||
<div className="flex items-center gap-2 overflow-hidden">
|
||||
{item && <FileIcon entry={item} size="md" />}
|
||||
<span className="font-medium truncate">{item?.name ?? 'Preview'}</span>
|
||||
</div>
|
||||
<Button isIconOnly={true} size="sm" variant="light" onPress={onClose}>
|
||||
<XIcon className="size-5" />
|
||||
</Button>
|
||||
</DrawerHeader>
|
||||
<Divider />
|
||||
<DrawerBody className="p-0">
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex-1 overflow-hidden">{renderPreview()}</div>
|
||||
|
||||
{item && (
|
||||
<>
|
||||
<Divider />
|
||||
<div className="p-4 space-y-2 text-sm shrink-0 bg-default-50">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-default-500">Type:</span>
|
||||
<span className="capitalize">{fileType}</span>
|
||||
</div>
|
||||
{item.size !== undefined && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-default-500">Size:</span>
|
||||
<span>{formatBytes(item.size)}</span>
|
||||
</div>
|
||||
)}
|
||||
{item.modTime && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-default-500">Modified:</span>
|
||||
<span>{item.modTime}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<span className="text-default-500">Location:</span>
|
||||
<span
|
||||
className="text-right truncate max-w-[200px]"
|
||||
title={item.fullPath}
|
||||
>
|
||||
{item.remote === 'UI_LOCAL_FS' ? 'Local' : item.remote}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DrawerBody>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { Button, ScrollShadow, Tooltip, cn } from '@heroui/react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { platform } from '@tauri-apps/plugin-os'
|
||||
import { LaptopIcon, StarIcon } from 'lucide-react'
|
||||
import rclone from '../../../lib/rclone/client.ts'
|
||||
import type { AllowedKey, RemoteString } from './types'
|
||||
|
||||
function RemoteButton({
|
||||
remote,
|
||||
onSelect,
|
||||
isSelected,
|
||||
}: {
|
||||
remote: string
|
||||
onSelect: (remote: string) => void
|
||||
isSelected: boolean
|
||||
}) {
|
||||
const remoteConfigQuery = useQuery({
|
||||
queryKey: ['remote', remote, 'config'],
|
||||
queryFn: async () => {
|
||||
return await rclone('/config/get', {
|
||||
params: {
|
||||
query: {
|
||||
name: remote,
|
||||
},
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const info = remoteConfigQuery.data ?? null
|
||||
|
||||
return (
|
||||
<Tooltip content={remote} placement="right" size="lg" color="foreground">
|
||||
<Button
|
||||
isIconOnly={true}
|
||||
size="lg"
|
||||
variant={isSelected ? 'faded' : 'light'}
|
||||
onPress={() => onSelect(remote)}
|
||||
className="shrink-0"
|
||||
>
|
||||
<img
|
||||
src={`/icons/backends/${info?.type}.png`}
|
||||
className="object-contain size-6"
|
||||
alt={info?.type}
|
||||
/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export default function RemoteSidebar({
|
||||
position,
|
||||
selectedRemote,
|
||||
onRemoteSelect,
|
||||
allowedKeys,
|
||||
remotes,
|
||||
}: {
|
||||
position: 'left' | 'right'
|
||||
selectedRemote: RemoteString
|
||||
onRemoteSelect: (remote: string | 'UI_LOCAL_FS' | 'UI_FAVORITES') => void
|
||||
allowedKeys: AllowedKey[]
|
||||
remotes: string[]
|
||||
}) {
|
||||
const canShowFavorites = allowedKeys.includes('FAVORITES')
|
||||
const canShowLocal = allowedKeys.includes('LOCAL_FS')
|
||||
const canShowRemotes = allowedKeys.includes('REMOTES')
|
||||
|
||||
const orderClass = position === 'right' ? 'order-last' : 'order-first'
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col items-center w-20 h-full shrink-0', orderClass)}>
|
||||
<ScrollShadow
|
||||
className={cn(
|
||||
'flex flex-col items-center w-full h-full gap-5 py-4 overflow-y-auto',
|
||||
platform() === 'macos' && 'pt-8'
|
||||
)}
|
||||
size={69}
|
||||
>
|
||||
{canShowFavorites && (
|
||||
<Tooltip
|
||||
content="Favorites"
|
||||
placement={position === 'left' ? 'right' : 'left'}
|
||||
size="lg"
|
||||
color="foreground"
|
||||
>
|
||||
<Button
|
||||
isIconOnly={true}
|
||||
className="shrink-0"
|
||||
size="lg"
|
||||
variant={selectedRemote === 'UI_FAVORITES' ? 'faded' : 'light'}
|
||||
onPress={() => onRemoteSelect('UI_FAVORITES')}
|
||||
>
|
||||
<StarIcon className="stroke-warning fill-warning size-6" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canShowLocal && (
|
||||
<Tooltip
|
||||
content="Local Filesystem"
|
||||
placement={position === 'left' ? 'right' : 'left'}
|
||||
color="foreground"
|
||||
size="lg"
|
||||
>
|
||||
<Button
|
||||
isIconOnly={true}
|
||||
className="shrink-0"
|
||||
size="lg"
|
||||
onPress={() => onRemoteSelect('UI_LOCAL_FS')}
|
||||
variant={selectedRemote === 'UI_LOCAL_FS' ? 'faded' : 'light'}
|
||||
>
|
||||
<LaptopIcon className="size-6" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canShowRemotes &&
|
||||
remotes.map((remote) => (
|
||||
<RemoteButton
|
||||
remote={remote}
|
||||
key={remote}
|
||||
onSelect={() => onRemoteSelect(remote)}
|
||||
isSelected={selectedRemote === remote}
|
||||
/>
|
||||
))}
|
||||
</ScrollShadow>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export { default as FilePanel, type FilePanelHandle } from './FilePanel'
|
||||
export { default as FileIcon, getFileType, isPreviewable } from './FileIcon'
|
||||
export { default as FileList } from './FileList'
|
||||
export { default as PanelToolbar, type ToolbarButtons } from './PanelToolbar'
|
||||
export { default as PathBreadcrumb } from './PathBreadcrumb'
|
||||
export { default as PreviewDrawer } from './PreviewDrawer'
|
||||
export { default as RemoteSidebar } from './RemoteSidebar'
|
||||
export { default as useFileNavigation } from './useFileNavigation'
|
||||
export * from './types'
|
||||
export * from './utils'
|
||||
@@ -0,0 +1,40 @@
|
||||
export type SelectItem = { path: string; type: 'file' | 'folder' }
|
||||
|
||||
export type RemoteString = string | 'UI_LOCAL_FS' | 'UI_FAVORITES' | null
|
||||
|
||||
export type Entry = {
|
||||
key: string
|
||||
name: string
|
||||
isDir: boolean
|
||||
size?: number
|
||||
modTime?: string
|
||||
mimeType?: string
|
||||
remote?: string | 'UI_LOCAL_FS'
|
||||
fullPath: string
|
||||
}
|
||||
|
||||
export type PaddingItem = {
|
||||
key: string
|
||||
padding: true
|
||||
}
|
||||
|
||||
export type VirtualizedEntry = Entry & { isSelected: boolean }
|
||||
|
||||
export type AllowedKey = 'REMOTES' | 'LOCAL_FS' | 'FAVORITES'
|
||||
|
||||
export type ContextMenuItem = {
|
||||
key: string
|
||||
label: string
|
||||
icon?: React.ReactNode
|
||||
color?: 'default' | 'primary' | 'secondary' | 'success' | 'warning' | 'danger'
|
||||
onPress: (entry: Entry) => void
|
||||
}
|
||||
|
||||
export type FilePanelHandle = {
|
||||
refresh: () => void
|
||||
getSelection: () => SelectItem[]
|
||||
clearSelection: () => void
|
||||
selectAll: (type: 'files' | 'folders' | 'all') => void
|
||||
navigate: (remote: string, path: string) => void
|
||||
getCurrentPath: () => { remote: RemoteString; path: string }
|
||||
}
|
||||
@@ -0,0 +1,653 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { homeDir } from '@tauri-apps/api/path'
|
||||
import { readDir } from '@tauri-apps/plugin-fs'
|
||||
import { startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import rclone from '../../../lib/rclone/client.ts'
|
||||
import { useHostStore } from '../../../store/host.ts'
|
||||
import type {
|
||||
AllowedKey,
|
||||
Entry,
|
||||
PaddingItem,
|
||||
RemoteString,
|
||||
SelectItem,
|
||||
VirtualizedEntry,
|
||||
} from './types'
|
||||
import {
|
||||
RE_BACKSLASH,
|
||||
RE_LEADING_SLASH,
|
||||
RE_PATH_SEPARATOR,
|
||||
RE_TRAILING_SLASH,
|
||||
VIRTUAL_PADDING_COUNT,
|
||||
cacheKey,
|
||||
getLocalParent,
|
||||
getRemoteParent,
|
||||
joinLocal,
|
||||
listRemotePath,
|
||||
log,
|
||||
serializeRemotePath,
|
||||
} from './utils'
|
||||
|
||||
export default function useFileNavigation({
|
||||
initialRemote,
|
||||
initialPath,
|
||||
allowedKeys = ['REMOTES', 'LOCAL_FS', 'FAVORITES'],
|
||||
allowFiles = true,
|
||||
allowMultiple = true,
|
||||
isActive = true,
|
||||
}: {
|
||||
initialRemote?: string | 'UI_LOCAL_FS'
|
||||
initialPath?: string
|
||||
allowedKeys?: AllowedKey[]
|
||||
allowFiles?: boolean
|
||||
allowMultiple?: boolean
|
||||
isActive?: boolean
|
||||
}) {
|
||||
const favoritePaths = useHostStore((state) => state.favoritePaths)
|
||||
|
||||
const remotesQuery = useQuery({
|
||||
queryKey: ['remotes', 'list', 'all'],
|
||||
queryFn: async () => await rclone('/config/listremotes').then((r) => r?.remotes),
|
||||
staleTime: 1000 * 60,
|
||||
})
|
||||
|
||||
const remotes = useMemo(() => remotesQuery.data ?? [], [remotesQuery.data])
|
||||
|
||||
const [selectedRemote, setSelectedRemote] = useState<RemoteString>(initialRemote ?? null)
|
||||
const [cwd, setCwd] = useState<string>(initialPath ?? '')
|
||||
const [pathInput, setPathInput] = useState<string>('')
|
||||
const [searchTerm, setSearchTerm] = useState<string>('')
|
||||
const [items, setItems] = useState<Entry[]>([])
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isUpDisabled, setIsUpDisabled] = useState(false)
|
||||
const [refreshKey, setRefreshKey] = useState(0)
|
||||
|
||||
const isRemote = useMemo(
|
||||
() =>
|
||||
selectedRemote !== 'UI_LOCAL_FS' &&
|
||||
selectedRemote !== 'UI_FAVORITES' &&
|
||||
selectedRemote !== null,
|
||||
[selectedRemote]
|
||||
)
|
||||
const canShowFavorites = useMemo(() => allowedKeys.includes('FAVORITES'), [allowedKeys])
|
||||
const canShowLocal = useMemo(() => allowedKeys.includes('LOCAL_FS'), [allowedKeys])
|
||||
const canShowRemotes = useMemo(() => allowedKeys.includes('REMOTES'), [allowedKeys])
|
||||
|
||||
const cacheRef = useRef<Map<string, Entry[]>>(new Map())
|
||||
const entryByKeyRef = useRef<Map<string, Entry>>(new Map())
|
||||
const abortControllerRef = useRef<AbortController | null>(null)
|
||||
const loadingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const isNavigatingRef = useRef(false)
|
||||
|
||||
const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set())
|
||||
const selectedTypesRef = useRef<Map<string, 'file' | 'folder'>>(new Map())
|
||||
|
||||
const visibleItems = useMemo(() => {
|
||||
const base = allowFiles ? items : items.filter((it) => it.isDir)
|
||||
if (!searchTerm) return base
|
||||
const lower = searchTerm.toLowerCase()
|
||||
return base.filter((item) => item.name.toLowerCase().includes(lower))
|
||||
}, [allowFiles, items, searchTerm])
|
||||
|
||||
const virtualizedItems: (VirtualizedEntry | PaddingItem)[] = useMemo(() => {
|
||||
const base: (VirtualizedEntry | PaddingItem)[] = visibleItems.map((item) => ({
|
||||
...item,
|
||||
isSelected: selectedPaths.has(item.key),
|
||||
}))
|
||||
for (let i = 0; i < VIRTUAL_PADDING_COUNT; i++) {
|
||||
base.push({ key: `__padding-${i}`, padding: true })
|
||||
}
|
||||
return base
|
||||
}, [visibleItems, selectedPaths])
|
||||
|
||||
const selectedCount = useMemo(() => selectedPaths.size, [selectedPaths])
|
||||
|
||||
const favoritedKeys = useMemo(() => {
|
||||
const map: Record<string, boolean> = {}
|
||||
for (const it of favoritePaths || []) {
|
||||
const remote = (it as any).remote as string | undefined
|
||||
const rawPath = (it as any).path as string
|
||||
let fullKey = rawPath
|
||||
if (rawPath?.includes(':/')) {
|
||||
fullKey = rawPath
|
||||
} else if (remote && remote !== 'UI_LOCAL_FS') {
|
||||
const rel = (rawPath || '').replace(RE_LEADING_SLASH, '')
|
||||
fullKey = serializeRemotePath(remote, rel)
|
||||
} else {
|
||||
fullKey = rawPath
|
||||
}
|
||||
if (fullKey) map[fullKey] = true
|
||||
}
|
||||
return map
|
||||
}, [favoritePaths])
|
||||
|
||||
const cleanupSelectionForRemote = useCallback(
|
||||
(newRemote: RemoteString) => {
|
||||
log('cleanupSelectionForRemote', { newRemote, selectedRemote })
|
||||
if (newRemote !== selectedRemote) {
|
||||
startTransition(() => {
|
||||
setSelectedPaths(new Set())
|
||||
})
|
||||
selectedTypesRef.current.clear()
|
||||
const currentPrefix = selectedRemote === 'UI_LOCAL_FS' ? '' : `${selectedRemote}:`
|
||||
const keysToRemove: string[] = []
|
||||
for (const key of entryByKeyRef.current.keys()) {
|
||||
if (selectedRemote === 'UI_LOCAL_FS' && !key.includes(':/')) {
|
||||
keysToRemove.push(key)
|
||||
} else if (selectedRemote !== 'UI_LOCAL_FS' && key.startsWith(currentPrefix)) {
|
||||
keysToRemove.push(key)
|
||||
}
|
||||
}
|
||||
for (const key of keysToRemove) {
|
||||
entryByKeyRef.current.delete(key)
|
||||
}
|
||||
}
|
||||
},
|
||||
[selectedRemote]
|
||||
)
|
||||
|
||||
const updatePathInput = useCallback((nextRemote: RemoteString, nextCwd: string) => {
|
||||
if (!nextRemote) {
|
||||
startTransition(() => setPathInput(''))
|
||||
return
|
||||
}
|
||||
if (nextRemote === 'UI_FAVORITES') {
|
||||
startTransition(() => setPathInput(''))
|
||||
return
|
||||
}
|
||||
if (nextRemote === 'UI_LOCAL_FS') {
|
||||
startTransition(() => setPathInput(nextCwd || ''))
|
||||
} else {
|
||||
startTransition(() => setPathInput(`${nextRemote}:/${nextCwd || ''}`))
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleNavigate = useCallback(
|
||||
async (entry: Entry) => {
|
||||
log('handleNavigate', { entry, isNavigating: isNavigatingRef.current, selectedRemote })
|
||||
if (isNavigatingRef.current) return
|
||||
if (!entry.isDir) return
|
||||
if (!selectedRemote) return
|
||||
if (selectedRemote === 'UI_FAVORITES') {
|
||||
const full = entry.fullPath
|
||||
if (full.includes(':/')) {
|
||||
const [remoteName, ...rest] = full.split(':/')
|
||||
const rel = rest.join('/')
|
||||
cleanupSelectionForRemote(remoteName as any)
|
||||
startTransition(() => {
|
||||
setSelectedRemote(remoteName as any)
|
||||
setCwd(rel)
|
||||
})
|
||||
} else {
|
||||
cleanupSelectionForRemote('UI_LOCAL_FS')
|
||||
startTransition(() => {
|
||||
setSelectedRemote('UI_LOCAL_FS')
|
||||
setCwd(full)
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
isNavigatingRef.current = true
|
||||
if (isRemote) {
|
||||
const base = cwd ? `${cwd}/` : ''
|
||||
startTransition(() => setCwd(`${base}${entry.name}`))
|
||||
} else {
|
||||
const newPath = await joinLocal(cwd, entry.name)
|
||||
startTransition(() => setCwd(newPath))
|
||||
}
|
||||
},
|
||||
[selectedRemote, isRemote, cwd, cleanupSelectionForRemote]
|
||||
)
|
||||
|
||||
const navigateUp = useCallback(async () => {
|
||||
log('navigateUp', { cwd, selectedRemote })
|
||||
if (!selectedRemote) return
|
||||
if (isRemote) {
|
||||
const parent = getRemoteParent(cwd)
|
||||
startTransition(() => setCwd(parent))
|
||||
}
|
||||
if (selectedRemote === 'UI_LOCAL_FS') {
|
||||
const parent = await getLocalParent(cwd)
|
||||
startTransition(() => setCwd(parent))
|
||||
return
|
||||
}
|
||||
}, [cwd, selectedRemote, isRemote])
|
||||
|
||||
const navigateTo = useCallback(
|
||||
(path: string) => {
|
||||
const value = path.trim()
|
||||
if (!value) return
|
||||
if (value.includes(':/')) {
|
||||
const [remote, ...rest] = value.split(':/')
|
||||
const rel = rest.join('/')
|
||||
cleanupSelectionForRemote(remote)
|
||||
startTransition(() => {
|
||||
setSelectedRemote(remote)
|
||||
setCwd(rel)
|
||||
})
|
||||
} else {
|
||||
cleanupSelectionForRemote('UI_LOCAL_FS')
|
||||
startTransition(() => {
|
||||
setSelectedRemote('UI_LOCAL_FS')
|
||||
setCwd(value)
|
||||
})
|
||||
}
|
||||
},
|
||||
[cleanupSelectionForRemote]
|
||||
)
|
||||
|
||||
const selectRemote = useCallback(
|
||||
async (remote: string | 'UI_LOCAL_FS' | 'UI_FAVORITES') => {
|
||||
cleanupSelectionForRemote(remote)
|
||||
if (remote === 'UI_LOCAL_FS') {
|
||||
const home = await homeDir()
|
||||
startTransition(() => {
|
||||
setSelectedRemote(remote)
|
||||
setCwd(home)
|
||||
})
|
||||
} else {
|
||||
startTransition(() => {
|
||||
setSelectedRemote(remote)
|
||||
setCwd('')
|
||||
})
|
||||
}
|
||||
},
|
||||
[cleanupSelectionForRemote]
|
||||
)
|
||||
|
||||
const handleToggleSelect = useCallback(
|
||||
(entry: Entry) => {
|
||||
const next = new Set(selectedPaths)
|
||||
if (next.has(entry.key)) {
|
||||
next.delete(entry.key)
|
||||
selectedTypesRef.current.delete(entry.key)
|
||||
} else {
|
||||
if (!allowMultiple && next.size > 0) return
|
||||
next.add(entry.key)
|
||||
selectedTypesRef.current.set(entry.key, entry.isDir ? 'folder' : 'file')
|
||||
}
|
||||
startTransition(() => setSelectedPaths(next))
|
||||
},
|
||||
[selectedPaths, allowMultiple]
|
||||
)
|
||||
|
||||
const getSelection = useCallback((): SelectItem[] => {
|
||||
const paths = Array.from(selectedPaths)
|
||||
return paths.map((path) => {
|
||||
const known = selectedTypesRef.current.get(path)
|
||||
if (known) return { path, type: known }
|
||||
const entry = entryByKeyRef.current.get(path)
|
||||
if (entry) return { path, type: entry.isDir ? 'folder' : 'file' }
|
||||
if (path.includes(':/')) {
|
||||
const rel = path.split(':/').slice(1).join('/')
|
||||
return { path, type: rel.endsWith('/') ? 'folder' : 'file' }
|
||||
}
|
||||
return { path, type: 'file' }
|
||||
})
|
||||
}, [selectedPaths])
|
||||
|
||||
const clearSelection = useCallback(() => {
|
||||
selectedTypesRef.current.clear()
|
||||
startTransition(() => setSelectedPaths(new Set()))
|
||||
}, [])
|
||||
|
||||
const selectAll = useCallback(
|
||||
(type: 'files' | 'folders' | 'all') => {
|
||||
const next = new Set(selectedPaths)
|
||||
for (const item of visibleItems) {
|
||||
if (type === 'files' && !item.isDir) {
|
||||
next.add(item.key)
|
||||
selectedTypesRef.current.set(item.key, 'file')
|
||||
} else if (type === 'folders' && item.isDir) {
|
||||
next.add(item.key)
|
||||
selectedTypesRef.current.set(item.key, 'folder')
|
||||
} else if (type === 'all') {
|
||||
next.add(item.key)
|
||||
selectedTypesRef.current.set(item.key, item.isDir ? 'folder' : 'file')
|
||||
}
|
||||
}
|
||||
startTransition(() => setSelectedPaths(next))
|
||||
},
|
||||
[selectedPaths, visibleItems]
|
||||
)
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
const cKey = cacheKey(selectedRemote, cwd)
|
||||
cacheRef.current.delete(cKey)
|
||||
startTransition(() => setItems([]))
|
||||
setIsLoading(true)
|
||||
setRefreshKey((k) => k + 1)
|
||||
}, [selectedRemote, cwd])
|
||||
|
||||
// Initialize on first mount if no initial values provided
|
||||
useEffect(() => {
|
||||
if (!isActive) return
|
||||
|
||||
const hasInitial = initialRemote !== undefined
|
||||
const needsLocalPath = initialRemote === 'UI_LOCAL_FS' && !initialPath
|
||||
|
||||
if (needsLocalPath || (!hasInitial && canShowLocal)) {
|
||||
setIsLoading(true)
|
||||
homeDir().then((home) => {
|
||||
startTransition(() => {
|
||||
setSelectedRemote('UI_LOCAL_FS')
|
||||
setCwd(home)
|
||||
setPathInput(home)
|
||||
})
|
||||
setIsLoading(false)
|
||||
})
|
||||
} else if (!hasInitial && canShowFavorites) {
|
||||
startTransition(() => setSelectedRemote('UI_FAVORITES'))
|
||||
} else if (!hasInitial && canShowRemotes && remotes.length > 0) {
|
||||
startTransition(() => {
|
||||
setSelectedRemote(remotes[0])
|
||||
setCwd('')
|
||||
})
|
||||
}
|
||||
}, [
|
||||
isActive,
|
||||
canShowLocal,
|
||||
canShowFavorites,
|
||||
canShowRemotes,
|
||||
remotes,
|
||||
initialRemote,
|
||||
initialPath,
|
||||
])
|
||||
|
||||
// Load directory content when remote/cwd changes
|
||||
useEffect(() => {
|
||||
if (!isActive) return
|
||||
|
||||
async function loadDir() {
|
||||
log('loadDir: start', { selectedRemote, cwd, isRemote })
|
||||
if (!selectedRemote) {
|
||||
startTransition(() => {
|
||||
setItems([])
|
||||
setError(null)
|
||||
setIsLoading(false)
|
||||
})
|
||||
isNavigatingRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
if (abortControllerRef.current) abortControllerRef.current.abort()
|
||||
if (loadingTimerRef.current) {
|
||||
clearTimeout(loadingTimerRef.current)
|
||||
loadingTimerRef.current = null
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
abortControllerRef.current = controller
|
||||
|
||||
let finished = false
|
||||
setError(null)
|
||||
loadingTimerRef.current = setTimeout(() => {
|
||||
if (!controller.signal.aborted && !finished) {
|
||||
startTransition(() => setIsLoading(true))
|
||||
}
|
||||
}, 200)
|
||||
|
||||
const cKey = cacheKey(selectedRemote, cwd)
|
||||
if (cacheRef.current.has(cKey)) {
|
||||
log('loadDir: cache hit', cKey)
|
||||
const cached = cacheRef.current.get(cKey)!
|
||||
startTransition(() => setItems(cached))
|
||||
isNavigatingRef.current = false
|
||||
}
|
||||
|
||||
let nextItems: Entry[] = []
|
||||
if (selectedRemote === 'UI_FAVORITES') {
|
||||
nextItems = (favoritePaths || []).map((fav) => {
|
||||
const remote = (fav as any).remote as string | undefined
|
||||
const isLocal = !remote || remote === 'UI_LOCAL_FS'
|
||||
const rawPath = (fav as any).path as string
|
||||
let fullPath = rawPath
|
||||
if (!isLocal) {
|
||||
if (rawPath.includes(':/')) {
|
||||
fullPath = rawPath
|
||||
} else {
|
||||
const rel = (rawPath || '').replace(RE_LEADING_SLASH, '')
|
||||
fullPath = serializeRemotePath(remote!, rel)
|
||||
}
|
||||
}
|
||||
const relForName = (() => {
|
||||
if (!isLocal) {
|
||||
if (rawPath.includes(':/'))
|
||||
return rawPath.split(':/').slice(1).join('/')
|
||||
return rawPath
|
||||
}
|
||||
return rawPath
|
||||
})()
|
||||
const normalized = (relForName || '')
|
||||
.replace(RE_BACKSLASH, '/')
|
||||
.replace(RE_TRAILING_SLASH, '')
|
||||
const baseName = normalized.split(RE_PATH_SEPARATOR).pop() || ''
|
||||
const prefix = isLocal ? '(LOCAL)' : `(${remote})`
|
||||
const addedLabel = `Added on ${new Date((fav as any).added).toLocaleString()}`
|
||||
return {
|
||||
key: fullPath,
|
||||
name: `${prefix} ${baseName}`,
|
||||
isDir: true,
|
||||
size: undefined,
|
||||
modTime: addedLabel,
|
||||
remote: isLocal ? 'UI_LOCAL_FS' : remote,
|
||||
fullPath,
|
||||
} as Entry
|
||||
})
|
||||
nextItems.sort((a, b) => a.name.localeCompare(b.name))
|
||||
if (!controller.signal.aborted) {
|
||||
finished = true
|
||||
if (loadingTimerRef.current) {
|
||||
clearTimeout(loadingTimerRef.current)
|
||||
loadingTimerRef.current = null
|
||||
}
|
||||
cacheRef.current.set(cKey, nextItems)
|
||||
const map = entryByKeyRef.current
|
||||
for (const e of nextItems) map.set(e.key, e)
|
||||
startTransition(() => {
|
||||
setItems(nextItems)
|
||||
setIsLoading(false)
|
||||
})
|
||||
isNavigatingRef.current = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (isRemote) {
|
||||
log('loadDir: fetching remote')
|
||||
const remote = selectedRemote as string
|
||||
const targetCwd = cwd || ''
|
||||
let listedItems: { list: any[] } | null = null
|
||||
try {
|
||||
listedItems = await listRemotePath(remote, targetCwd, {
|
||||
noModTime: false,
|
||||
noMimeType: true,
|
||||
})
|
||||
} catch (_err: unknown) {
|
||||
if (!controller.signal.aborted) {
|
||||
finished = true
|
||||
if (loadingTimerRef.current) {
|
||||
clearTimeout(loadingTimerRef.current)
|
||||
loadingTimerRef.current = null
|
||||
}
|
||||
startTransition(() => {
|
||||
setIsLoading(false)
|
||||
setItems([])
|
||||
setError('No access or folder does not exist')
|
||||
})
|
||||
isNavigatingRef.current = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (controller.signal.aborted) {
|
||||
log('loadDir: aborted after fetch')
|
||||
return
|
||||
}
|
||||
|
||||
nextItems = (listedItems?.list || [])
|
||||
.map((it) => {
|
||||
const rel = (it.Path || it.Name || '') as string
|
||||
const baseName = rel.split('/').pop() || ''
|
||||
const isDir = !!(it.IsDir || (it as any).IsBucket)
|
||||
const full = serializeRemotePath(remote, rel)
|
||||
return {
|
||||
key: full,
|
||||
name: baseName,
|
||||
isDir,
|
||||
size: it.Size,
|
||||
modTime: it.ModTime,
|
||||
mimeType: it.MimeType,
|
||||
remote,
|
||||
fullPath: full,
|
||||
} as Entry
|
||||
})
|
||||
.filter((e) => !e.name.startsWith('.'))
|
||||
} else if (cwd) {
|
||||
let entries: Awaited<ReturnType<typeof readDir>> | null = null
|
||||
try {
|
||||
entries = await readDir(cwd)
|
||||
} catch {
|
||||
if (!controller.signal.aborted) {
|
||||
finished = true
|
||||
if (loadingTimerRef.current) {
|
||||
clearTimeout(loadingTimerRef.current)
|
||||
loadingTimerRef.current = null
|
||||
}
|
||||
setIsLoading(false)
|
||||
setItems([])
|
||||
setError('No access or folder does not exist')
|
||||
isNavigatingRef.current = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (controller.signal.aborted) return
|
||||
|
||||
const processedEntries: Entry[] = []
|
||||
for (const e of entries || []) {
|
||||
if (e.isSymlink) continue
|
||||
const full = await joinLocal(cwd, e.name)
|
||||
processedEntries.push({
|
||||
key: full,
|
||||
name: e.name,
|
||||
isDir: e.isDirectory,
|
||||
fullPath: full,
|
||||
} as Entry)
|
||||
}
|
||||
nextItems = processedEntries.filter((e) => !e.name.startsWith('.'))
|
||||
} else {
|
||||
nextItems = []
|
||||
}
|
||||
|
||||
if (controller.signal.aborted) return
|
||||
|
||||
nextItems.sort((a, b) => {
|
||||
if (a.isDir && !b.isDir) return -1
|
||||
if (!a.isDir && b.isDir) return 1
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
|
||||
if (!controller.signal.aborted) {
|
||||
finished = true
|
||||
if (loadingTimerRef.current) {
|
||||
clearTimeout(loadingTimerRef.current)
|
||||
loadingTimerRef.current = null
|
||||
}
|
||||
cacheRef.current.set(cKey, nextItems)
|
||||
const map = entryByKeyRef.current
|
||||
for (const e of nextItems) map.set(e.key, e)
|
||||
setItems(nextItems)
|
||||
setIsLoading(false)
|
||||
isNavigatingRef.current = false
|
||||
}
|
||||
}
|
||||
|
||||
loadDir()
|
||||
|
||||
return () => {
|
||||
if (abortControllerRef.current) abortControllerRef.current.abort()
|
||||
if (loadingTimerRef.current) {
|
||||
clearTimeout(loadingTimerRef.current)
|
||||
loadingTimerRef.current = null
|
||||
}
|
||||
}
|
||||
}, [selectedRemote, cwd, isRemote, favoritePaths, isActive, refreshKey])
|
||||
|
||||
useEffect(() => {
|
||||
updatePathInput(selectedRemote, cwd)
|
||||
setSearchTerm('')
|
||||
}, [selectedRemote, cwd, updatePathInput])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
async function updateUpState() {
|
||||
if (!selectedRemote) {
|
||||
if (!cancelled) setIsUpDisabled(true)
|
||||
return
|
||||
}
|
||||
if (isRemote) {
|
||||
if (!cancelled) setIsUpDisabled(cwd === '')
|
||||
return
|
||||
}
|
||||
const current = cwd
|
||||
if (!current) {
|
||||
if (!cancelled) setIsUpDisabled(true)
|
||||
return
|
||||
}
|
||||
let parent = ''
|
||||
try {
|
||||
parent = await getLocalParent(current)
|
||||
} catch {
|
||||
parent = current
|
||||
}
|
||||
if (cancelled) return
|
||||
setIsUpDisabled(parent === current)
|
||||
}
|
||||
updateUpState()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [selectedRemote, cwd, isRemote])
|
||||
|
||||
return {
|
||||
// State
|
||||
selectedRemote,
|
||||
cwd,
|
||||
pathInput,
|
||||
items,
|
||||
visibleItems,
|
||||
virtualizedItems,
|
||||
isLoading,
|
||||
error,
|
||||
isUpDisabled,
|
||||
searchTerm,
|
||||
selectedPaths,
|
||||
selectedCount,
|
||||
isRemote,
|
||||
favoritedKeys,
|
||||
remotes,
|
||||
canShowFavorites,
|
||||
canShowLocal,
|
||||
canShowRemotes,
|
||||
|
||||
// Actions
|
||||
setPathInput,
|
||||
setSearchTerm,
|
||||
handleNavigate,
|
||||
navigateUp,
|
||||
navigateTo,
|
||||
selectRemote,
|
||||
handleToggleSelect,
|
||||
getSelection,
|
||||
clearSelection,
|
||||
selectAll,
|
||||
refresh,
|
||||
entryByKeyRef,
|
||||
|
||||
// For external control
|
||||
setSelectedRemote,
|
||||
setCwd,
|
||||
setSelectedPaths,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { dirname, join, sep } from '@tauri-apps/api/path'
|
||||
import { readDir } from '@tauri-apps/plugin-fs'
|
||||
import { createRef } from 'react'
|
||||
import rclone from '../../../lib/rclone/client.ts'
|
||||
import type { SelectItem } from './types'
|
||||
|
||||
export const dragStateRef = createRef<SelectItem[] | null>() as { current: SelectItem[] | null }
|
||||
dragStateRef.current = null
|
||||
|
||||
export const dropTargetsRef = createRef<
|
||||
Map<
|
||||
string,
|
||||
{
|
||||
element: HTMLElement
|
||||
onDrop: (items: SelectItem[], destination: string) => void
|
||||
getDestination: () => string
|
||||
}
|
||||
>
|
||||
>() as {
|
||||
current: Map<
|
||||
string,
|
||||
{
|
||||
element: HTMLElement
|
||||
onDrop: (items: SelectItem[], destination: string) => void
|
||||
getDestination: () => string
|
||||
}
|
||||
>
|
||||
}
|
||||
dropTargetsRef.current = new Map()
|
||||
|
||||
export const RE_BACKSLASH = /\\/g
|
||||
export const RE_TRAILING_SLASH = /\/+$/g
|
||||
export const RE_LEADING_SLASH = /^\/+/
|
||||
export const RE_PATH_SEPARATOR = /[/\\]/
|
||||
|
||||
export const VIRTUAL_PADDING_COUNT = 2
|
||||
|
||||
export function log(msg: string, ...args: any[]) {
|
||||
console.log(`[Navigator] ${msg}`, ...args)
|
||||
}
|
||||
|
||||
export async function joinLocal(base: string, name: string) {
|
||||
if (!base) return join(sep(), name)
|
||||
return join(base, name)
|
||||
}
|
||||
|
||||
export async function getLocalParent(path: string) {
|
||||
if (!path) return ''
|
||||
return dirname(path)
|
||||
}
|
||||
|
||||
export function getRemoteParent(path: string) {
|
||||
if (!path) return ''
|
||||
const parts = path.split('/').filter(Boolean)
|
||||
if (parts.length === 0) return ''
|
||||
return parts.slice(0, -1).join('/')
|
||||
}
|
||||
|
||||
export function serializeRemotePath(remote: string, relPath: string) {
|
||||
return `${remote}:/${relPath}`
|
||||
}
|
||||
|
||||
export function cacheKey(remote: string | 'UI_LOCAL_FS' | null, dir: string) {
|
||||
return `${remote ?? 'NONE'}::${dir || '/'}`
|
||||
}
|
||||
|
||||
export function normalizeRemoteDir(path: string) {
|
||||
if (!path) return ''
|
||||
const cleaned = path.replace(RE_BACKSLASH, '/').replace(RE_TRAILING_SLASH, '')
|
||||
return cleaned
|
||||
}
|
||||
|
||||
export async function listRemotePath(
|
||||
remote: string,
|
||||
dir: string,
|
||||
options: { noModTime?: boolean; noMimeType?: boolean }
|
||||
) {
|
||||
const base = normalizeRemoteDir(dir)
|
||||
const slashed = base ? `${base}/` : ''
|
||||
log('listRemotePath', { remote, dir, base, slashed, options })
|
||||
|
||||
const tasks: Promise<any>[] = []
|
||||
|
||||
const p1 = {
|
||||
fs: `${remote}:`,
|
||||
remote: base,
|
||||
...options,
|
||||
}
|
||||
log('listRemotePath: rclone call 1 params', p1)
|
||||
tasks.push(
|
||||
rclone('/operations/list', {
|
||||
params: {
|
||||
query: p1,
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
if (slashed) {
|
||||
const p2 = {
|
||||
fs: `${remote}:`,
|
||||
remote: slashed,
|
||||
...options,
|
||||
}
|
||||
log('listRemotePath: rclone call 2 params', p2)
|
||||
tasks.push(
|
||||
rclone('/operations/list', {
|
||||
params: {
|
||||
query: p2,
|
||||
},
|
||||
})
|
||||
)
|
||||
}
|
||||
const settled = await Promise.allSettled(tasks)
|
||||
log('listRemotePath: settled', settled)
|
||||
|
||||
const merged: any[] = []
|
||||
let anyFulfilled = false
|
||||
for (let i = 0; i < settled.length; i++) {
|
||||
const r = settled[i]
|
||||
if (r.status === 'fulfilled') {
|
||||
const list = Array.isArray(r.value) ? r.value : r.value?.list
|
||||
|
||||
if (Array.isArray(list)) {
|
||||
anyFulfilled = true
|
||||
for (let j = 0; j < list.length; j++) {
|
||||
merged.push(list[j])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!anyFulfilled) {
|
||||
log('listRemotePath: no successful tasks')
|
||||
throw new Error('No access or folder does not exist')
|
||||
}
|
||||
const seen = new Set<string>()
|
||||
const deduped: any[] = []
|
||||
for (let i = 0; i < merged.length; i++) {
|
||||
const it = merged[i]
|
||||
const k = (it && (it.Path || it.Name)) || ''
|
||||
if (!k) continue
|
||||
if (seen.has(k)) continue
|
||||
seen.add(k)
|
||||
deduped.push(it)
|
||||
}
|
||||
log('listRemotePath: result count', deduped.length)
|
||||
return { list: deduped, baseDir: base }
|
||||
}
|
||||
|
||||
export async function listLocalPath(dir: string) {
|
||||
log('listLocalPath', { dir })
|
||||
const entries = await readDir(dir)
|
||||
log('listLocalPath: entries', entries.length)
|
||||
return entries
|
||||
}
|
||||
|
||||
export function parseRemotePath(fullPath: string): { remote: string | null; path: string } {
|
||||
if (fullPath.includes(':/')) {
|
||||
const [remote, ...rest] = fullPath.split(':/')
|
||||
return { remote, path: rest.join('/') }
|
||||
}
|
||||
return { remote: null, path: fullPath }
|
||||
}
|
||||
|
||||
export function getPathSegments(path: string): string[] {
|
||||
if (!path) return []
|
||||
return path.replace(RE_BACKSLASH, '/').split('/').filter(Boolean)
|
||||
}
|
||||
|
||||
export function buildPathFromSegments(segments: string[], upToIndex: number): string {
|
||||
return segments.slice(0, upToIndex + 1).join('/')
|
||||
}
|
||||
|
||||
export function getFileExtension(filename: string): string {
|
||||
const lastDot = filename.lastIndexOf('.')
|
||||
if (lastDot === -1 || lastDot === 0) return ''
|
||||
return filename.slice(lastDot + 1).toLowerCase()
|
||||
}
|
||||
|
||||
export function formatModTime(modTime: string | undefined): string {
|
||||
if (!modTime) return '—'
|
||||
try {
|
||||
const date = new Date(modTime)
|
||||
return date.toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})
|
||||
} catch {
|
||||
return modTime
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,32 @@
|
||||
cursor: default !important;
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.show-scrollbar {
|
||||
-ms-overflow-style: auto !important;
|
||||
scrollbar-width: thin !important;
|
||||
}
|
||||
|
||||
.show-scrollbar::-webkit-scrollbar {
|
||||
display: block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.show-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.show-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: hsl(var(--heroui-default-300));
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.show-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: hsl(var(--heroui-default-400));
|
||||
}
|
||||
}
|
||||
|
||||
html[data-tauri-dragging="true"],
|
||||
html[data-tauri-dragging="true"] * {
|
||||
cursor: grabbing !important;
|
||||
|
||||
+7
-1
@@ -11,6 +11,7 @@ import { clearClient } from '../lib/rclone/client'
|
||||
import { initHostStore } from '../store/host'
|
||||
import { usePersistedStore } from '../store/persisted'
|
||||
import Bisync from './pages/Bisync'
|
||||
import Commander from './pages/Commander'
|
||||
import Copy from './pages/Copy'
|
||||
import Delete from './pages/Delete'
|
||||
import Download from './pages/Download'
|
||||
@@ -55,7 +56,8 @@ forwardConsole('error', error)
|
||||
if (
|
||||
!window.location?.pathname.startsWith('/toolbar') &&
|
||||
!window.location?.pathname.startsWith('/startup') &&
|
||||
!window.location?.pathname.startsWith('/onboarding')
|
||||
!window.location?.pathname.startsWith('/onboarding') &&
|
||||
!window.location?.pathname.startsWith('/commander')
|
||||
) {
|
||||
import('./setupDragRegions').then(({ initTauriDragRegions }) => {
|
||||
initTauriDragRegions()
|
||||
@@ -118,6 +120,10 @@ const router = createBrowserRouter([
|
||||
path: '/bisync',
|
||||
element: <Bisync />,
|
||||
},
|
||||
{
|
||||
path: '/commander',
|
||||
element: <Commander />,
|
||||
},
|
||||
{
|
||||
path: '/mount',
|
||||
element: <Mount />,
|
||||
|
||||
@@ -0,0 +1,720 @@
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
DropdownMenu,
|
||||
DropdownTrigger,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
Progress,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
ScrollShadow,
|
||||
Tooltip,
|
||||
} from '@heroui/react'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { message, save } from '@tauri-apps/plugin-dialog'
|
||||
import { platform } from '@tauri-apps/plugin-os'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import {
|
||||
CheckCircle2Icon,
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
CopyIcon,
|
||||
ExternalLinkIcon,
|
||||
LoaderIcon,
|
||||
MoveIcon,
|
||||
SearchCheckIcon,
|
||||
XCircleIcon,
|
||||
} from 'lucide-react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Group, Panel, Separator } from 'react-resizable-panels'
|
||||
import { getFsInfo } from '../../lib/format'
|
||||
// import { Document, Page, pdfjs } from 'react-pdf'
|
||||
import { formatBytes } from '../../lib/format.ts'
|
||||
import { startCopy, startMove } from '../../lib/rclone/api'
|
||||
import rclone from '../../lib/rclone/client'
|
||||
import { openWindow } from '../../lib/window'
|
||||
import { FileIcon } from '../components/navigator'
|
||||
import { FilePanel, type FilePanelHandle } from '../components/navigator'
|
||||
import type { Entry, SelectItem } from '../components/navigator/types'
|
||||
|
||||
export default function Browser() {
|
||||
const leftPanelRef = useRef<FilePanelHandle>(null)
|
||||
const rightPanelRef = useRef<FilePanelHandle>(null)
|
||||
|
||||
const [dropOperation, setDropOperation] = useState<{
|
||||
items: SelectItem[]
|
||||
destination: string
|
||||
} | null>(null)
|
||||
|
||||
const [contextMenu, setContextMenu] = useState<{
|
||||
entry: Entry
|
||||
x: number
|
||||
y: number
|
||||
panelSide: 'left' | 'right'
|
||||
} | null>(null)
|
||||
|
||||
const [trackedJobIds, setTrackedJobIds] = useState<Set<number>>(new Set())
|
||||
|
||||
const handleJobStarted = useCallback((jobId: number) => {
|
||||
// #region agent log
|
||||
fetch('http://127.0.0.1:7250/ingest/2834749a-e80b-4040-ad02-08316d12b97e', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
location: 'Commander.tsx:handleJobStarted',
|
||||
message: 'handleJobStarted called',
|
||||
data: { jobId },
|
||||
timestamp: Date.now(),
|
||||
sessionId: 'debug-session',
|
||||
hypothesisId: 'G',
|
||||
}),
|
||||
}).catch(() => {})
|
||||
// #endregion
|
||||
setTrackedJobIds((prev) => new Set([...prev, jobId]))
|
||||
}, [])
|
||||
|
||||
const remotesQuery = useQuery({
|
||||
queryKey: ['remotes', 'list', 'all'],
|
||||
queryFn: async () => await rclone('/config/listremotes').then((r) => r?.remotes),
|
||||
staleTime: 1000 * 60,
|
||||
})
|
||||
|
||||
const remotes = remotesQuery.data ?? []
|
||||
const firstRemote = remotes[0] ?? null
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(items: SelectItem[], destination: string, _sourceSide: 'left' | 'right') => {
|
||||
setDropOperation({ items, destination })
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const handleOperationComplete = useCallback(() => {
|
||||
leftPanelRef.current?.refresh()
|
||||
rightPanelRef.current?.refresh()
|
||||
}, [])
|
||||
|
||||
const handleDownload = useCallback(
|
||||
async (entry: Entry) => {
|
||||
const defaultName = entry.name
|
||||
const savePath = await save({
|
||||
title: `Save ${entry.isDir ? 'Folder' : 'File'}`,
|
||||
defaultPath: defaultName,
|
||||
})
|
||||
|
||||
if (!savePath) return
|
||||
|
||||
const srcInfo = getFsInfo(entry.fullPath)
|
||||
const dstInfo = getFsInfo(savePath)
|
||||
|
||||
const srcFs = srcInfo.root
|
||||
const srcRemote = srcInfo.filePath
|
||||
const dstFs = dstInfo.root === ':local:' ? ':local:/' : dstInfo.root
|
||||
const dstRemote = dstInfo.filePath
|
||||
|
||||
try {
|
||||
const endpoint = entry.isDir ? '/sync/copy' : '/operations/copyfile'
|
||||
const params = entry.isDir
|
||||
? {
|
||||
srcFs: `${srcFs}${srcRemote}/`,
|
||||
dstFs: `${dstFs}${dstRemote}`,
|
||||
createEmptySrcDirs: true,
|
||||
}
|
||||
: { srcFs, srcRemote, dstFs, dstRemote }
|
||||
|
||||
const result = await rclone(endpoint as any, {
|
||||
params: { query: { ...params, _async: true } },
|
||||
})
|
||||
|
||||
const jobId = result?.jobid
|
||||
if (jobId) handleJobStarted(jobId)
|
||||
} catch (error) {
|
||||
await message(error instanceof Error ? error.message : 'Download failed', {
|
||||
title: 'Error',
|
||||
kind: 'error',
|
||||
})
|
||||
}
|
||||
},
|
||||
[handleJobStarted]
|
||||
)
|
||||
|
||||
const closeContextMenu = useCallback(() => {
|
||||
setContextMenu(null)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'r' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault()
|
||||
leftPanelRef.current?.refresh()
|
||||
rightPanelRef.current?.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (contextMenu) {
|
||||
const handleClick = () => closeContextMenu()
|
||||
window.addEventListener('click', handleClick)
|
||||
return () => window.removeEventListener('click', handleClick)
|
||||
}
|
||||
}, [contextMenu, closeContextMenu])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-screen h-screen overflow-hidden">
|
||||
<Group orientation="horizontal" className="flex-1">
|
||||
<Panel defaultSize={50} minSize={25}>
|
||||
<FilePanel
|
||||
ref={leftPanelRef}
|
||||
sidebarPosition="left"
|
||||
initialRemote="UI_LOCAL_FS"
|
||||
selectionMode="both"
|
||||
allowFiles={true}
|
||||
allowMultiple={true}
|
||||
showPreviewColumn={true}
|
||||
onDrop={(items, dest) => handleDrop(items, dest, 'left')}
|
||||
onDownload={handleDownload}
|
||||
allowedKeys={['REMOTES', 'LOCAL_FS', 'FAVORITES']}
|
||||
isActive={true}
|
||||
/>
|
||||
</Panel>
|
||||
|
||||
<Separator className="w-1 transition-colors bg-divider hover:bg-primary-200 active:bg-primary-300" />
|
||||
|
||||
<Panel defaultSize={50} minSize={25}>
|
||||
<FilePanel
|
||||
ref={rightPanelRef}
|
||||
sidebarPosition="right"
|
||||
initialRemote={firstRemote ?? 'UI_LOCAL_FS'}
|
||||
selectionMode="both"
|
||||
allowFiles={true}
|
||||
allowMultiple={true}
|
||||
showPreviewColumn={true}
|
||||
onDrop={(items, dest) => handleDrop(items, dest, 'right')}
|
||||
onDownload={handleDownload}
|
||||
allowedKeys={['REMOTES', 'LOCAL_FS', 'FAVORITES']}
|
||||
isActive={true}
|
||||
/>
|
||||
</Panel>
|
||||
</Group>
|
||||
|
||||
<TransfersBar trackedJobIds={trackedJobIds} />
|
||||
|
||||
<OperationDialog
|
||||
items={dropOperation?.items ?? null}
|
||||
destination={dropOperation?.destination ?? null}
|
||||
onClose={() => setDropOperation(null)}
|
||||
onComplete={handleOperationComplete}
|
||||
onJobStarted={handleJobStarted}
|
||||
/>
|
||||
|
||||
{contextMenu && (
|
||||
<div className="fixed z-50" style={{ top: contextMenu.y, left: contextMenu.x }}>
|
||||
<Dropdown
|
||||
isOpen={true}
|
||||
onClose={closeContextMenu}
|
||||
shadow={platform() === 'windows' ? 'none' : undefined}
|
||||
>
|
||||
<DropdownTrigger>
|
||||
<span />
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu
|
||||
onAction={(key) => {
|
||||
if (key === 'preview' && !contextMenu.entry.isDir) {
|
||||
setPreviewItem(contextMenu.entry)
|
||||
} else if (key === 'copy-path') {
|
||||
navigator.clipboard.writeText(contextMenu.entry.fullPath)
|
||||
} else if (key === 'info') {
|
||||
setPreviewItem(contextMenu.entry)
|
||||
}
|
||||
closeContextMenu()
|
||||
}}
|
||||
>
|
||||
{contextMenu.entry.isDir ? (
|
||||
<>
|
||||
<DropdownItem key="copy-path">Copy Path</DropdownItem>
|
||||
<DropdownItem key="info">Get Info</DropdownItem>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<DropdownItem key="preview">Preview</DropdownItem>
|
||||
<DropdownItem key="copy-path">Copy Path</DropdownItem>
|
||||
<DropdownItem key="info">Get Info</DropdownItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TransfersBar({ trackedJobIds }: { trackedJobIds: Set<number> }) {
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const jobIds = Array.from(trackedJobIds)
|
||||
|
||||
// #region agent log
|
||||
fetch('http://127.0.0.1:7250/ingest/2834749a-e80b-4040-ad02-08316d12b97e', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
location: 'Commander.tsx:TransfersBar:render',
|
||||
message: 'TransfersBar rendering',
|
||||
data: { jobIdsCount: jobIds.length, jobIds },
|
||||
timestamp: Date.now(),
|
||||
sessionId: 'debug-session',
|
||||
hypothesisId: 'G,H',
|
||||
}),
|
||||
}).catch(() => {})
|
||||
// #endregion
|
||||
|
||||
const itemsQuery = useQuery({
|
||||
queryKey: ['transfers', 'items', jobIds],
|
||||
queryFn: async () => {
|
||||
if (jobIds.length === 0) return { transferring: [], checking: [], transferred: [] }
|
||||
|
||||
const results = await Promise.all(
|
||||
jobIds.map(async (jobId) => {
|
||||
const [stats, transferredData] = await Promise.all([
|
||||
rclone('/core/stats', { params: { query: { group: `job/${jobId}` } } }),
|
||||
rclone('/core/transferred', {
|
||||
params: { query: { group: `job/${jobId}` } },
|
||||
}),
|
||||
])
|
||||
return {
|
||||
jobId,
|
||||
transferring: (stats?.transferring || []).map((t: any) => ({
|
||||
...t,
|
||||
jobId,
|
||||
})),
|
||||
checking: (stats?.checking || []).map((c: any) => ({ ...c, jobId })),
|
||||
transferred: (transferredData?.transferred || []).map((t: any) => ({
|
||||
...t,
|
||||
jobId,
|
||||
})),
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return {
|
||||
transferring: results.flatMap((r) => r.transferring),
|
||||
checking: results.flatMap((r) => r.checking),
|
||||
transferred: results.flatMap((r) => r.transferred),
|
||||
}
|
||||
},
|
||||
refetchInterval: 1000,
|
||||
enabled: jobIds.length > 0,
|
||||
})
|
||||
|
||||
const transferring = itemsQuery.data?.transferring ?? []
|
||||
const checking = itemsQuery.data?.checking ?? []
|
||||
const transferred = itemsQuery.data?.transferred ?? []
|
||||
|
||||
const inProgressCount = transferring.length + checking.length
|
||||
const hasTransfers = transferring.length > 0 || checking.length > 0 || transferred.length > 0
|
||||
|
||||
const toggleExpanded = useCallback(() => {
|
||||
setIsExpanded((prev) => !prev)
|
||||
}, [])
|
||||
|
||||
const handleOpenTransfers = useCallback(async () => {
|
||||
await openWindow({ name: 'Transfers', url: '/transfers' })
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="border-t border-divider bg-content1">
|
||||
<div
|
||||
className="flex items-center justify-between h-10 px-4 cursor-pointer select-none hover:bg-content2"
|
||||
onClick={toggleExpanded}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-medium">Transfers</span>
|
||||
{inProgressCount > 0 && (
|
||||
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-primary-100 text-primary-700">
|
||||
{inProgressCount} active
|
||||
</span>
|
||||
)}
|
||||
{!hasTransfers && (
|
||||
<span className="text-sm text-default-400">No active transfers</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip content="Open Transfers page" size="sm">
|
||||
<Button
|
||||
isIconOnly={true}
|
||||
size="sm"
|
||||
variant="light"
|
||||
onPress={handleOpenTransfers}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ExternalLinkIcon className="size-4" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Button
|
||||
isIconOnly={true}
|
||||
size="sm"
|
||||
variant="light"
|
||||
onPress={toggleExpanded}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDownIcon className="size-4" />
|
||||
) : (
|
||||
<ChevronUpIcon className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: '40vh', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="overflow-hidden border-t border-divider"
|
||||
>
|
||||
<ScrollShadow className="h-[40vh] p-2">
|
||||
{hasTransfers ? (
|
||||
<div className="space-y-1">
|
||||
{checking.map((item: any, idx: number) => (
|
||||
<TransferItem
|
||||
key={`checking-${item.name}-${idx}`}
|
||||
item={item}
|
||||
status="checking"
|
||||
/>
|
||||
))}
|
||||
{transferring.map((item: any, idx: number) => (
|
||||
<TransferItem
|
||||
key={`transferring-${item.name}-${idx}`}
|
||||
item={item}
|
||||
status="transferring"
|
||||
/>
|
||||
))}
|
||||
{transferred.map((item: any, idx: number) => (
|
||||
<TransferItem
|
||||
key={`transferred-${item.name}-${idx}`}
|
||||
item={item}
|
||||
status={item.error ? 'error' : 'done'}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-sm text-default-400">
|
||||
No transfers in progress
|
||||
</div>
|
||||
)}
|
||||
</ScrollShadow>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TransferItem({
|
||||
item,
|
||||
status,
|
||||
}: {
|
||||
item: {
|
||||
name?: string
|
||||
size?: number
|
||||
bytes?: number
|
||||
percentage?: number
|
||||
speed?: number
|
||||
error?: string
|
||||
}
|
||||
status: 'transferring' | 'checking' | 'done' | 'error'
|
||||
}) {
|
||||
const fileName = item.name?.split('/').pop() || item.name || 'Unknown'
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 p-2 rounded-lg hover:bg-content2">
|
||||
<div className="shrink-0">
|
||||
{status === 'error' ? (
|
||||
<XCircleIcon className="text-danger size-4" />
|
||||
) : status === 'done' ? (
|
||||
<CheckCircle2Icon className="text-success size-4" />
|
||||
) : status === 'checking' ? (
|
||||
<SearchCheckIcon className="text-warning size-4 animate-pulse" />
|
||||
) : (
|
||||
<LoaderIcon className="text-primary size-4 animate-spin" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<span className="text-sm truncate" title={item.name}>
|
||||
{fileName}
|
||||
</span>
|
||||
<span className="text-xs shrink-0 text-default-500">
|
||||
{status === 'error'
|
||||
? 'Failed'
|
||||
: status === 'done'
|
||||
? formatBytes(item.size || 0)
|
||||
: status === 'checking'
|
||||
? 'Checking...'
|
||||
: (item.size || 0) > 0
|
||||
? `${formatBytes(item.bytes || 0)} / ${formatBytes(item.size || 0)}`
|
||||
: '—'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{(status === 'transferring' || status === 'checking') && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Progress
|
||||
value={item.percentage || 0}
|
||||
size="sm"
|
||||
color={status === 'checking' ? 'warning' : 'primary'}
|
||||
className="flex-1"
|
||||
aria-label="Transfer progress"
|
||||
isIndeterminate={status === 'checking'}
|
||||
/>
|
||||
{(item.speed || 0) > 0 && (
|
||||
<span className="text-xs shrink-0 text-default-400">
|
||||
{formatBytes(item.speed || 0)}/s
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'done' && item.name && (
|
||||
<span className="text-xs truncate text-default-400" title={item.name}>
|
||||
{item.name}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{status === 'error' && item.error && (
|
||||
<span className="text-xs truncate text-danger" title={item.error}>
|
||||
{item.error}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OperationDialog({
|
||||
items,
|
||||
destination,
|
||||
onClose,
|
||||
onComplete,
|
||||
onJobStarted,
|
||||
}: {
|
||||
items: SelectItem[] | null
|
||||
destination: string | null
|
||||
onClose: () => void
|
||||
onComplete?: () => void
|
||||
onJobStarted?: (jobId: number) => void
|
||||
}) {
|
||||
const [operation, setOperation] = useState<'copy' | 'move'>('copy')
|
||||
const [overwrite, setOverwrite] = useState(false)
|
||||
|
||||
const copyMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!items || !destination) throw new Error('Missing items or destination')
|
||||
|
||||
const sources = items.map((item) => item.path + (item.type === 'folder' ? '/' : ''))
|
||||
|
||||
const jobId = await startCopy({
|
||||
sources,
|
||||
destination,
|
||||
options: {
|
||||
copy: overwrite ? {} : { no_update_modtime: true },
|
||||
config: {},
|
||||
filter: {},
|
||||
},
|
||||
})
|
||||
if (jobId) onJobStarted?.(jobId)
|
||||
},
|
||||
onSuccess: () => {
|
||||
onComplete?.()
|
||||
onClose()
|
||||
},
|
||||
onError: async (error) => {
|
||||
await message(error instanceof Error ? error.message : 'Copy operation failed', {
|
||||
title: 'Error',
|
||||
kind: 'error',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const moveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!items || !destination) throw new Error('Missing items or destination')
|
||||
|
||||
const sources = items.map((item) => item.path + (item.type === 'folder' ? '/' : ''))
|
||||
|
||||
const jobId = await startMove({
|
||||
sources,
|
||||
destination,
|
||||
options: {
|
||||
move: overwrite ? {} : { no_update_modtime: true },
|
||||
config: {},
|
||||
filter: {},
|
||||
},
|
||||
})
|
||||
if (jobId) onJobStarted?.(jobId)
|
||||
},
|
||||
onSuccess: () => {
|
||||
onComplete?.()
|
||||
onClose()
|
||||
},
|
||||
onError: async (error) => {
|
||||
await message(error instanceof Error ? error.message : 'Move operation failed', {
|
||||
title: 'Error',
|
||||
kind: 'error',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
if (operation === 'copy') {
|
||||
copyMutation.mutate()
|
||||
} else {
|
||||
moveMutation.mutate()
|
||||
}
|
||||
}, [operation, copyMutation, moveMutation])
|
||||
|
||||
const isLoading = copyMutation.isPending || moveMutation.isPending
|
||||
const itemCount = items?.length ?? 0
|
||||
|
||||
const formatPath = (path: string) => {
|
||||
if (path.includes(':/')) {
|
||||
const [remote, ...rest] = path.split(':/')
|
||||
const relativePath = rest.join('/')
|
||||
const fileName = relativePath.split('/').pop() || relativePath
|
||||
return { remote, fileName, isRemote: true }
|
||||
}
|
||||
const fileName = path.split('/').pop() || path
|
||||
return { remote: 'Local', fileName, isRemote: false }
|
||||
}
|
||||
|
||||
const destinationInfo = destination ? formatPath(destination) : null
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={!!items && items.length > 0}
|
||||
onClose={onClose}
|
||||
size="lg"
|
||||
hideCloseButton={isLoading}
|
||||
>
|
||||
<ModalContent>
|
||||
<ModalHeader className="flex items-center gap-2">
|
||||
{operation === 'copy' ? (
|
||||
<CopyIcon className="size-5" />
|
||||
) : (
|
||||
<MoveIcon className="size-5" />
|
||||
)}
|
||||
<span>
|
||||
{operation === 'copy' ? 'Copy' : 'Move'} {itemCount} item
|
||||
{itemCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className="space-y-4">
|
||||
<RadioGroup
|
||||
label="Operation"
|
||||
value={operation}
|
||||
onValueChange={(val) => setOperation(val as 'copy' | 'move')}
|
||||
orientation="vertical"
|
||||
isDisabled={isLoading}
|
||||
>
|
||||
<Radio value="copy" description="Keep original files">
|
||||
<div className="flex items-center gap-2">
|
||||
<CopyIcon className="size-4" />
|
||||
Copy
|
||||
</div>
|
||||
</Radio>
|
||||
<Radio value="move" description="Delete after transfer">
|
||||
<div className="flex items-center gap-2">
|
||||
<MoveIcon className="size-4" />
|
||||
Move
|
||||
</div>
|
||||
</Radio>
|
||||
</RadioGroup>
|
||||
|
||||
<div className="p-3 rounded-lg bg-default-100">
|
||||
<p className="mb-2 text-sm font-medium text-default-600">
|
||||
Destination:
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="px-2 py-1 text-xs font-medium rounded bg-primary-100 text-primary-700">
|
||||
{destinationInfo?.remote}
|
||||
</span>
|
||||
<span className="text-sm truncate">
|
||||
{destinationInfo?.fileName || '/'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{itemCount > 1 && (
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-medium text-default-600">
|
||||
Items to transfer:
|
||||
</p>
|
||||
<ScrollShadow className="p-2 rounded-lg max-h-40 bg-default-50">
|
||||
<ul className="space-y-1">
|
||||
{items?.map((item) => {
|
||||
const info = formatPath(item.path)
|
||||
const mockEntry = {
|
||||
key: item.path,
|
||||
name: info.fileName,
|
||||
isDir: item.type === 'folder',
|
||||
fullPath: item.path,
|
||||
} as Entry
|
||||
return (
|
||||
<li
|
||||
key={item.path}
|
||||
className="flex items-center gap-2 text-sm"
|
||||
>
|
||||
<FileIcon entry={mockEntry} size="sm" />
|
||||
<span className="truncate">
|
||||
{info.fileName}
|
||||
</span>
|
||||
{info.isRemote && (
|
||||
<span className="px-1.5 py-0.5 text-xs rounded bg-default-200 text-default-600">
|
||||
{info.remote}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</ScrollShadow>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Checkbox
|
||||
isSelected={overwrite}
|
||||
onValueChange={setOverwrite}
|
||||
isDisabled={isLoading}
|
||||
size="sm"
|
||||
>
|
||||
Overwrite existing files
|
||||
</Checkbox>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="flat" onPress={onClose} isDisabled={isLoading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="primary" onPress={handleConfirm} isLoading={isLoading}>
|
||||
{operation === 'copy' ? 'Copy' : 'Move'}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -927,6 +927,27 @@ const actions: ToolbarActionDefinition[] = [
|
||||
await openCommandWindow('settings', args, context)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'commander',
|
||||
label: 'Commander',
|
||||
description: COMMAND_DESCRIPTIONS.commander,
|
||||
keywords: COMMAND_KEYWORDS.commander,
|
||||
getDefaultResult: () =>
|
||||
createBaseResult('Commander', COMMAND_DESCRIPTIONS.commander, {}, 32),
|
||||
getResults: ({ query }) => {
|
||||
if (query && matchesKeyword(query, COMMAND_KEYWORDS.commander)) {
|
||||
return [createBaseResult('Commander', COMMAND_DESCRIPTIONS.commander, {}, 32)]
|
||||
}
|
||||
return []
|
||||
},
|
||||
onPress: async () => {
|
||||
await openFullWindow({
|
||||
name: 'Commander',
|
||||
url: '/commander',
|
||||
hideTitleBar: true,
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'github',
|
||||
label: 'GitHub',
|
||||
|
||||
@@ -25,6 +25,7 @@ const REMOTE_AUTO_MOUNT_DESCRIPTION = 'Configure auto mount options for a remote
|
||||
const REMOTE_LIST_DESCRIPTION = 'Show all configured remotes.'
|
||||
const QUIT_DESCRIPTION = 'Quit the application.'
|
||||
const VFS_DESCRIPTION = 'Forget the local cache for one or all remotes.'
|
||||
const COMMANDER_DESCRIPTION = 'Open the Commander screen.'
|
||||
|
||||
export const COMMAND_CONFIG = {
|
||||
copy: { route: '/copy', windowLabel: 'Copy' },
|
||||
@@ -49,6 +50,7 @@ export const COMMAND_CONFIG = {
|
||||
remoteList: {},
|
||||
quit: {},
|
||||
vfs: {},
|
||||
commander: { route: '/commander', windowLabel: 'Commander' },
|
||||
} as const
|
||||
|
||||
export const COMMAND_DESCRIPTIONS: Record<ToolbarCommandId, string> = {
|
||||
@@ -74,6 +76,7 @@ export const COMMAND_DESCRIPTIONS: Record<ToolbarCommandId, string> = {
|
||||
remoteList: REMOTE_LIST_DESCRIPTION,
|
||||
quit: QUIT_DESCRIPTION,
|
||||
vfs: VFS_DESCRIPTION,
|
||||
commander: COMMANDER_DESCRIPTION,
|
||||
}
|
||||
|
||||
export const COMMAND_KEYWORDS: Record<ToolbarCommandId, string[]> = {
|
||||
@@ -99,4 +102,5 @@ export const COMMAND_KEYWORDS: Record<ToolbarCommandId, string[]> = {
|
||||
remoteList: ['remote', 'list', 'show'],
|
||||
quit: ['quit', 'exit', 'close'],
|
||||
vfs: ['vfs', 'cache', 'forget'],
|
||||
commander: ['commander', 'explorer', 'navigator', 'navigate'],
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ export type ToolbarCommandId =
|
||||
| 'quit'
|
||||
| 'remoteList'
|
||||
| 'vfs'
|
||||
| 'commander'
|
||||
|
||||
export type ToolbarActionArgs = Record<string, any>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user