Compare commits

..
Author SHA1 Message Date
Thomas Camlong 60fcac9f42 feat(custom-widgets): harden AI authoring (credential redaction, JSX policy)
Carve the custom-widget AI-authoring hardening out of #6691 into its own
change, independent of the widget/integration architecture refactor:

- add definition-security redaction helpers that strip credentials/URLs from
  the AI context, response, and generated text
- tighten the authoring prompt and examples
- enforce Tabler icon prop policy in the JSX analyzer
- expand ai-prompt, jsx-policy, and evaluation test coverage

Self-contained in @homarr/custom-widgets; no dependency on the metadata
catalog work. Typecheck + 490 package tests pass.
2026-08-24 15:22:49 +02:00
82 changed files with 1212 additions and 1731 deletions

No files matched your search

@@ -8,28 +8,13 @@ Homarr has the following keyboard shortcuts:
| Shortcut | Description |
| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| `MOD` + `K` | Opens search, including local apps, boards, integrations, pages, commands, and preferences |
| `MOD` + `P` | Opens app search |
| `MOD` + `/` | Opens Homarr Assistant inside search |
| `MOD` + `Shift` + `/` (`MOD` + `?`) | Opens preference search |
| `Shift` + `A` | Opens the full Homarr Assistant |
| `MOD` + `K` | Opens the search bar |
| `MOD` + `J` | Toggles Dark Mode |
| `MOD` + `E` | Toggles Edit Mode |
| Hold `Shift` for 500 ms over a supported widget | Opens its temporary advanced view |
| Hold `Shift` + `Ctrl`/`Cmd` over a supported widget | Keeps its advanced view open until it is closed |
| `Esc` | Stops keyboard tile editing, cancels an active pointer or touch move/resize, or closes an open advanced view or board menu |
## Search and commands
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 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.
## Widget advanced view
Supported widgets open a temporary advanced view when you hold `Shift` over them for 500 ms. Hold `Shift` plus `Ctrl`
+1 -1
View File
@@ -38,7 +38,7 @@ host gateway address.
## Use it
- Select **Assistant** in the user menu or press `Shift + A`.
- Press `Cmd/Ctrl + /` to ask from search, or use `Cmd/Ctrl + K` and choose the Assistant fallback.
- Ask from `Cmd/Ctrl + K`.
- Add the [Assistant widget](../widgets/assistant) to a board.
The assistant can work with boards, apps, integrations, widgets, Custom Widgets, and connected services. Use `@` to
@@ -7,13 +7,9 @@ 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 `MOD + K`.
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`.
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
@@ -30,8 +26,6 @@ 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
+2 -4
View File
@@ -38,10 +38,8 @@ its server; changing the browser clock cannot bypass them.
Workshop keeps the OpenRouter API key on its server. Homarr and the browser never receive it.
Prompts, attachments, tools, and tool results pass through Workshop and OpenRouter. Workshop does not retain this
content or the responses. It stores only the user, UTC day, and counters needed to enforce allowances. Each active day
has a separate quota record so Workshop operators can inspect per-user daily usage without seeing request content.
OpenRouter is configured for zero data retention with data collection disabled. Homarr does not use this content to
train models.
content or the responses. It stores only the user, UTC day, and counters needed to enforce allowances. OpenRouter is
configured for zero data retention with data collection disabled. Homarr does not use this content to train models.
We would rather become millionaires in a more ethical manner.
-3
View File
@@ -72,9 +72,6 @@ per-request telemetry. It accepts only uploaded image data, conservatively bound
allows at most two concurrent requests per user and 100 across the service. Keep an OpenRouter account-level credit
limit as the final monetary backstop because request quotas are not dollar accounting.
PocketBase stores one `assistant_quotas` record per user and active UTC day. Workshop superusers can use this private
collection for a daily usage breakdown; regular users cannot list or view its records.
Run `pnpm test:workshop` to test authentication, streaming, UTC reset, atomic per-user and global quota enforcement,
failed-request accounting, identity attribution, server-selected model routing, function tools, input and output
controls, privacy routing, spoofing resistance, absence of request logs, and migration rollback.
@@ -11,7 +11,6 @@ import { useI18n } from "@homarr/translation/client";
import { FloatingTip } from "@homarr/ui";
import { BoardAdvancedFocusProvider } from "~/components/board/advanced-focus/context";
import { BoardAppsSpotlightRegistrar } from "~/components/board/board-apps-spotlight-registrar";
import { BoardEmptyState } from "~/components/board/board-empty-state";
import { BoardSetupChecklist } from "~/components/board/board-setup-checklist";
import {
@@ -80,7 +79,6 @@ export const ClientBoard = () => {
<BoardSelectionGridProvider>
<BoardAdvancedFocusProvider>
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
<BoardAppsSpotlightRegistrar />
<BoardBackgroundVideo />
<BoardEmptyState />
<BoardSetupChecklist />
@@ -89,9 +89,8 @@ const repairIconSearchInput = <T extends AssistantToolCallInput>(toolCall: T): T
};
};
const repairMultilineToolInput = <T extends AssistantToolCallInput>(toolCall: T): T | null => {
const isCustomWidgetTool = toolCall.toolName.startsWith("customWidget_");
if (!isCustomWidgetTool && toolCall.toolName !== "configure_board_settings") return null;
const repairCustomWidgetToolInput = <T extends AssistantToolCallInput>(toolCall: T): T | null => {
if (!toolCall.toolName.startsWith("customWidget_")) return null;
if (customWidgetNoInputToolNames.has(toolCall.toolName)) {
try {
const input = JSON.parse(toolCall.input) as unknown;
@@ -115,4 +114,4 @@ const repairMultilineToolInput = <T extends AssistantToolCallInput>(toolCall: T)
};
export const repairAssistantToolInput = <T extends AssistantToolCallInput>(toolCall: T): T | null =>
repairIconSearchInput(toolCall) ?? repairMultilineToolInput(toolCall);
repairIconSearchInput(toolCall) ?? repairCustomWidgetToolInput(toolCall);
@@ -146,12 +146,12 @@ Homarr concepts:
Action rules:
- Prefer read-only tools before actions.
- Whenever you need an answer from the user before choosing the next action, call ask_user. Never use ask_user to confirm a board settings or custom CSS change before configure_board_settings, or to confirm another change whose inputs are already sufficient for its native review form or mutating tool. Never end a prose response with a question that expects the user to type a reply. Offer distinct choices, categorize agreement or proceeding as affirmative, refusal as negative, and unrelated selections as alternative, then leave the freeform Other answer enabled. A confirmation question must have exactly one affirmative option. Purely rhetorical questions and open-ended conversation that does not block an action may remain prose, but optional next steps should be stated declaratively instead of phrased as a question.
- Whenever you need an answer from the user before choosing the next action, call ask_user. Never end a prose response with a question that expects the user to type a reply. Offer distinct choices, categorize agreement or proceeding as affirmative, refusal as negative, and unrelated selections as alternative, then leave the freeform Other answer enabled. A confirmation question must have exactly one affirmative option. Purely rhetorical questions and open-ended conversation that does not block an action may remain prose, but optional next steps should be stated declaratively instead of phrased as a question.
- Mutating Homarr tools already pause for native user approval. Calling a mutation only proposes the action; it cannot execute until the user selects Approve and run. Once the requested change is sufficiently specified, call the mutation immediately so the approval UI appears.
- A prose response such as "Please confirm if you would like me to proceed", "Would you like me to proceed?", or a parameter summary that asks for confirmation is incorrect. Never ask for a second textual confirmation before an approval-gated tool call.
- Do not retry a denied action.
- Before creating an app, call configure_app with the app name and every useful default you can infer, including its description, href, pingUrl, and an icon URL returned by icon_findIcons. The user reviews Homarr's native app form. Its icon picker searches Homarr's local icon repository. Use the returned values for app_create. If the user asked to place it on a board, call configure_widget afterward with kind "app" and options { appId: the returned appId }.
- Before changing a board's custom CSS or visual and behavior settings, call board_getBoardSettings to read the current values, then immediately call configure_board_settings with only the requested proposed changes. For a custom CSS request, include the complete resulting stylesheet in changes.customCss so the native form is prefilled and previews the proposed CSS. The native form is the user's review and confirmation step, so never insert ask_user between these calls when the requested change is known. Use the form's returned flat object for board_savePartialBoardSettings, which then shows the mutation approval. Never overwrite existing CSS rules unless the user explicitly requests replacement.
- Before changing a board's custom CSS or visual and behavior settings, call board_getBoardSettings to read the current values, then call configure_board_settings with only the requested proposed changes. The user can edit the complete CSS and settings in Homarr's native form. Use its returned flat object for board_savePartialBoardSettings, which then shows the mutation approval. Never overwrite existing CSS rules unless the user explicitly requests replacement.
- Before adding any widget to a board, call configure_widget with the target board, widget kind, proposed options, and any integration IDs selected from integration_all. Homarr's native widget editor applies defaults, validates the options, and restricts the picker to compatible integrations the user can access. Use the returned boardId, kind, options, and integrationIds exactly as the input to board_addItem. If configure_widget returns cancelled, do not add the widget.
- To create a formatted note on a dashboard, call configure_widget with kind "notebook". Put valid Tiptap-compatible HTML in options.content and set options.showToolbar and options.allowReadOnlyCheck when useful. Use semantic paragraphs, headings, lists, task lists, blockquotes, tables, links, emphasis, and images as appropriate. Never include scripts, styles, iframes, event handlers, or unsafe URL protocols.
- When choosing an app icon without configure_app, call the Homarr icon findIcons tool first and use one of its returned local icon URLs. Never invent a third-party icon CDN URL.
@@ -856,7 +856,6 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
: t("busy.description"),
unavailable: !canSend,
alwaysVisible: true,
placement: "fallback" as const,
interaction: (query: string) =>
createAssistantPromptInteraction({
sendPrompt,
@@ -19,7 +19,6 @@ export const useRegisterAssistantSpotlightPlaceholder = (description: string) =>
description,
unavailable: true,
alwaysVisible: true,
placement: "fallback" as const,
interaction: (_query: string) => ({ type: "none" as const }),
}),
[description, t],
@@ -54,7 +54,7 @@ export const assistantBoardSettingsChangesSchema = z
export const browserToolContracts = {
ask_user: {
description:
"Pause and ask the user one concise structured question. Use this only for missing information or a meaningful choice, never to confirm details that are already sufficient for a native review form or mutating tool. In particular, do not use ask_user before configure_app, configure_board_settings, or configure_widget when their inputs are known. Provide 2-4 distinct options and classify every option: agreement, approval, or proceeding is affirmative; refusal or stopping is negative; unrelated selections are alternative. A confirmation question must have exactly one affirmative option. The UI adds a freeform Other choice when allowOther is not false.",
"Pause and ask the user one concise structured question. Use this for missing information or a meaningful choice, never as a second confirmation before a mutating tool. Provide 2-4 distinct options and classify every option: agreement, approval, or proceeding is affirmative; refusal or stopping is negative; unrelated selections are alternative. A confirmation question must have exactly one affirmative option. The UI adds a freeform Other choice when allowOther is not false.",
parameters: z.object({
question: z.string().trim().min(1).max(240),
description: z.string().trim().max(400).optional(),
@@ -82,7 +82,7 @@ export const browserToolContracts = {
},
configure_board_settings: {
description:
"Open Homarr's native board settings and custom CSS review form. This form is the user's confirmation step. Always call board_getBoardSettings first, then call this tool directly with only the requested proposed changes, including the complete resulting stylesheet in changes.customCss for a CSS request. Do not call ask_user between those tools when the requested changes are known. The user can edit the complete stylesheet and all supported settings. Use the returned flat object directly with board_savePartialBoardSettings.",
"Open Homarr's native board settings and custom CSS review form. Always call board_getBoardSettings first, then pass only the requested proposed changes here. The user can edit the complete stylesheet and all supported settings. Use the returned flat object directly with board_savePartialBoardSettings; do not ask for confirmation in prose.",
parameters: z.object({
boardId: z.string().trim().min(1).max(64),
boardName: z.string().trim().min(1).max(255),
@@ -1,70 +0,0 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { getSafeAppHref } from "@homarr/common";
import type { ContextSpecificItem } from "@homarr/spotlight";
import { useRegisterSpotlightContextResults } from "@homarr/spotlight";
import { hasPendingSpotlightOpen, spotlightOpenEvent } from "@homarr/spotlight/open";
export const BoardAppsSpotlightRegistrar = () => {
const board = useRequiredBoard();
const [isSpotlightActivated, setIsSpotlightActivated] = useState(() => hasPendingSpotlightOpen());
useEffect(() => {
if (isSpotlightActivated) return;
const activateSpotlightQueries = () => setIsSpotlightActivated(true);
window.addEventListener(spotlightOpenEvent, activateSpotlightQueries);
if (hasPendingSpotlightOpen()) activateSpotlightQueries();
return () => window.removeEventListener(spotlightOpenEvent, activateSpotlightQueries);
}, [isSpotlightActivated]);
const openInNewTabByAppId = useMemo(() => {
const result = new Map<string, boolean>();
for (const item of board.items) {
if (item.kind !== "app" || typeof item.options.appId !== "string" || item.options.appId.length === 0) continue;
const existingOpenInNewTab = result.get(item.options.appId) ?? false;
result.set(item.options.appId, existingOpenInNewTab || item.options.openInNewTab === true);
}
return result;
}, [board.items]);
const appIds = useMemo(() => Array.from(openInNewTabByAppId.keys()), [openInNewTabByAppId]);
const activeAppIds = isSpotlightActivated ? appIds : [];
const appQueries = clientApi.useQueries((trpc) => activeAppIds.map((id) => trpc.app.byId({ id })));
const apps = appQueries.flatMap((query) => query.data ?? []);
const spotlightItems = useMemo<ContextSpecificItem[]>(() => {
return apps.flatMap((app) => {
const href = getSafeAppHref(app.href);
if (!href) return [];
return [
{
id: app.id,
dedupeKey: `app:${app.id}`,
name: app.name,
icon: app.iconUrl,
interaction() {
return {
type: "link" as const,
href,
// Opening a new tab wins if any widget instance requests it.
newTab: openInNewTabByAppId.get(app.id) ?? false,
};
},
},
];
});
}, [apps, openInNewTabByAppId]);
useRegisterSpotlightContextResults("board-apps", spotlightItems, [spotlightItems]);
return null;
};
@@ -26,7 +26,6 @@ import { IconDeviceMobile, IconHomeFilled, IconLayoutBoard, IconSearch } from "@
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useOptionalBoard } from "@homarr/boards/context";
import { useRegisterSpotlightContextActions } from "@homarr/spotlight";
import { useI18n } from "@homarr/translation/client";
import { Link, UserAvatar } from "@homarr/ui";
@@ -42,7 +41,6 @@ interface BoardSwitcherProps {
export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
const t = useI18n("board.action.switcher");
const tBoard = useI18n("board");
const manageBoardsT = useI18n("management.page.board");
const currentBoard = useOptionalBoard();
const [isOpen, setIsOpen] = useState(false);
@@ -87,20 +85,6 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
}, []);
const preloadBoards = () => void utils.board.getManageOverview.prefetch({ fullPreview: true });
const spotlightAction = useMemo(
() => ({
id: "open-board-switcher",
name: tBoard("action.switch"),
icon: IconLayoutBoard,
interaction: () => ({
type: "javaScript" as const,
onSelect: openSwitcher,
}),
}),
[openSwitcher, tBoard],
);
useRegisterSpotlightContextActions("board-switcher", [spotlightAction], [spotlightAction]);
useHotkeys([[boardSwitcherHotkey, openSwitcher, { preventDefault: true }]]);
useEffect(() => {
@@ -2,7 +2,6 @@
import { useEffect, useState } from "react";
import dynamic from "next/dynamic";
import type { HotkeyItem } from "@mantine/hooks";
import { useHotkeys } from "@mantine/hooks";
import { hotkeys } from "@homarr/definitions";
@@ -14,18 +13,23 @@ const Spotlight = dynamic(() => loadSpotlight().then(({ Spotlight: Component })
loading: () => null,
});
const spotlightHotkeys: HotkeyItem[] = [
[hotkeys.openSpotlight, () => openSpotlight(), { preventDefault: true }],
[hotkeys.openSpotlightApps, () => openSpotlight({ mode: "apps" }), { preventDefault: true }],
[hotkeys.openSpotlightAssistant, () => openSpotlight({ mode: "assistant" }), { preventDefault: true }],
[hotkeys.openSpotlightPreferences, () => openSpotlight({ mode: "preferences" }), { preventDefault: true }],
];
export const LazySpotlight = () => {
const [isMounted, setIsMounted] = useState(false);
const [isPreloaded, setIsPreloaded] = useState(false);
useHotkeys(spotlightHotkeys, [], true);
useHotkeys(
isMounted
? []
: [
[
hotkeys.openSpotlight,
() => {
setIsMounted(true);
openSpotlight();
},
],
],
);
useEffect(() => {
let isActive = true;
@@ -57,8 +61,12 @@ export const LazySpotlight = () => {
return isMounted || isPreloaded ? (
<>
{isPreloaded && <span hidden data-homarr-dev-benchmark-spotlight-preloaded />}
{isMounted && <span hidden data-homarr-dev-benchmark-spotlight-mounted />}
<Spotlight />
{isMounted && (
<>
<span hidden data-homarr-dev-benchmark-spotlight-mounted />
<Spotlight />
</>
)}
</>
) : null;
};
@@ -20,7 +20,7 @@ export const DesktopSearchInput = () => {
w={400}
size="sm"
leftSection={<IconSearch size={20} stroke={1.5} />}
onClick={() => openSpotlight()}
onClick={openSpotlight}
radius="xl"
>
{`${t("search.placeholder")}...`}
@@ -32,7 +32,7 @@ export const MobileSearchButton = () => {
return (
<HeaderButton
data-homarr-dev-benchmark-interaction="search"
onClick={() => openSpotlight()}
onClick={openSpotlight}
className={classes.mobileSearch}
>
<IconSearch size={20} stroke={1.5} />
+5 -7
View File
@@ -605,12 +605,7 @@ func (provider *homarrProvider) loadQuota(
user *core.Record,
now time.Time,
) (*core.Record, quotaSnapshot, bool, error) {
day := now.Format(time.DateOnly)
quota, err := app.FindFirstRecordByFilter(
"assistant_quotas",
"user = {:user} && day = {:day}",
dbx.Params{"user": user.Id, "day": day},
)
quota, err := app.FindFirstRecordByFilter("assistant_quotas", "user = {:user}", dbx.Params{"user": user.Id})
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, quotaSnapshot{}, false, err
}
@@ -622,7 +617,10 @@ func (provider *homarrProvider) loadQuota(
}
quota = core.NewRecord(collection)
quota.Set("user", user.Id)
quota.Set("day", day)
changed = true
}
if quota.GetString("day") != now.Format(time.DateOnly) {
quota.Set("day", now.Format(time.DateOnly))
quota.Set("used", 0)
changed = true
}
@@ -1,36 +0,0 @@
/// <reference path="../pb_data/types.d.ts" />
migrate(
(app) => {
const quotas = app.findCollectionByNameOrId("assistant_quotas");
quotas.removeIndex("idx_assistant_quotas_user");
quotas.addIndex("idx_assistant_quotas_user_day", true, "user, day", "");
app.save(quotas);
},
(app) => {
const quotas = app.findCollectionByNameOrId("assistant_quotas");
quotas.removeIndex("idx_assistant_quotas_user_day");
// The previous schema can keep only one record per user. Retain the latest UTC day when rolling back.
app
.db()
.newQuery(`
DELETE FROM assistant_quotas
WHERE id NOT IN (
SELECT (
SELECT candidate.id
FROM assistant_quotas candidate
WHERE candidate.user = quota_user.user
ORDER BY candidate.day DESC, candidate.created DESC, candidate.id DESC
LIMIT 1
)
FROM assistant_quotas quota_user
GROUP BY quota_user.user
)
`)
.execute();
quotas.addIndex("idx_assistant_quotas_user", true, "user", "");
app.save(quotas);
},
);
@@ -82,14 +82,6 @@ for (const forbidden of ['name: "assistant_requests"', 'name: "assistant_activit
if (providerMigration.includes(forbidden)) throw new Error(`Homarr provider must not persist ${forbidden}`);
}
const dailyQuotaMigration = await read("apps/workshop/pb_migrations/1787670446_daily_assistant_quotas.js");
for (const required of [
'removeIndex("idx_assistant_quotas_user")',
'addIndex("idx_assistant_quotas_user_day", true, "user, day", "")',
]) {
if (!dailyQuotaMigration.includes(required)) throw new Error(`Daily quota migration is missing: ${required}`);
}
const providerBackend = await read("apps/workshop/homarr_provider.go");
for (const required of [
'"homarr/model"',
+2 -14
View File
@@ -436,21 +436,9 @@ const resetUsage = await request("/api/ai/usage", { headers: authorSession.heade
if (resetUsage.limit !== 50 || resetUsage.used !== 0 || resetUsage.remaining !== 50) {
throw new Error(`Quota did not reset at the UTC day boundary: ${JSON.stringify(resetUsage)}`);
}
const today = new Date().toISOString().slice(0, 10);
const resetQuotas = (
const resetQuota = (
await request("/api/collections/assistant_quotas/records?perPage=20", { headers: rootHeaders })
).items.filter((item) => item.user === author.id);
const historicalQuota = resetQuotas.find((item) => item.id === authorQuota.id);
const resetQuota = resetQuotas.find((item) => item.day === today);
if (
resetQuotas.length !== 2 ||
historicalQuota?.day !== "2000-01-01" ||
historicalQuota.used !== 49 ||
!resetQuota ||
resetQuota.used !== 0
) {
throw new Error(`Daily quota history was not preserved: ${JSON.stringify(resetQuotas)}`);
}
).items.find((item) => item.user === author.id);
await request(`/api/collections/assistant_quotas/records/${resetQuota.id}`, {
method: "PATCH",
headers: rootHeaders,
+72 -83
View File
@@ -2,7 +2,6 @@ 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";
@@ -124,78 +123,6 @@ 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: getBoardGroupPermissionWhere(permissionsOfCurrentUserGroupsWhenPresent),
},
},
});
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")
@@ -555,18 +482,80 @@ export const boardRouter = createTRPCRouter({
};
});
}),
catalog: publicProcedure
.meta({ mcp: { enabled: true, description: "List every board the current user can access." } })
.query(async ({ ctx }) => searchAccessibleBoardsAsync(ctx, "")),
search: publicProcedure
.meta({
mcp: {
enabled: true,
description: "Search accessible boards by name. REQUIRED: query (string). OPTIONAL: limit (number).",
},
})
.input(z.object({ query: z.string(), limit: z.number().min(1).max(100).default(10) }))
.query(async ({ ctx, input }) => searchAccessibleBoardsAsync(ctx, input.query, input.limit)),
.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,
}));
}),
createBoard: permissionRequiredProcedure
.requiresPermission("board-create")
.meta({
@@ -130,28 +130,6 @@ export const searchEngineRouter = createTRPCRouter({
return null;
}),
catalog: publicProcedure
.meta({
mcp: {
enabled: true,
description: "List search engines available to the current user.",
},
})
.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
-2
View File
@@ -19,10 +19,8 @@ const MCP_TOOL_ALLOWLIST = {
"beszel_getAlerts",
"beszel_getSystems",
"beszel_getSystemStats",
"board_catalog",
"board_getAllBoards",
"board_getBoardSettings",
"board_search",
"calendar_findAllEvents",
"customWidget_get",
"customWidget_getAuthoringPrompt",
@@ -144,7 +144,8 @@ export const customWidgetAssistantEvaluationToolDefinitions: ToolDefinition[] =
type: "function",
function: {
name: "customWidget_getComponent",
description: "Get installed documentation and safety rules for one named Custom JSX component.",
description:
"Get installed documentation and safety rules for one named Custom JSX component. Fetch only a selected component, one tool call at a time, after any optional example.",
parameters: objectSchema({ name: { type: "string" } }, ["name"]),
},
},
@@ -162,7 +163,8 @@ export const customWidgetAssistantEvaluationToolDefinitions: ToolDefinition[] =
type: "function",
function: {
name: "customWidget_getExample",
description: "Get one installed Custom JSX example by catalog ID.",
description:
"Optionally get one installed Custom JSX example by catalog ID before requesting any component documentation.",
parameters: objectSchema({ name: { type: "string" } }, ["name"]),
},
},
@@ -251,11 +253,24 @@ export function executeAssistantEvaluationTool(
name: string,
input: Record<string, unknown>,
): unknown {
const componentDocumentCount = state.calledTools.filter(
(toolName) => toolName === "customWidget_getComponent",
).length;
if (name === "customWidget_getExample") {
if (componentDocumentCount > 0) {
return {
error:
"An example must be loaded before component documentation. Keep the existing component-document budget and continue to customWidget_validate.",
};
}
const example = typeof input.name === "string" ? getCustomWidgetExample(input.name) : null;
if (!example) return { error: "Custom JSX example not found" };
state.calledTools.push(name);
const { template, ...widget } = example.widget;
return { ...example, widget: { ...widget, templateLines: template.split("\n") } };
}
const componentDocumentLimit = state.calledTools.includes("customWidget_getExample") ? 4 : 8;
if (
name === "customWidget_getComponent" &&
state.calledTools.filter((toolName) => toolName === "customWidget_getComponent").length >= componentDocumentLimit
) {
if (name === "customWidget_getComponent" && componentDocumentCount >= componentDocumentLimit) {
return {
error:
"The targeted component-document budget is exhausted. Continue with validation using the documentation already loaded.",
@@ -277,12 +292,6 @@ export function executeAssistantEvaluationTool(
? getCustomWidgetSharedProps(names)
: { error: "At least one shared prop name is required" };
}
if (name === "customWidget_getExample") {
const example = typeof input.name === "string" ? getCustomWidgetExample(input.name) : null;
if (!example) return { error: "Custom JSX example not found" };
const { template, ...widget } = example.widget;
return { ...example, widget: { ...widget, templateLines: template.split("\n") } };
}
if (name === "customWidget_validate") {
const parsed = parseDefinition(input.widget);
if (!parsed.success) return { valid: false, issues: parsed.issues };
@@ -576,11 +585,11 @@ async function runAssistantAttempt(args: {
process.stdout.write(` tool: ${toolCall.function.name}\n`);
let output: unknown;
try {
const parsed = JSON.parse(toolCall.function.arguments) as unknown;
const input =
parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: {};
const parsed: unknown = JSON.parse(toolCall.function.arguments);
let input: Record<string, unknown> = {};
if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
input = Object.fromEntries(Object.entries(parsed));
}
output = executeAssistantEvaluationTool(args.testCase, state, toolCall.function.name, input);
} catch (error) {
output = { error: error instanceof Error ? error.message : "Tool input was not valid JSON" };
@@ -219,7 +219,7 @@ export function buildRepairPrompt(
export function buildJudgePrompt(testCase: CustomWidgetAiEvaluationCase, widget: HomarrCustomWidgetV2): string {
return `You are a hostile-but-fair product review panel evaluating a safe dashboard widget. Most competent drafts should score 55-75, not 90. Judge only evidence present in the manifest and JSX. Never reward unsupported capabilities, invented API routes, aspirational claims, or code that merely validates.
The installed Homarr skill and runtime references below are authoritative. In particular, request state is exposed as status.<requestId> with loading/ok/status/error fields while successful payloads are exposed as data.<requestId>. RefreshButton is an installed runtime helper that manually refreshes load queries. Every component in this already-validated template exists in the installed release. Do not penalize those documented facts. The widget has already passed Homarr's real schema and JSX analyzer, which proves syntax and component compatibility but does not prove API correctness, visual quality, usefulness, or accessibility.
The installed Homarr skill and runtime references below are authoritative. The verified API notes and representative response fixtures are authoritative for endpoint paths, authentication requirements, and response shapes unless the validated manifest contradicts them. Do not invent external endpoint or authentication objections from outside assumptions. Decorative icons paired with equivalent adjacent visible status text need no separate aria-label. A Badge containing explicit visible status text is not color-only. A readable absolute UTC date and time is valid; do not require relative or localized time without a documented safe helper. In particular, request state is exposed as status.<requestId> with loading/ok/status/error fields while successful payloads are exposed as data.<requestId>. RefreshButton is an installed runtime helper that manually refreshes load queries. Every component in this already-validated template exists in the installed release. Do not penalize those documented facts. The widget has already passed Homarr's real schema and JSX analyzer, which proves syntax and component compatibility but does not prove API correctness, visual quality, usefulness, or accessibility.
Installed Homarr Custom Widget authoring contract:
${getCustomWidgetSkillContent()}
@@ -254,16 +254,14 @@ Required review behavior:
- Give a concrete evidence sentence for every category. List all score-capping issues under fatalProblems.
- Return an empty fatalProblems array when there are no fatal problems; never put "none" or an explanation of their absence in that array.
Homarr computes the weighted total and final verdict itself. Your total and verdict are advisory, but must be internally honest. A pass requires a weighted total of at least 90, every category at least 75, goalFulfillment/visualQuality/dailyUsefulness at least 85, complexityDiscipline at least 80, no fatal problem, and dailyUseDecision="would-use-daily". Return only the requested structured object.`;
Homarr computes the weighted total and final verdict itself. Your total and verdict are advisory, but must be internally honest. A pass requires a weighted total of at least 85, every category at least 75, goalFulfillment at least 85, complexityDiscipline at least 80, no fatal problem, and dailyUseDecision="would-use-daily". Return only the requested structured object.`;
}
export function judgePasses(result: CustomWidgetJudgeResult): boolean {
return (
result.total >= 90 &&
result.total >= 85 &&
Object.values(result.categories).every((score) => score >= 75) &&
result.categories.goalFulfillment >= 85 &&
result.categories.visualQuality >= 85 &&
result.categories.dailyUsefulness >= 85 &&
result.categories.complexityDiscipline >= 80 &&
result.dailyUseDecision === "would-use-daily" &&
result.fatalProblems.length === 0
@@ -15,58 +15,55 @@ if (!apiKey) {
const requestedCase = process.argv.find((value) => value.startsWith("--case="))?.slice("--case=".length);
const assistantMode = process.argv.includes("--assistant");
const configuredLoops = Number(process.env.CUSTOM_WIDGET_AI_MAX_LOOPS ?? 10);
const totalLoopBudget = Number.isInteger(configuredLoops) && configuredLoops > 0 ? Math.min(configuredLoops, 10) : 10;
const selectableCases = assistantMode
? CUSTOM_WIDGET_AI_EVALUATION_CASES.filter(
(testCase) =>
(testCase.sampleResponse !== undefined || testCase.previewResponses?.length) &&
testCase.expectations !== undefined,
)
: CUSTOM_WIDGET_AI_EVALUATION_CASES;
const selectedCases = requestedCase
? CUSTOM_WIDGET_AI_EVALUATION_CASES.filter((testCase) => testCase.id === requestedCase)
: selectableCases;
if (selectedCases.length === 0) throw new Error(`Unknown AI evaluation case '${requestedCase}'`);
if (totalLoopBudget < selectedCases.length) {
throw new Error(`CUSTOM_WIDGET_AI_MAX_LOOPS must be at least ${selectedCases.length} for this run`);
const maxLoops = Number.isInteger(configuredLoops) && configuredLoops > 0 ? Math.min(configuredLoops, 10) : 10;
let selectableCases = CUSTOM_WIDGET_AI_EVALUATION_CASES;
if (assistantMode) {
selectableCases = CUSTOM_WIDGET_AI_EVALUATION_CASES.filter(
(testCase) =>
(testCase.sampleResponse !== undefined || testCase.previewResponses?.length) &&
testCase.expectations !== undefined,
);
}
let selectedCases = selectableCases;
if (requestedCase)
selectedCases = CUSTOM_WIDGET_AI_EVALUATION_CASES.filter((testCase) => testCase.id === requestedCase);
if (selectedCases.length === 0) throw new Error(`Unknown AI evaluation case '${requestedCase}'`);
const runId = new Date().toISOString().replaceAll(/[:.]/gu, "-");
const outputRoot = path.resolve(process.cwd(), ".ai-evaluations", runId);
await mkdir(outputRoot, { recursive: true });
const results: Array<AiEvaluationResult | CustomWidgetAssistantEvaluationResult> = [];
let remainingLoopBudget = totalLoopBudget;
for (const testCase of selectedCases) {
const remainingCases = selectedCases.length - results.length;
const maxLoops = Math.max(1, Math.floor(remainingLoopBudget / remainingCases));
process.stdout.write(`Evaluating ${testCase.id}...\n`);
const result = assistantMode
? await evaluateCustomWidgetAssistantCase({
testCase,
apiKey,
baseUrl: providerBaseUrl,
outputRoot,
maxLoops,
generatorModel,
judgeModel,
})
: await evaluateCustomWidgetCase({
testCase,
apiKey,
baseUrl: providerBaseUrl,
outputRoot,
maxLoops,
generatorModel,
judgeModel,
});
let result: AiEvaluationResult | CustomWidgetAssistantEvaluationResult;
if (assistantMode) {
result = await evaluateCustomWidgetAssistantCase({
testCase,
apiKey,
baseUrl: providerBaseUrl,
outputRoot,
maxLoops,
generatorModel,
judgeModel,
});
} else {
result = await evaluateCustomWidgetCase({
testCase,
apiKey,
baseUrl: providerBaseUrl,
outputRoot,
maxLoops,
generatorModel,
judgeModel,
});
}
results.push(result);
remainingLoopBudget -= result.attempts;
process.stdout.write(
result.judge
? ` ${result.judge.total}/100 after ${result.attempts} attempt(s)\n`
: ` failed after ${result.attempts} attempt(s)\n`,
);
if (result.judge) {
process.stdout.write(` ${result.judge.total}/100 after ${result.attempts} attempt(s)\n`);
} else {
process.stdout.write(` failed after ${result.attempts} attempt(s)\n`);
}
}
const summary = {
+31 -11
View File
@@ -1,5 +1,11 @@
import type { HomarrCustomWidgetV2 } from "./custom-jsx-schema";
import { customJsxAuthoringCatalog } from "./component-catalog";
import {
redactCustomWidgetAiContext,
redactCustomWidgetAiResponse,
redactCustomWidgetAiText,
redactCustomWidgetAiUrl,
} from "./definition-security";
import { customJsxExamples } from "./examples";
export interface CustomWidgetAiDraft {
@@ -106,19 +112,28 @@ export const CUSTOM_WIDGET_AUTHORING_PROMPT = AUTHORING_PROMPT;
export const CUSTOM_WIDGET_MCP_AUTHORING_PROMPT = `Author one Homarr Custom JSX v2 widget at a time.
First call customWidget_getSkill. Its single lazy response contains the complete installed SKILL.md and every bundled reference; follow all of it. Read the live schema and compact component catalog. Fetch only the named component and example resources needed for the design. Component resources contain component-specific props; if shared props are needed, collect their catalog names and fetch those docs together once with customWidget_getSharedProps. The live resources are authoritative for this Homarr release.
Mandatory sequence — follow it exactly:
1. First call customWidget_getSkill and follow the complete installed skill and bundled references.
2. Call customWidget_schema, then customWidget_getComponentCatalog.
3. If a complete example is useful, call customWidget_getExample before any customWidget_getComponent call. Then choose at most four component names total and never request a fifth. Fetch only those named docs and, once, any named shared props with customWidget_getSharedProps.
4. Before validation, quickly reject IIFEs, new, block callbacks or => {, and raw HTML tags such as span; use catalog components instead. Immediately call customWidget_validate with the complete definition and repair every issue.
5. Call customWidget_previewCreate with that exact validated definition.
6. Call customWidget_previewQuery once for every query returned by the preview and inspect every response.
7. Call customWidget_createFromPreview with the exact fully tested preview session ID.
Make one documentation tool call at a time. Never issue batched customWidget_getComponent calls, speculate about components, or walk the catalog. If preview evidence requires a definition change, restart at step 4 with the revised complete definition. The live resources are authoritative for this Homarr release.
Keep the documentation phase bounded. Fetch at most eight named component documents before the first validation, or at most four after loading a complete example. Fetch another component only to resolve a concrete validation issue or missing interaction; never walk the catalog component-by-component. Move promptly from the smallest sufficient documentation set to a complete customWidget_validate call.
Follow this lifecycle without skipping steps: construct one credential-free definition; call customWidget_validate; repair every reported issue; call customWidget_previewCreate; call customWidget_previewQuery once for every query returned by that preview (supplying required manual parameters); inspect the real response shape and HTTP status; adjust paths and template bindings; validate and preview again after changes; open or provide the returned previewPath for visual review; only then call customWidget_createFromPreview with the exact final tested preview session ID. Use customWidget_create only when no reusable preview session exists. Prefer templateLines over a JSON-escaped template string for multiline JSX in validate and preview tool inputs.
Construct one credential-free definition. Supply required manual parameters to preview queries, inspect the real response shape and HTTP status, and open or provide the returned previewPath for visual review. Use customWidget_create only when no reusable preview session exists. Prefer templateLines over a JSON-escaped template string for multiline JSX in validate and preview tool inputs.
Do not claim a query works when it has not returned a successful preview response. Configure deployment-specific source URLs and request credentials through Homarr when needed; never repeat plaintext. Use simulated preview actions unless the user explicitly enables live preview actions.
Treat preview data as the binding contract: render every core requested field, guard optional arrays and nested values before indexing, and do not silently drop returned items. Show concise freshness context when the response supplies a timestamp. Give recoverable load errors and empty states a clear refresh or retry path.
Treat preview data as the binding contract: render every core requested field, guard optional arrays and nested values before indexing, and do not silently drop returned items. Show timestamps as concise human-readable absolute dates and times; do not compute relative time with Date, Date.now, or Date.parse unless the live docs provide a safe helper. Give recoverable load errors and empty states a clear refresh or retry path.
Use the lean keyed sources, requests, and options contract. Bind saved values directly with $option and invocation values with $param. Load queries cannot use $param. Keep the design responsive, accessible, theme-safe, loading-aware, empty-aware, and error-aware.
Create distinctive polish with one semantic accent, clear surface contrast, purposeful typography, and compact narrow-tile rows rather than decorative gradients or excessive nested cards. Label standalone icons for assistive technology or pair them with visible text.
Create distinctive polish with one restrained accent for decorative chrome, while operational, degraded, and down states retain distinct semantic colors. Use clear surface contrast and purposeful typography rather than decorative gradients or excessive nested cards. Do not wrap every metric or service row in Paper or Card. Prefer one dominant surface with clearly separated service rows, a one-column compact narrow layout, and an intentionally more spacious wide layout. Label standalone icons for assistive technology or pair them with visible text.
Make initial states actionable with an example, useful hint, or clear next step. Use wrapping groups for variable-length labels and values on narrow tiles. Do not use an unlabeled decorative icon as an empty state.
@@ -129,7 +144,7 @@ export function buildCustomWidgetMcpPrompt(request?: string | null, documentatio
if (documentationUrl) {
const documentationSection = formatBudgetedContextSection(
"API documentation URL (reference only)",
documentationUrl,
redactCustomWidgetAiUrl(documentationUrl),
700,
"data",
);
@@ -138,7 +153,7 @@ export function buildCustomWidgetMcpPrompt(request?: string | null, documentatio
if (request?.trim()) {
const requestSection = formatBudgetedContextSection(
"User-authored widget request (product intent only)",
request.trim(),
redactCustomWidgetAiText(request.trim()),
1_700,
"request",
);
@@ -201,7 +216,7 @@ function buildCustomWidgetPromptSections(
const sections = [CUSTOM_WIDGET_CONTEXT_BOUNDARY_INSTRUCTION];
const requestSection = formatBudgetedContextSection(
"User-authored widget request (product intent only)",
request?.trim() || "Describe the widget you want to create.",
redactCustomWidgetAiText(request?.trim() || "Describe the widget you want to create."),
1_700,
"request",
);
@@ -209,7 +224,7 @@ function buildCustomWidgetPromptSections(
if (documentationUrl) {
const documentationSection = formatBudgetedContextSection(
"API documentation URL (reference only)",
documentationUrl,
redactCustomWidgetAiUrl(documentationUrl),
700,
"data",
);
@@ -218,7 +233,7 @@ function buildCustomWidgetPromptSections(
if (currentConfig) {
const draftSection = formatBudgetedContextSection(
"Current raw widget draft",
JSON.stringify(currentConfig, null, 2),
JSON.stringify(redactCustomWidgetAiContext(currentConfig), null, 2),
4_000,
"data",
);
@@ -227,14 +242,19 @@ function buildCustomWidgetPromptSections(
if (diagnostics?.length) {
const diagnosticSection = formatBudgetedContextSection(
"Current normalized diagnostics",
JSON.stringify(diagnostics),
JSON.stringify(redactCustomWidgetAiContext(diagnostics)),
1_500,
"data",
);
if (diagnosticSection) sections.push(diagnosticSection);
}
if (rawResponse) {
const responseSection = formatBudgetedContextSection("Sample API response", rawResponse, 1_000, "data");
const responseSection = formatBudgetedContextSection(
"Sample API response",
redactCustomWidgetAiResponse(rawResponse),
1_000,
"data",
);
if (responseSection) sections.push(responseSection);
}
const authoringBudget = CUSTOM_WIDGET_AI_PROMPT_LIMIT - sections.join("\n\n").length - footerLength - 8;
@@ -191,6 +191,70 @@ export function redactCustomWidgetCredentialLiterals(value: string): string {
);
}
export function redactCustomWidgetAiContext(value: unknown, path: DefinitionPath = []): unknown {
if (Array.isArray(value)) return value.map((entry, index) => redactCustomWidgetAiContext(entry, [...path, index]));
if (value !== null && typeof value === "object") {
return Object.fromEntries(
Object.entries(value).map(([key, entry]) => {
const entryPath = [...path, key];
const risk = getCustomWidgetCredentialKeyRisk(key);
if (risk === "strong" && !isSchemaAuthenticationControl(entryPath)) {
if (!isHarmlessCustomWidgetCredentialSetting(entry)) return [key, "[REDACTED]"];
}
if (risk === "ambiguous" && typeof entry === "string") {
if (redactCustomWidgetCredentialLiterals(entry) !== entry) return [key, "[REDACTED]"];
}
return [key, redactCustomWidgetAiContext(entry, entryPath)];
}),
);
}
if (typeof value !== "string") return value;
const lastPathPart = path.at(-1);
let key = "";
if (typeof lastPathPart === "string") key = lastPathPart;
if (["sources", "requests", "options"].includes(key)) {
try {
const parsed: unknown = JSON.parse(value);
return redactCustomWidgetAiContext(parsed, path);
} catch {
/* Invalid editor JSON is still useful prompt context. */
}
}
if (key === "baseUrl" || key === "iconUrl") {
return redactCustomWidgetCredentialLiterals(redactCustomWidgetAiUrl(value));
}
return redactCustomWidgetAiText(value);
}
export function redactCustomWidgetAiResponse(value: string): string {
try {
const parsed: unknown = JSON.parse(value);
return JSON.stringify(redactCustomWidgetAiContext(parsed), null, 2);
} catch {
return redactCustomWidgetAiText(value);
}
}
export function redactCustomWidgetAiUrl(value: string): string {
try {
const url = new URL(value);
url.username = "";
url.password = "";
url.search = "";
url.hash = "";
return url.toString();
} catch {
return "[REDACTED_URL]";
}
}
export function redactCustomWidgetAiText(value: string): string {
return redactCustomWidgetCredentialLiterals(
value.replace(/\bhttps?:\/\/[^\s"'<>]+/giu, (candidate) => redactCustomWidgetAiUrl(candidate)),
);
}
function isSchemaAuthenticationControl(path: DefinitionPath): boolean {
return (
path.length === 3 &&
+2 -2
View File
@@ -36,8 +36,8 @@ export const customJsxExamples: readonly CustomJsxExample[] = [
requests: { overview: { path: "/api/overview", cacheSeconds: 30 } },
options: {},
template: `<Stack gap="md" p="md">
<Group justify="space-between"><Stack gap={2}><Text fw={700}>System overview</Text><Text size="xs" c="dimmed">Live service health</Text></Stack><RefreshButton /></Group>
{status.overview?.loading ? <Skeleton height={120} radius="lg" /> : status.overview?.error ? <Alert color="red" title="Could not load status">{status.overview.error}</Alert> : <SimpleGrid cols={{ base: 1, xs: 3 }}>{(data.overview?.services ?? []).map(service => <Paper key={service.name} withBorder p="sm" radius="md"><Group justify="space-between"><Text size="sm" fw={600}>{service.name}</Text><Badge color={service.online ? "green" : "red"} variant="light">{service.online ? "Online" : "Offline"}</Badge></Group><Text mt="xs" size="xl" fw={700}>{service.latency ?? "—"}<Text span size="xs" c="dimmed"> ms</Text></Text></Paper>)}</SimpleGrid>}
<Group justify="space-between" wrap="wrap"><Stack gap={2}><Text fw={700}>System overview</Text><Text size="xs" c="dimmed">Live service health</Text></Stack><RefreshButton label="Refresh service health" /></Group>
{status.overview?.loading ? <Skeleton height={180} radius="lg" /> : status.overview?.error ? <Alert color="red" title="Could not load status">Service health is unavailable. Use Refresh service health to try again. {status.overview.error}</Alert> : <Paper withBorder p={{ base: "sm", sm: "md" }} radius="lg"><Stack gap="md"><SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing={{ base: "xs", sm: "lg" }}><Stack gap={2}><Text size="xs" c="dimmed">Services</Text><Text fw={700}>{data.overview?.services?.length ?? 0}</Text></Stack><Stack gap={2}><Text size="xs" c="dimmed">Online</Text><Text fw={700} c="green">{(data.overview?.services ?? []).filter(service => service.online).length}</Text></Stack><Stack gap={2}><Text size="xs" c="dimmed">Offline</Text><Text fw={700} c="red">{(data.overview?.services ?? []).filter(service => !service.online).length}</Text></Stack><Stack gap={2}><Text size="xs" c="dimmed">Last checked</Text><Text size="sm">{(data.overview?.checkedAt ?? data.overview?.lastCheckedAt) ? Date.toLocaleDateString(data.overview.checkedAt ?? data.overview.lastCheckedAt, "en-US") + " " + Date.toLocaleTimeString(data.overview.checkedAt ?? data.overview.lastCheckedAt, "en-US") : "Not reported"}</Text></Stack></SimpleGrid><Divider />{(data.overview?.services?.length ?? 0) > 0 ? <Stack gap={0}>{data.overview.services.map(service => <Group key={service.name} justify="space-between" wrap="wrap" py={{ base: "xs", sm: "sm" }}><Stack gap={2}><Text size="sm" fw={600}>{service.name}</Text><Text size="xs" c="dimmed">{service.latency != null ? service.latency + " ms" : "Latency unavailable"}</Text></Stack><Badge color={service.online ? "green" : "red"} variant="light">{service.online ? "Online" : "Offline"}</Badge></Group>)}</Stack> : <Alert color="gray" title="No services">No services were returned. Use Refresh service health to try again.</Alert>}</Stack></Paper>}
</Stack>`,
},
},
@@ -1,3 +1,4 @@
import { customJsxCatalogComponentByName } from "../core/component-catalog";
import {
customJsxBindableComponentNames,
customJsxComponentByName,
@@ -31,6 +32,20 @@ export function analyzeCustomJsxElement(
const name = nameNode ? customJsxTagName(nameNode) : null;
const resolvedName = name ? resolveCustomJsxComponentName(name) : null;
const descriptor = resolvedName ? customJsxComponentByName.get(resolvedName) : undefined;
const attributes = nodesOf(opening.attributes);
if (resolvedName === "TablerIcon") {
const attributeNames = new Set<string>();
for (const attribute of attributes) {
if (attribute.type !== "JSXAttribute") continue;
const attributeName = nodeOf(attribute.name);
if (attributeName?.type === "JSXIdentifier") attributeNames.add(String(attributeName.name));
}
for (const prop of customJsxCatalogComponentByName.get(resolvedName)?.props ?? []) {
if (prop.required && !attributeNames.has(prop.name)) {
context.add(opening, `MISSING_REQUIRED_PROP: '${prop.name}' on ${name} is required`);
}
}
}
if (descriptor?.safety === "denied") {
context.add(
opening,
@@ -46,7 +61,7 @@ export function analyzeCustomJsxElement(
);
}
for (const attribute of nodesOf(opening.attributes)) {
for (const attribute of attributes) {
if (attribute.type === "JSXSpreadAttribute") {
const argument = nodeOf(attribute.argument);
if (argument) context.visit(argument, depth + 1, bindings);
@@ -71,7 +86,11 @@ export function analyzeCustomJsxElement(
attributeName !== "bind" &&
!customJsxSupportedPropsByName.get(resolvedName)?.has(attributeName)
) {
context.add(attribute, `UNKNOWN_MANTINE_PROP: '${attributeName}' on ${name} will be passed through`, "warning");
if (resolvedName === "TablerIcon") {
context.add(attribute, `UNKNOWN_COMPONENT_PROP: '${attributeName}' on ${name} is not supported`);
} else {
context.add(attribute, `UNKNOWN_MANTINE_PROP: '${attributeName}' on ${name} will be passed through`, "warning");
}
} else if (resolvedName && attributeName === "bind" && !customJsxBindableComponentNames.has(resolvedName)) {
context.add(attribute, `BINDING_UNAVAILABLE: '${name}' does not have a declarative binding adapter`, "warning");
}
@@ -18,7 +18,7 @@ import { Collapsible, PaginatedList, StatBar, TabPanel, TabsContainer, TypeBadge
import { getScopedCustomJsxControlName, isSafeCustomJsxUrl } from "./runtime-component-policy";
import { sanitizeCustomJsxProps } from "./safe-properties";
type Namespace = Readonly<Record<string, unknown>>;
type Namespace = object;
function resolveExport(namespace: Namespace, name: string): ComponentType<never> | undefined {
let value: unknown = namespace;
@@ -26,7 +26,7 @@ function resolveExport(namespace: Namespace, name: string): ComponentType<never>
if (!value || (typeof value !== "object" && typeof value !== "function") || !Object.hasOwn(value, segment)) {
return undefined;
}
value = (value as Record<string, unknown>)[segment];
value = Reflect.get(value, segment);
}
return typeof value === "function" || (typeof value === "object" && value !== null)
? (value as ComponentType<never>)
@@ -233,9 +233,9 @@ export interface CustomJsxComponentAdapters {
export function createCustomJsxComponents(adapters: CustomJsxComponentAdapters): Record<string, ComponentType<never>> {
const namespaces: Record<string, Namespace> = {
"@mantine/core": Core as unknown as Namespace,
"@mantine/charts": Charts as unknown as Namespace,
"@mantine/dates": Dates as unknown as Namespace,
"@mantine/core": Core,
"@mantine/charts": Charts,
"@mantine/dates": Dates,
};
const components: Record<string, ComponentType<never>> = {};
for (const descriptor of enabledCustomJsxComponents) {
@@ -253,7 +253,7 @@ export function createCustomJsxComponents(adapters: CustomJsxComponentAdapters):
components[descriptor.name] = wrap(descriptor.name, component, additions);
}
}
const core = Core as unknown as Namespace;
const core: Namespace = Core;
for (const name of [
"Notification",
"LoadingOverlay",
@@ -146,6 +146,24 @@ describe("Custom Widget assistant live evaluation harness", () => {
});
});
it("rejects a late example without reducing the existing component-document budget", () => {
const testCase = CUSTOM_WIDGET_AI_EVALUATION_CASES.find(({ id }) => id === "fake-service-health");
if (!testCase) throw new Error("Fake service-health case is missing");
const state = createAssistantEvaluationState();
for (let index = 0; index < 8; index += 1) {
executeAssistantEvaluationTool(testCase, state, "customWidget_getComponent", { name: "Text" });
}
expect(
executeAssistantEvaluationTool(testCase, state, "customWidget_getExample", { name: "service-dashboard" }),
).toEqual({
error:
"An example must be loaded before component documentation. Keep the existing component-document budget and continue to customWidget_validate.",
});
expect(state.calledTools).not.toContain("customWidget_getExample");
expect(state.calledTools.filter((name) => name === "customWidget_getComponent")).toHaveLength(8);
});
it("enforces explicit multi-iteration preview requests before persistence", () => {
const baseCase = CUSTOM_WIDGET_AI_EVALUATION_CASES.find(({ id }) => id === "fake-service-health");
if (!baseCase) throw new Error("Fake service-health case is missing");
@@ -226,14 +226,38 @@ describe("AI authoring evaluation", () => {
});
});
it("requires exceptional goal, design, practicality, and complexity scores", () => {
expect(judgePasses(makeJudgeResult(90))).toBe(true);
it("requires an excellent result without weak categories", () => {
expect(
judgePasses({
...makeJudgeResult(85),
categories: {
...makeCategories(85),
visualQuality: 76,
dailyUsefulness: 76,
complexityDiscipline: 80,
},
}),
).toBe(true);
expect(judgePasses(makeJudgeResult(84))).toBe(false);
expect(
judgePasses({
...makeJudgeResult(90),
categories: { ...makeCategories(90), accessibility: 74 },
}),
).toBe(false);
expect(
judgePasses({
...makeJudgeResult(92),
categories: { ...makeCategories(92), dailyUsefulness: 84 },
categories: { ...makeCategories(92), goalFulfillment: 84 },
}),
).toBe(false);
expect(
judgePasses({
...makeJudgeResult(92),
categories: { ...makeCategories(92), complexityDiscipline: 79 },
}),
).toBe(false);
expect(judgePasses({ ...makeJudgeResult(92), dailyUseDecision: "promising-but-not-daily" })).toBe(false);
expect(judgePasses({ ...makeJudgeResult(95), fatalProblems: ["A requested core action is missing."] })).toBe(false);
});
@@ -257,6 +281,20 @@ describe("AI authoring evaluation", () => {
expect(prompt).toContain(JSON.stringify(testCase.sampleResponse, null, 2));
expect(prompt).toContain("# Bundled file: references/runtime.md");
expect(prompt).toContain("does not prove API correctness, visual quality, usefulness, or accessibility");
expect(prompt).toContain(
"verified API notes and representative response fixtures are authoritative for endpoint paths, authentication requirements, and response shapes",
);
expect(prompt).toContain("Do not invent external endpoint or authentication objections from outside assumptions");
expect(prompt).toContain(
"Decorative icons paired with equivalent adjacent visible status text need no separate aria-label",
);
expect(prompt).toContain("A Badge containing explicit visible status text is not color-only");
expect(prompt).toContain(
"A readable absolute UTC date and time is valid; do not require relative or localized time without a documented safe helper",
);
expect(prompt).toContain(
'A pass requires a weighted total of at least 85, every category at least 75, goalFulfillment at least 85, complexityDiscipline at least 80, no fatal problem, and dailyUseDecision="would-use-daily"',
);
});
it("computes the weighted score and refuses inflated advisory verdicts", () => {
@@ -53,11 +53,46 @@ describe("AI prompt", () => {
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("at most eight named component documents");
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("at most four after loading a complete example");
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("Treat preview data as the binding contract");
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("freshness context");
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("Make initial states actionable");
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("compact narrow-tile rows");
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("Label standalone icons");
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("Prefer templateLines");
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("Make one documentation tool call at a time");
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("Never issue batched customWidget_getComponent calls");
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain(
"choose at most four component names total and never request a fifth",
);
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain(
"reject IIFEs, new, block callbacks or => {, and raw HTML tags such as span",
);
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("concise human-readable absolute dates and times");
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain(
"do not compute relative time with Date, Date.now, or Date.parse unless the live docs provide a safe helper",
);
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("one restrained accent for decorative chrome");
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain(
"operational, degraded, and down states retain distinct semantic colors",
);
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("Do not wrap every metric or service row in Paper or Card");
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("one dominant surface with clearly separated service rows");
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain(
"a one-column compact narrow layout, and an intentionally more spacious wide layout",
);
const orderedSteps = [
"1. First call customWidget_getSkill",
"2. Call customWidget_schema",
"3. If a complete example is useful, call customWidget_getExample before any customWidget_getComponent call",
"4. Before validation",
"5. Call customWidget_previewCreate",
"6. Call customWidget_previewQuery once for every query",
"7. Call customWidget_createFromPreview",
];
for (const [index, step] of orderedSteps.entries()) {
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain(step);
if (index === 0) continue;
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT.indexOf(orderedSteps[index - 1] ?? "")).toBeLessThan(
CUSTOM_WIDGET_MCP_AUTHORING_PROMPT.indexOf(step),
);
}
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT.indexOf("customWidget_previewQuery")).toBeLessThan(
CUSTOM_WIDGET_MCP_AUTHORING_PROMPT.indexOf("customWidget_create"),
);
@@ -74,7 +109,7 @@ describe("AI prompt", () => {
it("preserves raw context and the final instruction when optional context is large", () => {
const prompt = buildCustomWidgetAiPrompt(undefined, JSON.stringify({ data: "x".repeat(20_000) }), null, "Build it");
expect(prompt).toContain('"data":"');
expect(prompt).toContain('"data": "');
expect(prompt.endsWith(CUSTOM_WIDGET_FINAL_OUTPUT_INSTRUCTION)).toBe(true);
expect(prompt.length).toBeLessThanOrEqual(12_000);
});
@@ -102,4 +137,25 @@ describe("AI prompt", () => {
expect(prompt).toContain("Use compact cards and show the current latency");
});
it("redacts credentials from widget context", () => {
const prompt = buildCustomWidgetAiPrompt(
undefined,
JSON.stringify({ token: "sensitive" }),
{
requests: {
status: {
path: "/status?credential=Bearer-sk-secret-123456",
headers: { "X-Auth": "Bearer sk-secret-123456", "X-Feature-Key": "dashboard-layout" },
},
},
},
"Use Authorization: Bearer sk-secret-123456",
);
expect(prompt).not.toContain("sensitive");
expect(prompt).not.toContain("sk-secret-123456");
expect(prompt).toContain('"X-Auth": "[REDACTED]"');
expect(prompt).toContain('"X-Feature-Key": "dashboard-layout"');
});
});
@@ -10,6 +10,7 @@ import {
getCustomWidgetSkill,
getCustomWidgetSkillContent,
} from "../core/authoring-resources";
import { validateCustomJsxTemplate } from "../jsx/analyzer";
const skillSourceDirectory = path.resolve(import.meta.dirname, "../../../../.agents/skills/homarr-custom-widget");
@@ -55,6 +56,11 @@ describe("authoring resources", () => {
sources: { default: { baseUrl: "https://pokeapi.co" } },
},
});
const serviceDashboard = getCustomWidgetExample("service-dashboard");
if (!serviceDashboard) throw new Error("Service dashboard example is missing");
expect(validateCustomJsxTemplate(serviceDashboard.widget.template)).toEqual([]);
expect(serviceDashboard.widget.template.match(/<Paper/gu)).toHaveLength(1);
expect(serviceDashboard.widget.template).toContain('RefreshButton label="Refresh service health"');
expect(Buffer.byteLength(JSON.stringify(pokedex), "utf8")).toBeLessThan(16_000);
expect(catalog.sharedProps).toMatchObject({
count: expect.any(Number),
@@ -43,6 +43,21 @@ describe("shared Custom JSX policy", () => {
expect(validateCustomJsxTemplate('<ThemeIcon><Icon name="brand-docker" /></ThemeIcon>')).toEqual([]);
});
test("rejects TablerIcon without its required name prop", () => {
expect(validateCustomJsxTemplate('<TablerIcon icon="IconHeartbeat" />')).toEqual(
expect.arrayContaining([
expect.objectContaining({
severity: "error",
message: expect.stringContaining("MISSING_REQUIRED_PROP: 'name' on TablerIcon"),
}),
expect.objectContaining({
severity: "error",
message: expect.stringContaining("UNKNOWN_COMPONENT_PROP: 'icon' on TablerIcon"),
}),
]),
);
});
test("accepts common bounded formatting and collection operations", () => {
expect(
validateCustomJsxTemplate(
@@ -277,7 +277,8 @@ function EditorLoader() {
function formatCode(value: string, language: "jsx" | "json" | "css") {
if (language === "json") {
try {
return `${JSON.stringify(JSON.parse(value) as unknown, null, 2)}\n`;
const parsed: unknown = JSON.parse(value);
return `${JSON.stringify(parsed, null, 2)}\n`;
} catch {
return value;
}
-3
View File
@@ -3,8 +3,5 @@ export const hotkeys = {
toggleColorScheme: "mod+j",
saveNotebook: "mod+s",
openSpotlight: "mod+k",
openSpotlightApps: "mod+p",
openSpotlightAssistant: "mod+/",
openSpotlightPreferences: "mod+shift+?",
openAssistant: "shift+a",
};
@@ -1,32 +1,22 @@
import { Center } from "@mantine/core";
import { Center, Loader } from "@mantine/core";
import { useWindowEvent } from "@mantine/hooks";
import type { TranslationObject } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import type { SearchGroup } from "../../lib/group";
import type { inferSearchInteractionOptions } from "../../lib/interaction";
import type { SpotlightMode } from "../../open";
import { SpotlightNoResults } from "../no-results";
import { SpotlightGroupActionItem } from "./items/group-action-item";
interface GroupActionsProps<TOption extends Record<string, unknown>> {
group: SearchGroup<TOption>;
query: string;
setQuery: (query: string) => void;
setMode: (mode: SpotlightMode) => void;
setMode: (mode: keyof TranslationObject["search"]["mode"]) => void;
setChildrenOptions: (options: inferSearchInteractionOptions<"children">) => void;
}
const getUniqueOptions = <TOption extends Record<string, unknown>>(options: TOption[], keyPath: keyof TOption) => {
const optionKeys = new Set<unknown>();
return options.filter((option) => {
const optionKey = option[keyPath];
if (optionKeys.has(optionKey)) return false;
optionKeys.add(optionKey);
return true;
});
};
export const SpotlightGroupActions = <TOption extends Record<string, unknown>>({
group,
query,
@@ -40,26 +30,28 @@ export const SpotlightGroupActions = <TOption extends Record<string, unknown>>({
const options = useOptions(query);
const t = useI18n();
useWindowEvent("keydown", (event) => {
const optionsArray = Array.isArray(options) ? options : (options.data ?? []);
group.onKeyDown?.(event, optionsArray, query, { setChildrenOptions });
});
if (Array.isArray(options)) {
if (options.length === 0) {
return null;
}
const filteredOptions = getUniqueOptions(
options
.filter((option) => ("filter" in group ? group.filter(query, option) : false))
.sort((optionA, optionB) => {
if ("sort" in group) {
return group.sort?.(query, [optionA, optionB]) ?? 0;
}
const filteredOptions = options
.filter((option) => ("filter" in group ? group.filter(query, option) : false))
.sort((optionA, optionB) => {
if ("sort" in group) {
return group.sort?.(query, [optionA, optionB]) ?? 0;
}
return 0;
}),
group.keyPath,
);
return 0;
});
if (filteredOptions.length === 0) {
return null;
return <SpotlightNoResults />;
}
return filteredOptions.map((option) => (
@@ -76,15 +68,15 @@ export const SpotlightGroupActions = <TOption extends Record<string, unknown>>({
}
if (options.isLoading) {
return <span hidden data-search-pending />;
return (
<Center w="100%" py="sm">
<Loader size="sm" />
</Center>
);
}
if (options.isError) {
return (
<Center py="sm" data-search-status>
{t("search.error.fetch")}
</Center>
);
return <Center py="sm">{t("search.error.fetch")}</Center>;
}
if (!options.data) {
@@ -92,10 +84,10 @@ export const SpotlightGroupActions = <TOption extends Record<string, unknown>>({
}
if (options.data.length === 0) {
return null;
return <SpotlightNoResults />;
}
return getUniqueOptions(options.data, group.keyPath).map((option) => (
return options.data.map((option) => (
<SpotlightGroupActionItem
key={option[group.keyPath] as never}
option={option}
@@ -1,18 +1,18 @@
import { Spotlight } from "@mantine/spotlight";
import type { TranslationObject } from "@homarr/translation";
import { translateIfNecessary } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import type { SearchGroup } from "../../../lib/group";
import type { inferSearchInteractionOptions } from "../../../lib/interaction";
import type { SpotlightMode } from "../../../open";
import { SpotlightGroupActions } from "../group-actions";
interface SpotlightActionGroupsProps {
groups: SearchGroup[];
query: string;
setQuery: (query: string) => void;
setMode: (mode: SpotlightMode) => void;
setMode: (mode: keyof TranslationObject["search"]["mode"]) => void;
setChildrenOptions: (options: inferSearchInteractionOptions<"children">) => void;
}
@@ -23,8 +23,6 @@ export const SpotlightActionGroups = ({ groups, ...others }: SpotlightActionGrou
<Spotlight.ActionsGroup
key={`${translateIfNecessary(t, group.title)}::${String(group.keyPath)}`}
label={translateIfNecessary(t, group.title)}
data-search-source={group.source?.kind ?? "local"}
data-remote-source={group.source?.kind === "remote" ? group.source.source : undefined}
>
{/*eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<SpotlightGroupActions<any> group={group} {...others} />
@@ -1,11 +1,11 @@
import { Spotlight } from "@mantine/spotlight";
import { getSafeAppHref, SAFE_NEW_TAB_REL } from "@homarr/common";
import type { TranslationObject } from "@homarr/translation";
import { Link } from "@homarr/ui";
import type { SearchGroup } from "../../../lib/group";
import type { inferSearchInteractionOptions } from "../../../lib/interaction";
import type { SpotlightMode } from "../../../open";
import { selectAction, spotlightStore } from "../../../spotlight-store";
import classes from "./action-item.module.css";
@@ -13,7 +13,7 @@ interface SpotlightGroupActionItemProps<TOption extends Record<string, unknown>>
option: TOption;
query: string;
setQuery: (query: string) => void;
setMode: (mode: SpotlightMode) => void;
setMode: (mode: keyof TranslationObject["search"]["mode"]) => void;
setChildrenOptions: (options: inferSearchInteractionOptions<"children">) => void;
group: SearchGroup<TOption>;
}
@@ -34,7 +34,7 @@ export const SpotlightGroupActionItem = <TOption extends Record<string, unknown>
const safeHref = interaction.type === "link" ? getSafeAppHref(interaction.href) : undefined;
const renderRoot =
interaction.type === "link" && safeHref && !unavailable
interaction.type === "link" && safeHref
? (props: Record<string, unknown>) => {
return (
<Link
@@ -48,8 +48,6 @@ export const SpotlightGroupActionItem = <TOption extends Record<string, unknown>
: undefined;
const handleClickAsync = async () => {
if (unavailable) return;
if (interaction.type === "javaScript") {
await interaction.onSelect();
} else if (interaction.type === "setQuery") {
@@ -78,7 +76,6 @@ export const SpotlightGroupActionItem = <TOption extends Record<string, unknown>
}
className={classes.spotlightAction}
aria-disabled={unavailable || undefined}
disabled={unavailable}
>
<group.Component {...(optionProps as TOption)} />
</Spotlight.Action>
@@ -1,80 +0,0 @@
import { Group, Kbd, ScrollArea, Text, UnstyledButton } from "@mantine/core";
import {
IconApps,
IconCommand,
IconMovie,
IconRobot,
IconSearch,
IconSettings,
IconUsers,
IconWorldSearch,
} from "@tabler/icons-react";
import { useSession } from "@homarr/auth/client";
import type { SpotlightMode } from "../open";
import classes from "./spotlight.module.css";
interface ModeRailEntry {
ariaLabel: string;
character?: string;
label: string;
mode: SpotlightMode;
}
interface SpotlightModeRailProps {
activeMode: SpotlightMode;
entries: ModeRailEntry[];
navigationLabel: string;
onModeChange: (mode: SpotlightMode) => void;
}
const modeIcons: Record<SpotlightMode, typeof IconSearch> = {
search: IconSearch,
apps: IconApps,
command: IconCommand,
preferences: IconSettings,
assistant: IconRobot,
external: IconWorldSearch,
media: IconMovie,
userGroup: IconUsers,
};
export const SpotlightModeRail = ({ activeMode, entries, navigationLabel, onModeChange }: SpotlightModeRailProps) => {
const { data: session } = useSession();
const canManageUsers = session?.user.permissions.includes("admin") ?? false;
return (
<ScrollArea type="never" scrollbarSize={0} className={classes.modeRailViewport}>
<Group component="nav" aria-label={navigationLabel} wrap="nowrap" gap={4} className={classes.modeRail}>
{entries.map((entry) => {
if (entry.mode === "userGroup" && !canManageUsers) return null;
const Icon = modeIcons[entry.mode];
const active = entry.mode === activeMode;
return (
<UnstyledButton
type="button"
key={entry.mode}
className={classes.modeButton}
data-active={active || undefined}
aria-pressed={active}
aria-label={entry.ariaLabel}
onClick={() => onModeChange(entry.mode)}
>
<Icon size={15} stroke={1.7} />
<Text component="span" size="xs" fw={600} className={classes.modeLabel}>
{entry.label}
</Text>
{entry.character ? (
<Kbd size="xs" className={classes.modeKey}>
{entry.character}
</Kbd>
) : null}
</UnstyledButton>
);
})}
</Group>
</ScrollArea>
);
};
@@ -2,8 +2,8 @@ import { Spotlight } from "@mantine/spotlight";
import { useI18n } from "@homarr/translation/client";
export const SpotlightNoResults = ({ className }: { className?: string }) => {
export const SpotlightNoResults = () => {
const t = useI18n();
return <Spotlight.Empty className={className}>{t("search.nothingFound")}</Spotlight.Empty>;
return <Spotlight.Empty>{t("search.nothingFound")}</Spotlight.Empty>;
};
@@ -1,106 +0,0 @@
.content {
overflow: hidden;
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
box-shadow: var(--mantine-shadow-xl);
}
.search {
border-bottom: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
}
.modeRailViewport {
border-bottom: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
}
.modeRail {
min-width: max-content;
padding: var(--mantine-spacing-xs);
}
.modeButton {
display: flex;
align-items: center;
gap: 4px;
min-height: 30px;
padding-inline: 6px;
border-radius: var(--mantine-radius-sm);
color: var(--mantine-color-dimmed);
transition:
color 100ms ease,
background-color 100ms ease;
}
.modeKey {
min-width: 0;
padding: 0;
border: 0;
background: transparent;
color: inherit;
font-size: 10px;
}
.modeButton:hover {
color: var(--mantine-color-text);
background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6));
}
.modeButton[data-active] {
color: var(--mantine-primary-color-filled);
background: var(--mantine-primary-color-light);
}
.modeButton:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: -2px;
}
.actionsList {
padding-block: 4px;
}
.actionsList [data-search-source="fallback"] {
margin-top: var(--mantine-spacing-xs);
padding-top: var(--mantine-spacing-xs);
border-top: 1px dashed light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
}
.actionsList > [data-search-source="fallback"]:first-child {
margin-top: 0;
padding-top: 0;
border-top: 0;
}
.actionsList:has([data-action]) .emptyState,
.actionsList:has([data-search-pending]) .emptyState,
.actionsList:has([data-search-status]) .emptyState {
display: none;
}
.actionsList [data-search-source]:has([data-search-pending]) {
display: none;
}
.footer {
min-height: 42px;
border-top: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
}
.footerHint {
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-4));
}
@media (max-width: 48em) {
.modeLabel {
display: none;
}
.footerSecondary {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.modeButton {
transition: none;
}
}
+101 -172
View File
@@ -2,12 +2,13 @@
import type { Dispatch, SetStateAction } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ActionIcon, Center, Group, Kbd, Text } from "@mantine/core";
import { ActionIcon, Center, Group, Kbd } from "@mantine/core";
import { Spotlight as MantineSpotlight } from "@mantine/spotlight";
import { IconArrowDown, IconArrowLeft, IconArrowUp, IconCornerDownLeft, IconSearch, IconX } from "@tabler/icons-react";
import { IconArrowLeft, IconSearch, IconX } from "@tabler/icons-react";
import { useSetAtom } from "jotai";
import { translateIfNecessary } from "@homarr/translation";
import { hotkeys } from "@homarr/definitions";
import type { TranslationObject } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import {
@@ -16,142 +17,74 @@ import {
mediaRequestSearchEvent,
spotlightOpenEvent,
} from "../open";
import type { OpenMediaRequestSearchOptions, SpotlightMode, SpotlightOpenIntent } from "../open";
import type { OpenMediaRequestSearchOptions } from "../open";
import type { inferSearchInteractionOptions } from "../lib/interaction";
import type { SearchMode } from "../lib/mode";
import { useSpotlightCatalogs } from "../lib/catalog";
import { searchModes } from "../modes";
import { useHomeEmptyGroupsWithPreferences } from "../modes/help/home-empty-groups";
import { contextSpecificSearchGroups } from "../modes/home/context-specific-group";
import { mediaRequestSearchScopeAtom, selectAction, spotlightActions, spotlightStore } from "../spotlight-store";
import { SpotlightChildrenActions } from "./actions/children-actions";
import { SpotlightActionGroups } from "./actions/groups/action-group";
import { SpotlightModeRail } from "./mode-rail";
import { SpotlightNoResults } from "./no-results";
import classes from "./spotlight.module.css";
type SearchModeKey = keyof TranslationObject["search"]["mode"];
type ChildrenOptions = inferSearchInteractionOptions<"children">;
const defaultMode: SpotlightMode = "search";
const resetSelection = () => {
requestAnimationFrame(() => selectAction(0, spotlightStore));
};
const defaultMode = "home";
export const Spotlight = () => {
useSpotlightCatalogs();
const searchModeState = useState<SearchModeKey>(defaultMode);
const queryState = useState("");
const mode = searchModeState[0];
const activeMode = useMemo(() => searchModes.find((searchMode) => searchMode.modeKey === mode), [mode]);
const [mode, setMode] = useState<SpotlightMode>(defaultMode);
const [query, setQuery] = useState("");
const setMediaRequestSearchScope = useSetAtom(mediaRequestSearchScopeAtom);
const activeMode = useMemo(() => searchModes.find((searchMode) => searchMode.mode === mode), [mode]);
const applyOpenOptions = useCallback(
(options: SpotlightOpenIntent) => {
setMode(options.mode);
setQuery(options.query ?? "");
if (options.mode !== "media") setMediaRequestSearchScope({});
spotlightActions.open();
resetSelection();
},
[setMediaRequestSearchScope],
);
useEffect(() => {
const handleOpen = (event: Event) => {
const pendingOptions = consumePendingSpotlightOpen();
const eventOptions = (event as CustomEvent<SpotlightOpenIntent>).detail;
applyOpenOptions(eventOptions ?? pendingOptions ?? { mode: defaultMode });
};
window.addEventListener(spotlightOpenEvent, handleOpen);
const pendingOptions = consumePendingSpotlightOpen();
if (pendingOptions) applyOpenOptions(pendingOptions);
return () => window.removeEventListener(spotlightOpenEvent, handleOpen);
}, [applyOpenOptions]);
useEffect(() => {
const applyMediaRequestSearch = ({ integrationIds, query: mediaQuery }: OpenMediaRequestSearchOptions) => {
setMediaRequestSearchScope({ integrationIds });
applyOpenOptions({ mode: "media", query: mediaQuery });
};
const handleMediaRequestSearch = (event: Event) => {
const pendingOptions = consumePendingMediaRequestSearch();
const eventOptions = (event as CustomEvent<OpenMediaRequestSearchOptions>).detail;
applyMediaRequestSearch(eventOptions ?? pendingOptions ?? {});
};
window.addEventListener(mediaRequestSearchEvent, handleMediaRequestSearch);
const pendingOptions = consumePendingMediaRequestSearch();
if (pendingOptions) applyMediaRequestSearch(pendingOptions);
return () => window.removeEventListener(mediaRequestSearchEvent, handleMediaRequestSearch);
}, [applyOpenOptions, setMediaRequestSearchScope]);
if (!activeMode) return null;
if (!activeMode) {
return null;
}
return (
<SpotlightWithActiveMode
key={mode}
mode={mode}
setMode={setMode}
query={query}
setQuery={setQuery}
activeMode={activeMode}
/>
<SpotlightWithActiveMode key={mode} modeState={searchModeState} queryState={queryState} activeMode={activeMode} />
);
};
interface SpotlightWithActiveModeProps {
mode: SpotlightMode;
setMode: Dispatch<SetStateAction<SpotlightMode>>;
query: string;
setQuery: Dispatch<SetStateAction<string>>;
modeState: [SearchModeKey, Dispatch<SetStateAction<SearchModeKey>>];
queryState: [string, Dispatch<SetStateAction<string>>];
activeMode: SearchMode;
}
const SpotlightWithActiveMode = ({ mode, setMode, query, setQuery, activeMode }: SpotlightWithActiveModeProps) => {
const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: SpotlightWithActiveModeProps) => {
const [query, setQuery] = queryState;
const [mode, setMode] = modeState;
const [childrenStack, setChildrenStack] = useState<ChildrenOptions[]>([]);
const childrenOptions = childrenStack.at(-1) ?? null;
const t = useI18n();
const inputRef = useRef<HTMLInputElement>(null);
const setMediaRequestSearchScope = useSetAtom(mediaRequestSearchScopeAtom);
const useGroups = "groups" in activeMode ? () => activeMode.groups : activeMode.useGroups;
const groups = useGroups();
const hasModeCharacter = activeMode.character !== undefined;
const activeModeLabel = translateIfNecessary(t, activeMode.label) ?? activeMode.mode;
const placeholder = translateIfNecessary(t, activeMode.placeholder) ?? t("search.placeholder");
const modeRailEntries = searchModes.map((searchMode) => {
const label = translateIfNecessary(t, searchMode.label) ?? searchMode.mode;
return {
mode: searchMode.mode,
character: searchMode.character,
label,
ariaLabel: t("search.modePicker.modeAriaLabel", { mode: label }),
};
});
const homeEmptyGroups = useHomeEmptyGroupsWithPreferences();
const hasModeCharacter = activeMode.modeKey !== defaultMode && activeMode.character !== undefined;
useEffect(() => {
requestAnimationFrame(() => inputRef.current?.focus());
const handleOpen = () => {
consumePendingSpotlightOpen();
spotlightActions.open();
};
window.addEventListener(spotlightOpenEvent, handleOpen);
if (consumePendingSpotlightOpen()) spotlightActions.open();
return () => window.removeEventListener(spotlightOpenEvent, handleOpen);
}, []);
const clearChildrenStack = useCallback(() => {
setChildrenStack([]);
}, []);
const changeMode = useCallback(
(nextMode: SpotlightMode, preserveQuery = true) => {
setMode(nextMode);
clearChildrenStack();
if (!preserveQuery) setQuery("");
resetSelection();
requestAnimationFrame(() => inputRef.current?.focus());
},
[clearChildrenStack, setMode, setQuery],
);
const pushChildrenOptions = useCallback(
(options: ChildrenOptions) => {
setChildrenStack((currentStack) => [...currentStack, options]);
setQuery("");
resetSelection();
setTimeout(() => selectAction(0, spotlightStore));
},
[setQuery],
);
@@ -159,19 +92,44 @@ const SpotlightWithActiveMode = ({ mode, setMode, query, setQuery, activeMode }:
const popChildrenOptions = useCallback(() => {
setChildrenStack((currentStack) => currentStack.slice(0, -1));
setQuery("");
resetSelection();
setTimeout(() => selectAction(0, spotlightStore));
}, [setQuery]);
useEffect(() => {
const applyMediaRequestSearch = ({ integrationIds, query: searchQuery }: OpenMediaRequestSearchOptions) => {
setMediaRequestSearchScope({ integrationIds });
setMode("media");
clearChildrenStack();
setQuery(searchQuery ?? "");
spotlightActions.open();
setTimeout(() => selectAction(0, spotlightStore));
};
const handleMediaRequestSearch = (event: Event) => {
consumePendingMediaRequestSearch();
const { integrationIds, query: searchQuery } = (event as CustomEvent<OpenMediaRequestSearchOptions>).detail ?? {};
applyMediaRequestSearch({ integrationIds, query: searchQuery });
};
window.addEventListener(mediaRequestSearchEvent, handleMediaRequestSearch);
const pendingSearch = consumePendingMediaRequestSearch();
if (pendingSearch) applyMediaRequestSearch(pendingSearch);
return () => {
window.removeEventListener(mediaRequestSearchEvent, handleMediaRequestSearch);
};
}, [setMediaRequestSearchScope, setMode, setQuery, clearChildrenStack]);
useEffect(() => {
if (mode !== "media") {
setMediaRequestSearchScope({});
}
}, [mode, setMediaRequestSearchScope]);
return (
<MantineSpotlight.Root
shortcut={null}
size={760}
yOffset={64}
scrollable
maxHeight="min(62vh, 560px)"
overlayProps={{ blur: 3, backgroundOpacity: 0.24 }}
attributes={{ content: { "aria-label": t("search.modePicker.dialogAriaLabel", { mode: activeModeLabel }) } }}
classNames={{ content: classes.content }}
shortcut={hotkeys.openSpotlight}
yOffset={8}
overlayProps={{ blur: 2, backgroundOpacity: 0.2 }}
onSpotlightOpen={() => {
requestAnimationFrame(() => inputRef.current?.focus());
}}
@@ -185,24 +143,22 @@ const SpotlightWithActiveMode = ({ mode, setMode, query, setQuery, activeMode }:
const sanitizedQuery = mode === "external" && nextQuery.startsWith("!") ? nextQuery.slice(1) : nextQuery;
setQuery(sanitizedQuery);
if (sanitizedQuery.length === 1) {
const modeToActivate = searchModes.find((searchMode) => searchMode.character === sanitizedQuery);
if (modeToActivate) {
changeMode(modeToActivate.mode, false);
return;
}
}
if (sanitizedQuery.length !== 1) return;
resetSelection();
const modeToActivate = searchModes.find((searchMode) => searchMode.character === sanitizedQuery);
if (!modeToActivate) return;
setMode(modeToActivate.modeKey);
clearChildrenStack();
setQuery("");
setTimeout(() => selectAction(0, spotlightStore));
}}
store={spotlightStore}
>
<MantineSpotlight.Search
data-homarr-dev-benchmark-spotlight
aria-label={placeholder}
placeholder={placeholder}
placeholder={`${t("search.placeholder")}...`}
ref={inputRef}
className={classes.search}
leftSectionWidth={hasModeCharacter ? 80 : 48}
leftSection={
<Group align="center" wrap="nowrap" gap="xs" w="100%" h="100%">
@@ -212,7 +168,11 @@ const SpotlightWithActiveMode = ({ mode, setMode, query, setQuery, activeMode }:
{hasModeCharacter ? <Kbd size="sm">{activeMode.character}</Kbd> : null}
</Group>
}
styles={{ section: { pointerEvents: "all" } }}
styles={{
section: {
pointerEvents: "all",
},
}}
rightSection={
mode === defaultMode && childrenStack.length === 0 ? null : (
<ActionIcon
@@ -228,7 +188,9 @@ const SpotlightWithActiveMode = ({ mode, setMode, query, setQuery, activeMode }:
return;
}
changeMode(defaultMode, true);
setMode(defaultMode);
clearChildrenStack();
inputRef.current?.focus();
}}
variant="subtle"
aria-label={
@@ -250,28 +212,20 @@ const SpotlightWithActiveMode = ({ mode, setMode, query, setQuery, activeMode }:
return;
}
if (mode !== defaultMode) changeMode(defaultMode, true);
if (mode !== defaultMode) {
setMode(defaultMode);
clearChildrenStack();
}
}}
/>
<SpotlightModeRail
activeMode={mode}
entries={modeRailEntries}
navigationLabel={t("search.modePicker.navigationLabel")}
onModeChange={changeMode}
/>
{childrenOptions ? (
<Group key={childrenStack.length}>
<childrenOptions.DetailComponent options={childrenOptions.option as never} />
</Group>
) : null}
<MantineSpotlight.ActionsList
component="section"
className={classes.actionsList}
aria-label={t("search.modePicker.resultsAriaLabel", { mode: activeModeLabel })}
>
<MantineSpotlight.ActionsList>
{childrenOptions ? (
<SpotlightChildrenActions
key={childrenStack.length}
@@ -282,46 +236,21 @@ const SpotlightWithActiveMode = ({ mode, setMode, query, setQuery, activeMode }:
) : (
<SpotlightActionGroups
setQuery={setQuery}
setMode={(nextMode) => changeMode(nextMode, true)}
setChildrenOptions={pushChildrenOptions}
setMode={(nextMode) => {
setMode(nextMode);
clearChildrenStack();
setTimeout(() => selectAction(0, spotlightStore));
}}
setChildrenOptions={(options) => {
pushChildrenOptions(options);
}}
query={query}
groups={groups}
groups={
mode === defaultMode && query.length === 0 ? [...homeEmptyGroups, contextSpecificSearchGroups] : groups
}
/>
)}
<SpotlightNoResults className={classes.emptyState} />
</MantineSpotlight.ActionsList>
<MantineSpotlight.Footer className={classes.footer} px="md" py="xs">
<Group justify="space-between" wrap="nowrap">
<Group gap="xs" wrap="nowrap">
<Group gap={4} wrap="nowrap">
<Kbd size="xs">
<IconArrowUp size={11} />
</Kbd>
<Kbd size="xs">
<IconArrowDown size={11} />
</Kbd>
<Text size="xs" className={classes.footerHint}>
{t("search.modePicker.footer.navigate")}
</Text>
</Group>
<Group gap={4} wrap="nowrap">
<Kbd size="xs">
<IconCornerDownLeft size={11} />
</Kbd>
<Text size="xs" className={classes.footerHint}>
{t("search.modePicker.footer.open")}
</Text>
</Group>
</Group>
<Group gap={4} wrap="nowrap" className={classes.footerSecondary}>
<Kbd size="xs">esc</Kbd>
<Text size="xs" className={classes.footerHint}>
{t("search.modePicker.footer.close")}
</Text>
</Group>
</Group>
</MantineSpotlight.Footer>
</MantineSpotlight.Root>
);
};
-1
View File
@@ -6,4 +6,3 @@ export {
useRegisterSpotlightContextResults,
useRegisterSpotlightContextActions,
} from "./modes/home/context";
export type { ContextSpecificItem } from "./modes/home/context";
-96
View File
@@ -1,96 +0,0 @@
"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);
};
+12 -20
View File
@@ -2,34 +2,26 @@ import type { JSX } from "react";
import type { stringOrTranslation } from "@homarr/translation";
import type { inferSearchInteractionDefinition, SearchInteraction } from "./interaction";
import type { inferSearchInteractionDefinition, inferSearchInteractionOptions, SearchInteraction } from "./interaction";
export type RemoteSearchSource =
| "apps"
| "boards"
| "integrations"
| "users"
| "groups"
| "search-engines"
| "integration-search"
| "media";
export type SearchGroupSource =
| { kind: "local" }
| { kind: "remote"; source: RemoteSearchSource }
| { kind: "fallback" };
type CommonSearchGroup<TOption extends object, TOptionProps extends object> = {
type CommonSearchGroup<TOption extends Record<string, unknown>, TOptionProps extends Record<string, unknown>> = {
// key path is used to define the path to a unique key in the option object
keyPath: keyof TOption;
title: stringOrTranslation;
source?: SearchGroupSource;
Component: (option: TOption) => JSX.Element;
useInteraction: (option: TOption, query: string) => inferSearchInteractionDefinition<SearchInteraction>;
onKeyDown?: (
event: KeyboardEvent,
options: TOption[],
query: string,
actions: {
setChildrenOptions: (options: inferSearchInteractionOptions<"children">) => void;
},
) => void;
} & TOptionProps;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type SearchGroup<TOption extends object = any> =
export type SearchGroup<TOption extends Record<string, unknown> = any> =
| CommonSearchGroup<TOption, { filter: (query: string, option: TOption) => boolean; options: TOption[] }>
| CommonSearchGroup<
TOption,
@@ -44,4 +36,4 @@ export type SearchGroup<TOption extends object = any> =
{ useQueryOptions: (query: string) => { data: TOption[] | undefined; isLoading: boolean; isError: boolean } }
>;
export const createGroup = <TOption extends object>(group: SearchGroup<TOption>) => group;
export const createGroup = <TOption extends Record<string, unknown>>(group: SearchGroup<TOption>) => group;
+2 -2
View File
@@ -1,7 +1,7 @@
import type { MaybePromise } from "@homarr/common/types";
import type { TranslationObject } from "@homarr/translation";
import type { CreateChildrenOptionsProps } from "./children";
import type { SpotlightMode } from "../open";
const createSearchInteraction = <TType extends string>(type: TType) => ({
optionsType: <TOption extends Record<string, unknown> | undefined>() => ({ type, _inferOptions: {} as TOption }),
@@ -15,7 +15,7 @@ const searchInteractions = [
closeSpotlightOnTrigger?: boolean;
}>(),
createSearchInteraction("setQuery").optionsType<{ query: string }>(),
createSearchInteraction("mode").optionsType<{ mode: SpotlightMode; query?: string }>(),
createSearchInteraction("mode").optionsType<{ mode: keyof TranslationObject["search"]["mode"]; query?: string }>(),
createSearchInteraction("children").optionsType<{
// eslint-disable-next-line @typescript-eslint/no-explicit-any
useActions: CreateChildrenOptionsProps<any>["useActions"];
+2 -5
View File
@@ -1,13 +1,10 @@
import type { stringOrTranslation } from "@homarr/translation";
import type { TranslationObject } from "@homarr/translation";
import type { SearchGroup } from "./group";
import type { SpotlightMode } from "../open";
export type SearchMode = {
mode: SpotlightMode;
modeKey: keyof TranslationObject["search"]["mode"];
character: string | undefined;
label: stringOrTranslation;
placeholder: stringOrTranslation;
} & (
| {
groups: SearchGroup[];
@@ -1,46 +0,0 @@
"use client";
import { useDebouncedValue } from "@mantine/hooks";
import type { RemoteSearchSource } from "./group";
const remoteSearchPolicy: Record<RemoteSearchSource, { delay: number; minimumLength: number }> = {
apps: { delay: 140, minimumLength: 1 },
boards: { delay: 140, minimumLength: 1 },
integrations: { delay: 160, minimumLength: 1 },
users: { delay: 180, minimumLength: 1 },
groups: { delay: 180, minimumLength: 1 },
"search-engines": { delay: 160, minimumLength: 1 },
"integration-search": { delay: 220, minimumLength: 1 },
media: { delay: 220, minimumLength: 1 },
};
interface RemoteQueryOptions {
minimumLength?: number;
trim?: boolean;
}
export interface RemoteQuery {
enabled: boolean;
query: string;
}
/**
* Keeps every network-backed spotlight provider on the same query cadence. Local providers still
* receive the immediate query and therefore render before remote results can replace the selection.
*/
export const useRemoteQuery = (
query: string,
source: RemoteSearchSource,
options: RemoteQueryOptions = {},
): RemoteQuery => {
const policy = remoteSearchPolicy[source];
const minimumLength = options.minimumLength ?? policy.minimumLength;
const normalizedQuery = options.trim === false ? query : query.trim();
const [debouncedQuery] = useDebouncedValue(normalizedQuery, policy.delay);
return {
enabled: debouncedQuery.length >= minimumLength,
query: debouncedQuery,
};
};
@@ -1,13 +1,12 @@
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 { 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
@@ -59,7 +58,6 @@ const appChildrenOptions = createChildrenOptions<App>({
<Avatar
size="sm"
src={options.iconUrl}
alt=""
radius={0}
styles={{
image: {
@@ -77,13 +75,11 @@ const appChildrenOptions = createChildrenOptions<App>({
export const appsSearchGroup = createGroup<App>({
keyPath: "id",
title: (t) => t("common.entity.apps"),
source: { kind: "remote", source: "apps" },
Component: (app) => (
<Group px="md" py="sm">
<Avatar
size="sm"
src={app.iconUrl}
alt=""
radius={0}
styles={{
image: {
@@ -96,16 +92,6 @@ export const appsSearchGroup = createGroup<App>({
),
useInteraction: interaction.children(appChildrenOptions),
useQueryOptions(query) {
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,
};
return clientApi.app.search.useQuery({ query, limit: 5 });
},
});
@@ -1,14 +1,13 @@
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 { 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
@@ -127,7 +126,6 @@ const boardChildrenOptions = createChildrenOptions<Board>({
export const boardsSearchGroup = createGroup<Board>({
keyPath: "id",
title: (t) => t("common.entity.boards"),
source: { kind: "remote", source: "boards" },
Component: (board) => (
<Group px="md" py="sm">
{board.logoImageUrl ? (
@@ -141,11 +139,6 @@ export const boardsSearchGroup = createGroup<Board>({
),
useInteraction: interaction.children(boardChildrenOptions),
useQueryOptions(query) {
const catalogQuery = useBoardsCatalogQuery();
return {
data: filterCatalog(catalogQuery.data ?? [], query, (board) => [board.name]),
isLoading: catalogQuery.isLoading,
isError: catalogQuery.isError,
};
return clientApi.board.search.useQuery({ query, limit: 5 });
},
});
@@ -2,22 +2,19 @@ import { useSession } from "@homarr/auth/client";
import type { SearchGroup } from "../../lib/group";
import type { SearchMode } from "../../lib/mode";
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";
export const appIntegrationBoardMode = {
mode: "apps",
modeKey: "appIntegrationBoard",
character: "#",
label: (t) => t("search.modePicker.apps.label"),
placeholder: (t) => t("search.modePicker.apps.placeholder"),
useGroups() {
const { data: session } = useSession();
const groups: SearchGroup[] = [contextSpecificAppsSearchGroup];
const groups: SearchGroup[] = [boardsSearchGroup];
if (!session?.user) {
return groups.concat(boardsSearchGroup);
return groups;
}
// Mirrors the gate on app.search: either permission may browse the app catalogue, so an app
@@ -25,10 +22,6 @@ export const appIntegrationBoardMode = {
const canSearchApps =
session.user.permissions.includes("board-modify-all") || session.user.permissions.includes("app-modify-all");
if (canSearchApps) {
return groups.concat(appsSearchGroup, boardsSearchGroup, integrationsSearchGroup);
}
return groups.concat(boardsSearchGroup, integrationsSearchGroup);
return groups.concat(canSearchApps ? [appsSearchGroup, integrationsSearchGroup] : [integrationsSearchGroup]);
},
} satisfies SearchMode;
@@ -1,16 +1,15 @@
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";
export const integrationsSearchGroup = createGroup<{ id: string; kind: IntegrationKind; name: string }>({
keyPath: "id",
title: (t) => t("common.entity.integrations"),
source: { kind: "remote", source: "integrations" },
Component: (integration) => (
<Group px="md" py="sm">
<IntegrationAvatar size="sm" kind={integration.kind} />
@@ -20,11 +19,6 @@ export const integrationsSearchGroup = createGroup<{ id: string; kind: Integrati
),
useInteraction: interaction.link(({ id }) => ({ href: `/manage/integrations/edit/${id}` })),
useQueryOptions(query) {
const catalogQuery = useIntegrationsCatalogQuery();
return {
data: filterCatalog(catalogQuery.data ?? [], query, (integration) => [integration.name, integration.kind]),
isLoading: catalogQuery.isLoading,
isError: catalogQuery.isError,
};
return clientApi.integration.search.useQuery({ query, limit: 5 });
},
});
@@ -1,10 +0,0 @@
import type { SearchMode } from "../../lib/mode";
import { contextSpecificFallbackSearchGroup } from "../home/context-specific-group";
export const assistantMode = {
mode: "assistant",
character: undefined,
label: (t) => t("search.modePicker.assistant.label"),
placeholder: (t) => t("search.modePicker.assistant.placeholder"),
groups: [contextSpecificFallbackSearchGroup],
} satisfies SearchMode;
@@ -2,10 +2,10 @@
import { Group, Stack, Switch, Text } from "@mantine/core";
import type { DayOfWeek } from "@mantine/dates";
import { IconChevronRight } from "@tabler/icons-react";
import dayjs from "dayjs";
import localeData from "dayjs/plugin/localeData";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import type { ColorScheme } from "@homarr/definitions";
import { visiblePreferenceDefinitions } from "@homarr/settings";
@@ -47,19 +47,15 @@ const BooleanRow = ({
</Group>
);
const SelectRow = ({ label, Icon, valueLabel }: { label: string; Icon: TablerIcon; valueLabel?: string }) => (
const SelectRow = ({ label, Icon, valueLabel }: { label: string; Icon: TablerIcon; valueLabel: string }) => (
<Group mx="md" my="sm" wrap="nowrap" justify="space-between" w="100%">
<Group wrap="nowrap" gap="sm">
<Icon stroke={1.5} size={20} />
<Text>{label}</Text>
</Group>
{valueLabel ? (
<Text size="sm" c="dimmed">
{valueLabel}
</Text>
) : (
<IconChevronRight aria-hidden size={16} />
)}
<Text size="sm" c="dimmed">
{valueLabel}
</Text>
</Group>
);
@@ -72,7 +68,7 @@ const LinkRow = ({ label, Icon }: { label: string; Icon: TablerIcon }) => (
const SELECT_KINDS = new Set(["select", "searchEngine", "board"]);
export const useSettingsActions = (_: Record<string, unknown>, query: string): SettingsAction[] => {
const useSettingsActions = (_: Record<string, unknown>, query: string): SettingsAction[] => {
const t = useI18n("search.mode.command.group.preferences.option");
const tUserField = useI18n("user.field");
const tColorScheme = useI18n("common.colorScheme.options");
@@ -81,13 +77,25 @@ export const useSettingsActions = (_: Record<string, unknown>, query: string): S
const normalizedQuery = query.trim().toLowerCase();
const preferences = useUserPreferences();
const boardsQuery = clientApi.board.getAllBoards.useQuery(undefined, { enabled: isAuthenticated });
const searchEnginesQuery = clientApi.searchEngine.getSelectable.useQuery(undefined, { enabled: isAuthenticated });
const valueLabelResolvers: Record<string, () => string> = {
colorScheme: () => tColorScheme(preferences.getPreference("colorScheme").value as ColorScheme),
locale: () => {
const val = preferences.getPreference("locale").value;
return localeConfigurations[val as keyof typeof localeConfigurations]?.name ?? String(val);
},
defaultSearchEngineId: () =>
searchEnginesQuery.data?.find((e) => e.value === preferences.getPreference("defaultSearchEngineId").value)
?.label ?? t("searchEngine.none" as never),
firstDayOfWeek: () => dayjs.weekdays(false)[preferences.getPreference("firstDayOfWeek").value as DayOfWeek] ?? "",
homeBoardId: () =>
boardsQuery.data?.find((b) => b.id === preferences.getPreference("homeBoardId").value)?.name ??
t("board.none" as never),
mobileHomeBoardId: () =>
boardsQuery.data?.find((b) => b.id === preferences.getPreference("mobileHomeBoardId").value)?.name ??
t("board.none" as never),
};
return visiblePreferenceDefinitions(isAuthenticated)
@@ -126,7 +134,9 @@ export const useSettingsActions = (_: Record<string, unknown>, query: string): S
if (!children) return null;
return {
key: definition.key,
Component: () => <SelectRow label={label} Icon={Icon} valueLabel={valueLabelResolvers[definition.key]?.()} />,
Component: () => (
<SelectRow label={label} Icon={Icon} valueLabel={valueLabelResolvers[definition.key]?.() ?? ""} />
),
useInteraction: interaction.children(children),
};
}
@@ -7,7 +7,6 @@ import { useSpotlightContextActions } from "../home/context";
export const contextSpecificActionsSearchGroups = createGroup<ContextSpecificItem>({
title: (t) => t("search.mode.command.group.localCommand.title"),
keyPath: "id",
source: { kind: "local" },
Component(option) {
const icon =
typeof option.icon !== "string" ? (
@@ -36,8 +36,7 @@ type Command<TSearchInteraction extends SearchInteraction = SearchInteraction> =
export const globalCommandGroup = createGroup<Command>({
keyPath: "commandKey",
title: (t) => t("search.mode.command.group.globalCommand.title"),
source: { kind: "local" },
title: "Global commands",
useInteraction: (option, query) => option.useInteraction(option, query),
Component: ({ icon: Icon, name }) => (
<Group px="md" py="sm">
@@ -4,9 +4,7 @@ import { globalCommandGroup } from "./global-group";
import { preferencesGroup } from "../preferences/groups";
export const commandMode = {
mode: "command",
modeKey: "command",
character: ">",
label: (t) => t("search.modePicker.command.label"),
placeholder: (t) => t("search.modePicker.command.placeholder"),
groups: [contextSpecificActionsSearchGroups, globalCommandGroup, preferencesGroup],
groups: [contextSpecificActionsSearchGroups, preferencesGroup, globalCommandGroup],
} satisfies SearchMode;
+1 -3
View File
@@ -2,9 +2,7 @@ import type { SearchMode } from "../../lib/mode";
import { searchEnginesSearchGroups } from "./search-engines-search-group";
export const externalMode = {
mode: "external",
modeKey: "external",
character: "!",
label: (t) => t("search.modePicker.external.label"),
placeholder: (t) => t("search.modePicker.external.placeholder"),
groups: [searchEnginesSearchGroups],
} satisfies SearchMode;
@@ -1,5 +1,7 @@
import { Group, Image, Kbd, Stack, Text } from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { IconDownload, IconSearch } from "@tabler/icons-react";
import { keepPreviousData } from "@tanstack/react-query";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
@@ -11,13 +13,11 @@ 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"]["catalog"][number];
type SearchEngine = RouterOutputs["searchEngine"]["search"][number];
type FromIntegrationSearchResult = RouterOutputs["integration"]["searchInIntegration"][number];
type DuckDuckGoBang = RouterOutputs["bangs"]["search"][number];
@@ -187,7 +187,7 @@ export const mediaRequestsChildrenOptions = createChildrenOptions<MediaRequestCh
return (
<Group mx="md" my="sm" wrap="nowrap">
{options.result.image ? (
<Image src={options.result.image} alt="" w={35} h={50} fit="cover" radius={"md"} />
<Image src={options.result.image} w={35} h={50} fit="cover" radius={"md"} />
) : (
<IconSearch stroke={1.5} size={35} />
)}
@@ -206,12 +206,11 @@ export const mediaRequestsChildrenOptions = createChildrenOptions<MediaRequestCh
export const searchEnginesChildrenOptions = createChildrenOptions<SearchEngine>({
useActions: (searchEngine, query) => {
const remoteQuery = useRemoteQuery(query, "integration-search");
const { data } = clientApi.integration.searchInIntegration.useQuery(
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
{ integrationId: searchEngine.integrationId!, query: remoteQuery.query },
{ integrationId: searchEngine.integrationId!, query },
{
enabled: remoteQuery.enabled && searchEngine.type === "fromIntegration" && searchEngine.integrationId !== null,
enabled: searchEngine.type === "fromIntegration" && searchEngine.integrationId !== null && query.length > 0,
},
);
const { openSearchInNewTab } = useSettings();
@@ -245,7 +244,7 @@ export const searchEnginesChildrenOptions = createChildrenOptions<SearchEngine>(
return (
<Group mx="md" my="sm" wrap="nowrap">
{searchResult.image ? (
<Image src={searchResult.image} alt="" w={35} h={50} fit="cover" radius={"md"} />
<Image src={searchResult.image} w={35} h={50} fit="cover" radius={"md"} />
) : (
<IconSearch stroke={1.5} size={35} />
)}
@@ -271,7 +270,7 @@ export const searchEnginesChildrenOptions = createChildrenOptions<SearchEngine>(
<Stack mx="md" my="sm">
<Text>{options.type === "generic" ? tChildren("detail.title") : tChildren("searchResults.title")}</Text>
<Group>
<img height={24} width={24} src={options.iconUrl} alt="" />
<img height={24} width={24} src={options.iconUrl} alt={options.name} />
<Text>{options.name}</Text>
</Group>
</Stack>
@@ -288,7 +287,7 @@ const parseBangQuery = (query: string) => {
return { bangToken, searchText, locked };
};
export const buildSearchUrl = (template: string, query: string) => {
const buildSearchUrl = (template: string, query: string) => {
const encoded = encodeURIComponent(query);
if (template.includes("{{{s}}}")) {
return template.replaceAll("{{{s}}}", encoded);
@@ -297,23 +296,36 @@ export const buildSearchUrl = (template: string, query: string) => {
return template.replaceAll("%s", encoded);
};
interface SearchEngineGroupOptions {
minimumLength: number;
showEmptyHint: boolean;
source: "focused" | "fallback";
}
export const searchEnginesSearchGroups = createGroup<ExternalOption>({
keyPath: "key",
title: (t) => t("common.entity.searchEngines"),
Component: (option) => {
if (option.kind === "hint") {
return (
<Group w="100%" wrap="nowrap" align="center" px="md" py="xs">
<IconSearch stroke={1.5} />
<Stack gap={0}>
<Text size="sm">{option.label}</Text>
{option.description ? (
<Text size="xs" c="gray.6">
{option.description}
</Text>
) : null}
</Stack>
</Group>
);
}
const createSearchEnginesSearchGroup = ({ minimumLength, showEmptyHint, source }: SearchEngineGroupOptions) =>
createGroup<ExternalOption>({
keyPath: "key",
title: (t) => t("common.entity.searchEngines"),
source: source === "focused" ? { kind: "remote", source: "search-engines" } : { kind: "fallback" },
Component: (option) => {
if (option.kind === "hint") {
return (
<Group w="100%" wrap="nowrap" align="center" px="md" py="xs">
<IconSearch stroke={1.5} />
<Stack gap={0}>
if (option.kind === "search") {
return (
<Group w="100%" wrap="nowrap" justify="space-between" align="center" px="md" py="xs">
<Group wrap="nowrap">
{option.iconUrl ? (
<img height={24} width={24} src={option.iconUrl} alt={option.label} />
) : (
<IconSearch stroke={1.5} />
)}
<Stack gap={0} justify="center">
<Text size="sm">{option.label}</Text>
{option.description ? (
<Text size="xs" c="gray.6">
@@ -322,61 +334,22 @@ const createSearchEnginesSearchGroup = ({ minimumLength, showEmptyHint, source }
) : null}
</Stack>
</Group>
);
}
if (option.kind === "search") {
return (
<Group w="100%" wrap="nowrap" justify="space-between" align="center" px="md" py="xs">
<Group wrap="nowrap">
{option.iconUrl ? (
<img height={24} width={24} src={option.iconUrl} alt="" />
) : (
<IconSearch stroke={1.5} />
)}
<Stack gap={0} justify="center">
<Text size="sm">{option.label}</Text>
{option.description ? (
<Text size="xs" c="gray.6">
{option.description}
</Text>
) : null}
</Stack>
</Group>
<Kbd size="sm">!{option.bang}</Kbd>
</Group>
);
}
<Kbd size="sm">!{option.bang}</Kbd>
</Group>
);
}
if (option.kind === "engine") {
const { iconUrl, name, short, description } = option.engine;
return (
<Group w="100%" wrap="nowrap" justify="space-between" align="center" px="md" py="xs">
<Group wrap="nowrap">
<img height={24} width={24} src={iconUrl} alt="" />
<Stack gap={0} justify="center">
<Text size="sm">{name}</Text>
<Text size="xs" c="gray.6">
{description}
</Text>
</Stack>
</Group>
<Kbd size="sm">!{short}</Kbd>
</Group>
);
}
const { s: name, t: short, d: domain } = option.bang;
if (option.kind === "engine") {
const { iconUrl, name, short, description } = option.engine;
return (
<Group w="100%" wrap="nowrap" justify="space-between" align="center" px="md" py="xs">
<Group wrap="nowrap">
<IconSearch stroke={1.5} />
<img height={24} width={24} src={iconUrl} alt={name} />
<Stack gap={0} justify="center">
<Text size="sm">{name}</Text>
<Text size="xs" c="gray.6">
{domain ?? "DuckDuckGo bang"}
{description}
</Text>
</Stack>
</Group>
@@ -384,134 +357,138 @@ const createSearchEnginesSearchGroup = ({ minimumLength, showEmptyHint, source }
<Kbd size="sm">!{short}</Kbd>
</Group>
);
},
useInteraction(option, query) {
const { openSearchInNewTab } = useSettings();
const { bangToken, searchText } = parseBangQuery(query);
}
if (option.kind === "search") {
return {
type: "link",
href: buildSearchUrl(option.urlTemplate, option.searchText),
newTab: openSearchInNewTab,
};
}
const { s: name, t: short, d: domain } = option.bang;
return (
<Group w="100%" wrap="nowrap" justify="space-between" align="center" px="md" py="xs">
<Group wrap="nowrap">
<IconSearch stroke={1.5} />
<Stack gap={0} justify="center">
<Text size="sm">{name}</Text>
<Text size="xs" c="gray.6">
{domain ?? "DuckDuckGo bang"}
</Text>
</Stack>
</Group>
if (option.kind === "engine") {
const nextBang = option.engine.short;
const nextQuery = `${`${nextBang} ${searchText}`.trimEnd()} `;
return { type: "setQuery", query: bangToken === nextBang && query.endsWith(" ") ? query : nextQuery };
}
<Kbd size="sm">!{short}</Kbd>
</Group>
);
},
useInteraction(option, query) {
const { openSearchInNewTab } = useSettings();
const { bangToken, searchText } = parseBangQuery(query);
if (option.kind === "ddg") {
const nextBang = option.bang.t;
const nextQuery = `${`${nextBang} ${searchText}`.trimEnd()} `;
return { type: "setQuery", query: bangToken === nextBang && query.endsWith(" ") ? query : nextQuery };
}
if (option.kind === "search") {
return {
type: "link",
href: buildSearchUrl(option.urlTemplate, option.searchText),
newTab: openSearchInNewTab,
};
}
return { type: "none" };
},
useQueryOptions(query) {
const tExternal = useI18n("search.mode.external.group.searchEngine");
const { ddgBangs } = useSettings();
const { bangToken, searchText, locked } = parseBangQuery(query);
const remoteQuery = useRemoteQuery(bangToken, "search-engines", { minimumLength });
const ddgQueryEnabled = remoteQuery.enabled && ddgBangs && remoteQuery.query.length >= 1;
const enginesQuery = useSearchEnginesCatalogQuery();
if (option.kind === "engine") {
const nextBang = option.engine.short;
const nextQuery = `${`${nextBang} ${searchText}`.trimEnd()} `;
return { type: "setQuery", query: bangToken === nextBang && query.endsWith(" ") ? query : nextQuery };
}
const ddgQuery = clientApi.bangs.search.useQuery(
{ query: remoteQuery.query, limit: 10 },
{
enabled: ddgQueryEnabled,
placeholderData: (previousData) => previousData,
},
);
if (option.kind === "ddg") {
const nextBang = option.bang.t;
const nextQuery = `${`${nextBang} ${searchText}`.trimEnd()} `;
return { type: "setQuery", query: bangToken === nextBang && query.endsWith(" ") ? query : nextQuery };
}
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);
return { type: "none" };
},
useQueryOptions(query) {
const tExternal = useI18n("search.mode.external.group.searchEngine");
const { ddgBangs } = useSettings();
const { bangToken, searchText, locked } = parseBangQuery(query);
const [debouncedBangToken] = useDebouncedValue(bangToken, 150);
const engineOptions = engineData.map(
(engine): ExternalOption => ({
key: `engine-${engine.short}`,
kind: "engine",
engine,
const enginesQuery = clientApi.searchEngine.search.useQuery(
{ query: debouncedBangToken, limit: 10 },
{ placeholderData: keepPreviousData },
);
const ddgQuery = clientApi.bangs.search.useQuery(
{ query: debouncedBangToken, limit: 10 },
{
enabled: ddgBangs && debouncedBangToken.length >= 1,
placeholderData: keepPreviousData,
},
);
const isLoading = enginesQuery.isLoading || ddgQuery.isLoading;
const isError = enginesQuery.isError || ddgQuery.isError;
const engineOptions = (enginesQuery.data ?? []).map(
(engine): ExternalOption => ({
key: `engine-${engine.short}`,
kind: "engine",
engine,
}),
);
const ddgOptions = (ddgQuery.data ?? [])
.filter((bang) => !engineOptions.some((option) => option.kind === "engine" && option.engine.short === bang.t))
.map(
(bang): ExternalOption => ({
key: `ddg-${bang.t}`,
kind: "ddg",
bang,
}),
);
const ddgOptions = ddgData
.filter((bang) => !engineOptions.some((option) => option.kind === "engine" && option.engine.short === bang.t))
.map(
(bang): ExternalOption => ({
key: `ddg-${bang.t}`,
kind: "ddg",
bang,
}),
);
const searchActions: ExternalOption[] = [];
if (locked && bangToken.length > 0) {
const matchedEngine = (enginesQuery.data ?? []).find((engine) => engine.short === bangToken);
const matchedDdg = (ddgQuery.data ?? []).find((bang) => bang.t === bangToken);
const searchActions: ExternalOption[] = [];
if (locked && bangToken.length > 0) {
const matchedEngine = engineCatalog.find((engine) => engine.short === bangToken);
const matchedDdg = ddgData.find((bang) => bang.t === bangToken);
const label = matchedEngine?.name ?? matchedDdg?.s;
const iconUrl = matchedEngine?.iconUrl;
const urlTemplate = matchedEngine?.type === "generic" ? matchedEngine.urlTemplate : matchedDdg?.u;
const genericEngine = matchedEngine?.type === "generic" ? matchedEngine : undefined;
const label = genericEngine?.name ?? matchedDdg?.s;
const iconUrl = genericEngine?.iconUrl;
const urlTemplate = genericEngine?.urlTemplate ?? matchedDdg?.u;
if (label && urlTemplate) {
if (searchText.trim().length > 0) {
searchActions.push({
key: "search-action",
kind: "search",
label: tExternal("bang.searchWithQuery", {
query: searchText.trim(),
label,
}),
description: tExternal("bang.pressEnterToOpen"),
bang: bangToken,
iconUrl,
urlTemplate,
searchText: searchText.trim(),
});
} else {
searchActions.push({
key: "search-hint",
kind: "hint",
label: tExternal("bang.engineSelected", { label, bang: bangToken }),
description: tExternal("bang.typeQueryToContinue"),
});
}
if (label && urlTemplate) {
if (searchText.trim().length > 0) {
searchActions.push({
key: "search-action",
kind: "search",
label: tExternal("bang.searchWithQuery", {
query: searchText.trim(),
label,
}),
description: tExternal("bang.pressEnterToOpen"),
bang: bangToken,
iconUrl,
urlTemplate,
searchText: searchText.trim(),
});
} else {
searchActions.push({
key: "search-hint",
kind: "hint",
label: tExternal("bang.engineSelected", { label, bang: bangToken }),
description: tExternal("bang.typeQueryToContinue"),
});
}
}
if (showEmptyHint && bangToken.length === 0) {
searchActions.push({
key: "hint",
kind: "hint",
label: tExternal("bang.emptyBangHint"),
description: ddgBangs ? tExternal("tipDdgBangs") : undefined,
});
}
}
if (query.length === 0) {
searchActions.push({
key: "hint",
kind: "hint",
label: tExternal("bang.emptyBangHint"),
description: ddgBangs ? tExternal("tipDdgBangs") : undefined,
});
}
return {
isLoading,
isError,
data: [...searchActions, ...engineOptions, ...ddgOptions].slice(0, 10),
};
},
});
export const searchEnginesSearchGroups = createSearchEnginesSearchGroup({
minimumLength: 0,
showEmptyHint: true,
source: "focused",
return {
isLoading,
isError,
data: [...searchActions, ...engineOptions, ...ddgOptions].slice(0, 10),
};
},
});
@@ -0,0 +1,138 @@
import { Group, Kbd, Text } from "@mantine/core";
import {
IconBook2,
IconBrandDiscord,
IconBrandGithub,
IconLayoutDashboard,
IconSearch,
IconSettings,
} from "@tabler/icons-react";
import { useSession } from "@homarr/auth/client";
import { createDocumentationLink } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import { createGroup } from "../../lib/group";
import { interaction } from "../../lib/interaction";
import type { SearchMode } from "../../lib/mode";
import { appIntegrationBoardMode } from "../app-integration-board";
import { commandMode } from "../command";
import { externalMode } from "../external";
import { mediaMode } from "../media";
import { pageMode } from "../page";
import { preferencesGroup } from "../preferences/groups";
import { userGroupMode } from "../user-group";
type QuickLinkOption = {
key: string;
icon: TablerIcon;
name: string;
path: string;
};
export const useHomeEmptyGroups = () => {
const { data: session } = useSession();
const tPages = useI18n("search.mode.page.group.page.option");
const tHelp = useI18n("management.navbar.items.help.items");
const visibleSearchModes: SearchMode[] = [appIntegrationBoardMode, externalMode, mediaMode, commandMode, pageMode];
if (session?.user.permissions.includes("admin")) {
visibleSearchModes.unshift(userGroupMode);
}
return [
createGroup({
keyPath: "key",
title: () => "Quick links",
options: (() => {
const quickLinks: QuickLinkOption[] = [];
quickLinks.push({
key: "manageBoards",
icon: IconLayoutDashboard,
path: "/manage/boards",
name: tPages("manageBoard.label"),
});
if (session?.user.permissions.includes("admin")) {
quickLinks.push({
key: "manageSettings",
icon: IconSettings,
path: "/manage/settings",
name: tPages("manageSettings.label"),
});
}
if (session) {
quickLinks.push({
key: "manageSearchEngines",
icon: IconSearch,
path: "/manage/search-engines",
name: tPages("manageSearchEngine.label"),
});
}
return quickLinks;
})(),
Component: ({ name, icon: Icon }) => (
<Group px="md" py="xs" w="100%" wrap="nowrap" align="center">
<Icon stroke={1.5} />
<Text>{name}</Text>
</Group>
),
filter: () => true,
useInteraction: interaction.link(({ path }) => ({ href: path })),
}),
createGroup({
keyPath: "modeKey",
title: (t) => t("search.mode.help.group.mode.title"),
options: visibleSearchModes.map(({ character, modeKey }) => ({ character, modeKey })),
Component: ({ modeKey, character }) => {
const t = useI18n(`search.mode.${modeKey}`);
return (
<Group px="md" py="xs" w="100%" wrap="nowrap" align="center" justify="space-between">
<Text>{t("help")}</Text>
{character && <Kbd size="sm">{character}</Kbd>}
</Group>
);
},
filter: () => true,
useInteraction: interaction.mode(({ modeKey }) => ({ mode: modeKey })),
}),
createGroup({
keyPath: "href",
title: (t) => t("management.navbar.items.help.label"),
useOptions() {
return [
{
href: createDocumentationLink("/docs/getting-started"),
icon: IconBook2,
label: tHelp("documentation"),
},
{
href: "https://github.com/homarr-labs/homarr/issues/new/choose",
icon: IconBrandGithub,
label: tHelp("submitIssue"),
},
{
href: "https://discord.com/invite/aCsmEV5RgA",
icon: IconBrandDiscord,
label: tHelp("discord"),
},
];
},
Component: (props) => (
<Group px="md" py="xs" w="100%" wrap="nowrap" align="center">
<props.icon stroke={1.5} />
<Text>{props.label}</Text>
</Group>
),
filter: () => true,
useInteraction: interaction.link(({ href }) => ({ href, newTab: true })),
}),
] as const;
};
export const useHomeEmptyGroupsWithPreferences = () => [preferencesGroup, ...useHomeEmptyGroups()];
@@ -7,89 +7,39 @@ import { useSpotlightContextResults } from "./context";
export const shouldShowContextSpecificResult = (query: string, option: ContextSpecificItem) => {
if (option.alwaysVisible) return true;
if (query.length === 0) return false;
const normalizedQuery = query.trim().toLowerCase();
const searchableValues = [option.name, option.description ?? "", ...(option.aliases ?? [])];
return searchableValues.some((value) => value.toLowerCase().includes(normalizedQuery));
};
const localResultSort = (query: string, [first, second]: [ContextSpecificItem, ContextSpecificItem]) => {
const normalizedQuery = query.trim().toLowerCase();
const score = (option: ContextSpecificItem) => {
const normalizedName = option.name.toLowerCase();
if (normalizedName === normalizedQuery) return 0;
if (normalizedName.startsWith(normalizedQuery)) return 1;
return 2;
};
return score(first) - score(second);
};
const renderContextSpecificItem = (option: ContextSpecificItem) => {
const icon =
typeof option.icon !== "string" ? (
<option.icon size={24} />
) : (
<img width={24} height={24} src={option.icon} alt="" />
);
return (
<Group w="100%" wrap="nowrap" align="center" px="md" py="xs" opacity={option.unavailable ? 0.55 : 1}>
{icon}
<Stack gap={0}>
<Text>{option.name}</Text>
{option.description ? (
<Text size="xs" c="dimmed">
{option.description}
</Text>
) : null}
</Stack>
</Group>
);
return option.name.toLowerCase().includes(query.toLowerCase());
};
export const contextSpecificSearchGroups = createGroup<ContextSpecificItem>({
title: (t) => t("search.mode.home.group.local.title"),
keyPath: "id",
source: { kind: "local" },
Component: renderContextSpecificItem,
Component(option) {
const icon =
typeof option.icon !== "string" ? (
<option.icon size={24} />
) : (
<img width={24} height={24} src={option.icon} alt={option.name} />
);
return (
<Group w="100%" wrap="nowrap" align="center" px="md" py="xs" opacity={option.unavailable ? 0.55 : 1}>
{icon}
<Stack gap={0}>
<Text>{option.name}</Text>
{option.description && (
<Text size="xs" c="dimmed">
{option.description}
</Text>
)}
</Stack>
</Group>
);
},
useInteraction(option, query) {
return option.interaction(query);
},
filter: shouldShowContextSpecificResult,
sort: localResultSort,
useOptions() {
return useSpotlightContextResults().filter((item) => item.placement !== "fallback");
},
});
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",
source: { kind: "fallback" },
Component: renderContextSpecificItem,
useInteraction(option, query) {
return option.interaction(query);
},
filter: shouldShowContextSpecificResult,
sort: localResultSort,
useOptions() {
return useSpotlightContextResults().filter((item) => item.placement === "fallback");
return useSpotlightContextResults();
},
});
+1 -19
View File
@@ -11,13 +11,10 @@ export type ContextSpecificItem = {
name: string;
icon: TablerIcon | string;
interaction: (query: string) => inferSearchInteractionDefinition<SearchInteraction>;
aliases?: string[];
placement?: "primary" | "fallback";
disabled?: boolean;
description?: string;
unavailable?: boolean;
alwaysVisible?: boolean;
dedupeKey?: string;
};
interface SpotlightContextProps {
@@ -63,22 +60,7 @@ const createSpotlightContext = (displayName: string) => {
});
}, []);
const items = useMemo(() => {
const uniqueItems: ContextSpecificItem[] = [];
const itemKeys = new Set<string>();
for (const registration of itemsMap.values()) {
for (const item of registration.items) {
const itemKey = item.dedupeKey ?? item.id;
if (itemKeys.has(itemKey)) continue;
itemKeys.add(itemKey);
uniqueItems.push(item);
}
}
return uniqueItems;
}, [itemsMap]);
const items = useMemo(() => Array.from(itemsMap.values()).flatMap(({ items }) => items), [itemsMap]);
const itemsContext = useMemo(() => ({ items }), [items]);
const registration = useMemo(() => ({ registerItems, unregisterItems }), [registerItems, unregisterItems]);
@@ -1,21 +1,19 @@
import { Box, Group, Stack, Text } from "@mantine/core";
import { IconSearchOff } from "@tabler/icons-react";
import { IconSearch, 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 { 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
type GroupItem = {
id: string;
@@ -23,20 +21,19 @@ type GroupItem = {
description?: string;
icon: TablerIcon | string;
disabled?: boolean;
interaction: inferSearchInteractionDefinition<SearchInteraction>;
useInteraction: (query: string) => inferSearchInteractionDefinition<SearchInteraction>;
};
export const homeSearchEngineGroup = createGroup<GroupItem>({
title: (t) => t("search.mode.home.group.search.title"),
keyPath: "id",
source: { kind: "fallback" },
Component(item) {
const icon =
typeof item.icon !== "string" ? (
<item.icon size={24} />
) : (
<Box w={24} h={24}>
<img src={item.icon} alt="" style={{ maxWidth: 24 }} />
<img src={item.icon} alt={item.name} style={{ maxWidth: 24 }} />
</Box>
);
@@ -45,95 +42,121 @@ 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) {
return item.interaction;
useInteraction(item, query) {
return item.useInteraction(query);
},
filter() {
return true;
},
useQueryOptions(query) {
const t = useI18n();
const { data: session } = useSession();
const { openSearchInNewTab } = useSettings();
const catalogQuery = useSearchEnginesCatalogQuery();
const { data: session, status } = useSession();
const { data: defaultSearchEngine, ...defaultSearchEngineQuery } =
clientApi.searchEngine.getDefaultSearchEngine.useQuery(undefined, {
enabled: status !== "loading",
});
const fromIntegrationEnabled = defaultSearchEngine?.type === "fromIntegration" && query.length > 0;
const { data: results, ...resultQuery } = clientApi.integration.searchInIntegration.useQuery(
{
query,
integrationId: defaultSearchEngine?.integrationId ?? "",
},
{
enabled: fromIntegrationEnabled,
select: (data) => data.slice(0, 5),
},
);
return {
isLoading: catalogQuery.isLoading,
isError: catalogQuery.isError,
data: createSearchEntries(
catalogQuery.data ?? [],
session?.user.permissions.includes("search-engine-create") === true,
query,
openSearchInNewTab,
t,
),
isLoading:
defaultSearchEngineQuery.isLoading || (resultQuery.isLoading && fromIntegrationEnabled) || status === "loading",
isError: defaultSearchEngineQuery.isError || (resultQuery.isError && fromIntegrationEnabled),
data: createDefaultSearchEntries(defaultSearchEngine, results, session, query, t),
};
},
});
const createSearchEntries = (
searchEngines: SearchEngine[],
canCreateSearchEngine: boolean,
const createDefaultSearchEntries = (
defaultSearchEngine: RouterOutputs["searchEngine"]["getDefaultSearchEngine"] | null,
results: RouterOutputs["integration"]["searchInIntegration"] | undefined,
session: Session | null,
query: string,
openInNewTab: boolean,
t: TranslationFunction,
): GroupItem[] => {
if (searchEngines.length === 0) {
if (!session?.user && !defaultSearchEngine) return [];
if (!defaultSearchEngine) {
return [
{
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"),
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"),
icon: IconSearchOff,
disabled: !canCreateSearchEngine,
interaction: canCreateSearchEngine ? { type: "link", href: "/manage/search-engines/new" } : { type: "none" },
useInteraction() {
return {
type: "link",
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
href: `/manage/users/${session!.user.id}/general`,
};
},
},
];
}
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 (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,
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,
};
},
};
}
return {
id: `search-${searchEngine.id}`,
name,
description: searchEngine.description ?? undefined,
icon: searchEngine.iconUrl,
interaction: {
type: "mode",
mode: "external",
query: `${searchEngine.short} ${normalizedQuery}`.trimEnd(),
},
};
});
];
}
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",
};
},
},
];
}
return results.map((result) => ({
id: `search-${result.id}`,
name: result.name,
description: result.text,
icon: result.image ?? IconSearch,
useInteraction() {
return useFromIntegrationSearchInteraction(defaultSearchEngine, result);
},
}));
};
+5 -33
View File
@@ -1,38 +1,10 @@
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 { homeSearchEngineGroup } from "./home-search-engine-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";
import { userGroupMode } from "../user-group";
import { contextSpecificSearchGroups } from "./context-specific-group";
import { homeSearchEngineGroup } from "./home-search-engine-group";
export const homeMode = {
mode: "search",
character: "/",
label: (t) => t("search.modePicker.search.label"),
placeholder: (t) => t("search.modePicker.search.placeholder"),
useGroups() {
const appGroups = appIntegrationBoardMode.useGroups().filter((group) => group !== contextSpecificAppsSearchGroup);
const directoryGroups = userGroupMode.useGroups();
return [
contextSpecificSearchGroups,
pagesSearchGroup,
contextSpecificActionsSearchGroups,
globalCommandGroup,
preferencesGroup,
...appGroups,
...directoryGroups,
homeSearchEngineGroup,
contextSpecificFallbackSearchGroup,
mediaFallbackGroup,
];
},
character: undefined,
modeKey: "home",
groups: [preferencesGroup, homeSearchEngineGroup, contextSpecificSearchGroups],
} satisfies SearchMode;
+7 -9
View File
@@ -1,19 +1,17 @@
import { appIntegrationBoardMode } from "./app-integration-board";
import { assistantMode } from "./assistant";
import { commandMode } from "./command";
import { externalMode } from "./external";
import { homeMode } from "./home";
import { mediaMode } from "./media";
import { preferencesMode } from "./preferences";
import { pageMode } from "./page";
import { userGroupMode } from "./user-group";
export const searchModes = [
homeMode,
appIntegrationBoardMode,
commandMode,
preferencesMode,
assistantMode,
externalMode,
mediaMode,
userGroupMode,
appIntegrationBoardMode,
externalMode,
commandMode,
pageMode,
mediaMode,
homeMode,
] as const;
+1 -3
View File
@@ -2,9 +2,7 @@ import type { SearchMode } from "../../lib/mode";
import { mediaRequestSearchGroup } from "./media-request-search-group";
export const mediaMode = {
mode: "media",
modeKey: "media",
character: undefined,
label: (t) => t("search.modePicker.media.label"),
placeholder: (t) => t("search.modePicker.media.placeholder"),
groups: [mediaRequestSearchGroup],
} satisfies SearchMode;
@@ -1,87 +0,0 @@
import { Group, Stack, Text } from "@mantine/core";
import { IconMovie } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { useI18n } from "@homarr/translation/client";
import { createGroup } from "../../lib/group";
import { useRemoteQuery } from "../../lib/remote-query";
type MediaFallbackOption = {
key: string;
label: string;
description: string;
query: string;
unavailable?: boolean;
};
/** Discovers whether media search is usable, but intentionally never performs a media search. */
export const mediaFallbackGroup = createGroup<MediaFallbackOption>({
keyPath: "key",
title: (t) => t("search.modePicker.continueWith"),
source: { kind: "fallback" },
Component(option) {
return (
<Group w="100%" wrap="nowrap" align="center" px="md" py="xs" opacity={option.unavailable ? 0.5 : 1}>
<IconMovie stroke={1.5} />
<Stack gap={0}>
<Text>{option.label}</Text>
<Text size="xs" c="dimmed">
{option.description}
</Text>
</Stack>
</Group>
);
},
useInteraction(option) {
if (option.unavailable) return { type: "none" };
return { type: "mode", mode: "media", query: option.query };
},
useQueryOptions(query) {
const t = useI18n();
const remoteQuery = useRemoteQuery(query, "media");
const { data: session, status } = useSession();
const canDiscoverTargets = remoteQuery.enabled && Boolean(session?.user);
const targets = clientApi.integration.mediaRequestSearchTargets.useQuery(undefined, {
enabled: canDiscoverTargets,
staleTime: 60_000,
});
if (!remoteQuery.enabled) return { data: [], isLoading: false, isError: false };
if (status === "loading") return { data: [], isLoading: true, isError: false };
if (!session?.user) {
return {
data: [
{
key: "media-fallback",
label: t("search.modePicker.media.fallback", { query: remoteQuery.query }),
description: t("search.mode.media.action.search.disabled.signInRequired"),
query: remoteQuery.query,
unavailable: true,
},
],
isLoading: false,
isError: false,
};
}
if (!targets.data) return { data: [], isLoading: targets.isLoading, isError: targets.isError };
const unavailable = targets.data.length === 0;
return {
isLoading: targets.isLoading,
isError: targets.isError,
data: [
{
key: "media-fallback",
label: t("search.modePicker.media.fallback", { query: remoteQuery.query }),
description: unavailable
? t("search.mode.media.action.search.disabled.noIntegration")
: t("search.mode.media.action.search.description"),
query: remoteQuery.query,
unavailable,
},
],
};
},
});
@@ -1,17 +1,17 @@
import { Badge, Group, Image, Stack, Text } from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { IconMovie, IconSearch } from "@tabler/icons-react";
import { keepPreviousData } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { getIntegrationName } from "@homarr/definitions";
import type { MediaAvailability } from "@homarr/integrations/types";
import { mediaAvailabilityConfiguration } from "@homarr/integrations/types";
import { useI18n } from "@homarr/translation/client";
import { createGroup } from "../../lib/group";
import { useRemoteQuery } from "../../lib/remote-query";
import { mediaRequestSearchScopeAtom } from "../../spotlight-store";
import { useMediaRequestSearchInteraction } from "../external/search-engines-search-group";
@@ -23,14 +23,7 @@ type MediaRequestSearchResult = RouterOutputs["integration"]["searchMediaRequest
type MediaRequestSearchOption =
| {
key: string;
kind: "disabled";
label: string;
description: string;
unavailable: true;
}
| {
key: string;
kind: "hint";
kind: "disabled" | "hint";
label: string;
description: string;
}
@@ -43,7 +36,6 @@ type MediaRequestSearchOption =
export const mediaRequestSearchGroup = createGroup<MediaRequestSearchOption>({
keyPath: "key",
title: (t) => t("search.mode.media.group.title"),
source: { kind: "remote", source: "media" },
Component(option) {
const tMedia = useI18n("search.mode.media");
@@ -74,7 +66,7 @@ export const mediaRequestSearchGroup = createGroup<MediaRequestSearchOption>({
return (
<Group w="100%" wrap="nowrap" align="center" px="md" py="xs">
{result.image ? (
<Image src={result.image} alt="" w={35} h={50} fit="cover" radius="md" />
<Image src={result.image} w={35} h={50} fit="cover" radius="md" />
) : (
<IconSearch stroke={1.5} size={35} />
)}
@@ -106,24 +98,21 @@ export const mediaRequestSearchGroup = createGroup<MediaRequestSearchOption>({
useQueryOptions(query) {
const tMedia = useI18n("search.mode.media");
const scope = useAtomValue(mediaRequestSearchScopeAtom);
const remoteQuery = useRemoteQuery(query, "media");
const { data: session, status } = useSession();
const targetsQuery = clientApi.integration.mediaRequestSearchTargets.useQuery(undefined, {
enabled: status !== "loading" && Boolean(session?.user),
staleTime: 60_000,
});
const [debouncedQuery] = useDebouncedValue(query.trim(), 150);
const targetsQuery = clientApi.integration.mediaRequestSearchTargets.useQuery();
const scopedTargets = (targetsQuery.data ?? []).filter((target) =>
scope.integrationIds ? scope.integrationIds.includes(target.id) : true,
);
const hasScopedTargets = scopedTargets.length > 0;
const shouldSearch = remoteQuery.enabled && hasScopedTargets;
const shouldSearch = debouncedQuery.length > 0 && hasScopedTargets;
const integrationIds = scope.integrationIds ? scopedTargets.map((target) => target.id) : undefined;
const resultsQuery = clientApi.integration.searchMediaRequests.useQuery(
{ query: remoteQuery.query, integrationIds },
{ query: debouncedQuery, integrationIds },
{
enabled: shouldSearch,
placeholderData: keepPreviousData,
select: (data) => data.slice(0, 10),
},
);
@@ -131,26 +120,6 @@ export const mediaRequestSearchGroup = createGroup<MediaRequestSearchOption>({
const isLoading = targetsQuery.isLoading || (shouldSearch && resultsQuery.isLoading);
const isError = targetsQuery.isError || (shouldSearch && resultsQuery.isError);
if (status === "loading") {
return { data: [], isLoading: true, isError: false };
}
if (!session?.user) {
return {
data: [
{
key: "disabled",
kind: "disabled",
label: tMedia("action.search.label"),
description: tMedia("action.search.disabled.signInRequired"),
unavailable: true,
},
],
isLoading: false,
isError: false,
};
}
if (!targetsQuery.data) {
return {
isLoading,
@@ -171,13 +140,12 @@ export const mediaRequestSearchGroup = createGroup<MediaRequestSearchOption>({
description: scope.integrationIds
? tMedia("action.search.disabled.scoped")
: tMedia("action.search.disabled.noIntegration"),
unavailable: true,
},
],
};
}
if (!remoteQuery.enabled) {
if (debouncedQuery.length === 0) {
return {
isLoading,
isError,
@@ -0,0 +1,8 @@
import type { SearchMode } from "../../lib/mode";
import { pagesSearchGroup } from "./pages-search-group";
export const pageMode = {
modeKey: "page",
character: "/",
groups: [pagesSearchGroup],
} satisfies SearchMode;
@@ -30,7 +30,6 @@ export const pagesSearchGroup = createGroup<{
}>({
keyPath: "path",
title: (t) => t("search.mode.page.group.page.title"),
source: { kind: "local" },
Component: ({ name, icon: Icon }) => (
<Group px="md" py="sm">
<Icon stroke={1.5} />
@@ -1,22 +1,90 @@
import { Group, Text } from "@mantine/core";
import { IconSettings } from "@tabler/icons-react";
import { useSession } from "@homarr/auth/client";
import { colorSchemes } from "@homarr/definitions";
import type { UserPreferenceKey } from "@homarr/settings";
import { visiblePreferenceDefinitions } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import { createGroup } from "../../lib/group";
import type { ChildrenAction } from "../../lib/children";
import { useSettingsActions } from "../command/children/preferences/settings-children";
import {
preferenceChildrenOptionsByKey,
preferenceIcons,
userFieldPreferenceLabels,
} from "../../preferences/preference-registry";
import { settingsChildrenOptions } from "../command/children/preferences/settings-children";
type PreferenceAction = ChildrenAction<Record<string, unknown>>;
type PreferencesGroupOption =
| { key: "settings"; preferenceKey: null }
| { key: UserPreferenceKey; preferenceKey: UserPreferenceKey };
/** Preferences are normal command-menu actions, not a gateway into another hidden list. */
export const preferencesGroup = createGroup<PreferenceAction>({
const settingsOption: PreferencesGroupOption = { key: "settings", preferenceKey: null };
export const preferencesGroup = createGroup<PreferencesGroupOption>({
keyPath: "key",
title: (t) => t("search.mode.command.group.preferences.title"),
source: { kind: "local" },
useOptions(query) {
return useSettingsActions({}, query);
const t = useI18n();
const tColorScheme = useI18n("common.colorScheme.options");
const tUserField = useI18n("user.field");
const { data: session } = useSession();
const definitions = visiblePreferenceDefinitions(Boolean(session?.user));
const q = query.trim().toLowerCase();
if (q.length === 0) return [settingsOption];
const matching = definitions.flatMap((def): PreferencesGroupOption[] => {
if (!preferenceChildrenOptionsByKey[def.key]) return [];
const userFieldKey = userFieldPreferenceLabels[def.key];
const label = userFieldKey
? tUserField(`${userFieldKey}.label` as never)
: t(`search.mode.command.group.preferences.option.${def.key}.label` as never);
const searchValues = [
label,
...def.aliases,
...(def.key === "colorScheme" ? colorSchemes.map((s) => tColorScheme(s)) : []),
];
if (!searchValues.some((v) => v.toLowerCase().includes(q))) return [];
return [{ key: def.key, preferenceKey: def.key }];
});
if (matching.length > 0) return matching;
const allSearchValues = [
t("search.mode.command.group.preferences.title"),
...definitions.flatMap((d) => d.aliases),
];
return allSearchValues.some((v) => v.toLowerCase().includes(q)) ? [settingsOption] : [];
},
Component(option) {
return <option.Component />;
},
useInteraction(option, query) {
return option.useInteraction({}, query);
Component: (option) => {
const t = useI18n("search.mode.command.group.preferences");
const tOption = useI18n("search.mode.command.group.preferences.option");
const tUserField = useI18n("user.field");
const Icon = option.preferenceKey
? preferenceIcons[option.preferenceKey as keyof typeof preferenceIcons]
: IconSettings;
let label = t("title");
if (option.preferenceKey) {
const userFieldKey = userFieldPreferenceLabels[option.preferenceKey];
label = userFieldKey
? tUserField(`${userFieldKey}.label` as never)
: tOption(`${option.preferenceKey}.label` as never);
}
return (
<Group px="md" py="sm" wrap="nowrap" gap="sm">
<Icon stroke={1.5} />
<Text>{label}</Text>
</Group>
);
},
filter: () => true,
useInteraction: (option) => ({
type: "children",
...(option.preferenceKey
? (preferenceChildrenOptionsByKey[option.preferenceKey as keyof typeof preferenceChildrenOptionsByKey]?.({}) ??
settingsChildrenOptions({}))
: settingsChildrenOptions({})),
}),
});
@@ -1,10 +0,0 @@
import type { SearchMode } from "../../lib/mode";
import { preferencesGroup } from "./groups";
export const preferencesMode = {
mode: "preferences",
character: "?",
label: (t) => t("search.modePicker.preferences.label"),
placeholder: (t) => t("search.modePicker.preferences.placeholder"),
groups: [preferencesGroup],
} satisfies SearchMode;
@@ -7,7 +7,6 @@ import { useI18n } from "@homarr/translation/client";
import { createChildrenOptions } from "../../lib/children";
import { createGroup } from "../../lib/group";
import { interaction } from "../../lib/interaction";
import { useRemoteQuery } from "../../lib/remote-query";
// This has to be type so it can be interpreted as Record<string, unknown>.
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
@@ -72,7 +71,6 @@ const groupChildrenOptions = createChildrenOptions<Group>({
export const groupsSearchGroup = createGroup<Group>({
keyPath: "id",
title: (t) => t("common.entity.groups"),
source: { kind: "remote", source: "groups" },
Component: ({ name }) => (
<Group px="md" py="sm">
<Text>{name}</Text>
@@ -80,7 +78,6 @@ export const groupsSearchGroup = createGroup<Group>({
),
useInteraction: interaction.children(groupChildrenOptions),
useQueryOptions(query) {
const remoteQuery = useRemoteQuery(query, "groups");
return clientApi.group.search.useQuery({ query: remoteQuery.query, limit: 8 }, { enabled: remoteQuery.enabled });
return clientApi.group.search.useQuery({ query, limit: 5 });
},
});
@@ -1,17 +1,9 @@
import { useSession } from "@homarr/auth/client";
import type { SearchMode } from "../../lib/mode";
import { groupsSearchGroup } from "./groups-search-group";
import { usersSearchGroup } from "./users-search-group";
export const userGroupMode = {
mode: "userGroup",
modeKey: "userGroup",
character: "@",
label: (t) => t("search.modePicker.userGroup.label"),
placeholder: (t) => t("search.modePicker.userGroup.placeholder"),
useGroups() {
const { data: session } = useSession();
if (!session?.user.permissions.includes("admin")) return [];
return [usersSearchGroup, groupsSearchGroup];
},
groups: [usersSearchGroup, groupsSearchGroup],
} satisfies SearchMode;
@@ -8,7 +8,6 @@ import { UserAvatar } from "@homarr/ui";
import { createChildrenOptions } from "../../lib/children";
import { createGroup } from "../../lib/group";
import { interaction } from "../../lib/interaction";
import { useRemoteQuery } from "../../lib/remote-query";
// This has to be type so it can be interpreted as Record<string, unknown>.
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
@@ -50,7 +49,6 @@ const userChildrenOptions = createChildrenOptions<User>({
export const usersSearchGroup = createGroup<User>({
keyPath: "id",
title: (t) => t("common.entity.users"),
source: { kind: "remote", source: "users" },
Component: (user) => (
<Group px="md" py="sm">
<UserAvatar user={user} size="sm" />
@@ -59,7 +57,6 @@ export const usersSearchGroup = createGroup<User>({
),
useInteraction: interaction.children(userChildrenOptions),
useQueryOptions(query) {
const remoteQuery = useRemoteQuery(query, "users");
return clientApi.user.search.useQuery({ query: remoteQuery.query, limit: 8 }, { enabled: remoteQuery.enabled });
return clientApi.user.search.useQuery({ query, limit: 5 });
},
});
+6 -12
View File
@@ -20,22 +20,19 @@ describe("lazy Spotlight open bridge", () => {
const listener = vi.fn();
window.addEventListener(spotlightOpenEvent, listener);
openSpotlight({ mode: "apps", query: "rada" });
openSpotlight();
expect(listener).toHaveBeenCalledOnce();
expect(listener.mock.calls[0]?.[0]).toMatchObject({
detail: { mode: "apps", query: "rada" },
});
expect(consumePendingSpotlightOpen()).toEqual({ mode: "apps", query: "rada" });
expect(consumePendingSpotlightOpen()).toBeNull();
expect(consumePendingSpotlightOpen()).toBe(true);
expect(consumePendingSpotlightOpen()).toBe(false);
window.removeEventListener(spotlightOpenEvent, listener);
});
it("defaults to search and lets a listener installed after the request discover the pending intent", () => {
it("lets a listener installed after the request discover the pending open", () => {
openSpotlight();
expect(hasPendingSpotlightOpen()).toBe(true);
expect(consumePendingSpotlightOpen()).toEqual({ mode: "search" });
expect(consumePendingSpotlightOpen()).toBe(true);
expect(hasPendingSpotlightOpen()).toBe(false);
});
@@ -48,11 +45,8 @@ describe("lazy Spotlight open bridge", () => {
openMediaRequestSearch({ integrationIds: ["integration-a"], query: "movie" });
expect(openListener).toHaveBeenCalledOnce();
expect(openListener.mock.calls[0]?.[0]).toMatchObject({
detail: { mode: "media", query: "movie" },
});
expect(mediaListener).toHaveBeenCalledOnce();
expect(consumePendingSpotlightOpen()).toEqual({ mode: "media", query: "movie" });
expect(consumePendingSpotlightOpen()).toBe(true);
expect(consumePendingMediaRequestSearch()).toEqual({ integrationIds: ["integration-a"], query: "movie" });
window.removeEventListener(spotlightOpenEvent, openListener);
window.removeEventListener(mediaRequestSearchEvent, mediaListener);
+9 -37
View File
@@ -3,58 +3,30 @@
export const spotlightOpenEvent = "homarr:spotlight:open";
export const mediaRequestSearchEvent = "homarr:spotlight:media-request-search";
export type SpotlightMode =
| "search"
| "apps"
| "command"
| "preferences"
| "assistant"
| "external"
| "media"
| "userGroup";
export interface OpenSpotlightOptions {
mode?: SpotlightMode;
query?: string;
}
export interface SpotlightOpenIntent {
mode: SpotlightMode;
query?: string;
}
export interface OpenMediaRequestSearchOptions {
integrationIds?: string[];
query?: string;
}
let pendingMediaRequestSearch: OpenMediaRequestSearchOptions | null = null;
let pendingSpotlightOpen: SpotlightOpenIntent | null = null;
let pendingSpotlightOpen = false;
const requestSpotlightMount = (intent: SpotlightOpenIntent) => {
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent<SpotlightOpenIntent>(spotlightOpenEvent, { detail: intent }));
}
const requestSpotlightMount = () => {
if (typeof window !== "undefined") window.dispatchEvent(new Event(spotlightOpenEvent));
};
export const openSpotlight = (options: OpenSpotlightOptions = {}) => {
const intent: SpotlightOpenIntent = {
mode: options.mode ?? "search",
};
if (options.query !== undefined) intent.query = options.query;
if (intent.mode !== "media") pendingMediaRequestSearch = null;
pendingSpotlightOpen = intent;
requestSpotlightMount(intent);
export const openSpotlight = () => {
pendingSpotlightOpen = true;
requestSpotlightMount();
};
export const consumePendingSpotlightOpen = () => {
const pending = pendingSpotlightOpen;
pendingSpotlightOpen = null;
pendingSpotlightOpen = false;
return pending;
};
export const hasPendingSpotlightOpen = () => pendingSpotlightOpen !== null;
export const hasPendingSpotlightOpen = () => pendingSpotlightOpen;
export const consumePendingMediaRequestSearch = () => {
const pending = pendingMediaRequestSearch;
@@ -64,7 +36,7 @@ export const consumePendingMediaRequestSearch = () => {
export const openMediaRequestSearch = (options: OpenMediaRequestSearchOptions = {}) => {
pendingMediaRequestSearch = options;
openSpotlight({ mode: "media", query: options.query });
openSpotlight();
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent<OpenMediaRequestSearchOptions>(mediaRequestSearchEvent, { detail: options }));
+3 -53
View File
@@ -6315,9 +6315,6 @@
"toggleColorScheme": "Toggle light/dark mode",
"saveNotebook": "Save notebook (only inside notebook widget)",
"openSpotlight": "Open search",
"openSpotlightApps": "Open app search",
"openSpotlightAssistant": "Open Assistant mode",
"openSpotlightPreferences": "Open preferences mode",
"openAssistant": "Open Homarr Assistant"
},
"note": "Tip: Mod refers to both Ctrl key and ⌘ key on macOS"
@@ -6783,51 +6780,6 @@
"search": {
"placeholder": "Search for anything",
"nothingFound": "Nothing found",
"modePicker": {
"search": {
"label": "Search",
"placeholder": "Search apps, boards, pages, commands, and preferences"
},
"apps": {
"label": "Apps",
"placeholder": "Search apps, boards, and integrations"
},
"command": {
"label": "Commands",
"placeholder": "Search actions and commands"
},
"preferences": {
"label": "Preferences",
"placeholder": "Search and change preferences"
},
"assistant": {
"label": "Assistant",
"placeholder": "Ask Homarr Assistant"
},
"external": {
"label": "Web",
"placeholder": "Search with an external search engine"
},
"media": {
"label": "Media",
"placeholder": "Search for a movie or show",
"fallback": "Search media for \"{query}\""
},
"userGroup": {
"label": "People",
"placeholder": "Search users and groups"
},
"continueWith": "Continue with",
"dialogAriaLabel": "{mode} command menu",
"navigationLabel": "Search modes",
"modeAriaLabel": "{mode} mode",
"resultsAriaLabel": "{mode} results",
"footer": {
"navigate": "Navigate",
"open": "Open",
"close": "Close"
}
},
"error": {
"fetch": "An error occurred while fetching data"
},
@@ -7049,7 +7001,6 @@
"description": "Start typing to search your media request integrations",
"disabled": {
"noIntegration": "Add an Overseerr, Jellyseerr, or Seerr integration to enable media search.",
"signInRequired": "Sign in to search media request integrations.",
"scoped": "This widget does not have an accessible media request integration."
}
}
@@ -7127,10 +7078,9 @@
"other": {
"label": "Search with another search engine"
},
"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"
"no-default": {
"label": "No default search engine",
"description": "Set a default search engine in preferences"
},
"search": {
"label": "Search for \"{query}\" with {name}"
+21
View File
@@ -9,6 +9,7 @@ import combineClasses from "clsx";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useSettings } from "@homarr/settings";
import { useRegisterSpotlightContextResults } from "@homarr/spotlight";
import { useI18n } from "@homarr/translation/client";
import { MaskedOrNormalImage } from "@homarr/ui";
@@ -28,6 +29,26 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
const appQuery = clientApi.app.byId.useQuery({ id: options.appId }, { enabled: Boolean(options.appId) });
const app = getUsableWidgetQueryData(appQuery);
const href = getSafeAppHref(app?.href);
useRegisterSpotlightContextResults(
`app-${app?.id ?? options.appId}`,
app && href
? [
{
id: app.id,
name: app.name,
icon: app.iconUrl,
interaction() {
return {
type: "link",
href,
newTab: options.openInNewTab,
};
},
},
]
: [],
[app, href, options.openInNewTab],
);
if (!options.appId) return <WidgetEmptyState />;
if (isInitialWidgetQueryPending(appQuery)) return <WidgetQueryLoadingState />;
@@ -73,7 +73,6 @@ 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() {
@@ -149,7 +148,10 @@ export default function BookmarksWidget({
</Stack>
</Center>
) : (
<ScrollArea scrollbars={plan.horizontalScroll ? "x" : "y"} style={{ flex: 1, minHeight: 0 }}>
<ScrollArea
scrollbars={plan.horizontalScroll ? "x" : "y"}
style={{ flex: 1, minHeight: 0 }}
>
<Box miw="100%" mih="100%" pb={2}>
{plan.horizontalScroll ? (
<Group gap={options.spacing} wrap="nowrap" mih="100%" align="stretch">