mirror of
https://github.com/siteboon/claudecodeui.git
synced 2025-12-14 07:09:32 +00:00
Compare commits
18 Commits
v1.11.0
...
289c2334e0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
289c2334e0 | ||
|
|
de8c4d1845 | ||
|
|
23de8c7863 | ||
|
|
041c72b160 | ||
|
|
519b5e5209 | ||
|
|
7ab14750de | ||
|
|
255aed0b01 | ||
|
|
b416e542c7 | ||
|
|
43cbbb10d9 | ||
|
|
003b64f8f0 | ||
|
|
401223dcd5 | ||
|
|
499e33d910 | ||
|
|
0181883c8a | ||
|
|
a100aa598c | ||
|
|
c875907f55 | ||
|
|
b2c16002e4 | ||
|
|
c7dbab086b | ||
|
|
06d17eb22e |
@@ -35,6 +35,7 @@ VITE_PORT=5173
|
|||||||
# DATABASE_PATH=/path/to/your/custom/auth.db
|
# DATABASE_PATH=/path/to/your/custom/auth.db
|
||||||
#
|
#
|
||||||
# Claude Code context window size (maximum tokens per session)
|
# Claude Code context window size (maximum tokens per session)
|
||||||
# Note: VITE_ prefix makes it available to frontend
|
|
||||||
VITE_CONTEXT_WINDOW=160000
|
VITE_CONTEXT_WINDOW=160000
|
||||||
CONTEXT_WINDOW=160000
|
CONTEXT_WINDOW=160000
|
||||||
|
|
||||||
|
# VITE_IS_PLATFORM=false
|
||||||
|
|||||||
311
package-lock.json
generated
311
package-lock.json
generated
@@ -37,6 +37,7 @@
|
|||||||
"fuse.js": "^7.0.0",
|
"fuse.js": "^7.0.0",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"katex": "^0.16.25",
|
||||||
"lucide-react": "^0.515.0",
|
"lucide-react": "^0.515.0",
|
||||||
"mime-types": "^3.0.1",
|
"mime-types": "^3.0.1",
|
||||||
"multer": "^2.0.1",
|
"multer": "^2.0.1",
|
||||||
@@ -47,7 +48,9 @@
|
|||||||
"react-dropzone": "^14.2.3",
|
"react-dropzone": "^14.2.3",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^6.8.1",
|
"react-router-dom": "^6.8.1",
|
||||||
|
"rehype-katex": "^7.0.1",
|
||||||
"remark-gfm": "^4.0.0",
|
"remark-gfm": "^4.0.0",
|
||||||
|
"remark-math": "^6.0.0",
|
||||||
"sqlite": "^5.1.1",
|
"sqlite": "^5.1.1",
|
||||||
"sqlite3": "^5.1.7",
|
"sqlite3": "^5.1.7",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
@@ -2860,6 +2863,12 @@
|
|||||||
"@types/unist": "*"
|
"@types/unist": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/katex": {
|
||||||
|
"version": "0.16.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.7.tgz",
|
||||||
|
"integrity": "sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/mdast": {
|
"node_modules/@types/mdast": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
|
||||||
@@ -4610,6 +4619,18 @@
|
|||||||
"once": "^1.4.0"
|
"once": "^1.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/entities": {
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/env-paths": {
|
"node_modules/env-paths": {
|
||||||
"version": "2.2.1",
|
"version": "2.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
|
||||||
@@ -5540,6 +5561,101 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/hast-util-from-dom": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/hast-util-from-dom/-/hast-util-from-dom-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/hast": "^3.0.0",
|
||||||
|
"hastscript": "^9.0.0",
|
||||||
|
"web-namespaces": "^2.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/unified"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/hast-util-from-html": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/hast": "^3.0.0",
|
||||||
|
"devlop": "^1.1.0",
|
||||||
|
"hast-util-from-parse5": "^8.0.0",
|
||||||
|
"parse5": "^7.0.0",
|
||||||
|
"vfile": "^6.0.0",
|
||||||
|
"vfile-message": "^4.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/unified"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/hast-util-from-html-isomorphic": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/hast-util-from-html-isomorphic/-/hast-util-from-html-isomorphic-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/hast": "^3.0.0",
|
||||||
|
"hast-util-from-dom": "^5.0.0",
|
||||||
|
"hast-util-from-html": "^2.0.0",
|
||||||
|
"unist-util-remove-position": "^5.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/unified"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/hast-util-from-parse5": {
|
||||||
|
"version": "8.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz",
|
||||||
|
"integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/hast": "^3.0.0",
|
||||||
|
"@types/unist": "^3.0.0",
|
||||||
|
"devlop": "^1.0.0",
|
||||||
|
"hastscript": "^9.0.0",
|
||||||
|
"property-information": "^7.0.0",
|
||||||
|
"vfile": "^6.0.0",
|
||||||
|
"vfile-location": "^5.0.0",
|
||||||
|
"web-namespaces": "^2.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/unified"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/hast-util-is-element": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/hast": "^3.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/unified"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/hast-util-parse-selector": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/hast": "^3.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/unified"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/hast-util-to-jsx-runtime": {
|
"node_modules/hast-util-to-jsx-runtime": {
|
||||||
"version": "2.3.6",
|
"version": "2.3.6",
|
||||||
"resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz",
|
||||||
@@ -5567,6 +5683,22 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"url": "https://opencollective.com/unified"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/hast-util-to-text": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/hast": "^3.0.0",
|
||||||
|
"@types/unist": "^3.0.0",
|
||||||
|
"hast-util-is-element": "^3.0.0",
|
||||||
|
"unist-util-find-after": "^5.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/unified"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/hast-util-whitespace": {
|
"node_modules/hast-util-whitespace": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz",
|
||||||
@@ -5580,6 +5712,23 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"url": "https://opencollective.com/unified"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/hastscript": {
|
||||||
|
"version": "9.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz",
|
||||||
|
"integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/unified"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/html-url-attributes": {
|
"node_modules/html-url-attributes": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
|
||||||
@@ -6220,6 +6369,31 @@
|
|||||||
"safe-buffer": "^5.0.1"
|
"safe-buffer": "^5.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/katex": {
|
||||||
|
"version": "0.16.25",
|
||||||
|
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.25.tgz",
|
||||||
|
"integrity": "sha512-woHRUZ/iF23GBP1dkDQMh1QBad9dmr8/PAwNA54VrSOVYgI12MAcE14TqnDdQOdzyEonGzMepYnqBMYdsoAr8Q==",
|
||||||
|
"funding": [
|
||||||
|
"https://opencollective.com/katex",
|
||||||
|
"https://github.com/sponsors/katex"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"commander": "^8.3.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"katex": "cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/katex/node_modules/commander": {
|
||||||
|
"version": "8.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
|
||||||
|
"integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/kind-of": {
|
"node_modules/kind-of": {
|
||||||
"version": "6.0.3",
|
"version": "6.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
|
||||||
@@ -6584,6 +6758,25 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"url": "https://opencollective.com/unified"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mdast-util-math": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mdast-util-math/-/mdast-util-math-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/hast": "^3.0.0",
|
||||||
|
"@types/mdast": "^4.0.0",
|
||||||
|
"devlop": "^1.0.0",
|
||||||
|
"longest-streak": "^3.0.0",
|
||||||
|
"mdast-util-from-markdown": "^2.0.0",
|
||||||
|
"mdast-util-to-markdown": "^2.1.0",
|
||||||
|
"unist-util-remove-position": "^5.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/unified"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/mdast-util-mdx-expression": {
|
"node_modules/mdast-util-mdx-expression": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz",
|
||||||
@@ -6946,6 +7139,25 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"url": "https://opencollective.com/unified"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/micromark-extension-math": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/micromark-extension-math/-/micromark-extension-math-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/katex": "^0.16.0",
|
||||||
|
"devlop": "^1.0.0",
|
||||||
|
"katex": "^0.16.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"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/unified"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/micromark-factory-destination": {
|
"node_modules/micromark-factory-destination": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz",
|
||||||
@@ -8283,6 +8495,18 @@
|
|||||||
"node": ">=14.13.0"
|
"node": ">=14.13.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/parse5": {
|
||||||
|
"version": "7.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
|
||||||
|
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"entities": "^6.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/parseurl": {
|
"node_modules/parseurl": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||||
@@ -8946,6 +9170,25 @@
|
|||||||
"url": "https://paulmillr.com/funding/"
|
"url": "https://paulmillr.com/funding/"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/rehype-katex": {
|
||||||
|
"version": "7.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/rehype-katex/-/rehype-katex-7.0.1.tgz",
|
||||||
|
"integrity": "sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/hast": "^3.0.0",
|
||||||
|
"@types/katex": "^0.16.0",
|
||||||
|
"hast-util-from-html-isomorphic": "^2.0.0",
|
||||||
|
"hast-util-to-text": "^4.0.0",
|
||||||
|
"katex": "^0.16.0",
|
||||||
|
"unist-util-visit-parents": "^6.0.0",
|
||||||
|
"vfile": "^6.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/unified"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/release-it": {
|
"node_modules/release-it": {
|
||||||
"version": "19.0.5",
|
"version": "19.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/release-it/-/release-it-19.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/release-it/-/release-it-19.0.5.tgz",
|
||||||
@@ -9025,6 +9268,22 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"url": "https://opencollective.com/unified"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/remark-math": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/remark-math/-/remark-math-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/mdast": "^4.0.0",
|
||||||
|
"mdast-util-math": "^3.0.0",
|
||||||
|
"micromark-extension-math": "^3.0.0",
|
||||||
|
"unified": "^11.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/unified"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/remark-parse": {
|
"node_modules/remark-parse": {
|
||||||
"version": "11.0.0",
|
"version": "11.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",
|
||||||
@@ -11098,6 +11357,20 @@
|
|||||||
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
|
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/unist-util-find-after": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/unist": "^3.0.0",
|
||||||
|
"unist-util-is": "^6.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/unified"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/unist-util-is": {
|
"node_modules/unist-util-is": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.0.tgz",
|
||||||
@@ -11124,6 +11397,20 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"url": "https://opencollective.com/unified"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/unist-util-remove-position": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/unist": "^3.0.0",
|
||||||
|
"unist-util-visit": "^5.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/unified"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/unist-util-stringify-position": {
|
"node_modules/unist-util-stringify-position": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz",
|
||||||
@@ -11260,6 +11547,20 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"url": "https://opencollective.com/unified"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vfile-location": {
|
||||||
|
"version": "5.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz",
|
||||||
|
"integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/unist": "^3.0.0",
|
||||||
|
"vfile": "^6.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/unified"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vfile-message": {
|
"node_modules/vfile-message": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.2.tgz",
|
||||||
@@ -11386,6 +11687,16 @@
|
|||||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/web-namespaces": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/webidl-conversions": {
|
"node_modules/webidl-conversions": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||||
|
|||||||
@@ -68,6 +68,7 @@
|
|||||||
"fuse.js": "^7.0.0",
|
"fuse.js": "^7.0.0",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"katex": "^0.16.25",
|
||||||
"lucide-react": "^0.515.0",
|
"lucide-react": "^0.515.0",
|
||||||
"mime-types": "^3.0.1",
|
"mime-types": "^3.0.1",
|
||||||
"multer": "^2.0.1",
|
"multer": "^2.0.1",
|
||||||
@@ -78,7 +79,9 @@
|
|||||||
"react-dropzone": "^14.2.3",
|
"react-dropzone": "^14.2.3",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^6.8.1",
|
"react-router-dom": "^6.8.1",
|
||||||
|
"rehype-katex": "^7.0.1",
|
||||||
"remark-gfm": "^4.0.0",
|
"remark-gfm": "^4.0.0",
|
||||||
|
"remark-math": "^6.0.0",
|
||||||
"sqlite": "^5.1.1",
|
"sqlite": "^5.1.1",
|
||||||
"sqlite3": "^5.1.7",
|
"sqlite3": "^5.1.7",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
|
|||||||
@@ -117,6 +117,15 @@ const userDb = {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getFirstUser: () => {
|
||||||
|
try {
|
||||||
|
const row = db.prepare('SELECT id, username, created_at, last_login FROM users WHERE is_active = 1 LIMIT 1').get();
|
||||||
|
return row;
|
||||||
|
} catch (err) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ import mcpUtilsRoutes from './routes/mcp-utils.js';
|
|||||||
import commandsRoutes from './routes/commands.js';
|
import commandsRoutes from './routes/commands.js';
|
||||||
import settingsRoutes from './routes/settings.js';
|
import settingsRoutes from './routes/settings.js';
|
||||||
import agentRoutes from './routes/agent.js';
|
import agentRoutes from './routes/agent.js';
|
||||||
|
import projectsRoutes from './routes/projects.js';
|
||||||
import { initializeDatabase } from './database/db.js';
|
import { initializeDatabase } from './database/db.js';
|
||||||
import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
|
import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
|
||||||
|
|
||||||
@@ -169,6 +170,19 @@ const wss = new WebSocketServer({
|
|||||||
verifyClient: (info) => {
|
verifyClient: (info) => {
|
||||||
console.log('WebSocket connection attempt to:', info.req.url);
|
console.log('WebSocket connection attempt to:', info.req.url);
|
||||||
|
|
||||||
|
// Platform mode: always allow connection
|
||||||
|
if (process.env.VITE_IS_PLATFORM === 'true') {
|
||||||
|
const user = authenticateWebSocket(null); // Will return first user
|
||||||
|
if (!user) {
|
||||||
|
console.log('[WARN] Platform mode: No user found in database');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
info.req.user = user;
|
||||||
|
console.log('[OK] Platform mode WebSocket authenticated for user:', user.username);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal mode: verify token
|
||||||
// Extract token from query parameters or headers
|
// Extract token from query parameters or headers
|
||||||
const url = new URL(info.req.url, 'http://localhost');
|
const url = new URL(info.req.url, 'http://localhost');
|
||||||
const token = url.searchParams.get('token') ||
|
const token = url.searchParams.get('token') ||
|
||||||
@@ -195,12 +209,23 @@ app.use(cors());
|
|||||||
app.use(express.json({ limit: '50mb' }));
|
app.use(express.json({ limit: '50mb' }));
|
||||||
app.use(express.urlencoded({ limit: '50mb', extended: true }));
|
app.use(express.urlencoded({ limit: '50mb', extended: true }));
|
||||||
|
|
||||||
|
// Public health check endpoint (no authentication required)
|
||||||
|
app.get('/health', (req, res) => {
|
||||||
|
res.json({
|
||||||
|
status: 'ok',
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Optional API key validation (if configured)
|
// Optional API key validation (if configured)
|
||||||
app.use('/api', validateApiKey);
|
app.use('/api', validateApiKey);
|
||||||
|
|
||||||
// Authentication routes (public)
|
// Authentication routes (public)
|
||||||
app.use('/api/auth', authRoutes);
|
app.use('/api/auth', authRoutes);
|
||||||
|
|
||||||
|
// Projects API Routes (protected)
|
||||||
|
app.use('/api/projects', authenticateToken, projectsRoutes);
|
||||||
|
|
||||||
// Git API Routes (protected)
|
// Git API Routes (protected)
|
||||||
app.use('/api/git', authenticateToken, gitRoutes);
|
app.use('/api/git', authenticateToken, gitRoutes);
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,22 @@ const validateApiKey = (req, res, next) => {
|
|||||||
|
|
||||||
// JWT authentication middleware
|
// JWT authentication middleware
|
||||||
const authenticateToken = async (req, res, next) => {
|
const authenticateToken = async (req, res, next) => {
|
||||||
|
// Platform mode: use single database user
|
||||||
|
if (process.env.VITE_IS_PLATFORM === 'true') {
|
||||||
|
try {
|
||||||
|
const user = userDb.getFirstUser();
|
||||||
|
if (!user) {
|
||||||
|
return res.status(500).json({ error: 'Platform mode: No user found in database' });
|
||||||
|
}
|
||||||
|
req.user = user;
|
||||||
|
return next();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Platform mode error:', error);
|
||||||
|
return res.status(500).json({ error: 'Platform mode: Failed to fetch user' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal OSS JWT validation
|
||||||
const authHeader = req.headers['authorization'];
|
const authHeader = req.headers['authorization'];
|
||||||
const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
|
const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
|
||||||
|
|
||||||
@@ -29,13 +45,13 @@ const authenticateToken = async (req, res, next) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const decoded = jwt.verify(token, JWT_SECRET);
|
const decoded = jwt.verify(token, JWT_SECRET);
|
||||||
|
|
||||||
// Verify user still exists and is active
|
// Verify user still exists and is active
|
||||||
const user = userDb.getUserById(decoded.userId);
|
const user = userDb.getUserById(decoded.userId);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return res.status(401).json({ error: 'Invalid token. User not found.' });
|
return res.status(401).json({ error: 'Invalid token. User not found.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
req.user = user;
|
req.user = user;
|
||||||
next();
|
next();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -58,10 +74,25 @@ const generateToken = (user) => {
|
|||||||
|
|
||||||
// WebSocket authentication function
|
// WebSocket authentication function
|
||||||
const authenticateWebSocket = (token) => {
|
const authenticateWebSocket = (token) => {
|
||||||
|
// Platform mode: bypass token validation, return first user
|
||||||
|
if (process.env.VITE_IS_PLATFORM === 'true') {
|
||||||
|
try {
|
||||||
|
const user = userDb.getFirstUser();
|
||||||
|
if (user) {
|
||||||
|
return { userId: user.id, username: user.username };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Platform mode WebSocket error:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal OSS JWT validation
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const decoded = jwt.verify(token, JWT_SECRET);
|
const decoded = jwt.verify(token, JWT_SECRET);
|
||||||
return decoded;
|
return decoded;
|
||||||
|
|||||||
378
server/routes/projects.js
Normal file
378
server/routes/projects.js
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import { promises as fs } from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { spawn } from 'child_process';
|
||||||
|
import os from 'os';
|
||||||
|
import { addProjectManually } from '../projects.js';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Configure allowed workspace root (defaults to user's home directory)
|
||||||
|
const WORKSPACES_ROOT = process.env.WORKSPACES_ROOT || os.homedir();
|
||||||
|
|
||||||
|
// System-critical paths that should never be used as workspace directories
|
||||||
|
const FORBIDDEN_PATHS = [
|
||||||
|
'/',
|
||||||
|
'/etc',
|
||||||
|
'/bin',
|
||||||
|
'/sbin',
|
||||||
|
'/usr',
|
||||||
|
'/dev',
|
||||||
|
'/proc',
|
||||||
|
'/sys',
|
||||||
|
'/var',
|
||||||
|
'/boot',
|
||||||
|
'/root',
|
||||||
|
'/lib',
|
||||||
|
'/lib64',
|
||||||
|
'/opt',
|
||||||
|
'/tmp',
|
||||||
|
'/run'
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates that a path is safe for workspace operations
|
||||||
|
* @param {string} requestedPath - The path to validate
|
||||||
|
* @returns {Promise<{valid: boolean, resolvedPath?: string, error?: string}>}
|
||||||
|
*/
|
||||||
|
async function validateWorkspacePath(requestedPath) {
|
||||||
|
try {
|
||||||
|
// Resolve to absolute path
|
||||||
|
let absolutePath = path.resolve(requestedPath);
|
||||||
|
|
||||||
|
// Check if path is a forbidden system directory
|
||||||
|
const normalizedPath = path.normalize(absolutePath);
|
||||||
|
if (FORBIDDEN_PATHS.includes(normalizedPath) || normalizedPath === '/') {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: 'Cannot use system-critical directories as workspace locations'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Additional check for paths starting with forbidden directories
|
||||||
|
for (const forbidden of FORBIDDEN_PATHS) {
|
||||||
|
if (normalizedPath === forbidden ||
|
||||||
|
normalizedPath.startsWith(forbidden + path.sep)) {
|
||||||
|
// Exception: /var/tmp and similar user-accessible paths might be allowed
|
||||||
|
// but /var itself and most /var subdirectories should be blocked
|
||||||
|
if (forbidden === '/var' &&
|
||||||
|
(normalizedPath.startsWith('/var/tmp') ||
|
||||||
|
normalizedPath.startsWith('/var/folders'))) {
|
||||||
|
continue; // Allow these specific cases
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: `Cannot create workspace in system directory: ${forbidden}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to resolve the real path (following symlinks)
|
||||||
|
let realPath;
|
||||||
|
try {
|
||||||
|
// Check if path exists to resolve real path
|
||||||
|
await fs.access(absolutePath);
|
||||||
|
realPath = await fs.realpath(absolutePath);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'ENOENT') {
|
||||||
|
// Path doesn't exist yet - check parent directory
|
||||||
|
let parentPath = path.dirname(absolutePath);
|
||||||
|
try {
|
||||||
|
const parentRealPath = await fs.realpath(parentPath);
|
||||||
|
|
||||||
|
// Reconstruct the full path with real parent
|
||||||
|
realPath = path.join(parentRealPath, path.basename(absolutePath));
|
||||||
|
} catch (parentError) {
|
||||||
|
if (parentError.code === 'ENOENT') {
|
||||||
|
// Parent doesn't exist either - use the absolute path as-is
|
||||||
|
// We'll validate it's within allowed root
|
||||||
|
realPath = absolutePath;
|
||||||
|
} else {
|
||||||
|
throw parentError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the workspace root to its real path
|
||||||
|
const resolvedWorkspaceRoot = await fs.realpath(WORKSPACES_ROOT);
|
||||||
|
|
||||||
|
// Ensure the resolved path is contained within the allowed workspace root
|
||||||
|
if (!realPath.startsWith(resolvedWorkspaceRoot + path.sep) &&
|
||||||
|
realPath !== resolvedWorkspaceRoot) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: `Workspace path must be within the allowed workspace root: ${WORKSPACES_ROOT}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Additional symlink check for existing paths
|
||||||
|
try {
|
||||||
|
await fs.access(absolutePath);
|
||||||
|
const stats = await fs.lstat(absolutePath);
|
||||||
|
|
||||||
|
if (stats.isSymbolicLink()) {
|
||||||
|
// Verify symlink target is also within allowed root
|
||||||
|
const linkTarget = await fs.readlink(absolutePath);
|
||||||
|
const resolvedTarget = path.resolve(path.dirname(absolutePath), linkTarget);
|
||||||
|
const realTarget = await fs.realpath(resolvedTarget);
|
||||||
|
|
||||||
|
if (!realTarget.startsWith(resolvedWorkspaceRoot + path.sep) &&
|
||||||
|
realTarget !== resolvedWorkspaceRoot) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: 'Symlink target is outside the allowed workspace root'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code !== 'ENOENT') {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
// Path doesn't exist - that's fine for new workspace creation
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: true,
|
||||||
|
resolvedPath: realPath
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: `Path validation failed: ${error.message}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new workspace
|
||||||
|
* POST /api/projects/create-workspace
|
||||||
|
*
|
||||||
|
* Body:
|
||||||
|
* - workspaceType: 'existing' | 'new'
|
||||||
|
* - path: string (workspace path)
|
||||||
|
* - githubUrl?: string (optional, for new workspaces)
|
||||||
|
* - githubTokenId?: number (optional, ID of stored token)
|
||||||
|
* - newGithubToken?: string (optional, one-time token)
|
||||||
|
*/
|
||||||
|
router.post('/create-workspace', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { workspaceType, path: workspacePath, githubUrl, githubTokenId, newGithubToken } = req.body;
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!workspaceType || !workspacePath) {
|
||||||
|
return res.status(400).json({ error: 'workspaceType and path are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['existing', 'new'].includes(workspaceType)) {
|
||||||
|
return res.status(400).json({ error: 'workspaceType must be "existing" or "new"' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate path safety before any operations
|
||||||
|
const validation = await validateWorkspacePath(workspacePath);
|
||||||
|
if (!validation.valid) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Invalid workspace path',
|
||||||
|
details: validation.error
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const absolutePath = validation.resolvedPath;
|
||||||
|
|
||||||
|
// Handle existing workspace
|
||||||
|
if (workspaceType === 'existing') {
|
||||||
|
// Check if the path exists
|
||||||
|
try {
|
||||||
|
await fs.access(absolutePath);
|
||||||
|
const stats = await fs.stat(absolutePath);
|
||||||
|
|
||||||
|
if (!stats.isDirectory()) {
|
||||||
|
return res.status(400).json({ error: 'Path exists but is not a directory' });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'ENOENT') {
|
||||||
|
return res.status(404).json({ error: 'Workspace path does not exist' });
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the existing workspace to the project list
|
||||||
|
const project = await addProjectManually(absolutePath);
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
project,
|
||||||
|
message: 'Existing workspace added successfully'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle new workspace creation
|
||||||
|
if (workspaceType === 'new') {
|
||||||
|
// Check if path already exists
|
||||||
|
try {
|
||||||
|
await fs.access(absolutePath);
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Path already exists. Please choose a different path or use "existing workspace" option.'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code !== 'ENOENT') {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
// Path doesn't exist - good, we can create it
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the directory
|
||||||
|
await fs.mkdir(absolutePath, { recursive: true });
|
||||||
|
|
||||||
|
// If GitHub URL is provided, clone the repository
|
||||||
|
if (githubUrl) {
|
||||||
|
let githubToken = null;
|
||||||
|
|
||||||
|
// Get GitHub token if needed
|
||||||
|
if (githubTokenId) {
|
||||||
|
// Fetch token from database
|
||||||
|
const token = await getGithubTokenById(githubTokenId, req.user.id);
|
||||||
|
if (!token) {
|
||||||
|
// Clean up created directory
|
||||||
|
await fs.rm(absolutePath, { recursive: true, force: true });
|
||||||
|
return res.status(404).json({ error: 'GitHub token not found' });
|
||||||
|
}
|
||||||
|
githubToken = token.github_token;
|
||||||
|
} else if (newGithubToken) {
|
||||||
|
githubToken = newGithubToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clone the repository
|
||||||
|
try {
|
||||||
|
await cloneGitHubRepository(githubUrl, absolutePath, githubToken);
|
||||||
|
} catch (error) {
|
||||||
|
// Clean up created directory on failure
|
||||||
|
try {
|
||||||
|
await fs.rm(absolutePath, { recursive: true, force: true });
|
||||||
|
} catch (cleanupError) {
|
||||||
|
console.error('Failed to clean up directory after clone failure:', cleanupError);
|
||||||
|
// Continue to throw original error
|
||||||
|
}
|
||||||
|
throw new Error(`Failed to clone repository: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the new workspace to the project list
|
||||||
|
const project = await addProjectManually(absolutePath);
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
project,
|
||||||
|
message: githubUrl
|
||||||
|
? 'New workspace created and repository cloned successfully'
|
||||||
|
: 'New workspace created successfully'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating workspace:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: error.message || 'Failed to create workspace',
|
||||||
|
details: process.env.NODE_ENV === 'development' ? error.stack : undefined
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to get GitHub token from database
|
||||||
|
*/
|
||||||
|
async function getGithubTokenById(tokenId, userId) {
|
||||||
|
const { getDatabase } = await import('../database/db.js');
|
||||||
|
const db = await getDatabase();
|
||||||
|
|
||||||
|
const credential = await db.get(
|
||||||
|
'SELECT * FROM user_credentials WHERE id = ? AND user_id = ? AND credential_type = ? AND is_active = 1',
|
||||||
|
[tokenId, userId, 'github_token']
|
||||||
|
);
|
||||||
|
|
||||||
|
// Return in the expected format (github_token field for compatibility)
|
||||||
|
if (credential) {
|
||||||
|
return {
|
||||||
|
...credential,
|
||||||
|
github_token: credential.credential_value
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to clone a GitHub repository
|
||||||
|
*/
|
||||||
|
function cloneGitHubRepository(githubUrl, destinationPath, githubToken = null) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// Parse GitHub URL and inject token if provided
|
||||||
|
let cloneUrl = githubUrl;
|
||||||
|
|
||||||
|
if (githubToken) {
|
||||||
|
try {
|
||||||
|
const url = new URL(githubUrl);
|
||||||
|
// Format: https://TOKEN@github.com/user/repo.git
|
||||||
|
url.username = githubToken;
|
||||||
|
url.password = '';
|
||||||
|
cloneUrl = url.toString();
|
||||||
|
} catch (error) {
|
||||||
|
return reject(new Error('Invalid GitHub URL format'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const gitProcess = spawn('git', ['clone', cloneUrl, destinationPath], {
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
GIT_TERMINAL_PROMPT: '0' // Disable git password prompts
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
|
||||||
|
gitProcess.stdout.on('data', (data) => {
|
||||||
|
stdout += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
gitProcess.stderr.on('data', (data) => {
|
||||||
|
stderr += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
gitProcess.on('close', (code) => {
|
||||||
|
if (code === 0) {
|
||||||
|
resolve({ stdout, stderr });
|
||||||
|
} else {
|
||||||
|
// Parse git error messages to provide helpful feedback
|
||||||
|
let errorMessage = 'Git clone failed';
|
||||||
|
|
||||||
|
if (stderr.includes('Authentication failed') || stderr.includes('could not read Username')) {
|
||||||
|
errorMessage = 'Authentication failed. Please check your GitHub token.';
|
||||||
|
} else if (stderr.includes('Repository not found')) {
|
||||||
|
errorMessage = 'Repository not found. Please check the URL and ensure you have access.';
|
||||||
|
} else if (stderr.includes('already exists')) {
|
||||||
|
errorMessage = 'Directory already exists';
|
||||||
|
} else if (stderr) {
|
||||||
|
errorMessage = stderr;
|
||||||
|
}
|
||||||
|
|
||||||
|
reject(new Error(errorMessage));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
gitProcess.on('error', (error) => {
|
||||||
|
if (error.code === 'ENOENT') {
|
||||||
|
reject(new Error('Git is not installed or not in PATH'));
|
||||||
|
} else {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -33,11 +33,11 @@ function ApiKeysSettings() {
|
|||||||
setApiKeys(apiKeysData.apiKeys || []);
|
setApiKeys(apiKeysData.apiKeys || []);
|
||||||
|
|
||||||
// Fetch GitHub tokens
|
// Fetch GitHub tokens
|
||||||
const githubRes = await fetch('/api/settings/github-tokens', {
|
const githubRes = await fetch('/api/settings/credentials?type=github_token', {
|
||||||
headers: { 'Authorization': `Bearer ${token}` }
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
});
|
});
|
||||||
const githubData = await githubRes.json();
|
const githubData = await githubRes.json();
|
||||||
setGithubTokens(githubData.tokens || []);
|
setGithubTokens(githubData.credentials || []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching settings:', error);
|
console.error('Error fetching settings:', error);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -108,15 +108,16 @@ function ApiKeysSettings() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('auth-token');
|
const token = localStorage.getItem('auth-token');
|
||||||
const res = await fetch('/api/settings/github-tokens', {
|
const res = await fetch('/api/settings/credentials', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${token}`,
|
'Authorization': `Bearer ${token}`,
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
tokenName: newTokenName,
|
credentialName: newTokenName,
|
||||||
githubToken: newGithubToken
|
credentialType: 'github_token',
|
||||||
|
credentialValue: newGithubToken
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -137,7 +138,7 @@ function ApiKeysSettings() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('auth-token');
|
const token = localStorage.getItem('auth-token');
|
||||||
await fetch(`/api/settings/github-tokens/${tokenId}`, {
|
await fetch(`/api/settings/credentials/${tokenId}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { 'Authorization': `Bearer ${token}` }
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
});
|
});
|
||||||
@@ -150,7 +151,7 @@ function ApiKeysSettings() {
|
|||||||
const toggleGithubToken = async (tokenId, isActive) => {
|
const toggleGithubToken = async (tokenId, isActive) => {
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('auth-token');
|
const token = localStorage.getItem('auth-token');
|
||||||
await fetch(`/api/settings/github-tokens/${tokenId}/toggle`, {
|
await fetch(`/api/settings/credentials/${tokenId}/toggle`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${token}`,
|
'Authorization': `Bearer ${token}`,
|
||||||
@@ -349,7 +350,7 @@ function ApiKeysSettings() {
|
|||||||
className="flex items-center justify-between p-3 border rounded-lg"
|
className="flex items-center justify-between p-3 border rounded-lg"
|
||||||
>
|
>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="font-medium">{token.token_name}</div>
|
<div className="font-medium">{token.credential_name}</div>
|
||||||
<div className="text-xs text-muted-foreground mt-1">
|
<div className="text-xs text-muted-foreground mt-1">
|
||||||
Added: {new Date(token.created_at).toLocaleDateString()}
|
Added: {new Date(token.created_at).toLocaleDateString()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,6 +19,8 @@
|
|||||||
import React, { useState, useEffect, useRef, useMemo, useCallback, memo } from 'react';
|
import React, { useState, useEffect, useRef, useMemo, useCallback, memo } from 'react';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
|
import remarkMath from 'remark-math';
|
||||||
|
import rehypeKatex from 'rehype-katex';
|
||||||
import { useDropzone } from 'react-dropzone';
|
import { useDropzone } from 'react-dropzone';
|
||||||
import TodoList from './TodoList';
|
import TodoList from './TodoList';
|
||||||
import ClaudeLogo from './ClaudeLogo.jsx';
|
import ClaudeLogo from './ClaudeLogo.jsx';
|
||||||
@@ -57,12 +59,50 @@ function normalizeInlineCodeFences(text) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Unescape \n, \t, \r while protecting LaTeX formulas ($...$ and $$...$$) from being corrupted
|
||||||
|
function unescapeWithMathProtection(text) {
|
||||||
|
if (!text || typeof text !== 'string') return text;
|
||||||
|
|
||||||
|
const mathBlocks = [];
|
||||||
|
const PLACEHOLDER_PREFIX = '__MATH_BLOCK_';
|
||||||
|
const PLACEHOLDER_SUFFIX = '__';
|
||||||
|
|
||||||
|
// Extract and protect math formulas
|
||||||
|
let processedText = text.replace(/\$\$([\s\S]*?)\$\$|\$([^\$\n]+?)\$/g, (match) => {
|
||||||
|
const index = mathBlocks.length;
|
||||||
|
mathBlocks.push(match);
|
||||||
|
return `${PLACEHOLDER_PREFIX}${index}${PLACEHOLDER_SUFFIX}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Process escape sequences on non-math content
|
||||||
|
processedText = processedText.replace(/\\n/g, '\n')
|
||||||
|
.replace(/\\t/g, '\t')
|
||||||
|
.replace(/\\r/g, '\r');
|
||||||
|
|
||||||
|
// Restore math formulas
|
||||||
|
processedText = processedText.replace(
|
||||||
|
new RegExp(`${PLACEHOLDER_PREFIX}(\\d+)${PLACEHOLDER_SUFFIX}`, 'g'),
|
||||||
|
(match, index) => {
|
||||||
|
return mathBlocks[parseInt(index)];
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return processedText;
|
||||||
|
}
|
||||||
|
|
||||||
// Small wrapper to keep markdown behavior consistent in one place
|
// Small wrapper to keep markdown behavior consistent in one place
|
||||||
const Markdown = ({ children, className }) => {
|
const Markdown = ({ children, className }) => {
|
||||||
const content = normalizeInlineCodeFences(String(children ?? ''));
|
const content = normalizeInlineCodeFences(String(children ?? ''));
|
||||||
|
const remarkPlugins = useMemo(() => [remarkGfm, remarkMath], []);
|
||||||
|
const rehypePlugins = useMemo(() => [rehypeKatex], []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
|
<ReactMarkdown
|
||||||
|
remarkPlugins={remarkPlugins}
|
||||||
|
rehypePlugins={rehypePlugins}
|
||||||
|
components={markdownComponents}
|
||||||
|
>
|
||||||
{content}
|
{content}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
@@ -2523,10 +2563,8 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
content.startsWith('[Request interrupted');
|
content.startsWith('[Request interrupted');
|
||||||
|
|
||||||
if (!shouldSkip) {
|
if (!shouldSkip) {
|
||||||
// Unescape double-escaped newlines and other escape sequences
|
// Unescape with math formula protection
|
||||||
content = content.replace(/\\n/g, '\n')
|
content = unescapeWithMathProtection(content);
|
||||||
.replace(/\\t/g, '\t')
|
|
||||||
.replace(/\\r/g, '\r');
|
|
||||||
converted.push({
|
converted.push({
|
||||||
type: messageType,
|
type: messageType,
|
||||||
content: content,
|
content: content,
|
||||||
@@ -2540,12 +2578,10 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
if (Array.isArray(msg.message.content)) {
|
if (Array.isArray(msg.message.content)) {
|
||||||
for (const part of msg.message.content) {
|
for (const part of msg.message.content) {
|
||||||
if (part.type === 'text') {
|
if (part.type === 'text') {
|
||||||
// Unescape double-escaped newlines and other escape sequences
|
// Unescape with math formula protection
|
||||||
let text = part.text;
|
let text = part.text;
|
||||||
if (typeof text === 'string') {
|
if (typeof text === 'string') {
|
||||||
text = text.replace(/\\n/g, '\n')
|
text = unescapeWithMathProtection(text);
|
||||||
.replace(/\\t/g, '\t')
|
|
||||||
.replace(/\\r/g, '\r');
|
|
||||||
}
|
}
|
||||||
converted.push({
|
converted.push({
|
||||||
type: 'assistant',
|
type: 'assistant',
|
||||||
@@ -2574,11 +2610,9 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (typeof msg.message.content === 'string') {
|
} else if (typeof msg.message.content === 'string') {
|
||||||
// Unescape double-escaped newlines and other escape sequences
|
// Unescape with math formula protection
|
||||||
let text = msg.message.content;
|
let text = msg.message.content;
|
||||||
text = text.replace(/\\n/g, '\n')
|
text = unescapeWithMathProtection(text);
|
||||||
.replace(/\\t/g, '\t')
|
|
||||||
.replace(/\\r/g, '\r');
|
|
||||||
converted.push({
|
converted.push({
|
||||||
type: 'assistant',
|
type: 'assistant',
|
||||||
content: text,
|
content: text,
|
||||||
@@ -4451,6 +4485,51 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
total={tokenBudget?.total || parseInt(import.meta.env.VITE_CONTEXT_WINDOW) || 160000}
|
total={tokenBudget?.total || parseInt(import.meta.env.VITE_CONTEXT_WINDOW) || 160000}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Slash commands button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const isOpening = !showCommandMenu;
|
||||||
|
setShowCommandMenu(isOpening);
|
||||||
|
setCommandQuery('');
|
||||||
|
setSelectedCommandIndex(-1);
|
||||||
|
|
||||||
|
// When opening, ensure all commands are shown
|
||||||
|
if (isOpening) {
|
||||||
|
setFilteredCommands(slashCommands);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (textareaRef.current) {
|
||||||
|
textareaRef.current.focus();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="relative w-8 h-8 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 rounded-full flex items-center justify-center transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:ring-offset-gray-800"
|
||||||
|
title="Show all commands"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{/* Command count badge */}
|
||||||
|
{slashCommands.length > 0 && (
|
||||||
|
<span
|
||||||
|
className="absolute -top-1 -right-1 bg-blue-600 text-white text-xs font-bold rounded-full w-5 h-5 flex items-center justify-center"
|
||||||
|
style={{ fontSize: '10px' }}
|
||||||
|
>
|
||||||
|
{slashCommands.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
{/* Clear input button - positioned to the right of token pie, only shows when there's input */}
|
{/* Clear input button - positioned to the right of token pie, only shows when there's input */}
|
||||||
{input.trim() && (
|
{input.trim() && (
|
||||||
<button
|
<button
|
||||||
@@ -4629,57 +4708,12 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
|
|
||||||
{/* Mic button - HIDDEN */}
|
{/* Mic button - HIDDEN */}
|
||||||
<div className="absolute right-16 sm:right-16 top-1/2 transform -translate-y-1/2" style={{ display: 'none' }}>
|
<div className="absolute right-16 sm:right-16 top-1/2 transform -translate-y-1/2" style={{ display: 'none' }}>
|
||||||
<MicButton
|
<MicButton
|
||||||
onTranscript={handleTranscript}
|
onTranscript={handleTranscript}
|
||||||
className="w-10 h-10 sm:w-10 sm:h-10"
|
className="w-10 h-10 sm:w-10 sm:h-10"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Slash commands button */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
const isOpening = !showCommandMenu;
|
|
||||||
setShowCommandMenu(isOpening);
|
|
||||||
setCommandQuery('');
|
|
||||||
setSelectedCommandIndex(-1);
|
|
||||||
|
|
||||||
// When opening, ensure all commands are shown
|
|
||||||
if (isOpening) {
|
|
||||||
setFilteredCommands(slashCommands);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (textareaRef.current) {
|
|
||||||
textareaRef.current.focus();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="absolute right-14 sm:right-36 top-1/2 transform -translate-y-1/2 w-10 h-10 sm:w-10 sm:h-10 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 rounded-full flex items-center justify-center transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:ring-offset-gray-800 relative z-10"
|
|
||||||
title="Show all commands"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className="w-5 h-5"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
{/* Command count badge */}
|
|
||||||
{slashCommands.length > 0 && (
|
|
||||||
<span
|
|
||||||
className="absolute -top-1 -right-1 bg-blue-600 text-white text-xs font-bold rounded-full w-5 h-5 flex items-center justify-center"
|
|
||||||
style={{ fontSize: '10px' }}
|
|
||||||
>
|
|
||||||
{slashCommands.length}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Send button */}
|
{/* Send button */}
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -4710,13 +4744,15 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Hint text inside input box at bottom */}
|
{/* Hint text inside input box at bottom */}
|
||||||
<div className="absolute bottom-1 left-12 right-14 sm:right-40 text-xs text-gray-400 dark:text-gray-500 pointer-events-none hidden sm:block">
|
<div className={`absolute bottom-1 left-12 right-14 sm:right-40 text-xs text-gray-400 dark:text-gray-500 pointer-events-none hidden sm:block transition-opacity duration-200 ${
|
||||||
|
input.trim() ? 'opacity-0' : 'opacity-100'
|
||||||
|
}`}>
|
||||||
{sendByCtrlEnter
|
{sendByCtrlEnter
|
||||||
? "Ctrl+Enter to send • Shift+Enter for new line • Tab to change modes • / for slash commands"
|
? "Ctrl+Enter to send • Shift+Enter for new line • Tab to change modes • / for slash commands"
|
||||||
: "Enter to send • Shift+Enter for new line • Tab to change modes • / for slash commands"}
|
: "Enter to send • Shift+Enter for new line • Tab to change modes • / for slash commands"}
|
||||||
</div>
|
</div>
|
||||||
<div className={`absolute bottom-1 left-12 right-14 text-xs text-gray-400 dark:text-gray-500 pointer-events-none sm:hidden transition-opacity duration-200 ${
|
<div className={`absolute bottom-1 left-12 right-14 text-xs text-gray-400 dark:text-gray-500 pointer-events-none sm:hidden transition-opacity duration-200 ${
|
||||||
isInputFocused ? 'opacity-100' : 'opacity-0'
|
isInputFocused && !input.trim() ? 'opacity-100' : 'opacity-0'
|
||||||
}`}>
|
}`}>
|
||||||
{sendByCtrlEnter
|
{sendByCtrlEnter
|
||||||
? "Ctrl+Enter to send • Tab for modes • / for commands"
|
? "Ctrl+Enter to send • Tab for modes • / for commands"
|
||||||
|
|||||||
@@ -1,9 +1,63 @@
|
|||||||
import React from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import { X } from 'lucide-react';
|
import { X } from 'lucide-react';
|
||||||
|
|
||||||
function ImageViewer({ file, onClose }) {
|
function ImageViewer({ file, onClose }) {
|
||||||
const imagePath = `/api/projects/${file.projectName}/files/content?path=${encodeURIComponent(file.path)}`;
|
const imagePath = `/api/projects/${file.projectName}/files/content?path=${encodeURIComponent(file.path)}`;
|
||||||
|
const [imageUrl, setImageUrl] = useState(null);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let objectUrl;
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
const loadImage = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setImageUrl(null);
|
||||||
|
|
||||||
|
const token = localStorage.getItem('auth-token');
|
||||||
|
if (!token) {
|
||||||
|
setError('Missing authentication token');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(imagePath, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
},
|
||||||
|
signal: controller.signal
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Request failed with status ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
objectUrl = URL.createObjectURL(blob);
|
||||||
|
setImageUrl(objectUrl);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name === 'AbortError') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error('Error loading image:', err);
|
||||||
|
setError('Unable to load image');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadImage();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
controller.abort();
|
||||||
|
if (objectUrl) {
|
||||||
|
URL.revokeObjectURL(objectUrl);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [imagePath]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
@@ -23,22 +77,24 @@ function ImageViewer({ file, onClose }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-4 flex justify-center items-center bg-gray-50 dark:bg-gray-900 min-h-[400px]">
|
<div className="p-4 flex justify-center items-center bg-gray-50 dark:bg-gray-900 min-h-[400px]">
|
||||||
<img
|
{loading && (
|
||||||
src={imagePath}
|
<div className="text-center text-gray-500 dark:text-gray-400">
|
||||||
alt={file.name}
|
<p>Loading image…</p>
|
||||||
className="max-w-full max-h-[70vh] object-contain rounded-lg shadow-md"
|
</div>
|
||||||
onError={(e) => {
|
)}
|
||||||
e.target.style.display = 'none';
|
{!loading && imageUrl && (
|
||||||
e.target.nextSibling.style.display = 'block';
|
<img
|
||||||
}}
|
src={imageUrl}
|
||||||
/>
|
alt={file.name}
|
||||||
<div
|
className="max-w-full max-h-[70vh] object-contain rounded-lg shadow-md"
|
||||||
className="text-center text-gray-500 dark:text-gray-400"
|
/>
|
||||||
style={{ display: 'none' }}
|
)}
|
||||||
>
|
{!loading && !imageUrl && (
|
||||||
<p>Unable to load image</p>
|
<div className="text-center text-gray-500 dark:text-gray-400">
|
||||||
<p className="text-sm mt-2">{file.path}</p>
|
<p>{error || 'Unable to load image'}</p>
|
||||||
</div>
|
<p className="text-sm mt-2 break-all">{file.path}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-4 border-t bg-gray-50 dark:bg-gray-800">
|
<div className="p-4 border-t bg-gray-50 dark:bg-gray-800">
|
||||||
@@ -51,4 +107,4 @@ function ImageViewer({ file, onClose }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ImageViewer;
|
export default ImageViewer;
|
||||||
|
|||||||
570
src/components/ProjectCreationWizard.jsx
Normal file
570
src/components/ProjectCreationWizard.jsx
Normal file
@@ -0,0 +1,570 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { X, FolderPlus, GitBranch, Key, ChevronRight, ChevronLeft, Check, Loader2, AlertCircle } from 'lucide-react';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import { Input } from './ui/input';
|
||||||
|
import { api } from '../utils/api';
|
||||||
|
|
||||||
|
const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
|
||||||
|
// Wizard state
|
||||||
|
const [step, setStep] = useState(1); // 1: Choose type, 2: Configure, 3: Confirm
|
||||||
|
const [workspaceType, setWorkspaceType] = useState(null); // 'existing' or 'new'
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
const [workspacePath, setWorkspacePath] = useState('');
|
||||||
|
const [githubUrl, setGithubUrl] = useState('');
|
||||||
|
const [selectedGithubToken, setSelectedGithubToken] = useState('');
|
||||||
|
const [tokenMode, setTokenMode] = useState('stored'); // 'stored' | 'new' | 'none'
|
||||||
|
const [newGithubToken, setNewGithubToken] = useState('');
|
||||||
|
|
||||||
|
// UI state
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [availableTokens, setAvailableTokens] = useState([]);
|
||||||
|
const [loadingTokens, setLoadingTokens] = useState(false);
|
||||||
|
const [pathSuggestions, setPathSuggestions] = useState([]);
|
||||||
|
const [showPathDropdown, setShowPathDropdown] = useState(false);
|
||||||
|
|
||||||
|
// Load available GitHub tokens when needed
|
||||||
|
useEffect(() => {
|
||||||
|
if (step === 2 && workspaceType === 'new' && githubUrl) {
|
||||||
|
loadGithubTokens();
|
||||||
|
}
|
||||||
|
}, [step, workspaceType, githubUrl]);
|
||||||
|
|
||||||
|
// Load path suggestions
|
||||||
|
useEffect(() => {
|
||||||
|
if (workspacePath.length > 2) {
|
||||||
|
loadPathSuggestions(workspacePath);
|
||||||
|
} else {
|
||||||
|
setPathSuggestions([]);
|
||||||
|
setShowPathDropdown(false);
|
||||||
|
}
|
||||||
|
}, [workspacePath]);
|
||||||
|
|
||||||
|
const loadGithubTokens = async () => {
|
||||||
|
try {
|
||||||
|
setLoadingTokens(true);
|
||||||
|
const response = await api.get('/settings/credentials?type=github_token');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
const activeTokens = (data.credentials || []).filter(t => t.is_active);
|
||||||
|
setAvailableTokens(activeTokens);
|
||||||
|
|
||||||
|
// Auto-select first token if available
|
||||||
|
if (activeTokens.length > 0 && !selectedGithubToken) {
|
||||||
|
setSelectedGithubToken(activeTokens[0].id.toString());
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading GitHub tokens:', error);
|
||||||
|
} finally {
|
||||||
|
setLoadingTokens(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadPathSuggestions = async (inputPath) => {
|
||||||
|
try {
|
||||||
|
// Extract the directory to browse (parent of input)
|
||||||
|
const lastSlash = inputPath.lastIndexOf('/');
|
||||||
|
const dirPath = lastSlash > 0 ? inputPath.substring(0, lastSlash) : '~';
|
||||||
|
|
||||||
|
const response = await api.browseFilesystem(dirPath);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.suggestions) {
|
||||||
|
// Filter suggestions based on the input
|
||||||
|
const filtered = data.suggestions.filter(s =>
|
||||||
|
s.path.toLowerCase().startsWith(inputPath.toLowerCase())
|
||||||
|
);
|
||||||
|
setPathSuggestions(filtered.slice(0, 5));
|
||||||
|
setShowPathDropdown(filtered.length > 0);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading path suggestions:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
if (step === 1) {
|
||||||
|
if (!workspaceType) {
|
||||||
|
setError('Please select whether you have an existing workspace or want to create a new one');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStep(2);
|
||||||
|
} else if (step === 2) {
|
||||||
|
if (!workspacePath.trim()) {
|
||||||
|
setError('Please provide a workspace path');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No validation for GitHub token - it's optional (only needed for private repos)
|
||||||
|
setStep(3);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
setError(null);
|
||||||
|
setStep(step - 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
setIsCreating(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
workspaceType,
|
||||||
|
path: workspacePath.trim(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add GitHub info if creating new workspace with GitHub URL
|
||||||
|
if (workspaceType === 'new' && githubUrl) {
|
||||||
|
payload.githubUrl = githubUrl.trim();
|
||||||
|
|
||||||
|
if (tokenMode === 'stored' && selectedGithubToken) {
|
||||||
|
payload.githubTokenId = parseInt(selectedGithubToken);
|
||||||
|
} else if (tokenMode === 'new' && newGithubToken) {
|
||||||
|
payload.newGithubToken = newGithubToken.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await api.createWorkspace(payload);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || 'Failed to create workspace');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success!
|
||||||
|
if (onProjectCreated) {
|
||||||
|
onProjectCreated(data.project);
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating workspace:', error);
|
||||||
|
setError(error.message || 'Failed to create workspace');
|
||||||
|
} finally {
|
||||||
|
setIsCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectPathSuggestion = (suggestion) => {
|
||||||
|
setWorkspacePath(suggestion.path);
|
||||||
|
setShowPathDropdown(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed top-0 left-0 right-0 bottom-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-[60] p-0 sm:p-4">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-none sm:rounded-lg shadow-xl w-full h-full sm:h-auto sm:max-w-2xl border-0 sm:border border-gray-200 dark:border-gray-700 overflow-y-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 bg-blue-100 dark:bg-blue-900/50 rounded-lg flex items-center justify-center">
|
||||||
|
<FolderPlus className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
Create New Project
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||||
|
disabled={isCreating}
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Indicator */}
|
||||||
|
<div className="px-6 pt-4 pb-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
{[1, 2, 3].map((s) => (
|
||||||
|
<React.Fragment key={s}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className={`w-8 h-8 rounded-full flex items-center justify-center font-medium text-sm ${
|
||||||
|
s < step
|
||||||
|
? 'bg-green-500 text-white'
|
||||||
|
: s === step
|
||||||
|
? 'bg-blue-500 text-white'
|
||||||
|
: 'bg-gray-200 dark:bg-gray-700 text-gray-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{s < step ? <Check className="w-4 h-4" /> : s}
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 hidden sm:inline">
|
||||||
|
{s === 1 ? 'Type' : s === 2 ? 'Configure' : 'Confirm'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{s < 3 && (
|
||||||
|
<div
|
||||||
|
className={`flex-1 h-1 mx-2 rounded ${
|
||||||
|
s < step ? 'bg-green-500' : 'bg-gray-200 dark:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-6 space-y-6 min-h-[300px]">
|
||||||
|
{/* Error Display */}
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 flex items-start gap-3">
|
||||||
|
<AlertCircle className="w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm text-red-800 dark:text-red-200">{error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 1: Choose workspace type */}
|
||||||
|
{step === 1 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||||
|
Do you already have a workspace, or would you like to create a new one?
|
||||||
|
</h4>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{/* Existing Workspace */}
|
||||||
|
<button
|
||||||
|
onClick={() => setWorkspaceType('existing')}
|
||||||
|
className={`p-4 border-2 rounded-lg text-left transition-all ${
|
||||||
|
workspaceType === 'existing'
|
||||||
|
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||||||
|
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-10 h-10 bg-green-100 dark:bg-green-900/50 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||||
|
<FolderPlus className="w-5 h-5 text-green-600 dark:text-green-400" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h5 className="font-semibold text-gray-900 dark:text-white mb-1">
|
||||||
|
Existing Workspace
|
||||||
|
</h5>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
I already have a workspace on my server and just need to add it to the project list
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* New Workspace */}
|
||||||
|
<button
|
||||||
|
onClick={() => setWorkspaceType('new')}
|
||||||
|
className={`p-4 border-2 rounded-lg text-left transition-all ${
|
||||||
|
workspaceType === 'new'
|
||||||
|
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||||||
|
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-10 h-10 bg-purple-100 dark:bg-purple-900/50 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||||
|
<GitBranch className="w-5 h-5 text-purple-600 dark:text-purple-400" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h5 className="font-semibold text-gray-900 dark:text-white mb-1">
|
||||||
|
New Workspace
|
||||||
|
</h5>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Create a new workspace, optionally clone from a GitHub repository
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 2: Configure workspace */}
|
||||||
|
{step === 2 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Workspace Path */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
{workspaceType === 'existing' ? 'Workspace Path' : 'Where should the workspace be created?'}
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={workspacePath}
|
||||||
|
onChange={(e) => setWorkspacePath(e.target.value)}
|
||||||
|
placeholder={workspaceType === 'existing' ? '/path/to/existing/workspace' : '/path/to/new/workspace'}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
{showPathDropdown && pathSuggestions.length > 0 && (
|
||||||
|
<div className="absolute z-10 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg max-h-60 overflow-y-auto">
|
||||||
|
{pathSuggestions.map((suggestion, index) => (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
onClick={() => selectPathSuggestion(suggestion)}
|
||||||
|
className="w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700 text-sm"
|
||||||
|
>
|
||||||
|
<div className="font-medium text-gray-900 dark:text-white">{suggestion.name}</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">{suggestion.path}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{workspaceType === 'existing'
|
||||||
|
? 'Full path to your existing workspace directory'
|
||||||
|
: 'Full path where the new workspace will be created'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* GitHub URL (only for new workspace) */}
|
||||||
|
{workspaceType === 'new' && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
GitHub URL (Optional)
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={githubUrl}
|
||||||
|
onChange={(e) => setGithubUrl(e.target.value)}
|
||||||
|
placeholder="https://github.com/username/repository"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Leave empty to create an empty workspace, or provide a GitHub URL to clone
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* GitHub Token (only if GitHub URL is provided) */}
|
||||||
|
{githubUrl && (
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-start gap-3 mb-4">
|
||||||
|
<Key className="w-5 h-5 text-gray-600 dark:text-gray-400 flex-shrink-0 mt-0.5" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<h5 className="font-medium text-gray-900 dark:text-white mb-1">
|
||||||
|
GitHub Authentication (Optional)
|
||||||
|
</h5>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Only required for private repositories. Public repos can be cloned without authentication.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loadingTokens ? (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
Loading stored tokens...
|
||||||
|
</div>
|
||||||
|
) : availableTokens.length > 0 ? (
|
||||||
|
<>
|
||||||
|
{/* Token Selection Tabs */}
|
||||||
|
<div className="grid grid-cols-3 gap-2 mb-4">
|
||||||
|
<button
|
||||||
|
onClick={() => setTokenMode('stored')}
|
||||||
|
className={`px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||||
|
tokenMode === 'stored'
|
||||||
|
? 'bg-blue-500 text-white'
|
||||||
|
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Stored Token
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setTokenMode('new')}
|
||||||
|
className={`px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||||
|
tokenMode === 'new'
|
||||||
|
? 'bg-blue-500 text-white'
|
||||||
|
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
New Token
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setTokenMode('none');
|
||||||
|
setSelectedGithubToken('');
|
||||||
|
setNewGithubToken('');
|
||||||
|
}}
|
||||||
|
className={`px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||||
|
tokenMode === 'none'
|
||||||
|
? 'bg-green-500 text-white'
|
||||||
|
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
None (Public)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tokenMode === 'stored' ? (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Select Token
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={selectedGithubToken}
|
||||||
|
onChange={(e) => setSelectedGithubToken(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-sm"
|
||||||
|
>
|
||||||
|
<option value="">-- Select a token --</option>
|
||||||
|
{availableTokens.map((token) => (
|
||||||
|
<option key={token.id} value={token.id}>
|
||||||
|
{token.credential_name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
) : tokenMode === 'new' ? (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
GitHub Token
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={newGithubToken}
|
||||||
|
onChange={(e) => setNewGithubToken(e.target.value)}
|
||||||
|
placeholder="ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
This token will be used only for this operation
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-3 border border-blue-200 dark:border-blue-800">
|
||||||
|
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||||
|
💡 <strong>Public repositories</strong> don't require authentication. You can skip providing a token if cloning a public repo.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
GitHub Token (Optional for Public Repos)
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={newGithubToken}
|
||||||
|
onChange={(e) => setNewGithubToken(e.target.value)}
|
||||||
|
placeholder="ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx (leave empty for public repos)"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
No stored tokens available. You can add tokens in Settings → API Keys for easier reuse.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 3: Confirm */}
|
||||||
|
{step === 3 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||||
|
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-3">
|
||||||
|
Review Your Configuration
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-600 dark:text-gray-400">Workspace Type:</span>
|
||||||
|
<span className="font-medium text-gray-900 dark:text-white">
|
||||||
|
{workspaceType === 'existing' ? 'Existing Workspace' : 'New Workspace'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-600 dark:text-gray-400">Path:</span>
|
||||||
|
<span className="font-mono text-xs text-gray-900 dark:text-white break-all">
|
||||||
|
{workspacePath}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{workspaceType === 'new' && githubUrl && (
|
||||||
|
<>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-600 dark:text-gray-400">Clone From:</span>
|
||||||
|
<span className="font-mono text-xs text-gray-900 dark:text-white break-all">
|
||||||
|
{githubUrl}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-600 dark:text-gray-400">Authentication:</span>
|
||||||
|
<span className="text-xs text-gray-900 dark:text-white">
|
||||||
|
{tokenMode === 'stored' && selectedGithubToken
|
||||||
|
? `Using stored token: ${availableTokens.find(t => t.id.toString() === selectedGithubToken)?.credential_name || 'Unknown'}`
|
||||||
|
: tokenMode === 'new' && newGithubToken
|
||||||
|
? 'Using provided token'
|
||||||
|
: 'No authentication'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 border border-blue-200 dark:border-blue-800">
|
||||||
|
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||||
|
{workspaceType === 'existing'
|
||||||
|
? 'The workspace will be added to your project list and will be available for Claude/Cursor sessions.'
|
||||||
|
: githubUrl
|
||||||
|
? 'A new workspace will be created and the repository will be cloned from GitHub.'
|
||||||
|
: 'An empty workspace directory will be created at the specified path.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex items-center justify-between p-6 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={step === 1 ? onClose : handleBack}
|
||||||
|
disabled={isCreating}
|
||||||
|
>
|
||||||
|
{step === 1 ? (
|
||||||
|
'Cancel'
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ChevronLeft className="w-4 h-4 mr-1" />
|
||||||
|
Back
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={step === 3 ? handleCreate : handleNext}
|
||||||
|
disabled={isCreating || (step === 1 && !workspaceType)}
|
||||||
|
>
|
||||||
|
{isCreating ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Creating...
|
||||||
|
</>
|
||||||
|
) : step === 3 ? (
|
||||||
|
<>
|
||||||
|
<Check className="w-4 h-4 mr-1" />
|
||||||
|
Create Project
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Next
|
||||||
|
<ChevronRight className="w-4 h-4 ml-1" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProjectCreationWizard;
|
||||||
@@ -26,6 +26,12 @@ const LoadingScreen = () => (
|
|||||||
const ProtectedRoute = ({ children }) => {
|
const ProtectedRoute = ({ children }) => {
|
||||||
const { user, isLoading, needsSetup } = useAuth();
|
const { user, isLoading, needsSetup } = useAuth();
|
||||||
|
|
||||||
|
// Platform mode: skip all auth UI and directly render children
|
||||||
|
if (import.meta.env.VITE_IS_PLATFORM === 'true') {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal OSS mode: standard auth flow
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <LoadingScreen />;
|
return <LoadingScreen />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import ReactDOM from 'react-dom';
|
||||||
import { ScrollArea } from './ui/scroll-area';
|
import { ScrollArea } from './ui/scroll-area';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import { Badge } from './ui/badge';
|
import { Badge } from './ui/badge';
|
||||||
@@ -9,6 +10,7 @@ import { cn } from '../lib/utils';
|
|||||||
import ClaudeLogo from './ClaudeLogo';
|
import ClaudeLogo from './ClaudeLogo';
|
||||||
import CursorLogo from './CursorLogo.jsx';
|
import CursorLogo from './CursorLogo.jsx';
|
||||||
import TaskIndicator from './TaskIndicator';
|
import TaskIndicator from './TaskIndicator';
|
||||||
|
import ProjectCreationWizard from './ProjectCreationWizard';
|
||||||
import { api } from '../utils/api';
|
import { api } from '../utils/api';
|
||||||
import { useTaskMaster } from '../contexts/TaskMasterContext';
|
import { useTaskMaster } from '../contexts/TaskMasterContext';
|
||||||
import { useTasksSettings } from '../contexts/TasksSettingsContext';
|
import { useTasksSettings } from '../contexts/TasksSettingsContext';
|
||||||
@@ -64,8 +66,6 @@ function Sidebar({
|
|||||||
const [editingProject, setEditingProject] = useState(null);
|
const [editingProject, setEditingProject] = useState(null);
|
||||||
const [showNewProject, setShowNewProject] = useState(false);
|
const [showNewProject, setShowNewProject] = useState(false);
|
||||||
const [editingName, setEditingName] = useState('');
|
const [editingName, setEditingName] = useState('');
|
||||||
const [newProjectPath, setNewProjectPath] = useState('');
|
|
||||||
const [creatingProject, setCreatingProject] = useState(false);
|
|
||||||
const [loadingSessions, setLoadingSessions] = useState({});
|
const [loadingSessions, setLoadingSessions] = useState({});
|
||||||
const [additionalSessions, setAdditionalSessions] = useState({});
|
const [additionalSessions, setAdditionalSessions] = useState({});
|
||||||
const [initialSessionsLoaded, setInitialSessionsLoaded] = useState(new Set());
|
const [initialSessionsLoaded, setInitialSessionsLoaded] = useState(new Set());
|
||||||
@@ -76,10 +76,6 @@ function Sidebar({
|
|||||||
const [editingSessionName, setEditingSessionName] = useState('');
|
const [editingSessionName, setEditingSessionName] = useState('');
|
||||||
const [generatingSummary, setGeneratingSummary] = useState({});
|
const [generatingSummary, setGeneratingSummary] = useState({});
|
||||||
const [searchFilter, setSearchFilter] = useState('');
|
const [searchFilter, setSearchFilter] = useState('');
|
||||||
const [showPathDropdown, setShowPathDropdown] = useState(false);
|
|
||||||
const [pathList, setPathList] = useState([]);
|
|
||||||
const [filteredPaths, setFilteredPaths] = useState([]);
|
|
||||||
const [selectedPathIndex, setSelectedPathIndex] = useState(-1);
|
|
||||||
|
|
||||||
// TaskMaster context
|
// TaskMaster context
|
||||||
const { setCurrentProject, mcpServerStatus } = useTaskMaster();
|
const { setCurrentProject, mcpServerStatus } = useTaskMaster();
|
||||||
@@ -184,123 +180,6 @@ function Sidebar({
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Load available paths for suggestions
|
|
||||||
useEffect(() => {
|
|
||||||
const loadPaths = async () => {
|
|
||||||
try {
|
|
||||||
// Get recent paths from localStorage
|
|
||||||
const recentPaths = JSON.parse(localStorage.getItem('recentProjectPaths') || '[]');
|
|
||||||
|
|
||||||
// Load common/home directory paths
|
|
||||||
const response = await api.browseFilesystem();
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.suggestions) {
|
|
||||||
const homePaths = data.suggestions.map(s => ({ name: s.name, path: s.path }));
|
|
||||||
const allPaths = [...recentPaths.map(path => ({ name: path.split('/').pop(), path })), ...homePaths];
|
|
||||||
setPathList(allPaths);
|
|
||||||
} else {
|
|
||||||
setPathList(recentPaths.map(path => ({ name: path.split('/').pop(), path })));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading paths:', error);
|
|
||||||
const recentPaths = JSON.parse(localStorage.getItem('recentProjectPaths') || '[]');
|
|
||||||
setPathList(recentPaths.map(path => ({ name: path.split('/').pop(), path })));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
loadPaths();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Handle input change and path filtering with dynamic browsing (ChatInterface pattern + dynamic browsing)
|
|
||||||
useEffect(() => {
|
|
||||||
const inputValue = newProjectPath.trim();
|
|
||||||
|
|
||||||
if (inputValue.length === 0) {
|
|
||||||
setShowPathDropdown(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show dropdown when user starts typing
|
|
||||||
setShowPathDropdown(true);
|
|
||||||
|
|
||||||
const updateSuggestions = async () => {
|
|
||||||
// First show filtered existing suggestions from pathList
|
|
||||||
const staticFiltered = pathList.filter(pathItem =>
|
|
||||||
pathItem.name.toLowerCase().includes(inputValue.toLowerCase()) ||
|
|
||||||
pathItem.path.toLowerCase().includes(inputValue.toLowerCase())
|
|
||||||
);
|
|
||||||
|
|
||||||
// Check if input looks like a directory path for dynamic browsing
|
|
||||||
const isDirPath = inputValue.includes('/') && inputValue.length > 1;
|
|
||||||
|
|
||||||
if (isDirPath) {
|
|
||||||
try {
|
|
||||||
let dirToSearch;
|
|
||||||
|
|
||||||
// Determine which directory to search
|
|
||||||
if (inputValue.endsWith('/')) {
|
|
||||||
// User typed "/home/simos/" - search inside /home/simos
|
|
||||||
dirToSearch = inputValue.slice(0, -1);
|
|
||||||
} else {
|
|
||||||
// User typed "/home/simos/con" - search inside /home/simos for items starting with "con"
|
|
||||||
const lastSlashIndex = inputValue.lastIndexOf('/');
|
|
||||||
dirToSearch = inputValue.substring(0, lastSlashIndex);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only search if we have a valid directory path (not root only)
|
|
||||||
if (dirToSearch && dirToSearch !== '') {
|
|
||||||
const response = await api.browseFilesystem(dirToSearch);
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.suggestions) {
|
|
||||||
// Filter directories that match the current input
|
|
||||||
const partialName = inputValue.substring(inputValue.lastIndexOf('/') + 1);
|
|
||||||
const dynamicPaths = data.suggestions
|
|
||||||
.filter(suggestion => {
|
|
||||||
const dirName = suggestion.name;
|
|
||||||
return partialName ? dirName.toLowerCase().startsWith(partialName.toLowerCase()) : true;
|
|
||||||
})
|
|
||||||
.map(s => ({ name: s.name, path: s.path }))
|
|
||||||
.slice(0, 8);
|
|
||||||
|
|
||||||
// Combine static and dynamic suggestions, prioritize dynamic
|
|
||||||
const combined = [...dynamicPaths, ...staticFiltered].slice(0, 8);
|
|
||||||
setFilteredPaths(combined);
|
|
||||||
setSelectedPathIndex(-1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.debug('Dynamic browsing failed:', error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to just static filtered suggestions
|
|
||||||
setFilteredPaths(staticFiltered.slice(0, 8));
|
|
||||||
setSelectedPathIndex(-1);
|
|
||||||
};
|
|
||||||
|
|
||||||
updateSuggestions();
|
|
||||||
}, [newProjectPath, pathList]);
|
|
||||||
|
|
||||||
// Select path from dropdown (ChatInterface pattern)
|
|
||||||
const selectPath = (pathItem) => {
|
|
||||||
setNewProjectPath(pathItem.path);
|
|
||||||
setShowPathDropdown(false);
|
|
||||||
setSelectedPathIndex(-1);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Save path to recent paths
|
|
||||||
const saveToRecentPaths = (path) => {
|
|
||||||
try {
|
|
||||||
const recentPaths = JSON.parse(localStorage.getItem('recentProjectPaths') || '[]');
|
|
||||||
const updatedPaths = [path, ...recentPaths.filter(p => p !== path)].slice(0, 10);
|
|
||||||
localStorage.setItem('recentProjectPaths', JSON.stringify(updatedPaths));
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error saving recent paths:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleProject = (projectName) => {
|
const toggleProject = (projectName) => {
|
||||||
const newExpanded = new Set();
|
const newExpanded = new Set();
|
||||||
@@ -484,7 +363,6 @@ function Sidebar({
|
|||||||
|
|
||||||
setShowNewProject(false);
|
setShowNewProject(false);
|
||||||
setNewProjectPath('');
|
setNewProjectPath('');
|
||||||
setShowSuggestions(false);
|
|
||||||
|
|
||||||
// Refresh projects to show the new one
|
// Refresh projects to show the new one
|
||||||
if (window.refreshProjects) {
|
if (window.refreshProjects) {
|
||||||
@@ -507,7 +385,6 @@ function Sidebar({
|
|||||||
const cancelNewProject = () => {
|
const cancelNewProject = () => {
|
||||||
setShowNewProject(false);
|
setShowNewProject(false);
|
||||||
setNewProjectPath('');
|
setNewProjectPath('');
|
||||||
setShowSuggestions(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadMoreSessions = async (project) => {
|
const loadMoreSessions = async (project) => {
|
||||||
@@ -571,10 +448,27 @@ function Sidebar({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<>
|
||||||
className="h-full flex flex-col bg-card md:select-none"
|
{/* Project Creation Wizard Modal - Rendered via Portal at document root for full-screen on mobile */}
|
||||||
style={isPWA && isMobile ? { paddingTop: '44px' } : {}}
|
{showNewProject && ReactDOM.createPortal(
|
||||||
>
|
<ProjectCreationWizard
|
||||||
|
onClose={() => setShowNewProject(false)}
|
||||||
|
onProjectCreated={(project) => {
|
||||||
|
// Refresh projects list after creation
|
||||||
|
if (window.refreshProjects) {
|
||||||
|
window.refreshProjects();
|
||||||
|
} else {
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="h-full flex flex-col bg-card md:select-none"
|
||||||
|
style={isPWA && isMobile ? { paddingTop: '44px' } : {}}
|
||||||
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="md:p-4 md:border-b md:border-border">
|
<div className="md:p-4 md:border-b md:border-border">
|
||||||
{/* Desktop Header */}
|
{/* Desktop Header */}
|
||||||
@@ -648,263 +542,7 @@ function Sidebar({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
{/* New Project Form */}
|
|
||||||
{showNewProject && (
|
|
||||||
<div className="md:p-3 md:border-b md:border-border md:bg-muted/30">
|
|
||||||
{/* Desktop Form */}
|
|
||||||
<div className="hidden md:block space-y-2">
|
|
||||||
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
|
|
||||||
<FolderPlus className="w-4 h-4" />
|
|
||||||
Create New Project
|
|
||||||
</div>
|
|
||||||
<div className="relative">
|
|
||||||
<Input
|
|
||||||
value={newProjectPath}
|
|
||||||
onChange={(e) => setNewProjectPath(e.target.value)}
|
|
||||||
placeholder="/path/to/project or relative/path"
|
|
||||||
className="text-sm focus:ring-2 focus:ring-primary/20"
|
|
||||||
autoFocus
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
// Handle path dropdown navigation (ChatInterface pattern)
|
|
||||||
if (showPathDropdown && filteredPaths.length > 0) {
|
|
||||||
if (e.key === 'ArrowDown') {
|
|
||||||
e.preventDefault();
|
|
||||||
setSelectedPathIndex(prev =>
|
|
||||||
prev < filteredPaths.length - 1 ? prev + 1 : 0
|
|
||||||
);
|
|
||||||
} else if (e.key === 'ArrowUp') {
|
|
||||||
e.preventDefault();
|
|
||||||
setSelectedPathIndex(prev =>
|
|
||||||
prev > 0 ? prev - 1 : filteredPaths.length - 1
|
|
||||||
);
|
|
||||||
} else if (e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
if (selectedPathIndex >= 0) {
|
|
||||||
selectPath(filteredPaths[selectedPathIndex]);
|
|
||||||
} else if (filteredPaths.length > 0) {
|
|
||||||
selectPath(filteredPaths[0]);
|
|
||||||
} else {
|
|
||||||
createNewProject();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
} else if (e.key === 'Escape') {
|
|
||||||
e.preventDefault();
|
|
||||||
setShowPathDropdown(false);
|
|
||||||
return;
|
|
||||||
} else if (e.key === 'Tab') {
|
|
||||||
e.preventDefault();
|
|
||||||
if (selectedPathIndex >= 0) {
|
|
||||||
selectPath(filteredPaths[selectedPathIndex]);
|
|
||||||
} else if (filteredPaths.length > 0) {
|
|
||||||
selectPath(filteredPaths[0]);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Regular input handling
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
createNewProject();
|
|
||||||
}
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
cancelNewProject();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Path dropdown (ChatInterface pattern) */}
|
|
||||||
{showPathDropdown && filteredPaths.length > 0 && (
|
|
||||||
<div className="absolute top-full left-0 right-0 mt-1 bg-popover border border-border rounded-md shadow-lg max-h-48 overflow-y-auto z-50">
|
|
||||||
{filteredPaths.map((pathItem, index) => (
|
|
||||||
<div
|
|
||||||
key={pathItem.path}
|
|
||||||
className={`px-3 py-2 cursor-pointer border-b border-border last:border-b-0 ${
|
|
||||||
index === selectedPathIndex
|
|
||||||
? 'bg-accent text-accent-foreground'
|
|
||||||
: 'hover:bg-accent/50'
|
|
||||||
}`}
|
|
||||||
onMouseDown={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
}}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
selectPath(pathItem);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Folder className="w-3 h-3 text-muted-foreground flex-shrink-0" />
|
|
||||||
<div>
|
|
||||||
<div className="font-medium text-sm">{pathItem.name}</div>
|
|
||||||
<div className="text-xs text-muted-foreground font-mono">
|
|
||||||
{pathItem.path}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={createNewProject}
|
|
||||||
disabled={!newProjectPath.trim() || creatingProject}
|
|
||||||
className="flex-1 h-8 text-xs hover:bg-primary/90 transition-colors"
|
|
||||||
>
|
|
||||||
{creatingProject ? 'Creating...' : 'Create Project'}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={cancelNewProject}
|
|
||||||
disabled={creatingProject}
|
|
||||||
className="h-8 text-xs hover:bg-accent transition-colors"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mobile Form - Simple Overlay */}
|
|
||||||
<div className="md:hidden fixed inset-0 z-[70] bg-black/50 backdrop-blur-sm flex items-end justify-center px-4 pb-24">
|
|
||||||
<div className="w-full max-w-sm bg-card rounded-t-lg border-t border-border p-4 space-y-4 animate-in slide-in-from-bottom duration-300">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-6 h-6 bg-primary/10 rounded-md flex items-center justify-center">
|
|
||||||
<FolderPlus className="w-3 h-3 text-primary" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-base font-semibold text-foreground">New Project</h2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={cancelNewProject}
|
|
||||||
disabled={creatingProject}
|
|
||||||
className="w-6 h-6 rounded-md bg-muted flex items-center justify-center active:scale-95 transition-transform"
|
|
||||||
>
|
|
||||||
<X className="w-3 h-3" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="relative">
|
|
||||||
<Input
|
|
||||||
value={newProjectPath}
|
|
||||||
onChange={(e) => setNewProjectPath(e.target.value)}
|
|
||||||
placeholder="/path/to/project or relative/path"
|
|
||||||
className="text-sm h-10 rounded-md focus:border-primary transition-colors"
|
|
||||||
autoFocus
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
// Handle path dropdown navigation (same as desktop)
|
|
||||||
if (showPathDropdown && filteredPaths.length > 0) {
|
|
||||||
if (e.key === 'ArrowDown') {
|
|
||||||
e.preventDefault();
|
|
||||||
setSelectedPathIndex(prev =>
|
|
||||||
prev < filteredPaths.length - 1 ? prev + 1 : 0
|
|
||||||
);
|
|
||||||
} else if (e.key === 'ArrowUp') {
|
|
||||||
e.preventDefault();
|
|
||||||
setSelectedPathIndex(prev =>
|
|
||||||
prev > 0 ? prev - 1 : filteredPaths.length - 1
|
|
||||||
);
|
|
||||||
} else if (e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
if (selectedPathIndex >= 0) {
|
|
||||||
selectPath(filteredPaths[selectedPathIndex]);
|
|
||||||
} else if (filteredPaths.length > 0) {
|
|
||||||
selectPath(filteredPaths[0]);
|
|
||||||
} else {
|
|
||||||
createNewProject();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
} else if (e.key === 'Escape') {
|
|
||||||
e.preventDefault();
|
|
||||||
setShowPathDropdown(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Regular input handling
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
createNewProject();
|
|
||||||
}
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
cancelNewProject();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
fontSize: '16px', // Prevents zoom on iOS
|
|
||||||
WebkitAppearance: 'none'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Mobile Path dropdown */}
|
|
||||||
{showPathDropdown && filteredPaths.length > 0 && (
|
|
||||||
<div className="absolute bottom-full left-0 right-0 mb-2 bg-popover border border-border rounded-md shadow-lg max-h-40 overflow-y-auto">
|
|
||||||
{filteredPaths.map((pathItem, index) => (
|
|
||||||
<div
|
|
||||||
key={pathItem.path}
|
|
||||||
className={`px-3 py-2.5 cursor-pointer border-b border-border last:border-b-0 active:scale-95 transition-all ${
|
|
||||||
index === selectedPathIndex
|
|
||||||
? 'bg-accent text-accent-foreground'
|
|
||||||
: 'hover:bg-accent/50'
|
|
||||||
}`}
|
|
||||||
onMouseDown={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
}}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
selectPath(pathItem);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Folder className="w-3 h-3 text-muted-foreground flex-shrink-0" />
|
|
||||||
<div>
|
|
||||||
<div className="font-medium text-sm">{pathItem.name}</div>
|
|
||||||
<div className="text-xs text-muted-foreground font-mono">
|
|
||||||
{pathItem.path}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
onClick={cancelNewProject}
|
|
||||||
disabled={creatingProject}
|
|
||||||
variant="outline"
|
|
||||||
className="flex-1 h-9 text-sm rounded-md active:scale-95 transition-transform"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={createNewProject}
|
|
||||||
disabled={!newProjectPath.trim() || creatingProject}
|
|
||||||
className="flex-1 h-9 text-sm rounded-md bg-primary hover:bg-primary/90 active:scale-95 transition-all"
|
|
||||||
>
|
|
||||||
{creatingProject ? 'Creating...' : 'Create'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Safe area for mobile */}
|
|
||||||
<div className="h-4" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Search Filter and Actions */}
|
{/* Search Filter and Actions */}
|
||||||
{projects.length > 0 && !isLoading && (
|
{projects.length > 0 && !isLoading && (
|
||||||
<div className="px-3 md:px-4 py-2 border-b border-border space-y-2">
|
<div className="px-3 md:px-4 py-2 border-b border-border space-y-2">
|
||||||
@@ -1693,6 +1331,7 @@ function Sidebar({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,15 @@ export const AuthProvider = ({ children }) => {
|
|||||||
|
|
||||||
// Check authentication status on mount
|
// Check authentication status on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Platform mode: skip all auth checks, set dummy user
|
||||||
|
if (import.meta.env.VITE_IS_PLATFORM === 'true') {
|
||||||
|
setUser({ username: 'platform-user' });
|
||||||
|
setNeedsSetup(false);
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal OSS mode: check auth status
|
||||||
checkAuthStatus();
|
checkAuthStatus();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -36,22 +45,22 @@ export const AuthProvider = ({ children }) => {
|
|||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
// Check if system needs setup
|
// Check if system needs setup
|
||||||
const statusResponse = await api.auth.status();
|
const statusResponse = await api.auth.status();
|
||||||
const statusData = await statusResponse.json();
|
const statusData = await statusResponse.json();
|
||||||
|
|
||||||
if (statusData.needsSetup) {
|
if (statusData.needsSetup) {
|
||||||
setNeedsSetup(true);
|
setNeedsSetup(true);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we have a token, verify it
|
// If we have a token, verify it
|
||||||
if (token) {
|
if (token) {
|
||||||
try {
|
try {
|
||||||
const userResponse = await api.auth.user();
|
const userResponse = await api.auth.user();
|
||||||
|
|
||||||
if (userResponse.ok) {
|
if (userResponse.ok) {
|
||||||
const userData = await userResponse.json();
|
const userData = await userResponse.json();
|
||||||
setUser(userData.user);
|
setUser(userData.user);
|
||||||
@@ -70,7 +79,7 @@ export const AuthProvider = ({ children }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Auth status check failed:', error);
|
console.error('[AuthContext] Auth status check failed:', error);
|
||||||
setError('Failed to check authentication status');
|
setError('Failed to check authentication status');
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
|||||||
@@ -79,7 +79,7 @@
|
|||||||
--destructive: 0 62.8% 30.6%;
|
--destructive: 0 62.8% 30.6%;
|
||||||
--destructive-foreground: 210 40% 98%;
|
--destructive-foreground: 210 40% 98%;
|
||||||
--border: 217.2 32.6% 17.5%;
|
--border: 217.2 32.6% 17.5%;
|
||||||
--input: 217.2 32.6% 17.5%;
|
--input: 220 13% 46%;
|
||||||
--ring: 217.2 91.2% 59.8%;
|
--ring: 217.2 91.2% 59.8%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React from 'react'
|
|||||||
import ReactDOM from 'react-dom/client'
|
import ReactDOM from 'react-dom/client'
|
||||||
import App from './App.jsx'
|
import App from './App.jsx'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
import 'katex/dist/katex.min.css'
|
||||||
|
|
||||||
// Clean up stale service workers on app load to prevent caching issues after builds
|
// Clean up stale service workers on app load to prevent caching issues after builds
|
||||||
if ('serviceWorker' in navigator) {
|
if ('serviceWorker' in navigator) {
|
||||||
@@ -18,4 +19,4 @@ ReactDOM.createRoot(document.getElementById('root')).render(
|
|||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<App />
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -71,6 +71,11 @@ export const api = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ path }),
|
body: JSON.stringify({ path }),
|
||||||
}),
|
}),
|
||||||
|
createWorkspace: (workspaceData) =>
|
||||||
|
authenticatedFetch('/api/projects/create-workspace', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(workspaceData),
|
||||||
|
}),
|
||||||
readFile: (projectName, filePath) =>
|
readFile: (projectName, filePath) =>
|
||||||
authenticatedFetch(`/api/projects/${projectName}/file?filePath=${encodeURIComponent(filePath)}`),
|
authenticatedFetch(`/api/projects/${projectName}/file?filePath=${encodeURIComponent(filePath)}`),
|
||||||
saveFile: (projectName, filePath, content) =>
|
saveFile: (projectName, filePath, content) =>
|
||||||
|
|||||||
Reference in New Issue
Block a user