Skip to content
Source
Sign inSign up

threadFindStore.ts

198 lines6.9 KB

1import type {
2 OrchestrationThreadSearchMatch,
3 OrchestrationV2SearchThreadResult,
4 ScopedThreadRef,
5} from "@t3tools/contracts";
6import { scopedThreadKey } from "@t3tools/client-runtime/environment";
7import {
8 findSearchTokenRanges,
9 messagePhrase,
10 messageSearchTokens,
11} from "@t3tools/shared/paletteSearch";
12import { create } from "zustand";
13import type { ThreadFindPositionReader, ThreadFindStart } from "./threadFind";
14​
15export interface ThreadFindLocalSelection {
16 readonly base: OrchestrationV2SearchThreadResult;
17 readonly baseNavigationId: number;
18 readonly activeIndex: number;
19 readonly match: NonNullable<OrchestrationV2SearchThreadResult["match"]>;
20 readonly navigationId: number;
22​
23/** Find is open in at most one thread: the one the chat view shows. */
24export interface ThreadFindState {
25 readonly threadKey: string | null;
26 readonly query: string;
27 readonly offset: number;
28 readonly start: ThreadFindStart | undefined;
29 readonly focusRequestId: number;
30 readonly navigationId: number;
31 readonly requestId: number;
32 readonly localSelection: ThreadFindLocalSelection | null;
34​
35export const CLOSED_THREAD_FIND: ThreadFindState = {
36 threadKey: null,
37 query: "",
38 offset: 0,
39 start: undefined,
40 focusRequestId: 0,
41 navigationId: 0,
42 requestId: 0,
43 localSelection: null,
44};
45​
46export const useThreadFindStore = create<ThreadFindState>(() => CLOSED_THREAD_FIND);
47​
48/** What find needs from the timeline that shows a thread. */
49interface ThreadFindTimeline {
50 readonly threadKey: string;
51 readonly readPosition: ThreadFindPositionReader;
52 /** Opening find is manual navigation: it releases live follow. */
53 readonly navigate: () => void;
55​
56let attachedTimeline: ThreadFindTimeline | null = null;
57​
58function timelineFor(threadKey: string): ThreadFindTimeline | null {
59 return attachedTimeline?.threadKey === threadKey ? attachedTimeline : null;
61​
62/** Attaches the timeline showing a thread; returns the detach. */
63export function attachThreadFindTimeline(timeline: ThreadFindTimeline): () => void {
64 attachedTimeline = timeline;
65 return () => {
66 if (attachedTimeline === timeline) attachedTimeline = null;
67 };
69​
70function readThreadFindPosition(threadKey: string, query: string): ThreadFindStart | undefined {
71 return timelineFor(threadKey)?.readPosition(query);
73​
74/**
75 * Navigates the timeline showing the thread when find newly opens there. A
76 * timeline that starts showing the thread later (a search result opened find
77 * before the route switched or the thread painted) releases follow itself as
78 * it opens on the thread.
79 */
80function announceOpening(previous: ThreadFindState, threadKey: string): void {
81 if (previous.threadKey !== threadKey) timelineFor(threadKey)?.navigate();
83​
84/** Opens find, or refocuses it and restarts its search from the viewport when already open. */
85export function openThreadFind(threadKey: string): void {
86 const previous = useThreadFindStore.getState();
87 const current = previous.threadKey === threadKey ? previous : CLOSED_THREAD_FIND;
88 const query = current.query.trim();
89 const start = query ? readThreadFindPosition(threadKey, query) : undefined;
90 announceOpening(previous, threadKey);
91 useThreadFindStore.setState({
92 ...current,
93 threadKey,
94 focusRequestId: previous.focusRequestId + 1,
95 ...(query
96 ? {
97 start,
98 offset: 0,
99 localSelection: null,
100 navigationId: current.navigationId + 1,
101 requestId: current.navigationId + 1,
102 }
103 : {}),
104 });
106​
107export interface PendingThreadFind {
108 readonly query: string;
109 /** Where find starts, such as the message a palette result came from. */
110 readonly start?: ThreadFindStart;
112​
113/**
114 * Opens find on `query` in the thread, such as at the message a search result
115 * came from. The thread's view shows it once it is on screen.
116 */
117export function openThreadFindOnQuery(threadKey: string, find: PendingThreadFind): void {
118 const previous = useThreadFindStore.getState();
119 announceOpening(previous, threadKey);
120 useThreadFindStore.setState({
121 ...(previous.threadKey === threadKey ? previous : CLOSED_THREAD_FIND),
122 threadKey,
123 query: find.query,
124 offset: 0,
125 start: find.start,
126 localSelection: null,
127 focusRequestId: previous.focusRequestId + 1,
128 navigationId: previous.navigationId + 1,
129 requestId: previous.navigationId + 1,
130 });
132​
133export function setThreadFindQuery(threadKey: string, query: string): void {
134 const start = readThreadFindPosition(threadKey, query.trim());
135 useThreadFindStore.setState((previous) => ({
136 query,
137 offset: 0,
138 start,
139 localSelection: null,
140 navigationId: previous.navigationId + 1,
141 requestId: previous.navigationId + 1,
142 }));
144​
145export function closeThreadFind(): void {
146 useThreadFindStore.setState(CLOSED_THREAD_FIND);
148​
149/**
150 * Find belongs to the thread it was opened in: a route showing anything else
151 * closes it. Settings and Usage draw over the thread, so they keep it.
152 */
153export function retainThreadFindForRoute(route: {
154 readonly modalPage: boolean;
155 readonly threadRef: ScopedThreadRef | null;
156}): void {
157 if (route.modalPage) return;
158 const { threadKey } = useThreadFindStore.getState();
159 const shownKey = route.threadRef ? scopedThreadKey(route.threadRef) : null;
160 if (threadKey !== null && threadKey !== shownKey) closeThreadFind();
162​
163/** Whether find is open in `thread`, as long as find is available there. */
164export function useThreadFindOpen(thread: ScopedThreadRef | null, enabled: boolean): boolean {
165 const openThreadKey = useThreadFindStore((state) => state.threadKey);
166 return enabled && thread !== null && openThreadKey === scopedThreadKey(thread);
168​
169/**
170 * The find a message search result opens, at the matched message: the whole
171 * query when the snippet holds it as typed ("vp run dev"), else its longest
172 * word, as the most telling ("sidebar" in "fix sidebar"), written as the
173 * snippet writes it ("Café" for "cafe", since find matches accents).
174 */
175export function findForMessageMatch(
176 match: Pick<OrchestrationThreadSearchMatch, "snippet" | "messageId">,
177 query: string,
178): PendingThreadFind {
179 const start = match.messageId ? { start: { entryId: match.messageId, occurrence: 0 } } : {};
180 // Apostrophes are straight in the query; straightening keeps offsets.
181 const together = messagePhrase(query)?.exec(match.snippet.replace(/[\u2018\u2019]/g, "'"));
182 if (together) {
183 return {
184 query: match.snippet.slice(together.index, together.index + together[0].length),
185 ...start,
186 };
187 }
188 const word =
189 messageSearchTokens(query).long.reduce(
190 (longest, token) => (token.length > longest.length ? token : longest),
191 "",
192 ) || query;
193 const ranges = findSearchTokenRanges(match.snippet, word);
194 // A whole word first, as search ranked it: "Café", not "Cafe" in "Cafeteria".
195 const shown =
196 ranges.find((range) => !/[\p{L}\p{N}]/u.test(match.snippet[range.end] ?? "")) ?? ranges[0];
197 return { query: shown ? match.snippet.slice(shown.start, shown.end) : word, ...start };