feat(search): rank the portal bar over the same sources as the editor

The portal's provider now composes the shared source builders extracted
from useSuperSearch — tools, settings and Processor pages rank
identically in both apps, with Processor leading in the portal and the
editor keeping its own order. Files stay editor-only (a file can only
open there). Selecting a tool from the portal hands over to the editor
via its URL routing; settings opens the portal's settings modal.

Opening a file from the editor's search now loads its stored stub
instead of re-adding it, which used to persist a duplicate record.
This commit is contained in:
Reece
2026-07-15 17:06:06 +01:00
parent 11b8f5fa4e
commit d734867ea9
5 changed files with 360 additions and 225 deletions
@@ -7895,8 +7895,6 @@ viewAll = "View all"
description = "Pipeline runs, deploys and agent events will appear here."
title = "Nothing here yet"
[portal.search]
goTo = "Go to"
[portal.settings.groups]
admin = "Admin"
@@ -7985,9 +7985,6 @@ viewAll = "View all"
description = "Pipeline runs, deploys and agent events will appear here."
title = "Nothing here yet"
[portal.search]
goTo = "Go to"
[portal.settings.groups]
admin = "Admin"
+252 -169
View File
@@ -7,6 +7,7 @@ import {
useState,
} from "react";
import type React from "react";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
@@ -14,14 +15,18 @@ import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import { useNavigationActions } from "@app/contexts/NavigationContext";
import { ViewerContext } from "@app/contexts/ViewerContext";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { useFileHandler } from "@app/hooks/useFileHandler";
import { useFileActions } from "@app/contexts/file/fileHooks";
import { fileStorage } from "@app/services/fileStorage";
import { rankByFuzzy, idToWords } from "@app/utils/fuzzySearch";
import type { StirlingFileStub } from "@app/types/fileContext";
import type { ToolId } from "@app/types/toolId";
import type { ToolRegistry } from "@app/data/toolsTaxonomy";
import { SETTINGS_SEARCH_INDEX } from "@app/data/settingsSearchIndex";
import { SETTINGS_SECTION_REGISTRY } from "@app/data/settingsSectionRegistry";
import { PROCESSOR_SEARCH_INDEX } from "@app/data/processorSearchIndex";
import {
PROCESSOR_SEARCH_INDEX,
type ProcessorSearchEntry,
} from "@app/data/processorSearchIndex";
export type SuperSearchGroupId = "files" | "tools" | "settings" | "processor";
@@ -64,35 +69,23 @@ export interface UseSuperSearchResult {
loadingFiles: boolean;
}
/**
* Aggregates the three super-search providers — My Files, Tools, and Settings —
* into a single ranked, grouped result set, and wires each result's select
* action (open file → viewer, select tool, deep-link into settings).
*
* @param query current search text
* @param active whether the search surface is open; gates the My Files load
*/
export function useSuperSearch(
query: string,
active: boolean,
): UseSuperSearchResult {
const { t } = useTranslation();
const navigate = useNavigate();
const {
toolRegistry,
handleToolSelect,
handleToolSelectForced,
toolAvailability,
} = useToolWorkflow();
const { actions: navActions } = useNavigationActions();
const { addFiles } = useFileHandler();
const { config } = useAppConfig();
// ViewerContext is only present once the viewer subtree mounts; treat as optional.
const viewer = useContext(ViewerContext);
/** Visibility gates shared by the settings and Processor sources. */
export interface SuperSearchGates {
isAdmin: boolean;
loginEnabled: boolean;
}
const trimmed = query.trim();
// ---------------------------------------------------------------------------
// Shared sources. Every host bar (editor workbench, portal shell) builds its
// results from these, so a query ranks identically everywhere — only the
// select actions differ (in-app contexts vs cross-app navigation).
// ---------------------------------------------------------------------------
// --- My Files store ----------------------------------------------------
/** Loads the My Files stubs whenever the search surface is open. */
export function useMyFilesStubs(active: boolean): {
stubs: StirlingFileStub[];
loadingFiles: boolean;
} {
const [stubs, setStubs] = useState<StirlingFileStub[]>([]);
const [loadingFiles, setLoadingFiles] = useState(false);
const loadedOnceRef = useRef(false);
@@ -120,20 +113,200 @@ export function useSuperSearch(
};
}, [active]);
return { stubs, loadingFiles };
}
export function rankFileResults(
stubs: StirlingFileStub[],
trimmed: string,
openFile: (stub: StirlingFileStub) => void | Promise<void>,
): SuperSearchResult[] {
if (!trimmed) return [];
return rankByFuzzy(stubs, trimmed, [(s) => s.name])
.slice(0, GROUP_LIMIT)
.map(({ item, score }) => ({
key: `file:${item.id}`,
group: "files",
title: item.name,
iconName: "insert-drive-file-rounded",
score,
onSelect: () => openFile(item),
}));
}
export function rankToolResults(
registry: Partial<ToolRegistry>,
trimmed: string,
openTool: (id: ToolId) => void,
): SuperSearchResult[] {
if (!trimmed) return [];
const entries = Object.entries(registry) as [
ToolId,
ToolRegistry[ToolId] | undefined,
][];
return rankByFuzzy(entries, trimmed, [
([id]) => idToWords(id),
([, v]) => v?.name ?? "",
([, v]) => v?.description ?? "",
([, v]) => v?.synonyms?.join(" ") ?? "",
])
.slice(0, GROUP_LIMIT)
.map(({ item: [id, tool], score }) => ({
key: `tool:${id}`,
group: "tools",
title: tool?.name ?? id,
subtitle: tool?.description,
icon: tool?.icon,
score,
onSelect: () => openTool(id),
}));
}
export function rankSettingsResults(
trimmed: string,
t: TFunction,
gates: SuperSearchGates,
openSettings: (section: string, anchor?: string) => void,
): SuperSearchResult[] {
if (!trimmed) return [];
const { isAdmin, loginEnabled } = gates;
// Row-level entries (deep-link with ?focus=) take priority.
const rows = rankByFuzzy(SETTINGS_SEARCH_INDEX, trimmed, [
(e) => t(e.labelKey, e.labelFallback),
(e) => e.labelFallback,
(e) => e.keywords?.join(" ") ?? "",
]).map(({ item, score }) => ({
key: `setting:${item.section}:${item.anchor}`,
group: "settings",
title: t(item.labelKey, item.labelFallback),
subtitle: t(`settings.${item.section}.title`, item.section),
iconName: "settings-rounded",
score: score + 1, // nudge rows above bare section matches
onSelect: () => openSettings(item.section, item.anchor),
}));
// Section-level entries (whole tab), gated like the modal nav. The registry
// resolves per build (core / proprietary / saas / desktop), so this only
// ever sees sections the current build's settings modal can actually show.
const visibleSections = SETTINGS_SECTION_REGISTRY.filter((s) => {
if (s.requiresLogin && !loginEnabled) return false;
// Admin-area sections mirror the builder's `isAdmin || !loginEnabled` gate.
if (s.adminArea && !(isAdmin || !loginEnabled)) return false;
return true;
});
const sections = rankByFuzzy(visibleSections, trimmed, [
(s) => t(s.labelKey, s.labelFallback),
(s) => s.labelFallback,
(s) => s.keywords?.join(" ") ?? "",
]).map(({ item, score }) => ({
key: `setting-section:${item.key}`,
group: "settings",
title: t(item.labelKey, item.labelFallback),
iconName: "settings-rounded",
score,
onSelect: () => openSettings(item.key),
}));
return [...rows, ...sections]
.sort((a, b) => b.score - a.score)
.slice(0, GROUP_LIMIT);
}
export function rankProcessorResults(
trimmed: string,
t: TFunction,
gates: SuperSearchGates,
selectEntry: (entry: ProcessorSearchEntry) => void,
): SuperSearchResult[] {
if (!trimmed || PROCESSOR_SEARCH_INDEX.length === 0) return [];
// The portal is an admin surface; mirror the settings modal's admin gate
// (a login-disabled single-user deployment has a full-access operator).
if (!(gates.isAdmin || !gates.loginEnabled)) return [];
return rankByFuzzy(PROCESSOR_SEARCH_INDEX, trimmed, [
(e) => t(e.labelKey, e.labelFallback),
(e) => e.labelFallback,
(e) => e.keywords?.join(" ") ?? "",
])
.slice(0, GROUP_LIMIT)
.map(({ item, score }) => ({
key: `processor:${item.id}`,
group: "processor",
title: t(item.labelKey, item.labelFallback),
iconName: "grid-view-rounded",
score,
onSelect: () => selectEntry(item),
}));
}
/**
* Orders the sources into the shared group layout, dropping empties. Hosts
* pass their own order so local results lead (the editor puts its own
* files/tools first and Processor pages last; the portal the reverse).
*/
export function assembleSuperSearchGroups(
byId: Partial<Record<SuperSearchGroupId, SuperSearchResult[]>>,
t: TFunction,
order: SuperSearchGroupId[] = GROUP_ORDER,
): SuperSearchGroup[] {
const labels: Record<SuperSearchGroupId, string> = {
files: t("superSearch.group.files", "Files"),
tools: t("superSearch.group.tools", "Tools"),
settings: t("superSearch.group.settings", "Settings"),
processor: t("superSearch.group.processor", "Processor"),
};
return order
.map((id) => ({
id,
label: labels[id],
results: byId[id] ?? [],
}))
.filter((g) => g.results.length > 0);
}
/**
* The editor's results provider: the shared sources wired to in-app select
* actions (open file → viewer, select tool in the workbench, deep-link into
* the settings modal, route into the Processor).
*
* @param query current search text
* @param active whether the search surface is open; gates the My Files load
*/
export function useSuperSearch(
query: string,
active: boolean,
): UseSuperSearchResult {
const { t } = useTranslation();
const navigate = useNavigate();
const {
toolRegistry,
handleToolSelect,
handleToolSelectForced,
toolAvailability,
} = useToolWorkflow();
const { actions: navActions } = useNavigationActions();
const { actions: fileActions } = useFileActions();
const { config } = useAppConfig();
// ViewerContext is only present once the viewer subtree mounts; treat as optional.
const viewer = useContext(ViewerContext);
const trimmed = query.trim();
const { stubs, loadingFiles } = useMyFilesStubs(active);
// --- Actions -----------------------------------------------------------
const openFile = useCallback(
async (stub: StirlingFileStub) => {
try {
const file = await fileStorage.getStirlingFile(stub.id);
if (!file) return;
await addFiles([file], { selectFiles: true });
// The file already lives in storage — load it as a stub so its id and
// metadata are preserved (addFiles would persist a duplicate record).
await fileActions.addStirlingFileStubs([stub], { selectFiles: true });
navActions.setWorkbench("viewer");
viewer?.setActiveFileId?.(stub.id);
} catch (err) {
console.error("[SuperSearch] Failed to open file:", stub.name, err);
}
},
[addFiles, navActions, viewer],
[fileActions, navActions, viewer],
);
const openTool = useCallback(
@@ -163,144 +336,54 @@ export function useSuperSearch(
[navigate],
);
// --- Files results -----------------------------------------------------
const fileResults = useMemo<SuperSearchResult[]>(() => {
if (!trimmed) return [];
return rankByFuzzy(stubs, trimmed, [(s) => s.name])
.slice(0, GROUP_LIMIT)
.map(({ item, score }) => ({
key: `file:${item.id}`,
group: "files" as const,
title: item.name,
iconName: "insert-drive-file-rounded",
score,
onSelect: () => openFile(item),
}));
}, [trimmed, stubs, openFile]);
// --- Tools results -----------------------------------------------------
const toolResults = useMemo<SuperSearchResult[]>(() => {
if (!trimmed) return [];
const entries = Object.entries(toolRegistry) as [
ToolId,
(typeof toolRegistry)[ToolId],
][];
return rankByFuzzy(entries, trimmed, [
([id]) => idToWords(id),
([, v]) => v?.name ?? "",
([, v]) => v?.description ?? "",
([, v]) => v?.synonyms?.join(" ") ?? "",
])
.slice(0, GROUP_LIMIT)
.map(({ item: [id, tool], score }) => ({
key: `tool:${id}`,
group: "tools" as const,
title: tool?.name ?? id,
subtitle: tool?.description,
icon: tool?.icon,
score,
onSelect: () => openTool(id),
}));
}, [trimmed, toolRegistry, openTool]);
// --- Settings results --------------------------------------------------
const settingsResults = useMemo<SuperSearchResult[]>(() => {
if (!trimmed) return [];
const isAdmin = config?.isAdmin ?? false;
const loginEnabled = config?.enableLogin ?? false;
// Row-level entries (deep-link with ?focus=) take priority.
const rows = rankByFuzzy(SETTINGS_SEARCH_INDEX, trimmed, [
(e) => t(e.labelKey, e.labelFallback),
(e) => e.labelFallback,
(e) => e.keywords?.join(" ") ?? "",
]).map(({ item, score }) => ({
key: `setting:${item.section}:${item.anchor}`,
group: "settings" as const,
title: t(item.labelKey, item.labelFallback),
subtitle: t(`settings.${item.section}.title`, item.section),
iconName: "settings-rounded",
score: score + 1, // nudge rows above bare section matches
onSelect: () => openSettings(item.section, item.anchor),
}));
// Section-level entries (whole tab), gated like the modal nav. The registry
// resolves per build (core / proprietary / saas / desktop), so this only
// ever sees sections the current build's settings modal can actually show.
const visibleSections = SETTINGS_SECTION_REGISTRY.filter((s) => {
if (s.requiresLogin && !loginEnabled) return false;
// Admin-area sections mirror the builder's `isAdmin || !loginEnabled` gate.
if (s.adminArea && !(isAdmin || !loginEnabled)) return false;
return true;
});
const sections = rankByFuzzy(visibleSections, trimmed, [
(s) => t(s.labelKey, s.labelFallback),
(s) => s.labelFallback,
(s) => s.keywords?.join(" ") ?? "",
]).map(({ item, score }) => ({
key: `setting-section:${item.key}`,
group: "settings" as const,
title: t(item.labelKey, item.labelFallback),
iconName: "settings-rounded",
score,
onSelect: () => openSettings(item.key),
}));
return [...rows, ...sections]
.sort((a, b) => b.score - a.score)
.slice(0, GROUP_LIMIT);
}, [trimmed, config, t, openSettings]);
// --- Processor (admin portal) results ------------------------------------
const processorResults = useMemo<SuperSearchResult[]>(() => {
if (!trimmed || PROCESSOR_SEARCH_INDEX.length === 0) return [];
// The portal is an admin surface; mirror the settings modal's admin gate
// (a login-disabled single-user deployment has a full-access operator).
const isAdmin = config?.isAdmin ?? false;
const loginEnabled = config?.enableLogin ?? false;
if (!(isAdmin || !loginEnabled)) return [];
return rankByFuzzy(PROCESSOR_SEARCH_INDEX, trimmed, [
(e) => t(e.labelKey, e.labelFallback),
(e) => e.labelFallback,
(e) => e.keywords?.join(" ") ?? "",
])
.slice(0, GROUP_LIMIT)
.map(({ item, score }) => ({
key: `processor:${item.id}`,
group: "processor" as const,
title: t(item.labelKey, item.labelFallback),
iconName: "grid-view-rounded",
score,
onSelect: () => {
if (item.externalUrl) {
window.open(item.externalUrl, "_blank", "noopener,noreferrer");
} else {
navigate(item.path);
}
},
}));
}, [trimmed, config, t, navigate]);
const selectProcessorEntry = useCallback(
(item: ProcessorSearchEntry) => {
if (item.externalUrl) {
window.open(item.externalUrl, "_blank", "noopener,noreferrer");
} else {
navigate(item.path);
}
},
[navigate],
);
// --- Assemble ----------------------------------------------------------
const groups = useMemo<SuperSearchGroup[]>(() => {
const byId: Record<SuperSearchGroupId, SuperSearchResult[]> = {
files: fileResults,
tools: toolResults,
settings: settingsResults,
processor: processorResults,
};
const labels: Record<SuperSearchGroupId, string> = {
files: t("superSearch.group.files", "Files"),
tools: t("superSearch.group.tools", "Tools"),
settings: t("superSearch.group.settings", "Settings"),
processor: t("superSearch.group.processor", "Processor"),
};
return GROUP_ORDER.map((id) => ({
id,
label: labels[id],
results: byId[id],
})).filter((g) => g.results.length > 0);
}, [fileResults, toolResults, settingsResults, processorResults, t]);
const gates = useMemo<SuperSearchGates>(
() => ({
isAdmin: config?.isAdmin ?? false,
loginEnabled: config?.enableLogin ?? false,
}),
[config],
);
const groups = useMemo<SuperSearchGroup[]>(
() =>
assembleSuperSearchGroups(
{
files: rankFileResults(stubs, trimmed, openFile),
tools: rankToolResults(toolRegistry, trimmed, openTool),
settings: rankSettingsResults(trimmed, t, gates, openSettings),
processor: rankProcessorResults(
trimmed,
t,
gates,
selectProcessorEntry,
),
},
t,
),
[
stubs,
trimmed,
openFile,
toolRegistry,
openTool,
gates,
openSettings,
selectProcessorEntry,
t,
],
);
const flatResults = useMemo(() => groups.flatMap((g) => g.results), [groups]);
@@ -1,16 +1,21 @@
import { AppConfigProvider } from "@app/contexts/AppConfigContext";
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).
* workbench shows, fed by the portal-wired results provider. Cmd/Ctrl+K
* focuses it (the bar registers its own shortcut). The config provider
* supplies the admin/login gates the settings and Processor sources share
* with the editor bar.
*/
export function PortalSearchBar() {
return (
<div className="portal-searchbar">
<SuperSearch useResults={usePortalSearchResults} />
</div>
<AppConfigProvider bootstrapMode="non-blocking">
<div className="portal-searchbar">
<SuperSearch useResults={usePortalSearchResults} />
</div>
</AppConfigProvider>
);
}
@@ -1,62 +1,114 @@
import { useMemo } from "react";
import { useCallback, 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 { useNavigate } from "react-router-dom";
import { withBasePath } from "@app/constants/app";
import { getToolUrlPath } from "@app/data/toolsTaxonomy";
import { useToolRegistry } from "@app/contexts/ToolRegistryContext";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import type { ToolId } from "@app/types/toolId";
import type { ProcessorSearchEntry } from "@app/data/processorSearchIndex";
import {
GROUP_PRIMARY,
GROUP_OPERATIONAL,
GROUP_PLATFORM,
type NavEntry,
} from "@portal/components/sidebarGroups";
assembleSuperSearchGroups,
rankProcessorResults,
rankSettingsResults,
rankToolResults,
type SuperSearchGates,
type SuperSearchGroupId,
type UseSuperSearchResult,
} from "@app/hooks/useSuperSearch";
import { useUI } from "@portal/contexts/UIContext";
import { EDITOR_IS_SAME_APP, EDITOR_URL } from "@portal/auth/editorUrl";
/** Processor pages lead in the portal; the editor bar orders its own first. */
const PORTAL_GROUP_ORDER: SuperSearchGroupId[] = [
"processor",
"tools",
"settings",
];
/**
* 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.
* Tool results live in the editor app, so selecting one is a full page load
* there (the editor initialises its tool state from the URL on boot —
* client-side routing can't reach that init once mounted).
*/
function editorHref(path: string): string {
if (EDITOR_IS_SAME_APP) return withBasePath(path);
return EDITOR_URL.replace(/\/$/, "") + path;
}
/**
* The portal's results provider for the shared super search bar: the same
* sources the editor bar ranks minus files (a file only opens inside the
* editor), with Processor pages leading. Only the select actions differ —
* tools hand over to the editor, settings opens the portal's settings modal,
* Processor pages navigate in-app.
*/
export function usePortalSearchResults(
query: string,
_active: boolean,
): UseSuperSearchResult {
const { t } = useTranslation();
const { setActiveView } = useView();
const navigate = useNavigate();
const { openSettings } = useUI();
const { allTools } = useToolRegistry();
const { config } = useAppConfig();
const entries = useMemo<NavEntry[]>(
() => [
...GROUP_PRIMARY,
...GROUP_OPERATIONAL,
...GROUP_PLATFORM,
{ id: "editor" as ViewId, icon: null },
],
[],
const trimmed = query.trim();
const openTool = useCallback((id: ToolId) => {
window.location.assign(editorHref(getToolUrlPath(id)));
}, []);
const openSettingsSection = useCallback(
(section: string) => openSettings(section),
[openSettings],
);
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 selectProcessorEntry = useCallback(
(item: ProcessorSearchEntry) => {
if (item.externalUrl) {
window.open(item.externalUrl, "_blank", "noopener,noreferrer");
} else {
navigate(item.path);
}
},
[navigate],
);
const gates = useMemo<SuperSearchGates>(
() => ({
isAdmin: config?.isAdmin ?? false,
loginEnabled: config?.enableLogin ?? false,
}),
[config],
);
const groups = useMemo(
() =>
assembleSuperSearchGroups(
{
tools: rankToolResults(allTools, trimmed, openTool),
settings: rankSettingsResults(trimmed, t, gates, openSettingsSection),
processor: rankProcessorResults(
trimmed,
t,
gates,
selectProcessorEntry,
),
},
t,
PORTAL_GROUP_ORDER,
),
[
trimmed,
allTools,
openTool,
gates,
openSettingsSection,
selectProcessorEntry,
t,
],
);
const flatResults = useMemo(() => groups.flatMap((g) => g.results), [groups]);