diff --git a/frontend/editor/public/locales/en-GB/translation.toml b/frontend/editor/public/locales/en-GB/translation.toml index fcabd6942b..4f0f6bbd20 100644 --- a/frontend/editor/public/locales/en-GB/translation.toml +++ b/frontend/editor/public/locales/en-GB/translation.toml @@ -7896,14 +7896,10 @@ description = "Pipeline runs, deploys and agent events will appear here." title = "Nothing here yet" [portal.search] -ariaLabel = "Search" goTo = "Go to" +hint = "Type to jump to any portal page" placeholder = "Search the portal…" -[portal.search.empty] -noMatches = "No matches for \"{{query}}\"" -noMatchesDescription = "Try a different keyword." - [portal.settings.groups] admin = "Admin" diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 4bb6237843..b70b4b47c2 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -7986,14 +7986,10 @@ description = "Pipeline runs, deploys and agent events will appear here." title = "Nothing here yet" [portal.search] -ariaLabel = "Search" goTo = "Go to" +hint = "Type to jump to any portal page" placeholder = "Search the portal…" -[portal.search.empty] -noMatches = "No matches for \"{{query}}\"" -noMatchesDescription = "Try a different keyword." - [portal.settings.groups] admin = "Admin" diff --git a/frontend/editor/src/core/components/shared/superSearch/SuperSearch.tsx b/frontend/editor/src/core/components/shared/superSearch/SuperSearch.tsx index 2ec91bf825..6817eb6fa1 100644 --- a/frontend/editor/src/core/components/shared/superSearch/SuperSearch.tsx +++ b/frontend/editor/src/core/components/shared/superSearch/SuperSearch.tsx @@ -13,7 +13,11 @@ import { Button } from "@app/ui/Button"; import { TextInput } from "@app/components/shared/TextInput"; import LocalIcon from "@app/components/shared/LocalIcon"; import { isMacLike } from "@app/utils/hotkeys"; -import { useSuperSearch, SuperSearchResult } from "@app/hooks/useSuperSearch"; +import { + useSuperSearch, + SuperSearchResult, + type UseSuperSearchResult, +} from "@app/hooks/useSuperSearch"; import "@app/components/shared/superSearch/SuperSearch.css"; interface DropdownRect { @@ -22,15 +26,35 @@ interface DropdownRect { width: number; } +interface SuperSearchProps { + /** + * Results provider, called as a hook — it MUST be referentially stable for + * the component's lifetime. Defaults to the editor's files/tools/settings/ + * Processor provider; the portal passes its own destinations provider. + */ + useResults?: (query: string, active: boolean) => UseSuperSearchResult; + /** Override the input placeholder (defaults to the editor copy). */ + placeholder?: string; + /** Override the empty-query hint line (defaults to the editor copy). */ + hint?: string; + /** DOM id for the input — external focus helpers target it. */ + inputId?: string; +} + /** - * Global "super search": a single entry point that searches across My Files, - * Tools, and Settings from the (now permanent) top bar. The results hang in a - * dropdown directly below the input; Cmd/Ctrl+K focuses and opens it. + * Global "super search": a single entry point that searches across the host + * app's destinations from a persistent bar. The results hang in a dropdown + * directly below the input; Cmd/Ctrl+K focuses and opens it. * - * The dropdown is portalled to : the workbench bar's inner wrapper sets + * The dropdown is portalled to : the host bar's inner wrapper may set * `overflow: hidden`, which would otherwise clip it. */ -export default function SuperSearch() { +export default function SuperSearch({ + useResults = useSuperSearch, + placeholder, + hint, + inputId = "super-search-input", +}: SuperSearchProps = {}) { const { t } = useTranslation(); const [query, setQuery] = useState(""); const [open, setOpen] = useState(false); @@ -41,7 +65,7 @@ export default function SuperSearch() { const containerRef = useRef(null); const dropdownRef = useRef(null); - const { groups, flatResults, loadingFiles } = useSuperSearch(query, open); + const { groups, flatResults, loadingFiles } = useResults(query, open); const trimmed = query.trim(); const hasQuery = trimmed.length > 0; @@ -166,10 +190,11 @@ export default function SuperSearch() { > {!hasQuery && (
- {t( - "superSearch.hint", - "Type to search across your files, tools and settings", - )} + {hint ?? + t( + "superSearch.hint", + "Type to search across your files, tools and settings", + )}
)} @@ -229,15 +254,15 @@ export default function SuperSearch() { return (
} diff --git a/frontend/editor/src/core/hooks/useSuperSearch.ts b/frontend/editor/src/core/hooks/useSuperSearch.ts index 8484c8183b..bafaa6f1b0 100644 --- a/frontend/editor/src/core/hooks/useSuperSearch.ts +++ b/frontend/editor/src/core/hooks/useSuperSearch.ts @@ -28,7 +28,8 @@ export type SuperSearchGroupId = "files" | "tools" | "settings" | "processor"; export interface SuperSearchResult { /** Stable unique key across all groups. */ key: string; - group: SuperSearchGroupId; + /** Group id — the editor uses SuperSearchGroupId; other hosts use their own. */ + group: string; title: string; subtitle?: string; /** LocalIcon name (files/settings); tools provide a React node via `icon`. */ @@ -39,7 +40,7 @@ export interface SuperSearchResult { } export interface SuperSearchGroup { - id: SuperSearchGroupId; + id: string; label: string; results: SuperSearchResult[]; } diff --git a/frontend/editor/src/portal/components/AppShell.tsx b/frontend/editor/src/portal/components/AppShell.tsx index b3744d36bf..bdb508ddac 100644 --- a/frontend/editor/src/portal/components/AppShell.tsx +++ b/frontend/editor/src/portal/components/AppShell.tsx @@ -1,17 +1,19 @@ import type { ReactNode } from "react"; import { Sidebar } from "@portal/components/Sidebar"; +import { PortalSearchBar } from "@portal/components/PortalSearchBar"; import "@portal/components/AppShell.css"; /** * Two-column layout: fixed-width sidebar on the left, a scrolling main column on - * the right. The Sidebar reads its state from context, so this shell stays - * prop-free. + * the right (topped by the global search bar). The Sidebar reads its state from + * context, so this shell stays prop-free. */ export function AppShell({ children }: { children: ReactNode }) { return (
+
{children}
diff --git a/frontend/editor/src/portal/components/PortalChrome.tsx b/frontend/editor/src/portal/components/PortalChrome.tsx index 1262c84b8e..05d59a52b8 100644 --- a/frontend/editor/src/portal/components/PortalChrome.tsx +++ b/frontend/editor/src/portal/components/PortalChrome.tsx @@ -1,39 +1,10 @@ -import { useEffect } from "react"; import { useLocation } from "react-router-dom"; import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider"; import { ErrorBoundary } from "@portal/components/ErrorBoundary"; -import { useUI } from "@portal/contexts/UIContext"; import { AppShell } from "@portal/components/AppShell"; -import { SearchModal } from "@portal/components/SearchModal"; import { PortalSettingsHost } from "@portal/components/PortalSettingsHost"; import { ViewRouter } from "@portal/ViewRouter"; -/** - * Global keyboard shortcuts. Lives below the UIProvider so it can dispatch into - * the overlay state. Currently just ⌘K / Ctrl+K to toggle the search palette. - */ -function GlobalShortcuts() { - const { toggleSearch, closeSearch } = useUI(); - - useEffect(() => { - function onKey(e: KeyboardEvent) { - const isCmdK = (e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k"; - if (isCmdK) { - e.preventDefault(); - toggleSearch(); - return; - } - if (e.key === "Escape") { - closeSearch(); - } - } - document.addEventListener("keydown", onKey); - return () => document.removeEventListener("keydown", onKey); - }, [toggleSearch, closeSearch]); - - return null; -} - /** * The routed view, wrapped in an error boundary so a single view crashing can't * white-screen the portal (the shell + nav stay alive). Keyed by route so @@ -49,23 +20,21 @@ function RoutedContent() { } /** - * The flavor-agnostic portal chrome: the shell (sidebar + header + routed view) - * plus the global overlays that every flavor shares. Requires only the Tier and - * UI contexts above it — both flavors provide those. Flavor-specific overlays - * (e.g. the self-hosted account-link modal) are mounted by PortalProviders, not - * here. + * The flavor-agnostic portal chrome: the shell (sidebar + search bar + routed + * view) plus the global overlays that every flavor shares. Requires only the + * Tier and UI contexts above it — both flavors provide those. Flavor-specific + * overlays (e.g. the self-hosted account-link modal) are mounted by + * PortalProviders, not here. */ export function PortalChrome() { return ( <> - {/* The pipeline builder reads the tool registry to list and configure operations. */} - ); diff --git a/frontend/editor/src/portal/components/PortalSearchBar.css b/frontend/editor/src/portal/components/PortalSearchBar.css new file mode 100644 index 0000000000..dfa43fa300 --- /dev/null +++ b/frontend/editor/src/portal/components/PortalSearchBar.css @@ -0,0 +1,11 @@ +/* Slim strip at the top of the main column hosting the shared search bar. */ +.portal-searchbar { + display: flex; + justify-content: center; + padding: var(--space-2) var(--space-3) 0; +} + +.portal-searchbar .super-search { + width: 100%; + max-width: 34rem; +} diff --git a/frontend/editor/src/portal/components/PortalSearchBar.tsx b/frontend/editor/src/portal/components/PortalSearchBar.tsx new file mode 100644 index 0000000000..be2415dc04 --- /dev/null +++ b/frontend/editor/src/portal/components/PortalSearchBar.tsx @@ -0,0 +1,23 @@ +import { useTranslation } from "react-i18next"; +import SuperSearch from "@app/components/shared/superSearch/SuperSearch"; +import { usePortalSearchResults } from "@portal/hooks/usePortalSearchResults"; +import "@portal/components/PortalSearchBar.css"; + +/** + * The portal face of the global super search — the same bar the editor's + * workbench shows, fed by the portal's destinations provider. Cmd/Ctrl+K + * focuses it (the bar registers its own shortcut). + */ +export function PortalSearchBar() { + const { t } = useTranslation(); + return ( +
+ +
+ ); +} diff --git a/frontend/editor/src/portal/components/SearchModal.css b/frontend/editor/src/portal/components/SearchModal.css deleted file mode 100644 index 183937a790..0000000000 --- a/frontend/editor/src/portal/components/SearchModal.css +++ /dev/null @@ -1,74 +0,0 @@ -.portal-search__input-row { - display: flex; - align-items: center; - gap: 0.625rem; - padding: 0.25rem 0.25rem 0.75rem; - border-bottom: 1px solid var(--color-border-light); - color: var(--color-text-4); -} - -.portal-search__input { - flex: 1 1 auto; - font: inherit; - font-size: 0.9375rem; - background: transparent; - border: none; - outline: none; - color: var(--color-text-1); -} - -.portal-search__input::placeholder { - color: var(--color-text-placeholder); -} - -.portal-search__esc { - font-family: var(--font-mono); - font-size: 0.6875rem; - padding: 0.0625rem 0.375rem; - border-radius: var(--radius-xs); - background: var(--color-bg-muted); - color: var(--color-text-4); -} - -.portal-search__results { - padding-top: 0.75rem; - display: flex; - flex-direction: column; - gap: 1rem; - max-height: 24rem; - overflow-y: auto; -} - -.portal-search__group-label { - font-size: 0.6875rem; - text-transform: uppercase; - letter-spacing: 0.06em; - color: var(--color-section-label); - padding: 0 0.5rem 0.375rem; -} - -.portal-search__item { - width: 100%; - display: flex; - align-items: center; - justify-content: space-between; - padding: 0.5rem 0.625rem; - border-radius: var(--radius-sm); - color: var(--color-text-2); - font-size: 0.8125rem; - text-align: left; -} - -.portal-search__item:hover { - background: var(--color-bg-hover); -} - -.portal-search__item-hint { - font-family: var(--font-mono); - font-size: 0.6875rem; - color: var(--color-text-5); -} - -.portal-search__item--active { - background: var(--color-bg-hover); -} diff --git a/frontend/editor/src/portal/components/SearchModal.stories.tsx b/frontend/editor/src/portal/components/SearchModal.stories.tsx deleted file mode 100644 index 16de3449e6..0000000000 --- a/frontend/editor/src/portal/components/SearchModal.stories.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { useEffect } from "react"; -import { http, HttpResponse } from "msw"; -import { SearchModal } from "@portal/components/SearchModal"; -import { useUI } from "@portal/contexts/UIContext"; - -function ForceOpen() { - const { openSearch } = useUI(); - useEffect(() => { - openSearch(); - }, [openSearch]); - return null; -} - -const meta: Meta = { - title: "Portal/Header/SearchModal", - component: SearchModal, - parameters: { layout: "fullscreen" }, - decorators: [ - (S) => ( -
- - -
- ), - ], -}; -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const EmptyCatalogue: Story = { - parameters: { - msw: { - handlers: [ - http.get("/v1/search/quick-actions", () => HttpResponse.json([])), - ], - }, - }, -}; diff --git a/frontend/editor/src/portal/components/SearchModal.tsx b/frontend/editor/src/portal/components/SearchModal.tsx deleted file mode 100644 index 5940fc6bf2..0000000000 --- a/frontend/editor/src/portal/components/SearchModal.tsx +++ /dev/null @@ -1,156 +0,0 @@ -import { useEffect, useMemo, useRef, useState } from "react"; -import { Button, EmptyState, Modal } from "@app/ui"; -import { useTranslation } from "react-i18next"; -import { rankByFuzzy } from "@app/utils/fuzzySearch"; -import { useUI } from "@portal/contexts/UIContext"; -import { useView, type ViewId } from "@portal/contexts/ViewContext"; -import { - GROUP_PRIMARY, - GROUP_OPERATIONAL, - GROUP_PLATFORM, - type NavEntry, -} from "@portal/components/sidebarGroups"; -import { SearchIcon } from "@portal/components/icons"; -import "@portal/components/SearchModal.css"; - -/** - * The portal's ⌘K palette — the portal face of the global super search. It - * searches the portal's own destinations (the same flavor-aware nav set the - * sidebar shows, plus the editor) and navigates on select; the editor's top-bar - * search is the same idea pointed at files/tools/settings/Processor. - */ -export function SearchModal() { - const { t } = useTranslation(); - const { searchOpen, closeSearch } = useUI(); - const { setActiveView } = useView(); - const inputRef = useRef(null); - const [query, setQuery] = useState(""); - const [highlight, setHighlight] = useState(0); - - useEffect(() => { - if (searchOpen) { - setQuery(""); - setHighlight(0); - const timer = setTimeout(() => inputRef.current?.focus(), 0); - return () => clearTimeout(timer); - } - return undefined; - }, [searchOpen]); - - // Every destination the palette can jump to: the sidebar's nav groups (a - // flavor seam — saas ships a reduced set) plus the editor app itself. - const entries = useMemo( - () => [ - ...GROUP_PRIMARY, - ...GROUP_OPERATIONAL, - ...GROUP_PLATFORM, - { id: "editor" as ViewId, icon: null }, - ], - [], - ); - - const results = useMemo(() => { - const q = query.trim(); - if (!q) return entries; - return rankByFuzzy(entries, q, [ - (e) => t(`portal.nav.${e.id}`), - (e) => e.id, - ]).map(({ item }) => item); - }, [entries, query, t]); - - useEffect(() => { - setHighlight((h) => - results.length === 0 ? 0 : Math.min(h, results.length - 1), - ); - }, [results.length]); - - const select = (entry: NavEntry | undefined) => { - if (!entry) return; - closeSearch(); - if (entry.externalUrl) { - window.open(entry.externalUrl, "_blank", "noopener,noreferrer"); - return; - } - setActiveView(entry.id); - }; - - const onKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "ArrowDown") { - e.preventDefault(); - setHighlight((h) => Math.min(h + 1, results.length - 1)); - } else if (e.key === "ArrowUp") { - e.preventDefault(); - setHighlight((h) => Math.max(h - 1, 0)); - } else if (e.key === "Enter") { - e.preventDefault(); - select(results[highlight]); - } - }; - - return ( - -
-
- - { - setQuery(e.target.value); - setHighlight(0); - }} - onKeyDown={onKeyDown} - placeholder={t("portal.search.placeholder")} - aria-label={t("portal.search.ariaLabel")} - className="portal-search__input" - autoComplete="off" - spellCheck={false} - /> - - ESC - -
- -
- {results.length === 0 ? ( - - ) : ( -
-
- {t("portal.search.goTo")} -
- {results.map((entry, i) => ( - - ))} -
- )} -
-
-
- ); -} diff --git a/frontend/editor/src/portal/contexts/UIContext.tsx b/frontend/editor/src/portal/contexts/UIContext.tsx index ba2ccbd344..a2b7b977d1 100644 --- a/frontend/editor/src/portal/contexts/UIContext.tsx +++ b/frontend/editor/src/portal/contexts/UIContext.tsx @@ -7,11 +7,6 @@ import { } from "react"; interface UIContextValue { - searchOpen: boolean; - openSearch: () => void; - closeSearch: () => void; - toggleSearch: () => void; - assistantOpen: boolean; openAssistant: () => void; closeAssistant: () => void; @@ -47,7 +42,6 @@ interface UIContextValue { const UIContext = createContext(null); export function UIProvider({ children }: { children: ReactNode }) { - const [searchOpen, setSearchOpen] = useState(false); const [assistantOpen, setAssistantOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); const [settingsInitialSection, setSettingsInitialSection] = useState< @@ -63,11 +57,6 @@ export function UIProvider({ children }: { children: ReactNode }) { const value = useMemo( () => ({ - searchOpen, - openSearch: () => setSearchOpen(true), - closeSearch: () => setSearchOpen(false), - toggleSearch: () => setSearchOpen((o) => !o), - assistantOpen, openAssistant: () => setAssistantOpen(true), closeAssistant: () => setAssistantOpen(false), @@ -108,7 +97,6 @@ export function UIProvider({ children }: { children: ReactNode }) { }, }), [ - searchOpen, assistantOpen, settingsOpen, settingsInitialSection, diff --git a/frontend/editor/src/portal/hooks/usePortalSearchResults.ts b/frontend/editor/src/portal/hooks/usePortalSearchResults.ts new file mode 100644 index 0000000000..0e5ea6552e --- /dev/null +++ b/frontend/editor/src/portal/hooks/usePortalSearchResults.ts @@ -0,0 +1,64 @@ +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { rankByFuzzy } from "@app/utils/fuzzySearch"; +import type { UseSuperSearchResult } from "@app/hooks/useSuperSearch"; +import { useView, type ViewId } from "@portal/contexts/ViewContext"; +import { + GROUP_PRIMARY, + GROUP_OPERATIONAL, + GROUP_PLATFORM, + type NavEntry, +} from "@portal/components/sidebarGroups"; + +/** + * The portal's results provider for the shared SuperSearch bar: the sidebar's + * flavor-aware destinations plus the editor app. The editor's provider is the + * files/tools/settings/Processor aggregate; this is its portal counterpart. + */ +export function usePortalSearchResults( + query: string, + _active: boolean, +): UseSuperSearchResult { + const { t } = useTranslation(); + const { setActiveView } = useView(); + + const entries = useMemo( + () => [ + ...GROUP_PRIMARY, + ...GROUP_OPERATIONAL, + ...GROUP_PLATFORM, + { id: "editor" as ViewId, icon: null }, + ], + [], + ); + + const groups = useMemo(() => { + const q = query.trim(); + if (!q) return []; + const results = rankByFuzzy(entries, q, [ + (e) => t(`portal.nav.${e.id}`), + (e) => e.id, + ]).map(({ item, score }) => ({ + key: `nav:${item.id}`, + group: "nav", + title: t(`portal.nav.${item.id}`), + icon: item.icon ?? undefined, + iconName: item.icon ? undefined : "search-rounded", + score, + onSelect: () => { + if (item.externalUrl) { + window.open(item.externalUrl, "_blank", "noopener,noreferrer"); + return; + } + setActiveView(item.id); + }, + })); + return results.length > 0 + ? [{ id: "nav", label: t("portal.search.goTo", "Go to"), results }] + : []; + }, [entries, query, t, setActiveView]); + + const flatResults = useMemo(() => groups.flatMap((g) => g.results), [groups]); + + return { groups, flatResults, loadingFiles: false }; +}