From c0262c12cfc02153412456bf693cc8991cd20b3e Mon Sep 17 00:00:00 2001 From: arnavv-guptaa Date: Sat, 17 Jan 2026 21:34:42 +0800 Subject: [PATCH 01/51] added filesystem view in the application. --- bun.lock | 166 +++++++++++++++- src/main/lib/trpc/routers/files.ts | 148 +++++++++++++- src/renderer/features/agents/atoms/index.ts | 34 ++++ .../features/agents/main/active-chat.tsx | 78 ++++++++ .../agents/ui/file-tree/FileTreeNode.tsx | 93 +++++++++ .../agents/ui/file-tree/FileTreeSidebar.tsx | 187 ++++++++++++++++++ .../agents/ui/file-tree/build-file-tree.ts | 111 +++++++++++ .../features/agents/ui/file-tree/index.ts | 3 + 8 files changed, 815 insertions(+), 5 deletions(-) create mode 100644 src/renderer/features/agents/ui/file-tree/FileTreeNode.tsx create mode 100644 src/renderer/features/agents/ui/file-tree/FileTreeSidebar.tsx create mode 100644 src/renderer/features/agents/ui/file-tree/build-file-tree.ts create mode 100644 src/renderer/features/agents/ui/file-tree/index.ts diff --git a/bun.lock b/bun.lock index 2a6e878c..4c83cb85 100644 --- a/bun.lock +++ b/bun.lock @@ -5,15 +5,18 @@ "name": "21st-desktop", "dependencies": { "@ai-sdk/react": "^3.0.14", - "@anthropic-ai/claude-agent-sdk": "^0.2.3", + "@anthropic-ai/claude-agent-sdk": "^0.2.5", "@git-diff-view/react": "^0.0.35", "@git-diff-view/shiki": "^0.0.36", + "@radix-ui/react-accordion": "^1.2.11", "@radix-ui/react-alert-dialog": "^1.1.1", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-hover-card": "^1.1.14", + "@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-progress": "^1.1.8", @@ -43,6 +46,7 @@ "drizzle-orm": "^0.45.1", "electron-log": "^5.4.3", "electron-updater": "^6.7.3", + "gray-matter": "^4.0.3", "jotai": "^2.11.1", "lucide-react": "^0.468.0", "motion": "^11.15.0", @@ -55,8 +59,12 @@ "react-dom": "19.2.1", "react-hotkeys-hook": "^4.6.1", "react-icons": "^5.5.0", + "react-markdown": "^10.1.0", "react-syntax-highlighter": "^16.1.0", + "remark-breaks": "^4.0.0", + "remark-gfm": "^4.0.1", "shiki": "^1.24.4", + "simple-git": "^3.28.0", "sonner": "^1.7.1", "superjson": "^2.2.2", "tailwind-merge": "^2.6.0", @@ -102,7 +110,7 @@ "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], - "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.3", "", { "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.33.5", "@img/sharp-darwin-x64": "^0.33.5", "@img/sharp-linux-arm": "^0.33.5", "@img/sharp-linux-arm64": "^0.33.5", "@img/sharp-linux-x64": "^0.33.5", "@img/sharp-linuxmusl-arm64": "^0.33.5", "@img/sharp-linuxmusl-x64": "^0.33.5", "@img/sharp-win32-x64": "^0.33.5" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-6h54MzD4R5S15esMiK7sPKwotwoYd3qxXdqzRWqSkYo96IwvtSoK5yb0jbWEdDKSW71jjEctFJZBkonGalmTAQ=="], + "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.11", "", { "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.33.5", "@img/sharp-darwin-x64": "^0.33.5", "@img/sharp-linux-arm": "^0.33.5", "@img/sharp-linux-arm64": "^0.33.5", "@img/sharp-linux-x64": "^0.33.5", "@img/sharp-linuxmusl-arm64": "^0.33.5", "@img/sharp-linuxmusl-x64": "^0.33.5", "@img/sharp-win32-x64": "^0.33.5" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-50q4vfh57HYTUrwukULp3gvSaOZfRF5zukxhWvW6mmUHuD8+Xwhqn59sZhoDz9ZUw6Um+1lUCgWpJw5/TTMn5w=="], "@apm-js-collab/code-transformer": ["@apm-js-collab/code-transformer@0.8.2", "", {}, "sha512-YRjJjNq5KFSjDUoqu5pFUWrrsvGOxl6c3bu+uMFc9HNNptZ2rNU/TI2nLw4jnhQNtka972Ee2m3uqbvDQtPeCA=="], @@ -290,6 +298,10 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@kwsites/file-exists": ["@kwsites/file-exists@1.1.1", "", { "dependencies": { "debug": "^4.1.1" } }, "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw=="], + + "@kwsites/promise-deferred": ["@kwsites/promise-deferred@1.1.1", "", {}, "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw=="], + "@malept/cross-spawn-promise": ["@malept/cross-spawn-promise@2.0.0", "", { "dependencies": { "cross-spawn": "^7.0.1" } }, "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg=="], "@malept/flatpak-bundler": ["@malept/flatpak-bundler@0.4.0", "", { "dependencies": { "debug": "^4.1.1", "fs-extra": "^9.0.0", "lodash": "^4.17.15", "tmp-promise": "^3.0.2" } }, "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q=="], @@ -394,6 +406,8 @@ "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], + "@radix-ui/react-accordion": ["@radix-ui/react-accordion@1.2.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA=="], + "@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw=="], "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="], @@ -422,6 +436,10 @@ "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="], + "@radix-ui/react-hover-card": ["@radix-ui/react-hover-card@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg=="], + + "@radix-ui/react-icons": ["@radix-ui/react-icons@1.3.2", "", { "peerDependencies": { "react": "^16.x || ^17.x || ^18.x || ^19.0.0 || ^19.0.0-rc" } }, "sha512-fyQIhGDhzfc9pK2kH6Pl9c4BDJGfMkPqkyIgYDthyNYoNg3wVhoJMMh19WS4Up/1KMPFVpNsT2q3WmXn2N1m6g=="], + "@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="], "@radix-ui/react-label": ["@radix-ui/react-label@2.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A=="], @@ -600,6 +618,8 @@ "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], + "@types/fs-extra": ["@types/fs-extra@9.0.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA=="], "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], @@ -726,6 +746,8 @@ "autoprefixer": ["autoprefixer@10.4.23", "", { "dependencies": { "browserslist": "^4.28.1", "caniuse-lite": "^1.0.30001760", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA=="], + "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], @@ -980,7 +1002,11 @@ "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], - "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + "escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], + + "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], + + "estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="], "eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="], @@ -990,6 +1016,10 @@ "exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="], + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + + "extend-shallow": ["extend-shallow@2.0.1", "", { "dependencies": { "is-extendable": "^0.1.0" } }, "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug=="], + "extract-zip": ["extract-zip@2.0.1", "", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="], "extsprintf": ["extsprintf@1.4.1", "", {}, "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA=="], @@ -1078,6 +1108,8 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "gray-matter": ["gray-matter@4.0.3", "", { "dependencies": { "js-yaml": "^3.13.1", "kind-of": "^6.0.2", "section-matter": "^1.0.0", "strip-bom-string": "^1.0.0" } }, "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q=="], + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], @@ -1094,6 +1126,8 @@ "hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="], + "hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="], + "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="], "hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="], @@ -1104,6 +1138,8 @@ "hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="], + "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="], + "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], "http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="], @@ -1136,6 +1172,8 @@ "ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], + "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], + "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="], "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], @@ -1150,6 +1188,8 @@ "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], + "is-extendable": ["is-extendable@0.1.1", "", {}, "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="], + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], @@ -1164,6 +1204,8 @@ "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], + "is-unicode-supported": ["is-unicode-supported@0.1.0", "", {}, "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw=="], "is-what": ["is-what@5.5.0", "", {}, "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw=="], @@ -1202,6 +1244,8 @@ "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + "kind-of": ["kind-of@6.0.3", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="], + "lazy-val": ["lazy-val@1.0.5", "", {}, "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q=="], "lazystream": ["lazystream@1.0.1", "", { "dependencies": { "readable-stream": "^2.0.5" } }, "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw=="], @@ -1228,6 +1272,8 @@ "log-symbols": ["log-symbols@4.1.0", "", { "dependencies": { "chalk": "^4.1.0", "is-unicode-supported": "^0.1.0" } }, "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg=="], + "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], + "lowercase-keys": ["lowercase-keys@2.0.0", "", {}, "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA=="], "lowlight": ["lowlight@3.3.0", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.0.0", "highlight.js": "~11.11.0" } }, "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ=="], @@ -1242,20 +1288,98 @@ "make-fetch-happen": ["make-fetch-happen@10.2.1", "", { "dependencies": { "agentkeepalive": "^4.2.1", "cacache": "^16.1.0", "http-cache-semantics": "^4.1.0", "http-proxy-agent": "^5.0.0", "https-proxy-agent": "^5.0.0", "is-lambda": "^1.0.1", "lru-cache": "^7.7.1", "minipass": "^3.1.6", "minipass-collect": "^1.0.2", "minipass-fetch": "^2.0.3", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "negotiator": "^0.6.3", "promise-retry": "^2.0.1", "socks-proxy-agent": "^7.0.0", "ssri": "^9.0.0" } }, "sha512-NgOPbRiaQM10DYXvN3/hhGVI2M5MtITFryzBGxHM5p4wnFxsVCbxkrBrDsk+EZ5OB4jEOT7AjDxtdF+KVEFT7w=="], + "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], + "matcher": ["matcher@3.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="], "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + "mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="], + + "mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA=="], + + "mdast-util-gfm": ["mdast-util-gfm@3.1.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="], + + "mdast-util-gfm-autolink-literal": ["mdast-util-gfm-autolink-literal@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-find-and-replace": "^3.0.0", "micromark-util-character": "^2.0.0" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="], + + "mdast-util-gfm-footnote": ["mdast-util-gfm-footnote@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0" } }, "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ=="], + + "mdast-util-gfm-strikethrough": ["mdast-util-gfm-strikethrough@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg=="], + + "mdast-util-gfm-table": ["mdast-util-gfm-table@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "markdown-table": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="], + + "mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="], + + "mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="], + + "mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.2.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="], + + "mdast-util-mdxjs-esm": ["mdast-util-mdxjs-esm@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg=="], + + "mdast-util-newline-to-break": ["mdast-util-newline-to-break@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-find-and-replace": "^3.0.0" } }, "sha512-MbgeFca0hLYIEx/2zGsszCSEJJ1JSCdiY5xQxRcLDDGa8EPvlLPupJ4DSajbMPAnC0je8jfb9TiUATnxxrHUog=="], + + "mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="], + "mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="], + "mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="], + + "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="], + "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], + "micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="], + + "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="], + + "micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="], + + "micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="], + + "micromark-extension-gfm-footnote": ["micromark-extension-gfm-footnote@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="], + + "micromark-extension-gfm-strikethrough": ["micromark-extension-gfm-strikethrough@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="], + + "micromark-extension-gfm-table": ["micromark-extension-gfm-table@2.1.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg=="], + + "micromark-extension-gfm-tagfilter": ["micromark-extension-gfm-tagfilter@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="], + + "micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="], + + "micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="], + + "micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="], + + "micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="], + + "micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="], + + "micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="], + "micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], + "micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="], + + "micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="], + + "micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="], + + "micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="], + + "micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="], + "micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="], + "micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="], + + "micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="], + + "micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="], + "micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="], + "micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="], + "micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="], @@ -1472,6 +1596,8 @@ "react-icons": ["react-icons@5.5.0", "", { "peerDependencies": { "react": "*" } }, "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw=="], + "react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="], + "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], "react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="], @@ -1502,6 +1628,16 @@ "regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="], + "remark-breaks": ["remark-breaks@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-newline-to-break": "^2.0.0", "unified": "^11.0.0" } }, "sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ=="], + + "remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="], + + "remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="], + + "remark-rehype": ["remark-rehype@11.1.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="], + + "remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="], + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], "require-in-the-middle": ["require-in-the-middle@8.0.1", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3" } }, "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ=="], @@ -1540,6 +1676,8 @@ "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + "section-matter": ["section-matter@1.0.0", "", { "dependencies": { "extend-shallow": "^2.0.1", "kind-of": "^6.0.0" } }, "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA=="], + "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], "semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="], @@ -1560,6 +1698,8 @@ "simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="], + "simple-git": ["simple-git@3.30.0", "", { "dependencies": { "@kwsites/file-exists": "^1.1.1", "@kwsites/promise-deferred": "^1.1.1", "debug": "^4.4.0" } }, "sha512-q6lxyDsCmEal/MEGhP1aVyQ3oxnagGlBDOVSIB4XUVLl1iZh0Pah6ebC9V4xBap/RfgP2WlI8EKs0WS0rMEJHg=="], + "simple-update-notifier": ["simple-update-notifier@2.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w=="], "slice-ansi": ["slice-ansi@3.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "astral-regex": "^2.0.0", "is-fullwidth-code-point": "^3.0.0" } }, "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ=="], @@ -1580,7 +1720,7 @@ "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], - "sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="], + "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], "ssri": ["ssri@9.0.1", "", { "dependencies": { "minipass": "^3.1.1" } }, "sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q=="], @@ -1598,8 +1738,14 @@ "strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "strip-bom-string": ["strip-bom-string@1.0.0", "", {}, "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g=="], + "strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], + "style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="], + + "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], + "sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="], "sumchecker": ["sumchecker@3.0.1", "", { "dependencies": { "debug": "^4.1.0" } }, "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg=="], @@ -1646,6 +1792,8 @@ "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], + "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], + "trpc-electron": ["trpc-electron@0.1.2", "", { "peerDependencies": { "@trpc/client": ">=11.0.0", "@trpc/server": ">=11.0.0", "electron": ">19.0.0" } }, "sha512-sQpWBwQWzsgrERugjzUpPqY/+/n8NxkUq6YssQ5+5rALkvGCWq45T5Dreiwm2kh91dZMFlALTyMd8PhB0vgbIg=="], "truncate-utf8-bytes": ["truncate-utf8-bytes@1.0.2", "", { "dependencies": { "utf8-byte-length": "^1.0.1" } }, "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ=="], @@ -1662,6 +1810,8 @@ "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], + "unique-filename": ["unique-filename@2.0.1", "", { "dependencies": { "unique-slug": "^3.0.0" } }, "sha512-ODWHtkkdx3IAR+veKxFV+VBkUMcN+FaqzUUd7IZzt+0zhDZFPFxhlqwPF3YQvMHx1TD0tdgYl+kuPnJ8E6ql7A=="], "unique-names-generator": ["unique-names-generator@4.7.1", "", {}, "sha512-lMx9dX+KRmG8sq6gulYYpKWZc9RlGsgBR6aoO8Qsm3qvkSJ+3rAymr+TnV8EDMrIrwuFJ4kruzMWM/OpYzPoow=="], @@ -1838,6 +1988,8 @@ "glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + "gray-matter/js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="], + "hosted-git-info/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], "iconv-corefoundation/node-addon-api": ["node-addon-api@1.7.2", "", {}, "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg=="], @@ -1856,6 +2008,8 @@ "make-fetch-happen/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + "matcher/escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "minipass-collect/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], @@ -1884,6 +2038,8 @@ "readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "roarr/sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="], + "socks-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], "ssri/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], @@ -1976,6 +2132,8 @@ "glob/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], + "gray-matter/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], + "lazystream/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], "lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], diff --git a/src/main/lib/trpc/routers/files.ts b/src/main/lib/trpc/routers/files.ts index fc40757c..788712f2 100644 --- a/src/main/lib/trpc/routers/files.ts +++ b/src/main/lib/trpc/routers/files.ts @@ -1,7 +1,17 @@ import { z } from "zod" import { router, publicProcedure } from "../index" import { readdir, stat } from "node:fs/promises" +import { watch, type FSWatcher } from "node:fs" import { join, relative, basename } from "node:path" +import { observable } from "@trpc/server/observable" +import { EventEmitter } from "node:events" + +// Event emitter for file changes +const fileChangeEmitter = new EventEmitter() +fileChangeEmitter.setMaxListeners(100) // Allow many subscribers + +// Active file watchers per project path +const activeWatchers = new Map() // Directories to ignore when scanning const IGNORED_DIRS = new Set([ @@ -64,7 +74,81 @@ interface FileEntry { // Cache for file and folder listings const fileListCache = new Map() -const CACHE_TTL = 5000 // 5 seconds +const CACHE_TTL = 1000 // 1 second (short TTL since we have real-time watching) + +// Debounce timers for file change events +const debounceTimers = new Map() +const DEBOUNCE_MS = 100 // Debounce rapid changes + +/** + * Start watching a project directory for changes + */ +function startWatching(projectPath: string): void { + const existing = activeWatchers.get(projectPath) + if (existing) { + existing.refCount++ + return + } + + try { + const watcher = watch(projectPath, { recursive: true }, (eventType, filename) => { + if (!filename) return + + // Skip ignored directories and files + const parts = filename.split("/") + const shouldIgnore = parts.some(part => + IGNORED_DIRS.has(part) || + IGNORED_FILES.has(part) || + (part.startsWith(".") && !part.startsWith(".github") && !part.startsWith(".vscode")) + ) + if (shouldIgnore) return + + // Debounce rapid changes + const existing = debounceTimers.get(projectPath) + if (existing) { + clearTimeout(existing) + } + + debounceTimers.set(projectPath, setTimeout(() => { + debounceTimers.delete(projectPath) + // Invalidate cache + fileListCache.delete(projectPath) + // Emit change event + fileChangeEmitter.emit(`change:${projectPath}`, { eventType, filename }) + }, DEBOUNCE_MS)) + }) + + watcher.on("error", (error) => { + console.warn(`[files] Watcher error for ${projectPath}:`, error) + }) + + activeWatchers.set(projectPath, { watcher, refCount: 1 }) + console.log(`[files] Started watching: ${projectPath}`) + } catch (error) { + console.warn(`[files] Could not start watcher for ${projectPath}:`, error) + } +} + +/** + * Stop watching a project directory + */ +function stopWatching(projectPath: string): void { + const existing = activeWatchers.get(projectPath) + if (!existing) return + + existing.refCount-- + if (existing.refCount <= 0) { + existing.watcher.close() + activeWatchers.delete(projectPath) + // Clean up debounce timer + const timer = debounceTimers.get(projectPath) + if (timer) { + clearTimeout(timer) + debounceTimers.delete(projectPath) + } + console.log(`[files] Stopped watching: ${projectPath}`) + } +} /** * Recursively scan a directory and return all file and folder paths @@ -261,4 +345,66 @@ export const filesRouter = router({ fileListCache.delete(input.projectPath) return { success: true } }), + + /** + * List all files and folders in a project directory (for file tree) + */ + listAll: publicProcedure + .input(z.object({ projectPath: z.string() })) + .query(async ({ input }) => { + const { projectPath } = input + + if (!projectPath) { + return [] + } + + try { + // Verify the path exists and is a directory + const pathStat = await stat(projectPath) + if (!pathStat.isDirectory()) { + console.warn(`[files] Not a directory: ${projectPath}`) + return [] + } + + // Get full entry list (cached or fresh scan) + const entries = await getEntryList(projectPath) + return entries + } catch (error) { + console.error(`[files] Error listing files:`, error) + return [] + } + }), + + /** + * Subscribe to file changes in a project directory + * Emits events when files are created, modified, or deleted + */ + watchChanges: publicProcedure + .input(z.object({ projectPath: z.string() })) + .subscription(({ input }) => { + const { projectPath } = input + + return observable<{ eventType: string; filename: string }>((emit) => { + if (!projectPath) { + emit.complete() + return () => {} + } + + // Start watching this project + startWatching(projectPath) + + // Listen for change events + const onChange = (data: { eventType: string; filename: string }) => { + emit.next(data) + } + + fileChangeEmitter.on(`change:${projectPath}`, onChange) + + // Cleanup when subscription ends + return () => { + fileChangeEmitter.off(`change:${projectPath}`, onChange) + stopWatching(projectPath) + } + }) + }), }) diff --git a/src/renderer/features/agents/atoms/index.ts b/src/renderer/features/agents/atoms/index.ts index 18464f25..02abc89f 100644 --- a/src/renderer/features/agents/atoms/index.ts +++ b/src/renderer/features/agents/atoms/index.ts @@ -504,3 +504,37 @@ export type UndoItem = | { type: "subchat"; subChatId: string; chatId: string; timeoutId: ReturnType } export const undoStackAtom = atom([]) + +// File tree sidebar state +export const agentsFileTreeSidebarOpenAtom = atomWithStorage( + "agents-file-tree-sidebar-open", + false, + undefined, + { getOnInit: true }, +) + +export const agentsFileTreeSidebarWidthAtom = atomWithStorage( + "agents-file-tree-sidebar-width", + 250, + undefined, + { getOnInit: true }, +) + +// Expanded folders per project - stores Set of expanded folder paths +const expandedFoldersStorageAtom = atomWithStorage>( + "agents:expandedFolders", + {}, + undefined, + { getOnInit: true }, +) + +// atomFamily to get/set expanded folders per projectId +export const expandedFoldersAtomFamily = atomFamily((projectId: string) => + atom( + (get) => new Set(get(expandedFoldersStorageAtom)[projectId] ?? []), + (get, set, newSet: Set) => { + const current = get(expandedFoldersStorageAtom) + set(expandedFoldersStorageAtom, { ...current, [projectId]: Array.from(newSet) }) + }, + ), +) diff --git a/src/renderer/features/agents/main/active-chat.tsx b/src/renderer/features/agents/main/active-chat.tsx index 0164979f..2365dc32 100644 --- a/src/renderer/features/agents/main/active-chat.tsx +++ b/src/renderer/features/agents/main/active-chat.tsx @@ -54,6 +54,7 @@ import { ChevronDown, Columns2, Eye, + FolderTree, GitCommitHorizontal, GitMerge, ListTree, @@ -87,6 +88,8 @@ import { terminalSidebarOpenAtom } from "../../terminal/atoms" import { TerminalSidebar } from "../../terminal/terminal-sidebar" import { agentsDiffSidebarWidthAtom, + agentsFileTreeSidebarOpenAtom, + agentsFileTreeSidebarWidthAtom, agentsPreviewSidebarOpenAtom, agentsPreviewSidebarWidthAtom, agentsScrollPositionsAtom, @@ -171,6 +174,7 @@ import { AgentWebSearchCollapsible } from "../ui/agent-web-search-collapsible" import { AgentsHeaderControls } from "../ui/agents-header-controls" import { ChatTitleEditor } from "../ui/chat-title-editor" import { McpServersIndicator } from "../ui/mcp-servers-indicator" +import { FileTreeSidebar } from "../ui/file-tree" import { MobileChatHeader } from "../ui/mobile-chat-header" import { PrStatusBar } from "../ui/pr-status-bar" import { SubChatSelector } from "../ui/sub-chat-selector" @@ -3591,6 +3595,9 @@ export function ChatView({ const [isTerminalSidebarOpen, setIsTerminalSidebarOpen] = useAtom( terminalSidebarOpenAtom, ) + const [isFileTreeSidebarOpen, setIsFileTreeSidebarOpen] = useAtom( + agentsFileTreeSidebarOpenAtom, + ) const [diffStats, setDiffStats] = useState({ fileCount: 0, additions: 0, @@ -4669,6 +4676,31 @@ export function ChatView({ return () => window.removeEventListener("keydown", handleKeyDown, true) }, [diffStats.hasChanges, isDiffSidebarOpen]) + // Keyboard shortcut: Cmd + B to toggle file tree sidebar + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Check for Cmd (Meta) + B (without Alt/Shift) + if ( + e.metaKey && + !e.altKey && + !e.shiftKey && + !e.ctrlKey && + e.code === "KeyB" + ) { + e.preventDefault() + e.stopPropagation() + + // Toggle: only when worktree exists + if (worktreePath) { + setIsFileTreeSidebarOpen((prev) => !prev) + } + } + } + + window.addEventListener("keydown", handleKeyDown, true) + return () => window.removeEventListener("keydown", handleKeyDown, true) + }, [worktreePath, setIsFileTreeSidebarOpen]) + // Keyboard shortcut: Create PR (preview) // Web: Opt+Cmd+P (browser uses Cmd+P for print) // Desktop: Cmd+P @@ -4848,6 +4880,30 @@ export function ChatView({
{/* Main content */}
+ {/* File Tree Sidebar - LEFT side */} + {worktreePath && !isMobileFullscreen && ( + setIsFileTreeSidebarOpen(false)} + widthAtom={agentsFileTreeSidebarWidthAtom} + minWidth={180} + maxWidth={400} + side="left" + animationDuration={0} + initialWidth={0} + exitWidth={0} + showResizeTooltip={true} + className="bg-background border-r" + style={{ borderRightWidth: "0.5px", overflow: "hidden" }} + > + setIsFileTreeSidebarOpen(false)} + /> + + )} + {/* Chat Panel */}
))} + {/* File Tree Button - shows when file tree is closed and worktree exists (desktop only) */} + {!isMobileFullscreen && + !isFileTreeSidebarOpen && + worktreePath && ( + + + + + + Open file tree + ⌘B + + + )} {/* Terminal Button - shows when terminal is closed and worktree exists (desktop only) */} {!isMobileFullscreen && !isTerminalSidebarOpen && diff --git a/src/renderer/features/agents/ui/file-tree/FileTreeNode.tsx b/src/renderer/features/agents/ui/file-tree/FileTreeNode.tsx new file mode 100644 index 00000000..bfa07758 --- /dev/null +++ b/src/renderer/features/agents/ui/file-tree/FileTreeNode.tsx @@ -0,0 +1,93 @@ +"use client" + +import { ChevronRight, File, Folder, FolderOpen } from "lucide-react" +import { memo, useCallback } from "react" +import { cn } from "../../../../lib/utils" +import type { TreeNode } from "./build-file-tree" + +interface FileTreeNodeProps { + node: TreeNode + level: number + expandedFolders: Set + onToggleFolder: (path: string) => void + onSelectFile?: (path: string) => void +} + +export const FileTreeNode = memo(function FileTreeNode({ + node, + level, + expandedFolders, + onToggleFolder, + onSelectFile, +}: FileTreeNodeProps) { + const isExpanded = node.type === "folder" && expandedFolders.has(node.path) + const hasChildren = node.type === "folder" && node.children.length > 0 + + const handleClick = useCallback(() => { + if (node.type === "folder") { + onToggleFolder(node.path) + } else { + onSelectFile?.(node.path) + } + }, [node.type, node.path, onToggleFolder, onSelectFile]) + + const paddingLeft = level * 12 + 6 + + return ( +
+ + + {/* Children (only for expanded folders) */} + {isExpanded && node.children.length > 0 && ( +
+ {node.children.map((child) => ( + + ))} +
+ )} +
+ ) +}) diff --git a/src/renderer/features/agents/ui/file-tree/FileTreeSidebar.tsx b/src/renderer/features/agents/ui/file-tree/FileTreeSidebar.tsx new file mode 100644 index 00000000..d272d71a --- /dev/null +++ b/src/renderer/features/agents/ui/file-tree/FileTreeSidebar.tsx @@ -0,0 +1,187 @@ +"use client" + +import { useAtom } from "jotai" +import { ChevronDown, RefreshCw, X } from "lucide-react" +import { useCallback, useMemo } from "react" +import { Button } from "../../../../components/ui/button" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "../../../../components/ui/tooltip" +import { trpc } from "../../../../lib/trpc" +import { expandedFoldersAtomFamily } from "../../atoms" +import { buildFileTree, countFiles, countFolders } from "./build-file-tree" +import { FileTreeNode } from "./FileTreeNode" + +interface FileTreeSidebarProps { + projectPath: string | undefined + projectId: string + onClose: () => void + onSelectFile?: (path: string) => void +} + +export function FileTreeSidebar({ + projectPath, + projectId, + onClose, + onSelectFile, +}: FileTreeSidebarProps) { + const [expandedFolders, setExpandedFolders] = useAtom( + expandedFoldersAtomFamily(projectId), + ) + + // Fetch all files from project + const { + data: entries = [], + isLoading, + refetch, + isRefetching, + } = trpc.files.listAll.useQuery( + { projectPath: projectPath || "" }, + { + enabled: !!projectPath, + staleTime: 1000, // Short TTL since we have real-time watching + }, + ) + + // Subscribe to file changes for real-time sync + trpc.files.watchChanges.useSubscription( + { projectPath: projectPath || "" }, + { + enabled: !!projectPath, + onData: () => { + // Refetch when files change + refetch() + }, + }, + ) + + // Build tree from flat entries + const tree = useMemo(() => buildFileTree(entries), [entries]) + + // Stats for footer + const fileCount = useMemo(() => countFiles(tree), [tree]) + const folderCount = useMemo(() => countFolders(tree), [tree]) + + // Toggle folder expansion + const handleToggleFolder = useCallback( + (path: string) => { + const next = new Set(expandedFolders) + if (next.has(path)) { + next.delete(path) + } else { + next.add(path) + } + setExpandedFolders(next) + }, + [expandedFolders, setExpandedFolders], + ) + + // Collapse all folders + const handleCollapseAll = useCallback(() => { + setExpandedFolders(new Set()) + }, [setExpandedFolders]) + + // Refresh file list + const handleRefresh = useCallback(() => { + refetch() + }, [refetch]) + + return ( +
+ {/* Header */} +
+ + Files + +
+ {/* Refresh button */} + + + + + Refresh + + + {/* Collapse all button */} + + + + + Collapse all + + + {/* Close button */} + + + + + Close + +
+
+ + {/* Content */} +
+ {!projectPath ? ( +
+ No project selected +
+ ) : isLoading ? ( +
+ Loading files... +
+ ) : tree.length === 0 ? ( +
+ No files found +
+ ) : ( + tree.map((node) => ( + + )) + )} +
+ + {/* Footer with stats */} + {!isLoading && tree.length > 0 && ( +
+ {fileCount} file{fileCount !== 1 ? "s" : ""}, {folderCount} folder + {folderCount !== 1 ? "s" : ""} +
+ )} +
+ ) +} diff --git a/src/renderer/features/agents/ui/file-tree/build-file-tree.ts b/src/renderer/features/agents/ui/file-tree/build-file-tree.ts new file mode 100644 index 00000000..5a11b62f --- /dev/null +++ b/src/renderer/features/agents/ui/file-tree/build-file-tree.ts @@ -0,0 +1,111 @@ +/** + * Transforms a flat list of file entries into a hierarchical tree structure + */ + +export interface FileEntry { + path: string + type: "file" | "folder" +} + +export interface TreeNode { + name: string + path: string + type: "file" | "folder" + children: TreeNode[] +} + +/** + * Build a tree structure from a flat list of file entries + */ +export function buildFileTree(entries: FileEntry[]): TreeNode[] { + const root: TreeNode[] = [] + const folderMap = new Map() + + // Sort entries so folders come first, then alphabetically + const sortedEntries = [...entries].sort((a, b) => { + // Folders before files + if (a.type !== b.type) { + return a.type === "folder" ? -1 : 1 + } + // Alphabetical within same type + return a.path.localeCompare(b.path) + }) + + for (const entry of sortedEntries) { + const parts = entry.path.split("/") + const name = parts[parts.length - 1] + + const node: TreeNode = { + name, + path: entry.path, + type: entry.type, + children: [], + } + + if (entry.type === "folder") { + folderMap.set(entry.path, node) + } + + // Find parent folder + if (parts.length === 1) { + // Root level + root.push(node) + } else { + const parentPath = parts.slice(0, -1).join("/") + const parent = folderMap.get(parentPath) + if (parent) { + parent.children.push(node) + } else { + // Parent folder not in the list (shouldn't happen with proper scanning) + root.push(node) + } + } + } + + // Sort children of each folder: folders first, then files, alphabetically + const sortChildren = (nodes: TreeNode[]) => { + nodes.sort((a, b) => { + if (a.type !== b.type) { + return a.type === "folder" ? -1 : 1 + } + return a.name.toLowerCase().localeCompare(b.name.toLowerCase()) + }) + for (const node of nodes) { + if (node.children.length > 0) { + sortChildren(node.children) + } + } + } + + sortChildren(root) + return root +} + +/** + * Count total files in a tree (excluding folders) + */ +export function countFiles(nodes: TreeNode[]): number { + let count = 0 + for (const node of nodes) { + if (node.type === "file") { + count++ + } else { + count += countFiles(node.children) + } + } + return count +} + +/** + * Count total folders in a tree + */ +export function countFolders(nodes: TreeNode[]): number { + let count = 0 + for (const node of nodes) { + if (node.type === "folder") { + count++ + count += countFolders(node.children) + } + } + return count +} diff --git a/src/renderer/features/agents/ui/file-tree/index.ts b/src/renderer/features/agents/ui/file-tree/index.ts new file mode 100644 index 00000000..620d5f04 --- /dev/null +++ b/src/renderer/features/agents/ui/file-tree/index.ts @@ -0,0 +1,3 @@ +export { FileTreeSidebar } from "./FileTreeSidebar" +export { FileTreeNode } from "./FileTreeNode" +export { buildFileTree, countFiles, countFolders, type TreeNode, type FileEntry } from "./build-file-tree" From 66e174c17ac19352f0ca1471cc4eda7d2dac78a9 Mon Sep 17 00:00:00 2001 From: arnavv-guptaa Date: Sat, 17 Jan 2026 21:51:39 +0800 Subject: [PATCH 02/51] updated git file tracking --- src/main/lib/trpc/routers/files.ts | 264 ++++++++++++++++++ .../agents/ui/file-tree/FileTreeNode.tsx | 88 +++++- .../agents/ui/file-tree/FileTreeSidebar.tsx | 66 +++-- .../features/agents/ui/file-tree/index.ts | 2 +- 4 files changed, 397 insertions(+), 23 deletions(-) diff --git a/src/main/lib/trpc/routers/files.ts b/src/main/lib/trpc/routers/files.ts index 788712f2..dbec9c08 100644 --- a/src/main/lib/trpc/routers/files.ts +++ b/src/main/lib/trpc/routers/files.ts @@ -5,6 +5,29 @@ import { watch, type FSWatcher } from "node:fs" import { join, relative, basename } from "node:path" import { observable } from "@trpc/server/observable" import { EventEmitter } from "node:events" +import { exec } from "node:child_process" +import { promisify } from "node:util" + +const execAsync = promisify(exec) + +// Git status codes +export type GitStatusCode = + | "modified" // M - modified + | "added" // A - staged new file + | "deleted" // D - deleted + | "renamed" // R - renamed + | "copied" // C - copied + | "untracked" // ? - untracked + | "ignored" // ! - ignored + | "unmerged" // U - unmerged (conflict) + | "staged" // File is staged (index has changes) + | null // No changes + +export interface GitFileStatus { + path: string + status: GitStatusCode + staged: boolean // Whether the file has staged changes +} // Event emitter for file changes const fileChangeEmitter = new EventEmitter() @@ -13,6 +36,13 @@ fileChangeEmitter.setMaxListeners(100) // Allow many subscribers // Active file watchers per project path const activeWatchers = new Map() +// Git directory watchers (watch .git for status changes) +const gitWatchers = new Map() + +// Git status cache with longer TTL (invalidated by .git watcher) +const gitStatusCache = new Map; timestamp: number }>() +const GIT_STATUS_CACHE_TTL = 30000 // 30 seconds (longer since we watch .git) + // Directories to ignore when scanning const IGNORED_DIRS = new Set([ ".git", @@ -80,6 +110,181 @@ const CACHE_TTL = 1000 // 1 second (short TTL since we have real-time watching) const debounceTimers = new Map() const DEBOUNCE_MS = 100 // Debounce rapid changes +/** + * Parse git status output and return a map of file paths to their status + */ +function parseGitStatus(output: string): Map { + const statusMap = new Map() + + for (const line of output.split("\n")) { + if (!line || line.length < 4) continue + + // Git status format: XY PATH or XY ORIG -> PATH (for renames) + const indexStatus = line[0] // Status in index (staged) + const workingStatus = line[1] // Status in working tree + let filePath = line.slice(3) + + // Handle renames: "R old -> new" + if (filePath.includes(" -> ")) { + filePath = filePath.split(" -> ")[1] + } + + // Remove quotes if present (git quotes paths with special chars) + if (filePath.startsWith('"') && filePath.endsWith('"')) { + filePath = filePath.slice(1, -1) + } + + let status: GitStatusCode = null + let staged = false + + // Check index status (staged changes) + if (indexStatus === "M") { + status = "modified" + staged = true + } else if (indexStatus === "A") { + status = "added" + staged = true + } else if (indexStatus === "D") { + status = "deleted" + staged = true + } else if (indexStatus === "R") { + status = "renamed" + staged = true + } else if (indexStatus === "C") { + status = "copied" + staged = true + } + + // Check working tree status (unstaged changes) + if (workingStatus === "M") { + status = "modified" + } else if (workingStatus === "D") { + status = "deleted" + } else if (workingStatus === "?") { + status = "untracked" + } else if (workingStatus === "!") { + status = "ignored" + } else if (workingStatus === "U" || indexStatus === "U") { + status = "unmerged" + } + + if (status) { + statusMap.set(filePath, { path: filePath, status, staged }) + } + } + + return statusMap +} + +/** + * Get git status for all files in a directory (with caching) + */ +async function getGitStatus(projectPath: string): Promise> { + // Check cache first + const cached = gitStatusCache.get(projectPath) + const now = Date.now() + + if (cached && now - cached.timestamp < GIT_STATUS_CACHE_TTL) { + return cached.status + } + + try { + // Check if this is a git repository + const { stdout } = await execAsync("git status --porcelain -uall", { + cwd: projectPath, + maxBuffer: 10 * 1024 * 1024, // 10MB buffer for large repos + }) + const status = parseGitStatus(stdout) + + // Cache the result + gitStatusCache.set(projectPath, { status, timestamp: now }) + + return status + } catch (error) { + // Not a git repo or git not available + return new Map() + } +} + +/** + * Start watching .git directory for status changes + */ +function startGitWatching(projectPath: string): void { + const existing = gitWatchers.get(projectPath) + if (existing) { + existing.refCount++ + return + } + + const gitPath = join(projectPath, ".git") + + try { + // Check if .git exists + const watcher = watch(gitPath, { recursive: true }, (eventType, filename) => { + if (!filename) return + + // Only care about files that indicate git state changes + // index = staging area, HEAD = current branch, refs = branches/tags + const isRelevant = + filename === "index" || + filename === "HEAD" || + filename.startsWith("refs/") || + filename === "COMMIT_EDITMSG" || + filename === "MERGE_HEAD" || + filename === "REBASE_HEAD" + + if (!isRelevant) return + + // Debounce and invalidate cache + const timerKey = `git:${projectPath}` + const existingTimer = debounceTimers.get(timerKey) + if (existingTimer) { + clearTimeout(existingTimer) + } + + debounceTimers.set(timerKey, setTimeout(() => { + debounceTimers.delete(timerKey) + // Invalidate git status cache + gitStatusCache.delete(projectPath) + // Emit git change event + fileChangeEmitter.emit(`gitChange:${projectPath}`) + }, DEBOUNCE_MS)) + }) + + watcher.on("error", (error) => { + console.warn(`[files] Git watcher error for ${projectPath}:`, error) + }) + + gitWatchers.set(projectPath, { watcher, refCount: 1 }) + console.log(`[files] Started watching .git: ${projectPath}`) + } catch (error) { + // .git doesn't exist or not accessible + console.warn(`[files] Could not watch .git for ${projectPath}:`, error) + } +} + +/** + * Stop watching .git directory + */ +function stopGitWatching(projectPath: string): void { + const existing = gitWatchers.get(projectPath) + if (!existing) return + + existing.refCount-- + if (existing.refCount <= 0) { + existing.watcher.close() + gitWatchers.delete(projectPath) + // Clean up timer + const timerKey = `git:${projectPath}` + const timer = debounceTimers.get(timerKey) + if (timer) { + clearTimeout(timer) + debounceTimers.delete(timerKey) + } + console.log(`[files] Stopped watching .git: ${projectPath}`) + } +} + /** * Start watching a project directory for changes */ @@ -407,4 +612,63 @@ export const filesRouter = router({ } }) }), + + /** + * Get git status for all files in a project directory + */ + gitStatus: publicProcedure + .input(z.object({ projectPath: z.string() })) + .query(async ({ input }) => { + const { projectPath } = input + + if (!projectPath) { + return {} + } + + try { + const statusMap = await getGitStatus(projectPath) + // Convert Map to plain object for serialization + const result: Record = {} + statusMap.forEach((value, key) => { + result[key] = { status: value.status, staged: value.staged } + }) + return result + } catch (error) { + console.error(`[files] Error getting git status:`, error) + return {} + } + }), + + /** + * Subscribe to git status changes (watches .git directory) + * More efficient than watching all files - only updates when git state changes + */ + watchGitChanges: publicProcedure + .input(z.object({ projectPath: z.string() })) + .subscription(({ input }) => { + const { projectPath } = input + + return observable<{ type: "git-change" }>((emit) => { + if (!projectPath) { + emit.complete() + return () => {} + } + + // Start watching .git directory + startGitWatching(projectPath) + + // Listen for git change events + const onGitChange = () => { + emit.next({ type: "git-change" }) + } + + fileChangeEmitter.on(`gitChange:${projectPath}`, onGitChange) + + // Cleanup when subscription ends + return () => { + fileChangeEmitter.off(`gitChange:${projectPath}`, onGitChange) + stopGitWatching(projectPath) + } + }) + }), }) diff --git a/src/renderer/features/agents/ui/file-tree/FileTreeNode.tsx b/src/renderer/features/agents/ui/file-tree/FileTreeNode.tsx index bfa07758..af36810b 100644 --- a/src/renderer/features/agents/ui/file-tree/FileTreeNode.tsx +++ b/src/renderer/features/agents/ui/file-tree/FileTreeNode.tsx @@ -1,16 +1,54 @@ "use client" import { ChevronRight, File, Folder, FolderOpen } from "lucide-react" -import { memo, useCallback } from "react" +import { memo, useCallback, useMemo } from "react" import { cn } from "../../../../lib/utils" import type { TreeNode } from "./build-file-tree" +// Git status type matching the backend +type GitStatusCode = + | "modified" + | "added" + | "deleted" + | "renamed" + | "copied" + | "untracked" + | "ignored" + | "unmerged" + | null + +export type GitStatusMap = Record + +// Status colors matching VS Code conventions +const STATUS_COLORS: Record = { + modified: "text-yellow-500 dark:text-yellow-400", // Yellow for modified + added: "text-green-500 dark:text-green-400", // Green for added/staged + deleted: "text-red-500 dark:text-red-400", // Red for deleted + renamed: "text-green-500 dark:text-green-400", // Green for renamed + copied: "text-green-500 dark:text-green-400", // Green for copied + untracked: "text-green-600 dark:text-green-500", // Darker green for untracked + ignored: "text-muted-foreground/50", // Dimmed for ignored + unmerged: "text-red-600 dark:text-red-500", // Red for conflicts +} + +// Status indicators (shown after filename) +const STATUS_INDICATORS: Record = { + modified: "M", + added: "A", + deleted: "D", + renamed: "R", + copied: "C", + untracked: "U", + unmerged: "!", +} + interface FileTreeNodeProps { node: TreeNode level: number expandedFolders: Set onToggleFolder: (path: string) => void onSelectFile?: (path: string) => void + gitStatus?: GitStatusMap } export const FileTreeNode = memo(function FileTreeNode({ @@ -19,10 +57,26 @@ export const FileTreeNode = memo(function FileTreeNode({ expandedFolders, onToggleFolder, onSelectFile, + gitStatus = {}, }: FileTreeNodeProps) { const isExpanded = node.type === "folder" && expandedFolders.has(node.path) const hasChildren = node.type === "folder" && node.children.length > 0 + // Get git status for this file + const fileStatus = gitStatus[node.path] + const statusCode = fileStatus?.status + const isStaged = fileStatus?.staged + + // For folders, check if any children have changes + const folderHasChanges = useMemo(() => { + if (node.type !== "folder") return false + + // Check if any file in gitStatus starts with this folder path + return Object.keys(gitStatus).some(path => + path.startsWith(node.path + "/") + ) + }, [node.type, node.path, gitStatus]) + const handleClick = useCallback(() => { if (node.type === "folder") { onToggleFolder(node.path) @@ -33,6 +87,10 @@ export const FileTreeNode = memo(function FileTreeNode({ const paddingLeft = level * 12 + 6 + // Determine text color based on status + const textColorClass = statusCode ? STATUS_COLORS[statusCode] : "text-foreground" + const statusIndicator = statusCode ? STATUS_INDICATORS[statusCode] : null + return (
{/* Children (only for expanded folders) */} @@ -84,6 +161,7 @@ export const FileTreeNode = memo(function FileTreeNode({ expandedFolders={expandedFolders} onToggleFolder={onToggleFolder} onSelectFile={onSelectFile} + gitStatus={gitStatus} /> ))}
diff --git a/src/renderer/features/agents/ui/file-tree/FileTreeSidebar.tsx b/src/renderer/features/agents/ui/file-tree/FileTreeSidebar.tsx index d272d71a..44a10e18 100644 --- a/src/renderer/features/agents/ui/file-tree/FileTreeSidebar.tsx +++ b/src/renderer/features/agents/ui/file-tree/FileTreeSidebar.tsx @@ -1,9 +1,11 @@ "use client" import { useAtom } from "jotai" -import { ChevronDown, RefreshCw, X } from "lucide-react" +import { ChevronsDownUp, RefreshCw } from "lucide-react" import { useCallback, useMemo } from "react" import { Button } from "../../../../components/ui/button" +import { IconDoubleChevronRight } from "../../../../components/ui/icons" +import { Kbd } from "../../../../components/ui/kbd" import { Tooltip, TooltipContent, @@ -12,7 +14,7 @@ import { import { trpc } from "../../../../lib/trpc" import { expandedFoldersAtomFamily } from "../../atoms" import { buildFileTree, countFiles, countFolders } from "./build-file-tree" -import { FileTreeNode } from "./FileTreeNode" +import { FileTreeNode, type GitStatusMap } from "./FileTreeNode" interface FileTreeSidebarProps { projectPath: string | undefined @@ -45,18 +47,42 @@ export function FileTreeSidebar({ }, ) + // Fetch git status + const { + data: gitStatus = {}, + refetch: refetchGitStatus, + } = trpc.files.gitStatus.useQuery( + { projectPath: projectPath || "" }, + { + enabled: !!projectPath, + staleTime: 1000, + }, + ) + // Subscribe to file changes for real-time sync trpc.files.watchChanges.useSubscription( { projectPath: projectPath || "" }, { enabled: !!projectPath, onData: () => { - // Refetch when files change + // Refetch file list when files change refetch() }, }, ) + // Subscribe to git changes separately (more efficient - only watches .git directory) + trpc.files.watchGitChanges.useSubscription( + { projectPath: projectPath || "" }, + { + enabled: !!projectPath, + onData: () => { + // Refetch git status when git state changes (commits, staging, etc.) + refetchGitStatus() + }, + }, + ) + // Build tree from flat entries const tree = useMemo(() => buildFileTree(entries), [entries]) @@ -86,63 +112,68 @@ export function FileTreeSidebar({ // Refresh file list const handleRefresh = useCallback(() => { refetch() - }, [refetch]) + refetchGitStatus() + }, [refetch, refetchGitStatus]) return (
{/* Header */} -
- +
+ Files
{/* Refresh button */} - + - Refresh + Refresh {/* Collapse all button */} - + - Collapse all + Collapse all {/* Close button */} - + - Close + + Close file tree + ⌘B +
@@ -170,6 +201,7 @@ export function FileTreeSidebar({ expandedFolders={expandedFolders} onToggleFolder={handleToggleFolder} onSelectFile={onSelectFile} + gitStatus={gitStatus as GitStatusMap} /> )) )} diff --git a/src/renderer/features/agents/ui/file-tree/index.ts b/src/renderer/features/agents/ui/file-tree/index.ts index 620d5f04..a7b1242e 100644 --- a/src/renderer/features/agents/ui/file-tree/index.ts +++ b/src/renderer/features/agents/ui/file-tree/index.ts @@ -1,3 +1,3 @@ export { FileTreeSidebar } from "./FileTreeSidebar" -export { FileTreeNode } from "./FileTreeNode" +export { FileTreeNode, type GitStatusMap } from "./FileTreeNode" export { buildFileTree, countFiles, countFolders, type TreeNode, type FileEntry } from "./build-file-tree" From 3eb3263f08d8abdb1132efdcb4cce523da5cd23e Mon Sep 17 00:00:00 2001 From: arnavv-guptaa Date: Sat, 17 Jan 2026 23:08:45 +0800 Subject: [PATCH 03/51] updated filesystem tracking --- .../agents/ui/file-tree/FileTreeSidebar.tsx | 87 ++++--------------- 1 file changed, 19 insertions(+), 68 deletions(-) diff --git a/src/renderer/features/agents/ui/file-tree/FileTreeSidebar.tsx b/src/renderer/features/agents/ui/file-tree/FileTreeSidebar.tsx index 44a10e18..36b258b2 100644 --- a/src/renderer/features/agents/ui/file-tree/FileTreeSidebar.tsx +++ b/src/renderer/features/agents/ui/file-tree/FileTreeSidebar.tsx @@ -1,10 +1,9 @@ "use client" import { useAtom } from "jotai" -import { ChevronsDownUp, RefreshCw } from "lucide-react" import { useCallback, useMemo } from "react" import { Button } from "../../../../components/ui/button" -import { IconDoubleChevronRight } from "../../../../components/ui/icons" +import { IconDoubleChevronLeft } from "../../../../components/ui/icons" import { Kbd } from "../../../../components/ui/kbd" import { Tooltip, @@ -38,7 +37,6 @@ export function FileTreeSidebar({ data: entries = [], isLoading, refetch, - isRefetching, } = trpc.files.listAll.useQuery( { projectPath: projectPath || "" }, { @@ -104,17 +102,6 @@ export function FileTreeSidebar({ [expandedFolders, setExpandedFolders], ) - // Collapse all folders - const handleCollapseAll = useCallback(() => { - setExpandedFolders(new Set()) - }, [setExpandedFolders]) - - // Refresh file list - const handleRefresh = useCallback(() => { - refetch() - refetchGitStatus() - }, [refetch, refetchGitStatus]) - return (
{/* Header */} @@ -122,60 +109,24 @@ export function FileTreeSidebar({ Files -
- {/* Refresh button */} - - - - - Refresh - - - {/* Collapse all button */} - - - - - Collapse all - - - {/* Close button */} - - - - - - Close file tree - ⌘B - - -
+ {/* Close button */} + + + + + + Close file tree + ⌘B + +
{/* Content */} From 5596732d4e77c101c5ccb8ca9802e6b6e7e923fb Mon Sep 17 00:00:00 2001 From: arnavv-guptaa Date: Sun, 18 Jan 2026 16:00:02 +0800 Subject: [PATCH 04/51] added glide data viewer --- bun.lock | 105 +++ package.json | 3 + src/main/lib/parsers/csv-parser.ts | 151 ++++ src/main/lib/parsers/index.ts | 136 +++ src/main/lib/parsers/json-parser.ts | 208 +++++ src/main/lib/parsers/sqlite-parser.ts | 233 +++++ src/main/lib/parsers/types.ts | 23 + src/main/lib/trpc/routers/files.ts | 68 ++ src/renderer/features/agents/atoms/index.ts | 69 ++ .../features/agents/main/active-chat.tsx | 57 +- .../agents/ui/file-tree/FileTreeNode.tsx | 37 +- .../data/components/data-viewer-sidebar.tsx | 834 ++++++++++++++++++ .../features/data/components/index.ts | 1 + test-data/products.json | 122 +++ test-data/sample.csv | 21 + test-data/sample.db | Bin 0 -> 16384 bytes 16 files changed, 2065 insertions(+), 3 deletions(-) create mode 100644 src/main/lib/parsers/csv-parser.ts create mode 100644 src/main/lib/parsers/index.ts create mode 100644 src/main/lib/parsers/json-parser.ts create mode 100644 src/main/lib/parsers/sqlite-parser.ts create mode 100644 src/main/lib/parsers/types.ts create mode 100644 src/renderer/features/data/components/data-viewer-sidebar.tsx create mode 100644 src/renderer/features/data/components/index.ts create mode 100644 test-data/products.json create mode 100644 test-data/sample.csv create mode 100644 test-data/sample.db diff --git a/bun.lock b/bun.lock index 4c83cb85..948dafb7 100644 --- a/bun.lock +++ b/bun.lock @@ -8,6 +8,7 @@ "@anthropic-ai/claude-agent-sdk": "^0.2.5", "@git-diff-view/react": "^0.0.35", "@git-diff-view/shiki": "^0.0.36", + "@glideapps/glide-data-grid": "^6.0.3", "@radix-ui/react-accordion": "^1.2.11", "@radix-ui/react-alert-dialog": "^1.1.1", "@radix-ui/react-checkbox": "^1.3.3", @@ -52,6 +53,7 @@ "motion": "^11.15.0", "next-themes": "^0.4.4", "node-pty": "^1.1.0", + "papaparse": "^5.5.3", "pidtree": "^0.6.0", "posthog-js": "^1.239.1", "posthog-node": "^5.20.0", @@ -80,6 +82,7 @@ "@electron-toolkit/utils": "^4.0.0", "@types/better-sqlite3": "^7.6.13", "@types/node": "^20.17.50", + "@types/papaparse": "^5.5.2", "@types/react": "^19.0.7", "@types/react-dom": "^19.0.3", "@types/react-syntax-highlighter": "^15.5.13", @@ -144,8 +147,16 @@ "@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="], + "@babel/plugin-proposal-export-namespace-from": ["@babel/plugin-proposal-export-namespace-from@7.18.9", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.18.9", "@babel/plugin-syntax-export-namespace-from": "^7.8.3" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-k1NtHyOMvlDDFeb9G5PhUXuGj8m/wiwojgQVEhJ/fsVsMCpLyOP4h0uGEjYJKrRI+EVPlb5Jk+Gt9P97lOGwtA=="], + + "@babel/plugin-syntax-dynamic-import": ["@babel/plugin-syntax-dynamic-import@7.8.3", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ=="], + + "@babel/plugin-syntax-export-namespace-from": ["@babel/plugin-syntax-export-namespace-from@7.8.3", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.8.3" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-MXf5laXo6c1IbEbegDmzGPwGNTsHZmEy6QGznu5Sh2UCWvueywb2ee+CCE4zQiZstxU9BMoQO9i6zUFSY0Kj0Q=="], + "@babel/plugin-transform-arrow-functions": ["@babel/plugin-transform-arrow-functions@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA=="], + "@babel/plugin-transform-modules-commonjs": ["@babel/plugin-transform-modules-commonjs@7.28.6", "", { "dependencies": { "@babel/helper-module-transforms": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA=="], + "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="], "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="], @@ -178,6 +189,10 @@ "@electron/universal": ["@electron/universal@2.0.1", "", { "dependencies": { "@electron/asar": "^3.2.7", "@malept/cross-spawn-promise": "^2.0.0", "debug": "^4.3.1", "dir-compare": "^4.2.0", "fs-extra": "^11.1.1", "minimatch": "^9.0.3", "plist": "^3.1.0" } }, "sha512-fKpv9kg4SPmt+hY7SVBnIYULE9QJl8L3sCfcBsnqbJwwBwAeTLokJ9TRt9y7bK0JAzIW2y78TVVjvnQEms/yyA=="], + "@emotion/is-prop-valid": ["@emotion/is-prop-valid@1.4.0", "", { "dependencies": { "@emotion/memoize": "^0.9.0" } }, "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw=="], + + "@emotion/memoize": ["@emotion/memoize@0.9.0", "", {}, "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ=="], + "@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="], "@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="], @@ -252,6 +267,8 @@ "@git-diff-view/shiki": ["@git-diff-view/shiki@0.0.36", "", { "dependencies": { "@types/hast": "^3.0.0", "shiki": "^3.9.2" } }, "sha512-t6LGKISEvE0q7u2AR1rq2RLgCcccRttuKE617D1MiHecO0Xd/xvsMhqxW4PgDMkMadhbzWnt6IhBbrdgqBKPwQ=="], + "@glideapps/glide-data-grid": ["@glideapps/glide-data-grid@6.0.3", "", { "dependencies": { "@linaria/react": "^4.5.3", "canvas-hypertxt": "^1.0.3", "react-number-format": "^5.0.0" }, "peerDependencies": { "lodash": "^4.17.19", "marked": "^4.0.10", "react": "^16.12.0 || 17.x || 18.x", "react-dom": "^16.12.0 || 17.x || 18.x", "react-responsive-carousel": "^3.2.7" } }, "sha512-YXKggiNOaEemf0jP0jORq2EQKz+zXms+6mGzZc+q0mLMjmgzzoGLOQC1uYcynXSj1R61bd27JcPFsoH+Gj37Vg=="], + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="], "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.0.4" }, "os": "darwin", "cpu": "x64" }, "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q=="], @@ -302,6 +319,16 @@ "@kwsites/promise-deferred": ["@kwsites/promise-deferred@1.1.1", "", {}, "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw=="], + "@linaria/core": ["@linaria/core@4.5.4", "", { "dependencies": { "@linaria/logger": "^4.5.0", "@linaria/tags": "^4.5.4", "@linaria/utils": "^4.5.3" } }, "sha512-vMs/5iU0stxjfbBCxobIgY+wSQx4G8ukNwrhjPVD+6bF9QrTwi5rl0mKaCMxaGMjnfsLRiiM3i+hnWLIEYLdSg=="], + + "@linaria/logger": ["@linaria/logger@4.5.0", "", { "dependencies": { "debug": "^4.1.1", "picocolors": "^1.0.0" } }, "sha512-XdQLk242Cpcsc9a3Cz1ktOE5ysTo2TpxdeFQEPwMm8Z/+F/S6ZxBDdHYJL09srXWz3hkJr3oS2FPuMZNH1HIxw=="], + + "@linaria/react": ["@linaria/react@4.5.4", "", { "dependencies": { "@emotion/is-prop-valid": "^1.2.0", "@linaria/core": "^4.5.4", "@linaria/tags": "^4.5.4", "@linaria/utils": "^4.5.3", "minimatch": "^9.0.3", "react-html-attributes": "^1.4.6", "ts-invariant": "^0.10.3" }, "peerDependencies": { "react": ">=16" } }, "sha512-/dhCVCsfdGPfQCPV0q5yy+DDlFXepvfXrw/os2fC+Xo1v9J/9gyiaBBWHzcumauvNNFj8aN6vRkj89fMujPHew=="], + + "@linaria/tags": ["@linaria/tags@4.5.4", "", { "dependencies": { "@babel/generator": "^7.22.9", "@linaria/logger": "^4.5.0", "@linaria/utils": "^4.5.3" } }, "sha512-HPxLB6HlJWLi6o8+8lTLegOmDnbMbuzEE+zzunaPZEGSoIIYx8HAv5VbY/sG/zNyxDElk6laiAwEVWN8h5/zxg=="], + + "@linaria/utils": ["@linaria/utils@4.5.3", "", { "dependencies": { "@babel/core": "^7.22.9", "@babel/generator": "^7.22.9", "@babel/plugin-proposal-export-namespace-from": "^7.18.9", "@babel/plugin-syntax-dynamic-import": "^7.8.3", "@babel/plugin-transform-modules-commonjs": "^7.22.5", "@babel/template": "^7.22.5", "@babel/traverse": "^7.22.8", "@babel/types": "^7.22.5", "@linaria/logger": "^4.5.0", "babel-merge": "^3.0.0", "find-up": "^5.0.0", "minimatch": "^9.0.3" } }, "sha512-tSpxA3Zn0DKJ2n/YBnYAgiDY+MNvkmzAHrD8R9PKrpGaZ+wz1jQEmE1vGn1cqh8dJyWK0NzPAA8sf1cqa+RmAg=="], + "@malept/cross-spawn-promise": ["@malept/cross-spawn-promise@2.0.0", "", { "dependencies": { "cross-spawn": "^7.0.1" } }, "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg=="], "@malept/flatpak-bundler": ["@malept/flatpak-bundler@0.4.0", "", { "dependencies": { "debug": "^4.1.1", "fs-extra": "^9.0.0", "lodash": "^4.17.15", "tmp-promise": "^3.0.2" } }, "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q=="], @@ -636,6 +663,8 @@ "@types/node": ["@types/node@20.19.27", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug=="], + "@types/papaparse": ["@types/papaparse@5.5.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA=="], + "@types/pg": ["@types/pg@8.15.6", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ=="], "@types/pg-pool": ["@types/pg-pool@2.0.6", "", { "dependencies": { "@types/pg": "*" } }, "sha512-TaAUE5rq2VQYxab5Ts7WZhKNmuN78Q6PiFonTDdpbx8a1H0M1vhy3rhiMjl+e2iHmogyMw7jZF4FrE6eJUy5HQ=="], @@ -746,6 +775,8 @@ "autoprefixer": ["autoprefixer@10.4.23", "", { "dependencies": { "browserslist": "^4.28.1", "caniuse-lite": "^1.0.30001760", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA=="], + "babel-merge": ["babel-merge@3.0.0", "", { "dependencies": { "deepmerge": "^2.2.1", "object.omit": "^3.0.0" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-eBOBtHnzt9xvnjpYNI5HmaPp/b2vMveE5XggzqHnQeHJ8mFIBrBv6WZEVIj5jJ2uwTItkqKo9gWzEEcBxEq0yw=="], + "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], @@ -800,6 +831,8 @@ "caniuse-lite": ["caniuse-lite@1.0.30001762", "", {}, "sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw=="], + "canvas-hypertxt": ["canvas-hypertxt@1.0.3", "", {}, "sha512-+VsMpRr64jYgKq2IeFUNel3vCZH/IzS+iXSHxmUV3IUH5dXlC9xHz4AwtPZisDxZ5MWcuK0V+TXgPKFPiZnxzg=="], + "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -826,6 +859,8 @@ "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], + "classnames": ["classnames@2.5.1", "", {}, "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="], + "clean-stack": ["clean-stack@2.2.0", "", {}, "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A=="], "cli-cursor": ["cli-cursor@3.1.0", "", { "dependencies": { "restore-cursor": "^3.1.0" } }, "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw=="], @@ -898,6 +933,8 @@ "deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="], + "deepmerge": ["deepmerge@2.2.1", "", {}, "sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA=="], + "deepmerge-ts": ["deepmerge-ts@7.1.5", "", {}, "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw=="], "defaults": ["defaults@1.0.4", "", { "dependencies": { "clone": "^1.0.2" } }, "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A=="], @@ -1050,6 +1087,8 @@ "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], @@ -1138,6 +1177,8 @@ "hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="], + "html-element-attributes": ["html-element-attributes@1.3.1", "", {}, "sha512-UrRKgp5sQmRnDy4TEwAUsu14XBUlzKB8U3hjIYDjcZ3Hbp86Jtftzxfgrv6E/ii/h78tsaZwAnAE8HwnHr0dPA=="], + "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="], "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], @@ -1206,6 +1247,8 @@ "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], + "is-plain-object": ["is-plain-object@2.0.4", "", { "dependencies": { "isobject": "^3.0.1" } }, "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og=="], + "is-unicode-supported": ["is-unicode-supported@0.1.0", "", {}, "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw=="], "is-what": ["is-what@5.5.0", "", {}, "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw=="], @@ -1216,6 +1259,8 @@ "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "isobject": ["isobject@3.0.1", "", {}, "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg=="], + "jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], "jake": ["jake@10.9.4", "", { "dependencies": { "async": "^3.2.6", "filelist": "^1.0.4", "picocolors": "^1.1.1" }, "bin": { "jake": "bin/cli.js" } }, "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA=="], @@ -1254,6 +1299,8 @@ "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], "lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="], @@ -1274,6 +1321,8 @@ "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], + "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + "lowercase-keys": ["lowercase-keys@2.0.0", "", {}, "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA=="], "lowlight": ["lowlight@3.3.0", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.0.0", "highlight.js": "~11.11.0" } }, "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ=="], @@ -1290,6 +1339,8 @@ "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], + "marked": ["marked@4.3.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A=="], + "matcher": ["matcher@3.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="], "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], @@ -1470,6 +1521,8 @@ "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="], + "object.omit": ["object.omit@3.0.0", "", { "dependencies": { "is-extendable": "^1.0.0" } }, "sha512-EO+BCv6LJfu+gBIF3ggLicFebFLN5zqzz/WWJlMFfkMyGth+oBkhxzDl0wx2W4GkLzuQs/FsSkXZb2IMWQqmBQ=="], + "ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="], "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], @@ -1486,12 +1539,18 @@ "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], + "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + "p-map": ["p-map@4.0.0", "", { "dependencies": { "aggregate-error": "^3.0.0" } }, "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ=="], "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], + "papaparse": ["papaparse@5.5.3", "", {}, "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A=="], + "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], @@ -1570,6 +1629,8 @@ "promise-retry": ["promise-retry@2.0.1", "", { "dependencies": { "err-code": "^2.0.2", "retry": "^0.12.0" } }, "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g=="], + "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], + "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], "pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="], @@ -1592,18 +1653,28 @@ "react-dom": ["react-dom@19.2.1", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.1" } }, "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg=="], + "react-easy-swipe": ["react-easy-swipe@0.0.21", "", { "dependencies": { "prop-types": "^15.5.8" } }, "sha512-OeR2jAxdoqUMHIn/nS9fgreI5hSpgGoL5ezdal4+oO7YSSgJR8ga+PkYGJrSrJ9MKlPcQjMQXnketrD7WNmNsg=="], + "react-hotkeys-hook": ["react-hotkeys-hook@4.6.2", "", { "peerDependencies": { "react": ">=16.8.1", "react-dom": ">=16.8.1" } }, "sha512-FmP+ZriY3EG59Ug/lxNfrObCnW9xQShgk7Nb83+CkpfkcCpfS95ydv+E9JuXA5cp8KtskU7LGlIARpkc92X22Q=="], + "react-html-attributes": ["react-html-attributes@1.4.6", "", { "dependencies": { "html-element-attributes": "^1.0.0" } }, "sha512-uS3MmThNKFH2EZUQQw4k5pIcU7XIr208UE5dktrj/GOH1CMagqxDl4DCLpt3o2l9x+IB5nVYBeN3Cr4IutBXAg=="], + "react-icons": ["react-icons@5.5.0", "", { "peerDependencies": { "react": "*" } }, "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw=="], + "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + "react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="], + "react-number-format": ["react-number-format@5.4.4", "", { "peerDependencies": { "react": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-wOmoNZoOpvMminhifQYiYSTCLUDOiUbBunrMrMjA+dV52sY+vck1S4UhR6PkgnoCquvvMSeJjErXZ4qSaWCliA=="], + "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], "react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="], "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], + "react-responsive-carousel": ["react-responsive-carousel@3.2.23", "", { "dependencies": { "classnames": "^2.2.5", "prop-types": "^15.5.8", "react-easy-swipe": "^0.0.21" } }, "sha512-pqJLsBaKHWJhw/ItODgbVoziR2z4lpcJg+YwmRlSk4rKH32VE633mAtZZ9kDXjy4wFO+pgUZmDKPsPe1fPmHCg=="], + "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], "react-syntax-highlighter": ["react-syntax-highlighter@16.1.0", "", { "dependencies": { "@babel/runtime": "^7.28.4", "highlight.js": "^10.4.1", "highlightjs-vue": "^1.0.0", "lowlight": "^1.17.0", "prismjs": "^1.30.0", "refractor": "^5.0.0" }, "peerDependencies": { "react": ">= 0.14.0" } }, "sha512-E40/hBiP5rCNwkeBN1vRP+xow1X0pndinO+z3h7HLsHyjztbyjfzNWNKuAsJj+7DLam9iT4AaaOZnueCU+Nplg=="], @@ -1800,6 +1871,8 @@ "ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="], + "ts-invariant": ["ts-invariant@0.10.3", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-uivwYcQaxAucv1CzRp2n/QdYPo4ILf9VXgH19zEIjFx2EJufV16P0JtJVpYHy89DItG6Kwj2oIUjrcK5au+4tQ=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], @@ -1896,6 +1969,16 @@ "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@babel/plugin-proposal-export-namespace-from/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], + + "@babel/plugin-syntax-dynamic-import/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], + + "@babel/plugin-syntax-export-namespace-from/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], + + "@babel/plugin-transform-modules-commonjs/@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], + + "@babel/plugin-transform-modules-commonjs/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], + "@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="], "@electron/asar/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], @@ -1924,6 +2007,10 @@ "@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], + "@linaria/react/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + + "@linaria/utils/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + "@malept/flatpak-bundler/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], "@opentelemetry/instrumentation-http/@opentelemetry/core": ["@opentelemetry/core@2.2.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw=="], @@ -2024,6 +2111,8 @@ "minizlib/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + "object.omit/is-extendable": ["is-extendable@1.0.1", "", { "dependencies": { "is-plain-object": "^2.0.4" } }, "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA=="], + "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], "path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], @@ -2048,6 +2137,10 @@ "zip-stream/archiver-utils": ["archiver-utils@3.0.4", "", { "dependencies": { "glob": "^7.2.3", "graceful-fs": "^4.2.0", "lazystream": "^1.0.0", "lodash.defaults": "^4.2.0", "lodash.difference": "^4.5.0", "lodash.flatten": "^4.4.0", "lodash.isplainobject": "^4.0.6", "lodash.union": "^4.6.0", "normalize-path": "^3.0.0", "readable-stream": "^3.6.0" } }, "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw=="], + "@babel/plugin-transform-modules-commonjs/@babel/helper-module-transforms/@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], + + "@babel/plugin-transform-modules-commonjs/@babel/helper-module-transforms/@babel/traverse": ["@babel/traverse@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/types": "^7.28.6", "debug": "^4.3.1" } }, "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg=="], + "@electron/asar/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], "@electron/get/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], @@ -2142,6 +2235,18 @@ "make-fetch-happen/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], + "@babel/plugin-transform-modules-commonjs/@babel/helper-module-transforms/@babel/helper-module-imports/@babel/types": ["@babel/types@7.28.6", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg=="], + + "@babel/plugin-transform-modules-commonjs/@babel/helper-module-transforms/@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.28.6", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q=="], + + "@babel/plugin-transform-modules-commonjs/@babel/helper-module-transforms/@babel/traverse/@babel/generator": ["@babel/generator@7.28.6", "", { "dependencies": { "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw=="], + + "@babel/plugin-transform-modules-commonjs/@babel/helper-module-transforms/@babel/traverse/@babel/parser": ["@babel/parser@7.28.6", "", { "dependencies": { "@babel/types": "^7.28.6" }, "bin": "./bin/babel-parser.js" }, "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ=="], + + "@babel/plugin-transform-modules-commonjs/@babel/helper-module-transforms/@babel/traverse/@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], + + "@babel/plugin-transform-modules-commonjs/@babel/helper-module-transforms/@babel/traverse/@babel/types": ["@babel/types@7.28.6", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg=="], + "@git-diff-view/shiki/shiki/@shikijs/engine-javascript/oniguruma-to-es": ["oniguruma-to-es@4.3.4", "", { "dependencies": { "oniguruma-parser": "^0.12.1", "regex": "^6.0.1", "regex-recursion": "^6.0.2" } }, "sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA=="], "@git-diff-view/shiki/shiki/@shikijs/engine-javascript/oniguruma-to-es/regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="], diff --git a/package.json b/package.json index 243272fd..59fdbf3a 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@anthropic-ai/claude-agent-sdk": "^0.2.5", "@git-diff-view/react": "^0.0.35", "@git-diff-view/shiki": "^0.0.36", + "@glideapps/glide-data-grid": "^6.0.3", "@radix-ui/react-accordion": "^1.2.11", "@radix-ui/react-alert-dialog": "^1.1.1", "@radix-ui/react-checkbox": "^1.3.3", @@ -76,6 +77,7 @@ "motion": "^11.15.0", "next-themes": "^0.4.4", "node-pty": "^1.1.0", + "papaparse": "^5.5.3", "pidtree": "^0.6.0", "posthog-js": "^1.239.1", "posthog-node": "^5.20.0", @@ -104,6 +106,7 @@ "@electron-toolkit/utils": "^4.0.0", "@types/better-sqlite3": "^7.6.13", "@types/node": "^20.17.50", + "@types/papaparse": "^5.5.2", "@types/react": "^19.0.7", "@types/react-dom": "^19.0.3", "@types/react-syntax-highlighter": "^15.5.13", diff --git a/src/main/lib/parsers/csv-parser.ts b/src/main/lib/parsers/csv-parser.ts new file mode 100644 index 00000000..3b21b04b --- /dev/null +++ b/src/main/lib/parsers/csv-parser.ts @@ -0,0 +1,151 @@ +import Papa from "papaparse" +import { readFile } from "node:fs/promises" +import type { ParsedData, ParsedColumn, ColumnType } from "./types" + +/** + * Infer the type of a value + */ +function inferType(value: unknown): ColumnType { + if (value === null || value === undefined || value === "") { + return "null" + } + + if (typeof value === "boolean") { + return "boolean" + } + + if (typeof value === "number" && !isNaN(value)) { + return "number" + } + + const strValue = String(value).trim() + + // Check for boolean strings + if (strValue.toLowerCase() === "true" || strValue.toLowerCase() === "false") { + return "boolean" + } + + // Check for number + if (strValue !== "" && !isNaN(Number(strValue))) { + return "number" + } + + // Check for date (ISO format or common formats) + const datePatterns = [ + /^\d{4}-\d{2}-\d{2}$/, // YYYY-MM-DD + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/, // ISO 8601 + /^\d{2}\/\d{2}\/\d{4}$/, // MM/DD/YYYY + /^\d{2}-\d{2}-\d{4}$/, // DD-MM-YYYY + ] + + for (const pattern of datePatterns) { + if (pattern.test(strValue)) { + const date = new Date(strValue) + if (!isNaN(date.getTime())) { + return "date" + } + } + } + + return "string" +} + +/** + * Infer column types from sample rows + */ +function inferColumnTypes( + columns: string[], + rows: Record[] +): ParsedColumn[] { + const sampleSize = Math.min(rows.length, 100) + const typeMap = new Map>() + + // Initialize type sets for each column + for (const col of columns) { + typeMap.set(col, new Set()) + } + + // Sample rows to infer types + for (let i = 0; i < sampleSize; i++) { + const row = rows[i] + for (const col of columns) { + const value = row[col] + const type = inferType(value) + typeMap.get(col)?.add(type) + } + } + + // Determine final type for each column + return columns.map((name) => { + const types = typeMap.get(name) || new Set() + + // Remove null from consideration if there are other types + types.delete("null") + + if (types.size === 0) { + return { name, type: "null" as ColumnType } + } + + if (types.size === 1) { + return { name, type: Array.from(types)[0] } + } + + // Multiple types = mixed + return { name, type: "mixed" as ColumnType } + }) +} + +/** + * Parse a CSV or TSV file + */ +export async function parseCsvFile( + filePath: string, + options: { limit?: number; offset?: number } = {} +): Promise { + const { limit = 1000, offset = 0 } = options + + const content = await readFile(filePath, "utf-8") + + // Detect delimiter (CSV vs TSV) + const firstLine = content.split("\n")[0] || "" + const delimiter = firstLine.includes("\t") ? "\t" : "," + + const result = Papa.parse>(content, { + header: true, + delimiter, + skipEmptyLines: true, + dynamicTyping: true, // Automatically convert numbers + }) + + if (result.errors.length > 0) { + console.warn("[csv-parser] Parse warnings:", result.errors.slice(0, 5)) + } + + const allRows = result.data + const totalRows = allRows.length + const columns = result.meta.fields || [] + + // Apply offset and limit + const slicedRows = allRows.slice(offset, offset + limit) + const truncated = offset + limit < totalRows + + // Infer column types + const parsedColumns = inferColumnTypes(columns, slicedRows) + + return { + columns: parsedColumns, + rows: slicedRows, + totalRows, + truncated, + } +} + +/** + * Count rows in a CSV file without loading all data + */ +export async function countCsvRows(filePath: string): Promise { + const content = await readFile(filePath, "utf-8") + const lines = content.split("\n").filter((line) => line.trim() !== "") + // Subtract 1 for header row + return Math.max(0, lines.length - 1) +} diff --git a/src/main/lib/parsers/index.ts b/src/main/lib/parsers/index.ts new file mode 100644 index 00000000..14643161 --- /dev/null +++ b/src/main/lib/parsers/index.ts @@ -0,0 +1,136 @@ +import path from "node:path" +import { stat } from "node:fs/promises" +import type { DataFileInfo, DataFileType, ParsedData } from "./types" +import { parseCsvFile } from "./csv-parser" +import { parseJsonFile } from "./json-parser" +import { + listSqliteTables as listTables, + previewSqliteTable, + querySqlite as querySqliteDb, +} from "./sqlite-parser" + +// Re-export types +export * from "./types" + +/** + * Map file extensions to data file types + */ +const DATA_EXTENSIONS: Record = { + ".csv": "csv", + ".tsv": "csv", + ".json": "json", + ".jsonl": "json", + ".db": "sqlite", + ".sqlite": "sqlite", + ".sqlite3": "sqlite", +} + +/** + * Detect the data file type from the file path + */ +export function detectFileType(filePath: string): DataFileType { + const ext = path.extname(filePath).toLowerCase() + return DATA_EXTENSIONS[ext] || "unknown" +} + +/** + * Check if a file is a supported data file + */ +export function isDataFile(filePath: string): boolean { + return detectFileType(filePath) !== "unknown" +} + +/** + * Get information about a data file + */ +export async function getDataFileInfo(filePath: string): Promise { + const fileType = detectFileType(filePath) + const fileName = path.basename(filePath) + + try { + const fileStat = await stat(filePath) + + const info: DataFileInfo = { + path: filePath, + name: fileName, + type: fileType, + size: fileStat.size, + } + + // For SQLite files, list tables + if (fileType === "sqlite") { + try { + info.tables = listTables(filePath) + } catch (error) { + console.warn("[parsers] Failed to list SQLite tables:", error) + info.tables = [] + } + } + + return info + } catch (error) { + console.error("[parsers] Failed to get file info:", error) + return { + path: filePath, + name: fileName, + type: fileType, + size: 0, + } + } +} + +/** + * Parse a data file and return structured data + */ +export async function parseDataFile( + filePath: string, + options: { limit?: number; offset?: number; tableName?: string } = {} +): Promise { + const fileType = detectFileType(filePath) + const { limit = 1000, offset = 0, tableName } = options + + switch (fileType) { + case "csv": + return parseCsvFile(filePath, { limit, offset }) + + case "json": + return parseJsonFile(filePath, { limit, offset }) + + case "sqlite": { + // For SQLite, we need a table name + if (tableName) { + return previewSqliteTable(filePath, tableName, { limit, offset }) + } + + // If no table specified, get the first table + const tables = listTables(filePath) + if (tables.length === 0) { + return { + columns: [], + rows: [], + totalRows: 0, + truncated: false, + } + } + + return previewSqliteTable(filePath, tables[0], { limit, offset }) + } + + default: + throw new Error(`Unsupported file type: ${fileType}`) + } +} + +/** + * Execute a SQL query on a SQLite file + */ +export function querySqlite(filePath: string, sql: string): ParsedData { + return querySqliteDb(filePath, sql) +} + +/** + * List tables in a SQLite file + */ +export function listSqliteTables(filePath: string): string[] { + return listTables(filePath) +} diff --git a/src/main/lib/parsers/json-parser.ts b/src/main/lib/parsers/json-parser.ts new file mode 100644 index 00000000..380a7419 --- /dev/null +++ b/src/main/lib/parsers/json-parser.ts @@ -0,0 +1,208 @@ +import { readFile } from "node:fs/promises" +import type { ParsedData, ParsedColumn, ColumnType } from "./types" + +/** + * Infer the type of a value + */ +function inferType(value: unknown): ColumnType { + if (value === null || value === undefined) { + return "null" + } + + if (typeof value === "boolean") { + return "boolean" + } + + if (typeof value === "number" && !isNaN(value)) { + return "number" + } + + if (typeof value === "string") { + // Check for date strings + const datePatterns = [ + /^\d{4}-\d{2}-\d{2}$/, + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/, + ] + + for (const pattern of datePatterns) { + if (pattern.test(value)) { + const date = new Date(value) + if (!isNaN(date.getTime())) { + return "date" + } + } + } + + return "string" + } + + // Objects and arrays are treated as strings (JSON stringified) + return "string" +} + +/** + * Infer column types from sample rows + */ +function inferColumnTypes( + columns: string[], + rows: Record[] +): ParsedColumn[] { + const sampleSize = Math.min(rows.length, 100) + const typeMap = new Map>() + + for (const col of columns) { + typeMap.set(col, new Set()) + } + + for (let i = 0; i < sampleSize; i++) { + const row = rows[i] + for (const col of columns) { + const value = row[col] + const type = inferType(value) + typeMap.get(col)?.add(type) + } + } + + return columns.map((name) => { + const types = typeMap.get(name) || new Set() + types.delete("null") + + if (types.size === 0) { + return { name, type: "null" as ColumnType } + } + + if (types.size === 1) { + return { name, type: Array.from(types)[0] } + } + + return { name, type: "mixed" as ColumnType } + }) +} + +/** + * Extract all unique keys from an array of objects + */ +function extractColumns(rows: Record[]): string[] { + const columnSet = new Set() + const sampleSize = Math.min(rows.length, 100) + + for (let i = 0; i < sampleSize; i++) { + const row = rows[i] + if (row && typeof row === "object") { + for (const key of Object.keys(row)) { + columnSet.add(key) + } + } + } + + return Array.from(columnSet) +} + +/** + * Flatten nested objects for display + */ +function flattenRow(row: Record): Record { + const flattened: Record = {} + + for (const [key, value] of Object.entries(row)) { + if (value !== null && typeof value === "object" && !Array.isArray(value)) { + // Stringify nested objects + flattened[key] = JSON.stringify(value) + } else if (Array.isArray(value)) { + // Stringify arrays + flattened[key] = JSON.stringify(value) + } else { + flattened[key] = value + } + } + + return flattened +} + +/** + * Parse a JSON file (supports arrays and JSON Lines) + */ +export async function parseJsonFile( + filePath: string, + options: { limit?: number; offset?: number } = {} +): Promise { + const { limit = 1000, offset = 0 } = options + + const content = await readFile(filePath, "utf-8") + let allRows: Record[] = [] + + // Try parsing as JSON array first + try { + const parsed = JSON.parse(content) + + if (Array.isArray(parsed)) { + // JSON array of objects + allRows = parsed.filter( + (item): item is Record => + item !== null && typeof item === "object" + ) + } else if (typeof parsed === "object" && parsed !== null) { + // Single object - wrap in array + allRows = [parsed as Record] + } + } catch { + // Try parsing as JSON Lines (JSONL) + const lines = content.split("\n").filter((line) => line.trim() !== "") + + for (const line of lines) { + try { + const parsed = JSON.parse(line) + if (typeof parsed === "object" && parsed !== null) { + allRows.push(parsed as Record) + } + } catch { + // Skip invalid lines + console.warn("[json-parser] Skipping invalid JSON line") + } + } + } + + if (allRows.length === 0) { + return { + columns: [], + rows: [], + totalRows: 0, + truncated: false, + } + } + + const totalRows = allRows.length + + // Apply offset and limit + const slicedRows = allRows.slice(offset, offset + limit).map(flattenRow) + const truncated = offset + limit < totalRows + + // Extract and infer columns + const columns = extractColumns(slicedRows) + const parsedColumns = inferColumnTypes(columns, slicedRows) + + return { + columns: parsedColumns, + rows: slicedRows, + totalRows, + truncated, + } +} + +/** + * Count rows in a JSON file + */ +export async function countJsonRows(filePath: string): Promise { + const content = await readFile(filePath, "utf-8") + + try { + const parsed = JSON.parse(content) + if (Array.isArray(parsed)) { + return parsed.length + } + return 1 // Single object + } catch { + // JSON Lines - count lines + return content.split("\n").filter((line) => line.trim() !== "").length + } +} diff --git a/src/main/lib/parsers/sqlite-parser.ts b/src/main/lib/parsers/sqlite-parser.ts new file mode 100644 index 00000000..a7fcaadc --- /dev/null +++ b/src/main/lib/parsers/sqlite-parser.ts @@ -0,0 +1,233 @@ +import Database from "better-sqlite3" +import type { ParsedData, ParsedColumn, ColumnType } from "./types" + +/** + * Map SQLite type affinity to our column types + */ +function mapSqliteType(sqliteType: string | null): ColumnType { + if (!sqliteType) return "mixed" + + const upperType = sqliteType.toUpperCase() + + if ( + upperType.includes("INT") || + upperType.includes("REAL") || + upperType.includes("FLOAT") || + upperType.includes("DOUBLE") || + upperType.includes("NUMERIC") || + upperType.includes("DECIMAL") + ) { + return "number" + } + + if (upperType.includes("BOOL")) { + return "boolean" + } + + if ( + upperType.includes("DATE") || + upperType.includes("TIME") || + upperType.includes("TIMESTAMP") + ) { + return "date" + } + + if ( + upperType.includes("TEXT") || + upperType.includes("CHAR") || + upperType.includes("CLOB") || + upperType.includes("VARCHAR") + ) { + return "string" + } + + if (upperType.includes("BLOB")) { + return "string" // Display as hex or base64 + } + + return "mixed" +} + +/** + * List all tables in a SQLite database + */ +export function listSqliteTables(filePath: string): string[] { + const db = new Database(filePath, { readonly: true }) + + try { + const tables = db + .prepare( + `SELECT name FROM sqlite_master + WHERE type='table' AND name NOT LIKE 'sqlite_%' + ORDER BY name` + ) + .all() as { name: string }[] + + return tables.map((t) => t.name) + } finally { + db.close() + } +} + +/** + * Get column information for a table + */ +function getTableColumns( + db: Database.Database, + tableName: string +): ParsedColumn[] { + // Use PRAGMA to get column info + const columns = db.prepare(`PRAGMA table_info("${tableName}")`).all() as { + cid: number + name: string + type: string + notnull: number + dflt_value: unknown + pk: number + }[] + + return columns.map((col) => ({ + name: col.name, + type: mapSqliteType(col.type), + })) +} + +/** + * Query a SQLite database and return parsed data + */ +export function querySqlite( + filePath: string, + sql: string, + options: { limit?: number } = {} +): ParsedData { + const { limit = 1000 } = options + const db = new Database(filePath, { readonly: true }) + + try { + // Add LIMIT if not present (safety measure) + let querySql = sql.trim() + if ( + !querySql.toUpperCase().includes("LIMIT") && + querySql.toUpperCase().startsWith("SELECT") + ) { + querySql = `${querySql} LIMIT ${limit}` + } + + const stmt = db.prepare(querySql) + const rows = stmt.all() as Record[] + + if (rows.length === 0) { + return { + columns: [], + rows: [], + totalRows: 0, + truncated: false, + } + } + + // Get columns from first row + const columnNames = Object.keys(rows[0]) + const columns: ParsedColumn[] = columnNames.map((name) => ({ + name, + type: inferTypeFromValue(rows[0][name]), + })) + + return { + columns, + rows, + totalRows: rows.length, + truncated: rows.length >= limit, + } + } finally { + db.close() + } +} + +/** + * Preview data from a specific table + */ +export function previewSqliteTable( + filePath: string, + tableName: string, + options: { limit?: number; offset?: number } = {} +): ParsedData { + const { limit = 1000, offset = 0 } = options + const db = new Database(filePath, { readonly: true }) + + try { + // Get column info + const columns = getTableColumns(db, tableName) + + // Get total row count + const countResult = db + .prepare(`SELECT COUNT(*) as count FROM "${tableName}"`) + .get() as { count: number } + const totalRows = countResult.count + + // Get rows with pagination + const rows = db + .prepare(`SELECT * FROM "${tableName}" LIMIT ? OFFSET ?`) + .all(limit, offset) as Record[] + + const truncated = offset + limit < totalRows + + return { + columns, + rows, + totalRows, + truncated, + } + } finally { + db.close() + } +} + +/** + * Infer type from a single value (fallback) + */ +function inferTypeFromValue(value: unknown): ColumnType { + if (value === null || value === undefined) { + return "null" + } + + if (typeof value === "boolean") { + return "boolean" + } + + if (typeof value === "number") { + return "number" + } + + if (typeof value === "string") { + // Check for date patterns + if (/^\d{4}-\d{2}-\d{2}/.test(value)) { + return "date" + } + return "string" + } + + return "mixed" +} + +/** + * Get schema information for a SQLite database + */ +export function getSqliteSchema( + filePath: string +): Map { + const db = new Database(filePath, { readonly: true }) + const schema = new Map() + + try { + const tables = listSqliteTables(filePath) + + for (const tableName of tables) { + const columns = getTableColumns(db, tableName) + schema.set(tableName, columns) + } + + return schema + } finally { + db.close() + } +} diff --git a/src/main/lib/parsers/types.ts b/src/main/lib/parsers/types.ts new file mode 100644 index 00000000..5748b973 --- /dev/null +++ b/src/main/lib/parsers/types.ts @@ -0,0 +1,23 @@ +export type ColumnType = "string" | "number" | "boolean" | "date" | "null" | "mixed" + +export interface ParsedColumn { + name: string + type: ColumnType +} + +export interface ParsedData { + columns: ParsedColumn[] + rows: Record[] + totalRows: number + truncated: boolean +} + +export type DataFileType = "csv" | "json" | "sqlite" | "unknown" + +export interface DataFileInfo { + path: string + name: string + type: DataFileType + size: number + tables?: string[] // For SQLite files +} diff --git a/src/main/lib/trpc/routers/files.ts b/src/main/lib/trpc/routers/files.ts index dbec9c08..953adbb5 100644 --- a/src/main/lib/trpc/routers/files.ts +++ b/src/main/lib/trpc/routers/files.ts @@ -7,6 +7,13 @@ import { observable } from "@trpc/server/observable" import { EventEmitter } from "node:events" import { exec } from "node:child_process" import { promisify } from "node:util" +import { + getDataFileInfo, + parseDataFile, + querySqlite, + listSqliteTables, + isDataFile, +} from "../../parsers" const execAsync = promisify(exec) @@ -671,4 +678,65 @@ export const filesRouter = router({ } }) }), + + /** + * Check if a file is a supported data file (CSV, JSON, SQLite) + */ + isDataFile: publicProcedure + .input(z.object({ filePath: z.string() })) + .query(({ input }) => { + return isDataFile(input.filePath) + }), + + /** + * Get metadata about a data file (type, size, tables for SQLite) + */ + getDataFileInfo: publicProcedure + .input(z.object({ filePath: z.string() })) + .query(async ({ input }) => { + return await getDataFileInfo(input.filePath) + }), + + /** + * Parse and preview a data file (first N rows) + */ + previewDataFile: publicProcedure + .input( + z.object({ + filePath: z.string(), + limit: z.number().min(1).max(10000).default(1000), + offset: z.number().min(0).default(0), + tableName: z.string().optional(), // For SQLite files + }) + ) + .query(async ({ input }) => { + return await parseDataFile(input.filePath, { + limit: input.limit, + offset: input.offset, + tableName: input.tableName, + }) + }), + + /** + * Execute a SQL query on a SQLite file + */ + querySqliteFile: publicProcedure + .input( + z.object({ + filePath: z.string(), + sql: z.string(), + }) + ) + .query(({ input }) => { + return querySqlite(input.filePath, input.sql) + }), + + /** + * List tables in a SQLite file + */ + listSqliteTables: publicProcedure + .input(z.object({ filePath: z.string() })) + .query(({ input }) => { + return listSqliteTables(input.filePath) + }), }) diff --git a/src/renderer/features/agents/atoms/index.ts b/src/renderer/features/agents/atoms/index.ts index 02abc89f..5846174d 100644 --- a/src/renderer/features/agents/atoms/index.ts +++ b/src/renderer/features/agents/atoms/index.ts @@ -538,3 +538,72 @@ export const expandedFoldersAtomFamily = atomFamily((projectId: string) => }, ), ) + +// ============================================================================ +// Data Viewer Sidebar +// ============================================================================ + +// Data viewer sidebar width (global, shared across all chats) +export const dataViewerSidebarWidthAtom = atomWithStorage( + "agents-data-viewer-sidebar-width", + 500, + undefined, + { getOnInit: true }, +) + +// Data viewer sidebar open state per chat +const dataViewerSidebarOpenStorageAtom = atomWithStorage>( + "agents:dataViewerSidebarOpen", + {}, + undefined, + { getOnInit: true }, +) + +// atomFamily to get/set data viewer sidebar open state per chatId +export const dataViewerSidebarOpenAtomFamily = atomFamily((chatId: string) => + atom( + (get) => get(dataViewerSidebarOpenStorageAtom)[chatId] ?? false, + (get, set, isOpen: boolean) => { + const current = get(dataViewerSidebarOpenStorageAtom) + set(dataViewerSidebarOpenStorageAtom, { ...current, [chatId]: isOpen }) + }, + ), +) + +// Currently viewed data file path per chat +const viewedDataFileStorageAtom = atomWithStorage>( + "agents:viewedDataFile", + {}, + undefined, + { getOnInit: true }, +) + +// atomFamily to get/set viewed data file path per chatId +export const viewedDataFileAtomFamily = atomFamily((chatId: string) => + atom( + (get) => get(viewedDataFileStorageAtom)[chatId] ?? null, + (get, set, filePath: string | null) => { + const current = get(viewedDataFileStorageAtom) + set(viewedDataFileStorageAtom, { ...current, [chatId]: filePath }) + }, + ), +) + +// Selected table for SQLite files (per file path) +const selectedSqliteTableStorageAtom = atomWithStorage>( + "agents:selectedSqliteTable", + {}, + undefined, + { getOnInit: true }, +) + +// atomFamily to get/set selected SQLite table per file path +export const selectedSqliteTableAtomFamily = atomFamily((filePath: string) => + atom( + (get) => get(selectedSqliteTableStorageAtom)[filePath] ?? "", + (get, set, tableName: string) => { + const current = get(selectedSqliteTableStorageAtom) + set(selectedSqliteTableStorageAtom, { ...current, [filePath]: tableName }) + }, + ), +) diff --git a/src/renderer/features/agents/main/active-chat.tsx b/src/renderer/features/agents/main/active-chat.tsx index 2365dc32..534e1265 100644 --- a/src/renderer/features/agents/main/active-chat.tsx +++ b/src/renderer/features/agents/main/active-chat.tsx @@ -86,6 +86,7 @@ import { cn } from "../../../lib/utils" import { getShortcutKey, isDesktopApp } from "../../../lib/utils/platform" import { terminalSidebarOpenAtom } from "../../terminal/atoms" import { TerminalSidebar } from "../../terminal/terminal-sidebar" +import { DataViewerSidebar } from "../../data/components/data-viewer-sidebar" import { agentsDiffSidebarWidthAtom, agentsFileTreeSidebarOpenAtom, @@ -94,6 +95,9 @@ import { agentsPreviewSidebarWidthAtom, agentsScrollPositionsAtom, agentsSubChatsSidebarModeAtom, + dataViewerSidebarOpenAtomFamily, + dataViewerSidebarWidthAtom, + viewedDataFileAtomFamily, agentsSubChatUnseenChangesAtom, agentsUnseenChangesAtom, clearLoading, @@ -3598,6 +3602,17 @@ export function ChatView({ const [isFileTreeSidebarOpen, setIsFileTreeSidebarOpen] = useAtom( agentsFileTreeSidebarOpenAtom, ) + // Per-chat data viewer sidebar state + const dataViewerSidebarAtom = useMemo( + () => dataViewerSidebarOpenAtomFamily(chatId), + [chatId], + ) + const viewedDataFileAtom = useMemo( + () => viewedDataFileAtomFamily(chatId), + [chatId], + ) + const [isDataViewerSidebarOpen, setIsDataViewerSidebarOpen] = useAtom(dataViewerSidebarAtom) + const [viewedDataFile, setViewedDataFile] = useAtom(viewedDataFileAtom) const [diffStats, setDiffStats] = useState({ fileCount: 0, additions: 0, @@ -4897,9 +4912,19 @@ export function ChatView({ style={{ borderRightWidth: "0.5px", overflow: "hidden" }} > setIsFileTreeSidebarOpen(false)} + onSelectFile={(filePath) => { + // Check if it's a data file (CSV, JSON, SQLite) + const ext = filePath.includes(".") ? `.${filePath.split(".").pop()?.toLowerCase()}` : "" + const dataExtensions = [".csv", ".tsv", ".json", ".jsonl", ".db", ".sqlite", ".sqlite3"] + if (dataExtensions.includes(ext)) { + // Open in data viewer sidebar + setViewedDataFile(filePath) + setIsDataViewerSidebarOpen(true) + } + }} /> )} @@ -5569,6 +5594,36 @@ export function ChatView({ workspaceId={chatId} /> )} + + {/* Data Viewer Sidebar - for viewing CSV, JSON, SQLite files */} + {isDataViewerSidebarOpen && viewedDataFile && (worktreePath || originalProjectPath) && ( + { + setIsDataViewerSidebarOpen(false) + setViewedDataFile(null) + }} + widthAtom={dataViewerSidebarWidthAtom} + minWidth={400} + side="right" + animationDuration={0} + initialWidth={0} + exitWidth={0} + showResizeTooltip={true} + className="bg-background border-l" + style={{ borderLeftWidth: "0.5px", overflow: "hidden" }} + > + { + setIsDataViewerSidebarOpen(false) + setViewedDataFile(null) + }} + /> + + )}
) diff --git a/src/renderer/features/agents/ui/file-tree/FileTreeNode.tsx b/src/renderer/features/agents/ui/file-tree/FileTreeNode.tsx index af36810b..243e1b1e 100644 --- a/src/renderer/features/agents/ui/file-tree/FileTreeNode.tsx +++ b/src/renderer/features/agents/ui/file-tree/FileTreeNode.tsx @@ -1,10 +1,30 @@ "use client" -import { ChevronRight, File, Folder, FolderOpen } from "lucide-react" +import { ChevronRight, File, Folder, FolderOpen, FileSpreadsheet, FileJson, Database } from "lucide-react" import { memo, useCallback, useMemo } from "react" import { cn } from "../../../../lib/utils" import type { TreeNode } from "./build-file-tree" +// Data file extensions for special icons +const DATA_FILE_EXTENSIONS: Record = { + ".csv": "csv", + ".tsv": "csv", + ".json": "json", + ".jsonl": "json", + ".db": "sqlite", + ".sqlite": "sqlite", + ".sqlite3": "sqlite", +} + +function getDataFileType(filename: string): "csv" | "json" | "sqlite" | null { + const ext = filename.includes(".") ? `.${filename.split(".").pop()?.toLowerCase()}` : "" + return DATA_FILE_EXTENSIONS[ext] || null +} + +function isDataFile(filename: string): boolean { + return getDataFileType(filename) !== null +} + // Git status type matching the backend type GitStatusCode = | "modified" @@ -130,7 +150,20 @@ export const FileTreeNode = memo(function FileTreeNode({ )} /> ) ) : ( - + // Use special icons for data files + (() => { + const dataType = getDataFileType(node.name) + if (dataType === "csv") { + return + } + if (dataType === "json") { + return + } + if (dataType === "sqlite") { + return + } + return + })() )} {/* Name */} diff --git a/src/renderer/features/data/components/data-viewer-sidebar.tsx b/src/renderer/features/data/components/data-viewer-sidebar.tsx new file mode 100644 index 00000000..525a2682 --- /dev/null +++ b/src/renderer/features/data/components/data-viewer-sidebar.tsx @@ -0,0 +1,834 @@ +import { useCallback, useMemo, useState, useEffect } from "react" +import { + DataEditor, + GridColumn, + GridCell, + GridCellKind, + GridSelection, + CompactSelection, + type Item, + type Theme, + type Rectangle, +} from "@glideapps/glide-data-grid" +import "@glideapps/glide-data-grid/dist/index.css" +import { useAtom } from "jotai" +import { + X, + Loader2, + Database, + FileSpreadsheet, + FileJson, + Search, + Pin, + Hash, + ArrowUpAZ, + ArrowDownAZ, + EyeOff, +} from "lucide-react" +import { Button } from "@/components/ui/button" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { cn } from "@/lib/utils" +import { trpc } from "@/lib/trpc" +import { selectedSqliteTableAtomFamily } from "../../agents/atoms" +import { useTheme } from "next-themes" + +interface DataViewerSidebarProps { + chatId: string + filePath: string + projectPath: string + onClose: () => void +} + +/** + * Get the file extension + */ +function getFileExtension(filePath: string): string { + const parts = filePath.split(".") + return parts.length > 1 ? `.${parts[parts.length - 1].toLowerCase()}` : "" +} + +/** + * Get file type from extension + */ +function getFileType(filePath: string): "csv" | "json" | "sqlite" | "unknown" { + const ext = getFileExtension(filePath) + switch (ext) { + case ".csv": + case ".tsv": + return "csv" + case ".json": + case ".jsonl": + return "json" + case ".db": + case ".sqlite": + case ".sqlite3": + return "sqlite" + default: + return "unknown" + } +} + +/** + * Get file icon based on type + */ +function FileIcon({ filePath }: { filePath: string }) { + const fileType = getFileType(filePath) + + switch (fileType) { + case "csv": + return + case "json": + return + case "sqlite": + return + default: + return + } +} + +/** + * Get file name from path + */ +function getFileName(filePath: string): string { + const parts = filePath.split("/") + return parts[parts.length - 1] || filePath +} + +/** + * Format cell value for display + */ +function formatCellValue(value: unknown): string { + if (value === null) return "null" + if (value === undefined) return "" + if (typeof value === "object") return JSON.stringify(value) + return String(value) +} + +export function DataViewerSidebar({ + chatId, + filePath, + projectPath, + onClose, +}: DataViewerSidebarProps) { + const fileType = getFileType(filePath) + const fileName = getFileName(filePath) + const { resolvedTheme } = useTheme() + const isDark = resolvedTheme === "dark" + + // ============ Column State ============ + const [columnWidths, setColumnWidths] = useState>({}) + const [columnOrder, setColumnOrder] = useState([]) + const [hiddenColumns, setHiddenColumns] = useState>(new Set()) + + // ============ Selection State ============ + const [selection, setSelection] = useState({ + columns: CompactSelection.empty(), + rows: CompactSelection.empty(), + }) + + // ============ Feature State ============ + const [freezeColumns, setFreezeColumns] = useState(0) + const [showRowMarkers, setShowRowMarkers] = useState(true) + const [showSearch, setShowSearch] = useState(false) + const [searchValue, setSearchValue] = useState("") + const [searchResults, setSearchResults] = useState([]) + + // ============ Header Menu State ============ + const [menuColumn, setMenuColumn] = useState(null) + const [menuPosition, setMenuPosition] = useState<{ x: number; y: number } | null>(null) + + // ============ Sort State ============ + const [sortColumn, setSortColumn] = useState(null) + const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc") + + // Glide Data Grid theme - use explicit colors instead of CSS variables + const gridTheme: Partial = useMemo( + () => ({ + // Backgrounds + bgCell: isDark ? "#09090b" : "#ffffff", + bgHeader: isDark ? "#18181b" : "#f4f4f5", + bgHeaderHovered: isDark ? "#27272a" : "#e4e4e7", + bgHeaderHasFocus: isDark ? "#27272a" : "#e4e4e7", + bgBubble: isDark ? "#3f3f46" : "#e4e4e7", + bgBubbleSelected: isDark ? "#52525b" : "#d4d4d8", + bgSearchResult: isDark ? "#854d0e" : "#fef08a", + + // Text colors + textDark: isDark ? "#fafafa" : "#09090b", + textHeader: isDark ? "#fafafa" : "#09090b", + textLight: isDark ? "#a1a1aa" : "#71717a", + textMedium: isDark ? "#d4d4d8" : "#52525b", + textBubble: isDark ? "#fafafa" : "#09090b", + + // Borders + borderColor: isDark ? "#27272a" : "#e4e4e7", + horizontalBorderColor: isDark ? "#27272a" : "#e4e4e7", + drilldownBorder: isDark ? "#3f3f46" : "#d4d4d8", + + // Selection/Accent + accentColor: isDark ? "#3b82f6" : "#2563eb", + accentLight: isDark ? "#1e3a5f" : "#dbeafe", + accentFg: "#ffffff", + + // Cell states + bgCellMedium: isDark ? "#18181b" : "#f4f4f5", + bgIconHeader: isDark ? "#27272a" : "#d4d4d8", + fgIconHeader: isDark ? "#fafafa" : "#09090b", + + // Fonts + fontFamily: + "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace", + baseFontStyle: "12px", + headerFontStyle: "600 12px", + editorFontSize: "12px", + markerFontStyle: "11px", + + // Sizing + cellHorizontalPadding: 8, + cellVerticalPadding: 3, + headerIconSize: 16, + + // Shadows & effects + linkColor: isDark ? "#60a5fa" : "#2563eb", + }), + [isDark] + ) + + // Build absolute path + const absolutePath = filePath.startsWith("/") + ? filePath + : `${projectPath}/${filePath}` + + // For SQLite files, we need to select a table + const selectedTableAtom = useMemo( + () => selectedSqliteTableAtomFamily(absolutePath), + [absolutePath] + ) + const [selectedTable, setSelectedTable] = useAtom(selectedTableAtom) + + // Fetch tables for SQLite files + const { data: tables } = trpc.files.listSqliteTables.useQuery( + { filePath: absolutePath }, + { enabled: fileType === "sqlite" } + ) + + // Auto-select first table if none selected + useEffect(() => { + if (fileType === "sqlite" && tables && tables.length > 0 && !selectedTable) { + setSelectedTable(tables[0]) + } + }, [fileType, tables, selectedTable, setSelectedTable]) + + // Fetch data + const { data, isLoading, error } = trpc.files.previewDataFile.useQuery( + { + filePath: absolutePath, + limit: 1000, + offset: 0, + tableName: fileType === "sqlite" ? selectedTable || undefined : undefined, + }, + { + enabled: + fileType !== "unknown" && + (fileType !== "sqlite" || (!!selectedTable && selectedTable !== "")), + } + ) + + // Initialize column order when data changes + useEffect(() => { + if (data?.columns) { + setColumnOrder(data.columns.map((_, i) => i)) + setHiddenColumns(new Set()) + setSortColumn(null) + } + }, [data?.columns]) + + // Compute search results when search value changes + useEffect(() => { + if (!searchValue || !data) { + setSearchResults([]) + return + } + + const results: Item[] = [] + const searchLower = searchValue.toLowerCase() + + data.rows.forEach((row, rowIdx) => { + data.columns.forEach((col, colIdx) => { + if (hiddenColumns.has(col.name)) return + const value = formatCellValue(row[col.name]) + if (value.toLowerCase().includes(searchLower)) { + results.push([colIdx, rowIdx]) + } + }) + }) + + setSearchResults(results) + }, [searchValue, data, hiddenColumns]) + + // Sort rows if sort is active + const sortedRows = useMemo(() => { + if (!data?.rows || !sortColumn) return data?.rows ?? [] + + const sorted = [...data.rows].sort((a, b) => { + const aVal = a[sortColumn] + const bVal = b[sortColumn] + + // Handle nulls + if (aVal === null && bVal === null) return 0 + if (aVal === null) return sortDirection === "asc" ? -1 : 1 + if (bVal === null) return sortDirection === "asc" ? 1 : -1 + + // Compare + if (typeof aVal === "number" && typeof bVal === "number") { + return sortDirection === "asc" ? aVal - bVal : bVal - aVal + } + + const aStr = String(aVal) + const bStr = String(bVal) + return sortDirection === "asc" + ? aStr.localeCompare(bStr) + : bStr.localeCompare(aStr) + }) + + return sorted + }, [data?.rows, sortColumn, sortDirection]) + + // Build columns with proper widths and filtering hidden + const baseColumns: GridColumn[] = useMemo( + () => + data?.columns + .map((col, idx) => ({ + title: col.name, + id: col.name, + width: + columnWidths[col.name] ?? + Math.max(100, Math.min(300, col.name.length * 10 + 40)), + hasMenu: true, + originalIndex: idx, + })) + .filter((col) => !hiddenColumns.has(col.id)) ?? [], + [data?.columns, columnWidths, hiddenColumns] + ) + + // Apply column order + const orderedColumns: GridColumn[] = useMemo(() => { + if (!data?.columns || columnOrder.length === 0) return baseColumns + + // Filter column order to only include visible columns + const visibleIndices = columnOrder.filter( + (idx) => data.columns[idx] && !hiddenColumns.has(data.columns[idx].name) + ) + + return visibleIndices + .map((idx) => { + const col = data.columns[idx] + return baseColumns.find((bc) => bc.id === col.name) + }) + .filter(Boolean) as GridColumn[] + }, [baseColumns, columnOrder, data?.columns, hiddenColumns]) + + // ============ Handlers ============ + + const onColumnResize = useCallback( + (column: GridColumn, newSize: number) => { + setColumnWidths((prev) => ({ + ...prev, + [column.id ?? column.title]: newSize, + })) + }, + [] + ) + + const onColumnMoved = useCallback((startIndex: number, endIndex: number) => { + setColumnOrder((prev) => { + const newOrder = [...prev] + const [removed] = newOrder.splice(startIndex, 1) + newOrder.splice(endIndex, 0, removed) + return newOrder + }) + }, []) + + const onSelectionChange = useCallback((newSelection: GridSelection) => { + setSelection(newSelection) + }, []) + + const onSearchClose = useCallback(() => { + setShowSearch(false) + setSearchValue("") + setSearchResults([]) + }, []) + + const onHeaderMenuClick = useCallback( + (col: number, bounds: Rectangle) => { + const column = orderedColumns[col] + if (column) { + setMenuColumn(col) + setMenuPosition({ x: bounds.x, y: bounds.y + bounds.height }) + } + }, + [orderedColumns] + ) + + const handleSort = useCallback( + (direction: "asc" | "desc") => { + if (menuColumn !== null && orderedColumns[menuColumn]) { + const colName = orderedColumns[menuColumn].id as string + setSortColumn(colName) + setSortDirection(direction) + } + setMenuColumn(null) + setMenuPosition(null) + }, + [menuColumn, orderedColumns] + ) + + const handleFreezeColumn = useCallback(() => { + if (menuColumn !== null) { + setFreezeColumns(menuColumn + 1) + } + setMenuColumn(null) + setMenuPosition(null) + }, [menuColumn]) + + const handleHideColumn = useCallback(() => { + if (menuColumn !== null && orderedColumns[menuColumn]) { + const colName = orderedColumns[menuColumn].id as string + setHiddenColumns((prev) => new Set([...prev, colName])) + } + setMenuColumn(null) + setMenuPosition(null) + }, [menuColumn, orderedColumns]) + + const handleShowAllColumns = useCallback(() => { + setHiddenColumns(new Set()) + }, []) + + // Get cell content with proper cell types + const getCellContent = useCallback( + (cell: Item): GridCell => { + const [col, row] = cell + const column = orderedColumns[col] + const colName = column?.id as string + const colMeta = data?.columns.find((c) => c.name === colName) + const colType = colMeta?.type + const rowData = sortedRows[row] + const value = rowData?.[colName] + + // Handle null values + if (value === null || value === undefined) { + return { + kind: GridCellKind.Text, + data: "", + displayData: value === null ? "null" : "", + allowOverlay: true, + readonly: true, + style: "faded", + } + } + + // Number type + if (colType === "number" && typeof value === "number") { + return { + kind: GridCellKind.Number, + data: value, + displayData: value.toLocaleString(), + allowOverlay: true, + readonly: true, + } + } + + // Boolean type + if (colType === "boolean" || typeof value === "boolean") { + return { + kind: GridCellKind.Boolean, + data: Boolean(value), + allowOverlay: false, + readonly: true, + } + } + + // URI detection (http/https links) + if (typeof value === "string" && /^https?:\/\//i.test(value)) { + return { + kind: GridCellKind.Uri, + data: value, + displayData: value, + allowOverlay: true, + readonly: true, + } + } + + // Default: text + const displayValue = formatCellValue(value) + return { + kind: GridCellKind.Text, + data: displayValue, + displayData: displayValue, + allowOverlay: true, + readonly: true, + } + }, + [orderedColumns, sortedRows, data?.columns] + ) + + // Get cells for selection (enables copy) + const getCellsForSelection = useCallback( + (selection: Rectangle): readonly (readonly GridCell[])[] => { + const result: GridCell[][] = [] + for (let row = selection.y; row < selection.y + selection.height; row++) { + const rowCells: GridCell[] = [] + for (let col = selection.x; col < selection.x + selection.width; col++) { + rowCells.push(getCellContent([col, row])) + } + result.push(rowCells) + } + return result + }, + [getCellContent] + ) + + // Error state + if (error) { + return ( +
+
+
+
+

Failed to load file

+

{error.message}

+
+
+
+ ) + } + + return ( +
+ {/* Header */} +
+ + {/* Toolbar */} + setShowSearch((prev) => !prev)} + onFreeze={() => setFreezeColumns((prev) => (prev === 0 ? 1 : 0))} + freezeCount={freezeColumns} + showRowMarkers={showRowMarkers} + onToggleRowMarkers={() => setShowRowMarkers((prev) => !prev)} + hiddenColumnCount={hiddenColumns.size} + onShowAllColumns={handleShowAllColumns} + sortColumn={sortColumn} + sortDirection={sortDirection} + onClearSort={() => setSortColumn(null)} + /> + + {/* Table selector for SQLite */} + {fileType === "sqlite" && tables && tables.length > 0 && ( +
+ +
+ )} + + {/* Grid */} +
+ {isLoading ? ( +
+ +
+ ) : data && sortedRows.length > 0 ? ( + <> + 0} + // Row markers + rowMarkers={showRowMarkers ? "both" : "none"} + rowMarkerWidth={60} + rowMarkerStartIndex={1} + // Selection + gridSelection={selection} + onGridSelectionChange={onSelectionChange} + rowSelect="multi" + columnSelect="multi" + rangeSelect="multi-rect" + rowSelectionMode="auto" + drawFocusRing={true} + // Search + showSearch={showSearch} + searchValue={searchValue} + searchResults={searchResults} + onSearchValueChange={setSearchValue} + onSearchClose={onSearchClose} + // Header menu + onHeaderMenuClick={onHeaderMenuClick} + // Keyboard + keybindings={{ + selectAll: true, + selectColumn: true, + selectRow: true, + copy: true, + search: true, + first: true, + last: true, + }} + // Theme + theme={gridTheme} + /> + + {/* Header Menu Dropdown */} + {menuColumn !== null && menuPosition && ( +
+ { + setMenuColumn(null) + setMenuPosition(null) + }} + > + +
+ + + handleSort("asc")}> + + Sort Ascending + + handleSort("desc")}> + + Sort Descending + + + + + Freeze up to here + + + + Hide Column + + + +
+ )} + + ) : ( +
+

No data to display

+
+ )} +
+ + {/* Footer with row count */} + {data && ( +
+ + {data.truncated + ? `Showing ${sortedRows.length.toLocaleString()} of ${data.totalRows.toLocaleString()} rows` + : `${sortedRows.length.toLocaleString()} rows`} + + + {orderedColumns.length} + {hiddenColumns.size > 0 && ` (${hiddenColumns.size} hidden)`} columns + +
+ )} +
+ ) +} + +/** + * Header component for the sidebar + */ +function Header({ + fileName, + filePath, + onClose, +}: { + fileName: string + filePath: string + onClose: () => void +}) { + return ( +
+
+ + + {fileName} + +
+ +
+ ) +} + +/** + * Toolbar component with feature controls + */ +interface ToolbarProps { + onSearch: () => void + onFreeze: () => void + freezeCount: number + showRowMarkers: boolean + onToggleRowMarkers: () => void + hiddenColumnCount: number + onShowAllColumns: () => void + sortColumn: string | null + sortDirection: "asc" | "desc" + onClearSort: () => void +} + +function Toolbar({ + onSearch, + onFreeze, + freezeCount, + showRowMarkers, + onToggleRowMarkers, + hiddenColumnCount, + onShowAllColumns, + sortColumn, + onClearSort, +}: ToolbarProps) { + return ( +
+ + + + + + Search (Ctrl+F) + + + + + + + + {freezeCount > 0 ? "Unfreeze columns" : "Freeze first column"} + + + + + + + + Toggle row numbers + + + {hiddenColumnCount > 0 && ( + + + + + Show all columns + + )} + + {sortColumn && ( + + + + + Clear sort + + )} + +
+ ) +} + +export default DataViewerSidebar diff --git a/src/renderer/features/data/components/index.ts b/src/renderer/features/data/components/index.ts new file mode 100644 index 00000000..38a6142a --- /dev/null +++ b/src/renderer/features/data/components/index.ts @@ -0,0 +1 @@ +export { DataViewerSidebar } from "./data-viewer-sidebar" diff --git a/test-data/products.json b/test-data/products.json new file mode 100644 index 00000000..db8e907e --- /dev/null +++ b/test-data/products.json @@ -0,0 +1,122 @@ +[ + { + "id": 1, + "name": "Laptop Pro 15", + "category": "Electronics", + "price": 1299.99, + "stock": 45, + "rating": 4.5, + "in_stock": true, + "created_at": "2024-01-10" + }, + { + "id": 2, + "name": "Wireless Mouse", + "category": "Electronics", + "price": 29.99, + "stock": 150, + "rating": 4.2, + "in_stock": true, + "created_at": "2024-01-12" + }, + { + "id": 3, + "name": "Standing Desk", + "category": "Furniture", + "price": 549.00, + "stock": 20, + "rating": 4.8, + "in_stock": true, + "created_at": "2024-01-15" + }, + { + "id": 4, + "name": "Noise Cancelling Headphones", + "category": "Electronics", + "price": 249.99, + "stock": 0, + "rating": 4.7, + "in_stock": false, + "created_at": "2024-01-18" + }, + { + "id": 5, + "name": "Ergonomic Chair", + "category": "Furniture", + "price": 399.00, + "stock": 35, + "rating": 4.6, + "in_stock": true, + "created_at": "2024-01-20" + }, + { + "id": 6, + "name": "4K Monitor 27\"", + "category": "Electronics", + "price": 449.99, + "stock": 28, + "rating": 4.4, + "in_stock": true, + "created_at": "2024-01-22" + }, + { + "id": 7, + "name": "Mechanical Keyboard", + "category": "Electronics", + "price": 129.99, + "stock": 75, + "rating": 4.3, + "in_stock": true, + "created_at": "2024-01-25" + }, + { + "id": 8, + "name": "Desk Lamp LED", + "category": "Furniture", + "price": 49.99, + "stock": 100, + "rating": 4.1, + "in_stock": true, + "created_at": "2024-01-28" + }, + { + "id": 9, + "name": "USB-C Hub", + "category": "Electronics", + "price": 59.99, + "stock": 0, + "rating": 4.0, + "in_stock": false, + "created_at": "2024-02-01" + }, + { + "id": 10, + "name": "Webcam HD", + "category": "Electronics", + "price": 79.99, + "stock": 60, + "rating": 4.2, + "in_stock": true, + "created_at": "2024-02-05" + }, + { + "id": 11, + "name": "Cable Management Kit", + "category": "Accessories", + "price": 19.99, + "stock": 200, + "rating": 3.9, + "in_stock": true, + "created_at": "2024-02-08" + }, + { + "id": 12, + "name": "Monitor Stand", + "category": "Furniture", + "price": 89.99, + "stock": 40, + "rating": 4.5, + "in_stock": true, + "created_at": "2024-02-10" + } +] diff --git a/test-data/sample.csv b/test-data/sample.csv new file mode 100644 index 00000000..2f2341a2 --- /dev/null +++ b/test-data/sample.csv @@ -0,0 +1,21 @@ +id,name,email,age,city,salary,join_date,is_active +1,John Smith,john.smith@example.com,32,New York,75000,2022-01-15,true +2,Jane Doe,jane.doe@example.com,28,Los Angeles,68000,2022-03-22,true +3,Bob Johnson,bob.johnson@example.com,45,Chicago,92000,2021-06-10,true +4,Alice Williams,alice.w@example.com,35,Houston,81000,2022-08-05,false +5,Charlie Brown,charlie.b@example.com,29,Phoenix,62000,2023-01-20,true +6,Diana Ross,diana.ross@example.com,41,Philadelphia,88000,2021-11-30,true +7,Edward Lee,edward.lee@example.com,38,San Antonio,79000,2022-04-18,false +8,Fiona Green,fiona.green@example.com,26,San Diego,58000,2023-02-14,true +9,George White,george.w@example.com,52,Dallas,105000,2020-09-01,true +10,Helen Black,helen.black@example.com,33,San Jose,72000,2022-07-12,true +11,Ivan Gray,ivan.gray@example.com,44,Austin,95000,2021-03-25,false +12,Julia Adams,julia.adams@example.com,31,Jacksonville,67000,2022-10-08,true +13,Kevin Martin,kevin.m@example.com,37,Fort Worth,83000,2021-12-17,true +14,Laura Wilson,laura.wilson@example.com,27,Columbus,61000,2023-04-03,true +15,Michael Taylor,michael.t@example.com,49,Charlotte,98000,2020-07-22,false +16,Nancy Moore,nancy.moore@example.com,34,San Francisco,89000,2022-02-28,true +17,Oscar Davis,oscar.davis@example.com,42,Indianapolis,91000,2021-08-14,true +18,Patricia Clark,patricia.c@example.com,30,Seattle,70000,2022-11-21,true +19,Quinn Harris,quinn.harris@example.com,36,Denver,77000,2022-05-09,false +20,Rachel Lewis,rachel.lewis@example.com,25,Washington,55000,2023-03-16,true diff --git a/test-data/sample.db b/test-data/sample.db new file mode 100644 index 0000000000000000000000000000000000000000..061efc9408442c3d678c55faae61b97fa9178916 GIT binary patch literal 16384 zcmeI3O>7%Q6vubt7CUb31*s@RRphZs>zH(#-L>7iLIpdHsnghLuwA7=NM*b`v4^gA z&Fs36BC59Hz!kVu;s8<)hzo)flna#*hax19K!Ph5#2pF2fsZ%d*x6hV&=b;(jkv+1N^5FANxZuq`Nb;y2KAj1PkWxN(^)p z$J(6&)lbOLnN&)?xjIr!`UBhDCd8XOOzqJx zv{Ua=A3L~9*!XPX+3?ql=jKY{Wl6cGyrnRurlgaG)NPLxe7d{q4 z5D)|e0YN|z5CjAPK|l}?1Ox#=;J*`iOo>WIjzrSa%h;jBMyu56R-G<&2x0Ijn=cuf zp{ZJ4)r-fHQ7JPMNyp9+&!Z+ro9@u*wy;e+d2Sx;3{^X&YDF~>mDCj2ReFp<_CDxA z;#b4A-N5WJ@%ge3Uj%bq)iqTwEF6qVM?pfY3`wGe(MAt0gTor~w}Q0|)hNs#01byh z?*_GPiuU#4$FdNtU=8lv2B9tgp3VK>!ba(f@&0x zOv927!IDk4Lzs`wVrEiYYs!!Q3PuonQPqu1EGjL65yb8|bPEE4U?3E7wbqn=|0xid zR}F0;x)aSgs8>LmkGE}?g`?TH8*FW;dj8l00nzC?^)MbOxu=hzgp!t1pm;ipd+Nbt5T4L^t&MtH9gJ5IeQ^-W3Qkox8 zgI*?xuj=g@&X$5D`S~250)_nh(ZUS(&^fo~z}De-y3c_%P&%a=MplMp`B8E^)ap~e z7dEZO0tGoZ_3&4g=l}bY-%H9J%zGN`~r5#gg)1fUX$pHimKs6l}h|8|(#s zJ5mkU>ony1Dwn!JIv#J~ZxV}mwCjXqN}uss3lDMZs-1+AM)(kU{vS(rCFOJFb>&%Q z0dD{AC2zrm7=nNxAP5Kof`A|(2nYg#fFK|U2m*q@zbCK=clg=Eax~p0W)HPod&m=G z??P({)rOrzr8hp!@eyRJkW?2I`CWbr99n(oURa`6QN3P)RM&*QgW7VW)c-SnW^ntv ziGzLu?(@@Wa5!tb9c=S`>UaPh5IkvDO5enP*@7`nhmE2J1%gxt?8_$nxS`^*L5*N* z&~qJV9ayWxN^d{;{-LWs)FvK Date: Sun, 18 Jan 2026 16:40:30 +0800 Subject: [PATCH 05/51] Adding parquet support and file viewer --- bun.lock | 24 + package.json | 2 + src/main/lib/parsers/index.ts | 6 + src/main/lib/parsers/parquet-parser.ts | 178 +++++++ src/main/lib/parsers/parquetjs-lite.d.ts | 27 + src/main/lib/parsers/types.ts | 2 +- src/renderer/features/agents/atoms/index.ts | 58 +++ .../features/agents/main/active-chat.tsx | 69 ++- .../agents/ui/file-tree/FileTreeNode.tsx | 31 +- .../agents/ui/file-tree/FileTreeSidebar.tsx | 9 + .../data/components/data-viewer-sidebar.tsx | 470 +++++++++++++++++- .../components/file-viewer-sidebar.tsx | 280 +++++++++++ .../file-viewer/components/monaco-config.ts | 78 +++ .../file-viewer/hooks/use-file-content.ts | 127 +++++ src/renderer/features/file-viewer/index.ts | 3 + .../file-viewer/utils/language-map.ts | 230 +++++++++ test-data/employees.parquet | Bin 0 -> 2125 bytes 17 files changed, 1558 insertions(+), 36 deletions(-) create mode 100644 src/main/lib/parsers/parquet-parser.ts create mode 100644 src/main/lib/parsers/parquetjs-lite.d.ts create mode 100644 src/renderer/features/file-viewer/components/file-viewer-sidebar.tsx create mode 100644 src/renderer/features/file-viewer/components/monaco-config.ts create mode 100644 src/renderer/features/file-viewer/hooks/use-file-content.ts create mode 100644 src/renderer/features/file-viewer/index.ts create mode 100644 src/renderer/features/file-viewer/utils/language-map.ts create mode 100644 test-data/employees.parquet diff --git a/bun.lock b/bun.lock index 948dafb7..ba63c839 100644 --- a/bun.lock +++ b/bun.lock @@ -9,6 +9,7 @@ "@git-diff-view/react": "^0.0.35", "@git-diff-view/shiki": "^0.0.36", "@glideapps/glide-data-grid": "^6.0.3", + "@monaco-editor/react": "^4.7.0", "@radix-ui/react-accordion": "^1.2.11", "@radix-ui/react-alert-dialog": "^1.1.1", "@radix-ui/react-checkbox": "^1.3.3", @@ -54,6 +55,7 @@ "next-themes": "^0.4.4", "node-pty": "^1.1.0", "papaparse": "^5.5.3", + "parquetjs-lite": "^0.8.7", "pidtree": "^0.6.0", "posthog-js": "^1.239.1", "posthog-node": "^5.20.0", @@ -333,6 +335,10 @@ "@malept/flatpak-bundler": ["@malept/flatpak-bundler@0.4.0", "", { "dependencies": { "debug": "^4.1.1", "fs-extra": "^9.0.0", "lodash": "^4.17.15", "tmp-promise": "^3.0.2" } }, "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q=="], + "@monaco-editor/loader": ["@monaco-editor/loader@1.7.0", "", { "dependencies": { "state-local": "^1.0.6" } }, "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA=="], + + "@monaco-editor/react": ["@monaco-editor/react@4.7.0", "", { "dependencies": { "@monaco-editor/loader": "^1.5.0" }, "peerDependencies": { "monaco-editor": ">= 0.25.0 < 1", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA=="], + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], @@ -1215,6 +1221,8 @@ "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], + "int53": ["int53@0.2.4", "", {}, "sha512-a5jlKftS7HUOhkUyYD7j2sJ/ZnvWiNlZS1ldR+g1ifQ+/UuZXIE+YTc/lK1qGj/GwAU5F8Z0e1eVq2t1J5Ob2g=="], + "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="], "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], @@ -1471,6 +1479,8 @@ "module-details-from-path": ["module-details-from-path@1.0.4", "", {}, "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w=="], + "monaco-editor": ["monaco-editor@0.55.1", "", { "dependencies": { "dompurify": "3.2.7", "marked": "14.0.0" } }, "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A=="], + "motion": ["motion@11.18.2", "", { "dependencies": { "framer-motion": "^11.18.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-JLjvFDuFr42NFtcVoMAyC2sEjnpA8xpy6qWPyzQvCloznAyQ8FIXioxWfHiLtgYhoVpfUqSWpn1h9++skj9+Wg=="], "motion-dom": ["motion-dom@11.18.1", "", { "dependencies": { "motion-utils": "^11.18.1" } }, "sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw=="], @@ -1501,6 +1511,8 @@ "node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="], + "node-int64": ["node-int64@0.3.3", "", {}, "sha512-bLdNOp5SYyqfDz/ssGHt2OTg8u+jEkCx4EoZIzprqeonFIUhlSBrKu40e/x6hIFYJx4ZEt64/9IZJyafvhGZrw=="], + "node-pty": ["node-pty@1.1.0", "", { "dependencies": { "node-addon-api": "^7.1.0" } }, "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg=="], "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], @@ -1547,6 +1559,8 @@ "papaparse": ["papaparse@5.5.3", "", {}, "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A=="], + "parquetjs-lite": ["parquetjs-lite@0.8.7", "", { "dependencies": { "int53": "^0.2.4", "node-int64": "^0.3.3", "snappyjs": "^0.6.0", "varint": "^5.0.0" } }, "sha512-L0tdHzvy0btLJvAFZvjO0+ru+FL8tHPqiVE90KEnMy5jDqv+WmEy9Ii8SjiC+exAOi3RGyPYIdgJvFijYyEahA=="], + "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], @@ -1777,6 +1791,8 @@ "smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="], + "snappyjs": ["snappyjs@0.6.1", "", {}, "sha512-YIK6I2lsH072UE0aOFxxY1dPDCS43I5ktqHpeAsuLNYWkE5pGxRGWfDM4/vSUfNzXjC1Ivzt3qx31PCLmc9yqg=="], + "socks": ["socks@2.8.7", "", { "dependencies": { "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" } }, "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A=="], "socks-proxy-agent": ["socks-proxy-agent@7.0.0", "", { "dependencies": { "agent-base": "^6.0.2", "debug": "^4.3.3", "socks": "^2.6.2" } }, "sha512-Fgl0YPZ902wEsAyiQ+idGd1A7rSFx/ayC1CQVMw5P+EQx2V0SgpGtf6OKFhVjPflPUl9YMmEOnmfjCdMUsygww=="], @@ -1797,6 +1813,8 @@ "stat-mode": ["stat-mode@1.0.0", "", {}, "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg=="], + "state-local": ["state-local@1.0.7", "", {}, "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w=="], + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -1917,6 +1935,8 @@ "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + "varint": ["varint@5.0.2", "", {}, "sha512-lKxKYG6H03yCZUpAGOPOsMcGxd1RHCu1iKvEHYDPmTyq2HueGhD73ssNBqqQWfvYs04G9iUFRvmAVLW20Jw6ow=="], + "verror": ["verror@1.10.1", "", { "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", "extsprintf": "^1.2.0" } }, "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg=="], "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], @@ -2111,6 +2131,10 @@ "minizlib/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + "monaco-editor/dompurify": ["dompurify@3.2.7", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw=="], + + "monaco-editor/marked": ["marked@14.0.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ=="], + "object.omit/is-extendable": ["is-extendable@1.0.1", "", { "dependencies": { "is-plain-object": "^2.0.4" } }, "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA=="], "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], diff --git a/package.json b/package.json index 59fdbf3a..4821b4ec 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "@git-diff-view/react": "^0.0.35", "@git-diff-view/shiki": "^0.0.36", "@glideapps/glide-data-grid": "^6.0.3", + "@monaco-editor/react": "^4.7.0", "@radix-ui/react-accordion": "^1.2.11", "@radix-ui/react-alert-dialog": "^1.1.1", "@radix-ui/react-checkbox": "^1.3.3", @@ -78,6 +79,7 @@ "next-themes": "^0.4.4", "node-pty": "^1.1.0", "papaparse": "^5.5.3", + "parquetjs-lite": "^0.8.7", "pidtree": "^0.6.0", "posthog-js": "^1.239.1", "posthog-node": "^5.20.0", diff --git a/src/main/lib/parsers/index.ts b/src/main/lib/parsers/index.ts index 14643161..76f13eab 100644 --- a/src/main/lib/parsers/index.ts +++ b/src/main/lib/parsers/index.ts @@ -8,6 +8,7 @@ import { previewSqliteTable, querySqlite as querySqliteDb, } from "./sqlite-parser" +import { parseParquetFile } from "./parquet-parser" // Re-export types export * from "./types" @@ -23,6 +24,8 @@ const DATA_EXTENSIONS: Record = { ".db": "sqlite", ".sqlite": "sqlite", ".sqlite3": "sqlite", + ".parquet": "parquet", + ".pq": "parquet", } /** @@ -96,6 +99,9 @@ export async function parseDataFile( case "json": return parseJsonFile(filePath, { limit, offset }) + case "parquet": + return parseParquetFile(filePath, { limit, offset }) + case "sqlite": { // For SQLite, we need a table name if (tableName) { diff --git a/src/main/lib/parsers/parquet-parser.ts b/src/main/lib/parsers/parquet-parser.ts new file mode 100644 index 00000000..09deb4ca --- /dev/null +++ b/src/main/lib/parsers/parquet-parser.ts @@ -0,0 +1,178 @@ +import type { ParsedData, ParsedColumn, ColumnType } from "./types" + +/** + * Map Parquet types to our column types + */ +function mapParquetType(parquetType: string): ColumnType { + const type = parquetType?.toUpperCase() || "" + + // Integer types + if ( + type.includes("INT") || + type.includes("LONG") || + type.includes("SHORT") || + type.includes("BYTE") + ) { + return "number" + } + + // Float types + if (type.includes("FLOAT") || type.includes("DOUBLE") || type.includes("DECIMAL")) { + return "number" + } + + // Boolean + if (type.includes("BOOL")) { + return "boolean" + } + + // Date/Time types + if ( + type.includes("DATE") || + type.includes("TIME") || + type.includes("TIMESTAMP") + ) { + return "date" + } + + // Default to string for everything else (UTF8, BYTE_ARRAY, etc.) + return "string" +} + +/** + * Extract column info from Parquet schema + */ +function extractColumnsFromSchema(schema: any): ParsedColumn[] { + const columns: ParsedColumn[] = [] + + if (!schema || !schema.schema) { + return columns + } + + // The schema object has a 'schema' property with field definitions + const fields = schema.schema + for (const [fieldName, fieldDef] of Object.entries(fields)) { + if (fieldName === "root" || !fieldDef) continue + + const field = fieldDef as any + const parquetType = field.type || field.originalType || "UTF8" + + columns.push({ + name: fieldName, + type: mapParquetType(parquetType), + }) + } + + return columns +} + +/** + * Parse a Parquet file and return structured data + */ +export async function parseParquetFile( + filePath: string, + options: { limit?: number; offset?: number } = {} +): Promise { + const { limit = 1000, offset = 0 } = options + + // Dynamic require to handle CommonJS module + const parquetModule = await import("parquetjs-lite") + const parquet = parquetModule.default || parquetModule + const ParquetReader = parquet.ParquetReader + + const reader = await ParquetReader.openFile(filePath) + + try { + // Get schema info + const schema = reader.getSchema() + const columns = extractColumnsFromSchema(schema) + + // Get total row count + const totalRows = Number(reader.getRowCount()) + + // Read rows with cursor + const cursor = reader.getCursor() + const rows: Record[] = [] + + let currentRow = 0 + let record: Record | null = null + + // Skip to offset + while (currentRow < offset && (record = await cursor.next())) { + currentRow++ + } + + // Read rows up to limit + while (rows.length < limit && (record = await cursor.next())) { + // Convert any BigInt values to numbers for JSON serialization + const processedRecord: Record = {} + for (const [key, value] of Object.entries(record)) { + if (typeof value === "bigint") { + processedRecord[key] = Number(value) + } else if (value instanceof Date) { + processedRecord[key] = value.toISOString() + } else if (Buffer.isBuffer(value)) { + // Handle binary data + processedRecord[key] = value.toString("utf-8") + } else { + processedRecord[key] = value + } + } + rows.push(processedRecord) + currentRow++ + } + + await reader.close() + + return { + columns, + rows, + totalRows, + truncated: offset + rows.length < totalRows, + } + } catch (error) { + await reader.close() + throw error + } +} + +/** + * Get row count from a Parquet file without reading all data + */ +export async function getParquetRowCount(filePath: string): Promise { + const parquetModule = await import("parquetjs-lite") + const parquet = parquetModule.default || parquetModule + const ParquetReader = parquet.ParquetReader + + const reader = await ParquetReader.openFile(filePath) + + try { + const rowCount = Number(reader.getRowCount()) + await reader.close() + return rowCount + } catch (error) { + await reader.close() + throw error + } +} + +/** + * Get column info from a Parquet file without reading data + */ +export async function getParquetColumns(filePath: string): Promise { + const parquetModule = await import("parquetjs-lite") + const parquet = parquetModule.default || parquetModule + const ParquetReader = parquet.ParquetReader + + const reader = await ParquetReader.openFile(filePath) + + try { + const schema = reader.getSchema() + const columns = extractColumnsFromSchema(schema) + await reader.close() + return columns + } catch (error) { + await reader.close() + throw error + } +} diff --git a/src/main/lib/parsers/parquetjs-lite.d.ts b/src/main/lib/parsers/parquetjs-lite.d.ts new file mode 100644 index 00000000..6f61f05b --- /dev/null +++ b/src/main/lib/parsers/parquetjs-lite.d.ts @@ -0,0 +1,27 @@ +declare module "parquetjs-lite" { + export interface ParquetSchema { + schema: Record + } + + export interface ParquetFieldDef { + name?: string + type?: string + originalType?: string + optional?: boolean + repeated?: boolean + fields?: Record + } + + export interface ParquetCursor { + next(): Promise | null> + rewind(): void + } + + export class ParquetReader { + static openFile(filePath: string): Promise + getSchema(): ParquetSchema + getRowCount(): bigint + getCursor(): ParquetCursor + close(): Promise + } +} diff --git a/src/main/lib/parsers/types.ts b/src/main/lib/parsers/types.ts index 5748b973..7d75eee4 100644 --- a/src/main/lib/parsers/types.ts +++ b/src/main/lib/parsers/types.ts @@ -12,7 +12,7 @@ export interface ParsedData { truncated: boolean } -export type DataFileType = "csv" | "json" | "sqlite" | "unknown" +export type DataFileType = "csv" | "json" | "sqlite" | "parquet" | "unknown" export interface DataFileInfo { path: string diff --git a/src/renderer/features/agents/atoms/index.ts b/src/renderer/features/agents/atoms/index.ts index 5846174d..e4bb9f06 100644 --- a/src/renderer/features/agents/atoms/index.ts +++ b/src/renderer/features/agents/atoms/index.ts @@ -607,3 +607,61 @@ export const selectedSqliteTableAtomFamily = atomFamily((filePath: string) => }, ), ) + +// ============================================================================ +// File Viewer Sidebar (Monaco Editor) +// ============================================================================ + +// File viewer sidebar width (global, shared across all chats) +export const fileViewerSidebarWidthAtom = atomWithStorage( + "agents-file-viewer-sidebar-width", + 500, + undefined, + { getOnInit: true }, +) + +// File viewer sidebar open state per chat +const fileViewerSidebarOpenStorageAtom = atomWithStorage>( + "agents:fileViewerSidebarOpen", + {}, + undefined, + { getOnInit: true }, +) + +// atomFamily to get/set file viewer sidebar open state per chatId +export const fileViewerSidebarOpenAtomFamily = atomFamily((chatId: string) => + atom( + (get) => get(fileViewerSidebarOpenStorageAtom)[chatId] ?? false, + (get, set, isOpen: boolean) => { + const current = get(fileViewerSidebarOpenStorageAtom) + set(fileViewerSidebarOpenStorageAtom, { ...current, [chatId]: isOpen }) + }, + ), +) + +// Currently viewed source file path per chat +const viewedSourceFileStorageAtom = atomWithStorage>( + "agents:viewedSourceFile", + {}, + undefined, + { getOnInit: true }, +) + +// atomFamily to get/set viewed source file path per chatId +export const viewedSourceFileAtomFamily = atomFamily((chatId: string) => + atom( + (get) => get(viewedSourceFileStorageAtom)[chatId] ?? null, + (get, set, filePath: string | null) => { + const current = get(viewedSourceFileStorageAtom) + set(viewedSourceFileStorageAtom, { ...current, [chatId]: filePath }) + }, + ), +) + +// Word wrap preference for file viewer (persisted globally) +export const fileViewerWordWrapAtom = atomWithStorage( + "agents:fileViewerWordWrap", + false, + undefined, + { getOnInit: true }, +) diff --git a/src/renderer/features/agents/main/active-chat.tsx b/src/renderer/features/agents/main/active-chat.tsx index 534e1265..3d114882 100644 --- a/src/renderer/features/agents/main/active-chat.tsx +++ b/src/renderer/features/agents/main/active-chat.tsx @@ -87,6 +87,7 @@ import { getShortcutKey, isDesktopApp } from "../../../lib/utils/platform" import { terminalSidebarOpenAtom } from "../../terminal/atoms" import { TerminalSidebar } from "../../terminal/terminal-sidebar" import { DataViewerSidebar } from "../../data/components/data-viewer-sidebar" +import { FileViewerSidebar } from "../../file-viewer" import { agentsDiffSidebarWidthAtom, agentsFileTreeSidebarOpenAtom, @@ -98,6 +99,9 @@ import { dataViewerSidebarOpenAtomFamily, dataViewerSidebarWidthAtom, viewedDataFileAtomFamily, + fileViewerSidebarOpenAtomFamily, + fileViewerSidebarWidthAtom, + viewedSourceFileAtomFamily, agentsSubChatUnseenChangesAtom, agentsUnseenChangesAtom, clearLoading, @@ -3613,6 +3617,17 @@ export function ChatView({ ) const [isDataViewerSidebarOpen, setIsDataViewerSidebarOpen] = useAtom(dataViewerSidebarAtom) const [viewedDataFile, setViewedDataFile] = useAtom(viewedDataFileAtom) + // Per-chat file viewer sidebar state (for source code files) + const fileViewerSidebarAtom = useMemo( + () => fileViewerSidebarOpenAtomFamily(chatId), + [chatId], + ) + const viewedSourceFileAtom = useMemo( + () => viewedSourceFileAtomFamily(chatId), + [chatId], + ) + const [isFileViewerSidebarOpen, setIsFileViewerSidebarOpen] = useAtom(fileViewerSidebarAtom) + const [viewedSourceFile, setViewedSourceFile] = useAtom(viewedSourceFileAtom) const [diffStats, setDiffStats] = useState({ fileCount: 0, additions: 0, @@ -4915,15 +4930,21 @@ export function ChatView({ projectPath={worktreePath || originalProjectPath} projectId={chatId} onClose={() => setIsFileTreeSidebarOpen(false)} - onSelectFile={(filePath) => { - // Check if it's a data file (CSV, JSON, SQLite) - const ext = filePath.includes(".") ? `.${filePath.split(".").pop()?.toLowerCase()}` : "" - const dataExtensions = [".csv", ".tsv", ".json", ".jsonl", ".db", ".sqlite", ".sqlite3"] - if (dataExtensions.includes(ext)) { - // Open in data viewer sidebar - setViewedDataFile(filePath) - setIsDataViewerSidebarOpen(true) - } + onSelectDataFile={(filePath) => { + // Data files: open in Data Viewer, close File Viewer + setViewedDataFile(filePath) + setIsDataViewerSidebarOpen(true) + // Close file viewer (mutual exclusion - one sidebar at a time) + setIsFileViewerSidebarOpen(false) + setViewedSourceFile(null) + }} + onSelectSourceFile={(filePath) => { + // Source files: open in File Viewer, close Data Viewer + setViewedSourceFile(filePath) + setIsFileViewerSidebarOpen(true) + // Close data viewer (mutual exclusion - one sidebar at a time) + setIsDataViewerSidebarOpen(false) + setViewedDataFile(null) }} /> @@ -5624,6 +5645,36 @@ export function ChatView({ /> )} + + {/* File Viewer Sidebar - for viewing source code files with Monaco Editor */} + {isFileViewerSidebarOpen && viewedSourceFile && (worktreePath || originalProjectPath) && ( + { + setIsFileViewerSidebarOpen(false) + setViewedSourceFile(null) + }} + widthAtom={fileViewerSidebarWidthAtom} + minWidth={350} + side="right" + animationDuration={0} + initialWidth={0} + exitWidth={0} + showResizeTooltip={true} + className="bg-background border-l" + style={{ borderLeftWidth: "0.5px", overflow: "hidden" }} + > + { + setIsFileViewerSidebarOpen(false) + setViewedSourceFile(null) + }} + /> + + )}
) diff --git a/src/renderer/features/agents/ui/file-tree/FileTreeNode.tsx b/src/renderer/features/agents/ui/file-tree/FileTreeNode.tsx index 243e1b1e..e42b5964 100644 --- a/src/renderer/features/agents/ui/file-tree/FileTreeNode.tsx +++ b/src/renderer/features/agents/ui/file-tree/FileTreeNode.tsx @@ -1,12 +1,12 @@ "use client" -import { ChevronRight, File, Folder, FolderOpen, FileSpreadsheet, FileJson, Database } from "lucide-react" +import { ChevronRight, File, Folder, FolderOpen, FileSpreadsheet, FileJson, Database, FileBox } from "lucide-react" import { memo, useCallback, useMemo } from "react" import { cn } from "../../../../lib/utils" import type { TreeNode } from "./build-file-tree" // Data file extensions for special icons -const DATA_FILE_EXTENSIONS: Record = { +const DATA_FILE_EXTENSIONS: Record = { ".csv": "csv", ".tsv": "csv", ".json": "json", @@ -14,9 +14,11 @@ const DATA_FILE_EXTENSIONS: Record = { ".db": "sqlite", ".sqlite": "sqlite", ".sqlite3": "sqlite", + ".parquet": "parquet", + ".pq": "parquet", } -function getDataFileType(filename: string): "csv" | "json" | "sqlite" | null { +function getDataFileType(filename: string): "csv" | "json" | "sqlite" | "parquet" | null { const ext = filename.includes(".") ? `.${filename.split(".").pop()?.toLowerCase()}` : "" return DATA_FILE_EXTENSIONS[ext] || null } @@ -67,6 +69,11 @@ interface FileTreeNodeProps { level: number expandedFolders: Set onToggleFolder: (path: string) => void + /** Called when a data file (CSV, JSON, SQLite, Parquet) is clicked */ + onSelectDataFile?: (path: string) => void + /** Called when a source file (non-data file) is clicked */ + onSelectSourceFile?: (path: string) => void + /** @deprecated Use onSelectDataFile and onSelectSourceFile instead */ onSelectFile?: (path: string) => void gitStatus?: GitStatusMap } @@ -76,6 +83,8 @@ export const FileTreeNode = memo(function FileTreeNode({ level, expandedFolders, onToggleFolder, + onSelectDataFile, + onSelectSourceFile, onSelectFile, gitStatus = {}, }: FileTreeNodeProps) { @@ -101,9 +110,18 @@ export const FileTreeNode = memo(function FileTreeNode({ if (node.type === "folder") { onToggleFolder(node.path) } else { + // Determine if this is a data file or source file + if (isDataFile(node.name)) { + // Data files: CSV, JSON, SQLite, Parquet + onSelectDataFile?.(node.path) + } else { + // Source files: everything else + onSelectSourceFile?.(node.path) + } + // Also call legacy onSelectFile for backwards compatibility onSelectFile?.(node.path) } - }, [node.type, node.path, onToggleFolder, onSelectFile]) + }, [node.type, node.path, node.name, onToggleFolder, onSelectDataFile, onSelectSourceFile, onSelectFile]) const paddingLeft = level * 12 + 6 @@ -162,6 +180,9 @@ export const FileTreeNode = memo(function FileTreeNode({ if (dataType === "sqlite") { return } + if (dataType === "parquet") { + return + } return })() )} @@ -193,6 +214,8 @@ export const FileTreeNode = memo(function FileTreeNode({ level={level + 1} expandedFolders={expandedFolders} onToggleFolder={onToggleFolder} + onSelectDataFile={onSelectDataFile} + onSelectSourceFile={onSelectSourceFile} onSelectFile={onSelectFile} gitStatus={gitStatus} /> diff --git a/src/renderer/features/agents/ui/file-tree/FileTreeSidebar.tsx b/src/renderer/features/agents/ui/file-tree/FileTreeSidebar.tsx index 36b258b2..5dbd62b1 100644 --- a/src/renderer/features/agents/ui/file-tree/FileTreeSidebar.tsx +++ b/src/renderer/features/agents/ui/file-tree/FileTreeSidebar.tsx @@ -19,6 +19,11 @@ interface FileTreeSidebarProps { projectPath: string | undefined projectId: string onClose: () => void + /** Called when a data file (CSV, JSON, SQLite, Parquet) is clicked */ + onSelectDataFile?: (path: string) => void + /** Called when a source file (non-data file) is clicked */ + onSelectSourceFile?: (path: string) => void + /** @deprecated Use onSelectDataFile and onSelectSourceFile instead */ onSelectFile?: (path: string) => void } @@ -26,6 +31,8 @@ export function FileTreeSidebar({ projectPath, projectId, onClose, + onSelectDataFile, + onSelectSourceFile, onSelectFile, }: FileTreeSidebarProps) { const [expandedFolders, setExpandedFolders] = useAtom( @@ -151,6 +158,8 @@ export function FileTreeSidebar({ level={0} expandedFolders={expandedFolders} onToggleFolder={handleToggleFolder} + onSelectDataFile={onSelectDataFile} + onSelectSourceFile={onSelectSourceFile} onSelectFile={onSelectFile} gitStatus={gitStatus as GitStatusMap} /> diff --git a/src/renderer/features/data/components/data-viewer-sidebar.tsx b/src/renderer/features/data/components/data-viewer-sidebar.tsx index 525a2682..0d250a69 100644 --- a/src/renderer/features/data/components/data-viewer-sidebar.tsx +++ b/src/renderer/features/data/components/data-viewer-sidebar.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState, useEffect } from "react" +import { useCallback, useMemo, useState, useEffect, useRef } from "react" import { DataEditor, GridColumn, @@ -18,14 +18,23 @@ import { Database, FileSpreadsheet, FileJson, + FileBox, Search, Pin, Hash, ArrowUpAZ, ArrowDownAZ, EyeOff, + ChevronLeft, + ChevronRight, + ChevronsLeft, + ChevronsRight, + Copy, + Expand, + CornerDownRight, } from "lucide-react" import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" import { Select, SelectContent, @@ -46,6 +55,12 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" import { cn } from "@/lib/utils" import { trpc } from "@/lib/trpc" import { selectedSqliteTableAtomFamily } from "../../agents/atoms" @@ -58,6 +73,10 @@ interface DataViewerSidebarProps { onClose: () => void } +// Page size options +const PAGE_SIZE_OPTIONS = [100, 500, 1000, 5000] as const +type PageSize = (typeof PAGE_SIZE_OPTIONS)[number] + /** * Get the file extension */ @@ -69,7 +88,7 @@ function getFileExtension(filePath: string): string { /** * Get file type from extension */ -function getFileType(filePath: string): "csv" | "json" | "sqlite" | "unknown" { +function getFileType(filePath: string): "csv" | "json" | "sqlite" | "parquet" | "unknown" { const ext = getFileExtension(filePath) switch (ext) { case ".csv": @@ -82,6 +101,9 @@ function getFileType(filePath: string): "csv" | "json" | "sqlite" | "unknown" { case ".sqlite": case ".sqlite3": return "sqlite" + case ".parquet": + case ".pq": + return "parquet" default: return "unknown" } @@ -100,6 +122,8 @@ function FileIcon({ filePath }: { filePath: string }) { return case "sqlite": return + case "parquet": + return default: return } @@ -123,6 +147,37 @@ function formatCellValue(value: unknown): string { return String(value) } +/** + * Format JSON with syntax highlighting for display + */ +function formatJsonForDisplay(value: unknown): string { + try { + if (typeof value === "string") { + // Try to parse as JSON + const parsed = JSON.parse(value) + return JSON.stringify(parsed, null, 2) + } + return JSON.stringify(value, null, 2) + } catch { + return String(value) + } +} + +/** + * Check if value is JSON-like (object or array or parseable string) + */ +function isJsonLike(value: unknown): boolean { + if (typeof value === "object" && value !== null) return true + if (typeof value === "string") { + const trimmed = value.trim() + return ( + (trimmed.startsWith("{") && trimmed.endsWith("}")) || + (trimmed.startsWith("[") && trimmed.endsWith("]")) + ) + } + return false +} + export function DataViewerSidebar({ chatId, filePath, @@ -133,6 +188,13 @@ export function DataViewerSidebar({ const fileName = getFileName(filePath) const { resolvedTheme } = useTheme() const isDark = resolvedTheme === "dark" + const gridRef = useRef(null) + + // ============ Pagination State ============ + const [pageSize, setPageSize] = useState(1000) + const [currentPage, setCurrentPage] = useState(0) + const [jumpToRowInput, setJumpToRowInput] = useState("") + const [showJumpDialog, setShowJumpDialog] = useState(false) // ============ Column State ============ const [columnWidths, setColumnWidths] = useState>({}) @@ -154,7 +216,25 @@ export function DataViewerSidebar({ // ============ Header Menu State ============ const [menuColumn, setMenuColumn] = useState(null) - const [menuPosition, setMenuPosition] = useState<{ x: number; y: number } | null>(null) + const [menuPosition, setMenuPosition] = useState<{ + x: number + y: number + } | null>(null) + + // ============ Cell Context Menu State ============ + const [cellMenuPosition, setCellMenuPosition] = useState<{ + x: number + y: number + cell: Item + } | null>(null) + + // ============ Cell Details Dialog State ============ + const [cellDetailsOpen, setCellDetailsOpen] = useState(false) + const [cellDetailsContent, setCellDetailsContent] = useState<{ + columnName: string + value: unknown + rowIndex: number + } | null>(null) // ============ Sort State ============ const [sortColumn, setSortColumn] = useState(null) @@ -233,17 +313,27 @@ export function DataViewerSidebar({ // Auto-select first table if none selected useEffect(() => { - if (fileType === "sqlite" && tables && tables.length > 0 && !selectedTable) { + if ( + fileType === "sqlite" && + tables && + tables.length > 0 && + !selectedTable + ) { setSelectedTable(tables[0]) } }, [fileType, tables, selectedTable, setSelectedTable]) - // Fetch data + // Reset page when file/table changes + useEffect(() => { + setCurrentPage(0) + }, [absolutePath, selectedTable]) + + // Fetch data with pagination const { data, isLoading, error } = trpc.files.previewDataFile.useQuery( { filePath: absolutePath, - limit: 1000, - offset: 0, + limit: pageSize, + offset: currentPage * pageSize, tableName: fileType === "sqlite" ? selectedTable || undefined : undefined, }, { @@ -253,6 +343,12 @@ export function DataViewerSidebar({ } ) + // Calculate pagination info + const totalRows = data?.totalRows ?? 0 + const totalPages = Math.ceil(totalRows / pageSize) + const startRow = currentPage * pageSize + 1 + const endRow = Math.min((currentPage + 1) * pageSize, totalRows) + // Initialize column order when data changes useEffect(() => { if (data?.columns) { @@ -389,6 +485,45 @@ export function DataViewerSidebar({ [orderedColumns] ) + // Cell click handler for showing details + const onCellActivated = useCallback( + (cell: Item) => { + const [col, row] = cell + const column = orderedColumns[col] + if (!column) return + + const colName = column.id as string + const rowData = sortedRows[row] + const value = rowData?.[colName] + + // Show details dialog for long text or JSON + const stringValue = formatCellValue(value) + if (stringValue.length > 50 || isJsonLike(value)) { + setCellDetailsContent({ + columnName: colName, + value, + rowIndex: currentPage * pageSize + row + 1, + }) + setCellDetailsOpen(true) + } + }, + [orderedColumns, sortedRows, currentPage, pageSize] + ) + + // Cell context menu handler + const onCellContextMenu = useCallback( + (cell: Item, event: any) => { + event.preventDefault?.() + const bounds = event.bounds || { x: event.clientX, y: event.clientY } + setCellMenuPosition({ + x: bounds.x ?? event.clientX ?? 0, + y: bounds.y ?? event.clientY ?? 0, + cell, + }) + }, + [] + ) + const handleSort = useCallback( (direction: "asc" | "desc") => { if (menuColumn !== null && orderedColumns[menuColumn]) { @@ -423,6 +558,65 @@ export function DataViewerSidebar({ setHiddenColumns(new Set()) }, []) + // Copy cell value to clipboard + const handleCopyCellValue = useCallback(() => { + if (!cellMenuPosition) return + const [col, row] = cellMenuPosition.cell + const column = orderedColumns[col] + if (!column) return + + const colName = column.id as string + const rowData = sortedRows[row] + const value = rowData?.[colName] + const stringValue = formatCellValue(value) + + navigator.clipboard.writeText(stringValue) + setCellMenuPosition(null) + }, [cellMenuPosition, orderedColumns, sortedRows]) + + // Show cell details from context menu + const handleShowCellDetails = useCallback(() => { + if (!cellMenuPosition) return + const [col, row] = cellMenuPosition.cell + const column = orderedColumns[col] + if (!column) return + + const colName = column.id as string + const rowData = sortedRows[row] + const value = rowData?.[colName] + + setCellDetailsContent({ + columnName: colName, + value, + rowIndex: currentPage * pageSize + row + 1, + }) + setCellDetailsOpen(true) + setCellMenuPosition(null) + }, [cellMenuPosition, orderedColumns, sortedRows, currentPage, pageSize]) + + // Jump to row handler + const handleJumpToRow = useCallback(() => { + const rowNum = parseInt(jumpToRowInput, 10) + if (isNaN(rowNum) || rowNum < 1 || rowNum > totalRows) return + + // Calculate which page contains this row + const targetPage = Math.floor((rowNum - 1) / pageSize) + setCurrentPage(targetPage) + + // Calculate row index within the page + const rowIndexInPage = (rowNum - 1) % pageSize + + // Scroll to the row after data loads + setTimeout(() => { + if (gridRef.current) { + gridRef.current.scrollTo?.(0, rowIndexInPage) + } + }, 100) + + setShowJumpDialog(false) + setJumpToRowInput("") + }, [jumpToRowInput, totalRows, pageSize]) + // Get cell content with proper cell types const getCellContent = useCallback( (cell: Item): GridCell => { @@ -497,7 +691,11 @@ export function DataViewerSidebar({ const result: GridCell[][] = [] for (let row = selection.y; row < selection.y + selection.height; row++) { const rowCells: GridCell[] = [] - for (let col = selection.x; col < selection.x + selection.width; col++) { + for ( + let col = selection.x; + col < selection.x + selection.width; + col++ + ) { rowCells.push(getCellContent([col, row])) } result.push(rowCells) @@ -515,7 +713,9 @@ export function DataViewerSidebar({

Failed to load file

-

{error.message}

+

+ {error.message} +

@@ -539,6 +739,7 @@ export function DataViewerSidebar({ sortColumn={sortColumn} sortDirection={sortDirection} onClearSort={() => setSortColumn(null)} + onJumpToRow={() => setShowJumpDialog(true)} /> {/* Table selector for SQLite */} @@ -562,13 +763,14 @@ export function DataViewerSidebar({ {/* Grid */}
- {isLoading ? ( + {isLoading && !data ? (
) : data && sortedRows.length > 0 ? ( <> + {/* Loading overlay for page changes */} + {isLoading && ( +
+ +
+ )} + {/* Header Menu Dropdown */} {menuColumn !== null && menuPosition && (
)} + + {/* Cell Context Menu */} + {cellMenuPosition && ( +
+ setCellMenuPosition(null)} + > + +
+ + + + + Copy Value + + + + View Details + + + +
+ )} ) : (
@@ -668,20 +907,191 @@ export function DataViewerSidebar({ )}
- {/* Footer with row count */} + {/* Pagination Footer */} {data && ( -
- - {data.truncated - ? `Showing ${sortedRows.length.toLocaleString()} of ${data.totalRows.toLocaleString()} rows` - : `${sortedRows.length.toLocaleString()} rows`} - - - {orderedColumns.length} - {hiddenColumns.size > 0 && ` (${hiddenColumns.size} hidden)`} columns - +
+
+ + {totalRows > 0 + ? `${startRow.toLocaleString()}-${endRow.toLocaleString()} of ${totalRows.toLocaleString()}` + : "0 rows"} + + | + + {orderedColumns.length} + {hiddenColumns.size > 0 && ` (${hiddenColumns.size} hidden)`} cols + +
+ + {/* Pagination controls */} + {totalPages > 1 && ( +
+ + + + + + First page + + + + + + + Previous page + + + + + {currentPage + 1} / {totalPages} + + + + + + + + Next page + + + + + + + Last page + + + + {/* Page size selector */} + +
+ )}
)} + + {/* Jump to Row Dialog */} + + + + Jump to Row + +
+ setJumpToRowInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + handleJumpToRow() + } + }} + autoFocus + /> + +
+
+
+ + {/* Cell Details Dialog */} + + + + + + {cellDetailsContent?.columnName} + + + Row {cellDetailsContent?.rowIndex} + + + +
+ {cellDetailsContent && ( +
+
+                  {isJsonLike(cellDetailsContent.value)
+                    ? formatJsonForDisplay(cellDetailsContent.value)
+                    : formatCellValue(cellDetailsContent.value)}
+                
+ +
+ )} +
+
+
) } @@ -732,6 +1142,7 @@ interface ToolbarProps { sortColumn: string | null sortDirection: "asc" | "desc" onClearSort: () => void + onJumpToRow: () => void } function Toolbar({ @@ -744,6 +1155,7 @@ function Toolbar({ onShowAllColumns, sortColumn, onClearSort, + onJumpToRow, }: ToolbarProps) { return (
@@ -792,6 +1204,20 @@ function Toolbar({ Toggle row numbers + + + + + Jump to row + + {hiddenColumnCount > 0 && ( diff --git a/src/renderer/features/file-viewer/components/file-viewer-sidebar.tsx b/src/renderer/features/file-viewer/components/file-viewer-sidebar.tsx new file mode 100644 index 00000000..aa44775f --- /dev/null +++ b/src/renderer/features/file-viewer/components/file-viewer-sidebar.tsx @@ -0,0 +1,280 @@ +import { useCallback, useEffect, useMemo } from "react" +import Editor from "@monaco-editor/react" +import { useAtom } from "jotai" +import { useTheme } from "next-themes" +import { + X, + Loader2, + FileCode, + WrapText, + AlertCircle, + RefreshCw, +} from "lucide-react" +import { Button } from "@/components/ui/button" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" +import { cn } from "@/lib/utils" +import { fileViewerWordWrapAtom } from "../../agents/atoms" +import { useFileContent, getErrorMessage } from "../hooks/use-file-content" +import { getMonacoLanguage } from "../utils/language-map" +import { defaultEditorOptions, getMonacoTheme } from "./monaco-config" + +interface FileViewerSidebarProps { + chatId: string + filePath: string + projectPath: string + onClose: () => void +} + +/** + * Get file name from path + */ +function getFileName(filePath: string): string { + const parts = filePath.split("/") + return parts[parts.length - 1] || filePath +} + +/** + * Format file size for display + */ +function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` + return `${(bytes / (1024 * 1024)).toFixed(2)} MB` +} + +/** + * File icon based on language + */ +function FileIcon({ language }: { language: string }) { + // For now, use a generic file code icon + // Could be expanded to show language-specific icons + return +} + +/** + * Loading spinner component + */ +function LoadingSpinner() { + return ( +
+
+ + Loading file... +
+
+ ) +} + +/** + * Error display component + */ +function ErrorDisplay({ + error, + onRetry, +}: { + error: string + onRetry: () => void +}) { + return ( +
+
+ +
+

{error}

+

+ The file cannot be displayed in the viewer. +

+
+ +
+
+ ) +} + +/** + * Header component for the sidebar + */ +function Header({ + fileName, + filePath, + byteLength, + wordWrap, + onToggleWordWrap, + onClose, +}: { + fileName: string + filePath: string + byteLength: number | null + wordWrap: boolean + onToggleWordWrap: () => void + onClose: () => void +}) { + const language = getMonacoLanguage(filePath) + + return ( +
+
+ + + {fileName} + + {byteLength !== null && ( + + {formatFileSize(byteLength)} + + )} +
+
+ {/* Word wrap toggle */} + + + + + + {wordWrap ? "Disable word wrap" : "Enable word wrap"} + + + {/* Close button */} + +
+
+ ) +} + +/** + * FileViewerSidebar - Monaco Editor-based file viewer + */ +export function FileViewerSidebar({ + chatId, + filePath, + projectPath, + onClose, +}: FileViewerSidebarProps) { + const fileName = getFileName(filePath) + const language = getMonacoLanguage(filePath) + const { resolvedTheme } = useTheme() + const monacoTheme = getMonacoTheme(resolvedTheme || "dark") + + // Word wrap preference + const [wordWrap, setWordWrap] = useAtom(fileViewerWordWrapAtom) + + // Load file content + const { content, isLoading, error, byteLength, refetch } = useFileContent( + projectPath, + filePath + ) + + // Handle keyboard shortcuts + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Escape to close + if (e.key === "Escape") { + e.preventDefault() + onClose() + } + } + + window.addEventListener("keydown", handleKeyDown) + return () => window.removeEventListener("keydown", handleKeyDown) + }, [onClose]) + + // Toggle word wrap + const handleToggleWordWrap = useCallback(() => { + setWordWrap(!wordWrap) + }, [wordWrap, setWordWrap]) + + // Editor options with word wrap setting + const editorOptions = useMemo( + () => ({ + ...defaultEditorOptions, + wordWrap: wordWrap ? ("on" as const) : ("off" as const), + }), + [wordWrap] + ) + + // Loading state + if (isLoading) { + return ( +
+
+ +
+ ) + } + + // Error state + if (error) { + return ( +
+
+ +
+ ) + } + + return ( +
+
+
+ } + /> +
+
+ ) +} diff --git a/src/renderer/features/file-viewer/components/monaco-config.ts b/src/renderer/features/file-viewer/components/monaco-config.ts new file mode 100644 index 00000000..82d509ce --- /dev/null +++ b/src/renderer/features/file-viewer/components/monaco-config.ts @@ -0,0 +1,78 @@ +import { loader } from "@monaco-editor/react" +import type { editor } from "monaco-editor" + +// Configure Monaco loader for Electron +// In Electron, we need to ensure Monaco loads from local node_modules +// rather than from CDN, which may not work in packaged apps +export function configureMonacoLoader() { + // Monaco will automatically resolve from node_modules in Electron + // The @monaco-editor/react package handles this well by default + // but we can configure it explicitly if needed + loader.config({ + // Use default CDN in development, will work in Electron + // For production builds, the bundler handles Monaco correctly + }) +} + +// Default editor options for read-only file viewing +export const defaultEditorOptions: editor.IStandaloneEditorConstructionOptions = { + readOnly: true, + minimap: { enabled: true }, + lineNumbers: "on", + wordWrap: "off", + automaticLayout: true, + fontSize: 13, + fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace", + folding: true, + foldingStrategy: "indentation", + showFoldingControls: "mouseover", + bracketPairColorization: { enabled: true }, + guides: { + bracketPairs: true, + indentation: true, + }, + scrollBeyondLastLine: false, + renderWhitespace: "selection", + scrollbar: { + vertical: "auto", + horizontal: "auto", + useShadows: false, + verticalScrollbarSize: 10, + horizontalScrollbarSize: 10, + }, + padding: { + top: 8, + bottom: 8, + }, + // Disable features not needed for read-only viewing + quickSuggestions: false, + parameterHints: { enabled: false }, + suggestOnTriggerCharacters: false, + acceptSuggestionOnEnter: "off", + tabCompletion: "off", + wordBasedSuggestions: "off", + // Enable search functionality + find: { + addExtraSpaceOnTop: false, + autoFindInSelection: "never", + seedSearchStringFromSelection: "always", + }, + // Make it look nice + smoothScrolling: true, + cursorBlinking: "solid", + cursorStyle: "line", + renderLineHighlight: "line", + contextmenu: true, + mouseWheelZoom: true, +} + +// Map app theme to Monaco theme +export function getMonacoTheme(appTheme: string): string { + // Check if it's a dark theme + const isDark = appTheme.includes("dark") || + appTheme === "vesper" || + appTheme === "min-dark" || + appTheme === "vitesse-dark" + + return isDark ? "vs-dark" : "vs" +} diff --git a/src/renderer/features/file-viewer/hooks/use-file-content.ts b/src/renderer/features/file-viewer/hooks/use-file-content.ts new file mode 100644 index 00000000..8762d093 --- /dev/null +++ b/src/renderer/features/file-viewer/hooks/use-file-content.ts @@ -0,0 +1,127 @@ +import { trpc } from "../../../lib/trpc" + +/** + * Error reasons for file loading failures + */ +export type FileLoadError = + | "not-found" + | "too-large" + | "binary" + | "outside-worktree" + | "symlink-escape" + | "unknown" + +/** + * Result of file content loading + */ +export interface FileContentResult { + content: string | null + isLoading: boolean + error: FileLoadError | null + byteLength: number | null + refetch: () => void +} + +/** + * Get user-friendly error message for file load errors + */ +export function getErrorMessage(error: FileLoadError): string { + switch (error) { + case "not-found": + return "File not found" + case "too-large": + return "File is too large to display (max 2 MB)" + case "binary": + return "Cannot display binary file" + case "outside-worktree": + return "File is outside the project directory" + case "symlink-escape": + return "Cannot follow symlink outside project" + case "unknown": + default: + return "Failed to load file" + } +} + +/** + * Hook to fetch file content from the backend + * Uses the existing tRPC changes.readWorkingFile procedure + */ +export function useFileContent( + projectPath: string | null, + filePath: string | null, +): FileContentResult { + const enabled = !!projectPath && !!filePath + + const query = trpc.changes.readWorkingFile.useQuery( + { + worktreePath: projectPath || "", + filePath: filePath || "", + }, + { + enabled, + staleTime: 30000, // Cache for 30 seconds + refetchOnWindowFocus: false, + }, + ) + + // Parse the result + if (!enabled) { + return { + content: null, + isLoading: false, + error: null, + byteLength: null, + refetch: () => {}, + } + } + + if (query.isLoading) { + return { + content: null, + isLoading: true, + error: null, + byteLength: null, + refetch: query.refetch, + } + } + + if (query.error) { + return { + content: null, + isLoading: false, + error: "unknown", + byteLength: null, + refetch: query.refetch, + } + } + + const result = query.data + if (!result) { + return { + content: null, + isLoading: false, + error: "unknown", + byteLength: null, + refetch: query.refetch, + } + } + + if (result.ok) { + return { + content: result.content, + isLoading: false, + error: null, + byteLength: result.byteLength, + refetch: query.refetch, + } + } + + return { + content: null, + isLoading: false, + error: result.reason as FileLoadError, + byteLength: null, + refetch: query.refetch, + } +} diff --git a/src/renderer/features/file-viewer/index.ts b/src/renderer/features/file-viewer/index.ts new file mode 100644 index 00000000..545ce326 --- /dev/null +++ b/src/renderer/features/file-viewer/index.ts @@ -0,0 +1,3 @@ +export { FileViewerSidebar } from "./components/file-viewer-sidebar" +export { useFileContent, getErrorMessage, type FileLoadError } from "./hooks/use-file-content" +export { getMonacoLanguage, isDataFile } from "./utils/language-map" diff --git a/src/renderer/features/file-viewer/utils/language-map.ts b/src/renderer/features/file-viewer/utils/language-map.ts new file mode 100644 index 00000000..409141e5 --- /dev/null +++ b/src/renderer/features/file-viewer/utils/language-map.ts @@ -0,0 +1,230 @@ +/** + * Map file extensions to Monaco Editor language IDs + * Monaco uses slightly different language IDs than Shiki in some cases + */ + +// Extension to Monaco language ID mapping +const extensionToMonacoLanguage: Record = { + // JavaScript/TypeScript + ".ts": "typescript", + ".tsx": "typescript", + ".js": "javascript", + ".jsx": "javascript", + ".mjs": "javascript", + ".cjs": "javascript", + ".mts": "typescript", + ".cts": "typescript", + + // Web + ".html": "html", + ".htm": "html", + ".css": "css", + ".scss": "scss", + ".less": "less", + ".vue": "html", // Monaco doesn't have Vue, use HTML + ".svelte": "html", // Monaco doesn't have Svelte, use HTML + + // Data formats + ".json": "json", + ".jsonc": "json", + ".json5": "json", + ".yaml": "yaml", + ".yml": "yaml", + ".toml": "ini", // Monaco uses 'ini' for TOML-like formats + ".xml": "xml", + ".svg": "xml", + + // Markdown + ".md": "markdown", + ".mdx": "markdown", + ".markdown": "markdown", + + // Python + ".py": "python", + ".pyw": "python", + ".pyi": "python", + + // Ruby + ".rb": "ruby", + ".rake": "ruby", + ".gemspec": "ruby", + + // Go + ".go": "go", + ".mod": "go", // go.mod + + // Rust + ".rs": "rust", + + // Java/Kotlin + ".java": "java", + ".kt": "kotlin", + ".kts": "kotlin", + + // Swift + ".swift": "swift", + + // C/C++ + ".c": "c", + ".h": "c", + ".cpp": "cpp", + ".cc": "cpp", + ".cxx": "cpp", + ".hpp": "cpp", + ".hxx": "cpp", + ".hh": "cpp", + + // C# + ".cs": "csharp", + + // PHP + ".php": "php", + ".phtml": "php", + + // SQL + ".sql": "sql", + + // Shell + ".sh": "shell", + ".bash": "shell", + ".zsh": "shell", + ".fish": "shell", + ".ps1": "powershell", + ".psm1": "powershell", + + // GraphQL + ".graphql": "graphql", + ".gql": "graphql", + + // Docker + ".dockerfile": "dockerfile", + + // Config files + ".ini": "ini", + ".conf": "ini", + ".cfg": "ini", + ".properties": "ini", + + // Lua + ".lua": "lua", + + // R + ".r": "r", + ".R": "r", + + // Perl + ".pl": "perl", + ".pm": "perl", + + // Clojure + ".clj": "clojure", + ".cljs": "clojure", + ".cljc": "clojure", + ".edn": "clojure", + + // Elixir/Erlang + ".ex": "elixir", + ".exs": "elixir", + ".erl": "erlang", + + // Haskell + ".hs": "haskell", + + // Scala + ".scala": "scala", + ".sc": "scala", + + // F# + ".fs": "fsharp", + ".fsx": "fsharp", + + // Objective-C + ".m": "objective-c", + ".mm": "objective-c", + + // Dart + ".dart": "dart", + + // Plain text / config + ".txt": "plaintext", + ".log": "plaintext", + ".gitignore": "plaintext", + ".gitattributes": "plaintext", + ".env": "plaintext", + ".env.local": "plaintext", + ".env.development": "plaintext", + ".env.production": "plaintext", + ".editorconfig": "ini", + ".prettierrc": "json", + ".eslintrc": "json", + ".babelrc": "json", + + // Diff/Patch + ".diff": "plaintext", + ".patch": "plaintext", +} + +// Special filename mappings (no extension or special names) +const filenameToMonacoLanguage: Record = { + "dockerfile": "dockerfile", + "Dockerfile": "dockerfile", + "makefile": "makefile", + "Makefile": "makefile", + "GNUmakefile": "makefile", + "CMakeLists.txt": "cmake", + "Gemfile": "ruby", + "Rakefile": "ruby", + "Vagrantfile": "ruby", + "Podfile": "ruby", + ".gitignore": "plaintext", + ".gitattributes": "plaintext", + ".dockerignore": "plaintext", + ".npmignore": "plaintext", + ".prettierignore": "plaintext", + ".eslintignore": "plaintext", + "package.json": "json", + "tsconfig.json": "json", + "jsconfig.json": "json", + ".prettierrc": "json", + ".eslintrc": "json", + ".babelrc": "json", +} + +/** + * Get Monaco Editor language ID from file path + */ +export function getMonacoLanguage(filePath: string): string { + // Get filename from path + const filename = filePath.split("/").pop() || filePath + + // Check special filenames first + if (filenameToMonacoLanguage[filename]) { + return filenameToMonacoLanguage[filename] + } + + // Check extension + const ext = filename.toLowerCase().match(/\.[^.]+$/)?.[0] || "" + if (extensionToMonacoLanguage[ext]) { + return extensionToMonacoLanguage[ext] + } + + return "plaintext" +} + +/** + * Check if a file is a data file (should open in Data Viewer instead) + */ +export function isDataFile(filePath: string): boolean { + const ext = filePath.toLowerCase().match(/\.[^.]+$/)?.[0] || "" + const dataExtensions = [ + ".csv", + ".tsv", + ".json", + ".jsonl", + ".db", + ".sqlite", + ".sqlite3", + ".parquet", + ] + return dataExtensions.includes(ext) +} diff --git a/test-data/employees.parquet b/test-data/employees.parquet new file mode 100644 index 0000000000000000000000000000000000000000..5e4b30f4554fa734a8fa4ea4fd9767aedaf68f26 GIT binary patch literal 2125 zcmb_ePiWI%6#vr1uFJX&M&D;5Q3A5S4h|V zzLy{YgcAWWq$x3(_>U43f)yn+(dito8zPZFR1CNj@B;yl2D~TWv4Hmmyx|libvQ~? zC1TG5dZmve_!_ZfC->dM!ray(__pUJ&zRW-{ghd=%S!)v!OmLxjGK4LuETA0zs4y0 zk@l#t&?nq0`drbjP^mCTQvOnXQ7|vwg;-b@*<1I2<7#mYPTU*pKdAC@?J(F%F0&%}$l4BMvUb9V+ zZi+@-Oe)hyf4POw9*=aSV@Mj3fy9IlBaI*(MPh2lks#uS zESfn#;&p+p3&sY1=8(ak9dv~(+9YJ_Yf9m;ifz43ab+K5d(OEeAKLhNqphLx?dK-mZgytavT zwzWsOfp&S`Dq$xTEvLc@z9P~U(byhLc8>d38I80Co!S~RMAc{vaQtAjBW=S%4L24O ztc!q6$0d1hs6LL_)kX{u-TbNvPIQ9@92HDtM{fENaP$fIqtUei$oOjEFQYcoV=~)= zfO(@KY^dAQlIOY=h!c>837ek;;<(^rVVH^FhBX`k8CL;VapdEs1tMQ3R9w`g2~|@I z#qp=K3BpYDJAf-WD#0AEo!LFnBCO%OG%$k=q( Ss#az6YME5=Z+;s8k@x{+=8w7n literal 0 HcmV?d00001 From a7072b0f01e9d5d311f839dcb7aa5446c824bfe0 Mon Sep 17 00:00:00 2001 From: arnavv-guptaa Date: Sun, 18 Jan 2026 17:02:26 +0800 Subject: [PATCH 06/51] working file viewer --- bun.lock | 15 +- package.json | 3 + src/main/lib/trpc/routers/files.ts | 54 +++++++- .../file-viewer/components/monaco-config.ts | 39 ++++-- .../file-viewer/hooks/use-file-content.ts | 131 ++++++++++-------- 5 files changed, 166 insertions(+), 76 deletions(-) diff --git a/bun.lock b/bun.lock index ba63c839..204b49e3 100644 --- a/bun.lock +++ b/bun.lock @@ -34,6 +34,7 @@ "@trpc/client": "^11.7.1", "@trpc/react-query": "^11.7.1", "@trpc/server": "^11.7.1", + "@types/lodash-es": "^4.17.12", "@xterm/addon-canvas": "^0.7.0", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-search": "^0.16.0", @@ -50,7 +51,9 @@ "electron-updater": "^6.7.3", "gray-matter": "^4.0.3", "jotai": "^2.11.1", + "lodash-es": "^4.17.22", "lucide-react": "^0.468.0", + "monaco-editor": "^0.55.1", "motion": "^11.15.0", "next-themes": "^0.4.4", "node-pty": "^1.1.0", @@ -661,6 +664,10 @@ "@types/keyv": ["@types/keyv@3.1.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg=="], + "@types/lodash": ["@types/lodash@4.17.23", "", {}, "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA=="], + + "@types/lodash-es": ["@types/lodash-es@4.17.12", "", { "dependencies": { "@types/lodash": "*" } }, "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ=="], + "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], @@ -979,7 +986,7 @@ "dmg-license": ["dmg-license@1.0.11", "", { "dependencies": { "@types/plist": "^3.0.1", "@types/verror": "^1.10.3", "ajv": "^6.10.0", "crc": "^3.8.0", "iconv-corefoundation": "^1.1.7", "plist": "^3.0.4", "smart-buffer": "^4.0.2", "verror": "^1.10.0" }, "os": "darwin", "bin": { "dmg-license": "bin/dmg-license.js" } }, "sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q=="], - "dompurify": ["dompurify@3.3.1", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q=="], + "dompurify": ["dompurify@3.2.7", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw=="], "dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], @@ -1311,6 +1318,8 @@ "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], + "lodash-es": ["lodash-es@4.17.22", "", {}, "sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q=="], + "lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="], "lodash.difference": ["lodash.difference@4.5.0", "", {}, "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA=="], @@ -2131,8 +2140,6 @@ "minizlib/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], - "monaco-editor/dompurify": ["dompurify@3.2.7", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw=="], - "monaco-editor/marked": ["marked@14.0.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ=="], "object.omit/is-extendable": ["is-extendable@1.0.1", "", { "dependencies": { "is-plain-object": "^2.0.4" } }, "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA=="], @@ -2143,6 +2150,8 @@ "path-scurry/minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], + "posthog-js/dompurify": ["dompurify@3.3.1", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q=="], + "react-syntax-highlighter/highlight.js": ["highlight.js@10.7.3", "", {}, "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A=="], "react-syntax-highlighter/lowlight": ["lowlight@1.20.0", "", { "dependencies": { "fault": "^1.0.0", "highlight.js": "~10.7.0" } }, "sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw=="], diff --git a/package.json b/package.json index 4821b4ec..6850515e 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "@trpc/client": "^11.7.1", "@trpc/react-query": "^11.7.1", "@trpc/server": "^11.7.1", + "@types/lodash-es": "^4.17.12", "@xterm/addon-canvas": "^0.7.0", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-search": "^0.16.0", @@ -74,7 +75,9 @@ "electron-updater": "^6.7.3", "gray-matter": "^4.0.3", "jotai": "^2.11.1", + "lodash-es": "^4.17.22", "lucide-react": "^0.468.0", + "monaco-editor": "^0.55.1", "motion": "^11.15.0", "next-themes": "^0.4.4", "node-pty": "^1.1.0", diff --git a/src/main/lib/trpc/routers/files.ts b/src/main/lib/trpc/routers/files.ts index 953adbb5..a5ac89f5 100644 --- a/src/main/lib/trpc/routers/files.ts +++ b/src/main/lib/trpc/routers/files.ts @@ -1,6 +1,6 @@ import { z } from "zod" import { router, publicProcedure } from "../index" -import { readdir, stat } from "node:fs/promises" +import { readdir, stat, readFile } from "node:fs/promises" import { watch, type FSWatcher } from "node:fs" import { join, relative, basename } from "node:path" import { observable } from "@trpc/server/observable" @@ -739,4 +739,56 @@ export const filesRouter = router({ .query(({ input }) => { return listSqliteTables(input.filePath) }), + + /** + * Read a text file's content for the file viewer + * Returns the content as a string with size and binary detection + */ + readTextFile: publicProcedure + .input(z.object({ filePath: z.string() })) + .query(async ({ input }): Promise<{ + ok: true + content: string + byteLength: number + } | { + ok: false + reason: "not-found" | "too-large" | "binary" + }> => { + const MAX_FILE_SIZE = 2 * 1024 * 1024 // 2 MB + const BINARY_CHECK_SIZE = 8192 + + console.log("[files.readTextFile] Reading file:", input.filePath) + + try { + // Check file size first + const stats = await stat(input.filePath) + console.log("[files.readTextFile] File size:", stats.size) + if (stats.size > MAX_FILE_SIZE) { + console.log("[files.readTextFile] File too large") + return { ok: false, reason: "too-large" } + } + + // Read file content + const buffer = await readFile(input.filePath) + + // Check for binary content (NUL bytes in first 8KB) + const checkLength = Math.min(buffer.length, BINARY_CHECK_SIZE) + for (let i = 0; i < checkLength; i++) { + if (buffer[i] === 0) { + console.log("[files.readTextFile] Binary file detected") + return { ok: false, reason: "binary" } + } + } + + console.log("[files.readTextFile] Success, returning", buffer.length, "bytes") + return { + ok: true, + content: buffer.toString("utf-8"), + byteLength: buffer.length, + } + } catch (error) { + console.error("[files.readTextFile] Error:", error) + return { ok: false, reason: "not-found" } + } + }), }) diff --git a/src/renderer/features/file-viewer/components/monaco-config.ts b/src/renderer/features/file-viewer/components/monaco-config.ts index 82d509ce..be7610c9 100644 --- a/src/renderer/features/file-viewer/components/monaco-config.ts +++ b/src/renderer/features/file-viewer/components/monaco-config.ts @@ -1,19 +1,36 @@ import { loader } from "@monaco-editor/react" +import * as monaco from "monaco-editor" import type { editor } from "monaco-editor" +import editorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker" +import jsonWorker from "monaco-editor/esm/vs/language/json/json.worker?worker" +import cssWorker from "monaco-editor/esm/vs/language/css/css.worker?worker" +import htmlWorker from "monaco-editor/esm/vs/language/html/html.worker?worker" +import tsWorker from "monaco-editor/esm/vs/language/typescript/ts.worker?worker" -// Configure Monaco loader for Electron -// In Electron, we need to ensure Monaco loads from local node_modules -// rather than from CDN, which may not work in packaged apps -export function configureMonacoLoader() { - // Monaco will automatically resolve from node_modules in Electron - // The @monaco-editor/react package handles this well by default - // but we can configure it explicitly if needed - loader.config({ - // Use default CDN in development, will work in Electron - // For production builds, the bundler handles Monaco correctly - }) +// Configure Monaco workers for Vite +// @ts-ignore - Monaco's global window setup +self.MonacoEnvironment = { + getWorker(_: unknown, label: string) { + if (label === "json") { + return new jsonWorker() + } + if (label === "css" || label === "scss" || label === "less") { + return new cssWorker() + } + if (label === "html" || label === "handlebars" || label === "razor") { + return new htmlWorker() + } + if (label === "typescript" || label === "javascript") { + return new tsWorker() + } + return new editorWorker() + }, } +// Configure Monaco to use local package instead of CDN +// This is required for Electron apps due to CSP restrictions +loader.config({ monaco }) + // Default editor options for read-only file viewing export const defaultEditorOptions: editor.IStandaloneEditorConstructionOptions = { readOnly: true, diff --git a/src/renderer/features/file-viewer/hooks/use-file-content.ts b/src/renderer/features/file-viewer/hooks/use-file-content.ts index 8762d093..5e5da660 100644 --- a/src/renderer/features/file-viewer/hooks/use-file-content.ts +++ b/src/renderer/features/file-viewer/hooks/use-file-content.ts @@ -1,3 +1,4 @@ +import { useMemo } from "react" import { trpc } from "../../../lib/trpc" /** @@ -7,8 +8,6 @@ export type FileLoadError = | "not-found" | "too-large" | "binary" - | "outside-worktree" - | "symlink-escape" | "unknown" /** @@ -33,10 +32,6 @@ export function getErrorMessage(error: FileLoadError): string { return "File is too large to display (max 2 MB)" case "binary": return "Cannot display binary file" - case "outside-worktree": - return "File is outside the project directory" - case "symlink-escape": - return "Cannot follow symlink outside project" case "unknown": default: return "Failed to load file" @@ -45,19 +40,26 @@ export function getErrorMessage(error: FileLoadError): string { /** * Hook to fetch file content from the backend - * Uses the existing tRPC changes.readWorkingFile procedure + * Uses the files.readTextFile procedure with absolute path */ export function useFileContent( projectPath: string | null, filePath: string | null, ): FileContentResult { - const enabled = !!projectPath && !!filePath + // Build absolute path like DataViewerSidebar does + const absolutePath = useMemo(() => { + if (!projectPath || !filePath) return null + const path = filePath.startsWith("/") + ? filePath + : `${projectPath}/${filePath}` + console.log("[useFileContent] Building path:", { projectPath, filePath, absolutePath: path }) + return path + }, [projectPath, filePath]) - const query = trpc.changes.readWorkingFile.useQuery( - { - worktreePath: projectPath || "", - filePath: filePath || "", - }, + const enabled = !!absolutePath + + const { data, isLoading, error, refetch } = trpc.files.readTextFile.useQuery( + { filePath: absolutePath || "" }, { enabled, staleTime: 30000, // Cache for 30 seconds @@ -65,63 +67,70 @@ export function useFileContent( }, ) - // Parse the result - if (!enabled) { - return { - content: null, - isLoading: false, - error: null, - byteLength: null, - refetch: () => {}, + // Return result based on query state + return useMemo((): FileContentResult => { + console.log("[useFileContent] Computing result:", { enabled, isLoading, error, data }) + + if (!enabled) { + return { + content: null, + isLoading: false, + error: null, + byteLength: null, + refetch: () => {}, + } } - } - if (query.isLoading) { - return { - content: null, - isLoading: true, - error: null, - byteLength: null, - refetch: query.refetch, + if (isLoading) { + return { + content: null, + isLoading: true, + error: null, + byteLength: null, + refetch, + } } - } - if (query.error) { - return { - content: null, - isLoading: false, - error: "unknown", - byteLength: null, - refetch: query.refetch, + if (error) { + console.error("[useFileContent] Query error:", error) + return { + content: null, + isLoading: false, + error: "unknown", + byteLength: null, + refetch, + } } - } - const result = query.data - if (!result) { - return { - content: null, - isLoading: false, - error: "unknown", - byteLength: null, - refetch: query.refetch, + if (!data) { + console.log("[useFileContent] No data returned") + return { + content: null, + isLoading: false, + error: "unknown", + byteLength: null, + refetch, + } } - } - if (result.ok) { + if (data.ok) { + console.log("[useFileContent] Success:", data.byteLength, "bytes") + return { + content: data.content, + isLoading: false, + error: null, + byteLength: data.byteLength, + refetch, + } + } + + console.log("[useFileContent] Server returned error:", data.reason) return { - content: result.content, + content: null, isLoading: false, - error: null, - byteLength: result.byteLength, - refetch: query.refetch, + error: data.reason as FileLoadError, + byteLength: null, + refetch, } - } - - return { - content: null, - isLoading: false, - error: result.reason as FileLoadError, - byteLength: null, - refetch: query.refetch, - } + }, [enabled, isLoading, error, data, refetch]) } From 5bd815b65c062da20f360d7d5de75dbae0d088e2 Mon Sep 17 00:00:00 2001 From: arnavv-guptaa Date: Sun, 18 Jan 2026 17:20:02 +0800 Subject: [PATCH 07/51] added file actions --- src/main/lib/trpc/routers/files.ts | 58 +++- .../ui/file-tree/FileTreeContextMenu.tsx | 254 ++++++++++++++++++ .../agents/ui/file-tree/FileTreeNode.tsx | 154 ++++++----- .../agents/ui/file-tree/FileTreeSidebar.tsx | 1 + .../file-viewer/hooks/use-file-content.ts | 10 +- src/renderer/lib/utils/path.ts | 42 +++ .../{employees.parquet => sample.parquet} | Bin 7 files changed, 441 insertions(+), 78 deletions(-) create mode 100644 src/renderer/features/agents/ui/file-tree/FileTreeContextMenu.tsx create mode 100644 src/renderer/lib/utils/path.ts rename test-data/{employees.parquet => sample.parquet} (100%) diff --git a/src/main/lib/trpc/routers/files.ts b/src/main/lib/trpc/routers/files.ts index a5ac89f5..9af9f756 100644 --- a/src/main/lib/trpc/routers/files.ts +++ b/src/main/lib/trpc/routers/files.ts @@ -1,12 +1,13 @@ import { z } from "zod" import { router, publicProcedure } from "../index" -import { readdir, stat, readFile } from "node:fs/promises" +import { readdir, stat, readFile, rm, rename } from "node:fs/promises" import { watch, type FSWatcher } from "node:fs" import { join, relative, basename } from "node:path" import { observable } from "@trpc/server/observable" import { EventEmitter } from "node:events" import { exec } from "node:child_process" import { promisify } from "node:util" +import { shell } from "electron" import { getDataFileInfo, parseDataFile, @@ -757,14 +758,14 @@ export const filesRouter = router({ const MAX_FILE_SIZE = 2 * 1024 * 1024 // 2 MB const BINARY_CHECK_SIZE = 8192 - console.log("[files.readTextFile] Reading file:", input.filePath) + // console.log("[files.readTextFile] Reading file:", input.filePath) try { // Check file size first const stats = await stat(input.filePath) - console.log("[files.readTextFile] File size:", stats.size) + // console.log("[files.readTextFile] File size:", stats.size) if (stats.size > MAX_FILE_SIZE) { - console.log("[files.readTextFile] File too large") + // console.log("[files.readTextFile] File too large") return { ok: false, reason: "too-large" } } @@ -775,12 +776,12 @@ export const filesRouter = router({ const checkLength = Math.min(buffer.length, BINARY_CHECK_SIZE) for (let i = 0; i < checkLength; i++) { if (buffer[i] === 0) { - console.log("[files.readTextFile] Binary file detected") + // console.log("[files.readTextFile] Binary file detected") return { ok: false, reason: "binary" } } } - console.log("[files.readTextFile] Success, returning", buffer.length, "bytes") + // console.log("[files.readTextFile] Success, returning", buffer.length, "bytes") return { ok: true, content: buffer.toString("utf-8"), @@ -791,4 +792,49 @@ export const filesRouter = router({ return { ok: false, reason: "not-found" } } }), + + /** + * Delete a file or folder + */ + deleteFile: publicProcedure + .input(z.object({ filePath: z.string() })) + .mutation(async ({ input }) => { + try { + await rm(input.filePath, { recursive: true }) + return { success: true } + } catch (error) { + console.error("[files.deleteFile] Error:", error) + throw new Error(`Failed to delete: ${error instanceof Error ? error.message : "Unknown error"}`) + } + }), + + /** + * Rename a file or folder + */ + renameFile: publicProcedure + .input(z.object({ oldPath: z.string(), newPath: z.string() })) + .mutation(async ({ input }) => { + try { + await rename(input.oldPath, input.newPath) + return { success: true } + } catch (error) { + console.error("[files.renameFile] Error:", error) + throw new Error(`Failed to rename: ${error instanceof Error ? error.message : "Unknown error"}`) + } + }), + + /** + * Reveal a file or folder in the system file manager (Finder/Explorer) + */ + revealInFileManager: publicProcedure + .input(z.object({ filePath: z.string() })) + .mutation(({ input }) => { + try { + shell.showItemInFolder(input.filePath) + return { success: true } + } catch (error) { + console.error("[files.revealInFileManager] Error:", error) + throw new Error(`Failed to reveal: ${error instanceof Error ? error.message : "Unknown error"}`) + } + }), }) diff --git a/src/renderer/features/agents/ui/file-tree/FileTreeContextMenu.tsx b/src/renderer/features/agents/ui/file-tree/FileTreeContextMenu.tsx new file mode 100644 index 00000000..6d731d17 --- /dev/null +++ b/src/renderer/features/agents/ui/file-tree/FileTreeContextMenu.tsx @@ -0,0 +1,254 @@ +"use client" + +import { useState } from "react" +import { Copy, Trash2, Edit2, FolderOpen } from "lucide-react" +import { toast } from "sonner" +import { + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, +} from "../../../../components/ui/context-menu" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "../../../../components/ui/alert-dialog" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "../../../../components/ui/dialog" +import { Button } from "../../../../components/ui/button" +import { Input } from "../../../../components/ui/input" +import { trpc } from "../../../../lib/trpc" +import { join, basename, dirname } from "../../../../lib/utils/path" + +interface FileTreeContextMenuProps { + /** Relative path from project root */ + path: string + /** File or folder */ + type: "file" | "folder" + /** Absolute path to project root */ + projectPath: string + /** Callback after successful delete */ + onDeleted?: () => void + /** Callback after successful rename */ + onRenamed?: () => void +} + +export function FileTreeContextMenu({ + path, + type, + projectPath, + onDeleted, + onRenamed, +}: FileTreeContextMenuProps) { + const [showDeleteDialog, setShowDeleteDialog] = useState(false) + const [showRenameDialog, setShowRenameDialog] = useState(false) + const [newName, setNewName] = useState(basename(path)) + const [isDeleting, setIsDeleting] = useState(false) + const [isRenaming, setIsRenaming] = useState(false) + + const absolutePath = join(projectPath, path) + const fileName = basename(path) + const parentDir = dirname(path) + + // tRPC mutations + const deleteFileMutation = trpc.files.deleteFile.useMutation() + const renameFileMutation = trpc.files.renameFile.useMutation() + const revealMutation = trpc.files.revealInFileManager.useMutation() + + // Copy absolute path to clipboard + const handleCopyPath = async () => { + try { + await window.desktopApi.clipboardWrite(absolutePath) + toast.success("Path copied to clipboard") + } catch { + toast.error("Failed to copy path") + } + } + + // Copy relative path to clipboard + const handleCopyRelativePath = async () => { + try { + await window.desktopApi.clipboardWrite(path) + toast.success("Relative path copied to clipboard") + } catch { + toast.error("Failed to copy path") + } + } + + // Copy just the file/folder name to clipboard + const handleCopyName = async () => { + try { + await window.desktopApi.clipboardWrite(fileName) + toast.success("Name copied to clipboard") + } catch { + toast.error("Failed to copy name") + } + } + + // Delete file or folder + const handleDelete = async () => { + setIsDeleting(true) + try { + await deleteFileMutation.mutateAsync({ filePath: absolutePath }) + toast.success(`${type === "folder" ? "Folder" : "File"} deleted`) + setShowDeleteDialog(false) + onDeleted?.() + } catch (error) { + toast.error(`Failed to delete ${type}`) + } finally { + setIsDeleting(false) + } + } + + // Rename file or folder + const handleRename = async () => { + if (!newName.trim() || newName === fileName) { + setShowRenameDialog(false) + return + } + + setIsRenaming(true) + try { + const newPath = parentDir === "." ? newName : join(projectPath, parentDir, newName) + await renameFileMutation.mutateAsync({ + oldPath: absolutePath, + newPath, + }) + toast.success(`${type === "folder" ? "Folder" : "File"} renamed`) + setShowRenameDialog(false) + onRenamed?.() + } catch (error) { + toast.error(`Failed to rename ${type}`) + } finally { + setIsRenaming(false) + } + } + + // Reveal in Finder/Explorer + const handleRevealInFinder = async () => { + try { + await revealMutation.mutateAsync({ filePath: absolutePath }) + } catch { + toast.error("Failed to reveal in file manager") + } + } + + return ( + <> + + {/* Copy actions */} + + + Copy Path + + + + Copy Relative Path + + {type === "file" && ( + + + Copy File Name + + )} + + + + {/* Edit actions */} + { + setNewName(fileName) + setShowRenameDialog(true) + }}> + + Rename... + + setShowDeleteDialog(true)} + className="text-red-500 focus:text-red-500" + > + + Delete + + + + + {/* System actions */} + + + Reveal in Finder + + + + {/* Delete confirmation dialog */} + + + + + Delete {type === "folder" ? "folder" : "file"}? + + + Are you sure you want to delete {fileName}? + {type === "folder" && " This will delete all contents inside."} + {" "}This action cannot be undone. + + + + Cancel + + {isDeleting ? "Deleting..." : "Delete"} + + + + + + {/* Rename dialog */} + + + + + Rename {type === "folder" ? "folder" : "file"} + + + Enter a new name for {fileName} + + + setNewName(e.target.value)} + placeholder="New name" + autoFocus + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault() + handleRename() + } + }} + /> + + + + + + + + ) +} diff --git a/src/renderer/features/agents/ui/file-tree/FileTreeNode.tsx b/src/renderer/features/agents/ui/file-tree/FileTreeNode.tsx index e42b5964..7b5892ec 100644 --- a/src/renderer/features/agents/ui/file-tree/FileTreeNode.tsx +++ b/src/renderer/features/agents/ui/file-tree/FileTreeNode.tsx @@ -4,6 +4,11 @@ import { ChevronRight, File, Folder, FolderOpen, FileSpreadsheet, FileJson, Data import { memo, useCallback, useMemo } from "react" import { cn } from "../../../../lib/utils" import type { TreeNode } from "./build-file-tree" +import { + ContextMenu, + ContextMenuTrigger, +} from "../../../../components/ui/context-menu" +import { FileTreeContextMenu } from "./FileTreeContextMenu" // Data file extensions for special icons const DATA_FILE_EXTENSIONS: Record = { @@ -76,6 +81,8 @@ interface FileTreeNodeProps { /** @deprecated Use onSelectDataFile and onSelectSourceFile instead */ onSelectFile?: (path: string) => void gitStatus?: GitStatusMap + /** Absolute path to project root (for context menu actions) */ + projectPath?: string } export const FileTreeNode = memo(function FileTreeNode({ @@ -87,6 +94,7 @@ export const FileTreeNode = memo(function FileTreeNode({ onSelectSourceFile, onSelectFile, gitStatus = {}, + projectPath, }: FileTreeNodeProps) { const isExpanded = node.type === "folder" && expandedFolders.has(node.path) const hasChildren = node.type === "folder" && node.children.length > 0 @@ -131,78 +139,89 @@ export const FileTreeNode = memo(function FileTreeNode({ return (
- + + {projectPath && ( + )} - + {/* Children (only for expanded folders) */} {isExpanded && node.children.length > 0 && ( @@ -218,6 +237,7 @@ export const FileTreeNode = memo(function FileTreeNode({ onSelectSourceFile={onSelectSourceFile} onSelectFile={onSelectFile} gitStatus={gitStatus} + projectPath={projectPath} /> ))}
diff --git a/src/renderer/features/agents/ui/file-tree/FileTreeSidebar.tsx b/src/renderer/features/agents/ui/file-tree/FileTreeSidebar.tsx index 5dbd62b1..0f17616d 100644 --- a/src/renderer/features/agents/ui/file-tree/FileTreeSidebar.tsx +++ b/src/renderer/features/agents/ui/file-tree/FileTreeSidebar.tsx @@ -162,6 +162,7 @@ export function FileTreeSidebar({ onSelectSourceFile={onSelectSourceFile} onSelectFile={onSelectFile} gitStatus={gitStatus as GitStatusMap} + projectPath={projectPath} /> )) )} diff --git a/src/renderer/features/file-viewer/hooks/use-file-content.ts b/src/renderer/features/file-viewer/hooks/use-file-content.ts index 5e5da660..30d17829 100644 --- a/src/renderer/features/file-viewer/hooks/use-file-content.ts +++ b/src/renderer/features/file-viewer/hooks/use-file-content.ts @@ -52,7 +52,7 @@ export function useFileContent( const path = filePath.startsWith("/") ? filePath : `${projectPath}/${filePath}` - console.log("[useFileContent] Building path:", { projectPath, filePath, absolutePath: path }) + // console.log("[useFileContent] Building path:", { projectPath, filePath, absolutePath: path }) return path }, [projectPath, filePath]) @@ -69,7 +69,7 @@ export function useFileContent( // Return result based on query state return useMemo((): FileContentResult => { - console.log("[useFileContent] Computing result:", { enabled, isLoading, error, data }) + // console.log("[useFileContent] Computing result:", { enabled, isLoading, error, data }) if (!enabled) { return { @@ -103,7 +103,7 @@ export function useFileContent( } if (!data) { - console.log("[useFileContent] No data returned") + // console.log("[useFileContent] No data returned") return { content: null, isLoading: false, @@ -114,7 +114,7 @@ export function useFileContent( } if (data.ok) { - console.log("[useFileContent] Success:", data.byteLength, "bytes") + // console.log("[useFileContent] Success:", data.byteLength, "bytes") return { content: data.content, isLoading: false, @@ -124,7 +124,7 @@ export function useFileContent( } } - console.log("[useFileContent] Server returned error:", data.reason) + // console.log("[useFileContent] Server returned error:", data.reason) return { content: null, isLoading: false, diff --git a/src/renderer/lib/utils/path.ts b/src/renderer/lib/utils/path.ts new file mode 100644 index 00000000..8a10270b --- /dev/null +++ b/src/renderer/lib/utils/path.ts @@ -0,0 +1,42 @@ +/** + * Simple path utilities for the renderer process + * These work with forward slashes (Unix-style paths) + */ + +/** + * Join path segments with forward slashes + */ +export function join(...segments: string[]): string { + return segments + .filter(Boolean) + .join("/") + .replace(/\/+/g, "/") +} + +/** + * Get the base name (last segment) of a path + */ +export function basename(path: string): string { + const segments = path.split("/").filter(Boolean) + return segments[segments.length - 1] || "" +} + +/** + * Get the directory name (all but last segment) of a path + */ +export function dirname(path: string): string { + const segments = path.split("/").filter(Boolean) + if (segments.length <= 1) return "." + segments.pop() + return segments.join("/") +} + +/** + * Get the file extension including the dot + */ +export function extname(path: string): string { + const name = basename(path) + const lastDot = name.lastIndexOf(".") + if (lastDot <= 0) return "" + return name.slice(lastDot) +} diff --git a/test-data/employees.parquet b/test-data/sample.parquet similarity index 100% rename from test-data/employees.parquet rename to test-data/sample.parquet From 952aa768b261642486b82e61a99602a5facfd14d Mon Sep 17 00:00:00 2001 From: arnavv-guptaa Date: Sun, 18 Jan 2026 17:26:01 +0800 Subject: [PATCH 08/51] Fixed parquet support with duckdb --- bun.lock | 31 ++++ package.json | 1 + src/main/lib/parsers/parquet-parser.ts | 188 ++++++++++++----------- src/main/lib/parsers/parquetjs-lite.d.ts | 27 ---- 4 files changed, 131 insertions(+), 116 deletions(-) delete mode 100644 src/main/lib/parsers/parquetjs-lite.d.ts diff --git a/bun.lock b/bun.lock index 204b49e3..0dd1b231 100644 --- a/bun.lock +++ b/bun.lock @@ -47,6 +47,7 @@ "clsx": "^2.1.1", "date-fns": "^3.6.0", "drizzle-orm": "^0.45.1", + "duckdb": "^1.4.3", "electron-log": "^5.4.3", "electron-updater": "^6.7.3", "gray-matter": "^4.0.3", @@ -310,6 +311,8 @@ "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], + "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], @@ -338,6 +341,8 @@ "@malept/flatpak-bundler": ["@malept/flatpak-bundler@0.4.0", "", { "dependencies": { "debug": "^4.1.1", "fs-extra": "^9.0.0", "lodash": "^4.17.15", "tmp-promise": "^3.0.2" } }, "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q=="], + "@mapbox/node-pre-gyp": ["@mapbox/node-pre-gyp@2.0.3", "", { "dependencies": { "consola": "^3.2.3", "detect-libc": "^2.0.0", "https-proxy-agent": "^7.0.5", "node-fetch": "^2.6.7", "nopt": "^8.0.0", "semver": "^7.5.3", "tar": "^7.4.0" }, "bin": { "node-pre-gyp": "bin/node-pre-gyp" } }, "sha512-uwPAhccfFJlsfCxMYTwOdVfOz3xqyj8xYL3zJj8f0pb30tLohnnFPhLuqp4/qoEz8sNxe4SESZedcBojRefIzg=="], + "@monaco-editor/loader": ["@monaco-editor/loader@1.7.0", "", { "dependencies": { "state-local": "^1.0.6" } }, "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA=="], "@monaco-editor/react": ["@monaco-editor/react@4.7.0", "", { "dependencies": { "@monaco-editor/loader": "^1.5.0" }, "peerDependencies": { "monaco-editor": ">= 0.25.0 < 1", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA=="], @@ -996,6 +1001,8 @@ "drizzle-orm": ["drizzle-orm@0.45.1", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA=="], + "duckdb": ["duckdb@1.4.3", "", { "dependencies": { "@mapbox/node-pre-gyp": "^2.0.0", "node-addon-api": "^7.0.0", "node-gyp": "^9.4.1" } }, "sha512-xTh2H/xhAkM4luLNi0uRQWqKq2VzX9VWa9JOpnI4pM9705JY/sLFxWgxWMuS1AVsOsA0ROIOKevhuCWSLPR1Jg=="], + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], @@ -1514,6 +1521,8 @@ "node-api-version": ["node-api-version@0.1.4", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-KGXihXdUChwJAOHO53bv9/vXcLmdUsZ6jIptbvYvkpKfth+r7jw44JkVxQFA3kX5nQjzjmGu1uAu/xNNLNlI5g=="], + "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + "node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="], "node-gyp": ["node-gyp@9.4.1", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "glob": "^7.1.4", "graceful-fs": "^4.2.6", "make-fetch-happen": "^10.0.3", "nopt": "^6.0.0", "npmlog": "^6.0.0", "rimraf": "^3.0.2", "semver": "^7.3.5", "tar": "^6.1.2", "which": "^2.0.2" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-OQkWKbjQKbGkMf/xqI1jjy3oCTgMKJac58G2+bjZb3fza6gW2YrCSdMQYaoTb70crvE//Gngr4f0AgVHmqHvBQ=="], @@ -1888,6 +1897,8 @@ "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], + "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], @@ -1958,6 +1969,10 @@ "web-vitals": ["web-vitals@4.2.4", "", {}, "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw=="], + "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + + "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "wide-align": ["wide-align@1.1.5", "", { "dependencies": { "string-width": "^1.0.2 || 2 || 3 || 4" } }, "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg=="], @@ -2036,12 +2051,18 @@ "@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], + "@isaacs/fs-minipass/minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], + "@linaria/react/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], "@linaria/utils/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], "@malept/flatpak-bundler/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], + "@mapbox/node-pre-gyp/nopt": ["nopt@8.1.0", "", { "dependencies": { "abbrev": "^3.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A=="], + + "@mapbox/node-pre-gyp/tar": ["tar@7.5.3", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-ENg5JUHUm2rDD7IvKNFGzyElLXNjachNLp6RaGf4+JOgxXHkqA+gq81ZAMCUmtMtqBsoU62lcp6S27g1LCYGGQ=="], + "@opentelemetry/instrumentation-http/@opentelemetry/core": ["@opentelemetry/core@2.2.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw=="], "@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], @@ -2242,6 +2263,16 @@ "@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + "@mapbox/node-pre-gyp/nopt/abbrev": ["abbrev@3.0.1", "", {}, "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg=="], + + "@mapbox/node-pre-gyp/tar/chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], + + "@mapbox/node-pre-gyp/tar/minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], + + "@mapbox/node-pre-gyp/tar/minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="], + + "@mapbox/node-pre-gyp/tar/yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], + "archiver-utils/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], "archiver-utils/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], diff --git a/package.json b/package.json index 6850515e..abd516c7 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "clsx": "^2.1.1", "date-fns": "^3.6.0", "drizzle-orm": "^0.45.1", + "duckdb": "^1.4.3", "electron-log": "^5.4.3", "electron-updater": "^6.7.3", "gray-matter": "^4.0.3", diff --git a/src/main/lib/parsers/parquet-parser.ts b/src/main/lib/parsers/parquet-parser.ts index 09deb4ca..bd7034b4 100644 --- a/src/main/lib/parsers/parquet-parser.ts +++ b/src/main/lib/parsers/parquet-parser.ts @@ -1,23 +1,30 @@ import type { ParsedData, ParsedColumn, ColumnType } from "./types" +import duckdb from "duckdb" /** - * Map Parquet types to our column types + * Map DuckDB types to our column types */ -function mapParquetType(parquetType: string): ColumnType { - const type = parquetType?.toUpperCase() || "" +function mapDuckDBType(duckdbType: string): ColumnType { + const type = duckdbType?.toUpperCase() || "" // Integer types if ( type.includes("INT") || - type.includes("LONG") || - type.includes("SHORT") || - type.includes("BYTE") + type.includes("BIGINT") || + type.includes("SMALLINT") || + type.includes("TINYINT") || + type.includes("HUGEINT") ) { return "number" } // Float types - if (type.includes("FLOAT") || type.includes("DOUBLE") || type.includes("DECIMAL")) { + if ( + type.includes("FLOAT") || + type.includes("DOUBLE") || + type.includes("DECIMAL") || + type.includes("REAL") + ) { return "number" } @@ -35,39 +42,49 @@ function mapParquetType(parquetType: string): ColumnType { return "date" } - // Default to string for everything else (UTF8, BYTE_ARRAY, etc.) + // Default to string for everything else return "string" } /** - * Extract column info from Parquet schema + * Process a row to handle BigInt and Date values for JSON serialization */ -function extractColumnsFromSchema(schema: any): ParsedColumn[] { - const columns: ParsedColumn[] = [] - - if (!schema || !schema.schema) { - return columns +function processRow(row: Record): Record { + const processed: Record = {} + for (const [key, value] of Object.entries(row)) { + if (typeof value === "bigint") { + processed[key] = Number(value) + } else if (value instanceof Date) { + processed[key] = value.toISOString() + } else if (Buffer.isBuffer(value)) { + processed[key] = value.toString("utf-8") + } else { + processed[key] = value + } } + return processed +} - // The schema object has a 'schema' property with field definitions - const fields = schema.schema - for (const [fieldName, fieldDef] of Object.entries(fields)) { - if (fieldName === "root" || !fieldDef) continue - - const field = fieldDef as any - const parquetType = field.type || field.originalType || "UTF8" - - columns.push({ - name: fieldName, - type: mapParquetType(parquetType), +/** + * Execute a DuckDB query and return results as a promise + */ +function queryDuckDB( + db: duckdb.Database, + sql: string +): Promise[]> { + return new Promise((resolve, reject) => { + db.all(sql, (err, rows) => { + if (err) { + reject(err) + } else { + resolve(rows as Record[]) + } }) - } - - return columns + }) } /** - * Parse a Parquet file and return structured data + * Parse a Parquet file and return structured data using DuckDB */ export async function parseParquetFile( filePath: string, @@ -75,54 +92,39 @@ export async function parseParquetFile( ): Promise { const { limit = 1000, offset = 0 } = options - // Dynamic require to handle CommonJS module - const parquetModule = await import("parquetjs-lite") - const parquet = parquetModule.default || parquetModule - const ParquetReader = parquet.ParquetReader + // Escape single quotes in file path for SQL + const escapedPath = filePath.replace(/'/g, "''") - const reader = await ParquetReader.openFile(filePath) + const db = new duckdb.Database(":memory:") try { - // Get schema info - const schema = reader.getSchema() - const columns = extractColumnsFromSchema(schema) + // Get column info using DESCRIBE + const describeResult = await queryDuckDB( + db, + `DESCRIBE SELECT * FROM read_parquet('${escapedPath}')` + ) - // Get total row count - const totalRows = Number(reader.getRowCount()) + const columns: ParsedColumn[] = describeResult.map((row) => ({ + name: String(row.column_name || row.name || ""), + type: mapDuckDBType(String(row.column_type || row.type || "")), + })) - // Read rows with cursor - const cursor = reader.getCursor() - const rows: Record[] = [] + // Get total row count + const countResult = await queryDuckDB( + db, + `SELECT COUNT(*) as cnt FROM read_parquet('${escapedPath}')` + ) + const totalRows = Number(countResult[0]?.cnt || 0) - let currentRow = 0 - let record: Record | null = null + // Get rows with pagination + const dataResult = await queryDuckDB( + db, + `SELECT * FROM read_parquet('${escapedPath}') LIMIT ${limit} OFFSET ${offset}` + ) - // Skip to offset - while (currentRow < offset && (record = await cursor.next())) { - currentRow++ - } + const rows = dataResult.map(processRow) - // Read rows up to limit - while (rows.length < limit && (record = await cursor.next())) { - // Convert any BigInt values to numbers for JSON serialization - const processedRecord: Record = {} - for (const [key, value] of Object.entries(record)) { - if (typeof value === "bigint") { - processedRecord[key] = Number(value) - } else if (value instanceof Date) { - processedRecord[key] = value.toISOString() - } else if (Buffer.isBuffer(value)) { - // Handle binary data - processedRecord[key] = value.toString("utf-8") - } else { - processedRecord[key] = value - } - } - rows.push(processedRecord) - currentRow++ - } - - await reader.close() + db.close() return { columns, @@ -131,7 +133,7 @@ export async function parseParquetFile( truncated: offset + rows.length < totalRows, } } catch (error) { - await reader.close() + db.close() throw error } } @@ -140,18 +142,19 @@ export async function parseParquetFile( * Get row count from a Parquet file without reading all data */ export async function getParquetRowCount(filePath: string): Promise { - const parquetModule = await import("parquetjs-lite") - const parquet = parquetModule.default || parquetModule - const ParquetReader = parquet.ParquetReader - - const reader = await ParquetReader.openFile(filePath) + const escapedPath = filePath.replace(/'/g, "''") + const db = new duckdb.Database(":memory:") try { - const rowCount = Number(reader.getRowCount()) - await reader.close() - return rowCount + const result = await queryDuckDB( + db, + `SELECT COUNT(*) as cnt FROM read_parquet('${escapedPath}')` + ) + const count = Number(result[0]?.cnt || 0) + db.close() + return count } catch (error) { - await reader.close() + db.close() throw error } } @@ -159,20 +162,27 @@ export async function getParquetRowCount(filePath: string): Promise { /** * Get column info from a Parquet file without reading data */ -export async function getParquetColumns(filePath: string): Promise { - const parquetModule = await import("parquetjs-lite") - const parquet = parquetModule.default || parquetModule - const ParquetReader = parquet.ParquetReader - - const reader = await ParquetReader.openFile(filePath) +export async function getParquetColumns( + filePath: string +): Promise { + const escapedPath = filePath.replace(/'/g, "''") + const db = new duckdb.Database(":memory:") try { - const schema = reader.getSchema() - const columns = extractColumnsFromSchema(schema) - await reader.close() + const result = await queryDuckDB( + db, + `DESCRIBE SELECT * FROM read_parquet('${escapedPath}')` + ) + + const columns: ParsedColumn[] = result.map((row) => ({ + name: String(row.column_name || row.name || ""), + type: mapDuckDBType(String(row.column_type || row.type || "")), + })) + + db.close() return columns } catch (error) { - await reader.close() + db.close() throw error } } diff --git a/src/main/lib/parsers/parquetjs-lite.d.ts b/src/main/lib/parsers/parquetjs-lite.d.ts deleted file mode 100644 index 6f61f05b..00000000 --- a/src/main/lib/parsers/parquetjs-lite.d.ts +++ /dev/null @@ -1,27 +0,0 @@ -declare module "parquetjs-lite" { - export interface ParquetSchema { - schema: Record - } - - export interface ParquetFieldDef { - name?: string - type?: string - originalType?: string - optional?: boolean - repeated?: boolean - fields?: Record - } - - export interface ParquetCursor { - next(): Promise | null> - rewind(): void - } - - export class ParquetReader { - static openFile(filePath: string): Promise - getSchema(): ParquetSchema - getRowCount(): bigint - getCursor(): ParquetCursor - close(): Promise - } -} From 1141060cfa7603c6181c6967891613e54576e052 Mon Sep 17 00:00:00 2001 From: arnavv-guptaa Date: Sun, 18 Jan 2026 17:37:26 +0800 Subject: [PATCH 09/51] Updated duckdb data parser --- src/main/lib/parsers/duckdb-parser.ts | 312 ++++++++++++++++++ src/main/lib/parsers/index.ts | 36 +- src/main/lib/parsers/parquet-parser.ts | 188 ----------- src/main/lib/parsers/types.ts | 2 +- src/main/lib/trpc/routers/files.ts | 10 + .../agents/ui/file-tree/FileTreeNode.tsx | 17 +- .../data/components/data-viewer-sidebar.tsx | 49 ++- 7 files changed, 409 insertions(+), 205 deletions(-) create mode 100644 src/main/lib/parsers/duckdb-parser.ts delete mode 100644 src/main/lib/parsers/parquet-parser.ts diff --git a/src/main/lib/parsers/duckdb-parser.ts b/src/main/lib/parsers/duckdb-parser.ts new file mode 100644 index 00000000..e0ad94af --- /dev/null +++ b/src/main/lib/parsers/duckdb-parser.ts @@ -0,0 +1,312 @@ +import type { ParsedData, ParsedColumn, ColumnType } from "./types" +import duckdb from "duckdb" +import path from "node:path" + +/** + * DuckDB-supported file types for this parser + */ +export type DuckDBFileType = "parquet" | "excel" | "arrow" + +/** + * Detect DuckDB file type from extension + */ +export function getDuckDBFileType(filePath: string): DuckDBFileType | null { + const ext = path.extname(filePath).toLowerCase() + switch (ext) { + case ".parquet": + case ".pq": + return "parquet" + case ".xlsx": + case ".xls": + return "excel" + case ".arrow": + case ".feather": + case ".ipc": + return "arrow" + default: + return null + } +} + +/** + * Map DuckDB types to our column types + */ +function mapDuckDBType(duckdbType: string): ColumnType { + const type = duckdbType?.toUpperCase() || "" + + // Integer types + if ( + type.includes("INT") || + type.includes("BIGINT") || + type.includes("SMALLINT") || + type.includes("TINYINT") || + type.includes("HUGEINT") + ) { + return "number" + } + + // Float types + if ( + type.includes("FLOAT") || + type.includes("DOUBLE") || + type.includes("DECIMAL") || + type.includes("REAL") + ) { + return "number" + } + + // Boolean + if (type.includes("BOOL")) { + return "boolean" + } + + // Date/Time types + if ( + type.includes("DATE") || + type.includes("TIME") || + type.includes("TIMESTAMP") + ) { + return "date" + } + + // Default to string for everything else + return "string" +} + +/** + * Process a row to handle BigInt and Date values for JSON serialization + */ +function processRow(row: Record): Record { + const processed: Record = {} + for (const [key, value] of Object.entries(row)) { + if (typeof value === "bigint") { + processed[key] = Number(value) + } else if (value instanceof Date) { + processed[key] = value.toISOString() + } else if (Buffer.isBuffer(value)) { + processed[key] = value.toString("utf-8") + } else { + processed[key] = value + } + } + return processed +} + +/** + * Execute a DuckDB query and return results as a promise + */ +function queryDuckDB( + db: duckdb.Database, + sql: string +): Promise[]> { + return new Promise((resolve, reject) => { + db.all(sql, (err, rows) => { + if (err) { + reject(err) + } else { + resolve(rows as Record[]) + } + }) + }) +} + +/** + * Get the DuckDB read function for a file type + */ +function getReadFunction( + fileType: DuckDBFileType, + escapedPath: string, + sheetName?: string +): string { + switch (fileType) { + case "parquet": + return `read_parquet('${escapedPath}')` + case "excel": + // If a sheet name is provided, use it + if (sheetName) { + const escapedSheet = sheetName.replace(/'/g, "''") + return `read_xlsx('${escapedPath}', sheet='${escapedSheet}')` + } + return `read_xlsx('${escapedPath}')` + case "arrow": + return `read_parquet('${escapedPath}')` // DuckDB uses read_parquet for Arrow IPC files too + default: + throw new Error(`Unsupported file type: ${fileType}`) + } +} + +/** + * Install required DuckDB extensions for file type + */ +async function installExtensions( + db: duckdb.Database, + fileType: DuckDBFileType +): Promise { + if (fileType === "excel") { + // Excel support requires the spatial extension (for xlsx) + await queryDuckDB(db, "INSTALL spatial") + await queryDuckDB(db, "LOAD spatial") + } +} + +export interface DuckDBParseOptions { + limit?: number + offset?: number + sheetName?: string // For Excel files +} + +/** + * Parse a file using DuckDB and return structured data + */ +export async function parseDuckDBFile( + filePath: string, + options: DuckDBParseOptions = {} +): Promise { + const { limit = 1000, offset = 0, sheetName } = options + + const fileType = getDuckDBFileType(filePath) + if (!fileType) { + throw new Error(`Unsupported file type for DuckDB parser: ${filePath}`) + } + + // Escape single quotes in file path for SQL + const escapedPath = filePath.replace(/'/g, "''") + const readFn = getReadFunction(fileType, escapedPath, sheetName) + + const db = new duckdb.Database(":memory:") + + try { + // Install extensions if needed + await installExtensions(db, fileType) + + // Get column info using DESCRIBE + const describeResult = await queryDuckDB( + db, + `DESCRIBE SELECT * FROM ${readFn}` + ) + + const columns: ParsedColumn[] = describeResult.map((row) => ({ + name: String(row.column_name || row.name || ""), + type: mapDuckDBType(String(row.column_type || row.type || "")), + })) + + // Get total row count + const countResult = await queryDuckDB( + db, + `SELECT COUNT(*) as cnt FROM ${readFn}` + ) + const totalRows = Number(countResult[0]?.cnt || 0) + + // Get rows with pagination + const dataResult = await queryDuckDB( + db, + `SELECT * FROM ${readFn} LIMIT ${limit} OFFSET ${offset}` + ) + + const rows = dataResult.map(processRow) + + db.close() + + return { + columns, + rows, + totalRows, + truncated: offset + rows.length < totalRows, + } + } catch (error) { + db.close() + throw error + } +} + +/** + * Get row count from a file without reading all data + */ +export async function getDuckDBRowCount(filePath: string): Promise { + const fileType = getDuckDBFileType(filePath) + if (!fileType) { + throw new Error(`Unsupported file type for DuckDB parser: ${filePath}`) + } + + const escapedPath = filePath.replace(/'/g, "''") + const readFn = getReadFunction(fileType, escapedPath) + const db = new duckdb.Database(":memory:") + + try { + await installExtensions(db, fileType) + + const result = await queryDuckDB( + db, + `SELECT COUNT(*) as cnt FROM ${readFn}` + ) + const count = Number(result[0]?.cnt || 0) + db.close() + return count + } catch (error) { + db.close() + throw error + } +} + +/** + * Get column info from a file without reading data + */ +export async function getDuckDBColumns( + filePath: string +): Promise { + const fileType = getDuckDBFileType(filePath) + if (!fileType) { + throw new Error(`Unsupported file type for DuckDB parser: ${filePath}`) + } + + const escapedPath = filePath.replace(/'/g, "''") + const readFn = getReadFunction(fileType, escapedPath) + const db = new duckdb.Database(":memory:") + + try { + await installExtensions(db, fileType) + + const result = await queryDuckDB( + db, + `DESCRIBE SELECT * FROM ${readFn}` + ) + + const columns: ParsedColumn[] = result.map((row) => ({ + name: String(row.column_name || row.name || ""), + type: mapDuckDBType(String(row.column_type || row.type || "")), + })) + + db.close() + return columns + } catch (error) { + db.close() + throw error + } +} + +/** + * List sheets in an Excel file + */ +export async function listExcelSheets(filePath: string): Promise { + const db = new duckdb.Database(":memory:") + + try { + await installExtensions(db, "excel") + + const escapedPath = filePath.replace(/'/g, "''") + // Use st_read_meta to get sheet names + const result = await queryDuckDB( + db, + `SELECT DISTINCT sheet_name FROM read_xlsx_metadata('${escapedPath}')` + ) + + const sheets = result.map((row) => String(row.sheet_name || "")) + db.close() + return sheets + } catch (error) { + db.close() + // If metadata reading fails, return a default sheet + console.warn("[duckdb-parser] Failed to list Excel sheets:", error) + return ["Sheet1"] + } +} diff --git a/src/main/lib/parsers/index.ts b/src/main/lib/parsers/index.ts index 76f13eab..8fc259e9 100644 --- a/src/main/lib/parsers/index.ts +++ b/src/main/lib/parsers/index.ts @@ -8,7 +8,11 @@ import { previewSqliteTable, querySqlite as querySqliteDb, } from "./sqlite-parser" -import { parseParquetFile } from "./parquet-parser" +import { + parseDuckDBFile, + getDuckDBFileType, + listExcelSheets as listExcelSheetsFromDuckDB, +} from "./duckdb-parser" // Re-export types export * from "./types" @@ -26,6 +30,11 @@ const DATA_EXTENSIONS: Record = { ".sqlite3": "sqlite", ".parquet": "parquet", ".pq": "parquet", + ".xlsx": "excel", + ".xls": "excel", + ".arrow": "arrow", + ".feather": "arrow", + ".ipc": "arrow", } /** @@ -70,6 +79,16 @@ export async function getDataFileInfo(filePath: string): Promise { } } + // For Excel files, list sheets + if (fileType === "excel") { + try { + info.tables = await listExcelSheetsFromDuckDB(filePath) + } catch (error) { + console.warn("[parsers] Failed to list Excel sheets:", error) + info.tables = ["Sheet1"] + } + } + return info } catch (error) { console.error("[parsers] Failed to get file info:", error) @@ -100,7 +119,13 @@ export async function parseDataFile( return parseJsonFile(filePath, { limit, offset }) case "parquet": - return parseParquetFile(filePath, { limit, offset }) + case "arrow": + // Use unified DuckDB parser for Parquet and Arrow files + return parseDuckDBFile(filePath, { limit, offset }) + + case "excel": + // Use unified DuckDB parser for Excel files + return parseDuckDBFile(filePath, { limit, offset, sheetName: tableName }) case "sqlite": { // For SQLite, we need a table name @@ -140,3 +165,10 @@ export function querySqlite(filePath: string, sql: string): ParsedData { export function listSqliteTables(filePath: string): string[] { return listTables(filePath) } + +/** + * List sheets in an Excel file + */ +export async function listExcelSheets(filePath: string): Promise { + return listExcelSheetsFromDuckDB(filePath) +} diff --git a/src/main/lib/parsers/parquet-parser.ts b/src/main/lib/parsers/parquet-parser.ts deleted file mode 100644 index bd7034b4..00000000 --- a/src/main/lib/parsers/parquet-parser.ts +++ /dev/null @@ -1,188 +0,0 @@ -import type { ParsedData, ParsedColumn, ColumnType } from "./types" -import duckdb from "duckdb" - -/** - * Map DuckDB types to our column types - */ -function mapDuckDBType(duckdbType: string): ColumnType { - const type = duckdbType?.toUpperCase() || "" - - // Integer types - if ( - type.includes("INT") || - type.includes("BIGINT") || - type.includes("SMALLINT") || - type.includes("TINYINT") || - type.includes("HUGEINT") - ) { - return "number" - } - - // Float types - if ( - type.includes("FLOAT") || - type.includes("DOUBLE") || - type.includes("DECIMAL") || - type.includes("REAL") - ) { - return "number" - } - - // Boolean - if (type.includes("BOOL")) { - return "boolean" - } - - // Date/Time types - if ( - type.includes("DATE") || - type.includes("TIME") || - type.includes("TIMESTAMP") - ) { - return "date" - } - - // Default to string for everything else - return "string" -} - -/** - * Process a row to handle BigInt and Date values for JSON serialization - */ -function processRow(row: Record): Record { - const processed: Record = {} - for (const [key, value] of Object.entries(row)) { - if (typeof value === "bigint") { - processed[key] = Number(value) - } else if (value instanceof Date) { - processed[key] = value.toISOString() - } else if (Buffer.isBuffer(value)) { - processed[key] = value.toString("utf-8") - } else { - processed[key] = value - } - } - return processed -} - -/** - * Execute a DuckDB query and return results as a promise - */ -function queryDuckDB( - db: duckdb.Database, - sql: string -): Promise[]> { - return new Promise((resolve, reject) => { - db.all(sql, (err, rows) => { - if (err) { - reject(err) - } else { - resolve(rows as Record[]) - } - }) - }) -} - -/** - * Parse a Parquet file and return structured data using DuckDB - */ -export async function parseParquetFile( - filePath: string, - options: { limit?: number; offset?: number } = {} -): Promise { - const { limit = 1000, offset = 0 } = options - - // Escape single quotes in file path for SQL - const escapedPath = filePath.replace(/'/g, "''") - - const db = new duckdb.Database(":memory:") - - try { - // Get column info using DESCRIBE - const describeResult = await queryDuckDB( - db, - `DESCRIBE SELECT * FROM read_parquet('${escapedPath}')` - ) - - const columns: ParsedColumn[] = describeResult.map((row) => ({ - name: String(row.column_name || row.name || ""), - type: mapDuckDBType(String(row.column_type || row.type || "")), - })) - - // Get total row count - const countResult = await queryDuckDB( - db, - `SELECT COUNT(*) as cnt FROM read_parquet('${escapedPath}')` - ) - const totalRows = Number(countResult[0]?.cnt || 0) - - // Get rows with pagination - const dataResult = await queryDuckDB( - db, - `SELECT * FROM read_parquet('${escapedPath}') LIMIT ${limit} OFFSET ${offset}` - ) - - const rows = dataResult.map(processRow) - - db.close() - - return { - columns, - rows, - totalRows, - truncated: offset + rows.length < totalRows, - } - } catch (error) { - db.close() - throw error - } -} - -/** - * Get row count from a Parquet file without reading all data - */ -export async function getParquetRowCount(filePath: string): Promise { - const escapedPath = filePath.replace(/'/g, "''") - const db = new duckdb.Database(":memory:") - - try { - const result = await queryDuckDB( - db, - `SELECT COUNT(*) as cnt FROM read_parquet('${escapedPath}')` - ) - const count = Number(result[0]?.cnt || 0) - db.close() - return count - } catch (error) { - db.close() - throw error - } -} - -/** - * Get column info from a Parquet file without reading data - */ -export async function getParquetColumns( - filePath: string -): Promise { - const escapedPath = filePath.replace(/'/g, "''") - const db = new duckdb.Database(":memory:") - - try { - const result = await queryDuckDB( - db, - `DESCRIBE SELECT * FROM read_parquet('${escapedPath}')` - ) - - const columns: ParsedColumn[] = result.map((row) => ({ - name: String(row.column_name || row.name || ""), - type: mapDuckDBType(String(row.column_type || row.type || "")), - })) - - db.close() - return columns - } catch (error) { - db.close() - throw error - } -} diff --git a/src/main/lib/parsers/types.ts b/src/main/lib/parsers/types.ts index 7d75eee4..b70b2981 100644 --- a/src/main/lib/parsers/types.ts +++ b/src/main/lib/parsers/types.ts @@ -12,7 +12,7 @@ export interface ParsedData { truncated: boolean } -export type DataFileType = "csv" | "json" | "sqlite" | "parquet" | "unknown" +export type DataFileType = "csv" | "json" | "sqlite" | "parquet" | "excel" | "arrow" | "unknown" export interface DataFileInfo { path: string diff --git a/src/main/lib/trpc/routers/files.ts b/src/main/lib/trpc/routers/files.ts index 9af9f756..2281a71f 100644 --- a/src/main/lib/trpc/routers/files.ts +++ b/src/main/lib/trpc/routers/files.ts @@ -13,6 +13,7 @@ import { parseDataFile, querySqlite, listSqliteTables, + listExcelSheets, isDataFile, } from "../../parsers" @@ -741,6 +742,15 @@ export const filesRouter = router({ return listSqliteTables(input.filePath) }), + /** + * List sheets in an Excel file + */ + listExcelSheets: publicProcedure + .input(z.object({ filePath: z.string() })) + .query(async ({ input }) => { + return listExcelSheets(input.filePath) + }), + /** * Read a text file's content for the file viewer * Returns the content as a string with size and binary detection diff --git a/src/renderer/features/agents/ui/file-tree/FileTreeNode.tsx b/src/renderer/features/agents/ui/file-tree/FileTreeNode.tsx index 7b5892ec..cbd2d562 100644 --- a/src/renderer/features/agents/ui/file-tree/FileTreeNode.tsx +++ b/src/renderer/features/agents/ui/file-tree/FileTreeNode.tsx @@ -1,6 +1,6 @@ "use client" -import { ChevronRight, File, Folder, FolderOpen, FileSpreadsheet, FileJson, Database, FileBox } from "lucide-react" +import { ChevronRight, File, Folder, FolderOpen, FileSpreadsheet, FileJson, Database, FileBox, Table2, ArrowRight } from "lucide-react" import { memo, useCallback, useMemo } from "react" import { cn } from "../../../../lib/utils" import type { TreeNode } from "./build-file-tree" @@ -11,7 +11,7 @@ import { import { FileTreeContextMenu } from "./FileTreeContextMenu" // Data file extensions for special icons -const DATA_FILE_EXTENSIONS: Record = { +const DATA_FILE_EXTENSIONS: Record = { ".csv": "csv", ".tsv": "csv", ".json": "json", @@ -21,9 +21,14 @@ const DATA_FILE_EXTENSIONS: Record } + if (dataType === "excel") { + return + } + if (dataType === "arrow") { + return + } return })() )} diff --git a/src/renderer/features/data/components/data-viewer-sidebar.tsx b/src/renderer/features/data/components/data-viewer-sidebar.tsx index 0d250a69..8d21e7b2 100644 --- a/src/renderer/features/data/components/data-viewer-sidebar.tsx +++ b/src/renderer/features/data/components/data-viewer-sidebar.tsx @@ -32,6 +32,8 @@ import { Copy, Expand, CornerDownRight, + Table2, + ArrowRight, } from "lucide-react" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" @@ -88,7 +90,7 @@ function getFileExtension(filePath: string): string { /** * Get file type from extension */ -function getFileType(filePath: string): "csv" | "json" | "sqlite" | "parquet" | "unknown" { +function getFileType(filePath: string): "csv" | "json" | "sqlite" | "parquet" | "excel" | "arrow" | "unknown" { const ext = getFileExtension(filePath) switch (ext) { case ".csv": @@ -104,6 +106,13 @@ function getFileType(filePath: string): "csv" | "json" | "sqlite" | "parquet" | case ".parquet": case ".pq": return "parquet" + case ".xlsx": + case ".xls": + return "excel" + case ".arrow": + case ".feather": + case ".ipc": + return "arrow" default: return "unknown" } @@ -124,6 +133,10 @@ function FileIcon({ filePath }: { filePath: string }) { return case "parquet": return + case "excel": + return + case "arrow": + return default: return } @@ -298,7 +311,7 @@ export function DataViewerSidebar({ ? filePath : `${projectPath}/${filePath}` - // For SQLite files, we need to select a table + // For SQLite and Excel files, we need to select a table/sheet const selectedTableAtom = useMemo( () => selectedSqliteTableAtomFamily(absolutePath), [absolutePath] @@ -306,15 +319,24 @@ export function DataViewerSidebar({ const [selectedTable, setSelectedTable] = useAtom(selectedTableAtom) // Fetch tables for SQLite files - const { data: tables } = trpc.files.listSqliteTables.useQuery( + const { data: sqliteTables } = trpc.files.listSqliteTables.useQuery( { filePath: absolutePath }, { enabled: fileType === "sqlite" } ) - // Auto-select first table if none selected + // Fetch sheets for Excel files + const { data: excelSheets } = trpc.files.listExcelSheets.useQuery( + { filePath: absolutePath }, + { enabled: fileType === "excel" } + ) + + // Use the appropriate tables/sheets based on file type + const tables = fileType === "sqlite" ? sqliteTables : fileType === "excel" ? excelSheets : undefined + + // Auto-select first table/sheet if none selected useEffect(() => { if ( - fileType === "sqlite" && + (fileType === "sqlite" || fileType === "excel") && tables && tables.length > 0 && !selectedTable @@ -334,12 +356,13 @@ export function DataViewerSidebar({ filePath: absolutePath, limit: pageSize, offset: currentPage * pageSize, - tableName: fileType === "sqlite" ? selectedTable || undefined : undefined, + tableName: (fileType === "sqlite" || fileType === "excel") ? selectedTable || undefined : undefined, }, { enabled: fileType !== "unknown" && - (fileType !== "sqlite" || (!!selectedTable && selectedTable !== "")), + (fileType !== "sqlite" || (!!selectedTable && selectedTable !== "")) && + (fileType !== "excel" || (!!selectedTable && selectedTable !== "")), } ) @@ -742,13 +765,17 @@ export function DataViewerSidebar({ onJumpToRow={() => setShowJumpDialog(true)} /> - {/* Table selector for SQLite */} - {fileType === "sqlite" && tables && tables.length > 0 && ( + {/* Table/Sheet selector for SQLite and Excel */} + {(fileType === "sqlite" || fileType === "excel") && tables && tables.length > 0 && (
setSearchQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Escape") { + e.preventDefault() + setSearchQuery("") + searchInputRef.current?.blur() + } + }} + className="w-full h-7 rounded-lg text-sm bg-muted border border-input placeholder:text-muted-foreground/40" + />
+ {/* Root folder name */} + {projectPath && ( +
+
+ {projectPath.split("/").pop()} +
+
+ )} + {/* Content with drag-and-drop */}
No files found Drag & drop or paste (⌘V) files here
+ ) : filteredTree.length === 0 ? ( +
+ No files matching "{searchQuery}" +
) : ( - tree.map((node) => ( + filteredTree.map((node) => ( )) )} diff --git a/src/renderer/features/agents/ui/file-tree/build-file-tree.ts b/src/renderer/features/agents/ui/file-tree/build-file-tree.ts index 5a11b62f..13af232f 100644 --- a/src/renderer/features/agents/ui/file-tree/build-file-tree.ts +++ b/src/renderer/features/agents/ui/file-tree/build-file-tree.ts @@ -109,3 +109,38 @@ export function countFolders(nodes: TreeNode[]): number { } return count } + +/** + * Filter tree nodes by search query + * Returns nodes that match the query or have children that match + */ +export function filterTree(nodes: TreeNode[], query: string): TreeNode[] { + if (!query.trim()) return nodes + + const lowerQuery = query.toLowerCase() + + const filterNode = (node: TreeNode): TreeNode | null => { + const nameMatches = node.name.toLowerCase().includes(lowerQuery) + + if (node.type === "file") { + return nameMatches ? node : null + } + + // For folders, check if any children match + const filteredChildren = node.children + .map(filterNode) + .filter((n): n is TreeNode => n !== null) + + // Include folder if its name matches OR it has matching children + if (nameMatches || filteredChildren.length > 0) { + return { + ...node, + children: filteredChildren, + } + } + + return null + } + + return nodes.map(filterNode).filter((n): n is TreeNode => n !== null) +} From 3424afa1e9a0ff50c6c07802948c5bf250afa524 Mon Sep 17 00:00:00 2001 From: arnavv-guptaa Date: Sun, 18 Jan 2026 22:21:02 +0800 Subject: [PATCH 13/51] removed json from data viewer --- .../features/agents/ui/file-tree/FileTreeNode.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/renderer/features/agents/ui/file-tree/FileTreeNode.tsx b/src/renderer/features/agents/ui/file-tree/FileTreeNode.tsx index c21db66d..199aa0ad 100644 --- a/src/renderer/features/agents/ui/file-tree/FileTreeNode.tsx +++ b/src/renderer/features/agents/ui/file-tree/FileTreeNode.tsx @@ -10,12 +10,10 @@ import { } from "../../../../components/ui/context-menu" import { FileTreeContextMenu } from "./FileTreeContextMenu" -// Data file extensions for special icons -const DATA_FILE_EXTENSIONS: Record = { +// Data file extensions for special icons (files that open in data viewer) +const DATA_FILE_EXTENSIONS: Record = { ".csv": "csv", ".tsv": "csv", - ".json": "json", - ".jsonl": "json", ".db": "sqlite", ".sqlite": "sqlite", ".sqlite3": "sqlite", @@ -28,7 +26,7 @@ const DATA_FILE_EXTENSIONS: Record Date: Sun, 18 Jan 2026 23:06:32 +0800 Subject: [PATCH 14/51] added sql runner in data viewer. --- .gitignore | 1 + src/main/lib/parsers/duckdb-parser.ts | 82 +++++ src/main/lib/parsers/index.ts | 22 ++ src/main/lib/trpc/routers/files.ts | 20 + .../data/components/data-viewer-sidebar.tsx | 344 +++++++++++++++++- 5 files changed, 461 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index 54d8ce1a..4a39e0ef 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,4 @@ electron.vite.config.*.mjs # Claude binary (downloaded at build time) resources/bin/ +/test-data diff --git a/src/main/lib/parsers/duckdb-parser.ts b/src/main/lib/parsers/duckdb-parser.ts index e0ad94af..52399b6b 100644 --- a/src/main/lib/parsers/duckdb-parser.ts +++ b/src/main/lib/parsers/duckdb-parser.ts @@ -284,6 +284,88 @@ export async function getDuckDBColumns( } } +/** + * Execute an arbitrary SQL query against a data file + * The file is available as the 'data' table in the query + */ +export async function queryDataFile( + filePath: string, + sql: string, + options: { sheetName?: string } = {} +): Promise { + const { sheetName } = options + + // Detect file type - support CSV in addition to DuckDB native types + const ext = path.extname(filePath).toLowerCase() + let readFn: string + + // Escape single quotes in file path for SQL + const escapedPath = filePath.replace(/'/g, "''") + + // Determine the read function based on file type + if (ext === ".csv" || ext === ".tsv") { + readFn = `read_csv('${escapedPath}', auto_detect=true)` + } else if (ext === ".json" || ext === ".jsonl") { + readFn = `read_json('${escapedPath}', auto_detect=true)` + } else { + const fileType = getDuckDBFileType(filePath) + if (!fileType) { + throw new Error(`Unsupported file type for SQL queries: ${filePath}`) + } + readFn = getReadFunction(fileType, escapedPath, sheetName) + } + + const db = new duckdb.Database(":memory:") + + try { + // Install extensions if needed (for Excel) + const fileType = getDuckDBFileType(filePath) + if (fileType) { + await installExtensions(db, fileType) + } + + // Create a view named 'data' for the file + await queryDuckDB(db, `CREATE VIEW data AS SELECT * FROM ${readFn}`) + + // Execute the user's SQL query + const dataResult = await queryDuckDB(db, sql) + + if (dataResult.length === 0) { + db.close() + return { + columns: [], + rows: [], + totalRows: 0, + truncated: false, + } + } + + // Infer columns from the first row + const columns: ParsedColumn[] = Object.keys(dataResult[0]).map((name) => { + const value = dataResult[0][name] + let colType: ColumnType = "string" + if (typeof value === "number") colType = "number" + else if (typeof value === "boolean") colType = "boolean" + else if (value instanceof Date) colType = "date" + return { name, type: colType } + }) + + const rows = dataResult.map(processRow) + + db.close() + + return { + columns, + rows, + totalRows: rows.length, + truncated: false, + } + } catch (error) { + db.close() + throw error + } +} + /** * List sheets in an Excel file */ diff --git a/src/main/lib/parsers/index.ts b/src/main/lib/parsers/index.ts index 8fc259e9..1c6d4d72 100644 --- a/src/main/lib/parsers/index.ts +++ b/src/main/lib/parsers/index.ts @@ -12,6 +12,7 @@ import { parseDuckDBFile, getDuckDBFileType, listExcelSheets as listExcelSheetsFromDuckDB, + queryDataFile as queryDataFileDuckDB, } from "./duckdb-parser" // Re-export types @@ -172,3 +173,24 @@ export function listSqliteTables(filePath: string): string[] { export async function listExcelSheets(filePath: string): Promise { return listExcelSheetsFromDuckDB(filePath) } + +/** + * Execute a SQL query against any supported data file (CSV, JSON, Parquet, Excel, Arrow) + * The file is available as the 'data' table in the query + * For SQLite files, use querySqlite instead + */ +export async function queryDataFile( + filePath: string, + sql: string, + options: { sheetName?: string } = {} +): Promise { + const fileType = detectFileType(filePath) + + // For SQLite, use the SQLite-specific query function + if (fileType === "sqlite") { + return querySqliteDb(filePath, sql) + } + + // For all other types, use DuckDB + return queryDataFileDuckDB(filePath, sql, options) +} diff --git a/src/main/lib/trpc/routers/files.ts b/src/main/lib/trpc/routers/files.ts index c77a28ab..f894f950 100644 --- a/src/main/lib/trpc/routers/files.ts +++ b/src/main/lib/trpc/routers/files.ts @@ -12,6 +12,7 @@ import { getDataFileInfo, parseDataFile, querySqlite, + queryDataFile, listSqliteTables, listExcelSheets, isDataFile, @@ -733,6 +734,25 @@ export const filesRouter = router({ return querySqlite(input.filePath, input.sql) }), + /** + * Execute a SQL query on any data file (CSV, JSON, Parquet, Excel, Arrow, SQLite) + * The file is available as the 'data' table in the query + * Example: SELECT * FROM data WHERE column > 100 + */ + queryDataFile: publicProcedure + .input( + z.object({ + filePath: z.string(), + sql: z.string(), + sheetName: z.string().optional(), // For Excel files + }) + ) + .mutation(async ({ input }) => { + return queryDataFile(input.filePath, input.sql, { + sheetName: input.sheetName, + }) + }), + /** * List tables in a SQLite file */ diff --git a/src/renderer/features/data/components/data-viewer-sidebar.tsx b/src/renderer/features/data/components/data-viewer-sidebar.tsx index 8d21e7b2..9b2cf0f7 100644 --- a/src/renderer/features/data/components/data-viewer-sidebar.tsx +++ b/src/renderer/features/data/components/data-viewer-sidebar.tsx @@ -34,6 +34,13 @@ import { CornerDownRight, Table2, ArrowRight, + Play, + ChevronUp, + ChevronDown, + Terminal, + AlertCircle, + History, + Trash2, } from "lucide-react" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" @@ -79,6 +86,71 @@ interface DataViewerSidebarProps { const PAGE_SIZE_OPTIONS = [100, 500, 1000, 5000] as const type PageSize = (typeof PAGE_SIZE_OPTIONS)[number] +// Query history constants +const MAX_HISTORY_SIZE = 15 +const HISTORY_STORAGE_PREFIX = "sql-history:" + +interface QueryHistoryEntry { + query: string + timestamp: number +} + +/** + * Get query history for a specific file from localStorage + */ +function getQueryHistory(filePath: string): QueryHistoryEntry[] { + try { + const key = `${HISTORY_STORAGE_PREFIX}${filePath}` + const stored = localStorage.getItem(key) + if (!stored) return [] + return JSON.parse(stored) as QueryHistoryEntry[] + } catch { + return [] + } +} + +/** + * Save a query to history for a specific file + */ +function saveQueryToHistory(filePath: string, query: string): QueryHistoryEntry[] { + const trimmedQuery = query.trim() + if (!trimmedQuery) return getQueryHistory(filePath) + + try { + const key = `${HISTORY_STORAGE_PREFIX}${filePath}` + let history = getQueryHistory(filePath) + + // Remove duplicate if exists + history = history.filter((h) => h.query !== trimmedQuery) + + // Add new entry at the beginning + history.unshift({ + query: trimmedQuery, + timestamp: Date.now(), + }) + + // Keep only last N entries + history = history.slice(0, MAX_HISTORY_SIZE) + + localStorage.setItem(key, JSON.stringify(history)) + return history + } catch { + return [] + } +} + +/** + * Clear query history for a specific file + */ +function clearQueryHistory(filePath: string): void { + try { + const key = `${HISTORY_STORAGE_PREFIX}${filePath}` + localStorage.removeItem(key) + } catch { + // Ignore errors + } +} + /** * Get the file extension */ @@ -253,6 +325,28 @@ export function DataViewerSidebar({ const [sortColumn, setSortColumn] = useState(null) const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc") + // ============ SQL Query Panel State ============ + const [showQueryPanel, setShowQueryPanel] = useState(false) + const [sqlQuery, setSqlQuery] = useState("SELECT * FROM data LIMIT 100") + const [queryError, setQueryError] = useState(null) + const [isQueryMode, setIsQueryMode] = useState(false) + const [showHistory, setShowHistory] = useState(false) + const [queryHistory, setQueryHistory] = useState([]) + const textareaRef = useRef(null) + const historyRef = useRef(null) + + // Build absolute path - must be defined before useEffects that depend on it + const absolutePath = filePath.startsWith("/") + ? filePath + : `${projectPath}/${filePath}` + + // Load query history when file changes + useEffect(() => { + if (absolutePath) { + setQueryHistory(getQueryHistory(absolutePath)) + } + }, [absolutePath]) + // Glide Data Grid theme - use explicit colors instead of CSS variables const gridTheme: Partial = useMemo( () => ({ @@ -306,11 +400,6 @@ export function DataViewerSidebar({ [isDark] ) - // Build absolute path - const absolutePath = filePath.startsWith("/") - ? filePath - : `${projectPath}/${filePath}` - // For SQLite and Excel files, we need to select a table/sheet const selectedTableAtom = useMemo( () => selectedSqliteTableAtomFamily(absolutePath), @@ -350,8 +439,8 @@ export function DataViewerSidebar({ setCurrentPage(0) }, [absolutePath, selectedTable]) - // Fetch data with pagination - const { data, isLoading, error } = trpc.files.previewDataFile.useQuery( + // Fetch data with pagination (normal mode) + const { data: fileData, isLoading: isFileLoading, error: fileError } = trpc.files.previewDataFile.useQuery( { filePath: absolutePath, limit: pageSize, @@ -360,12 +449,93 @@ export function DataViewerSidebar({ }, { enabled: + !isQueryMode && fileType !== "unknown" && (fileType !== "sqlite" || (!!selectedTable && selectedTable !== "")) && (fileType !== "excel" || (!!selectedTable && selectedTable !== "")), } ) + // SQL query state for query mode + const [queryData, setQueryData] = useState(null) + const [isQueryLoading, setIsQueryLoading] = useState(false) + + // Query mutation for SQL queries + const queryMutation = trpc.files.queryDataFile.useMutation({ + onSuccess: (result) => { + setQueryData(result) + setQueryError(null) + setIsQueryLoading(false) + // Reset column state for new query results + if (result.columns) { + setColumnOrder(result.columns.map((_, i) => i)) + setHiddenColumns(new Set()) + setSortColumn(null) + } + }, + onError: (err) => { + setQueryError(err.message) + setIsQueryLoading(false) + }, + }) + + // Execute SQL query + const executeQuery = useCallback(() => { + if (!sqlQuery.trim()) return + setIsQueryLoading(true) + setQueryError(null) + setIsQueryMode(true) + setShowHistory(false) + // Save to history + const newHistory = saveQueryToHistory(absolutePath, sqlQuery) + setQueryHistory(newHistory) + queryMutation.mutate({ + filePath: absolutePath, + sql: sqlQuery, + sheetName: (fileType === "sqlite" || fileType === "excel") ? selectedTable || undefined : undefined, + }) + }, [sqlQuery, absolutePath, fileType, selectedTable, queryMutation]) + + // Load query from history + const loadQueryFromHistory = useCallback((query: string) => { + setSqlQuery(query) + setShowHistory(false) + textareaRef.current?.focus() + }, []) + + // Clear all history for this file + const handleClearHistory = useCallback(() => { + clearQueryHistory(absolutePath) + setQueryHistory([]) + setShowHistory(false) + }, [absolutePath]) + + // Close history dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (historyRef.current && !historyRef.current.contains(e.target as Node)) { + setShowHistory(false) + } + } + if (showHistory) { + document.addEventListener("mousedown", handleClickOutside) + return () => document.removeEventListener("mousedown", handleClickOutside) + } + }, [showHistory]) + + // Reset to file view + const resetToFileView = useCallback(() => { + setIsQueryMode(false) + setQueryData(null) + setQueryError(null) + setCurrentPage(0) + }, []) + + // Use query data when in query mode, otherwise use file data + const data = isQueryMode ? queryData : fileData + const isLoading = isQueryMode ? isQueryLoading : isFileLoading + const error = isQueryMode ? null : fileError + // Calculate pagination info const totalRows = data?.totalRows ?? 0 const totalPages = Math.ceil(totalRows / pageSize) @@ -788,6 +958,164 @@ export function DataViewerSidebar({
)} + {/* SQL Query Panel */} +
+ {/* Query Panel Header */} + + + {/* Query Panel Content */} + {showQueryPanel && ( +
+ {/* Textarea with inline buttons */} +
+