diff options
| author | Adam <[email protected]> | 2025-12-26 20:47:13 -0600 |
|---|---|---|
| committer | Adam <[email protected]> | 2025-12-26 20:47:13 -0600 |
| commit | 4385fa4dd79955cdb1d7086365ee1a238ebf9748 (patch) | |
| tree | 1809f39a2a00d8040fe1cd8b073a03d54cb2922a /packages/app/src/components | |
| parent | 2b054bec9582b6a6ba421d5ea40576878f8e59e8 (diff) | |
| download | opencode-4385fa4dd79955cdb1d7086365ee1a238ebf9748.tar.gz opencode-4385fa4dd79955cdb1d7086365ee1a238ebf9748.zip | |
fix(desktop): prompt input fixes, directory and branch in status bar
Diffstat (limited to 'packages/app/src/components')
| -rw-r--r-- | packages/app/src/components/prompt-input.tsx | 161 | ||||
| -rw-r--r-- | packages/app/src/components/status-bar.tsx | 26 |
2 files changed, 168 insertions, 19 deletions
diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 03fa02fe3..2407fe97a 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -82,6 +82,37 @@ export const PromptInput: Component<PromptInputProps> = (props) => { const command = useCommand() let editorRef!: HTMLDivElement let fileInputRef!: HTMLInputElement + let scrollRef!: HTMLDivElement + + const scrollCursorIntoView = () => { + const container = scrollRef + const selection = window.getSelection() + if (!container || !selection || selection.rangeCount === 0) return + + const range = selection.getRangeAt(0) + if (!editorRef.contains(range.startContainer)) return + + const rect = range.getBoundingClientRect() + if (!rect.height) return + + const containerRect = container.getBoundingClientRect() + const top = rect.top - containerRect.top + container.scrollTop + const bottom = rect.bottom - containerRect.top + container.scrollTop + const padding = 12 + + if (top < container.scrollTop + padding) { + container.scrollTop = Math.max(0, top - padding) + return + } + + if (bottom > container.scrollTop + container.clientHeight - padding) { + container.scrollTop = bottom - container.clientHeight + padding + } + } + + const queueScroll = () => { + requestAnimationFrame(scrollCursorIntoView) + } const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const tabs = createMemo(() => layout.tabs(sessionKey())) @@ -153,6 +184,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { editorRef.focus() setCursorPosition(editorRef, length) setStore("applyingHistory", false) + queueScroll() }) } @@ -357,9 +389,23 @@ export const PromptInput: Component<PromptInputProps> = (props) => { (currentParts) => { const domParts = parseFromDOM() const normalized = Array.from(editorRef.childNodes).every((node) => { - if (node.nodeType === Node.TEXT_NODE) return true + if (node.nodeType === Node.TEXT_NODE) { + const text = node.textContent ?? "" + if (!text.includes("\u200B")) return true + if (text !== "\u200B") return false + + const prev = node.previousSibling + const next = node.nextSibling + const prevIsBr = prev?.nodeType === Node.ELEMENT_NODE && (prev as HTMLElement).tagName === "BR" + const nextIsBr = next?.nodeType === Node.ELEMENT_NODE && (next as HTMLElement).tagName === "BR" + if (!prevIsBr && !nextIsBr) return false + if (nextIsBr && !prevIsBr && prev) return false + return true + } if (node.nodeType !== Node.ELEMENT_NODE) return false - return (node as HTMLElement).dataset.type === "file" + const el = node as HTMLElement + if (el.dataset.type === "file") return true + return el.tagName === "BR" }) if (normalized && isPromptEqual(currentParts, domParts)) return @@ -372,7 +418,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { editorRef.innerHTML = "" currentParts.forEach((part) => { if (part.type === "text") { - editorRef.appendChild(document.createTextNode(part.content)) + editorRef.appendChild(createTextFragment(part.content)) } else if (part.type === "file") { const pill = document.createElement("span") pill.textContent = part.content @@ -398,7 +444,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { let buffer = "" const flushText = () => { - const content = buffer.replace(/\r\n?/g, "\n") + const content = buffer.replace(/\r\n?/g, "\n").replace(/\u200B/g, "") buffer = "" if (!content) return parts.push({ type: "text", content, start: position, end: position + content.length }) @@ -472,6 +518,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { if (prompt.dirty()) { prompt.set(DEFAULT_PROMPT, 0) } + queueScroll() return } @@ -500,6 +547,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { } prompt.set(rawParts, cursorPosition) + queueScroll() } const addPart = (part: ContentPart) => { @@ -529,9 +577,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => { const nodes = Array.from(editorRef.childNodes) for (const node of nodes) { - const length = node.textContent?.length ?? 0 + const length = getNodeLength(node) const isText = node.nodeType === Node.TEXT_NODE const isFile = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.type === "file" + const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR" if (isText && remaining <= length) { if (edge === "start") range.setStart(node, remaining) @@ -539,7 +588,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { return } - if (isFile && remaining <= length) { + if ((isFile || isBreak) && 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) @@ -565,11 +614,25 @@ export const PromptInput: Component<PromptInputProps> = (props) => { selection.removeAllRanges() selection.addRange(range) } else if (part.type === "text") { - const textNode = document.createTextNode(part.content) const range = selection.getRangeAt(0) + const fragment = createTextFragment(part.content) + const last = fragment.lastChild range.deleteContents() - range.insertNode(textNode) - range.setStartAfter(textNode) + range.insertNode(fragment) + if (last) { + if (last.nodeType === Node.TEXT_NODE) { + const text = last.textContent ?? "" + if (text === "\u200B") { + range.setStart(last, 0) + } + if (text !== "\u200B") { + range.setStart(last, text.length) + } + } + if (last.nodeType !== Node.TEXT_NODE) { + range.setStartAfter(last) + } + } range.collapse(true) selection.removeAllRanges() selection.addRange(range) @@ -646,6 +709,24 @@ export const PromptInput: Component<PromptInputProps> = (props) => { } const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Backspace") { + const selection = window.getSelection() + if (selection && selection.isCollapsed) { + const node = selection.anchorNode + const offset = selection.anchorOffset + if (node && node.nodeType === Node.TEXT_NODE) { + const text = node.textContent ?? "" + if (/^\u200B+$/.test(text) && offset > 0) { + const range = document.createRange() + range.setStart(node, 0) + range.collapse(true) + selection.removeAllRanges() + selection.addRange(range) + } + } + } + } + if (event.key === "!" && store.mode === "normal") { const cursorPosition = getCursorPosition(editorRef) if (cursorPosition === 0) { @@ -686,7 +767,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => { const cursorPosition = getCursorPosition(editorRef) const textLength = promptLength(prompt.current()) - const textContent = editorRef.textContent ?? "" + const textContent = prompt + .current() + .map((part) => ("content" in part ? part.content : "")) + .join("") const isEmpty = textContent.trim() === "" || textLength <= 1 const hasNewlines = textContent.includes("\n") const inHistory = store.historyIndex >= 0 @@ -978,7 +1062,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { </For> </div> </Show> - <div class="relative max-h-[240px] overflow-y-auto"> + <div class="relative max-h-[240px] overflow-y-auto" ref={(el) => (scrollRef = el)}> <div data-component="prompt-input" ref={(el) => { @@ -1119,23 +1203,56 @@ export const PromptInput: Component<PromptInputProps> = (props) => { ) } +function createTextFragment(content: string): DocumentFragment { + const fragment = document.createDocumentFragment() + const segments = content.split("\n") + segments.forEach((segment, index) => { + if (segment) { + fragment.appendChild(document.createTextNode(segment)) + } else if (segments.length > 1) { + fragment.appendChild(document.createTextNode("\u200B")) + } + if (index < segments.length - 1) { + fragment.appendChild(document.createElement("br")) + } + }) + return fragment +} + +function getNodeLength(node: Node): number { + if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1 + return (node.textContent ?? "").replace(/\u200B/g, "").length +} + +function getTextLength(node: Node): number { + if (node.nodeType === Node.TEXT_NODE) return (node.textContent ?? "").replace(/\u200B/g, "").length + if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1 + let length = 0 + for (const child of Array.from(node.childNodes)) { + length += getTextLength(child) + } + return length +} + function getCursorPosition(parent: HTMLElement): number { const selection = window.getSelection() if (!selection || selection.rangeCount === 0) return 0 const range = selection.getRangeAt(0) + if (!parent.contains(range.startContainer)) return 0 const preCaretRange = range.cloneRange() preCaretRange.selectNodeContents(parent) preCaretRange.setEnd(range.startContainer, range.startOffset) - return preCaretRange.toString().length + return getTextLength(preCaretRange.cloneContents()) } function setCursorPosition(parent: HTMLElement, position: number) { let remaining = position let node = parent.firstChild while (node) { - const length = node.textContent ? node.textContent.length : 0 + const length = getNodeLength(node) const isText = node.nodeType === Node.TEXT_NODE const isFile = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.type === "file" + const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR" if (isText && remaining <= length) { const range = document.createRange() @@ -1147,10 +1264,24 @@ function setCursorPosition(parent: HTMLElement, position: number) { return } - if (isFile && remaining <= length) { + if ((isFile || isBreak) && remaining <= length) { const range = document.createRange() const selection = window.getSelection() - range.setStartAfter(node) + if (remaining === 0) { + range.setStartBefore(node) + } + if (remaining > 0 && isFile) { + range.setStartAfter(node) + } + if (remaining > 0 && isBreak) { + const next = node.nextSibling + if (next && next.nodeType === Node.TEXT_NODE) { + range.setStart(next, 0) + } + if (!next || next.nodeType !== Node.TEXT_NODE) { + range.setStartAfter(node) + } + } range.collapse(true) selection?.removeAllRanges() selection?.addRange(range) diff --git a/packages/app/src/components/status-bar.tsx b/packages/app/src/components/status-bar.tsx index e0e25c60b..d8a88503f 100644 --- a/packages/app/src/components/status-bar.tsx +++ b/packages/app/src/components/status-bar.tsx @@ -1,13 +1,31 @@ -import { Show, type ParentProps } from "solid-js" +import { createMemo, Show, type ParentProps } from "solid-js" import { usePlatform } from "@/context/platform" +import { useSync } from "@/context/sync" +import { useGlobalSync } from "@/context/global-sync" export function StatusBar(props: ParentProps) { const platform = usePlatform() + const sync = useSync() + const globalSync = useGlobalSync() + + const directoryDisplay = createMemo(() => { + const directory = sync.data.path.directory || "" + const home = globalSync.data.path.home || "" + const short = home && directory.startsWith(home) ? directory.replace(home, "~") : directory + const branch = sync.data.vcs?.branch + return branch ? `${short}:${branch}` : short + }) + return ( <div class="h-8 w-full shrink-0 flex items-center justify-between px-2 border-t border-border-weak-base bg-background-base"> - <Show when={platform.version}> - <span class="text-12-regular text-text-weak">v{platform.version}</span> - </Show> + <div class="flex items-center gap-3"> + <Show when={platform.version}> + <span class="text-12-regular text-text-weak">v{platform.version}</span> + </Show> + <Show when={directoryDisplay()}> + <span class="text-12-regular text-text-weak">{directoryDisplay()}</span> + </Show> + </div> <div class="flex items-center">{props.children}</div> </div> ) |
