diff options
Diffstat (limited to 'packages/desktop/src/components/prompt-form-hooks.ts')
| -rw-r--r-- | packages/desktop/src/components/prompt-form-hooks.ts | 396 |
1 files changed, 0 insertions, 396 deletions
diff --git a/packages/desktop/src/components/prompt-form-hooks.ts b/packages/desktop/src/components/prompt-form-hooks.ts deleted file mode 100644 index c4d18fa70..000000000 --- a/packages/desktop/src/components/prompt-form-hooks.ts +++ /dev/null @@ -1,396 +0,0 @@ -import { createEffect, createMemo, createResource, type Accessor } from "solid-js" -import type { SetStoreFunction } from "solid-js/store" -import { getDirectory, getFilename } from "@/utils" -import { createSpeechRecognition } from "@/utils/speech" -import { - createAttachmentDisplay, - mentionPattern, - mentionTriggerPattern, - type PromptAttachmentPart, - type PromptAttachmentSegment, -} from "./prompt-form-helpers" -import type { LocalFile, TextSelection } from "@/context/local" - -export type MentionRange = { - start: number - end: number -} - -export interface PromptFormState { - promptInput: string - isDragOver: boolean - mentionOpen: boolean - mentionQuery: string - mentionRange: MentionRange | undefined - mentionIndex: number - mentionAnchorOffset: { x: number; y: number } - inlineAliases: Map<string, PromptAttachmentPart> -} - -interface MentionControllerOptions { - state: PromptFormState - setState: SetStoreFunction<PromptFormState> - attachmentSegments: Accessor<PromptAttachmentSegment[]> - getInputRef: () => HTMLTextAreaElement | undefined - getOverlayRef: () => HTMLDivElement | undefined - getMeasureRef: () => HTMLDivElement | undefined - searchFiles: (query: string) => Promise<string[]> - resolveFile: (path: string) => LocalFile | undefined - addContextFile: (path: string, selection?: TextSelection) => void - getActiveContext: () => { path: string; selection?: TextSelection } | undefined -} - -interface MentionKeyDownOptions { - event: KeyboardEvent & { currentTarget: HTMLTextAreaElement } - mentionItems: () => string[] - insertMention: (path: string) => void -} - -interface ScrollSyncOptions { - state: PromptFormState - getInputRef: () => HTMLTextAreaElement | undefined - getOverlayRef: () => HTMLDivElement | undefined - interim: Accessor<string> - updateMentionPosition: (element: HTMLTextAreaElement, range?: MentionRange) => void -} - -export function usePromptSpeech(updatePromptInput: (updater: (prev: string) => string) => void) { - return createSpeechRecognition({ - onFinal: (text) => updatePromptInput((prev) => (prev && !prev.endsWith(" ") ? `${prev} ` : prev) + text), - }) -} - -export function useMentionController(options: MentionControllerOptions) { - const mentionSource = createMemo(() => (options.state.mentionOpen ? options.state.mentionQuery : undefined)) - const [mentionResults, { mutate: mutateMentionResults }] = createResource(mentionSource, (query) => { - if (!options.state.mentionOpen) return [] - return options.searchFiles(query ?? "") - }) - const mentionItems = createMemo(() => mentionResults() ?? []) - - createEffect(() => { - if (!options.state.mentionOpen) return - options.state.mentionQuery - options.setState("mentionIndex", 0) - }) - - createEffect(() => { - if (!options.state.mentionOpen) return - queueMicrotask(() => { - const input = options.getInputRef() - if (!input) return - if (document.activeElement === input) return - input.focus() - }) - }) - - createEffect(() => { - const used = new Set<string>() - for (const match of options.state.promptInput.matchAll(mentionPattern)) { - const token = match[1] - if (token) used.add(token.toLowerCase()) - } - options.setState("inlineAliases", (prev) => { - if (prev.size === 0) return prev - const next = new Map(prev) - let changed = false - for (const key of prev.keys()) { - if (!used.has(key.toLowerCase())) { - next.delete(key) - changed = true - } - } - return changed ? next : prev - }) - }) - - createEffect(() => { - if (!options.state.mentionOpen) return - const items = mentionItems() - if (items.length === 0) { - options.setState("mentionIndex", 0) - return - } - if (options.state.mentionIndex < items.length) return - options.setState("mentionIndex", items.length - 1) - }) - - createEffect(() => { - if (!options.state.mentionOpen) return - const rangeValue = options.state.mentionRange - if (!rangeValue) return - options.state.promptInput - queueMicrotask(() => { - const input = options.getInputRef() - if (!input) return - updateMentionPosition(input, rangeValue) - }) - }) - - function closeMention() { - if (options.state.mentionOpen) options.setState("mentionOpen", false) - options.setState("mentionQuery", "") - options.setState("mentionRange", undefined) - options.setState("mentionIndex", 0) - mutateMentionResults(() => undefined) - options.setState("mentionAnchorOffset", { x: 0, y: 0 }) - } - - function updateMentionPosition(element: HTMLTextAreaElement, rangeValue = options.state.mentionRange) { - const measure = options.getMeasureRef() - if (!measure) return - if (!rangeValue) return - measure.style.width = `${element.clientWidth}px` - const measurement = element.value.slice(0, rangeValue.end) - measure.textContent = measurement - const caretSpan = document.createElement("span") - caretSpan.textContent = "\u200b" - measure.append(caretSpan) - const caretRect = caretSpan.getBoundingClientRect() - const containerRect = measure.getBoundingClientRect() - measure.removeChild(caretSpan) - const left = caretRect.left - containerRect.left - const top = caretRect.top - containerRect.top - element.scrollTop - options.setState("mentionAnchorOffset", { x: left, y: top < 0 ? 0 : top }) - } - - function isValidMentionQuery(value: string) { - return /^[A-Za-z0-9_\-./]*$/.test(value) - } - - function syncMentionFromCaret(element: HTMLTextAreaElement) { - if (!options.state.mentionOpen) return - const rangeValue = options.state.mentionRange - if (!rangeValue) { - closeMention() - return - } - const caret = element.selectionEnd ?? element.selectionStart ?? element.value.length - if (rangeValue.start < 0 || rangeValue.start >= element.value.length) { - closeMention() - return - } - if (element.value[rangeValue.start] !== "@") { - closeMention() - return - } - if (caret <= rangeValue.start) { - closeMention() - return - } - const mentionValue = element.value.slice(rangeValue.start + 1, caret) - if (!isValidMentionQuery(mentionValue)) { - closeMention() - return - } - options.setState("mentionRange", { start: rangeValue.start, end: caret }) - options.setState("mentionQuery", mentionValue) - updateMentionPosition(element, { start: rangeValue.start, end: caret }) - } - - function tryOpenMentionFromCaret(element: HTMLTextAreaElement) { - const selectionStart = element.selectionStart ?? element.value.length - const selectionEnd = element.selectionEnd ?? selectionStart - if (selectionStart !== selectionEnd) return false - const caret = selectionEnd - if (options.attachmentSegments().some((segment) => caret >= segment.start && caret <= segment.end)) { - return false - } - const before = element.value.slice(0, caret) - const match = before.match(mentionTriggerPattern) - if (!match) return false - const token = match[2] ?? "" - const start = caret - token.length - 1 - if (start < 0) return false - options.setState("mentionOpen", true) - options.setState("mentionRange", { start, end: caret }) - options.setState("mentionQuery", token) - options.setState("mentionIndex", 0) - queueMicrotask(() => { - updateMentionPosition(element, { start, end: caret }) - }) - return true - } - - function handlePromptInput(event: InputEvent & { currentTarget: HTMLTextAreaElement }) { - const element = event.currentTarget - options.setState("promptInput", element.value) - if (options.state.mentionOpen) { - syncMentionFromCaret(element) - if (options.state.mentionOpen) return - } - const isDeletion = event.inputType ? event.inputType.startsWith("delete") : false - if (!isDeletion && tryOpenMentionFromCaret(element)) return - closeMention() - } - - function handleMentionKeyDown({ event, mentionItems: items, insertMention }: MentionKeyDownOptions) { - if (!options.state.mentionOpen) return false - const list = items() - if (event.key === "ArrowDown") { - event.preventDefault() - if (list.length === 0) return true - const next = options.state.mentionIndex + 1 >= list.length ? 0 : options.state.mentionIndex + 1 - options.setState("mentionIndex", next) - return true - } - if (event.key === "ArrowUp") { - event.preventDefault() - if (list.length === 0) return true - const previous = options.state.mentionIndex - 1 < 0 ? list.length - 1 : options.state.mentionIndex - 1 - options.setState("mentionIndex", previous) - return true - } - if (event.key === "Enter") { - event.preventDefault() - const targetItem = list[options.state.mentionIndex] ?? list[0] - if (targetItem) insertMention(targetItem) - return true - } - if (event.key === "Escape") { - event.preventDefault() - closeMention() - return true - } - return false - } - - function generateMentionAlias(path: string) { - const existing = new Set<string>() - for (const key of options.state.inlineAliases.keys()) { - existing.add(key.toLowerCase()) - } - for (const match of options.state.promptInput.matchAll(mentionPattern)) { - const token = match[1] - if (token) existing.add(token.toLowerCase()) - } - - const base = getFilename(path) - if (base) { - if (!existing.has(base.toLowerCase())) return base - } - - const directory = getDirectory(path) - if (base && directory) { - const segments = directory.split("/").filter(Boolean) - for (let i = segments.length - 1; i >= 0; i -= 1) { - const candidate = `${segments.slice(i).join("/")}/${base}` - if (!existing.has(candidate.toLowerCase())) return candidate - } - } - - if (!existing.has(path.toLowerCase())) return path - - const fallback = base || path || "file" - let index = 2 - let candidate = `${fallback}-${index}` - while (existing.has(candidate.toLowerCase())) { - index += 1 - candidate = `${fallback}-${index}` - } - return candidate - } - - function insertMention(path: string) { - const input = options.getInputRef() - if (!input) return - const rangeValue = options.state.mentionRange - if (!rangeValue) return - const node = options.resolveFile(path) - const alias = generateMentionAlias(path) - const mentionText = `@${alias}` - const value = options.state.promptInput - const before = value.slice(0, rangeValue.start) - const after = value.slice(rangeValue.end) - const needsLeadingSpace = before.length > 0 && !/\s$/.test(before) - const needsTrailingSpace = after.length > 0 && !/^\s/.test(after) - const leading = needsLeadingSpace ? `${before} ` : before - const trailingSpacer = needsTrailingSpace ? " " : "" - const nextValue = `${leading}${mentionText}${trailingSpacer}${after}` - const origin = options.getActiveContext()?.path === path ? "active" : "context" - const part: PromptAttachmentPart = { - kind: "file", - token: alias, - display: createAttachmentDisplay(path, node?.selection), - path, - selection: node?.selection, - origin, - } - options.setState("promptInput", nextValue) - if (input.value !== nextValue) { - input.value = nextValue - } - options.setState("inlineAliases", (prev) => { - const next = new Map(prev) - next.set(alias, part) - return next - }) - options.addContextFile(path, node?.selection) - closeMention() - queueMicrotask(() => { - const caret = leading.length + mentionText.length + trailingSpacer.length - input.setSelectionRange(caret, caret) - syncMentionFromCaret(input) - }) - } - - return { - mentionResults, - mentionItems, - closeMention, - syncMentionFromCaret, - tryOpenMentionFromCaret, - updateMentionPosition, - handlePromptInput, - handleMentionKeyDown, - insertMention, - } -} - -export function usePromptScrollSync(options: ScrollSyncOptions) { - let shouldAutoScroll = true - - createEffect(() => { - options.state.promptInput - options.interim() - queueMicrotask(() => { - const input = options.getInputRef() - const overlay = options.getOverlayRef() - if (!input || !overlay) return - if (!shouldAutoScroll) { - overlay.scrollTop = input.scrollTop - if (options.state.mentionOpen) options.updateMentionPosition(input) - return - } - const maxInputScroll = input.scrollHeight - input.clientHeight - const next = maxInputScroll > 0 ? maxInputScroll : 0 - input.scrollTop = next - overlay.scrollTop = next - if (options.state.mentionOpen) options.updateMentionPosition(input) - }) - }) - - function handlePromptScroll(event: Event & { currentTarget: HTMLTextAreaElement }) { - const target = event.currentTarget - shouldAutoScroll = target.scrollTop + target.clientHeight >= target.scrollHeight - 4 - const overlay = options.getOverlayRef() - if (overlay) overlay.scrollTop = target.scrollTop - if (options.state.mentionOpen) options.updateMentionPosition(target) - } - - function resetScrollPosition() { - shouldAutoScroll = true - const input = options.getInputRef() - const overlay = options.getOverlayRef() - if (input) input.scrollTop = 0 - if (overlay) overlay.scrollTop = 0 - } - - return { - handlePromptScroll, - resetScrollPosition, - setAutoScroll: (value: boolean) => { - shouldAutoScroll = value - }, - } -} |
