From 8e6761daeaed5fa278a9b7faf7a92882d9401f15 Mon Sep 17 00:00:00 2001 From: ryansoe Date: Sun, 1 Mar 2026 19:07:54 -0800 Subject: [PATCH] feat: ai features --- package-lock.json | 15 +- package.json | 3 +- src/app/api/quick-edit/route.ts | 113 +++++++++ src/app/api/suggestion/route.ts | 92 +++++++ src/app/globals.css | 2 +- src/app/layout.tsx | 6 +- .../editor/components/code-editor.tsx | 10 +- .../editor/components/editor-view.tsx | 11 +- .../editor/extensions/quick-edit/fetcher.ts | 44 ++++ .../editor/extensions/quick-edit/index.ts | 210 ++++++++++++++++ .../editor/extensions/selection-tooltip.ts | 93 +++++++ .../editor/extensions/suggestion/fetcher.ts | 49 ++++ .../editor/extensions/suggestion/index.ts | 234 ++++++++++++++++++ 13 files changed, 873 insertions(+), 9 deletions(-) create mode 100644 src/app/api/quick-edit/route.ts create mode 100644 src/app/api/suggestion/route.ts create mode 100644 src/features/editor/extensions/quick-edit/fetcher.ts create mode 100644 src/features/editor/extensions/quick-edit/index.ts create mode 100644 src/features/editor/extensions/selection-tooltip.ts create mode 100644 src/features/editor/extensions/suggestion/fetcher.ts create mode 100644 src/features/editor/extensions/suggestion/index.ts diff --git a/package-lock.json b/package-lock.json index 28e741c..fd332a0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,6 @@ "@base-ui/react": "^1.2.0", "@clerk/nextjs": "^6.36.5", "@clerk/themes": "^2.4.55", - "@codemirror/commands": "^6.10.2", "@codemirror/lang-css": "^6.3.1", "@codemirror/lang-html": "^6.4.11", "@codemirror/lang-javascript": "^6.2.4", @@ -21,7 +20,6 @@ "@codemirror/lang-markdown": "^6.5.0", "@codemirror/lang-python": "^6.2.1", "@codemirror/theme-one-dark": "^6.1.3", - "@codemirror/view": "^6.39.15", "@hookform/resolvers": "^5.2.2", "@inngest/middleware-sentry": "^0.1.3", "@mendable/firecrawl-js": "^1.21.1", @@ -40,6 +38,7 @@ "embla-carousel-react": "^8.6.0", "inngest": "^3.48.1", "input-otp": "^1.4.2", + "ky": "^1.14.3", "lucide-react": "^0.575.0", "next": "16.1.1", "next-themes": "^0.4.6", @@ -13627,6 +13626,18 @@ "node": ">=6" } }, + "node_modules/ky": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/ky/-/ky-1.14.3.tgz", + "integrity": "sha512-9zy9lkjac+TR1c2tG+mkNSVlyOpInnWdSMiue4F+kq8TwJSgv6o8jhLRg8Ho6SnZ9wOYUq/yozts9qQCfk7bIw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/ky?sponsor=1" + } + }, "node_modules/language-subtag-registry": { "version": "0.3.23", "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", diff --git a/package.json b/package.json index 44ec17d..959483a 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,6 @@ "@base-ui/react": "^1.2.0", "@clerk/nextjs": "^6.36.5", "@clerk/themes": "^2.4.55", - "@codemirror/commands": "^6.10.2", "@codemirror/lang-css": "^6.3.1", "@codemirror/lang-html": "^6.4.11", "@codemirror/lang-javascript": "^6.2.4", @@ -22,7 +21,6 @@ "@codemirror/lang-markdown": "^6.5.0", "@codemirror/lang-python": "^6.2.1", "@codemirror/theme-one-dark": "^6.1.3", - "@codemirror/view": "^6.39.15", "@hookform/resolvers": "^5.2.2", "@inngest/middleware-sentry": "^0.1.3", "@mendable/firecrawl-js": "^1.21.1", @@ -41,6 +39,7 @@ "embla-carousel-react": "^8.6.0", "inngest": "^3.48.1", "input-otp": "^1.4.2", + "ky": "^1.14.3", "lucide-react": "^0.575.0", "next": "16.1.1", "next-themes": "^0.4.6", diff --git a/src/app/api/quick-edit/route.ts b/src/app/api/quick-edit/route.ts new file mode 100644 index 0000000..3355ff9 --- /dev/null +++ b/src/app/api/quick-edit/route.ts @@ -0,0 +1,113 @@ +import { z } from "zod"; +import { generateText, Output } from "ai"; +import { NextResponse } from "next/server"; +import { auth } from "@clerk/nextjs/server"; +import { anthropic } from "@ai-sdk/anthropic"; +import { firecrawl } from "@/lib/firecrawl"; + +const quickEditSchema = z.object({ + editedCode: z + .string() + .describe( + "The edited version of the selected code based on the instruction" + ), +}); + +const URL_REGEX = /https?:\/\/[^\s)>\]]+/g; + +const QUICK_EDIT_PROMPT = `You are a code editing assistant. Edit the selected code based on the user's instruction. + + + +{selectedCode} + + +{fullCode} + + + +{documentation} + + +{instruction} + + + +Return ONLY the edited version of the selected code. +Maintain the same indentation level as the original. +Do not include any explanations or comments unless requested. +If the instruction is unclear or cannot be applied, return the original code unchanged. +`; + +export async function POST(request: Request) { + try { + const { userId } = await auth(); + const { selectedCode, fullCode, instruction } = await request.json(); + + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 400 }); + } + + if (!selectedCode) { + return NextResponse.json( + { error: "Selected code is required" }, + { status: 400 } + ); + } + + if (!instruction) { + return NextResponse.json( + { error: "Instruction is required" }, + { status: 400 } + ); + } + + const urls: string[] = instruction.match(URL_REGEX) || []; + let documentationContext = ""; + + if (urls.length > 0) { + const scrapedResults = await Promise.all( + urls.map(async (url) => { + try { + const result = await firecrawl.scrapeUrl(url, { + formats: ["markdown"], + }); + + if (result.success && result.markdown) { + return `\n${result.markdown}\n`; + } + + return null; + } catch { + return null; + } + }) + ); + + const validResults = scrapedResults.filter(Boolean); + + if (validResults.length > 0) { + documentationContext = `\n${validResults.join("\n\n")}\n`; + } + } + + const prompt = QUICK_EDIT_PROMPT.replace("{selectedCode}", selectedCode) + .replace("{fullCode}", fullCode || "") + .replace("{instruction}", instruction) + .replace("{documentation}", documentationContext); + + const { output } = await generateText({ + model: anthropic("claude-haiku-4-5-20251001"), + output: Output.object({ schema: quickEditSchema }), + prompt, + }); + + return NextResponse.json({ editedCode: output.editedCode }); + } catch (error) { + console.error("Edit error:", error); + return NextResponse.json( + { error: "Failed to generate edit" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/suggestion/route.ts b/src/app/api/suggestion/route.ts new file mode 100644 index 0000000..002b16a --- /dev/null +++ b/src/app/api/suggestion/route.ts @@ -0,0 +1,92 @@ +import { generateText, Output } from "ai"; +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { anthropic } from "@ai-sdk/anthropic"; +import { auth } from "@clerk/nextjs/server"; +// import { google } from "@ai-sdk/google"; + +const suggestionSchema = z.object({ + suggestion: z + .string() + .describe( + "The code to insert at cursor, or empty string if no complete needed" + ), +}); + +const SUGGESTION_PROMPT = `You are a code suggestion assistant. + + +{fileName} + +{previousLines} + +{currentLine} +{textBeforeCursor} +{textAfterCursor} + +{nextLines} + + +{code} + + + + +Follow these steps IN ORDER: + +1. First, look at next_lines. If next_lines contains ANY code, check if it continues from where the cursor is. If it does, return empty string immediately - the code is already written. + +2. Check if before_cursor ends with a complete statement (;, }, )). If yes, return empty string. + +3. Only if steps 1 and 2 don't apply: suggest what should be typed at the cursor position, using context from full_code. + +Your suggestion is inserted immediately after the cursor, so never suggest code that's already in the file. +`; + +export async function POST(request: Request) { + try { + const { userId } = await auth(); + + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 403 }); + } + + const { + fileName, + code, + currentLine, + previousLines, + textBeforeCursor, + textAfterCursor, + nextLines, + lineNumber, + } = await request.json(); + + if (!code) { + return NextResponse.json({ error: "Code is required" }, { status: 400 }); + } + + const prompt = SUGGESTION_PROMPT.replace("{fileName}", fileName) + .replace("{code}", code) + .replace("{currentLine}", currentLine) + .replace("{previousLines}", previousLines || "") + .replace("{textBeforeCursor}", textBeforeCursor) + .replace("{textAfterCursor}", textAfterCursor) + .replace("{nextLines}", nextLines || "") + .replace("{lineNumber}", lineNumber.toString()); + + const { output } = await generateText({ + model: anthropic("claude-haiku-4-5-20251001"), + output: Output.object({ schema: suggestionSchema }), + prompt, + }); + + return NextResponse.json({ suggestion: output.suggestion }); + } catch (error) { + console.error("Suggestion error: ", error); + return NextResponse.json( + { error: "Failed to generate suggestion" }, + { status: 500 } + ); + } +} diff --git a/src/app/globals.css b/src/app/globals.css index 66e8dfb..f0b5954 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -119,7 +119,7 @@ @apply border-border outline-ring/50; } body { - @apply bg-background text-foreground select-none; + @apply bg-background text-foreground; } } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index e2cda74..d7d74df 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import { IBM_Plex_Mono, Inter } from "next/font/google"; +import { Toaster } from "sonner"; import { Providers } from "@/components/providers"; import "./globals.css"; @@ -29,7 +30,10 @@ export default function RootLayout({ return ( - {children} + + {children} + + ); diff --git a/src/features/editor/components/code-editor.tsx b/src/features/editor/components/code-editor.tsx index a046c4b..fd52d63 100644 --- a/src/features/editor/components/code-editor.tsx +++ b/src/features/editor/components/code-editor.tsx @@ -8,6 +8,9 @@ import { keymap } from "@codemirror/view"; import { minimap } from "../extensions/minimap"; import { indentationMarkers } from "@replit/codemirror-indentation-markers"; import { customSetup } from "../extensions/custom-setup"; +import { suggestion } from "../extensions/suggestion"; +import { quickEdit } from "../extensions/quick-edit"; +import { selectionTooltip } from "../extensions/selection-tooltip"; interface Props { fileName: string; @@ -36,11 +39,14 @@ export const CodeEditor = ({ extensions: [ oneDark, customTheme, - languageExtension, customSetup, + languageExtension, + suggestion(fileName), + quickEdit(fileName), + selectionTooltip(), + keymap.of([indentWithTab]), minimap(), indentationMarkers(), - keymap.of([indentWithTab]), EditorView.updateListener.of((update) => { if (update.docChanged) { onChange?.(update.state.doc.toString()); diff --git a/src/features/editor/components/editor-view.tsx b/src/features/editor/components/editor-view.tsx index 4f6a94d..462cd42 100644 --- a/src/features/editor/components/editor-view.tsx +++ b/src/features/editor/components/editor-view.tsx @@ -6,7 +6,7 @@ import { useFile } from "@/features/projects/hooks/use-files"; import Image from "next/image"; import { CodeEditor } from "./code-editor"; import { useUpdateFile } from "@/features/projects/hooks/use-files"; -import { useRef } from "react"; +import { useEffect, useRef } from "react"; const DEBOUNCE_MS = 1500; @@ -19,6 +19,15 @@ export const EditorView = ({ projectId }: { projectId: Id<"projects"> }) => { const isActiveFileBinary = activeFile && activeFile.storageId; const isActiveFileText = activeFile && !activeFile.storageId; + // Clean-up pending debounced updates on unmount or file change + useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, [activeTabId]); + return (
diff --git a/src/features/editor/extensions/quick-edit/fetcher.ts b/src/features/editor/extensions/quick-edit/fetcher.ts new file mode 100644 index 0000000..fa795ff --- /dev/null +++ b/src/features/editor/extensions/quick-edit/fetcher.ts @@ -0,0 +1,44 @@ +import ky from "ky"; +import { z } from "zod"; +import { toast } from "sonner"; + +const editRequestSchema = z.object({ + selectedCode: z.string(), + fullCode: z.string(), + instruction: z.string(), +}); + +const editResponseSchema = z.object({ + editedCode: z.string(), +}); + +type EditRequest = z.infer; +type EditResponse = z.infer; + +export const fetcher = async ( + payload: EditRequest, + signal: AbortSignal +): Promise => { + try { + const validatedPayload = editRequestSchema.parse(payload); + + const response = await ky + .post("/api/quick-edit", { + json: validatedPayload, + signal, + timeout: 30_000, + retry: 0, + }) + .json(); + + const validatedResponse = editResponseSchema.parse(response); + + return validatedResponse.editedCode || null; + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + return null; + } + toast.error("Failed to fetch AI quick edit"); + return null; + } +}; diff --git a/src/features/editor/extensions/quick-edit/index.ts b/src/features/editor/extensions/quick-edit/index.ts new file mode 100644 index 0000000..70d26bb --- /dev/null +++ b/src/features/editor/extensions/quick-edit/index.ts @@ -0,0 +1,210 @@ +import { + Decoration, + DecorationSet, + EditorView, + Tooltip, + ViewPlugin, + ViewUpdate, + WidgetType, + keymap, + showTooltip, +} from "@codemirror/view"; +import { EditorState, StateEffect, StateField } from "@codemirror/state"; + +import { fetcher } from "./fetcher"; + +export const showQuickEditEffect = StateEffect.define(); + +let editorView: EditorView | null = null; +let currentAbortController: AbortController | null = null; + +export const quickEditState = StateField.define({ + create() { + return false; + }, + update(value, transaction) { + for (const effect of transaction.effects) { + if (effect.is(showQuickEditEffect)) { + return effect.value; + } + } + if (transaction.selection) { + const selection = transaction.state.selection.main; + if (selection.empty) { + return false; + } + } + + return value; + }, +}); + +const createQuickEditTooltip = (state: EditorState): readonly Tooltip[] => { + const selection = state.selection.main; + + if (selection.empty) { + return []; + } + + const isQuickEditActive = state.field(quickEditState); + if (!isQuickEditActive) { + return []; + } + + return [ + { + pos: selection.to, + above: false, + strictSide: false, + create() { + const dom = document.createElement("div"); + dom.className = + "bg-popover text-popover-foreground z-50 rounded-sm border border-input p-2 shadow-md flex flex-col gap-2 text-sm"; + + const form = document.createElement("form"); + form.className = "flex flex-col gap-2"; + + const input = document.createElement("input"); + input.type = "text"; + input.placeholder = "Edit selected code"; + input.className = + "bg-transparent border-none outline-none px-2 py-1 font-sans w-100"; + input.autofocus = true; + + const buttonContainer = document.createElement("div"); + buttonContainer.className = "flex items-center justify-between gap-2"; + + const cancelButton = document.createElement("button"); + cancelButton.type = "button"; + cancelButton.textContent = "Cancel"; + cancelButton.className = + "font-sans p-1 px-2 text-muted-foreground hover:text-foreground hover:bg-foreground/10 rounded-sm"; + cancelButton.onclick = () => { + if (currentAbortController) { + currentAbortController.abort(); + currentAbortController = null; + } + if (editorView) { + editorView.dispatch({ + effects: showQuickEditEffect.of(false), + }); + } + }; + + const submitButton = document.createElement("button"); + submitButton.type = "submit"; + submitButton.textContent = "Submit"; + submitButton.className = + "font-sans p-1 px-2 text-muted-foreground hover:text-foreground hover:bg-foreground/10 rounded-sm"; + + form.onsubmit = async (e) => { + e.preventDefault(); + + if (!editorView) return; + + const instruction = input.value.trim(); + if (!instruction) return; + + const selection = editorView.state.selection.main; + const selectedCode = editorView.state.doc.sliceString( + selection.from, + selection.to + ); + const fullCode = editorView.state.doc.toString(); + + submitButton.disabled = true; + submitButton.textContent = "Editing..."; + + currentAbortController = new AbortController(); + const editedCode = await fetcher( + { + selectedCode, + fullCode, + instruction, + }, + currentAbortController.signal + ); + + if (editedCode) { + editorView.dispatch({ + changes: { + from: selection.from, + to: selection.to, + insert: editedCode, + }, + selection: { anchor: selection.from + editedCode.length }, + effects: showQuickEditEffect.of(false), + }); + } else { + submitButton.disabled = false; + submitButton.textContent = "Submit"; + } + + currentAbortController = null; + }; + + buttonContainer.appendChild(cancelButton); + buttonContainer.appendChild(submitButton); + + form.appendChild(input); + form.appendChild(buttonContainer); + + dom.appendChild(form); + + setTimeout(() => { + input.focus(); + }, 0); + + return { dom }; + }, + }, + ]; +}; + +const quickEditTooltipField = StateField.define({ + create(state) { + return createQuickEditTooltip(state); + }, + + update(tooltips, transaction) { + if (transaction.docChanged || transaction.selection) { + return createQuickEditTooltip(transaction.state); + } + for (const effect of transaction.effects) { + if (effect.is(showQuickEditEffect)) { + return createQuickEditTooltip(transaction.state); + } + } + return tooltips; + }, + provide: (field) => + showTooltip.computeN([field], (state) => state.field(field)), +}); + +const quickEditKeymap = keymap.of([ + { + key: "Mod-k", + run: (view) => { + const selection = view.state.selection.main; + if (selection.empty) { + return false; + } + + view.dispatch({ + effects: showQuickEditEffect.of(true), + }); + return true; + }, + }, +]); + +const captureViewExtension = EditorView.updateListener.of((update) => { + editorView = update.view; +}); + +export const quickEdit = (fileName: string) => [ + quickEditState, + quickEditTooltipField, + quickEditKeymap, + captureViewExtension, +]; diff --git a/src/features/editor/extensions/selection-tooltip.ts b/src/features/editor/extensions/selection-tooltip.ts new file mode 100644 index 0000000..81b0985 --- /dev/null +++ b/src/features/editor/extensions/selection-tooltip.ts @@ -0,0 +1,93 @@ +import { Tooltip, showTooltip, EditorView } from "@codemirror/view"; +import { StateField, EditorState } from "@codemirror/state"; +import { showQuickEditEffect, quickEditState } from "./quick-edit"; + +let editorView: EditorView | null = null; + +const createTooltipForSelection = (state: EditorState): readonly Tooltip[] => { + const selection = state.selection.main; + + if (selection.empty) { + return []; + } + + const isQuickEditActive = state.field(quickEditState); + if (isQuickEditActive) { + return []; + } + + return [ + { + pos: selection.to, + above: false, + strictSide: false, + create() { + const dom = document.createElement("div"); + dom.className = + "bg-popover text-popover-foreground z-50 rounded-sm border border-input p-1 shadow-md flex items-center gap-2 text-sm"; + + const addToChatButton = document.createElement("button"); + addToChatButton.textContent = "Add to Chat"; + addToChatButton.className = + "font-sans p-1 px-2 hover:bg-foreground/10 rounded-sm"; + + const quickEditButton = document.createElement("button"); + quickEditButton.className = + "font-sans p-1 px-2 hover:bg-foreground/10 rounded-sm flex items-center gap-1"; + + const quickEditButtonText = document.createElement("span"); + quickEditButtonText.textContent = "Quick Edit"; + + const quickEditButtonShortcut = document.createElement("span"); + quickEditButtonShortcut.textContent = "⌘K"; + quickEditButtonShortcut.className = "text-sm opacity-60"; + + quickEditButton.appendChild(quickEditButtonText); + quickEditButton.appendChild(quickEditButtonShortcut); + + quickEditButton.onclick = () => { + if (editorView) { + editorView.dispatch({ + effects: showQuickEditEffect.of(true), + }); + } + }; + + dom.appendChild(addToChatButton); + dom.appendChild(quickEditButton); + + return { dom }; + }, + }, + ]; +}; + +const selectionTooltipField = StateField.define({ + create(state) { + return createTooltipForSelection(state); + }, + + update(tooltips, transaction) { + if (transaction.docChanged || transaction.selection) { + return createTooltipForSelection(transaction.state); + } + for (const effect of transaction.effects) { + if (effect.is(showQuickEditEffect)) { + return createTooltipForSelection(transaction.state); + } + } + return tooltips; + }, + + provide: (field) => + showTooltip.computeN([field], (state) => state.field(field)), +}); + +const captureViewExtension = EditorView.updateListener.of((update) => { + editorView = update.view; +}); + +export const selectionTooltip = () => [ + selectionTooltipField, + captureViewExtension, +]; diff --git a/src/features/editor/extensions/suggestion/fetcher.ts b/src/features/editor/extensions/suggestion/fetcher.ts new file mode 100644 index 0000000..e7fc874 --- /dev/null +++ b/src/features/editor/extensions/suggestion/fetcher.ts @@ -0,0 +1,49 @@ +import ky from "ky"; +import { z } from "zod"; +import { toast } from "sonner"; + +const suggestionRequestSchema = z.object({ + fileName: z.string(), + code: z.string(), + currentLine: z.string(), + previousLines: z.string(), + textBeforeCursor: z.string(), + textAfterCursor: z.string(), + nextLines: z.string(), + lineNumber: z.number(), +}); + +const suggestionResponseSchema = z.object({ + suggestion: z.string(), +}); + +type SuggestionRequest = z.infer; +type SuggestionResponse = z.infer; + +export const fetcher = async ( + payload: SuggestionRequest, + signal: AbortSignal +): Promise => { + try { + const validatedPayload = suggestionRequestSchema.parse(payload); + + const response = await ky + .post("/api/suggestion", { + json: validatedPayload, + signal, + timeout: 10_000, + retry: 0, + }) + .json(); + + const validatedResponse = suggestionResponseSchema.parse(response); + + return validatedResponse.suggestion || null; + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + return null; + } + toast.error("Failed to fetch AI completion"); + return null; + } +}; diff --git a/src/features/editor/extensions/suggestion/index.ts b/src/features/editor/extensions/suggestion/index.ts new file mode 100644 index 0000000..bda1862 --- /dev/null +++ b/src/features/editor/extensions/suggestion/index.ts @@ -0,0 +1,234 @@ +import { + Decoration, + DecorationSet, + EditorView, + ViewPlugin, + ViewUpdate, + WidgetType, + keymap, +} from "@codemirror/view"; +import { StateEffect, StateField } from "@codemirror/state"; + +import { fetcher } from "./fetcher"; + +// StateEffect: A way to send "messages" to update state. +// We define one effect type for setting the suggestion text. +const setSuggestionEffect = StateEffect.define(); + +// StateField: Holds our suggestion state in the editor. +// - create(): Returns the initial value when the editor loads +// - update(): Called on every transaction (keystroke, etc.) to potentially update the value +const suggestionState = StateField.define({ + create() { + return null; + }, + update(value, transaction) { + // Check each effect in this transaction + // If we find our setSuggestionEffect, return its new value + // Otherwise, keep the current value unchanged + for (const effect of transaction.effects) { + if (effect.is(setSuggestionEffect)) { + return effect.value; + } + } + return value; + }, +}); + +// WidgetType: Creates custom DOM elements to display in the editor. +// toDOM() is called by CodeMirror to create the actual HTML element. +class SuggestionWidget extends WidgetType { + constructor(readonly text: string) { + super(); + } + + toDOM() { + const span = document.createElement("span"); + span.textContent = this.text; + span.style.opacity = "0.4"; + span.style.pointerEvents = "none"; + return span; + } +} + +let debounceTimer: number | null = null; +let isWaitingForSuggestion = false; +const DEBOUNCE_DELAY = 300; + +let currentAbortController: AbortController | null = null; + +const generateFakeSuggestion = (textBeforeCursor: string): string | null => { + const trimmed = textBeforeCursor.trimEnd(); + if (trimmed.endsWith("const")) return " myVariable = "; + if (trimmed.endsWith("function")) return " myFunction() {\n \n}"; + if (trimmed.endsWith("console.")) return "log()"; + if (trimmed.endsWith("return")) return "null"; + return null; +}; + +const generatePayload = (view: EditorView, fileName: string) => { + const code = view.state.doc.toString(); + if (!code || code.trim().length === 0) return null; + + const cursorPosition = view.state.selection.main.head; + const currentLine = view.state.doc.lineAt(cursorPosition); + const cursorInLine = cursorPosition - currentLine.from; + + const previousLines: string[] = []; + const previousLinesToFetch = Math.min(5, currentLine.number - 1); + for (let i = previousLinesToFetch; i >= 1; i--) { + previousLines.push(view.state.doc.line(currentLine.number - i).text); + } + + const nextLines: string[] = []; + const totalLines = view.state.doc.lines; + const linesToFetch = Math.min(5, totalLines - currentLine.number); + for (let i = 1; i <= linesToFetch; i++) { + nextLines.push(view.state.doc.line(currentLine.number + i).text); + } + + return { + fileName, + code, + currentLine: currentLine.text, + previousLines: previousLines.join("\n"), + textBeforeCursor: currentLine.text.slice(0, cursorInLine), + textAfterCursor: currentLine.text.slice(cursorInLine), + nextLines: nextLines.join("\n"), + lineNumber: currentLine.number, + }; +}; + +const createDebouncePlugin = (fileName: string) => { + return ViewPlugin.fromClass( + class { + constructor(view: EditorView) { + this.triggerSuggestion(view); + } + + update(update: ViewUpdate) { + if (update.docChanged || update.selectionSet) { + this.triggerSuggestion(update.view); + } + } + + triggerSuggestion(view: EditorView) { + if (debounceTimer !== null) { + clearTimeout(debounceTimer); + } + + if (currentAbortController !== null) { + currentAbortController.abort(); + } + + isWaitingForSuggestion = true; + + debounceTimer = window.setTimeout(async () => { + const payload = generatePayload(view, fileName); + if (!payload) { + isWaitingForSuggestion = false; + view.dispatch({ effects: setSuggestionEffect.of(null) }); + return; + } + currentAbortController = new AbortController(); + const suggestion = await fetcher( + payload, + currentAbortController.signal + ); + + isWaitingForSuggestion = false; + view.dispatch({ + effects: setSuggestionEffect.of(suggestion), + }); + }, DEBOUNCE_DELAY); + } + + destroy() { + if (debounceTimer !== null) { + clearTimeout(debounceTimer); + } + + if (currentAbortController !== null) { + currentAbortController.abort(); + } + } + } + ); +}; + +const renderPlugin = ViewPlugin.fromClass( + class { + decorations: DecorationSet; + + constructor(view: EditorView) { + this.decorations = this.build(view); + } + + update(update: ViewUpdate) { + // Rebuild decorations if doc changed, cursor moved, or suggestion changed + const suggestionChanged = update.transactions.some((transaction) => { + return transaction.effects.some((effect) => { + return effect.is(setSuggestionEffect); + }); + }); + + // Rebuild decorations if doc changed, cursor moved, or suggestion changed + const shouldRebuild = + update.docChanged || update.selectionSet || suggestionChanged; + + if (shouldRebuild) { + this.decorations = this.build(update.view); + } + } + + build(view: EditorView) { + if (isWaitingForSuggestion) { + return Decoration.none; + } + + // Get current suggestion from state + const suggestion = view.state.field(suggestionState); + if (!suggestion) { + return Decoration.none; + } + + // Create a widget decoration at the cursor position + // side: 1 means the widget will be on the right side of the cursor + const cursor = view.state.selection.main.head; + return Decoration.set([ + Decoration.widget({ + widget: new SuggestionWidget(suggestion), + side: 1, + }).range(cursor), + ]); + } + }, + { decorations: (plugin) => plugin.decorations } // Tell CodeMirror to use our decorations +); + +const acceptSuggestionKeymap = keymap.of([ + { + key: "Tab", + run: (view) => { + const suggestion = view.state.field(suggestionState); + if (!suggestion) { + return false; // No suggestion? Let Tab do its normal thing (indent) + } + + const cursor = view.state.selection.main.head; + view.dispatch({ + changes: { from: cursor, insert: suggestion }, // Insert the suggestion text + selection: { anchor: cursor + suggestion.length }, // Move cursor to end + effects: setSuggestionEffect.of(null), // Clear the suggestion + }); + return true; // We handled Tab, don't indent + }, + }, +]); + +export const suggestion = (fileName: string) => [ + suggestionState, // Our state storage + createDebouncePlugin(fileName), + renderPlugin, // Renders the ghost text + acceptSuggestionKeymap, // Accepts the suggestion +];