Commander (in chief)

This commit is contained in:
FTCHD
2026-02-03 15:51:16 +07:00
parent dc46bbc1ef
commit f304411bdb
22 changed files with 3909 additions and 1456 deletions
+483 -5
View File
@@ -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",
+2
View File
@@ -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",
+1
View File
@@ -18,6 +18,7 @@
"Bisync",
"Download",
"Browse",
"Commander",
"Startup",
"Toolbar",
"Onboarding",
+2
View File
@@ -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>
)
File diff suppressed because it is too large Load Diff
+139
View File
@@ -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`} />
}
}
+325
View File
@@ -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>
)
}
+371
View File
@@ -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
+118
View File
@@ -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>
)
}
+201
View File
@@ -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>
)
}
+298
View File
@@ -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>
)
}
+127
View File
@@ -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>
)
}
+10
View File
@@ -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'
+40
View File
@@ -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,
}
}
+191
View File
@@ -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
}
}
+26
View File
@@ -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
View File
@@ -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 />,
+720
View File
@@ -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>
)
}
+21
View File
@@ -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',
+4
View File
@@ -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'],
}
+1
View File
@@ -21,6 +21,7 @@ export type ToolbarCommandId =
| 'quit'
| 'remoteList'
| 'vfs'
| 'commander'
export type ToolbarActionArgs = Record<string, any>