diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7d451f6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,44 @@ +name: CI + +on: + push: + branches: [ main, master ] + pull_request: + branches: [ main, master ] + +jobs: + build: + runs-on: ubuntu-latest + env: + VITE_USE_PERF_LOGS: 'false' + USE_PERF_LOGS: 'false' + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Check import cycles (madge) + run: npm run check:imports + + - name: Run lint (biome) + run: npm run lint + + - name: Typecheck + run: npm run typecheck + + - name: Run tests and coverage + run: npm run test:coverage + + - name: Upload coverage + uses: actions/upload-artifact@v6 + with: + name: coverage-report + path: coverage diff --git a/.gitignore b/.gitignore index f621cf1..3aadf50 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ dist dist-ssr *.local coverage/ +test-results/ # Editor directories and files .vscode/* diff --git a/README.md b/README.md index 87c36e5..fed92c8 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ ![React](https://img.shields.io/badge/React-19.2-61dafb) ![TypeScript](https://img.shields.io/badge/TypeScript-5.9-3178c6) +[![CI](https://github.com///actions/workflows/ci.yml/badge.svg)](https://github.com///actions) + ## Возможности - 📁 Навигация по файловой системе с историей (назад/вперёд) diff --git a/biome.json b/biome.json index 0413e29..80f8429 100644 --- a/biome.json +++ b/biome.json @@ -30,7 +30,8 @@ "noStaticElementInteractions": "off", "useKeyWithClickEvents": "off", "useSemanticElements": "off" - } + }, + "style": {} } }, "overrides": [ @@ -56,6 +57,59 @@ "afterAll" ] } + }, + { + "includes": ["**/src/entities/**"], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "group": [ + "@/features/*", + "src/features/*", + "./src/features/*", + "*src/features/*" + ], + "message": "Importing from higher FSD layers into 'entities' is forbidden. Move logic to features or pass values via props/shared adapter." + }, + { + "group": ["@/widgets/*", "src/widgets/*", "./src/widgets/*", "*src/widgets/*"], + "message": "Importing from widgets into entities is forbidden." + }, + { + "group": ["@/pages/*", "src/pages/*", "./src/pages/*", "*src/pages/*"], + "message": "Importing from pages into entities is forbidden." + } + ] + } + } + } + } + } + }, + { + "includes": ["**/src/features/**"], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "group": ["@/widgets/*", "src/widgets/*", "./src/widgets/*", "*src/widgets/*"], + "message": "Importing from widgets into features is forbidden. Use shared adapters or move logic." + } + ] + } + } + } + } + } } ], "javascript": { diff --git a/e2e/file-row-hover.spec.ts b/e2e/file-row-hover.spec.ts new file mode 100644 index 0000000..9db3d3f --- /dev/null +++ b/e2e/file-row-hover.spec.ts @@ -0,0 +1,23 @@ +import type { Page } from "@playwright/test" +import { expect, test } from "@playwright/test" + +// NOTE: Use DEV_SERVER_URL or default; requires dev server running + +test.describe("FileRow hover & cursor", () => { + test("hover shows actions and cursor is pointer", async ({ page }: { page: Page }) => { + const base = process.env.DEV_SERVER_URL ?? "http://localhost:5173" + await page.goto(base) + + const row = page.locator('[data-testid^="file-row-"]').first() + await expect(row).toBeVisible() + + await row.hover() + const cursor = await row.evaluate((el: HTMLElement) => getComputedStyle(el).cursor) + expect(cursor).toBe("pointer") + + const actions = row.locator(".mr-2").first() + await expect(actions).toBeVisible() + const actionOpacity = await actions.evaluate((el: HTMLElement) => getComputedStyle(el).opacity) + expect(Number(actionOpacity)).toBeGreaterThan(0) + }) +}) diff --git a/e2e/recent-folders-hover.spec.ts b/e2e/recent-folders-hover.spec.ts new file mode 100644 index 0000000..0398ee6 --- /dev/null +++ b/e2e/recent-folders-hover.spec.ts @@ -0,0 +1,44 @@ +import type { Page } from "@playwright/test" +import { expect, test } from "@playwright/test" + +// NOTE: Use DEV_SERVER_URL or default; ensure dev server and recent folders exist + +test.describe("RecentFolders hover & cursor", () => { + test("hover shows remove button and cursor is pointer", async ({ page }: { page: Page }) => { + const base = process.env.DEV_SERVER_URL ?? "http://localhost:5173" + + // Hydrate recent-folders store for deterministic test data + await page.addInitScript(() => { + try { + const key = "recent-folders" + const payload = { + state: { folders: [{ path: "/one", name: "One", lastVisited: Date.now() }] }, + } + localStorage.setItem(key, JSON.stringify(payload)) + } catch { + /* ignore */ + } + }) + + await page.goto(base) + + await page.waitForSelector("text=Недавние", { state: "visible" }) + + const folder = page.locator('[aria-label^="Open "]').first() + await expect(folder).toBeVisible() + + await folder.hover() + const cursor = await folder.evaluate((el: HTMLElement) => getComputedStyle(el).cursor) + expect(cursor).toBe("pointer") + + const aria = await folder.getAttribute("aria-label") + const name = aria?.replace(/^Open\s*/, "") ?? "" + const removeBtn = page.locator(`[aria-label="Remove ${name}"]`).first() + if ((await removeBtn.count()) > 0) { + const opacity = await removeBtn.evaluate((el: HTMLElement) => getComputedStyle(el).opacity) + expect(Number(opacity)).toBeGreaterThan(0) + } else { + test.skip(true, "No remove button found for the folder (no recent items?)") + } + }) +}) diff --git a/e2e/sidebar-persistence.spec.ts b/e2e/sidebar-persistence.spec.ts new file mode 100644 index 0000000..c5bff89 --- /dev/null +++ b/e2e/sidebar-persistence.spec.ts @@ -0,0 +1,50 @@ +import { expect, test } from "@playwright/test" + +// NOTE: Use DEV_SERVER_URL or default; ensure dev server and recent folders exist + +test("Sidebar sections persist collapsed state across reload", async ({ page }) => { + const base = process.env.DEV_SERVER_URL ?? "http://localhost:5173" + + // Hydrate recent-folders store for deterministic test data + await page.addInitScript(() => { + try { + const key = "recent-folders" + const payload = { + state: { folders: [{ path: "/one", name: "One", lastVisited: Date.now() }] }, + } + localStorage.setItem(key, JSON.stringify(payload)) + } catch { + /* ignore */ + } + }) + + await page.goto(base) + + await page.waitForSelector("text=Недавние", { state: "visible" }) + + const folderBtn = page.locator('[aria-label^="Open "]').first() + if ((await folderBtn.count()) === 0) { + test.skip(true, "No recent items to exercise persistence") + return + } + + await page.click("text=Недавние") + + const raw = await page.evaluate(() => localStorage.getItem("layout-storage")) + expect(raw).not.toBeNull() + expect(raw).toContain('"expandedSections"') + expect(raw).toContain('"recent":false') + const parsed = JSON.parse(raw || "{}") + expect(parsed?.layout?.expandedSections?.recent).toBe(false) + + await page.reload() + await page.waitForSelector("text=Недавние", { state: "visible" }) + + const raw2 = await page.evaluate(() => localStorage.getItem("layout-storage")) + if (raw2?.includes('"recent":false')) { + await page.waitForTimeout(100) + expect(await page.locator('[aria-label^="Open "]').count()).toBe(0) + } else { + throw new Error(`Persisted layout not found after reload: ${String(raw2)}`) + } +}) diff --git a/package-lock.json b/package-lock.json index 2aeeb6b..52fb626 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ }, "devDependencies": { "@biomejs/biome": "^2.3.9", + "@playwright/test": "^1.57.0", "@tauri-apps/cli": "^2.9.6", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.1", @@ -37,12 +38,16 @@ "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.2", "@vitest/coverage-v8": "^4.0.15", + "axe-core": "^4.8.0", "globals": "^16.5.0", "jsdom": "^27.3.0", + "madge": "^8.0.0", "tailwindcss": "^4.1.18", "typescript": "~5.9.3", "vite": "^7.3.0", - "vitest": "^4.0.15" + "vitest": "^4.0.15", + "vitest-axe": "^0.1.0", + "zod": "^3.21.4" } }, "node_modules/@acemir/cssom": { @@ -717,6 +722,20 @@ "node": ">=18" } }, + "node_modules/@dependents/detective-less": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@dependents/detective-less/-/detective-less-5.0.1.tgz", + "integrity": "sha512-Y6+WUMsTFWE5jb20IFP4YGa5IrGY/+a/FbOSjDF/wz9gepU2hwCYSXRHP/vPwBvwcY3SVMASt4yXxbXNXigmZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "gonzales-pe": "^4.3.0", + "node-source-walk": "^7.0.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.1", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.1.tgz", @@ -1216,6 +1235,22 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@playwright/test": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz", + "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.57.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@radix-ui/number": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", @@ -2893,6 +2928,96 @@ } } }, + "node_modules/@ts-graphviz/adapter": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@ts-graphviz/adapter/-/adapter-2.0.6.tgz", + "integrity": "sha512-kJ10lIMSWMJkLkkCG5gt927SnGZcBuG0s0HHswGzcHTgvtUe7yk5/3zTEr0bafzsodsOq5Gi6FhQeV775nC35Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ts-graphviz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/ts-graphviz" + } + ], + "license": "MIT", + "dependencies": { + "@ts-graphviz/common": "^2.1.5" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ts-graphviz/ast": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@ts-graphviz/ast/-/ast-2.0.7.tgz", + "integrity": "sha512-e6+2qtNV99UT6DJSoLbHfkzfyqY84aIuoV8Xlb9+hZAjgpum8iVHprGeAMQ4rF6sKUAxrmY8rfF/vgAwoPc3gw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ts-graphviz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/ts-graphviz" + } + ], + "license": "MIT", + "dependencies": { + "@ts-graphviz/common": "^2.1.5" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ts-graphviz/common": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@ts-graphviz/common/-/common-2.1.5.tgz", + "integrity": "sha512-S6/9+T6x8j6cr/gNhp+U2olwo1n0jKj/682QVqsh7yXWV6ednHYqxFw0ZsY3LyzT0N8jaZ6jQY9YD99le3cmvg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ts-graphviz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/ts-graphviz" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@ts-graphviz/core": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@ts-graphviz/core/-/core-2.0.7.tgz", + "integrity": "sha512-w071DSzP94YfN6XiWhOxnLpYT3uqtxJBDYdh6Jdjzt+Ce6DNspJsPQgpC7rbts/B8tEkq0LHoYuIF/O5Jh5rPg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ts-graphviz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/ts-graphviz" + } + ], + "license": "MIT", + "dependencies": { + "@ts-graphviz/ast": "^2.0.7", + "@ts-graphviz/common": "^2.1.5" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -3000,6 +3125,144 @@ "@types/react": "^19.2.0" } }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.50.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.50.0.tgz", + "integrity": "sha512-Cg/nQcL1BcoTijEWyx4mkVC56r8dj44bFDvBdygifuS20f3OZCHmFbjF34DPSi07kwlFvqfv/xOLnJ5DquxSGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.50.0", + "@typescript-eslint/types": "^8.50.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.50.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.50.0.tgz", + "integrity": "sha512-vxd3G/ybKTSlm31MOA96gqvrRGv9RJ7LGtZCn2Vrc5htA0zCDvcMqUkifcjrWNNKXHUU3WCkYOzzVSFBd0wa2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.50.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.50.0.tgz", + "integrity": "sha512-iX1mgmGrXdANhhITbpp2QQM2fGehBse9LbTf0sidWK6yg/NE+uhV5dfU1g6EYPlcReYmkE9QLPq/2irKAmtS9w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.50.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.50.0.tgz", + "integrity": "sha512-W7SVAGBR/IX7zm1t70Yujpbk+zdPq/u4soeFSknWFdXIFuWsBGBOUu/Tn/I6KHSKvSh91OiMuaSnYp3mtPt5IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.50.0", + "@typescript-eslint/tsconfig-utils": "8.50.0", + "@typescript-eslint/types": "8.50.0", + "@typescript-eslint/visitor-keys": "8.50.0", + "debug": "^4.3.4", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.50.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.50.0.tgz", + "integrity": "sha512-Xzmnb58+Db78gT/CCj/PVCvK+zxbnsw6F+O1oheYszJbBSdEjVhQi3C/Xttzxgi/GLmpvOggRs1RFpiJ8+c34Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.50.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, "node_modules/@vitejs/plugin-react": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.2.tgz", @@ -3164,6 +3427,94 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@vue/compiler-core": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.26.tgz", + "integrity": "sha512-vXyI5GMfuoBCnv5ucIT7jhHKl55Y477yxP6fc4eUswjP8FG3FFVFd41eNDArR+Uk3QKn2Z85NavjaxLxOC19/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@vue/shared": "3.5.26", + "entities": "^7.0.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-core/node_modules/entities": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.0.tgz", + "integrity": "sha512-FDWG5cmEYf2Z00IkYRhbFrwIwvdFKH07uV8dvNy0omp/Qb1xcyCWp2UDtcwJF4QZZvk0sLudP6/hAu42TaqVhQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/@vue/compiler-core/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.26.tgz", + "integrity": "sha512-y1Tcd3eXs834QjswshSilCBnKGeQjQXB6PqFn/1nxcQw4pmG42G8lwz+FZPAZAby6gZeHSt/8LMPfZ4Rb+Bd/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.26", + "@vue/shared": "3.5.26" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.26.tgz", + "integrity": "sha512-egp69qDTSEZcf4bGOSsprUr4xI73wfrY5oRs6GSgXFTiHrWj4Y3X5Ydtip9QMqiCMCPVwLglB9GBxXtTadJ3mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@vue/compiler-core": "3.5.26", + "@vue/compiler-dom": "3.5.26", + "@vue/compiler-ssr": "3.5.26", + "@vue/shared": "3.5.26", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.6", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-sfc/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.26.tgz", + "integrity": "sha512-lZT9/Y0nSIRUPVvapFJEVDbEXruZh2IYHMk2zTtEgJSlP5gVOqeWXH54xDKAaFS4rTnDeDBQUYDtxKyoW9FwDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.26", + "@vue/shared": "3.5.26" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.26.tgz", + "integrity": "sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A==", + "dev": true, + "license": "MIT" + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -3180,7 +3531,6 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -3199,6 +3549,20 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/app-module-path": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/app-module-path/-/app-module-path-2.2.0.tgz", + "integrity": "sha512-gkco+qxENJV+8vFcDiiFhuoSvRXb2a/QPqpSoWhVz829VNJfOTnELbBmPmNKFxf3xdNnw4DWCkzkDaavcX/1YQ==", + "dev": true, + "license": "BSD-2-Clause" + }, "node_modules/aria-hidden": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", @@ -3231,6 +3595,16 @@ "node": ">=12" } }, + "node_modules/ast-module-types": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ast-module-types/-/ast-module-types-6.0.1.tgz", + "integrity": "sha512-WHw67kLXYbZuHTmcdbIrVArCq5wxo6NEuj3hiYAWr8mwJeC+C2mMCIBIWCiDoCye/OF/xelc+teJ1ERoWmnEIA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/ast-v8-to-istanbul": { "version": "0.3.9", "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.9.tgz", @@ -3250,6 +3624,44 @@ "dev": true, "license": "MIT" }, + "node_modules/axe-core": { + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.0.tgz", + "integrity": "sha512-ilYanEU8vxxBexpJd8cWM4ElSQq4QctCLKih0TSfjIfCQTeyH/6zVrmIJfLPrKTKJRbiG+cfnZbQIjAlJmF1jQ==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.9.8", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.8.tgz", @@ -3270,6 +3682,29 @@ "require-from-string": "^2.0.2" } }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/browserslist": { "version": "4.28.1", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", @@ -3304,6 +3739,31 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001760", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001760.tgz", @@ -3335,6 +3795,75 @@ "node": ">=18" } }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -3344,6 +3873,50 @@ "node": ">=6" } }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -3433,6 +4006,58 @@ "dev": true, "license": "MIT" }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/dependency-tree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/dependency-tree/-/dependency-tree-11.2.0.tgz", + "integrity": "sha512-+C1H3mXhcvMCeu5i2Jpg9dc0N29TWTuT6vJD7mHLAfVmAbo9zW8NlkvQ1tYd3PDMab0IRQM0ccoyX68EZtx9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^12.1.0", + "filing-cabinet": "^5.0.3", + "precinct": "^12.2.0", + "typescript": "^5.8.3" + }, + "bin": { + "dependency-tree": "bin/cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/dependency-tree/node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -3458,13 +4083,153 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, + "node_modules/detective-amd": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/detective-amd/-/detective-amd-6.0.1.tgz", + "integrity": "sha512-TtyZ3OhwUoEEIhTFoc1C9IyJIud3y+xYkSRjmvCt65+ycQuc3VcBrPRTMWoO/AnuCyOB8T5gky+xf7Igxtjd3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "ast-module-types": "^6.0.1", + "escodegen": "^2.1.0", + "get-amd-module-type": "^6.0.1", + "node-source-walk": "^7.0.1" + }, + "bin": { + "detective-amd": "bin/cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/detective-cjs": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/detective-cjs/-/detective-cjs-6.0.1.tgz", + "integrity": "sha512-tLTQsWvd2WMcmn/60T2inEJNhJoi7a//PQ7DwRKEj1yEeiQs4mrONgsUtEJKnZmrGWBBmE0kJ1vqOG/NAxwaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ast-module-types": "^6.0.1", + "node-source-walk": "^7.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/detective-es6": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/detective-es6/-/detective-es6-5.0.1.tgz", + "integrity": "sha512-XusTPuewnSUdoxRSx8OOI6xIA/uld/wMQwYsouvFN2LAg7HgP06NF1lHRV3x6BZxyL2Kkoih4ewcq8hcbGtwew==", + "dev": true, + "license": "MIT", + "dependencies": { + "node-source-walk": "^7.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/detective-postcss": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/detective-postcss/-/detective-postcss-7.0.1.tgz", + "integrity": "sha512-bEOVpHU9picRZux5XnwGsmCN4+8oZo7vSW0O0/Enq/TO5R2pIAP2279NsszpJR7ocnQt4WXU0+nnh/0JuK4KHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-url": "^1.2.4", + "postcss-values-parser": "^6.0.2" + }, + "engines": { + "node": "^14.0.0 || >=16.0.0" + }, + "peerDependencies": { + "postcss": "^8.4.47" + } + }, + "node_modules/detective-sass": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/detective-sass/-/detective-sass-6.0.1.tgz", + "integrity": "sha512-jSGPO8QDy7K7pztUmGC6aiHkexBQT4GIH+mBAL9ZyBmnUIOFbkfZnO8wPRRJFP/QP83irObgsZHCoDHZ173tRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "gonzales-pe": "^4.3.0", + "node-source-walk": "^7.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/detective-scss": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/detective-scss/-/detective-scss-5.0.1.tgz", + "integrity": "sha512-MAyPYRgS6DCiS6n6AoSBJXLGVOydsr9huwXORUlJ37K3YLyiN0vYHpzs3AdJOgHobBfispokoqrEon9rbmKacg==", + "dev": true, + "license": "MIT", + "dependencies": { + "gonzales-pe": "^4.3.0", + "node-source-walk": "^7.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/detective-stylus": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/detective-stylus/-/detective-stylus-5.0.1.tgz", + "integrity": "sha512-Dgn0bUqdGbE3oZJ+WCKf8Dmu7VWLcmRJGc6RCzBgG31DLIyai9WAoEhYRgIHpt/BCRMrnXLbGWGPQuBUrnF0TA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/detective-typescript": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/detective-typescript/-/detective-typescript-14.0.0.tgz", + "integrity": "sha512-pgN43/80MmWVSEi5LUuiVvO/0a9ss5V7fwVfrJ4QzAQRd3cwqU1SfWGXJFcNKUqoD5cS+uIovhw5t/0rSeC5Mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/typescript-estree": "^8.23.0", + "ast-module-types": "^6.0.1", + "node-source-walk": "^7.0.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "typescript": "^5.4.4" + } + }, + "node_modules/detective-vue2": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/detective-vue2/-/detective-vue2-2.2.0.tgz", + "integrity": "sha512-sVg/t6O2z1zna8a/UIV6xL5KUa2cMTQbdTIIvqNM0NIPswp52fe43Nwmbahzj3ww4D844u/vC2PYfiGLvD3zFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@dependents/detective-less": "^5.0.1", + "@vue/compiler-sfc": "^3.5.13", + "detective-es6": "^5.0.1", + "detective-sass": "^6.0.1", + "detective-scss": "^5.0.1", + "detective-stylus": "^5.0.1", + "detective-typescript": "^14.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "typescript": "^5.4.4" + } + }, "node_modules/dom-accessibility-api": { "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/electron-to-chromium": { "version": "1.5.267", @@ -3557,6 +4322,65 @@ "node": ">=6" } }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -3567,6 +4391,16 @@ "@types/estree": "^1.0.0" } }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -3594,6 +4428,49 @@ } } }, + "node_modules/filing-cabinet": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/filing-cabinet/-/filing-cabinet-5.0.3.tgz", + "integrity": "sha512-PlPcMwVWg60NQkhvfoxZs4wEHjhlOO/y7OAm4sKM60o1Z9nttRY4mcdQxp/iZ+kg/Vv6Hw1OAaTbYVM9DA9pYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "app-module-path": "^2.2.0", + "commander": "^12.1.0", + "enhanced-resolve": "^5.18.0", + "module-definition": "^6.0.1", + "module-lookup-amd": "^9.0.3", + "resolve": "^1.22.10", + "resolve-dependency-path": "^4.0.1", + "sass-lookup": "^6.1.0", + "stylus-lookup": "^6.1.0", + "tsconfig-paths": "^4.2.0", + "typescript": "^5.7.3" + }, + "bin": { + "filing-cabinet": "bin/cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/filing-cabinet/node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -3608,6 +4485,16 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -3618,6 +4505,20 @@ "node": ">=6.9.0" } }, + "node_modules/get-amd-module-type": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-amd-module-type/-/get-amd-module-type-6.0.1.tgz", + "integrity": "sha512-MtjsmYiCXcYDDrGqtNbeIYdAl85n+5mSv2r3FbzER/YV3ZILw4HNNIw34HuV5pyl0jzs6GFYU1VHVEefhgcNHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ast-module-types": "^6.0.1", + "node-source-walk": "^7.0.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/get-nonce": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", @@ -3627,6 +4528,35 @@ "node": ">=6" } }, + "node_modules/get-own-enumerable-property-symbols": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz", + "integrity": "sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==", + "dev": true, + "license": "ISC" + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/globals": { "version": "16.5.0", "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", @@ -3640,6 +4570,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/gonzales-pe": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/gonzales-pe/-/gonzales-pe-4.3.0.tgz", + "integrity": "sha512-otgSPpUmdWJ43VXyiNgEYE4luzHCL2pz4wQ0OnDluC6Eg4Ko3Vexy/SrSynglw/eR+OhkzmqFCZa/OFa/RgAOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5" + }, + "bin": { + "gonzales": "bin/gonzales.js" + }, + "engines": { + "node": ">=0.6.0" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -3656,6 +4602,19 @@ "node": ">=8" } }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/html-encoding-sniffer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", @@ -3674,66 +4633,192 @@ "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "dev": true, - "license": "MIT" + "license": "MIT" + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", "dev": true, "license": "MIT", "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" + "hasown": "^2.0.2" }, "engines": { - "node": ">= 14" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "node_modules/is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", "dev": true, "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, "engines": { - "node": ">= 14" + "node": ">=8" } }, - "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "node_modules/is-obj": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz", + "integrity": "sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==", "dev": true, "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, "engines": { "node": ">=0.10.0" } }, - "node_modules/indent-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-regexp": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-1.0.0.tgz", + "integrity": "sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA==", "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": ">=0.10.0" } }, - "node_modules/is-potential-custom-element-name": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", - "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-url": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz", + "integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==", "dev": true, "license": "MIT" }, + "node_modules/is-url-superb": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-url-superb/-/is-url-superb-4.0.0.tgz", + "integrity": "sha512-GI+WjezhPPcbM+tqE9LnmsY5qqjwHzTvjJ36wxYX5ujNXefSUJ/T17r5bqDV8yLhcgB59KTPNOc9O9cmHTPWsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", @@ -4119,6 +5204,30 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lodash-es": { + "version": "4.17.22", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.22.tgz", + "integrity": "sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -4149,6 +5258,45 @@ "lz-string": "bin/bin.js" } }, + "node_modules/madge": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/madge/-/madge-8.0.0.tgz", + "integrity": "sha512-9sSsi3TBPhmkTCIpVQF0SPiChj1L7Rq9kU2KDG1o6v2XH9cCw086MopjVCD+vuoL5v8S77DTbVopTO8OUiQpIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2", + "commander": "^7.2.0", + "commondir": "^1.0.1", + "debug": "^4.3.4", + "dependency-tree": "^11.0.0", + "ora": "^5.4.1", + "pluralize": "^8.0.0", + "pretty-ms": "^7.0.1", + "rc": "^1.2.8", + "stream-to-array": "^2.3.0", + "ts-graphviz": "^2.1.2", + "walkdir": "^0.4.1" + }, + "bin": { + "madge": "bin/cli.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "individual", + "url": "https://www.paypal.me/pahen" + }, + "peerDependencies": { + "typescript": "^5.4.4" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -4206,6 +5354,16 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -4216,6 +5374,75 @@ "node": ">=4" } }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/module-definition": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/module-definition/-/module-definition-6.0.1.tgz", + "integrity": "sha512-FeVc50FTfVVQnolk/WQT8MX+2WVcDnTGiq6Wo+/+lJ2ET1bRVi3HG3YlJUfqagNMc/kUlFSoR96AJkxGpKz13g==", + "dev": true, + "license": "MIT", + "dependencies": { + "ast-module-types": "^6.0.1", + "node-source-walk": "^7.0.1" + }, + "bin": { + "module-definition": "bin/cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/module-lookup-amd": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/module-lookup-amd/-/module-lookup-amd-9.0.5.tgz", + "integrity": "sha512-Rs5FVpVcBYRHPLuhHOjgbRhosaQYLtEo3JIeDIbmNo7mSssi1CTzwMh8v36gAzpbzLGXI9wB/yHh+5+3fY1QVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^12.1.0", + "glob": "^7.2.3", + "requirejs": "^2.3.7", + "requirejs-config-file": "^4.0.0" + }, + "bin": { + "lookup-amd": "bin/cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/module-lookup-amd/node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -4248,6 +5475,19 @@ "dev": true, "license": "MIT" }, + "node_modules/node-source-walk": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/node-source-walk/-/node-source-walk-7.0.1.tgz", + "integrity": "sha512-3VW/8JpPqPvnJvseXowjZcirPisssnBuDikk6JIZ8jQzF7KJQX52iPFX4RYYxLycYH7IbMRSPUOga/esVjy5Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.26.7" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/obug": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", @@ -4259,6 +5499,66 @@ ], "license": "MIT" }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-2.1.0.tgz", + "integrity": "sha512-kHt7kzLoS9VBZfUsiKjv43mr91ea+U05EyKkEtqp7vNbHxmaVuEqN7XxeEVnGrMtYOAxGrDElSi96K7EgO1zCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/parse5": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", @@ -4272,6 +5572,23 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -4297,6 +5614,63 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", + "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.57.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", + "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -4322,7 +5696,65 @@ "source-map-js": "^1.2.1" }, "engines": { - "node": "^10 || ^12 || >=14" + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-values-parser": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/postcss-values-parser/-/postcss-values-parser-6.0.2.tgz", + "integrity": "sha512-YLJpK0N1brcNJrs9WatuJFtHaV9q5aAOj+S4DI5S7jgHlRfm0PIbDCAFRYMQD5SHq7Fy6xsDhyutgS0QOAs0qw==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "color-name": "^1.1.4", + "is-url-superb": "^4.0.0", + "quote-unquote": "^1.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "postcss": "^8.2.9" + } + }, + "node_modules/precinct": { + "version": "12.2.0", + "resolved": "https://registry.npmjs.org/precinct/-/precinct-12.2.0.tgz", + "integrity": "sha512-NFBMuwIfaJ4SocE9YXPU/n4AcNSoFMVFjP72nvl3cx69j/ke61/hPOWFREVxLkFhhEGnA8ZuVfTqJBa+PK3b5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@dependents/detective-less": "^5.0.1", + "commander": "^12.1.0", + "detective-amd": "^6.0.1", + "detective-cjs": "^6.0.1", + "detective-es6": "^5.0.1", + "detective-postcss": "^7.0.1", + "detective-sass": "^6.0.1", + "detective-scss": "^5.0.1", + "detective-stylus": "^5.0.1", + "detective-typescript": "^14.0.0", + "detective-vue2": "^2.2.0", + "module-definition": "^6.0.1", + "node-source-walk": "^7.0.1", + "postcss": "^8.5.1", + "typescript": "^5.7.3" + }, + "bin": { + "precinct": "bin/cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/precinct/node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" } }, "node_modules/pretty-format": { @@ -4341,6 +5773,22 @@ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } }, + "node_modules/pretty-ms": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-7.0.1.tgz", + "integrity": "sha512-973driJZvxiGOQ5ONsFhOF/DtzPMOMtgC11kCpUrPGMTgqp2q/1gwzCquocrN33is0VZ5GFHXZYMM9l6h67v2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse-ms": "^2.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -4351,6 +5799,29 @@ "node": ">=6" } }, + "node_modules/quote-unquote": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/quote-unquote/-/quote-unquote-1.0.0.tgz", + "integrity": "sha512-twwRO/ilhlG/FIgYeKGFqyHhoEhqgnKVkcmqMKi2r524gz3ZbDTcyFt38E9xjJI2vT+KbRNHVbnJ/e0I25Azwg==", + "dev": true, + "license": "MIT" + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, "node_modules/react": { "version": "19.2.3", "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", @@ -4469,6 +5940,21 @@ } } }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -4493,6 +5979,79 @@ "node": ">=0.10.0" } }, + "node_modules/requirejs": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/requirejs/-/requirejs-2.3.8.tgz", + "integrity": "sha512-7/cTSLOdYkNBNJcDMWf+luFvMriVm7eYxp4BcFCsAX0wF421Vyce5SXP17c+Jd5otXKGNehIonFlyQXSowL6Mw==", + "dev": true, + "license": "MIT", + "bin": { + "r_js": "bin/r.js", + "r.js": "bin/r.js" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/requirejs-config-file": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/requirejs-config-file/-/requirejs-config-file-4.0.0.tgz", + "integrity": "sha512-jnIre8cbWOyvr8a5F2KuqBnY+SDA4NXr/hzEZJG79Mxm2WiFQz2dzhC8ibtPJS7zkmBEl1mxSwp5HhC1W4qpxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esprima": "^4.0.0", + "stringify-object": "^3.2.1" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-dependency-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/resolve-dependency-path/-/resolve-dependency-path-4.0.1.tgz", + "integrity": "sha512-YQftIIC4vzO9UMhO/sCgXukNyiwVRCVaxiWskCBy7Zpqkplm8kTAISZ8O1MoKW1ca6xzgLUBjZTcDgypXvXxiQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/rollup": { "version": "4.53.5", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.5.tgz", @@ -4534,6 +6093,27 @@ "fsevents": "~2.3.2" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -4541,6 +6121,33 @@ "dev": true, "license": "MIT" }, + "node_modules/sass-lookup": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/sass-lookup/-/sass-lookup-6.1.0.tgz", + "integrity": "sha512-Zx+lVyoWqXZxHuYWlTA17Z5sczJ6braNT2C7rmClw+c4E7r/n911Zwss3h1uHI9reR5AgHZyNHF7c2+VIp5AUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^12.1.0", + "enhanced-resolve": "^5.18.0" + }, + "bin": { + "sass-lookup": "bin/cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/sass-lookup/node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", @@ -4577,6 +6184,24 @@ "dev": true, "license": "ISC" }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -4600,6 +6225,64 @@ "dev": true, "license": "MIT" }, + "node_modules/stream-to-array": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/stream-to-array/-/stream-to-array-2.3.0.tgz", + "integrity": "sha512-UsZtOYEn4tWU2RGLOXr/o/xjRBftZRlG3dEWoaHr8j4GuypJ3isitGbVyjQKAuMu+xbiop8q224TjiZWc4XTZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.1.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/stringify-object": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-3.3.0.tgz", + "integrity": "sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "get-own-enumerable-property-symbols": "^3.0.0", + "is-obj": "^1.0.1", + "is-regexp": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/strip-indent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", @@ -4613,6 +6296,42 @@ "node": ">=8" } }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stylus-lookup": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/stylus-lookup/-/stylus-lookup-6.1.0.tgz", + "integrity": "sha512-5QSwgxAzXPMN+yugy61C60PhoANdItfdjSEZR8siFwz7yL9jTmV0UBKDCfn3K8GkGB4g0Y9py7vTCX8rFu4/pQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^12.1.0" + }, + "bin": { + "stylus-lookup": "bin/cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/stylus-lookup/node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -4626,6 +6345,19 @@ "node": ">=8" } }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -4751,6 +6483,60 @@ "node": ">=20" } }, + "node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-graphviz": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/ts-graphviz/-/ts-graphviz-2.1.6.tgz", + "integrity": "sha512-XyLVuhBVvdJTJr2FJJV2L1pc4MwSjMhcunRVgDE9k4wbb2ee7ORYnPewxMWUav12vxyfUM686MSGsqnVRIInuw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ts-graphviz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/ts-graphviz" + } + ], + "license": "MIT", + "dependencies": { + "@ts-graphviz/adapter": "^2.0.6", + "@ts-graphviz/ast": "^2.0.7", + "@ts-graphviz/common": "^2.1.5", + "@ts-graphviz/core": "^2.0.7" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tsconfig-paths": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", + "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "json5": "^2.2.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -4852,6 +6638,13 @@ } } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, "node_modules/vite": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz", @@ -5004,6 +6797,37 @@ } } }, + "node_modules/vitest-axe": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/vitest-axe/-/vitest-axe-0.1.0.tgz", + "integrity": "sha512-jvtXxeQPg8R/2ANTY8QicA5pvvdRP4F0FsVUAHANJ46YCDASie/cuhlSzu0DGcLmZvGBSBNsNuK3HqfaeknyvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "aria-query": "^5.0.0", + "axe-core": "^4.4.2", + "chalk": "^5.0.1", + "dom-accessibility-api": "^0.5.14", + "lodash-es": "^4.17.21", + "redent": "^3.0.0" + }, + "peerDependencies": { + "vitest": ">=0.16.0" + } + }, + "node_modules/vitest-axe/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", @@ -5017,6 +6841,26 @@ "node": ">=18" } }, + "node_modules/walkdir": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/walkdir/-/walkdir-0.4.1.tgz", + "integrity": "sha512-3eBwRyEln6E1MSzcxcVpQIhRG8Q1jLvEqRmCZqS3dsfXEDR/AhOF4d+jHg1qvDCpYaVRZjENPQyrVxAkQqxPgQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "dev": true, + "license": "MIT", + "dependencies": { + "defaults": "^1.0.3" + } + }, "node_modules/webidl-conversions": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.0.tgz", @@ -5081,6 +6925,13 @@ "node": ">=8" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, "node_modules/ws": { "version": "8.18.3", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", @@ -5127,6 +6978,16 @@ "dev": true, "license": "ISC" }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/zustand": { "version": "5.0.9", "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.9.tgz", diff --git a/package.json b/package.json index 1a060da..e59841e 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "check": "biome check .", "check:fix": "biome check . --write", "lint:all": "npm run check && npm run lint:rust", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "check:imports": "npx madge --circular src" }, "dependencies": { "@radix-ui/react-context-menu": "^2.2.16", @@ -42,6 +43,7 @@ }, "devDependencies": { "@biomejs/biome": "^2.3.9", + "@playwright/test": "^1.57.0", "@tauri-apps/cli": "^2.9.6", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.1", @@ -50,11 +52,15 @@ "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.2", "@vitest/coverage-v8": "^4.0.15", + "axe-core": "^4.8.0", "globals": "^16.5.0", "jsdom": "^27.3.0", + "madge": "^8.0.0", "tailwindcss": "^4.1.18", "typescript": "~5.9.3", "vite": "^7.3.0", - "vitest": "^4.0.15" + "vitest": "^4.0.15", + "vitest-axe": "^0.1.0", + "zod": "^3.21.4" } } diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 40db348..c32f4ab 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -231,6 +231,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + [[package]] name = "bitflags" version = "1.3.2" @@ -475,6 +481,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "combine" version = "4.6.7" @@ -602,6 +614,12 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.7" @@ -943,6 +961,21 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "exr" +version = "1.74.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "rayon-core", + "smallvec", + "zune-inflate", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -976,6 +1009,7 @@ dependencies = [ "chrono", "grep-regex", "grep-searcher", + "image", "mime_guess", "notify", "rayon", @@ -1319,6 +1353,16 @@ dependencies = [ "wasip2", ] +[[package]] +name = "gif" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gio" version = "0.18.4" @@ -1504,6 +1548,17 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -1778,6 +1833,24 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.24.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5690139d2f55868e080017335e4b94cb7414274c74f1669c84fb5feba2c9f69d" +dependencies = [ + "bytemuck", + "byteorder", + "color_quant", + "exr", + "gif", + "jpeg-decoder", + "num-traits", + "png", + "qoi", + "tiff", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -1916,6 +1989,15 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" +[[package]] +name = "jpeg-decoder" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07" +dependencies = [ + "rayon", +] + [[package]] name = "js-sys" version = "0.3.83" @@ -1997,6 +2079,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + [[package]] name = "libappindicator" version = "0.9.0" @@ -2988,6 +3076,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + [[package]] name = "quick-xml" version = "0.38.4" @@ -4179,6 +4276,17 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "tiff" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba1310fcea54c6a9a4fd1aad794ecc02c31682f6bfbecdf460bf19533eed1e3e" +dependencies = [ + "flate2", + "jpeg-decoder", + "weezl", +] + [[package]] name = "time" version = "0.3.44" @@ -4824,6 +4932,12 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "winapi" version = "0.3.9" @@ -5579,6 +5693,15 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + [[package]] name = "zvariant" version = "5.8.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 2639532..23be1ca 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -30,6 +30,7 @@ notify = "6.1" base64 = "0.22" mime_guess = "2.0" thiserror = "1.0" +image = "0.24" [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.59", features = ["Win32_Storage_FileSystem"] } diff --git a/src-tauri/src/commands/file_ops.rs b/src-tauri/src/commands/file_ops.rs index 018eec8..4421ddf 100644 --- a/src-tauri/src/commands/file_ops.rs +++ b/src-tauri/src/commands/file_ops.rs @@ -174,7 +174,6 @@ pub async fn create_file(path: String) -> std::result::Result<(), String> { return Err(FileManagerError::NotAbsolutePath(path).to_string()); } - // Ensure parent directory exists if let Some(parent) = file_path.parent() { if !parent.exists() { fs::create_dir_all(parent) diff --git a/src-tauri/src/commands/preview.rs b/src-tauri/src/commands/preview.rs index 935fa00..5a80947 100644 --- a/src-tauri/src/commands/preview.rs +++ b/src-tauri/src/commands/preview.rs @@ -74,3 +74,41 @@ fn generate_image_preview(path: &str, extension: &str) -> Result Result { + use image::imageops::FilterType; + use image::io::Reader as ImageReader; + + let file_path = Path::new(&path); + let _extension = get_extension(file_path).unwrap_or_default(); + + // Try to open and decode image + let img = ImageReader::open(path) + .map_err(|e| e.to_string())? + .decode() + .map_err(|e| e.to_string())?; + + // Resize preserving aspect ratio to fit inside max_side x max_side + let resized = img.resize(max_side, max_side, FilterType::Lanczos3); + + // Encode as PNG + let mut buf: Vec = Vec::new(); + resized + .write_to( + &mut std::io::Cursor::new(&mut buf), + image::ImageOutputFormat::Png, + ) + .map_err(|e| e.to_string())?; + + let base64 = STANDARD.encode(&buf); + let mime = "image/png".to_string(); + + Ok(crate::models::Thumbnail { base64, mime }) +} diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index 6a536c4..c543a05 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -10,4 +10,5 @@ pub use drive_info::DriveInfo; pub use events::{CopyProgress, FsChangeEvent}; pub use file_entry::FileEntry; pub use preview::FilePreview; +pub use preview::Thumbnail; pub use search::{ContentMatch, SearchOptions, SearchProgress, SearchResult}; diff --git a/src-tauri/src/models/preview.rs b/src-tauri/src/models/preview.rs index 7345106..90741d6 100644 --- a/src-tauri/src/models/preview.rs +++ b/src-tauri/src/models/preview.rs @@ -4,6 +4,14 @@ use serde::{Deserialize, Serialize}; use specta::Type; /// File preview content types. +#[allow(dead_code)] +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +#[serde(tag = "type")] +pub struct Thumbnail { + pub base64: String, + pub mime: String, +} + #[derive(Debug, Clone, Serialize, Deserialize, Type)] #[serde(tag = "type")] pub enum FilePreview { diff --git a/src/app/providers/QueryProvider.tsx b/src/app/providers/QueryProvider.tsx index b332d4b..0fd213a 100644 --- a/src/app/providers/QueryProvider.tsx +++ b/src/app/providers/QueryProvider.tsx @@ -1,5 +1,6 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import { type ReactNode, useState } from "react" +import { useApplyAppearance } from "@/features/settings" interface QueryProviderProps { children: ReactNode @@ -13,12 +14,15 @@ export function QueryProvider({ children }: QueryProviderProps) { queries: { staleTime: 1000 * 60, gcTime: 1000 * 60 * 5, - retry: 1, refetchOnWindowFocus: false, + retry: 1, }, }, }), ) + // Apply appearance settings to DOM + useApplyAppearance() + return {children} } diff --git a/src/app/styles/globals.css b/src/app/styles/globals.css index 9b896b2..02fea4d 100644 --- a/src/app/styles/globals.css +++ b/src/app/styles/globals.css @@ -1,56 +1,164 @@ -/* src/app/styles/globals.css */ @import "tailwindcss"; @theme { --color-background: oklch(0.145 0 0); --color-foreground: oklch(0.985 0 0); - --color-card: oklch(0.145 0 0); - --color-card-foreground: oklch(0.985 0 0); - --color-popover: oklch(0.145 0 0); - --color-popover-foreground: oklch(0.985 0 0); - --color-primary: oklch(0.985 0 0); - --color-primary-foreground: oklch(0.205 0 0); - --color-secondary: oklch(0.269 0 0); - --color-secondary-foreground: oklch(0.985 0 0); --color-muted: oklch(0.269 0 0); --color-muted-foreground: oklch(0.708 0 0); - --color-accent: oklch(0.269 0 0); - --color-accent-foreground: oklch(0.985 0 0); - --color-destructive: oklch(0.396 0.141 25.723); - --color-destructive-foreground: oklch(0.985 0 0); + --accent-color: oklch(0.269 0 0); + --accent-color-foreground: oklch(0.985 0 0); + --color-primary: oklch(0.985 0 0); + --color-primary-foreground: oklch(0.145 0 0); + --color-destructive: oklch(0.396 0.141 25.768); --color-border: oklch(0.269 0 0); - --color-input: oklch(0.269 0 0); --color-ring: oklch(0.439 0 0); - --radius: 0.5rem; + + /* Custom properties for settings */ + --transition-duration: 150ms; + --accent-color: #3b82f6; +} + +/* Theme overrides applied via html.dark / html.light classes. This makes class-based + theme switching effective even if Tailwind is configured to use media-based dark + mode. We set the main color variables so components relying on CSS variables update. */ +html.dark { + --color-background: oklch(0.145 0 0); + --color-foreground: oklch(0.985 0 0); + --color-muted: oklch(0.269 0 0); + --color-muted-foreground: oklch(0.708 0 0); + --accent-color: oklch(0.269 0 0); + --accent-color-foreground: oklch(0.985 0 0); + --color-primary: oklch(0.985 0 0); + --color-primary-foreground: oklch(0.145 0 0); +} + +html.light { + /* Invert background/foreground for light mode */ + --color-background: oklch(0.985 0 0); + --color-foreground: oklch(0.145 0 0); + --color-muted: oklch(0.7 0 0); + --color-muted-foreground: oklch(0.2 0 0); + --accent-color-foreground: oklch(0.145 0 0); + --color-primary-foreground: oklch(0.985 0 0); +} + +/* Ensure Tailwind utility-like classes reflect CSS variables for accent/primary + to make dynamic user-selected accent colors apply immediately at runtime. */ +.bg-accent { + background-color: var(--accent-color); +} +.bg-accent\/50 { + background-color: rgba(var(--accent-color-rgb, 59 130 246), 0.5); +} +.bg-accent\/70 { + background-color: rgba(var(--accent-color-rgb, 59 130 246), 0.7); +} +.hover\:bg-accent\/50:hover { + background-color: rgba(var(--accent-color-rgb, 59 130 246), 0.5); +} +.text-accent-foreground { + color: var(--accent-color-foreground, var(--color-primary-foreground, #fff)); +} +/* Icon helpers: ensure icon elements can be colored by accent variables */ +.icon-accent { + color: var(--accent-color); + stroke: var(--accent-color); +} +.icon-accent-foreground { + color: var(--accent-color-foreground, var(--color-primary-foreground, #fff)); + stroke: var(--accent-color-foreground, var(--color-primary-foreground, #fff)); +} +/* Make SVG icons follow current text color by default. Do not fill by default so outlined icons remain outlined. */ +svg { + color: inherit; + stroke: currentColor; + fill: none; /* default: no fill; use .icon-fill-current or inline fill prop to fill */ +} + +/* Utility to fill icon with currentColor when needed */ +.icon-fill-current { + fill: currentColor; +} + +/* Utility to fill icon explicitly with accent color */ +.icon-fill-accent { + fill: var(--accent-color); + color: var(--accent-color); +} +.ring-primary { + /* Fallback ring using accent color */ + box-shadow: 0 0 0 2px var(--accent-color); +} +.border-primary { + border-color: var(--accent-color); } * { border-color: var(--color-border); - box-sizing: border-box; } body { - font-family: system-ui, -apple-system, sans-serif; background-color: var(--color-background); color: var(--color-foreground); - margin: 0; - padding: 0; + font-family: system-ui, -apple-system, sans-serif; overflow: hidden; } +/* Reduce motion when enabled - use specific selectors instead of !important */ +.reduce-motion, +.reduce-motion *, +.reduce-motion *::before, +.reduce-motion *::after { + animation-duration: 0ms; + animation-delay: 0ms; + transition-duration: 0ms; + transition-delay: 0ms; +} + +/* Animations OFF: explicit global toggle that disables animations and transitions */ +.animations-off, +.animations-off *, +.animations-off *::before, +.animations-off *::after { + animation-duration: 0ms; + animation-delay: 0ms; + transition-duration: 0ms; + transition-delay: 0ms; +} + +/* Font sizes */ +:root { + font-size: 16px; /* Default, overridden by settings */ +} + +/* Accent color application */ +.accent-primary { + color: var(--accent-color); +} + +.bg-accent-primary { + background-color: var(--accent-color); +} + +/* Dynamic transitions */ +.transition-colors { + transition-duration: var(--transition-duration); +} + /* Hide scrollbar for tabs */ .scrollbar-none { - -ms-overflow-style: none; scrollbar-width: none; + -ms-overflow-style: none; } + .scrollbar-none::-webkit-scrollbar { display: none; } /* Custom scrollbar */ ::-webkit-scrollbar { - width: 10px; - height: 10px; + width: 8px; + height: 8px; } ::-webkit-scrollbar-track { @@ -58,15 +166,12 @@ body { } ::-webkit-scrollbar-thumb { - background: var(--color-border); - border-radius: 5px; - border: 2px solid transparent; - background-clip: content-box; + background: var(--color-muted); + border-radius: 4px; } ::-webkit-scrollbar-thumb:hover { background: var(--color-muted-foreground); - background-clip: content-box; } /* Prevent text selection during drag */ @@ -85,20 +190,18 @@ body { } /* Tauri: frameless window drag region */ -/* Apply drag region where attribute contains data-tauri-drag-region */ [data-tauri-drag-region] { -webkit-app-region: drag; - -webkit-user-select: none; - user-select: none; } -/* Interactive controls inside drag region must be non-draggable so they remain clickable */ [data-tauri-drag-region] button, -[data-tauri-drag-region] a, [data-tauri-drag-region] input, -[data-tauri-drag-region] textarea, -[data-tauri-drag-region] select, -[data-tauri-drag-region] .no-drag { +[data-tauri-drag-region] a { + -webkit-app-region: no-drag; +} + +/* Utility to mark element as non-draggable (clickable inside drag regions) */ +.no-drag { -webkit-app-region: no-drag; } @@ -113,13 +216,13 @@ body { /* Quick filter animation */ .quick-filter-enter { - animation: slideDown 0.15s ease-out; + animation: slideDown var(--transition-duration) ease-out; } @keyframes slideDown { from { opacity: 0; - transform: translateY(-8px); + transform: translateY(-10px); } to { opacity: 1; @@ -127,23 +230,58 @@ body { } } -/* Path input focus state */ +/* Path input focus state - use outline instead of ring */ .path-input:focus { - outline: none; - box-shadow: 0 0 0 2px var(--color-ring); + outline: 2px solid var(--color-ring); + outline-offset: 0; } /* Quick look overlay */ .quick-look-active { - box-shadow: 0 0 0 2px var(--color-primary); + box-shadow: 0 0 0 2px var(--accent-color); +} + +/* Popover surface: centralized translucent background with blur for menus/tooltips */ +:root { + --popover-bg: rgba(17, 17, 19, 0.6); + --popover-border: rgba(255, 255, 255, 0.06); + --popover-opacity: 0.6; + --popover-blur: 6px; +} + +html.light { + --popover-bg: rgba(255, 255, 255, 0.85); + --popover-border: rgba(0, 0, 0, 0.06); + --popover-opacity: 0.85; + --popover-blur: 6px; +} + +.popover-surface { + /* Use theme-aware popover color; `--popover-bg` is set for dark/light by default or by appearance hook */ + background-color: var(--popover-bg); + -webkit-backdrop-filter: blur(var(--popover-blur)); + backdrop-filter: blur(var(--popover-blur)); + /* keep border color consistent with theme */ + border-color: var(--popover-border); } /* Breadcrumb hover state */ .breadcrumb-segment:hover { - background-color: var(--color-accent); + background-color: var(--accent-color); } /* Filter bar transition */ .filter-bar { - transition: all 0.15s ease-out; + transition: all var(--transition-duration) ease-out; +} + +/* Compact mode */ +.compact-mode .file-row { + padding-top: 0.25rem; + padding-bottom: 0.25rem; +} + +.compact-mode .file-icon { + width: 16px; + height: 16px; } diff --git a/src/entities/drive/ui/DriveItem.tsx b/src/entities/drive/ui/DriveItem.tsx index 7631b8c..7147d87 100644 --- a/src/entities/drive/ui/DriveItem.tsx +++ b/src/entities/drive/ui/DriveItem.tsx @@ -15,11 +15,19 @@ export function DriveItem({ drive, isSelected, onSelect }: DriveItemProps) { onClick={onSelect} className={cn( "flex items-center gap-2 w-full px-3 py-2 text-sm rounded-md", - "hover:bg-accent/50 transition-colors text-left", - isSelected && "bg-accent", + // Only show hover highlight when not selected to avoid flipping selected state color + !isSelected && "hover:bg-accent/50", + "transition-colors text-left", + isSelected ? "bg-accent text-accent-foreground" : "text-muted-foreground", )} > - + {drive.name} ) diff --git a/src/entities/file-entry/api/__tests__/readDirectory-perf-disabled.test.tsx b/src/entities/file-entry/api/__tests__/readDirectory-perf-disabled.test.tsx new file mode 100644 index 0000000..ee441f2 --- /dev/null +++ b/src/entities/file-entry/api/__tests__/readDirectory-perf-disabled.test.tsx @@ -0,0 +1,49 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { render, waitFor } from "@testing-library/react" +import { describe, expect, it, vi } from "vitest" +import { tauriClient } from "@/shared/api/tauri/client" +import { useDirectoryContents } from "../queries" + +function TestComponent({ path }: { path: string | null }) { + const { data } = useDirectoryContents(path) + return
{(data ?? []).length}
+} + +describe("readDirectory perf disabled (integration)", () => { + it("does not log perf when USE_PERF_LOGS=false", async () => { + const debugSpy = vi.spyOn(console, "debug").mockImplementation(() => {}) + + // disable perf via safe global accessor + const proc = ( + globalThis as unknown as { process?: { env?: Record } } + ).process + const old = proc?.env?.USE_PERF_LOGS + if (proc) { + proc.env = proc.env ?? {} + proc.env.USE_PERF_LOGS = "false" + } + + const readSpy = vi.spyOn(tauriClient, "readDirectory").mockResolvedValue([]) + + try { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const { getByTestId } = render( + + + , + ) + + await waitFor(() => expect(getByTestId("count")).toBeTruthy()) + + expect(readSpy).toHaveBeenCalled() + expect(debugSpy).not.toHaveBeenCalled() + } finally { + readSpy.mockRestore() + if (proc?.env) { + if (old === undefined) delete proc.env.USE_PERF_LOGS + else proc.env.USE_PERF_LOGS = old + } + debugSpy.mockRestore() + } + }) +}) diff --git a/src/entities/file-entry/api/mutations.ts b/src/entities/file-entry/api/mutations.ts index 8b44551..29e0b9a 100644 --- a/src/entities/file-entry/api/mutations.ts +++ b/src/entities/file-entry/api/mutations.ts @@ -1,14 +1,8 @@ import { useMutation, useQueryClient } from "@tanstack/react-query" -import { commands, type Result } from "@/shared/api/tauri" +import { commands } from "@/shared/api/tauri" +import { unwrapResult } from "@/shared/api/tauri/client" import { fileKeys } from "./keys" -function unwrapResult(result: Result): T { - if (result.status === "ok") { - return result.data - } - throw new Error(String(result.error)) -} - export function useCreateDirectory() { const queryClient = useQueryClient() diff --git a/src/entities/file-entry/api/queries.ts b/src/entities/file-entry/api/queries.ts index c6629f6..73a5064 100644 --- a/src/entities/file-entry/api/queries.ts +++ b/src/entities/file-entry/api/queries.ts @@ -1,21 +1,42 @@ import { useQuery } from "@tanstack/react-query" -import { commands, type Result } from "@/shared/api/tauri" +import { tauriClient } from "@/shared/api/tauri/client" +import { getLastNav, setPerfLog } from "@/shared/lib/devLogger" +import { markPerf, withPerf } from "@/shared/lib/perf" import { fileKeys } from "./keys" -function unwrapResult(result: Result): T { - if (result.status === "ok") { - return result.data - } - throw new Error(String(result.error)) -} - export function useDirectoryContents(path: string | null) { return useQuery({ queryKey: fileKeys.directory(path), queryFn: async () => { if (!path) return [] - const result = await commands.readDirectory(path) - return unwrapResult(result) + return withPerf("readDirectory", { path }, async () => { + const start = performance.now() + const entries = await tauriClient.readDirectory(path) + const duration = performance.now() - start + + try { + const last = getLastNav() + if (last && last.path === path) { + const navToRead = performance.now() - last.t + markPerf("nav->readDirectory", { id: last.id, path, navToRead }) + setPerfLog({ + lastRead: { + id: last.id, + path, + duration, + navToRead, + ts: Date.now(), + }, + }) + } else { + setPerfLog({ lastRead: { path, duration, ts: Date.now() } }) + } + } catch { + /* ignore */ + } + + return entries + }) }, enabled: !!path, staleTime: 30_000, @@ -26,8 +47,7 @@ export function useDrives() { return useQuery({ queryKey: fileKeys.drives(), queryFn: async () => { - const result = await commands.getDrives() - return unwrapResult(result) + return tauriClient.getDrives() }, staleTime: 60_000, }) diff --git a/src/entities/file-entry/api/useFileWatcher.ts b/src/entities/file-entry/api/useFileWatcher.ts index 8e25ab2..82365b9 100644 --- a/src/entities/file-entry/api/useFileWatcher.ts +++ b/src/entities/file-entry/api/useFileWatcher.ts @@ -17,7 +17,7 @@ export function useFileWatcher(currentPath: string | null) { const currentPathRef = useRef(null) const debounceTimerRef = useRef | null>(null) - // Используем useCallback с currentPath в замыкании через ref + // Use useCallback with currentPath captured via ref const invalidateDirectoryQueries = useCallback(() => { const path = currentPathRef.current if (path) { diff --git a/src/entities/file-entry/api/useStreamingDirectory.ts b/src/entities/file-entry/api/useStreamingDirectory.ts index cd7a60e..5ec55a2 100644 --- a/src/entities/file-entry/api/useStreamingDirectory.ts +++ b/src/entities/file-entry/api/useStreamingDirectory.ts @@ -1,7 +1,7 @@ import { listen } from "@tauri-apps/api/event" import { useCallback, useEffect, useReducer, useRef } from "react" import type { FileEntry } from "@/shared/api/tauri" -import { commands } from "@/shared/api/tauri" +import { tauriClient } from "@/shared/api/tauri/client" interface State { entries: FileEntry[] @@ -86,10 +86,13 @@ export function useStreamingDirectory(path: string | null) { dispatch({ type: "COMPLETE" }) }) - // Start streaming - const result = await commands.readDirectoryStream(path) - if (result.status === "error" && !cancelled) { - dispatch({ type: "ERROR", payload: result.error }) + // Start streaming: client throws on error, keep event listeners for entries/completion + try { + await tauriClient.readDirectoryStream(path) + } catch (err) { + if (!cancelled) { + dispatch({ type: "ERROR", payload: String(err) }) + } } // Cleanup complete listener on completion diff --git a/src/entities/file-entry/model/__tests__/types.test.ts b/src/entities/file-entry/model/__tests__/types.test.ts index b6f8d69..eda80ee 100644 --- a/src/entities/file-entry/model/__tests__/types.test.ts +++ b/src/entities/file-entry/model/__tests__/types.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest" import type { FileEntry } from "@/shared/api/tauri" import { filterEntries, type SortConfig, sortEntries } from "../types" -// Helper для создания тестовых файлов +// Helper to create test files function createFileEntry(overrides: Partial = {}): FileEntry { return { name: "test.txt", @@ -137,7 +137,6 @@ describe("sortEntries", () => { const config: SortConfig = { field: "modified", direction: "asc" } const result = sortEntries(files, config) - // Should not throw expect(result).toHaveLength(2) }) }) @@ -228,7 +227,6 @@ describe("filterEntries", () => { it("should filter by single extension", () => { const result = filterEntries(files, { showHidden: true, extensions: ["txt"] }) - // Should return: visible.txt + folder (folders always pass) expect(result.filter((f) => !f.is_dir)).toHaveLength(1) expect(result.some((f) => f.name === "visible.txt")).toBe(true) expect(result.some((f) => f.name === "folder")).toBe(true) // folder always included @@ -237,7 +235,6 @@ describe("filterEntries", () => { it("should filter by multiple extensions", () => { const result = filterEntries(files, { showHidden: true, extensions: ["txt", "pdf"] }) - // Should return: visible.txt, document.pdf + folder const nonDirs = result.filter((f) => !f.is_dir) expect(nonDirs).toHaveLength(2) expect(result.some((f) => f.name === "visible.txt")).toBe(true) diff --git a/src/entities/file-entry/model/types.ts b/src/entities/file-entry/model/types.ts index 8556f89..9578953 100644 --- a/src/entities/file-entry/model/types.ts +++ b/src/entities/file-entry/model/types.ts @@ -9,22 +9,22 @@ export interface SortConfig { } export function sortEntries(entries: FileEntry[], config: SortConfig): FileEntry[] { - const sorted = [...entries] - - sorted.sort((a, b) => { + return [...entries].sort((a, b) => { // Folders always first if (a.is_dir !== b.is_dir) { return a.is_dir ? -1 : 1 } let comparison = 0 - switch (config.field) { case "name": - comparison = a.name.toLowerCase().localeCompare(b.name.toLowerCase()) + comparison = a.name.localeCompare(b.name, undefined, { + numeric: true, + sensitivity: "base", + }) break case "size": - comparison = a.size - b.size + comparison = (a.size ?? 0) - (b.size ?? 0) break case "modified": comparison = (a.modified ?? 0) - (b.modified ?? 0) @@ -36,18 +36,15 @@ export function sortEntries(entries: FileEntry[], config: SortConfig): FileEntry return config.direction === "asc" ? comparison : -comparison }) +} - return sorted +export interface FilterOptions { + showHidden?: boolean + extensions?: string[] + searchQuery?: string } -export function filterEntries( - entries: FileEntry[], - options: { - showHidden?: boolean - extensions?: string[] - searchQuery?: string - }, -): FileEntry[] { +export function filterEntries(entries: FileEntry[], options: FilterOptions): FileEntry[] { const { showHidden = false, extensions, searchQuery } = options return entries.filter((entry) => { @@ -57,19 +54,17 @@ export function filterEntries( } // Filter by extensions (case-insensitive, folders always pass) - if (extensions && extensions.length > 0) { - if (!entry.is_dir) { - const entryExt = entry.extension?.toLowerCase() - const hasMatchingExt = extensions.some((ext) => ext.toLowerCase() === entryExt) - if (!hasMatchingExt) { - return false - } + if (extensions?.length && !entry.is_dir) { + const ext = entry.extension?.toLowerCase() + if (!ext || !extensions.some((e) => e.toLowerCase() === ext)) { + return false } } // Filter by search query (case-insensitive) - if (searchQuery?.trim()) { - if (!entry.name.toLowerCase().includes(searchQuery.toLowerCase())) { + if (searchQuery) { + const query = searchQuery.toLowerCase() + if (!entry.name.toLowerCase().includes(query)) { return false } } diff --git a/src/entities/file-entry/ui/ColumnHeader.tsx b/src/entities/file-entry/ui/ColumnHeader.tsx index 427bd33..79445a3 100644 --- a/src/entities/file-entry/ui/ColumnHeader.tsx +++ b/src/entities/file-entry/ui/ColumnHeader.tsx @@ -1,8 +1,15 @@ import { ArrowDown, ArrowUp, ArrowUpDown } from "lucide-react" import { useCallback, useRef } from "react" -import { type SortConfig, type SortField, useSortingStore } from "@/features/sorting" import { cn } from "@/shared/lib" +// Local types to avoid importing from features +export type SortField = "name" | "size" | "modified" | "type" +export type SortDirection = "asc" | "desc" +export interface SortConfig { + field: SortField + direction: SortDirection +} + interface ColumnHeaderProps { columnWidths: { size: number @@ -10,6 +17,13 @@ interface ColumnHeaderProps { padding: number } onColumnResize: (column: "size" | "date" | "padding", width: number) => void + // Sorting is provided by higher layer (widgets/pages) + sortConfig: SortConfig + onSort: (field: SortField) => void + displaySettings?: { + showFileSizes: boolean + showFileDates: boolean + } className?: string } @@ -50,6 +64,18 @@ function SortableHeader({ field, label, sortConfig, onSort, className }: Sortabl function ResizeHandle({ onResize }: { onResize: (delta: number) => void }) { const startXRef = useRef(0) + const pendingDelta = useRef(0) + const rafRef = useRef(null) + + const flush = useCallback(() => { + if (pendingDelta.current !== 0) { + onResize(pendingDelta.current) + pendingDelta.current = 0 + } + if (rafRef.current !== null) { + rafRef.current = null + } + }, [onResize]) const handleMouseDown = useCallback( (e: React.MouseEvent) => { @@ -59,10 +85,19 @@ function ResizeHandle({ onResize }: { onResize: (delta: number) => void }) { const handleMove = (moveEvent: MouseEvent) => { const delta = moveEvent.clientX - startXRef.current startXRef.current = moveEvent.clientX - onResize(delta) + // accumulate delta and schedule a single RAF flush per frame + pendingDelta.current += delta + if (rafRef.current === null) { + rafRef.current = window.requestAnimationFrame(flush) + } } const handleUp = () => { + if (rafRef.current !== null) { + window.cancelAnimationFrame(rafRef.current) + rafRef.current = null + } + flush() document.removeEventListener("mousemove", handleMove) document.removeEventListener("mouseup", handleUp) } @@ -70,7 +105,7 @@ function ResizeHandle({ onResize }: { onResize: (delta: number) => void }) { document.addEventListener("mousemove", handleMove) document.addEventListener("mouseup", handleUp) }, - [onResize], + [flush], ) return ( @@ -81,9 +116,14 @@ function ResizeHandle({ onResize }: { onResize: (delta: number) => void }) { ) } -export function ColumnHeader({ columnWidths, onColumnResize, className }: ColumnHeaderProps) { - const { sortConfig, setSortField } = useSortingStore() - +export function ColumnHeader({ + columnWidths, + onColumnResize, + className, + sortConfig, + onSort, + displaySettings, +}: ColumnHeaderProps) { const handleResize = useCallback( (column: "size" | "date" | "padding") => (delta: number) => { const currentWidth = columnWidths[column] @@ -94,6 +134,12 @@ export function ColumnHeader({ columnWidths, onColumnResize, className }: Column [columnWidths, onColumnResize], ) + const showFileSizes = displaySettings?.showFileSizes ?? true + const showFileDates = displaySettings?.showFileDates ?? true + + const effectiveSortConfig = sortConfig ?? { field: "name", direction: "asc" } + const effectiveOnSort = onSort ?? (() => {}) + return (
{/* Icon placeholder */} {/* Name column */}
- - -
- {/* Size column */} -
- +
+ {/* Size column */} + {showFileSizes && ( +
+ + +
+ )} {/* Date column */} -
- - -
+ {showFileDates && ( +
+ + +
+ )} {/* Padding column */}
diff --git a/src/entities/file-entry/ui/FileRow.tsx b/src/entities/file-entry/ui/FileRow.tsx index 9242843..915fdb9 100644 --- a/src/entities/file-entry/ui/FileRow.tsx +++ b/src/entities/file-entry/ui/FileRow.tsx @@ -1,9 +1,23 @@ import { memo, useCallback, useEffect, useRef, useState } from "react" import type { FileEntry } from "@/shared/api/tauri" -import { cn, formatBytes, formatDate } from "@/shared/lib" +import { cn, formatBytes, formatDate, formatRelativeDate, formatRelativeStrict } from "@/shared/lib" +import { getPerfLog, setPerfLog } from "@/shared/lib/devLogger" import { FileIcon } from "./FileIcon" import { FileRowActions } from "./FileRowActions" +// Minimal local types to avoid importing from higher layers +type FileDisplaySettings = { + showFileExtensions: boolean + showFileSizes: boolean + showFileDates: boolean + dateFormat: "relative" | "absolute" | "auto" + thumbnailSize: "small" | "medium" | "large" +} + +type AppearanceSettings = { + reducedMotion?: boolean +} + interface FileRowProps { file: FileEntry isSelected: boolean @@ -25,9 +39,12 @@ interface FileRowProps { date: number padding: number } + // New props: pass settings from higher layers (widgets/pages) + displaySettings?: FileDisplaySettings + appearance?: AppearanceSettings } -function FileRowComponent({ +export const FileRow = memo(function FileRow({ file, isSelected, isFocused, @@ -37,28 +54,79 @@ function FileRowComponent({ onOpen, onDrop, getSelectedPaths, - onCopy, - onCut, - onRename, - onDelete, onQuickLook, onToggleBookmark, - columnWidths, + columnWidths = { size: 100, date: 180, padding: 8 }, + displaySettings: displaySettingsProp, + appearance, }: FileRowProps) { + // Instrument render counts to help diagnose excessive re-renders in large directories + try { + const rc = (getPerfLog()?.renderCounts as Record) ?? { fileRows: 0 } + rc.fileRows = (rc.fileRows ?? 0) + 1 + setPerfLog({ renderCounts: rc }) + } catch { + /* ignore */ + } const rowRef = useRef(null) const [isDragOver, setIsDragOver] = useState(false) + const [isHovered, setIsHovered] = useState(false) - // Scroll into view when focused + // Use passed display settings or sensible defaults to avoid depending on higher layers + const defaultDisplaySettings: FileDisplaySettings = { + showFileExtensions: true, + showFileSizes: true, + showFileDates: true, + dateFormat: "relative", + thumbnailSize: "medium", + } + const displaySettings = displaySettingsProp ?? defaultDisplaySettings + + // Map thumbnailSize setting to icon size for list mode + const iconSizeMap: Record = { small: 14, medium: 18, large: 22 } + const iconSize = iconSizeMap[displaySettings.thumbnailSize] ?? 18 + + const defaultAppearance: AppearanceSettings = { reducedMotion: false } + const appearanceLocal = appearance ?? defaultAppearance + + // Scroll into view when focused; respect reduced motion setting useEffect(() => { if (isFocused && rowRef.current) { - rowRef.current.scrollIntoView({ block: "nearest" }) + const behavior: ScrollBehavior = appearanceLocal.reducedMotion ? "auto" : "smooth" + rowRef.current.scrollIntoView({ block: "nearest", behavior }) } - }, [isFocused]) + }, [isFocused, appearanceLocal.reducedMotion]) + + // Format the display name based on settings + const displayName = displaySettings.showFileExtensions + ? file.name + : file.is_dir + ? file.name + : file.name.replace(/\.[^/.]+$/, "") + + // Format date based on settings + const formattedDate = + displaySettings.dateFormat === "absolute" + ? formatDate(file.modified) + : displaySettings.dateFormat === "relative" + ? formatRelativeStrict(file.modified) + : // auto + formatRelativeDate(file.modified) + + const handleDragStart = useCallback( + (e: React.DragEvent) => { + const paths = getSelectedPaths?.() ?? [file.path] + e.dataTransfer.setData("application/json", JSON.stringify({ paths, action: "move" })) + e.dataTransfer.effectAllowed = "copyMove" + }, + [file.path, getSelectedPaths], + ) const handleDragOver = useCallback( (e: React.DragEvent) => { if (!file.is_dir) return e.preventDefault() + e.dataTransfer.dropEffect = e.ctrlKey ? "copy" : "move" setIsDragOver(true) }, [file.is_dir], @@ -74,116 +142,98 @@ function FileRowComponent({ setIsDragOver(false) if (!file.is_dir || !onDrop) return - const paths = getSelectedPaths?.() ?? [] - if (paths.includes(file.path)) return - try { - const data = e.dataTransfer.getData("application/json") - if (data) { - const parsed = JSON.parse(data) - onDrop(parsed.paths || paths, file.path) - } else { - onDrop(paths, file.path) + const data = JSON.parse(e.dataTransfer.getData("application/json")) + if (data.paths?.length > 0) { + onDrop(data.paths, file.path) } } catch { - onDrop(paths, file.path) - } - }, - [file.is_dir, file.path, onDrop, getSelectedPaths], - ) - - const handleDragStart = useCallback( - (e: React.DragEvent) => { - const paths = getSelectedPaths?.() ?? [file.path] - const dragPaths = paths.includes(file.path) ? paths : [file.path] - e.dataTransfer.setData("application/json", JSON.stringify({ paths: dragPaths })) - e.dataTransfer.effectAllowed = "copyMove" - }, - [file.path, getSelectedPaths], - ) - - const handleContextMenu = useCallback( - (e: React.MouseEvent) => { - if (!isSelected) { - onSelect(e) + // Ignore parse errors } }, - [isSelected, onSelect], + [file.is_dir, file.path, onDrop], ) return (
setIsHovered(true)} + onPointerLeave={() => setIsHovered(false)} + onFocus={() => setIsHovered(true)} + onBlur={() => setIsHovered(false)} + draggable + tabIndex={0} + data-path={file.path} > - {/* Icon */} - {/* Name */} - - {file.name} - + {displayName} - {/* Hover Actions */} -
+ {onQuickLook && ( {})} - onCut={onCut ?? (() => {})} - onRename={onRename ?? (() => {})} - onDelete={onDelete ?? (() => {})} onQuickLook={onQuickLook} onToggleBookmark={onToggleBookmark} + className={cn( + "no-drag", + // show actions when hovered, focused, or selected; keep CSS hover fallback + isHovered || isSelected || isFocused + ? "opacity-100" + : "opacity-0 group-hover:opacity-100", + )} /> -
- - {/* Size */} - - {file.is_dir ? "--" : formatBytes(file.size)} - - - {/* Date */} - - {formatDate(file.modified)} - - - {/* Padding for scrollbar */} -
+ )} + + {displaySettings.showFileSizes && ( + + {file.is_dir ? "" : formatBytes(file.size)} + + )} + + {displaySettings.showFileDates && ( + + {formattedDate} + + )} + +
) -} +}, arePropsEqual) -// Custom comparison - check all relevant props -function areEqual(prev: FileRowProps, next: FileRowProps): boolean { +function arePropsEqual(prev: FileRowProps, next: FileRowProps): boolean { return ( prev.file.path === next.file.path && prev.file.name === next.file.name && @@ -195,8 +245,16 @@ function areEqual(prev: FileRowProps, next: FileRowProps): boolean { prev.isBookmarked === next.isBookmarked && prev.columnWidths?.size === next.columnWidths?.size && prev.columnWidths?.date === next.columnWidths?.date && - prev.columnWidths?.padding === next.columnWidths?.padding + // Compare relevant settings to avoid needless re-renders when they change + (prev.displaySettings?.thumbnailSize ?? "medium") === + (next.displaySettings?.thumbnailSize ?? "medium") && + (prev.displaySettings?.showFileExtensions ?? true) === + (next.displaySettings?.showFileExtensions ?? true) && + (prev.displaySettings?.showFileSizes ?? true) === + (next.displaySettings?.showFileSizes ?? true) && + (prev.displaySettings?.showFileDates ?? true) === + (next.displaySettings?.showFileDates ?? true) && + (prev.displaySettings?.dateFormat ?? "auto") === (next.displaySettings?.dateFormat ?? "auto") && + (prev.appearance?.reducedMotion ?? false) === (next.appearance?.reducedMotion ?? false) ) } - -export const FileRow = memo(FileRowComponent, areEqual) diff --git a/src/entities/file-entry/ui/FileRowActions.tsx b/src/entities/file-entry/ui/FileRowActions.tsx index f197e06..620b75d 100644 --- a/src/entities/file-entry/ui/FileRowActions.tsx +++ b/src/entities/file-entry/ui/FileRowActions.tsx @@ -1,26 +1,11 @@ -import { Copy, Eye, FolderOpen, MoreHorizontal, Pencil, Scissors, Star, Trash2 } from "lucide-react" +import { Eye, Star } from "lucide-react" import { memo, useCallback } from "react" import { cn } from "@/shared/lib" -import { - Button, - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuSeparator, - ContextMenuTrigger, - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/shared/ui" +import { Button, Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui" interface FileRowActionsProps { isDir: boolean isBookmarked?: boolean - onOpen: () => void - onCopy: () => void - onCut: () => void - onRename: () => void - onDelete: () => void onQuickLook?: () => void onToggleBookmark?: () => void className?: string @@ -29,11 +14,6 @@ interface FileRowActionsProps { export const FileRowActions = memo(function FileRowActions({ isDir, isBookmarked = false, - onOpen, - onCopy, - onCut, - onRename, - onDelete, onQuickLook, onToggleBookmark, className, @@ -64,7 +44,14 @@ export const FileRowActions = memo(function FileRowActions({ {onQuickLook && !isDir && ( - @@ -80,6 +67,9 @@ export const FileRowActions = memo(function FileRowActions({ size="icon" className={cn("h-6 w-6", isBookmarked && "text-yellow-500")} onClick={handleToggleBookmark} + aria-label={isBookmarked ? "Remove bookmark" : "Add bookmark"} + aria-pressed={isBookmarked} + title={isBookmarked ? "Remove bookmark" : "Add bookmark"} > @@ -87,39 +77,6 @@ export const FileRowActions = memo(function FileRowActions({ {isBookmarked ? "Remove bookmark" : "Add bookmark"} )} - - {/* More actions dropdown */} - - - - - - - - Open - - - - - Copy - - - - Cut - - - - - Rename - - - - Delete - - -
) }) diff --git a/src/entities/file-entry/ui/FileThumbnail.tsx b/src/entities/file-entry/ui/FileThumbnail.tsx index 100b5a9..0ed55f5 100644 --- a/src/entities/file-entry/ui/FileThumbnail.tsx +++ b/src/entities/file-entry/ui/FileThumbnail.tsx @@ -8,12 +8,21 @@ interface FileThumbnailProps { isDir: boolean size: number className?: string + // New prop: performance settings passed from higher layer + performanceSettings?: { + lazyLoadImages: boolean + thumbnailCacheSize: number + } + // When true, use object-contain to avoid cropping (useful in grid mode) + useContain?: boolean + // Optional: ask Tauri to generate a small thumbnail (max side px) + thumbnailGenerator?: { maxSide: number } } // Shared loading pool to limit concurrent image loads const loadingPool = { active: 0, - maxConcurrent: 5, + maxConcurrent: 3, queue: [] as (() => void)[], acquire(callback: () => void) { @@ -37,26 +46,62 @@ const loadingPool = { }, } +// Simple LRU cache for thumbnails to respect thumbnailCacheSize setting +const thumbnailCache = new Map() +function maybeCacheThumbnail(path: string, url: string, maxSize: number) { + if (thumbnailCache.has(path)) { + // Move to newest + thumbnailCache.delete(path) + thumbnailCache.set(path, url) + return + } + + thumbnailCache.set(path, url) + // Trim cache if needed + while (thumbnailCache.size > maxSize) { + const firstKey = thumbnailCache.keys().next().value + if (firstKey) { + thumbnailCache.delete(firstKey) + } else { + break + } + } +} + export const FileThumbnail = memo(function FileThumbnail({ path, extension, isDir, size, className, + performanceSettings, + useContain, + thumbnailGenerator, }: FileThumbnailProps) { const [isLoaded, setIsLoaded] = useState(false) const [hasError, setHasError] = useState(false) const [isVisible, setIsVisible] = useState(false) const [shouldLoad, setShouldLoad] = useState(false) + const [lqipSrc, setLqipSrc] = useState(null) const containerRef = useRef(null) const imageRef = useRef(null) const showThumbnail = canShowThumbnail(extension) && !isDir - // Intersection observer for lazy loading + const performanceDefaults = { lazyLoadImages: true, thumbnailCacheSize: 100 } + const performance = performanceSettings ?? performanceDefaults + + // Intersection observer for lazy loading (or eager load based on settings) useEffect(() => { if (!showThumbnail || !containerRef.current) return + if (!performance.lazyLoadImages) { + // Eager loading: enqueue load immediately via pool to respect concurrency + setIsVisible(true) + loadingPool.acquire(() => setShouldLoad(true)) + return + } + const observer = new IntersectionObserver( (entries) => { if (entries[0].isIntersecting) { @@ -72,11 +117,59 @@ export const FileThumbnail = memo(function FileThumbnail({ observer.observe(containerRef.current) return () => observer.disconnect() - }, [showThumbnail]) + }, [showThumbnail, performance.lazyLoadImages]) - // Load image when visible (with pool limiting) + // Load image when visible (with pool limiting) or ask Tauri to generate thumbnail useEffect(() => { - if (!isVisible || !showThumbnail || shouldLoad) return + if (!isVisible || !showThumbnail) return + // If we already decided to load (shouldLoad) and there's no thumbnailGenerator, skip + if (shouldLoad && !thumbnailGenerator) return + + if (thumbnailGenerator) { + // Ensure the image element is mounted so src/state-driven updates can apply + setShouldLoad(true) + + // LQIP: request a tiny thumbnail first, show blurred LQIP, then request a larger thumbnail + ;(async () => { + try { + const smallSide = Math.max(16, Math.min(64, Math.floor(thumbnailGenerator.maxSide / 4))) + + // small LQIP + const tSmall = await import("@/shared/api/tauri/client").then((m) => + m.tauriClient.getThumbnail(path, smallSide), + ) + if (!tSmall) throw new Error("no thumbnail") + const lqip = `data:${tSmall.mime};base64,${tSmall.base64}` + // Use state to drive the rendered src so it works even before the image ref is set + setLqipSrc(lqip) + + // allow one tick so LQIP can render before we fetch/replace with full thumbnail + await new Promise((res) => setTimeout(res, 0)) + + // Try full thumbnail + try { + const tFull = await import("@/shared/api/tauri/client").then((m) => + m.tauriClient.getThumbnail(path, thumbnailGenerator.maxSide), + ) + if (!tFull) throw new Error("no thumbnail") + const full = `data:${tFull.mime};base64,${tFull.base64}` + maybeCacheThumbnail(path, full, performance.thumbnailCacheSize) + // mark loaded so render switches from lqip to full cached src + setIsLoaded(true) + return + } catch { + // If full thumb fails, fallback to pool-load of file:// + loadingPool.acquire(() => setShouldLoad(true)) + return + } + } catch { + // If LQIP generation fails, fall back to pool-load of file:// + loadingPool.acquire(() => setShouldLoad(true)) + return + } + })() + return + } loadingPool.acquire(() => { setShouldLoad(true) @@ -85,15 +178,55 @@ export const FileThumbnail = memo(function FileThumbnail({ return () => { // Don't release here - release when image loads or errors } - }, [isVisible, showThumbnail, shouldLoad]) + }, [ + isVisible, + showThumbnail, + shouldLoad, + thumbnailGenerator, + path, + performance.thumbnailCacheSize, + ]) // Handle image load complete const handleLoad = () => { setIsLoaded(true) loadingPool.release() + + const url = imageRef.current?.src + if (url) { + maybeCacheThumbnail(path, url, performance.thumbnailCacheSize) + } } + const fallbackAttempted = useRef(false) + const handleError = () => { + // Try a fallback to tauri-based base64 preview once, in case file:// URL is blocked + if (!fallbackAttempted.current) { + fallbackAttempted.current = true + ;(async () => { + try { + const p = (await import("@/shared/api/tauri/client").then((m) => + m.tauriClient.getFilePreview(path), + )) as import("@/shared/api/tauri").FilePreview + if (p && p.type === "Image") { + const dataUrl = `data:${p.mime};base64,${p.base64}` + if (imageRef.current) imageRef.current.src = dataUrl + setHasError(false) + setIsLoaded(true) + maybeCacheThumbnail(path, dataUrl, performance.thumbnailCacheSize) + loadingPool.release() + return + } + } catch { + // ignore + } + setHasError(true) + loadingPool.release() + })() + return + } + setHasError(true) loadingPool.release() } @@ -106,6 +239,10 @@ export const FileThumbnail = memo(function FileThumbnail({ ) } + const cached = thumbnailCache.get(path) + const fileUrl = cached ?? getLocalImageUrl(path) + const imgSrc = lqipSrc && !isLoaded ? lqipSrc : fileUrl + return (
)}
) }) + +// Test-only exports for verifying LRU cache behavior in unit tests +export const __thumbnailCache = thumbnailCache +export const __maybeCacheThumbnail = maybeCacheThumbnail diff --git a/src/entities/file-entry/ui/InlineEditRow.tsx b/src/entities/file-entry/ui/InlineEditRow.tsx index 85a535a..e7cbd01 100644 --- a/src/entities/file-entry/ui/InlineEditRow.tsx +++ b/src/entities/file-entry/ui/InlineEditRow.tsx @@ -27,11 +27,10 @@ export function InlineEditRow({ useEffect(() => { if (inputRef.current) { - // Defer focus to the next animation frame to ensure the element is visible requestAnimationFrame(() => { if (!inputRef.current) return inputRef.current.focus() - // Для переименования выделяем имя без расширения + // When renaming, select the filename without the extension if (mode === "rename" && initialName) { const dotIndex = initialName.lastIndexOf(".") if (dotIndex > 0) { @@ -50,12 +49,12 @@ export function InlineEditRow({ if (!name.trim()) { return "Имя не может быть пустым" } - // Windows forbidden characters + // Forbidden characters (Windows) const forbiddenChars = /[<>:"/\\|?*]/ if (forbiddenChars.test(name)) { return "Имя содержит недопустимые символы" } - // Reserved Windows names + // Reserved names (Windows) const reserved = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i if (reserved.test(name.split(".")[0])) { return "Это имя зарезервировано системой" @@ -87,7 +86,7 @@ export function InlineEditRow({ ) const handleBlur = useCallback(() => { - // Небольшая задержка чтобы проверить не был ли клик по кнопке + // Delay to allow button click to register setTimeout(() => { if (document.activeElement !== inputRef.current) { if (value.trim()) { diff --git a/src/entities/file-entry/ui/__tests__/FileRow.dateFormat.test.tsx b/src/entities/file-entry/ui/__tests__/FileRow.dateFormat.test.tsx new file mode 100644 index 0000000..a5cbf0a --- /dev/null +++ b/src/entities/file-entry/ui/__tests__/FileRow.dateFormat.test.tsx @@ -0,0 +1,130 @@ +import { render } from "@testing-library/react" +import { expect, test } from "vitest" +import { formatDate, formatRelativeStrict } from "@/shared/lib" +import { FileRow } from "../FileRow" + +const nowSec = Math.floor(Date.now() / 1000) + +const defaultAppearance = { reducedMotion: false } + +type TestFileEntry = { + path: string + name: string + is_dir: boolean + is_hidden: boolean + extension: string | null + size: number + modified: number | null + created: number | null +} + +test("dateFormat='absolute' renders absolute dates", () => { + type FileEntry = { + path: string + name: string + is_dir: boolean + is_hidden: boolean + extension: string | null + size: number + modified: number | null + created: number | null + } + + const file: FileEntry = { + path: "/f", + name: "f", + is_dir: false, + is_hidden: false, + extension: null, + size: 0, + modified: nowSec, + created: null, + } + + const { container } = render( + {}} + onOpen={() => {}} + displaySettings={{ + showFileExtensions: true, + showFileSizes: false, + showFileDates: true, + dateFormat: "absolute", + thumbnailSize: "medium", + }} + appearance={defaultAppearance} + />, + ) + + expect(container.textContent).toContain(formatDate(nowSec)) +}) + +test("dateFormat='relative' renders strict relative even for older dates", () => { + const twentyDaysAgo = nowSec - 20 * 24 * 60 * 60 + const file: TestFileEntry = { + path: "/f", + name: "f", + is_dir: false, + is_hidden: false, + extension: null, + size: 0, + modified: twentyDaysAgo, + created: null, + } + + const { container } = render( + {}} + onOpen={() => {}} + displaySettings={{ + showFileExtensions: true, + showFileSizes: false, + showFileDates: true, + dateFormat: "relative", + thumbnailSize: "medium", + }} + appearance={defaultAppearance} + />, + ) + + // strict relative should render in days + expect(container.textContent).toContain(formatRelativeStrict(twentyDaysAgo)) +}) + +test("dateFormat='auto' uses mixed behaviour (old -> absolute)", () => { + const twentyDaysAgo = nowSec - 20 * 24 * 60 * 60 + const file: TestFileEntry = { + path: "/f", + name: "f", + is_dir: false, + is_hidden: false, + extension: null, + size: 0, + modified: twentyDaysAgo, + created: null, + } + + const { container } = render( + {}} + onOpen={() => {}} + displaySettings={{ + showFileExtensions: true, + showFileSizes: false, + showFileDates: true, + dateFormat: "auto", + thumbnailSize: "medium", + }} + appearance={defaultAppearance} + />, + ) + + // auto should fall back to absolute for > 1 week + expect(container.textContent).toContain(formatDate(twentyDaysAgo)) +}) diff --git a/src/entities/file-entry/ui/__tests__/FileRow.hover.test.tsx b/src/entities/file-entry/ui/__tests__/FileRow.hover.test.tsx new file mode 100644 index 0000000..2b44735 --- /dev/null +++ b/src/entities/file-entry/ui/__tests__/FileRow.hover.test.tsx @@ -0,0 +1,69 @@ +import * as rtl from "@testing-library/react" +import { describe, expect, it, vi } from "vitest" +import { TooltipProvider } from "@/shared/ui" +import { FileRow } from "../FileRow" + +const baseFile = { + name: "file.txt", + path: "/file.txt", + is_dir: false, + is_hidden: false, + size: 1024, + modified: Date.now(), + created: Date.now(), + extension: "txt", +} + +describe("FileRow hover behavior", () => { + it("shows actions on pointerEnter and hides on pointerLeave", async () => { + const onSelect = vi.fn() + const onOpen = vi.fn() + const onQuickLook = vi.fn() + const props = { + file: baseFile, + isSelected: false, + onSelect, + onOpen, + onCopy: () => {}, + onCut: () => {}, + onRename: () => {}, + onDelete: () => {}, + onQuickLook, + } + + const { container } = rtl.render( + + + , + ) + const actions = container.querySelector(".mr-2") + expect(actions).toBeTruthy() + expect(actions?.classList.contains("opacity-0")).toBe(true) + + const row = container.firstElementChild as Element + + // row should have no-drag applied so it's clickable inside drag regions + expect(row.classList.contains("no-drag")).toBe(true) + expect(row.getAttribute("data-testid")).toBe(`file-row-${encodeURIComponent("/file.txt")}`) + + rtl.fireEvent.pointerEnter(row) + expect(actions?.classList.contains("opacity-100")).toBe(true) + + const btn = actions?.querySelector("button") + expect(btn).toBeTruthy() + expect(btn?.classList.contains("no-drag")).toBe(true) + + // There should be no More actions menu/button + const moreBtn = actions?.querySelector("button[aria-label='More actions']") + expect(moreBtn).toBeNull() + + // Quick Look button should exist and call handler + const quickLookBtn = actions?.querySelector("button[aria-label='Quick Look']") as Element + expect(quickLookBtn).toBeTruthy() + rtl.fireEvent.click(quickLookBtn) + expect(onQuickLook).toHaveBeenCalled() + + rtl.fireEvent.pointerLeave(row) + expect(actions?.classList.contains("opacity-0")).toBe(true) + }) +}) diff --git a/src/entities/file-entry/ui/__tests__/FileRow.selectionHover.test.tsx b/src/entities/file-entry/ui/__tests__/FileRow.selectionHover.test.tsx new file mode 100644 index 0000000..252ef0f --- /dev/null +++ b/src/entities/file-entry/ui/__tests__/FileRow.selectionHover.test.tsx @@ -0,0 +1,36 @@ +import { fireEvent, render } from "@testing-library/react" +import { describe, expect, it } from "vitest" +import { FileRow } from "../FileRow" + +const baseFile = { + name: "file.txt", + path: "/file.txt", + is_dir: false, + is_hidden: false, + size: 1024, + modified: Date.now(), + created: Date.now(), + extension: "txt", +} + +describe("FileRow selection + hover", () => { + it("keeps selection visual when hovered", () => { + const onSelect = () => {} + const onOpen = () => {} + + const { container } = render( + , + ) + + const row = container.firstElementChild as Element + expect(row).toBeTruthy() + + expect(row.classList.contains("bg-accent")).toBe(true) + + fireEvent.pointerEnter(row) + expect(row.classList.contains("bg-accent")).toBe(true) + + fireEvent.pointerLeave(row) + expect(row.classList.contains("bg-accent")).toBe(true) + }) +}) diff --git a/src/entities/file-entry/ui/__tests__/FileRow.settingsReactive.test.tsx b/src/entities/file-entry/ui/__tests__/FileRow.settingsReactive.test.tsx new file mode 100644 index 0000000..38516e6 --- /dev/null +++ b/src/entities/file-entry/ui/__tests__/FileRow.settingsReactive.test.tsx @@ -0,0 +1,92 @@ +import { fireEvent, render, waitFor } from "@testing-library/react" +import { useState } from "react" +import { describe, expect, it } from "vitest" +import { FileRow } from "../FileRow" + +const nowSec = Math.floor(Date.now() / 1000) +const defaultAppearance = { reducedMotion: false } + +type TestFileEntry = { + path: string + name: string + is_dir: boolean + is_hidden: boolean + extension: string | null + size: number + modified: number | null + created: number | null +} + +type TestFileDisplaySettings = { + showFileExtensions: boolean + showFileSizes: boolean + showFileDates: boolean + dateFormat: "relative" | "absolute" | "auto" + thumbnailSize: "small" | "medium" | "large" +} + +function Wrapper({ file }: { file: TestFileEntry }) { + const [dateFormat, setDateFormat] = useState<"relative" | "absolute" | "auto">("relative") + + const displaySettings: TestFileDisplaySettings = { + showFileExtensions: true, + showFileSizes: false, + showFileDates: true, + dateFormat, + thumbnailSize: "medium", + } + + return ( +
+ + {}} + onOpen={() => {}} + displaySettings={displaySettings} + appearance={defaultAppearance} + /> +
+ ) +} + +describe("FileRow reacts to FileDisplaySettings changes", () => { + it("updates date display immediately when dateFormat changes", async () => { + const file: TestFileEntry = { + path: "/f", + name: "f.txt", + is_dir: false, + is_hidden: false, + extension: "txt", + size: 0, + modified: nowSec, + created: null, + } + + const { container, getByTestId } = render() + + // initially should contain relative text + await waitFor(() => { + const text = container.textContent ?? "" + expect(text).toMatch(/(только|мин\.)/) + }) + + // Switch to absolute via the wrapper control + const btn = getByTestId("toggle-date-format") + fireEvent.click(btn) + + // Expect DOM to update without navigation + await waitFor(() => { + const text = container.textContent ?? "" + // absolute produces a numeric date string like 'dd.mm.yyyy' per formatDate + expect(text).toMatch(/\d{2}\.\d{2}\.\d{4}/) + }) + }) +}) diff --git a/src/entities/file-entry/ui/__tests__/FileRow.test.tsx b/src/entities/file-entry/ui/__tests__/FileRow.test.tsx index 033cb2d..dffa22a 100644 --- a/src/entities/file-entry/ui/__tests__/FileRow.test.tsx +++ b/src/entities/file-entry/ui/__tests__/FileRow.test.tsx @@ -1,8 +1,32 @@ import { render } from "@testing-library/react" import { expect, test, vi } from "vitest" -import type { FileEntry } from "@/shared/api/tauri" + +// Minimal FileDisplaySettings for unit tests +type FileDisplaySettings = { + showFileExtensions: boolean + showFileSizes: boolean + showFileDates: boolean + showHiddenFiles: boolean + dateFormat: "relative" | "absolute" + thumbnailSize: "small" | "medium" | "large" +} + +// Minimal FileEntry for unit tests +type FileEntry = { + path: string + name: string + is_dir: boolean + is_hidden: boolean + extension: string | null + size: number + modified: number | null + created: number | null +} + import { FileRow } from "../FileRow" +const nowSec = Math.floor(Date.now() / 1000) + const file: FileEntry = { path: "/tmp/file.txt", name: "file.txt", @@ -10,15 +34,33 @@ const file: FileEntry = { is_hidden: false, extension: "txt", size: 100, - modified: Date.now(), + modified: nowSec, created: null, } +const defaultDisplay: FileDisplaySettings = { + showFileExtensions: true, + showFileSizes: true, + showFileDates: true, + showHiddenFiles: false, + dateFormat: "relative", + thumbnailSize: "medium", +} + +const defaultAppearance = { reducedMotion: false } + test("right-click selects item and doesn't prevent default", () => { const onSelect = vi.fn() const onOpen = vi.fn() const { getByText } = render( - , + , ) const node = getByText("file.txt") @@ -26,7 +68,59 @@ test("right-click selects item and doesn't prevent default", () => { const event = new MouseEvent("contextmenu", { bubbles: true, cancelable: true, button: 2 }) const prevented = node.dispatchEvent(event) - // dispatchEvent returns false if preventDefault was called expect(prevented).toBe(true) expect(onSelect).toHaveBeenCalled() }) + +test("scrollIntoView uses smooth by default and auto when reducedMotion", () => { + type ScrollIntoViewFn = (options?: ScrollIntoViewOptions | boolean) => void + const proto = Element.prototype as unknown as { scrollIntoView?: ScrollIntoViewFn } + const original = proto.scrollIntoView + const scrollSpy = vi.fn() + Object.defineProperty(Element.prototype, "scrollIntoView", { + configurable: true, + value: scrollSpy, + }) + + try { + const { rerender } = render( + {}} + onOpen={() => {}} + displaySettings={defaultDisplay} + appearance={defaultAppearance} + />, + ) + + expect(scrollSpy).toHaveBeenCalled() + const lastCall = scrollSpy.mock.calls[scrollSpy.mock.calls.length - 1] as unknown[] | undefined + const lastArg = lastCall ? (lastCall[0] as ScrollIntoViewOptions) : undefined + expect(lastArg?.behavior).toBe("smooth") + + rerender( + {}} + onOpen={() => {}} + displaySettings={defaultDisplay} + appearance={{ reducedMotion: true }} + />, + ) + + const lastCall2 = scrollSpy.mock.calls[scrollSpy.mock.calls.length - 1] as unknown[] | undefined + const lastArg2 = lastCall2 ? (lastCall2[0] as ScrollIntoViewOptions) : undefined + expect(lastArg2?.behavior).toBe("auto") + } finally { + if (original === undefined) delete proto.scrollIntoView + else + Object.defineProperty(Element.prototype, "scrollIntoView", { + configurable: true, + value: original, + }) + } +}) diff --git a/src/entities/file-entry/ui/__tests__/thumbnail.lqip.slow.test.tsx b/src/entities/file-entry/ui/__tests__/thumbnail.lqip.slow.test.tsx new file mode 100644 index 0000000..fddac59 --- /dev/null +++ b/src/entities/file-entry/ui/__tests__/thumbnail.lqip.slow.test.tsx @@ -0,0 +1,48 @@ +/// +import { render, waitFor } from "@testing-library/react" +import { expect, test, vi } from "vitest" +import type { Thumbnail } from "@/shared/api/tauri" +import { tauriClient } from "@/shared/api/tauri/client" +import { FileThumbnail } from "../FileThumbnail" + +test("shows LQIP quickly and replaces with delayed full thumbnail", async () => { + const small = { base64: "c21hbGw=", mime: "image/png" } + const full = { base64: "Zm9vYmFy", mime: "image/png" } + + // Mock getThumbnail so that the first call resolves immediately (LQIP) + // and the second call resolves after a short delay to simulate slow generation + const spy = vi.spyOn(tauriClient, "getThumbnail") + spy.mockImplementationOnce(async () => small as Thumbnail) + spy.mockImplementationOnce(async () => { + // simulate delay + await new Promise((res) => setTimeout(res, 50)) + return full as Thumbnail + }) + + const { container } = render( +
+ +
, + ) + + const img = container.querySelector("img")! + + // LQIP should appear quickly + await waitFor(() => expect(img.src).toContain("data:image/png;base64,c21hbGw="), { + timeout: 100, + }) + + // Then the delayed full thumbnail should replace it + await waitFor(() => expect(img.src).toContain("data:image/png;base64,Zm9vYmFy"), { + timeout: 500, + }) + + spy.mockRestore() +}) diff --git a/src/entities/file-entry/ui/__tests__/thumbnail.lqip.test.tsx b/src/entities/file-entry/ui/__tests__/thumbnail.lqip.test.tsx new file mode 100644 index 0000000..f38d4e0 --- /dev/null +++ b/src/entities/file-entry/ui/__tests__/thumbnail.lqip.test.tsx @@ -0,0 +1,36 @@ +/// +import { render, waitFor } from "@testing-library/react" +import { expect, test, vi } from "vitest" +import type { Thumbnail } from "@/shared/api/tauri" +import { tauriClient } from "@/shared/api/tauri/client" +import { FileThumbnail } from "../FileThumbnail" + +test("shows LQIP then replaces with full thumbnail", async () => { + const small = { base64: "c21hbGw=", mime: "image/png" } + const full = { base64: "Zm9vYmFy", mime: "image/png" } + + const spy = vi.spyOn(tauriClient, "getThumbnail") + spy.mockResolvedValueOnce(small as Thumbnail).mockResolvedValueOnce(full as Thumbnail) + + const { container } = render( +
+ +
, + ) + + const img = container.querySelector("img")! + // initially LQIP should be set + await waitFor(() => expect(img.src).toContain("data:image/png;base64,c21hbGw=")) + + // then full should replace it + await waitFor(() => expect(img.src).toContain("data:image/png;base64,Zm9vYmFy")) + + spy.mockRestore() +}) diff --git a/src/entities/file-entry/ui/__tests__/thumbnail.lru.test.tsx b/src/entities/file-entry/ui/__tests__/thumbnail.lru.test.tsx new file mode 100644 index 0000000..803238f --- /dev/null +++ b/src/entities/file-entry/ui/__tests__/thumbnail.lru.test.tsx @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest" +import { __maybeCacheThumbnail, __thumbnailCache } from "../FileThumbnail" + +describe("thumbnail cache LRU behaviour", () => { + it("keeps newest entries and prunes oldest when exceeding max size", () => { + // Clear cache initially + __thumbnailCache.clear() + + // Add N entries + __maybeCacheThumbnail("/a", "url-a", 3) + __maybeCacheThumbnail("/b", "url-b", 3) + __maybeCacheThumbnail("/c", "url-c", 3) + + expect(__thumbnailCache.size).toBe(3) + expect(Array.from(__thumbnailCache.keys())).toEqual(["/a", "/b", "/c"]) // insertion order + + // Adding a new entry should prune the oldest (/a) + __maybeCacheThumbnail("/d", "url-d", 3) + + expect(__thumbnailCache.size).toBe(3) + expect(__thumbnailCache.has("/a")).toBe(false) + expect(__thumbnailCache.has("/b")).toBe(true) + expect(__thumbnailCache.has("/c")).toBe(true) + expect(__thumbnailCache.has("/d")).toBe(true) + + // Touch /b to make it newest + __maybeCacheThumbnail("/b", "url-b-v2", 3) + // Add another entry to cause prune + __maybeCacheThumbnail("/e", "url-e", 3) + + // Now the oldest should be /c + expect(__thumbnailCache.has("/c")).toBe(false) + expect(__thumbnailCache.has("/b")).toBe(true) + expect(__thumbnailCache.has("/d")).toBe(true) + expect(__thumbnailCache.has("/e")).toBe(true) + }) +}) diff --git a/src/entities/file-entry/ui/__tests__/thumbnail.test.tsx b/src/entities/file-entry/ui/__tests__/thumbnail.test.tsx new file mode 100644 index 0000000..7df9a82 --- /dev/null +++ b/src/entities/file-entry/ui/__tests__/thumbnail.test.tsx @@ -0,0 +1,55 @@ +/// +import { fireEvent, render, waitFor } from "@testing-library/react" +import { expect, test, vi } from "vitest" +import type { FilePreview } from "@/shared/api/tauri" +import { tauriClient } from "@/shared/api/tauri/client" +import { FileThumbnail } from "../FileThumbnail" + +test("uses CSS var for transition duration so animations-off works", () => { + const { container } = render( +
+ +
, + ) + + const img = container.querySelector("img") + if (!img) { + expect(true).toBe(true) + return + } + + expect(img.getAttribute("style")).toContain("var(--transition-duration)") +}) + +test("falls back to base64 preview when file:// image fails", async () => { + const preview = { type: "Image", mime: "image/png", base64: "dGVzdA==" } as FilePreview + const spy = vi.spyOn(tauriClient, "getFilePreview").mockResolvedValue(preview) + + const { container } = render( +
+ +
, + ) + + const img = container.querySelector("img")! + // simulate native image load error + fireEvent.error(img) + + await waitFor(() => { + expect(img.src).toContain("data:image/png;base64,dGVzdA==") + }) + + spy.mockRestore() +}) diff --git a/src/features/command-palette/hooks/useRegisterCommands.ts b/src/features/command-palette/hooks/useRegisterCommands.ts index f0b478d..9f9b82f 100644 --- a/src/features/command-palette/hooks/useRegisterCommands.ts +++ b/src/features/command-palette/hooks/useRegisterCommands.ts @@ -1,10 +1,11 @@ -import { useEffect } from "react" +import { useCallback, useEffect } from "react" import { useBookmarksStore } from "@/features/bookmarks" import { useClipboardStore } from "@/features/clipboard" import { useSelectionStore } from "@/features/file-selection" import { useInlineEditStore } from "@/features/inline-edit" import { useNavigationStore } from "@/features/navigation" import { useQuickFilterStore } from "@/features/quick-filter" +import { useFileDisplaySettings, useSettingsStore } from "@/features/settings" import { useViewModeStore } from "@/features/view-mode" import { type Command, useCommandPaletteStore } from "../model/store" @@ -24,7 +25,12 @@ export function useRegisterCommands({ const { copy, cut } = useClipboardStore() const { getSelectedPaths, clearSelection } = useSelectionStore() const { startNewFolder, startNewFile } = useInlineEditStore() - const { settings, setViewMode, toggleHidden } = useViewModeStore() + const { setViewMode } = useViewModeStore() + const displaySettings = useFileDisplaySettings() + const updateFileDisplay = useSettingsStore((s) => s.updateFileDisplay) + const toggleHidden = useCallback(() => { + updateFileDisplay({ showHiddenFiles: !displaySettings.showHiddenFiles }) + }, [displaySettings.showHiddenFiles, updateFileDisplay]) const { toggle: toggleQuickFilter } = useQuickFilterStore() const { isBookmarked, addBookmark, removeBookmark, getBookmarkByPath } = useBookmarksStore() @@ -168,9 +174,9 @@ export function useRegisterCommands({ }, { id: "view-toggle-hidden", - title: settings.showHidden ? "Скрыть скрытые файлы" : "Показать скрытые файлы", + title: displaySettings.showHiddenFiles ? "Скрыть скрытые файлы" : "Показать скрытые файлы", description: "Переключить отображение скрытых файлов", - icon: settings.showHidden ? "eye-off" : "eye", + icon: displaySettings.showHiddenFiles ? "eye-off" : "eye", category: "view", action: toggleHidden, keywords: ["hidden", "show", "скрытые"], @@ -245,7 +251,7 @@ export function useRegisterCommands({ setViewMode, toggleHidden, toggleQuickFilter, - settings.showHidden, + displaySettings.showHiddenFiles, isBookmarked, addBookmark, removeBookmark, diff --git a/src/features/command-palette/ui/CommandPalette.tsx b/src/features/command-palette/ui/CommandPalette.tsx index ee6d910..2cde1fb 100644 --- a/src/features/command-palette/ui/CommandPalette.tsx +++ b/src/features/command-palette/ui/CommandPalette.tsx @@ -153,17 +153,19 @@ export function CommandPalette() { ) // Global keyboard shortcut + const togglePalette = useCommandPaletteStore((s) => s.toggle) + useEffect(() => { const handleGlobalKeyDown = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key === "k") { e.preventDefault() - useCommandPaletteStore.getState().toggle() + togglePalette() } } window.addEventListener("keydown", handleGlobalKeyDown) return () => window.removeEventListener("keydown", handleGlobalKeyDown) - }, []) + }, [togglePalette]) let globalIndex = 0 diff --git a/src/features/confirm/__tests__/confirm.test.ts b/src/features/confirm/__tests__/confirm.test.ts new file mode 100644 index 0000000..8332eb3 --- /dev/null +++ b/src/features/confirm/__tests__/confirm.test.ts @@ -0,0 +1,21 @@ +// @ts-nocheck +/// +import { act } from "@testing-library/react" +import { useConfirmStore } from "../model/store" + +describe("confirm store", () => { + it("resolves true when confirmed, false when cancelled", async () => { + // Open confirm and resolve via confirm() + const p = act(() => useConfirmStore.getState().open("T", "M")) + + // Confirm should resolve true + act(() => useConfirmStore.getState().confirm()) + + await expect(p).resolves.toBe(true) + + // Open again and cancel + const p2 = act(() => useConfirmStore.getState().open("T2", "M2")) + act(() => useConfirmStore.getState().cancel()) + await expect(p2).resolves.toBe(false) + }) +}) diff --git a/src/features/confirm/index.ts b/src/features/confirm/index.ts new file mode 100644 index 0000000..cc12b87 --- /dev/null +++ b/src/features/confirm/index.ts @@ -0,0 +1,2 @@ +export { useConfirmStore } from "./model/store" +export { ConfirmDialog } from "./ui/ConfirmDialog" diff --git a/src/features/confirm/model/store.ts b/src/features/confirm/model/store.ts new file mode 100644 index 0000000..5575539 --- /dev/null +++ b/src/features/confirm/model/store.ts @@ -0,0 +1,44 @@ +import { create } from "zustand" + +interface ConfirmState { + isOpen: boolean + title?: string + message?: string + onConfirm: (() => void) | null + open: (title: string, message: string) => Promise + close: () => void + confirm: () => void + cancel: () => void +} + +export const useConfirmStore = create((set, get) => ({ + isOpen: false, + title: undefined, + message: undefined, + onConfirm: null, + + open: (title, message) => { + return new Promise((resolve) => { + set({ isOpen: true, title, message, onConfirm: () => resolve(true) }) + + const unsubscribe = useConfirmStore.subscribe((state) => { + if (!state.isOpen && !state.onConfirm) { + resolve(false) + unsubscribe() + } + }) + }) + }, + + close: () => set({ isOpen: false, title: undefined, message: undefined, onConfirm: null }), + + confirm: () => { + const { onConfirm } = get() + if (onConfirm) onConfirm() + set({ isOpen: false, title: undefined, message: undefined, onConfirm: null }) + }, + + cancel: () => { + set({ isOpen: false, title: undefined, message: undefined, onConfirm: null }) + }, +})) diff --git a/src/features/confirm/ui/ConfirmDialog.tsx b/src/features/confirm/ui/ConfirmDialog.tsx new file mode 100644 index 0000000..445c90d --- /dev/null +++ b/src/features/confirm/ui/ConfirmDialog.tsx @@ -0,0 +1,26 @@ +import { Button } from "@/shared/ui/button" +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/shared/ui/dialog" +import { useConfirmStore } from "../model/store" + +export function ConfirmDialog() { + const { isOpen, title, message, confirm, cancel } = useConfirmStore() + + return ( + cancel()}> + + + {title || "Подтверждение"} + + +
{message}
+ + + + + +
+
+ ) +} diff --git a/src/features/context-menu/__tests__/FileContextMenu.platform.test.tsx b/src/features/context-menu/__tests__/FileContextMenu.platform.test.tsx new file mode 100644 index 0000000..1d78cbd --- /dev/null +++ b/src/features/context-menu/__tests__/FileContextMenu.platform.test.tsx @@ -0,0 +1,29 @@ +/// +import { render, screen } from "@testing-library/react" +import { expect, it } from "vitest" +import { FileContextMenu } from "@/features/context-menu/ui/FileContextMenu" + +it("shows Open in Explorer when context menu is opened", async () => { + render( + {}} + onCut={() => {}} + onPaste={() => {}} + onDelete={() => {}} + onRename={() => {}} + onNewFolder={() => {}} + onNewFile={() => {}} + onRefresh={() => {}} + canPaste={false} + > +
Trigger
+
, + ) + + // open the menu + const trigger = screen.getByText("Trigger") + trigger.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true })) + + expect(await screen.findByText("Открыть в проводнике")).toBeTruthy() +}) diff --git a/src/features/context-menu/ui/FileContextMenu.tsx b/src/features/context-menu/ui/FileContextMenu.tsx index 89cdc5f..ff2c234 100644 --- a/src/features/context-menu/ui/FileContextMenu.tsx +++ b/src/features/context-menu/ui/FileContextMenu.tsx @@ -13,6 +13,7 @@ import { Trash2, } from "lucide-react" import { useBookmarksStore } from "@/features/bookmarks" +import { useSelectionStore } from "@/features/file-selection" import { ContextMenu, ContextMenuContent, @@ -58,11 +59,17 @@ export function FileContextMenu({ onOpenInTerminal, canPaste, }: FileContextMenuProps) { - const hasSelection = selectedPaths.length > 0 - const singleSelection = selectedPaths.length === 1 + // Derive selection from the selection store at render time to avoid race conditions + // where the context menu may render before parent props are updated on right-click. + // If a parent provides `selectedPaths` prop (used in tests), prefer that when non-empty. + const getSelectedPathsFromStore = useSelectionStore((s) => s.getSelectedPaths) + const selectedFromStore = getSelectedPathsFromStore() + const selected = (selectedFromStore.length > 0 ? selectedFromStore : selectedPaths) ?? [] + const hasSelection = selected.length > 0 + const singleSelection = selected.length === 1 const { isBookmarked, addBookmark, removeBookmark, getBookmarkByPath } = useBookmarksStore() - const selectedPath = singleSelection ? selectedPaths[0] : null + const selectedPath = singleSelection ? selected[0] : null const isBookmark = selectedPath ? isBookmarked(selectedPath) : false const handleToggleBookmark = () => { diff --git a/src/features/inline-edit/model/store.ts b/src/features/inline-edit/model/store.ts index 275a4ff..16425b4 100644 --- a/src/features/inline-edit/model/store.ts +++ b/src/features/inline-edit/model/store.ts @@ -4,8 +4,8 @@ export type InlineEditMode = "new-folder" | "new-file" | "rename" | null interface InlineEditState { mode: InlineEditMode - targetPath: string | null // для rename - путь к файлу - parentPath: string | null // для new - путь к папке + targetPath: string | null // for rename - path to file + parentPath: string | null // for new - path to parent folder startNewFolder: (parentPath: string) => void startNewFile: (parentPath: string) => void diff --git a/src/features/keyboard-navigation/model/useKeyboardNavigation.ts b/src/features/keyboard-navigation/model/useKeyboardNavigation.ts index b0afaa6..e6c4e97 100644 --- a/src/features/keyboard-navigation/model/useKeyboardNavigation.ts +++ b/src/features/keyboard-navigation/model/useKeyboardNavigation.ts @@ -23,7 +23,6 @@ export function useKeyboardNavigation({ }: UseKeyboardNavigationOptions): UseKeyboardNavigationResult { const [focusedIndex, setFocusedIndex] = useState(-1) const filesRef = useRef(files) - const lastSelectionRef = useRef(null) // Update ref when files change useEffect(() => { @@ -32,24 +31,27 @@ export function useKeyboardNavigation({ // Sync focused index with selection useEffect(() => { - if (selectedPaths.size === 1) { - const selectedPath = Array.from(selectedPaths)[0] - if (selectedPath !== lastSelectionRef.current) { - const index = files.findIndex((f) => f.path === selectedPath) - if (index !== -1) { - setFocusedIndex(index) - lastSelectionRef.current = selectedPath - } + // Guard against undefined selectedPaths + if (!selectedPaths || selectedPaths.size === 0) { + return + } + + // Find the index of the last selected file + const lastSelected = Array.from(selectedPaths).pop() + if (lastSelected) { + const index = files.findIndex((f) => f.path === lastSelected) + if (index !== -1 && index !== focusedIndex) { + setFocusedIndex(index) } - } else if (selectedPaths.size === 0) { - lastSelectionRef.current = null } - }, [selectedPaths, files]) + }, [selectedPaths, files, focusedIndex]) // Reset focus when files change significantly useEffect(() => { - if (focusedIndex >= files.length) { - setFocusedIndex(files.length > 0 ? files.length - 1 : -1) + if (files.length === 0) { + setFocusedIndex(-1) + } else if (focusedIndex >= files.length) { + setFocusedIndex(Math.max(0, files.length - 1)) } }, [files.length, focusedIndex]) @@ -71,75 +73,51 @@ export function useKeyboardNavigation({ e.preventDefault() const nextIndex = Math.min(focusedIndex + 1, currentFiles.length - 1) setFocusedIndex(nextIndex) - const file = currentFiles[nextIndex] - if (file) { - onSelect(file.path, { ctrlKey: e.ctrlKey, shiftKey: e.shiftKey }) - } + onSelect(currentFiles[nextIndex].path, { ctrlKey: e.ctrlKey, shiftKey: e.shiftKey }) break } - case "ArrowUp": { e.preventDefault() const prevIndex = Math.max(focusedIndex - 1, 0) setFocusedIndex(prevIndex) - const file = currentFiles[prevIndex] - if (file) { - onSelect(file.path, { ctrlKey: e.ctrlKey, shiftKey: e.shiftKey }) - } + onSelect(currentFiles[prevIndex].path, { ctrlKey: e.ctrlKey, shiftKey: e.shiftKey }) break } - case "Home": { e.preventDefault() setFocusedIndex(0) - const file = currentFiles[0] - if (file) { - onSelect(file.path, { ctrlKey: e.ctrlKey, shiftKey: e.shiftKey }) - } + onSelect(currentFiles[0].path, { ctrlKey: e.ctrlKey, shiftKey: e.shiftKey }) break } - case "End": { e.preventDefault() const lastIndex = currentFiles.length - 1 setFocusedIndex(lastIndex) - const file = currentFiles[lastIndex] - if (file) { - onSelect(file.path, { ctrlKey: e.ctrlKey, shiftKey: e.shiftKey }) + onSelect(currentFiles[lastIndex].path, { ctrlKey: e.ctrlKey, shiftKey: e.shiftKey }) + break + } + case "Enter": { + e.preventDefault() + if (focusedIndex >= 0 && focusedIndex < currentFiles.length) { + const file = currentFiles[focusedIndex] + onOpen(file.path, file.is_dir) } break } - case "PageDown": { e.preventDefault() const pageSize = 10 const nextIndex = Math.min(focusedIndex + pageSize, currentFiles.length - 1) setFocusedIndex(nextIndex) - const file = currentFiles[nextIndex] - if (file) { - onSelect(file.path, { ctrlKey: e.ctrlKey, shiftKey: e.shiftKey }) - } + onSelect(currentFiles[nextIndex].path, { ctrlKey: e.ctrlKey, shiftKey: e.shiftKey }) break } - case "PageUp": { e.preventDefault() const pageSize = 10 const prevIndex = Math.max(focusedIndex - pageSize, 0) setFocusedIndex(prevIndex) - const file = currentFiles[prevIndex] - if (file) { - onSelect(file.path, { ctrlKey: e.ctrlKey, shiftKey: e.shiftKey }) - } - break - } - - case "Enter": { - e.preventDefault() - const file = currentFiles[focusedIndex] - if (file) { - onOpen(file.path, file.is_dir) - } + onSelect(currentFiles[prevIndex].path, { ctrlKey: e.ctrlKey, shiftKey: e.shiftKey }) break } } diff --git a/src/features/layout/__tests__/debounceDelayChange.test.ts b/src/features/layout/__tests__/debounceDelayChange.test.ts new file mode 100644 index 0000000..fb81877 --- /dev/null +++ b/src/features/layout/__tests__/debounceDelayChange.test.ts @@ -0,0 +1,39 @@ +/// + +import { beforeEach, describe, expect, it, vi } from "vitest" +import { useLayoutStore } from "@/features/layout" +import { initLayoutSync } from "@/features/layout/sync" +import { useSettingsStore } from "@/features/settings" + +describe("layout sync - debounceDelay change", () => { + beforeEach(() => { + useSettingsStore.getState().resetSettings() + useLayoutStore.getState().resetLayout() + }) + + it("respects updated debounceDelay from settings", async () => { + vi.useFakeTimers() + const spy = vi.spyOn(useSettingsStore, "setState") + + const cleanup = initLayoutSync() + + // Change debounce delay to a small value + useSettingsStore.getState().updatePerformance({ debounceDelay: 10 }) + + // Make rapid layout updates + useLayoutStore.getState().setColumnWidth("size", 120) + useLayoutStore.getState().setColumnWidth("size", 130) + + // advance less than new debounce — should not flush yet + vi.advanceTimersByTime(5) + expect(spy).not.toHaveBeenCalled() + + // advance beyond new debounce delay + vi.advanceTimersByTime(10) + expect(spy).toHaveBeenCalled() + + spy.mockRestore() + vi.useRealTimers() + cleanup() + }) +}) diff --git a/src/features/layout/__tests__/sync.test.ts b/src/features/layout/__tests__/sync.test.ts new file mode 100644 index 0000000..89ea81d --- /dev/null +++ b/src/features/layout/__tests__/sync.test.ts @@ -0,0 +1,55 @@ +/// +import { beforeEach, describe, expect, it } from "vitest" +import { useLayoutStore } from "@/features/layout" +import { initLayoutSync } from "@/features/layout/sync" +import { useSettingsStore } from "@/features/settings" + +describe("layout sync module", () => { + beforeEach(() => { + useSettingsStore.getState().resetSettings() + useLayoutStore.getState().resetLayout() + }) + + it("syncs settings -> runtime on init", () => { + // change settings first + useSettingsStore.getState().updatePanelLayout({ sidebarSize: 28 }) + + const cleanup = initLayoutSync() + + const runtime = useLayoutStore.getState().layout + expect(runtime.sidebarSize).toBe(28) + + cleanup() + }) + + it("syncs runtime -> settings on change", async () => { + const cleanup = initLayoutSync() + + // change runtime + useLayoutStore.getState().setSidebarSize(29) + + // Wait for debounce window to pass + await new Promise((r) => setTimeout(r, 220)) + + const settings = useSettingsStore.getState().settings.layout.panelLayout + expect(settings.sidebarSize).toBe(29) + + cleanup() + }) + + it("syncs column widths both ways", async () => { + const cleanup = initLayoutSync() + + useSettingsStore.getState().updateColumnWidths({ size: 140 }) + expect(useLayoutStore.getState().layout.columnWidths.size).toBe(140) + + useLayoutStore.getState().setColumnWidth("date", 200) + + // Wait for debounce window to pass + await new Promise((r) => setTimeout(r, 220)) + + expect(useSettingsStore.getState().settings.layout.columnWidths.date).toBe(200) + + cleanup() + }) +}) diff --git a/src/features/layout/__tests__/syncDebounce.test.ts b/src/features/layout/__tests__/syncDebounce.test.ts new file mode 100644 index 0000000..798548f --- /dev/null +++ b/src/features/layout/__tests__/syncDebounce.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from "vitest" + +// Duplicate test removed — keep a skipped suite to avoid vitest "no test suite found" error +describe.skip("syncDebounce duplicate (removed)", () => { + it("skipped", () => { + expect(true).toBe(true) + }) +}) diff --git a/src/features/layout/__tests__/syncDebounce.test.tsx b/src/features/layout/__tests__/syncDebounce.test.tsx new file mode 100644 index 0000000..b29556a --- /dev/null +++ b/src/features/layout/__tests__/syncDebounce.test.tsx @@ -0,0 +1,51 @@ +/// + +import { render } from "@testing-library/react" +import { act } from "react" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { useSyncLayoutWithSettings } from "../../../pages/file-browser/hooks/useSyncLayoutWithSettings" +import { useSettingsStore } from "../../settings/model/store" +import { useLayoutStore } from "../model/layoutStore" + +function Harness() { + useSyncLayoutWithSettings() + return null +} + +describe("layout sync debounce", () => { + beforeEach(() => { + // reset stores + useSettingsStore.getState().resetSettings() + useLayoutStore.getState().resetLayout() + }) + + it("batches rapid layout updates into a single settings update", async () => { + vi.useFakeTimers() + const spy = vi.spyOn(useSettingsStore, "setState") + + render() + + act(() => { + useLayoutStore.getState().setColumnWidth("size", 120) + useLayoutStore.getState().setColumnWidth("size", 130) + useLayoutStore.getState().setColumnWidth("date", 160) + }) + + // advance less than debounce — should not flush yet + act(() => { + vi.advanceTimersByTime(100) + }) + + expect(spy).not.toHaveBeenCalled() + + // advance beyond debounce delay + act(() => { + vi.advanceTimersByTime(200) + }) + + expect(spy).toHaveBeenCalled() + + spy.mockRestore() + vi.useRealTimers() + }) +}) diff --git a/src/features/layout/index.ts b/src/features/layout/index.ts index 29df25a..60f81cf 100644 --- a/src/features/layout/index.ts +++ b/src/features/layout/index.ts @@ -1,5 +1,8 @@ export { type ColumnWidths, type PanelLayout, + useColumnWidths, useLayoutStore, + usePreviewLayout, + useSidebarLayout, } from "./model/layoutStore" diff --git a/src/features/layout/model/layoutStore.ts b/src/features/layout/model/layoutStore.ts index 7718c28..36e28a7 100644 --- a/src/features/layout/model/layoutStore.ts +++ b/src/features/layout/model/layoutStore.ts @@ -1,5 +1,5 @@ import { create } from "zustand" -import { persist } from "zustand/middleware" +import { persist, subscribeWithSelector } from "zustand/middleware" export interface ColumnWidths { size: number @@ -15,6 +15,34 @@ export interface PanelLayout { sidebarCollapsed?: boolean showPreview: boolean columnWidths: ColumnWidths + // Persisted expanded/collapsed state for sidebar sections + expandedSections?: Record + // Lock flags: when true, size is controlled via settings sliders and resizing is disabled + sidebarSizeLocked?: boolean + previewSizeLocked?: boolean +} + +const DEFAULT_LAYOUT: PanelLayout = { + sidebarSize: 15, + mainPanelSize: 60, + previewPanelSize: 25, + showSidebar: true, + sidebarCollapsed: false, + showPreview: true, + columnWidths: { + size: 100, + date: 180, + padding: 8, + }, + // Default: all sections expanded + expandedSections: { + bookmarks: true, + recent: true, + drives: true, + quickAccess: true, + }, + sidebarSizeLocked: false, + previewSizeLocked: false, } interface LayoutState { @@ -27,31 +55,20 @@ interface LayoutState { setSidebarCollapsed: (collapsed: boolean) => void toggleSidebar: () => void togglePreview: () => void + setSectionExpanded: (section: string, expanded: boolean) => void + toggleSectionExpanded: (section: string) => void resetLayout: () => void -} - -const defaultLayout: PanelLayout = { - sidebarSize: 20, - sidebarCollapsed: false, - mainPanelSize: 80, - previewPanelSize: 0, - showSidebar: true, - showPreview: false, - columnWidths: { - size: 80, - date: 140, - padding: 12, - }, + applyLayout: (layout: PanelLayout) => void } export const useLayoutStore = create()( persist( - (set) => ({ - layout: defaultLayout, + subscribeWithSelector((set) => ({ + layout: DEFAULT_LAYOUT, - setLayout: (newLayout) => + setLayout: (updates) => set((state) => ({ - layout: { ...state.layout, ...newLayout }, + layout: { ...state.layout, ...updates }, })), setSidebarSize: (size) => @@ -59,11 +76,6 @@ export const useLayoutStore = create()( layout: { ...state.layout, sidebarSize: size }, })), - setSidebarCollapsed: (collapsed: boolean) => - set((state) => ({ - layout: { ...state.layout, sidebarCollapsed: collapsed }, - })), - setMainPanelSize: (size) => set((state) => ({ layout: { ...state.layout, mainPanelSize: size }, @@ -82,6 +94,11 @@ export const useLayoutStore = create()( }, })), + setSidebarCollapsed: (collapsed) => + set((state) => ({ + layout: { ...state.layout, sidebarCollapsed: collapsed }, + })), + toggleSidebar: () => set((state) => ({ layout: { ...state.layout, showSidebar: !state.layout.showSidebar }, @@ -92,24 +109,55 @@ export const useLayoutStore = create()( layout: { ...state.layout, showPreview: !state.layout.showPreview }, })), - resetLayout: () => set({ layout: defaultLayout }), - }), - { - name: "file-manager-layout", - merge: (persistedState, currentState) => { - const persisted = persistedState as Partial | undefined - return { - ...currentState, + setSectionExpanded: (section: string, expanded: boolean) => + set((state) => ({ + layout: { + ...state.layout, + expandedSections: { ...(state.layout.expandedSections ?? {}), [section]: expanded }, + }, + })), + + toggleSectionExpanded: (section: string) => + set((state) => ({ layout: { - ...defaultLayout, - ...persisted?.layout, - columnWidths: { - ...defaultLayout.columnWidths, - ...persisted?.layout?.columnWidths, + ...state.layout, + expandedSections: { + ...(state.layout.expandedSections ?? {}), + [section]: !(state.layout.expandedSections?.[section] ?? true), }, }, - } - }, + })), + + resetLayout: () => set({ layout: DEFAULT_LAYOUT }), + + applyLayout: (layout) => + set((state) => ({ + layout: { + ...layout, + // Preserve persisted expandedSections if already set in runtime state + expandedSections: state.layout.expandedSections ?? layout.expandedSections, + }, + })), + })), + { + name: "layout-storage", + partialize: (state) => ({ layout: state.layout }), }, ), ) + +// Selector hooks for optimized re-renders +export const useSidebarLayout = () => + useLayoutStore((s) => ({ + showSidebar: s.layout.showSidebar, + sidebarSize: s.layout.sidebarSize, + sidebarCollapsed: s.layout.sidebarCollapsed, + })) + +export const usePreviewLayout = () => + useLayoutStore((s) => ({ + showPreview: s.layout.showPreview, + previewPanelSize: s.layout.previewPanelSize, + })) + +export const useColumnWidths = () => useLayoutStore((s) => s.layout.columnWidths) diff --git a/src/features/layout/panelController.ts b/src/features/layout/panelController.ts new file mode 100644 index 0000000..aaf5112 --- /dev/null +++ b/src/features/layout/panelController.ts @@ -0,0 +1,39 @@ +import type { ImperativePanelHandle } from "react-resizable-panels" +import type { PanelLayout } from "./model/layoutStore" + +let sidebarRef: React.RefObject | null = null +let _previewRef: React.RefObject | null = null +// Keep reference variable intentionally — mark as used to avoid TS unused var error +void _previewRef + +export function registerSidebar(ref: React.RefObject | null) { + sidebarRef = ref +} + +export function registerPreview(ref: React.RefObject | null) { + _previewRef = ref +} + +function defer(fn: () => void) { + // Defer to next tick to allow panels to mount — use globalThis so it's safe in browser and Node + globalThis.setTimeout(fn, 0) +} + +export function applyLayoutToPanels(layout: PanelLayout) { + // Sidebar collapsed state + if (layout.sidebarCollapsed) { + defer(() => sidebarRef?.current?.collapse?.()) + } else { + defer(() => sidebarRef?.current?.expand?.()) + } + + // No imperative API for preview collapse/expand; keep placeholder for future +} + +export function forceCollapseSidebar() { + defer(() => sidebarRef?.current?.collapse?.()) +} + +export function forceExpandSidebar() { + defer(() => sidebarRef?.current?.expand?.()) +} diff --git a/src/features/layout/sync.ts b/src/features/layout/sync.ts new file mode 100644 index 0000000..4474a79 --- /dev/null +++ b/src/features/layout/sync.ts @@ -0,0 +1,156 @@ +import { useSettingsStore } from "@/features/settings" +import type { LayoutSettings } from "@/features/settings/model/types" +import type { PanelLayout } from "./model/layoutStore" +import { useLayoutStore } from "./model/layoutStore" +import { applyLayoutToPanels } from "./panelController" + +let applyingSettings = false +let settingsUnsub: (() => void) | null = null +let columnUnsub: (() => void) | null = null +let layoutUnsub: (() => void) | null = null +let perfUnsub: (() => void) | null = null +let debounceDelay = 150 + +export function initLayoutSync() { + // Apply current settings -> runtime + const settingsStateInitial = useSettingsStore.getState().settings + const settingsPanel = settingsStateInitial.layout.panelLayout + const cw = settingsStateInitial.layout.columnWidths + debounceDelay = settingsStateInitial.performance.debounceDelay ?? 150 + + useLayoutStore.getState().applyLayout(settingsPanel) + // Clamp incoming settings to sensible minimums to avoid zero-width columns + const clamp = (v: number | undefined, min: number) => Math.max(min, Math.floor(v ?? min)) + useLayoutStore.getState().setColumnWidth("size", clamp(cw.size, 50)) + useLayoutStore.getState().setColumnWidth("date", clamp(cw.date, 80)) + useLayoutStore.getState().setColumnWidth("padding", clamp(cw.padding, 0)) + + applyLayoutToPanels(settingsPanel) + + // Subscribe to settings.panelLayout changes and apply to runtime + settingsUnsub = useSettingsStore.subscribe( + (s) => s.settings.layout.panelLayout, + (newPanel: PanelLayout, oldPanel: PanelLayout | undefined) => { + // Basic reference check + if (newPanel === oldPanel) return + applyingSettings = true + try { + useLayoutStore.getState().applyLayout(newPanel) + // reflect in panels + applyLayoutToPanels(newPanel) + } finally { + applyingSettings = false + } + }, + ) + + // Subscribe to settings.columnWidths and apply to runtime + columnUnsub = useSettingsStore.subscribe( + (s) => s.settings.layout.columnWidths, + (newCW, oldCW) => { + if (newCW === oldCW) return + const clamp = (v: number | undefined, min: number) => Math.max(min, Math.floor(v ?? min)) + useLayoutStore.getState().setColumnWidth("size", clamp(newCW.size, 50)) + useLayoutStore.getState().setColumnWidth("date", clamp(newCW.date, 80)) + useLayoutStore.getState().setColumnWidth("padding", clamp(newCW.padding, 0)) + }, + ) + + // Subscribe to runtime layout changes and persist into settings (two-way sync) + // Use a debounce to batch frequent updates (e.g., during column resize) + let layoutDebounceTimer: ReturnType | null = null + let pendingLayoutForSync: PanelLayout | null = null + + // Subscribe to performance debounce setting so we react to updates without + // repeatedly querying getState inside the timeout handler. + perfUnsub = useSettingsStore.subscribe( + (s) => s.settings.performance.debounceDelay, + (d) => { + debounceDelay = d ?? 150 + }, + ) + + const scheduleFlush = () => { + if (layoutDebounceTimer) clearTimeout(layoutDebounceTimer) + const delay = debounceDelay + layoutDebounceTimer = setTimeout(() => { + const toSync = pendingLayoutForSync + pendingLayoutForSync = null + layoutDebounceTimer = null + if (!toSync) return + + // Read settings once to avoid multiple getState() calls and races + const settingsState = useSettingsStore.getState().settings + const settingsPanelNow = settingsState.layout.panelLayout + const updates: Partial = {} + + // Panel layout fields to compare + const samePanel = + settingsPanelNow.showSidebar === toSync.showSidebar && + settingsPanelNow.showPreview === toSync.showPreview && + settingsPanelNow.sidebarSize === toSync.sidebarSize && + settingsPanelNow.previewPanelSize === toSync.previewPanelSize && + (settingsPanelNow.sidebarCollapsed ?? false) === (toSync.sidebarCollapsed ?? false) + + if (!samePanel) updates.panelLayout = toSync + + const settingsCW = settingsState.layout.columnWidths + if ( + settingsCW.size !== toSync.columnWidths.size || + settingsCW.date !== toSync.columnWidths.date || + settingsCW.padding !== toSync.columnWidths.padding + ) { + updates.columnWidths = toSync.columnWidths + } + + if (Object.keys(updates).length > 0) { + // Apply updates to settings via setState to avoid getState() call and keep a single source of truth + useSettingsStore.setState((s) => ({ + settings: { + ...s.settings, + layout: { + ...s.settings.layout, + ...updates, + }, + }, + })) + } + }, delay) + } + + layoutUnsub = useLayoutStore.subscribe( + (s) => s.layout, + (newLayout) => { + if (applyingSettings) return + + // schedule a debounced sync + pendingLayoutForSync = newLayout + scheduleFlush() + }, + ) + + return () => { + settingsUnsub?.() + columnUnsub?.() + layoutUnsub?.() + perfUnsub?.() + if (layoutDebounceTimer) { + clearTimeout(layoutDebounceTimer) + layoutDebounceTimer = null + pendingLayoutForSync = null + } + settingsUnsub = null + layoutUnsub = null + perfUnsub = null + } +} + +export function stopLayoutSync() { + settingsUnsub?.() + columnUnsub?.() + layoutUnsub?.() + perfUnsub?.() + settingsUnsub = null + layoutUnsub = null + perfUnsub = null +} diff --git a/src/features/navigation/model/__tests__/perf-disabled.test.ts b/src/features/navigation/model/__tests__/perf-disabled.test.ts new file mode 100644 index 0000000..a2cf72b --- /dev/null +++ b/src/features/navigation/model/__tests__/perf-disabled.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it, vi } from "vitest" +import { useNavigationStore } from "@/features/navigation/model/store" + +describe("perf integration", () => { + it("does not emit perf logs when USE_PERF_LOGS=false", () => { + const debugSpy = vi.spyOn(console, "debug").mockImplementation(() => {}) + + // Manipulate a safe accessor to process.env used by isPerfEnabled + const proc = ( + globalThis as unknown as { process?: { env?: Record } } + ).process + const old = proc?.env?.USE_PERF_LOGS + if (proc) { + proc.env = proc.env ?? {} + proc.env.USE_PERF_LOGS = "false" + } + + try { + const { navigate } = useNavigationStore.getState() + navigate("/perf-test") + expect(debugSpy).not.toHaveBeenCalled() + } finally { + if (proc?.env) { + if (old === undefined) delete proc.env.USE_PERF_LOGS + else proc.env.USE_PERF_LOGS = old + } + debugSpy.mockRestore() + } + }) +}) diff --git a/src/features/navigation/model/store.ts b/src/features/navigation/model/store.ts index 78510d9..8aacf84 100644 --- a/src/features/navigation/model/store.ts +++ b/src/features/navigation/model/store.ts @@ -1,6 +1,8 @@ import { create } from "zustand" import { persist } from "zustand/middleware" -import { commands } from "@/shared/api/tauri" +import { tauriClient } from "@/shared/api/tauri/client" +import { setLastNav } from "@/shared/lib/devLogger" +import { markPerf } from "@/shared/lib/perf" interface NavigationState { currentPath: string | null @@ -29,6 +31,15 @@ export const useNavigationStore = create()( return } + // Mark navigation start for performance debugging + try { + const id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}` + setLastNav({ id, path, t: performance.now() }) + markPerf("nav:start", { id, path }) + } catch { + /* ignore */ + } + // Truncate forward history if navigating from middle const newHistory = history.slice(0, historyIndex + 1) newHistory.push(path) @@ -67,9 +78,9 @@ export const useNavigationStore = create()( if (!currentPath) return try { - const result = await commands.getParentPath(currentPath) - if (result.status === "ok" && result.data) { - navigate(result.data) + const parent = await tauriClient.getParentPath(currentPath) + if (parent) { + navigate(parent) } } catch (error) { console.error("Failed to navigate up:", error) diff --git a/src/features/operations-history/ui/UndoToast.tsx b/src/features/operations-history/ui/UndoToast.tsx index 902ea3c..d4718f3 100644 --- a/src/features/operations-history/ui/UndoToast.tsx +++ b/src/features/operations-history/ui/UndoToast.tsx @@ -73,9 +73,10 @@ export function UndoToast({ operation, onUndo, duration = 5000 }: UndoToastProps } // Hook to show undo toast for last operation -export function useUndoToast() { +export function useUndoToast(onOperation?: (op: Operation) => void) { const [currentOperation, setCurrentOperation] = useState(null) const { undoLastOperation } = useOperationsHistoryStore() + const operations = useOperationsHistoryStore((s) => s.operations) const showUndo = useCallback((operation: Operation) => { setCurrentOperation(operation) @@ -91,6 +92,14 @@ export function useUndoToast() { [undoLastOperation], ) + // Auto-show toasts for new operations and call optional callback + useEffect(() => { + if (!operations || operations.length === 0) return + const op = operations[0] + if (onOperation) onOperation(op) + if (op.canUndo) showUndo(op) + }, [operations, onOperation, showUndo]) + const toast = currentOperation ? ( ) : null diff --git a/src/features/quick-filter/ui/QuickFilterBar.tsx b/src/features/quick-filter/ui/QuickFilterBar.tsx index 758dab8..d3c3f68 100644 --- a/src/features/quick-filter/ui/QuickFilterBar.tsx +++ b/src/features/quick-filter/ui/QuickFilterBar.tsx @@ -1,5 +1,6 @@ import { Filter, X } from "lucide-react" import { useCallback, useEffect, useRef, useState } from "react" +import { usePerformanceSettings } from "@/features/settings" import { cn } from "@/shared/lib" import { Button, Input } from "@/shared/ui" import { useQuickFilterStore } from "../model/store" @@ -11,10 +12,13 @@ interface QuickFilterBarProps { } export function QuickFilterBar({ totalCount, filteredCount, className }: QuickFilterBarProps) { - const { filter, setFilter, deactivate, clear, isActive } = useQuickFilterStore() const inputRef = useRef(null) + const timeoutRef = useRef | null>(null) + + const { filter, setFilter, deactivate } = useQuickFilterStore() + const performanceSettings = usePerformanceSettings() + const [localValue, setLocalValue] = useState(filter) - const debounceRef = useRef(undefined) // Focus input when activated useEffect(() => { @@ -26,30 +30,30 @@ export function QuickFilterBar({ totalCount, filteredCount, className }: QuickFi setLocalValue(filter) }, [filter]) - // Debounced filter update + // Debounced filter update using settings const handleChange = useCallback( (e: React.ChangeEvent) => { const value = e.target.value setLocalValue(value) // Clear previous timeout - if (debounceRef.current) { - clearTimeout(debounceRef.current) + if (timeoutRef.current) { + clearTimeout(timeoutRef.current) } // Debounce the actual filter update - debounceRef.current = window.setTimeout(() => { + timeoutRef.current = setTimeout(() => { setFilter(value) - }, 150) + }, performanceSettings.debounceDelay) }, - [setFilter], + [setFilter, performanceSettings.debounceDelay], ) // Cleanup on unmount useEffect(() => { return () => { - if (debounceRef.current) { - window.clearTimeout(debounceRef.current) + if (timeoutRef.current) { + clearTimeout(timeoutRef.current) } } }, []) @@ -57,55 +61,49 @@ export function QuickFilterBar({ totalCount, filteredCount, className }: QuickFi const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === "Escape") { - if (localValue) { - clear() - setLocalValue("") - } else { - deactivate() - } + deactivate() } }, - [localValue, clear, deactivate], + [deactivate], ) const handleClear = useCallback(() => { - clear() setLocalValue("") + setFilter("") inputRef.current?.focus() - }, [clear]) - - if (!isActive) { - return null - } + }, [setFilter]) return (
- + + - {filter && ( - - {filteredCount} из {totalCount} - - )} - {filter && ( + + + {filteredCount} / {totalCount} + + + {localValue && ( )} +
) diff --git a/src/features/recent-folders/ui/RecentFoldersList.tsx b/src/features/recent-folders/ui/RecentFoldersList.tsx index 933a750..fcf99f9 100644 --- a/src/features/recent-folders/ui/RecentFoldersList.tsx +++ b/src/features/recent-folders/ui/RecentFoldersList.tsx @@ -28,51 +28,45 @@ interface RecentFolderItemProps { function RecentFolderItem({ folder, isActive, onSelect, onRemove }: RecentFolderItemProps) { return ( - -
{ - if (e.key === "Enter" || e.key === " ") { - e.preventDefault() - onSelect() - } +
+ + + + +
- + + +
+ diff --git a/src/features/recent-folders/ui/__tests__/RecentFoldersList.test.tsx b/src/features/recent-folders/ui/__tests__/RecentFoldersList.test.tsx new file mode 100644 index 0000000..eb33006 --- /dev/null +++ b/src/features/recent-folders/ui/__tests__/RecentFoldersList.test.tsx @@ -0,0 +1,39 @@ +import { fireEvent, render } from "@testing-library/react" +import { describe, expect, it, vi } from "vitest" +import { RecentFoldersList } from "../RecentFoldersList" + +vi.mock("../../model/store", async () => { + const actual = await vi.importActual("../../model/store") + return { + ...actual, + useRecentFoldersStore: () => ({ + folders: [ + { name: "One", path: "/one", lastVisited: Date.now() }, + { name: "Two", path: "/two", lastVisited: Date.now() }, + ], + removeFolder: vi.fn(), + clearAll: vi.fn(), + }), + } +}) + +describe("RecentFoldersList", () => { + it("renders folder items as buttons and shows remove button with aria-label", () => { + const onSelect = vi.fn() + const { getAllByRole, getByLabelText } = render( + , + ) + + const buttons = getAllByRole("button") + // Should have at least clear button plus two folder buttons and two remove buttons + expect(buttons.length).toBeGreaterThanOrEqual(3) + + // Find remove button for folder One + const remove = getByLabelText(/Remove One/) + expect(remove).toBeTruthy() + + // Simulate clicking remove + fireEvent.click(remove) + // The mock removeFolder should have been called via handler; we can't assert internal store mock here easily but ensure click doesn't throw + }) +}) diff --git a/src/features/search-content/__tests__/useSearchWithProgress.performance.test.tsx b/src/features/search-content/__tests__/useSearchWithProgress.performance.test.tsx new file mode 100644 index 0000000..d99c500 --- /dev/null +++ b/src/features/search-content/__tests__/useSearchWithProgress.performance.test.tsx @@ -0,0 +1,39 @@ +import { render, waitFor } from "@testing-library/react" +import React from "react" +import { describe, expect, it, vi } from "vitest" +import { useSearchStore } from "@/features/search-content" +import { useSearchWithProgress } from "@/features/search-content/hooks/useSearchWithProgress" +import { useSettingsStore } from "@/features/settings" +import type { SearchResult } from "@/shared/api/tauri" +import { tauriClient } from "@/shared/api/tauri/client" + +function TestComponent() { + const { search } = useSearchWithProgress() + React.useEffect(() => { + // Trigger search on mount + void search() + }, [search]) + return null +} + +describe("useSearchWithProgress - maxSearchResults propagation", () => { + it("passes performance.maxSearchResults to tauriClient.searchFilesStream", async () => { + // Arrange + useSearchStore.getState().setQuery("query") + useSearchStore.getState().setSearchPath("/") + + useSettingsStore.getState().updatePerformance({ maxSearchResults: 5 }) + + const spy = vi.spyOn(tauriClient, "searchFilesStream").mockResolvedValue([] as SearchResult[]) + + render() + + await waitFor(() => { + expect(spy).toHaveBeenCalled() + const calledWith = spy.mock.calls[0][0] + expect(calledWith.max_results).toBe(5) + }) + + spy.mockRestore() + }) +}) diff --git a/src/features/search-content/api/queries.ts b/src/features/search-content/api/queries.ts index b2ecc73..52cec3d 100644 --- a/src/features/search-content/api/queries.ts +++ b/src/features/search-content/api/queries.ts @@ -1,13 +1,8 @@ import { useQuery } from "@tanstack/react-query" -import { commands, type Result, type SearchOptions } from "@/shared/api/tauri" +import { commands, type SearchOptions } from "@/shared/api/tauri" -// Хелпер для распаковки Result из tauri-specta -function unwrapResult(result: Result): T { - if (result.status === "ok") { - return result.data - } - throw new Error(String(result.error)) -} +// Helper to unwrap Result from tauri-specta +import { unwrapResult } from "@/shared/api/tauri/client" export const searchKeys = { all: ["search"] as const, diff --git a/src/features/search-content/hooks/useSearchWithProgress.ts b/src/features/search-content/hooks/useSearchWithProgress.ts index 38344d7..5b959ab 100644 --- a/src/features/search-content/hooks/useSearchWithProgress.ts +++ b/src/features/search-content/hooks/useSearchWithProgress.ts @@ -1,6 +1,8 @@ import { listen } from "@tauri-apps/api/event" import { useCallback, useEffect, useRef } from "react" -import { commands, type SearchOptions } from "@/shared/api/tauri" +import { usePerformanceSettings } from "@/features/settings" +import type { SearchOptions } from "@/shared/api/tauri" +import { tauriClient } from "@/shared/api/tauri/client" import { toast } from "@/shared/ui" import { useSearchStore } from "../model/store" @@ -24,7 +26,7 @@ export function useSearchWithProgress() { setProgress, } = useSearchStore() - // Очистка слушателя при размонтировании + // Cleanup listener on unmount useEffect(() => { return () => { if (unlistenRef.current) { @@ -34,6 +36,8 @@ export function useSearchWithProgress() { } }, []) + const performance = usePerformanceSettings() + const search = useCallback(async () => { if (!query.trim() || !searchPath) { console.log("Search cancelled: no query or path", { query, searchPath }) @@ -42,7 +46,7 @@ export function useSearchWithProgress() { console.log("Starting search:", { query, searchPath, searchContent }) - // Удаляем предыдущий слушатель + // Remove previous listener if (unlistenRef.current) { unlistenRef.current() unlistenRef.current = null @@ -53,10 +57,10 @@ export function useSearchWithProgress() { setResults([]) try { - // Подписываемся на события прогресса с throttle + // Subscribe to progress events with throttle unlistenRef.current = await listen("search-progress", (event) => { const now = Date.now() - // Throttle: обновляем UI максимум раз в 100ms + // Throttle: update UI at most once every 100ms if (now - lastUpdateRef.current > 100) { lastUpdateRef.current = now setProgress({ @@ -72,23 +76,18 @@ export function useSearchWithProgress() { search_path: searchPath, search_content: searchContent, case_sensitive: caseSensitive, - max_results: 1000, + max_results: performance.maxSearchResults, file_extensions: null, } console.log("Calling searchFilesStream with options:", options) - const result = await commands.searchFilesStream(options) + const files = await tauriClient.searchFilesStream(options) - console.log("Search result:", result) + console.log("Search result:", files) - if (result.status === "ok") { - setResults(result.data) - toast.success(`Найдено ${result.data.length} файлов`) - } else { - console.error("Search error:", result.error) - toast.error(`Ошибка поиска: ${result.error}`) - } + setResults(files) + toast.success(`Найдено ${files.length} файлов`) } catch (error) { console.error("Search exception:", error) toast.error(`Ошибка поиска: ${String(error)}`) @@ -96,13 +95,22 @@ export function useSearchWithProgress() { setIsSearching(false) setProgress(null) - // Очищаем слушатель + // Clear listener if (unlistenRef.current) { unlistenRef.current() unlistenRef.current = null } } - }, [query, searchPath, searchContent, caseSensitive, setIsSearching, setResults, setProgress]) + }, [ + query, + searchPath, + searchContent, + caseSensitive, + setIsSearching, + setResults, + setProgress, + performance.maxSearchResults, + ]) return { search } } diff --git a/src/features/search-content/ui/SearchBar.tsx b/src/features/search-content/ui/SearchBar.tsx index c580279..7661e18 100644 --- a/src/features/search-content/ui/SearchBar.tsx +++ b/src/features/search-content/ui/SearchBar.tsx @@ -30,14 +30,14 @@ export function SearchBar({ onSearch, className }: SearchBarProps) { const { currentPath } = useNavigationStore() const { search } = useSearchWithProgress() - // Синхронизируем searchPath с currentPath + // Sync searchPath with currentPath useEffect(() => { if (currentPath) { setSearchPath(currentPath) } }, [currentPath, setSearchPath]) - // Синхронизируем localQuery с query из store + // Sync localQuery with store query useEffect(() => { setLocalQuery(query) }, [query]) @@ -74,7 +74,7 @@ export function SearchBar({ onSearch, className }: SearchBarProps) { cancelSearch() }, [cancelSearch]) - // Сокращаем путь для отображения + // Shorten path for display const shortenPath = (path: string, maxLength: number = 30) => { if (path.length <= maxLength) return path const parts = path.split(/[/\\]/) @@ -144,7 +144,7 @@ export function SearchBar({ onSearch, className }: SearchBarProps) { )}
- {/* Индикатор прогресса поиска */} + {/* Search progress indicator */} {isSearching && progress && (
Найдено: {progress.found} diff --git a/src/features/settings/__tests__/FileDisplaySettings.integration.test.tsx b/src/features/settings/__tests__/FileDisplaySettings.integration.test.tsx new file mode 100644 index 0000000..6f78d7c --- /dev/null +++ b/src/features/settings/__tests__/FileDisplaySettings.integration.test.tsx @@ -0,0 +1,91 @@ +import { fireEvent, render, waitFor } from "@testing-library/react" +import { beforeEach, describe, expect, it } from "vitest" +import { FileRow } from "@/entities/file-entry/ui/FileRow" +import { useFileDisplaySettings, useSettingsStore } from "@/features/settings" +import { FileDisplaySettings } from "@/features/settings/ui/FileDisplaySettings" +import type { FileEntry } from "@/shared/api/tauri" + +function Combined({ file }: { file: FileEntry }) { + const displaySettings = useFileDisplaySettings() + return ( +
+ + {}} + onOpen={() => {}} + displaySettings={displaySettings} + appearance={{ reducedMotion: false }} + /> +
+ ) +} + +describe("FileDisplaySettings integration", () => { + beforeEach(() => { + // reset settings to ensure test isolation + useSettingsStore.getState().resetSettings() + }) + it("toggle 'Расширения файлов' updates store and FileRow re-renders accordingly", async () => { + const file = { + path: "/f", + name: "file.txt", + is_dir: false, + is_hidden: false, + extension: "txt", + size: 0, + modified: Math.floor(Date.now() / 1000), + created: null, + } + + const { getByRole, container } = render() + + // Ensure initial shows extension + await waitFor(() => { + expect(container.textContent).toContain("file.txt") + }) + + // Click toggle to hide extensions. Find the toggle by label text "Расширения файлов" + const toggle = getByRole("switch", { name: /Расширения файлов/i }) + fireEvent.click(toggle) + + // Now FileRow should update to show name without extension + await waitFor(() => { + expect(container.textContent).toContain("file") + expect(container.textContent).not.toContain("file.txt") + }) + }) + + it("changing date format update reflects in FileRow date display", async () => { + const nowSec = Math.floor(Date.now() / 1000) + const file = { + path: "/f", + name: "f.txt", + is_dir: false, + is_hidden: false, + extension: "txt", + size: 0, + modified: nowSec, + created: null, + } + + const { getByText, container } = render() + + // Initially dateFormat default is 'auto' which may render relative + await waitFor(() => { + const t = container.textContent ?? "" + expect(/(только|мин\.|\d{2}\.\d{2}\.\d{4})/.test(t)).toBeTruthy() + }) + + // Click the 'Абсолютная' button + const absBtn = getByText("Абсолютная") + fireEvent.click(absBtn) + + // Expect absolute date format + await waitFor(() => { + const t = container.textContent ?? "" + expect(/\d{2}\.\d{2}\.\d{4}/.test(t)).toBeTruthy() + }) + }) +}) diff --git a/src/features/settings/__tests__/PerformanceSettings.integration.test.tsx b/src/features/settings/__tests__/PerformanceSettings.integration.test.tsx new file mode 100644 index 0000000..9935f37 --- /dev/null +++ b/src/features/settings/__tests__/PerformanceSettings.integration.test.tsx @@ -0,0 +1,64 @@ +import { fireEvent, render, waitFor } from "@testing-library/react" +import { beforeEach, describe, expect, it } from "vitest" +import { usePerformanceSettings } from "@/features/settings" +import { PerformanceSettings } from "@/features/settings/ui/PerformanceSettings" + +function Combined() { + const perf = usePerformanceSettings() + return ( +
+ +
+ lazy:{String(perf.lazyLoadImages)} + cache:{String(perf.thumbnailCacheSize)} +
+
+ ) +} + +import { useSettingsStore } from "@/features/settings" + +describe("PerformanceSettings integration", () => { + beforeEach(() => { + // ensure default settings for isolation + useSettingsStore.getState().resetSettings() + }) + + it("toggle 'Ленивая загрузка изображений' updates store and reflected in consumer", async () => { + const { getByRole, getByTestId } = render() + + // Initially default is true + await waitFor(() => { + expect(getByTestId("perf-values").textContent).toContain("lazy:true") + }) + + const toggle = getByRole("switch", { name: /Ленивая загрузка изображений/i }) + fireEvent.click(toggle) + + await waitFor(() => { + expect(getByTestId("perf-values").textContent).toContain("lazy:false") + }) + }) + + it("slider 'Размер кэша миниатюр' updates store and reflected in consumer", async () => { + const { container, getByTestId } = render() + + // find the slider by searching range inputs for the one whose parent contains the label text + const inputs = container.querySelectorAll('input[type="range"]') + let input: HTMLInputElement | null = null + for (const el of inputs) { + if (el.parentElement?.textContent?.includes("Размер кэша миниатюр")) { + input = el as HTMLInputElement + break + } + } + expect(input).toBeTruthy() + + // change to 150 + fireEvent.change(input!, { target: { value: "150" } }) + + await waitFor(() => { + expect(getByTestId("perf-values").textContent).toContain("cache:150") + }) + }) +}) diff --git a/src/features/settings/__tests__/importSettings.test.ts b/src/features/settings/__tests__/importSettings.test.ts new file mode 100644 index 0000000..640cba4 --- /dev/null +++ b/src/features/settings/__tests__/importSettings.test.ts @@ -0,0 +1,35 @@ +// @ts-nocheck +/// +import { useSettingsStore } from "../model/store" + +describe("importSettings", () => { + beforeEach(() => { + // reset to defaults + useSettingsStore.setState({ settings: useSettingsStore.getState().settings }) + }) + + it("imports partial settings and merges defaults", () => { + const partial = JSON.stringify({ appearance: { theme: "light" } }) + const ok = useSettingsStore.getState().importSettings(partial) + expect(ok).toBe(true) + const s = useSettingsStore.getState().settings + expect(s.appearance.theme).toBe("light") + // other fields preserved from defaults + expect(s.appearance.fontSize).toBeDefined() + }) + + it("rejects invalid types", () => { + const invalid = JSON.stringify({ appearance: { fontSize: 123 } }) + const ok = useSettingsStore.getState().importSettings(invalid) + expect(ok).toBe(false) + }) + + it("handles version mismatch but still imports and sets canonical version", () => { + const old = JSON.stringify({ version: 0, appearance: { theme: "light" } }) + const ok = useSettingsStore.getState().importSettings(old) + expect(ok).toBe(true) + const s = useSettingsStore.getState().settings + expect(s.version).toBe(1) // canonicalized + expect(s.appearance.theme).toBe("light") + }) +}) diff --git a/src/features/settings/__tests__/layoutSizeLock.test.tsx b/src/features/settings/__tests__/layoutSizeLock.test.tsx new file mode 100644 index 0000000..605520d --- /dev/null +++ b/src/features/settings/__tests__/layoutSizeLock.test.tsx @@ -0,0 +1,46 @@ +/// +import { render, waitFor } from "@testing-library/react" +import { act } from "react" +import { beforeEach, describe, expect, it } from "vitest" +import { useLayoutStore } from "@/features/layout" +import { initLayoutSync } from "@/features/layout/sync" +import { useSettingsStore } from "@/features/settings" +import { LayoutSettings } from "@/features/settings/ui/LayoutSettings" + +describe("LayoutSettings size lock behavior", () => { + beforeEach(() => { + useSettingsStore.getState().resetSettings() + useLayoutStore.getState().resetLayout() + }) + + it("disables sidebar slider when lock is off and enables when on", async () => { + const { getByLabelText } = render() + + // Initially disabled + const sidebarInput = getByLabelText("Ширина сайдбара") as HTMLInputElement + expect(sidebarInput.disabled).toBeTruthy() + + // Enable lock inside act and await re-render + act(() => { + useSettingsStore.getState().updatePanelLayout({ sidebarSizeLocked: true }) + }) + + await waitFor(() => { + const sidebarInputAfter = getByLabelText("Ширина сайдбара") as HTMLInputElement + expect(sidebarInputAfter.disabled).toBeFalsy() + }) + }) + + it("applies sidebar size to runtime layout when locked via settings", () => { + // Start sync + const cleanup = initLayoutSync() + + // Toggle lock and set size + useSettingsStore.getState().updatePanelLayout({ sidebarSizeLocked: true, sidebarSize: 28 }) + + const runtime = useLayoutStore.getState() + expect(runtime.layout.sidebarSize).toBe(28) + + cleanup?.() + }) +}) diff --git a/src/features/settings/__tests__/migrate.test.ts b/src/features/settings/__tests__/migrate.test.ts new file mode 100644 index 0000000..02b3f9b --- /dev/null +++ b/src/features/settings/__tests__/migrate.test.ts @@ -0,0 +1,37 @@ +/// +import { describe, expect, it } from "vitest" +import { migrateSettings } from "../model/store" + +// Note: migrateSettings should update persisted settings to the canonical schema + +describe("migrateSettings", () => { + it("fills missing fields and updates version", async () => { + const persisted = { + settings: { + version: 0, + layout: { + panelLayout: { + sidebarSize: 20, + mainPanelSize: 60, + previewPanelSize: 20, + showSidebar: true, + showPreview: true, + columnWidths: { size: 0, date: 0, padding: 0 }, + }, + }, + }, + } + + const migrated = await migrateSettings(persisted, 0) + + expect(migrated).toBeTruthy() + // @ts-expect-error + expect(migrated.settings.version).toBeDefined() + // Ensure showColumnHeadersInSimpleList exists after migration + // @ts-expect-error + expect(migrated.settings.layout.showColumnHeadersInSimpleList).toBeDefined() + // Ensure columnWidths were merged with sensible defaults (non-zero) + // @ts-expect-error + expect(migrated.settings.layout.columnWidths.size).toBeGreaterThanOrEqual(50) + }) +}) diff --git a/src/features/settings/__tests__/performance.reset.test.tsx b/src/features/settings/__tests__/performance.reset.test.tsx new file mode 100644 index 0000000..b2614e9 --- /dev/null +++ b/src/features/settings/__tests__/performance.reset.test.tsx @@ -0,0 +1,26 @@ +import { fireEvent, render, screen } from "@testing-library/react" +import { describe, expect, it } from "vitest" +import { useSettingsStore } from "@/features/settings" +import { PerformanceSettings } from "@/features/settings/ui/PerformanceSettings" + +describe("PerformanceSettings UI", () => { + it("reset button resets performance section to defaults", async () => { + // Arrange - set non-default values + useSettingsStore + .getState() + .updatePerformance({ virtualListThreshold: 500, thumbnailCacheSize: 200 }) + + // Sanity check before rendering + expect(useSettingsStore.getState().settings.performance.virtualListThreshold).toBe(500) + + render() + + // Click reset + const btn = screen.getByRole("button", { name: /сбросить/i }) + fireEvent.click(btn) + + // Assert reset to defaults (default virtualListThreshold is 100 per store) + expect(useSettingsStore.getState().settings.performance.virtualListThreshold).toBe(100) + expect(useSettingsStore.getState().settings.performance.thumbnailCacheSize).toBe(100) + }) +}) diff --git a/src/features/settings/__tests__/persistence.allSections.test.tsx b/src/features/settings/__tests__/persistence.allSections.test.tsx new file mode 100644 index 0000000..ce1a6e0 --- /dev/null +++ b/src/features/settings/__tests__/persistence.allSections.test.tsx @@ -0,0 +1,34 @@ +import { waitFor } from "@testing-library/react" +import { describe, expect, it } from "vitest" +import { useSettingsStore } from "@/features/settings" + +describe("Settings persistence - all sections", () => { + it("persists changes from all sections to localStorage under 'app-settings'", async () => { + // Ensure clean state + localStorage.removeItem("app-settings") + + // Apply updates across sections + useSettingsStore.getState().updateAppearance({ theme: "light", reducedMotion: true }) + useSettingsStore.getState().updateBehavior({ doubleClickToOpen: false }) + useSettingsStore + .getState() + .updateFileDisplay({ dateFormat: "absolute", showFileExtensions: false }) + useSettingsStore.getState().updateLayout({ showToolbar: false }) + useSettingsStore.getState().updatePerformance({ thumbnailCacheSize: 50 }) + useSettingsStore.getState().updateKeyboard({ enableVimMode: true }) + + // Wait for persistence to occur + await waitFor(() => { + const raw = localStorage.getItem("app-settings") + if (!raw) throw new Error("no persisted state yet") + expect(raw).toContain('"theme":"light"') + expect(raw).toContain('"reducedMotion":true') + expect(raw).toContain('"doubleClickToOpen":false') + expect(raw).toContain('"dateFormat":"absolute"') + expect(raw).toContain('"showFileExtensions":false') + expect(raw).toContain('"showToolbar":false') + expect(raw).toContain('"thumbnailCacheSize":50') + expect(raw).toContain('"enableVimMode":true') + }) + }) +}) diff --git a/src/features/settings/__tests__/persistence.performance.test.tsx b/src/features/settings/__tests__/persistence.performance.test.tsx new file mode 100644 index 0000000..b399010 --- /dev/null +++ b/src/features/settings/__tests__/persistence.performance.test.tsx @@ -0,0 +1,20 @@ +import { waitFor } from "@testing-library/react" +import { describe, expect, it } from "vitest" +import { useSettingsStore } from "@/features/settings" + +describe("Settings persistence", () => { + it("persists performance settings to localStorage under 'app-settings'", async () => { + // Ensure clean state + localStorage.removeItem("app-settings") + + // Update performance setting + useSettingsStore.getState().updatePerformance({ virtualListThreshold: 77 }) + + // Wait for localStorage to contain the updated setting + await waitFor(() => { + const raw = localStorage.getItem("app-settings") + if (!raw) throw new Error("no persisted state yet") + expect(raw).toContain('"virtualListThreshold":77') + }) + }) +}) diff --git a/src/features/settings/__tests__/resetSections.test.tsx b/src/features/settings/__tests__/resetSections.test.tsx new file mode 100644 index 0000000..5476743 --- /dev/null +++ b/src/features/settings/__tests__/resetSections.test.tsx @@ -0,0 +1,53 @@ +import { waitFor } from "@testing-library/react" +import { describe, expect, it } from "vitest" +import { useSettingsStore } from "@/features/settings" + +// These expected defaults mirror the defaults declared in the settings store +describe("Settings resetSection / resetSettings", () => { + it("resetSection restores defaults for each top-level section", () => { + const s = useSettingsStore.getState() + + // Set non-defaults + s.updateAppearance({ theme: "light", reducedMotion: true }) + s.updateBehavior({ doubleClickToOpen: false }) + s.updateFileDisplay({ dateFormat: "absolute", showFileExtensions: false }) + s.updateLayout({ showToolbar: false }) + s.updatePerformance({ thumbnailCacheSize: 50 }) + s.updateKeyboard({ enableVimMode: true }) + + // Reset each section and assert defaults + s.resetSection("appearance") + expect(s.settings.appearance.theme).toBe("dark") + expect(s.settings.appearance.reducedMotion).toBe(false) + + s.resetSection("behavior") + expect(s.settings.behavior.doubleClickToOpen).toBe(true) + + s.resetSection("fileDisplay") + expect(s.settings.fileDisplay.dateFormat).toBe("auto") + expect(s.settings.fileDisplay.showFileExtensions).toBe(true) + + s.resetSection("layout") + expect(s.settings.layout.showToolbar).toBe(true) + + s.resetSection("performance") + expect(s.settings.performance.thumbnailCacheSize).toBe(100) + + s.resetSection("keyboard") + expect(s.settings.keyboard.enableVimMode).toBe(false) + }) + + it("resetSettings restores global defaults", async () => { + const s = useSettingsStore.getState() + + // Ensure clean environment (clear persisted overrides) + localStorage.removeItem("app-settings") + s.resetSettings() + + // Set a non-default then reset all (we don't rely on immediate readback due to persistence timing) + s.updatePerformance({ thumbnailCacheSize: 30 }) + + s.resetSettings() + await waitFor(() => expect(s.settings.performance.thumbnailCacheSize).toBe(100)) + }) +}) diff --git a/src/features/settings/hooks/index.ts b/src/features/settings/hooks/index.ts new file mode 100644 index 0000000..1e9def1 --- /dev/null +++ b/src/features/settings/hooks/index.ts @@ -0,0 +1 @@ +export { useApplyAppearance } from "./useApplyAppearance" diff --git a/src/features/settings/hooks/useApplyAppearance.test.tsx b/src/features/settings/hooks/useApplyAppearance.test.tsx new file mode 100644 index 0000000..73c4979 --- /dev/null +++ b/src/features/settings/hooks/useApplyAppearance.test.tsx @@ -0,0 +1,124 @@ +import { cleanup, render } from "@testing-library/react" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { useSettingsStore } from "../model/store" +import { applyAppearanceToRoot, useApplyAppearance } from "./useApplyAppearance" + +function TestHookRunner() { + useApplyAppearance() + return null +} + +describe("applyAppearanceToRoot and useApplyAppearance", () => { + beforeEach(() => { + // Reset DOM classes and styles + document.documentElement.className = "" + document.documentElement.style.cssText = "" + // Reset settings store to defaults + useSettingsStore.setState({ settings: useSettingsStore.getState().settings }) + // Clear localStorage to avoid interference + localStorage.clear() + }) + + afterEach(() => { + cleanup() + vi.restoreAllMocks() + }) + + it("applies theme, font size, accent color and animations", () => { + applyAppearanceToRoot({ + theme: "dark", + fontSize: "large", + accentColor: "#ff0000", + enableAnimations: true, + reducedMotion: false, + }) + + expect(document.documentElement.classList.contains("dark")).toBe(true) + expect(document.documentElement.style.fontSize).toBe("18px") + expect(document.documentElement.style.getPropertyValue("--accent-color")).toBe("#ff0000") + expect(document.documentElement.style.getPropertyValue("--color-primary")).toBe("#ff0000") + expect(document.documentElement.style.getPropertyValue("--color-primary-foreground")).toBe( + "#ffffff", + ) + expect(document.documentElement.classList.contains("reduce-motion")).toBe(false) + expect(document.documentElement.style.getPropertyValue("--transition-duration")).toBe("150ms") + }) + + it("applies reduced motion when enabled", () => { + applyAppearanceToRoot({ + theme: "light", + fontSize: "medium", + accentColor: "#00ff00", + enableAnimations: false, + reducedMotion: true, + }) + + expect(document.documentElement.classList.contains("reduce-motion")).toBe(true) + expect(document.documentElement.style.getPropertyValue("--transition-duration")).toBe("0ms") + }) + + it("accepts non-hex accent values gracefully", () => { + applyAppearanceToRoot({ + theme: "light", + fontSize: "medium", + accentColor: "not-a-color", + enableAnimations: true, + reducedMotion: false, + }) + + expect(document.documentElement.style.getPropertyValue("--accent-color")).toBe("not-a-color") + }) + + it("listens and reacts to system theme changes when theme=system", () => { + // Mock matchMedia + let changeHandler: ((e: MediaQueryListEvent) => void) | null = null + const mockMatchMedia = vi.fn().mockImplementation((query) => { + return { + matches: false, + media: query, + addEventListener: (_: string, handler: (e: MediaQueryListEvent) => void) => { + changeHandler = handler + }, + removeEventListener: (_: string, _handler: (e: MediaQueryListEvent) => void) => { + changeHandler = null + }, + } + }) + + window.matchMedia = mockMatchMedia + + // Set settings to system theme + useSettingsStore.getState().updateAppearance({ theme: "system" }) + + // Render hook to install listener + render() + + // Initially, since mocked matches=false, expect 'light' class + expect(document.documentElement.classList.contains("light")).toBe(true) + + // Simulate system change to dark + if (changeHandler) { + // TS typing in JSDOM mocks can be subtle — cast to any for invocation + ;(changeHandler as unknown as (e: MediaQueryListEvent) => void)({ + matches: true, + media: "(prefers-color-scheme: dark)", + } as unknown as MediaQueryListEvent) + expect(document.documentElement.classList.contains("dark")).toBe(true) + } else { + throw new Error("matchMedia handler was not registered") + } + }) + + it("useApplyAppearance applies settings from store on mount", () => { + // Update settings + useSettingsStore + .getState() + .updateAppearance({ theme: "dark", fontSize: "small", accentColor: "#123456" }) + + render() + + expect(document.documentElement.classList.contains("dark")).toBe(true) + expect(document.documentElement.style.fontSize).toBe("14px") + expect(document.documentElement.style.getPropertyValue("--accent-color")).toBe("#123456") + }) +}) diff --git a/src/features/settings/hooks/useApplyAppearance.ts b/src/features/settings/hooks/useApplyAppearance.ts new file mode 100644 index 0000000..55f7730 --- /dev/null +++ b/src/features/settings/hooks/useApplyAppearance.ts @@ -0,0 +1,154 @@ +import { useEffect, useLayoutEffect } from "react" +import { useAppearanceSettings } from "../model/store" +import type { AppearanceSettings } from "../model/types" + +export function applyAppearanceToRoot(appearance: AppearanceSettings) { + try { + const root = document.documentElement + + // Theme + root.classList.remove("light", "dark") + if (appearance.theme === "system") { + const prefersDark = + typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: dark)").matches + root.classList.add(prefersDark ? "dark" : "light") + } else { + root.classList.add(appearance.theme) + } + + // Font size + const fontSizes: Record = { small: "14px", medium: "16px", large: "18px" } + root.style.fontSize = fontSizes[appearance.fontSize] || "16px" + + // Accent color - validate basic HEX format (allow 3/4/6/8 hex) + const isHex = /^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(appearance.accentColor) + if (isHex) { + // Set canonical variable + root.style.setProperty("--accent-color", appearance.accentColor) + + // Also set primary color to the accent for consistency in UI tokens + root.style.setProperty("--color-primary", appearance.accentColor) + + // Compute a readable foreground (white or black) for the accent + try { + const hex = appearance.accentColor.replace("#", "") + const r = parseInt(hex.substring(0, 2), 16) + const g = parseInt(hex.substring(2, 4), 16) + const b = parseInt(hex.substring(4, 6), 16) + // Perceived brightness + const brightness = (r * 299 + g * 587 + b * 114) / 1000 + const fg = brightness > 160 ? "#000000" : "#ffffff" + root.style.setProperty("--color-primary-foreground", fg) + root.style.setProperty("--accent-color-foreground", fg) + + // Also set RGB variables for tailwind color-with-alpha support + root.style.setProperty("--accent-color-rgb", `${r} ${g} ${b}`) + root.style.setProperty("--color-primary-rgb", `${r} ${g} ${b}`) + root.style.setProperty( + "--accent-color-foreground-rgb", + `${parseInt(fg.slice(1, 3), 16)} ${parseInt(fg.slice(3, 5), 16)} ${parseInt(fg.slice(5, 7), 16)}`, + ) + root.style.setProperty( + "--color-primary-foreground-rgb", + `${parseInt(fg.slice(1, 3), 16)} ${parseInt(fg.slice(3, 5), 16)} ${parseInt(fg.slice(5, 7), 16)}`, + ) + } catch { + // ignore parsing errors + } + } else if (!appearance.accentColor) { + root.style.removeProperty("--accent-color") + root.style.removeProperty("--color-primary") + root.style.removeProperty("--color-primary-foreground") + root.style.removeProperty("--accent-color-foreground") + } else { + // Fallback: try applying as-is but guard against throwing + try { + root.style.setProperty("--accent-color", appearance.accentColor) + root.style.setProperty("--color-primary", appearance.accentColor) + } catch { + // ignore invalid color value + } + } + + // Animations / reduced motion + // Keep separate classes for explicit "animations off" and reduced-motion preference + if (!appearance.enableAnimations) { + root.classList.add("animations-off") + } else { + root.classList.remove("animations-off") + } + + if (appearance.reducedMotion) { + root.classList.add("reduce-motion") + } else { + root.classList.remove("reduce-motion") + } + + // If either flag disables transitions, set transition duration to 0 + if (!appearance.enableAnimations || appearance.reducedMotion) { + root.style.setProperty("--transition-duration", "0ms") + } else { + root.style.setProperty("--transition-duration", "150ms") + } + + // Popover visual settings (translucent + blur) + // Compute and apply CSS variables used by .popover-surface + const opacity = typeof appearance.popoverOpacity === "number" ? appearance.popoverOpacity : 0.6 + const blurRadius = + typeof appearance.popoverBlurRadius === "number" ? `${appearance.popoverBlurRadius}px` : "6px" + + // For dark/light base color, prefer existing variables; compute popover bg using opacity + const isLight = document.documentElement.classList.contains("light") + if (appearance.popoverTranslucent === false) { + // Remove translucency variables + root.style.removeProperty("--popover-opacity") + root.style.removeProperty("--popover-blur") + root.style.removeProperty("--popover-bg") + } else { + // Apply opacity & blur; and update base RGBA depending on theme + root.style.setProperty("--popover-opacity", String(opacity)) + root.style.setProperty("--popover-blur", blurRadius) + + if (isLight) { + // light theme: white base + root.style.setProperty("--popover-bg", `rgba(255,255,255,${opacity})`) + root.style.setProperty("--popover-border", "rgba(0,0,0,0.06)") + } else { + root.style.setProperty("--popover-bg", `rgba(17,17,19,${opacity})`) + root.style.setProperty("--popover-border", "rgba(255,255,255,0.06)") + } + + // If blur is disabled, set blur to 0 + if (appearance.popoverBlur === false) { + root.style.setProperty("--popover-blur", "0px") + } + } + } catch (e) { + // In environments without DOM, do nothing + // eslint-disable-next-line no-console + console.warn("applyAppearanceToRoot: failed to apply appearance", e) + } +} + +export function useApplyAppearance() { + const appearance = useAppearanceSettings() + + // Apply synchronously to avoid FOUC + useLayoutEffect(() => { + applyAppearanceToRoot(appearance) + }, [appearance]) + + // Listen for system theme changes when theme === 'system' + useEffect(() => { + if (appearance.theme !== "system") return + + const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)") + const handler = (e: MediaQueryListEvent) => { + document.documentElement.classList.remove("light", "dark") + document.documentElement.classList.add(e.matches ? "dark" : "light") + } + + mediaQuery.addEventListener("change", handler) + return () => mediaQuery.removeEventListener("change", handler) + }, [appearance.theme]) +} diff --git a/src/features/settings/index.ts b/src/features/settings/index.ts index ad47aeb..02f7fbd 100644 --- a/src/features/settings/index.ts +++ b/src/features/settings/index.ts @@ -1,2 +1,23 @@ -export { type AppSettings, useSettingsStore } from "./model/store" -export { SettingsDialog } from "./ui" +export { useApplyAppearance } from "./hooks/useApplyAppearance" +export { + useAppearanceSettings, + useBehaviorSettings, + useFileDisplaySettings, + useKeyboardSettings, + useLayoutSettings, + usePerformanceSettings, + useSettingsStore, +} from "./model/store" +export type { + AppearanceSettings, + AppSettings, + BehaviorSettings, + DateFormat, + FileDisplaySettings, + FontSize, + KeyboardSettings, + LayoutSettings, + PerformanceSettings, + Theme, +} from "./model/types" +export { SettingsDialog } from "./ui/SettingsDialog" diff --git a/src/features/settings/model/layoutPresets.ts b/src/features/settings/model/layoutPresets.ts new file mode 100644 index 0000000..c8c7e29 --- /dev/null +++ b/src/features/settings/model/layoutPresets.ts @@ -0,0 +1,103 @@ +import type { PanelLayout } from "@/features/layout" +import type { LayoutPreset, LayoutPresetId } from "./types" + +const defaultColumnWidths = { + size: 90, + date: 140, + padding: 16, +} + +export const layoutPresets: Record = { + compact: { + id: "compact", + name: "Компактный", + description: "Минимальный интерфейс для максимального пространства файлов", + layout: { + sidebarSize: 15, + mainPanelSize: 85, + previewPanelSize: 0, + showSidebar: true, + sidebarCollapsed: true, + showPreview: false, + columnWidths: { + size: 70, + date: 100, + padding: 8, + }, + sidebarSizeLocked: false, + previewSizeLocked: false, + }, + }, + default: { + id: "default", + name: "Стандартный", + description: "Сбалансированный лейаут для повседневного использования", + layout: { + sidebarSize: 20, + mainPanelSize: 55, + previewPanelSize: 25, + showSidebar: true, + sidebarCollapsed: false, + showPreview: true, + columnWidths: defaultColumnWidths, + sidebarSizeLocked: false, + previewSizeLocked: false, + }, + }, + wide: { + id: "wide", + name: "Широкий", + description: "Расширенная боковая панель с большим превью", + layout: { + sidebarSize: 25, + mainPanelSize: 40, + previewPanelSize: 35, + showSidebar: true, + sidebarCollapsed: false, + showPreview: true, + columnWidths: { + size: 100, + date: 160, + padding: 20, + }, + sidebarSizeLocked: false, + previewSizeLocked: false, + }, + }, + custom: { + id: "custom", + name: "Пользовательский", + description: "Ваши собственные настройки лейаута", + layout: { + sidebarSize: 20, + mainPanelSize: 55, + previewPanelSize: 25, + showSidebar: true, + sidebarCollapsed: false, + showPreview: true, + columnWidths: defaultColumnWidths, + sidebarSizeLocked: false, + previewSizeLocked: false, + }, + }, +} + +export function getPresetLayout(presetId: LayoutPresetId): PanelLayout { + return layoutPresets[presetId]?.layout ?? layoutPresets.default.layout +} + +export function isCustomLayout(current: PanelLayout, presetId: LayoutPresetId): boolean { + if (presetId === "custom") return true + + const preset = layoutPresets[presetId]?.layout + if (!preset) return true + + return ( + current.sidebarSize !== preset.sidebarSize || + current.mainPanelSize !== preset.mainPanelSize || + current.previewPanelSize !== preset.previewPanelSize || + current.showSidebar !== preset.showSidebar || + current.showPreview !== preset.showPreview || + current.sidebarCollapsed !== preset.sidebarCollapsed + ) +} diff --git a/src/features/settings/model/store.ts b/src/features/settings/model/store.ts index 509339a..219e6f1 100644 --- a/src/features/settings/model/store.ts +++ b/src/features/settings/model/store.ts @@ -1,66 +1,486 @@ +import { z } from "zod" import { create } from "zustand" -import { persist } from "zustand/middleware" - -export interface AppSettings { - // Appearance - theme: "dark" | "light" | "system" - fontSize: "small" | "medium" | "large" - - // Behavior - confirmDelete: boolean - doubleClickToOpen: boolean - singleClickToSelect: boolean - - // File display - showFileExtensions: boolean - showFileSizes: boolean - showFileDates: boolean - dateFormat: "relative" | "absolute" - - // Performance - enableAnimations: boolean - virtualListThreshold: number -} +import { persist, subscribeWithSelector } from "zustand/middleware" +import type { ColumnWidths, PanelLayout } from "@/features/layout" +import { getPresetLayout, isCustomLayout } from "./layoutPresets" +import type { + AppearanceSettings, + AppSettings, + BehaviorSettings, + CustomLayout, + FileDisplaySettings, + KeyboardSettings, + LayoutPresetId, + LayoutSettings, + PerformanceSettings, +} from "./types" -interface SettingsState { - settings: AppSettings - isOpen: boolean - open: () => void - close: () => void - updateSettings: (updates: Partial) => void - resetSettings: () => void -} +const SETTINGS_VERSION = 1 -const DEFAULT_SETTINGS: AppSettings = { +const defaultAppearance: AppearanceSettings = { theme: "dark", fontSize: "medium", + accentColor: "#3b82f6", + enableAnimations: true, + reducedMotion: false, + // Popover defaults + popoverTranslucent: true, + popoverOpacity: 0.6, + popoverBlur: true, + popoverBlurRadius: 6, +} + +const defaultBehavior: BehaviorSettings = { confirmDelete: true, + confirmOverwrite: true, doubleClickToOpen: true, singleClickToSelect: true, + autoRefreshOnFocus: true, + rememberLastPath: true, + openFoldersInNewTab: false, +} + +const defaultFileDisplay: FileDisplaySettings = { showFileExtensions: true, showFileSizes: true, showFileDates: true, - dateFormat: "relative", - enableAnimations: true, + showHiddenFiles: false, + dateFormat: "auto", + thumbnailSize: "medium", +} + +const defaultLayout: LayoutSettings = { + currentPreset: "default", + customLayouts: [], + panelLayout: getPresetLayout("default"), + columnWidths: { size: 90, date: 140, padding: 16 }, + showStatusBar: true, + showToolbar: true, + showBreadcrumbs: true, + compactMode: false, + // By default keep previous behavior (no headers in simple list) + showColumnHeadersInSimpleList: false, +} + +const defaultPerformance: PerformanceSettings = { virtualListThreshold: 100, + thumbnailCacheSize: 100, + maxSearchResults: 1000, + debounceDelay: 150, + lazyLoadImages: true, +} + +const defaultKeyboard: KeyboardSettings = { + shortcuts: [ + { id: "copy", action: "Копировать", keys: "Ctrl+C", enabled: true }, + { id: "cut", action: "Вырезать", keys: "Ctrl+X", enabled: true }, + { id: "paste", action: "Вставить", keys: "Ctrl+V", enabled: true }, + { id: "delete", action: "Удалить", keys: "Delete", enabled: true }, + { id: "rename", action: "Переименовать", keys: "F2", enabled: true }, + { id: "newFolder", action: "Новая папка", keys: "Ctrl+Shift+N", enabled: true }, + { id: "refresh", action: "Обновить", keys: "F5", enabled: true }, + { id: "search", action: "Поиск", keys: "Ctrl+F", enabled: true }, + { id: "quickFilter", action: "Быстрый фильтр", keys: "Ctrl+Shift+F", enabled: true }, + { id: "settings", action: "Настройки", keys: "Ctrl+,", enabled: true }, + { id: "commandPalette", action: "Палитра команд", keys: "Ctrl+K", enabled: true }, + ], + enableVimMode: false, +} + +const defaultSettings: AppSettings = { + appearance: defaultAppearance, + behavior: defaultBehavior, + fileDisplay: defaultFileDisplay, + layout: defaultLayout, + performance: defaultPerformance, + keyboard: defaultKeyboard, + version: SETTINGS_VERSION, +} + +interface SettingsState { + settings: AppSettings + isOpen: boolean + activeTab: string + + // Dialog actions + open: () => void + close: () => void + setActiveTab: (tab: string) => void + + // Settings updates + updateAppearance: (updates: Partial) => void + updateBehavior: (updates: Partial) => void + updateFileDisplay: (updates: Partial) => void + updateLayout: (updates: Partial) => void + updatePerformance: (updates: Partial) => void + updateKeyboard: (updates: Partial) => void + + // Layout specific + setLayoutPreset: (presetId: LayoutPresetId) => void + updatePanelLayout: (layout: Partial) => void + updateColumnWidths: (widths: Partial) => void + saveCustomLayout: (name: string) => string + deleteCustomLayout: (id: string) => void + applyCustomLayout: (id: string) => void + + // Utility + resetSettings: () => void + resetSection: (section: keyof Omit) => void + exportSettings: () => string + importSettings: (json: string) => boolean +} + +const generateId = () => Math.random().toString(36).substring(2, 9) + +export async function migrateSettings(persistedState: unknown, fromVersion: number) { + // If no persisted state, nothing to do + if (!persistedState || typeof persistedState !== "object") return persistedState + + const isObject = (v: unknown): v is Record => + typeof v === "object" && v !== null && !Array.isArray(v) + + // If already correct version, return as-is + if (fromVersion === SETTINGS_VERSION) return persistedState + + // Attempt to deep-merge persisted settings with defaults + const persisted = persistedState as Record + const base = + persisted.settings && isObject(persisted.settings) + ? (persisted.settings as Record) + : {} + + const deepMerge = (baseV: unknown, patch: unknown): unknown => { + if (!isObject(baseV) || !isObject(patch)) return patch === undefined ? baseV : patch + const out: Record = { ...(baseV as Record) } + for (const key of Object.keys(patch as Record)) { + const pv = (patch as Record)[key] + const bv = (baseV as Record)[key] + if (Array.isArray(pv)) { + out[key] = pv + } else if (isObject(pv) && isObject(bv)) { + out[key] = deepMerge(bv, pv) + } else { + out[key] = pv + } + } + return out + } + + const merged = deepMerge(defaultSettings, base) as unknown as AppSettings + merged.version = SETTINGS_VERSION + + return { settings: merged } } export const useSettingsStore = create()( persist( - (set) => ({ - settings: DEFAULT_SETTINGS, + subscribeWithSelector((set, get) => ({ + settings: defaultSettings, isOpen: false, + activeTab: "appearance", + open: () => set({ isOpen: true }), close: () => set({ isOpen: false }), - updateSettings: (updates) => + setActiveTab: (tab) => set({ activeTab: tab }), + + updateAppearance: (updates) => + set((state) => ({ + settings: { + ...state.settings, + appearance: { ...state.settings.appearance, ...updates }, + }, + })), + + updateBehavior: (updates) => + set((state) => ({ + settings: { + ...state.settings, + behavior: { ...state.settings.behavior, ...updates }, + }, + })), + + updateFileDisplay: (updates) => + set((state) => ({ + settings: { + ...state.settings, + fileDisplay: { ...state.settings.fileDisplay, ...updates }, + }, + })), + + updateLayout: (updates) => + set((state) => ({ + settings: { + ...state.settings, + layout: { ...state.settings.layout, ...updates }, + }, + })), + + updatePerformance: (updates) => + set((state) => ({ + settings: { + ...state.settings, + performance: { ...state.settings.performance, ...updates }, + }, + })), + + updateKeyboard: (updates) => set((state) => ({ - settings: { ...state.settings, ...updates }, + settings: { + ...state.settings, + keyboard: { ...state.settings.keyboard, ...updates }, + }, })), - resetSettings: () => set({ settings: DEFAULT_SETTINGS }), - }), + + setLayoutPreset: (presetId) => { + const presetLayout = getPresetLayout(presetId) + set((state) => ({ + settings: { + ...state.settings, + layout: { + ...state.settings.layout, + currentPreset: presetId, + panelLayout: presetLayout, + }, + }, + })) + }, + + updatePanelLayout: (layout) => + set((state) => { + const newLayout = { ...state.settings.layout.panelLayout, ...layout } + const currentPreset = state.settings.layout.currentPreset + const isCustom = isCustomLayout(newLayout, currentPreset) + + return { + settings: { + ...state.settings, + layout: { + ...state.settings.layout, + panelLayout: newLayout, + currentPreset: isCustom ? "custom" : currentPreset, + }, + }, + } + }), + + updateColumnWidths: (widths) => + set((state) => { + const merged = { ...state.settings.layout.columnWidths, ...widths } + + return { + settings: { + ...state.settings, + layout: { + ...state.settings.layout, + columnWidths: merged, + }, + }, + } + }), + saveCustomLayout: (name) => { + const id = generateId() + const customLayout: CustomLayout = { + id, + name, + layout: get().settings.layout.panelLayout, + createdAt: Date.now(), + } + + set((state) => ({ + settings: { + ...state.settings, + layout: { + ...state.settings.layout, + customLayouts: [...state.settings.layout.customLayouts, customLayout], + }, + }, + })) + + return id + }, + + deleteCustomLayout: (id) => + set((state) => ({ + settings: { + ...state.settings, + layout: { + ...state.settings.layout, + customLayouts: state.settings.layout.customLayouts.filter((l) => l.id !== id), + }, + }, + })), + + applyCustomLayout: (id) => { + const { customLayouts } = get().settings.layout + const layout = customLayouts.find((l) => l.id === id) + if (layout) { + set((state) => ({ + settings: { + ...state.settings, + layout: { + ...state.settings.layout, + panelLayout: layout.layout, + currentPreset: "custom", + }, + }, + })) + } + }, + + resetSettings: () => set({ settings: defaultSettings }), + + resetSection: (section) => + set((state) => { + const defaults: Record = { + appearance: defaultAppearance, + behavior: defaultBehavior, + fileDisplay: defaultFileDisplay, + layout: defaultLayout, + performance: defaultPerformance, + keyboard: defaultKeyboard, + } + + return { + settings: { + ...state.settings, + [section]: defaults[section], + }, + } + }), + + exportSettings: () => JSON.stringify(get().settings, null, 2), + + // Import settings with validation and deep-merge. Returns true on success, false on invalid input. + importSettings: (json) => { + try { + const importedRaw = JSON.parse(json) + + // Validate with Zod schema (partial import allowed) + const appearanceSchema = z.object({ + theme: z.enum(["dark", "light", "system"]).optional(), + fontSize: z.enum(["small", "medium", "large"]).optional(), + accentColor: z.string().optional(), + enableAnimations: z.boolean().optional(), + reducedMotion: z.boolean().optional(), + // Popover settings + popoverTranslucent: z.boolean().optional(), + popoverOpacity: z.number().min(0).max(1).optional(), + popoverBlur: z.boolean().optional(), + popoverBlurRadius: z.number().min(0).optional(), + }) + + const behaviorSchema = z.object({ + confirmDelete: z.boolean().optional(), + confirmOverwrite: z.boolean().optional(), + doubleClickToOpen: z.boolean().optional(), + singleClickToSelect: z.boolean().optional(), + autoRefreshOnFocus: z.boolean().optional(), + rememberLastPath: z.boolean().optional(), + openFoldersInNewTab: z.boolean().optional(), + }) + + const fileDisplaySchema = z.object({ + showFileExtensions: z.boolean().optional(), + showFileSizes: z.boolean().optional(), + showFileDates: z.boolean().optional(), + showHiddenFiles: z.boolean().optional(), + dateFormat: z.enum(["relative", "absolute"]).optional(), + thumbnailSize: z.enum(["small", "medium", "large"]).optional(), + }) + + const layoutSchema = z.object({ + currentPreset: z.string().optional(), + panelLayout: z.any().optional(), + columnWidths: z.any().optional(), + showStatusBar: z.boolean().optional(), + showToolbar: z.boolean().optional(), + showBreadcrumbs: z.boolean().optional(), + compactMode: z.boolean().optional(), + showColumnHeadersInSimpleList: z.boolean().optional(), + }) + + const performanceSchema = z.object({ + virtualListThreshold: z.number().optional(), + thumbnailCacheSize: z.number().optional(), + maxSearchResults: z.number().optional(), + debounceDelay: z.number().optional(), + lazyLoadImages: z.boolean().optional(), + }) + + const keyboardSchema = z.object({ + shortcuts: z.any().optional(), + enableVimMode: z.boolean().optional(), + }) + + const settingsImportSchema = z.object({ + appearance: appearanceSchema.optional(), + behavior: behaviorSchema.optional(), + fileDisplay: fileDisplaySchema.optional(), + layout: layoutSchema.optional(), + performance: performanceSchema.optional(), + keyboard: keyboardSchema.optional(), + version: z.number().optional(), + }) + + if (!settingsImportSchema.safeParse(importedRaw).success) return false + + const imported = importedRaw as Partial + + // Simple migrator placeholder — if version mismatch, log and proceed + if (typeof imported.version === "number" && imported.version !== SETTINGS_VERSION) { + console.warn( + "Settings version mismatch, attempting to migrate. Some fields may be reset.", + ) + // A real migration pipeline would be implemented here. + } + + // Deep merge helper — arrays in imported override defaults, objects are merged recursively + const isObject = (v: unknown): v is Record => + typeof v === "object" && v !== null && !Array.isArray(v) + + const deepMerge = (base: unknown, patch: unknown): unknown => { + if (!isObject(base) || !isObject(patch)) return patch === undefined ? base : patch + const out: Record = { ...(base as Record) } + for (const key of Object.keys(patch as Record)) { + const pv = (patch as Record)[key] + const bv = (base as Record)[key] + if (Array.isArray(pv)) { + out[key] = pv + } else if (isObject(pv) && isObject(bv)) { + out[key] = deepMerge(bv, pv) + } else { + out[key] = pv + } + } + return out + } + + const merged = deepMerge(defaultSettings, imported) as unknown as AppSettings + + merged.version = SETTINGS_VERSION + + set({ settings: merged }) + return true + } catch (e) { + // invalid JSON or other error + console.warn("Failed to import settings: ", e) + return false + } + }, + })), { name: "app-settings", + version: SETTINGS_VERSION, + migrate: migrateSettings, partialize: (state) => ({ settings: state.settings }), }, ), ) + +// Selectors for optimized re-renders +export const useAppearanceSettings = () => useSettingsStore((s) => s.settings.appearance) +export const useBehaviorSettings = () => useSettingsStore((s) => s.settings.behavior) +export const useFileDisplaySettings = () => useSettingsStore((s) => s.settings.fileDisplay) +export const useLayoutSettings = () => useSettingsStore((s) => s.settings.layout) +export const usePerformanceSettings = () => useSettingsStore((s) => s.settings.performance) +export const useKeyboardSettings = () => useSettingsStore((s) => s.settings.keyboard) diff --git a/src/features/settings/model/types.ts b/src/features/settings/model/types.ts new file mode 100644 index 0000000..4e51e37 --- /dev/null +++ b/src/features/settings/model/types.ts @@ -0,0 +1,100 @@ +import type { ColumnWidths, PanelLayout } from "@/features/layout" + +export type Theme = "dark" | "light" | "system" +export type FontSize = "small" | "medium" | "large" +export type DateFormat = "relative" | "absolute" | "auto" +export type LayoutPresetId = "compact" | "default" | "wide" | "custom" + +export interface LayoutPreset { + id: LayoutPresetId + name: string + description: string + layout: PanelLayout +} + +export interface CustomLayout { + id: string + name: string + layout: PanelLayout + createdAt: number +} + +export interface AppearanceSettings { + theme: Theme + fontSize: FontSize + accentColor: string + enableAnimations: boolean + reducedMotion: boolean + + // Popover surface visual options + // Whether to use a translucent popover background (true by default) + popoverTranslucent?: boolean + // Opacity for the popover background (0.0 - 1.0) + popoverOpacity?: number + // Whether to enable backdrop blur on popovers + popoverBlur?: boolean + // Blur radius in pixels + popoverBlurRadius?: number +} + +export interface BehaviorSettings { + confirmDelete: boolean + confirmOverwrite: boolean + doubleClickToOpen: boolean + singleClickToSelect: boolean + autoRefreshOnFocus: boolean + rememberLastPath: boolean + openFoldersInNewTab: boolean +} + +export interface FileDisplaySettings { + showFileExtensions: boolean + showFileSizes: boolean + showFileDates: boolean + showHiddenFiles: boolean + dateFormat: DateFormat + thumbnailSize: "small" | "medium" | "large" +} + +export interface LayoutSettings { + currentPreset: LayoutPresetId + customLayouts: CustomLayout[] + panelLayout: PanelLayout + columnWidths: ColumnWidths + showStatusBar: boolean + showToolbar: boolean + showBreadcrumbs: boolean + compactMode: boolean + // Whether to show column headers even when using the simple (non-virtual) list + showColumnHeadersInSimpleList: boolean +} + +export interface PerformanceSettings { + virtualListThreshold: number + thumbnailCacheSize: number + maxSearchResults: number + debounceDelay: number + lazyLoadImages: boolean +} + +export interface KeyboardShortcut { + id: string + action: string + keys: string + enabled: boolean +} + +export interface KeyboardSettings { + shortcuts: KeyboardShortcut[] + enableVimMode: boolean +} + +export interface AppSettings { + appearance: AppearanceSettings + behavior: BehaviorSettings + fileDisplay: FileDisplaySettings + layout: LayoutSettings + performance: PerformanceSettings + keyboard: KeyboardSettings + version: number +} diff --git a/src/features/settings/ui/AppearanceSettings.tsx b/src/features/settings/ui/AppearanceSettings.tsx new file mode 100644 index 0000000..7d1d2b5 --- /dev/null +++ b/src/features/settings/ui/AppearanceSettings.tsx @@ -0,0 +1,261 @@ +import { Moon, Palette, RotateCcw, Sun, Type } from "lucide-react" +import { memo, useCallback } from "react" +import { cn } from "@/shared/lib" +import { Button, ScrollArea, Separator } from "@/shared/ui" +import { useAppearanceSettings, useSettingsStore } from "../model/store" +import type { FontSize, Theme } from "../model/types" + +const themes: { id: Theme; label: string; icon: React.ReactNode }[] = [ + { id: "light", label: "Светлая", icon: }, + { id: "dark", label: "Тёмная", icon: }, + { id: "system", label: "Системная", icon: }, +] + +const fontSizes: { id: FontSize; label: string; preview: string }[] = [ + { id: "small", label: "Маленький", preview: "12px" }, + { id: "medium", label: "Средний", preview: "14px" }, + { id: "large", label: "Большой", preview: "16px" }, +] + +const accentColors = [ + "#3b82f6", // blue + "#10b981", // emerald + "#f59e0b", // amber + "#ef4444", // red + "#8b5cf6", // violet + "#ec4899", // pink + "#06b6d4", // cyan + "#84cc16", // lime +] + +interface SettingItemProps { + label: string + description?: string + children: React.ReactNode +} + +const SettingItem = memo(function SettingItem({ label, description, children }: SettingItemProps) { + return ( +
+
+ {label} + {description &&

{description}

} +
+ {children} +
+ ) +}) + +interface ToggleSwitchProps { + checked: boolean + onChange: (checked: boolean) => void +} + +const ToggleSwitch = memo(function ToggleSwitch({ checked, onChange }: ToggleSwitchProps) { + return ( + + ) +}) + +export const AppearanceSettings = memo(function AppearanceSettings() { + const appearance = useAppearanceSettings() + const { updateAppearance, resetSection } = useSettingsStore() + + const handleThemeChange = useCallback( + (theme: Theme) => () => updateAppearance({ theme }), + [updateAppearance], + ) + + const handleFontSizeChange = useCallback( + (fontSize: FontSize) => () => updateAppearance({ fontSize }), + [updateAppearance], + ) + + const handleColorChange = useCallback( + (color: string) => () => updateAppearance({ accentColor: color }), + [updateAppearance], + ) + + return ( + +
+ {/* Theme */} +
+

+ + Тема +

+
+ {themes.map((theme) => ( + + ))} +
+
+ + + + {/* Accent Color */} +
+

Акцентный цвет

+
+ {accentColors.map((color) => ( +
+
+ + + + {/* Font Size */} +
+

+ + Размер шрифта +

+
+ {fontSizes.map((size) => ( + + ))} +
+
+ + + + {/* Animations */} +
+

Анимации

+
+ + updateAppearance({ enableAnimations: v })} + /> + + + updateAppearance({ reducedMotion: v })} + /> + +
+
+ + + + {/* Popover visuals */} +
+

Поповеры и меню

+
+ + updateAppearance({ popoverTranslucent: v })} + /> + + + + updateAppearance({ popoverOpacity: Number(e.target.value) })} + className="w-48" + /> + + + + updateAppearance({ popoverBlur: v })} + /> + + + + updateAppearance({ popoverBlurRadius: Number(e.target.value) })} + className="w-48" + /> + +
+
+ + + +
+ +
+
+
+ ) +}) diff --git a/src/features/settings/ui/BehaviorSettings.tsx b/src/features/settings/ui/BehaviorSettings.tsx new file mode 100644 index 0000000..291fe56 --- /dev/null +++ b/src/features/settings/ui/BehaviorSettings.tsx @@ -0,0 +1,161 @@ +import { MousePointer, RotateCcw } from "lucide-react" +import { memo } from "react" +import { cn } from "@/shared/lib" +import { Button, ScrollArea, Separator } from "@/shared/ui" +import { useBehaviorSettings, useSettingsStore } from "../model/store" + +interface SettingItemProps { + label: string + description?: string + children: React.ReactNode +} + +const SettingItem = memo(function SettingItem({ label, description, children }: SettingItemProps) { + return ( +
+
+ {label} + {description &&

{description}

} +
+ {children} +
+ ) +}) + +interface ToggleSwitchProps { + checked: boolean + onChange: (checked: boolean) => void +} + +const ToggleSwitch = memo(function ToggleSwitch({ checked, onChange }: ToggleSwitchProps) { + return ( + + ) +}) + +export const BehaviorSettings = memo(function BehaviorSettings() { + const behavior = useBehaviorSettings() + const { updateBehavior, resetSection } = useSettingsStore() + + return ( + +
+ {/* Confirmations */} +
+

+ + Подтверждения +

+
+ + updateBehavior({ confirmDelete: v })} + /> + + + updateBehavior({ confirmOverwrite: v })} + /> + +
+
+ + + + {/* Click Behavior */} +
+

Поведение кликов

+
+ + updateBehavior({ doubleClickToOpen: v })} + /> + + + updateBehavior({ singleClickToSelect: v })} + /> + +
+
+ + + + {/* Auto Features */} +
+

Автоматизация

+
+ + updateBehavior({ autoRefreshOnFocus: v })} + /> + + + updateBehavior({ rememberLastPath: v })} + /> + + + updateBehavior({ openFoldersInNewTab: v })} + /> + +
+
+ + + +
+ +
+
+
+ ) +}) diff --git a/src/features/settings/ui/FileDisplaySettings.tsx b/src/features/settings/ui/FileDisplaySettings.tsx new file mode 100644 index 0000000..6191b61 --- /dev/null +++ b/src/features/settings/ui/FileDisplaySettings.tsx @@ -0,0 +1,225 @@ +import { Calendar, FileText, Image, RotateCcw } from "lucide-react" +import { memo, useCallback } from "react" +import { cn } from "@/shared/lib" +import { Button, ScrollArea, Separator } from "@/shared/ui" +import { useFileDisplaySettings, useSettingsStore } from "../model/store" +import type { DateFormat } from "../model/types" + +interface SettingItemProps { + label: string + description?: string + children: React.ReactNode +} + +const SettingItem = memo(function SettingItem({ label, description, children }: SettingItemProps) { + return ( +
+
+ {label} + {description &&

{description}

} +
+ {children} +
+ ) +}) + +interface ToggleSwitchProps { + checked: boolean + onChange: (checked: boolean) => void + ariaLabel?: string +} + +const ToggleSwitch = memo(function ToggleSwitch({ + checked, + onChange, + ariaLabel, +}: ToggleSwitchProps) { + return ( + + ) +}) + +const dateFormats: { id: DateFormat; label: string; example: string }[] = [ + { id: "auto", label: "Автоматический", example: "2 дня назад / 15.01.2024" }, + { id: "relative", label: "Относительная", example: "2 дня назад" }, + { id: "absolute", label: "Абсолютная", example: "15.01.2024" }, +] + +const thumbnailSizes = [ + { id: "small" as const, label: "Маленький", size: 48 }, + { id: "medium" as const, label: "Средний", size: 64 }, + { id: "large" as const, label: "Большой", size: 96 }, +] + +export const FileDisplaySettings = memo(function FileDisplaySettings() { + const fileDisplay = useFileDisplaySettings() + const { updateFileDisplay, resetSection } = useSettingsStore() + + const handleDateFormatChange = useCallback( + (format: DateFormat) => () => updateFileDisplay({ dateFormat: format }), + [updateFileDisplay], + ) + + const handleThumbnailSizeChange = useCallback( + (size: "small" | "medium" | "large") => () => updateFileDisplay({ thumbnailSize: size }), + [updateFileDisplay], + ) + + return ( + +
+ {/* Visibility */} +
+

+ + Отображение информации +

+
+ + updateFileDisplay({ showFileExtensions: v })} + /> + + + updateFileDisplay({ showFileSizes: v })} + /> + + + updateFileDisplay({ showFileDates: v })} + /> + + + updateFileDisplay({ showHiddenFiles: v })} + /> + +
+
+ + + + {/* Date Format */} +
+

+ + Формат даты +

+
+ {dateFormats.map((format) => ( + + ))} +
+
+ + + + {/* Thumbnail Size */} +
+

+ + Размер миниатюр +

+
+
+ {thumbnailSizes.map((size) => ( + + ))} +
+ + {/* Live preview box */} +
+ Preview +
s.id === fileDisplay.thumbnailSize)?.size || 64, + height: + thumbnailSizes.find((s) => s.id === fileDisplay.thumbnailSize)?.size || 64, + }} + > + s.id === fileDisplay.thumbnailSize)?.size || 64) / + 3, + ), + )} + className="text-muted-foreground" + /> +
+
+
+
+ + + +
+ +
+
+
+ ) +}) diff --git a/src/features/settings/ui/KeyboardSettings.tsx b/src/features/settings/ui/KeyboardSettings.tsx new file mode 100644 index 0000000..234962e --- /dev/null +++ b/src/features/settings/ui/KeyboardSettings.tsx @@ -0,0 +1,97 @@ +import { Keyboard, RotateCcw } from "lucide-react" +import { memo } from "react" +import { cn } from "@/shared/lib" +import { Button, ScrollArea, Separator } from "@/shared/ui" +import { useKeyboardSettings, useSettingsStore } from "../model/store" + +interface ToggleSwitchProps { + checked: boolean + onChange: (checked: boolean) => void +} + +const ToggleSwitch = memo(function ToggleSwitch({ checked, onChange }: ToggleSwitchProps) { + return ( + + ) +}) + +export const KeyboardSettings = memo(function KeyboardSettings() { + const keyboard = useKeyboardSettings() + const { updateKeyboard, resetSection } = useSettingsStore() + + const handleToggleShortcut = (id: string) => { + const updated = keyboard.shortcuts.map((s) => (s.id === id ? { ...s, enabled: !s.enabled } : s)) + updateKeyboard({ shortcuts: updated }) + } + + return ( + +
+
+

+ + Горячие клавиши +

+
+ {keyboard.shortcuts.map((shortcut) => ( +
+
+ handleToggleShortcut(shortcut.id)} + /> + {shortcut.action} +
+ {shortcut.keys} +
+ ))} +
+
+ + + +
+

Дополнительно

+
+
+ Vim режим +

Навигация в стиле Vim (h, j, k, l)

+
+ updateKeyboard({ enableVimMode: v })} + /> +
+
+ + + +
+ +
+
+
+ ) +}) diff --git a/src/features/settings/ui/LayoutSettings.tsx b/src/features/settings/ui/LayoutSettings.tsx new file mode 100644 index 0000000..86c5a0a --- /dev/null +++ b/src/features/settings/ui/LayoutSettings.tsx @@ -0,0 +1,442 @@ +import { + Check, + Columns, + Eye, + EyeOff, + Layout, + Monitor, + PanelLeft, + PanelRight, + Plus, + RotateCcw, + Save, + Trash2, +} from "lucide-react" +import { memo, useCallback, useState } from "react" +import { cn } from "@/shared/lib" +import { Button, Input, ScrollArea, Separator } from "@/shared/ui" +import { layoutPresets } from "../model/layoutPresets" +import { useLayoutSettings, useSettingsStore } from "../model/store" +import type { LayoutPresetId } from "../model/types" + +interface SliderProps { + label: string + value: number + min: number + max: number + step?: number + unit?: string + disabled?: boolean + onChange: (value: number) => void +} + +const Slider = memo(function Slider({ + label, + value, + min, + max, + step = 1, + unit = "", + disabled = false, + onChange, +}: SliderProps) { + return ( +
+ {label} + onChange(Number(e.target.value))} + disabled={disabled} + className={cn( + "flex-1 h-2 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary", + disabled && "opacity-50 pointer-events-none", + )} + /> + + {value} + {unit} + +
+ ) +}) + +interface ToggleProps { + label: string + description?: string + checked: boolean + onChange: (checked: boolean) => void + icon?: React.ReactNode +} + +const Toggle = memo(function Toggle({ label, description, checked, onChange, icon }: ToggleProps) { + return ( + + ) +}) + +interface PresetCardProps { + preset: (typeof layoutPresets)[LayoutPresetId] + isActive: boolean + onSelect: () => void +} + +const PresetCard = memo(function PresetCard({ preset, isActive, onSelect }: PresetCardProps) { + return ( + + ) +}) + +export const LayoutSettings = memo(function LayoutSettings() { + const layout = useLayoutSettings() + const { + setLayoutPreset, + updatePanelLayout, + updateLayout, + updateColumnWidths, + saveCustomLayout, + deleteCustomLayout, + applyCustomLayout, + resetSection, + } = useSettingsStore() + + const [newLayoutName, setNewLayoutName] = useState("") + const [showSaveDialog, setShowSaveDialog] = useState(false) + + const handlePresetSelect = useCallback( + (presetId: LayoutPresetId) => () => setLayoutPreset(presetId), + [setLayoutPreset], + ) + + const handleSaveLayout = useCallback(() => { + if (newLayoutName.trim()) { + saveCustomLayout(newLayoutName.trim()) + setNewLayoutName("") + setShowSaveDialog(false) + } + }, [newLayoutName, saveCustomLayout]) + + const handleDeleteCustom = useCallback( + (id: string) => () => deleteCustomLayout(id), + [deleteCustomLayout], + ) + + const handleApplyCustom = useCallback( + (id: string) => () => applyCustomLayout(id), + [applyCustomLayout], + ) + + return ( + +
+ {/* Presets */} +
+

+ + Пресеты лейаута +

+
+ {(Object.keys(layoutPresets) as LayoutPresetId[]).map((id) => ( + + ))} +
+
+ + + + {/* Panel Settings */} +
+

+ + Настройки панелей +

+
+ updatePanelLayout({ showSidebar: v })} + icon={} + /> + + {layout.panelLayout.showSidebar && ( + <> + updatePanelLayout({ sidebarCollapsed: v })} + icon={} + /> + + updatePanelLayout({ sidebarSizeLocked: v })} + icon={} + /> + + updatePanelLayout({ sidebarSize: v })} + disabled={!layout.panelLayout.sidebarSizeLocked} + /> + + )} + + updatePanelLayout({ showPreview: v })} + icon={} + /> + + {layout.panelLayout.showPreview && ( + <> + updatePanelLayout({ previewSizeLocked: v })} + icon={} + /> + + updatePanelLayout({ previewPanelSize: v })} + disabled={!layout.panelLayout.previewSizeLocked} + /> + + )} +
+
+ + + + {/* UI Elements */} +
+

+ + Элементы интерфейса +

+
+ updateLayout({ showToolbar: v })} + icon={} + /> + updateLayout({ showBreadcrumbs: v })} + icon={} + /> + updateLayout({ showStatusBar: v })} + icon={} + /> + updateLayout({ compactMode: v })} + icon={} + /> + + updateLayout({ showColumnHeadersInSimpleList: v })} + icon={} + /> +
+
+ + + + {/* Column Widths */} +
+

Ширина колонок

+
+ updateColumnWidths({ size: v })} + /> + updateColumnWidths({ date: v })} + /> +
+
+ + + + {/* Custom Layouts */} +
+

+ + + Сохранённые лейауты + + +

+ + {showSaveDialog && ( +
+ setNewLayoutName(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleSaveLayout()} + className="flex-1" + /> + +
+ )} + + {layout.customLayouts.length === 0 ? ( +

+ Нет сохранённых лейаутов +

+ ) : ( +
+ {layout.customLayouts.map((custom) => ( +
+ + +
+ ))} +
+ )} +
+ + + + {/* Reset */} +
+ +
+
+
+ ) +}) diff --git a/src/features/settings/ui/PerformanceSettings.tsx b/src/features/settings/ui/PerformanceSettings.tsx new file mode 100644 index 0000000..8546311 --- /dev/null +++ b/src/features/settings/ui/PerformanceSettings.tsx @@ -0,0 +1,187 @@ +import { Gauge, RotateCcw } from "lucide-react" +import { memo } from "react" +import { cn } from "@/shared/lib" +import { Button, ScrollArea, Separator } from "@/shared/ui" +import { usePerformanceSettings, useSettingsStore } from "../model/store" + +interface SliderProps { + label: string + description?: string + value: number + min: number + max: number + step?: number + unit?: string + onChange: (value: number) => void +} + +const Slider = memo(function Slider({ + label, + description, + value, + min, + max, + step = 1, + unit = "", + onChange, +}: SliderProps) { + return ( +
+
+
+ {label} + {description &&

{description}

} +
+ + {value} + {unit} + +
+ onChange(Number(e.target.value))} + className="w-full h-2 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary" + /> +
+ ) +}) + +interface ToggleSwitchProps { + checked: boolean + onChange: (checked: boolean) => void + ariaLabel?: string +} + +const ToggleSwitch = memo(function ToggleSwitch({ + checked, + onChange, + ariaLabel, +}: ToggleSwitchProps) { + return ( + + ) +}) + +export const PerformanceSettings = memo(function PerformanceSettings() { + const performance = usePerformanceSettings() + const { updatePerformance, resetSection } = useSettingsStore() + + return ( + +
+
+

+ + Виртуализация +

+
+ updatePerformance({ virtualListThreshold: v })} + /> +
+
+ + + +
+

Кэширование

+
+ updatePerformance({ thumbnailCacheSize: v })} + /> +
+
+ Ленивая загрузка изображений +

+ Загружать изображения только при прокрутке +

+
+ updatePerformance({ lazyLoadImages: v })} + /> +
+
+
+ + + +
+

Поиск

+
+ updatePerformance({ maxSearchResults: v })} + /> +
+
+ + + +
+

Задержки

+
+ updatePerformance({ debounceDelay: v })} + /> +
+
+ + + +
+ +
+
+
+ ) +}) diff --git a/src/features/settings/ui/SettingsDialog.tsx b/src/features/settings/ui/SettingsDialog.tsx index e9c1126..7680d43 100644 --- a/src/features/settings/ui/SettingsDialog.tsx +++ b/src/features/settings/ui/SettingsDialog.tsx @@ -1,221 +1,128 @@ -import { RotateCcw } from "lucide-react" -import { useCallback } from "react" -import { cn } from "@/shared/lib" -import { - Button, - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - ScrollArea, - Separator, -} from "@/shared/ui" -import { type AppSettings, useSettingsStore } from "../model/store" - -interface SettingItemProps { - label: string - description?: string - children: React.ReactNode -} - -function SettingItem({ label, description, children }: SettingItemProps) { - return ( -
-
-
{label}
- {description &&
{description}
} -
-
{children}
-
- ) -} - -interface ToggleSwitchProps { - checked: boolean - onChange: (checked: boolean) => void -} - -function ToggleSwitch({ checked, onChange }: ToggleSwitchProps) { - return ( - - ) -} - -interface SelectProps { - value: string - options: { value: string; label: string }[] - onChange: (value: string) => void -} - -function Select({ value, options, onChange }: SelectProps) { - return ( - - ) -} - -export function SettingsDialog() { - const { settings, isOpen, close, updateSettings, resetSettings } = useSettingsStore() - - const handleUpdate = useCallback( - (key: K, value: AppSettings[K]) => { - updateSettings({ [key]: value }) +import { Download, RotateCcw, Upload, X } from "lucide-react" +import { memo, useCallback, useRef } from "react" +import { Button, Dialog, DialogContent, DialogHeader, DialogTitle, Separator } from "@/shared/ui" +import { toast } from "@/shared/ui/toast" +import { useSettingsStore } from "../model/store" +import { AppearanceSettings } from "./AppearanceSettings" +import { BehaviorSettings } from "./BehaviorSettings" +import { FileDisplaySettings } from "./FileDisplaySettings" +import { KeyboardSettings } from "./KeyboardSettings" +import { LayoutSettings } from "./LayoutSettings" +import { PerformanceSettings } from "./PerformanceSettings" +import { type SettingsTabId, SettingsTabs } from "./SettingsTabs" + +export const SettingsDialog = memo(function SettingsDialog() { + const { isOpen, close, activeTab, setActiveTab, exportSettings, importSettings, resetSettings } = + useSettingsStore() + const fileInputRef = useRef(null) + + const handleTabChange = useCallback((tab: SettingsTabId) => setActiveTab(tab), [setActiveTab]) + + const handleExport = useCallback(() => { + const json = exportSettings() + const blob = new Blob([json], { type: "application/json" }) + const url = URL.createObjectURL(blob) + const a = document.createElement("a") + a.href = url + a.download = "file-manager-settings.json" + a.click() + URL.revokeObjectURL(url) + toast.success("Настройки экспортированы") + }, [exportSettings]) + + const handleImport = useCallback(() => { + fileInputRef.current?.click() + }, []) + + const handleFileChange = useCallback( + (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (!file) return + + const reader = new FileReader() + reader.onload = (event) => { + const json = event.target?.result as string + if (importSettings(json)) { + toast.success("Настройки импортированы") + } else { + toast.error("Ошибка импорта настроек") + } + } + reader.readAsText(file) + e.target.value = "" }, - [updateSettings], + [importSettings], ) + const handleReset = useCallback(() => { + if (confirm("Сбросить все настройки к значениям по умолчанию?")) { + resetSettings() + toast.success("Настройки сброшены") + } + }, [resetSettings]) + + const renderContent = () => { + switch (activeTab) { + case "appearance": + return + case "layout": + return + case "behavior": + return + case "fileDisplay": + return + case "performance": + return + case "keyboard": + return + default: + return + } + } + return ( !open && close()}> - - - Настройки - - - -
- {/* Appearance */} -
-

Внешний вид

-
- - handleUpdate("fontSize", v as AppSettings["fontSize"])} - /> - - - - handleUpdate("enableAnimations", v)} - /> - -
-
- - - - {/* Behavior */} -
-

Поведение

-
- - handleUpdate("confirmDelete", v)} - /> - - - - handleUpdate("doubleClickToOpen", v)} - /> - -
-
- - - - {/* File display */} -
-

Отображение файлов

-
- - handleUpdate("showFileExtensions", v)} - /> - - - - handleUpdate("showFileSizes", v)} - /> - - - - handleUpdate("showFileDates", v)} - /> - - - - + + + + +
- + + +
+
+ +
+
{renderContent()}
+
) -} +}) diff --git a/src/features/settings/ui/SettingsTabs.tsx b/src/features/settings/ui/SettingsTabs.tsx new file mode 100644 index 0000000..38c54cb --- /dev/null +++ b/src/features/settings/ui/SettingsTabs.tsx @@ -0,0 +1,59 @@ +import { FileText, Gauge, Keyboard, Layout, Monitor, MousePointer } from "lucide-react" +import { memo, useCallback } from "react" +import { cn } from "@/shared/lib" + +export type SettingsTabId = + | "appearance" + | "behavior" + | "fileDisplay" + | "layout" + | "performance" + | "keyboard" + +interface Tab { + id: SettingsTabId + label: string + icon: React.ReactNode +} + +const tabs: Tab[] = [ + { id: "appearance", label: "Внешний вид", icon: }, + { id: "layout", label: "Лейаут", icon: }, + { id: "behavior", label: "Поведение", icon: }, + { id: "fileDisplay", label: "Отображение", icon: }, + { id: "performance", label: "Производительность", icon: }, + { id: "keyboard", label: "Клавиатура", icon: }, +] + +interface SettingsTabsProps { + activeTab: string + onTabChange: (tab: SettingsTabId) => void +} + +export const SettingsTabs = memo(function SettingsTabs({ + activeTab, + onTabChange, +}: SettingsTabsProps) { + const handleClick = useCallback((tabId: SettingsTabId) => () => onTabChange(tabId), [onTabChange]) + + return ( + + ) +}) diff --git a/src/features/settings/ui/index.ts b/src/features/settings/ui/index.ts index d5369d6..7b64eee 100644 --- a/src/features/settings/ui/index.ts +++ b/src/features/settings/ui/index.ts @@ -1 +1,8 @@ +export { AppearanceSettings } from "./AppearanceSettings" +export { BehaviorSettings } from "./BehaviorSettings" +export { FileDisplaySettings } from "./FileDisplaySettings" +export { KeyboardSettings } from "./KeyboardSettings" +export { LayoutSettings } from "./LayoutSettings" +export { PerformanceSettings } from "./PerformanceSettings" export { SettingsDialog } from "./SettingsDialog" +export { type SettingsTabId, SettingsTabs } from "./SettingsTabs" diff --git a/src/features/tabs/ui/TabBar.tsx b/src/features/tabs/ui/TabBar.tsx index 296095a..a8cfce4 100644 --- a/src/features/tabs/ui/TabBar.tsx +++ b/src/features/tabs/ui/TabBar.tsx @@ -8,12 +8,13 @@ import { ContextMenuSeparator, ContextMenuTrigger, } from "@/shared/ui" -import { WindowControls } from "@/widgets" import { type Tab, useTabsStore } from "../model/store" interface TabBarProps { onTabChange?: (path: string) => void className?: string + // Controls slot allows higher layers (pages/widgets) to inject controls like WindowControls + controls?: React.ReactNode } interface TabItemProps { @@ -125,7 +126,7 @@ function TabItem({ ) } -export function TabBar({ onTabChange, className }: TabBarProps) { +export function TabBar({ onTabChange, className, controls }: TabBarProps) { const { tabs, activeTabId, @@ -140,6 +141,7 @@ export function TabBar({ onTabChange, className }: TabBarProps) { closeTabsToRight, closeAllTabs, } = useTabsStore() + const getTabById = useTabsStore((s) => s.getTabById) const dragIndexRef = useRef(null) @@ -153,11 +155,11 @@ export function TabBar({ onTabChange, className }: TabBarProps) { const handleNewTab = useCallback(() => { const id = addTab("", "New Tab") - const tab = useTabsStore.getState().getTabById(id) + const tab = getTabById(id) if (tab) { onTabChange?.(tab.path) } - }, [addTab, onTabChange]) + }, [addTab, onTabChange, getTabById]) const handleContextMenu = useCallback( (tabId: string, action: string) => { @@ -252,10 +254,8 @@ export function TabBar({ onTabChange, className }: TabBarProps) {
- {/* Window controls - aligned to the right */} -
- -
+ {/* Window controls - aligned to the right (injected by parent) */} +
{controls}
) } diff --git a/src/features/view-mode/__tests__/toggleHidden.test.ts b/src/features/view-mode/__tests__/toggleHidden.test.ts new file mode 100644 index 0000000..8293ad8 --- /dev/null +++ b/src/features/view-mode/__tests__/toggleHidden.test.ts @@ -0,0 +1,14 @@ +/// +import { expect, it } from "vitest" +import { useSettingsStore } from "@/features/settings" +import { useViewModeStore } from "../model/store" + +it("toggleHidden delegates to settings store", () => { + useSettingsStore.getState().updateFileDisplay({ showHiddenFiles: false }) + + useViewModeStore.getState().toggleHidden() + expect(useSettingsStore.getState().settings.fileDisplay.showHiddenFiles).toBe(true) + + useViewModeStore.getState().toggleHidden() + expect(useSettingsStore.getState().settings.fileDisplay.showHiddenFiles).toBe(false) +}) diff --git a/src/features/view-mode/model/store.ts b/src/features/view-mode/model/store.ts index 3afd865..da5d865 100644 --- a/src/features/view-mode/model/store.ts +++ b/src/features/view-mode/model/store.ts @@ -3,9 +3,10 @@ import { persist } from "zustand/middleware" export type ViewMode = "list" | "grid" | "details" +import { useSettingsStore } from "@/features/settings" + export interface ViewSettings { mode: ViewMode - showHidden: boolean gridSize: "small" | "medium" | "large" // Per-folder settings folderSettings: Record @@ -14,7 +15,7 @@ export interface ViewSettings { interface ViewModeState { settings: ViewSettings setViewMode: (mode: ViewMode) => void - setShowHidden: (show: boolean) => void + // toggleHidden will delegate to settings store to keep single source of truth toggleHidden: () => void setGridSize: (size: "small" | "medium" | "large") => void setFolderViewMode: (path: string, mode: ViewMode) => void @@ -23,7 +24,6 @@ interface ViewModeState { const DEFAULT_SETTINGS: ViewSettings = { mode: "list", - showHidden: false, gridSize: "medium", folderSettings: {}, } @@ -39,15 +39,16 @@ export const useViewModeStore = create()( })) }, - setShowHidden: (show: boolean) => { - set((state) => ({ - settings: { ...state.settings, showHidden: show }, - })) - }, - + // Toggle hidden files via settings store to avoid a secondary source of truth toggleHidden: () => { - set((state) => ({ - settings: { ...state.settings, showHidden: !state.settings.showHidden }, + useSettingsStore.setState((s) => ({ + settings: { + ...s.settings, + fileDisplay: { + ...s.settings.fileDisplay, + showHiddenFiles: !s.settings.fileDisplay.showHiddenFiles, + }, + }, })) }, @@ -76,6 +77,25 @@ export const useViewModeStore = create()( }), { name: "file-manager-view-mode", + // Migrate legacy persisted `showHidden` into global settings on rehydrate + onRehydrateStorage: (state) => (err) => { + try { + if (err) return + const persisted = (state?.settings as Partial<{ showHidden?: boolean }>) ?? null + if (persisted && typeof persisted.showHidden === "boolean") { + const s = persisted.showHidden + // Push to settings store + useSettingsStore.setState((state) => ({ + settings: { + ...state.settings, + fileDisplay: { ...state.settings.fileDisplay, showHiddenFiles: s }, + }, + })) + } + } catch { + /* ignore */ + } + }, }, ), ) diff --git a/src/main.tsx b/src/main.tsx index 2cb4ae3..e81d09d 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,2 +1,32 @@ -export {} from "@/app" +import { applyAppearanceToRoot } from "@/features/settings/hooks/useApplyAppearance" + +// Try to apply persisted appearance settings before React mounts to avoid FOUC. +try { + const raw = localStorage.getItem("app-settings") + if (raw) { + try { + const parsed = JSON.parse(raw) + // Zustand persist can store { state: { settings: { appearance: ... } } } or { settings } + const appearance = parsed?.state?.settings?.appearance ?? parsed?.settings?.appearance + if (appearance) applyAppearanceToRoot(appearance) + + // If user disabled remembering last path, clear navigation storage so app starts fresh + const remember = + parsed?.state?.settings?.behavior?.rememberLastPath ?? + parsed?.settings?.behavior?.rememberLastPath + if (remember === false) { + try { + localStorage.removeItem("navigation-storage") + } catch { + // ignore + } + } + } catch (_e) { + // ignore parse errors + } + } +} catch (_e) { + // ignore localStorage access errors (e.g., in restricted envs) +} + import "@/app" diff --git a/src/pages/file-browser/__tests__/FileBrowserPage.test.tsx b/src/pages/file-browser/__tests__/FileBrowserPage.test.tsx new file mode 100644 index 0000000..86e01da --- /dev/null +++ b/src/pages/file-browser/__tests__/FileBrowserPage.test.tsx @@ -0,0 +1,97 @@ +/// + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { render, waitFor } from "@testing-library/react" +import { act } from "react" +import { beforeEach, describe, expect, it } from "vitest" +import { useLayoutStore } from "@/features/layout" +import { useLayoutSettings, useSettingsStore } from "@/features/settings" +import { useSyncLayoutWithSettings } from "@/pages/file-browser/hooks/useSyncLayoutWithSettings" + +function renderWithProviders(ui: React.ReactElement) { + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + // Provide a safe default query function to avoid tests failing when a real queryFn + // is not provided in mocks (returns an empty array) + queryFn: () => [] as unknown, + }, + }, + }) + return render({ui}) +} + +function TestHarness() { + useSyncLayoutWithSettings() + return
+} + +function CompactTest() { + const layout = useLayoutSettings() + return
+} + +describe("FileBrowserPage layout sync", () => { + beforeEach(() => { + // Reset stores to defaults + useSettingsStore.getState().resetSettings() + useLayoutStore.getState().resetLayout() + }) + + it("applies panelLayout changes from settings to layout store", async () => { + const { container } = renderWithProviders() + + // initial + expect(useLayoutStore.getState().layout.showSidebar).toBe(true) + + act(() => { + useSettingsStore.getState().updateLayout({ + panelLayout: { + ...useSettingsStore.getState().settings.layout.panelLayout, + showSidebar: false, + }, + }) + }) + + await waitFor(() => { + expect(useLayoutStore.getState().layout.showSidebar).toBe(false) + }) + + // cleanup (not strictly necessary because of beforeEach) + container.remove() + }) + + it("syncs column widths from settings into layout store", async () => { + const { container } = renderWithProviders() + + const newWidths = { size: 120, date: 160, padding: 12 } + + act(() => { + useSettingsStore.getState().updateLayout({ columnWidths: newWidths }) + }) + + await waitFor(() => { + const cw = useLayoutStore.getState().layout.columnWidths + expect(cw.size).toBe(newWidths.size) + expect(cw.date).toBe(newWidths.date) + expect(cw.padding).toBe(newWidths.padding) + }) + + container.remove() + }) + + it("applies compact mode class to root when enabled in settings", async () => { + const { container } = renderWithProviders() + + act(() => { + useSettingsStore.getState().updateLayout({ compactMode: true }) + }) + + await waitFor(() => { + expect(container.querySelector(".compact-mode")).toBeTruthy() + }) + + container.remove() + }) +}) diff --git a/src/pages/file-browser/__tests__/layoutSyncEdgeCases.test.tsx b/src/pages/file-browser/__tests__/layoutSyncEdgeCases.test.tsx new file mode 100644 index 0000000..541346f --- /dev/null +++ b/src/pages/file-browser/__tests__/layoutSyncEdgeCases.test.tsx @@ -0,0 +1,60 @@ +/// +import { render } from "@testing-library/react" +import { beforeEach, describe, expect, it } from "vitest" +import { useLayoutStore } from "@/features/layout" +import { useSettingsStore } from "@/features/settings" +import { layoutPresets } from "@/features/settings/model/layoutPresets" +import { useSyncLayoutWithSettings } from "@/pages/file-browser/hooks/useSyncLayoutWithSettings" + +function TestHarness() { + useSyncLayoutWithSettings() + return null +} + +describe("Layout sync edge cases", () => { + beforeEach(() => { + useSettingsStore.getState().resetSettings() + useLayoutStore.getState().resetLayout() + }) + + it("applies preset to runtime layout when selecting a preset", () => { + render() + useSettingsStore.getState().setLayoutPreset("wide") + + const runtime = useLayoutStore.getState().layout + expect(runtime.sidebarSize).toBe(layoutPresets.wide.layout.sidebarSize) + expect(runtime.previewPanelSize).toBe(layoutPresets.wide.layout.previewPanelSize) + expect(runtime.showPreview).toBe(layoutPresets.wide.layout.showPreview) + }) + + it("hides sidebar in runtime when settings toggles showSidebar=false", () => { + render() + + // initially visible + expect(useLayoutStore.getState().layout.showSidebar).toBe(true) + + useSettingsStore.getState().updatePanelLayout({ showSidebar: false }) + + expect(useLayoutStore.getState().layout.showSidebar).toBe(false) + }) + + it("updateColumnWidths updates runtime column widths", () => { + render() + + const newWidths = { size: 130, date: 170, padding: 14 } + useSettingsStore.getState().updateColumnWidths(newWidths) + + const cw = useLayoutStore.getState().layout.columnWidths + expect(cw.size).toBe(newWidths.size) + expect(cw.date).toBe(newWidths.date) + expect(cw.padding).toBe(newWidths.padding) + }) + + it("preview size lock applies previewPanelSize immediately", () => { + render() + + useSettingsStore.getState().updatePanelLayout({ previewSizeLocked: true, previewPanelSize: 33 }) + + expect(useLayoutStore.getState().layout.previewPanelSize).toBe(33) + }) +}) diff --git a/src/pages/file-browser/hooks/useSyncLayoutWithSettings.ts b/src/pages/file-browser/hooks/useSyncLayoutWithSettings.ts new file mode 100644 index 0000000..d16cb42 --- /dev/null +++ b/src/pages/file-browser/hooks/useSyncLayoutWithSettings.ts @@ -0,0 +1,9 @@ +import { useEffect } from "react" +import { initLayoutSync } from "@/features/layout/sync" + +export function useSyncLayoutWithSettings() { + useEffect(() => { + const cleanup = initLayoutSync() + return () => cleanup?.() + }, []) +} diff --git a/src/pages/file-browser/ui/FileBrowserPage.tsx b/src/pages/file-browser/ui/FileBrowserPage.tsx index 0587129..c071319 100644 --- a/src/pages/file-browser/ui/FileBrowserPage.tsx +++ b/src/pages/file-browser/ui/FileBrowserPage.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react" import type { ImperativePanelHandle } from "react-resizable-panels" import { fileKeys } from "@/entities/file-entry" import { CommandPalette, useRegisterCommands } from "@/features/command-palette" +import { ConfirmDialog } from "@/features/confirm" import { DeleteConfirmDialog, useDeleteConfirmStore } from "@/features/delete-confirm" import { useSelectionStore } from "@/features/file-selection" import { useInlineEditStore } from "@/features/inline-edit" @@ -14,10 +15,11 @@ import { useUndoToast, } from "@/features/operations-history" import { SearchResultItem, useSearchStore } from "@/features/search-content" -import { SettingsDialog, useSettingsStore } from "@/features/settings" +import { SettingsDialog, useLayoutSettings, useSettingsStore } from "@/features/settings" import { TabBar, useTabsStore } from "@/features/tabs" import type { FileEntry } from "@/shared/api/tauri" -import { commands } from "@/shared/api/tauri" +import { tauriClient } from "@/shared/api/tauri/client" +import { cn } from "@/shared/lib" import { ResizableHandle, ResizablePanel, @@ -26,14 +28,18 @@ import { TooltipProvider, toast, } from "@/shared/ui" -import { Breadcrumbs, FileExplorer, PreviewPanel, Sidebar, StatusBar, Toolbar } from "@/widgets" - -const COLLAPSE_THRESHOLD = 8 -const COLLAPSED_SIZE = 4 +import { + Breadcrumbs, + FileExplorer, + PreviewPanel, + Sidebar, + StatusBar, + Toolbar, + WindowControls, +} from "@/widgets" +import { useSyncLayoutWithSettings } from "../hooks/useSyncLayoutWithSettings" export function FileBrowserPage() { - const queryClient = useQueryClient() - // Navigation const { currentPath, navigate } = useNavigationStore() @@ -41,42 +47,89 @@ export function FileBrowserPage() { const { tabs, addTab, updateTabPath, getActiveTab } = useTabsStore() // Selection - use atomic selectors + const selectedPaths = useSelectionStore((s) => s.selectedPaths) const lastSelectedPath = useSelectionStore((s) => s.lastSelectedPath) - const getSelectedPaths = useSelectionStore((s) => s.getSelectedPaths) const clearSelection = useSelectionStore((s) => s.clearSelection) - // Inline edit - const { startNewFolder, startNewFile } = useInlineEditStore() + // Layout from settings + const layoutSettings = useLayoutSettings() + const { layout: panelLayout, setLayout } = useLayoutStore() - // Layout - const { layout, setSidebarSize, setPreviewPanelSize, setSidebarCollapsed, togglePreview } = - useLayoutStore() + // Sync layout store with settings (encapsulated in a hook) + // This handles initial sync, applying presets/custom layouts, column widths and persisting + // back to settings when changes occur. + // The hook avoids DOM operations so it's safe to unit-test in isolation. + useSyncLayoutWithSettings() + + // Register panel refs with the panel controller so DOM imperative calls are centralized + useEffect(() => { + let mounted = true + let cleanup = () => {} + + ;(async () => { + const mod = await import("@/features/layout/panelController") + if (!mounted) return + mod.registerSidebar(sidebarPanelRef) + mod.registerPreview(previewPanelRef) + + cleanup = () => { + try { + mod.registerSidebar(null) + mod.registerPreview(null) + } catch { + /* ignore */ + } + } + })() + + return () => { + mounted = false + cleanup() + // cancel any outstanding RAFs + if (sidebarRafRef.current !== null) { + window.cancelAnimationFrame(sidebarRafRef.current) + sidebarRafRef.current = null + sidebarPendingRef.current = null + } + if (previewRafRef.current !== null) { + window.cancelAnimationFrame(previewRafRef.current) + previewRafRef.current = null + previewPendingRef.current = null + } + } + }, []) // Settings - const { open: openSettings } = useSettingsStore() - const confirmDelete = useSettingsStore((s) => s.settings.confirmDelete) + const openSettings = useSettingsStore((s) => s.open) // Delete confirmation - const { open: openDeleteConfirm } = useDeleteConfirmStore() + const openDeleteConfirm = useDeleteConfirmStore((s) => s.open) // Operations history const addOperation = useOperationsHistoryStore((s) => s.addOperation) // Search - const searchResults = useSearchStore((s) => s.results) - const isSearching = useSearchStore((s) => s.isSearching) - const resetSearch = useSearchStore((s) => s.reset) + const { results: searchResults, isSearching, reset: resetSearch } = useSearchStore() // Quick Look state const [quickLookFile, setQuickLookFile] = useState(null) // Panel refs for imperative control - const sidebarRef = useRef(null) - const previewRef = useRef(null) + const sidebarPanelRef = useRef(null) + const previewPanelRef = useRef(null) + + // RAF batching refs to throttle high-frequency resize events + const sidebarPendingRef = useRef<{ size: number } | null>(null) + const sidebarRafRef = useRef(null) + const previewPendingRef = useRef<{ size: number } | null>(null) + const previewRafRef = useRef(null) // Files cache for preview lookup const filesRef = useRef([]) + // Query client for invalidation + const queryClient = useQueryClient() + // Initialize first tab if none exists useEffect(() => { if (tabs.length === 0 && currentPath) { @@ -96,39 +149,47 @@ export function FileBrowserPage() { const handleTabChange = useCallback( (path: string) => { navigate(path) + resetSearch() }, - [navigate], + [navigate, resetSearch], ) - // Get selected file for preview - optimized to avoid Set iteration - const previewFile = useMemo(() => { + const selectedFile = useMemo(() => { // Quick look takes priority if (quickLookFile) return quickLookFile // Find file by lastSelectedPath - if (!lastSelectedPath) return null - - return filesRef.current.find((f) => f.path === lastSelectedPath) ?? null + if (lastSelectedPath && filesRef.current.length > 0) { + return filesRef.current.find((f) => f.path === lastSelectedPath) ?? null + } + return null }, [quickLookFile, lastSelectedPath]) // Show search results when we have results const showSearchResults = searchResults.length > 0 || isSearching + const selectFile = useSelectionStore((s) => s.selectFile) + // Handle search result selection const handleSearchResultSelect = useCallback( - (path: string) => { - commands.getParentPath(path).then((result) => { - if (result.status === "ok" && result.data) { - navigate(result.data) + async (path: string) => { + // Navigate to parent directory + try { + const parentPath = await tauriClient.getParentPath(path) + if (parentPath) { + navigate(parentPath) resetSearch() + // Select the file after navigation setTimeout(() => { - useSelectionStore.getState().selectFile(path) + selectFile(path) }, 100) } - }) + } catch { + // ignore + } }, - [navigate, resetSearch], + [navigate, resetSearch, selectFile], ) // Quick Look handler @@ -136,11 +197,9 @@ export function FileBrowserPage() { (file: FileEntry) => { setQuickLookFile(file) // Show preview panel if hidden - if (!layout.showPreview) { - togglePreview() - } + setLayout({ showPreview: true }) }, - [layout.showPreview, togglePreview], + [setLayout], ) // Close Quick Look on Escape @@ -150,42 +209,15 @@ export function FileBrowserPage() { setQuickLookFile(null) } } + document.addEventListener("keydown", handleKeyDown) return () => document.removeEventListener("keydown", handleKeyDown) }, [quickLookFile]) - // Update files ref when FileExplorer provides files const handleFilesChange = useCallback((files: FileEntry[]) => { filesRef.current = files }, []) - // Immediate resize handlers (no debounce) - const handleSidebarResize = useCallback( - (size: number) => { - if (size < COLLAPSE_THRESHOLD && !layout.sidebarCollapsed) { - setSidebarCollapsed(true) - sidebarRef.current?.resize(COLLAPSED_SIZE) - } else if (size >= COLLAPSE_THRESHOLD && layout.sidebarCollapsed) { - setSidebarCollapsed(false) - } - setSidebarSize(size) - }, - [layout.sidebarCollapsed, setSidebarCollapsed, setSidebarSize], - ) - - const handlePreviewResize = useCallback( - (size: number) => { - const mainPanelSize = 100 - layout.sidebarSize - size - if (mainPanelSize < 30) { - const newPreviewSize = 100 - layout.sidebarSize - 30 - setPreviewPanelSize(Math.max(newPreviewSize, 15)) - } else { - setPreviewPanelSize(size) - } - }, - [layout.sidebarSize, setPreviewPanelSize], - ) - // Handlers const handleRefresh = useCallback(() => { if (currentPath) { @@ -193,167 +225,215 @@ export function FileBrowserPage() { } }, [currentPath, queryClient]) + const startNewFolder = useInlineEditStore((s) => s.startNewFolder) + const startNewFile = useInlineEditStore((s) => s.startNewFile) + const handleNewFolder = useCallback(() => { - if (currentPath) startNewFolder(currentPath) + if (currentPath) { + startNewFolder(currentPath) + } }, [currentPath, startNewFolder]) const handleNewFile = useCallback(() => { - if (currentPath) startNewFile(currentPath) - }, [currentPath, startNewFile]) - - const handleDelete = useCallback(async () => { - const selectedPaths = getSelectedPaths() - if (selectedPaths.length === 0) return - - const performDelete = async () => { - try { - const result = await commands.deleteEntries(selectedPaths, false) - if (result.status === "ok") { - clearSelection() - handleRefresh() - addOperation({ - type: "delete", - description: createOperationDescription("delete", { deletedPaths: selectedPaths }), - data: { deletedPaths: selectedPaths }, - canUndo: false, - }) - toast.success(`Удалено: ${selectedPaths.length} элемент(ов)`) - } else { - toast.error(`Ошибка удаления: ${result.error}`) - } - } catch (err) { - toast.error(`Ошибка: ${err}`) - } + if (currentPath) { + startNewFile(currentPath) } + }, [currentPath, startNewFile]) - if (confirmDelete) { - const confirmed = await openDeleteConfirm(selectedPaths) - if (confirmed) await performDelete() - } else { - await performDelete() + const performDelete = useCallback(async () => { + const paths = Array.from(selectedPaths) + if (paths.length === 0) return + + const confirmed = await openDeleteConfirm(paths, false) + if (!confirmed) return + + try { + await tauriClient.deleteEntries(paths, false) + toast.success(`Удалено: ${paths.length} элемент(ов)`) + addOperation({ + type: "delete", + description: createOperationDescription("delete", { deletedPaths: paths }), + data: { deletedPaths: paths }, + canUndo: false, + }) + clearSelection() + handleRefresh() + } catch (error) { + toast.error(`Ошибка удаления: ${error}`) } - }, [ - getSelectedPaths, - confirmDelete, - openDeleteConfirm, - clearSelection, - handleRefresh, - addOperation, - ]) + }, [selectedPaths, openDeleteConfirm, addOperation, clearSelection, handleRefresh]) // Register commands useRegisterCommands({ onRefresh: handleRefresh, - onDelete: handleDelete, + onDelete: performDelete, onOpenSettings: openSettings, }) // Show undo toast for last operation - const { toast: undoToast } = useUndoToast() + useUndoToast((operation) => { + // Handle undo based on operation type + toast.info(`Отмена: ${operation.description}`) + }) + + // Toggle preview + const handleTogglePreview = useCallback(() => { + setLayout({ showPreview: !panelLayout.showPreview }) + }, [panelLayout.showPreview, setLayout]) + + const mainDefaultSize = (() => { + const sidebar = panelLayout.showSidebar ? panelLayout.sidebarSize : 0 + const preview = panelLayout.showPreview ? panelLayout.previewPanelSize : 0 + + if (panelLayout.showSidebar && panelLayout.showPreview) { + return Math.max(10, 100 - sidebar - preview) + } + if (panelLayout.showSidebar) return Math.max(30, 100 - sidebar) + if (panelLayout.showPreview) return Math.max(30, 100 - preview) + return 100 + })() return ( - -
+ +
{/* Tab Bar */} - + } /> {/* Header */} -
+
{/* Breadcrumbs */} - + {layoutSettings.showBreadcrumbs && ( +
+ +
+ )} {/* Toolbar */} - + {layoutSettings.showToolbar && ( + + )}
{/* Main Content */} - + {/* Sidebar */} - {layout.showSidebar && ( + {panelLayout.showSidebar && ( <> { + // allow runtime resizing only when not locked + if (!panelLayout.sidebarSizeLocked) { + // Throttle updates to once per animation frame to avoid jank + // store pending size in a ref and apply once per RAF + if (!sidebarPendingRef.current) sidebarPendingRef.current = { size } + else sidebarPendingRef.current.size = size + if (sidebarRafRef.current === null) { + sidebarRafRef.current = window.requestAnimationFrame(() => { + const pending = sidebarPendingRef.current + sidebarPendingRef.current = null + sidebarRafRef.current = null + if (pending) { + setLayout({ + sidebarSize: pending.size, + sidebarCollapsed: pending.size <= 4.1, + }) + } + }) + } + } + }} + onCollapse={() => setLayout({ sidebarCollapsed: true })} + onExpand={() => setLayout({ sidebarCollapsed: false })} > - + - + {!panelLayout.sidebarSizeLocked && } )} - {/* Main Panel */} - -
- {/* Search Results Overlay */} - {showSearchResults ? ( - -
- {isSearching && ( -
Поиск...
- )} - {searchResults.map((result) => ( - - ))} - {!isSearching && searchResults.length === 0 && ( -
Ничего не найдено
- )} -
-
- ) : ( - - )} -
+ + {showSearchResults ? ( + +
+ {searchResults.map((result) => ( + + ))} +
+
+ ) : ( + + )}
{/* Preview Panel */} - {layout.showPreview && ( + {panelLayout.showPreview && ( <> - + {!panelLayout.previewSizeLocked && } { + if (!panelLayout.previewSizeLocked) { + if (!previewPendingRef.current) previewPendingRef.current = { size } + else previewPendingRef.current.size = size + + if (previewRafRef.current === null) { + previewRafRef.current = window.requestAnimationFrame(() => { + const pending = previewPendingRef.current + previewPendingRef.current = null + previewRafRef.current = null + if (pending) setLayout({ previewPanelSize: pending.size }) + }) + } + } + }} > - setQuickLookFile(null)} - className="h-full" - /> + setQuickLookFile(null)} /> )}
{/* Status Bar */} - + {layoutSettings.showStatusBar && } - {/* Global UI: Command palette, settings dialog, undo toast, delete confirm */} + {/* Global UI: Command palette, settings dialog, delete confirm */} - {undoToast} +
) diff --git a/src/shared/api/tauri/bindings.ts b/src/shared/api/tauri/bindings.ts index 198c5d5..1a1aefd 100644 --- a/src/shared/api/tauri/bindings.ts +++ b/src/shared/api/tauri/bindings.ts @@ -262,9 +262,6 @@ export type DriveInfo = { name: string; path: string; total_space: number; free_ * Represents a file or directory entry in the filesystem. */ export type FileEntry = { name: string; path: string; is_dir: boolean; is_hidden: boolean; size: number; modified: number | null; created: number | null; extension: string | null } -/** - * File preview content types. - */ export type FilePreview = { type: "Text"; content: string; truncated: boolean } | { type: "Image"; base64: string; mime: string } | { type: "Unsupported"; mime: string } /** * Options for file search operations. diff --git a/src/shared/api/tauri/client.ts b/src/shared/api/tauri/client.ts new file mode 100644 index 0000000..77c7ca3 --- /dev/null +++ b/src/shared/api/tauri/client.ts @@ -0,0 +1,128 @@ +import type { + DriveInfo, + FileEntry, + FilePreview, + Result, + SearchOptions, + SearchResult, +} from "./bindings" +import { commands } from "./bindings" + +// Thumbnail command may or may not exist in generated bindings; define local type +export type Thumbnail = { base64: string; mime: string } | null + +export function unwrapResult(result: Result): T { + if (result.status === "ok") return result.data + throw new Error(String(result.error)) +} + +export const tauriClient = { + async readDirectory(path: string): Promise { + return unwrapResult(await commands.readDirectory(path)) + }, + + async readDirectoryStream(path: string): Promise { + return unwrapResult(await commands.readDirectoryStream(path)) + }, + + async getDrives(): Promise { + return unwrapResult(await commands.getDrives()) + }, + + async createDirectory(path: string): Promise { + return unwrapResult(await commands.createDirectory(path)) + }, + + async createFile(path: string): Promise { + return unwrapResult(await commands.createFile(path)) + }, + + async deleteEntries(paths: string[], permanent: boolean): Promise { + return unwrapResult(await commands.deleteEntries(paths, permanent)) + }, + + async renameEntry(oldPath: string, newName: string): Promise { + return unwrapResult(await commands.renameEntry(oldPath, newName)) + }, + + async copyEntries(sources: string[], destination: string): Promise { + return unwrapResult(await commands.copyEntries(sources, destination)) + }, + + async copyEntriesParallel(sources: string[], destination: string): Promise { + return unwrapResult(await commands.copyEntriesParallel(sources, destination)) + }, + + async moveEntries(sources: string[], destination: string): Promise { + return unwrapResult(await commands.moveEntries(sources, destination)) + }, + + async getFileContent(path: string): Promise { + return unwrapResult(await commands.getFileContent(path)) + }, + + async getParentPath(path: string): Promise { + return unwrapResult(await commands.getParentPath(path)) + }, + + async pathExists(path: string): Promise { + return unwrapResult(await commands.pathExists(path)) + }, + + async searchFiles(options: SearchOptions): Promise { + return unwrapResult(await commands.searchFiles(options)) + }, + + async searchFilesStream(options: SearchOptions): Promise { + return unwrapResult(await commands.searchFilesStream(options)) + }, + + async searchByName( + searchPath: string, + query: string, + maxResults: number | null, + ): Promise { + return unwrapResult(await commands.searchByName(searchPath, query, maxResults)) + }, + + async searchContent( + searchPath: string, + query: string, + extensions: string[] | null, + maxResults: number | null, + ): Promise { + return unwrapResult(await commands.searchContent(searchPath, query, extensions, maxResults)) + }, + + async getFilePreview(path: string): Promise { + return unwrapResult(await commands.getFilePreview(path)) + }, + + async getThumbnail(path: string, max_side: number): Promise { + // The generated bindings don't always include getThumbnail; guard and provide a clear error if missing. + const fn = (commands as unknown as Record).getThumbnail + if (typeof fn === "function") { + // The generated command should return a Result + const res = await (fn as (...args: unknown[]) => Promise>)( + path, + max_side, + ) + return unwrapResult(res) + } + throw new Error("tauri command 'getThumbnail' is not available in bindings") + }, + + async watchDirectory(path: string): Promise { + return unwrapResult(await commands.watchDirectory(path)) + }, + + async unwatchDirectory(path: string): Promise { + return unwrapResult(await commands.unwatchDirectory(path)) + }, + + async unwatchAll(): Promise { + return unwrapResult(await commands.unwatchAll()) + }, +} + +export type { FileEntry, DriveInfo, FilePreview, SearchResult, Result } diff --git a/src/shared/api/tauri/index.ts b/src/shared/api/tauri/index.ts index 9874a8a..237db15 100644 --- a/src/shared/api/tauri/index.ts +++ b/src/shared/api/tauri/index.ts @@ -8,3 +8,4 @@ export type { SearchResult, } from "./bindings" export { commands } from "./bindings" +export type { Thumbnail } from "./client" diff --git a/src/shared/lib/__tests__/devLogger.test.ts b/src/shared/lib/__tests__/devLogger.test.ts new file mode 100644 index 0000000..1bfd9fc --- /dev/null +++ b/src/shared/lib/__tests__/devLogger.test.ts @@ -0,0 +1,25 @@ +/// +import { beforeEach, describe, expect, it } from "vitest" +import type { FileEntry } from "@/shared/api/tauri" +import { getLastFiles, getPerfLog, setLastFiles, setPerfLog } from "../devLogger" + +describe("devLogger", () => { + beforeEach(() => { + // ensure perf logs are enabled unless environment disables it + ;(globalThis as unknown as { __fm_perfEnabled?: boolean }).__fm_perfEnabled = true + ;(globalThis as unknown as { __fm_perfLog?: Record }).__fm_perfLog = undefined + ;(globalThis as unknown as { __fm_lastFiles?: unknown }).__fm_lastFiles = undefined + }) + + it("merges perf log entries", () => { + setPerfLog({ a: 1 }) + setPerfLog({ b: 2 }) + expect(getPerfLog()).toMatchObject({ a: 1, b: 2 }) + }) + + it("sets last files and getLastFiles returns them", () => { + const files = [{ path: "/foo", name: "foo" }] as unknown as FileEntry[] + setLastFiles(files) + expect(getLastFiles()).toEqual(files) + }) +}) diff --git a/src/shared/lib/__tests__/perf.test.ts b/src/shared/lib/__tests__/perf.test.ts new file mode 100644 index 0000000..3ff7488 --- /dev/null +++ b/src/shared/lib/__tests__/perf.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it, vi } from "vitest" +import { markPerf, withPerf, withPerfSync } from "../perf" + +describe("withPerf", () => { + it("logs duration and returns value on success", async () => { + const debugSpy = vi.spyOn(console, "debug").mockImplementation(() => {}) + + // Ensure perf logs are enabled for this test even if CI disables them via env + const g = globalThis as unknown as { __fm_perfEnabled?: boolean } + const oldGlobal = g.__fm_perfEnabled + g.__fm_perfEnabled = true + + try { + const result = await withPerf("test", { a: 1 }, async () => { + await new Promise((r) => setTimeout(r, 10)) + return 42 + }) + expect(result).toBe(42) + expect(debugSpy).toHaveBeenCalled() + } finally { + if (oldGlobal === undefined) delete g.__fm_perfEnabled + else g.__fm_perfEnabled = oldGlobal + debugSpy.mockRestore() + } + }) + + it("logs duration and error on failure", async () => { + const debugSpy = vi.spyOn(console, "debug").mockImplementation(() => {}) + + // Ensure perf logs are enabled for this test even if CI disables them via env + const g = globalThis as unknown as { __fm_perfEnabled?: boolean } + const oldGlobal = g.__fm_perfEnabled + g.__fm_perfEnabled = true + + try { + await expect( + withPerf("test-err", null, async () => { + await new Promise((r) => setTimeout(r, 5)) + throw new Error("boom") + }), + ).rejects.toThrow("boom") + expect(debugSpy).toHaveBeenCalled() + } finally { + if (oldGlobal === undefined) delete g.__fm_perfEnabled + else g.__fm_perfEnabled = oldGlobal + debugSpy.mockRestore() + } + }) + + it("does not log when disabled via env", async () => { + const debugSpy = vi.spyOn(console, "debug").mockImplementation(() => {}) + const proc = ( + globalThis as unknown as { process?: { env?: Record } } + ).process + const old = proc?.env?.USE_PERF_LOGS + if (proc) { + proc.env = proc.env ?? {} + proc.env.USE_PERF_LOGS = "false" + } + try { + const v = await withPerf("disabled", {}, async () => 1) + expect(v).toBe(1) + expect(debugSpy).not.toHaveBeenCalled() + + const s = withPerfSync("disabled-sync", {}, () => 2) + expect(s).toBe(2) + expect(debugSpy).not.toHaveBeenCalled() + + markPerf("noop", {}) + expect(debugSpy).not.toHaveBeenCalled() + } finally { + if (proc?.env) { + if (old === undefined) delete proc.env.USE_PERF_LOGS + else proc.env.USE_PERF_LOGS = old + } + debugSpy.mockRestore() + } + }) +}) diff --git a/src/shared/lib/devLogger.ts b/src/shared/lib/devLogger.ts new file mode 100644 index 0000000..c36f130 --- /dev/null +++ b/src/shared/lib/devLogger.ts @@ -0,0 +1,64 @@ +import type { FileEntry } from "@/shared/api/tauri" +import { isPerfEnabled } from "./perf" + +export function setPerfLog(partial: Record) { + if (!isPerfEnabled()) return + try { + ;(globalThis as unknown as { __fm_perfLog?: Record }).__fm_perfLog = { + ...((globalThis as unknown as { __fm_perfLog?: Record }).__fm_perfLog ?? {}), + ...partial, + } + } catch { + /* ignore */ + } +} + +export function getPerfLog(): Record | undefined { + try { + return (globalThis as unknown as { __fm_perfLog?: Record }).__fm_perfLog + } catch { + return undefined + } +} + +export function setLastFiles(files: FileEntry[] | undefined) { + if (!isPerfEnabled()) return + try { + ;(globalThis as unknown as { __fm_lastFiles?: FileEntry[] | undefined }).__fm_lastFiles = files + } catch { + /* ignore */ + } +} + +export function getLastFiles(): FileEntry[] | undefined { + try { + return (globalThis as unknown as { __fm_lastFiles?: FileEntry[] | undefined }).__fm_lastFiles + } catch { + return undefined + } +} + +export function setLastNav(nav: { id: string; path: string; t: number } | undefined) { + if (!isPerfEnabled()) return + try { + ;( + globalThis as unknown as { + __fm_lastNav?: { id: string; path: string; t: number } | undefined + } + ).__fm_lastNav = nav + } catch { + /* ignore */ + } +} + +export function getLastNav(): { id: string; path: string; t: number } | undefined { + try { + return ( + globalThis as unknown as { + __fm_lastNav?: { id: string; path: string; t: number } | undefined + } + ).__fm_lastNav + } catch { + return undefined + } +} diff --git a/src/shared/lib/format-date.ts b/src/shared/lib/format-date.ts index 27f644f..da9d419 100644 --- a/src/shared/lib/format-date.ts +++ b/src/shared/lib/format-date.ts @@ -30,3 +30,27 @@ export function formatRelativeDate(timestamp: number | null): string { return formatDate(timestamp) } + +// Strict relative formatter: always returns a relative description (no absolute fallback) +export function formatRelativeStrict(timestamp: number | null): string { + if (!timestamp) return "—" + + const now = Date.now() + const date = timestamp * 1000 + const diff = now - date + + const minute = 60 * 1000 + const hour = 60 * minute + const day = 24 * hour + const week = 7 * day + const month = 30 * day + const year = 365 * day + + if (diff < minute) return "только что" + if (diff < hour) return `${Math.floor(diff / minute)} мин. назад` + if (diff < day) return `${Math.floor(diff / hour)} ч. назад` + if (diff < week) return `${Math.floor(diff / day)} дн. назад` + if (diff < month) return `${Math.floor(diff / week)} нед. назад` + if (diff < year) return `${Math.floor(diff / month)} мес. назад` + return `${Math.floor(diff / year)} г. назад` +} diff --git a/src/shared/lib/index.ts b/src/shared/lib/index.ts index e336084..a22720b 100644 --- a/src/shared/lib/index.ts +++ b/src/shared/lib/index.ts @@ -10,5 +10,5 @@ export { } from "./drag-drop" export { type FileType, getBasename, getExtension, getFileType, joinPath } from "./file-utils" export { formatBytes } from "./format-bytes" -export { formatDate, formatRelativeDate } from "./format-date" +export { formatDate, formatRelativeDate, formatRelativeStrict } from "./format-date" export { canShowThumbnail, getLocalImageUrl, THUMBNAIL_EXTENSIONS } from "./image-utils" diff --git a/src/shared/lib/perf.ts b/src/shared/lib/perf.ts new file mode 100644 index 0000000..7666e0c --- /dev/null +++ b/src/shared/lib/perf.ts @@ -0,0 +1,97 @@ +export type PerfPayload = Record + +function parseBoolLike(value: unknown): boolean | undefined { + if (value === undefined || value === null) return undefined + const s = String(value).toLowerCase() + if (s === "false" || s === "0" || s === "off" || s === "no") return false + if (s === "true" || s === "1" || s === "on" || s === "yes") return true + return undefined +} + +export function isPerfEnabled(): boolean { + try { + // global override (runtime toggle) + const globalPerf = (globalThis as unknown as { __fm_perfEnabled?: boolean }).__fm_perfEnabled + if (globalPerf !== undefined) return Boolean(globalPerf) + + // Vite env on the client: import.meta.env.VITE_USE_PERF_LOGS + const metaEnv = + typeof import.meta !== "undefined" + ? (import.meta as unknown as { env?: Record }).env + : undefined + const v = metaEnv?.VITE_USE_PERF_LOGS + const parsedMeta = parseBoolLike(v) + if (parsedMeta !== undefined) return parsedMeta + + // Node env used in tests/CI + const proc = ( + globalThis as unknown as { process?: { env?: Record } } + ).process + const p = proc?.env?.USE_PERF_LOGS + const parsedProc = parseBoolLike(p) + if (parsedProc !== undefined) return parsedProc + } catch { + // fallthrough + } + return true +} + +export function withPerf( + label: string, + payload: PerfPayload | null, + fn: () => Promise, +): Promise { + if (!isPerfEnabled()) return fn() + const start = performance.now() + return fn() + .then((result) => { + const duration = performance.now() - start + try { + console.debug(`[perf] ${label}`, { ...(payload ?? {}), duration }) + } catch { + /* ignore */ + } + return result + }) + .catch((err) => { + const duration = performance.now() - start + try { + console.debug(`[perf] ${label}`, { ...(payload ?? {}), duration, error: String(err) }) + } catch { + /* ignore */ + } + throw err + }) +} + +export function withPerfSync(label: string, payload: PerfPayload | null, fn: () => T): T { + if (!isPerfEnabled()) return fn() + const start = performance.now() + try { + const result = fn() + const duration = performance.now() - start + try { + console.debug(`[perf] ${label}`, { ...(payload ?? {}), duration }) + } catch { + /* ignore */ + } + return result + } catch (err) { + const duration = performance.now() - start + try { + console.debug(`[perf] ${label}`, { ...(payload ?? {}), duration, error: String(err) }) + } catch { + /* ignore */ + } + throw err + } +} + +export function markPerf(label: string, payload: PerfPayload | null) { + if (!isPerfEnabled()) return + try { + console.debug(`[perf] ${label}`, payload ?? {}) + } catch { + /* ignore */ + } +} diff --git a/src/shared/ui/button/index.tsx b/src/shared/ui/button/index.tsx index 6f4bd8c..e38efed 100644 --- a/src/shared/ui/button/index.tsx +++ b/src/shared/ui/button/index.tsx @@ -11,7 +11,7 @@ const Button = React.forwardRef( return ( - )} - - {showThumbnail ? ( + {/* Thumbnail or Icon */} +
- ) : ( - - )} + {/* Quick Look button on hover */} + {onQuickLook && ( + + )}{" "} +
- - {file.name} - - {!file.is_dir && ( - {formatBytes(file.size)} - )} + {/* Name */} + {displayName}
) }) diff --git a/src/widgets/file-explorer/ui/VirtualFileList.tsx b/src/widgets/file-explorer/ui/VirtualFileList.tsx index 65f1778..75f8c72 100644 --- a/src/widgets/file-explorer/ui/VirtualFileList.tsx +++ b/src/widgets/file-explorer/ui/VirtualFileList.tsx @@ -7,8 +7,12 @@ import { useInlineEditStore } from "@/features/inline-edit" import { useKeyboardNavigation } from "@/features/keyboard-navigation" import { useLayoutStore } from "@/features/layout" import { RubberBandOverlay } from "@/features/rubber-band" +import { useAppearanceSettings, useFileDisplaySettings } from "@/features/settings" +import { useSortingStore } from "@/features/sorting" import type { FileEntry } from "@/shared/api/tauri" -import { cn, getBasename } from "@/shared/lib" +import { cn } from "@/shared/lib" +import { setPerfLog } from "@/shared/lib/devLogger" +import { withPerfSync } from "@/shared/lib/perf" interface VirtualFileListProps { files: FileEntry[] @@ -51,77 +55,100 @@ export function VirtualFileList({ className, }: VirtualFileListProps) { const parentRef = useRef(null) - const { mode, targetPath, parentPath } = useInlineEditStore() - const { layout, setColumnWidth } = useLayoutStore() - const { columnWidths } = layout + const { mode, targetPath } = useInlineEditStore() + const columnWidths = useLayoutStore((s) => s.layout.columnWidths) + const setColumnWidth = useLayoutStore((s) => s.setColumnWidth) - // Get clipboard state for cut indication - const clipboardPaths = useClipboardStore((s) => s.paths) - const isCutMode = useClipboardStore((s) => s.isCut()) - const cutPathsSet = useMemo( - () => (isCutMode ? new Set(clipboardPaths) : new Set()), - [clipboardPaths, isCutMode], - ) + const displaySettings = useFileDisplaySettings() + const appearance = useAppearanceSettings() + const { sortConfig, setSortField } = useSortingStore() + + const cutPaths = useClipboardStore((s) => s.paths) + const isCut = useClipboardStore((s) => s.isCut) // Get bookmarks state const isBookmarked = useBookmarksStore((s) => s.isBookmarked) const addBookmark = useBookmarksStore((s) => s.addBookmark) const removeBookmark = useBookmarksStore((s) => s.removeBookmark) const getBookmarkByPath = useBookmarksStore((s) => s.getBookmarkByPath) + const inlineCancel = useInlineEditStore((s) => s.cancel) + const startRename = useInlineEditStore((s) => s.startRename) + + const safeSelectedPaths = useMemo(() => { + return selectedPaths instanceof Set ? selectedPaths : new Set() + }, [selectedPaths]) - // Find index where inline edit row should appear const inlineEditIndex = useMemo(() => { - if (!mode) return -1 if (mode === "rename" && targetPath) { return files.findIndex((f) => f.path === targetPath) } - if ((mode === "new-folder" || mode === "new-file") && parentPath) { + if (mode === "new-folder" || mode === "new-file") { // Insert after last folder for new items - const lastFolderIdx = findLastIndex(files, (f) => f.is_dir) - return lastFolderIdx + 1 + const lastFolderIndex = findLastIndex(files, (f) => f.is_dir) + return lastFolderIndex + 1 } - return 0 - }, [mode, targetPath, parentPath, files]) + return -1 + }, [mode, targetPath, files]) // Calculate total rows const totalRows = files.length + (mode && mode !== "rename" ? 1 : 0) // Virtualizer - const virtualizer = useVirtualizer({ + const rowVirtualizer = useVirtualizer({ count: totalRows, getScrollElement: () => parentRef.current, estimateSize: () => 32, overscan: 10, }) - // Keyboard navigation + useEffect(() => { + try { + withPerfSync("virtualizer", { totalRows, overscan: 10 }, () => { + const now = Date.now() + setPerfLog({ virtualizer: { totalRows, overscan: 10, ts: now } }) + }) + } catch { + /* ignore */ + } + }, [totalRows]) + const { focusedIndex } = useKeyboardNavigation({ files, - selectedPaths, - onSelect: (path, e) => onSelect(path, e as unknown as React.MouseEvent), - onOpen: (path, isDir) => onOpen(path, isDir), - enabled: !mode, + selectedPaths: safeSelectedPaths, + onSelect: (path, e) => { + onSelect(path, e as unknown as React.MouseEvent) + }, + onOpen, + enabled: !mode, // Disable when editing }) - // Scroll to inline edit row useEffect(() => { - if (mode && inlineEditIndex >= 0) { - virtualizer.scrollToIndex(inlineEditIndex, { align: "center" }) + if (inlineEditIndex >= 0) { + rowVirtualizer.scrollToIndex(inlineEditIndex, { align: "center" }) } - }, [mode, inlineEditIndex, virtualizer]) + }, [inlineEditIndex, rowVirtualizer]) // Memoize handlers const handleSelect = useCallback( - (path: string) => (e: React.MouseEvent) => onSelect(path, e), + (path: string) => (e: React.MouseEvent) => { + onSelect(path, e) + }, [onSelect], ) const handleOpen = useCallback( - (path: string, isDir: boolean) => () => onOpen(path, isDir), + (path: string, isDir: boolean) => () => { + onOpen(path, isDir) + }, [onOpen], ) - const handleQuickLook = useCallback((file: FileEntry) => () => onQuickLook?.(file), [onQuickLook]) + const handleQuickLook = useCallback( + (file: FileEntry) => () => { + onQuickLook?.(file) + }, + [onQuickLook], + ) const handleToggleBookmark = useCallback( (path: string) => () => { @@ -132,83 +159,89 @@ export function VirtualFileList({ addBookmark(path) } }, - [isBookmarked, getBookmarkByPath, removeBookmark, addBookmark], + [isBookmarked, addBookmark, removeBookmark, getBookmarkByPath], ) // Memoize file path getter - const memoizedGetSelectedPaths = useCallback(() => { - return getSelectedPaths?.() ?? Array.from(selectedPaths) - }, [getSelectedPaths, selectedPaths]) + const handleGetSelectedPaths = useCallback(() => { + return getSelectedPaths?.() ?? Array.from(safeSelectedPaths) + }, [getSelectedPaths, safeSelectedPaths]) // Helper to get path from element const getPathFromElement = useCallback((element: Element): string | null => { return element.getAttribute("data-path") }, []) - const handleColumnResize = useCallback( - (column: "size" | "date" | "padding", width: number) => { - setColumnWidth(column, width) - }, - [setColumnWidth], - ) - - const handleInlineConfirm = useCallback( - (name: string) => { - if (mode === "new-folder") { - onCreateFolder?.(name) - } else if (mode === "new-file") { - onCreateFile?.(name) - } else if (mode === "rename" && targetPath) { - onRename?.(targetPath, name) - } - }, - [mode, targetPath, onCreateFolder, onCreateFile, onRename], - ) - - const handleInlineCancel = useCallback(() => { - useInlineEditStore.getState().cancel() - }, []) - return (
- {/* Column Header */} { + setColumnWidth(column, width) + }} + sortConfig={sortConfig} + onSort={setSortField} + displaySettings={displaySettings} className="shrink-0" /> - {/* Scrollable content */} -
-
- {virtualizer.getVirtualItems().map((virtualRow) => { - // Check if this is the inline edit row position - const isInlineEditRow = - mode && mode !== "rename" && virtualRow.index === inlineEditIndex +
+
+ {/* If the virtualizer fails to include the inline edit row in its visible items + (for example in tests when scrollToIndex can't complete), render a fallback + absolute InlineEditRow at the computed position so rename mode always works. */} + {mode === "rename" && + inlineEditIndex >= 0 && + !rowVirtualizer.getVirtualItems().some((v) => v.index === inlineEditIndex) && + (() => { + const file = files[inlineEditIndex] + if (!file) return null + const top = inlineEditIndex * 32 + return ( +
+ { + onRename?.(file.path, newName) + inlineCancel() + }} + onCancel={() => inlineCancel()} + columnWidths={columnWidths} + /> +
+ ) + })()} + + {rowVirtualizer.getVirtualItems().map((virtualRow) => { + const rowIndex = virtualRow.index - if (isInlineEditRow) { + if (mode && mode !== "rename" && rowIndex === inlineEditIndex) { return (
{ + if (mode === "new-folder") onCreateFolder?.(name) + else if (mode === "new-file") onCreateFile?.(name) + }} + onCancel={() => inlineCancel()} columnWidths={columnWidths} />
@@ -217,9 +250,7 @@ export function VirtualFileList({ // Get actual file index const fileIndex = - mode && mode !== "rename" && virtualRow.index > inlineEditIndex - ? virtualRow.index - 1 - : virtualRow.index + mode && mode !== "rename" && rowIndex > inlineEditIndex ? rowIndex - 1 : rowIndex const file = files[fileIndex] if (!file) return null @@ -229,55 +260,53 @@ export function VirtualFileList({ return (
onRename?.(file.path, newName)} + onCancel={() => inlineCancel()} columnWidths={columnWidths} />
) } + const isFileCut = isCut() && cutPaths.includes(file.path) + return (
useInlineEditStore.getState().startRename(file.path)} + onRename={() => startRename(file.path)} onDelete={onDelete} onQuickLook={onQuickLook ? handleQuickLook(file) : undefined} onToggleBookmark={handleToggleBookmark(file.path)} columnWidths={columnWidths} + displaySettings={displaySettings} + appearance={appearance} />
) diff --git a/src/widgets/file-explorer/ui/types.ts b/src/widgets/file-explorer/ui/types.ts new file mode 100644 index 0000000..56ed2c3 --- /dev/null +++ b/src/widgets/file-explorer/ui/types.ts @@ -0,0 +1,42 @@ +import type { SortConfig } from "@/entities/file-entry" +import type { ColumnWidths } from "@/features/layout" +import type { AppearanceSettings, FileDisplaySettings } from "@/features/settings" +import type { ViewMode } from "@/features/view-mode" +import type { FileEntry } from "@/shared/api/tauri" + +export type FileExplorerHandlers = { + handleSelect: (path: string, e: React.MouseEvent) => void + handleOpen: (path: string, isDir: boolean) => void + handleDrop: (sources: string[], destination: string) => void + handleCreateFolder: (name: string) => void + handleCreateFile: (name: string) => void + handleRename: (oldPath: string, newName: string) => void + handleCopy: () => void + handleCut: () => void + handlePaste: () => void + handleDelete: () => void + handleStartNewFolder: () => void + handleStartNewFile: () => void + handleStartRenameAt: (path: string) => void +} + +export interface FileExplorerViewProps { + className?: string + isLoading: boolean + files: FileEntry[] + processedFilesCount: number + selectedPaths: Set + onQuickLook?: (file: FileEntry) => void + handlers: FileExplorerHandlers + viewMode: ViewMode + showColumnHeadersInSimpleList: boolean + columnWidths: ColumnWidths + setColumnWidth: (column: keyof ColumnWidths, width: number) => void + performanceThreshold: number + // New props: settings and sorting provided by container + displaySettings?: FileDisplaySettings + appearance?: AppearanceSettings + performanceSettings?: { lazyLoadImages: boolean; thumbnailCacheSize: number } + sortConfig?: SortConfig + onSort?: (field: SortConfig["field"]) => void +} diff --git a/src/widgets/file-explorer/ui/useFileExplorer.ts b/src/widgets/file-explorer/ui/useFileExplorer.ts new file mode 100644 index 0000000..f5c039f --- /dev/null +++ b/src/widgets/file-explorer/ui/useFileExplorer.ts @@ -0,0 +1,36 @@ +import { useMemo } from "react" +import type { AppearanceSettings, FileDisplaySettings } from "@/features/settings" + +export function useFileExplorer({ + displaySettings, + appearance, +}: { + displaySettings?: FileDisplaySettings + appearance?: AppearanceSettings +}) { + const display = useMemo(() => { + return ( + displaySettings ?? + ({ + showFileExtensions: true, + showFileSizes: true, + showFileDates: true, + showHiddenFiles: false, + dateFormat: "relative", + thumbnailSize: "medium", + } as FileDisplaySettings) + ) + }, [displaySettings]) + + const appearanceLocal = useMemo(() => { + return (appearance ?? { + theme: "system", + fontSize: "medium", + accentColor: "#0078d4", + enableAnimations: true, + reducedMotion: false, + }) as AppearanceSettings + }, [appearance]) + + return { display, appearanceLocal } +} diff --git a/src/widgets/preview-panel/__tests__/PreviewPanel.folder-click.test.tsx b/src/widgets/preview-panel/__tests__/PreviewPanel.folder-click.test.tsx new file mode 100644 index 0000000..5cdf5c1 --- /dev/null +++ b/src/widgets/preview-panel/__tests__/PreviewPanel.folder-click.test.tsx @@ -0,0 +1,170 @@ +/// +import { fireEvent, render, screen, waitFor } from "@testing-library/react" +import { describe, expect, it, vi } from "vitest" +import type { FileEntry, FilePreview } from "@/shared/api/tauri" +import { tauriClient } from "@/shared/api/tauri/client" +import { PreviewPanel } from "../ui/PreviewPanel" + +const folder: FileEntry = { + path: "/test/folder", + name: "folder", + is_dir: true, + is_hidden: false, + size: 0, + modified: Date.now(), + created: Date.now(), + extension: null, +} + +const fileEntry = (name: string, path: string): FileEntry => ({ + path, + name, + is_dir: false, + is_hidden: false, + size: 123, + modified: Date.now(), + created: Date.now(), + extension: "txt", +}) + +const dirEntry = (name: string, path: string): FileEntry => ({ + path, + name, + is_dir: true, + is_hidden: false, + size: 0, + modified: Date.now(), + created: Date.now(), + extension: null, +}) + +describe("FolderPreview interactions", () => { + it("shows folder contents when clicked and allows drilling into subfolders", async () => { + const readSpy = vi.spyOn(tauriClient, "readDirectory") + readSpy.mockImplementation(async (path: string) => { + if (path === "/test/folder") + return [ + fileEntry("file1.txt", "/test/folder/file1.txt"), + dirEntry("sub", "/test/folder/sub"), + ] + if (path === "/test/folder/sub") return [fileEntry("file2.txt", "/test/folder/sub/file2.txt")] + return [] + }) + + render() + + // folder name is visible + const names = screen.getAllByText("folder") + expect(names.length).toBeGreaterThan(0) + + // wait for entries to load + await waitFor(() => expect(readSpy).toHaveBeenCalledWith("/test/folder")) + + // file and subfolder should appear + expect(await screen.findByText("file1.txt")).toBeTruthy() + expect(await screen.findByText("sub")).toBeTruthy() + + // click subfolder to drill in + fireEvent.click(screen.getByText("sub")) + await waitFor(() => expect(readSpy).toHaveBeenCalledWith("/test/folder/sub")) + + expect(await screen.findByText("file2.txt")).toBeTruthy() + }) + + it("truncates long folder name in header and preserves full name in title", async () => { + const longName = `${"a".repeat(120)}` + const folderWithLongName: FileEntry = { ...folder, name: longName } + const readSpy = vi.spyOn(tauriClient, "readDirectory").mockResolvedValue([]) + + render() + + await waitFor(() => expect(readSpy).toHaveBeenCalledWith(folderWithLongName.path)) + + const header = (await screen.findByRole("heading", { level: 4 })) as HTMLElement // h4 + // displayed text is truncated to MAX_DISPLAY_NAME chars + ellipsis in both folder header and main panel header + const expectedDisplay = `${longName.slice(0, 24)}…` + expect(header.textContent).toBe(expectedDisplay) + expect(header.getAttribute("title")).toBe(longName) + + const mainHeader = (await screen.findByRole("heading", { level: 3 })) as HTMLElement // h3 + expect(mainHeader.textContent).toBe(expectedDisplay) + expect(mainHeader.getAttribute("title")).toBe(longName) + }) + + it("truncates long file names in folder list and sets title + single-line classes", async () => { + const longName = `${"b".repeat(120)}.txt` + const longFile = fileEntry(longName, `/test/folder/${longName}`) + const readSpy = vi.spyOn(tauriClient, "readDirectory").mockResolvedValue([longFile]) + + render() + + await waitFor(() => expect(readSpy).toHaveBeenCalledWith(folder.path)) + + const span = await screen.findByText(longFile.name) + expect(span).toBeTruthy() + expect(span.getAttribute("title")).toBe(longName) + expect(span.classList.contains("truncate")).toBeTruthy() + expect(span.classList.contains("whitespace-nowrap")).toBeTruthy() + + const li = span.closest("li") as HTMLElement + expect(li).toBeTruthy() + expect(li.className.includes("min-w-0")).toBeTruthy() + }) + + it("shows image thumbnails for image files in a folder", async () => { + const imageFile = fileEntry("img.png", "/test/folder/img.png") + // ensure extension is png + imageFile.extension = "png" + + const readSpy = vi.spyOn(tauriClient, "readDirectory").mockResolvedValue([imageFile]) + + type Thumbnail = Awaited> + const thumb: Thumbnail = { base64: "c21hbGw=", mime: "image/png" } + const thumbSpy = vi.spyOn(tauriClient, "getThumbnail").mockResolvedValue(thumb) + + render() + + await waitFor(() => expect(readSpy).toHaveBeenCalledWith("/test/folder")) + + const fileBtn = await screen.findByText("img.png") + // the file button contains the thumbnail img + const parent = fileBtn.closest("button") || fileBtn.parentElement + + await waitFor(() => { + const img = parent?.querySelector("img") + expect(img).toBeTruthy() + expect((img as HTMLImageElement).src).toContain("data:image/png;base64,c21hbGw=") + }) + + // open-file button exists on hover; invoke directly + const openFileBtn = await screen.findByTestId("open-file") + fireEvent.click(openFileBtn) + const opener = await import("@tauri-apps/plugin-opener") + expect(opener.openPath).toHaveBeenCalledWith("/test/folder/img.png") + + thumbSpy.mockRestore() + }) + + it("opens clicked file in the main preview", async () => { + const txtFile = fileEntry("readme.txt", "/test/folder/readme.txt") + txtFile.extension = "txt" + + const readSpy = vi.spyOn(tauriClient, "readDirectory").mockResolvedValue([txtFile]) + + const preview: FilePreview = { type: "Text", content: "hello world", truncated: false } + const pSpy = vi.spyOn(tauriClient, "getFilePreview").mockResolvedValue(preview) + + render() + + await waitFor(() => expect(readSpy).toHaveBeenCalledWith("/test/folder")) + + // click the filename to open preview + const btn = await screen.findByText("readme.txt") + fireEvent.click(btn) + + // preview content should be displayed + await waitFor(() => expect(screen.getByText("hello world")).toBeTruthy()) + + pSpy.mockRestore() + }) +}) diff --git a/src/widgets/preview-panel/__tests__/PreviewPanel.image-viewer.test.tsx b/src/widgets/preview-panel/__tests__/PreviewPanel.image-viewer.test.tsx new file mode 100644 index 0000000..f52eb2e --- /dev/null +++ b/src/widgets/preview-panel/__tests__/PreviewPanel.image-viewer.test.tsx @@ -0,0 +1,62 @@ +/// +import { fireEvent, render, screen, waitFor } from "@testing-library/react" +import { describe, expect, it, vi } from "vitest" +import type { FileEntry, FilePreview } from "@/shared/api/tauri" +import { tauriClient } from "@/shared/api/tauri/client" +import { PreviewPanel } from "../ui/PreviewPanel" + +const file: FileEntry = { + path: "/img.png", + name: "img.png", + is_dir: false, + is_hidden: false, + size: 10, + modified: Date.now(), + created: Date.now(), + extension: "png", +} + +const base64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEX///+nxBvIAAAAAXRSTlMAQObYZgAAAApJREFUCNdjYAAAAAIAAeIhvDMAAAAASUVORK5CYII=" + +const preview = { type: "Image", mime: "image/png", base64 } + +describe("ImageViewer basics", () => { + it("renders and supports zoom-in", async () => { + const spy = vi + .spyOn(tauriClient, "getFilePreview") + .mockResolvedValue(preview as unknown as FilePreview) + + render() + + const img = await screen.findByAltText("img.png") + expect(img).toBeTruthy() + + const zoomIn = screen.getByTestId("zoom-in") + fireEvent.click(zoomIn) + + await waitFor(() => { + expect(img.style.transform).toContain("scale(1.25)") + }) + + spy.mockRestore() + }) + + it("close button calls onClose", async () => { + const spy = vi + .spyOn(tauriClient, "getFilePreview") + .mockResolvedValue(preview as unknown as FilePreview) + + const onClose = vi.fn() + render() + + const closeBtn = await screen.findByTestId("close") + fireEvent.click(closeBtn) + + await waitFor(() => { + expect(onClose).toHaveBeenCalled() + }) + + spy.mockRestore() + }) +}) diff --git a/src/widgets/preview-panel/lib/index.ts b/src/widgets/preview-panel/lib/index.ts new file mode 100644 index 0000000..aae0369 --- /dev/null +++ b/src/widgets/preview-panel/lib/index.ts @@ -0,0 +1,2 @@ +export * from "./useFolderPreview" +export * from "./usePreviewPanel" diff --git a/src/widgets/preview-panel/lib/useFolderPreview.ts b/src/widgets/preview-panel/lib/useFolderPreview.ts new file mode 100644 index 0000000..f110eca --- /dev/null +++ b/src/widgets/preview-panel/lib/useFolderPreview.ts @@ -0,0 +1,100 @@ +import { openPath } from "@tauri-apps/plugin-opener" +import { useEffect, useState } from "react" +import type { FileEntry, FilePreview } from "@/shared/api/tauri" +import { tauriClient } from "@/shared/api/tauri/client" +export type UseFolderPreviewReturn = { + entries: FileEntry[] | null + isLoadingEntries: boolean + error: string | null + pathStack: string[] + currentPath: string + handleToggleUp: () => void + handleEnterFolder: (entry: FileEntry) => void + handleShowFile: (entry: FileEntry) => Promise + handleOpenExternal: (path: string) => Promise +} + +export function useFolderPreview( + root: FileEntry | null, + opts?: { + onOpenFile?: (entry: FileEntry, preview: FilePreview) => void + onOpenFolder?: (entry: FileEntry) => void + }, +) { + const [entries, setEntries] = useState(null) + const [isLoadingEntries, setIsLoadingEntries] = useState(false) + const [error, setError] = useState(null) + + const [pathStack, setPathStack] = useState(root ? [root.path] : []) + + const currentPath = pathStack[pathStack.length - 1] + + useEffect(() => { + if (!root) return + setPathStack([root.path]) + setEntries(null) + setError(null) + }, [root]) + + useEffect(() => { + if (!currentPath) return + let cancelled = false + const load = async () => { + setIsLoadingEntries(true) + setError(null) + try { + const dir = await tauriClient.readDirectory(currentPath) + if (!cancelled) setEntries(dir) + } catch (err) { + if (!cancelled) setError(String(err)) + } finally { + if (!cancelled) setIsLoadingEntries(false) + } + } + load() + return () => { + cancelled = true + } + }, [currentPath]) + + const handleToggleUp = async () => { + if (pathStack.length > 1) setPathStack((s) => s.slice(0, s.length - 1)) + } + + const handleEnterFolder = (entry: FileEntry) => { + if (!entry.is_dir) return + if (opts?.onOpenFolder) return opts.onOpenFolder(entry) + setPathStack((s) => [...s, entry.path]) + } + + const handleShowFile = async (entry: FileEntry) => { + if (entry.is_dir) return + try { + setIsLoadingEntries(true) + const preview = await tauriClient.getFilePreview(entry.path) + if (opts?.onOpenFile) return opts.onOpenFile(entry, preview) + // attach preview inline + setEntries([{ ...entry, _preview: preview } as unknown as FileEntry]) + } catch (err) { + setError(String(err)) + } finally { + setIsLoadingEntries(false) + } + } + + const handleOpenExternal = async (path: string) => { + await openPath(path) + } + + return { + entries, + isLoadingEntries, + error, + pathStack, + currentPath, + handleToggleUp, + handleEnterFolder, + handleShowFile, + handleOpenExternal, + } as UseFolderPreviewReturn +} diff --git a/src/widgets/preview-panel/lib/usePreviewPanel.ts b/src/widgets/preview-panel/lib/usePreviewPanel.ts new file mode 100644 index 0000000..5d17c3b --- /dev/null +++ b/src/widgets/preview-panel/lib/usePreviewPanel.ts @@ -0,0 +1,123 @@ +import { useEffect, useState } from "react" +import type { FileEntry, FilePreview } from "@/shared/api/tauri" +import { tauriClient } from "@/shared/api/tauri/client" + +export function usePreviewPanel(file: FileEntry | null) { + const [preview, setPreview] = useState(null) + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + + // Local resolved metadata for the file (FileEntry with all fields) + const [fileEntry, setFileEntry] = useState(null) + + const openFilePreview = (entry: FileEntry, p: FilePreview) => { + setPreview(p) + setFileEntry(entry) + } + + const openFolderPreview = (entry: FileEntry) => { + setPreview(null) + setFileEntry(entry) + } + + useEffect(() => { + let cancelled = false + + const resolveMetadata = async () => { + setFileEntry(null) + + if (!file) return + + if (file.name != null || file.size != null || file.modified != null || file.created != null) { + setFileEntry(file) + return + } + + try { + const parentPath = await tauriClient.getParentPath(file.path) + if (parentPath) { + const dir = await tauriClient.readDirectory(parentPath) + const found = dir.find((f: FileEntry) => f.path === file.path) + if (!cancelled) { + if (found) setFileEntry(found) + else + setFileEntry({ + ...file, + name: file.path.split("\\").pop() || file.path, + size: 0, + is_dir: false, + is_hidden: false, + extension: null, + modified: null, + created: null, + }) + } + return + } + } catch { + // ignore + } + + if (!cancelled) + setFileEntry({ + ...file, + name: file.path.split("\\").pop() || file.path, + size: 0, + is_dir: false, + is_hidden: false, + extension: null, + modified: null, + created: null, + }) + } + + resolveMetadata() + + return () => { + cancelled = true + } + }, [file]) + + useEffect(() => { + let cancelled = false + + if (!fileEntry || fileEntry.is_dir) { + setPreview(null) + setError(null) + return + } + + const loadPreview = async () => { + setPreview(null) + setIsLoading(true) + setError(null) + + try { + const preview = await tauriClient.getFilePreview(fileEntry.path) + if (!cancelled) { + setPreview(preview) + } + } catch (err) { + if (!cancelled) setError(String(err)) + } finally { + if (!cancelled) setIsLoading(false) + } + } + + loadPreview() + + return () => { + cancelled = true + } + }, [fileEntry]) + + return { + preview, + isLoading, + error, + fileEntry, + setFileEntry, + openFilePreview, + openFolderPreview, + } +} diff --git a/src/widgets/preview-panel/ui/FileMetadata.tsx b/src/widgets/preview-panel/ui/FileMetadata.tsx new file mode 100644 index 0000000..b67be9f --- /dev/null +++ b/src/widgets/preview-panel/ui/FileMetadata.tsx @@ -0,0 +1,33 @@ +import type { FileEntry } from "@/shared/api/tauri" +import { cn, formatBytes, formatDate, getExtension } from "@/shared/lib" + +export default function FileMetadata({ file }: { file: FileEntry }) { + const extension = getExtension(file.name) + + return ( +
+ + {!file.is_dir && } + {file.modified && } + {file.created && } + +
+ ) +} + +function MetadataRow({ + label, + value, + className, +}: { + label: string + value: string + className?: string +}) { + return ( +
+ {label}: + {value} +
+ ) +} diff --git a/src/widgets/preview-panel/ui/FilePreviewContent.tsx b/src/widgets/preview-panel/ui/FilePreviewContent.tsx new file mode 100644 index 0000000..6cded9d --- /dev/null +++ b/src/widgets/preview-panel/ui/FilePreviewContent.tsx @@ -0,0 +1,42 @@ +import { FileText } from "lucide-react" +import type { FilePreview } from "@/shared/api/tauri" +import ImageViewer from "./ImageViewer" + +export default function FilePreviewContent({ + preview, + fileName, + filePath, + onClose, +}: { + preview: FilePreview + fileName: string + filePath: string + onClose?: () => void +}) { + if (preview.type === "Text") { + return ( +
+
+          {preview.content}
+          {preview.truncated && (
+            {"\n\n... (содержимое обрезано)"}
+          )}
+        
+
+ ) + } + + if (preview.type === "Image") { + return ( + + ) + } + + return ( +
+ +

Предпросмотр недоступен

+

{preview.mime}

+
+ ) +} diff --git a/src/widgets/preview-panel/ui/FolderPreview.tsx b/src/widgets/preview-panel/ui/FolderPreview.tsx new file mode 100644 index 0000000..3e73db8 --- /dev/null +++ b/src/widgets/preview-panel/ui/FolderPreview.tsx @@ -0,0 +1,116 @@ +import { File, Folder, Loader2 } from "lucide-react" +import { FileThumbnail } from "@/entities/file-entry/ui/FileThumbnail" +import type { FileEntry } from "@/shared/api/tauri" +import { getExtension } from "@/shared/lib" +import { Button } from "@/shared/ui" +import type { UseFolderPreviewReturn } from "../lib/useFolderPreview" + +export default function FolderPreview({ + file, + hook, +}: { + file: FileEntry + hook: UseFolderPreviewReturn +}) { + const { + entries, + currentPath, + isLoadingEntries, + error, + handleEnterFolder, + handleShowFile, + handleToggleUp, + handleOpenExternal, + pathStack, + } = hook + + const folderDisplayName = + file.name && file.name.length > 24 ? `${file.name.slice(0, 24)}…` : file.name + + return ( +
+
+
+ +
+
+

+ {folderDisplayName} +

+

{currentPath}

+
+
+ {pathStack.length > 1 && ( + + )} +
+
+ +
+ {isLoadingEntries ? ( +
+ +
+ ) : error ? ( +
{error}
+ ) : entries && entries.length > 0 ? ( +
    + {entries.map((entry: FileEntry) => ( +
  • +
    + +
    + {!entry.is_dir && ( +
    + +
    + )} +
  • + ))} +
+ ) : ( +
Пустая папка
+ )} +
+
+ ) +} diff --git a/src/widgets/preview-panel/ui/ImageViewer.tsx b/src/widgets/preview-panel/ui/ImageViewer.tsx new file mode 100644 index 0000000..90258c0 --- /dev/null +++ b/src/widgets/preview-panel/ui/ImageViewer.tsx @@ -0,0 +1,129 @@ +import { openPath } from "@tauri-apps/plugin-opener" +import { File, RefreshCw, RotateCw, X, ZoomIn, ZoomOut } from "lucide-react" +import { useRef, useState } from "react" +import { toast } from "@/shared/ui" + +type ImagePreview = { + type: "Image" + mime: string + base64: string +} + +export default function ImageViewer({ + preview, + fileName, + filePath, + onClose, +}: { + preview: ImagePreview + fileName: string + filePath: string + onClose?: () => void +}) { + const [scale, setScale] = useState(1) + const [rotate, setRotate] = useState(0) + const imgRef = useRef(null) + const containerRef = useRef(null) + + const zoomIn = () => setScale((s) => Math.round(s * 1.25 * 100) / 100) + const zoomOut = () => setScale((s) => Math.round((s / 1.25) * 100) / 100) + const reset = () => { + setScale(1) + setRotate(0) + } + const rotateCW = () => setRotate((r) => (r + 90) % 360) + + const openFile = async () => { + if (!filePath) return + try { + await openPath(filePath) + } catch (err) { + toast.error(`Не удалось открыть файл: ${String(err)}`) + } + } + + return ( +
+
+
+ + + +
+
+
{fileName}
+
+
+ + + +
+
+ +
+ {fileName} +
+
+ ) +} diff --git a/src/widgets/preview-panel/ui/PreviewPanel.tsx b/src/widgets/preview-panel/ui/PreviewPanel.tsx index ee99fae..e54ee44 100644 --- a/src/widgets/preview-panel/ui/PreviewPanel.tsx +++ b/src/widgets/preview-panel/ui/PreviewPanel.tsx @@ -1,8 +1,11 @@ -import { FileQuestion, FileText, Image, Loader2, X } from "lucide-react" -import { useEffect, useState } from "react" -import { commands, type FileEntry, type FilePreview } from "@/shared/api/tauri" -import { cn, formatBytes, formatDate, getExtension } from "@/shared/lib" -import { Button, ScrollArea } from "@/shared/ui" +import { FileQuestion } from "lucide-react" +import type { FileEntry } from "@/shared/api/tauri" +import { cn, formatBytes } from "@/shared/lib" +import { useFolderPreview } from "../lib/useFolderPreview" +import { usePreviewPanel } from "../lib/usePreviewPanel" +import FileMetadata from "./FileMetadata" +import FilePreviewContent from "./FilePreviewContent" +import FolderPreview from "./FolderPreview" interface PreviewPanelProps { file: FileEntry | null @@ -11,115 +14,11 @@ interface PreviewPanelProps { } export function PreviewPanel({ file, onClose, className }: PreviewPanelProps) { - const [preview, setPreview] = useState(null) - const [isLoading, setIsLoading] = useState(false) - const [error, setError] = useState(null) + const { preview, isLoading, error, fileEntry, setFileEntry, openFilePreview } = + usePreviewPanel(file) - // Local resolved metadata for the file (FileEntry with all fields) - const [fileEntry, setFileEntry] = useState(null) - - // Resolve missing metadata (name/size) when only path is provided - useEffect(() => { - let cancelled = false - - const resolveMetadata = async () => { - // Reset file entry immediately to avoid showing stale preview - setFileEntry(null) - - if (!file) { - return - } - - // If full entry provided, use as-is - if (file.name != null || file.size != null || file.modified != null || file.created != null) { - setFileEntry(file) - return - } - - try { - const parent = await commands.getParentPath(file.path) - if (parent.status === "ok" && parent.data) { - const dir = await commands.readDirectory(parent.data) - if (dir.status === "ok") { - const found = dir.data.find((f: FileEntry) => f.path === file.path) - if (!cancelled) { - if (found) setFileEntry(found) - else - setFileEntry({ - ...file, - name: file.path.split("\\").pop() || file.path, - size: 0, - is_dir: false, - is_hidden: false, - extension: null, - modified: null, - created: null, - }) - } - return - } - } - } catch { - // ignore - } - - if (!cancelled) - setFileEntry({ - ...file, - name: file.path.split("\\").pop() || file.path, - size: 0, - is_dir: false, - is_hidden: false, - extension: null, - modified: null, - created: null, - }) - } - - resolveMetadata() - - return () => { - cancelled = true - } - }, [file]) - - useEffect(() => { - let cancelled = false - - if (!fileEntry || fileEntry.is_dir) { - setPreview(null) - setError(null) - return - } - - const loadPreview = async () => { - // Clear previous preview immediately - setPreview(null) - setIsLoading(true) - setError(null) - - try { - const result = await commands.getFilePreview(fileEntry.path) - if (!cancelled) { - if (result.status === "ok") { - setPreview(result.data) - } else { - setError(result.error) - } - } - } catch (err) { - if (!cancelled) setError(String(err)) - } finally { - if (!cancelled) setIsLoading(false) - } - } - - loadPreview() - - return () => { - cancelled = true - } - }, [fileEntry]) + // initialize folder hook (call at top-level to preserve hooks ordering) + const folderHook = useFolderPreview(file, { onOpenFile: openFilePreview }) if (!file) { return ( @@ -137,29 +36,38 @@ export function PreviewPanel({ file, onClose, className }: PreviewPanelProps) { const activeFile = fileEntry ?? file + const MAX_DISPLAY_NAME = 24 + const activeDisplayName = + activeFile?.name && activeFile.name.length > MAX_DISPLAY_NAME + ? `${activeFile.name.slice(0, MAX_DISPLAY_NAME)}…` + : (activeFile?.name ?? activeFile?.path) + + const handleClose = () => { + setFileEntry(null) + if (onClose) onClose() + } + return (
- {/* Header */}
-

{activeFile?.name ?? activeFile?.path}

+

+ {activeDisplayName} +

{activeFile?.is_dir ? "Папка" : formatBytes(activeFile?.size)} - {activeFile?.modified && ` • ${formatDate(activeFile.modified)}`} + {activeFile?.modified && ` • ${new Date(activeFile.modified).toLocaleString()}`}

- {onClose && ( - - )}
- {/* Content */}
{isLoading ? (
- +
) : error ? (
@@ -167,113 +75,18 @@ export function PreviewPanel({ file, onClose, className }: PreviewPanelProps) {

{error}

) : activeFile?.is_dir ? ( - + ) : preview ? ( - + ) : null}
- {/* Metadata */} {activeFile && }
) } - -function FilePreviewContent({ preview, fileName }: { preview: FilePreview; fileName: string }) { - if (preview.type === "Text") { - return ( - -
-          {preview.content}
-          {preview.truncated && (
-            {"\n\n... (содержимое обрезано)"}
-          )}
-        
-
- ) - } - - if (preview.type === "Image") { - return ( -
- {fileName} -
- ) - } - - // Unsupported - return ( -
- -

Предпросмотр недоступен

-

{preview.mime}

-
- ) -} - -function FolderPreview({ file }: { file: FileEntry }) { - const [itemCount, setItemCount] = useState(null) - - useEffect(() => { - const loadCount = async () => { - try { - const result = await commands.readDirectory(file.path) - if (result.status === "ok") { - setItemCount(result.data.length) - } - } catch { - // Ignore errors - } - } - loadCount() - }, [file.path]) - - return ( -
-
- -
-

{file.name}

- {itemCount !== null && ( -

- {itemCount} {itemCount === 1 ? "элемент" : "элементов"} -

- )} -
- ) -} - -function FileMetadata({ file }: { file: FileEntry }) { - const extension = getExtension(file.name) - - return ( -
- - {!file.is_dir && } - {file.modified && } - {file.created && } - -
- ) -} - -function MetadataRow({ - label, - value, - className, -}: { - label: string - value: string - className?: string -}) { - return ( -
- {label}: - {value} -
- ) -} diff --git a/src/widgets/sidebar/__tests__/Sidebar.order.test.tsx b/src/widgets/sidebar/__tests__/Sidebar.order.test.tsx new file mode 100644 index 0000000..1b2fbab --- /dev/null +++ b/src/widgets/sidebar/__tests__/Sidebar.order.test.tsx @@ -0,0 +1,65 @@ +import { render } from "@testing-library/react" +import { describe, expect, it, vi } from "vitest" +import { Sidebar } from "../ui/Sidebar" + +vi.mock("@/entities/file-entry", async () => { + const actual = + await vi.importActual("@/entities/file-entry") + return { + ...actual, + useDrives: () => ({ + data: [ + { path: "/C:/", name: "C:" }, + { path: "/D:/", name: "D:" }, + ], + }), + } +}) + +vi.mock("@/features/recent-folders", async () => { + const actual = await vi.importActual( + "@/features/recent-folders", + ) + return { + ...actual, + useRecentFoldersStore: () => ({ + folders: [ + { name: "One", path: "/one", lastVisited: Date.now() }, + { name: "Two", path: "/two", lastVisited: Date.now() }, + ], + removeFolder: vi.fn(), + clearAll: vi.fn(), + addFolder: vi.fn(), + }), + } +}) + +vi.mock("@/features/bookmarks", async () => { + const actual = + await vi.importActual("@/features/bookmarks") + return { + ...actual, + useBookmarksStore: () => ({ bookmarks: [] }), + } +}) + +vi.mock("@/features/navigation", async () => { + const actual = + await vi.importActual("@/features/navigation") + return { + ...actual, + useNavigationStore: () => ({ currentPath: "/", navigate: vi.fn() }), + } +}) + +describe("Sidebar layout order", () => { + it("renders Drives before Recent section in expanded view", () => { + const { getByText } = render() + const drives = getByText("Диски") + const recent = getByText("Недавние") + + // drives should come before recent in DOM order + const isBefore = !!(drives.compareDocumentPosition(recent) & Node.DOCUMENT_POSITION_FOLLOWING) + expect(isBefore).toBe(true) + }) +}) diff --git a/src/widgets/sidebar/__tests__/Sidebar.persist.test.tsx b/src/widgets/sidebar/__tests__/Sidebar.persist.test.tsx new file mode 100644 index 0000000..2c2e2cc --- /dev/null +++ b/src/widgets/sidebar/__tests__/Sidebar.persist.test.tsx @@ -0,0 +1,80 @@ +import { fireEvent, render } from "@testing-library/react" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { useLayoutStore } from "@/features/layout" +import { Sidebar } from "../ui/Sidebar" + +// Mock recent folders so Sidebar can render list items +vi.mock("@/features/recent-folders", async () => { + const actual = await vi.importActual( + "@/features/recent-folders", + ) + return { + ...actual, + useRecentFoldersStore: () => ({ + folders: [ + { name: "One", path: "/one", lastVisited: Date.now() }, + { name: "Two", path: "/two", lastVisited: Date.now() }, + ], + removeFolder: vi.fn(), + clearAll: vi.fn(), + addFolder: vi.fn(), + }), + } +}) + +// Mock drives to avoid react-query requirement in tests +vi.mock("@/entities/file-entry", async () => { + const actual = + await vi.importActual("@/entities/file-entry") + return { + ...actual, + useDrives: () => ({ data: [] }), + } +}) + +// Mock bookmarks store to prevent undefined issues +vi.mock("@/features/bookmarks", async () => { + const actual = + await vi.importActual("@/features/bookmarks") + return { + ...actual, + useBookmarksStore: () => ({ bookmarks: [] }), + } +}) + +// Mock navigation store +vi.mock("@/features/navigation", async () => { + const actual = + await vi.importActual("@/features/navigation") + return { + ...actual, + useNavigationStore: () => ({ currentPath: "/", navigate: vi.fn() }), + } +}) + +describe("Sidebar persistence", () => { + beforeEach(() => { + // reset layout store to defaults before each test + useLayoutStore.getState().resetLayout() + }) + + it("toggle updates store", () => { + const { getByText } = render() + + const recent = getByText("Недавние") + // initially expanded -> toggle to collapse + fireEvent.click(recent) + + expect(useLayoutStore.getState().layout.expandedSections?.recent).toBe(false) + }) + + it("restores stored state on mount", () => { + // set collapsed in store before mounting + useLayoutStore.getState().setSectionExpanded("recent", false) + + const { queryByText } = render() + + // The RecentFoldersList should not render folder "One" + expect(queryByText("One")).toBeNull() + }) +}) diff --git a/src/widgets/sidebar/ui/Sidebar.tsx b/src/widgets/sidebar/ui/Sidebar.tsx index a2db356..dba9d94 100644 --- a/src/widgets/sidebar/ui/Sidebar.tsx +++ b/src/widgets/sidebar/ui/Sidebar.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useState } from "react" import { DriveItem } from "@/entities/drive" import { useDrives } from "@/entities/file-entry" import { BookmarksList, useBookmarksStore } from "@/features/bookmarks" +import { useLayoutStore } from "@/features/layout" import { useNavigationStore } from "@/features/navigation" import { RecentFoldersList, useRecentFoldersStore } from "@/features/recent-folders" import { cn } from "@/shared/lib" @@ -83,19 +84,12 @@ export function Sidebar({ className, collapsed = false }: SidebarProps) { const { bookmarks, addBookmark } = useBookmarksStore() const { addFolder } = useRecentFoldersStore() const [homePath, setHomePath] = useState(null) - const [expandedSections, setExpandedSections] = useState>({ - bookmarks: true, - recent: true, - drives: true, - quickAccess: true, - }) - const toggleSection = (section: SidebarSection) => { - setExpandedSections((prev) => ({ - ...prev, - [section]: !prev[section], - })) - } + // Persisted expanded/collapsed state lives in layout store + const expandedSections = useLayoutStore((s) => s.layout.expandedSections) + const toggleSectionExpanded = useLayoutStore((s) => s.toggleSectionExpanded) + + const toggleSection = (section: SidebarSection) => toggleSectionExpanded(section) const handleDrop = (e: React.DragEvent) => { e.preventDefault() @@ -202,10 +196,10 @@ export function Sidebar({ className, collapsed = false }: SidebarProps) { } - expanded={expandedSections.quickAccess} + expanded={expandedSections?.quickAccess ?? true} onToggle={() => toggleSection("quickAccess")} /> - {expandedSections.quickAccess && ( + {(expandedSections?.quickAccess ?? true) && (
{homePath && ( @@ -85,6 +105,8 @@ export function Toolbar({ onClick={goForward} disabled={!canGoForward()} className="h-8 w-8" + aria-label="Forward" + title="Forward" > @@ -94,7 +116,14 @@ export function Toolbar({ - @@ -103,7 +132,14 @@ export function Toolbar({ - @@ -117,7 +153,14 @@ export function Toolbar({
- @@ -126,7 +169,14 @@ export function Toolbar({ - @@ -146,13 +196,21 @@ export function Toolbar({ variant="ghost" size="icon" onClick={toggleHidden} - className={cn("h-8 w-8", settings.showHidden && "bg-accent")} + className={cn("h-8 w-8", displaySettings.showHiddenFiles && "bg-accent")} + aria-label={ + displaySettings.showHiddenFiles ? "Hide hidden files" : "Show hidden files" + } + title={displaySettings.showHiddenFiles ? "Hide hidden files" : "Show hidden files"} > - {settings.showHidden ? : } + {displaySettings.showHiddenFiles ? ( + + ) : ( + + )} - {settings.showHidden ? "Скрыть скрытые файлы" : "Показать скрытые файлы"} + {displaySettings.showHiddenFiles ? "Скрыть скрытые файлы" : "Показать скрытые файлы"} @@ -181,8 +239,9 @@ export function Toolbar({ @@ -199,6 +258,9 @@ export function Toolbar({ onClick={handleToggleBookmark} disabled={!currentPath} className={cn("h-8 w-8", bookmarked && "text-yellow-500")} + aria-label={bookmarked ? "Remove bookmark" : "Add bookmark"} + aria-pressed={bookmarked} + title={bookmarked ? "Remove bookmark" : "Add bookmark"} > @@ -210,6 +272,21 @@ export function Toolbar({
+ + + + + Настройки (Ctrl+,) + + {/* Search */}
@@ -217,8 +294,8 @@ export function Toolbar({