返回 DeepSeek-Reasonix
ChatFileLinkContext.tsx
根目录 / desktop / frontend / src / components / ChatFileLinkContext.tsx
1 // The chat body's link map.
2 //
3 // One answer can name the same file three ways: a `present` declaration, a
4 // native write or edit the turn already recorded, or a path the host verified
5 // from the answer text. All three become the same clickable reference, so a
6 // reader cannot tell which route produced it — and the answer text itself is
7 // never rewritten.
8
9 import { createContext, useContext, useEffect, useMemo, useRef, useSyncExternalStore, type ReactNode } from "react";
10 import type { PresentedFileView } from "../lib/chatViewSource";
11 import type { TurnFileView } from "../lib/turnFiles";
12 import { ChatFileReferenceStore, type ChatFileReferenceView } from "../lib/chatFileReferences";
13 import { chatFileCandidates, type ChatFileCandidate } from "../lib/chatFileCandidates";
14 import { fileIdentity, pathBasename } from "../lib/filePaths";
15 import type { FileResourceRef } from "../lib/presentedFileNavigation";
16
17 export type ChatFileLink = {
18 /** Path handed to a host action: the verified display path when there is one. */
19 path: string;
20 ref: FileResourceRef;
21 /** Host-verified actions, or undefined when only the extension is known. */
22 actions?: readonly string[];
23 };
24
25 type TurnValue = { turnKey: string; factsVersion: number; links: ReadonlyMap<string, ChatFileLink> };
26
27 const ScopeContext = createContext<ChatFileReferenceStore | null>(null);
28 const TurnContext = createContext<TurnValue | null>(null);
29
30 /** Owns the session's reference store; it is disposed with the session. */
31 export function ChatFileScopeProvider({ scopeKey, tabId, hostId, children }: { scopeKey: string; tabId?: string; hostId?: string; children: ReactNode }) {
32 const store = useMemo(() => new ChatFileReferenceStore(tabId ?? "", hostId ?? "local"), [scopeKey, tabId, hostId]);
33 // Attach/detach rather than dispose directly: a StrictMode mount replay runs
34 // the cleanup without a re-render, and an unconditional dispose would leave
35 // the session with a dead store.
36 useEffect(() => { store.attach(); return () => store.detach(); }, [store]);
37 return <ScopeContext.Provider value={store}>{children}</ScopeContext.Provider>;
38 }
39
40 export function useChatFileStore(): ChatFileReferenceStore | null {
41 return useContext(ScopeContext);
42 }
43
44 /**
45 * Reports the candidates a committed block named. Markdown surfaces outside a
46 * chat turn — the dock's `.md` preview, an extension card — have no turn
47 * context and report nothing.
48 */
49 export function useChatFileReporter(): ((candidates: readonly ChatFileCandidate[]) => void) | null {
50 const store = useChatFileStore();
51 const turn = useContext(TurnContext);
52 const latest = useRef({ store, turn });
53 latest.current = { store, turn };
54 return useMemo(() => {
55 if (!store || !turn) return null;
56 return (candidates: readonly ChatFileCandidate[]) => {
57 const { store: current, turn: owner } = latest.current;
58 if (!current || !owner) return;
59 current.report(owner.turnKey, owner.factsVersion, candidates);
60 };
61 }, [store, turn]);
62 }
63
64 /** Candidates are reported once per parse revision, not once per render. */
65 export function useChatFileCandidateReport(blocks: readonly { children: readonly unknown[] }[] | undefined, revision: number): void {
66 const report = useChatFileReporter();
67 const previous = useRef<{ revision: number; blocks: unknown; report: unknown }>(undefined);
68 useEffect(() => {
69 if (!report || !blocks) return;
70 // The reporter identity changes when the turn's file facts change, so a
71 // turn whose text has settled still re-reports once its facts arrive.
72 if (previous.current?.revision === revision && previous.current.blocks === blocks && previous.current.report === report) return;
73 previous.current = { revision, blocks, report };
74 report(chatFileCandidates(blocks as never));
75 }, [blocks, report, revision]);
76 }
77
78 export function ChatFileTurnProvider({ turnKey, factsVersion, presentedFiles, modifiedFiles, tabId, hostId, children }: {
79 turnKey: string; factsVersion: number;
80 presentedFiles: readonly PresentedFileView[]; modifiedFiles: readonly TurnFileView[];
81 tabId?: string; hostId?: string; children: ReactNode;
82 }) {
83 const store = useChatFileStore();
84 const references = useChatFileReferences(store, turnKey);
85 const value = useMemo<TurnValue>(() => ({
86 turnKey,
87 factsVersion,
88 links: buildLinks({ presentedFiles, modifiedFiles, references, tabId: tabId ?? "", hostId: hostId ?? "local" }),
89 }), [factsVersion, hostId, modifiedFiles, presentedFiles, references, tabId, turnKey]);
90 return <TurnContext.Provider value={value}>{children}</TurnContext.Provider>;
91 }
92
93 const EMPTY_REFERENCES: ReadonlyMap<string, ChatFileReferenceView> = new Map();
94
95 function useChatFileReferences(store: ChatFileReferenceStore | null, turnKey: string): ReadonlyMap<string, ChatFileReferenceView> {
96 const snapshot = useMemo(() => (() => store?.getTurnSnapshot(turnKey) ?? EMPTY_REFERENCES), [store, turnKey]);
97 const subscribe = useMemo(() => (listener: () => void) => store?.subscribe(listener) ?? (() => {}), [store]);
98 return useSyncExternalStore(subscribe, snapshot, snapshot);
99 }
100
101 function buildLinks({ presentedFiles, modifiedFiles, references, tabId, hostId }: {
102 presentedFiles: readonly PresentedFileView[]; modifiedFiles: readonly TurnFileView[];
103 references: ReadonlyMap<string, ChatFileReferenceView>; tabId: string; hostId: string;
104 }): ReadonlyMap<string, ChatFileLink> {
105 // One entry per file. A presented delivery is registered first and owns the
106 // entry's resource ref, because it carries the authorization the host
107 // already trusts; a verified reference to the same file only adds spellings.
108 type Entry = { paths: string[]; ref: FileResourceRef; actions?: readonly string[] };
109 const entries: Entry[] = [];
110 const byIdentity = new Map<string, Entry>();
111 // `path` is the entry's primary spelling — the one actions are performed
112 // with. `aliases` are extra spellings of the same file that the answer may
113 // have used, and they do not create a second entry.
114 const define = (path: string, ref: FileResourceRef, actions?: readonly string[], aliases: readonly string[] = []) => {
115 const identity = fileIdentity(path);
116 const existing = byIdentity.get(identity);
117 if (existing) {
118 for (const spelling of [path, ...aliases]) if (!existing.paths.includes(spelling)) existing.paths.push(spelling);
119 return;
120 }
121 const entry: Entry = { paths: [path, ...aliases].filter((spelling, index, all) => all.indexOf(spelling) === index), ref, actions };
122 byIdentity.set(identity, entry);
123 entries.push(entry);
124 };
125 for (const file of presentedFiles) {
126 define(file.path, { source: "presented", hostId, tabId, toolCallId: file.toolCallId, path: file.path });
127 }
128 for (const file of modifiedFiles) {
129 define(file.path, { source: "workspace", hostId, tabId, toolCallId: file.toolCallId, path: file.path });
130 }
131 for (const reference of references.values()) {
132 if (reference.status !== "resolved" || !reference.displayPath) continue;
133 // The answer's own spelling is an alias: the host canonicalized the path,
134 // but the reader must still be able to click the text they were shown.
135 define(reference.displayPath, { source: "reference", hostId, tabId, path: reference.displayPath },
136 reference.actions, [reference.path]);
137 }
138
139 const claims = new Map<string, number>();
140 for (const entry of entries) claims.set(pathBasename(entry.paths[0]), (claims.get(pathBasename(entry.paths[0])) ?? 0) + 1);
141
142 const links = new Map<string, ChatFileLink>();
143 for (const entry of entries) {
144 const link: ChatFileLink = { path: entry.paths[0], ref: entry.ref, actions: entry.actions };
145 for (const path of entry.paths) if (!links.has(path)) links.set(path, link);
146 // A basename alias is offered only when exactly one file claims the name:
147 // an ambiguous short name stays ordinary text while full paths keep working.
148 const name = pathBasename(entry.paths[0]);
149 if (name && name !== entry.paths[0] && claims.get(name) === 1 && !links.has(name)) links.set(name, link);
150 }
151 return links;
152 }
153
154 export function useChatFileLink(text: string): ChatFileLink | undefined {
155 const turn = useContext(TurnContext);
156 return turn?.links.get(text.trim());
157 }
158
158 lines Plain Text