Browse Source

fix(desktop): so many prompt input fixes, merry christmas

Adam 2 months ago
parent
commit
5b5b8c57d9
1 changed files with 85 additions and 63 deletions
  1. 85 63
      packages/app/src/components/prompt-input.tsx

+ 85 - 63
packages/app/src/components/prompt-input.tsx

@@ -103,7 +103,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     imageAttachments: ImageAttachmentPart[]
     mode: "normal" | "shell"
     applyingHistory: boolean
-    userHasEdited: boolean
   }>({
     popover: null,
     historyIndex: -1,
@@ -113,7 +112,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     imageAttachments: [],
     mode: "normal",
     applyingHistory: false,
-    userHasEdited: false,
   })
 
   const MAX_HISTORY = 100
@@ -150,7 +148,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   const applyHistoryPrompt = (p: Prompt, position: "start" | "end") => {
     const length = position === "start" ? 0 : promptLength(p)
     setStore("applyingHistory", true)
-    setStore("userHasEdited", false)
     prompt.set(p, length)
     requestAnimationFrame(() => {
       editorRef.focus()
@@ -279,11 +276,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   })
 
   createEffect(() => {
-    if (isFocused()) {
-      handleInput()
-    } else {
-      setStore("popover", null)
-    }
+    if (!isFocused()) setStore("popover", null)
   })
 
   const handleFileSelect = (path: string | undefined) => {
@@ -363,7 +356,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       () => prompt.current(),
       (currentParts) => {
         const domParts = parseFromDOM()
-        if (isPromptEqual(currentParts, domParts)) return
+        const normalized = Array.from(editorRef.childNodes).every((node) => {
+          if (node.nodeType === Node.TEXT_NODE) return true
+          if (node.nodeType !== Node.ELEMENT_NODE) return false
+          return (node as HTMLElement).dataset.type === "file"
+        })
+        if (normalized && isPromptEqual(currentParts, domParts)) return
 
         const selection = window.getSelection()
         let cursorPosition: number | null = null
@@ -395,34 +393,21 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   )
 
   const parseFromDOM = (): Prompt => {
-    const newParts: Prompt = []
+    const parts: Prompt = []
     let position = 0
+    let buffer = ""
 
-    const pushText = (content: string) => {
+    const flushText = () => {
+      const content = buffer.replace(/\r\n?/g, "\n")
+      buffer = ""
       if (!content) return
-      newParts.push({ type: "text", content, start: position, end: position + content.length })
+      parts.push({ type: "text", content, start: position, end: position + content.length })
       position += content.length
     }
 
-    const rangeText = (range: Range) => {
-      const fragment = range.cloneContents()
-      const container = document.createElement("div")
-      container.append(fragment)
-      return container.innerText
-    }
-
-    const files = Array.from(editorRef.querySelectorAll<HTMLElement>("[data-type=file]"))
-    let last: HTMLElement | undefined
-
-    files.forEach((file) => {
-      const before = document.createRange()
-      before.selectNodeContents(editorRef)
-      if (last) before.setStartAfter(last)
-      before.setEndBefore(file)
-      pushText(rangeText(before))
-
+    const pushFile = (file: HTMLElement) => {
       const content = file.textContent ?? ""
-      newParts.push({
+      parts.push({
         type: "file",
         path: file.dataset.path!,
         content,
@@ -430,16 +415,44 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
         end: position + content.length,
       })
       position += content.length
-      last = file
+    }
+
+    const visit = (node: Node) => {
+      if (node.nodeType === Node.TEXT_NODE) {
+        buffer += node.textContent ?? ""
+        return
+      }
+      if (node.nodeType !== Node.ELEMENT_NODE) return
+
+      const el = node as HTMLElement
+      if (el.dataset.type === "file") {
+        flushText()
+        pushFile(el)
+        return
+      }
+      if (el.tagName === "BR") {
+        buffer += "\n"
+        return
+      }
+
+      for (const child of Array.from(el.childNodes)) {
+        visit(child)
+      }
+    }
+
+    const children = Array.from(editorRef.childNodes)
+    children.forEach((child, index) => {
+      const isBlock = child.nodeType === Node.ELEMENT_NODE && ["DIV", "P"].includes((child as HTMLElement).tagName)
+      visit(child)
+      if (isBlock && index < children.length - 1) {
+        buffer += "\n"
+      }
     })
 
-    const after = document.createRange()
-    after.selectNodeContents(editorRef)
-    if (last) after.setStartAfter(last)
-    pushText(rangeText(after))
+    flushText()
 
-    if (newParts.length === 0) newParts.push(...DEFAULT_PROMPT)
-    return newParts
+    if (parts.length === 0) parts.push(...DEFAULT_PROMPT)
+    return parts
   }
 
   const handleInput = () => {
@@ -452,7 +465,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
 
     if (shouldReset) {
       setStore("popover", null)
-      setStore("userHasEdited", false)
       if (store.historyIndex >= 0 && !store.applyingHistory) {
         setStore("historyIndex", -1)
         setStore("savedPrompt", null)
@@ -487,10 +499,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       setStore("savedPrompt", null)
     }
 
-    if (!store.applyingHistory) {
-      setStore("userHasEdited", true)
-    }
-
     prompt.set(rawParts, cursorPosition)
   }
 
@@ -516,27 +524,39 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       const gap = document.createTextNode(" ")
       const range = selection.getRangeAt(0)
 
-      if (atMatch) {
-        let runningLength = 0
-
-        const walker = document.createTreeWalker(editorRef, NodeFilter.SHOW_TEXT, null)
-        let currentNode = walker.nextNode()
-        while (currentNode) {
-          const textContent = currentNode.textContent || ""
-          if (runningLength + textContent.length >= atMatch.index!) {
-            const localStart = atMatch.index! - runningLength
-            const localEnd = cursorPosition - runningLength
-            if (currentNode === range.startContainer || runningLength + textContent.length >= cursorPosition) {
-              range.setStart(currentNode, localStart)
-              range.setEnd(currentNode, Math.min(localEnd, textContent.length))
-              break
-            }
+      const setEdge = (edge: "start" | "end", offset: number) => {
+        let remaining = offset
+        const nodes = Array.from(editorRef.childNodes)
+
+        for (const node of nodes) {
+          const length = node.textContent?.length ?? 0
+          const isText = node.nodeType === Node.TEXT_NODE
+          const isFile = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.type === "file"
+
+          if (isText && remaining <= length) {
+            if (edge === "start") range.setStart(node, remaining)
+            if (edge === "end") range.setEnd(node, remaining)
+            return
+          }
+
+          if (isFile && remaining <= length) {
+            if (edge === "start" && remaining === 0) range.setStartBefore(node)
+            if (edge === "start" && remaining > 0) range.setStartAfter(node)
+            if (edge === "end" && remaining === 0) range.setEndBefore(node)
+            if (edge === "end" && remaining > 0) range.setEndAfter(node)
+            return
           }
-          runningLength += textContent.length
-          currentNode = walker.nextNode()
+
+          remaining -= length
         }
       }
 
+      if (atMatch) {
+        const start = atMatch.index ?? cursorPosition - atMatch[0].length
+        setEdge("start", start)
+        setEdge("end", cursorPosition)
+      }
+
       range.deleteContents()
       range.insertNode(gap)
       range.insertNode(pill)
@@ -584,8 +604,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   }
 
   const navigateHistory = (direction: "up" | "down") => {
-    if (store.userHasEdited) return false
-
     const entries = store.mode === "shell" ? shellHistory.entries : history.entries
     const current = store.historyIndex
 
@@ -692,6 +710,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       return
     }
 
+    if (event.key === "Enter" && event.shiftKey) {
+      addPart({ type: "text", content: "\n", start: 0, end: 0 })
+      event.preventDefault()
+      return
+    }
     if (event.key === "Enter" && !event.shiftKey) {
       handleSubmit(event)
     }
@@ -717,7 +740,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     addToHistory(currentPrompt, store.mode)
     setStore("historyIndex", -1)
     setStore("savedPrompt", null)
-    setStore("userHasEdited", false)
 
     let existing = info()
     if (!existing) {
@@ -968,7 +990,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
             onKeyDown={handleKeyDown}
             classList={{
               "w-full px-5 py-3 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
-              "[&>[data-type=file]]:text-icon-info-active": true,
+              "[&_[data-type=file]]:text-icon-info-active": true,
               "font-mono!": store.mode === "shell",
             }}
           />