Merge pull request #4 from Code-Victor/feat/ai-edit-selection-n-a11y

Feat/ai edit selection n a11y
This commit is contained in:
James Murdza 2024-09-06 14:09:43 -07:00 committed by GitHub
commit 31d74ddc2d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 312 additions and 151 deletions

View File

@ -1,6 +1,6 @@
"use client"
import { useEffect, useRef, useState } from "react"
import { useCallback, useEffect, useRef, useState } from "react"
import { Button } from "../ui/button"
import { Check, Loader2, RotateCw, Sparkles, X } from "lucide-react"
import { Socket } from "socket.io-client"
@ -84,6 +84,13 @@ export default function GenerateInput({
}
)
}
const handleGenerateForm = useCallback(
(e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
handleGenerate({ regenerate: false })
},
[]
)
useEffect(() => {
if (code) {
@ -106,7 +113,10 @@ export default function GenerateInput({
return (
<div className="w-full pr-4 space-y-2">
<div className="flex items-center font-sans space-x-2">
<form
onSubmit={handleGenerateForm}
className="flex items-center font-sans space-x-2"
>
<input
ref={inputRef}
style={{
@ -120,8 +130,8 @@ export default function GenerateInput({
<Button
size="sm"
type="submit"
disabled={loading.generate || loading.regenerate || input === ""}
onClick={() => handleGenerate({})}
>
{loading.generate ? (
<>
@ -137,13 +147,14 @@ export default function GenerateInput({
</Button>
<Button
onClick={onClose}
type="button"
variant="outline"
size="smIcon"
className="bg-transparent shrink-0 border-muted-foreground"
>
<X className="h-3 w-3" />
</Button>
</div>
</form>
{expanded ? (
<>
<div className="rounded-md border border-muted-foreground w-full h-28 overflow-y-scroll p-2">

View File

@ -1,10 +1,11 @@
"use client"
import { SetStateAction, useCallback, useEffect, useRef, useState } from "react"
import monaco from "monaco-editor"
import * as monaco from "monaco-editor"
import Editor, { BeforeMount, OnMount } from "@monaco-editor/react"
import { toast } from "sonner"
import { useClerk } from "@clerk/nextjs"
import { AnimatePresence, motion } from "framer-motion"
import * as Y from "yjs"
import LiveblocksProvider from "@liveblocks/yjs"
@ -17,7 +18,7 @@ import {
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable"
import { FileJson, Loader2, TerminalSquare } from "lucide-react"
import { FileJson, Loader2, Sparkles, TerminalSquare } from "lucide-react"
import Tab from "../ui/tab"
import Sidebar from "./sidebar"
import GenerateInput from "./generate"
@ -30,8 +31,10 @@ import Loading from "./loading"
import PreviewWindow from "./preview"
import Terminals from "./terminals"
import { ImperativePanelHandle } from "react-resizable-panels"
import { PreviewProvider, usePreview } from '@/context/PreviewContext';
import { PreviewProvider, usePreview } from "@/context/PreviewContext"
import { useSocket } from "@/context/SocketContext"
import { Button } from "../ui/button"
import React from "react"
export default function CodeEditor({
userData,
@ -40,9 +43,8 @@ export default function CodeEditor({
userData: User
sandboxData: Sandbox
}) {
//SocketContext functions and effects
const { socket, setUserAndSandboxId } = useSocket();
const { socket, setUserAndSandboxId } = useSocket()
useEffect(() => {
// Ensure userData.id and sandboxData.id are available before attempting to connect
@ -50,10 +52,10 @@ export default function CodeEditor({
// Check if the socket is not initialized or not connected
if (!socket || (socket && !socket.connected)) {
// Initialize socket connection
setUserAndSandboxId(userData.id, sandboxData.id);
setUserAndSandboxId(userData.id, sandboxData.id)
}
}
}, [socket, userData.id, sandboxData.id, setUserAndSandboxId]);
}, [socket, userData.id, sandboxData.id, setUserAndSandboxId])
//Preview Button state
const [isPreviewCollapsed, setIsPreviewCollapsed] = useState(true)
@ -89,7 +91,8 @@ export default function CodeEditor({
options: monaco.editor.IModelDeltaDecoration[]
instance: monaco.editor.IEditorDecorationsCollection | undefined
}>({ options: [], instance: undefined })
const [isSelected, setIsSelected] = useState(false)
const [showSuggestion, setShowSuggestion] = useState(false)
// Terminal state
const [terminals, setTerminals] = useState<
{
@ -99,13 +102,13 @@ export default function CodeEditor({
>([])
// Preview state
const [previewURL, setPreviewURL] = useState<string>("");
const [previewURL, setPreviewURL] = useState<string>("")
const loadPreviewURL = (url: string) => {
// This will cause a reload if previewURL changed.
setPreviewURL(url);
setPreviewURL(url)
// If the URL didn't change, still reload the preview.
previewWindowRef.current?.refreshIframe();
previewWindowRef.current?.refreshIframe()
}
const isOwner = sandboxData.userId === userData.id
@ -118,23 +121,29 @@ export default function CodeEditor({
// Liveblocks providers map to prevent reinitializing providers
type ProviderData = {
provider: LiveblocksProvider<never, never, never, never>;
yDoc: Y.Doc;
yText: Y.Text;
binding?: MonacoBinding;
onSync: (isSynced: boolean) => void;
};
const providersMap = useRef(new Map<string, ProviderData>());
provider: LiveblocksProvider<never, never, never, never>
yDoc: Y.Doc
yText: Y.Text
binding?: MonacoBinding
onSync: (isSynced: boolean) => void
}
const providersMap = useRef(new Map<string, ProviderData>())
// Refs for libraries / features
const editorContainerRef = useRef<HTMLDivElement>(null)
const monacoRef = useRef<typeof monaco | null>(null)
const generateRef = useRef<HTMLDivElement>(null)
const suggestionRef = useRef<HTMLDivElement>(null)
const generateWidgetRef = useRef<HTMLDivElement>(null)
const previewPanelRef = useRef<ImperativePanelHandle>(null)
const editorPanelRef = useRef<ImperativePanelHandle>(null)
const previewWindowRef = useRef<{ refreshIframe: () => void }>(null)
const debouncedSetIsSelected = useRef(
debounce((value: boolean) => {
setIsSelected(value)
}, 800) //
).current
// Pre-mount editor keybindings
const handleEditorWillMount: BeforeMount = (monaco) => {
monaco.editor.addKeybindingRules([
@ -151,6 +160,13 @@ export default function CodeEditor({
monacoRef.current = monaco
editor.onDidChangeCursorPosition((e) => {
setIsSelected(false)
const selection = editor.getSelection()
if (selection !== null) {
const hasSelection = !selection.isEmpty()
debouncedSetIsSelected(hasSelection)
setShowSuggestion(hasSelection)
}
const { column, lineNumber } = e.position
if (lineNumber === cursorLine) return
setCursorLine(lineNumber)
@ -204,7 +220,44 @@ export default function CodeEditor({
},
})
}
const handleAiEdit = React.useCallback(() => {
if (!editorRef) return
const selection = editorRef.getSelection()
if (!selection) return
const pos = selection.getPosition()
const start = selection.getStartPosition()
const end = selection.getEndPosition()
let pref: monaco.editor.ContentWidgetPositionPreference
let id = ""
const isMultiline = start.lineNumber !== end.lineNumber
if (isMultiline) {
if (pos.lineNumber <= start.lineNumber) {
pref = monaco.editor.ContentWidgetPositionPreference.ABOVE
} else {
pref = monaco.editor.ContentWidgetPositionPreference.BELOW
}
} else {
pref = monaco.editor.ContentWidgetPositionPreference.ABOVE
}
editorRef.changeViewZones(function (changeAccessor) {
if (!generateRef.current) return
if (pref === monaco.editor.ContentWidgetPositionPreference.ABOVE) {
id = changeAccessor.addZone({
afterLineNumber: start.lineNumber - 1,
heightInLines: 2,
domNode: generateRef.current,
})
}
})
setGenerate((prev) => {
return {
...prev,
show: true,
pref: [pref],
id,
}
})
}, [editorRef])
// Generate widget effect
useEffect(() => {
if (!ai) {
@ -217,8 +270,10 @@ export default function CodeEditor({
return
}
if (generate.show) {
setShowSuggestion(false)
editorRef?.changeViewZones(function (changeAccessor) {
if (!generateRef.current) return
if (!generate.id) {
const id = changeAccessor.addZone({
afterLineNumber: cursorLine,
heightInLines: 3,
@ -227,6 +282,10 @@ export default function CodeEditor({
setGenerate((prev) => {
return { ...prev, id, line: cursorLine }
})
}
setGenerate((prev) => {
return { ...prev, line: cursorLine }
})
})
if (!generateWidgetRef.current) return
@ -286,6 +345,41 @@ export default function CodeEditor({
})
}
}, [generate.show])
// Suggestion widget effect
useEffect(() => {
if (!suggestionRef.current || !editorRef) return
const widgetElement = suggestionRef.current
const suggestionWidget: monaco.editor.IContentWidget = {
getDomNode: () => {
return widgetElement
},
getId: () => {
return "suggestion.widget"
},
getPosition: () => {
const selection = editorRef?.getSelection()
const column = Math.max(3, selection?.positionColumn ?? 1)
let lineNumber = selection?.positionLineNumber ?? 1
let pref = monaco.editor.ContentWidgetPositionPreference.ABOVE
if (lineNumber <= 3) {
pref = monaco.editor.ContentWidgetPositionPreference.BELOW
}
return {
preference: [pref],
position: {
lineNumber,
column,
},
}
},
}
if (isSelected) {
editorRef?.addContentWidget(suggestionWidget)
editorRef?.applyFontInfo(suggestionRef.current)
} else {
editorRef?.removeContentWidget(suggestionWidget)
}
}, [isSelected])
// Decorations effect for generate widget tips
useEffect(() => {
@ -325,27 +419,27 @@ export default function CodeEditor({
prev.map((tab) =>
tab.id === activeFileId ? { ...tab, saved: true } : tab
)
);
console.log(`Saving file...${activeFileId}`);
console.log(`Saving file...${value}`);
socket?.emit("saveFile", activeFileId, value);
)
console.log(`Saving file...${activeFileId}`)
console.log(`Saving file...${value}`)
socket?.emit("saveFile", activeFileId, value)
}, Number(process.env.FILE_SAVE_DEBOUNCE_DELAY) || 1000),
[socket]
);
)
useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === "s" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
debouncedSaveData(editorRef?.getValue(), activeFileId);
e.preventDefault()
debouncedSaveData(editorRef?.getValue(), activeFileId)
}
};
document.addEventListener("keydown", down);
}
document.addEventListener("keydown", down)
return () => {
document.removeEventListener("keydown", down);
};
}, [activeFileId, tabs, debouncedSaveData]);
document.removeEventListener("keydown", down)
}
}, [activeFileId, tabs, debouncedSaveData])
// Liveblocks live collaboration setup effect
useEffect(() => {
@ -354,13 +448,13 @@ export default function CodeEditor({
if (!editorRef || !tab || !model) return
let providerData: ProviderData;
let providerData: ProviderData
// When a file is opened for the first time, create a new provider and store in providersMap.
if (!providersMap.current.has(tab.id)) {
const yDoc = new Y.Doc();
const yText = yDoc.getText(tab.id);
const yProvider = new LiveblocksProvider(room, yDoc);
const yDoc = new Y.Doc()
const yText = yDoc.getText(tab.id)
const yProvider = new LiveblocksProvider(room, yDoc)
// Inserts the file content into the editor once when the tab is changed.
const onSync = (isSynced: boolean) => {
@ -381,12 +475,11 @@ export default function CodeEditor({
yProvider.on("sync", onSync)
// Save the provider to the map.
providerData = { provider: yProvider, yDoc, yText, onSync };
providersMap.current.set(tab.id, providerData);
providerData = { provider: yProvider, yDoc, yText, onSync }
providersMap.current.set(tab.id, providerData)
} else {
// When a tab is opened that has been open before, reuse the existing provider.
providerData = providersMap.current.get(tab.id)!;
providerData = providersMap.current.get(tab.id)!
}
const binding = new MonacoBinding(
@ -394,21 +487,21 @@ export default function CodeEditor({
model,
new Set([editorRef]),
providerData.provider.awareness as unknown as Awareness
);
)
providerData.binding = binding;
setProvider(providerData.provider);
providerData.binding = binding
setProvider(providerData.provider)
return () => {
// Cleanup logic
if (binding) {
binding.destroy();
binding.destroy()
}
if (providerData.binding) {
providerData.binding = undefined;
providerData.binding = undefined
}
};
}, [room, activeFileContent]);
}
}, [room, activeFileContent])
// Added this effect to clean up when the component unmounts
useEffect(() => {
@ -416,14 +509,14 @@ export default function CodeEditor({
// Clean up all providers when the component unmounts
providersMap.current.forEach((data) => {
if (data.binding) {
data.binding.destroy();
data.binding.destroy()
}
data.provider.disconnect();
data.yDoc.destroy();
});
providersMap.current.clear();
};
}, []);
data.provider.disconnect()
data.yDoc.destroy()
})
providersMap.current.clear()
}
}, [])
// Connection/disconnection effect
useEffect(() => {
@ -435,7 +528,7 @@ export default function CodeEditor({
// Socket event listener effect
useEffect(() => {
const onConnect = () => { }
const onConnect = () => {}
const onDisconnect = () => {
setTerminals([])
@ -481,48 +574,55 @@ export default function CodeEditor({
socket?.off("disableAccess", onDisableAccess)
socket?.off("previewURL", loadPreviewURL)
}
}, [socket, terminals, setTerminals, setFiles, toast, setDisableAccess, isOwner, loadPreviewURL]);
}, [
socket,
terminals,
setTerminals,
setFiles,
toast,
setDisableAccess,
isOwner,
loadPreviewURL,
])
// Helper functions for tabs:
// Select file and load content
// Initialize debounced function once
const fileCache = useRef(new Map());
const fileCache = useRef(new Map())
// Debounced function to get file content
const debouncedGetFile =
(tabId: any, callback: any) => {
socket?.emit('getFile', tabId, callback);
const debouncedGetFile = (tabId: any, callback: any) => {
socket?.emit("getFile", tabId, callback)
} // 300ms debounce delay, adjust as needed
const selectFile = (tab: TTab) => {
if (tab.id === activeFileId) return
if (tab.id === activeFileId) return;
setGenerate((prev) => ({ ...prev, show: false }))
setGenerate((prev) => ({ ...prev, show: false }));
const exists = tabs.find((t) => t.id === tab.id);
const exists = tabs.find((t) => t.id === tab.id)
setTabs((prev) => {
if (exists) {
setActiveFileId(exists.id);
return prev;
setActiveFileId(exists.id)
return prev
}
return [...prev, tab];
});
return [...prev, tab]
})
if (fileCache.current.has(tab.id)) {
setActiveFileContent(fileCache.current.get(tab.id));
setActiveFileContent(fileCache.current.get(tab.id))
} else {
debouncedGetFile(tab.id, (response: SetStateAction<string>) => {
fileCache.current.set(tab.id, response);
setActiveFileContent(response);
});
fileCache.current.set(tab.id, response)
setActiveFileContent(response)
})
}
setEditorLanguage(processFileType(tab.name));
setActiveFileId(tab.id);
};
setEditorLanguage(processFileType(tab.name))
setActiveFileId(tab.id)
}
// Close tab and remove from tabs
const closeTab = (id: string) => {
@ -632,7 +732,7 @@ export default function CodeEditor({
<DisableAccessModal
message={disableAccess.message}
open={disableAccess.isDisabled}
setOpen={() => { }}
setOpen={() => {}}
/>
<Loading />
</>
@ -643,6 +743,23 @@ export default function CodeEditor({
{/* Copilot DOM elements */}
<PreviewProvider>
<div ref={generateRef} />
<div ref={suggestionRef} className="absolute">
<AnimatePresence>
{isSelected && ai && showSuggestion && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ ease: "easeOut", duration: 0.2 }}
>
<Button size="xs" type="submit" onClick={handleAiEdit}>
<Sparkles className="h-3 w-3 mr-1" />
Edit Code
</Button>
</motion.div>
)}
</AnimatePresence>
</div>
<div className="z-50 p-1" ref={generateWidgetRef}>
{generate.show && ai ? (
<GenerateInput
@ -651,22 +768,49 @@ export default function CodeEditor({
width={generate.width - 90}
data={{
fileName: tabs.find((t) => t.id === activeFileId)?.name ?? "",
code: editorRef?.getValue() ?? "",
code:
(isSelected && editorRef?.getSelection()
? editorRef
?.getModel()
?.getValueInRange(editorRef?.getSelection()!)
: editorRef?.getValue()) ?? "",
line: generate.line,
}}
editor={{
language: editorLanguage,
}}
onExpand={() => {
const line = generate.line
editorRef?.changeViewZones(function (changeAccessor) {
changeAccessor.removeZone(generate.id)
if (!generateRef.current) return
const id = changeAccessor.addZone({
afterLineNumber: cursorLine,
heightInLines: 12,
let id = ""
if (isSelected) {
const selection = editorRef?.getSelection()
if (!selection) return
const isAbove =
generate.pref?.[0] ===
monaco.editor.ContentWidgetPositionPreference.ABOVE
const afterLineNumber = isAbove ? line - 1 : line
id = changeAccessor.addZone({
afterLineNumber,
heightInLines: isAbove?11: 12,
domNode: generateRef.current,
})
const contentWidget= generate.widget
if (contentWidget){
editorRef?.layoutContentWidget(contentWidget)
}
} else {
id = changeAccessor.addZone({
afterLineNumber: cursorLine,
heightInLines: 12,
domNode: generateRef.current,
})
}
setGenerate((prev) => {
return { ...prev, id }
})
@ -680,12 +824,14 @@ export default function CodeEditor({
show: !prev.show,
}
})
const file = editorRef?.getValue()
const lines = file?.split("\n") || []
lines.splice(line - 1, 0, code)
const updatedFile = lines.join("\n")
editorRef?.setValue(updatedFile)
const selection = editorRef?.getSelection()
const range =
isSelected && selection
? selection
: new monaco.Range(line, 1, line, 1)
editorRef?.executeEdits("ai-generation", [
{ range, text: code, forceMoveMarkers: true },
])
}}
onClose={() => {
setGenerate((prev) => {
@ -825,7 +971,11 @@ export default function CodeEditor({
open={() => {
usePreview().previewPanelRef.current?.expand()
setIsPreviewCollapsed(false)
}} collapsed={isPreviewCollapsed} src={previewURL} ref={previewWindowRef} />
}}
collapsed={isPreviewCollapsed}
src={previewURL}
ref={previewWindowRef}
/>
</ResizablePanel>
<ResizableHandle />
<ResizablePanel
@ -849,4 +999,3 @@ export default function CodeEditor({
</>
)
}

View File

@ -22,6 +22,7 @@ const buttonVariants = cva(
},
size: {
default: "h-9 px-4 py-2",
xs: "h-6 px-2.5 py-1.5 rounded-sm text-[0.7rem]",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",