├── .env.example ├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── app ├── 404 │ └── page.tsx ├── actions.tsx ├── actions │ └── github.ts ├── globals.css ├── layout.tsx ├── login │ └── page.tsx ├── new │ └── page.tsx ├── page.tsx ├── project │ ├── [id] │ │ └── page.tsx │ └── layout.tsx └── projects │ └── page.tsx ├── components.json ├── components ├── editor │ ├── constants │ │ └── editorDefaults.tsx │ ├── editor-container.tsx │ ├── editor.tsx │ ├── hooks │ │ ├── useAIAssist.tsx │ │ ├── useEditorSetup.tsx │ │ ├── useEditorTheme.tsx │ │ └── useLatexSyntaxHighlighting.tsx │ ├── image-viewer.tsx │ ├── types.ts │ └── utils │ │ ├── WidgetCreator.tsx │ │ ├── applyEdit.tsx │ │ ├── calculateDiff.tsx │ │ └── promptModal.tsx ├── file-tree │ ├── file-tree-loading.tsx │ ├── file-tree-node.jsx │ └── file-tree.jsx ├── latex-render │ ├── latex-canvas.tsx │ ├── latex-error.tsx │ ├── latex-loading.tsx │ └── latex.tsx ├── loading │ └── loading-page.tsx ├── nav │ ├── loading-side-nav.tsx │ └── side-nav.tsx ├── page-utils │ └── grid-texture.tsx ├── profile │ └── profile.tsx ├── projects │ ├── auth-buttons.tsx │ ├── project-card.tsx │ ├── project-nav.tsx │ └── project-skeleton.tsx ├── theme-provider.tsx └── ui │ ├── alert.tsx │ ├── avatar.tsx │ ├── badge.tsx │ ├── button.tsx │ ├── card.tsx │ ├── context-menu.tsx │ ├── dialog.tsx │ ├── dropdown-menu.tsx │ ├── input.tsx │ ├── label.tsx │ ├── mode-toggle.tsx │ ├── popover.tsx │ ├── rainbow-button.tsx │ ├── resizable.tsx │ ├── scroll-area.tsx │ ├── select.tsx │ ├── separator.tsx │ ├── skeleton.tsx │ ├── switch.tsx │ ├── textarea.tsx │ ├── toggle.tsx │ └── tooltip.tsx ├── contexts ├── FrontendContext.tsx └── ProjectContext.tsx ├── hooks ├── data.ts └── useDebounce.tsx ├── lib ├── constants.ts ├── constants │ └── templates.ts ├── utils.ts └── utils │ ├── client-utils.ts │ ├── db-utils.ts │ └── pdf-utils.ts ├── next.config.js ├── next.config.mjs ├── package-lock.json ├── package.json ├── postcss.config.mjs ├── public ├── article_preview.webp ├── blank_preview.webp ├── favicon.ico ├── hero.png ├── letter_preview.webp ├── next.svg ├── pdf.worker.mjs ├── placeholder.svg ├── proposal_preview.webp ├── report_preview.webp ├── resume_preview.webp ├── tex.tsx └── vercel.svg ├── railway-api ├── .gitignore ├── main.py ├── railway.json └── requirements.txt ├── tailwind.config.ts └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | ANTHROPIC_API_KEY=sk-ant-apixx-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx 2 | INSTANT_APP_ADMIN_TOKEN=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx 3 | NEXT_PUBLIC_INSTANT_APP_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx 4 | NEXT_PUBLIC_RAILWAY_ENDPOINT_URL=https://your-railway-endpoint-url.com/ -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | .env 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "tabWidth": 2, 5 | "trailingComma": "es5", 6 | "printWidth": 120, 7 | "bracketSpacing": true, 8 | "arrowParens": "always", 9 | "endOfLine": "lf" 10 | } 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Shelwin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Jules: AI LaTeX Editor 2 | 3 | [Jules](https://juleseditor.com) is a proof of concept AI-Latex Editor. You select a range, type into the dialogue, and the LLM will (hopefully) create a diff that turns your natural language request into compileable LaTeX. 4 | 5 | https://github.com/user-attachments/assets/b5587351-7ff4-40be-9ba1-bffdbbb71b39 6 | 7 | 8 | It has basic LaTeX project management features, like adding/deleting files and folders, images, svgs, etc. `main.tex` is required to compile. 9 | 10 | Note this is a `proof-of-concept` and not a "production" application. There will be bugs, important missing features, and UX issues that make it not quite ready yet for daily usage. 11 | 12 | ## Local Setup 13 | 14 | To get Jules working locally, you need: 15 | - An Anthropic Key 16 | - An App ID from [InstantDB](https://www.instantdb.com/) 17 | - A deployed instance of the API on railway (`railway-api`). 18 | 19 | Set those in a `.env.local` file. 20 | 21 | You can then: 22 | 23 | ``` 24 | git clone git@github.com:shelwinsunga/jules.git 25 | cd jules 26 | npm install i 27 | npm run dev 28 | ``` 29 | 30 | You can run the flask endpoint locally by running: 31 | 32 | ``` 33 | cd railway-api 34 | hypercorn main:app --reload 35 | ``` 36 | 37 | You need pdflatex installed as well as the stuff in requirements.txt 38 | 39 | 40 | ## Acknowledgments 41 | 42 | Created by [Shelwin Sunga](https://x.com/shelwin_). Licensed under the [MIT License](https://github.com/shelwinsunga/jules/blob/main/LICENSE) 43 | 44 | - Inspired by Cursor and Overleaf 45 | 46 | -------------------------------------------------------------------------------- /app/404/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link' 2 | import { Home } from 'lucide-react' 3 | 4 | export default function NotFound() { 5 | return ( 6 |
7 |
8 |

9 | 10 | 404 11 | 12 |

13 |

14 | Whoops! Page not found 15 |

16 |
17 |

18 | Looks like you've stumbled into the void. Don't panic! Even the best of us get lost in cyberspace sometimes. 19 |

20 |
21 | 25 | 26 | Beam Me Home, Scotty! 27 | 28 |
29 |
30 | ) 31 | } -------------------------------------------------------------------------------- /app/actions.tsx: -------------------------------------------------------------------------------- 1 | 'use server' 2 | 3 | import { streamText } from 'ai' 4 | import { openai } from '@ai-sdk/openai' 5 | import { createStreamableValue } from 'ai/rsc' 6 | import { anthropic } from '@ai-sdk/anthropic' 7 | export async function generate(input: string) { 8 | const stream = createStreamableValue({ content: '', isComplete: false }) 9 | 10 | ;(async () => { 11 | const { textStream } = await streamText({ 12 | model: anthropic('claude-3-5-sonnet-20240620'), 13 | system: 14 | 'Imagine you are embedded in a latex editor. You only write latex. You do not write anything else or converse; you only write latex.' + 15 | "Do not write in backticks like this: ```latex ...```. Write your latex directly. You must consider the context of where you're starting from and ending from. For example, if you start in the middle of the document, would not write documentclass{article} or \begin{document} and add packages. You must consider the context of where you're starting from and ending from.", 16 | prompt: input, 17 | }) 18 | 19 | for await (const delta of textStream) { 20 | stream.update({ content: delta, isComplete: false }) 21 | } 22 | 23 | stream.update({ content: '', isComplete: true }) 24 | stream.done() 25 | })() 26 | 27 | return { output: stream.value } 28 | } 29 | 30 | // 'use server'; 31 | 32 | // import { createStreamableValue } from 'ai/rsc'; 33 | 34 | // export async function generate(input: string) { 35 | // const stream = createStreamableValue({content: '', isComplete: false}); 36 | 37 | // (async () => { 38 | // const fakeLatexContent = `section{Introduction} 39 | // This is a sample introduction to demonstrate the fake stream. 40 | 41 | // \\subsection{Background} 42 | // Here's some background information about the topic. 43 | 44 | // \\section{Methodology} 45 | // We used the following methods in our study: 46 | // \\begin{itemize} 47 | // \\item Method 1 48 | // \\item Method 2 49 | // \\item Method 3 50 | // \\end{itemize} 51 | 52 | // \\section{Results} 53 | // Our results show that...`; 54 | 55 | // for (const char of fakeLatexContent) { 56 | // await new Promise(resolve => setTimeout(resolve, 1)); // Simulate delay 57 | // stream.update({content: char, isComplete: false}); 58 | // } 59 | 60 | // stream.update({content: '', isComplete: true}); 61 | // stream.done(); 62 | // })(); 63 | 64 | // return { output: stream.value}; 65 | // } 66 | -------------------------------------------------------------------------------- /app/actions/github.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 20 14.3% 4.1%; 9 | --card: 0 0% 100%; 10 | --card-foreground: 20 14.3% 4.1%; 11 | --popover: 0 0% 100%; 12 | --popover-foreground: 20 14.3% 4.1%; 13 | --primary: 47.9 95.8% 53.1%; 14 | --primary-foreground: 26 83.3% 14.1%; 15 | --secondary: 60 4.8% 95.9%; 16 | --secondary-foreground: 24 9.8% 10%; 17 | --muted: 60 4.8% 95.9%; 18 | --muted-foreground: 25 5.3% 44.7%; 19 | --accent: 60 4.8% 95.9%; 20 | --accent-foreground: 24 9.8% 10%; 21 | --destructive: 0 84.2% 60.2%; 22 | --destructive-foreground: 60 9.1% 97.8%; 23 | --border: 20 5.9% 90%; 24 | --input: 20 5.9% 90%; 25 | --ring: 20 14.3% 4.1%; 26 | --radius: 0.5rem; 27 | --chart-1: 12 76% 61%; 28 | --chart-2: 173 58% 39%; 29 | --chart-3: 197 37% 24%; 30 | --chart-4: 43 74% 66%; 31 | --chart-5: 27 87% 67%; 32 | --color-1: 0 100% 63%; 33 | --color-2: 270 100% 63%; 34 | --color-3: 210 100% 63%; 35 | --color-4: 195 100% 63%; 36 | --color-5: 90 100% 63%; 37 | } 38 | 39 | .dark { 40 | --background: 0 0 5%; 41 | --foreground: 60 9.1% 97.8%; 42 | --card: 20 14.3% 4.1%; 43 | --card-foreground: 60 9.1% 97.8%; 44 | --popover: 20 14.3% 4.1%; 45 | --popover-foreground: 60 9.1% 97.8%; 46 | --primary: 47.9 95.8% 53.1%; 47 | --primary-foreground: 26 83.3% 14.1%; 48 | --secondary: 12 6.5% 15.1%; 49 | --secondary-foreground: 60 9.1% 97.8%; 50 | --muted: 12 6.5% 15.1%; 51 | --muted-foreground: 24 5.4% 63.9%; 52 | --accent: 12 6.5% 15.1%; 53 | --accent-foreground: 60 9.1% 97.8%; 54 | --destructive: 0 62.8% 30.6%; 55 | --destructive-foreground: 60 9.1% 97.8%; 56 | --border: 12 6.5% 15.1%; 57 | --input: 12 6.5% 15.1%; 58 | --ring: 35.5 91.7% 32.9%; 59 | --chart-1: 220 70% 50%; 60 | --chart-2: 160 60% 45%; 61 | --chart-3: 30 80% 55%; 62 | --chart-4: 280 65% 60%; 63 | --chart-5: 340 75% 55%; 64 | --color-1: 0 100% 63%; 65 | --color-2: 270 100% 63%; 66 | --color-3: 210 100% 63%; 67 | --color-4: 195 100% 63%; 68 | --color-5: 90 100% 63%; 69 | } 70 | } 71 | 72 | @layer base { 73 | * { 74 | @apply border-border; 75 | } 76 | body { 77 | @apply bg-background text-foreground; 78 | } 79 | 80 | .monaco-editor .scroll-decoration { 81 | box-shadow: none !important; 82 | } 83 | } 84 | 85 | .diff-old-content { 86 | background-color: #ffeeee; 87 | } 88 | .diff-new-content { 89 | background-color: #eeffee; 90 | } 91 | 92 | .dark .diff-old-content { 93 | background-color: #2a1414; 94 | } 95 | .dark .diff-new-content { 96 | background-color: #142a14; 97 | } 98 | 99 | .tree-overflow { 100 | overflow: auto; 101 | scrollbar-width: thin; 102 | scrollbar-color: hsl(var(--border)) transparent; 103 | } 104 | 105 | .tree-overflow::-webkit-scrollbar { 106 | width: 10px; 107 | height: 10px; 108 | } 109 | 110 | .tree-overflow::-webkit-scrollbar-track { 111 | background: transparent; 112 | } 113 | 114 | .tree-overflow::-webkit-scrollbar-thumb { 115 | background-color: hsl(var(--border)); 116 | border-radius: 9999px; 117 | border: 3px solid transparent; 118 | background-clip: content-box; 119 | } 120 | 121 | .tree-overflow::-webkit-scrollbar-thumb:hover { 122 | background-color: hsl(var(--border) / 0.8); 123 | } 124 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next' 2 | import { Inter } from 'next/font/google' 3 | import './globals.css' 4 | import { FrontendProvider } from '@/contexts/FrontendContext' 5 | import { ThemeProvider } from '@/components/theme-provider' 6 | import { TooltipProvider } from '@/components/ui/tooltip' 7 | import { Analytics } from "@vercel/analytics/react" 8 | 9 | const inter = Inter({ subsets: ['latin'] }) 10 | export const metadata: Metadata = { 11 | title: 'Jules', 12 | description: 'AI LaTeX Editor', 13 | icons: { 14 | icon: '/favicon.ico', 15 | }, 16 | } 17 | 18 | export default function RootLayout({ 19 | children, 20 | }: Readonly<{ 21 | children: React.ReactNode 22 | }>) { 23 | return ( 24 | 25 | 26 | 27 | 28 | 29 |
30 | {children} 31 | 32 |
33 |
34 |
35 |
36 | 37 | 38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /app/login/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import React, { useState } from 'react' 4 | import { Button } from '@/components/ui/button' 5 | import { Input } from '@/components/ui/input' 6 | import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card' 7 | import { useRouter } from 'next/navigation' 8 | import { db } from '@/lib/constants' 9 | import { useEffect } from 'react' 10 | import { tx } from '@instantdb/react' 11 | 12 | export default function Login() { 13 | const router = useRouter() 14 | const { isLoading, user, error } = db.useAuth() 15 | 16 | useEffect(() => { 17 | if (user) { 18 | const userProperties = Object.entries(user).reduce((acc, [key, value]) => { 19 | if (key !== 'id') { 20 | acc[key] = value; 21 | } 22 | return acc; 23 | }, {} as Record); 24 | 25 | db.transact(tx.users[user.id].update(userProperties)); 26 | router.push('/projects') 27 | } 28 | }, [user, router]); 29 | 30 | if (isLoading) { 31 | return null 32 | } 33 | if (error) { 34 | return
Uh oh! {error.message}
35 | } 36 | return 37 | } 38 | 39 | function LoginForm() { 40 | const [sentEmail, setSentEmail] = useState('') 41 | return ( 42 |
43 | {!sentEmail ? : } 44 |
45 | ) 46 | } 47 | 48 | function Email({ setSentEmail }: { setSentEmail: (email: string) => void }) { 49 | const [email, setEmail] = useState('') 50 | 51 | const handleSubmit = (e: React.FormEvent) => { 52 | e.preventDefault() 53 | if (!email) return 54 | setSentEmail(email) 55 | db.auth.sendMagicCode({ email }).catch((err) => { 56 | alert('Uh oh :' + err.body?.message) 57 | setSentEmail('') 58 | }) 59 | } 60 | 61 | return ( 62 | 63 | 64 | Let's log you in! 65 | 66 | 67 |
68 | setEmail(e.target.value)} /> 69 | 72 |
73 |
74 |
75 | ) 76 | } 77 | 78 | function MagicCode({ sentEmail }: { sentEmail: string }) { 79 | const [code, setCode] = useState('') 80 | 81 | const handleSubmit = (e: React.FormEvent) => { 82 | e.preventDefault() 83 | const user = db.auth.signInWithMagicCode({ email: sentEmail, code }).catch((err) => { 84 | alert('Uh oh :' + err.body?.message) 85 | setCode('') 86 | }) 87 | } 88 | 89 | return ( 90 | 91 | 92 | Okay, we sent you an email! What was the code? 93 | 94 | 95 |
96 | setCode(e.target.value)} /> 97 | 100 |
101 |
102 |
103 | ) 104 | } 105 | -------------------------------------------------------------------------------- /app/new/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useState } from 'react' 4 | import ProjectNav from '@/components/projects/project-nav' 5 | import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' 6 | import { Input } from '@/components/ui/input' 7 | import { Label } from '@/components/ui/label' 8 | import { Button } from '@/components/ui/button' 9 | import { db } from '@/lib/constants' 10 | import { tx, id } from '@instantdb/react' 11 | import { CheckIcon } from 'lucide-react' 12 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' 13 | import { useRouter } from 'next/navigation' 14 | import { templateContent } from '@/lib/constants/templates' 15 | import { useFrontend } from '@/contexts/FrontendContext' 16 | 17 | const templates = [ 18 | { id: 'blank', title: 'Blank', image: '/blank_preview.webp' }, 19 | { id: 'article', title: 'Article', image: '/article_preview.webp' }, 20 | { id: 'report', title: 'Report', image: '/report_preview.webp' }, 21 | { id: 'resume', title: 'Resume', image: '/resume_preview.webp' }, 22 | { id: 'letter', title: 'Letter', image: '/letter_preview.webp' }, 23 | { id: 'proposal', title: 'Proposal', image: '/proposal_preview.webp' }, 24 | ] 25 | 26 | type TemplateKey = keyof typeof templateContent 27 | 28 | export default function NewDocument() { 29 | const { user } = useFrontend() 30 | const router = useRouter() 31 | const [title, setTitle] = useState('New Document') 32 | const [selectedTemplate, setSelectedTemplate] = useState('blank') 33 | const [titleError, setTitleError] = useState('') 34 | 35 | const handleSubmit = (e: React.FormEvent) => { 36 | e.preventDefault() 37 | if (!title.trim()) { 38 | setTitleError('Title cannot be empty') 39 | return 40 | } 41 | setTitleError('') 42 | const newProjectId = id() 43 | 44 | const createFileStructure = () => { 45 | 46 | return [ 47 | { 48 | name: 'main.tex', 49 | type: 'file', 50 | parent_id: null, 51 | content: templateContent[selectedTemplate], 52 | isExpanded: null, 53 | pathname: 'main.tex', 54 | user_id: user.id, 55 | }, 56 | ] 57 | } 58 | 59 | const fileStructure = createFileStructure() 60 | 61 | db.transact([ 62 | tx.projects[newProjectId].update({ 63 | user_id: user?.id, 64 | title: title.trim(), 65 | project_content: templateContent[selectedTemplate], // remove later because its stored in files table 66 | template: selectedTemplate, 67 | last_compiled: new Date(), 68 | word_count: 0, 69 | page_count: 0, 70 | document_class: selectedTemplate, 71 | created_at: new Date(), 72 | }), 73 | ...fileStructure.map((node) => 74 | tx.files[id()].update({ 75 | user_id: user?.id, 76 | projectId: newProjectId, 77 | name: node.name, 78 | type: node.type, 79 | parent_id: node.parent_id, 80 | content: node.content || '', 81 | created_at: new Date(), 82 | isExpanded: node.isExpanded, 83 | isOpen: node.name === 'main.tex' ? true : false, 84 | main_file: node.name === 'main.tex' ? true : false, 85 | pathname: node.pathname, 86 | }) 87 | ), 88 | ]) 89 | 90 | router.push(`/project/${newProjectId}`) 91 | } 92 | 93 | return ( 94 |
95 | 96 |
97 |
98 |
99 |
100 | {templates.map((template) => ( 101 | 107 | 108 | 129 | 130 | 131 | ))} 132 |
133 |
134 | 135 | 136 | Create a new Document 137 | 138 | 139 |
140 |
141 | 142 | { 146 | setTitle(e.target.value) 147 | if (titleError) setTitleError('') 148 | }} 149 | placeholder="Enter document title" 150 | className={titleError ? 'border-red-500' : ''} 151 | /> 152 | {titleError &&

{titleError}

} 153 |
154 |
155 | 156 | 168 |
169 | 172 |
173 |
174 |
175 |
176 |
177 |
178 | ) 179 | } 180 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from '@/components/ui/button' 2 | import { ArrowRight } from 'lucide-react' 3 | import Link from 'next/link' 4 | import AuthButtons from '@/components/projects/auth-buttons'; 5 | import Spline from '@splinetool/react-spline/next'; 6 | import GridTexture from '@/components/page-utils/grid-texture'; 7 | import { RainbowButton } from "@/components/ui/rainbow-button"; 8 | import { Star } from 'lucide-react'; 9 | import { Github } from 'lucide-react'; 10 | 11 | export default async function Home() { 12 | const stars = await getGitHubStars(); 13 | 14 | return ( 15 |
16 | 17 |
18 | 19 | Jules 20 | 21 | 24 |
25 |
26 |
27 |
28 |
29 | 30 | 31 | 32 | Star on GitHub 33 |
34 | 35 | {stars} 36 |
37 | 38 |
39 |
40 |

41 | AI-Powered LaTeX Editing 42 |

43 |

44 | Jules is inspired by Cursor and Overleaf. This is a proof of concept. 45 |

46 |
47 | 53 |
54 |
55 |
56 |
57 | 61 |
62 |
63 |
64 | ) 65 | } 66 | 67 | async function getGitHubStars() { 68 | const response = await fetch('https://api.github.com/repos/shelwinsunga/jules', { 69 | headers: { 70 | Authorization: `Bearer ${process.env.GITHUB_API_KEY}`, 71 | }, 72 | next: { revalidate: 3600 }, // Cache for 1 hour 73 | }); 74 | 75 | if (!response.ok) { 76 | return null; 77 | } 78 | 79 | const data = await response.json(); 80 | return data.stargazers_count; 81 | } -------------------------------------------------------------------------------- /app/project/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable' 3 | import SideNav from '@/components/nav/side-nav' 4 | import LatexRenderer from '@/components/latex-render/latex' 5 | import EditorContainer from '@/components/editor/editor-container' 6 | import { ProjectProvider } from '@/contexts/ProjectContext' 7 | import { useParams } from 'next/navigation' 8 | import { useFrontend } from '@/contexts/FrontendContext' 9 | export const maxDuration = 30 10 | 11 | export default function Home() { 12 | const { id } = useParams<{ id: string }>() 13 | const sideNavSize = typeof window !== 'undefined' ? (window.innerWidth < 1440 ? 20 : 16) : 16 14 | 15 | return ( 16 | 17 |
18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 |
32 |
33 | ) 34 | } 35 | -------------------------------------------------------------------------------- /app/project/layout.tsx: -------------------------------------------------------------------------------- 1 | export default function ProjectLayout({ 2 | children, 3 | }: Readonly<{ 4 | children: React.ReactNode 5 | }>) { 6 | return <>{children} 7 | } 8 | -------------------------------------------------------------------------------- /app/projects/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import { useState } from 'react' 3 | import { Card, CardContent, CardFooter } from '@/components/ui/card' 4 | import { Button } from '@/components/ui/button' 5 | import { Input } from '@/components/ui/input' 6 | import { SearchIcon, PlusIcon } from 'lucide-react' 7 | import ProjectNav from '@/components/projects/project-nav' 8 | import Link from 'next/link' 9 | import ProjectSkeleton from '@/components/projects/project-skeleton' 10 | import ProjectCard from '@/components/projects/project-card' 11 | import { useFrontend } from '@/contexts/FrontendContext' 12 | import { getAllProjects } from '@/hooks/data' 13 | 14 | export default function Projects() { 15 | const { user } = useFrontend(); 16 | const [searchTerm, setSearchTerm] = useState(''); 17 | const { isLoading, error, data } = getAllProjects(user.id); 18 | 19 | if (isLoading) return 20 | 21 | const projects = data?.projects || [] 22 | const filteredProjects = projects.filter((project) => project.title.toLowerCase().includes(searchTerm.toLowerCase())) 23 | const recentProjects = projects.slice(0, 3) 24 | const allProjects = projects 25 | 26 | return ( 27 |
28 | 29 |
30 |
31 |
32 | 33 | setSearchTerm(e.target.value)} 38 | /> 39 |
40 | 46 |
47 | 48 | {allProjects.length === 0 ? ( 49 | 50 |

No Projects Yet

51 |

Get started by creating your first LaTeX project.

52 | 58 |
59 | ) : searchTerm ? ( 60 |
61 |

Results

62 |
63 | {filteredProjects.map((project) => ( 64 | 65 | ))} 66 |
67 |
68 | ) : ( 69 | <> 70 |
71 |

Recents

72 |
73 | {recentProjects.map((project) => ( 74 | 75 | ))} 76 |
77 |
78 | 79 |
80 |

All Projects

81 |
82 | {allProjects.map((project) => ( 83 | 84 | ))} 85 |
86 |
87 | 88 | )} 89 |
90 |
91 | ) 92 | } 93 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "app/globals.css", 9 | "baseColor": "slate", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /components/editor/constants/editorDefaults.tsx: -------------------------------------------------------------------------------- 1 | import { editor } from 'monaco-editor' 2 | 3 | export const editorDefaultOptions: editor.IStandaloneEditorConstructionOptions = { 4 | wordWrap: 'on', 5 | folding: false, 6 | lineNumbersMinChars: 3, 7 | fontSize: 16, 8 | scrollBeyondLastLine: true, 9 | scrollBeyondLastColumn: 5, 10 | automaticLayout: true, 11 | minimap: { enabled: false }, 12 | overviewRulerLanes: 0, 13 | hideCursorInOverviewRuler: true, 14 | scrollbar: { 15 | vertical: 'hidden', 16 | horizontal: 'visible', 17 | useShadows: true, 18 | verticalScrollbarSize: 10, 19 | horizontalScrollbarSize: 10, 20 | verticalHasArrows: false, 21 | horizontalHasArrows: false, 22 | arrowSize: 30, 23 | }, 24 | overviewRulerBorder: false, 25 | } 26 | -------------------------------------------------------------------------------- /components/editor/editor-container.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import React, { useState, useCallback, useEffect, useRef } from 'react' 4 | import { CodeEditor } from './editor' 5 | import { Button } from '@/components/ui/button' 6 | import { useTheme } from 'next-themes' 7 | import { db } from '@/lib/constants' 8 | import { tx } from '@instantdb/react' 9 | import { Skeleton } from '@/components/ui/skeleton' 10 | import { useProject } from '@/contexts/ProjectContext' 11 | import { getFileExtension } from '@/lib/utils/client-utils'; 12 | import ImageViewer from './image-viewer' 13 | import { MousePointer2 } from 'lucide-react' 14 | import { Command } from 'lucide-react' // Add this import for the Command icon 15 | 16 | 17 | const isMac = navigator.userAgent.includes('Macintosh'); 18 | 19 | const EditorContainer = () => { 20 | const { theme, systemTheme } = useTheme() 21 | const [localContent, setLocalContent] = useState('') 22 | const [openFile, setOpenFile] = useState(null) 23 | const { currentlyOpen, isFilesLoading, isProjectLoading } = useProject(); 24 | const [isStreaming,setIsStreaming] = useState(false); 25 | const isStreamingRef = useRef(false); 26 | const fileType = getFileExtension(currentlyOpen?.name || ''); 27 | 28 | 29 | const isImageFile = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'].includes(fileType.toLowerCase()); 30 | 31 | useEffect(() => { 32 | if (currentlyOpen && currentlyOpen.id !== openFile?.id) { 33 | setOpenFile(currentlyOpen) 34 | setLocalContent(currentlyOpen.content) 35 | } 36 | }, [currentlyOpen?.id]) 37 | 38 | const handleCodeChange = useCallback( 39 | (newCode: string) => { 40 | if (newCode !== localContent && !isStreamingRef.current) { 41 | setLocalContent(newCode); 42 | db.transact([tx.files[openFile.id].update({ content: newCode })]); 43 | } 44 | }, 45 | [localContent, openFile] 46 | ) 47 | 48 | const handleIsStreamingChange = useCallback((streaming: boolean) => { 49 | setIsStreaming(streaming); 50 | isStreamingRef.current = streaming; 51 | }, []); 52 | 53 | if (isProjectLoading || isFilesLoading) { 54 | return ( 55 |
56 |
57 | 58 |
59 | 60 |
61 | ) 62 | } 63 | 64 | return ( 65 |
66 |
67 |
68 | Select and 69 | {isMac ? ( 70 | <> 71 | 72 | K 73 | 74 | ) : ( 75 | Ctrl+K 76 | )} 77 | for AI Autocomplete 78 |
79 |
80 | {!currentlyOpen ? ( 81 |
82 | No file open 83 |
84 | ) : isImageFile ? ( 85 |
86 |
93 | 97 |
98 |
99 | ) : ( 100 | 101 | )} 102 |
103 | ) 104 | } 105 | 106 | export default EditorContainer 107 | -------------------------------------------------------------------------------- /components/editor/editor.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Editor from '@monaco-editor/react' 3 | import { useEditorSetup } from './hooks/useEditorSetup' 4 | import { useAIAssist } from './hooks/useAIAssist' 5 | import { editorDefaultOptions } from './constants/editorDefaults' 6 | import { Loader2 } from 'lucide-react' 7 | 8 | interface CodeEditorProps { 9 | onChange: (value: string) => void 10 | value: string 11 | setIsStreaming: (isStreaming: boolean) => void 12 | } 13 | 14 | const EditorLoading = () => null 15 | 16 | export const CodeEditor = ({ onChange, value, setIsStreaming }: CodeEditorProps) => { 17 | const { editorRef, handleEditorDidMount } = useEditorSetup(onChange, value) 18 | const { handleAIAssist } = useAIAssist() 19 | 20 | return ( 21 | { 29 | handleEditorDidMount(editor, monaco) 30 | handleAIAssist(editor, monaco, setIsStreaming) 31 | }} 32 | options={editorDefaultOptions} 33 | loading={} 34 | /> 35 | ) 36 | } 37 | 38 | export default CodeEditor 39 | -------------------------------------------------------------------------------- /components/editor/hooks/useAIAssist.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import { useState } from 'react' 3 | import { generate } from '@/app/actions' 4 | import { readStreamableValue } from 'ai/rsc' 5 | import { calculateDiff } from '../utils/calculateDiff' 6 | import { createContentWidget } from '../utils/WidgetCreator' 7 | import { promptModal } from '../utils/promptModal' 8 | import { applyEdit } from '../utils/applyEdit' 9 | import * as monaco from 'monaco-editor' 10 | import type { editor } from 'monaco-editor' 11 | 12 | export const useAIAssist = () => { 13 | const handleAIAssist = (editor: editor.IStandaloneCodeEditor, monacoInstance: typeof monaco, setIsStreaming: (isStreaming: boolean) => void) => { 14 | editor.addCommand(monacoInstance.KeyMod.CtrlCmd | monacoInstance.KeyCode.KeyK, async () => { 15 | const selection = editor.getSelection() 16 | const model = editor.getModel() 17 | if (!model || !selection) return 18 | const initialText = model.getValue() 19 | const range = new monaco.Range( 20 | selection.startLineNumber, 21 | selection.startColumn, 22 | selection.endLineNumber, 23 | selection.endColumn 24 | ) 25 | 26 | const oldText = model.getValueInRange(range) 27 | const context = `Replace lines ${selection.startLineNumber}-${selection.endLineNumber}:\n${oldText}` 28 | 29 | const userInput = await promptModal(editor, monacoInstance, selection) 30 | 31 | const { output } = await generate( 32 | `File content:\n${initialText}\n\nContext: ${context}\n\nUser input: ${userInput}` 33 | ) 34 | 35 | let newText = '' 36 | let oldDecorations: string[] = [] 37 | let currentLine = selection.startLineNumber 38 | let buffer = '' 39 | setIsStreaming(true) 40 | 41 | 42 | for await (const delta of readStreamableValue(output)) { 43 | if (!delta) continue 44 | buffer += delta.content 45 | if (buffer.endsWith('\n') || buffer.length > 0) { 46 | newText += buffer 47 | const { 48 | diffText, 49 | decorations, 50 | currentLine: updatedLine, 51 | } = calculateDiff(oldText, newText, monacoInstance, selection) 52 | currentLine = updatedLine 53 | await applyEdit(editor, initialText, range, diffText) 54 | oldDecorations = editor.deltaDecorations(oldDecorations, decorations) 55 | buffer = '' 56 | } 57 | } 58 | 59 | setIsStreaming(false) 60 | 61 | const contentWidget = createContentWidget( 62 | editor, 63 | monacoInstance, 64 | selection, 65 | oldText, 66 | newText, 67 | currentLine, 68 | oldDecorations 69 | ) 70 | editor.addContentWidget(contentWidget) 71 | }) 72 | } 73 | 74 | return { handleAIAssist } 75 | } 76 | -------------------------------------------------------------------------------- /components/editor/hooks/useEditorSetup.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import { useRef } from 'react' 3 | import { useEditorTheme } from './useEditorTheme' 4 | import { editor, languages } from 'monaco-editor' 5 | import * as monaco from 'monaco-editor' 6 | import latex from 'monaco-latex' 7 | import { useLatexSyntaxHighlighting } from './useLatexSyntaxHighlighting' 8 | 9 | export function useEditorSetup(onChange: (value: string) => void, value: string) { 10 | const editorRef = useRef(null) 11 | const { setTheme } = useEditorTheme() 12 | const { setupLatexSyntaxHighlighting } = useLatexSyntaxHighlighting() 13 | 14 | const handleEditorDidMount = (editor: editor.IStandaloneCodeEditor, monacoInstance: typeof monaco) => { 15 | editorRef.current = editor 16 | editor.onDidChangeModelContent(() => { 17 | onChange(editor.getValue()) 18 | }) 19 | editor.getModel()?.updateOptions({ tabSize: 4, insertSpaces: true }) 20 | monacoInstance.editor.setModelLanguage(editor.getModel()!, 'latex') 21 | editor.setScrollTop(1) 22 | editor.setPosition({ lineNumber: 2, column: 0 }) 23 | editor.focus() 24 | 25 | setTheme(monacoInstance) 26 | 27 | setupLatexSyntaxHighlighting(monacoInstance) 28 | 29 | languages.register({ id: 'latex' }); 30 | languages.setMonarchTokensProvider('latex', latex); 31 | 32 | monacoInstance.editor.setModelLanguage(editor.getModel()!, 'latex'); 33 | 34 | editor.setValue(value) 35 | 36 | editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.Slash, () => { 37 | const selection = editor.getSelection() 38 | if (selection) { 39 | const model = editor.getModel() 40 | if (model) { 41 | const startLineNumber = selection.startLineNumber 42 | const endLineNumber = selection.endLineNumber 43 | const operations = [] 44 | 45 | for (let i = startLineNumber; i <= endLineNumber; i++) { 46 | const lineContent = model.getLineContent(i) 47 | if (lineContent.startsWith('%')) { 48 | operations.push({ 49 | range: new monaco.Range(i, 1, i, 2), 50 | text: '', 51 | }) 52 | } else { 53 | operations.push({ 54 | range: new monaco.Range(i, 1, i, 1), 55 | text: '%', 56 | }) 57 | } 58 | } 59 | 60 | model.pushEditOperations([], operations, () => null) 61 | } 62 | } 63 | }) 64 | } 65 | 66 | return { editorRef, handleEditorDidMount } 67 | } 68 | -------------------------------------------------------------------------------- /components/editor/hooks/useEditorTheme.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import * as monaco from 'monaco-editor' 3 | import { useTheme } from 'next-themes' 4 | const cssVariables = ['--background', '--foreground', '--primary', '--secondary', '--muted', '--accent', '--border'] 5 | 6 | export const useEditorTheme = () => { 7 | const { theme, systemTheme } = useTheme() 8 | const setTheme = (monacoInstance: typeof monaco) => { 9 | const getColorFromVariable = (variable: string) => { 10 | const hslColor = getComputedStyle(document.documentElement).getPropertyValue(variable).trim() 11 | const rgbColor = hslToRgb(hslColor) 12 | return rgbToHex(rgbColor.r, rgbColor.g, rgbColor.b) 13 | } 14 | 15 | const colors = cssVariables.reduce( 16 | (acc, variable) => { 17 | acc[variable] = getColorFromVariable(variable) 18 | return acc 19 | }, 20 | {} as Record 21 | ) 22 | 23 | const base = theme === 'system' ? (systemTheme === 'dark' ? 'vs-dark' : 'vs') : theme === 'dark' ? 'vs-dark' : 'vs' 24 | 25 | monacoInstance.editor.defineTheme('myDarkTheme', { 26 | base: base, 27 | inherit: true, 28 | rules: [], 29 | colors: { 30 | 'editor.background': colors['--background'], 31 | 'editor.foreground': colors['--foreground'], 32 | 'editor.lineHighlightBackground': colors['--background'], 33 | 'editorCursor.foreground': '#d4d4d4', 34 | 'editorWhitespace.foreground': '#3a3a3a', 35 | 'editorIndentGuide.background': '#404040', 36 | 'editor.selectionBackground': colors['--secondary'], 37 | 'editor.inactiveSelectionBackground': colors['--muted'], 38 | focusBorder: `${colors['--border']}00`, // Appending '00' for full transparency 39 | }, 40 | }) 41 | monacoInstance.editor.setTheme('myDarkTheme') 42 | } 43 | 44 | return { setTheme } 45 | } 46 | 47 | // Helper function to convert HSL to RGB 48 | function hslToRgb(hsl: string): { r: number; g: number; b: number } { 49 | const [h, s, l] = hsl.split(' ').map(parseFloat) 50 | if (isNaN(h) || isNaN(s) || isNaN(l)) { 51 | console.error('Invalid HSL values:', hsl) 52 | return { r: 0, g: 0, b: 0 } 53 | } 54 | const hue = h / 360 55 | const saturation = s / 100 56 | const lightness = l / 100 57 | let r, g, b 58 | 59 | if (saturation === 0) { 60 | r = g = b = lightness 61 | } else { 62 | const hue2rgb = (p: number, q: number, t: number) => { 63 | if (t < 0) t += 1 64 | if (t > 1) t -= 1 65 | if (t < 1 / 6) return p + (q - p) * 6 * t 66 | if (t < 1 / 2) return q 67 | if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6 68 | return p 69 | } 70 | 71 | const q = lightness < 0.5 ? lightness * (1 + saturation) : lightness + saturation - lightness * saturation 72 | const p = 2 * lightness - q 73 | r = hue2rgb(p, q, hue + 1 / 3) 74 | g = hue2rgb(p, q, hue) 75 | b = hue2rgb(p, q, hue - 1 / 3) 76 | } 77 | 78 | return { 79 | r: Math.round(r * 255), 80 | g: Math.round(g * 255), 81 | b: Math.round(b * 255), 82 | } 83 | } 84 | 85 | // Helper function to convert RGB to Hex 86 | function rgbToHex(r: number, g: number, b: number): string { 87 | const toHex = (c: number) => { 88 | const hex = Math.max(0, Math.min(255, Math.round(c))).toString(16) 89 | return hex.length === 1 ? '0' + hex : hex 90 | } 91 | return '#' + toHex(r) + toHex(g) + toHex(b) 92 | } 93 | -------------------------------------------------------------------------------- /components/editor/hooks/useLatexSyntaxHighlighting.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { loader } from '@monaco-editor/react' 4 | import * as monaco from 'monaco-editor' 5 | 6 | export const useLatexSyntaxHighlighting = () => { 7 | const setupLatexSyntaxHighlighting = (monacoInstance: typeof monaco) => { 8 | monacoInstance.languages.register({ id: 'latex' }) 9 | monacoInstance.languages.setMonarchTokensProvider('latex', { 10 | tokenizer: { 11 | root: [ 12 | [/\\[a-zA-Z]+/, 'keyword'], 13 | [/\{|\}/, 'delimiter.curly'], 14 | [/\[|\]/, 'delimiter.square'], 15 | [/\$.*?\$/, 'string'], 16 | [/%.*$/, 'comment'], 17 | ], 18 | }, 19 | }) 20 | } 21 | 22 | loader.init().then(setupLatexSyntaxHighlighting) 23 | 24 | return { setupLatexSyntaxHighlighting } 25 | } -------------------------------------------------------------------------------- /components/editor/image-viewer.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useState, useEffect } from 'react' 4 | import { Dialog, DialogTrigger, DialogContent } from '@/components/ui/dialog' 5 | import Image from 'next/image' 6 | 7 | export default function ImageViewer({ src, alt }: { src: string, alt: string }) { 8 | const [isOpen, setIsOpen] = useState(false) 9 | const [dimensions, setDimensions] = useState({ width: 0, height: 0 }) 10 | const [aspectRatio, setAspectRatio] = useState(1) 11 | 12 | useEffect(() => { 13 | if (dimensions.width && dimensions.height) { 14 | setAspectRatio(dimensions.width / dimensions.height) 15 | } 16 | }, [dimensions]) 17 | 18 | const getIntrinsicSize = () => { 19 | const maxWidth = typeof window !== 'undefined' ? window.innerWidth * 0.8 : 1000 20 | const maxHeight = typeof window !== 'undefined' ? window.innerHeight * 0.8 : 800 21 | 22 | let width = dimensions.width 23 | let height = dimensions.height 24 | 25 | if (width > maxWidth) { 26 | width = maxWidth 27 | height = width / aspectRatio 28 | } 29 | 30 | if (height > maxHeight) { 31 | height = maxHeight 32 | width = height * aspectRatio 33 | } 34 | 35 | return { width, height } 36 | } 37 | 38 | useEffect(() => { 39 | if (src) { 40 | const img = new window.Image() 41 | img.onload = () => { 42 | setDimensions({ width: img.width, height: img.height }) 43 | } 44 | img.src = src 45 | } 46 | }, [src]) 47 | return ( 48 | 49 | 50 |
51 | {alt} 58 |
59 |
60 | 61 |
62 | {alt} 68 |
69 |
70 |
71 | ) 72 | } -------------------------------------------------------------------------------- /components/editor/types.ts: -------------------------------------------------------------------------------- 1 | import type { editor, languages } from 'monaco-editor' 2 | import * as monaco from 'monaco-editor' 3 | 4 | export interface CodeEditorProps { 5 | onChange: (value: string) => void 6 | value: string 7 | } 8 | 9 | export interface ApplyEditProps { 10 | editor: Editor 11 | initialText: string 12 | range: Range 13 | diffText: string 14 | } 15 | 16 | export type Range = monaco.Range 17 | 18 | export type Editor = editor.IStandaloneCodeEditor 19 | 20 | export type StreamableValue = {} 21 | -------------------------------------------------------------------------------- /components/editor/utils/WidgetCreator.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import React from 'react' 4 | import ReactDOM from 'react-dom/client' 5 | import { Button } from '@/components/ui/button' 6 | import * as monaco from 'monaco-editor' 7 | 8 | export const createContentWidget = ( 9 | editor: monaco.editor.IStandaloneCodeEditor, 10 | monacoInstance: typeof monaco, 11 | selection: monaco.Range, 12 | oldText: string, 13 | newText: string, 14 | currentLine: number, 15 | oldDecorations: string[] 16 | ) => { 17 | return { 18 | getDomNode: function () { 19 | const container = document.createElement('div') 20 | const model = editor.getModel() 21 | if (!model) return container // Return empty container if no model 22 | 23 | const handleReject = () => { 24 | editor.executeEdits('reject-changes', [ 25 | { 26 | range: new monacoInstance.Range( 27 | selection.startLineNumber, 28 | 1, 29 | currentLine - 1, 30 | model.getLineMaxColumn(currentLine - 1) 31 | ), 32 | text: oldText, 33 | forceMoveMarkers: true, 34 | }, 35 | ]) 36 | editor.deltaDecorations(oldDecorations, []) 37 | editor.removeContentWidget(this) 38 | } 39 | 40 | const handleApprove = () => { 41 | editor.executeEdits('approve-changes', [ 42 | { 43 | range: new monacoInstance.Range( 44 | selection.startLineNumber, 45 | 1, 46 | currentLine - 1, 47 | model.getLineMaxColumn(currentLine - 1) 48 | ), 49 | text: newText, 50 | forceMoveMarkers: true, 51 | }, 52 | ]) 53 | editor.deltaDecorations(oldDecorations, []) 54 | editor.removeContentWidget(this) 55 | } 56 | 57 | const WidgetContent = () => ( 58 |
59 | 62 | 65 |
66 | ) 67 | 68 | const root = ReactDOM.createRoot(container) 69 | root.render() 70 | return container 71 | }, 72 | getId: function () { 73 | return 'diff-widget' 74 | }, 75 | getPosition: function () { 76 | return { 77 | position: { 78 | lineNumber: currentLine, 79 | column: 1, 80 | }, 81 | preference: [monacoInstance.editor.ContentWidgetPositionPreference.BELOW], 82 | } 83 | }, 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /components/editor/utils/applyEdit.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import { editor, Range } from 'monaco-editor' 3 | 4 | export const applyEdit = async ( 5 | editor: editor.IStandaloneCodeEditor, 6 | initialText: string, 7 | range: Range, 8 | diffText: string 9 | ) => { 10 | const model = editor.getModel() 11 | if (!model) return 12 | model.setValue(initialText) 13 | model.pushEditOperations( 14 | [], 15 | [ 16 | { 17 | range: range, 18 | text: diffText, 19 | }, 20 | ], 21 | () => null 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /components/editor/utils/calculateDiff.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import * as monaco from 'monaco-editor' 3 | 4 | export const calculateDiff = ( 5 | oldText: string, 6 | newText: string, 7 | monacoInstance: typeof monaco, 8 | selection: monaco.Selection 9 | ) => { 10 | const oldLines = oldText.split('\n') 11 | const newLines = newText.split('\n') 12 | 13 | let diffText = '' 14 | let decorations: monaco.editor.IModelDeltaDecoration[] = [] 15 | let currentLine = selection.startLineNumber 16 | 17 | const diff = [] 18 | let oldIndex = 0 19 | let newIndex = 0 20 | 21 | while (oldIndex < oldLines.length || newIndex < newLines.length) { 22 | if (oldIndex < oldLines.length && newIndex < newLines.length && oldLines[oldIndex] === newLines[newIndex]) { 23 | diff.push({ value: oldLines[oldIndex] }) 24 | oldIndex++ 25 | newIndex++ 26 | } else if (oldIndex < oldLines.length) { 27 | diff.push({ removed: true, value: oldLines[oldIndex] }) 28 | oldIndex++ 29 | } else if (newIndex < newLines.length) { 30 | diff.push({ added: true, value: newLines[newIndex] }) 31 | newIndex++ 32 | } 33 | } 34 | 35 | diff.forEach((part) => { 36 | if (part.removed) { 37 | diffText += part.value + '\n' 38 | decorations.push({ 39 | range: new monacoInstance.Range(currentLine, 1, currentLine, 1), 40 | options: { isWholeLine: true, className: 'diff-old-content' }, 41 | }) 42 | currentLine++ 43 | } else if (part.added) { 44 | diffText += part.value + '\n' 45 | decorations.push({ 46 | range: new monacoInstance.Range(currentLine, 1, currentLine, 1), 47 | options: { isWholeLine: true, className: 'diff-new-content' }, 48 | }) 49 | currentLine++ 50 | } else { 51 | diffText += part.value + '\n' 52 | currentLine++ 53 | } 54 | }) 55 | 56 | // Remove trailing newline if it wasn't in the original text 57 | if (!oldText.endsWith('\n') && !newText.endsWith('\n')) { 58 | diffText = diffText.slice(0, -1) 59 | } 60 | 61 | return { diffText, decorations, currentLine } 62 | } 63 | -------------------------------------------------------------------------------- /components/editor/utils/promptModal.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useRef } from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import { Button } from '@/components/ui/button' 4 | import { Textarea } from '@/components/ui/textarea' 5 | import * as monaco from 'monaco-editor' 6 | 7 | export const promptModal = async ( 8 | editor: monaco.editor.IStandaloneCodeEditor, 9 | monacoInstance: typeof monaco, 10 | selection: monaco.Range 11 | ) => { 12 | return new Promise((resolve, reject) => { 13 | const inputContainer = document.createElement('div') 14 | inputContainer.style.position = 'fixed' 15 | inputContainer.style.width = '400px' 16 | inputContainer.style.height = '150px' 17 | inputContainer.style.zIndex = '1000' 18 | 19 | const PromptContent = () => { 20 | const [inputValue, setInputValue] = useState('') 21 | const textareaRef = useRef(null) 22 | 23 | useEffect(() => { 24 | if (textareaRef.current) { 25 | textareaRef.current.focus() 26 | } 27 | }, []) 28 | 29 | const handleSubmit = () => { 30 | resolve(inputValue) 31 | document.body.removeChild(inputContainer) 32 | } 33 | 34 | const handleClose = () => { 35 | document.body.removeChild(inputContainer) 36 | reject(new Error('User cancelled input')) 37 | } 38 | 39 | const handleKeyDown = (event: React.KeyboardEvent) => { 40 | if (event.key === 'Enter' && !event.shiftKey) { 41 | event.preventDefault() 42 | handleSubmit() 43 | } else if (event.key === 'k' && (event.ctrlKey || event.metaKey)) { 44 | event.preventDefault() 45 | handleClose() 46 | } 47 | } 48 | 49 | return ( 50 |
51 |
52 |