diff --git a/bun.lock b/bun.lock index 8288395..256b1bb 100644 --- a/bun.lock +++ b/bun.lock @@ -13,17 +13,13 @@ "@tauri-apps/plugin-shell": "^2.3.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "d3-shape": "^3.2.0", "i18next": "^25.6.0", - "i18next-resources-to-backend": "^1.2.1", "lucide-react": "^0.545.0", "next": "15.5.7", "next-themes": "^0.4.6", "react": "^19.2.0", "react-dom": "^19.2.0", "react-i18next": "^16.0.1", - "recharts": "^3.2.1", - "recharts-scale": "^0.4.5", "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", }, @@ -156,12 +152,6 @@ "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], - "@reduxjs/toolkit": ["@reduxjs/toolkit@2.9.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^10.0.3", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-fSfQlSRu9Z5yBkvsNhYF2rPS8cGXn/TZVrlwN1948QyZ8xMZ0JvP50S2acZNaf+o63u6aEeMjipFyksjIcWrog=="], - - "@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="], - - "@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="], - "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], "@tailwindcss/node": ["@tailwindcss/node@4.1.14", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.0", "lightningcss": "1.30.1", "magic-string": "^0.30.19", "source-map-js": "^1.2.1", "tailwindcss": "4.1.14" } }, "sha512-hpz+8vFk3Ic2xssIA3e01R6jkmsAhvkQdXlEbRTk6S10xDAtiQiM3FyvZVGsucefq764euO/b8WUW9ysLdThHw=="], @@ -230,32 +220,12 @@ "@types/bun": ["@types/bun@1.3.0", "", { "dependencies": { "bun-types": "1.3.0" } }, "sha512-+lAGCYjXjip2qY375xX/scJeVRmZ5cY0wyHYyCYxNcdEXrQ4AOe3gACgd4iQ8ksOslJtW4VNxBJ8llUwc3a6AA=="], - "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], - - "@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], - - "@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="], - - "@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="], - - "@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="], - - "@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="], - - "@types/d3-shape": ["@types/d3-shape@3.1.7", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg=="], - - "@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="], - - "@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="], - "@types/node": ["@types/node@24.7.2", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA=="], "@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="], "@types/react-dom": ["@types/react-dom@19.2.2", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw=="], - "@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="], - "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], "bun-types": ["bun-types@1.3.0", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-u8X0thhx+yJ0KmkxuEo9HAtdfgCBaM/aI9K90VQcQioAmkVp3SG3FkwWGibUFz3WdXAdcsqOcbU40lK7tbHdkQ=="], @@ -272,40 +242,12 @@ "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], - "d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="], - - "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="], - - "d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="], - - "d3-format": ["d3-format@3.1.0", "", {}, "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA=="], - - "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="], - - "d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="], - - "d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="], - - "d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="], - - "d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="], - - "d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="], - - "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="], - - "decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="], - "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], "enhanced-resolve": ["enhanced-resolve@5.18.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww=="], - "es-toolkit": ["es-toolkit@1.40.0", "", {}, "sha512-8o6w0KFmU0CiIl0/Q/BCEOabF2IJaELM1T2PWj6e8KqzHv1gdx+7JtFnDwOx1kJH/isJ5NwlDG1nCr1HrRF94Q=="], - - "eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="], - "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], @@ -314,12 +256,6 @@ "i18next": ["i18next@25.7.1", "", { "dependencies": { "@babel/runtime": "^7.28.4" }, "peerDependencies": { "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-XbTnkh1yCZWSAZGnA9xcQfHcYNgZs2cNxm+c6v1Ma9UAUGCeJPplRe1ILia6xnDvXBjk0uXU+Z8FYWhA19SKFw=="], - "i18next-resources-to-backend": ["i18next-resources-to-backend@1.2.1", "", { "dependencies": { "@babel/runtime": "^7.23.2" } }, "sha512-okHbVA+HZ7n1/76MsfhPqDou0fptl2dAlhRDu2ideXloRRduzHsqDOznJBef+R3DFZnbvWoBW+KxJ7fnFjd6Yw=="], - - "immer": ["immer@10.1.3", "", {}, "sha512-tmjF/k8QDKydUlm3mZU+tjM6zeq9/fFpPqH9SzWmBnVVKsPBg/V66qsMwb3/Bo90cgUN+ghdVBess+hPsxUyRw=="], - - "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], - "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], "lightningcss": ["lightningcss@1.30.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.30.1", "lightningcss-darwin-x64": "1.30.1", "lightningcss-freebsd-x64": "1.30.1", "lightningcss-linux-arm-gnueabihf": "1.30.1", "lightningcss-linux-arm64-gnu": "1.30.1", "lightningcss-linux-arm64-musl": "1.30.1", "lightningcss-linux-x64-gnu": "1.30.1", "lightningcss-linux-x64-musl": "1.30.1", "lightningcss-win32-arm64-msvc": "1.30.1", "lightningcss-win32-x64-msvc": "1.30.1" } }, "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg=="], @@ -368,26 +304,12 @@ "react-i18next": ["react-i18next@16.4.0", "", { "dependencies": { "@babel/runtime": "^7.27.6", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 25.6.2", "react": ">= 16.8.0", "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-bxVeBA8Ky2UeItNhF4JRxHCFIrpEJHGFG/mOAa4CR0JkqaDEYSLmlEgmC4Os63SBlZ+E5U0YyrNJOSVl2mtVqQ=="], - "react-is": ["react-is@19.2.0", "", {}, "sha512-x3Ax3kNSMIIkyVYhWPyO09bu0uttcAIoecO/um/rKGQ4EltYWVYtyiGkS/3xMynrbVQdS69Jhlv8FXUEZehlzA=="], - - "react-redux": ["react-redux@9.2.0", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g=="], - "react-remove-scroll": ["react-remove-scroll@2.7.1", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA=="], "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], - "recharts": ["recharts@3.2.1", "", { "dependencies": { "@reduxjs/toolkit": "1.x.x || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^10.1.1", "react-redux": "8.x.x || 9.x.x", "reselect": "5.1.1", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-0JKwHRiFZdmLq/6nmilxEZl3pqb4T+aKkOkOi/ZISRZwfBhVMgInxzlYU9D4KnCH3KINScLy68m/OvMXoYGZUw=="], - - "recharts-scale": ["recharts-scale@0.4.5", "", { "dependencies": { "decimal.js-light": "^2.4.1" } }, "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w=="], - - "redux": ["redux@5.0.1", "", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="], - - "redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="], - - "reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="], - "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], @@ -408,8 +330,6 @@ "tar": ["tar@7.5.1", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g=="], - "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], - "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], @@ -422,8 +342,6 @@ "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], - "victory-vendor": ["victory-vendor@37.3.6", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="], - "void-elements": ["void-elements@3.1.0", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="], "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], diff --git a/package.json b/package.json index a94dc5b..9aee045 100644 --- a/package.json +++ b/package.json @@ -31,17 +31,13 @@ "@tauri-apps/plugin-shell": "^2.3.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "d3-shape": "^3.2.0", "i18next": "^25.6.0", - "i18next-resources-to-backend": "^1.2.1", "lucide-react": "^0.545.0", "next": "15.5.7", "next-themes": "^0.4.6", "react": "^19.2.0", "react-dom": "^19.2.0", "react-i18next": "^16.0.1", - "recharts": "^3.2.1", - "recharts-scale": "^0.4.5", "sonner": "^2.0.7", "tailwind-merge": "^3.3.1" } diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 7ebfda5..0ad80d7 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -16,11 +16,8 @@ tauri-plugin-dialog = "2" tauri-plugin-updater = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" -walkdir = "2" sysinfo = "0.36.1" -lazy_static = "1.5" rayon = "1.10" -filesize = "0.2" [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-opener = "2" diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 1c33e33..837ac19 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1,465 +1,14 @@ // Prevents additional console window on Windows in release, DO NOT REMOVE!! #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] -use serde::{Deserialize, Serialize}; +mod scan; + +use serde::Serialize; use std::fs; -use std::path::{Path, PathBuf}; -use std::sync::{Arc, Mutex}; -use std::collections::HashSet; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::path::Path; use tauri::ipc::Channel; -use rayon::prelude::*; -use sysinfo::Disks; -#[cfg(unix)] -use std::os::unix::fs::MetadataExt; -use filesize::PathExt; use tauri_plugin_updater::UpdaterExt; -// Constants -const BATCH_SIZE: usize = 10000; -const MAX_DEPTH: usize = 100; // Increased depth limit -const PATH_UPDATE_INTERVAL: usize = 10; // Update path display every N files - -// Global scan state for cancellation -use std::sync::OnceLock; -static CURRENT_SCAN_STATE: OnceLock>>> = OnceLock::new(); - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct FileNode { - name: String, - size: u64, - path: String, - children: Option>, - #[serde(rename = "isDirectory")] - is_directory: bool, -} - -// Compact version - remove redundant path info -#[derive(Debug, Clone, Serialize)] -struct CompactFileNode { - #[serde(rename = "n")] - name: String, - #[serde(rename = "s")] - size: u64, - #[serde(rename = "c", skip_serializing_if = "Option::is_none")] - children: Option>, - #[serde(rename = "d")] - is_directory: bool, -} - -#[derive(Clone, Serialize)] -struct PartialScanResult { - nodes: Vec, - compact_nodes: Vec, // Batch of compact nodes - total_scanned: u64, - total_size: u64, - is_complete: bool, - root_node: Option, - compact_root: Option, - disk_info: Option, - current_path: Option, -} - -#[derive(Clone, Serialize)] -struct DiskInfo { - total_space: u64, - available_space: u64, - used_space: u64, -} - -// Helper struct for shared state -#[derive(Clone)] -struct ScanState { - counter: Arc>, - scanned_size: Arc>, - compact_batch_buffer: Arc>>, // Buffer for compact nodes - #[cfg(unix)] - visited_inodes: Arc>>, - recursion_stack: Arc>>, - cancelled: Arc, - current_path: Arc>, - path_update_counter: Arc>, -} - -impl ScanState { - fn new() -> Self { - Self { - counter: Arc::new(Mutex::new(0)), - scanned_size: Arc::new(Mutex::new(0)), - compact_batch_buffer: Arc::new(Mutex::new(Vec::new())), - #[cfg(unix)] - visited_inodes: Arc::new(Mutex::new(HashSet::new())), - recursion_stack: Arc::new(Mutex::new(HashSet::new())), - cancelled: Arc::new(AtomicBool::new(false)), - current_path: Arc::new(Mutex::new(String::new())), - path_update_counter: Arc::new(Mutex::new(0)), - } - } - - fn is_cancelled(&self) -> bool { - self.cancelled.load(Ordering::Relaxed) - } - - fn cancel(&self) { - self.cancelled.store(true, Ordering::Relaxed) - } - - fn increment_counter(&self) { - if let Ok(mut count) = self.counter.lock() { - *count += 1; - } - } - - fn add_size(&self, size: u64) { - if let Ok(mut total) = self.scanned_size.lock() { - *total += size; - } - } - - fn get_stats(&self) -> (u64, u64) { - let count = self.counter.lock().unwrap(); - let size = self.scanned_size.lock().unwrap(); - (*count, *size) - } - - fn add_compact_to_buffer(&self, node: CompactFileNode) -> bool { - if let Ok(mut buffer) = self.compact_batch_buffer.lock() { - buffer.push(node); - buffer.len() >= BATCH_SIZE - } else { - false - } - } - - fn clear_compact_buffer(&self) -> Vec { - if let Ok(mut buffer) = self.compact_batch_buffer.lock() { - buffer.drain(..).collect() - } else { - Vec::new() - } - } - - #[cfg(unix)] - fn is_visited_inode(&self, inode: u64) -> bool { - if let Ok(visited) = self.visited_inodes.lock() { - visited.contains(&inode) - } else { - false - } - } - - #[cfg(unix)] - fn mark_visited_inode(&self, inode: u64) -> bool { - if let Ok(mut visited) = self.visited_inodes.lock() { - visited.insert(inode) - } else { - false - } - } - - fn push_to_recursion_stack(&self, path: &Path) -> bool { - if let Ok(mut stack) = self.recursion_stack.lock() { - stack.insert(path.to_path_buf()) - } else { - false - } - } - - fn pop_from_recursion_stack(&self, path: &Path) { - if let Ok(mut stack) = self.recursion_stack.lock() { - stack.remove(path); - } - } - - fn is_in_recursion_stack(&self, path: &Path) -> bool { - if let Ok(stack) = self.recursion_stack.lock() { - stack.contains(path) - } else { - false - } - } - - fn set_current_path(&self, path: &str) { - if let Ok(mut current) = self.current_path.lock() { - *current = path.to_string(); - } - } - - fn get_current_path(&self) -> String { - if let Ok(current) = self.current_path.lock() { - current.clone() - } else { - String::new() - } - } - - fn should_send_path_update(&self) -> bool { - if let Ok(mut counter) = self.path_update_counter.lock() { - *counter += 1; - if *counter >= PATH_UPDATE_INTERVAL { - *counter = 0; - return true; - } - } - false - } - -} - -// Get disk space information using sysinfo -fn get_disk_info(path: &Path) -> Option { - let disks = Disks::new_with_refreshed_list(); - - // Convert path to string for comparison - let path_str = path.to_string_lossy(); - - #[cfg(target_os = "macos")] - { - // First, try to find an exact or most specific mount point match - // This handles external drives before falling back to root - let mut best_match: Option<(&sysinfo::Disk, usize)> = None; - - for disk in disks.list() { - let disk_path = disk.mount_point().to_string_lossy(); - - // Check if path starts with this mount point - if path_str.starts_with(&*disk_path) { - let match_length = disk_path.len(); - - // Keep the longest (most specific) match - if let Some((_, current_length)) = best_match { - if match_length > current_length { - best_match = Some((disk, match_length)); - } - } else { - best_match = Some((disk, match_length)); - } - } - } - - // If we found a best match - if let Some((matched_disk, _)) = best_match { - let disk_path = matched_disk.mount_point().to_string_lossy(); - - // For root scan ("/"), sum all system-related volumes - if path_str == "/" && disk_path == "/" { - let mut total_space = 0u64; - let mut total_available = 0u64; - let mut total_used = 0u64; - let mut found_any = false; - - for disk in disks.list() { - let dp = disk.mount_point().to_string_lossy(); - - if dp == "/" || - dp.starts_with("/System/Volumes/Data") || - dp.starts_with("/System/Volumes/Preboot") || - dp.starts_with("/System/Volumes/VM") || - dp.starts_with("/System/Volumes/Update") { - - let used = disk.total_space() - disk.available_space(); - println!("📊 macOS partition {}: used={} GB", - dp, - used / 1024 / 1024 / 1024 - ); - - // Sum the used space from each partition - total_used += used; - - // Total and available are shared across all APFS volumes - total_space = disk.total_space(); - total_available = disk.available_space(); - found_any = true; - } - } - - if found_any { - println!("✅ Total macOS disk usage: {} GB (total: {} GB, available: {} GB)", - total_used / 1024 / 1024 / 1024, - total_space / 1024 / 1024 / 1024, - total_available / 1024 / 1024 / 1024 - ); - - return Some(DiskInfo { - total_space, - available_space: total_available, - used_space: total_used, - }); - } - } else { - // For non-root paths (including external drives), use the specific disk - let total = matched_disk.total_space(); - let available = matched_disk.available_space(); - let used = total - available; - - println!("📊 Disk info for '{}' (mount: {}): total={} GB, available={} GB, used={} GB", - path_str, - disk_path, - total / 1024 / 1024 / 1024, - available / 1024 / 1024 / 1024, - used / 1024 / 1024 / 1024 - ); - - return Some(DiskInfo { - total_space: total, - available_space: available, - used_space: used, - }); - } - } - } - - #[cfg(not(target_os = "macos"))] - { - // Windows/Linux: Simple disk usage - for disk in disks.list() { - let disk_path = disk.mount_point().to_string_lossy(); - - if path_str.starts_with(&*disk_path) { - let total = disk.total_space(); - let available = disk.available_space(); - let used = total - available; - - println!("💾 Disk Info for {}: total={} GB, available={} GB, used={} GB", - disk_path, - total / 1024 / 1024 / 1024, - available / 1024 / 1024 / 1024, - used / 1024 / 1024 / 1024 - ); - - return Some(DiskInfo { - total_space: total, - available_space: available, - used_space: used, - }); - } - } - } - - None -} - -// Check if path is a root directory -fn is_root_directory(path: &str) -> bool { - #[cfg(unix)] - { - // Check for actual root directory - if path == "/" || path == "\\" { - return true; - } - - // Check for macOS volume mount points (e.g., /Volumes/YuYu1015) - if path.starts_with("/Volumes/") { - let parts: Vec<&str> = path.split('/').collect(); - // Should be exactly ["", "Volumes", "VolumeName"] - if parts.len() == 3 && parts[0] == "" && parts[1] == "Volumes" && !parts[2].is_empty() { - return true; - } - } - - // Check for Linux mount points (e.g., /mnt/disk, /media/user/disk) - if path.starts_with("/mnt/") || path.starts_with("/media/") { - let parts: Vec<&str> = path.split('/').collect(); - // Should be exactly ["", "mnt", "diskname"] or ["", "media", "user", "diskname"] - if (parts.len() == 3 && parts[0] == "" && parts[1] == "mnt" && !parts[2].is_empty()) || - (parts.len() == 4 && parts[0] == "" && parts[1] == "media" && !parts[2].is_empty() && !parts[3].is_empty()) { - return true; - } - } - - false - } - - #[cfg(windows)] - { - path.len() == 3 && path.ends_with(":\\") // e.g., "C:\" - } - - #[cfg(not(any(unix, windows)))] - { - false - } -} - -#[tauri::command] -async fn scan_directory_streaming(path: String, on_batch: Channel) -> Result<(), String> { - let root_path = Path::new(&path); - if !root_path.exists() { - return Err("路徑不存在".to_string()); - } - - // Spawn background scanning task - std::thread::spawn(move || { - let state = ScanState::new(); - - // Register the current scan state for cancellation - let global_state = CURRENT_SCAN_STATE.get_or_init(|| Arc::new(Mutex::new(None))); - if let Ok(mut current) = global_state.lock() { - *current = Some(state.clone()); - } - - let root_path = Path::new(&path); - - // Get disk info for root directory scans - let is_root = is_root_directory(&path); - - let disk_info = if is_root { - get_disk_info(root_path) - } else { - None - }; - - // Send initial message with disk_info for progress calculation - if disk_info.is_some() { - let initial_payload = PartialScanResult { - nodes: Vec::new(), - compact_nodes: Vec::new(), - total_scanned: 0, - total_size: 0, - is_complete: false, - root_node: None, - compact_root: None, - disk_info: disk_info.clone(), - current_path: Some(path.clone()), - }; - let _ = on_batch.send(initial_payload); - } - - match scan_directory_recursive(root_path, &on_batch, &state, root_path) { - Ok(root_node) => { - let limited_root = build_limited_depth_node(&root_node, MAX_DEPTH); - send_final_batch(&on_batch, &state, limited_root, disk_info); - } - Err(e) => { - eprintln!("Scan failed: {}", e); - } - } - - // Clear the current scan state when done - if let Some(global_state) = CURRENT_SCAN_STATE.get() { - if let Ok(mut current) = global_state.lock() { - *current = None; - } - } - }); - - Ok(()) -} - -#[tauri::command] -fn cancel_scan() -> Result<(), String> { - if let Some(global_state) = CURRENT_SCAN_STATE.get() { - if let Ok(current) = global_state.lock() { - if let Some(state) = &*current { - state.cancel(); - return Ok(()); - } - } - } - Err("No active scan to cancel".to_string()) -} - -// Deletion progress message #[derive(Clone, Serialize)] struct DeletionProgress { current: usize, @@ -472,35 +21,24 @@ struct DeletionProgress { } #[tauri::command] -async fn delete_files_batch(paths: Vec, on_progress: Channel) -> Result<(), String> { +async fn delete_files_batch( + paths: Vec, + on_progress: Channel, +) -> Result<(), String> { let total = paths.len(); - // Spawn background deletion task std::thread::spawn(move || { let mut deleted_count = 0usize; let mut deleted_size = 0u64; let mut failed_paths = Vec::new(); for (index, path) in paths.iter().enumerate() { - // Normalize path separators (replace backslash with forward slash) let normalized_path = path.replace("\\", "/"); let path_obj = Path::new(&normalized_path); let current_path = normalized_path.clone(); - // Calculate size before deletion - let size_before = if path_obj.exists() { - if path_obj.is_file() { - path_obj.size_on_disk().unwrap_or(0) - } else if path_obj.is_dir() { - calculate_dir_size(path_obj) - } else { - 0 - } - } else { - 0 - }; + let size_before = scan::path_disk_usage(path_obj); - // Send progress update let progress = DeletionProgress { current: index + 1, total, @@ -512,13 +50,15 @@ async fn delete_files_batch(paths: Vec, on_progress: Channel, on_progress: Channel, on_progress: Channel u64 { - let mut total_size = 0u64; - - if let Ok(entries) = fs::read_dir(path) { - for entry in entries.flatten() { - let entry_path = entry.path(); - if entry_path.is_file() { - total_size += entry_path.size_on_disk().unwrap_or(0); - } else if entry_path.is_dir() { - total_size += calculate_dir_size(&entry_path); - } - } - } - - total_size -} - -// Convert FileNode to compact format (removes path, uses short keys) -fn to_compact_node(node: &FileNode) -> CompactFileNode { - CompactFileNode { - name: node.name.clone(), - size: node.size, - children: node.children.as_ref().map(|children| { - children.iter().map(|child| to_compact_node(child)).collect() - }), - is_directory: node.is_directory, - } -} - -fn send_final_batch(channel: &Channel, state: &ScanState, root_node: FileNode, disk_info: Option) { - let (total_items, total_size) = state.get_stats(); - let mut remaining_compact_nodes = state.clear_compact_buffer(); - - // Add root-level files as compact nodes (directories were already added during scan) - if let Some(ref children) = root_node.children { - let root_files: Vec = children - .iter() - .filter(|child| !child.is_directory) - .map(|child| to_compact_node(child)) - .collect(); - - if !root_files.is_empty() { - remaining_compact_nodes.extend(root_files); - } - } - - // Send remaining compact nodes if any - if !remaining_compact_nodes.is_empty() { - let batch_payload = PartialScanResult { - nodes: Vec::new(), - compact_nodes: remaining_compact_nodes, - total_scanned: total_items, - total_size, - is_complete: false, - root_node: None, - compact_root: None, - disk_info: None, - current_path: None, - }; - let _ = channel.send(batch_payload); - } - - // Send final completion message with root metadata only - let root_metadata = FileNode { - name: root_node.name.clone(), - size: root_node.size, - path: root_node.path.clone(), - children: Some(Vec::new()), - is_directory: true, - }; - - let payload = PartialScanResult { - nodes: Vec::new(), - compact_nodes: Vec::new(), - total_scanned: total_items, - total_size, - is_complete: true, - root_node: Some(root_metadata), - compact_root: None, - disk_info, - current_path: None, - }; - - let _ = channel.send(payload); -} - -fn send_compact_batch(channel: &Channel, state: &ScanState) { - let (total_items, total_size) = state.get_stats(); - let compact_nodes = state.clear_compact_buffer(); - let current_path = state.get_current_path(); - - let payload = PartialScanResult { - nodes: Vec::new(), - compact_nodes, - total_scanned: total_items, - total_size, - is_complete: false, - root_node: None, - compact_root: None, - disk_info: None, - current_path: Some(current_path), - }; - - let _ = channel.send(payload); -} - -fn send_path_update(channel: &Channel, state: &ScanState) { - let (total_items, total_size) = state.get_stats(); - let current_path = state.get_current_path(); - - let payload = PartialScanResult { - nodes: Vec::new(), - compact_nodes: Vec::new(), - total_scanned: total_items, - total_size, - is_complete: false, - root_node: None, - compact_root: None, - disk_info: None, - current_path: Some(current_path), - }; - - let _ = channel.send(payload); -} - -fn build_limited_depth_node(node: &FileNode, max_depth: usize) -> FileNode { - build_limited_depth_node_recursive(node, 0, max_depth) -} - -fn build_limited_depth_node_recursive(node: &FileNode, current_depth: usize, max_depth: usize) -> FileNode { - if current_depth >= max_depth { - return FileNode { - name: node.name.clone(), - size: node.size, - path: node.path.clone(), - children: if node.is_directory { Some(Vec::new()) } else { None }, - is_directory: node.is_directory, - }; - } - - let limited_children = node.children.as_ref().map(|children| { - children - .iter() - .map(|child| build_limited_depth_node_recursive(child, current_depth + 1, max_depth)) - .collect() - }); - - FileNode { - name: node.name.clone(), - size: node.size, - path: node.path.clone(), - children: limited_children, - is_directory: node.is_directory, - } -} - -fn scan_directory_recursive( - path: &Path, - channel: &Channel, - state: &ScanState, - root_path: &Path, -) -> Result { - // Check if scan has been cancelled - if state.is_cancelled() { - return Err("Scan cancelled".to_string()); - } - - let path_str = path.to_string_lossy().to_string(); - - // Update current scanning path - state.set_current_path(&path_str); - - // Send path update if interval reached - if state.should_send_path_update() { - send_path_update(channel, state); - } - - // Prevent scanning above the root path to avoid duplicate counting - // Use canonicalized paths for accurate comparison - if let Ok(canonical_root) = fs::canonicalize(root_path) { - if let Ok(canonical_path) = fs::canonicalize(path) { - if !canonical_path.starts_with(&canonical_root) { - return Ok(FileNode { - name: path.file_name().and_then(|n| n.to_str()).unwrap_or("").to_string(), - size: 0, - path: path_str, - children: None, - is_directory: false, - }); - } - } - } - - // Check for circular path using canonicalized path - if let Ok(canonical_path) = fs::canonicalize(path) { - if state.is_in_recursion_stack(&canonical_path) { - return Ok(FileNode { - name: path.file_name().and_then(|n| n.to_str()).unwrap_or("").to_string(), - size: 0, - path: path_str, - children: None, - is_directory: false, - }); - } - } - - let metadata = fs::metadata(path).map_err(|e| e.to_string())?; - - // Check if we've already visited this inode (prevents symlink loops and hard link duplicates) - // Only use inode tracking on Unix systems - #[cfg(unix)] - { - let inode = metadata.ino(); - if state.is_visited_inode(inode) { - return Ok(FileNode { - name: path.file_name().and_then(|n| n.to_str()).unwrap_or("").to_string(), - size: 0, - path: path_str, - children: None, - is_directory: false, - }); - } - state.mark_visited_inode(inode); - } - - let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("").to_string(); - state.increment_counter(); - - // Handle symlinks by following them - if metadata.file_type().is_symlink() { - // Try to follow the symlink - if let Ok(target_path) = fs::read_link(path) { - if let Ok(target_metadata) = fs::metadata(&target_path) { - if target_metadata.is_file() { - // Use filesize to get actual disk usage for symlinked files - let file_size = target_path.size_on_disk().unwrap_or(0); - state.add_size(file_size); - - return Ok(FileNode { - name, - size: file_size, - path: path_str, - children: None, - is_directory: false, - }); - } else if target_metadata.is_dir() { - // For directory symlinks, check if target is above root path or in recursion stack - if let Ok(canonical_root) = fs::canonicalize(root_path) { - if let Ok(canonical_target) = fs::canonicalize(&target_path) { - if !canonical_target.starts_with(&canonical_root) { - return Ok(FileNode { - name, - size: 0, - path: path_str, - children: None, - is_directory: false, - }); - } - } - } - - if let Ok(canonical_target) = fs::canonicalize(&target_path) { - if state.is_in_recursion_stack(&canonical_target) { - return Ok(FileNode { - name, - size: 0, - path: path_str, - children: None, - is_directory: false, - }); - } - } - - // Safe to scan the target directory - return scan_directory_recursive(&target_path, channel, state, root_path); - } - } - } - - // If we can't follow the symlink, return size 0 - return Ok(FileNode { - name, - size: 0, - path: path_str, - children: None, - is_directory: false, - }); - } - - if metadata.is_file() { - // Use filesize to get actual disk usage (handles sparse files correctly) - let file_size = path.size_on_disk().unwrap_or(0); - - state.add_size(file_size); - - // Don't add files to batch buffer - only send directories to reduce IPC load - let node = FileNode { - name, - size: file_size, - path: path_str, - children: None, - is_directory: false, - }; - - return Ok(node); - } - - // Scan directory with parallel processing - if let Ok(entries) = fs::read_dir(path) { - // Add current directory to recursion stack - if let Ok(canonical_path) = fs::canonicalize(path) { - state.push_to_recursion_stack(&canonical_path); - } - - let entries_vec: Vec<_> = entries.flatten().collect(); - - // No filtering - scan everything - let filtered_entries = entries_vec; - - // For root directory's direct children, send immediate progress updates - let is_root_level = path == root_path; - - let children: Vec = filtered_entries - .par_iter() - .filter_map(|entry| { - // Send progress update before scanning each root-level directory - if is_root_level { - send_path_update(channel, state); - } - scan_directory_recursive(&entry.path(), channel, state, root_path).ok() - }) - .collect(); - - // Remove current directory from recursion stack - if let Ok(canonical_path) = fs::canonicalize(path) { - state.pop_from_recursion_stack(&canonical_path); - } - - let dir_total_size: u64 = children.iter().map(|c| c.size).sum(); - - // Create directory node for return (with full children tree) - let dir_node_with_children = FileNode { - name: name.clone(), - size: dir_total_size, - path: path_str.clone(), - children: Some(children), - is_directory: true, - }; - - // Only send compact nodes for direct children of root (depth 1) - // This prevents sending duplicate nested directories - if let Some(parent) = path.parent() { - if parent == root_path { - // This is a direct child of root - send it with full subtree - let compact_dir = to_compact_node(&dir_node_with_children); - if state.add_compact_to_buffer(compact_dir) { - send_compact_batch(channel, state); - } - } - } - - Ok(dir_node_with_children) - } else { - Ok(FileNode { - name, - size: 0, - path: path_str, - children: Some(Vec::new()), - is_directory: true, - }) - } -} - async fn update(app: tauri::AppHandle) -> tauri_plugin_updater::Result<()> { if let Some(update) = app.updater()?.check().await? { let mut downloaded = 0; - // alternatively we could also call update.download() and update.install() separately update .download_and_install( |chunk_length, content_length| { @@ -966,7 +130,11 @@ fn main() { }); Ok(()) }) - .invoke_handler(tauri::generate_handler![scan_directory_streaming, cancel_scan, delete_files_batch]) + .invoke_handler(tauri::generate_handler![ + scan::scan_directory_streaming, + scan::cancel_scan, + delete_files_batch + ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/src-tauri/src/scan/disk.rs b/src-tauri/src/scan/disk.rs new file mode 100644 index 0000000..87fe928 --- /dev/null +++ b/src-tauri/src/scan/disk.rs @@ -0,0 +1,189 @@ +use std::path::Path; + +use super::DiskInfo; +use sysinfo::Disks; + +pub fn info_if_root(path: &str) -> Option { + if is_root_directory(path) { + get_disk_info(Path::new(path)) + } else { + None + } +} + +fn get_disk_info(path: &Path) -> Option { + let disks = Disks::new_with_refreshed_list(); + + let path_str = path.to_string_lossy(); + + #[cfg(target_os = "macos")] + { + let mut best_match: Option<(&sysinfo::Disk, usize)> = None; + + for disk in disks.list() { + let disk_path = disk.mount_point().to_string_lossy(); + + if path_str.starts_with(&*disk_path) { + let match_length = disk_path.len(); + + if let Some((_, current_length)) = best_match { + if match_length > current_length { + best_match = Some((disk, match_length)); + } + } else { + best_match = Some((disk, match_length)); + } + } + } + + if let Some((matched_disk, _)) = best_match { + let disk_path = matched_disk.mount_point().to_string_lossy(); + + // For root scan ("/"), sum all system-related volumes + if path_str == "/" && disk_path == "/" { + let mut total_space = 0u64; + let mut total_available = 0u64; + let mut total_used = 0u64; + let mut found_any = false; + + for disk in disks.list() { + let dp = disk.mount_point().to_string_lossy(); + + if dp == "/" + || dp.starts_with("/System/Volumes/Data") + || dp.starts_with("/System/Volumes/Preboot") + || dp.starts_with("/System/Volumes/VM") + || dp.starts_with("/System/Volumes/Update") + { + let used = disk.total_space() - disk.available_space(); + println!( + "📊 macOS partition {}: used={} GB", + dp, + used / 1024 / 1024 / 1024 + ); + + total_used += used; + + // Total and available are shared across all APFS volumes + total_space = disk.total_space(); + total_available = disk.available_space(); + found_any = true; + } + } + + if found_any { + println!( + "✅ Total macOS disk usage: {} GB (total: {} GB, available: {} GB)", + total_used / 1024 / 1024 / 1024, + total_space / 1024 / 1024 / 1024, + total_available / 1024 / 1024 / 1024 + ); + + return Some(DiskInfo { + total_space, + available_space: total_available, + used_space: total_used, + }); + } + } else { + let total = matched_disk.total_space(); + let available = matched_disk.available_space(); + let used = total - available; + + println!( + "📊 Disk info for '{}' (mount: {}): total={} GB, available={} GB, used={} GB", + path_str, + disk_path, + total / 1024 / 1024 / 1024, + available / 1024 / 1024 / 1024, + used / 1024 / 1024 / 1024 + ); + + return Some(DiskInfo { + total_space: total, + available_space: available, + used_space: used, + }); + } + } + } + + #[cfg(not(target_os = "macos"))] + { + for disk in disks.list() { + let disk_path = disk.mount_point().to_string_lossy(); + + if path_str.starts_with(&*disk_path) { + let total = disk.total_space(); + let available = disk.available_space(); + let used = total - available; + + println!( + "💾 Disk Info for {}: total={} GB, available={} GB, used={} GB", + disk_path, + total / 1024 / 1024 / 1024, + available / 1024 / 1024 / 1024, + used / 1024 / 1024 / 1024 + ); + + return Some(DiskInfo { + total_space: total, + available_space: available, + used_space: used, + }); + } + } + } + + None +} + +fn is_root_directory(path: &str) -> bool { + #[cfg(unix)] + { + if path == "/" || path == "\\" { + return true; + } + + // macOS volume mount points (e.g., /Volumes/YuYu1015) + if path.starts_with("/Volumes/") { + let parts: Vec<&str> = path.split('/').collect(); + if parts.len() == 3 + && parts[0].is_empty() + && parts[1] == "Volumes" + && !parts[2].is_empty() + { + return true; + } + } + + // Linux mount points (e.g., /mnt/disk, /media/user/disk) + if path.starts_with("/mnt/") || path.starts_with("/media/") { + let parts: Vec<&str> = path.split('/').collect(); + if (parts.len() == 3 + && parts[0].is_empty() + && parts[1] == "mnt" + && !parts[2].is_empty()) + || (parts.len() == 4 + && parts[0].is_empty() + && parts[1] == "media" + && !parts[2].is_empty() + && !parts[3].is_empty()) + { + return true; + } + } + + false + } + + #[cfg(windows)] + { + path.len() == 3 && path.ends_with(":\\") + } + + #[cfg(not(any(unix, windows)))] + { + false + } +} diff --git a/src-tauri/src/scan/mod.rs b/src-tauri/src/scan/mod.rs new file mode 100644 index 0000000..fd0b451 --- /dev/null +++ b/src-tauri/src/scan/mod.rs @@ -0,0 +1,1106 @@ +//! Directory walk shared by the scan command and deletion size reporting. +//! +//! The scan root is canonicalized once. Each directory is listed with the +//! platform bulk API (getattrlistbulk, getdents64+statx, or FindFirstFileExW). +//! macOS and Linux then open child directories with `openat` on that fd. +//! Symlinks are the only names that get a second lookup, and only to decide +//! whether the target is inside the root. +//! +//! The resulting tree is an arena: one node `Vec`, one name string, and a side +//! table of child indexes. The webview receives that arena as parallel arrays +//! (`n`, `s`, `m`, `k`, `r`) instead of one JSON object per file. + +mod disk; +mod platform; +mod tree; + +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::thread; + +use platform::{EntryKind, EntryStat, FileId, FollowKind, LinkResolve, RawEntry}; +use rayon::prelude::*; +use serde::{Deserialize, Serialize}; +use tauri::ipc::Channel; +use tree::{PackedForest, Tree}; + +const BATCH_SIZE: usize = 10000; +const PATH_UPDATE_INTERVAL: usize = 10; +/// Parallel subdirectory walks pay off once a directory is wide enough to hide +/// rayon overhead. Metadata is already bulk-fetched, so only directory walks +/// are spawned — not one task per file. +const PARALLEL_MIN_ENTRIES: usize = 64; + +static CURRENT_SCAN_STATE: OnceLock>> = OnceLock::new(); + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FileNode { + pub name: String, + pub size: u64, + pub path: String, + pub children: Option>, + #[serde(rename = "isDirectory")] + pub is_directory: bool, +} + +#[derive(Clone, Serialize)] +pub struct PartialScanResult { + nodes: Vec, + /// Packed subtrees. Each element is a flat forest, not one object per node. + compact_nodes: Vec, + total_scanned: u64, + total_size: u64, + is_complete: bool, + root_node: Option, + compact_root: Option, + disk_info: Option, + current_path: Option, +} + +#[derive(Clone, Serialize)] +pub struct DiskInfo { + total_space: u64, + available_space: u64, + used_space: u64, +} + +struct VisitSet { + ids: Mutex>, + paths: Mutex>, +} + +impl VisitSet { + fn new() -> Self { + Self { + ids: Mutex::new(HashSet::new()), + paths: Mutex::new(HashSet::new()), + } + } + + fn claim_id(&self, id: FileId) -> bool { + self.ids + .lock() + .unwrap_or_else(|err| err.into_inner()) + .insert(id) + } + + fn claim_path(&self, path: &Path) -> bool { + self.paths + .lock() + .unwrap_or_else(|err| err.into_inner()) + .insert(path.to_path_buf()) + } +} + +#[derive(Clone)] +struct ScanState { + counter: Arc, + scanned_size: Arc, + visits: Arc, + packed: Arc>, + cancelled: Arc, + current_path: Arc>, + path_tick: Arc, +} + +impl ScanState { + fn new() -> Self { + Self { + counter: Arc::new(AtomicU64::new(0)), + scanned_size: Arc::new(AtomicU64::new(0)), + visits: Arc::new(VisitSet::new()), + packed: Arc::new(Mutex::new(PackedForest::new())), + cancelled: Arc::new(AtomicBool::new(false)), + current_path: Arc::new(Mutex::new(String::new())), + path_tick: Arc::new(AtomicUsize::new(0)), + } + } + + fn is_cancelled(&self) -> bool { + self.cancelled.load(Ordering::Relaxed) + } + + fn cancel(&self) { + self.cancelled.store(true, Ordering::Relaxed) + } + + fn increment(&self) { + self.counter.fetch_add(1, Ordering::Relaxed); + } + + fn add_size(&self, size: u64) { + self.scanned_size.fetch_add(size, Ordering::Relaxed); + } + + fn stats(&self) -> (u64, u64) { + ( + self.counter.load(Ordering::Relaxed), + self.scanned_size.load(Ordering::Relaxed), + ) + } + + fn set_current_path(&self, path: &str) { + *self + .current_path + .lock() + .unwrap_or_else(|err| err.into_inner()) = path.to_string(); + } + + fn current_path(&self) -> String { + self.current_path + .lock() + .unwrap_or_else(|err| err.into_inner()) + .clone() + } + + fn tick_path(&self) -> bool { + let n = self.path_tick.fetch_add(1, Ordering::Relaxed); + (n + 1) % PATH_UPDATE_INTERVAL == 0 + } + + fn push_subtree(&self, tree: &Tree, id: u32) -> Option { + let mut pack = self.packed.lock().unwrap_or_else(|err| err.into_inner()); + tree.append_packed(id, &mut pack); + if pack.root_count() >= BATCH_SIZE { + Some(std::mem::take(&mut *pack)) + } else { + None + } + } + + fn drain_packed(&self) -> PackedForest { + std::mem::take(&mut *self.packed.lock().unwrap_or_else(|err| err.into_inner())) + } +} + +struct Walk<'a> { + channel: Option<&'a Channel>, + state: &'a ScanState, + canonical_root: Option<&'a Path>, +} + +#[derive(Clone, Copy)] +enum Disposition { + Skip, + Zero, + File(u64), + Directory, +} + +#[tauri::command] +pub async fn scan_directory_streaming( + path: String, + on_batch: Channel, +) -> Result<(), String> { + if !Path::new(&path).exists() { + return Err("路徑不存在".to_string()); + } + let disk_info = disk::info_if_root(&path); + spawn_scan(path, on_batch, disk_info); + Ok(()) +} + +#[tauri::command] +pub fn cancel_scan() -> Result<(), String> { + let Some(slot) = CURRENT_SCAN_STATE.get() else { + return Err("No active scan to cancel".to_string()); + }; + let guard = slot.lock().unwrap_or_else(|err| err.into_inner()); + if let Some(state) = guard.as_ref() { + state.cancel(); + Ok(()) + } else { + Err("No active scan to cancel".to_string()) + } +} + +pub fn path_disk_usage(path: &Path) -> u64 { + let state = ScanState::new(); + let canonical_root = platform::canonicalize(path); + let ctx = Walk { + channel: None, + state: &state, + canonical_root: canonical_root.as_deref(), + }; + match scan_root(&ctx, path, false) { + Ok(tree) => tree.size_of(tree.root), + Err(_) => 0, + } +} + +fn spawn_scan(path: String, channel: Channel, disk_info: Option) { + thread::spawn(move || { + let state = ScanState::new(); + register_scan(&state); + let _clear = ClearScan { + cancelled: Arc::clone(&state.cancelled), + }; + let root = PathBuf::from(&path); + if let Err(err) = walk(&root, Some(&channel), &state, disk_info) { + eprintln!("Scan failed: {err}"); + } + }); +} + +fn register_scan(state: &ScanState) { + let slot = CURRENT_SCAN_STATE.get_or_init(|| Mutex::new(None)); + *slot.lock().unwrap_or_else(|err| err.into_inner()) = Some(state.clone()); +} + +struct ClearScan { + cancelled: Arc, +} + +impl Drop for ClearScan { + fn drop(&mut self) { + let Some(slot) = CURRENT_SCAN_STATE.get() else { + return; + }; + let mut guard = slot.lock().unwrap_or_else(|err| err.into_inner()); + let still_ours = guard + .as_ref() + .is_some_and(|state| Arc::ptr_eq(&state.cancelled, &self.cancelled)); + if still_ours { + *guard = None; + } + } +} + +fn walk( + path: &Path, + channel: Option<&Channel>, + state: &ScanState, + disk_info: Option, +) -> Result { + if let Some(channel) = channel { + if let Some(info) = disk_info.clone() { + let _ = channel.send(event( + Vec::new(), + state, + false, + None, + Some(info), + Some(path.to_string_lossy().into_owned()), + )); + } + } + + let canonical_root = platform::canonicalize(path); + let ctx = Walk { + channel, + state, + canonical_root: canonical_root.as_deref(), + }; + let tree = scan_root(&ctx, path, channel.is_some())?; + let node = if channel.is_some() { + shell_node(&tree, path) + } else { + materialize(&tree, tree.root, path) + }; + if let Some(channel) = channel { + finish(channel, state, &node, disk_info); + } + Ok(node) +} + +fn scan_root(ctx: &Walk<'_>, path: &Path, pack_children: bool) -> Result { + if ctx.state.is_cancelled() { + return Err("Scan cancelled".to_string()); + } + let stat = platform::stat_path(path).map_err(|err| err.to_string())?; + let name = entry_name(path); + let mut tree = Tree::new(); + let root = match disposition(path, &stat, ctx.canonical_root, &ctx.state.visits) { + Disposition::Skip => return Err("無法掃描此路徑".to_string()), + Disposition::Zero => tree.add_leaf(name.as_bytes(), 0), + Disposition::File(size) => { + ctx.state.increment(); + ctx.state.add_size(size); + tree.add_leaf(name.as_bytes(), size) + } + Disposition::Directory => { + ctx.state.increment(); + let kids = if ctx.state.is_cancelled() { + Vec::new() + } else { + match platform::list_path(path) { + Ok(listed) => build_children(ctx, &mut tree, path, listed, pack_children), + Err(_) => Vec::new(), + } + }; + let size = tree.sum(&kids); + tree.add_dir(name.as_bytes(), size, &kids) + } + }; + tree.root = root; + Ok(tree) +} + +fn build_children( + ctx: &Walk<'_>, + tree: &mut Tree, + dir: &Path, + listed: platform::ListedDir, + pack_root_children: bool, +) -> Vec { + if ctx.state.is_cancelled() || listed.batch.is_empty() { + return Vec::new(); + } + let plans = plan_entries(ctx, dir, &listed.batch, pack_root_children); + let dir_jobs: Vec = plans + .iter() + .enumerate() + .filter(|(_, action)| matches!(action, Disposition::Directory)) + .map(|(index, _)| index) + .collect(); + + let ids = if listed.batch.len() >= PARALLEL_MIN_ENTRIES + && dir_jobs.len() >= 2 + && !ctx.state.is_cancelled() + { + assemble_parallel(ctx, tree, dir, &listed, &plans, &dir_jobs) + } else { + assemble_sequential(ctx, tree, dir, &listed, &plans) + }; + + if pack_root_children { + emit_root_children(ctx, tree, &ids); + } + ids +} + +fn assemble_sequential( + ctx: &Walk<'_>, + tree: &mut Tree, + dir: &Path, + listed: &platform::ListedDir, + plans: &[Disposition], +) -> Vec { + let mut ids = Vec::with_capacity(plans.len()); + for (index, action) in plans.iter().enumerate() { + if ctx.state.is_cancelled() { + break; + } + let entry = &listed.batch.entries()[index]; + let name = listed.batch.name_bytes(entry); + match *action { + Disposition::Skip => {} + Disposition::Zero => ids.push(tree.add_leaf(name, 0)), + Disposition::File(size) => { + ctx.state.increment(); + ctx.state.add_size(size); + ids.push(tree.add_leaf(name, size)); + } + Disposition::Directory => { + ctx.state.increment(); + let child = platform::joined(dir, name); + let follow = entry.kind == EntryKind::Symlink; + let kids = match platform::list_child(&listed.token, &child, name, follow) { + Ok(child_listed) => build_children(ctx, tree, &child, child_listed, false), + Err(_) => Vec::new(), + }; + let size = tree.sum(&kids); + ids.push(tree.add_dir(name, size, &kids)); + } + } + } + ids +} + +fn assemble_parallel( + ctx: &Walk<'_>, + tree: &mut Tree, + dir: &Path, + listed: &platform::ListedDir, + plans: &[Disposition], + dir_jobs: &[usize], +) -> Vec { + let built: HashMap = dir_jobs + .par_iter() + .filter_map(|&index| { + if ctx.state.is_cancelled() { + return None; + } + let entry = &listed.batch.entries()[index]; + let name = listed.batch.name_bytes(entry); + let child = platform::joined(dir, name); + let follow = entry.kind == EntryKind::Symlink; + let child_listed = platform::list_child(&listed.token, &child, name, follow).ok(); + Some((index, build_directory_tree(ctx, &child, name, child_listed))) + }) + .collect(); + + let mut built = built; + let mut ids = Vec::with_capacity(plans.len()); + for (index, action) in plans.iter().enumerate() { + let entry = &listed.batch.entries()[index]; + let name = listed.batch.name_bytes(entry); + match *action { + Disposition::Directory => { + if let Some(sub) = built.remove(&index) { + ids.push(tree.absorb(sub)); + } + } + Disposition::Skip => {} + Disposition::Zero => { + if !ctx.state.is_cancelled() { + ids.push(tree.add_leaf(name, 0)); + } + } + Disposition::File(size) => { + if ctx.state.is_cancelled() { + continue; + } + ctx.state.increment(); + ctx.state.add_size(size); + ids.push(tree.add_leaf(name, size)); + } + } + } + ids +} + +fn build_directory_tree( + ctx: &Walk<'_>, + path: &Path, + name: &[u8], + listed: Option, +) -> Tree { + ctx.state.increment(); + let mut tree = Tree::new(); + let kids = match listed { + Some(listed) => build_children(ctx, &mut tree, path, listed, false), + None => Vec::new(), + }; + let size = tree.sum(&kids); + let id = tree.add_dir(name, size, &kids); + tree.root = id; + tree +} + +fn plan_entries( + ctx: &Walk<'_>, + dir: &Path, + batch: &platform::DirBatch, + force_path: bool, +) -> Vec { + let mut plans = Vec::with_capacity(batch.len()); + for entry in batch.entries() { + if ctx.state.is_cancelled() { + plans.push(Disposition::Skip); + continue; + } + let interval = ctx.state.tick_path(); + let send_now = ctx.channel.is_some() && (force_path || interval); + let needs_path = send_now + || matches!(entry.kind, EntryKind::Symlink | EntryKind::Other) + || (entry.kind == EntryKind::Directory && entry.id.is_none()); + if needs_path { + let child = platform::joined(dir, batch.name_bytes(entry)); + if send_now { + let text = child.to_string_lossy().into_owned(); + ctx.state.set_current_path(&text); + send_path_update(ctx); + } + let stat = stat_for(entry, Some(&child)); + plans.push(disposition( + &child, + &stat, + ctx.canonical_root, + &ctx.state.visits, + )); + } else { + let stat = stat_for(entry, None); + plans.push(disposition( + dir, + &stat, + ctx.canonical_root, + &ctx.state.visits, + )); + } + } + plans +} + +fn stat_for(entry: &RawEntry, path: Option<&Path>) -> EntryStat { + let mut stat = EntryStat { + kind: entry.kind, + size_on_disk: entry.size_on_disk, + id: entry.id, + link_is_dir: entry.link_is_dir, + }; + if stat.kind == EntryKind::Directory && stat.id.is_none() { + if let Some(path) = path { + stat.id = platform::directory_id(path); + } + } + stat +} + +fn emit_root_children(ctx: &Walk<'_>, tree: &Tree, ids: &[u32]) { + let mut dirs = Vec::new(); + let mut files = Vec::new(); + for id in ids { + if tree.is_dir(*id) { + dirs.push(*id); + } else { + files.push(*id); + } + } + for id in dirs.into_iter().chain(files) { + if let Some(forest) = ctx.state.push_subtree(tree, id) { + send_forest(ctx, forest); + } + } +} + +fn disposition( + path: &Path, + stat: &EntryStat, + root: Option<&Path>, + visits: &VisitSet, +) -> Disposition { + match stat.kind { + EntryKind::File => { + if let Some(id) = stat.id { + if !visits.claim_id(id) { + return Disposition::Zero; + } + } + Disposition::File(stat.size_on_disk) + } + EntryKind::Directory | EntryKind::Other => { + if !claim_entry(visits, stat, path) { + return Disposition::Zero; + } + // Sockets and devices fail `read_dir` and stay empty directories, + // which is what the previous walker produced. + Disposition::Directory + } + EntryKind::Symlink => match platform::resolve_link(path, root, stat.link_is_dir) { + LinkResolve::Broken => Disposition::Skip, + LinkResolve::Outside => Disposition::Zero, + LinkResolve::Inside(follow) => match follow.kind { + FollowKind::Dir => { + if !claim_target(visits, follow.id, follow.canonical.as_deref()) { + return Disposition::Zero; + } + Disposition::Directory + } + FollowKind::File => { + // Own lstat identity only. The target file keeps its bytes. + if let Some(id) = stat.id { + if !visits.claim_id(id) { + return Disposition::Zero; + } + } + Disposition::File(stat.size_on_disk) + } + FollowKind::Other => { + if let Some(id) = stat.id { + if !visits.claim_id(id) { + return Disposition::Zero; + } + } + Disposition::Directory + } + }, + }, + } +} + +fn claim_entry(visits: &VisitSet, stat: &EntryStat, path: &Path) -> bool { + if let Some(id) = stat.id { + visits.claim_id(id) + } else { + let key = platform::canonicalize(path).unwrap_or_else(|| path.to_path_buf()); + visits.claim_path(&key) + } +} + +fn claim_target(visits: &VisitSet, id: Option, canonical: Option<&Path>) -> bool { + if let Some(id) = id { + visits.claim_id(id) + } else if let Some(path) = canonical { + visits.claim_path(path) + } else { + true + } +} + +fn finish( + channel: &Channel, + state: &ScanState, + root: &FileNode, + disk_info: Option, +) { + let remaining = state.drain_packed(); + if !remaining.is_empty() { + let _ = channel.send(event( + vec![remaining], + state, + false, + None, + None, + Some(state.current_path()), + )); + } + + let root_metadata = FileNode { + name: root.name.clone(), + size: root.size, + path: root.path.clone(), + children: if root.is_directory { + Some(Vec::new()) + } else { + None + }, + is_directory: root.is_directory, + }; + let _ = channel.send(event( + Vec::new(), + state, + true, + Some(root_metadata), + disk_info, + None, + )); +} + +fn send_path_update(ctx: &Walk<'_>) { + let Some(channel) = ctx.channel else { + return; + }; + let _ = channel.send(event( + Vec::new(), + ctx.state, + false, + None, + None, + Some(ctx.state.current_path()), + )); +} + +fn send_forest(ctx: &Walk<'_>, forest: PackedForest) { + let Some(channel) = ctx.channel else { + return; + }; + let _ = channel.send(event( + vec![forest], + ctx.state, + false, + None, + None, + Some(ctx.state.current_path()), + )); +} + +fn event( + compact_nodes: Vec, + state: &ScanState, + is_complete: bool, + root_node: Option, + disk_info: Option, + current_path: Option, +) -> PartialScanResult { + let (total_scanned, total_size) = state.stats(); + PartialScanResult { + nodes: Vec::new(), + compact_nodes, + total_scanned, + total_size, + is_complete, + root_node, + compact_root: None, + disk_info, + current_path, + } +} + +fn shell_node(tree: &Tree, path: &Path) -> FileNode { + let is_directory = tree.is_dir(tree.root); + FileNode { + name: tree.name(tree.root).to_string(), + size: tree.size_of(tree.root), + path: path.to_string_lossy().into_owned(), + children: if is_directory { Some(Vec::new()) } else { None }, + is_directory, + } +} + +fn materialize(tree: &Tree, id: u32, path: &Path) -> FileNode { + let is_directory = tree.is_dir(id); + let children = if is_directory { + Some( + tree.children(id) + .iter() + .map(|child| { + let child_path = path.join(tree.name(*child)); + materialize(tree, *child, &child_path) + }) + .collect(), + ) + } else { + None + }; + FileNode { + name: tree.name(id).to_string(), + size: tree.size_of(id), + path: path.to_string_lossy().into_owned(), + children, + is_directory, + } +} + +fn entry_name(path: &Path) -> String { + path.file_name() + .and_then(|name| name.to_str()) + .unwrap_or("") + .to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use std::sync::Mutex as StdMutex; + use std::time::{SystemTime, UNIX_EPOCH}; + + struct TempDir(PathBuf); + + impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + fn scratch(label: &str) -> TempDir { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let path = + std::env::temp_dir().join(format!("storviz-{label}-{}-{nanos}", std::process::id())); + std::fs::create_dir_all(&path).unwrap(); + TempDir(path) + } + + fn write_file(path: &Path, bytes: usize) { + let mut file = std::fs::File::create(path).unwrap(); + file.write_all(&vec![7u8; bytes]).unwrap(); + } + + fn scan(path: &Path) -> (FileNode, u64, u64) { + let state = ScanState::new(); + let node = walk(path, None, &state, None).unwrap(); + let (count, size) = state.stats(); + (node, count, size) + } + + fn on_disk(path: &Path) -> u64 { + platform::stat_path(path).unwrap().size_on_disk + } + + #[test] + fn directory_size_is_files_only() { + let dir = scratch("files"); + let file = dir.0.join("data.bin"); + write_file(&file, 128 * 1024); + std::fs::create_dir(dir.0.join("empty")).unwrap(); + + let (node, count, size) = scan(&dir.0); + let file_size = on_disk(&file); + assert!(file_size > 0, "fixture should allocate blocks"); + assert_eq!(size, file_size); + assert_eq!(node.size, size); + assert_eq!(path_disk_usage(&dir.0), size); + // root + empty dir + file + assert_eq!(count, 3); + assert!(node.is_directory); + } + + #[cfg(unix)] + #[test] + fn hardlink_counted_once() { + let dir = scratch("hard"); + let original = dir.0.join("original.bin"); + write_file(&original, 128 * 1024); + std::fs::hard_link(&original, dir.0.join("alias.bin")).unwrap(); + + let (node, count, size) = scan(&dir.0); + assert_eq!(size, on_disk(&original)); + assert_eq!(node.size, size); + assert_eq!(path_disk_usage(&dir.0), size); + // root + one of the two names + assert_eq!(count, 2); + let children = node.children.unwrap(); + assert_eq!(children.len(), 2); + let sizes: Vec = children.iter().map(|child| child.size).collect(); + assert!(sizes.contains(&size)); + assert!(sizes.contains(&0)); + } + + #[cfg(unix)] + #[test] + fn symlink_file_keeps_target_size() { + let dir = scratch("sylink"); + let original = dir.0.join("original.bin"); + write_file(&original, 64 * 1024); + std::os::unix::fs::symlink(&original, dir.0.join("link.bin")).unwrap(); + + let (node, count, size) = scan(&dir.0); + let link_size = on_disk(&dir.0.join("link.bin")); + assert_eq!(size, on_disk(&original).saturating_add(link_size)); + assert_eq!(node.size, size); + assert_eq!(count, 3); + assert_eq!(path_disk_usage(&dir.0), size); + } + + #[cfg(unix)] + #[test] + fn symlink_outside_and_directory_cycle_are_not_followed_twice() { + let dir = scratch("escape"); + let outside = scratch("outside"); + let outside_file = outside.0.join("secret.bin"); + write_file(&outside_file, 128 * 1024); + std::os::unix::fs::symlink(&outside_file, dir.0.join("out")).unwrap(); + std::os::unix::fs::symlink(".", dir.0.join("loop")).unwrap(); + + let (node, count, size) = scan(&dir.0); + assert_eq!(size, 0); + assert_eq!(node.size, 0); + // root only; the outside link and the cycle are zero leaves + assert_eq!(count, 1); + assert_eq!(node.children.unwrap().len(), 2); + assert_eq!(path_disk_usage(&dir.0), 0); + } + + #[cfg(unix)] + #[test] + fn directory_symlink_counts_children_once() { + let dir = scratch("dirlink"); + let real = dir.0.join("real"); + std::fs::create_dir(&real).unwrap(); + let file = real.join("data.bin"); + write_file(&file, 32 * 1024); + std::os::unix::fs::symlink(&real, dir.0.join("link")).unwrap(); + + let (node, count, size) = scan(&dir.0); + assert_eq!(size, on_disk(&file)); + assert_eq!(node.size, size); + assert_eq!(count, 3); + assert_eq!(path_disk_usage(&dir.0), size); + } + + #[cfg(unix)] + #[test] + fn mutual_directory_links_terminate() { + let dir = scratch("cycle"); + let a = dir.0.join("a"); + let b = dir.0.join("b"); + std::fs::create_dir(&a).unwrap(); + std::fs::create_dir(&b).unwrap(); + let file = b.join("data.bin"); + write_file(&file, 16 * 1024); + std::os::unix::fs::symlink(&b, a.join("to-b")).unwrap(); + std::os::unix::fs::symlink(&a, b.join("to-a")).unwrap(); + + let (node, count, size) = scan(&dir.0); + assert_eq!(size, on_disk(&file)); + assert_eq!(node.size, size); + assert_eq!(count, 4); + assert_eq!(path_disk_usage(&dir.0), size); + } + + #[cfg(unix)] + #[test] + fn broken_symlink_is_omitted() { + let dir = scratch("broken"); + std::os::unix::fs::symlink("missing-target", dir.0.join("broken")).unwrap(); + let (_node, count, size) = scan(&dir.0); + assert_eq!(size, 0); + assert_eq!(count, 1); + } + + #[test] + fn wide_directory_keeps_record_totals() { + let dir = scratch("wide"); + for index in 0..70 { + write_file(&dir.0.join(format!("f{index}.bin")), 4096); + } + std::fs::create_dir(dir.0.join("d0")).unwrap(); + std::fs::create_dir(dir.0.join("d1")).unwrap(); + write_file(&dir.0.join("d0").join("inner.bin"), 8192); + + let (node, count, size) = scan(&dir.0); + assert_eq!(count, 74); + assert_eq!(node.children.as_ref().unwrap().len(), 72); + assert_eq!(node.size, size); + assert_eq!(path_disk_usage(&dir.0), size); + assert!(size > 0); + } + + #[cfg(target_os = "macos")] + #[test] + fn bulk_read_matches_lstat_and_fstatat() { + use std::collections::HashMap; + use std::os::unix::fs::MetadataExt; + + let dir = scratch("bulk"); + write_file(&dir.0.join("hello.bin"), 100_000); + std::os::unix::fs::symlink("hello.bin", dir.0.join("link.bin")).unwrap(); + std::fs::hard_link(dir.0.join("hello.bin"), dir.0.join("hard.bin")).unwrap(); + std::fs::create_dir(dir.0.join("sub")).unwrap(); + + let bulk = platform::read_directory(&dir.0).unwrap(); + let fallback = platform::read_directory_fstatat(&dir.0).unwrap(); + assert_eq!(bulk.len(), 4); + assert_eq!(fallback.len(), bulk.len()); + + let mut from_bulk = HashMap::new(); + for entry in bulk.entries() { + let name = bulk.name_str(entry).into_owned(); + let id = entry.id.expect("file id"); + from_bulk.insert(name, (entry.kind, entry.size_on_disk, id.dev, id.ino)); + } + for entry in fallback.entries() { + let name = fallback.name_str(entry).into_owned(); + let id = entry.id.expect("fstatat id"); + let (kind, size, dev, ino) = from_bulk[&name]; + assert_eq!(kind, entry.kind, "{name}"); + assert_eq!(size, entry.size_on_disk, "{name}"); + assert_eq!(dev, id.dev, "{name}"); + assert_eq!(ino, id.ino, "{name}"); + } + + for (name, (kind, size, dev, ino)) in &from_bulk { + let meta = std::fs::symlink_metadata(dir.0.join(name)).unwrap(); + assert_eq!(*dev, meta.dev(), "{name}"); + assert_eq!(*ino, meta.ino(), "{name}"); + let expect_kind = if meta.file_type().is_symlink() { + EntryKind::Symlink + } else if meta.is_dir() { + EntryKind::Directory + } else if meta.is_file() { + EntryKind::File + } else { + EntryKind::Other + }; + assert_eq!(*kind, expect_kind, "{name}"); + let expect_size = match expect_kind { + EntryKind::File | EntryKind::Symlink => meta.blocks().saturating_mul(512), + _ => 0, + }; + assert_eq!(*size, expect_size, "{name}"); + } + + let link = from_bulk["link.bin"]; + let hello = from_bulk["hello.bin"]; + assert_ne!(link.3, hello.3, "symlink must keep its own inode"); + assert_eq!(from_bulk["hard.bin"].3, hello.3); + } + + #[cfg(target_os = "macos")] + #[test] + fn bulk_read_spans_small_buffers() { + let dir = scratch("span"); + for index in 0..100 { + write_file(&dir.0.join(format!("f{index}.bin")), 1); + } + let batch = platform::read_directory_buffered(&dir.0, 1024).unwrap(); + assert_eq!(batch.len(), 100); + } + + #[test] + fn stream_contract_matches_frontend_fields() { + let dir = scratch("stream"); + write_file(&dir.0.join("a.bin"), 4096); + let sub = dir.0.join("sub"); + std::fs::create_dir(&sub).unwrap(); + write_file(&sub.join("b.bin"), 8192); + + let messages = Arc::new(StdMutex::new(Vec::::new())); + let collect = messages.clone(); + let channel = Channel::new(move |body| { + if let tauri::ipc::InvokeResponseBody::Json(json) = body { + collect.lock().unwrap().push(json); + } + Ok(()) + }); + + let state = ScanState::new(); + let node = walk(&dir.0, Some(&channel), &state, None).unwrap(); + let (count, size) = state.stats(); + assert_eq!(node.size, size); + assert_eq!( + size, + on_disk(&dir.0.join("a.bin")) + on_disk(&sub.join("b.bin")) + ); + + let stored = messages.lock().unwrap(); + assert!(!stored.is_empty()); + let mut complete = None; + let mut names = Vec::new(); + for json in stored.iter() { + let value: serde_json::Value = serde_json::from_str(json).unwrap(); + assert!(value.get("total_scanned").is_some()); + assert!(value.get("total_size").is_some()); + assert!(value.get("compact_nodes").is_some()); + assert!(value.get("disk_info").is_some()); + if value["is_complete"].as_bool() == Some(true) { + assert_eq!(value["root_node"]["isDirectory"], true); + assert_eq!(value["root_node"]["size"], size); + assert_eq!(value["root_node"]["path"], node.path); + assert_eq!(value["total_scanned"], count); + assert!(value["root_node"]["children"] + .as_array() + .unwrap() + .is_empty()); + complete = Some(value); + } else if let Some(forests) = value["compact_nodes"].as_array() { + for forest in forests { + collect_packed_names(forest, &mut names); + } + } + } + assert!(complete.is_some()); + assert!(names.iter().any(|(name, dir)| name == "a.bin" && !dir)); + assert!(names.iter().any(|(name, dir)| name == "sub" && *dir)); + assert!(names.iter().any(|(name, _)| name == "b.bin")); + } + + fn collect_packed_names(forest: &serde_json::Value, names: &mut Vec<(String, bool)>) { + let n = forest["n"].as_array().expect("names"); + let s = forest["s"].as_array().expect("sizes"); + let m = forest["m"].as_array().expect("meta"); + let k = forest["k"].as_array().expect("kids"); + let roots = forest["r"].as_array().expect("roots"); + assert_eq!(m.len(), n.len() * 3); + assert_eq!(s.len(), n.len()); + for root in roots { + let index = root.as_u64().unwrap() as usize; + walk_packed(n, m, s, k, index, names); + } + } + + fn walk_packed( + n: &[serde_json::Value], + m: &[serde_json::Value], + s: &[serde_json::Value], + k: &[serde_json::Value], + index: usize, + names: &mut Vec<(String, bool)>, + ) { + assert!(s[index].as_u64().is_some()); + let is_dir = m[index * 3 + 2].as_u64().unwrap() & 1 == 1; + names.push((n[index].as_str().unwrap().to_string(), is_dir)); + let child_off = m[index * 3].as_u64().unwrap() as usize; + let child_count = m[index * 3 + 1].as_u64().unwrap() as usize; + for child in 0..child_count { + let next = k[child_off + child].as_u64().unwrap() as usize; + walk_packed(n, m, s, k, next, names); + } + } +} diff --git a/src-tauri/src/scan/platform/fallback.rs b/src-tauri/src/scan/platform/fallback.rs new file mode 100644 index 0000000..4c5dc76 --- /dev/null +++ b/src-tauri/src/scan/platform/fallback.rs @@ -0,0 +1,10 @@ +//! Targets without a bulk directory API. One `read_dir` plus per-entry metadata. + +use std::io; +use std::path::Path; + +use super::{read_directory_std, DirBatch}; + +pub fn read_directory(path: &Path) -> io::Result { + read_directory_std(path) +} diff --git a/src-tauri/src/scan/platform/linux.rs b/src-tauri/src/scan/platform/linux.rs new file mode 100644 index 0000000..10011ca --- /dev/null +++ b/src-tauri/src/scan/platform/linux.rs @@ -0,0 +1,323 @@ +//! Linux directory reads: `getdents64` for names, then `statx` on the +//! directory fd (`STATX_BASIC_STATS`, `AT_SYMLINK_NOFOLLOW`). +//! +//! `statx` avoids walking parent components again. `stx_blocks` is in +//! 512-byte units, the same as `st_blocks`. Device numbers are rebuilt with +//! glibc's `makedev` so they match `MetadataExt::dev` used for symlink targets. +//! Kernels without `statx` fall back to `std::fs::read_dir`. + +use std::cell::RefCell; +use std::io; +use std::path::Path; + +use super::{cstring_path, read_directory_std, DirBatch, EntryKind, FileId}; + +const O_RDONLY: i32 = 0; +const O_NOFOLLOW: i32 = 0x20000; +const O_DIRECTORY: i32 = 0x10000; +const O_CLOEXEC: i32 = 0x80000; + +const AT_SYMLINK_NOFOLLOW: i32 = 0x100; +const AT_NO_AUTOMOUNT: i32 = 0x800; +const AT_STATX_DONT_SYNC: i32 = 0x4000; +const STATX_BASIC_STATS: u32 = 0x7ff; + +const DEFAULT_BUF: usize = 64 * 1024; +const ENOSYS: i32 = 38; + +const S_IFMT: u16 = 0o170000; +const S_IFREG: u16 = 0o100000; +const S_IFDIR: u16 = 0o040000; +const S_IFLNK: u16 = 0o120000; + +extern "C" { + fn open(path: *const i8, flags: i32, ...) -> i32; + fn openat(dirfd: i32, path: *const i8, flags: i32, ...) -> i32; + fn close(fd: i32) -> i32; +} + +thread_local! { + static DENT_BUF: RefCell> = RefCell::new(vec![0u8; DEFAULT_BUF]); +} + +struct OwnedFd(i32); + +impl Drop for OwnedFd { + fn drop(&mut self) { + unsafe { close(self.0) }; + } +} + +#[cfg_attr(not(test), allow(dead_code))] +pub fn read_directory(path: &Path) -> io::Result { + let fd = open_path(path)?; + let fd = OwnedFd(fd); + match read_fd(fd.0)? { + Some(batch) => Ok(batch), + None => read_directory_std(path), + } +} + +pub fn open_path(path: &Path) -> io::Result { + let c_path = cstring_path(path)?; + let fd = unsafe { open(c_path.as_ptr(), O_RDONLY | O_DIRECTORY | O_CLOEXEC) }; + if fd < 0 { + Err(io::Error::last_os_error()) + } else { + Ok(fd) + } +} + +/// `follow` is set only for a directory symlink the walker has decided to expand. +pub fn open_child(dirfd: i32, name: &[u8], follow: bool) -> io::Result { + let c_name = std::ffi::CString::new(name) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "directory name contains NUL"))?; + let mut flags = O_RDONLY | O_DIRECTORY | O_CLOEXEC; + if !follow { + flags |= O_NOFOLLOW; + } + let fd = unsafe { openat(dirfd, c_name.as_ptr(), flags) }; + if fd < 0 { + Err(io::Error::last_os_error()) + } else { + Ok(fd) + } +} + +/// `Ok(None)` means `statx` returned `ENOSYS`. The caller lists by path instead. +/// The directory fd stays open so children can still use `openat`. +pub fn read_fd(fd: i32) -> io::Result> { + let listed = DENT_BUF.with(|cell| -> io::Result> { + let mut buf = cell.borrow_mut(); + if buf.len() < DEFAULT_BUF { + buf.resize(DEFAULT_BUF, 0); + } + let mut batch = DirBatch::new(); + loop { + let n = syscall_getdents(fd, buf.as_mut_ptr(), buf.len())?; + if n == 0 { + return Ok(Ok(batch)); + } + let mut off = 0usize; + while off + 19 <= n { + let reclen = u16::from_ne_bytes([buf[off + 16], buf[off + 17]]) as usize; + if reclen < 19 || off + reclen > n { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "getdents64 record length", + )); + } + let name_at = off + 19; + let region = &buf[name_at..off + reclen]; + let nul = region + .iter() + .position(|byte| *byte == 0) + .unwrap_or(region.len()); + let name = ®ion[..nul]; + if name.is_empty() || name == b"." || name == b".." { + off += reclen; + continue; + } + // Copy out of the dent buffer before `statx` so the name bytes + // and the syscall pointer are not aliases of that buffer. + let Ok(c_name) = std::ffi::CString::new(name) else { + off += reclen; + continue; + }; + match statx_at(fd, c_name.as_ptr() as *const u8) { + Ok(parsed) => batch.push(c_name.as_bytes(), parsed.0, parsed.1, parsed.2, None), + Err(err) if err.raw_os_error() == Some(ENOSYS) => return Ok(Err(())), + Err(_) => {} + } + off += reclen; + } + if off != n { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "getdents64 leftover bytes", + )); + } + } + })?; + match listed { + Ok(batch) => Ok(Some(batch)), + Err(()) => Ok(None), + } +} + +struct Parsed(EntryKind, u64, Option); + +fn statx_at(dirfd: i32, name: *const u8) -> io::Result { + let mut raw = [0u8; 256]; + let flags = AT_SYMLINK_NOFOLLOW | AT_NO_AUTOMOUNT | AT_STATX_DONT_SYNC; + syscall_statx(dirfd, name, flags, STATX_BASIC_STATS, raw.as_mut_ptr())?; + let mode = u16::from_ne_bytes([raw[28], raw[29]]); + let ino = u64::from_ne_bytes(raw[32..40].try_into().unwrap()); + let blocks = u64::from_ne_bytes(raw[48..56].try_into().unwrap()); + let major = u32::from_ne_bytes(raw[136..140].try_into().unwrap()); + let minor = u32::from_ne_bytes(raw[140..144].try_into().unwrap()); + let kind = match mode & S_IFMT { + S_IFREG => EntryKind::File, + S_IFDIR => EntryKind::Directory, + S_IFLNK => EntryKind::Symlink, + _ => EntryKind::Other, + }; + let size_on_disk = match kind { + EntryKind::File | EntryKind::Symlink => blocks.saturating_mul(512), + EntryKind::Directory | EntryKind::Other => 0, + }; + Ok(Parsed( + kind, + size_on_disk, + Some(FileId { + dev: makedev(major, minor), + ino, + }), + )) +} + +/// glibc `gnu_dev_makedev`, so `statx` device ids match libc `st_dev`. +fn makedev(major: u32, minor: u32) -> u64 { + let major = u64::from(major); + let minor = u64::from(minor); + ((major & 0xfff) << 8) | ((major & !0xfff) << 32) | (minor & 0xff) | ((minor & !0xff) << 12) +} + +fn syscall_getdents(fd: i32, buf: *mut u8, len: usize) -> io::Result { + #[cfg(target_arch = "x86_64")] + let n = 217isize; + #[cfg(target_arch = "aarch64")] + let n = 61isize; + #[cfg(target_arch = "riscv64")] + let n = 61isize; + #[cfg(not(any( + target_arch = "x86_64", + target_arch = "aarch64", + target_arch = "riscv64" + )))] + { + let _ = (fd, buf, len); + return Err(io::Error::from_raw_os_error(ENOSYS)); + } + #[cfg(any( + target_arch = "x86_64", + target_arch = "aarch64", + target_arch = "riscv64" + ))] + cvt(unsafe { syscall3(n, fd as usize, buf as usize, len) }) +} + +fn syscall_statx( + dirfd: i32, + name: *const u8, + flags: i32, + mask: u32, + out: *mut u8, +) -> io::Result<()> { + #[cfg(target_arch = "x86_64")] + let n = 332isize; + #[cfg(target_arch = "aarch64")] + let n = 291isize; + #[cfg(target_arch = "riscv64")] + let n = 291isize; + #[cfg(not(any( + target_arch = "x86_64", + target_arch = "aarch64", + target_arch = "riscv64" + )))] + { + let _ = (dirfd, name, flags, mask, out); + return Err(io::Error::from_raw_os_error(ENOSYS)); + } + #[cfg(any( + target_arch = "x86_64", + target_arch = "aarch64", + target_arch = "riscv64" + ))] + { + cvt(unsafe { + syscall5( + n, + dirfd as usize, + name as usize, + flags as usize, + mask as usize, + out as usize, + ) + })?; + Ok(()) + } +} + +fn cvt(result: isize) -> io::Result { + if result < 0 { + Err(io::Error::from_raw_os_error((-result) as i32)) + } else { + Ok(result as usize) + } +} + +#[cfg(target_arch = "x86_64")] +unsafe fn syscall3(n: isize, a0: usize, a1: usize, a2: usize) -> isize { + syscall5(n, a0, a1, a2, 0, 0) +} + +#[cfg(target_arch = "x86_64")] +unsafe fn syscall5(n: isize, a0: usize, a1: usize, a2: usize, a3: usize, a4: usize) -> isize { + let ret: isize; + std::arch::asm!( + "syscall", + inlateout("rax") n => ret, + in("rdi") a0, + in("rsi") a1, + in("rdx") a2, + in("r10") a3, + in("r8") a4, + in("r9") 0usize, + out("rcx") _, + out("r11") _, + ); + ret +} + +#[cfg(target_arch = "aarch64")] +unsafe fn syscall3(n: isize, a0: usize, a1: usize, a2: usize) -> isize { + syscall5(n, a0, a1, a2, 0, 0) +} + +#[cfg(target_arch = "aarch64")] +unsafe fn syscall5(n: isize, a0: usize, a1: usize, a2: usize, a3: usize, a4: usize) -> isize { + let ret: usize; + std::arch::asm!( + "svc #0", + in("x8") n, + inlateout("x0") a0 => ret, + in("x1") a1, + in("x2") a2, + in("x3") a3, + in("x4") a4, + in("x5") 0usize, + ); + ret as isize +} + +#[cfg(target_arch = "riscv64")] +unsafe fn syscall3(n: isize, a0: usize, a1: usize, a2: usize) -> isize { + syscall5(n, a0, a1, a2, 0, 0) +} + +#[cfg(target_arch = "riscv64")] +unsafe fn syscall5(n: isize, a0: usize, a1: usize, a2: usize, a3: usize, a4: usize) -> isize { + let ret: usize; + std::arch::asm!( + "ecall", + in("a7") n, + inlateout("a0") a0 => ret, + in("a1") a1, + in("a2") a2, + in("a3") a3, + in("a4") a4, + in("a5") 0usize, + ); + ret as isize +} diff --git a/src-tauri/src/scan/platform/macos.rs b/src-tauri/src/scan/platform/macos.rs new file mode 100644 index 0000000..007c105 --- /dev/null +++ b/src-tauri/src/scan/platform/macos.rs @@ -0,0 +1,451 @@ +//! macOS directory reads via `getattrlistbulk` on an open directory fd. +//! +//! One syscall returns a batch of names, vnode types, device ids, file ids, +//! flags, and allocated sizes. `FSOPT_NOFOLLOW` keeps a symlink's own inode +//! and block count. `ATTR_FILE_ALLOCSIZE` is in bytes and matches +//! `st_blocks * 512` for the files this scanner charges. +//! +//! Record layout (attribute order is bit order, not request order): +//! `u32` length, then `attribute_set` (20 bytes), then the requested common +//! attributes. `u64` fields are packed on a 4-byte boundary. The name is an +//! `attrreference` whose offset is relative to the reference itself and +//! includes the trailing NUL. File attributes are omitted for directories +//! even when `FSOPT_PACK_INVAL_ATTRS` is set, so the parser follows the +//! returned attribute bits. + +use std::cell::RefCell; +use std::io; +use std::path::Path; + +use super::{cstring_path, DirBatch, EntryKind, FileId}; + +const FSOPT_NOFOLLOW: u64 = 0x1; +const FSOPT_PACK_INVAL_ATTRS: u64 = 0x8; +const ATTR_BIT_MAP_COUNT: u16 = 5; +const ATTR_CMN_NAME: u32 = 0x0000_0001; +const ATTR_CMN_DEVID: u32 = 0x0000_0002; +const ATTR_CMN_OBJTYPE: u32 = 0x0000_0008; +const ATTR_CMN_FLAGS: u32 = 0x0004_0000; +const ATTR_CMN_FILEID: u32 = 0x0200_0000; +const ATTR_CMN_ERROR: u32 = 0x2000_0000; +const ATTR_CMN_RETURNED_ATTRS: u32 = 0x8000_0000; +const ATTR_FILE_ALLOCSIZE: u32 = 0x0000_0004; + +const VREG: u32 = 1; +const VDIR: u32 = 2; +const VLNK: u32 = 5; + +const O_RDONLY: i32 = 0; +const O_NOFOLLOW: i32 = 0x0100; +const O_DIRECTORY: i32 = 0x0010_0000; +const O_CLOEXEC: i32 = 0x0100_0000; +const AT_SYMLINK_NOFOLLOW: i32 = 0x0020; + +const DEFAULT_BUF: usize = 256 * 1024; +const MAX_BUF: usize = 8 * 1024 * 1024; + +const S_IFMT: u16 = 0o170000; +const S_IFREG: u16 = 0o100000; +const S_IFDIR: u16 = 0o040000; +const S_IFLNK: u16 = 0o120000; + +thread_local! { + static ATTR_BUF: RefCell> = RefCell::new(Vec::new()); +} + +#[repr(C)] +struct AttrList { + bitmapcount: u16, + reserved: u16, + commonattr: u32, + volattr: u32, + dirattr: u32, + fileattr: u32, + forkattr: u32, +} + +#[repr(C)] +struct DirEnt { + d_ino: u64, + d_seekoff: u64, + d_reclen: u16, + d_namlen: u16, + d_type: u8, + d_name: [u8; 1024], +} + +extern "C" { + fn open(path: *const i8, flags: i32, ...) -> i32; + fn openat(dirfd: i32, path: *const i8, flags: i32, ...) -> i32; + fn close(fd: i32) -> i32; + fn dup(fd: i32) -> i32; + fn getattrlistbulk( + dirfd: i32, + alist: *mut AttrList, + buf: *mut u8, + size: usize, + options: u64, + ) -> i32; + fn fdopendir(fd: i32) -> *mut std::ffi::c_void; + fn readdir(dir: *mut std::ffi::c_void) -> *mut DirEnt; + fn closedir(dir: *mut std::ffi::c_void) -> i32; + fn fstatat(dirfd: i32, path: *const i8, buf: *mut u8, flags: i32) -> i32; + fn __error() -> *mut i32; +} + +struct OwnedFd(i32); + +impl Drop for OwnedFd { + fn drop(&mut self) { + unsafe { close(self.0) }; + } +} + +#[cfg(test)] +pub fn read_directory(path: &Path) -> io::Result { + read_directory_buffered(path, DEFAULT_BUF) +} + +#[cfg(test)] +pub fn read_directory_buffered(path: &Path, buf_size: usize) -> io::Result { + let fd = open_path(path)?; + let fd = OwnedFd(fd); + read_fd(fd.0, buf_size) +} + +/// Open `path` itself. Callers that descend use [`open_child`] so later +/// directories do not walk every parent component again. +pub fn open_path(path: &Path) -> io::Result { + let c_path = cstring_path(path)?; + let fd = unsafe { open(c_path.as_ptr(), O_RDONLY | O_DIRECTORY | O_CLOEXEC) }; + if fd < 0 { + Err(io::Error::last_os_error()) + } else { + Ok(fd) + } +} + +/// `follow` is set only for a directory symlink the walker has decided to expand. +/// Real directories use `O_NOFOLLOW` so the fd is this entry, not a link target. +pub fn open_child(dirfd: i32, name: &[u8], follow: bool) -> io::Result { + let c_name = std::ffi::CString::new(name) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "directory name contains NUL"))?; + let mut flags = O_RDONLY | O_DIRECTORY | O_CLOEXEC; + if !follow { + flags |= O_NOFOLLOW; + } + let fd = unsafe { openat(dirfd, c_name.as_ptr(), flags) }; + if fd < 0 { + Err(io::Error::last_os_error()) + } else { + Ok(fd) + } +} + +pub fn read_open_fd(fd: i32) -> io::Result { + read_fd(fd, DEFAULT_BUF) +} + +pub fn read_fd(fd: i32, buf_size: usize) -> io::Result { + match read_bulk(fd, buf_size.max(64)) { + Ok(batch) => Ok(batch), + Err(err) if is_unsupported(&err) => read_fstatat_fd(fd), + Err(err) => Err(err), + } +} + +fn read_bulk(fd: i32, buf_size: usize) -> io::Result { + let mut list = AttrList { + bitmapcount: ATTR_BIT_MAP_COUNT, + reserved: 0, + commonattr: ATTR_CMN_RETURNED_ATTRS + | ATTR_CMN_NAME + | ATTR_CMN_DEVID + | ATTR_CMN_OBJTYPE + | ATTR_CMN_FLAGS + | ATTR_CMN_FILEID + | ATTR_CMN_ERROR, + volattr: 0, + dirattr: 0, + fileattr: ATTR_FILE_ALLOCSIZE, + forkattr: 0, + }; + + ATTR_BUF.with(|cell| { + let mut words = cell.borrow_mut(); + let mut size = buf_size; + let mut batch = DirBatch::new(); + loop { + let word_len = size.div_ceil(8); + if words.len() < word_len { + words.resize(word_len, 0); + } + let n = unsafe { + getattrlistbulk( + fd, + &mut list, + words.as_mut_ptr() as *mut u8, + size, + FSOPT_NOFOLLOW | FSOPT_PACK_INVAL_ATTRS, + ) + }; + if n == 0 { + return Ok(batch); + } + if n < 0 { + let err = io::Error::last_os_error(); + match err.raw_os_error() { + Some(4) => continue, // EINTR + Some(34) if size < MAX_BUF => { + // ERANGE: the buffer cannot hold one entry. The directory + // offset does not advance, so growing and retrying is safe. + size = (size.saturating_mul(2)).min(MAX_BUF); + continue; + } + _ => return Err(err), + } + } + let bytes = unsafe { std::slice::from_raw_parts(words.as_ptr() as *const u8, size) }; + parse_records(bytes, n, &mut batch)?; + } + }) +} + +#[cfg(test)] +pub fn read_directory_fstatat(path: &Path) -> io::Result { + let fd = open_path(path)?; + let fd = OwnedFd(fd); + read_fstatat_fd(fd.0) +} + +/// List via `fstatat` without consuming `fd`, so a bulk-attr fallback can +/// still `openat` children from the same directory. +fn read_fstatat_fd(fd: i32) -> io::Result { + let dir_fd = unsafe { dup(fd) }; + if dir_fd < 0 { + return Err(io::Error::last_os_error()); + } + let stat_fd = unsafe { dup(fd) }; + if stat_fd < 0 { + let err = io::Error::last_os_error(); + unsafe { close(dir_fd) }; + return Err(err); + } + let stat_fd = OwnedFd(stat_fd); + // `fdopendir` takes ownership of `dir_fd`. `stat_fd` stays usable for `fstatat`. + let dir = unsafe { fdopendir(dir_fd) }; + if dir.is_null() { + let err = io::Error::last_os_error(); + unsafe { close(dir_fd) }; + return Err(err); + } + + let mut batch = DirBatch::new(); + loop { + unsafe { *__error() = 0 }; + let ent = unsafe { readdir(dir) }; + if ent.is_null() { + let err = unsafe { *__error() }; + unsafe { closedir(dir) }; + if err == 0 { + return Ok(batch); + } + return Err(io::Error::from_raw_os_error(err)); + } + let ent = unsafe { &*ent }; + let len = ent.d_namlen as usize; + if len == 0 || len > ent.d_name.len() { + continue; + } + let name = &ent.d_name[..len]; + if name == b"." || name == b".." { + continue; + } + let Ok(c_name) = std::ffi::CString::new(name) else { + continue; + }; + let mut raw = [0u8; 144]; + let rc = unsafe { + fstatat( + stat_fd.0, + c_name.as_ptr(), + raw.as_mut_ptr(), + AT_SYMLINK_NOFOLLOW, + ) + }; + if rc != 0 { + continue; + } + push_stat(&mut batch, name, &raw); + } +} + +fn is_unsupported(err: &io::Error) -> bool { + // ENOTSUP, EOPNOTSUPP, ENOTTY: some volumes have no bulk getattr. + matches!(err.raw_os_error(), Some(45 | 102 | 25)) +} + +fn parse_records(buf: &[u8], count: i32, batch: &mut DirBatch) -> io::Result<()> { + let mut off = 0usize; + for _ in 0..count { + if off + 4 > buf.len() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "getattrlistbulk record truncated", + )); + } + let len = read_u32(buf, off)? as usize; + if len < 4 || off + len > buf.len() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "getattrlistbulk record length", + )); + } + parse_one(&buf[off..off + len], batch)?; + off += len; + } + Ok(()) +} + +fn parse_one(rec: &[u8], batch: &mut DirBatch) -> io::Result<()> { + if rec.len() < 24 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "getattrlistbulk record header", + )); + } + let common = read_u32(rec, 4)?; + let file = read_u32(rec, 16)?; + // `ATTR_CMN_ERROR`, when returned, sits immediately after the attribute + // set rather than in bit order. The remaining common attributes follow + // their bit order. + let mut cursor = 24usize; + let mut name_at: Option<(usize, usize)> = None; + let mut dev = 0u64; + let mut objtype = 0u32; + let mut file_id: Option = None; + let mut entry_errno = 0u32; + let mut alloc: Option = None; + + if common & ATTR_CMN_ERROR != 0 { + entry_errno = read_u32(rec, cursor)?; + cursor += 4; + } + if common & ATTR_CMN_NAME != 0 { + let rel = read_i32(rec, cursor)?; + let nlen = read_u32(rec, cursor + 4)? as usize; + let pos = cursor as isize + rel as isize; + if pos >= 0 { + name_at = Some((pos as usize, nlen)); + } + cursor += 8; + } + if common & ATTR_CMN_DEVID != 0 { + dev = u64::from(read_u32(rec, cursor)?); + cursor += 4; + } + if common & ATTR_CMN_OBJTYPE != 0 { + objtype = read_u32(rec, cursor)?; + cursor += 4; + } + if common & ATTR_CMN_FLAGS != 0 { + // Flags are fetched so the cursor stays aligned with the kernel record. + // Allocated size already reflects compression, so flags do not change + // what the walker charges. + let _flags = read_u32(rec, cursor)?; + cursor += 4; + } + if common & ATTR_CMN_FILEID != 0 { + let id = read_u64(rec, cursor)?; + if id != u64::MAX { + file_id = Some(id); + } + cursor += 8; + } + if file & ATTR_FILE_ALLOCSIZE != 0 { + let size = read_u64(rec, cursor)?; + if size != u64::MAX { + alloc = Some(size); + } + cursor += 8; + } + if cursor > rec.len() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "getattrlistbulk attribute overrun", + )); + } + if entry_errno != 0 { + return Ok(()); + } + let Some((pos, nlen)) = name_at else { + return Ok(()); + }; + if nlen == 0 || pos >= rec.len() { + return Ok(()); + } + let end = pos + nlen.min(rec.len() - pos); + let raw = &rec[pos..end]; + let name = match raw.iter().position(|byte| *byte == 0) { + Some(0) => return Ok(()), + Some(index) => &raw[..index], + None => raw, + }; + if name.is_empty() { + return Ok(()); + } + + let kind = match objtype { + VREG => EntryKind::File, + VDIR => EntryKind::Directory, + VLNK => EntryKind::Symlink, + _ => EntryKind::Other, + }; + let size_on_disk = match kind { + EntryKind::File | EntryKind::Symlink => alloc.unwrap_or(0), + EntryKind::Directory | EntryKind::Other => 0, + }; + let id = file_id.map(|ino| FileId { dev, ino }); + batch.push(name, kind, size_on_disk, id, None); + Ok(()) +} + +fn push_stat(batch: &mut DirBatch, name: &[u8], raw: &[u8; 144]) { + let dev = u64::from(u32::from_ne_bytes(raw[0..4].try_into().unwrap())); + let mode = u16::from_ne_bytes(raw[4..6].try_into().unwrap()); + let ino = u64::from_ne_bytes(raw[8..16].try_into().unwrap()); + let blocks = i64::from_ne_bytes(raw[104..112].try_into().unwrap()); + let kind = match mode & S_IFMT { + S_IFREG => EntryKind::File, + S_IFDIR => EntryKind::Directory, + S_IFLNK => EntryKind::Symlink, + _ => EntryKind::Other, + }; + let size_on_disk = match kind { + EntryKind::File | EntryKind::Symlink if blocks > 0 => (blocks as u64).saturating_mul(512), + _ => 0, + }; + batch.push(name, kind, size_on_disk, Some(FileId { dev, ino }), None); +} + +fn read_u32(buf: &[u8], off: usize) -> io::Result { + let bytes = buf + .get(off..off + 4) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "getattrlistbulk short read"))?; + let mut raw = [0u8; 4]; + raw.copy_from_slice(bytes); + Ok(u32::from_ne_bytes(raw)) +} + +fn read_i32(buf: &[u8], off: usize) -> io::Result { + Ok(read_u32(buf, off)? as i32) +} + +fn read_u64(buf: &[u8], off: usize) -> io::Result { + let bytes = buf + .get(off..off + 8) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "getattrlistbulk short read"))?; + let mut raw = [0u8; 8]; + raw.copy_from_slice(bytes); + Ok(u64::from_ne_bytes(raw)) +} diff --git a/src-tauri/src/scan/platform/mod.rs b/src-tauri/src/scan/platform/mod.rs new file mode 100644 index 0000000..5e597e9 --- /dev/null +++ b/src-tauri/src/scan/platform/mod.rs @@ -0,0 +1,477 @@ +//! Directory entry records. +//! +//! The walker asks for one bulk listing per directory. Platform modules turn +//! that listing into [`DirBatch`]. Symlink targets are resolved separately, +//! and only for names that are actually links. + +use std::fs; +#[cfg(not(windows))] +use std::fs::Metadata; +use std::io; +use std::path::{Path, PathBuf}; + +#[cfg(not(any(target_os = "macos", target_os = "linux", windows)))] +mod fallback; +#[cfg(target_os = "linux")] +mod linux; +#[cfg(target_os = "macos")] +mod macos; +#[cfg(windows)] +mod windows; + +/// `st_dev` + `st_ino` on Unix, volume serial + file index on Windows. +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub struct FileId { + pub dev: u64, + pub ino: u64, +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum EntryKind { + File, + Directory, + /// Symlink, or a Windows name-surrogate reparse point (junction). + Symlink, + Other, +} + +/// What a symlink or junction points at, after one follow. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum FollowKind { + File, + Dir, + Other, +} + +#[derive(Clone, Debug)] +pub struct EntryStat { + pub kind: EntryKind, + /// Allocated size for a file or for the link itself. Directories stay 0; + /// the walker sums children instead of charging the directory inode. + pub size_on_disk: u64, + /// Identity of this entry, not of a symlink target. + /// `None` when the platform cannot provide it without opening every file. + pub id: Option, + /// Windows directory entries know whether a reparse point is a directory + /// without following it. Unix listings leave this `None`. + pub link_is_dir: Option, +} + +#[derive(Clone, Debug)] +pub struct Followed { + pub kind: FollowKind, + pub id: Option, + /// Populated when `canonicalize` succeeded. Used as a cycle key only if + /// the platform could not produce [`FileId`]. + pub canonical: Option, +} + +#[derive(Clone, Debug)] +pub enum LinkResolve { + /// Dangling link. The walker omits it. + Broken, + /// Target is outside the canonical scan root. Counted as a zero leaf. + Outside, + Inside(Followed), +} + +#[derive(Clone, Debug)] +pub struct RawEntry { + name_off: u32, + name_len: u32, + pub kind: EntryKind, + pub size_on_disk: u64, + pub id: Option, + pub link_is_dir: Option, +} + +/// Names for one directory, packed into a single buffer. +#[derive(Clone, Debug)] +pub struct DirBatch { + names: Vec, + entries: Vec, +} + +impl DirBatch { + fn new() -> Self { + Self { + names: Vec::new(), + entries: Vec::new(), + } + } + + pub fn len(&self) -> usize { + self.entries.len() + } + + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + pub fn entries(&self) -> &[RawEntry] { + &self.entries + } + + pub fn name_bytes(&self, entry: &RawEntry) -> &[u8] { + let start = entry.name_off as usize; + let end = start + entry.name_len as usize; + &self.names[start..end] + } + + #[cfg(test)] + pub fn name_str(&self, entry: &RawEntry) -> std::borrow::Cow<'_, str> { + String::from_utf8_lossy(self.name_bytes(entry)) + } + + fn push( + &mut self, + name: &[u8], + kind: EntryKind, + size_on_disk: u64, + id: Option, + link_is_dir: Option, + ) { + let name_off = self.names.len() as u32; + self.names.extend_from_slice(name); + self.entries.push(RawEntry { + name_off, + name_len: name.len() as u32, + kind, + size_on_disk, + id, + link_is_dir, + }); + } +} + +/// An open directory. On macOS and Linux the walker uses this fd with +/// `openat` so child directories are not looked up from the filesystem root. +pub struct ListedDir { + pub token: DirToken, + pub batch: DirBatch, +} + +#[cfg(any(target_os = "macos", target_os = "linux"))] +pub struct DirToken { + fd: i32, +} + +#[cfg(not(any(target_os = "macos", target_os = "linux")))] +pub struct DirToken; + +#[cfg(any(target_os = "macos", target_os = "linux"))] +impl Drop for DirToken { + fn drop(&mut self) { + unsafe { close_dir(self.fd) }; + } +} + +#[cfg(any(target_os = "macos", target_os = "linux"))] +extern "C" { + fn close(fd: i32) -> i32; +} + +#[cfg(any(target_os = "macos", target_os = "linux"))] +unsafe fn close_dir(fd: i32) { + unsafe { close(fd) }; +} + +pub fn list_path(path: &Path) -> io::Result { + #[cfg(target_os = "macos")] + { + let fd = macos::open_path(path)?; + let batch = match macos::read_open_fd(fd) { + Ok(batch) => batch, + Err(err) => { + unsafe { close_dir(fd) }; + return Err(err); + } + }; + return Ok(ListedDir { + token: DirToken { fd }, + batch, + }); + } + #[cfg(target_os = "linux")] + { + let fd = linux::open_path(path)?; + let batch = match linux::read_fd(fd) { + Ok(Some(batch)) => batch, + Ok(None) => match read_directory_std(path) { + Ok(batch) => batch, + Err(err) => { + unsafe { close_dir(fd) }; + return Err(err); + } + }, + Err(err) => { + unsafe { close_dir(fd) }; + return Err(err); + } + }; + return Ok(ListedDir { + token: DirToken { fd }, + batch, + }); + } + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + { + Ok(ListedDir { + token: DirToken, + batch: read_directory(path)?, + }) + } +} + +pub fn list_child( + parent: &DirToken, + path: &Path, + name: &[u8], + follow: bool, +) -> io::Result { + #[cfg(target_os = "macos")] + { + let fd = match macos::open_child(parent.fd, name, follow) { + Ok(fd) => fd, + Err(_) => macos::open_path(path)?, + }; + let batch = match macos::read_open_fd(fd) { + Ok(batch) => batch, + Err(err) => { + unsafe { close_dir(fd) }; + return Err(err); + } + }; + return Ok(ListedDir { + token: DirToken { fd }, + batch, + }); + } + #[cfg(target_os = "linux")] + { + let fd = match linux::open_child(parent.fd, name, follow) { + Ok(fd) => fd, + Err(_) => linux::open_path(path)?, + }; + let batch = match linux::read_fd(fd) { + Ok(Some(batch)) => batch, + Ok(None) => match read_directory_std(path) { + Ok(batch) => batch, + Err(err) => { + unsafe { close_dir(fd) }; + return Err(err); + } + }, + Err(err) => { + unsafe { close_dir(fd) }; + return Err(err); + } + }; + return Ok(ListedDir { + token: DirToken { fd }, + batch, + }); + } + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + { + let _ = (parent, name, follow); + list_path(path) + } +} + +#[cfg(any(test, not(any(target_os = "macos", target_os = "linux"))))] +pub fn read_directory(path: &Path) -> io::Result { + #[cfg(target_os = "macos")] + { + macos::read_directory(path) + } + #[cfg(target_os = "linux")] + { + linux::read_directory(path) + } + #[cfg(windows)] + { + windows::read_directory(path) + } + #[cfg(not(any(target_os = "macos", target_os = "linux", windows)))] + { + fallback::read_directory(path) + } +} + +#[cfg(all(test, target_os = "macos"))] +pub fn read_directory_buffered(path: &Path, buf_size: usize) -> io::Result { + macos::read_directory_buffered(path, buf_size) +} + +#[cfg(all(test, target_os = "macos"))] +pub fn read_directory_fstatat(path: &Path) -> io::Result { + macos::read_directory_fstatat(path) +} + +pub fn stat_path(path: &Path) -> io::Result { + #[cfg(windows)] + { + windows::stat_path(path) + } + #[cfg(not(windows))] + { + from_std_meta(&fs::symlink_metadata(path)?) + } +} + +pub fn resolve_link(path: &Path, root: Option<&Path>, link_is_dir: Option) -> LinkResolve { + #[cfg(windows)] + { + windows::resolve_link(path, root, link_is_dir) + } + #[cfg(not(windows))] + { + let _ = link_is_dir; + resolve_link_std(path, root) + } +} + +/// Identity of a directory inode. Unix listings already carry it. +/// Windows opens one handle so junctions can be tied to a directory already walked. +pub fn directory_id(path: &Path) -> Option { + #[cfg(windows)] + { + windows::directory_id(path) + } + #[cfg(not(windows))] + { + let _ = path; + None + } +} + +pub fn canonicalize(path: &Path) -> Option { + fs::canonicalize(path).ok() +} + +pub fn push_component(path: &mut PathBuf, name: &[u8]) { + #[cfg(unix)] + { + use std::os::unix::ffi::OsStrExt; + path.push(std::ffi::OsStr::from_bytes(name)); + } + #[cfg(not(unix))] + { + path.push(String::from_utf8_lossy(name).as_ref()); + } +} + +pub fn joined(parent: &Path, name: &[u8]) -> PathBuf { + let mut path = parent.to_path_buf(); + push_component(&mut path, name); + path +} + +#[cfg(unix)] +pub(crate) fn cstring_path(path: &Path) -> io::Result { + use std::os::unix::ffi::OsStrExt; + std::ffi::CString::new(path.as_os_str().as_bytes()) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "path contains an interior NUL")) +} + +#[cfg(not(windows))] +fn resolve_link_std(path: &Path, root: Option<&Path>) -> LinkResolve { + let followed = match fs::metadata(path) { + Ok(meta) => meta, + Err(_) => return LinkResolve::Broken, + }; + let canonical = fs::canonicalize(path).ok(); + if let (Some(root), Some(canon)) = (root, canonical.as_ref()) { + if !canon.starts_with(root) { + return LinkResolve::Outside; + } + } + let kind = if followed.is_dir() { + FollowKind::Dir + } else if followed.is_file() { + FollowKind::File + } else { + FollowKind::Other + }; + LinkResolve::Inside(Followed { + kind, + id: file_id_of(&followed), + canonical, + }) +} + +#[cfg(unix)] +fn file_id_of(meta: &Metadata) -> Option { + use std::os::unix::fs::MetadataExt; + Some(FileId { + dev: meta.dev(), + ino: meta.ino(), + }) +} + +#[cfg(not(any(unix, windows)))] +fn file_id_of(_meta: &Metadata) -> Option { + None +} + +#[cfg(not(windows))] +fn from_std_meta(meta: &Metadata) -> io::Result { + let ft = meta.file_type(); + let kind = if ft.is_symlink() { + EntryKind::Symlink + } else if ft.is_dir() { + EntryKind::Directory + } else if ft.is_file() { + EntryKind::File + } else { + EntryKind::Other + }; + let size_on_disk = match kind { + EntryKind::File | EntryKind::Symlink => allocated_len(meta), + EntryKind::Directory | EntryKind::Other => 0, + }; + Ok(EntryStat { + kind, + size_on_disk, + id: file_id_of(meta), + link_is_dir: None, + }) +} + +#[cfg(unix)] +fn allocated_len(meta: &Metadata) -> u64 { + use std::os::unix::fs::MetadataExt; + meta.blocks().saturating_mul(512) +} + +#[cfg(not(any(unix, windows)))] +fn allocated_len(meta: &Metadata) -> u64 { + meta.len() +} + +#[cfg(not(any(target_os = "macos", windows)))] +pub(crate) fn read_directory_std(path: &Path) -> io::Result { + let mut batch = DirBatch::new(); + for entry in fs::read_dir(path)? { + let entry = entry?; + let meta = entry.metadata()?; + let stat = from_std_meta(&meta)?; + let name = entry.file_name(); + #[cfg(unix)] + let bytes = { + use std::os::unix::ffi::OsStrExt; + name.as_bytes().to_vec() + }; + #[cfg(not(unix))] + let bytes = name.to_string_lossy().into_owned().into_bytes(); + batch.push( + &bytes, + stat.kind, + stat.size_on_disk, + stat.id, + stat.link_is_dir, + ); + } + Ok(batch) +} diff --git a/src-tauri/src/scan/platform/windows.rs b/src-tauri/src/scan/platform/windows.rs new file mode 100644 index 0000000..ee4bcbf --- /dev/null +++ b/src-tauri/src/scan/platform/windows.rs @@ -0,0 +1,369 @@ +//! Windows directory reads via `FindFirstFileExW`. +//! +//! `FindExInfoBasic` plus `FIND_FIRST_EX_LARGE_FETCH` asks the kernel for a +//! large directory buffer and skips the short 8.3 name. File size comes from +//! that `WIN32_FIND_DATA`. `GetCompressedFileSizeW` runs only for compressed +//! or sparse files. File hard links are not deduplicated: opening every file +//! for a file index costs more than counting each name, which is what the +//! previous scanner did. Directory handles are opened only when the walker +//! needs a volume serial and file index to stop junction cycles. + +use std::fs::{self, Metadata}; +use std::io; +use std::os::windows::ffi::OsStrExt; +use std::os::windows::fs::{FileTypeExt, MetadataExt}; +use std::path::Path; + +use super::{DirBatch, EntryKind, EntryStat, FileId, FollowKind, Followed, LinkResolve}; + +const FILE_ATTRIBUTE_DEVICE: u32 = 0x0000_0040; +const FILE_ATTRIBUTE_DIRECTORY: u32 = 0x0000_0010; +const FILE_ATTRIBUTE_SPARSE_FILE: u32 = 0x0000_0200; +const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; +const FILE_ATTRIBUTE_COMPRESSED: u32 = 0x0000_0800; +const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; +const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000; +const FILE_SHARE_READ: u32 = 0x1; +const FILE_SHARE_WRITE: u32 = 0x2; +const FILE_SHARE_DELETE: u32 = 0x4; +const OPEN_EXISTING: u32 = 3; +const ERROR_NO_MORE_FILES: u32 = 18; +const FIND_EX_INFO_BASIC: i32 = 1; +const FIND_EX_SEARCH_NAME_MATCH: i32 = 0; +const FIND_FIRST_EX_LARGE_FETCH: u32 = 0x2; +const INVALID_HANDLE: isize = -1; + +#[repr(C)] +#[allow(dead_code)] +struct FileTime { + low: u32, + high: u32, +} + +#[repr(C)] +struct Win32FindDataW { + file_attributes: u32, + creation_time: FileTime, + last_access_time: FileTime, + last_write_time: FileTime, + file_size_high: u32, + file_size_low: u32, + reserved0: u32, + reserved1: u32, + file_name: [u16; 260], + alternate_file_name: [u16; 14], +} + +/// Layout matches Win32 `BY_HANDLE_FILE_INFORMATION`. Fields we do not read are +/// still required so the struct size and offsets stay ABI-compatible. +#[repr(C)] +#[allow(dead_code)] +struct ByHandleFileInformation { + file_attributes: u32, + creation_time: FileTime, + last_access_time: FileTime, + last_write_time: FileTime, + volume_serial: u32, + file_size_high: u32, + file_size_low: u32, + number_of_links: u32, + file_index_high: u32, + file_index_low: u32, +} + +struct OwnedHandle(*mut std::ffi::c_void); + +impl Drop for OwnedHandle { + fn drop(&mut self) { + unsafe { + CloseHandle(self.0); + } + } +} + +struct FindHandle(*mut std::ffi::c_void); + +impl Drop for FindHandle { + fn drop(&mut self) { + unsafe { + FindClose(self.0); + } + } +} + +#[link(name = "kernel32")] +extern "system" { + fn CreateFileW( + file_name: *const u16, + desired_access: u32, + share_mode: u32, + security: *mut std::ffi::c_void, + disposition: u32, + flags: u32, + template: *mut std::ffi::c_void, + ) -> *mut std::ffi::c_void; + fn CloseHandle(handle: *mut std::ffi::c_void) -> i32; + fn GetFileInformationByHandle( + handle: *mut std::ffi::c_void, + info: *mut ByHandleFileInformation, + ) -> i32; + fn GetCompressedFileSizeW(file_name: *const u16, file_size_high: *mut u32) -> u32; + fn GetLastError() -> u32; + fn FindFirstFileExW( + file_name: *const u16, + info_level: i32, + find_data: *mut Win32FindDataW, + search_op: i32, + search_filter: *mut std::ffi::c_void, + additional_flags: u32, + ) -> *mut std::ffi::c_void; + fn FindNextFileW(find: *mut std::ffi::c_void, find_data: *mut Win32FindDataW) -> i32; + fn FindClose(find: *mut std::ffi::c_void) -> i32; +} + +pub fn read_directory(path: &Path) -> io::Result { + let wide = search_pattern(path); + let mut data = unsafe { std::mem::zeroed::() }; + let handle = unsafe { + FindFirstFileExW( + wide.as_ptr(), + FIND_EX_INFO_BASIC, + &mut data, + FIND_EX_SEARCH_NAME_MATCH, + std::ptr::null_mut(), + FIND_FIRST_EX_LARGE_FETCH, + ) + }; + if handle.is_null() || handle as isize == INVALID_HANDLE { + return Err(io::Error::last_os_error()); + } + let handle = FindHandle(handle); + let mut batch = DirBatch::new(); + loop { + push_find_data(path, &data, &mut batch); + let more = unsafe { FindNextFileW(handle.0, &mut data) }; + if more == 0 { + let err = unsafe { GetLastError() }; + if err == ERROR_NO_MORE_FILES { + break; + } + return Err(io::Error::from_raw_os_error(err as i32)); + } + } + Ok(batch) +} + +fn push_find_data(parent: &Path, data: &Win32FindDataW, batch: &mut DirBatch) { + let wide_name = match c_wide(&data.file_name) { + Some(name) if !is_dot(name) => name, + _ => return, + }; + let name = String::from_utf16_lossy(wide_name); + let attrs = data.file_attributes; + let reparse = attrs & FILE_ATTRIBUTE_REPARSE_POINT != 0; + let directory = attrs & FILE_ATTRIBUTE_DIRECTORY != 0; + let (kind, link_is_dir) = if reparse { + (EntryKind::Symlink, Some(directory)) + } else if directory { + (EntryKind::Directory, None) + } else if attrs & FILE_ATTRIBUTE_DEVICE != 0 { + (EntryKind::Other, None) + } else { + (EntryKind::File, None) + }; + let mut size_on_disk = 0u64; + if matches!(kind, EntryKind::File | EntryKind::Symlink) { + size_on_disk = (u64::from(data.file_size_high) << 32) | u64::from(data.file_size_low); + if kind == EntryKind::File + && attrs & (FILE_ATTRIBUTE_COMPRESSED | FILE_ATTRIBUTE_SPARSE_FILE) != 0 + { + let child = parent.join(&name); + if let Some(size) = compressed_size(&child) { + size_on_disk = size; + } + } + } + batch.push(name.as_bytes(), kind, size_on_disk, None, link_is_dir); +} + +pub fn stat_path(path: &Path) -> io::Result { + let meta = fs::symlink_metadata(path)?; + let mut stat = stat_from_meta(&meta); + if stat.kind == EntryKind::Directory { + stat.id = directory_id(path); + } else if stat.kind == EntryKind::File && sparse_or_compressed(&meta) { + if let Some(size) = compressed_size(path) { + stat.size_on_disk = size; + } + } + Ok(stat) +} + +pub fn resolve_link(path: &Path, root: Option<&Path>, link_is_dir: Option) -> LinkResolve { + if link_is_dir == Some(false) { + return resolve_file_link(path, root); + } + + let canonical = match fs::canonicalize(path) { + Ok(canon) => canon, + Err(_) => return LinkResolve::Broken, + }; + if let Some(root) = root { + if !canonical.starts_with(root) { + return LinkResolve::Outside; + } + } + + let id = followed_id(path); + let kind = if link_is_dir == Some(true) || canonical.is_dir() { + FollowKind::Dir + } else if canonical.is_file() { + FollowKind::File + } else { + FollowKind::Other + }; + LinkResolve::Inside(Followed { + kind, + id, + canonical: Some(canonical), + }) +} + +pub fn directory_id(path: &Path) -> Option { + let handle = open_meta(path, false).ok()?; + let info = handle_info(&handle).ok()?; + Some(id_from(&info)) +} + +fn resolve_file_link(path: &Path, root: Option<&Path>) -> LinkResolve { + let canonical = match fs::canonicalize(path) { + Ok(canon) => canon, + Err(_) => return LinkResolve::Broken, + }; + if let Some(root) = root { + if !canonical.starts_with(root) { + return LinkResolve::Outside; + } + } + LinkResolve::Inside(Followed { + kind: FollowKind::File, + id: None, + canonical: Some(canonical), + }) +} + +fn stat_from_meta(meta: &Metadata) -> EntryStat { + let ft = meta.file_type(); + let (kind, link_is_dir) = if ft.is_symlink() { + (EntryKind::Symlink, Some(ft.is_symlink_dir())) + } else if ft.is_dir() { + (EntryKind::Directory, None) + } else if ft.is_file() { + (EntryKind::File, None) + } else { + (EntryKind::Other, None) + }; + let size_on_disk = match kind { + EntryKind::File | EntryKind::Symlink => meta.len(), + EntryKind::Directory | EntryKind::Other => 0, + }; + EntryStat { + kind, + size_on_disk, + id: None, + link_is_dir, + } +} + +fn sparse_or_compressed(meta: &Metadata) -> bool { + let attrs = meta.file_attributes(); + attrs & (FILE_ATTRIBUTE_COMPRESSED | FILE_ATTRIBUTE_SPARSE_FILE) != 0 +} + +fn followed_id(path: &Path) -> Option { + let handle = open_meta(path, true).ok()?; + let info = handle_info(&handle).ok()?; + Some(id_from(&info)) +} + +fn id_from(info: &ByHandleFileInformation) -> FileId { + FileId { + dev: u64::from(info.volume_serial), + ino: (u64::from(info.file_index_high) << 32) | u64::from(info.file_index_low), + } +} + +fn compressed_size(path: &Path) -> Option { + let wide = wide_path(path); + let mut high = 0u32; + let low = unsafe { GetCompressedFileSizeW(wide.as_ptr(), &mut high) }; + if low == u32::MAX { + let err = unsafe { GetLastError() }; + if err != 0 { + return None; + } + } + Some((u64::from(high) << 32) | u64::from(low)) +} + +fn open_meta(path: &Path, follow: bool) -> io::Result { + let wide = wide_path(path); + let mut flags = FILE_FLAG_BACKUP_SEMANTICS; + if !follow { + flags |= FILE_FLAG_OPEN_REPARSE_POINT; + } + let handle = unsafe { + CreateFileW( + wide.as_ptr(), + 0, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + std::ptr::null_mut(), + OPEN_EXISTING, + flags, + std::ptr::null_mut(), + ) + }; + if handle.is_null() || handle as isize == INVALID_HANDLE { + Err(io::Error::last_os_error()) + } else { + Ok(OwnedHandle(handle)) + } +} + +fn handle_info(handle: &OwnedHandle) -> io::Result { + let mut info = unsafe { std::mem::zeroed() }; + let ok = unsafe { GetFileInformationByHandle(handle.0, &mut info) }; + if ok == 0 { + Err(io::Error::last_os_error()) + } else { + Ok(info) + } +} + +fn wide_path(path: &Path) -> Vec { + path.as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect() +} + +fn search_pattern(path: &Path) -> Vec { + let mut wide: Vec = path.as_os_str().encode_wide().collect(); + let slash = b'\\' as u16; + let slash_alt = b'/' as u16; + if !wide.ends_with(&[slash]) && !wide.ends_with(&[slash_alt]) { + wide.push(slash); + } + wide.push(b'*' as u16); + wide.push(0); + wide +} + +fn c_wide(buf: &[u16]) -> Option<&[u16]> { + let end = buf.iter().position(|unit| *unit == 0)?; + Some(&buf[..end]) +} + +fn is_dot(name: &[u16]) -> bool { + name == [u16::from(b'.')] || name == [u16::from(b'.'), u16::from(b'.')] +} diff --git a/src-tauri/src/scan/tree.rs b/src-tauri/src/scan/tree.rs new file mode 100644 index 0000000..f8e63ec --- /dev/null +++ b/src-tauri/src/scan/tree.rs @@ -0,0 +1,191 @@ +//! Flat directory tree. +//! +//! Nodes live in one `Vec`. Children are a contiguous range in a side table of +//! indexes, so a directory does not allocate its own child `Vec`. File names +//! are bytes in one string arena. + +use std::collections::HashMap; + +pub const DIR_FLAG: u32 = 1; + +#[derive(Clone, Copy)] +struct Node { + name_off: u32, + name_len: u32, + size: u64, + child_off: u32, + child_len: u32, + flags: u32, +} + +#[derive(Clone)] +pub struct Tree { + names: String, + nodes: Vec, + kids: Vec, + pub root: u32, +} + +impl Tree { + pub fn new() -> Self { + Self { + names: String::new(), + nodes: Vec::new(), + kids: Vec::new(), + root: 0, + } + } + + pub fn add_leaf(&mut self, name: &[u8], size: u64) -> u32 { + self.push(name, size, &[], 0) + } + + pub fn add_dir(&mut self, name: &[u8], size: u64, kids: &[u32]) -> u32 { + self.push(name, size, kids, DIR_FLAG) + } + + pub fn is_dir(&self, id: u32) -> bool { + self.nodes[id as usize].flags & DIR_FLAG != 0 + } + + pub fn size_of(&self, id: u32) -> u64 { + self.nodes[id as usize].size + } + + pub fn sum(&self, ids: &[u32]) -> u64 { + ids.iter() + .fold(0u64, |total, id| total.saturating_add(self.size_of(*id))) + } + + pub fn name(&self, id: u32) -> &str { + let node = &self.nodes[id as usize]; + let start = node.name_off as usize; + let end = start + node.name_len as usize; + &self.names[start..end] + } + + pub fn children(&self, id: u32) -> &[u32] { + let node = &self.nodes[id as usize]; + let start = node.child_off as usize; + let end = start + node.child_len as usize; + &self.kids[start..end] + } + + /// Move `other` onto the end of this tree. Node indexes inside `other` + /// stay valid relative to each other. Returns the new index of its root. + pub fn absorb(&mut self, other: Tree) -> u32 { + let name_base = self.names.len() as u32; + let node_base = self.nodes.len() as u32; + let kid_base = self.kids.len() as u32; + self.names.push_str(&other.names); + self.kids + .extend(other.kids.into_iter().map(|index| index + node_base)); + for node in other.nodes { + self.nodes.push(Node { + name_off: node.name_off + name_base, + name_len: node.name_len, + size: node.size, + child_off: node.child_off + kid_base, + child_len: node.child_len, + flags: node.flags, + }); + } + other.root + node_base + } + + pub fn append_packed(&self, id: u32, pack: &mut PackedForest) { + let mut order = Vec::new(); + let mut stack = vec![id]; + let mut seen = HashMap::new(); + while let Some(current) = stack.pop() { + if seen.insert(current, 0).is_some() { + continue; + } + order.push(current); + for child in self.children(current).iter().rev() { + stack.push(*child); + } + } + + let base = pack.n.len() as u32; + for (offset, node_id) in order.iter().enumerate() { + seen.insert(*node_id, base + offset as u32); + } + + for node_id in &order { + let node = &self.nodes[*node_id as usize]; + let child_off = pack.k.len() as u32; + for child in self.children(*node_id) { + pack.k.push(seen[child]); + } + pack.n.push(self.name(*node_id).to_string()); + pack.s.push(node.size); + pack.m + .extend_from_slice(&[child_off, node.child_len, node.flags]); + } + pack.r.push(seen[&id]); + } + + fn push(&mut self, name: &[u8], size: u64, kids: &[u32], flags: u32) -> u32 { + let (name_off, name_len) = self.intern(name); + let child_off = self.kids.len() as u32; + self.kids.extend_from_slice(kids); + let id = self.nodes.len() as u32; + self.nodes.push(Node { + name_off, + name_len, + size, + child_off, + child_len: kids.len() as u32, + flags, + }); + id + } + + fn intern(&mut self, name: &[u8]) -> (u32, u32) { + let off = self.names.len() as u32; + match std::str::from_utf8(name) { + Ok(text) => self.names.push_str(text), + Err(_) => self.names.push_str(&String::from_utf8_lossy(name)), + } + (off, self.names.len() as u32 - off) + } +} + +/// One JSON object for many nodes. `n[i]` is the name, `s[i]` the size. +/// `m` repeats `child_off, child_count, flags` per node (`flags & 1` is a directory). +/// `k` holds child indexes. `r` lists roots of this batch in emit order. +#[derive(Clone, serde::Serialize)] +pub struct PackedForest { + pub n: Vec, + pub s: Vec, + pub m: Vec, + pub k: Vec, + pub r: Vec, +} + +impl Default for PackedForest { + fn default() -> Self { + Self::new() + } +} + +impl PackedForest { + pub fn new() -> Self { + Self { + n: Vec::new(), + s: Vec::new(), + m: Vec::new(), + k: Vec::new(), + r: Vec::new(), + } + } + + pub fn is_empty(&self) -> bool { + self.r.is_empty() + } + + pub fn root_count(&self) -> usize { + self.r.len() + } +} diff --git a/src/app/analyze/page.tsx b/src/app/analyze/page.tsx index 9a3e0d9..1520395 100644 --- a/src/app/analyze/page.tsx +++ b/src/app/analyze/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useState, useRef, Suspense } from 'react' +import { useEffect, useState, useRef, useMemo, useLayoutEffect, Suspense } from 'react' import { useSearchParams, useRouter } from 'next/navigation' import { ArrowLeft, Loader2, X, ArrowUpDown } from 'lucide-react' import { invoke, Channel } from '@tauri-apps/api/core' @@ -11,6 +11,7 @@ import { useVirtualizer } from '@tanstack/react-virtual' import { toast } from 'sonner' import { Toaster } from '@/components/ui/sonner' import { useTranslation } from 'react-i18next' +import { CursorGlow } from '@/components/CursorGlow' interface FileNode { name: string @@ -20,6 +21,16 @@ interface FileNode { isDirectory: boolean } +// Flat scan payload. One slot per node: n[i] name, s[i] size. +// m repeats childOff, childCount, flags (bit 0 = directory). k holds child indexes. r lists roots. +interface PackedForest { + n: string[] + s: number[] + m: number[] + k: number[] + r: number[] +} + interface ChartData { name: string value: number @@ -61,48 +72,27 @@ function hashName(name: string): number { return Math.abs(hash) } -// Generate HSL color based on hash -function generateColorFromHash(name: string, depth: number = 0): string { - const hash = hashName(name) - - // Generate hue from hash (0-360) - const hue = hash % 360 - - // Generate saturation and lightness based on depth - // Deeper levels have lower saturation and higher lightness (lighter colors) - const saturation = Math.max(20, 80 - depth * 15) // 80% to 20% - const lightness = Math.min(80, 40 + depth * 8) // 40% to 80% - - return `hsl(${hue}, ${saturation}%, ${lightness}%)` +type Hsl = { h: number; s: number; l: number } + +function hue2rgb(p: number, q: number, t: number): number { + if (t < 0) t += 1 + if (t > 1) t -= 1 + if (t < 1/6) return p + (q - p) * 6 * t + if (t < 1/2) return q + if (t < 2/3) return p + (q - p) * (2/3 - t) * 6 + return p } -// Mix color with white based on depth -function mixColorWithWhite(baseColor: string, depth: number): string { - // Convert HSL to RGB for mixing - const hslMatch = baseColor.match(/hsl\((\d+),\s*(\d+)%,\s*(\d+)%\)/) - if (!hslMatch) return baseColor - - const hue = parseInt(hslMatch[1]) - const saturation = parseInt(hslMatch[2]) - const lightness = parseInt(hslMatch[3]) - - // Convert HSL to RGB +function mixHslWithWhite(hue: number, saturation: number, lightness: number, depth: number): Hsl { const h = hue / 360 const s = saturation / 100 const l = lightness / 100 - - const hue2rgb = (p: number, q: number, t: number) => { - if (t < 0) t += 1 - if (t > 1) t -= 1 - if (t < 1/6) return p + (q - p) * 6 * t - if (t < 1/2) return q - if (t < 2/3) return p + (q - p) * (2/3 - t) * 6 - return p - } - - let r, g, b + + let r: number + let g: number + let b: number if (s === 0) { - r = g = b = l // achromatic + r = g = b = l } else { const q = l < 0.5 ? l * (1 + s) : l + s - l * s const p = 2 * l - q @@ -110,24 +100,22 @@ function mixColorWithWhite(baseColor: string, depth: number): string { g = hue2rgb(p, q, h) b = hue2rgb(p, q, h - 1/3) } - - // Mix with white based on depth (more white = lighter) - const whiteAmount = Math.min(0.8, depth * 0.2) // Max 80% white at depth 4+ + + const whiteAmount = Math.min(0.8, depth * 0.2) r = r + (1 - r) * whiteAmount g = g + (1 - g) * whiteAmount b = b + (1 - b) * whiteAmount - - // Convert back to HSL + const max = Math.max(r, g, b) const min = Math.min(r, g, b) let newHue = 0 let newSaturation = 0 const newLightness = (max + min) / 2 - + if (max !== min) { const d = max - min newSaturation = newLightness > 0.5 ? d / (2 - max - min) : d / (max + min) - + switch (max) { case r: newHue = (g - b) / d + (g < b ? 6 : 0); break case g: newHue = (b - r) / d + 2; break @@ -135,23 +123,45 @@ function mixColorWithWhite(baseColor: string, depth: number): string { } newHue /= 6 } - - return `hsl(${Math.round(newHue * 360)}, ${Math.round(newSaturation * 100)}%, ${Math.round(newLightness * 100)}%)` + + return { + h: Math.round(newHue * 360), + s: Math.round(newSaturation * 100), + l: Math.round(newLightness * 100), + } } -// Generate color scheme for a name (base color + lighter variants) -function generateColorScheme(name: string): string[] { - const hash = hashName(name) - const hue = hash % 360 - - const schemes = [] - for (let i = 0; i < 5; i++) { - const saturation = Math.max(20, 80 - i * 15) - const lightness = Math.min(80, 40 + i * 8) - schemes.push(`hsl(${hue}, ${saturation}%, ${lightness}%)`) +function formatHsl(h: number, s: number, l: number): string { + return `hsl(${h}, ${s}%, ${l}%)` +} + +const MAX_MIX_DEPTH = 7 + +const MIXED_COLOR_BY_HUE: string[][] = Array.from({ length: 360 }, (_, hue) => { + const chain = [formatHsl(hue, 80, 40)] + let current: Hsl = { h: hue, s: 80, l: 40 } + for (let depth = 1; depth <= MAX_MIX_DEPTH; depth++) { + current = mixHslWithWhite(current.h, current.s, current.l, depth) + chain.push(formatHsl(current.h, current.s, current.l)) } - - return schemes + return chain +}) + +function colorFromHue(hue: number, depth: number): string { + if (depth <= MAX_MIX_DEPTH) return MIXED_COLOR_BY_HUE[hue][depth] + let current: Hsl = { h: hue, s: 80, l: 40 } + for (let d = 1; d <= depth; d++) { + current = mixHslWithWhite(current.h, current.s, current.l, d) + } + return formatHsl(current.h, current.s, current.l) +} + +function generateColorFromHash(name: string, depth: number = 0): string { + const hue = hashName(name) % 360 + if (depth === 0) return MIXED_COLOR_BY_HUE[hue][0] + const saturation = Math.max(20, 80 - depth * 15) + const lightness = Math.min(80, 40 + depth * 8) + return formatHsl(hue, saturation, lightness) } @@ -182,85 +192,56 @@ function formatBytesCompact(bytes: number): string { } } -function rebuildTreeFromCompactNodes(rootNode: FileNode, compactNodes: any[]): FileNode { - const nodeMap = new Map() - - const rootPathNormalized = rootNode.path.toLowerCase().replace(/\\/g, '/').replace(/\/+$/, '') - - const rootCopy = { ...rootNode } - nodeMap.set(rootPathNormalized, rootCopy) - - compactNodes.forEach((compactNode, index) => { - expandAndAddToMap(compactNode, rootNode.path, nodeMap) - }) - - const childrenMap = new Map() - - - nodeMap.forEach((node, normalizedPath) => { - if (normalizedPath === rootPathNormalized) { - return - } - - const lastSlashIndex = normalizedPath.lastIndexOf('/') - let parentPath: string - - if (lastSlashIndex <= 0) { - parentPath = rootPathNormalized - } else { - parentPath = normalizedPath.substring(0, lastSlashIndex) - - if (parentPath.length === 2 && parentPath.endsWith(':')) { - parentPath = rootPathNormalized - } - } - - if (!childrenMap.has(parentPath)) { - childrenMap.set(parentPath, []) - } - childrenMap.get(parentPath)!.push(node) - }) - - let assignedCount = 0 - nodeMap.forEach((node, normalizedPath) => { - const children = childrenMap.get(normalizedPath) || [] - if (children.length > 0) { - node.children = children - assignedCount++ - } else { - node.children = [] +function rebuildTreeFromCompactNodes(rootNode: FileNode, forests: PackedForest[]): FileNode { + const rootCopy: FileNode = { ...rootNode, children: [] } + for (const forest of forests) { + if (!forest?.n || !forest.r || !forest.m || !forest.k || !forest.s) continue + const nodes = unpackForest(forest) + for (const rootIndex of forest.r) { + const node = nodes[rootIndex] + if (!node) continue + assignPackedPaths(node, rootNode.path) + rootCopy.children!.push(node) } - }) - - const result = nodeMap.get(rootPathNormalized) - - return result || rootNode + } + return rootCopy } -function expandAndAddToMap(compactNode: any, parentPath: string, nodeMap: Map) { - let nodePath: string - if (parentPath.endsWith('\\') || parentPath.endsWith('/')) { - nodePath = `${parentPath}${compactNode.n}` - } else { - nodePath = `${parentPath}\\${compactNode.n}` +function unpackForest(forest: PackedForest): FileNode[] { + const count = forest.n.length + const nodes: FileNode[] = new Array(count) + for (let i = 0; i < count; i++) { + nodes[i] = { + name: forest.n[i], + size: forest.s[i], + path: '', + isDirectory: (forest.m[i * 3 + 2] & 1) !== 0, + children: [], + } } - - const normalizedPath = nodePath.toLowerCase().replace(/\\/g, '/').replace(/\/+$/, '') - - const node: FileNode = { - name: compactNode.n, - size: compactNode.s, - path: nodePath, - isDirectory: compactNode.d, - children: [] + for (let i = 0; i < count; i++) { + const childOff = forest.m[i * 3] + const childCount = forest.m[i * 3 + 1] + if (childCount > 0) { + const kids: FileNode[] = new Array(childCount) + for (let c = 0; c < childCount; c++) { + kids[c] = nodes[forest.k[childOff + c]] + } + nodes[i].children = kids + } } + return nodes +} - nodeMap.set(normalizedPath, node) - - if (compactNode.c && Array.isArray(compactNode.c)) { - compactNode.c.forEach((child: any) => { - expandAndAddToMap(child, nodePath, nodeMap) - }) +function assignPackedPaths(node: FileNode, parentPath: string) { + const nodePath = parentPath.endsWith('\\') || parentPath.endsWith('/') + ? `${parentPath}${node.name}` + : `${parentPath}\\${node.name}` + node.path = nodePath + if (node.children) { + for (const child of node.children) { + assignPackedPaths(child, nodePath) + } } } @@ -276,7 +257,6 @@ function AnalyzeContent() { const [isLoading, setIsLoading] = useState(true) const [scanProgress, setScanProgress] = useState<{ currentPath: string; filesScanned: number; scannedSize: number; estimatedTotal: number } | null>(null) const [diskInfo, setDiskInfo] = useState<{ totalSpace: number; availableSpace: number; usedSpace: number } | null>(null) - const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 }) const [scanStartTime, setScanStartTime] = useState(null) const [scanElapsedTime, setScanElapsedTime] = useState(0) const [scanCompleteTime, setScanCompleteTime] = useState(null) @@ -299,53 +279,51 @@ function AnalyzeContent() { const elapsedTimeIntervalRef = useRef(null) // Cache compact nodes from batches - const compactNodesCache = useRef([]) + const compactNodesCache = useRef([]) const svgRef = useRef(null) const [hoveredSectorId, setHoveredSectorId] = useState(null) - const [tooltip, setTooltip] = useState<{ x: number; y: number; content: string; label: string; size: string; icon: any; color: string } | null>(null) + const [tooltip, setTooltip] = useState<{ content: string; label: string; size: string; icon: any; color: string } | null>(null) - // Hover handlers with smart positioning const tooltipRef = useRef(null) + const tooltipMouseRef = useRef({ x: 0, y: 0 }) const listContainerRef = useRef(null) - const calculateTooltipPosition = (mouseX: number, mouseY: number) => { + const positionTooltip = (mouseX: number, mouseY: number) => { + const el = tooltipRef.current + if (!el) return + const offsetX = 8 const offsetY = 8 - - // Use actual tooltip dimensions if available, otherwise use estimates - const tooltipWidth = tooltipRef.current?.offsetWidth || 200 - const tooltipHeight = tooltipRef.current?.offsetHeight || 70 - - // Check if mouse is on left or right half of screen + const tooltipWidth = el.offsetWidth || 200 + const tooltipHeight = el.offsetHeight || 70 const isLeftHalf = mouseX < window.innerWidth / 2 - // Check if mouse is on top or bottom half of screen const isTopHalf = mouseY < window.innerHeight / 2 - - let x = isLeftHalf ? mouseX + offsetX : mouseX - tooltipWidth - offsetX - let y = isTopHalf ? mouseY + offsetY : mouseY - tooltipHeight - offsetY - - return { x, y } + const x = isLeftHalf ? mouseX + offsetX : mouseX - tooltipWidth - offsetX + const y = isTopHalf ? mouseY + offsetY : mouseY - tooltipHeight - offsetY + el.style.left = `${x}px` + el.style.top = `${y}px` } + useLayoutEffect(() => { + if (!tooltip) return + positionTooltip(tooltipMouseRef.current.x, tooltipMouseRef.current.y) + }, [tooltip]) + const handleHover = (sectorId: string, event: React.MouseEvent, label: string, name: string, size: string, icon: any, color: string) => { + tooltipMouseRef.current = { x: event.clientX, y: event.clientY } setHoveredSectorId(sectorId) - const pos = calculateTooltipPosition(event.clientX, event.clientY) - setTooltip({ - x: pos.x, - y: pos.y, - content: name, - label: label, - size: size, - icon: icon, - color: color + setTooltip(prev => { + if (prev && prev.content === name && prev.label === label && prev.size === size && prev.color === color && prev.icon === icon) { + return prev + } + return { content: name, label, size, icon, color } }) + positionTooltip(event.clientX, event.clientY) } const handleMouseMove = (event: React.MouseEvent) => { - if (tooltip) { - const pos = calculateTooltipPosition(event.clientX, event.clientY) - setTooltip(prev => prev ? { ...prev, x: pos.x, y: pos.y } : null) - } + tooltipMouseRef.current = { x: event.clientX, y: event.clientY } + positionTooltip(event.clientX, event.clientY) } const handleLeave = () => { setHoveredSectorId(null) @@ -593,24 +571,6 @@ function AnalyzeContent() { return filterNode(root) || root } - // Mouse tracking for cursor glow effect - useEffect(() => { - let animationFrame: number - - const handleMouseMove = (e: MouseEvent) => { - if (animationFrame) cancelAnimationFrame(animationFrame) - animationFrame = requestAnimationFrame(() => { - setMousePosition({ x: e.clientX, y: e.clientY }) - }) - } - - window.addEventListener('mousemove', handleMouseMove) - return () => { - window.removeEventListener('mousemove', handleMouseMove) - if (animationFrame) cancelAnimationFrame(animationFrame) - } - }, []) - // Sync hover states between chart and file list useEffect(() => { if (!svgRef.current) return @@ -667,12 +627,12 @@ function AnalyzeContent() { // Create channel for streaming batches const onBatch = new Channel<{ nodes: FileNode[]; - compact_nodes?: any[]; // Batch of compact nodes + compact_nodes?: PackedForest[]; total_scanned: number; total_size: number; is_complete: boolean; root_node?: FileNode; - compact_root?: any; // Compact format from backend + compact_root?: PackedForest; disk_info?: { total_space: number; available_space: number; used_space: number }; current_path?: string }>() @@ -803,7 +763,7 @@ function AnalyzeContent() { depth: number, startAngle: number, endAngle: number, - parentColor?: string + parentHue?: number ) => { if (depth >= maxDepth || !nodes || nodes.length === 0) return @@ -826,11 +786,10 @@ function AnalyzeContent() { return } - // Get color for this node - use parent color mixed with white for deeper levels - const color = depth === 0 + const color = depth === 0 ? generateColorFromHash(node.name, 0) - : parentColor - ? mixColorWithWhite(parentColor, depth) + : parentHue !== undefined + ? colorFromHue(parentHue, depth) : generateColorFromHash(node.name, depth) // Add to current layer @@ -855,7 +814,7 @@ function AnalyzeContent() { depth + 1, currentAngle, nodeEndAngle, - color + depth === 0 ? hashName(node.name) % 360 : parentHue ) } @@ -902,8 +861,8 @@ function AnalyzeContent() { const nodeAngleRange = 360 * proportion const nodeEndAngle = currentAngle + nodeAngleRange - // Generate color based on name hash - const color = generateColorFromHash(node.name, 0) + const hue = hashName(node.name) % 360 + const color = MIXED_COLOR_BY_HUE[hue][0] if (!layers.has(0)) { layers.set(0, []) @@ -921,7 +880,7 @@ function AnalyzeContent() { // Process children if (node.isDirectory && node.children && node.children.length > 0) { - buildHierarchy(node.children, 1, currentAngle, nodeEndAngle, color) + buildHierarchy(node.children, 1, currentAngle, nodeEndAngle, hue) } currentAngle = nodeEndAngle @@ -964,8 +923,8 @@ function AnalyzeContent() { const nodeEndAngle = tinyNodeAngle + nodeAngleRange if (node.isDirectory && node.children && node.children.length > 0) { - const tinyNodeColor = generateColorFromHash(node.name, 0) - buildHierarchy(node.children, 1, tinyNodeAngle, nodeEndAngle, tinyNodeColor) + const tinyHue = hashName(node.name) % 360 + buildHierarchy(node.children, 1, tinyNodeAngle, nodeEndAngle, tinyHue) } tinyNodeAngle = nodeEndAngle @@ -1073,8 +1032,14 @@ function AnalyzeContent() { } // Prepare data (must be before conditional returns) - const layers = prepareMultiLayerData(currentLevel) - const listData = prepareListData(currentLevel) + const layers = useMemo( + () => prepareMultiLayerData(currentLevel), + [currentLevel, diskInfo, data, t] + ) + const listData = useMemo( + () => prepareListData(currentLevel), + [currentLevel, diskInfo, data, sortBy, sortOrder] + ) // Virtual list using TanStack Virtual const rowVirtualizer = useVirtualizer({ @@ -1256,20 +1221,7 @@ function AnalyzeContent() {
- {/* Cursor Follow Glow */} -
-
-
+
@@ -1445,20 +1397,7 @@ function AnalyzeContent() {
- {/* Cursor Follow Glow */} -
-
-
+