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:
@@ -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"
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user