diff --git a/.gitignore b/.gitignore
index c6d0044..e68838c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -52,3 +52,6 @@ node_modules/
/playwright-report/
/blob-report/
/playwright/.cache/
+
+# clerk configuration (can include secrets)
+/.clerk/
diff --git a/package.json b/package.json
index b8ddf7f..28c6aa4 100644
--- a/package.json
+++ b/package.json
@@ -9,7 +9,7 @@
"main": "dist/main/main.js",
"scripts": {
"dev": "next dev",
- "build": "pnpm build:workers && next build",
+ "build": "pnpm workers:build && next build",
"start": "next start",
"postinstall": "#prisma generate",
"typecheck": "tsc --noEmit",
@@ -33,6 +33,10 @@
"test:e2e:ui": "playwright test --ui"
},
"dependencies": {
+ "@ai-sdk/openai": "^1.3.20",
+ "@ai-sdk/react": "^1.2.9",
+ "@ai-sdk/xai": "^1.2.15",
+ "@clerk/nextjs": "^6.18.0",
"@monaco-editor/react": "^4.7.0",
"@prisma/client": "^6.5.0",
"@radix-ui/react-avatar": "^1.1.7",
@@ -73,6 +77,7 @@
"lodash": "^4.17.21",
"lucide-react": "^0.503.0",
"marked": "^15.0.11",
+ "monaco-editor": "^0.52.2",
"monaco-themes": "^0.4.4",
"motion": "^12.7.5",
"next": "^15.2.3",
@@ -86,10 +91,11 @@
"socket.io-client": "^4.8.1",
"sonner": "^2.0.3",
"tailwind-merge": "^3.2.0",
+ "tiptap-markdown": "^0.8.10",
"tw-animate-css": "^1.2.8",
"utf-8-validate": "^6.0.5",
"ws": "^8.18.1",
- "zod": "^3.24.2",
+ "zod": "^3.24.3",
"zustand": "^5.0.3"
},
"devDependencies": {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a66aba6..f3b8506 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -8,6 +8,18 @@ importers:
.:
dependencies:
+ '@ai-sdk/openai':
+ specifier: ^1.3.20
+ version: 1.3.20(zod@3.24.3)
+ '@ai-sdk/react':
+ specifier: ^1.2.9
+ version: 1.2.9(react@19.1.0)(zod@3.24.3)
+ '@ai-sdk/xai':
+ specifier: ^1.2.15
+ version: 1.2.15(zod@3.24.3)
+ '@clerk/nextjs':
+ specifier: ^6.18.0
+ version: 6.18.0(next@15.3.1(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@monaco-editor/react':
specifier: ^4.7.0
version: 4.7.0(monaco-editor@0.52.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@@ -128,6 +140,9 @@ importers:
marked:
specifier: ^15.0.11
version: 15.0.11
+ monaco-editor:
+ specifier: ^0.52.2
+ version: 0.52.2
monaco-themes:
specifier: ^0.4.4
version: 0.4.4
@@ -167,6 +182,9 @@ importers:
tailwind-merge:
specifier: ^3.2.0
version: 3.2.0
+ tiptap-markdown:
+ specifier: ^0.8.10
+ version: 0.8.10(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))
tw-animate-css:
specifier: ^1.2.8
version: 1.2.8
@@ -177,7 +195,7 @@ importers:
specifier: ^8.18.1
version: 8.18.1(bufferutil@4.0.9)(utf-8-validate@6.0.5)
zod:
- specifier: ^3.24.2
+ specifier: ^3.24.3
version: 3.24.3
zustand:
specifier: ^5.0.3
@@ -297,6 +315,18 @@ packages:
'@adobe/css-tools@4.4.2':
resolution: {integrity: sha512-baYZExFpsdkBNuvGKTKWCwKH57HRZLVtycZS05WTQNVOiXVSeAki3nU35zlRbToeMW8aHlJfyS+1C4BOv27q0A==}
+ '@ai-sdk/openai-compatible@0.2.13':
+ resolution: {integrity: sha512-tB+lL8Z3j0qDod/mvxwjrPhbLUHp/aQW+NvMoJaqeTtP+Vmv5qR800pncGczxn5WN0pllQm+7aIRDnm69XeSbg==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ zod: ^3.0.0
+
+ '@ai-sdk/openai@1.3.20':
+ resolution: {integrity: sha512-/DflUy7ROG9k6n6YTXMBFPbujBKnbGY58f3CwvicLvDar9nDAloVnUWd3LUoOxpSVnX8vtQ7ngxF52SLWO6RwQ==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ zod: ^3.0.0
+
'@ai-sdk/provider-utils@2.2.7':
resolution: {integrity: sha512-kM0xS3GWg3aMChh9zfeM+80vEZfXzR3JEUBdycZLtbRZ2TRT8xOj3WodGHPb06sUK5yD7pAXC/P7ctsi2fvUGQ==}
engines: {node: '>=18'}
@@ -323,6 +353,12 @@ packages:
peerDependencies:
zod: ^3.23.8
+ '@ai-sdk/xai@1.2.15':
+ resolution: {integrity: sha512-18qEYyVHIqTiOMePE00bfx4kJrTHM4dV3D3Rpe+eBISlY80X1FnzZRnRTJo3Q6MOSmW5+ZKVaX9jtryhoFpn0A==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ zod: ^3.0.0
+
'@alloc/quick-lru@5.2.0':
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
engines: {node: '>=10'}
@@ -474,6 +510,46 @@ packages:
cpu: [x64]
os: [win32]
+ '@clerk/backend@1.30.0':
+ resolution: {integrity: sha512-VB8a0jbfb5eiHENCT9ts8ISozUbKOcREj2rXQaYcOhEQ1EzO6Jng1Pb0nRR4RojsAvPe9XGMQrA4FufHp+dUWQ==}
+ engines: {node: '>=18.17.0'}
+ peerDependencies:
+ svix: ^1.62.0
+ peerDependenciesMeta:
+ svix:
+ optional: true
+
+ '@clerk/clerk-react@5.30.0':
+ resolution: {integrity: sha512-ruC8I31wu5vpYFTZ5Qj+avZwHbyzmhY6fatV6EzYMCIu7tOgDK5Af92gerfJ5drbRTL+NM4tDPmSNXiMIdS/nw==}
+ engines: {node: '>=18.17.0'}
+ peerDependencies:
+ react: ^18.0.0 || ^19.0.0 || ^19.0.0-0
+ react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-0
+
+ '@clerk/nextjs@6.18.0':
+ resolution: {integrity: sha512-7cWFserYByK65eSX7eCyGXHlDb3ejNTyf3O8nHc4DgDLHjZn5dJA+6J0Hz1XSgUATOCSbnmkKSy1iekNMvSnYA==}
+ engines: {node: '>=18.17.0'}
+ peerDependencies:
+ next: ^13.5.7 || ^14.2.25 || ^15.2.3
+ react: ^18.0.0 || ^19.0.0 || ^19.0.0-0
+ react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-0
+
+ '@clerk/shared@3.7.4':
+ resolution: {integrity: sha512-Ua6MyDyXjkfrV4h7ftC5LUsz7YL+0athsiNuMUyYjE3x8r/vmJzhLjZJ7C2C3KhDq2XnMWud0iQ7SGRTop+9WQ==}
+ engines: {node: '>=18.17.0'}
+ peerDependencies:
+ react: ^18.0.0 || ^19.0.0 || ^19.0.0-0
+ react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-0
+ peerDependenciesMeta:
+ react:
+ optional: true
+ react-dom:
+ optional: true
+
+ '@clerk/types@4.55.1':
+ resolution: {integrity: sha512-BS/shDMWfQ7M8Jlms/RJFH20N8cK1EfAWKTpiIiICFi5e/5H0gpaaWx277x341GQz4gZzVDPGdF+EhoknYOrWQ==}
+ engines: {node: '>=18.17.0'}
+
'@csstools/color-helpers@5.0.2':
resolution: {integrity: sha512-JqWH1vsgdGcw2RR6VliXXdA0/59LttzlU8UlRT/iUUsEeWfYq8I+K0yhihEUTTHLRm1EXvpsCx3083EU15ecsA==}
engines: {node: '>=18'}
@@ -2020,18 +2096,27 @@ packages:
'@types/keyv@3.1.4':
resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==}
+ '@types/linkify-it@3.0.5':
+ resolution: {integrity: sha512-yg6E+u0/+Zjva+buc3EIb+29XEg4wltq7cSmd4Uc2EE/1nUVmxyzpX6gUXD0V8jIrG0r7YeOGVIbYRkxeooCtw==}
+
'@types/linkify-it@5.0.0':
resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==}
'@types/lodash@4.17.16':
resolution: {integrity: sha512-HX7Em5NYQAXKW+1T+FiuG27NGwzJfCX3s1GjOa7ujxZa52kjJLOr4FUxT+giF6Tgxv1e+/czV/iTtBw27WTU9g==}
+ '@types/markdown-it@13.0.9':
+ resolution: {integrity: sha512-1XPwR0+MgXLWfTn9gCsZ55AHOKW1WN+P9vr0PaQh5aerR9LLQXUbjfEAFhjmEmyoYFWAyuN2Mqkn40MZ4ukjBw==}
+
'@types/markdown-it@14.1.2':
resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==}
'@types/mdast@4.0.4':
resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
+ '@types/mdurl@1.0.5':
+ resolution: {integrity: sha512-6L6VymKTzYSrEf4Nev4Xa1LCHKrlTlYCBMTlQKFuddo1CvQcE52I0mwfOJayueUC7MJuXOeHTcIU683lzd0cUA==}
+
'@types/mdurl@2.0.0':
resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==}
@@ -2572,6 +2657,10 @@ packages:
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
engines: {node: '>= 0.6'}
+ cookie@1.0.2:
+ resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==}
+ engines: {node: '>=18'}
+
core-util-is@1.0.2:
resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==}
@@ -2705,6 +2794,9 @@ packages:
dom-accessibility-api@0.5.16:
resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
+ dot-case@3.0.4:
+ resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==}
+
dotenv-expand@11.0.7:
resolution: {integrity: sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==}
engines: {node: '>=12'}
@@ -3276,6 +3368,10 @@ packages:
joi@17.13.3:
resolution: {integrity: sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==}
+ js-cookie@3.0.5:
+ resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==}
+ engines: {node: '>=14'}
+
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -3425,6 +3521,9 @@ packages:
loupe@3.1.3:
resolution: {integrity: sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==}
+ lower-case@2.0.2:
+ resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==}
+
lowercase-keys@2.0.0:
resolution: {integrity: sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==}
engines: {node: '>=8'}
@@ -3471,6 +3570,13 @@ packages:
resolution: {integrity: sha512-NgOPbRiaQM10DYXvN3/hhGVI2M5MtITFryzBGxHM5p4wnFxsVCbxkrBrDsk+EZ5OB4jEOT7AjDxtdF+KVEFT7w==}
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
+ map-obj@4.3.0:
+ resolution: {integrity: sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==}
+ engines: {node: '>=8'}
+
+ markdown-it-task-lists@2.1.1:
+ resolution: {integrity: sha512-TxFAc76Jnhb2OUu+n3yz9RMu4CwGfaT788br6HhEDlvWfdeJcLUsxk1Hgw2yJio0OXsxv7pyIPmvECY7bMbluA==}
+
markdown-it@14.1.0:
resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==}
hasBin: true
@@ -3699,6 +3805,9 @@ packages:
sass:
optional: true
+ no-case@3.0.4:
+ resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==}
+
node-abi@3.74.0:
resolution: {integrity: sha512-c5XK0MjkGBrQPGYG24GBADZud0NCbznxNx0ZkS+ebUTrmV1qTDxPxSL8zEAPURXSbLRWVexxmP4986BziahL5w==}
engines: {node: '>=10'}
@@ -4205,6 +4314,9 @@ packages:
serialize-javascript@6.0.2:
resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==}
+ server-only@0.0.1:
+ resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==}
+
set-blocking@2.0.0:
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
@@ -4256,6 +4368,13 @@ packages:
resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==}
engines: {node: '>= 6.0.0', npm: '>= 3.0.0'}
+ snake-case@3.0.4:
+ resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==}
+
+ snakecase-keys@8.0.1:
+ resolution: {integrity: sha512-Sj51kE1zC7zh6TDlNNz0/Jn1n5HiHdoQErxO8jLtnyrkJW/M5PrI7x05uDgY3BO7OUQYKCvmeMurW6BPUdwEOw==}
+ engines: {node: '>=18'}
+
socket.io-adapter@2.5.5:
resolution: {integrity: sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg==}
@@ -4464,6 +4583,11 @@ packages:
tippy.js@6.3.7:
resolution: {integrity: sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==}
+ tiptap-markdown@0.8.10:
+ resolution: {integrity: sha512-iDVkR2BjAqkTDtFX0h94yVvE2AihCXlF0Q7RIXSJPRSR5I0PA1TMuAg6FHFpmqTn4tPxJ0by0CK7PUMlnFLGEQ==}
+ peerDependencies:
+ '@tiptap/core': ^2.0.3
+
tldts-core@6.1.86:
resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==}
@@ -4520,6 +4644,10 @@ packages:
resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==}
engines: {node: '>=10'}
+ type-fest@4.40.1:
+ resolution: {integrity: sha512-9YvLNnORDpI+vghLU/Nf+zSv0kL47KbVJ1o3sKgoTefl6i+zebxbiDQWoe/oWWqPhIgQdRZRT1KA9sCPL810SA==}
+ engines: {node: '>=16'}
+
type-is@1.6.18:
resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==}
engines: {node: '>= 0.6'}
@@ -4894,6 +5022,18 @@ snapshots:
'@adobe/css-tools@4.4.2': {}
+ '@ai-sdk/openai-compatible@0.2.13(zod@3.24.3)':
+ dependencies:
+ '@ai-sdk/provider': 1.1.3
+ '@ai-sdk/provider-utils': 2.2.7(zod@3.24.3)
+ zod: 3.24.3
+
+ '@ai-sdk/openai@1.3.20(zod@3.24.3)':
+ dependencies:
+ '@ai-sdk/provider': 1.1.3
+ '@ai-sdk/provider-utils': 2.2.7(zod@3.24.3)
+ zod: 3.24.3
+
'@ai-sdk/provider-utils@2.2.7(zod@3.24.3)':
dependencies:
'@ai-sdk/provider': 1.1.3
@@ -4922,6 +5062,13 @@ snapshots:
zod: 3.24.3
zod-to-json-schema: 3.24.5(zod@3.24.3)
+ '@ai-sdk/xai@1.2.15(zod@3.24.3)':
+ dependencies:
+ '@ai-sdk/openai-compatible': 0.2.13(zod@3.24.3)
+ '@ai-sdk/provider': 1.1.3
+ '@ai-sdk/provider-utils': 2.2.7(zod@3.24.3)
+ zod: 3.24.3
+
'@alloc/quick-lru@5.2.0': {}
'@ampproject/remapping@2.3.0':
@@ -5088,6 +5235,55 @@ snapshots:
'@biomejs/cli-win32-x64@1.9.4':
optional: true
+ '@clerk/backend@1.30.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
+ dependencies:
+ '@clerk/shared': 3.7.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ '@clerk/types': 4.55.1
+ cookie: 1.0.2
+ snakecase-keys: 8.0.1
+ tslib: 2.8.1
+ transitivePeerDependencies:
+ - react
+ - react-dom
+
+ '@clerk/clerk-react@5.30.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
+ dependencies:
+ '@clerk/shared': 3.7.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ '@clerk/types': 4.55.1
+ react: 19.1.0
+ react-dom: 19.1.0(react@19.1.0)
+ tslib: 2.8.1
+
+ '@clerk/nextjs@6.18.0(next@15.3.1(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
+ dependencies:
+ '@clerk/backend': 1.30.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ '@clerk/clerk-react': 5.30.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ '@clerk/shared': 3.7.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ '@clerk/types': 4.55.1
+ next: 15.3.1(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ react: 19.1.0
+ react-dom: 19.1.0(react@19.1.0)
+ server-only: 0.0.1
+ tslib: 2.8.1
+ transitivePeerDependencies:
+ - svix
+
+ '@clerk/shared@3.7.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
+ dependencies:
+ '@clerk/types': 4.55.1
+ dequal: 2.0.3
+ glob-to-regexp: 0.4.1
+ js-cookie: 3.0.5
+ std-env: 3.9.0
+ swr: 2.3.3(react@19.1.0)
+ optionalDependencies:
+ react: 19.1.0
+ react-dom: 19.1.0(react@19.1.0)
+
+ '@clerk/types@4.55.1':
+ dependencies:
+ csstype: 3.1.3
+
'@csstools/color-helpers@5.0.2': {}
'@csstools/css-calc@2.1.3(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3)':
@@ -6560,10 +6756,17 @@ snapshots:
dependencies:
'@types/node': 20.17.30
+ '@types/linkify-it@3.0.5': {}
+
'@types/linkify-it@5.0.0': {}
'@types/lodash@4.17.16': {}
+ '@types/markdown-it@13.0.9':
+ dependencies:
+ '@types/linkify-it': 3.0.5
+ '@types/mdurl': 1.0.5
+
'@types/markdown-it@14.1.2':
dependencies:
'@types/linkify-it': 5.0.0
@@ -6573,6 +6776,8 @@ snapshots:
dependencies:
'@types/unist': 3.0.3
+ '@types/mdurl@1.0.5': {}
+
'@types/mdurl@2.0.0': {}
'@types/ms@2.1.0': {}
@@ -7231,6 +7436,8 @@ snapshots:
cookie@0.7.2: {}
+ cookie@1.0.2: {}
+
core-util-is@1.0.2:
optional: true
@@ -7366,6 +7573,11 @@ snapshots:
dom-accessibility-api@0.5.16: {}
+ dot-case@3.0.4:
+ dependencies:
+ no-case: 3.0.4
+ tslib: 2.8.1
+
dotenv-expand@11.0.7:
dependencies:
dotenv: 16.5.0
@@ -8060,6 +8272,8 @@ snapshots:
'@sideway/formula': 3.0.1
'@sideway/pinpoint': 2.0.0
+ js-cookie@3.0.5: {}
+
js-tokens@4.0.0: {}
js-yaml@4.1.0:
@@ -8200,6 +8414,10 @@ snapshots:
loupe@3.1.3: {}
+ lower-case@2.0.2:
+ dependencies:
+ tslib: 2.8.1
+
lowercase-keys@2.0.0: {}
lru-cache@10.4.3: {}
@@ -8262,6 +8480,10 @@ snapshots:
- bluebird
- supports-color
+ map-obj@4.3.0: {}
+
+ markdown-it-task-lists@2.1.1: {}
+
markdown-it@14.1.0:
dependencies:
argparse: 2.0.1
@@ -8464,6 +8686,11 @@ snapshots:
- '@babel/core'
- babel-plugin-macros
+ no-case@3.0.4:
+ dependencies:
+ lower-case: 2.0.2
+ tslib: 2.8.1
+
node-abi@3.74.0:
dependencies:
semver: 7.7.1
@@ -9018,6 +9245,8 @@ snapshots:
dependencies:
randombytes: 2.1.0
+ server-only@0.0.1: {}
+
set-blocking@2.0.0: {}
sharp@0.34.1:
@@ -9097,6 +9326,17 @@ snapshots:
smart-buffer@4.2.0: {}
+ snake-case@3.0.4:
+ dependencies:
+ dot-case: 3.0.4
+ tslib: 2.8.1
+
+ snakecase-keys@8.0.1:
+ dependencies:
+ map-obj: 4.3.0
+ snake-case: 3.0.4
+ type-fest: 4.40.1
+
socket.io-adapter@2.5.5(bufferutil@4.0.9)(utf-8-validate@6.0.5):
dependencies:
debug: 4.3.7
@@ -9321,6 +9561,14 @@ snapshots:
dependencies:
'@popperjs/core': 2.11.8
+ tiptap-markdown@0.8.10(@tiptap/core@2.11.7(@tiptap/pm@2.11.7)):
+ dependencies:
+ '@tiptap/core': 2.11.7(@tiptap/pm@2.11.7)
+ '@types/markdown-it': 13.0.9
+ markdown-it: 14.1.0
+ markdown-it-task-lists: 2.1.1
+ prosemirror-markdown: 1.13.2
+
tldts-core@6.1.86: {}
tldts@6.1.86:
@@ -9362,6 +9610,8 @@ snapshots:
type-fest@0.13.1:
optional: true
+ type-fest@4.40.1: {}
+
type-is@1.6.18:
dependencies:
media-typer: 0.3.0
diff --git a/public/workers/workers/file-system.worker.js b/public/workers/workers/file-system.worker.js
index 219b73f..899b76b 100644
--- a/public/workers/workers/file-system.worker.js
+++ b/public/workers/workers/file-system.worker.js
@@ -79,15 +79,15 @@ async function listFilesNonRecursively(dirPath = "") {
const dirHandle = await getDirHandleFromPath(dirPath);
const files = [];
try {
- console.log("[Worker] Starting to list directory contents");
+ // console.log("[Worker] Starting to list directory contents");
const handle = dirHandle;
for await (const entry of handle.values()) {
const entryPath = dirPath ? `${dirPath}/${entry.name}` : entry.name;
- console.log("[Worker] Processing entry:", entryPath);
+ // console.log("[Worker] Processing entry:", entryPath);
const fileInfo = await getFileInfo(entry, entry.name, entryPath);
files.push(fileInfo);
}
- console.log("[Worker] Found", files.length, "files in directory");
+ // console.log("[Worker] Found", files.length, "files in directory");
}
catch (error) {
console.error("[Worker] Error listing files in", dirPath, ":", error);
diff --git a/src/app/(ide)/@editor/default.tsx b/src/app/(ide)/@editor/default.tsx
index 1c36d3d..6a40406 100644
--- a/src/app/(ide)/@editor/default.tsx
+++ b/src/app/(ide)/@editor/default.tsx
@@ -2,4 +2,4 @@
export default function Default() {
return null;
-}
+}
\ No newline at end of file
diff --git a/src/app/(ide)/ide/layout.tsx b/src/app/(ide)/ide/layout.tsx
index 1b93aaa..010000f 100644
--- a/src/app/(ide)/ide/layout.tsx
+++ b/src/app/(ide)/ide/layout.tsx
@@ -25,10 +25,13 @@ export default function IDELayout({
terminal: ReactNode;
agent: ReactNode;
}) {
- const { loadDirectory } = useFileStore(useShallow(state => ({
+ const { loadDirectory, filteredFiles, searchQuery } = useFileStore(useShallow(state => ({
loadDirectory: state.loadDirectory,
+ filteredFiles: state.filteredFiles,
+ searchQuery: state.searchQuery,
})));
+
const { projects, activeProject, removeProject, setActiveProject } =
useIDEStore(useShallow(state => ({
projects: state.projects,
@@ -115,6 +118,10 @@ export default function IDELayout({
+
+ {filteredFiles.length} items
+ {searchQuery && " (filtered)"}
+
);
}
diff --git a/src/app/(ide)/layout.tsx b/src/app/(ide)/layout.tsx
index cd2da8e..5b6a671 100644
--- a/src/app/(ide)/layout.tsx
+++ b/src/app/(ide)/layout.tsx
@@ -1,3 +1,4 @@
+import { CommandDialogDemo } from "@/components/commandbar/commandbar";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { cookies } from "next/headers";
import type { ReactNode } from "react";
@@ -14,6 +15,7 @@ export default async function IDELayout({ children }: { children: ReactNode }) {
{/* */}
+
);
}
diff --git a/src/app/(landing)/layout.tsx b/src/app/(landing)/layout.tsx
index cfc1e7d..691c076 100644
--- a/src/app/(landing)/layout.tsx
+++ b/src/app/(landing)/layout.tsx
@@ -1,4 +1,5 @@
import Header from "@/components/layout/Header";
+import { ClerkProvider } from "@clerk/nextjs";
export default function LandingLayout({
children,
@@ -6,10 +7,12 @@ export default function LandingLayout({
children: React.ReactNode;
}) {
return (
-
-
-
{children}
- {/*
*/}
-
+
+
+
+
{children}
+ {/*
*/}
+
+
);
}
diff --git a/src/app/(landing)/page.tsx b/src/app/(landing)/page.tsx
index 5988cd9..2c8be4a 100644
--- a/src/app/(landing)/page.tsx
+++ b/src/app/(landing)/page.tsx
@@ -1,5 +1,4 @@
import { Hero } from "@/components/landing/Hero";
-
export const dynamic = "force-dynamic";
export default function Home() {
@@ -8,6 +7,7 @@ export default function Home() {
{/* Hero Section */}
+
);
diff --git a/src/app/api/completion/prompt.ts b/src/app/api/completion/prompt.ts
new file mode 100644
index 0000000..646c05c
--- /dev/null
+++ b/src/app/api/completion/prompt.ts
@@ -0,0 +1,24 @@
+export const GenerateInstructions = (language: string) => ({
+ content: `## Task: Code Completion
+
+ ### Language: ${language}
+
+ ### Instructions:
+ - You are a world class coding assistant.
+ - Given the current text, context, and the last character of the user input, provide a suggestion for code completion.
+ - The suggestion must be based on the current text, as well as the text before the cursor.
+ - This is not a conversation, so please do not ask questions or prompt for additional information.
+
+ ### Notes
+ - NEVER INCLUDE ANY MARKDOWN IN THE RESPONSE - THIS MEANS CODEBLOCKS AS WELL.
+ - Never include any annotations such as "# Suggestion:" or "# Suggestions:".
+ - Newlines should be included after any of the following characters: "{", "[", "(", ")", "]", "}", and ",".
+ - Never suggest a newline after a space or newline.
+ - Ensure that newline suggestions follow the same indentation as the current line.
+ - The suggestion must start with the last character of the current user input.
+ - Only ever return the code snippet, do not return any markdown unless it is part of the code snippet.
+ - Do not return any code that is already present in the current text.
+ - Do not return anything that is not valid code.
+ - If you do not have a suggestion, return an empty string.`,
+ role: "system",
+ });
\ No newline at end of file
diff --git a/src/app/api/completion/route.ts b/src/app/api/completion/route.ts
new file mode 100644
index 0000000..7fa7721
--- /dev/null
+++ b/src/app/api/completion/route.ts
@@ -0,0 +1,18 @@
+import { openai } from "@ai-sdk/openai";
+import { streamText } from "ai";
+
+// Allow streaming responses up to 30 seconds
+export const maxDuration = 30;
+
+export async function POST(req: Request) {
+ const { messages } = await req.json();
+
+ const result = streamText({
+ model: openai("gpt-4o"),
+ messages,
+ maxTokens: 16,
+ temperature: 0.1,
+ });
+
+ return result.toDataStreamResponse();
+}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 55d7d85..d6397f4 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,8 +1,10 @@
import "@/styles/globals.css";
import { FileStoreInitializer } from "@/components/file-store-initializer";
+import { env } from "@/env";
import type { Metadata } from "next";
import { Geist } from "next/font/google";
+import { Toaster } from "sonner";
export const metadata: Metadata = {
title: "Floki",
@@ -21,14 +23,17 @@ export default function RootLayout({
return (
-
+ {env.NODE_ENV === "development" && (
+
+ )}
{children}
+
);
diff --git a/src/app/robots.ts b/src/app/robots.ts
index 53009ba..7652956 100644
--- a/src/app/robots.ts
+++ b/src/app/robots.ts
@@ -2,10 +2,10 @@ import type { MetadataRoute } from 'next'
export default function robots(): MetadataRoute.Robots {
return {
- rules: {
+ rules: [{
userAgent: "*",
allow: "/",
disallow: ["/ide", "/terminal", "/api"],
- },
+ }]
};
}
diff --git a/src/components/commandbar/commandbar.tsx b/src/components/commandbar/commandbar.tsx
new file mode 100644
index 0000000..da85633
--- /dev/null
+++ b/src/components/commandbar/commandbar.tsx
@@ -0,0 +1,87 @@
+"use client";
+
+import {
+ Calculator,
+ Calendar,
+ CreditCard,
+ Settings,
+ Smile,
+ User,
+} from "lucide-react";
+import * as React from "react";
+
+import {
+ CommandDialog,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+ CommandSeparator,
+ CommandShortcut,
+} from "@/components/ui/command";
+
+export function CommandDialogDemo() {
+ const [open, setOpen] = React.useState(false);
+
+ React.useEffect(() => {
+ const down = (e: KeyboardEvent) => {
+ if (e.key === "j" && (e.metaKey || e.ctrlKey)) {
+ e.preventDefault();
+ setOpen((open) => !open);
+ }
+ };
+
+ document.addEventListener("keydown", down);
+ return () => document.removeEventListener("keydown", down);
+ }, []);
+
+ return (
+ <>
+ {/*
+ Press{" "}
+
+ ⌘ J
+
+
*/}
+
+
+
+ No results found.
+
+
+
+ Calendar
+
+
+
+ Search Emoji
+
+
+
+ Calculator
+
+
+
+
+
+
+ Profile
+ ⌘P
+
+
+
+ Billing
+ ⌘B
+
+
+
+ Settings
+ ⌘S
+
+
+
+
+ >
+ );
+}
diff --git a/src/components/directory-permission-prompt.tsx b/src/components/directory-permission-prompt.tsx
index b8fa9ae..645f729 100644
--- a/src/components/directory-permission-prompt.tsx
+++ b/src/components/directory-permission-prompt.tsx
@@ -9,6 +9,7 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
+
import { useFileStore } from "@/store/file-store";
import { FolderOpen } from "lucide-react";
import { useState } from "react";
diff --git a/src/components/editor/custom-dialog.tsx b/src/components/editor/custom-dialog.tsx
new file mode 100644
index 0000000..1e8b5c1
--- /dev/null
+++ b/src/components/editor/custom-dialog.tsx
@@ -0,0 +1,70 @@
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Textarea } from "@/components/ui/textarea";
+import { Send } from "lucide-react";
+import { useState } from "react";
+
+interface CustomDialogProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onSendMessage: (message: string) => void;
+}
+
+export const CustomDialog = ({
+ isOpen,
+ onClose,
+ onSendMessage,
+}: CustomDialogProps) => {
+ const [message, setMessage] = useState("");
+
+ const handleSendMessage = () => {
+ if (message.trim()) {
+ onSendMessage(message);
+ setMessage("");
+ }
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault();
+ handleSendMessage();
+ }
+ };
+
+ return (
+ !open && onClose()}>
+
+
+ Ask AI
+
+
+
+ {/* Message history would go here */}
+
+ Ask a question about your code...
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/src/components/editor/file-monaco-editor/components/completion-formatter.ts b/src/components/editor/file-monaco-editor/components/completion-formatter.ts
new file mode 100644
index 0000000..64633f6
--- /dev/null
+++ b/src/components/editor/file-monaco-editor/components/completion-formatter.ts
@@ -0,0 +1,290 @@
+import * as monacoeditor from "monaco-editor";
+
+const OPENING_BRACKETS = ["(", "[", "{"];
+const CLOSING_BRACKETS = [")", "]", "}"];
+const QUOTES = ['"', "'", "`"];
+export const ALL_BRACKETS = [...OPENING_BRACKETS, ...CLOSING_BRACKETS] as const;
+export type Bracket = (typeof ALL_BRACKETS)[number];
+
+class CompletionFormatter {
+ private _characterAfterCursor: string;
+ private _completion = "";
+ private _normalisedCompletion = "";
+ private _originalCompletion = "";
+ private _textAfterCursor: string;
+ private _lineText: string;
+ private _characterBeforeCursor: string;
+ private _editor: monacoeditor.editor.ITextModel;
+ private _cursorPosition: monacoeditor.Position;
+ private _lineCount: number;
+
+ constructor(
+ editor: monacoeditor.editor.ITextModel,
+ position: monacoeditor.Position,
+ ) {
+ this._editor = editor;
+ this._cursorPosition = position;
+ const lineEndPosition = editor.getFullModelRange()?.getEndPosition();
+ const textAfterRange = new monacoeditor.Range(
+ /* Start position */ this._cursorPosition.lineNumber,
+ /* Start column */ this._cursorPosition.column,
+ /* End position */ lineEndPosition?.lineNumber ?? 1,
+ /* End column */ lineEndPosition?.column ?? 1,
+ );
+ this._lineText = editor.getLineContent(this._cursorPosition.lineNumber);
+ this._textAfterCursor = editor.getValueInRange(textAfterRange);
+ this._editor = editor;
+ this._characterBeforeCursor =
+ this._lineText[this._cursorPosition.column - 2] ?? "";
+ this._characterAfterCursor =
+ this._lineText[this._cursorPosition.column] ?? "";
+ this._lineCount = editor.getLineCount();
+ }
+
+ // Check if the open and close brackets are a matching pair
+ private isMatchingPair = (open?: Bracket, close?: string): boolean => {
+ return (
+ (open === "(" && close === ")") ||
+ (open === "[" && close === "]") ||
+ (open === "{" && close === "}")
+ );
+ };
+
+ // Match the completion brackets to ensure they are balanced
+ private matchCompletionBrackets = (): CompletionFormatter => {
+ let accumulatedCompletion = "";
+ const openBrackets: Bracket[] = [];
+ for (const character of this._originalCompletion) {
+ if (OPENING_BRACKETS.includes(character)) {
+ openBrackets.push(character);
+ }
+
+ if (CLOSING_BRACKETS.includes(character)) {
+ if (
+ openBrackets.length &&
+ this.isMatchingPair(openBrackets[openBrackets.length - 1], character)
+ ) {
+ openBrackets.pop();
+ } else {
+ break;
+ }
+ }
+ accumulatedCompletion += character;
+ }
+
+ // If the brackets are not balanced, return the original completion, otherwise return the matched completion
+ this._completion =
+ accumulatedCompletion.trimEnd() || this._originalCompletion.trimEnd();
+
+ return this;
+ };
+
+ private ignoreBlankLines = (): CompletionFormatter => {
+ // If the completion is a blank line, return an empty string
+ if (
+ this._completion.trimStart() === "" &&
+ this._originalCompletion !== "\n"
+ ) {
+ this._completion = this._completion.trim();
+ }
+ return this;
+ };
+
+ // Remove leading and trailing whitespace from the text
+ private normalise = (text: string) => text?.trim();
+
+ private removeDuplicateStartOfSuggestions(): this {
+ // Remove the text that is already present in the editor from the completion
+ const before = this._editor
+ .getValueInRange(
+ new monacoeditor.Range(
+ 1,
+ 1,
+ this._cursorPosition.lineNumber,
+ this._cursorPosition.column,
+ ),
+ )
+ .trim();
+
+ const completion = this.normalise(this._completion);
+
+ const maxLength = Math.min(completion.length, before.length);
+ let overlapLength = 0;
+
+ for (let length = 1; length <= maxLength; length++) {
+ const endOfBefore = before.substring(before.length - length);
+ const startOfCompletion = completion.substring(0, length);
+ if (endOfBefore === startOfCompletion) {
+ overlapLength = length;
+ }
+ }
+
+ // Remove the overlapping part from the start of completion
+ if (overlapLength > 0) {
+ this._completion = this._completion.substring(overlapLength);
+ }
+
+ return this;
+ }
+
+ // Check if the cursor is in the middle of a word
+ private isCursorAtMiddleOfWord() {
+ return (
+ this._characterBeforeCursor &&
+ /\w/.test(this._characterBeforeCursor) &&
+ /\w/.test(this._characterAfterCursor)
+ );
+ }
+
+ // Remove unnecessary quotes in the middle of the completion
+ private removeUnnecessaryMiddleQuote(): CompletionFormatter {
+ const startsWithQuote = QUOTES.includes(this._completion[0] ?? "");
+ const endsWithQuote = QUOTES.includes(
+ this._completion[this._completion.length - 1] ?? "",
+ );
+
+ if (startsWithQuote && endsWithQuote) {
+ this._completion = this._completion.substring(1);
+ }
+
+ if (endsWithQuote && this.isCursorAtMiddleOfWord()) {
+ this._completion = this._completion.slice(0, -1);
+ }
+
+ return this;
+ }
+
+ // Remove duplicate lines that are already present in the editor
+ private preventDuplicateLines = (): CompletionFormatter => {
+ let nextLineIndex = this._cursorPosition.lineNumber + 1;
+ while (
+ nextLineIndex < this._cursorPosition.lineNumber + 3 &&
+ nextLineIndex < this._lineCount
+ ) {
+ const line = this._editor.getLineContent(nextLineIndex);
+ if (this.normalise(line) === this.normalise(this._originalCompletion)) {
+ this._completion = "";
+ return this;
+ }
+ nextLineIndex++;
+ }
+ return this;
+ };
+
+ // Remove newlines after spaces or newlines
+ public removeInvalidLineBreaks = (): CompletionFormatter => {
+ if (this._completion.endsWith("\n")) {
+ this._completion = this._completion.trimEnd();
+ }
+ return this;
+ };
+
+ private newLineCount = () => {
+ return this._completion.match(/\n/g) || [];
+ };
+
+ private getLastLineColumnCount = () => {
+ const lines = this._completion.split("\n");
+ return lines[lines.length - 1]?.length || 0;
+ };
+
+ private trimStart = () => {
+ const firstNonSpaceIndex = this._completion.search(/\S/);
+
+ /* If the first non-space character is in front of the cursor, remove it */
+ if (firstNonSpaceIndex > this._cursorPosition.column - 1) {
+ this._completion = this._completion.substring(firstNonSpaceIndex);
+ }
+
+ return this;
+ };
+
+ private stripMarkdownAndSuggestionText = () => {
+ // Remove the backticks and the language name from a code block
+ this._completion = this._completion.replace(/```.*\n/g, "");
+ this._completion = this._completion.replace(/```/g, "");
+ this._completion = this._completion.replace(/`/g, "");
+
+ // Remove variations of "# Suggestion:" and "# Suggestions:" if they exist
+ this._completion = this._completion.replace(/# ?Suggestions?: ?/g, "");
+
+ return this;
+ };
+
+ private getNoTextBeforeOrAfter = () => {
+ const textAfter = this._textAfterCursor;
+ const textBeforeRange = new monacoeditor.Range(
+ 0,
+ 0,
+ this._cursorPosition.lineNumber,
+ this._cursorPosition.column,
+ );
+
+ const textBefore = this._editor.getValueInRange(textBeforeRange);
+
+ return !textAfter || !textBefore;
+ };
+
+ private ignoreContextCompletionAtStartOrEnd = () => {
+ const isNoTextBeforeOrAfter = this.getNoTextBeforeOrAfter();
+
+ const contextMatch = this._normalisedCompletion.match(
+ /\/\*\s*Language:\s*(.*)\s*\*\//,
+ );
+
+ const extensionContext = this._normalisedCompletion.match(
+ /\/\*\s*File extension:\s*(.*)\s*\*\//,
+ );
+
+ const commentMatch = this._normalisedCompletion.match(/\/\*\s*\*\//);
+
+ if (
+ isNoTextBeforeOrAfter &&
+ (contextMatch || extensionContext || commentMatch)
+ ) {
+ this._completion = "";
+ }
+
+ return this;
+ };
+
+ // Format the completion based on the cursor position, formatted completion, and range
+ private formatCompletion = (range: monacoeditor.IRange) => {
+ const newLineCount = this.newLineCount();
+ const getLastLineLength = this.getLastLineColumnCount();
+ return {
+ insertText: this._completion,
+ range: {
+ startLineNumber: this._cursorPosition.lineNumber,
+ startColumn: this._cursorPosition.column,
+ endLineNumber: this._cursorPosition.lineNumber + newLineCount.length,
+ endColumn:
+ this._cursorPosition.lineNumber === range.startLineNumber &&
+ newLineCount.length === 0
+ ? this._cursorPosition.column + getLastLineLength
+ : getLastLineLength,
+ },
+ };
+ };
+
+ public format = (
+ insertText: string,
+ range: monacoeditor.IRange,
+ ): { insertText: string; range: monacoeditor.IRange } => {
+ this._completion = "";
+ this._normalisedCompletion = this.normalise(insertText);
+ this._originalCompletion = insertText;
+ return this.matchCompletionBrackets()
+ .ignoreBlankLines()
+ .removeDuplicateStartOfSuggestions()
+ .removeUnnecessaryMiddleQuote()
+ .preventDuplicateLines()
+ .removeInvalidLineBreaks()
+ .trimStart()
+ .stripMarkdownAndSuggestionText()
+ .ignoreContextCompletionAtStartOrEnd()
+ .formatCompletion(range);
+ };
+}
+
+export { CompletionFormatter };
diff --git a/src/components/editor/file-monaco-editor/components/editor-toolbar.tsx b/src/components/editor/file-monaco-editor/components/editor-toolbar.tsx
index d9c8fb1..13cf5e9 100644
--- a/src/components/editor/file-monaco-editor/components/editor-toolbar.tsx
+++ b/src/components/editor/file-monaco-editor/components/editor-toolbar.tsx
@@ -57,7 +57,7 @@ export const EditorToolbar = React.memo(
}, [selectedFile?.path, currentPath]);
return (
-
+
{/* Edit toggle button */}
-
+
{/* Theme selector */}
{/* Git Status Badge */}
- {fileStatus && (
-
- )}
+ {fileStatus && }
);
diff --git a/src/components/editor/file-monaco-editor/components/monaco-wrapper.tsx b/src/components/editor/file-monaco-editor/components/monaco-wrapper.tsx
index 1eebbeb..402120d 100644
--- a/src/components/editor/file-monaco-editor/components/monaco-wrapper.tsx
+++ b/src/components/editor/file-monaco-editor/components/monaco-wrapper.tsx
@@ -1,9 +1,12 @@
+import { GenerateInstructions } from "@/app/api/completion/prompt";
+import { useDebounce } from "@/hooks/use-debounce";
+import { useCompletion } from "@ai-sdk/react";
import dynamic from "next/dynamic";
-import React, { useEffect } from "react";
+import React, { useCallback, useEffect, useRef, useState } from "react";
import { useMonaco } from "../hooks/use-monaco";
import type { FileInfo } from "../types";
import { getLanguageFromFileName } from "../utils/language-map";
-
+import { CompletionFormatter } from "./completion-formatter";
// Monaco must load on the client only with no loading UI
const MonacoEditor = dynamic(() => import("@monaco-editor/react"), {
ssr: false,
@@ -26,52 +29,367 @@ export const MonacoWrapper = React.memo(
theme,
onContentChange,
}: MonacoWrapperProps) => {
- const { handleEditorDidMount, setTheme: updateTheme } = useMonaco({
+ console.log("MonacoWrapper rendering", { selectedFile, theme });
+
+ const {
+ handleEditorDidMount,
+ setTheme: updateTheme,
+ editorRef,
+ monacoRef,
+ } = useMonaco({
onContentChange,
readOnly: !isEditing,
});
+ const [isDialogOpen, setIsDialogOpen] = useState(false);
+ const [highlightedText, setHighlightedText] = useState("");
+ const [selectedLines, setSelectedLines] = useState<{
+ startLineNumber: number;
+ endLineNumber: number;
+ } | null>(null);
+
+ // Add debounced content change handler to prevent excessive saving
+ const { debouncedCallback: debouncedContentChange } = useDebounce(
+ (value: string) => {
+ if (isEditing && onContentChange) {
+ onContentChange(value);
+ }
+ },
+ 1000, // 1 second debounce to reduce saving toasts
+ );
+
+ const { completion, complete } = useCompletion({
+ api: "/api/completion",
+ });
+
+ // Refs to manage fetching and timing of suggestions
+ const fetchSuggestionsIntervalRef = useRef(undefined);
+ const timeoutRef = useRef(undefined);
+
+ const refreshInterval = 500;
+ const language = getLanguageFromFileName(selectedFile.name);
+
+ // State to cache suggestions received from the AI completion API
+ const [cachedSuggestions, setCachedSuggestions] = useState<
+ {
+ insertText: string;
+ range: {
+ startLineNumber: number;
+ startColumn: number;
+ endLineNumber: number;
+ endColumn: number;
+ };
+ }[]
+ >([]);
+
+ const debouncedSuggestions = useCallback(() => {
+ // Access the current model (document) of the editor
+ const model = monacoRef.current?.editor.getModels()[0];
+
+ if (!model || !model.getValue()) {
+ setCachedSuggestions([]);
+ return;
+ }
+
+ const position = editorRef.current?.getPosition();
+ if (!position) return;
+ const currentLine = model.getLineContent(position.lineNumber);
+ const offset = model.getOffsetAt(position);
+ const textBeforeCursor = model
+ .getValue()
+ .substring(0, offset - currentLine.length);
+ const textBeforeCursorOnCurrentLine = currentLine.substring(
+ 0,
+ position.column - 1,
+ );
+
+ if (!textBeforeCursor) return;
+
+ const messages = [
+ GenerateInstructions(getLanguageFromFileName(selectedFile.name)),
+ {
+ content: textBeforeCursor,
+ role: "user",
+ name: "TextBeforeCursor",
+ },
+ {
+ content: textBeforeCursorOnCurrentLine,
+ role: "user",
+ name: "TextBeforeCursorOnCurrentLine",
+ },
+ ];
+
+ // Call the completion API and handle the response
+ complete("", {
+ body: {
+ messages,
+ },
+ })
+ .then((newCompletion) => {
+ if (newCompletion) {
+ // Construct a new suggestion object based on the API response
+ const newSuggestion = {
+ insertText: newCompletion,
+ range: {
+ startLineNumber: position.lineNumber,
+ startColumn: position.column,
+ endLineNumber:
+ // Calculate the number of new lines in the completion text and add it to the current line number
+ position.lineNumber +
+ (newCompletion.match(/\n/g) || []).length,
+ // If the suggestion is on the same line, return the length of the completion text
+ endColumn: position.column + newCompletion.length,
+ },
+ };
+
+ // Update the cached suggestions with the new suggestion (up to the cache size limit)
+ // Cache size is set to 6 by default, which I found to be a good balance between performance and usability
+ setCachedSuggestions((prev) => [...prev, newSuggestion].slice(-6));
+ }
+ })
+ .catch((error) => {
+ console.error("error", error);
+ });
+ }, [monacoRef, complete, selectedFile.name, editorRef]);
+
+ const startOrResetFetching = useCallback(() => {
+ // Check if the fetching interval is not already set
+ if (fetchSuggestionsIntervalRef.current === undefined) {
+ // Immediately invoke suggestions once
+ debouncedSuggestions();
+
+ // Set an interval to fetch suggestions every refresh interval
+ // (default is 500ms which seems to align will with the
+ // average typing speed and latency of OpenAI API calls)
+ fetchSuggestionsIntervalRef.current = setInterval(
+ debouncedSuggestions,
+ refreshInterval,
+ ) as unknown as number; // Cast to number as setInterval returns a NodeJS.Timeout in Node environments
+ }
+
+ // Clear any previous timeout to reset the timer
+ clearTimeout(timeoutRef.current);
+
+ // Set a new timeout to stop fetching suggestions if no typing occurs for 2x the refresh interval
+ timeoutRef.current = setTimeout(() => {
+ if (fetchSuggestionsIntervalRef.current !== undefined) {
+ window.clearInterval(fetchSuggestionsIntervalRef.current);
+ fetchSuggestionsIntervalRef.current = undefined;
+ }
+ }, refreshInterval * 2) as unknown as number;
+ }, [debouncedSuggestions]);
+
+ // Cleanup on component unmount
+ useEffect(() => {
+ return () => {
+ // Clear the interval and timeout when the component is unmounted
+ window.clearInterval(fetchSuggestionsIntervalRef.current);
+ window.clearTimeout(timeoutRef.current);
+ };
+ }, []);
+
+ // Handle editor changes, using debouncing for content updates
+ const handleEditorChange = useCallback(
+ (value: string) => {
+ startOrResetFetching();
+ // Use debounced content changes to prevent frequent saving
+ if (value) {
+ debouncedContentChange(value);
+ }
+ },
+ [startOrResetFetching, debouncedContentChange],
+ );
+
+ // setup autocomplete
+ useEffect(() => {
+ if (!monacoRef.current) return;
+ const provider =
+ monacoRef.current.languages.registerInlineCompletionsProvider(
+ language,
+ {
+ provideInlineCompletions: async (model, position) => {
+ // Filter cached suggestions to include only those that start with the current word at the cursor position
+ const suggestions = cachedSuggestions.filter((suggestion) =>
+ suggestion.insertText.startsWith(
+ model.getValueInRange(suggestion.range),
+ ),
+ );
+
+ // Less restrictive filtering - show suggestions on the current line
+ const localSuggestions = suggestions.filter(
+ (suggestion) =>
+ suggestion.range.startLineNumber === position.lineNumber,
+ );
+
+ return {
+ items: localSuggestions.map((suggestion) =>
+ new CompletionFormatter(model, position).format(
+ suggestion.insertText,
+ suggestion.range,
+ ),
+ ),
+ };
+ },
+ freeInlineCompletions: () => {},
+ },
+ );
+
+ return () => provider.dispose();
+ }, [monacoRef, language, cachedSuggestions]);
+
// Update theme when it changes
useEffect(() => {
void updateTheme(theme);
}, [theme, updateTheme]);
- return (
-
-
{
- if (isEditing && onContentChange) {
- onContentChange(value || "");
+ // Setup cmd+k hotkey
+ useEffect(() => {
+ console.log("Setting up hotkey", {
+ editorExists: !!editorRef.current,
+ monacoExists: !!monacoRef.current,
+ });
+
+ if (!editorRef.current || !monacoRef.current) return;
+
+ const monaco = monacoRef.current;
+ const editor = editorRef.current;
+
+ console.log("Registering Cmd+K action");
+
+ // Ensure the editor is focused first
+ setTimeout(() => {
+ // Focus the editor
+ editor.focus();
+
+ // Register cmd+k command
+ const disposable = editor.addAction({
+ id: "custom-dialog-command",
+ label: "Open Custom Dialog",
+ keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyK],
+ run: () => {
+ console.log("Cmd+K triggered!");
+
+ // Get current editor content
+ const content = editor.getValue();
+ console.log("Editor content length:", content.length);
+
+ // Get current selection
+ const selection = editor.getSelection();
+ console.log("Selection:", selection);
+
+ let selText = "";
+ let selLines = null;
+
+ if (selection && !selection.isEmpty()) {
+ selText = editor.getModel()?.getValueInRange(selection) || "";
+ selLines = {
+ startLineNumber: selection.startLineNumber,
+ endLineNumber: selection.endLineNumber,
+ };
+ console.log("Selected text:", selText);
+ console.log("Selected lines:", selLines);
}
+
+ // Update state to open dialog
+ setHighlightedText(selText);
+ setSelectedLines(selLines);
+ setIsDialogOpen(true);
+ console.log("Dialog state set to open:", isDialogOpen);
+ },
+ });
+
+ // Test if action was registered
+ console.log(
+ "Registered action:",
+ editor.getAction("custom-dialog-command"),
+ );
+
+ // Store disposable in ref for cleanup
+ return () => {
+ console.log("Disposing Cmd+K action");
+ disposable.dispose();
+ };
+ }, 500); // Short delay to ensure everything is loaded
+ }, [editorRef, monacoRef, isDialogOpen]);
+
+ // Additional hook to ensure focus when editor content changes
+ useEffect(() => {
+ if (editorRef.current) {
+ editorRef.current.focus();
+ }
+ }, [editorRef]);
+
+ const handleDialogOpen = () => {
+ console.log("Manually opening dialog");
+ setIsDialogOpen(true);
+ };
+
+ console.log("Dialog state:", {
+ isDialogOpen,
+ hasHighlightedText: !!highlightedText,
+ });
+
+ return (
+ <>
+
+ {
+ if (isEditing) {
+ handleEditorChange(value || "");
+ }
+ }}
+ onMount={(editor, monaco) => {
+ console.log("Monaco editor mounted");
+ handleEditorDidMount(editor, monaco);
+ }}
+ />
+
+
+ {/* Debug button to manually open dialog */}
+ {/*
+ Debug: Open Dialog
+ */}
+
+ {/* {
+ console.log("Dialog closed");
+ setIsDialogOpen(false);
}}
- onMount={handleEditorDidMount}
- />
-
+ editorContent={fileContent || ""}
+ highlightedText={highlightedText}
+ selectedLines={selectedLines}
+ /> */}
+ >
);
},
);
diff --git a/src/components/editor/file-monaco-editor/components/theme-selector.tsx b/src/components/editor/file-monaco-editor/components/theme-selector.tsx
index 8684767..c91cbc1 100644
--- a/src/components/editor/file-monaco-editor/components/theme-selector.tsx
+++ b/src/components/editor/file-monaco-editor/components/theme-selector.tsx
@@ -17,7 +17,7 @@ export const ThemeSelector = React.memo(
({ currentTheme, availableThemes, onThemeChange }: ThemeSelectorProps) => {
return (
-
+
diff --git a/src/components/editor/file-monaco-editor/hooks/use-monaco.ts b/src/components/editor/file-monaco-editor/hooks/use-monaco.ts
index 45b7609..0e2e861 100644
--- a/src/components/editor/file-monaco-editor/hooks/use-monaco.ts
+++ b/src/components/editor/file-monaco-editor/hooks/use-monaco.ts
@@ -61,6 +61,7 @@ export const useMonaco = ({
"toml",
"yaml",
"yml",
+ "prisma"
] as const satisfies Parameters[0]["langs"];
for (const lang of ADDITIONAL_LANGUAGES) {
diff --git a/src/components/editor/file-monaco-editor/index.tsx b/src/components/editor/file-monaco-editor/index.tsx
index 81d9762..195cae7 100644
--- a/src/components/editor/file-monaco-editor/index.tsx
+++ b/src/components/editor/file-monaco-editor/index.tsx
@@ -36,7 +36,7 @@ const loadAvailableThemes = async () => {
return data.themes as string[];
} catch (error) {
console.error("Failed to load available themes:", error);
- return ["OneDark-Pro"]; // Fallback to default theme
+ return ["Twilight"]; // Fallback to default theme
}
};
@@ -60,7 +60,7 @@ export const FileMonacoEditor = React.memo(
}: FileMonacoEditorProps) => {
const [isEditing, setIsEditing] = useState(false);
const [availableThemes, setAvailableThemes] = useState([]);
- const [editorTheme, setEditorTheme] = useState("OneDark-Pro");
+ const [editorTheme, setEditorTheme] = useState("Twilight");
const [localContent, setLocalContent] = useState(fileContent);
const [isDirty, setIsDirty] = useState(false);
diff --git a/src/components/file-tree/components/directory-node.tsx b/src/components/file-tree/components/directory-node.tsx
index 6519c34..14d8871 100644
--- a/src/components/file-tree/components/directory-node.tsx
+++ b/src/components/file-tree/components/directory-node.tsx
@@ -6,7 +6,7 @@ import {
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
-import { getFileIcon } from "@/lib/file-utils";
+import { getFileIcon, getFolderIcon } from "@/lib/file-utils";
import { cn } from "@/lib/utils";
import { useDirectoryTreeStore } from "@/store/directory-tree-store";
import { useFileStore } from "@/store/file-store";
@@ -17,7 +17,6 @@ import {
ChevronRight,
FilePlus,
Folder,
- FolderOpen,
FolderPlus,
Pencil,
} from "lucide-react";
@@ -60,40 +59,40 @@ export const DirectoryNode = memo(
// Get directory data from the tree structure
const dirData = useFileStore(
- useShallow(state => {
+ useShallow((state) => {
// Get the directory's path segments
- const pathParts = directory.path.split('/').filter(Boolean);
-
+ const pathParts = directory.path.split("/").filter(Boolean);
+
// Start at the root of the tree
let node = state.tree;
-
+
// If we're at root or tree is not available, return minimal data
if (pathParts.length === 0 || !node) {
- return {
+ return {
node,
isIgnored: state.isIgnored,
- getFileStatus: state.getFileStatus
+ getFileStatus: state.getFileStatus,
};
}
-
+
// Traverse the tree to find this directory's node
for (const part of pathParts) {
if (!node || !node.children[part]) {
- return {
+ return {
node: null,
isIgnored: state.isIgnored,
- getFileStatus: state.getFileStatus
+ getFileStatus: state.getFileStatus,
};
}
node = node.children[part];
}
-
- return {
+
+ return {
node,
isIgnored: state.isIgnored,
- getFileStatus: state.getFileStatus
+ getFileStatus: state.getFileStatus,
};
- })
+ }),
);
const handleToggle = useCallback(async () => {
@@ -133,10 +132,14 @@ export const DirectoryNode = memo(
// Extract children from the tree node
for (const [name, childNode] of Object.entries(dirData.node.children)) {
// Skip ignored files unless showIgnoredFiles is true
- if (childNode.file && !showIgnoredFiles && dirData.isIgnored(childNode.file.path)) {
+ if (
+ childNode.file &&
+ !showIgnoredFiles &&
+ dirData.isIgnored(childNode.file.path)
+ ) {
continue;
}
-
+
if (childNode.file) {
if (childNode.file.isDirectory) {
childDirs.push(childNode.file);
@@ -158,13 +161,13 @@ export const DirectoryNode = memo(
// If we don't have the tree data yet, use a fallback to keep things working
// This can happen during initial load or if we haven't built the tree yet
const shouldFallbackToLegacyMethod = !dirData.node && allFiles.length > 0;
-
+
// Legacy method as fallback - only used if tree data is not available
const legacyChildrenData = useMemo(() => {
if (!shouldFallbackToLegacyMethod) {
return { childDirs: [], childFiles: [] };
}
-
+
const childDirs: typeof allFiles = [];
const childFiles: typeof allFiles = [];
const basePathWithSlash = `${directory.path}/`;
@@ -172,7 +175,7 @@ export const DirectoryNode = memo(
for (const file of allFiles) {
if (file.path === directory.path) continue;
if (!file.path.startsWith(basePathWithSlash)) continue;
-
+
// Skip ignored files unless showIgnoredFiles is true
if (!showIgnoredFiles && isIgnored(file.path)) continue;
@@ -206,11 +209,21 @@ export const DirectoryNode = memo(
childFiles.sort((a, b) => a.name.localeCompare(b.name));
return { childDirs, childFiles };
- }, [shouldFallbackToLegacyMethod, allFiles, directory.path, isIgnored, showIgnoredFiles]);
+ }, [
+ shouldFallbackToLegacyMethod,
+ allFiles,
+ directory.path,
+ isIgnored,
+ showIgnoredFiles,
+ ]);
// Use the appropriate data source
- const finalChildDirs = shouldFallbackToLegacyMethod ? legacyChildrenData.childDirs : childDirs;
- const finalChildFiles = shouldFallbackToLegacyMethod ? legacyChildrenData.childFiles : childFiles;
+ const finalChildDirs = shouldFallbackToLegacyMethod
+ ? legacyChildrenData.childDirs
+ : childDirs;
+ const finalChildFiles = shouldFallbackToLegacyMethod
+ ? legacyChildrenData.childFiles
+ : childFiles;
return (
@@ -243,10 +256,8 @@ export const DirectoryNode = memo(
{loading ? (
- ) : isExpanded ? (
-
) : (
-
+ getFolderIcon(isExpanded, directory.name)
)}
{isRenaming ? (
@@ -398,7 +409,7 @@ export const DirectoryNode = memo(
// Optimizing memo comparison by checking exactly what matters
return (
prev.directory.path === next.directory.path &&
- prev.allFiles === next.allFiles &&
+ prev.allFiles === next.allFiles &&
prev.selectedFile?.path === next.selectedFile?.path &&
prev.showIgnoredFiles === next.showIgnoredFiles &&
prev.renameTarget === next.renameTarget &&
diff --git a/src/components/ide/file-tabs.tsx b/src/components/ide/file-tabs.tsx
new file mode 100644
index 0000000..f0a377e
--- /dev/null
+++ b/src/components/ide/file-tabs.tsx
@@ -0,0 +1,190 @@
+import { Button } from "@/components/ui/button";
+import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { getFileIcon } from "@/lib/file-utils";
+import { useFileStore } from "@/store/file-store";
+import { useIDEStore } from "@/store/ide-store";
+import { X } from "lucide-react";
+import { useCallback, useEffect, useRef, useState } from "react";
+import { useShallow } from "zustand/react/shallow";
+
+import { cn } from "@/lib/utils";
+
+// Custom hook to track scroll indicators
+function useScrollIndicators(ref: React.RefObject) {
+ const [showLeft, setShowLeft] = useState(false);
+ const [showRight, setShowRight] = useState(false);
+
+ useEffect(() => {
+ const checkScroll = () => {
+ const element = ref.current;
+ if (!element) return;
+
+ const hasScrollLeft = element.scrollLeft > 0;
+ const hasScrollRight =
+ element.scrollLeft < element.scrollWidth - element.clientWidth;
+
+ setShowLeft(hasScrollLeft);
+ setShowRight(hasScrollRight);
+ };
+
+ const element = ref.current;
+ if (!element) return;
+
+ // Check on mount and whenever content changes
+ checkScroll();
+
+ // Add scroll event listener
+ element.addEventListener("scroll", checkScroll);
+ // Add resize observer to check when width changes
+ const resizeObserver = new ResizeObserver(checkScroll);
+ resizeObserver.observe(element);
+
+ return () => {
+ element.removeEventListener("scroll", checkScroll);
+ resizeObserver.disconnect();
+ };
+ }, [ref]);
+
+ return { showLeft, showRight };
+}
+
+export function FileTabs() {
+ // Get file tabs from IDE store
+ const { openTabs, activeTab, closeFileTab, setActiveFileTab } = useIDEStore(
+ useShallow((state) => ({
+ openTabs: state.getOpenTabs(),
+ activeTab: state.getActiveFileTab(),
+ closeFileTab: state.closeFileTab,
+ setActiveFileTab: state.setActiveFileTab,
+ })),
+ );
+
+ // Get file loading state and file content loading function
+ const { fileLoading, loadFileContent } = useFileStore(
+ useShallow((state) => ({
+ fileLoading: state.fileLoading,
+ loadFileContent: state.loadFileContent,
+ })),
+ );
+
+ // Ref for the active tab element and tabs list
+ const activeTabRef = useRef(null);
+ const tabsListRef = useRef(null);
+ const { showLeft, showRight } = useScrollIndicators(tabsListRef);
+
+ // Auto-scroll to active tab when it changes
+ useEffect(() => {
+ const scrollToTab = () => {
+ if (activeTabRef.current) {
+ activeTabRef.current.scrollIntoView({
+ behavior: "smooth",
+ block: "nearest",
+ inline: "nearest",
+ });
+ }
+ };
+ scrollToTab();
+ }, []);
+
+ // Handle tab click
+ const handleTabChange = useCallback(
+ (tabId: string) => {
+ // Set active tab in store
+ setActiveFileTab(tabId);
+
+ // Find the file in open tabs
+ const fileTab = openTabs.find((tab) => tab.id === tabId);
+ if (fileTab) {
+ // Load file content using the path
+ loadFileContent({
+ path: fileTab.path,
+ name: fileTab.name,
+ isDirectory: false,
+ lastModified: new Date(),
+ size: 0,
+ });
+ }
+ },
+ [openTabs, setActiveFileTab, loadFileContent],
+ );
+
+ // Handle tab close button click
+ const handleCloseTab = useCallback(
+ (e: React.MouseEvent, tabId: string) => {
+ // Stop both the click and mousedown events from bubbling
+ e.preventDefault();
+ e.stopPropagation();
+ closeFileTab(tabId);
+ },
+ [closeFileTab],
+ );
+
+ // If no tabs are open, don't render the component
+ if (openTabs.length === 0) {
+ return null;
+ }
+
+ return (
+
+
+ {/* Left fade indicator */}
+ {showLeft && (
+
+ )}
+
+ {/* Right fade indicator */}
+ {showRight && (
+
+ )}
+
+
+ {openTabs.map((tab) => (
+ {
+ // Only handle tab activation if the click wasn't on the close button
+ if (!(e.target as HTMLElement).closest("button")) {
+ handleTabChange(tab.id);
+ }
+ }}
+ >
+ {getFileIcon(tab.name)}
+ {tab.name}
+ handleCloseTab(e, tab.id)}
+ onMouseDown={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ }}
+ onMouseUp={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ }}
+ className="ml-1 rounded-full p-1 hover:bg-neutral-700"
+ variant="ghost"
+ size="icon"
+ style={{ pointerEvents: "auto" }}
+ >
+
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/components/ide/file-viewer-panel.tsx b/src/components/ide/file-viewer-panel.tsx
index 4367dbb..00217a3 100644
--- a/src/components/ide/file-viewer-panel.tsx
+++ b/src/components/ide/file-viewer-panel.tsx
@@ -1,13 +1,14 @@
"use client";
import { FileMonacoEditor as FileViewer } from "@/components/editor/file-monaco-editor";
-// import { FileViewer } from "@/components/old/file-monaco-editor";
+import { FileTabs } from "@/components/ide/file-tabs";
import { TiptapViewer } from "@/components/tiptap-editor";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useDebounce } from "@/hooks/use-debounce";
import { createFileSystem } from "@/lib/file-system";
import { useFileStore } from "@/store/file-store";
import { useGitStatusStore } from "@/store/git-status-store";
+import { useIDEStore } from "@/store/ide-store";
import type { FileDiff, FileInfo } from "@/types/files";
import React, { useState, useMemo, useCallback, useEffect } from "react";
import { toast } from "sonner";
@@ -225,8 +226,13 @@ const FileViewerContent = React.memo(
return (
+ {/* File tabs bar */}
+
+
+
+
{selectedFile && (
-
+
onViewChange(value as ViewType)}
@@ -271,6 +277,7 @@ export const FileViewerPanel: React.FC = () => {
useFileStore();
const { gitStatus } = useGitStatusStore();
const [activeView, setActiveView] = useState("code");
+ const addFileTab = useIDEStore((state) => state.addFileTab);
// Memoize the onViewChange callback
const handleViewChange = useCallback((view: ViewType) => {
@@ -311,6 +318,16 @@ export const FileViewerPanel: React.FC = () => {
500,
);
+ // Add file to tabs when selected
+ useEffect(() => {
+ if (selectedFile && !selectedFile.isDirectory) {
+ addFileTab({
+ path: selectedFile.path,
+ name: selectedFile.name
+ });
+ }
+ }, [selectedFile, addFileTab]);
+
// Memoize the current file state to prevent changes when switching folders
const fileState = useMemo(
() => ({
diff --git a/src/components/landing/Hero.tsx b/src/components/landing/Hero.tsx
index eafa210..a79ae2a 100644
--- a/src/components/landing/Hero.tsx
+++ b/src/components/landing/Hero.tsx
@@ -1,3 +1,4 @@
+import { Waitlist } from "@clerk/nextjs";
import {
Tooltip,
TooltipContent,
@@ -6,8 +7,6 @@ import {
} from "@radix-ui/react-tooltip";
import * as motion from "motion/react-client";
import Image from "next/image";
-import Link from "next/link";
-import { ClientTweet } from "./client-tweet";
export function Hero() {
const codingAgents = [
@@ -33,7 +32,7 @@ export function Hero() {
{/* Background */}
{/* Animated gradients */}
@@ -79,12 +78,12 @@ export function Hero() {
Development Environment
-
+
Build better software, faster with AI-powered assistance
-
@@ -110,9 +109,9 @@ export function Hero() {
-
+ */}
-
@@ -138,11 +137,11 @@ export function Hero() {
GitHub
-
+ */}
-
+
Powered by leading AI models:
@@ -187,7 +186,8 @@ export function Hero() {
-
+ {/* */}
+
diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx
index efd89a2..008a25e 100644
--- a/src/components/layout/Header.tsx
+++ b/src/components/layout/Header.tsx
@@ -1,10 +1,12 @@
"use client";
import { cn } from "@/lib/utils";
+import { Waitlist } from "@clerk/nextjs";
import { motion } from "motion/react";
import Image from "next/image";
import Link from "next/link";
import { usePathname } from "next/navigation";
+import { Button } from "../ui/button";
const navItems = [
// { name: "Go to app", href: "/ide" },
@@ -44,6 +46,7 @@ export default function Header({ className }: { className?: string }) {
+
{navItems.map((item, i) => (
{
extensions: [
StarterKit.configure({
heading: {
- levels: [1, 2, 3],
+ levels: [1, 2, 3, 4, 5, 6],
},
paragraph: {
HTMLAttributes: {
@@ -24,12 +21,14 @@ const TiptapViewer = ({ content, className }: TiptapViewerProps) => {
},
},
}),
- Document,
- Paragraph,
- Text,
+ Markdown.configure({
+ html: true,
+ transformPastedText: true,
+ transformCopiedText: true,
+ breaks: true,
+ }),
],
- content: marked.parse(content),
-
+ content: content,
editable: false,
editorProps: {
attributes: {
diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx
index 776e94a..7d7a9d3 100644
--- a/src/components/ui/dialog.tsx
+++ b/src/components/ui/dialog.tsx
@@ -1,135 +1,135 @@
-"use client";
+"use client"
-import * as DialogPrimitive from "@radix-ui/react-dialog";
-import { XIcon } from "lucide-react";
-import type * as React from "react";
+import * as React from "react"
+import * as DialogPrimitive from "@radix-ui/react-dialog"
+import { XIcon } from "lucide-react"
-import { cn } from "@/lib/utils";
+import { cn } from "@/lib/utils"
function Dialog({
- ...props
+ ...props
}: React.ComponentProps) {
- return ;
+ return
}
function DialogTrigger({
- ...props
+ ...props
}: React.ComponentProps) {
- return ;
+ return
}
function DialogPortal({
- ...props
+ ...props
}: React.ComponentProps) {
- return ;
+ return
}
function DialogClose({
- ...props
+ ...props
}: React.ComponentProps) {
- return ;
+ return
}
function DialogOverlay({
- className,
- ...props
+ className,
+ ...props
}: React.ComponentProps) {
- return (
-
- );
+ return (
+
+ )
}
function DialogContent({
- className,
- children,
- ...props
+ className,
+ children,
+ ...props
}: React.ComponentProps) {
- return (
-
-
-
- {children}
-
-
- Close
-
-
-
- );
+ return (
+
+
+
+ {children}
+
+
+ Close
+
+
+
+ )
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
- return (
-
- );
+ return (
+
+ )
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
- return (
-
- );
+ return (
+
+ )
}
function DialogTitle({
- className,
- ...props
+ className,
+ ...props
}: React.ComponentProps) {
- return (
-
- );
+ return (
+
+ )
}
function DialogDescription({
- className,
- ...props
+ className,
+ ...props
}: React.ComponentProps) {
- return (
-
- );
+ return (
+
+ )
}
export {
- Dialog,
- DialogClose,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogOverlay,
- DialogPortal,
- DialogTitle,
- DialogTrigger,
-};
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogOverlay,
+ DialogPortal,
+ DialogTitle,
+ DialogTrigger,
+}
diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx
index ad4d876..1a627bf 100644
--- a/src/components/ui/tabs.tsx
+++ b/src/components/ui/tabs.tsx
@@ -27,6 +27,7 @@ function TabsList({
data-slot="tabs-list"
className={cn(
"inline-flex h-9 w-fit items-center justify-center rounded-lg bg-muted p-[3px] text-muted-foreground",
+ "scrollbar-none [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
className,
)}
{...props}
diff --git a/src/components/ui/textarea.tsx b/src/components/ui/textarea.tsx
new file mode 100644
index 0000000..59c799a
--- /dev/null
+++ b/src/components/ui/textarea.tsx
@@ -0,0 +1,18 @@
+import type * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
+ return (
+
+ );
+}
+
+export { Textarea };
diff --git a/src/env.js b/src/env.js
index c50addc..4620383 100644
--- a/src/env.js
+++ b/src/env.js
@@ -8,6 +8,7 @@ export const env = createEnv({
*/
server: {
DATABASE_URL: z.string().url(),
+ CLERK_SECRET_KEY: z.string(),
NODE_ENV: z
.enum(["development", "test", "production"])
.default("development"),
@@ -19,7 +20,7 @@ export const env = createEnv({
* `NEXT_PUBLIC_`.
*/
client: {
- // NEXT_PUBLIC_CLIENTVAR: z.string(),
+ NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: z.string(),
},
/**
@@ -29,7 +30,9 @@ export const env = createEnv({
runtimeEnv: {
DATABASE_URL: process.env.DATABASE_URL,
NODE_ENV: process.env.NODE_ENV,
- // NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR,
+ CLERK_SECRET_KEY: process.env.CLERK_SECRET_KEY,
+ NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY:
+ process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY,
},
/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially
diff --git a/src/lib/file-utils.tsx b/src/lib/file-utils.tsx
index 171df75..f9c83de 100644
--- a/src/lib/file-utils.tsx
+++ b/src/lib/file-utils.tsx
@@ -1,11 +1,234 @@
import { FileBadge } from "lucide-react";
import Image from "next/image";
+// Helper function to get icon for files without extension
+const getIconForSpecialFile = (fileName: string) => {
+ const lowerFileName = fileName.toLowerCase();
+
+ // Common special files
+ switch (fileName) {
+ case "LICENSE":
+ case "LICENSE.md":
+ case "LICENSE.txt":
+ return (
+
+ );
+ case "README":
+ case "README.md":
+ return (
+
+ );
+ case "CHANGELOG":
+ case "CHANGELOG.md":
+ return (
+
+ );
+ case "CONTRIBUTING":
+ case "CONTRIBUTING.md":
+ return (
+
+ );
+ case "CODE_OF_CONDUCT":
+ case "CODE_OF_CONDUCT.md":
+ return (
+
+ );
+ case ".env":
+ case ".env.local":
+ case ".env.development":
+ case ".env.production":
+ case ".env.example":
+ return (
+
+ );
+ case ".gitignore":
+ case ".gitconfig":
+ case ".gitattributes":
+ return (
+
+ );
+ case "package.json":
+ return (
+
+ );
+ case "package-lock.json":
+ case "yarn.lock":
+ case "pnpm-lock.yaml":
+ return (
+
+ );
+ case "tsconfig.json":
+ case "tsconfig.base.json":
+ return (
+
+ );
+ case "next.config.js":
+ case "next.config.mjs":
+ return (
+
+ );
+ case "vite.config.ts":
+ case "vite.config.js":
+ return (
+
+ );
+ case "tailwind.config.js":
+ case "tailwind.config.ts":
+ return (
+
+ );
+ case "postcss.config.js":
+ return (
+
+ );
+ case "prettier.config.js":
+ case ".prettierrc":
+ case ".prettierrc.json":
+ case ".prettierrc.js":
+ return (
+
+ );
+ case "eslint.config.js":
+ case ".eslintrc":
+ case ".eslintrc.js":
+ case ".eslintrc.json":
+ return (
+
+ );
+ case "jest.config.js":
+ case "jest.config.ts":
+ return (
+
+ );
+ case "vitest.config.ts":
+ case "vitest.config.js":
+ return (
+
+ );
+ case "Dockerfile":
+ return (
+
+ );
+ case "docker-compose.yml":
+ case "docker-compose.yaml":
+ return (
+
+ );
+ case "Makefile":
+ return (
+
+ );
+ default:
+ if (lowerFileName.includes("dockerfile")) {
+ return (
+
+ );
+ }
+ return null;
+ }
+};
+
// Get file icon based on extension
export const getFileIcon = (fileName: string) => {
+ // First check for special files without extensions or with specific names
+ const specialFileIcon = getIconForSpecialFile(fileName);
+ if (specialFileIcon) {
+ return specialFileIcon;
+ }
+
const ext = fileName.split(".").pop()?.toLowerCase();
- // First try to match with available icon in the public/icons directory
+ // Then try to match with available icon in the public/icons directory
switch (ext) {
// JavaScript/TypeScript files
case "js":
@@ -44,9 +267,29 @@ export const getFileIcon = (fileName: string) => {
height={18}
/>
);
+ case "mjs":
+ case "cjs":
+ return (
+
+ );
+ case "d.ts":
+ return (
+
+ );
// Web files
case "html":
+ case "htm":
return (
);
@@ -59,6 +302,28 @@ export const getFileIcon = (fileName: string) => {
return (
);
+ case "less":
+ return (
+
+ );
+ case "styl":
+ return (
+
+ );
+ case "postcss":
+ return (
+
+ );
// Config files
case "json":
@@ -75,9 +340,21 @@ export const getFileIcon = (fileName: string) => {
return (
);
+ case "ini":
+ case "conf":
+ case "config":
+ return (
+
+ );
// Documentation
case "md":
+ case "mdx":
return (
{
return (
);
+ case "pdf":
+ return (
+
+ );
+ case "doc":
+ case "docx":
+ return (
+
+ );
+ case "xls":
+ case "xlsx":
+ return (
+
+ );
+ case "ppt":
+ case "pptx":
+ return (
+
+ );
// Images
case "svg":
@@ -100,6 +401,9 @@ export const getFileIcon = (fileName: string) => {
case "jpg":
case "jpeg":
case "gif":
+ case "bmp":
+ case "ico":
+ case "webp":
return (
{
/>
);
+ // Media
+ case "mp3":
+ case "wav":
+ case "ogg":
+ case "flac":
+ return (
+
+ );
+ case "mp4":
+ case "webm":
+ case "mkv":
+ case "avi":
+ case "mov":
+ return (
+
+ );
+
+ // Archives
+ case "zip":
+ case "rar":
+ case "7z":
+ case "tar":
+ case "gz":
+ return (
+
+ );
+
// Languages
case "go":
+ case "mod":
return ;
case "py":
return (
@@ -131,20 +463,50 @@ export const getFileIcon = (fileName: string) => {
);
case "java":
+ case "class":
+ case "jar":
return (
);
case "c":
return ;
+ case "h":
+ return (
+
+ );
case "cpp":
+ case "cc":
+ case "cxx":
return (
);
+ case "hpp":
+ case "hxx":
+ return (
+
+ );
+ case "cs":
+ return (
+
+ );
case "sh":
case "bash":
+ case "zsh":
+ case "fish":
+ case "ksh":
+ case "csh":
+ case "tcsh":
+ case "ps1": // PowerShell
+ case "psm1": // PowerShell module
+ case "psd1": // PowerShell data
return (
{
return (
);
- case "prisma":
+ case "svelte":
return (
);
- case "graphql":
+ case "prisma":
return (
);
- case "docker":
- case "dockerfile":
+ case "graphql":
+ case "gql":
return (
);
- case "terraform":
case "tf":
+ case "tfvars":
+ case "hcl":
return (
{
/>
);
case "xml":
+ case "xaml":
return (
);
@@ -213,41 +577,100 @@ export const getFileIcon = (fileName: string) => {
return (
);
- case "env":
+ case "kt":
+ case "kts":
return (
-
+
);
- case "gitignore":
+ case "dart":
return (
-
+
);
- case "gitconfig":
+ case "ex":
+ case "exs":
return (
-
+
);
- case "gitattributes":
+ case "elm":
return (
-
+
);
- case "LICENSE":
+ case "erl":
+ return (
+
+ );
+ case "fs":
+ case "fsx":
return (
-
+
);
- case "README":
- return ;
- case "CONTRIBUTING":
- return ;
- case "CHANGELOG":
- return ;
- case "CODE_OF_CONDUCT":
- return ;
- case "ISSUE_TEMPLATE":
- return ;
- case "PULL_REQUEST_TEMPLATE":
- return ;
-
-
-
+ case "hs":
+ return (
+
+ );
+ case "lua":
+ return (
+
+ );
+ case "r":
+ return ;
+ case "scala":
+ return (
+
+ );
+ case "pl":
+ case "pm":
+ return (
+
+ );
+ case "rkt":
+ return (
+
+ );
+ case "clj":
+ case "cljs":
+ return (
+
+ );
+ case "vim":
+ return (
+
+ );
+ case "astro":
+ return (
+
+ );
+
// Fallback to Lucide icons for types without matching SVG icons
default:
return ;
@@ -292,35 +715,441 @@ export const formatDate = (date: Date): string => {
// Is this a text file that can be displayed?
export const isTextFile = (fileName: string) => {
const textExtensions = [
+ // Documentation
"txt",
"md",
+ "mdx",
+ "rst",
+ "tex",
+ "adoc",
+ "asc",
+ "asciidoc",
+
+ // Web
+ "html",
+ "htm",
+ "xhtml",
+ "css",
+ "scss",
+ "sass",
+ "less",
+ "styl",
"js",
"jsx",
"ts",
"tsx",
- "css",
- "scss",
- "html",
+ "mjs",
+ "cjs",
"json",
"jsonc",
+ "vue",
+ "svelte",
+ "astro",
+
+ // Config
"toml",
"yml",
"yaml",
"xml",
- "svg",
+ "ini",
+ "conf",
+ "config",
+ "env",
+ "properties",
+ "prop",
+ "cfg",
+
+ // Programming Languages
"py",
"rb",
- "sh",
- "bash",
+ "php",
+ "java",
+ "kt",
+ "kts",
"c",
- "cpp",
"h",
- "java",
- "php",
- "go",
- "rust",
+ "cpp",
+ "hpp",
+ "cc",
+ "cxx",
+ "hxx",
+ "cs",
"fs",
+ "fsx",
+ "go",
+ "rs",
+ "swift",
+ "sh",
+ "bash",
+ "zsh",
+ "fish",
+ "lua",
+ "tcl",
+ "pl",
+ "pm",
+ "t",
+ "r",
+ "scala",
+ "groovy",
+ "dart",
+ "ex",
+ "exs",
+ "erl",
+ "hrl",
+ "elm",
+ "hs",
+ "lhs",
+ "rkt",
+ "clj",
+ "cljs",
+ "vim",
+ "sql",
+ "graphql",
+ "gql",
+
+ // Build/Config
+ "Dockerfile",
+ "docker-compose.yml",
+ "docker-compose.yaml",
+ "Makefile",
+ "cmake",
+ "ninja",
+ "gradle",
+ "pom",
+ "ivy",
+
+ // Version Control
+ "gitignore",
+ "gitattributes",
+ "gitmodules",
+
+ // Lock files
+ "lock",
+ "lockfile",
+
+ // Other
+ "log",
+ "diff",
+ "patch",
+ ];
+
+ // First check for special files without extensions
+ const specialFiles = [
+ "LICENSE",
+ "README",
+ "CHANGELOG",
+ "CONTRIBUTING",
+ "CODE_OF_CONDUCT",
+ "AUTHORS",
+ "MAINTAINERS",
+ "Dockerfile",
+ "Makefile",
+ "Vagrantfile",
+ ".env",
+ ".gitignore",
+ ".prettierrc",
+ ".eslintrc",
];
+
+ if (specialFiles.includes(fileName)) {
+ return true;
+ }
+
const ext = fileName.split(".").pop()?.toLowerCase();
return textExtensions.includes(ext || "");
};
+
+// Get folder icon based on state and name
+export const getFolderIcon = (isOpen: boolean, folderName?: string) => {
+ // Use default 'other' icon if no name provided
+ if (!folderName) {
+ return isOpen ? (
+
+ ) : (
+
+ );
+ }
+
+ const name = folderName.toLowerCase();
+
+ // List derived from the ls command output
+ const specialFolders = [
+ "admin",
+ "android",
+ "angular",
+ "animation",
+ "ansible",
+ "api",
+ "apollo",
+ "app",
+ "archive",
+ "astro",
+ "audio",
+ "aurelia",
+ "aws",
+ "azure-pipelines",
+ "base",
+ "batch",
+ "benchmark",
+ "bicep",
+ "bloc",
+ "bower",
+ "buildkite",
+ "cart",
+ "changesets",
+ "ci",
+ "circleci",
+ "class",
+ "client",
+ "cline",
+ "cloud-functions",
+ "cloudflare",
+ "cluster",
+ "cobol",
+ "command",
+ "components",
+ "config",
+ "connection",
+ "console",
+ "constant",
+ "container",
+ "content",
+ "context",
+ "contract",
+ "controller",
+ "core",
+ "coverage",
+ "css",
+ "custom",
+ "cypress",
+ "dart",
+ "database",
+ "debug",
+ "decorators",
+ "delta",
+ "desktop",
+ "directive",
+ "dist",
+ "docker",
+ "docs",
+ "download",
+ "drizzle",
+ "dump",
+ "element",
+ "enum",
+ "environment",
+ "error",
+ "event",
+ "examples",
+ "expo",
+ "export",
+ "fastlane",
+ "favicon",
+ "firebase",
+ "firestore",
+ "flow",
+ "flutter",
+ "font",
+ "forgejo",
+ "functions",
+ "gamemaker",
+ "generator",
+ "gh-workflows",
+ "git",
+ "gitea",
+ "github",
+ "gitlab",
+ "global",
+ "godot",
+ "gradle",
+ "graphql",
+ "guard",
+ "gulp",
+ "helm",
+ "helper",
+ "home",
+ "hook",
+ "husky",
+ "i18n",
+ "images",
+ "import",
+ "include",
+ "intellij",
+ "interface",
+ "ios",
+ "java",
+ "javascript",
+ "jinja",
+ "job",
+ "json",
+ "jupyter",
+ "keys",
+ "kubernetes",
+ "kusto",
+ "layout",
+ "lefthook",
+ "less",
+ "lib",
+ "linux",
+ "liquibase",
+ "log",
+ "lottie",
+ "lua",
+ "luau",
+ "macos",
+ "mail",
+ "mappings",
+ "markdown",
+ "mercurial",
+ "messages",
+ "meta",
+ "middleware",
+ "mjml",
+ "mobile",
+ "mock",
+ "mojo",
+ "moon",
+ "netlify",
+ "next",
+ "ngrx-store",
+ "node",
+ "nuxt",
+ "obsidian",
+ "other",
+ "packages",
+ "pdf",
+ "pdm",
+ "php",
+ "phpmailer",
+ "pipe",
+ "plastic",
+ "plugin",
+ "policy",
+ "powershell",
+ "prisma",
+ "private",
+ "project",
+ "proto",
+ "public",
+ "python",
+ "quasar",
+ "queue",
+ "react-components",
+ "redux-reducer",
+ "repository",
+ "resolver",
+ "resource",
+ "review",
+ "robot",
+ "routes",
+ "rules",
+ "rust",
+ "sandbox",
+ "sass",
+ "scala",
+ "scons",
+ "scripts",
+ "secure",
+ "seeders",
+ "server",
+ "serverless",
+ "shader",
+ "shared",
+ "snapcraft",
+ "snippet",
+ "src",
+ "src-tauri",
+ "stack",
+ "stencil",
+ "store",
+ "storybook",
+ "stylus",
+ "sublime",
+ "supabase",
+ "svelte",
+ "svg",
+ "syntax",
+ "target",
+ "taskfile",
+ "tasks",
+ "television",
+ "temp",
+ "template",
+ "terraform",
+ "test",
+ "theme",
+ "tools",
+ "trash",
+ "turborepo",
+ "typescript",
+ "ui",
+ "unity",
+ "update",
+ "upload",
+ "utils",
+ "vercel",
+ "verdaccio",
+ "video",
+ "views",
+ "vm",
+ "vscode",
+ "vue-directives",
+ "vue",
+ "vuepress",
+ "vuex-store",
+ "wakatime",
+ "webpack",
+ "windows",
+ "wordpress",
+ "yarn",
+ "zeabur",
+ ];
+
+ // Check if folder name exactly matches one of our special folders
+ if (specialFolders.includes(name)) {
+ const iconPath = `/icons/folder-${name}${isOpen ? "-open" : ""}.svg`;
+ const altText = `${isOpen ? "Open " : ""}${folderName} folder`;
+ // NOTE: We assume the corresponding open/closed icon exists based on the ls output
+ // A more robust solution might check file existence, but this is simpler for now.
+ return (
+
+ );
+ }
+
+ // Default folder icon for non-special folders (using 'other')
+ return isOpen ? (
+
+ ) : (
+
+ );
+};
diff --git a/src/middleware.ts b/src/middleware.ts
new file mode 100644
index 0000000..9b730c5
--- /dev/null
+++ b/src/middleware.ts
@@ -0,0 +1,12 @@
+import { clerkMiddleware } from "@clerk/nextjs/server";
+
+export default clerkMiddleware();
+
+export const config = {
+ matcher: [
+ // Skip Next.js internals and all static files, unless found in search params
+ "/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)",
+ // Always run for API routes
+ "/(api|trpc)(.*)",
+ ],
+};
diff --git a/src/store/file-store.ts b/src/store/file-store.ts
index b61fe87..28b1ec4 100644
--- a/src/store/file-store.ts
+++ b/src/store/file-store.ts
@@ -607,10 +607,11 @@ export const useFileStore = create()((set, get): FileState => ({
// Handle directory navigation for files
handleFileClick: (file: FileInfo) => {
const { loadDirectory, loadFileContent } = get();
-
+
if (file.isDirectory) {
loadDirectory(file.path);
} else {
+ // Set the file tab and load the content
loadFileContent(file);
}
},
diff --git a/src/store/ide-store.ts b/src/store/ide-store.ts
index 2978177..f363fb8 100644
--- a/src/store/ide-store.ts
+++ b/src/store/ide-store.ts
@@ -7,6 +7,12 @@ export interface Project {
name: string;
}
+export interface FileTab {
+ id: string; // File path as ID
+ name: string;
+ path: string;
+}
+
interface MonacoThemeData {
base: string;
inherit: boolean;
@@ -36,6 +42,10 @@ interface IDEState {
monacoThemes: Record;
projects: Project[];
activeProject: string | null;
+
+ // File tabs related state
+ openTabs: Record; // Map of projectPath -> opened file tabs
+ activeTab: Record; // Map of projectPath -> active tab id
// Actions
setCurrentAgent: (agentId: string | null) => void;
@@ -51,6 +61,13 @@ interface IDEState {
removeProject: (projectPath: string) => void;
setActiveProject: (projectPath: string) => void;
getProjectByPath: (projectPath: string) => Project | undefined;
+
+ // File tabs related actions
+ addFileTab: (file: { path: string; name: string }) => void;
+ closeFileTab: (tabId: string) => void;
+ setActiveFileTab: (tabId: string | null) => void;
+ getOpenTabs: () => FileTab[];
+ getActiveFileTab: () => string | null;
}
export const useIDEStore = create()(
@@ -67,6 +84,8 @@ export const useIDEStore = create()(
monacoThemes: {},
projects: [],
activeProject: null,
+ openTabs: {},
+ activeTab: {},
// Actions
setCurrentAgent: (agentId: string | null) =>
@@ -115,18 +134,38 @@ export const useIDEStore = create()(
};
}
+ // Ensure openTabs & activeTab have entries for the new project
+ const openTabs = { ...state.openTabs };
+ openTabs[projectPath] = [];
+
+ const activeTab = { ...state.activeTab };
+ activeTab[projectPath] = null;
+
return {
projects: [...state.projects, newProject],
activeProject: projectPath,
+ openTabs,
+ activeTab
};
}),
removeProject: (projectPath: string) =>
- set((state) => ({
- projects: state.projects.filter((p) => p.path !== projectPath),
- activeProject:
- state.activeProject === projectPath ? null : state.activeProject,
- })),
+ set((state) => {
+ // Create new objects without the removed project
+ const openTabs = { ...state.openTabs };
+ delete openTabs[projectPath];
+
+ const activeTab = { ...state.activeTab };
+ delete activeTab[projectPath];
+
+ return {
+ projects: state.projects.filter((p) => p.path !== projectPath),
+ activeProject:
+ state.activeProject === projectPath ? null : state.activeProject,
+ openTabs,
+ activeTab
+ };
+ }),
setActiveProject: (projectPath: string) => {
set(() => ({
@@ -144,6 +183,105 @@ export const useIDEStore = create()(
getProjectByPath: (projectPath: string) => {
return get().projects.find((p) => p.path === projectPath);
},
+
+ // File tabs related actions
+ addFileTab: (file) => {
+ const { activeProject } = get();
+ if (!activeProject) return;
+
+ set((state) => {
+ // Get current tabs for this project
+ const projectTabs = state.openTabs[activeProject] || [];
+
+ // Check if file is already open
+ const existingTabIndex = projectTabs.findIndex(tab => tab.id === file.path);
+
+ // If already open, just set it as active
+ if (existingTabIndex >= 0) {
+ const activeTab = { ...state.activeTab };
+ activeTab[activeProject] = file.path;
+
+ return { activeTab };
+ }
+
+ // Otherwise, add new tab and set as active
+ const newTab: FileTab = {
+ id: file.path,
+ name: file.name,
+ path: file.path
+ };
+
+ const openTabs = { ...state.openTabs };
+ const tabs = [...projectTabs, newTab];
+ openTabs[activeProject] = tabs;
+
+ const activeTab = { ...state.activeTab };
+ activeTab[activeProject] = file.path;
+
+ return { openTabs, activeTab };
+ });
+ },
+
+ closeFileTab: (tabId) => {
+ const { activeProject } = get();
+ if (!activeProject) return;
+
+ set((state) => {
+ // Get current tabs for this project
+ const projectTabs = state.openTabs[activeProject] || [];
+
+ // Find the tab to close
+ const tabIndex = projectTabs.findIndex(tab => tab.id === tabId);
+ if (tabIndex === -1) return state;
+
+ // Create new tabs array without the closed tab
+ const newTabs = projectTabs.filter((_, i) => i !== tabIndex);
+
+ // Update openTabs
+ const openTabs = { ...state.openTabs };
+ openTabs[activeProject] = newTabs;
+
+ // Determine new active tab
+ const activeTab = { ...state.activeTab };
+ const currentActive = activeTab[activeProject];
+
+ // If we're closing the active tab, set a new active tab
+ if (currentActive === tabId) {
+ if (newTabs.length > 0) {
+ // Try to select the tab to the left, or the first tab
+ const newActiveIndex = Math.min(tabIndex, newTabs.length - 1);
+ activeTab[activeProject] = newTabs[newActiveIndex]?.id || null;
+ } else {
+ activeTab[activeProject] = null;
+ }
+ }
+
+ return { openTabs, activeTab };
+ });
+ },
+
+ setActiveFileTab: (tabId) => {
+ const { activeProject } = get();
+ if (!activeProject) return;
+
+ set((state) => {
+ const activeTab = { ...state.activeTab };
+ activeTab[activeProject] = tabId;
+ return { activeTab };
+ });
+ },
+
+ getOpenTabs: () => {
+ const { activeProject, openTabs } = get();
+ if (!activeProject) return [];
+ return openTabs[activeProject] || [];
+ },
+
+ getActiveFileTab: () => {
+ const { activeProject, activeTab } = get();
+ if (!activeProject) return null;
+ return activeTab[activeProject] || null;
+ }
}),
{
name: "ide-store",
diff --git a/src/styles/globals.css b/src/styles/globals.css
index 7eca503..3dd5fe2 100644
--- a/src/styles/globals.css
+++ b/src/styles/globals.css
@@ -143,7 +143,7 @@
to right,
var(--foreground) 1px,
transparent 1px
- ), linear-gradient(to bottom, var(--foreground) 1px, transparent 1px);
+ ), linear-gradient(to top, var(--foreground) 1px, transparent 1px);
background-attachment: fixed;
background-position: 0 0;
}
diff --git a/src/workers/file-system.worker.ts b/src/workers/file-system.worker.ts
index 0c5315d..195baa4 100644
--- a/src/workers/file-system.worker.ts
+++ b/src/workers/file-system.worker.ts
@@ -117,15 +117,15 @@ async function listFilesNonRecursively(dirPath = ""): Promise {
const files: FileInfo[] = [];
try {
- console.log("[Worker] Starting to list directory contents");
+ // console.log("[Worker] Starting to list directory contents");
const handle = dirHandle as FileSystemDirectoryHandleWithValues;
for await (const entry of handle.values()) {
const entryPath = dirPath ? `${dirPath}/${entry.name}` : entry.name;
- console.log("[Worker] Processing entry:", entryPath);
+ // console.log("[Worker] Processing entry:", entryPath);
const fileInfo = await getFileInfo(entry, entry.name, entryPath);
files.push(fileInfo);
}
- console.log("[Worker] Found", files.length, "files in directory");
+ // console.log("[Worker] Found", files.length, "files in directory");
} catch (error) {
console.error("[Worker] Error listing files in", dirPath, ":", error);
}