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

@@ -23,8 +23,9 @@ Homarr has the following keyboard shortcuts:
Search is designed as the fastest route to Homarr. Start typing after `MOD` + `K` to find local apps, boards, Search is designed as the fastest route to Homarr. Start typing after `MOD` + `K` to find local apps, boards,
integrations, pages, commands, and preferences without entering a separate mode first. Local Homarr results appear integrations, pages, commands, and preferences without entering a separate mode first. Local Homarr results appear
before external search and Assistant fallbacks. Commands include opening the board switcher without leaving the before configured search-engine actions and Assistant fallbacks. Commands include opening the board switcher without
keyboard. leaving the keyboard. Type `!` to enter Web mode when you want to browse configured engine shortcuts or discover
DuckDuckGo bangs; bang discovery is excluded from ordinary Search results.
Use the dedicated shortcuts above to start in apps, Assistant, or preferences. Inside search, choose a mode to narrow Use the dedicated shortcuts above to start in apps, Assistant, or preferences. Inside search, choose a mode to narrow
the same query when needed. Media request search is available only when a supported Seerr integration is configured. the same query when needed. Media request search is available only when a supported Seerr integration is configured.
@@ -7,9 +7,13 @@ tags:
# Search Engines # Search Engines
Using the search engines, terms can be quickly searched simply by opening the search bar at the top or using the keyboard shortcut `CTRL + K`. Using the search engines, terms can be quickly searched simply by opening the search bar at the top or using the keyboard shortcut `MOD + K`.
The management page provides a searchable list with the shortcut, URL template, source, and available row actions for each engine. The management page provides a searchable list with the shortcut, URL template, source, and available row actions for each engine.
The default **Search** mode lists every configured search engine as a fallback action for the current query. To discover
engines by shortcut, switch to **Web** with `!`. DuckDuckGo bang discovery is intentionally limited to Web mode so an
ordinary search term never expands into similarly named bang providers.
You can add any search engine that has a search URL that accepts a search term as a parameter. You can add any search engine that has a search URL that accepts a search term as a parameter.
## Create search engine ## Create search engine
@@ -26,6 +30,8 @@ The name can be chosen freely. It will be displayed in the search box popover.
Prefix that is used to choose the search engine in the popover using the keyboard. Prefix that is used to choose the search engine in the popover using the keyboard.
It is recommended that this value is kept short (for example `g` for Google and `e` for Ecosia). It is recommended that this value is kept short (for example `g` for Google and `e` for Ecosia).
Type `!` in Spotlight to browse these shortcuts and, when DuckDuckGo bangs are enabled in preferences, discover
additional bang providers.
### Configuration ### Configuration
@@ -48,6 +48,7 @@ export const BoardAppsSpotlightRegistrar = () => {
return [ return [
{ {
id: app.id, id: app.id,
dedupeKey: `app:${app.id}`,
name: app.name, name: app.name,
icon: app.iconUrl, icon: app.iconUrl,
interaction() { interaction() {
@@ -57,12 +57,8 @@ export const LazySpotlight = () => {
return isMounted || isPreloaded ? ( return isMounted || isPreloaded ? (
<> <>
{isPreloaded && <span hidden data-homarr-dev-benchmark-spotlight-preloaded />} {isPreloaded && <span hidden data-homarr-dev-benchmark-spotlight-preloaded />}
{isMounted && ( {isMounted && <span hidden data-homarr-dev-benchmark-spotlight-mounted />}
<> <Spotlight />
<span hidden data-homarr-dev-benchmark-spotlight-mounted />
<Spotlight />
</>
)}
</> </>
) : null; ) : null;
}; };
+81 -72
View File
@@ -2,6 +2,7 @@ import { TRPCError } from "@trpc/server";
import superjson from "superjson"; import superjson from "superjson";
import { z } from "zod/v4"; import { z } from "zod/v4";
import type { Session } from "@homarr/auth";
import { constructBoardPermissions } from "@homarr/auth/shared"; import { constructBoardPermissions } from "@homarr/auth/shared";
import { createId, generateResponsiveGridFor } from "@homarr/common"; import { createId, generateResponsiveGridFor } from "@homarr/common";
import type { GridAlgorithmItem } from "@homarr/common"; import type { GridAlgorithmItem } from "@homarr/common";
@@ -123,6 +124,84 @@ const findFirstAvailableBoardItemPosition = (
return null; return null;
}; };
const searchAccessibleBoardsAsync = async (
ctx: { db: Database; session: Session | null },
query: string,
limit?: number,
) => {
const userId = ctx.session?.user.id;
const permissionsOfCurrentUserWhenPresent = await ctx.db.query.boardUserPermissions.findMany({
where: eq(boardUserPermissions.userId, userId ?? ""),
});
const permissionsOfCurrentUserGroupsWhenPresent = await ctx.db.query.groupMembers.findMany({
where: eq(groupMembers.userId, userId ?? ""),
with: {
group: {
with: {
boardPermissions: {},
},
},
},
});
const boardIds = permissionsOfCurrentUserWhenPresent
.map((permission) => permission.boardId)
.concat(
permissionsOfCurrentUserGroupsWhenPresent
.map((groupMember) => groupMember.group.boardPermissions.map((permission) => permission.boardId))
.flat(),
);
const currentUserWhenPresent = await ctx.db.query.users.findFirst({
where: eq(users.id, userId ?? ""),
});
const foundBoards = await ctx.db.query.boards.findMany({
where: and(
like(boards.name, `%${query}%`),
ctx.session?.user.permissions.includes("board-view-all")
? undefined
: or(
eq(boards.isPublic, true),
eq(boards.creatorId, ctx.session?.user.id ?? ""),
inArray(boards.id, boardIds),
),
),
limit,
orderBy: asc(boards.name),
columns: {
id: true,
name: true,
creatorId: true,
isPublic: true,
logoImageUrl: true,
},
with: {
userPermissions: {
where: eq(boardUserPermissions.userId, ctx.session?.user.id ?? ""),
},
groupPermissions: {
where:
permissionsOfCurrentUserGroupsWhenPresent.length >= 1
? inArray(
boardGroupPermissions.groupId,
permissionsOfCurrentUserGroupsWhenPresent.map((groupMember) => groupMember.groupId),
)
: undefined,
},
},
});
return foundBoards.map((board) => ({
id: board.id,
name: board.name,
logoImageUrl: board.logoImageUrl,
permissions: constructBoardPermissions(board, ctx.session),
isHome: currentUserWhenPresent?.homeBoardId === board.id,
isMobileHome: currentUserWhenPresent?.mobileHomeBoardId === board.id,
}));
};
export const boardRouter = createTRPCRouter({ export const boardRouter = createTRPCRouter({
exists: permissionRequiredProcedure exists: permissionRequiredProcedure
.requiresPermission("board-create") .requiresPermission("board-create")
@@ -482,80 +561,10 @@ export const boardRouter = createTRPCRouter({
}; };
}); });
}), }),
catalog: publicProcedure.query(async ({ ctx }) => searchAccessibleBoardsAsync(ctx, "")),
search: publicProcedure search: publicProcedure
.input(z.object({ query: z.string(), limit: z.number().min(1).max(100).default(10) })) .input(z.object({ query: z.string(), limit: z.number().min(1).max(100).default(10) }))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => searchAccessibleBoardsAsync(ctx, input.query, input.limit)),
const userId = ctx.session?.user.id;
const permissionsOfCurrentUserWhenPresent = await ctx.db.query.boardUserPermissions.findMany({
where: eq(boardUserPermissions.userId, userId ?? ""),
});
const permissionsOfCurrentUserGroupsWhenPresent = await ctx.db.query.groupMembers.findMany({
where: eq(groupMembers.userId, userId ?? ""),
with: {
group: {
with: {
boardPermissions: {},
},
},
},
});
const boardIds = permissionsOfCurrentUserWhenPresent
.map((permission) => permission.boardId)
.concat(
permissionsOfCurrentUserGroupsWhenPresent
.map((groupMember) => groupMember.group.boardPermissions.map((permission) => permission.boardId))
.flat(),
);
const currentUserWhenPresent = await ctx.db.query.users.findFirst({
where: eq(users.id, userId ?? ""),
});
const foundBoards = await ctx.db.query.boards.findMany({
where: and(
like(boards.name, `%${input.query}%`),
ctx.session?.user.permissions.includes("board-view-all")
? undefined
: or(
eq(boards.isPublic, true),
eq(boards.creatorId, ctx.session?.user.id ?? ""),
inArray(boards.id, boardIds),
),
),
limit: input.limit,
columns: {
id: true,
name: true,
creatorId: true,
isPublic: true,
logoImageUrl: true,
},
with: {
userPermissions: {
where: eq(boardUserPermissions.userId, ctx.session?.user.id ?? ""),
},
groupPermissions: {
where:
permissionsOfCurrentUserGroupsWhenPresent.length >= 1
? inArray(
boardGroupPermissions.groupId,
permissionsOfCurrentUserGroupsWhenPresent.map((groupMember) => groupMember.groupId),
)
: undefined,
},
},
});
return foundBoards.map((board) => ({
id: board.id,
name: board.name,
logoImageUrl: board.logoImageUrl,
permissions: constructBoardPermissions(board, ctx.session),
isHome: currentUserWhenPresent?.homeBoardId === board.id,
isMobileHome: currentUserWhenPresent?.mobileHomeBoardId === board.id,
}));
}),
createBoard: permissionRequiredProcedure createBoard: permissionRequiredProcedure
.requiresPermission("board-create") .requiresPermission("board-create")
.meta({ .meta({
@@ -130,6 +130,21 @@ export const searchEngineRouter = createTRPCRouter({
return null; return null;
}), }),
catalog: publicProcedure.query(async ({ ctx }) => {
return await ctx.db.query.searchEngines.findMany({
where: ctx.session?.user ? undefined : eq(searchEngines.type, "generic"),
orderBy: asc(searchEngines.name),
with: {
integration: {
columns: {
kind: true,
url: true,
id: true,
},
},
},
});
}),
search: publicProcedure.input(searchSchema).query(async ({ ctx, input }) => { search: publicProcedure.input(searchSchema).query(async ({ ctx, input }) => {
return await ctx.db.query.searchEngines.findMany({ return await ctx.db.query.searchEngines.findMany({
// Public dashboards have no session: restrict anonymous users to generic // Public dashboards have no session: restrict anonymous users to generic
@@ -1,4 +1,4 @@
import { Center, Loader } from "@mantine/core"; import { Center } from "@mantine/core";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
@@ -61,11 +61,7 @@ export const SpotlightGroupActions = <TOption extends Record<string, unknown>>({
} }
if (options.isLoading) { if (options.isLoading) {
return ( return <span hidden data-search-pending />;
<Center w="100%" py="sm" data-search-loading>
<Loader size="sm" />
</Center>
);
} }
if (options.isError) { if (options.isError) {
@@ -70,12 +70,8 @@
border-top: 0; border-top: 0;
} }
.actionsList [data-search-loading] ~ [data-search-loading] {
display: none;
}
.actionsList:has([data-action]) .emptyState, .actionsList:has([data-action]) .emptyState,
.actionsList:has([data-search-loading]) .emptyState, .actionsList:has([data-search-pending]) .emptyState,
.actionsList:has([data-search-status]) .emptyState { .actionsList:has([data-search-status]) .emptyState {
display: none; display: none;
} }
@@ -19,6 +19,7 @@ import {
import type { OpenMediaRequestSearchOptions, SpotlightMode, SpotlightOpenIntent } from "../open"; import type { OpenMediaRequestSearchOptions, SpotlightMode, SpotlightOpenIntent } from "../open";
import type { inferSearchInteractionOptions } from "../lib/interaction"; import type { inferSearchInteractionOptions } from "../lib/interaction";
import type { SearchMode } from "../lib/mode"; import type { SearchMode } from "../lib/mode";
import { useSpotlightCatalogs } from "../lib/catalog";
import { searchModes } from "../modes"; import { searchModes } from "../modes";
import { mediaRequestSearchScopeAtom, selectAction, spotlightActions, spotlightStore } from "../spotlight-store"; import { mediaRequestSearchScopeAtom, selectAction, spotlightActions, spotlightStore } from "../spotlight-store";
import { SpotlightChildrenActions } from "./actions/children-actions"; import { SpotlightChildrenActions } from "./actions/children-actions";
@@ -36,6 +37,8 @@ const resetSelection = () => {
}; };
export const Spotlight = () => { export const Spotlight = () => {
useSpotlightCatalogs();
const [mode, setMode] = useState<SpotlightMode>(defaultMode); const [mode, setMode] = useState<SpotlightMode>(defaultMode);
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const setMediaRequestSearchScope = useSetAtom(mediaRequestSearchScopeAtom); 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 { Avatar, Group, Stack, Text } from "@mantine/core";
import { IconExternalLink, IconEye } from "@tabler/icons-react"; import { IconExternalLink, IconEye } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { createChildrenOptions } from "../../lib/children"; import { createChildrenOptions } from "../../lib/children";
import { filterCatalog, useAppsCatalogQuery } from "../../lib/catalog";
import { createGroup } from "../../lib/group"; import { createGroup } from "../../lib/group";
import { interaction } from "../../lib/interaction"; 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>. // This has to be type so it can be interpreted as Record<string, unknown>.
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions // eslint-disable-next-line @typescript-eslint/consistent-type-definitions
@@ -96,7 +96,16 @@ export const appsSearchGroup = createGroup<App>({
), ),
useInteraction: interaction.children(appChildrenOptions), useInteraction: interaction.children(appChildrenOptions),
useQueryOptions(query) { useQueryOptions(query) {
const remoteQuery = useRemoteQuery(query, "apps"); const catalogQuery = useAppsCatalogQuery();
return clientApi.app.search.useQuery({ query: remoteQuery.query, limit: 8 }, { enabled: remoteQuery.enabled }); 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 { Group, Stack, Text } from "@mantine/core";
import { IconDeviceMobile, IconHome, IconLayoutDashboard, IconLink, IconSettings } from "@tabler/icons-react"; import { IconDeviceMobile, IconHome, IconLayoutDashboard, IconLink, IconSettings } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import type { ChildrenAction } from "../../lib/children"; import type { ChildrenAction } from "../../lib/children";
import { filterCatalog, useBoardsCatalogQuery } from "../../lib/catalog";
import { createChildrenOptions } from "../../lib/children"; import { createChildrenOptions } from "../../lib/children";
import { createGroup } from "../../lib/group"; import { createGroup } from "../../lib/group";
import { interaction } from "../../lib/interaction"; 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>. // This has to be type so it can be interpreted as Record<string, unknown>.
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions // eslint-disable-next-line @typescript-eslint/consistent-type-definitions
@@ -141,7 +141,11 @@ export const boardsSearchGroup = createGroup<Board>({
), ),
useInteraction: interaction.children(boardChildrenOptions), useInteraction: interaction.children(boardChildrenOptions),
useQueryOptions(query) { useQueryOptions(query) {
const remoteQuery = useRemoteQuery(query, "boards"); const catalogQuery = useBoardsCatalogQuery();
return clientApi.board.search.useQuery({ query: remoteQuery.query, limit: 8 }, { enabled: remoteQuery.enabled }); 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 { SearchGroup } from "../../lib/group";
import type { SearchMode } from "../../lib/mode"; 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 { appsSearchGroup } from "./apps-search-group";
import { boardsSearchGroup } from "./boards-search-group"; import { boardsSearchGroup } from "./boards-search-group";
import { integrationsSearchGroup } from "./integrations-search-group"; import { integrationsSearchGroup } from "./integrations-search-group";
@@ -14,7 +14,7 @@ export const appIntegrationBoardMode = {
placeholder: (t) => t("search.modePicker.apps.placeholder"), placeholder: (t) => t("search.modePicker.apps.placeholder"),
useGroups() { useGroups() {
const { data: session } = useSession(); const { data: session } = useSession();
const groups: SearchGroup[] = [contextSpecificSearchGroups]; const groups: SearchGroup[] = [contextSpecificAppsSearchGroup];
if (!session?.user) { if (!session?.user) {
return groups.concat(boardsSearchGroup); return groups.concat(boardsSearchGroup);
@@ -1,12 +1,11 @@
import { Group, Text } from "@mantine/core"; import { Group, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import type { IntegrationKind } from "@homarr/definitions"; import type { IntegrationKind } from "@homarr/definitions";
import { IntegrationAvatar } from "@homarr/ui"; import { IntegrationAvatar } from "@homarr/ui";
import { filterCatalog, useIntegrationsCatalogQuery } from "../../lib/catalog";
import { createGroup } from "../../lib/group"; import { createGroup } from "../../lib/group";
import { interaction } from "../../lib/interaction"; import { interaction } from "../../lib/interaction";
import { useRemoteQuery } from "../../lib/remote-query";
export const integrationsSearchGroup = createGroup<{ id: string; kind: IntegrationKind; name: string }>({ export const integrationsSearchGroup = createGroup<{ id: string; kind: IntegrationKind; name: string }>({
keyPath: "id", keyPath: "id",
@@ -21,10 +20,11 @@ export const integrationsSearchGroup = createGroup<{ id: string; kind: Integrati
), ),
useInteraction: interaction.link(({ id }) => ({ href: `/manage/integrations/edit/${id}` })), useInteraction: interaction.link(({ id }) => ({ href: `/manage/integrations/edit/${id}` })),
useQueryOptions(query) { useQueryOptions(query) {
const remoteQuery = useRemoteQuery(query, "integrations"); const catalogQuery = useIntegrationsCatalogQuery();
return clientApi.integration.search.useQuery( return {
{ query: remoteQuery.query, limit: 8 }, data: filterCatalog(catalogQuery.data ?? [], query, (integration) => [integration.name, integration.kind]),
{ enabled: remoteQuery.enabled }, isLoading: catalogQuery.isLoading,
); isError: catalogQuery.isError,
};
}, },
}); });
@@ -11,12 +11,13 @@ import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { createChildrenOptions } from "../../lib/children"; import { createChildrenOptions } from "../../lib/children";
import { filterCatalog, useSearchEnginesCatalogQuery } from "../../lib/catalog";
import { createGroup } from "../../lib/group"; import { createGroup } from "../../lib/group";
import type { inferSearchInteractionDefinition } from "../../lib/interaction"; import type { inferSearchInteractionDefinition } from "../../lib/interaction";
import { interaction } from "../../lib/interaction"; import { interaction } from "../../lib/interaction";
import { useRemoteQuery } from "../../lib/remote-query"; 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 FromIntegrationSearchResult = RouterOutputs["integration"]["searchInIntegration"][number];
type DuckDuckGoBang = RouterOutputs["bangs"]["search"][number]; type DuckDuckGoBang = RouterOutputs["bangs"]["search"][number];
@@ -287,7 +288,7 @@ const parseBangQuery = (query: string) => {
return { bangToken, searchText, locked }; return { bangToken, searchText, locked };
}; };
const buildSearchUrl = (template: string, query: string) => { export const buildSearchUrl = (template: string, query: string) => {
const encoded = encodeURIComponent(query); const encoded = encodeURIComponent(query);
if (template.includes("{{{s}}}")) { if (template.includes("{{{s}}}")) {
return template.replaceAll("{{{s}}}", encoded); return template.replaceAll("{{{s}}}", encoded);
@@ -416,23 +417,28 @@ const createSearchEnginesSearchGroup = ({ minimumLength, showEmptyHint, source }
const { bangToken, searchText, locked } = parseBangQuery(query); const { bangToken, searchText, locked } = parseBangQuery(query);
const remoteQuery = useRemoteQuery(bangToken, "search-engines", { minimumLength }); const remoteQuery = useRemoteQuery(bangToken, "search-engines", { minimumLength });
const ddgQueryEnabled = remoteQuery.enabled && ddgBangs && remoteQuery.query.length >= 1; const ddgQueryEnabled = remoteQuery.enabled && ddgBangs && remoteQuery.query.length >= 1;
const enginesQuery = useSearchEnginesCatalogQuery();
const enginesQuery = clientApi.searchEngine.search.useQuery(
{ query: remoteQuery.query, limit: 10 },
{ enabled: remoteQuery.enabled },
);
const ddgQuery = clientApi.bangs.search.useQuery( const ddgQuery = clientApi.bangs.search.useQuery(
{ query: remoteQuery.query, limit: 10 }, { query: remoteQuery.query, limit: 10 },
{ {
enabled: ddgQueryEnabled, enabled: ddgQueryEnabled,
placeholderData: (previousData) => previousData,
}, },
); );
const isLoading = (enginesQuery.isLoading && remoteQuery.enabled) || (ddgQuery.isLoading && ddgQueryEnabled); const engineCatalog = enginesQuery.data ?? [];
const isError = (enginesQuery.isError && remoteQuery.enabled) || (ddgQuery.isError && ddgQueryEnabled); const engineData = filterCatalog(engineCatalog, bangToken, (engine) => [engine.short, engine.name], 10);
const engineData = remoteQuery.enabled ? (enginesQuery.data ?? []) : []; const ddgData = ddgQueryEnabled && bangToken.length > 0
const ddgData = ddgQueryEnabled ? (ddgQuery.data ?? []) : []; ? (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( const engineOptions = engineData.map(
(engine): ExternalOption => ({ (engine): ExternalOption => ({
@@ -454,7 +460,7 @@ const createSearchEnginesSearchGroup = ({ minimumLength, showEmptyHint, source }
const searchActions: ExternalOption[] = []; const searchActions: ExternalOption[] = [];
if (locked && bangToken.length > 0) { 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 matchedDdg = ddgData.find((bang) => bang.t === bangToken);
const genericEngine = matchedEngine?.type === "generic" ? matchedEngine : undefined; 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({ searchActions.push({
key: "hint", key: "hint",
kind: "hint", kind: "hint",
@@ -509,9 +515,3 @@ export const searchEnginesSearchGroups = createSearchEnginesSearchGroup({
showEmptyHint: true, showEmptyHint: true,
source: "focused", 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>({ export const contextSpecificFallbackSearchGroup = createGroup<ContextSpecificItem>({
title: (t) => t("search.modePicker.continueWith"), title: (t) => t("search.modePicker.continueWith"),
keyPath: "id", keyPath: "id",
@@ -17,6 +17,7 @@ export type ContextSpecificItem = {
description?: string; description?: string;
unavailable?: boolean; unavailable?: boolean;
alwaysVisible?: boolean; alwaysVisible?: boolean;
dedupeKey?: string;
}; };
interface SpotlightContextProps { interface SpotlightContextProps {
@@ -1,20 +1,21 @@
import { Box, Group, Stack, Text } from "@mantine/core"; 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 type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import type { Session } from "@homarr/auth";
import { useSession } from "@homarr/auth/client"; import { useSession } from "@homarr/auth/client";
import { useSettings } from "@homarr/settings"; import { useSettings } from "@homarr/settings";
import type { TranslationFunction } from "@homarr/translation"; import type { TranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui"; import type { TablerIcon } from "@homarr/ui";
import { useSearchEnginesCatalogQuery } from "../../lib/catalog";
import { createGroup } from "../../lib/group"; import { createGroup } from "../../lib/group";
import type { inferSearchInteractionDefinition, SearchInteraction } from "../../lib/interaction"; import type { inferSearchInteractionDefinition, SearchInteraction } from "../../lib/interaction";
import { useRemoteQuery } from "../../lib/remote-query"; import { buildSearchUrl } from "../external/search-engines-search-group";
import { useFromIntegrationSearchInteraction } 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 // eslint-disable-next-line @typescript-eslint/consistent-type-definitions
type GroupItem = { type GroupItem = {
id: string; id: string;
@@ -22,7 +23,7 @@ type GroupItem = {
description?: string; description?: string;
icon: TablerIcon | string; icon: TablerIcon | string;
disabled?: boolean; disabled?: boolean;
useInteraction: (query: string) => inferSearchInteractionDefinition<SearchInteraction>; interaction: inferSearchInteractionDefinition<SearchInteraction>;
}; };
export const homeSearchEngineGroup = createGroup<GroupItem>({ export const homeSearchEngineGroup = createGroup<GroupItem>({
@@ -44,127 +45,95 @@ export const homeSearchEngineGroup = createGroup<GroupItem>({
{icon} {icon}
<Stack gap={0}> <Stack gap={0}>
<Text>{item.name}</Text> <Text>{item.name}</Text>
{item.description && ( {item.description ? (
<Text c="gray.6" size="sm"> <Text c="gray.6" size="sm">
{item.description} {item.description}
</Text> </Text>
)} ) : null}
</Stack> </Stack>
</Group> </Group>
); );
}, },
useInteraction(item, query) { useInteraction(item) {
return item.useInteraction(query); return item.interaction;
},
filter() {
return true;
}, },
useQueryOptions(query) { useQueryOptions(query) {
const t = useI18n(); const t = useI18n();
const { data: session, status } = useSession(); const { data: session } = useSession();
const remoteQuery = useRemoteQuery(query, "integration-search"); const { openSearchInNewTab } = useSettings();
const { data: defaultSearchEngine, ...defaultSearchEngineQuery } = const catalogQuery = useSearchEnginesCatalogQuery();
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),
},
);
return { return {
isLoading: isLoading: catalogQuery.isLoading,
defaultSearchEngineQuery.isLoading || (resultQuery.isLoading && fromIntegrationEnabled) || status === "loading", isError: catalogQuery.isError,
isError: data: createSearchEntries(
(defaultSearchEngineQuery.isError && status !== "loading" && remoteQuery.enabled) || catalogQuery.data ?? [],
(resultQuery.isError && fromIntegrationEnabled), session?.user.permissions.includes("search-engine-create") === true,
data: remoteQuery.enabled ? createDefaultSearchEntries(defaultSearchEngine, results, session, query, t) : [], query,
openSearchInNewTab,
t,
),
}; };
}, },
}); });
const createDefaultSearchEntries = ( const createSearchEntries = (
defaultSearchEngine: RouterOutputs["searchEngine"]["getDefaultSearchEngine"] | null, searchEngines: SearchEngine[],
results: RouterOutputs["integration"]["searchInIntegration"] | undefined, canCreateSearchEngine: boolean,
session: Session | null,
query: string, query: string,
openInNewTab: boolean,
t: TranslationFunction, t: TranslationFunction,
): GroupItem[] => { ): GroupItem[] => {
if (!session?.user && !defaultSearchEngine) return []; if (searchEngines.length === 0) {
if (!defaultSearchEngine) {
return [ return [
{ {
id: "no-default", id: "no-search-engines",
name: t("search.mode.home.group.search.option.no-default.label"), name: t("search.mode.home.group.search.option.no-engines.label"),
description: t("search.mode.home.group.search.option.no-default.description"), description: canCreateSearchEngine
? t("search.mode.home.group.search.option.no-engines.descriptionCreate")
: t("search.mode.home.group.search.option.no-engines.descriptionUnavailable"),
icon: IconSearchOff, icon: IconSearchOff,
useInteraction() { disabled: !canCreateSearchEngine,
return { interaction: canCreateSearchEngine ? { type: "link", href: "/manage/search-engines/new" } : { type: "none" },
type: "link",
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
href: `/manage/users/${session!.user.id}/general`,
};
},
}, },
]; ];
} }
if (defaultSearchEngine.type === "generic") { const normalizedQuery = query.trim();
return [ return searchEngines.map((searchEngine) => {
{ const name =
id: "search", normalizedQuery.length === 0
name: t("search.mode.home.group.search.option.search.label", { ? searchEngine.name
query, : t("search.mode.home.group.search.option.search.label", {
name: defaultSearchEngine.name, query: normalizedQuery,
}), name: searchEngine.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,
};
},
},
];
}
if (!results) { if (searchEngine.type === "generic") {
return [ return {
{ id: `search-${searchEngine.id}`,
id: "from-integration", name,
name: defaultSearchEngine.name, description: searchEngine.description ?? undefined,
icon: defaultSearchEngine.iconUrl, icon: searchEngine.iconUrl,
description: t("search.mode.home.group.search.option.from-integration.description"), interaction: {
useInteraction() { type: "link",
return { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
type: "none", href: buildSearchUrl(searchEngine.urlTemplate!, normalizedQuery),
}; newTab: openInNewTab,
}, },
}, };
]; }
}
return results.map((result) => ({ return {
id: `search-${result.id}`, id: `search-${searchEngine.id}`,
name: result.name, name,
description: result.text, description: searchEngine.description ?? undefined,
icon: result.image ?? IconSearch, icon: searchEngine.iconUrl,
useInteraction() { interaction: {
return useFromIntegrationSearchInteraction(defaultSearchEngine, result); 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 { appIntegrationBoardMode } from "../app-integration-board";
import { contextSpecificActionsSearchGroups } from "../command/context-specific-group"; import { contextSpecificActionsSearchGroups } from "../command/context-specific-group";
import { globalCommandGroup } from "../command/global-group"; import { globalCommandGroup } from "../command/global-group";
import { globalSearchEnginesSearchGroup } from "../external/search-engines-search-group";
import { homeSearchEngineGroup } from "./home-search-engine-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 { mediaFallbackGroup } from "../media/media-fallback-group";
import { pagesSearchGroup } from "../page/pages-search-group"; import { pagesSearchGroup } from "../page/pages-search-group";
import { preferencesGroup } from "../preferences/groups"; import { preferencesGroup } from "../preferences/groups";
@@ -16,7 +19,7 @@ export const homeMode = {
label: (t) => t("search.modePicker.search.label"), label: (t) => t("search.modePicker.search.label"),
placeholder: (t) => t("search.modePicker.search.placeholder"), placeholder: (t) => t("search.modePicker.search.placeholder"),
useGroups() { useGroups() {
const appGroups = appIntegrationBoardMode.useGroups().filter((group) => group !== contextSpecificSearchGroups); const appGroups = appIntegrationBoardMode.useGroups().filter((group) => group !== contextSpecificAppsSearchGroup);
const directoryGroups = userGroupMode.useGroups(); const directoryGroups = userGroupMode.useGroups();
return [ return [
@@ -27,7 +30,6 @@ export const homeMode = {
preferencesGroup, preferencesGroup,
...appGroups, ...appGroups,
...directoryGroups, ...directoryGroups,
globalSearchEnginesSearchGroup,
mediaFallbackGroup, mediaFallbackGroup,
homeSearchEngineGroup, homeSearchEngineGroup,
contextSpecificFallbackSearchGroup, contextSpecificFallbackSearchGroup,
+4 -3
View File
@@ -7126,9 +7126,10 @@
"other": { "other": {
"label": "Search with another search engine" "label": "Search with another search engine"
}, },
"no-default": { "no-engines": {
"label": "No default search engine", "label": "No search engines configured",
"description": "Set a default search engine in preferences" "descriptionCreate": "Add a search engine to search from the command menu",
"descriptionUnavailable": "Ask an administrator to configure a search engine"
}, },
"search": { "search": {
"label": "Search for \"{query}\" with {name}" "label": "Search for \"{query}\" with {name}"
@@ -73,6 +73,7 @@ export default function BookmarksWidget({
return [ return [
{ {
id: bookmark.id, id: bookmark.id,
dedupeKey: appsById.has(bookmark.id) ? `app:${bookmark.id}` : undefined,
name: bookmark.name, name: bookmark.name,
icon: bookmark.iconUrl ?? IconLink, icon: bookmark.iconUrl ?? IconLink,
interaction() { interaction() {
@@ -148,10 +149,7 @@ export default function BookmarksWidget({
</Stack> </Stack>
</Center> </Center>
) : ( ) : (
<ScrollArea <ScrollArea scrollbars={plan.horizontalScroll ? "x" : "y"} style={{ flex: 1, minHeight: 0 }}>
scrollbars={plan.horizontalScroll ? "x" : "y"}
style={{ flex: 1, minHeight: 0 }}
>
<Box miw="100%" mih="100%" pb={2}> <Box miw="100%" mih="100%" pb={2}>
{plan.horizontalScroll ? ( {plan.horizontalScroll ? (
<Group gap={options.spacing} wrap="nowrap" mih="100%" align="stretch"> <Group gap={options.spacing} wrap="nowrap" mih="100%" align="stretch">