From f304411bdba7d221368915a7115620744cf8d79b Mon Sep 17 00:00:00 2001 From: FTCHD <144691102+FTCHD@users.noreply.github.com> Date: Tue, 3 Feb 2026 15:51:16 +0700 Subject: [PATCH] Commander (in chief) --- package-lock.json | 488 ++++- package.json | 2 + src-tauri/capabilities/default.json | 1 + src/components/PathFinder.tsx | 2 + src/components/PathSelector.tsx | 1619 ++--------------- src/components/navigator/FileIcon.tsx | 139 ++ src/components/navigator/FileList.tsx | 325 ++++ src/components/navigator/FilePanel.tsx | 371 ++++ src/components/navigator/PanelToolbar.tsx | 118 ++ src/components/navigator/PathBreadcrumb.tsx | 201 ++ src/components/navigator/PreviewDrawer.tsx | 298 +++ src/components/navigator/RemoteSidebar.tsx | 127 ++ src/components/navigator/index.ts | 10 + src/components/navigator/types.ts | 40 + src/components/navigator/useFileNavigation.ts | 653 +++++++ src/components/navigator/utils.ts | 191 ++ src/global.css | 26 + src/main.tsx | 8 +- src/pages/Commander.tsx | 720 ++++++++ toolbar/actions.ts | 21 + toolbar/constants.ts | 4 + toolbar/types.ts | 1 + 22 files changed, 3909 insertions(+), 1456 deletions(-) create mode 100644 src/components/navigator/FileIcon.tsx create mode 100644 src/components/navigator/FileList.tsx create mode 100644 src/components/navigator/FilePanel.tsx create mode 100644 src/components/navigator/PanelToolbar.tsx create mode 100644 src/components/navigator/PathBreadcrumb.tsx create mode 100644 src/components/navigator/PreviewDrawer.tsx create mode 100644 src/components/navigator/RemoteSidebar.tsx create mode 100644 src/components/navigator/index.ts create mode 100644 src/components/navigator/types.ts create mode 100644 src/components/navigator/useFileNavigation.ts create mode 100644 src/components/navigator/utils.ts create mode 100644 src/pages/Commander.tsx diff --git a/package-lock.json b/package-lock.json index 23ebe33..cb02bc4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 2a4b034..eefddb7 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 07328b1..bb6055e 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -18,6 +18,7 @@ "Bisync", "Download", "Browse", + "Commander", "Startup", "Toolbar", "Onboarding", diff --git a/src/components/PathFinder.tsx b/src/components/PathFinder.tsx index 8ef3679..0f1f22d 100644 --- a/src/components/PathFinder.tsx +++ b/src/components/PathFinder.tsx @@ -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} /> ) diff --git a/src/components/PathSelector.tsx b/src/components/PathSelector.tsx index cbe3a52..014a607 100644 --- a/src/components/PathSelector.tsx +++ b/src/components/PathSelector.tsx @@ -1,7 +1,5 @@ import { Button, - Checkbox, - Divider, Drawer, DrawerBody, DrawerContent, @@ -9,172 +7,19 @@ import { DropdownItem, DropdownMenu, DropdownTrigger, - Input, - Listbox, - ListboxItem, - ScrollShadow, Tooltip, - cn, } from '@heroui/react' -import { useQuery } from '@tanstack/react-query' -import { dirname, homeDir, join, sep } from '@tauri-apps/api/path' -import { readDir } from '@tauri-apps/plugin-fs' import { platform } from '@tauri-apps/plugin-os' -import { AnimatePresence, motion } from 'framer-motion' +import { ArrowBigDownIcon, LassoSelectIcon } from 'lucide-react' +import { useCallback, useRef, useState } from 'react' import { - ArrowBigDownIcon, - ArrowLeftIcon, - LaptopIcon, - LassoSelectIcon, - StarIcon, -} from 'lucide-react' -import { startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { formatBytes } from '../../lib/format.ts' -import rclone from '../../lib/rclone/client.ts' -import { useHostStore } from '../../store/host.ts' - -const RE_BACKSLASH = /\\/g -const RE_TRAILING_SLASH = /\/+$/g -const RE_LEADING_SLASH = /^\/+/ -const RE_PATH_SEPARATOR = /[/\\]/ - -function log(msg: string, ...args: any[]) { - console.log(`[PathSelector] ${msg}`, ...args) -} - -async function joinLocal(base: string, name: string) { - if (!base) return join(sep(), name) - return join(base, name) -} - -async function getLocalParent(path: string) { - if (!path) return '' - return dirname(path) -} - -function getRemoteParent(path: string) { - if (!path) return '' - const parts = path.split('/').filter(Boolean) - if (parts.length === 0) return '' - return parts.slice(0, -1).join('/') -} - -function serializeRemotePath(remote: string, relPath: string) { - return `${remote}:/${relPath}` -} - -function cacheKey(remote: string | 'UI_LOCAL_FS' | null, dir: string) { - return `${remote ?? 'NONE'}::${dir || '/'}` -} - -function normalizeRemoteDir(path: string) { - if (!path) return '' - const cleaned = path.replace(RE_BACKSLASH, '/').replace(RE_TRAILING_SLASH, '') - return cleaned -} - -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[] = [] - - 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') { - // rclone operations/list returns { list: [...] } - // but some other calls might return array directly? Support both just in case. - 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') - // intentionally throw here so callers can uniformly handle unreachable/forbidden - // note: throwing here avoids "throw inside try" in callers if they avoid inline throws - throw new Error('No access or folder does not exist') - } - // de-duplicate by Path or Name - const seen = new Set() - 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 } -} - -type SelectItem = { path: string; type: 'file' | 'folder' } - -type RemoteString = string | 'UI_LOCAL_FS' | 'UI_FAVORITES' | null -type Entry = { - key: string - name: string - isDir: boolean - size?: number - modTime?: string - remote?: string | 'UI_LOCAL_FS' - fullPath: string // serialized returned path (remote:/path or local absolute) -} - -type PaddingItem = { - key: string - padding: true -} - -type VirtualizedEntry = Entry & { isSelected: boolean } - -const VIRTUAL_PADDING_COUNT = 2 + FilePanel, + type FilePanelHandle, + RE_PATH_SEPARATOR, + type SelectItem, + type ToolbarButtons, + serializeRemotePath, +} from './navigator' export default function PathSelector({ onClose, @@ -193,1315 +38,189 @@ export default function PathSelector({ allowFiles?: boolean allowMultiple?: boolean }) { - const favoritePaths = useHostStore((state) => state.favoritePaths) + const panelRef = useRef(null) + const [selectedCount, setSelectedCount] = useState(0) + const [currentRemote, setCurrentRemote] = useState(null) + const [currentPath, setCurrentPath] = useState('') + const [isFavorites, setIsFavorites] = useState(false) - const remotesQuery = useQuery({ - queryKey: ['remotes', 'list', 'all'], - queryFn: async () => await rclone('/config/listremotes').then((r) => r?.remotes), - staleTime: 1000 * 60, // 1 minute - }) - - const remotes = useMemo(() => remotesQuery.data ?? [], [remotesQuery.data]) - - const [selectedRemote, setSelectedRemote] = useState(null) - - const [cwd, setCwd] = useState('') - const [pathInput, setPathInput] = useState('') - const [searchTerm, setSearchTerm] = useState('') - const [items, setItems] = useState([]) - - const [isLoading, setIsLoading] = useState(false) - const [error, setError] = useState(null) - - const isRemote = useMemo( - () => selectedRemote !== 'UI_LOCAL_FS' && selectedRemote !== 'UI_FAVORITES', - [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>(new Map()) - const entryByKeyRef = useRef>(new Map()) - const abortControllerRef = useRef(null) - const loadingTimerRef = useRef | null>(null) - const isNavigatingRef = useRef(false) - - const [selectedPaths, setSelectedPaths] = useState>(new Set()) - const selectedTypesRef = useRef>(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, - // Include selection state so virtualized list re-renders when selection changes - 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 [isUpDisabled, setIsUpDisabled] = useState(false) - - const listRef = useRef(null) - const [listHeight, setListHeight] = useState(400) - - useEffect(() => { - // Re-run when drawer opens to measure the container correctly - if (!isOpen || !listRef.current) return - // Measure initial height immediately (with a small delay to allow drawer animation) - const measureInitial = () => { - if (listRef.current) { - const height = listRef.current.getBoundingClientRect().height - if (height > 0) { - startTransition(() => { - setListHeight(height) - }) - } - } - } - measureInitial() - // Also measure after a short delay to catch post-animation dimensions - 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() - } - }, [isOpen]) - - const favoritedKeys = useMemo(() => { - const map: Record = {} - 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] = it - } - return map - }, [favoritePaths]) - - // Clean up selection when switching remotes - const cleanupSelectionForRemote = useCallback( - (newRemote: RemoteString) => { - log('cleanupSelectionForRemote', { newRemote, selectedRemote }) - if (newRemote !== selectedRemote) { - startTransition(() => { - setSelectedPaths(new Set()) - }) - selectedTypesRef.current.clear() - // Also clean up stale entries from entryByKeyRef - 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 handleSelectionChange = useCallback((selected: SelectItem[]) => { + setSelectedCount(selected.length) }, []) - 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') { - // Jump into the actual directory represented by this favorite - 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 - } - // UI_FAVORITES -> no-op - }, [cwd, selectedRemote, isRemote]) - - 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) { - // In single select mode, if one is already selected, do not allow selecting another - // (unless the user deselected the first one, handled by the 'delete' branch) - return - } - next.add(entry.key) - selectedTypesRef.current.set(entry.key, entry.isDir ? 'folder' : 'file') - } - startTransition(() => { - setSelectedPaths(next) - }) - }, - [selectedPaths, allowMultiple] - ) + const handleNavigate = useCallback((remote: string, path: string) => { + setCurrentRemote(remote) + setCurrentPath(path) + setIsFavorites(remote === 'UI_FAVORITES') + }, []) const handleConfirm = useCallback(() => { - const paths = Array.from(selectedPaths) - const results: SelectItem[] = 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' } - // Fallback for edge cases - if (path.includes(':/')) { - const rel = path.split(':/').slice(1).join('/') - return { path, type: rel.endsWith('/') ? 'folder' : 'file' } - } - return { path, type: 'file' } - }) - onSelect?.(results) - }, [selectedPaths, onSelect]) + if (!panelRef.current) return + const selection = panelRef.current.getSelection() + onSelect?.(selection) + }, [onSelect]) - // Clear caches when open state changes and cleanup entries - useEffect(() => { - cacheRef.current.clear() - entryByKeyRef.current.clear() - selectedTypesRef.current.clear() - - if (isOpen) { - if (canShowLocal) { - setIsLoading(true) - setTimeout(async () => { - setSelectedRemote('UI_LOCAL_FS') - const home = await homeDir() - setCwd(home) - setPathInput(home) - setIsLoading(false) - }, 100) - } else if (canShowFavorites) { - setIsLoading(true) - setTimeout(() => { - setSelectedRemote('UI_FAVORITES') - setIsLoading(false) - }, 100) - } - } else { - abortControllerRef.current?.abort() - setSelectedPaths(new Set()) - setItems([]) - setSelectedRemote(null) - setCwd('') - setPathInput('') - setSearchTerm('') - setError(null) - setIsLoading(false) - isNavigatingRef.current = false + const handleSelectCurrentFolder = useCallback(() => { + if (!currentRemote) return + let path = currentPath + if (currentRemote !== 'UI_LOCAL_FS' && currentRemote !== 'UI_FAVORITES') { + path = serializeRemotePath(currentRemote, currentPath) } - }, [isOpen, canShowLocal, canShowFavorites]) + onSelect?.([{ path, type: 'folder' }]) + }, [currentRemote, currentPath, onSelect]) - // Initialize from initialPaths - // biome-ignore lint/correctness/useExhaustiveDependencies: - useEffect(() => { - async function initializePaths() { - if (!initialPaths || initialPaths.length === 0) { - return - } - log('initializePaths', initialPaths) + const initialRemote = initialPaths[0]?.includes(':/') + ? initialPaths[0].split(':/')[0] + : initialPaths.length > 0 + ? 'UI_LOCAL_FS' + : undefined - // seed selection set - startTransition(() => { - setSelectedPaths(new Set(initialPaths)) - }) + const initialPath = initialPaths[0]?.includes(':/') + ? initialPaths[0].split(':/').slice(1).join('/') + : initialPaths[0] + ? undefined // Will be computed by the hook based on parent + : undefined - const first = initialPaths[0] - if (first.includes(':/')) { - const [remote, ...rest] = first.split(':/') - const rel = rest.join('/') - const parent = getRemoteParent(rel) - startTransition(() => { - setSelectedRemote(remote) - setCwd(parent) - updatePathInput(remote, parent) - }) - } else { - const parent = await getLocalParent(first) - startTransition(() => { - setSelectedRemote('UI_LOCAL_FS') - setCwd(parent) - updatePathInput('UI_LOCAL_FS', parent) - }) - } - } - initializePaths() - }, [initialPaths]) - - // Load directory content when remote/cwd changes - useEffect(() => { - async function loadDir() { - log('loadDir: start', { selectedRemote, cwd, isRemote }) - if (!selectedRemote) { - startTransition(() => { - setItems([]) - setError(null) - setIsLoading(false) - }) - isNavigatingRef.current = false - return - } - - // Cancel previous request - 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') { - // Build favorites list - 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) - } - } - // Derive basename from relative or absolute path - 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 - }) - // sort by name asc (all dirs) - nextItems.sort((a, b) => a.name.localeCompare(b.name)) - // Update cache and maps only if not aborted - 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 (let i = 0; i < nextItems.length; i++) { - const e = nextItems[i] - 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 - let targetCwd = cwd - if (!targetCwd) { - targetCwd = '' - } - 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 - } - - // Check if request was aborted - 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, - remote, - fullPath: full, - } as Entry - }) - .filter((e) => !e.name.startsWith('.')) - } else if (cwd) { - // Local filesystem - let entries: Awaited> | 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 - } - - // Check if request was aborted - if (controller.signal.aborted) { - return - } - - const processedEntries: Entry[] = [] - for (const e of entries || []) { - // Skip symlinks to avoid circular references - 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 = [] - } - - // Check if request was aborted - if (controller.signal.aborted) { - return - } - - // sort: dirs first, name asc - nextItems.sort((a, b) => { - if (a.isDir && !b.isDir) return -1 - if (!a.isDir && b.isDir) return 1 - return a.name.localeCompare(b.name) - }) - - // Update cache and maps only if not aborted - 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 (let i = 0; i < nextItems.length; i++) { - const e = nextItems[i] - map.set(e.key, e) - } - setItems(nextItems) - setIsLoading(false) - isNavigatingRef.current = false - } - } - - loadDir() - - // Cleanup function - return () => { - if (abortControllerRef.current) { - abortControllerRef.current.abort() - } - if (loadingTimerRef.current) { - clearTimeout(loadingTimerRef.current) - loadingTimerRef.current = null - } - } - }, [selectedRemote, cwd, isRemote, favoritePaths]) - - //! prev compiler - useEffect(() => { - updatePathInput(selectedRemote, cwd) - setSearchTerm('') - }, [selectedRemote, cwd, updatePathInput]) - - // Update up button disabled state - 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) // At root when dirname equals self - } - updateUpState() - return () => { - cancelled = true - } - }, [selectedRemote, cwd, isRemote]) + const renderToolbar = useCallback( + (buttons: ToolbarButtons) => [ + [buttons.BackButton, buttons.RefreshButton], + [ + buttons.SearchInput, + + + + + + + Current Folder ( + {currentPath.split(RE_PATH_SEPARATOR).pop() || currentRemote || 'root'}) + + panelRef.current?.selectAll('files')} + > + All Files + + panelRef.current?.selectAll('folders')} + > + All Folders + + panelRef.current?.selectAll('all')} + > + All Files & Folders + + panelRef.current?.clearSelection()} + color="danger" + > + Deselect All + + + , + + + , + ], + [ + 0} + > +
+ +
+
, + ], + ], + [ + handleSelectCurrentFolder, + currentPath, + currentRemote, + onClose, + selectedCount, + allowMultiple, + handleConfirm, + ] + ) return ( - {(close) => ( + {() => ( -
- - {canShowFavorites && ( - - - - )} - {canShowLocal && ( - - - - )} - {canShowRemotes && - remotes.map((remote) => ( - { - setTimeout(() => { - cleanupSelectionForRemote(remote) - startTransition(() => { - setSelectedRemote(remote) - setCwd('') - }) - }, 100) - }} - isSelected={selectedRemote === remote} - /> - ))} - -
- - - -
- - setPathInput(e.target.value)} - onKeyDown={(e) => { - if (e.key !== 'Enter') return - const value = pathInput.trim() - if (!value) return - if (value.includes(':/')) { - const [remote, ...rest] = value.split(':/') - const rel = rest.join('/') - startTransition(() => { - setSelectedRemote(remote) - setCwd(rel) - }) - } else { - startTransition(() => { - setSelectedRemote('UI_LOCAL_FS') - setCwd(value) - }) - } - }} - isReadOnly={selectedRemote === 'UI_FAVORITES'} - /> - - - -
-
-
-
Name
-
Size
-
Last Modified
-
-
- -
- {isLoading && ( -
-
Loading...
-
- )} - - {!isLoading && error && ( -
- {error} -
- )} - - {!isLoading && !error && visibleItems.length === 0 && ( -
- No items -
- )} - - {!isLoading && !error && visibleItems.length > 0 && ( - - {(item) => { - if ('padding' in item && item.padding) { - return ( - - -
- -
- - {selectedRemote !== 'UI_FAVORITES' && ( - - - - - - - - - - - { - let path = cwd - if (isRemote && selectedRemote) { - path = serializeRemotePath( - selectedRemote, - cwd - ) - } - onSelect?.([{ path, type: 'folder' }]) - }} - > - Current Folder ( - {cwd.split(RE_PATH_SEPARATOR).pop() || - selectedRemote} - ) - - { - const next = new Set(selectedPaths) - for (const item of visibleItems) { - if (!item.isDir) { - next.add(item.key) - selectedTypesRef.current.set( - item.key, - 'file' - ) - } - } - startTransition(() => { - setSelectedPaths(next) - }) - }} - > - All Files - - { - const next = new Set(selectedPaths) - for (const item of visibleItems) { - if (item.isDir) { - next.add(item.key) - selectedTypesRef.current.set( - item.key, - 'folder' - ) - } - } - startTransition(() => { - setSelectedPaths(next) - }) - }} - > - All Folders - - { - const next = new Set(selectedPaths) - for (const item of visibleItems) { - next.add(item.key) - selectedTypesRef.current.set( - item.key, - item.isDir ? 'folder' : 'file' - ) - } - startTransition(() => { - setSelectedPaths(next) - }) - }} - > - All Files & Folders - - { - selectedTypesRef.current.clear() - startTransition(() => { - setSelectedPaths(new Set()) - }) - }} - color="danger" - > - Deselect All - - - - - setSearchTerm('')} - autoCapitalize="off" - autoComplete="off" - autoCorrect="off" - spellCheck="false" - /> - - - - - - )} - - - {selectedRemote !== 'UI_FAVORITES' && ( - - 0} - > -
- -
-
-
- )} -
-
-
+ )} ) } - -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 ( - - - - ) -} diff --git a/src/components/navigator/FileIcon.tsx b/src/components/navigator/FileIcon.tsx new file mode 100644 index 0000000..87f9977 --- /dev/null +++ b/src/components/navigator/FileIcon.tsx @@ -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 + case 'image': + return + case 'video': + return + case 'audio': + return + case 'archive': + return + case 'code': + return + case 'config': + return + case 'document': + return + case 'spreadsheet': + return + case 'presentation': + return + case 'text': + return + default: + return + } +} diff --git a/src/components/navigator/FileList.tsx b/src/components/navigator/FileList.tsx new file mode 100644 index 0000000..677e858 --- /dev/null +++ b/src/components/navigator/FileList.tsx @@ -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 + 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 + 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 ( +
+
Loading...
+
+ ) + } + + if (error) { + return ( +
+ {error} +
+ ) + } + + const visibleItems = items.filter((item) => !('padding' in item)) as VirtualizedEntry[] + + if (visibleItems.length === 0) { + return ( +
+ No items +
+ ) + } + + const gridCols = showPreviewColumn + ? 'grid-cols-[2.5rem_1fr_6rem_9rem_5rem]' + : 'grid-cols-[2.5rem_1fr_6rem_9rem_2.5rem]' + + return ( + + {(item) => { + if ('padding' in item && item.padding) { + return ( + +