openclaw/apps/web/lib/search-index.ts
kumarabhirup dee323b7ad
fix lint/build errors and bump to 2026.2.15-1.4
- Fix all oxlint errors (curly, no-unused-vars, no-base-to-string,
  no-floating-promises, approx-constant, restrict-template-expressions)
- Fix TS build errors: rewrite update-cli.ts as thin wrapper over
  submodules, restore missing chat abort helpers in chat.ts
- Fix web build: wrap handleNewSession in async for ChatPanelHandle,
  add missing safeString helper to entry-detail-modal
- Bump version to 2026.2.15-1.4 and publish

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-16 00:30:13 -08:00

128 lines
3.6 KiB
TypeScript

"use client";
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import Fuse from "fuse.js";
// --- Types (must match the API response) ---
export type SearchIndexItem = {
id: string;
label: string;
sublabel?: string;
kind: "file" | "object" | "entry";
icon?: string;
objectName?: string;
entryId?: string;
fields?: Record<string, string>;
path?: string;
nodeType?: "document" | "folder" | "file" | "report" | "database";
};
// --- Fuse.js config ---
const FUSE_OPTIONS: ConstructorParameters<typeof Fuse<SearchIndexItem>>[1] = {
keys: [
{ name: "label", weight: 3 },
{ name: "sublabel", weight: 1 },
{ name: "objectName", weight: 1.5 },
// Search within field values for entries
{ name: "fieldValues", weight: 2 },
],
threshold: 0.4,
distance: 200,
includeScore: true,
shouldSort: true,
minMatchCharLength: 1,
};
/** Flatten field values into a searchable string for Fuse.js. */
function enrichForSearch(
items: SearchIndexItem[],
): Array<SearchIndexItem & { fieldValues?: string }> {
return items.map((item) => ({
...item,
fieldValues: item.fields
? Object.values(item.fields).join(" ")
: undefined,
}));
}
// --- Hook ---
/**
* Hook that fetches the workspace search index and provides fuzzy search.
* Refetches when `refreshSignal` changes (wire to tree watcher refresh count).
*/
export function useSearchIndex(refreshSignal?: number) {
const [items, setItems] = useState<SearchIndexItem[]>([]);
const [loading, setLoading] = useState(true);
const mountedRef = useRef(true);
const fetchIndex = useCallback(async () => {
try {
const res = await fetch("/api/workspace/search-index");
const data = await res.json();
if (mountedRef.current) {
setItems(data.items ?? []);
setLoading(false);
}
} catch {
if (mountedRef.current) {setLoading(false);}
}
}, []);
useEffect(() => {
mountedRef.current = true;
void fetchIndex();
return () => {
mountedRef.current = false;
};
}, [fetchIndex, refreshSignal]);
// Build the Fuse instance whenever items change
const fuse = useMemo(() => {
if (items.length === 0) {return null;}
const enriched = enrichForSearch(items);
return new Fuse(enriched, FUSE_OPTIONS);
}, [items]);
/** Inner search implementation (recreated when fuse/items change). */
const searchImpl = useCallback(
(query: string, limit = 20): SearchIndexItem[] => {
if (!query.trim()) {
// No query: return first N items, files/objects first, then entries
const sorted = [...items].toSorted((a, b) => {
const kindOrder = { object: 0, file: 1, entry: 2 };
return (kindOrder[a.kind] ?? 9) - (kindOrder[b.kind] ?? 9);
});
return sorted.slice(0, limit);
}
if (!fuse) {return [];}
const results = fuse.search(query, { limit });
return results.map((r) => r.item);
},
[fuse, items],
);
// Keep a ref to the latest implementation so the tiptap extension
// (which captures the search function at creation time) always calls
// the current version, not a stale closure.
const searchImplRef = useRef(searchImpl);
searchImplRef.current = searchImpl;
/**
* Stable search function -- identity never changes, but always delegates
* to the latest searchImpl via ref. Safe to capture in closures/extensions.
*/
const search = useCallback(
(query: string, limit?: number): SearchIndexItem[] => {
return searchImplRef.current(query, limit);
},
[],
);
return { items, loading, search };
}