| 1 | import { useEffect, useMemo, useRef, useState } from "react"; |
| 2 | import { FileText, Folder } from "lucide-react"; |
| 3 | import { asArray } from "../lib/array"; |
| 4 | import { filterAtMatches } from "../lib/atMatches"; |
| 5 | import { app } from "../lib/bridge"; |
| 6 | import { activeRefTokenRe, escapeRefPath, unescapeRefPath } from "../lib/refToken"; |
| 7 | import type { DirEntry } from "../lib/types"; |
| 8 | import { VirtualMenu } from "./VirtualMenu"; |
| 9 | |
| 10 | const FILE_REF_SEARCH_CACHE_TTL_MS = 5000; |
| 11 | |
| 12 | type FileRefSearchCacheEntry = { |
| 13 | entries: DirEntry[]; |
| 14 | cachedAt: number; |
| 15 | }; |
| 16 | |
| 17 | // dirEntrySubmitPath returns the real filesystem path for a picked entry. The |
| 18 | // typed atDir may carry backslash-escaped spaces (the @token grammar), so it |
| 19 | // is unescaped before joining with the entry name. |
| 20 | export function dirEntrySubmitPath(entry: DirEntry, atDir: string): string { |
| 21 | return entry.path || unescapeRefPath(atDir) + entry.name; |
| 22 | } |
| 23 | |
| 24 | export function dirEntryMenuLabel(entry: DirEntry): string { |
| 25 | return entry.displayName || entry.name; |
| 26 | } |
| 27 | |
| 28 | function fileReferenceItemKey(entry: DirEntry): string { |
| 29 | return (entry.isDir ? "d:" : "f:") + (entry.path || entry.name); |
| 30 | } |
| 31 | |
| 32 | export function activeFileReferenceToken(text: string): { raw: string; dir: string; frag: string } | null { |
| 33 | const queryText = text.replace(/[\r\n]+$/u, ""); |
| 34 | const match = activeRefTokenRe.exec(queryText); |
| 35 | if (!match) return null; |
| 36 | const raw = match[1]; |
| 37 | const slash = raw.lastIndexOf("/"); |
| 38 | return { |
| 39 | raw, |
| 40 | dir: slash >= 0 ? raw.slice(0, slash + 1) : "", |
| 41 | frag: unescapeRefPath(slash >= 0 ? raw.slice(slash + 1) : raw).toLowerCase(), |
| 42 | }; |
| 43 | } |
| 44 | |
| 45 | // pickInlineFileReference replaces the typed token with an inline @reference. |
| 46 | // Whitespace in the path is escaped so the ref survives @-token parsing on |
| 47 | // submit (the control layer unescapes it back to the real path). |
| 48 | export function pickInlineFileReference(text: string, atRaw: string | null, atDir: string, entry: DirEntry): string { |
| 49 | const queryText = text.replace(/[\r\n]+$/u, ""); |
| 50 | const atPos = queryText.length - (atRaw?.length ?? 0) - 1; |
| 51 | const prefix = queryText.slice(0, Math.max(0, atPos)); |
| 52 | const refPath = dirEntrySubmitPath(entry, atDir); |
| 53 | return prefix + "@" + escapeRefPath(refPath) + (entry.isDir ? "/" : " "); |
| 54 | } |
| 55 | |
| 56 | export function insertTextAtSelection( |
| 57 | value: string, |
| 58 | insert: string, |
| 59 | selectionStart = value.length, |
| 60 | selectionEnd = selectionStart, |
| 61 | ): { value: string; caret: number } { |
| 62 | const before = value.slice(0, selectionStart); |
| 63 | const after = value.slice(selectionEnd); |
| 64 | const needsLeadingSpace = before.length > 0 && !/\s$/.test(before) && !/^\s/.test(insert); |
| 65 | const needsTrailingSpace = after.length > 0 && !/\s$/.test(insert) && !/^\s/.test(after); |
| 66 | const text = `${needsLeadingSpace ? " " : ""}${insert}${needsTrailingSpace ? " " : ""}`; |
| 67 | const next = before + text + after; |
| 68 | return { value: next, caret: before.length + text.length }; |
| 69 | } |
| 70 | |
| 71 | export function useFileReferenceMenu(text: string, cwd?: string, tabId?: string, workspaceScopeKey?: string) { |
| 72 | const token = useMemo(() => activeFileReferenceToken(text), [text]); |
| 73 | const atRaw = token?.raw ?? null; |
| 74 | const atDir = token?.dir ?? ""; |
| 75 | const atFrag = token?.frag ?? ""; |
| 76 | const [entries, setEntries] = useState<DirEntry[]>([]); |
| 77 | const [searchEntries, setSearchEntries] = useState<DirEntry[]>([]); |
| 78 | const [active, setActive] = useState(0); |
| 79 | const [dismissed, setDismissed] = useState(false); |
| 80 | const dirCache = useRef<Record<string, DirEntry[]>>({}); |
| 81 | const searchCache = useRef<Record<string, FileRefSearchCacheEntry>>({}); |
| 82 | const fileRefTabId = tabId ?? ""; |
| 83 | const fileRefScopeKey = workspaceScopeKey ?? `${fileRefTabId}\u0000${cwd ?? ""}`; |
| 84 | const prevFileRefScopeRef = useRef(fileRefScopeKey); |
| 85 | |
| 86 | useEffect(() => { |
| 87 | if (prevFileRefScopeRef.current === fileRefScopeKey) return; |
| 88 | prevFileRefScopeRef.current = fileRefScopeKey; |
| 89 | dirCache.current = {}; |
| 90 | searchCache.current = {}; |
| 91 | setEntries([]); |
| 92 | setSearchEntries([]); |
| 93 | setActive(0); |
| 94 | setDismissed(false); |
| 95 | }, [fileRefScopeKey]); |
| 96 | |
| 97 | useEffect(() => { |
| 98 | setActive(0); |
| 99 | setDismissed(false); |
| 100 | }, [atRaw]); |
| 101 | |
| 102 | useEffect(() => { |
| 103 | if (atRaw === null) return; |
| 104 | const cached = dirCache.current[atDir]; |
| 105 | if (cached) { |
| 106 | setEntries(cached); |
| 107 | } else { |
| 108 | setEntries([]); |
| 109 | } |
| 110 | let live = true; |
| 111 | app |
| 112 | .ListDirForTab(fileRefTabId, unescapeRefPath(atDir)) |
| 113 | .then((next) => { |
| 114 | const list = asArray(next); |
| 115 | if (!live) return; |
| 116 | dirCache.current[atDir] = list; |
| 117 | setEntries(list); |
| 118 | }) |
| 119 | .catch(() => {}); |
| 120 | return () => { |
| 121 | live = false; |
| 122 | }; |
| 123 | }, [atRaw === null, atDir, fileRefScopeKey, fileRefTabId]); |
| 124 | |
| 125 | useEffect(() => { |
| 126 | if (atRaw === null || atDir !== "" || atFrag === "") { |
| 127 | setSearchEntries([]); |
| 128 | return; |
| 129 | } |
| 130 | const cached = searchCache.current[atFrag]; |
| 131 | if (cached) { |
| 132 | setSearchEntries(cached.entries); |
| 133 | if (Date.now() - cached.cachedAt < FILE_REF_SEARCH_CACHE_TTL_MS) return; |
| 134 | } else { |
| 135 | setSearchEntries([]); |
| 136 | } |
| 137 | let live = true; |
| 138 | app |
| 139 | .SearchFileRefsForTab(fileRefTabId, atFrag) |
| 140 | .then((next) => { |
| 141 | const list = asArray(next); |
| 142 | if (!live) return; |
| 143 | searchCache.current[atFrag] = { entries: list, cachedAt: Date.now() }; |
| 144 | setSearchEntries(list); |
| 145 | }) |
| 146 | .catch(() => {}); |
| 147 | return () => { |
| 148 | live = false; |
| 149 | }; |
| 150 | }, [atRaw === null, atDir, atFrag, fileRefScopeKey, fileRefTabId]); |
| 151 | |
| 152 | const items = useMemo(() => { |
| 153 | if (atRaw === null) return []; |
| 154 | return filterAtMatches(entries, searchEntries, atFrag); |
| 155 | }, [atRaw, atFrag, entries, searchEntries]); |
| 156 | |
| 157 | useEffect(() => { |
| 158 | const maxIdx = Math.max(0, items.length - 1); |
| 159 | setActive((prev) => (prev > maxIdx ? 0 : prev)); |
| 160 | }, [items.length]); |
| 161 | |
| 162 | return { |
| 163 | atRaw, |
| 164 | atDir, |
| 165 | items, |
| 166 | active, |
| 167 | setActive, |
| 168 | count: atRaw !== null && !dismissed ? items.length : 0, |
| 169 | open: atRaw !== null && !dismissed, |
| 170 | dismiss: () => setDismissed(true), |
| 171 | }; |
| 172 | } |
| 173 | |
| 174 | export function FileReferenceMenu({ |
| 175 | items, |
| 176 | activeIndex, |
| 177 | onPick, |
| 178 | onHover, |
| 179 | }: { |
| 180 | items: DirEntry[]; |
| 181 | activeIndex: number; |
| 182 | onPick: (entry: DirEntry) => void; |
| 183 | onHover: (index: number) => void; |
| 184 | }) { |
| 185 | const renderEntry = (entry: DirEntry, index: number) => ( |
| 186 | <button |
| 187 | role="option" |
| 188 | aria-selected={index === activeIndex} |
| 189 | className={`slashmenu__item ${index === activeIndex ? "slashmenu__item--active" : ""}`} |
| 190 | onMouseDown={(event) => { |
| 191 | event.preventDefault(); |
| 192 | onPick(entry); |
| 193 | }} |
| 194 | onMouseMove={() => onHover(index)} |
| 195 | > |
| 196 | {entry.isDir ? ( |
| 197 | <Folder size={13} className="filemenu__icon filemenu__icon--dir" /> |
| 198 | ) : ( |
| 199 | <FileText size={13} className="filemenu__icon" /> |
| 200 | )} |
| 201 | <span className="slashmenu__name slashmenu__name--file"> |
| 202 | {dirEntryMenuLabel(entry)} |
| 203 | {entry.isDir ? "/" : ""} |
| 204 | </span> |
| 205 | </button> |
| 206 | ); |
| 207 | |
| 208 | if (typeof ResizeObserver === "undefined") { |
| 209 | return ( |
| 210 | <div className="slashmenu" role="listbox"> |
| 211 | {items.map((entry, index) => ( |
| 212 | <div key={fileReferenceItemKey(entry)}> |
| 213 | {renderEntry(entry, index)} |
| 214 | </div> |
| 215 | ))} |
| 216 | </div> |
| 217 | ); |
| 218 | } |
| 219 | |
| 220 | return ( |
| 221 | <VirtualMenu |
| 222 | items={items} |
| 223 | activeIndex={activeIndex} |
| 224 | itemKey={fileReferenceItemKey} |
| 225 | renderItem={renderEntry} |
| 226 | /> |
| 227 | ); |
| 228 | } |
| 229 |