fix(spotlight): stabilize catalog search

This commit is contained in:
ajnart committed 2026-08-24 09:15:50 +02:00
1 parent 389a99a203
commit 7acff56abb
21 files changed
+359 -239

No files matched your search

@@ -1,4 +1,4 @@
import { Center, Loader } from "@mantine/core";
import { Center } from "@mantine/core";
import { useI18n } from "@homarr/translation/client";
@@ -61,11 +61,7 @@ export const SpotlightGroupActions = <TOption extends Record<string, unknown>>({
}
if (options.isLoading) {
return (
<Center w="100%" py="sm" data-search-loading>
<Loader size="sm" />
</Center>
);
return <span hidden data-search-pending />;
}
if (options.isError) {
@@ -70,12 +70,8 @@
border-top: 0;
}
.actionsList [data-search-loading] ~ [data-search-loading] {
display: none;
}
.actionsList:has([data-action]) .emptyState,
.actionsList:has([data-search-loading]) .emptyState,
.actionsList:has([data-search-pending]) .emptyState,
.actionsList:has([data-search-status]) .emptyState {
display: none;
}
@@ -19,6 +19,7 @@ import {
import type { OpenMediaRequestSearchOptions, SpotlightMode, SpotlightOpenIntent } from "../open";
import type { inferSearchInteractionOptions } from "../lib/interaction";
import type { SearchMode } from "../lib/mode";
import { useSpotlightCatalogs } from "../lib/catalog";
import { searchModes } from "../modes";
import { mediaRequestSearchScopeAtom, selectAction, spotlightActions, spotlightStore } from "../spotlight-store";
import { SpotlightChildrenActions } from "./actions/children-actions";
@@ -36,6 +37,8 @@ const resetSelection = () => {
};
export const Spotlight = () => {
useSpotlightCatalogs();
const [mode, setMode] = useState<SpotlightMode>(defaultMode);
const [query, setQuery] = useState("");
const setMediaRequestSearchScope = useSetAtom(mediaRequestSearchScopeAtom);
+96
View File
@@ -0,0 +1,96 @@
"use client";
import { useEffect } from "react";
import { clientApi } from "@homarr/api/client";
import { queryCacheDefaultStaleTimeMs } from "@homarr/api/query-cache";
import { useSession } from "@homarr/auth/client";
import { spotlightOpenEvent } from "../open";
const catalogQueryOptions = (enabled = true) => ({
enabled,
refetchOnWindowFocus: false,
staleTime: queryCacheDefaultStaleTimeMs,
});
export const useAppsCatalogQuery = (enabled = true) => {
const { status } = useSession();
return clientApi.app.selectable.useQuery(undefined, catalogQueryOptions(enabled && status === "authenticated"));
};
export const useBoardsCatalogQuery = () => {
const { status } = useSession();
return clientApi.board.catalog.useQuery(undefined, catalogQueryOptions(status !== "loading"));
};
export const useIntegrationsCatalogQuery = (enabled = true) => {
const { status } = useSession();
return clientApi.integration.all.useQuery(undefined, catalogQueryOptions(enabled && status === "authenticated"));
};
export const useSearchEnginesCatalogQuery = () => {
const { status } = useSession();
return clientApi.searchEngine.catalog.useQuery(undefined, catalogQueryOptions(status !== "loading"));
};
export const useSpotlightCatalogs = () => {
const { data: session, status } = useSession();
const isAuthenticated = status === "authenticated";
const isSessionResolved = status !== "loading";
const canBrowseApps =
session?.user.permissions.includes("board-modify-all") === true ||
session?.user.permissions.includes("app-modify-all") === true;
const appsQuery = useAppsCatalogQuery(isAuthenticated && canBrowseApps);
const boardsQuery = useBoardsCatalogQuery();
const integrationsQuery = useIntegrationsCatalogQuery(isAuthenticated);
const searchEnginesQuery = useSearchEnginesCatalogQuery();
useEffect(() => {
const refreshCatalogs = () => {
if (canBrowseApps) void appsQuery.refetch();
if (isSessionResolved) void boardsQuery.refetch();
if (isAuthenticated) void integrationsQuery.refetch();
if (isSessionResolved) void searchEnginesQuery.refetch();
};
window.addEventListener(spotlightOpenEvent, refreshCatalogs);
return () => window.removeEventListener(spotlightOpenEvent, refreshCatalogs);
}, [
appsQuery,
boardsQuery,
canBrowseApps,
integrationsQuery,
isAuthenticated,
isSessionResolved,
searchEnginesQuery,
]);
};
export const filterCatalog = <TOption>(
options: TOption[],
query: string,
getSearchableValues: (option: TOption) => string[],
limit = 8,
) => {
const normalizedQuery = query.trim().toLowerCase();
if (normalizedQuery.length === 0) return options.slice(0, limit);
return options
.map((option, index) => {
const values = getSearchableValues(option).map((value) => value.toLowerCase());
const score = values.reduce((currentScore, value) => {
if (value === normalizedQuery) return Math.min(currentScore, 0);
if (value.startsWith(normalizedQuery)) return Math.min(currentScore, 1);
if (value.includes(normalizedQuery)) return Math.min(currentScore, 2);
return currentScore;
}, Number.POSITIVE_INFINITY);
return { option, index, score };
})
.filter(({ score }) => Number.isFinite(score))
.toSorted((first, second) => first.score - second.score || first.index - second.index)
.slice(0, limit)
.map(({ option }) => option);
};
@@ -1,13 +1,13 @@
import { Avatar, Group, Stack, Text } from "@mantine/core";
import { IconExternalLink, IconEye } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
import { createChildrenOptions } from "../../lib/children";
import { filterCatalog, useAppsCatalogQuery } from "../../lib/catalog";
import { createGroup } from "../../lib/group";
import { interaction } from "../../lib/interaction";
import { useRemoteQuery } from "../../lib/remote-query";
import { useSpotlightContextResults } from "../home/context";
// This has to be type so it can be interpreted as Record<string, unknown>.
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
@@ -96,7 +96,16 @@ export const appsSearchGroup = createGroup<App>({
),
useInteraction: interaction.children(appChildrenOptions),
useQueryOptions(query) {
const remoteQuery = useRemoteQuery(query, "apps");
return clientApi.app.search.useQuery({ query: remoteQuery.query, limit: 8 }, { enabled: remoteQuery.enabled });
const catalogQuery = useAppsCatalogQuery();
const localAppKeys = new Set(
useSpotlightContextResults().flatMap((item) => (item.dedupeKey ? [item.dedupeKey] : [])),
);
const apps = (catalogQuery.data ?? []).filter((app) => !localAppKeys.has(`app:${app.id}`));
return {
data: filterCatalog(apps, query, (app) => [app.name]),
isLoading: catalogQuery.isLoading,
isError: catalogQuery.isError,
};
},
});
@@ -1,14 +1,14 @@
import { Group, Stack, Text } from "@mantine/core";
import { IconDeviceMobile, IconHome, IconLayoutDashboard, IconLink, IconSettings } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
import type { ChildrenAction } from "../../lib/children";
import { filterCatalog, useBoardsCatalogQuery } from "../../lib/catalog";
import { createChildrenOptions } from "../../lib/children";
import { createGroup } from "../../lib/group";
import { interaction } from "../../lib/interaction";
import { useRemoteQuery } from "../../lib/remote-query";
import { clientApi } from "@homarr/api/client";
// This has to be type so it can be interpreted as Record<string, unknown>.
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
@@ -141,7 +141,11 @@ export const boardsSearchGroup = createGroup<Board>({
),
useInteraction: interaction.children(boardChildrenOptions),
useQueryOptions(query) {
const remoteQuery = useRemoteQuery(query, "boards");
return clientApi.board.search.useQuery({ query: remoteQuery.query, limit: 8 }, { enabled: remoteQuery.enabled });
const catalogQuery = useBoardsCatalogQuery();
return {
data: filterCatalog(catalogQuery.data ?? [], query, (board) => [board.name]),
isLoading: catalogQuery.isLoading,
isError: catalogQuery.isError,
};
},
});
@@ -2,7 +2,7 @@ import { useSession } from "@homarr/auth/client";
import type { SearchGroup } from "../../lib/group";
import type { SearchMode } from "../../lib/mode";
import { contextSpecificSearchGroups } from "../home/context-specific-group";
import { contextSpecificAppsSearchGroup } from "../home/context-specific-group";
import { appsSearchGroup } from "./apps-search-group";
import { boardsSearchGroup } from "./boards-search-group";
import { integrationsSearchGroup } from "./integrations-search-group";
@@ -14,7 +14,7 @@ export const appIntegrationBoardMode = {
placeholder: (t) => t("search.modePicker.apps.placeholder"),
useGroups() {
const { data: session } = useSession();
const groups: SearchGroup[] = [contextSpecificSearchGroups];
const groups: SearchGroup[] = [contextSpecificAppsSearchGroup];
if (!session?.user) {
return groups.concat(boardsSearchGroup);
@@ -1,12 +1,11 @@
import { Group, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import type { IntegrationKind } from "@homarr/definitions";
import { IntegrationAvatar } from "@homarr/ui";
import { filterCatalog, useIntegrationsCatalogQuery } from "../../lib/catalog";
import { createGroup } from "../../lib/group";
import { interaction } from "../../lib/interaction";
import { useRemoteQuery } from "../../lib/remote-query";
export const integrationsSearchGroup = createGroup<{ id: string; kind: IntegrationKind; name: string }>({
keyPath: "id",
@@ -21,10 +20,11 @@ export const integrationsSearchGroup = createGroup<{ id: string; kind: Integrati
),
useInteraction: interaction.link(({ id }) => ({ href: `/manage/integrations/edit/${id}` })),
useQueryOptions(query) {
const remoteQuery = useRemoteQuery(query, "integrations");
return clientApi.integration.search.useQuery(
{ query: remoteQuery.query, limit: 8 },
{ enabled: remoteQuery.enabled },
);
const catalogQuery = useIntegrationsCatalogQuery();
return {
data: filterCatalog(catalogQuery.data ?? [], query, (integration) => [integration.name, integration.kind]),
isLoading: catalogQuery.isLoading,
isError: catalogQuery.isError,
};
},
});
@@ -11,12 +11,13 @@ import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import { createChildrenOptions } from "../../lib/children";
import { filterCatalog, useSearchEnginesCatalogQuery } from "../../lib/catalog";
import { createGroup } from "../../lib/group";
import type { inferSearchInteractionDefinition } from "../../lib/interaction";
import { interaction } from "../../lib/interaction";
import { useRemoteQuery } from "../../lib/remote-query";
type SearchEngine = RouterOutputs["searchEngine"]["search"][number];
type SearchEngine = RouterOutputs["searchEngine"]["catalog"][number];
type FromIntegrationSearchResult = RouterOutputs["integration"]["searchInIntegration"][number];
type DuckDuckGoBang = RouterOutputs["bangs"]["search"][number];
@@ -287,7 +288,7 @@ const parseBangQuery = (query: string) => {
return { bangToken, searchText, locked };
};
const buildSearchUrl = (template: string, query: string) => {
export const buildSearchUrl = (template: string, query: string) => {
const encoded = encodeURIComponent(query);
if (template.includes("{{{s}}}")) {
return template.replaceAll("{{{s}}}", encoded);
@@ -416,23 +417,28 @@ const createSearchEnginesSearchGroup = ({ minimumLength, showEmptyHint, source }
const { bangToken, searchText, locked } = parseBangQuery(query);
const remoteQuery = useRemoteQuery(bangToken, "search-engines", { minimumLength });
const ddgQueryEnabled = remoteQuery.enabled && ddgBangs && remoteQuery.query.length >= 1;
const enginesQuery = clientApi.searchEngine.search.useQuery(
{ query: remoteQuery.query, limit: 10 },
{ enabled: remoteQuery.enabled },
);
const enginesQuery = useSearchEnginesCatalogQuery();
const ddgQuery = clientApi.bangs.search.useQuery(
{ query: remoteQuery.query, limit: 10 },
{
enabled: ddgQueryEnabled,
placeholderData: (previousData) => previousData,
},
);
const isLoading = (enginesQuery.isLoading && remoteQuery.enabled) || (ddgQuery.isLoading && ddgQueryEnabled);
const isError = (enginesQuery.isError && remoteQuery.enabled) || (ddgQuery.isError && ddgQueryEnabled);
const engineData = remoteQuery.enabled ? (enginesQuery.data ?? []) : [];
const ddgData = ddgQueryEnabled ? (ddgQuery.data ?? []) : [];
const engineCatalog = enginesQuery.data ?? [];
const engineData = filterCatalog(engineCatalog, bangToken, (engine) => [engine.short, engine.name], 10);
const ddgData = ddgQueryEnabled && bangToken.length > 0
? (ddgQuery.data ?? []).filter((bang) => bang.t.toLowerCase().startsWith(bangToken.toLowerCase()))
: [];
const isWaitingForDdgResults =
ddgBangs &&
bangToken.length > 0 &&
engineData.length === 0 &&
(remoteQuery.query !== bangToken || (ddgQueryEnabled && ddgQuery.isFetching && ddgData.length === 0));
const isLoading = enginesQuery.isLoading || isWaitingForDdgResults;
const isError = enginesQuery.isError || (ddgQuery.isError && ddgQueryEnabled && bangToken.length > 0);
const engineOptions = engineData.map(
(engine): ExternalOption => ({
@@ -454,7 +460,7 @@ const createSearchEnginesSearchGroup = ({ minimumLength, showEmptyHint, source }
const searchActions: ExternalOption[] = [];
if (locked && bangToken.length > 0) {
const matchedEngine = engineData.find((engine) => engine.short === bangToken);
const matchedEngine = engineCatalog.find((engine) => engine.short === bangToken);
const matchedDdg = ddgData.find((bang) => bang.t === bangToken);
const genericEngine = matchedEngine?.type === "generic" ? matchedEngine : undefined;
@@ -487,7 +493,7 @@ const createSearchEnginesSearchGroup = ({ minimumLength, showEmptyHint, source }
}
}
}
if (showEmptyHint && remoteQuery.query.length === 0) {
if (showEmptyHint && bangToken.length === 0) {
searchActions.push({
key: "hint",
kind: "hint",
@@ -509,9 +515,3 @@ export const searchEnginesSearchGroups = createSearchEnginesSearchGroup({
showEmptyHint: true,
source: "focused",
});
export const globalSearchEnginesSearchGroup = createSearchEnginesSearchGroup({
minimumLength: 1,
showEmptyHint: false,
source: "fallback",
});
@@ -62,6 +62,23 @@ export const contextSpecificSearchGroups = createGroup<ContextSpecificItem>({
},
});
export const contextSpecificAppsSearchGroup = createGroup<ContextSpecificItem>({
title: (t) => t("search.mode.home.group.local.title"),
keyPath: "id",
source: { kind: "local" },
Component: renderContextSpecificItem,
useInteraction(option, query) {
return option.interaction(query);
},
filter(query, option) {
return query.trim().length === 0 || shouldShowContextSpecificResult(query, option);
},
sort: localResultSort,
useOptions() {
return useSpotlightContextResults().filter((item) => item.placement !== "fallback");
},
});
export const contextSpecificFallbackSearchGroup = createGroup<ContextSpecificItem>({
title: (t) => t("search.modePicker.continueWith"),
keyPath: "id",
@@ -17,6 +17,7 @@ export type ContextSpecificItem = {
description?: string;
unavailable?: boolean;
alwaysVisible?: boolean;
dedupeKey?: string;
};
interface SpotlightContextProps {
@@ -1,20 +1,21 @@
import { Box, Group, Stack, Text } from "@mantine/core";
import { IconSearch, IconSearchOff } from "@tabler/icons-react";
import { IconSearchOff } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import type { Session } from "@homarr/auth";
import { useSession } from "@homarr/auth/client";
import { useSettings } from "@homarr/settings";
import type { TranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import { useSearchEnginesCatalogQuery } from "../../lib/catalog";
import { createGroup } from "../../lib/group";
import type { inferSearchInteractionDefinition, SearchInteraction } from "../../lib/interaction";
import { useRemoteQuery } from "../../lib/remote-query";
import { useFromIntegrationSearchInteraction } from "../external/search-engines-search-group";
import { buildSearchUrl } from "../external/search-engines-search-group";
type SearchEngine = RouterOutputs["searchEngine"]["catalog"][number];
// This has to be type so it can be interpreted as Record<string, unknown>.
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
type GroupItem = {
id: string;
@@ -22,7 +23,7 @@ type GroupItem = {
description?: string;
icon: TablerIcon | string;
disabled?: boolean;
useInteraction: (query: string) => inferSearchInteractionDefinition<SearchInteraction>;
interaction: inferSearchInteractionDefinition<SearchInteraction>;
};
export const homeSearchEngineGroup = createGroup<GroupItem>({
@@ -44,127 +45,95 @@ export const homeSearchEngineGroup = createGroup<GroupItem>({
{icon}
<Stack gap={0}>
<Text>{item.name}</Text>
{item.description && (
{item.description ? (
<Text c="gray.6" size="sm">
{item.description}
</Text>
)}
) : null}
</Stack>
</Group>
);
},
useInteraction(item, query) {
return item.useInteraction(query);
},
filter() {
return true;
useInteraction(item) {
return item.interaction;
},
useQueryOptions(query) {
const t = useI18n();
const { data: session, status } = useSession();
const remoteQuery = useRemoteQuery(query, "integration-search");
const { data: defaultSearchEngine, ...defaultSearchEngineQuery } =
clientApi.searchEngine.getDefaultSearchEngine.useQuery(undefined, {
enabled: status !== "loading" && remoteQuery.enabled,
});
const fromIntegrationEnabled =
defaultSearchEngine?.type === "fromIntegration" &&
defaultSearchEngine.integrationId !== null &&
remoteQuery.enabled;
const { data: results, ...resultQuery } = clientApi.integration.searchInIntegration.useQuery(
{
query: remoteQuery.query,
integrationId: defaultSearchEngine?.integrationId ?? "",
},
{
enabled: fromIntegrationEnabled,
select: (data) => data.slice(0, 5),
},
);
const { data: session } = useSession();
const { openSearchInNewTab } = useSettings();
const catalogQuery = useSearchEnginesCatalogQuery();
return {
isLoading:
defaultSearchEngineQuery.isLoading || (resultQuery.isLoading && fromIntegrationEnabled) || status === "loading",
isError:
(defaultSearchEngineQuery.isError && status !== "loading" && remoteQuery.enabled) ||
(resultQuery.isError && fromIntegrationEnabled),
data: remoteQuery.enabled ? createDefaultSearchEntries(defaultSearchEngine, results, session, query, t) : [],
isLoading: catalogQuery.isLoading,
isError: catalogQuery.isError,
data: createSearchEntries(
catalogQuery.data ?? [],
session?.user.permissions.includes("search-engine-create") === true,
query,
openSearchInNewTab,
t,
),
};
},
});
const createDefaultSearchEntries = (
defaultSearchEngine: RouterOutputs["searchEngine"]["getDefaultSearchEngine"] | null,
results: RouterOutputs["integration"]["searchInIntegration"] | undefined,
session: Session | null,
const createSearchEntries = (
searchEngines: SearchEngine[],
canCreateSearchEngine: boolean,
query: string,
openInNewTab: boolean,
t: TranslationFunction,
): GroupItem[] => {
if (!session?.user && !defaultSearchEngine) return [];
if (!defaultSearchEngine) {
if (searchEngines.length === 0) {
return [
{
id: "no-default",
name: t("search.mode.home.group.search.option.no-default.label"),
description: t("search.mode.home.group.search.option.no-default.description"),
id: "no-search-engines",
name: t("search.mode.home.group.search.option.no-engines.label"),
description: canCreateSearchEngine
? t("search.mode.home.group.search.option.no-engines.descriptionCreate")
: t("search.mode.home.group.search.option.no-engines.descriptionUnavailable"),
icon: IconSearchOff,
useInteraction() {
return {
type: "link",
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
href: `/manage/users/${session!.user.id}/general`,
};
},
disabled: !canCreateSearchEngine,
interaction: canCreateSearchEngine ? { type: "link", href: "/manage/search-engines/new" } : { type: "none" },
},
];
}
if (defaultSearchEngine.type === "generic") {
return [
{
id: "search",
name: t("search.mode.home.group.search.option.search.label", {
query,
name: defaultSearchEngine.name,
}),
icon: defaultSearchEngine.iconUrl,
useInteraction(query) {
const { openSearchInNewTab } = useSettings();
return {
type: "link",
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
href: defaultSearchEngine.urlTemplate!.replace("%s", query),
newTab: openSearchInNewTab,
};
},
},
];
}
const normalizedQuery = query.trim();
return searchEngines.map((searchEngine) => {
const name =
normalizedQuery.length === 0
? searchEngine.name
: t("search.mode.home.group.search.option.search.label", {
query: normalizedQuery,
name: searchEngine.name,
});
if (!results) {
return [
{
id: "from-integration",
name: defaultSearchEngine.name,
icon: defaultSearchEngine.iconUrl,
description: t("search.mode.home.group.search.option.from-integration.description"),
useInteraction() {
return {
type: "none",
};
if (searchEngine.type === "generic") {
return {
id: `search-${searchEngine.id}`,
name,
description: searchEngine.description ?? undefined,
icon: searchEngine.iconUrl,
interaction: {
type: "link",
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
href: buildSearchUrl(searchEngine.urlTemplate!, normalizedQuery),
newTab: openInNewTab,
},
},
];
}
};
}
return results.map((result) => ({
id: `search-${result.id}`,
name: result.name,
description: result.text,
icon: result.image ?? IconSearch,
useInteraction() {
return useFromIntegrationSearchInteraction(defaultSearchEngine, result);
},
}));
return {
id: `search-${searchEngine.id}`,
name,
description: searchEngine.description ?? undefined,
icon: searchEngine.iconUrl,
interaction: {
type: "mode",
mode: "external",
query: `${searchEngine.short} ${normalizedQuery}`.trimEnd(),
},
};
});
};
+6 -4
View File
@@ -2,9 +2,12 @@ import type { SearchMode } from "../../lib/mode";
import { appIntegrationBoardMode } from "../app-integration-board";
import { contextSpecificActionsSearchGroups } from "../command/context-specific-group";
import { globalCommandGroup } from "../command/global-group";
import { globalSearchEnginesSearchGroup } from "../external/search-engines-search-group";
import { homeSearchEngineGroup } from "./home-search-engine-group";
import { contextSpecificFallbackSearchGroup, contextSpecificSearchGroups } from "./context-specific-group";
import {
contextSpecificAppsSearchGroup,
contextSpecificFallbackSearchGroup,
contextSpecificSearchGroups,
} from "./context-specific-group";
import { mediaFallbackGroup } from "../media/media-fallback-group";
import { pagesSearchGroup } from "../page/pages-search-group";
import { preferencesGroup } from "../preferences/groups";
@@ -16,7 +19,7 @@ export const homeMode = {
label: (t) => t("search.modePicker.search.label"),
placeholder: (t) => t("search.modePicker.search.placeholder"),
useGroups() {
const appGroups = appIntegrationBoardMode.useGroups().filter((group) => group !== contextSpecificSearchGroups);
const appGroups = appIntegrationBoardMode.useGroups().filter((group) => group !== contextSpecificAppsSearchGroup);
const directoryGroups = userGroupMode.useGroups();
return [
@@ -27,7 +30,6 @@ export const homeMode = {
preferencesGroup,
...appGroups,
...directoryGroups,
globalSearchEnginesSearchGroup,
mediaFallbackGroup,
homeSearchEngineGroup,
contextSpecificFallbackSearchGroup,