mirror of
https://github.com/outline/outline.git
synced 2026-08-03 13:27:25 +03:00
feat: Fuse powered local content search (#13115)
* wip * generalize * refactor * Extract SearchResultIcon for command bar results Both search action builders rendered the same document icon inline. Move it into a shared component alongside the other command bar pieces, forwarding size so the command bar can continue to size icons via cloneElement. Also drop the redundant charAt(0).toUpperCase() on the initial, as the Icon component already normalizes it. Co-Authored-By: Claude <noreply@anthropic.com> * PR feedback Prevent same doc showing twice in cmd k Improve result ordering in main app command bar Refactor to flattenTree * Upgrade Fuse to 7.5.0, use workers * fix: Re-register search actions when index is enriched Include result contexts in the command bar registration key so that snippets refresh when the same documents are enriched, and only mark a server search as cached once its results are actually fed to the index. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -84,6 +84,7 @@ import {
|
||||
documentEditPath,
|
||||
} from "~/utils/routeHelpers";
|
||||
import { getFocusedSplitPane, openRouteInSplit } from "~/utils/splitView";
|
||||
import { recentDocuments } from "~/components/CommandBar/useRecentDocumentActions";
|
||||
import { documentBreadcrumbText } from "~/components/DocumentBreadcrumb";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import type {
|
||||
@@ -112,34 +113,46 @@ export const openDocument = createActionWithChildren({
|
||||
shortcut: ["o", "d"],
|
||||
keywords: "go to",
|
||||
icon: <DocumentIcon />,
|
||||
children: ({ stores, t }) => {
|
||||
children: ({ stores, activeDocumentId, t }) => {
|
||||
const nodes = stores.collections.navigationNodes.reduce(
|
||||
(acc, node) => [...acc, ...node.children],
|
||||
[] as NavigationNode[]
|
||||
);
|
||||
const documents = stores.documents.orderedData;
|
||||
|
||||
return uniqBy([...documents, ...nodes], "id").map((item) => {
|
||||
const document = stores.documents.get(item.id);
|
||||
return createInternalLinkAction({
|
||||
// Note: using url which includes the slug rather than id here to bust
|
||||
// cache if the document is renamed
|
||||
id: item.url,
|
||||
name: item.title,
|
||||
description: document ? documentBreadcrumbText(document, t) : undefined,
|
||||
icon: item.icon ? (
|
||||
<Icon
|
||||
value={item.icon}
|
||||
initial={item.title}
|
||||
color={item.color ?? undefined}
|
||||
/>
|
||||
) : (
|
||||
<DocumentIcon outline={item.isDraft} />
|
||||
),
|
||||
section: DocumentSection,
|
||||
to: item.url,
|
||||
// Documents already listed under "Recently viewed" are skipped so that they
|
||||
// do not appear twice in the command bar.
|
||||
const recentIds = new Set(
|
||||
recentDocuments(stores.documents.recentlyViewed, activeDocumentId).map(
|
||||
(document) => document.id
|
||||
)
|
||||
);
|
||||
|
||||
return uniqBy([...documents, ...nodes], "id")
|
||||
.filter((item) => !recentIds.has(item.id))
|
||||
.map((item) => {
|
||||
const document = stores.documents.get(item.id);
|
||||
return createInternalLinkAction({
|
||||
// Note: using url which includes the slug rather than id here to bust
|
||||
// cache if the document is renamed
|
||||
id: item.url,
|
||||
name: item.title,
|
||||
description: document
|
||||
? documentBreadcrumbText(document, t)
|
||||
: undefined,
|
||||
icon: item.icon ? (
|
||||
<Icon
|
||||
value={item.icon}
|
||||
initial={item.title}
|
||||
color={item.color ?? undefined}
|
||||
/>
|
||||
) : (
|
||||
<DocumentIcon outline={item.isDraft} />
|
||||
),
|
||||
section: DocumentSection,
|
||||
to: item.url,
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
+14
-7
@@ -220,12 +220,19 @@ export function actionToKBar(
|
||||
const section = resolve<string>(action.section, context);
|
||||
const subtitle = resolve<string>(action.description, context);
|
||||
|
||||
const sectionPriority =
|
||||
typeof action.section !== "string" && "priority" in action.section
|
||||
? ((action.section.priority as number) ?? 0)
|
||||
: 0;
|
||||
// Sections are passed to the command bar as objects so that their declared
|
||||
// priority orders the sections themselves – given a bare string it would
|
||||
// instead order them by the match score of whichever result happens to come
|
||||
// first, which lets a section with an exact keyword match jump to the top.
|
||||
const sectionWithPriority = {
|
||||
name: section,
|
||||
priority:
|
||||
typeof action.section !== "string" && "priority" in action.section
|
||||
? ((action.section.priority as number) ?? 0)
|
||||
: 0,
|
||||
};
|
||||
|
||||
const priority = (1 + (action.priority ?? 0)) * (1 + (sectionPriority ?? 0));
|
||||
const priority = 1 + (action.priority ?? 0);
|
||||
|
||||
switch (action.variant) {
|
||||
case "action":
|
||||
@@ -235,7 +242,7 @@ export function actionToKBar(
|
||||
{
|
||||
id: action.id,
|
||||
name,
|
||||
section,
|
||||
section: sectionWithPriority,
|
||||
keywords: action.keywords,
|
||||
shortcut: action.shortcut,
|
||||
subtitle,
|
||||
@@ -260,7 +267,7 @@ export function actionToKBar(
|
||||
{
|
||||
id: action.id,
|
||||
name,
|
||||
section,
|
||||
section: sectionWithPriority,
|
||||
keywords: action.keywords,
|
||||
shortcut: action.shortcut,
|
||||
icon,
|
||||
|
||||
@@ -22,8 +22,12 @@ export const DocumentSection = ({ t }: ActionContext) => t("Document");
|
||||
export const SearchResultsSection = ({ t }: ActionContext) =>
|
||||
t("Search results");
|
||||
|
||||
SearchResultsSection.priority = -1;
|
||||
|
||||
export const DocumentsSection = ({ t }: ActionContext) => t("Documents");
|
||||
|
||||
DocumentsSection.priority = 0.8;
|
||||
|
||||
export const ActiveDocumentSection = ({ t, stores }: ActionContext) => {
|
||||
const activeDocument = stores.documents.active;
|
||||
return `${t("Document")} · ${activeDocument?.titleWithDefault}`;
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
import { SearchIndex } from "./SearchIndex";
|
||||
|
||||
describe("SearchIndex", () => {
|
||||
it("returns no results for an empty query", async () => {
|
||||
const index = new SearchIndex();
|
||||
index.update([{ id: "1", title: "Engineering Handbook", url: "/doc/1" }]);
|
||||
|
||||
expect(await index.search("")).toEqual([]);
|
||||
expect(await index.search(" ")).toEqual([]);
|
||||
});
|
||||
|
||||
it("matches titles despite spelling mistakes", async () => {
|
||||
const index = new SearchIndex();
|
||||
index.update([
|
||||
{ id: "1", title: "Engineering Handbook", url: "/doc/1" },
|
||||
{ id: "2", title: "Marketing Plan", url: "/doc/2" },
|
||||
]);
|
||||
|
||||
const results = await index.search("enginering handbok");
|
||||
expect(results[0]?.document.id).toBe("1");
|
||||
});
|
||||
|
||||
it("matches document content and highlights the context", async () => {
|
||||
const index = new SearchIndex();
|
||||
index.update([
|
||||
{
|
||||
id: "1",
|
||||
title: "Untitled",
|
||||
url: "/doc/1",
|
||||
text: "The quarterly revenue numbers exceeded all expectations.",
|
||||
},
|
||||
]);
|
||||
|
||||
const results = await index.search("revenue");
|
||||
expect(results[0]?.document.id).toBe("1");
|
||||
expect(results[0]?.context).toContain("<b>revenue</b>");
|
||||
});
|
||||
|
||||
it("highlights only exact matches, not fuzzy character runs", async () => {
|
||||
const index = new SearchIndex();
|
||||
index.update([
|
||||
{
|
||||
id: "1",
|
||||
title: "Untitled",
|
||||
url: "/doc/1",
|
||||
text: "The quarterly revenue numbers exceeded all expectations.",
|
||||
},
|
||||
]);
|
||||
|
||||
// The typo "numbrs" has no literal occurrence, so only whole contiguous
|
||||
// matches are marked rather than the scattered characters Fuse matched on.
|
||||
const results = await index.search("revenu numbrs");
|
||||
expect(results[0]?.document.id).toBe("1");
|
||||
expect(results[0]?.context).toContain("<b>revenu</b>");
|
||||
expect(results[0]?.context).not.toContain("numbrs");
|
||||
expect(results[0]?.context?.match(/<b>/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("highlights the query where it appears inside a longer word", async () => {
|
||||
const index = new SearchIndex();
|
||||
index.update([
|
||||
{
|
||||
id: "1",
|
||||
title: "Untitled",
|
||||
url: "/doc/1",
|
||||
text: "The quarterly revenue numbers exceeded all expectations.",
|
||||
},
|
||||
]);
|
||||
|
||||
// Partway through typing "revenue", the typed prefix is still marked.
|
||||
expect((await index.search("revenu"))[0]?.context).toContain(
|
||||
"<b>revenu</b>"
|
||||
);
|
||||
});
|
||||
|
||||
it("highlights each term of a multi-word query separately", async () => {
|
||||
const index = new SearchIndex();
|
||||
index.update([
|
||||
{
|
||||
id: "1",
|
||||
title: "Untitled",
|
||||
url: "/doc/1",
|
||||
text: "The quarterly revenue numbers exceeded all expectations.",
|
||||
},
|
||||
]);
|
||||
|
||||
const context = (await index.search("revenue numbers"))[0]?.context;
|
||||
expect(context).toContain("<b>revenue numbers</b>");
|
||||
});
|
||||
|
||||
it("marks a match as one contiguous run rather than letter runs", async () => {
|
||||
const index = new SearchIndex();
|
||||
index.update([
|
||||
{
|
||||
id: "1",
|
||||
title: "Untitled",
|
||||
url: "/doc/1",
|
||||
text: "Engineering onboarding covers the deployment pipeline.",
|
||||
},
|
||||
]);
|
||||
|
||||
const context = (await index.search("deployment"))[0]?.context;
|
||||
// The whole word is marked once, rather than scattered letter runs.
|
||||
expect(context).toContain("<b>deployment</b>");
|
||||
expect(context?.match(/<b>/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("preserves previously indexed content when a later update omits it", async () => {
|
||||
const index = new SearchIndex();
|
||||
index.update([
|
||||
{
|
||||
id: "1",
|
||||
title: "Report",
|
||||
url: "/doc/1",
|
||||
text: "Contains the word pineapple somewhere inside.",
|
||||
},
|
||||
]);
|
||||
// A subsequent update (e.g. a title-only tree pass) must not drop content.
|
||||
index.update([{ id: "1", title: "Report", url: "/doc/1" }]);
|
||||
|
||||
expect((await index.search("pineapple"))[0]?.document.id).toBe("1");
|
||||
});
|
||||
|
||||
it("clears content when an update sets text to an empty string", async () => {
|
||||
const index = new SearchIndex();
|
||||
index.update([
|
||||
{
|
||||
id: "1",
|
||||
title: "Report",
|
||||
url: "/doc/1",
|
||||
text: "Contains the word pineapple somewhere inside.",
|
||||
},
|
||||
]);
|
||||
// An explicit empty string (e.g. content emptied) must replace stale text.
|
||||
index.update([{ id: "1", title: "Report", url: "/doc/1", text: "" }]);
|
||||
|
||||
expect(await index.search("pineapple")).toEqual([]);
|
||||
});
|
||||
|
||||
it("previews content for a title match that has no content match", async () => {
|
||||
const index = new SearchIndex();
|
||||
index.update([
|
||||
{
|
||||
id: "1",
|
||||
title: "Engineering Handbook",
|
||||
url: "/doc/1",
|
||||
text: "Everything a new hire needs to know about our team.",
|
||||
},
|
||||
]);
|
||||
|
||||
const context = (await index.search("engineering"))[0]?.context;
|
||||
expect(context).toBe("Everything a new hire needs to know about our team.");
|
||||
});
|
||||
|
||||
it("has no context for a document with no content", async () => {
|
||||
const index = new SearchIndex();
|
||||
index.update([{ id: "1", title: "Engineering Handbook", url: "/doc/1" }]);
|
||||
|
||||
expect((await index.search("engineering"))[0]?.context).toBeUndefined();
|
||||
});
|
||||
|
||||
it("truncates a long preview", async () => {
|
||||
const index = new SearchIndex();
|
||||
index.update([
|
||||
{
|
||||
id: "1",
|
||||
title: "Engineering Handbook",
|
||||
url: "/doc/1",
|
||||
text: "word ".repeat(200),
|
||||
},
|
||||
]);
|
||||
|
||||
const context = (await index.search("engineering"))[0]?.context ?? "";
|
||||
expect(context.endsWith("…")).toBe(true);
|
||||
expect(context.length).toBeLessThan(220);
|
||||
});
|
||||
|
||||
it("orders a title prefix ahead of a shorter mid-title match", async () => {
|
||||
const index = new SearchIndex();
|
||||
index.update([
|
||||
// Fuse scores this higher on its own, as the field is shorter and
|
||||
// position is ignored.
|
||||
{ id: "mid", title: "Our Design", url: "/doc/mid" },
|
||||
{
|
||||
id: "prefix",
|
||||
title: "Design System Guidelines Handbook",
|
||||
url: "/doc/prefix",
|
||||
},
|
||||
]);
|
||||
|
||||
expect((await index.search("design"))[0]?.document.id).toBe("prefix");
|
||||
});
|
||||
|
||||
it("orders a title prefix ahead of a shorter trailing-word match", async () => {
|
||||
const index = new SearchIndex();
|
||||
index.update([
|
||||
{ id: "mid", title: "Team Onboarding", url: "/doc/mid" },
|
||||
{
|
||||
id: "prefix",
|
||||
title: "Onboarding Checklist For New Hires",
|
||||
url: "/doc/prefix",
|
||||
},
|
||||
]);
|
||||
|
||||
expect((await index.search("onboarding"))[0]?.document.id).toBe("prefix");
|
||||
});
|
||||
|
||||
it("orders a title prefix ahead of an equally scored mid-title match", async () => {
|
||||
const index = new SearchIndex();
|
||||
index.update([
|
||||
// Both score identically in Fuse, leaving the order arbitrary.
|
||||
{ id: "mid", title: "A Guide To Design", url: "/doc/mid" },
|
||||
{ id: "prefix", title: "Design Is A Guide", url: "/doc/prefix" },
|
||||
]);
|
||||
|
||||
expect((await index.search("design"))[0]?.document.id).toBe("prefix");
|
||||
});
|
||||
|
||||
it("orders a title prefix ahead of a content-only match", async () => {
|
||||
const index = new SearchIndex();
|
||||
index.update([
|
||||
{ id: "content", title: "Q3", url: "/doc/content", text: "revenue" },
|
||||
{
|
||||
id: "prefix",
|
||||
title: "Revenue Recognition Policy Handbook",
|
||||
url: "/doc/prefix",
|
||||
},
|
||||
]);
|
||||
|
||||
expect((await index.search("revenue"))[0]?.document.id).toBe("prefix");
|
||||
});
|
||||
|
||||
it("breaks ties within a tier by fuzzy score", async () => {
|
||||
const index = new SearchIndex();
|
||||
index.update([
|
||||
{ id: "long", title: "Design System Guidelines", url: "/doc/long" },
|
||||
{ id: "short", title: "Design", url: "/doc/short" },
|
||||
]);
|
||||
|
||||
// Both are prefix matches, so the closer title wins.
|
||||
expect((await index.search("design"))[0]?.document.id).toBe("short");
|
||||
});
|
||||
|
||||
it("weights title matches above content matches", async () => {
|
||||
const index = new SearchIndex();
|
||||
index.update([
|
||||
{ id: "1", title: "Onboarding", url: "/doc/1", text: "unrelated body" },
|
||||
{
|
||||
id: "2",
|
||||
title: "Team",
|
||||
url: "/doc/2",
|
||||
text: "please read the onboarding guide",
|
||||
},
|
||||
]);
|
||||
|
||||
const results = await index.search("onboarding");
|
||||
expect(results[0]?.document.id).toBe("1");
|
||||
});
|
||||
|
||||
it("reports whether an update changed the collection", async () => {
|
||||
const index = new SearchIndex();
|
||||
expect(index.update([{ id: "1", title: "A", url: "/doc/1" }])).toBe(true);
|
||||
expect(index.update([{ id: "1", title: "A", url: "/doc/1" }])).toBe(false);
|
||||
});
|
||||
|
||||
it("clears all indexed documents", async () => {
|
||||
const index = new SearchIndex();
|
||||
index.update([{ id: "1", title: "Engineering", url: "/doc/1" }]);
|
||||
index.clear();
|
||||
|
||||
expect(await index.search("engineering")).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,294 @@
|
||||
import { escapeRegExp } from "es-toolkit/compat";
|
||||
import Fuse, { type IFuseOptions } from "fuse.js";
|
||||
import { FuseWorker } from "fuse.js/worker";
|
||||
|
||||
/** A document as represented within the search index. */
|
||||
export interface SearchIndexDocument {
|
||||
id: string;
|
||||
/** Display title, already defaulted for documents without one. */
|
||||
title: string;
|
||||
/** Plain text content of the document, may be absent until it has loaded. */
|
||||
text?: string;
|
||||
url: string;
|
||||
icon?: string | null;
|
||||
color?: string | null;
|
||||
}
|
||||
|
||||
/** A single document matched by a search of the index. */
|
||||
export interface SearchIndexResult {
|
||||
document: SearchIndexDocument;
|
||||
/** Fuse relevance score, lower is a better match. */
|
||||
score: number;
|
||||
/** A highlighted snippet of content surrounding the match, if any. */
|
||||
context?: string;
|
||||
}
|
||||
|
||||
/** The shortest term considered for matching and highlighting. */
|
||||
const minTermLength = 2;
|
||||
|
||||
/** How directly a title matches the query, ordered most to least direct. */
|
||||
enum TitleRanking {
|
||||
Prefix = 0,
|
||||
WordPrefix = 1,
|
||||
Fuzzy = 2,
|
||||
}
|
||||
|
||||
const options: IFuseOptions<SearchIndexDocument> = {
|
||||
keys: [
|
||||
{ name: "title", weight: 2 },
|
||||
{ name: "text", weight: 1 },
|
||||
],
|
||||
includeScore: true,
|
||||
ignoreLocation: true,
|
||||
threshold: 0.3,
|
||||
minMatchCharLength: minTermLength,
|
||||
};
|
||||
|
||||
/**
|
||||
* Scanning document content is the expensive part of a query, so it is sharded
|
||||
* across web workers to keep it off the main thread. Indices here hold at most
|
||||
* a few hundred documents, where more shards cost more in messaging overhead
|
||||
* and worker startup than they win back in parallelism.
|
||||
*/
|
||||
const workerOptions = { numWorkers: 2 };
|
||||
|
||||
const resultLimit = 25;
|
||||
const contextLead = 40;
|
||||
const contextLength = 200;
|
||||
|
||||
/**
|
||||
* Finds the literal, case-insensitive occurrences of the query — and of each of
|
||||
* its terms when it has more than one — within the given text. Fuzzy matching
|
||||
* decides *which* documents rank, but only literal matches are worth
|
||||
* highlighting, as the underlying fuzzy indices span scattered characters and
|
||||
* read as noise.
|
||||
*
|
||||
* @param text the text to search within.
|
||||
* @param query the search query.
|
||||
* @returns the matched ranges as start/end offsets, in document order.
|
||||
*/
|
||||
function findExactMatches(text: string, query: string): [number, number][] {
|
||||
const terms = query
|
||||
.split(/\s+/)
|
||||
.filter((term) => term.length >= minTermLength);
|
||||
const patterns = [query, ...(terms.length > 1 ? terms : [])].map(
|
||||
escapeRegExp
|
||||
);
|
||||
const regex = new RegExp(patterns.join("|"), "gi");
|
||||
|
||||
const ranges: [number, number][] = [];
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
if (match[0].length === 0) {
|
||||
regex.lastIndex++;
|
||||
continue;
|
||||
}
|
||||
ranges.push([match.index, match.index + match[0].length]);
|
||||
}
|
||||
return ranges;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ranks how directly a title matches the query, so that literal prefix matches
|
||||
* can be ordered ahead of fuzzy ones. Fuse scores purely on fuzzy distance and
|
||||
* will otherwise rank a typo-tolerant match above a title the user is part-way
|
||||
* through typing.
|
||||
*
|
||||
* @param title the document title.
|
||||
* @param query the search query.
|
||||
* @returns the ranking, where a lower number is a more direct match.
|
||||
*/
|
||||
function getTitleRanking(title: string, query: string): TitleRanking {
|
||||
const normalizedTitle = title.toLowerCase();
|
||||
const normalizedQuery = query.toLowerCase();
|
||||
|
||||
if (normalizedTitle.startsWith(normalizedQuery)) {
|
||||
return TitleRanking.Prefix;
|
||||
}
|
||||
|
||||
// A word within the title starting with the query, e.g. "hand" in
|
||||
// "Engineering Handbook".
|
||||
const boundary = /^\w/.test(query) ? "\\b" : "";
|
||||
if (new RegExp(`${boundary}${escapeRegExp(query)}`, "i").test(title)) {
|
||||
return TitleRanking.WordPrefix;
|
||||
}
|
||||
|
||||
return TitleRanking.Fuzzy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a preview snippet of the document content, trimmed to a short window.
|
||||
* When the content contains exact matches for the query the window is centered
|
||||
* on the first of them and each match is wrapped in `<b>` tags, otherwise the
|
||||
* opening of the content is used unhighlighted.
|
||||
*
|
||||
* @param text the document content.
|
||||
* @param query the search query.
|
||||
* @returns the snippet, or undefined when there is no content to preview.
|
||||
*/
|
||||
function buildContext(
|
||||
text: string | undefined,
|
||||
query: string
|
||||
): string | undefined {
|
||||
const trimmedText = text?.trim();
|
||||
if (!trimmedText) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const ranges =
|
||||
query.length >= minTermLength ? findExactMatches(trimmedText, query) : [];
|
||||
|
||||
// Without a literal match there is nothing worth marking, but the opening of
|
||||
// the document still makes a useful preview.
|
||||
if (!ranges.length) {
|
||||
const excerpt = trimmedText.slice(0, contextLength);
|
||||
return excerpt.length < trimmedText.length ? `${excerpt.trim()}…` : excerpt;
|
||||
}
|
||||
|
||||
const windowStart = Math.max(0, ranges[0][0] - contextLead);
|
||||
const windowEnd = Math.min(trimmedText.length, windowStart + contextLength);
|
||||
|
||||
let out = "";
|
||||
let cursor = windowStart;
|
||||
for (const [start, end] of ranges) {
|
||||
if (end <= windowStart || start >= windowEnd) {
|
||||
continue;
|
||||
}
|
||||
const from = Math.max(start, windowStart);
|
||||
const to = Math.min(end, windowEnd);
|
||||
if (from > cursor) {
|
||||
out += trimmedText.slice(cursor, from);
|
||||
}
|
||||
out += `<b>${trimmedText.slice(from, to)}</b>`;
|
||||
cursor = to;
|
||||
}
|
||||
if (cursor < windowEnd) {
|
||||
out += trimmedText.slice(cursor, windowEnd);
|
||||
}
|
||||
|
||||
const prefix = windowStart > 0 ? "…" : "";
|
||||
const suffix = windowEnd < trimmedText.length ? "…" : "";
|
||||
return `${prefix}${out.trim()}${suffix}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* An in-memory fuzzy search index over a set of documents, backed by Fuse.js.
|
||||
* Records are merged by id over time so that titles (known upfront) and content
|
||||
* (loaded lazily or returned by the server) can progressively enrich the index.
|
||||
*/
|
||||
export class SearchIndex {
|
||||
private records = new Map<string, SearchIndexDocument>();
|
||||
|
||||
// Workers are unavailable when server rendering and under jsdom, where the
|
||||
// main-thread implementation stands in with an identical API.
|
||||
private fuse: Fuse<SearchIndexDocument> | FuseWorker<SearchIndexDocument> =
|
||||
typeof Worker === "undefined"
|
||||
? new Fuse<SearchIndexDocument>([], { ...options, useTokenSearch: true })
|
||||
: new FuseWorker<SearchIndexDocument>([], options, workerOptions);
|
||||
|
||||
/**
|
||||
* Merges documents into the index, rebuilding the collection only when
|
||||
* something changed. Existing content text is preserved when an incoming
|
||||
* record has none, so partial updates never discard richer data.
|
||||
*
|
||||
* @param documents the documents to add or update.
|
||||
* @returns whether the indexed collection changed as a result.
|
||||
*/
|
||||
public update(documents: SearchIndexDocument[]): boolean {
|
||||
let changed = false;
|
||||
|
||||
for (const incoming of documents) {
|
||||
const existing = this.records.get(incoming.id);
|
||||
const merged: SearchIndexDocument = existing
|
||||
? { ...existing, ...incoming }
|
||||
: incoming;
|
||||
|
||||
// Preserve previously-indexed content when an update omits text entirely,
|
||||
// but respect an update that intentionally clears it to an empty string.
|
||||
if (incoming.text === undefined && existing?.text !== undefined) {
|
||||
merged.text = existing.text;
|
||||
}
|
||||
|
||||
if (
|
||||
existing &&
|
||||
existing.title === merged.title &&
|
||||
existing.text === merged.text &&
|
||||
existing.url === merged.url
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.records.set(incoming.id, merged);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
this.setCollection(Array.from(this.records.values()));
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all documents from the index.
|
||||
*/
|
||||
public clear(): void {
|
||||
this.records.clear();
|
||||
this.setCollection([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Releases the workers backing the index. The index must not be used again
|
||||
* afterwards.
|
||||
*/
|
||||
public dispose(): void {
|
||||
if (this.fuse instanceof FuseWorker) {
|
||||
this.fuse.terminate();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs a fuzzy search across indexed titles and content. Titles that the
|
||||
* query prefixes are always ordered ahead of purely fuzzy matches, with the
|
||||
* fuzzy score breaking ties within each tier.
|
||||
*
|
||||
* @param query the search query.
|
||||
* @returns the matching documents ordered by relevance.
|
||||
*/
|
||||
public async search(query: string): Promise<SearchIndexResult[]> {
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const matches = await this.fuse.search(trimmed);
|
||||
|
||||
// Building context scans the full text of a document, so it is deferred
|
||||
// until the results have been truncated to those actually displayed.
|
||||
return matches
|
||||
.map((result) => ({
|
||||
document: result.item,
|
||||
score: result.score ?? 1,
|
||||
ranking: getTitleRanking(result.item.title, trimmed),
|
||||
}))
|
||||
.sort((a, b) => a.ranking - b.ranking || a.score - b.score)
|
||||
.slice(0, resultLimit)
|
||||
.map(({ document, score }) => ({
|
||||
document,
|
||||
score,
|
||||
context: buildContext(document.text, trimmed),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the indexed collection. Workers apply this asynchronously, but
|
||||
* they process messages in order, so a search issued afterwards always sees
|
||||
* the new collection.
|
||||
*/
|
||||
private setCollection(documents: SearchIndexDocument[]): void {
|
||||
void Promise.resolve(this.fuse.setCollection(documents)).catch(() => {
|
||||
// A worker that failed to accept the collection will also fail the next
|
||||
// search, which is where the error surfaces.
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { DocumentIcon } from "outline-icons";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import type { SearchIndexDocument } from "./SearchIndex";
|
||||
|
||||
interface Props {
|
||||
/** The matched document to show an icon for. */
|
||||
document: SearchIndexDocument;
|
||||
/** Icon size, applied by the command bar when rendering the action. */
|
||||
size?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays the icon for a document in the command bar, falling back to a
|
||||
* generic document icon when it has none of its own.
|
||||
*/
|
||||
export function SearchResultIcon({ document, size }: Props) {
|
||||
if (!document.icon) {
|
||||
return <DocumentIcon size={size} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Icon
|
||||
value={document.icon}
|
||||
initial={document.title}
|
||||
color={document.color ?? undefined}
|
||||
size={size}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useKBar } from "kbar";
|
||||
import { escapeRegExp } from "es-toolkit/compat";
|
||||
import { autorun } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import { DocumentIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useShare from "@shared/hooks/useShare";
|
||||
import { Minute } from "@shared/utils/time";
|
||||
import { NavigationNodeType, type NavigationNode } from "@shared/types";
|
||||
import { flattenTree } from "@shared/utils/tree";
|
||||
import { createAction } from "~/actions";
|
||||
import {
|
||||
RecentSearchesSection,
|
||||
@@ -13,64 +13,36 @@ import {
|
||||
} from "~/actions/sections";
|
||||
import useCommandBarActions from "~/hooks/useCommandBarActions";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import type Document from "~/models/Document";
|
||||
import history from "~/utils/history";
|
||||
import { sharedModelPath } from "~/utils/routeHelpers";
|
||||
import type { SearchResult } from "~/types";
|
||||
import type { SearchIndexDocument } from "./SearchIndex";
|
||||
import { SearchResultIcon } from "./SearchResultIcon";
|
||||
import {
|
||||
toActionPriority,
|
||||
toSearchRecord,
|
||||
useSearchIndex,
|
||||
} from "./useSearchIndex";
|
||||
|
||||
interface CacheEntry {
|
||||
timestamp: number;
|
||||
results: SearchResult[];
|
||||
}
|
||||
|
||||
const cacheTTL = Minute.ms * 5;
|
||||
const maxRecentDocs = 5;
|
||||
const serverSearchDelay = 350;
|
||||
|
||||
/**
|
||||
* Strip server-generated `<b>` highlight tags from context and re-apply them
|
||||
* using the current search query. This prevents stale highlights when the
|
||||
* displayed results are from a previous (in-flight) query.
|
||||
*
|
||||
* @param context the server-generated context string with `<b>` tags.
|
||||
* @param query the current search query to highlight.
|
||||
* @returns the context string with highlights matching the current query.
|
||||
* A shared tree is rooted at the collection for collection shares, and at a
|
||||
* document for document shares – only the latter are searchable.
|
||||
*/
|
||||
function rehighlightContext(
|
||||
context: string | undefined,
|
||||
query: string
|
||||
): string | undefined {
|
||||
if (!context) {
|
||||
return context;
|
||||
}
|
||||
|
||||
const plain = context.replace(/<b\b[^>]*>(.*?)<\/b>/gi, "$1");
|
||||
const trimmed = query.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
return plain;
|
||||
}
|
||||
|
||||
const terms = trimmed.split(/\s+/).filter(Boolean);
|
||||
const patterns = [escapeRegExp(trimmed)];
|
||||
|
||||
if (terms.length > 1) {
|
||||
patterns.push(...terms.map((t) => `\\b${escapeRegExp(t)}\\b`));
|
||||
}
|
||||
|
||||
const regex = new RegExp(patterns.join("|"), "gi");
|
||||
return plain.replace(regex, "<b>$&</b>");
|
||||
}
|
||||
const isDocumentNode = (node: NavigationNode) =>
|
||||
node.type !== NavigationNodeType.Collection;
|
||||
|
||||
/**
|
||||
* Registers search result actions in the command bar scoped to a public share.
|
||||
* Results are driven entirely by a client-side fuzzy index — providing
|
||||
* typo-tolerant matching — which is progressively fed by document titles from
|
||||
* the shared tree, the content of loaded documents, and server responses.
|
||||
*/
|
||||
function SharedSearchActions() {
|
||||
const { t } = useTranslation();
|
||||
const { documents } = useStores();
|
||||
const { shareId } = useShare();
|
||||
const searchCache = React.useRef<Map<string, CacheEntry>>(new Map());
|
||||
const [results, setResults] = React.useState<SearchResult[]>([]);
|
||||
const recentDocsRef = React.useRef<Document[]>([]);
|
||||
const [recentDocs, setRecentDocs] = React.useState<Document[]>([]);
|
||||
const { shareId, sharedTree } = useShare();
|
||||
|
||||
const { searchQuery } = useKBar((state) => ({
|
||||
searchQuery: state.searchQuery,
|
||||
@@ -79,65 +51,95 @@ function SharedSearchActions() {
|
||||
const searchQueryRef = React.useRef(searchQuery);
|
||||
searchQueryRef.current = searchQuery;
|
||||
|
||||
const { results, feed, reset } = useSearchIndex(searchQuery);
|
||||
|
||||
const recentDocsRef = React.useRef<SearchIndexDocument[]>([]);
|
||||
const [recentDocs, setRecentDocs] = React.useState<SearchIndexDocument[]>([]);
|
||||
|
||||
// Start from a clean index whenever the share changes.
|
||||
React.useEffect(() => {
|
||||
if (!searchQuery || !shareId) {
|
||||
setResults([]);
|
||||
reset();
|
||||
}, [reset, shareId]);
|
||||
|
||||
// Feed the index with titles from the shared tree and content from any
|
||||
// documents that have loaded into the store, re-running as documents load.
|
||||
React.useEffect(() => {
|
||||
if (!sharedTree) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const cachedEntry = searchCache.current.get(searchQuery);
|
||||
const isExpired = cachedEntry
|
||||
? now - cachedEntry.timestamp > cacheTTL
|
||||
: true;
|
||||
const nodes = flattenTree(sharedTree).filter(isDocumentNode);
|
||||
|
||||
if (cachedEntry && !isExpired) {
|
||||
setResults(cachedEntry.results);
|
||||
return autorun(() => {
|
||||
feed(
|
||||
nodes.map((node) => {
|
||||
const doc = documents.get(node.id);
|
||||
return doc
|
||||
? toSearchRecord(doc)
|
||||
: {
|
||||
id: node.id,
|
||||
title: node.title || t("Untitled"),
|
||||
url: node.url,
|
||||
icon: node.icon,
|
||||
color: node.color,
|
||||
};
|
||||
})
|
||||
);
|
||||
});
|
||||
}, [documents, sharedTree, feed, t]);
|
||||
|
||||
// Enrich the index from the server so that content we have not loaded
|
||||
// client-side can still surface.
|
||||
React.useEffect(() => {
|
||||
if (!searchQuery || !shareId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentQuery = searchQuery;
|
||||
void documents.search({ query: searchQuery, shareId }).then((res) => {
|
||||
searchCache.current.set(currentQuery, { timestamp: now, results: res });
|
||||
if (searchQueryRef.current === currentQuery) {
|
||||
setResults(res);
|
||||
}
|
||||
});
|
||||
}, [documents, searchQuery, shareId]);
|
||||
let disposed = false;
|
||||
|
||||
const addRecentDoc = React.useCallback((doc: Document) => {
|
||||
const prev = recentDocsRef.current;
|
||||
const filtered = prev.filter((d) => d.id !== doc.id);
|
||||
const handle = setTimeout(() => {
|
||||
void documents
|
||||
.search({ query: currentQuery, shareId })
|
||||
.then((res) => {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
feed(
|
||||
res.map((result) => toSearchRecord(result.document, result.context))
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
// Failing to enrich the index is not worth surfacing, local results
|
||||
// are still shown.
|
||||
});
|
||||
}, serverSearchDelay);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
clearTimeout(handle);
|
||||
};
|
||||
}, [documents, searchQuery, shareId, feed]);
|
||||
|
||||
const addRecentDoc = React.useCallback((doc: SearchIndexDocument) => {
|
||||
const filtered = recentDocsRef.current.filter((d) => d.id !== doc.id);
|
||||
const next = [doc, ...filtered].slice(0, maxRecentDocs);
|
||||
recentDocsRef.current = next;
|
||||
setRecentDocs(next);
|
||||
}, []);
|
||||
|
||||
const documentIcon = React.useCallback(
|
||||
(doc: Document) =>
|
||||
doc.icon ? (
|
||||
<Icon
|
||||
value={doc.icon}
|
||||
initial={doc.initial}
|
||||
color={doc.color ?? undefined}
|
||||
/>
|
||||
) : (
|
||||
<DocumentIcon />
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
const actions = React.useMemo(
|
||||
() =>
|
||||
results.map((result) =>
|
||||
results.map((result, index) =>
|
||||
createAction({
|
||||
id: `shared-search-${result.document.id}`,
|
||||
name: result.document.titleWithDefault,
|
||||
description: rehighlightContext(result.context, searchQuery),
|
||||
name: result.document.title,
|
||||
description: result.context,
|
||||
keywords: searchQuery,
|
||||
analyticsName: "Open shared search result",
|
||||
section: SearchResultsSection,
|
||||
icon: documentIcon(result.document),
|
||||
priority: toActionPriority(index, results.length),
|
||||
icon: <SearchResultIcon document={result.document} />,
|
||||
perform: () => {
|
||||
if (shareId) {
|
||||
const currentQuery = searchQueryRef.current;
|
||||
@@ -152,7 +154,7 @@ function SharedSearchActions() {
|
||||
},
|
||||
})
|
||||
),
|
||||
[results, shareId, searchQuery, addRecentDoc, documentIcon]
|
||||
[results, shareId, searchQuery, addRecentDoc]
|
||||
);
|
||||
|
||||
const recentDocActions = React.useMemo(
|
||||
@@ -160,10 +162,10 @@ function SharedSearchActions() {
|
||||
recentDocs.map((doc) =>
|
||||
createAction({
|
||||
id: `shared-recent-doc-${doc.id}`,
|
||||
name: doc.titleWithDefault,
|
||||
name: doc.title,
|
||||
analyticsName: "Open recent shared document",
|
||||
section: RecentSearchesSection,
|
||||
icon: documentIcon(doc),
|
||||
icon: <SearchResultIcon document={doc} />,
|
||||
perform: () => {
|
||||
if (shareId) {
|
||||
history.push(sharedModelPath(shareId, doc.url));
|
||||
@@ -171,13 +173,18 @@ function SharedSearchActions() {
|
||||
},
|
||||
})
|
||||
),
|
||||
[recentDocs, shareId, documentIcon]
|
||||
[recentDocs, shareId]
|
||||
);
|
||||
|
||||
// Enriching the index can change snippets without changing which documents
|
||||
// matched, so the key must cover contexts as well as ids.
|
||||
const resultsKey = React.useMemo(
|
||||
() => results.map((r) => `${r.document.id}:${r.context ?? ""}`).join(""),
|
||||
[results]
|
||||
);
|
||||
|
||||
useCommandBarActions(searchQuery ? actions : recentDocActions, [
|
||||
searchQuery
|
||||
? actions.map((a) => a.id).join("")
|
||||
: recentDocActions.map((a) => a.id).join(""),
|
||||
searchQuery ? resultsKey : recentDocActions.map((a) => a.id).join(""),
|
||||
searchQuery,
|
||||
]);
|
||||
|
||||
|
||||
@@ -6,35 +6,59 @@ import { createInternalLinkAction } from "~/actions";
|
||||
import { RecentSection } from "~/actions/sections";
|
||||
import { documentBreadcrumbText } from "~/components/DocumentBreadcrumb";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import type Document from "~/models/Document";
|
||||
import { documentPath } from "~/utils/routeHelpers";
|
||||
|
||||
const useRecentDocumentActions = (count = 6) => {
|
||||
/** The number of documents listed under "Recently viewed" in the command bar. */
|
||||
export const recentDocumentCount = 6;
|
||||
|
||||
/**
|
||||
* Narrows recently viewed documents to those listed under "Recently viewed" in
|
||||
* the command bar, so that other actions can avoid offering them a second time.
|
||||
*
|
||||
* @param recentlyViewed the recently viewed documents, most recent first.
|
||||
* @param activeDocumentId the currently open document, which is excluded.
|
||||
* @param count the maximum number of documents to return.
|
||||
* @returns the documents shown in the command bar.
|
||||
*/
|
||||
export function recentDocuments(
|
||||
recentlyViewed: Document[],
|
||||
activeDocumentId: string | undefined,
|
||||
count = recentDocumentCount
|
||||
): Document[] {
|
||||
return recentlyViewed
|
||||
.filter((document) => document.id !== activeDocumentId)
|
||||
.slice(0, count);
|
||||
}
|
||||
|
||||
const useRecentDocumentActions = (count = recentDocumentCount) => {
|
||||
const { documents, ui } = useStores();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
documents.recentlyViewed
|
||||
.filter((document) => document.id !== ui.activeDocumentId)
|
||||
.slice(0, count)
|
||||
.map((item) =>
|
||||
createInternalLinkAction({
|
||||
name: item.titleWithDefault,
|
||||
analyticsName: "Recently viewed document",
|
||||
section: RecentSection,
|
||||
description: documentBreadcrumbText(item, t),
|
||||
icon: item.icon ? (
|
||||
<Icon
|
||||
value={item.icon}
|
||||
initial={item.initial}
|
||||
color={item.color ?? undefined}
|
||||
/>
|
||||
) : (
|
||||
<DocumentIcon outline={item.isDraft} />
|
||||
),
|
||||
to: documentPath(item),
|
||||
})
|
||||
),
|
||||
recentDocuments(
|
||||
documents.recentlyViewed,
|
||||
ui.activeDocumentId ?? undefined,
|
||||
count
|
||||
).map((item) =>
|
||||
createInternalLinkAction({
|
||||
name: item.titleWithDefault,
|
||||
analyticsName: "Recently viewed document",
|
||||
section: RecentSection,
|
||||
description: documentBreadcrumbText(item, t),
|
||||
icon: item.icon ? (
|
||||
<Icon
|
||||
value={item.icon}
|
||||
initial={item.initial}
|
||||
color={item.color ?? undefined}
|
||||
/>
|
||||
) : (
|
||||
<DocumentIcon outline={item.isDraft} />
|
||||
),
|
||||
to: documentPath(item),
|
||||
})
|
||||
),
|
||||
[count, ui.activeDocumentId, documents.recentlyViewed, t]
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type Document from "~/models/Document";
|
||||
import { ProsemirrorHelper } from "~/models/helpers/ProsemirrorHelper";
|
||||
import {
|
||||
SearchIndex,
|
||||
type SearchIndexDocument,
|
||||
type SearchIndexResult,
|
||||
} from "./SearchIndex";
|
||||
|
||||
const plainTextCache = new Map<string, { updatedAt: string; text: string }>();
|
||||
|
||||
/**
|
||||
* Returns the plain text content of a document, memoized so that repeated
|
||||
* indexing passes do not reparse unchanged ProseMirror data. The cache holds at
|
||||
* most one entry per document, invalidated when the document is edited.
|
||||
*/
|
||||
function getPlainText(doc: Document): string {
|
||||
const updatedAt = String(doc.updatedAt ?? "");
|
||||
const cached = plainTextCache.get(doc.id);
|
||||
if (cached?.updatedAt === updatedAt) {
|
||||
return cached.text;
|
||||
}
|
||||
const text = ProsemirrorHelper.toPlainText(doc);
|
||||
plainTextCache.set(doc.id, { updatedAt, text });
|
||||
return text;
|
||||
}
|
||||
|
||||
/** Removes server-generated `<b>` highlight tags from a context snippet. */
|
||||
function stripHighlightTags(context: string | undefined): string | undefined {
|
||||
return context?.replace(/<b\b[^>]*>(.*?)<\/b>/gi, "$1");
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an index record from a document model, using its content when loaded
|
||||
* and otherwise falling back to a server-provided context snippet.
|
||||
*
|
||||
* @param doc the document to index.
|
||||
* @param context an optional server search context snippet to use as content.
|
||||
* @returns the index record.
|
||||
*/
|
||||
export function toSearchRecord(
|
||||
doc: Document,
|
||||
context?: string
|
||||
): SearchIndexDocument {
|
||||
return {
|
||||
id: doc.id,
|
||||
title: doc.titleWithDefault,
|
||||
url: doc.url,
|
||||
text: doc.data ? getPlainText(doc) : stripHighlightTags(context),
|
||||
icon: doc.icon,
|
||||
color: doc.color,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the command bar priority that preserves a result's position in the
|
||||
* list. The command bar re-ranks registered actions with its own fuzzy matcher
|
||||
* and orders each section by `priority + score`, where score never spans more
|
||||
* than 0.5 — so a step of one per position keeps our ordering intact.
|
||||
*
|
||||
* @param index the position of the result.
|
||||
* @param total the total number of results.
|
||||
* @returns the priority to assign to the action.
|
||||
*/
|
||||
export function toActionPriority(index: number, total: number): number {
|
||||
return total - index;
|
||||
}
|
||||
|
||||
export interface UseSearchIndex {
|
||||
/** Fuzzy matches for the current query, ordered by relevance. */
|
||||
results: SearchIndexResult[];
|
||||
/** Merges documents into the index, re-running the search if anything changed. */
|
||||
feed: (documents: SearchIndexDocument[]) => void;
|
||||
/** Empties the index. */
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maintains a client-side fuzzy search index and searches it for the given
|
||||
* query. The index is fed incrementally via the returned `feed` function, and
|
||||
* matching runs in web workers so that typing is never blocked by it.
|
||||
*
|
||||
* @param query the current search query.
|
||||
* @returns the current results and functions to feed or reset the index.
|
||||
*/
|
||||
export function useSearchIndex(query: string): UseSearchIndex {
|
||||
const indexRef = useRef<SearchIndex>();
|
||||
if (!indexRef.current) {
|
||||
indexRef.current = new SearchIndex();
|
||||
}
|
||||
const index = indexRef.current;
|
||||
|
||||
// Bumped whenever the index changes, to re-run the search below.
|
||||
const [version, setVersion] = useState(0);
|
||||
const [results, setResults] = useState<SearchIndexResult[]>([]);
|
||||
|
||||
const feed = useCallback(
|
||||
(documents: SearchIndexDocument[]) => {
|
||||
if (index.update(documents)) {
|
||||
setVersion((v) => v + 1);
|
||||
}
|
||||
},
|
||||
[index]
|
||||
);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
index.clear();
|
||||
setVersion((v) => v + 1);
|
||||
}, [index]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!query) {
|
||||
setResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Searches resolve out of order, so a stale response must not overwrite the
|
||||
// results of a query the user has since moved on from.
|
||||
let disposed = false;
|
||||
|
||||
void index
|
||||
.search(query)
|
||||
.then((next) => {
|
||||
if (!disposed) {
|
||||
setResults(next);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!disposed) {
|
||||
setResults([]);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
};
|
||||
}, [index, query, version]);
|
||||
|
||||
useEffect(() => () => index.dispose(), [index]);
|
||||
|
||||
return { results, feed, reset };
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import { useKBar } from "kbar";
|
||||
import { observer } from "mobx-react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Minute } from "@shared/utils/time";
|
||||
import { searchDocumentsForQueryActionFactory } from "~/actions/definitions/documents";
|
||||
import { navigateToRecentSearchQueryActionFactory } from "~/actions/definitions/navigation";
|
||||
import useCommandBarActions from "~/hooks/useCommandBarActions";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
// Type for cache entries
|
||||
interface CacheEntry {
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
// Cache configuration
|
||||
const cacheTTL = Minute.ms * 5;
|
||||
|
||||
function SearchActions() {
|
||||
const { searches, documents } = useStores();
|
||||
|
||||
// Cache structure: Map of search queries to timestamp of last search
|
||||
const searchCache = useRef<Map<string, CacheEntry>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
if (!searches.isLoaded && !searches.isFetching) {
|
||||
void searches.fetchPage({
|
||||
source: "app",
|
||||
});
|
||||
}
|
||||
}, [searches]);
|
||||
|
||||
const { searchQuery } = useKBar((state) => ({
|
||||
searchQuery: state.searchQuery,
|
||||
}));
|
||||
|
||||
// Search for matching documents
|
||||
useEffect(() => {
|
||||
if (searchQuery) {
|
||||
const now = Date.now();
|
||||
const cachedEntry = searchCache.current.get(searchQuery);
|
||||
const isExpired = cachedEntry
|
||||
? now - cachedEntry.timestamp > cacheTTL
|
||||
: true;
|
||||
|
||||
if (!cachedEntry || isExpired) {
|
||||
void documents.searchTitles({ query: searchQuery }).then(() => {
|
||||
searchCache.current.set(searchQuery, { timestamp: now });
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [documents, searchQuery]);
|
||||
|
||||
useCommandBarActions(
|
||||
searchQuery ? [searchDocumentsForQueryActionFactory(searchQuery)] : [],
|
||||
[searchQuery]
|
||||
);
|
||||
|
||||
useCommandBarActions(
|
||||
searches.recent.map(navigateToRecentSearchQueryActionFactory)
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default observer(SearchActions);
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useKBar } from "kbar";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Minute } from "@shared/utils/time";
|
||||
import { createInternalLinkAction } from "~/actions";
|
||||
import { searchDocumentsForQueryActionFactory } from "~/actions/definitions/documents";
|
||||
import { navigateToRecentSearchQueryActionFactory } from "~/actions/definitions/navigation";
|
||||
import { SearchResultsSection } from "~/actions/sections";
|
||||
import { SearchResultIcon } from "~/components/CommandBar/SearchResultIcon";
|
||||
import {
|
||||
toActionPriority,
|
||||
toSearchRecord,
|
||||
useSearchIndex,
|
||||
} from "~/components/CommandBar/useSearchIndex";
|
||||
import useCommandBarActions from "~/hooks/useCommandBarActions";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
const cacheTTL = Minute.ms * 5;
|
||||
const serverSearchDelay = 350;
|
||||
|
||||
function SearchActions() {
|
||||
const { searches, documents } = useStores();
|
||||
|
||||
// Tracks the timestamp of the last server search for each query.
|
||||
const searchCache = React.useRef<Map<string, number>>(new Map());
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!searches.isLoaded && !searches.isFetching) {
|
||||
void searches.fetchPage({
|
||||
source: "app",
|
||||
});
|
||||
}
|
||||
}, [searches]);
|
||||
|
||||
const { searchQuery } = useKBar((state) => ({
|
||||
searchQuery: state.searchQuery,
|
||||
}));
|
||||
|
||||
const { results, feed } = useSearchIndex(searchQuery);
|
||||
|
||||
// Seed instant, local fuzzy matches from recently viewed documents.
|
||||
React.useEffect(() => {
|
||||
feed(documents.recentlyViewed.map((doc) => toSearchRecord(doc)));
|
||||
}, [documents.recentlyViewed, feed]);
|
||||
|
||||
// Enrich the index with server title matches, debounced and cached.
|
||||
React.useEffect(() => {
|
||||
if (!searchQuery) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cached = searchCache.current.get(searchQuery);
|
||||
if (cached && Date.now() - cached < cacheTTL) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentQuery = searchQuery;
|
||||
let disposed = false;
|
||||
|
||||
const handle = setTimeout(() => {
|
||||
void documents
|
||||
.searchTitles({ query: currentQuery })
|
||||
.then((res) => {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
searchCache.current.set(currentQuery, Date.now());
|
||||
feed(res.map((result) => toSearchRecord(result.document)));
|
||||
})
|
||||
.catch(() => {
|
||||
// Failing to enrich the index is not worth surfacing, local results
|
||||
// are still shown.
|
||||
});
|
||||
}, serverSearchDelay);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
clearTimeout(handle);
|
||||
};
|
||||
}, [documents, searchQuery, feed]);
|
||||
|
||||
const resultActions = React.useMemo(
|
||||
() =>
|
||||
results.map((result, index) =>
|
||||
createInternalLinkAction({
|
||||
id: `search-result-${result.document.id}`,
|
||||
name: result.document.title,
|
||||
description: result.context,
|
||||
keywords: searchQuery,
|
||||
analyticsName: "Open search result",
|
||||
section: SearchResultsSection,
|
||||
priority: toActionPriority(index, results.length),
|
||||
icon: <SearchResultIcon document={result.document} />,
|
||||
to: result.document.url,
|
||||
})
|
||||
),
|
||||
[results, searchQuery]
|
||||
);
|
||||
|
||||
// Enriching the index can change snippets without changing which documents
|
||||
// matched, so the key must cover contexts as well as ids.
|
||||
const resultsKey = React.useMemo(
|
||||
() => results.map((r) => `${r.document.id}:${r.context ?? ""}`).join(""),
|
||||
[results]
|
||||
);
|
||||
|
||||
useCommandBarActions(
|
||||
searchQuery
|
||||
? [...resultActions, searchDocumentsForQueryActionFactory(searchQuery)]
|
||||
: [],
|
||||
[resultsKey, searchQuery]
|
||||
);
|
||||
|
||||
useCommandBarActions(
|
||||
searches.recent.map(navigateToRecentSearchQueryActionFactory)
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default observer(SearchActions);
|
||||
@@ -95,7 +95,6 @@ describe("isSplittablePath", () => {
|
||||
expect(isSplittablePath("doc/my-doc")).toBe(false);
|
||||
expect(isSplittablePath("")).toBe(false);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe("focused split pane", () => {
|
||||
|
||||
@@ -140,6 +140,7 @@
|
||||
"framer-motion": "^6.5.1",
|
||||
"franc": "^6.2.0",
|
||||
"fs-extra": "^11.3.6",
|
||||
"fuse.js": "^7.5.0",
|
||||
"fuzzy-search": "^3.2.1",
|
||||
"glob": "^11.1.0",
|
||||
"hot-shots": "^12.1.0",
|
||||
|
||||
@@ -50,7 +50,7 @@ import type {
|
||||
SourceMetadata,
|
||||
NavigationNode,
|
||||
} from "@shared/types";
|
||||
import { CollectionPermission } from "@shared/types";
|
||||
import { CollectionPermission, NavigationNodeType } from "@shared/types";
|
||||
import { UrlHelper } from "@shared/utils/UrlHelper";
|
||||
import { sortNavigationNodes } from "@shared/utils/collections";
|
||||
import slugify from "@shared/utils/slugify";
|
||||
@@ -1117,6 +1117,7 @@ class Collection extends ParanoidModel<
|
||||
id: this.id,
|
||||
title: this.name,
|
||||
url: this.path,
|
||||
type: NavigationNodeType.Collection,
|
||||
icon: isNil(this.icon) ? undefined : this.icon,
|
||||
color: isNil(this.color) ? undefined : this.color,
|
||||
children: sortNavigationNodes(this.documentStructure ?? [], this.sort),
|
||||
|
||||
@@ -246,6 +246,7 @@
|
||||
"Are you sure about that? Deleting the <em>{{collectionName}}</em> collection is permanent and cannot be restored, however all published documents within will be moved to the trash.": "Are you sure about that? Deleting the <em>{{collectionName}}</em> collection is permanent and cannot be restored, however all published documents within will be moved to the trash.",
|
||||
"Also, <em>{{collectionName}}</em> is being used as the start view – deleting it will reset the start view to the Home page.": "Also, <em>{{collectionName}}</em> is being used as the start view – deleting it will reset the start view to the Home page.",
|
||||
"Type a command or search": "Type a command or search",
|
||||
"Untitled": "Untitled",
|
||||
"New from template": "New from template",
|
||||
"Choose a template": "Choose a template",
|
||||
"Are you sure you want to permanently delete this entire comment thread?": "Are you sure you want to permanently delete this entire comment thread?",
|
||||
@@ -263,7 +264,6 @@
|
||||
"Start view": "Start view",
|
||||
"Install now": "Install now",
|
||||
"Deleted Collection": "Deleted Collection",
|
||||
"Untitled": "Untitled",
|
||||
"Collection options": "Collection options",
|
||||
"Document options": "Document options",
|
||||
"Unpin": "Unpin",
|
||||
|
||||
+2
-1
@@ -37,7 +37,8 @@
|
||||
// Vite 8 and its plugins ship types only via their exports map, which
|
||||
// resolvePackageJsonExports disables; point tsc at their declaration entries.
|
||||
"vite": ["./node_modules/vite/dist/node/index.d.ts"],
|
||||
"@vitejs/plugin-react": ["./node_modules/@vitejs/plugin-react/dist/index.d.ts"]
|
||||
"@vitejs/plugin-react": ["./node_modules/@vitejs/plugin-react/dist/index.d.ts"],
|
||||
"fuse.js/worker": ["./node_modules/fuse.js/dist/fuse-worker.d.ts"]
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules", "build", "server/migrations"]
|
||||
|
||||
@@ -11678,6 +11678,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"fuse.js@npm:^7.5.0":
|
||||
version: 7.5.0
|
||||
resolution: "fuse.js@npm:7.5.0"
|
||||
checksum: 10c0/1fa51a66063b9b0579921ed1d774864e5e027719d3a4fdb469e6636befa4a9f2f7da626c07da757c6e1ef8a761c7bd3a217e6ecc8e16f0276e1a0f8dd941ff77
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"fuzzy-search@npm:^3.2.1":
|
||||
version: 3.2.1
|
||||
resolution: "fuzzy-search@npm:3.2.1"
|
||||
@@ -15546,6 +15553,7 @@ __metadata:
|
||||
framer-motion: "npm:^6.5.1"
|
||||
franc: "npm:^6.2.0"
|
||||
fs-extra: "npm:^11.3.6"
|
||||
fuse.js: "npm:^7.5.0"
|
||||
fuzzy-search: "npm:^3.2.1"
|
||||
glob: "npm:^11.1.0"
|
||||
hot-shots: "npm:^12.1.0"
|
||||
|
||||
Reference in New Issue
Block a user