diff --git a/apps/docs/docs/advanced/keyboard-shortcuts/index.mdx b/apps/docs/docs/advanced/keyboard-shortcuts/index.mdx
index e1eb51562..4c3bca3fe 100644
--- a/apps/docs/docs/advanced/keyboard-shortcuts/index.mdx
+++ b/apps/docs/docs/advanced/keyboard-shortcuts/index.mdx
@@ -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,
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
-keyboard.
+before configured search-engine actions and Assistant fallbacks. Commands include opening the board switcher without
+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
the same query when needed. Media request search is available only when a supported Seerr integration is configured.
diff --git a/apps/docs/docs/management/search-engines/index.mdx b/apps/docs/docs/management/search-engines/index.mdx
index 186853d8c..0afe333d4 100644
--- a/apps/docs/docs/management/search-engines/index.mdx
+++ b/apps/docs/docs/management/search-engines/index.mdx
@@ -7,9 +7,13 @@ tags:
# 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 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.
## 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.
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
diff --git a/apps/nextjs/src/components/board/board-apps-spotlight-registrar.tsx b/apps/nextjs/src/components/board/board-apps-spotlight-registrar.tsx
index 157d14e94..721301860 100644
--- a/apps/nextjs/src/components/board/board-apps-spotlight-registrar.tsx
+++ b/apps/nextjs/src/components/board/board-apps-spotlight-registrar.tsx
@@ -48,6 +48,7 @@ export const BoardAppsSpotlightRegistrar = () => {
return [
{
id: app.id,
+ dedupeKey: `app:${app.id}`,
name: app.name,
icon: app.iconUrl,
interaction() {
diff --git a/apps/nextjs/src/components/layout/header/lazy-spotlight.tsx b/apps/nextjs/src/components/layout/header/lazy-spotlight.tsx
index 823ec01bd..427988c74 100644
--- a/apps/nextjs/src/components/layout/header/lazy-spotlight.tsx
+++ b/apps/nextjs/src/components/layout/header/lazy-spotlight.tsx
@@ -57,12 +57,8 @@ export const LazySpotlight = () => {
return isMounted || isPreloaded ? (
<>
{isPreloaded && }
- {isMounted && (
- <>
-
-
- >
- )}
+ {isMounted && }
+
>
) : null;
};
diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts
index 345dd1161..7a5bb2d69 100644
--- a/packages/api/src/router/board.ts
+++ b/packages/api/src/router/board.ts
@@ -2,6 +2,7 @@ import { TRPCError } from "@trpc/server";
import superjson from "superjson";
import { z } from "zod/v4";
+import type { Session } from "@homarr/auth";
import { constructBoardPermissions } from "@homarr/auth/shared";
import { createId, generateResponsiveGridFor } from "@homarr/common";
import type { GridAlgorithmItem } from "@homarr/common";
@@ -123,6 +124,84 @@ const findFirstAvailableBoardItemPosition = (
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({
exists: permissionRequiredProcedure
.requiresPermission("board-create")
@@ -482,80 +561,10 @@ export const boardRouter = createTRPCRouter({
};
});
}),
+ catalog: publicProcedure.query(async ({ ctx }) => searchAccessibleBoardsAsync(ctx, "")),
search: publicProcedure
.input(z.object({ query: z.string(), limit: z.number().min(1).max(100).default(10) }))
- .query(async ({ ctx, input }) => {
- 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,
- }));
- }),
+ .query(async ({ ctx, input }) => searchAccessibleBoardsAsync(ctx, input.query, input.limit)),
createBoard: permissionRequiredProcedure
.requiresPermission("board-create")
.meta({
diff --git a/packages/api/src/router/search-engine/search-engine-router.ts b/packages/api/src/router/search-engine/search-engine-router.ts
index 55cdc85ea..547ce8544 100644
--- a/packages/api/src/router/search-engine/search-engine-router.ts
+++ b/packages/api/src/router/search-engine/search-engine-router.ts
@@ -130,6 +130,21 @@ export const searchEngineRouter = createTRPCRouter({
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 }) => {
return await ctx.db.query.searchEngines.findMany({
// Public dashboards have no session: restrict anonymous users to generic
diff --git a/packages/spotlight/src/components/actions/group-actions.tsx b/packages/spotlight/src/components/actions/group-actions.tsx
index 56a12a3ad..f8516deff 100644
--- a/packages/spotlight/src/components/actions/group-actions.tsx
+++ b/packages/spotlight/src/components/actions/group-actions.tsx
@@ -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 = >({
}
if (options.isLoading) {
- return (
-
-
-
- );
+ return ;
}
if (options.isError) {
diff --git a/packages/spotlight/src/components/spotlight.module.css b/packages/spotlight/src/components/spotlight.module.css
index cc71a7c1e..62bbdafb6 100644
--- a/packages/spotlight/src/components/spotlight.module.css
+++ b/packages/spotlight/src/components/spotlight.module.css
@@ -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;
}
diff --git a/packages/spotlight/src/components/spotlight.tsx b/packages/spotlight/src/components/spotlight.tsx
index 52aaf4ee3..c887da960 100644
--- a/packages/spotlight/src/components/spotlight.tsx
+++ b/packages/spotlight/src/components/spotlight.tsx
@@ -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(defaultMode);
const [query, setQuery] = useState("");
const setMediaRequestSearchScope = useSetAtom(mediaRequestSearchScopeAtom);
diff --git a/packages/spotlight/src/lib/catalog.ts b/packages/spotlight/src/lib/catalog.ts
new file mode 100644
index 000000000..3b0a1c3d3
--- /dev/null
+++ b/packages/spotlight/src/lib/catalog.ts
@@ -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 = (
+ 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);
+};
diff --git a/packages/spotlight/src/modes/app-integration-board/apps-search-group.tsx b/packages/spotlight/src/modes/app-integration-board/apps-search-group.tsx
index a86444deb..18308a8c1 100644
--- a/packages/spotlight/src/modes/app-integration-board/apps-search-group.tsx
+++ b/packages/spotlight/src/modes/app-integration-board/apps-search-group.tsx
@@ -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.
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
@@ -96,7 +96,16 @@ export const appsSearchGroup = createGroup({
),
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,
+ };
},
});
diff --git a/packages/spotlight/src/modes/app-integration-board/boards-search-group.tsx b/packages/spotlight/src/modes/app-integration-board/boards-search-group.tsx
index a5d6fe299..1b421a336 100644
--- a/packages/spotlight/src/modes/app-integration-board/boards-search-group.tsx
+++ b/packages/spotlight/src/modes/app-integration-board/boards-search-group.tsx
@@ -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.
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
@@ -141,7 +141,11 @@ export const boardsSearchGroup = createGroup({
),
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,
+ };
},
});
diff --git a/packages/spotlight/src/modes/app-integration-board/index.tsx b/packages/spotlight/src/modes/app-integration-board/index.tsx
index 0be8ab42a..149285a59 100644
--- a/packages/spotlight/src/modes/app-integration-board/index.tsx
+++ b/packages/spotlight/src/modes/app-integration-board/index.tsx
@@ -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);
diff --git a/packages/spotlight/src/modes/app-integration-board/integrations-search-group.tsx b/packages/spotlight/src/modes/app-integration-board/integrations-search-group.tsx
index ff0e6cf17..836f191d6 100644
--- a/packages/spotlight/src/modes/app-integration-board/integrations-search-group.tsx
+++ b/packages/spotlight/src/modes/app-integration-board/integrations-search-group.tsx
@@ -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,
+ };
},
});
diff --git a/packages/spotlight/src/modes/external/search-engines-search-group.tsx b/packages/spotlight/src/modes/external/search-engines-search-group.tsx
index 61b2b48d6..2e12cb6f0 100644
--- a/packages/spotlight/src/modes/external/search-engines-search-group.tsx
+++ b/packages/spotlight/src/modes/external/search-engines-search-group.tsx
@@ -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",
-});
diff --git a/packages/spotlight/src/modes/home/context-specific-group.tsx b/packages/spotlight/src/modes/home/context-specific-group.tsx
index 9c0dd326d..70952b768 100644
--- a/packages/spotlight/src/modes/home/context-specific-group.tsx
+++ b/packages/spotlight/src/modes/home/context-specific-group.tsx
@@ -62,6 +62,23 @@ export const contextSpecificSearchGroups = createGroup({
},
});
+export const contextSpecificAppsSearchGroup = createGroup({
+ 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({
title: (t) => t("search.modePicker.continueWith"),
keyPath: "id",
diff --git a/packages/spotlight/src/modes/home/context.tsx b/packages/spotlight/src/modes/home/context.tsx
index 1268608a6..7e8bdaf5e 100644
--- a/packages/spotlight/src/modes/home/context.tsx
+++ b/packages/spotlight/src/modes/home/context.tsx
@@ -17,6 +17,7 @@ export type ContextSpecificItem = {
description?: string;
unavailable?: boolean;
alwaysVisible?: boolean;
+ dedupeKey?: string;
};
interface SpotlightContextProps {
diff --git a/packages/spotlight/src/modes/home/home-search-engine-group.tsx b/packages/spotlight/src/modes/home/home-search-engine-group.tsx
index b502e6cb6..82a994e37 100644
--- a/packages/spotlight/src/modes/home/home-search-engine-group.tsx
+++ b/packages/spotlight/src/modes/home/home-search-engine-group.tsx
@@ -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.
// 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;
+ interaction: inferSearchInteractionDefinition;
};
export const homeSearchEngineGroup = createGroup({
@@ -44,127 +45,95 @@ export const homeSearchEngineGroup = createGroup({
{icon}
{item.name}
- {item.description && (
+ {item.description ? (
{item.description}
- )}
+ ) : null}
);
},
- 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(),
+ },
+ };
+ });
};
diff --git a/packages/spotlight/src/modes/home/index.tsx b/packages/spotlight/src/modes/home/index.tsx
index d737f4335..ae26da5dc 100644
--- a/packages/spotlight/src/modes/home/index.tsx
+++ b/packages/spotlight/src/modes/home/index.tsx
@@ -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,
diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json
index a4f08f284..174a2850a 100644
--- a/packages/translation/src/lang/en.json
+++ b/packages/translation/src/lang/en.json
@@ -7126,9 +7126,10 @@
"other": {
"label": "Search with another search engine"
},
- "no-default": {
- "label": "No default search engine",
- "description": "Set a default search engine in preferences"
+ "no-engines": {
+ "label": "No search engines configured",
+ "descriptionCreate": "Add a search engine to search from the command menu",
+ "descriptionUnavailable": "Ask an administrator to configure a search engine"
},
"search": {
"label": "Search for \"{query}\" with {name}"
diff --git a/packages/widgets/src/bookmarks/bookmarks-widget.tsx b/packages/widgets/src/bookmarks/bookmarks-widget.tsx
index 7719d49a0..c93ca5351 100644
--- a/packages/widgets/src/bookmarks/bookmarks-widget.tsx
+++ b/packages/widgets/src/bookmarks/bookmarks-widget.tsx
@@ -73,6 +73,7 @@ export default function BookmarksWidget({
return [
{
id: bookmark.id,
+ dedupeKey: appsById.has(bookmark.id) ? `app:${bookmark.id}` : undefined,
name: bookmark.name,
icon: bookmark.iconUrl ?? IconLink,
interaction() {
@@ -148,10 +149,7 @@ export default function BookmarksWidget({
) : (
-
+
{plan.horizontalScroll ? (