返回 DeepSeek-Reasonix
FileReferenceMenu.tsx
根目录 / desktop / frontend / src / components / FileReferenceMenu.tsx
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
229 lines Plain Text