Code editor and widget
This commit is contained in:
@@ -7,6 +7,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.2.41",
|
"@anthropic-ai/claude-agent-sdk": "^0.2.41",
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
|
"@monaco-editor/react": "^4.7.0",
|
||||||
"@radix-ui/react-accordion": "^1.2.12",
|
"@radix-ui/react-accordion": "^1.2.12",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-aspect-ratio": "^1.1.7",
|
"@radix-ui/react-aspect-ratio": "^1.1.7",
|
||||||
@@ -74,6 +75,7 @@
|
|||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"markdown-it": "^14.1.0",
|
"markdown-it": "^14.1.0",
|
||||||
"material-file-icons": "^2.4.0",
|
"material-file-icons": "^2.4.0",
|
||||||
|
"monaco-editor": "^0.55.1",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"node-pty": "^1.1.0",
|
"node-pty": "^1.1.0",
|
||||||
"nodemailer": "^7.0.12",
|
"nodemailer": "^7.0.12",
|
||||||
@@ -483,6 +485,10 @@
|
|||||||
|
|
||||||
"@mediapipe/tasks-vision": ["@mediapipe/tasks-vision@0.10.17", "", {}, "sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg=="],
|
"@mediapipe/tasks-vision": ["@mediapipe/tasks-vision@0.10.17", "", {}, "sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg=="],
|
||||||
|
|
||||||
|
"@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=="],
|
||||||
|
|
||||||
"@monogrid/gainmap-js": ["@monogrid/gainmap-js@3.4.0", "", { "dependencies": { "promise-worker-transferable": "^1.0.4" }, "peerDependencies": { "three": ">= 0.159.0" } }, "sha512-2Z0FATFHaoYJ8b+Y4y4Hgfn3FRFwuU5zRrk+9dFWp4uGAdHGqVEdP7HP+gLA3X469KXHmfupJaUbKo1b/aDKIg=="],
|
"@monogrid/gainmap-js": ["@monogrid/gainmap-js@3.4.0", "", { "dependencies": { "promise-worker-transferable": "^1.0.4" }, "peerDependencies": { "three": ">= 0.159.0" } }, "sha512-2Z0FATFHaoYJ8b+Y4y4Hgfn3FRFwuU5zRrk+9dFWp4uGAdHGqVEdP7HP+gLA3X469KXHmfupJaUbKo1b/aDKIg=="],
|
||||||
|
|
||||||
"@next/env": ["@next/env@15.1.2", "", {}, "sha512-Hm3jIGsoUl6RLB1vzY+dZeqb+/kWPZ+h34yiWxW0dV87l8Im/eMOwpOA+a0L78U0HM04syEjXuRlCozqpwuojQ=="],
|
"@next/env": ["@next/env@15.1.2", "", {}, "sha512-Hm3jIGsoUl6RLB1vzY+dZeqb+/kWPZ+h34yiWxW0dV87l8Im/eMOwpOA+a0L78U0HM04syEjXuRlCozqpwuojQ=="],
|
||||||
@@ -985,6 +991,8 @@
|
|||||||
|
|
||||||
"@types/three": ["@types/three@0.182.0", "", { "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", "@types/webxr": ">=0.5.17", "@webgpu/types": "*", "fflate": "~0.8.2", "meshoptimizer": "~0.22.0" } }, "sha512-WByN9V3Sbwbe2OkWuSGyoqQO8Du6yhYaXtXLoA5FkKTUJorZ+yOHBZ35zUUPQXlAKABZmbYp5oAqpA4RBjtJ/Q=="],
|
"@types/three": ["@types/three@0.182.0", "", { "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", "@types/webxr": ">=0.5.17", "@webgpu/types": "*", "fflate": "~0.8.2", "meshoptimizer": "~0.22.0" } }, "sha512-WByN9V3Sbwbe2OkWuSGyoqQO8Du6yhYaXtXLoA5FkKTUJorZ+yOHBZ35zUUPQXlAKABZmbYp5oAqpA4RBjtJ/Q=="],
|
||||||
|
|
||||||
|
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
|
||||||
|
|
||||||
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
|
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
|
||||||
|
|
||||||
"@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
|
"@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
|
||||||
@@ -1241,6 +1249,8 @@
|
|||||||
|
|
||||||
"domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="],
|
"domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="],
|
||||||
|
|
||||||
|
"dompurify": ["dompurify@3.2.7", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw=="],
|
||||||
|
|
||||||
"domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
|
"domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
|
||||||
|
|
||||||
"dotenv": ["dotenv@17.2.3", "", {}, "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w=="],
|
"dotenv": ["dotenv@17.2.3", "", {}, "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w=="],
|
||||||
@@ -1521,7 +1531,7 @@
|
|||||||
|
|
||||||
"markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
|
"markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
|
||||||
|
|
||||||
"marked": ["marked@7.0.4", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-t8eP0dXRJMtMvBojtkcsA7n48BkauktUKzfkPSCq85ZMTJ0v76Rke4DYz01omYpPTUh4p/f7HePgRo3ebG8+QQ=="],
|
"marked": ["marked@14.0.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ=="],
|
||||||
|
|
||||||
"material-file-icons": ["material-file-icons@2.4.0", "", {}, "sha512-MgxhwBgoiNXyQdZVtXvdqP8t7Fu/Z3zW1aPeYN+UqtepzbKyf41b+Wme6DnwGk5Crt2JzmWLtl1XGE2YMooaQw=="],
|
"material-file-icons": ["material-file-icons@2.4.0", "", {}, "sha512-MgxhwBgoiNXyQdZVtXvdqP8t7Fu/Z3zW1aPeYN+UqtepzbKyf41b+Wme6DnwGk5Crt2JzmWLtl1XGE2YMooaQw=="],
|
||||||
|
|
||||||
@@ -1635,6 +1645,8 @@
|
|||||||
|
|
||||||
"minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="],
|
"minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="],
|
||||||
|
|
||||||
|
"monaco-editor": ["monaco-editor@0.55.1", "", { "dependencies": { "dompurify": "3.2.7", "marked": "14.0.0" } }, "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A=="],
|
||||||
|
|
||||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||||
|
|
||||||
"mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
|
"mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
|
||||||
@@ -1961,6 +1973,8 @@
|
|||||||
|
|
||||||
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
|
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
|
||||||
|
|
||||||
|
"state-local": ["state-local@1.0.7", "", {}, "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w=="],
|
||||||
|
|
||||||
"stats-gl": ["stats-gl@2.4.2", "", { "dependencies": { "@types/three": "*", "three": "^0.170.0" } }, "sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ=="],
|
"stats-gl": ["stats-gl@2.4.2", "", { "dependencies": { "@types/three": "*", "three": "^0.170.0" } }, "sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ=="],
|
||||||
|
|
||||||
"stats.js": ["stats.js@0.17.0", "", {}, "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw=="],
|
"stats.js": ["stats.js@0.17.0", "", {}, "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw=="],
|
||||||
@@ -2227,6 +2241,8 @@
|
|||||||
|
|
||||||
"hastscript/property-information": ["property-information@6.5.0", "", {}, "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig=="],
|
"hastscript/property-information": ["property-information@6.5.0", "", {}, "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig=="],
|
||||||
|
|
||||||
|
"md-to-react-email/marked": ["marked@7.0.4", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-t8eP0dXRJMtMvBojtkcsA7n48BkauktUKzfkPSCq85ZMTJ0v76Rke4DYz01omYpPTUh4p/f7HePgRo3ebG8+QQ=="],
|
||||||
|
|
||||||
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||||
|
|
||||||
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
|
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
|
||||||
|
|||||||
+6
-4
@@ -26,6 +26,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.2.41",
|
"@anthropic-ai/claude-agent-sdk": "^0.2.41",
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
|
"@monaco-editor/react": "^4.7.0",
|
||||||
"@radix-ui/react-accordion": "^1.2.12",
|
"@radix-ui/react-accordion": "^1.2.12",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-aspect-ratio": "^1.1.7",
|
"@radix-ui/react-aspect-ratio": "^1.1.7",
|
||||||
@@ -83,8 +84,8 @@
|
|||||||
"helpers": "workspace:*",
|
"helpers": "workspace:*",
|
||||||
"hono": "^4.11.1",
|
"hono": "^4.11.1",
|
||||||
"hooks": "workspace:*",
|
"hooks": "workspace:*",
|
||||||
"i18n": "workspace:*",
|
|
||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
|
"i18n": "workspace:*",
|
||||||
"idb-keyval": "^6.2.2",
|
"idb-keyval": "^6.2.2",
|
||||||
"injector": "workspace:*",
|
"injector": "workspace:*",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
@@ -93,13 +94,13 @@
|
|||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"markdown-it": "^14.1.0",
|
"markdown-it": "^14.1.0",
|
||||||
"material-file-icons": "^2.4.0",
|
"material-file-icons": "^2.4.0",
|
||||||
|
"monaco-editor": "^0.55.1",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"node-pty": "^1.1.0",
|
"node-pty": "^1.1.0",
|
||||||
"nodemailer": "^7.0.12",
|
"nodemailer": "^7.0.12",
|
||||||
"officerdb": "workspace:*",
|
"officerdb": "workspace:*",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"postgres": "^3.4.5",
|
"postgres": "^3.4.5",
|
||||||
"widgets": "workspace:*",
|
|
||||||
"react": "^19",
|
"react": "^19",
|
||||||
"react-countup": "^6.5.3",
|
"react-countup": "^6.5.3",
|
||||||
"react-day-picker": "^9.13.0",
|
"react-day-picker": "^9.13.0",
|
||||||
@@ -122,8 +123,9 @@
|
|||||||
"three": "^0.182.0",
|
"three": "^0.182.0",
|
||||||
"types": "workspace:*",
|
"types": "workspace:*",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
"zod": "^4.2.1",
|
"widgets": "workspace:*",
|
||||||
"ws": "^8.18.1"
|
"ws": "^8.18.1",
|
||||||
|
"zod": "^4.2.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.57.0",
|
"@playwright/test": "^1.57.0",
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export function App() {
|
|||||||
<Route path="/plans" element={<Dashboard.Plans />} />
|
<Route path="/plans" element={<Dashboard.Plans />} />
|
||||||
<Route path="/files" element={<Dashboard.Files />} />
|
<Route path="/files" element={<Dashboard.Files />} />
|
||||||
<Route path="/terminal" element={<Dashboard.Terminal />} />
|
<Route path="/terminal" element={<Dashboard.Terminal />} />
|
||||||
|
<Route path="/code-editor" element={<Dashboard.CodeEditor />} />
|
||||||
<Route path="/skills" element={<Dashboard.Skills />} />
|
<Route path="/skills" element={<Dashboard.Skills />} />
|
||||||
<Route path="/tasks" element={<Dashboard.Tasks />} />
|
<Route path="/tasks" element={<Dashboard.Tasks />} />
|
||||||
<Route path="/processes" element={<Dashboard.Processes />} />
|
<Route path="/processes" element={<Dashboard.Processes />} />
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { CodeEditorView } from 'widgets/CodeEditor';
|
||||||
|
import { Widget } from '@/components/Widget';
|
||||||
|
|
||||||
|
export const CodeEditor = () => (
|
||||||
|
<div className="h-full w-full flex items-center justify-center">
|
||||||
|
<Widget title="Code Editor" className="h-[70vh] w-[70vw] overflow-hidden" resizable moveable>
|
||||||
|
<CodeEditorView className="h-full w-full" />
|
||||||
|
</Widget>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -91,13 +91,14 @@ export const Dock = ({ items, className }: DockProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
import { Terminal, TerminalSquare, FileText, FolderOpen } from 'lucide-react';
|
import { Terminal, TerminalSquare, FileText, FolderOpen, Code } from 'lucide-react';
|
||||||
import { Sparkles, ClipboardList, ScrollText, Workflow } from 'lucide-react';
|
import { Sparkles, ClipboardList, ScrollText, Workflow } from 'lucide-react';
|
||||||
|
|
||||||
export const dockItems: DockItem[] = [
|
export const dockItems: DockItem[] = [
|
||||||
{ label: 'Chat', to: '/chat', icon: Terminal, color: '#60a5fa' },
|
{ label: 'Chat', to: '/chat', icon: Terminal, color: '#60a5fa' },
|
||||||
{ label: 'Files', to: '/files', icon: FolderOpen, color: '#fbbf24' },
|
{ label: 'Files', to: '/files', icon: FolderOpen, color: '#fbbf24' },
|
||||||
{ label: 'Terminal', to: '/terminal', icon: TerminalSquare, color: '#34d399' },
|
{ label: 'Terminal', to: '/terminal', icon: TerminalSquare, color: '#34d399' },
|
||||||
|
{ label: 'Editor', to: '/code-editor', icon: Code, color: '#a78bfa' },
|
||||||
{ label: 'Plans', to: '/plans', icon: FileText, color: '#f472b6' },
|
{ label: 'Plans', to: '/plans', icon: FileText, color: '#f472b6' },
|
||||||
{ label: 'Skills', to: '/skills', icon: Sparkles, color: '#c084fc' },
|
{ label: 'Skills', to: '/skills', icon: Sparkles, color: '#c084fc' },
|
||||||
{ label: 'Tasks', to: '/tasks', icon: ClipboardList, color: '#fb923c' },
|
{ label: 'Tasks', to: '/tasks', icon: ClipboardList, color: '#fb923c' },
|
||||||
|
|||||||
@@ -13,3 +13,4 @@ export * from './Tasks';
|
|||||||
export * from './Terminal';
|
export * from './Terminal';
|
||||||
export * from './Files';
|
export * from './Files';
|
||||||
export * from './ChatHistory';
|
export * from './ChatHistory';
|
||||||
|
export * from './CodeEditor';
|
||||||
|
|||||||
@@ -298,6 +298,30 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* File Browser — Context menu hover behavior */
|
/* File Browser — Context menu hover behavior */
|
||||||
|
/* Code Editor — thin dark scrollbars */
|
||||||
|
.code-editor-scrollable {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: #444 transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-editor-scrollable::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-editor-scrollable::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-editor-scrollable::-webkit-scrollbar-thumb {
|
||||||
|
background: #444;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-editor-scrollable::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #555;
|
||||||
|
}
|
||||||
|
|
||||||
.file-grid:has([data-state="open"]) [data-file-item]:not([data-state="open"]) {
|
.file-grid:has([data-state="open"]) [data-file-item]:not([data-state="open"]) {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,6 +79,23 @@ router.get('/read', async (ctx) => {
|
|||||||
return ctx.json({ content, size: s.size });
|
return ctx.json({ content, size: s.size });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Write file contents
|
||||||
|
router.post('/write', async (ctx) => {
|
||||||
|
const user = ctx.get('user');
|
||||||
|
const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
|
||||||
|
const { path, content } = ctx.get('body') as { path: string; content: string };
|
||||||
|
if (!path) throw errors.BAD_REQUEST('path is required');
|
||||||
|
if (typeof content !== 'string') throw errors.BAD_REQUEST('content must be a string');
|
||||||
|
|
||||||
|
const MAX_SIZE = 5 * 1024 * 1024; // 5 MB
|
||||||
|
if (new TextEncoder().encode(content).length > MAX_SIZE) throw errors.BAD_REQUEST('Content too large (max 5 MB)');
|
||||||
|
|
||||||
|
const absPath = resolveUserPath(rootDir, path);
|
||||||
|
await mkdir(dirname(absPath), { recursive: true });
|
||||||
|
await Bun.write(absPath, content);
|
||||||
|
return ctx.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
// Create directory
|
// Create directory
|
||||||
router.post('/mkdir', async (ctx) => {
|
router.post('/mkdir', async (ctx) => {
|
||||||
const user = ctx.get('user');
|
const user = ctx.get('user');
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { CSSProperties, ComponentPropsWithoutRef } from 'react';
|
import type { CSSProperties, ComponentPropsWithRef } from 'react';
|
||||||
import { cn } from 'helpers/cn';
|
import { cn } from 'helpers/cn';
|
||||||
|
|
||||||
export const cardStyle = (overrides?: CSSProperties): CSSProperties => ({
|
export const cardStyle = (overrides?: CSSProperties): CSSProperties => ({
|
||||||
@@ -11,7 +11,7 @@ export const cardStyle = (overrides?: CSSProperties): CSSProperties => ({
|
|||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
type CardProps = ComponentPropsWithoutRef<'div'>;
|
type CardProps = ComponentPropsWithRef<'div'>;
|
||||||
|
|
||||||
export const Card = ({ className, style, ...props }: CardProps) => {
|
export const Card = ({ className, style, ...props }: CardProps) => {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import type { CSSProperties, ComponentPropsWithoutRef, PointerEvent as ReactPointerEvent, ReactNode } from 'react';
|
||||||
|
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
|
||||||
|
import type { LucideIcon } from 'lucide-react';
|
||||||
|
import { ChevronDown, ChevronUp, Minus, Plus } from 'lucide-react';
|
||||||
|
import { cn } from 'helpers/cn';
|
||||||
|
import { Card } from './Card';
|
||||||
|
|
||||||
|
type WidgetProps = ComponentPropsWithoutRef<'div'> & {
|
||||||
|
title?: string;
|
||||||
|
resizable?: boolean;
|
||||||
|
collapsible?: boolean | { title: string; icon?: LucideIcon };
|
||||||
|
moveable?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Widget = ({ title, className, style, resizable, collapsible, moveable, children, ...props }: WidgetProps) => {
|
||||||
|
const [expanded, setExpanded] = useState(true);
|
||||||
|
const [minimized, setMinimized] = useState(false);
|
||||||
|
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||||
|
const cardRef = useRef<HTMLDivElement>(null);
|
||||||
|
const dragRef = useRef<{
|
||||||
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
originX: number;
|
||||||
|
originY: number;
|
||||||
|
naturalLeft: number;
|
||||||
|
naturalTop: number;
|
||||||
|
cardWidth: number;
|
||||||
|
cardHeight: number;
|
||||||
|
} | null>(null);
|
||||||
|
const preToggleRect = useRef<{ left: number; top: number } | null>(null);
|
||||||
|
|
||||||
|
const HEADER_HEIGHT = 64;
|
||||||
|
|
||||||
|
const toggleMinimized = useCallback(() => {
|
||||||
|
if (cardRef.current) {
|
||||||
|
const rect = cardRef.current.getBoundingClientRect();
|
||||||
|
preToggleRect.current = { left: rect.left, top: rect.top };
|
||||||
|
}
|
||||||
|
setMinimized((v) => !v);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!cardRef.current || !preToggleRect.current) return;
|
||||||
|
const prev = preToggleRect.current;
|
||||||
|
preToggleRect.current = null;
|
||||||
|
const newRect = cardRef.current.getBoundingClientRect();
|
||||||
|
setPosition((p) => ({
|
||||||
|
x: p.x + (prev.left - newRect.left),
|
||||||
|
y: p.y + (prev.top - newRect.top),
|
||||||
|
}));
|
||||||
|
}, [minimized]);
|
||||||
|
|
||||||
|
const onPointerDown = useCallback(
|
||||||
|
(ev: ReactPointerEvent) => {
|
||||||
|
if (!moveable) return;
|
||||||
|
const target = ev.target as HTMLElement;
|
||||||
|
const card = ev.currentTarget as HTMLElement;
|
||||||
|
const isCardPadding = target === card;
|
||||||
|
const isHeader = !isCardPadding && target.closest('[data-widget-header]') && !target.closest('button');
|
||||||
|
if (!isCardPadding && !isHeader) return;
|
||||||
|
if (isHeader && ev.detail === 2) {
|
||||||
|
toggleMinimized();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rect = card.getBoundingClientRect();
|
||||||
|
dragRef.current = {
|
||||||
|
startX: ev.clientX,
|
||||||
|
startY: ev.clientY,
|
||||||
|
originX: position.x,
|
||||||
|
originY: position.y,
|
||||||
|
naturalLeft: rect.left - position.x,
|
||||||
|
naturalTop: rect.top - position.y,
|
||||||
|
cardWidth: rect.width,
|
||||||
|
cardHeight: rect.height,
|
||||||
|
};
|
||||||
|
card.setPointerCapture(ev.pointerId);
|
||||||
|
},
|
||||||
|
[moveable, position],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onPointerMove = useCallback((ev: ReactPointerEvent) => {
|
||||||
|
if (!dragRef.current) return;
|
||||||
|
const d = dragRef.current;
|
||||||
|
const dx = ev.clientX - d.startX;
|
||||||
|
const dy = ev.clientY - d.startY;
|
||||||
|
const newX = d.originX + dx;
|
||||||
|
const newY = d.originY + dy;
|
||||||
|
const vw = window.innerWidth;
|
||||||
|
const vh = window.innerHeight;
|
||||||
|
setPosition({
|
||||||
|
x: Math.min(Math.max(newX, -d.naturalLeft), vw - d.naturalLeft - d.cardWidth),
|
||||||
|
y: Math.min(Math.max(newY, HEADER_HEIGHT - d.naturalTop), vh - d.naturalTop - d.cardHeight),
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onPointerUp = useCallback(() => {
|
||||||
|
dragRef.current = null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resizableStyle: CSSProperties | undefined = resizable ? { resize: 'both', overflow: 'auto' } : undefined;
|
||||||
|
const moveableStyle: CSSProperties | undefined = moveable
|
||||||
|
? { position: 'relative', transform: `translate(${position.x}px, ${position.y}px)` }
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
ref={cardRef}
|
||||||
|
className={cn(
|
||||||
|
'relative p-0',
|
||||||
|
collapsible && 'pt-0',
|
||||||
|
moveable && 'cursor-grab [&>*]:cursor-auto',
|
||||||
|
className,
|
||||||
|
minimized && '!h-auto !w-auto',
|
||||||
|
)}
|
||||||
|
style={{ ...resizableStyle, ...moveableStyle, ...style }}
|
||||||
|
onPointerDown={moveable ? onPointerDown : undefined}
|
||||||
|
onPointerMove={moveable ? onPointerMove : undefined}
|
||||||
|
onPointerUp={moveable ? onPointerUp : undefined}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div data-widget-header className="flex h-8 items-center gap-12 px-3 select-none cursor-grab">
|
||||||
|
{title && <span className="text-sm font-bold text-muted-foreground pointer-events-none">{title}</span>}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ml-auto p-1 rounded cursor-pointer text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => toggleMinimized()}
|
||||||
|
>
|
||||||
|
{minimized ? <Plus size={14} /> : <Minus size={14} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{!minimized && (
|
||||||
|
<>
|
||||||
|
{collapsible && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex w-full items-center gap-2 p-3 cursor-pointer"
|
||||||
|
onClick={() => setExpanded((v) => !v)}
|
||||||
|
>
|
||||||
|
{typeof collapsible === 'object' && collapsible.icon && (
|
||||||
|
<collapsible.icon size={16} className="text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
{typeof collapsible === 'object' && collapsible.title && (
|
||||||
|
<span className="text-sm text-muted-foreground">{collapsible.title}</span>
|
||||||
|
)}
|
||||||
|
<span className="ml-auto text-muted-foreground">
|
||||||
|
{expanded ? <ChevronDown size={16} /> : <ChevronUp size={16} />}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{collapsible ? expanded && <div className="px-5 pb-5">{children}</div> : children}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
|
import Editor, { type OnMount } from '@monaco-editor/react';
|
||||||
|
import type { editor as MonacoEditor } from 'monaco-editor';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable';
|
||||||
|
import { useFiles } from 'widgets/FileBrowser';
|
||||||
|
import { FileTree } from './FileTree';
|
||||||
|
import { EditorTabs } from './EditorTabs';
|
||||||
|
import { useEditorState } from './useEditorState';
|
||||||
|
import { getLanguage } from './language-map';
|
||||||
|
|
||||||
|
type CodeEditorViewProps = {
|
||||||
|
className?: string;
|
||||||
|
theme?: string;
|
||||||
|
root?: string;
|
||||||
|
initialPath?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CodeEditorView = ({ className, theme = 'vs-dark', root = 'home', initialPath }: CodeEditorViewProps) => {
|
||||||
|
const { files, activePath, setActivePath, openFile, closeFile, setContent, markSaved, getActiveFile } =
|
||||||
|
useEditorState();
|
||||||
|
const { readFile, writeFile } = useFiles(root);
|
||||||
|
const editorRef = useRef<MonacoEditor.IStandaloneCodeEditor | null>(null);
|
||||||
|
|
||||||
|
const activeFile = getActiveFile();
|
||||||
|
|
||||||
|
const handleOpenFile = useCallback(
|
||||||
|
async (path: string, name: string) => {
|
||||||
|
const existing = files.find((f) => f.path === path);
|
||||||
|
if (existing) {
|
||||||
|
setActivePath(path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await readFile(path);
|
||||||
|
openFile(path, name, res.content);
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to read file');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[files, setActivePath, readFile, openFile],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
if (!activeFile || !activeFile.isDirty) return;
|
||||||
|
try {
|
||||||
|
await writeFile(activeFile.path, activeFile.content);
|
||||||
|
markSaved(activeFile.path, activeFile.content);
|
||||||
|
toast.success('File saved');
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to save file');
|
||||||
|
}
|
||||||
|
}, [activeFile, writeFile, markSaved]);
|
||||||
|
|
||||||
|
const handleEditorMount: OnMount = useCallback(
|
||||||
|
(editor, monaco) => {
|
||||||
|
editorRef.current = editor;
|
||||||
|
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
|
||||||
|
handleSave();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[handleSave],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (ev: KeyboardEvent) => {
|
||||||
|
if ((ev.ctrlKey || ev.metaKey) && ev.key === 's') {
|
||||||
|
ev.preventDefault();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handler);
|
||||||
|
return () => window.removeEventListener('keydown', handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const bg = theme === 'vs-dark' ? '#1e1e1e' : theme === 'vs' ? '#ffffff' : '#1e1e1e';
|
||||||
|
const borderColor = theme === 'vs-dark' ? '#333' : '#e0e0e0';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className} style={{ background: bg }}>
|
||||||
|
<ResizablePanelGroup direction="horizontal" className="h-full rounded-lg" style={{ borderColor }}>
|
||||||
|
<ResizablePanel defaultSize={20} minSize={10} maxSize={40}>
|
||||||
|
<div className="h-full flex flex-col" style={{ background: bg }}>
|
||||||
|
<div
|
||||||
|
className="px-3 py-2 text-xs font-semibold uppercase tracking-wider"
|
||||||
|
style={{ color: '#888', borderBottom: `1px solid ${borderColor}` }}
|
||||||
|
>
|
||||||
|
Explorer
|
||||||
|
</div>
|
||||||
|
<FileTree root={root} basePath={initialPath ?? '/'} onOpenFile={handleOpenFile} />
|
||||||
|
</div>
|
||||||
|
</ResizablePanel>
|
||||||
|
<ResizableHandle />
|
||||||
|
<ResizablePanel defaultSize={80}>
|
||||||
|
<div className="h-full flex flex-col" style={{ background: bg }}>
|
||||||
|
<EditorTabs
|
||||||
|
files={files}
|
||||||
|
activePath={activePath}
|
||||||
|
onSelect={setActivePath}
|
||||||
|
onClose={closeFile}
|
||||||
|
theme={theme}
|
||||||
|
/>
|
||||||
|
{activeFile ? (
|
||||||
|
<Editor
|
||||||
|
key={activeFile.path}
|
||||||
|
theme={theme}
|
||||||
|
language={getLanguage(activeFile.name)}
|
||||||
|
value={activeFile.content}
|
||||||
|
onChange={(value) => setContent(activeFile.path, value ?? '')}
|
||||||
|
onMount={handleEditorMount}
|
||||||
|
options={{
|
||||||
|
automaticLayout: true,
|
||||||
|
minimap: { enabled: true },
|
||||||
|
fontSize: 14,
|
||||||
|
tabSize: 2,
|
||||||
|
wordWrap: 'on',
|
||||||
|
scrollBeyondLastLine: false,
|
||||||
|
renderWhitespace: 'selection',
|
||||||
|
smoothScrolling: true,
|
||||||
|
cursorBlinking: 'smooth',
|
||||||
|
cursorSmoothCaretAnimation: 'on',
|
||||||
|
padding: { top: 8 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 flex items-center justify-center text-sm" style={{ color: '#888' }}>
|
||||||
|
Open a file from the explorer to start editing
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ResizablePanel>
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
import { getIcon } from 'material-file-icons';
|
||||||
|
import type { OpenFile } from './useEditorState';
|
||||||
|
|
||||||
|
type EditorTabsProps = {
|
||||||
|
files: OpenFile[];
|
||||||
|
activePath: string | null;
|
||||||
|
onSelect: (path: string) => void;
|
||||||
|
onClose: (path: string) => void;
|
||||||
|
theme?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FileIcon = ({ name }: { name: string }) => {
|
||||||
|
const svg = useMemo(() => getIcon(name).svg, [name]);
|
||||||
|
return <span className="inline-flex h-4 w-4 shrink-0" dangerouslySetInnerHTML={{ __html: svg }} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EditorTabs = ({ files, activePath, onSelect, onClose, theme }: EditorTabsProps) => {
|
||||||
|
if (files.length === 0) return null;
|
||||||
|
|
||||||
|
const isDark = theme !== 'vs';
|
||||||
|
const borderColor = isDark ? '#333' : '#e0e0e0';
|
||||||
|
const activeBg = isDark ? '#1e1e1e' : '#ffffff';
|
||||||
|
const inactiveBg = isDark ? '#181818' : '#f3f3f3';
|
||||||
|
const activeColor = isDark ? '#ccc' : '#333';
|
||||||
|
const inactiveColor = isDark ? '#888' : '#666';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center overflow-x-auto shrink-0 code-editor-scrollable" style={{ borderBottom: `1px solid ${borderColor}` }}>
|
||||||
|
{files.map((file) => {
|
||||||
|
const isActive = file.path === activePath;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={file.path}
|
||||||
|
onClick={() => onSelect(file.path)}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 text-sm cursor-pointer whitespace-nowrap transition-colors"
|
||||||
|
style={{
|
||||||
|
background: isActive ? activeBg : inactiveBg,
|
||||||
|
color: isActive ? activeColor : inactiveColor,
|
||||||
|
borderRight: `1px solid ${borderColor}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FileIcon name={file.name} />
|
||||||
|
<span>{file.name}</span>
|
||||||
|
{file.isDirty && <span className="w-2 h-2 rounded-full bg-blue-400 shrink-0" />}
|
||||||
|
<span
|
||||||
|
role="button"
|
||||||
|
className="ml-1 p-0.5 rounded transition-colors"
|
||||||
|
style={{ color: inactiveColor }}
|
||||||
|
onClick={(ev) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
onClose(file.path);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { useState, useMemo, useCallback } from 'react';
|
||||||
|
import { ChevronRight, ChevronDown, Folder } from 'lucide-react';
|
||||||
|
import { getIcon } from 'material-file-icons';
|
||||||
|
import { useFiles, type DirEntry } from 'widgets/FileBrowser';
|
||||||
|
|
||||||
|
type FileTreeProps = {
|
||||||
|
root: string;
|
||||||
|
basePath: string;
|
||||||
|
onOpenFile: (path: string, name: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TreeNodeProps = {
|
||||||
|
entry: DirEntry;
|
||||||
|
parentPath: string;
|
||||||
|
root: string;
|
||||||
|
onOpenFile: (path: string, name: string) => void;
|
||||||
|
depth: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FileIcon = ({ name }: { name: string }) => {
|
||||||
|
const svg = useMemo(() => getIcon(name).svg, [name]);
|
||||||
|
return <span className="inline-flex h-4 w-4 shrink-0" dangerouslySetInnerHTML={{ __html: svg }} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortEntries = (entries: DirEntry[]) => {
|
||||||
|
const dirs = entries.filter((e) => e.type === 'directory').sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
const files = entries.filter((e) => e.type === 'file').sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
return [...dirs, ...files];
|
||||||
|
};
|
||||||
|
|
||||||
|
const TreeNode = ({ entry, parentPath, root, onOpenFile, depth }: TreeNodeProps) => {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const [children, setChildren] = useState<DirEntry[] | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const { listDir } = useFiles(root);
|
||||||
|
const isDir = entry.type === 'directory';
|
||||||
|
const fullPath = parentPath === '/' ? `/${entry.name}` : `${parentPath}/${entry.name}`;
|
||||||
|
|
||||||
|
const handleClick = useCallback(async () => {
|
||||||
|
if (!isDir) {
|
||||||
|
onOpenFile(fullPath, entry.name);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!expanded && children === null) {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await listDir(fullPath);
|
||||||
|
setChildren(sortEntries(res.entries));
|
||||||
|
} catch {
|
||||||
|
setChildren([]);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
setExpanded((prev) => !prev);
|
||||||
|
}, [isDir, expanded, children, fullPath, entry.name, listDir, onOpenFile]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={handleClick}
|
||||||
|
className="flex items-center gap-1 w-full px-1 py-0.5 text-sm rounded cursor-pointer transition-colors text-left text-[#ccc] hover:bg-white/5"
|
||||||
|
style={{ paddingLeft: `${depth * 12 + 4}px` }}
|
||||||
|
>
|
||||||
|
{isDir ? (
|
||||||
|
<>
|
||||||
|
{expanded ? (
|
||||||
|
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-[#888]" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-[#888]" />
|
||||||
|
)}
|
||||||
|
<Folder className="h-4 w-4 shrink-0 text-duck-yellow fill-duck-yellow/30" />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="w-3.5 shrink-0" />
|
||||||
|
<FileIcon name={entry.name} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span className="truncate">{entry.name}</span>
|
||||||
|
{loading && <span className="text-xs text-[#888] ml-auto">...</span>}
|
||||||
|
</button>
|
||||||
|
{isDir && expanded && children && (
|
||||||
|
<div>
|
||||||
|
{children.map((child) => (
|
||||||
|
<TreeNode
|
||||||
|
key={child.name}
|
||||||
|
entry={child}
|
||||||
|
parentPath={fullPath}
|
||||||
|
root={root}
|
||||||
|
onOpenFile={onOpenFile}
|
||||||
|
depth={depth + 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FileTree = ({ root, basePath, onOpenFile }: FileTreeProps) => {
|
||||||
|
const { listDir } = useFiles(root);
|
||||||
|
const [entries, setEntries] = useState<DirEntry[] | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const loadRoot = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await listDir(basePath);
|
||||||
|
setEntries(sortEntries(res.entries));
|
||||||
|
} catch {
|
||||||
|
setEntries([]);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}, [listDir, basePath]);
|
||||||
|
|
||||||
|
if (entries === null && !loading) {
|
||||||
|
loadRoot();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading && entries === null) {
|
||||||
|
return <div className="p-2 text-sm text-[#888]">Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!entries || entries.length === 0) {
|
||||||
|
return <div className="p-2 text-sm text-[#888]">No files</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="py-1 overflow-y-auto h-full code-editor-scrollable">
|
||||||
|
{entries.map((entry) => (
|
||||||
|
<TreeNode
|
||||||
|
key={entry.name}
|
||||||
|
entry={entry}
|
||||||
|
parentPath={basePath}
|
||||||
|
root={root}
|
||||||
|
onOpenFile={onOpenFile}
|
||||||
|
depth={0}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { CodeEditorView } from './CodeEditor';
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
const extensionToLanguage: Record<string, string> = {
|
||||||
|
// JavaScript / TypeScript
|
||||||
|
ts: 'typescript',
|
||||||
|
tsx: 'typescript',
|
||||||
|
js: 'javascript',
|
||||||
|
jsx: 'javascript',
|
||||||
|
mjs: 'javascript',
|
||||||
|
cjs: 'javascript',
|
||||||
|
|
||||||
|
// Web
|
||||||
|
html: 'html',
|
||||||
|
htm: 'html',
|
||||||
|
css: 'css',
|
||||||
|
scss: 'scss',
|
||||||
|
less: 'less',
|
||||||
|
vue: 'html',
|
||||||
|
svelte: 'html',
|
||||||
|
|
||||||
|
// Data / Config
|
||||||
|
json: 'json',
|
||||||
|
jsonc: 'json',
|
||||||
|
json5: 'json',
|
||||||
|
geojson: 'json',
|
||||||
|
yaml: 'yaml',
|
||||||
|
yml: 'yaml',
|
||||||
|
toml: 'ini',
|
||||||
|
ini: 'ini',
|
||||||
|
cfg: 'ini',
|
||||||
|
conf: 'ini',
|
||||||
|
properties: 'ini',
|
||||||
|
env: 'ini',
|
||||||
|
xml: 'xml',
|
||||||
|
xsl: 'xml',
|
||||||
|
xslt: 'xml',
|
||||||
|
xsd: 'xml',
|
||||||
|
svg: 'xml',
|
||||||
|
plist: 'xml',
|
||||||
|
csproj: 'xml',
|
||||||
|
fsproj: 'xml',
|
||||||
|
vcxproj: 'xml',
|
||||||
|
sln: 'xml',
|
||||||
|
|
||||||
|
// Markdown / Text
|
||||||
|
md: 'markdown',
|
||||||
|
mdx: 'markdown',
|
||||||
|
markdown: 'markdown',
|
||||||
|
txt: 'plaintext',
|
||||||
|
log: 'plaintext',
|
||||||
|
|
||||||
|
// Shell
|
||||||
|
sh: 'shell',
|
||||||
|
bash: 'shell',
|
||||||
|
zsh: 'shell',
|
||||||
|
fish: 'shell',
|
||||||
|
ksh: 'shell',
|
||||||
|
csh: 'shell',
|
||||||
|
ps1: 'powershell',
|
||||||
|
psm1: 'powershell',
|
||||||
|
psd1: 'powershell',
|
||||||
|
bat: 'bat',
|
||||||
|
cmd: 'bat',
|
||||||
|
|
||||||
|
// Python
|
||||||
|
py: 'python',
|
||||||
|
pyw: 'python',
|
||||||
|
pyi: 'python',
|
||||||
|
pyx: 'python',
|
||||||
|
ipynb: 'json',
|
||||||
|
|
||||||
|
// Ruby
|
||||||
|
rb: 'ruby',
|
||||||
|
erb: 'ruby',
|
||||||
|
gemspec: 'ruby',
|
||||||
|
rake: 'ruby',
|
||||||
|
|
||||||
|
// Rust
|
||||||
|
rs: 'rust',
|
||||||
|
|
||||||
|
// Go
|
||||||
|
go: 'go',
|
||||||
|
mod: 'go',
|
||||||
|
|
||||||
|
// Java / JVM
|
||||||
|
java: 'java',
|
||||||
|
kt: 'kotlin',
|
||||||
|
kts: 'kotlin',
|
||||||
|
scala: 'scala',
|
||||||
|
sc: 'scala',
|
||||||
|
groovy: 'groovy',
|
||||||
|
gradle: 'groovy',
|
||||||
|
|
||||||
|
// C / C++ / Objective-C
|
||||||
|
c: 'c',
|
||||||
|
h: 'c',
|
||||||
|
cpp: 'cpp',
|
||||||
|
cc: 'cpp',
|
||||||
|
cxx: 'cpp',
|
||||||
|
hpp: 'cpp',
|
||||||
|
hxx: 'cpp',
|
||||||
|
hh: 'cpp',
|
||||||
|
m: 'objective-c',
|
||||||
|
mm: 'objective-c',
|
||||||
|
|
||||||
|
// C# / F#
|
||||||
|
cs: 'csharp',
|
||||||
|
csx: 'csharp',
|
||||||
|
fs: 'fsharp',
|
||||||
|
fsx: 'fsharp',
|
||||||
|
fsi: 'fsharp',
|
||||||
|
|
||||||
|
// Swift
|
||||||
|
swift: 'swift',
|
||||||
|
|
||||||
|
// Dart
|
||||||
|
dart: 'dart',
|
||||||
|
|
||||||
|
// PHP
|
||||||
|
php: 'php',
|
||||||
|
phtml: 'php',
|
||||||
|
|
||||||
|
// SQL
|
||||||
|
sql: 'sql',
|
||||||
|
mysql: 'sql',
|
||||||
|
pgsql: 'pgsql',
|
||||||
|
|
||||||
|
// Lua
|
||||||
|
lua: 'lua',
|
||||||
|
|
||||||
|
// R
|
||||||
|
r: 'r',
|
||||||
|
rmd: 'markdown',
|
||||||
|
|
||||||
|
// Perl
|
||||||
|
pl: 'perl',
|
||||||
|
pm: 'perl',
|
||||||
|
perl: 'perl',
|
||||||
|
|
||||||
|
// GraphQL
|
||||||
|
graphql: 'graphql',
|
||||||
|
gql: 'graphql',
|
||||||
|
|
||||||
|
// Docker
|
||||||
|
dockerfile: 'dockerfile',
|
||||||
|
|
||||||
|
// Elixir / Erlang
|
||||||
|
ex: 'elixir',
|
||||||
|
exs: 'elixir',
|
||||||
|
erl: 'erlang',
|
||||||
|
hrl: 'erlang',
|
||||||
|
|
||||||
|
// Haskell
|
||||||
|
hs: 'haskell',
|
||||||
|
lhs: 'haskell',
|
||||||
|
|
||||||
|
// Clojure
|
||||||
|
clj: 'clojure',
|
||||||
|
cljs: 'clojure',
|
||||||
|
cljc: 'clojure',
|
||||||
|
edn: 'clojure',
|
||||||
|
|
||||||
|
// Handlebars
|
||||||
|
hbs: 'handlebars',
|
||||||
|
handlebars: 'handlebars',
|
||||||
|
|
||||||
|
// Twig
|
||||||
|
twig: 'twig',
|
||||||
|
|
||||||
|
// Pug
|
||||||
|
pug: 'pug',
|
||||||
|
jade: 'pug',
|
||||||
|
|
||||||
|
// Coffee
|
||||||
|
coffee: 'coffeescript',
|
||||||
|
|
||||||
|
// Diff / Patch
|
||||||
|
diff: 'diff',
|
||||||
|
patch: 'diff',
|
||||||
|
|
||||||
|
// Protocol Buffers
|
||||||
|
proto: 'protobuf',
|
||||||
|
|
||||||
|
// Terraform
|
||||||
|
tf: 'hcl',
|
||||||
|
tfvars: 'hcl',
|
||||||
|
hcl: 'hcl',
|
||||||
|
|
||||||
|
// ABAP
|
||||||
|
abap: 'abap',
|
||||||
|
|
||||||
|
// Apex
|
||||||
|
apex: 'apex',
|
||||||
|
cls: 'apex',
|
||||||
|
trigger: 'apex',
|
||||||
|
|
||||||
|
// Pascal
|
||||||
|
pas: 'pascal',
|
||||||
|
pp: 'pascal',
|
||||||
|
|
||||||
|
// Tcl
|
||||||
|
tcl: 'tcl',
|
||||||
|
|
||||||
|
// Scheme / Lisp
|
||||||
|
scm: 'scheme',
|
||||||
|
ss: 'scheme',
|
||||||
|
rkt: 'scheme',
|
||||||
|
lisp: 'scheme',
|
||||||
|
lsp: 'scheme',
|
||||||
|
el: 'scheme',
|
||||||
|
|
||||||
|
// Misc
|
||||||
|
sol: 'sol',
|
||||||
|
bicep: 'bicep',
|
||||||
|
azcli: 'azcli',
|
||||||
|
redis: 'redis',
|
||||||
|
sb: 'sb',
|
||||||
|
st: 'st',
|
||||||
|
lex: 'lexon',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getLanguage = (filename: string): string => {
|
||||||
|
const ext = filename.split('.').pop()?.toLowerCase() ?? '';
|
||||||
|
const base = filename.toLowerCase();
|
||||||
|
if (base === 'dockerfile' || base.startsWith('dockerfile.')) return 'dockerfile';
|
||||||
|
if (base === 'makefile' || base === 'gnumakefile') return 'shell';
|
||||||
|
if (base === 'gemfile' || base === 'rakefile' || base === 'vagrantfile') return 'ruby';
|
||||||
|
if (base === 'justfile') return 'shell';
|
||||||
|
if (base === '.gitignore' || base === '.dockerignore' || base === '.editorconfig') return 'ini';
|
||||||
|
if (base === '.prettierrc' || base === '.eslintrc' || base === 'tsconfig.json' || base === 'package.json')
|
||||||
|
return 'json';
|
||||||
|
// Dotfiles that are shell scripts
|
||||||
|
if (/^\.(bash|zsh|sh|ksh|csh)rc$/.test(base)) return 'shell';
|
||||||
|
if (/^\.(bash_|zsh_|sh_)/.test(base)) return 'shell'; // .bash_profile, .zsh_history, etc.
|
||||||
|
if (base === '.profile' || base === '.bash_logout' || base === '.bash_login') return 'shell';
|
||||||
|
return extensionToLanguage[ext] ?? 'plaintext';
|
||||||
|
};
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
|
||||||
|
export type OpenFile = {
|
||||||
|
path: string;
|
||||||
|
name: string;
|
||||||
|
content: string;
|
||||||
|
originalContent: string;
|
||||||
|
isDirty: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useEditorState = () => {
|
||||||
|
const [files, setFiles] = useState<OpenFile[]>([]);
|
||||||
|
const [activePath, setActivePath] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const openFile = useCallback((path: string, name: string, content: string) => {
|
||||||
|
setFiles((prev) => {
|
||||||
|
const existing = prev.find((f) => f.path === path);
|
||||||
|
if (existing) return prev;
|
||||||
|
return [...prev, { path, name, content, originalContent: content, isDirty: false }];
|
||||||
|
});
|
||||||
|
setActivePath(path);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closeFile = useCallback(
|
||||||
|
(path: string) => {
|
||||||
|
setFiles((prev) => {
|
||||||
|
const next = prev.filter((f) => f.path !== path);
|
||||||
|
if (activePath === path) {
|
||||||
|
const idx = prev.findIndex((f) => f.path === path);
|
||||||
|
const newActive = next[Math.min(idx, next.length - 1)] ?? null;
|
||||||
|
setActivePath(newActive?.path ?? null);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[activePath],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setContent = useCallback((path: string, content: string) => {
|
||||||
|
setFiles((prev) =>
|
||||||
|
prev.map((f) => (f.path === path ? { ...f, content, isDirty: content !== f.originalContent } : f)),
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const markSaved = useCallback((path: string, content: string) => {
|
||||||
|
setFiles((prev) =>
|
||||||
|
prev.map((f) => (f.path === path ? { ...f, originalContent: content, content, isDirty: false } : f)),
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getActiveFile = useCallback((): OpenFile | null => {
|
||||||
|
return files.find((f) => f.path === activePath) ?? null;
|
||||||
|
}, [files, activePath]);
|
||||||
|
|
||||||
|
return { files, activePath, setActivePath, openFile, closeFile, setContent, markSaved, getActiveFile };
|
||||||
|
};
|
||||||
@@ -34,6 +34,9 @@ export const useFiles = (root: string = 'home') => {
|
|||||||
readFile: (path: string) =>
|
readFile: (path: string) =>
|
||||||
client.get<{ content: string; size: number }>(withRoot(`/file-browser/read?path=${encodeURIComponent(path)}`)),
|
client.get<{ content: string; size: number }>(withRoot(`/file-browser/read?path=${encodeURIComponent(path)}`)),
|
||||||
|
|
||||||
|
writeFile: (path: string, content: string) =>
|
||||||
|
client.post(withRoot('/file-browser/write'), { path, content }),
|
||||||
|
|
||||||
search: (query: string) =>
|
search: (query: string) =>
|
||||||
client.get<{ results: DirEntry[] }>(withRoot(`/file-browser/search?q=${encodeURIComponent(query)}`)),
|
client.get<{ results: DirEntry[] }>(withRoot(`/file-browser/search?q=${encodeURIComponent(query)}`)),
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"./Terminal": "./Terminal/index.ts",
|
"./Terminal": "./Terminal/index.ts",
|
||||||
"./FileBrowser": "./FileBrowser/index.ts",
|
"./FileBrowser": "./FileBrowser/index.ts",
|
||||||
"./ChatHistory": "./ChatHistory/index.ts",
|
"./ChatHistory": "./ChatHistory/index.ts",
|
||||||
"./Chat": "./Chat/index.ts"
|
"./Chat": "./Chat/index.ts",
|
||||||
|
"./CodeEditor": "./CodeEditor/index.ts"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user