Super search across Editor and Processor (#6734)

One search bar for the whole app, in both the Editor and the Processor.
**Cmd/Ctrl+K** from anywhere.

- **Editor** — your files, tools, settings, and everything in the
Processor: its pages plus live users, policies, pipelines, sources, and
full-text developer docs.
- **Processor** — the same bar over its own data (users, policies,
pipelines, sources, docs) plus tools and settings. Files stay
editor-only; pages stay out (the sidebar covers them).
- **Filter chips** (and typed prefixes like `policy: invoice`) narrow to
one lane; each group shows a few results with **Show more** to expand.
- **Access-gated**: users only see lanes and settings sections they can
actually open — no Processor chips, results, or entity fetches without
portal access, no admin settings for non-admins, no account-bound
sections for anonymous SaaS sessions. Asserted by unit tests and a
stubbed Playwright suite.
- Selecting a result takes you straight there, across apps when needed —
results group under collapsible sections.

Replaces the separate search boxes that previously lived in the file
sidebar, the tool panel header, the settings modal, and the docs page.

### Editor

<img
src="https://gist.githubusercontent.com/reecebrowne/5879e797c5ab6abc027a7bfd5cd4de17/raw/editor-search.png?v=2"
width="800" alt="Editor super search" />

### Processor

<img
src="https://gist.githubusercontent.com/reecebrowne/5879e797c5ab6abc027a7bfd5cd4de17/raw/portal-search.png?v=2"
width="800" alt="Processor super search" />

---------

Co-authored-by: EthanHealy01 <ethan.healy.21@gmail.com>
This commit is contained in:
Reece Browne
2026-08-13 12:38:45 +00:00
committed by GitHub
co-authored by EthanHealy01
parent cd49daf5c6
commit d2844c4f90
93 changed files with 6403 additions and 1618 deletions
@@ -3895,8 +3895,6 @@ openFromComputer = "Open from computer"
openSettings = "Open settings"
other = "Other"
recent = "Recent"
search = "Search"
searchPlaceholder = "Search files..."
viewAll = "View all {{count}} files"
[fileSidebar.fileItem]
@@ -3925,7 +3923,6 @@ backToMyFiles = "Back to My Files"
breadcrumbs = "Folder path"
cancel = "Cancel"
classification = "Classification"
clearSearch = "Clear search"
clearSelection = "Clear selection"
closeDetails = "Close details"
create = "Create"
@@ -3990,8 +3987,6 @@ resizeFolderTree = "Resize folder tree (arrow keys, Shift for bigger steps; doub
save = "Save"
saveToServer = "Save to server"
saveToServerDisabledHint = "Saving to the server isn't enabled on this server. Ask your admin to enable it."
search = "Search"
searchPlaceholder = "Search this folder & subfolders"
selectAll = "Select all"
selectAllHint = "Click to select all. Tip: hold Ctrl (or Cmd) to add files one at a time, Shift to select a range."
selectedCount = "{{count}} selected"
@@ -4051,6 +4046,10 @@ title = "No cloud files yet"
hint = "Files saved without uploading stay here. Drop a file to add one."
title = "No local-only files"
[filesPage.empty.noResults]
hint = "No files in this folder match your filter. Try a different term or clear the filter."
title = "No matching files"
[filesPage.empty.recent]
hint = "Files you open or edit will appear here."
title = "Nothing modified yet"
@@ -4120,6 +4119,11 @@ localHint = "Only stored in this browser"
shared = "Shared"
sharedHint = "Shared with you via link"
[filesPage.search]
clear = "Clear filter"
label = "Filter files by name"
placeholder = "Filter files…"
[filesPage.sort]
modifiedAsc = "Oldest first"
modifiedDesc = "Recent first"
@@ -5005,9 +5009,6 @@ title = "Upload from Mobile"
tags = "Multi Tool,Multi operation,UI,click drag,front end,client side,interactive,intractable,move,delete,migrate,divide"
title = "PDF Multi Tool"
[navbar]
search = "Search"
[oauth.error]
message = "Authentication was not successful. You can close this window and try again."
title = "Authentication Failed"
@@ -7091,11 +7092,6 @@ title = "Official SDKs"
beta = "Beta"
deprecated = "Deprecated"
[portal.docs.search]
empty = "No matching docs"
placeholder = "Search docs"
results = "{{count}} results"
[portal.docs.skills]
eyebrow = "SKILLS"
lead = "Bundled, named capabilities your agent invokes as a single tool. Each skill is a deterministic op chain with evals attached."
@@ -8701,16 +8697,6 @@ maxed = "Maxed out"
subtitle = "Your free trial runs through {{date}}. No card required."
title = "Enterprise trial"
[portal.search]
ariaLabel = "Search"
placeholder = "Search Stirling — endpoints, pipelines, docs…"
[portal.search.empty]
noActionsDescription = "Quick actions will appear here once they're available."
noActionsTitle = "No quick actions"
noMatches = "No matches for \"{{query}}\""
noMatchesDescription = "Try a different keyword or browse the catalogue."
[portal.settings.groups]
admin = "Admin"
@@ -9999,9 +9985,6 @@ title = "Policies & Privacy"
[settings.preferences]
title = "Preferences"
[settings.search]
placeholder = "Search settings pages..."
[settings.security]
description = "Update your password to keep your account secure."
title = "Security"
@@ -10644,6 +10627,23 @@ title = "Upload to Server"
updateButton = "Update on Server"
uploadButton = "Upload to Server"
[superSearch]
all = "All"
ariaLabel = "Super search"
filtersAriaLabel = "Search filters"
hint = "Type to search"
placeholder = "Search Stirling"
showLess = "Show less"
showMore = "Show {{count}} more"
[superSearch.group]
docs = "Docs"
files = "Files"
pages = "Pages"
processor = "Processor"
settings = "Settings"
tools = "Tools"
[survey]
title = "Stirling-PDF Survey"
@@ -10793,7 +10793,6 @@ goBack = "Go back"
pdfTools = "PDF Tools"
placeholder = "Choose a tool to get started"
premiumFeature = "Premium feature:"
searchTools = "Search tools"
toolsHeader = "Tools"
viewAllTools = "View all tools"
@@ -11665,6 +11664,7 @@ exitRedaction = "Exit Redaction Mode"
exportAll = "Export PDF"
exportSelected = "Export Selected Pages"
formFill = "Fill Form"
hideToolbar = "Hide toolbar"
multiTool = "Multi-Tool"
panMode = "Pan Mode"
print = "Print PDF"
@@ -11685,6 +11685,7 @@ selectAll = "Select All"
selectByNumber = "Select by Page Numbers"
selectLanguage = "Select language"
share = "Share"
showToolbar = "Show toolbar"
toggleAnnotations = "Toggle Annotations Visibility"
toggleAttachments = "Toggle Attachments"
toggleBookmarks = "Toggle Bookmarks"
@@ -3904,8 +3904,6 @@ openFromComputer = "Open from computer"
openSettings = "Open settings"
other = "Other"
recent = "Recent"
search = "Search"
searchPlaceholder = "Search files..."
viewAll = "View all {{count}} files"
[fileSidebar.fileItem]
@@ -3934,7 +3932,6 @@ backToMyFiles = "Back to My Files"
breadcrumbs = "Folder path"
cancel = "Cancel"
classification = "Classification"
clearSearch = "Clear search"
clearSelection = "Clear selection"
closeDetails = "Close details"
create = "Create"
@@ -3999,8 +3996,6 @@ resizeFolderTree = "Resize folder tree (arrow keys, Shift for bigger steps; doub
save = "Save"
saveToServer = "Save to server"
saveToServerDisabledHint = "Saving to the server isn't enabled on this server. Ask your admin to enable it."
search = "Search"
searchPlaceholder = "Search this folder & subfolders"
selectAll = "Select all"
selectAllHint = "Click to select all. Tip: hold Ctrl (or Cmd) to add files one at a time, Shift to select a range."
selectedCount = "{{count}} selected"
@@ -4060,6 +4055,10 @@ title = "No cloud files yet"
hint = "Files saved without uploading stay here. Drop a file to add one."
title = "No local-only files"
[filesPage.empty.noResults]
hint = "No files in this folder match your filter. Try a different term or clear the filter."
title = "No matching files"
[filesPage.empty.recent]
hint = "Files you open or edit will appear here."
title = "Nothing modified yet"
@@ -4129,6 +4128,11 @@ localHint = "Only stored in this browser"
shared = "Shared"
sharedHint = "Shared with you via link"
[filesPage.search]
clear = "Clear filter"
label = "Filter files by name"
placeholder = "Filter files…"
[filesPage.sort]
modifiedAsc = "Oldest first"
modifiedDesc = "Recent first"
@@ -5047,9 +5051,6 @@ title = "Upload from Mobile"
tags = "Multi Tool,Multi operation,UI,click drag,front end,client side,interactive,intractable,move,delete,migrate,divide"
title = "PDF Multi Tool"
[navbar]
search = "Search"
[oauth.error]
message = "Authentication was not successful. You can close this window and try again."
title = "Authentication Failed"
@@ -7133,11 +7134,6 @@ title = "Official SDKs"
beta = "Beta"
deprecated = "Deprecated"
[portal.docs.search]
empty = "No matching docs"
placeholder = "Search docs"
results = "{{count}} results"
[portal.docs.skills]
eyebrow = "SKILLS"
lead = "Bundled, named capabilities your agent invokes as a single tool. Each skill is a deterministic op chain with evals attached."
@@ -8813,16 +8809,6 @@ maxed = "Maxed out"
subtitle = "Your free trial runs through {{date}}. No card required."
title = "Enterprise trial"
[portal.search]
ariaLabel = "Search"
placeholder = "Search Stirling — endpoints, pipelines, docs…"
[portal.search.empty]
noActionsDescription = "Quick actions will appear here once they're available."
noActionsTitle = "No quick actions"
noMatches = "No matches for \"{{query}}\""
noMatchesDescription = "Try a different keyword or browse the catalogue."
[portal.settings.groups]
admin = "Admin"
@@ -10111,9 +10097,6 @@ title = "Policies & Privacy"
[settings.preferences]
title = "Preferences"
[settings.search]
placeholder = "Search settings pages..."
[settings.security]
description = "Update your password to keep your account secure."
title = "Security"
@@ -10769,6 +10752,23 @@ title = "Upload to Server"
updateButton = "Update on Server"
uploadButton = "Upload to Server"
[superSearch]
all = "All"
ariaLabel = "Super search"
filtersAriaLabel = "Search filters"
hint = "Type to search"
placeholder = "Search Stirling"
showLess = "Show less"
showMore = "Show {{count}} more"
[superSearch.group]
docs = "Docs"
files = "Files"
pages = "Pages"
processor = "Processor"
settings = "Settings"
tools = "Tools"
[survey]
title = "Stirling-PDF Survey"
@@ -10918,7 +10918,6 @@ goBack = "Go back"
pdfTools = "PDF Tools"
placeholder = "Choose a tool to get started"
premiumFeature = "Premium feature:"
searchTools = "Search tools"
toolsHeader = "Tools"
viewAllTools = "View all tools"
@@ -11799,6 +11798,7 @@ exitRedaction = "Exit Redaction Mode"
exportAll = "Export PDF"
exportSelected = "Export Selected Pages"
formFill = "Fill Form"
hideToolbar = "Hide toolbar"
multiTool = "Multi-Tool"
panMode = "Pan Mode"
print = "Print PDF"
@@ -11819,6 +11819,7 @@ selectAll = "Select All"
selectByNumber = "Select by Page Numbers"
selectLanguage = "Select language"
share = "Share"
showToolbar = "Show toolbar"
toggleAnnotations = "Toggle Annotations Visibility"
toggleAttachments = "Toggle Attachments"
toggleBookmarks = "Toggle Bookmarks"
@@ -26,6 +26,14 @@ export interface AuthContextType {
error: Error | null;
signOut: () => Promise<void>;
refreshSession: () => Promise<void>;
/**
* Session-level permission flags, provided by layers whose auth knows them
* (proprietary's Spring session carries both). Absent in core OSS, which
* has no auth context — consumers must treat undefined as "not granted"
* and fall back to app-config gates.
*/
isAdmin?: boolean;
portalAccess?: boolean;
}
/**
@@ -16,6 +16,7 @@ import DriveFileRenameOutlineIcon from "@mui/icons-material/DriveFileRenameOutli
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
import UploadFileIcon from "@mui/icons-material/UploadFile";
import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder";
import SearchIcon from "@mui/icons-material/Search";
import { FileId } from "@app/types/file";
import { FolderId, FolderRecord, ROOT_FOLDER_ID } from "@app/types/folder";
@@ -89,6 +90,8 @@ interface FileGridProps {
onChangeSortMode?: (mode: FilesPageSortMode) => void;
/** Drives the empty-state copy. */
currentTab?: "all" | "local" | "cloud" | "recent" | "shared" | "sharedByMe";
/** A filter is applied; an empty result then means "no matches", not "no files". */
searchActive?: boolean;
/** Cloud reachability; switches the cloud empty-state copy. */
serverReachable?: boolean;
/** Empty-state CTA handlers; if absent the matching button hides. */
@@ -104,6 +107,7 @@ export function FileGrid(props: FileGridProps & { loading?: boolean }) {
entries,
loading,
currentTab,
searchActive,
serverReachable,
onEmptyUpload,
onEmptyCreateFolder,
@@ -115,15 +119,28 @@ export function FileGrid(props: FileGridProps & { loading?: boolean }) {
}
if (entries.length === 0) {
return (
const emptyState = (
<EmptyState
tab={currentTab}
searchActive={searchActive}
serverReachable={serverReachable}
onUpload={onEmptyUpload}
onCreateFolder={onEmptyCreateFolder}
newFolderDisabledReason={newFolderDisabledReason}
/>
);
// When a filter empties the list view, keep the column headers in place and
// show the no-results message beneath them, rather than replacing the whole
// table. Grid view (cards, no headers) just shows the empty state.
if (viewMode === "list" && searchActive) {
return (
<>
<ListView {...props} />
{emptyState}
</>
);
}
return emptyState;
}
if (viewMode === "list") {
@@ -187,6 +204,8 @@ function SkeletonGrid({ viewMode }: { viewMode: FilesPageViewMode }) {
interface EmptyStateProps {
/** Drives copy + iconography. */
tab?: "all" | "local" | "cloud" | "recent" | "shared" | "sharedByMe";
/** When true the empty list is the result of a filter, not a bare folder. */
searchActive?: boolean;
/** Switches the cloud empty-state copy. */
serverReachable?: boolean;
/** CTA handlers; absent => button hidden. */
@@ -198,12 +217,35 @@ interface EmptyStateProps {
function EmptyState({
tab = "all",
searchActive = false,
serverReachable = true,
onUpload,
onCreateFolder,
newFolderDisabledReason,
}: EmptyStateProps) {
const { t } = useTranslation();
// A filter with no matches isn't an empty folder - say so, and skip the
// upload / new-folder CTAs since clearing the filter is the way out.
if (searchActive) {
return (
<div className="files-page-empty">
<span className="files-page-empty-icon">
<SearchIcon style={{ fontSize: "2.5rem" }} />
</span>
<div className="files-page-empty-title">
{t("filesPage.empty.noResults.title", "No matching files")}
</div>
<div className="files-page-empty-hint">
{t(
"filesPage.empty.noResults.hint",
"No files in this folder match your filter. Try a different term or clear the filter.",
)}
</div>
</div>
);
}
const { titleKey, titleFallback, hintKey, hintFallback } = (() => {
switch (tab) {
case "local":
@@ -7,11 +7,19 @@ import React, {
} from "react";
import { useTranslation } from "react-i18next";
import { useLocation, useNavigate } from "react-router-dom";
import { Drawer, Group, MultiSelect, Select, Tooltip } from "@mantine/core";
import {
Drawer,
Group,
MultiSelect,
Select,
TextInput,
Tooltip,
} from "@mantine/core";
import { Button } from "@app/ui/Button";
import { ActionIcon } from "@app/ui/ActionIcon";
import { SegmentedControl } from "@app/ui/SegmentedControl";
import { useMediaQuery } from "@mantine/hooks";
import CloseIcon from "@mui/icons-material/Close";
import SearchIcon from "@mui/icons-material/Search";
import UploadFileIcon from "@mui/icons-material/UploadFile";
import QrCode2Icon from "@mui/icons-material/QrCode2";
@@ -51,6 +59,8 @@ import { StirlingFileStub } from "@app/types/fileContext";
import { FolderId, ROOT_FOLDER_ID } from "@app/types/folder";
import { FileGrid, FilesPageEntry } from "@app/components/filesPage/FileGrid";
import SuperSearch from "@app/components/shared/superSearch/SuperSearch";
import { useEditorSearchScopes } from "@app/hooks/useSuperSearch";
import { FileDetailsPanel } from "@app/components/filesPage/FileDetailsPanel";
import BulkUploadToServerModal from "@app/components/shared/BulkUploadToServerModal";
import MobileUploadModal from "@app/components/shared/MobileUploadModal";
@@ -74,6 +84,7 @@ export default function FileManagerView() {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const searchScopes = useEditorSearchScopes();
// Hide Shared tab when storageSharingEnabled is false.
const { sharingEnabled } = useSharingEnabled();
@@ -686,12 +697,11 @@ export default function FileManagerView() {
}, [navigate]);
// ─── keyboard shortcuts ─────────────────────────────────────────────────
const searchInputRef = useRef<HTMLInputElement | null>(null);
// External focus trigger (used by the FileSidebar rail Search button).
useEffect(() => {
const onFocus = () => searchInputRef.current?.focus();
window.addEventListener("files-page:focus-search", onFocus);
return () => window.removeEventListener("files-page:focus-search", onFocus);
// Focus the super-search input (stable id), used by the "/" shortcut.
const focusSearch = useCallback(() => {
(
document.getElementById("super-search-input") as HTMLInputElement | null
)?.focus();
}, []);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
@@ -735,13 +745,19 @@ export default function FileManagerView() {
// "/" focuses the search field.
if (e.key === "/" && !inInput) {
e.preventDefault();
searchInputRef.current?.focus();
focusSearch();
return;
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [visibleFiles, selectedFileIds, removeFiles, setSelectedFileIds]);
}, [
visibleFiles,
selectedFileIds,
removeFiles,
setSelectedFileIds,
focusSearch,
]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
@@ -890,11 +906,9 @@ export default function FileManagerView() {
};
return (
<>
<SearchField
ref={searchInputRef}
value={search}
onChange={setSearch}
/>
<div className="files-page-header-search">
<SuperSearch scopes={searchScopes} />
</div>
<div className="files-page-header-actions">
<Tooltip
label={
@@ -1343,6 +1357,27 @@ export default function FileManagerView() {
aria-label={t("filesPage.typeFilter.label", "Filter by type")}
/>
)}
<TextInput
size="xs"
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
placeholder={t("filesPage.search.placeholder", "Filter files…")}
leftSection={<SearchIcon sx={{ fontSize: "1rem" }} />}
rightSection={
search ? (
<ActionIcon
variant="tertiary"
size="sm"
onClick={() => setSearch("")}
aria-label={t("filesPage.search.clear", "Clear filter")}
>
<CloseIcon sx={{ fontSize: "0.9rem" }} />
</ActionIcon>
) : null
}
aria-label={t("filesPage.search.label", "Filter files by name")}
style={{ width: 180 }}
/>
<Select
size="xs"
value={sortMode}
@@ -1434,6 +1469,7 @@ export default function FileManagerView() {
entries={entries}
loading={loading}
currentTab={currentTab}
searchActive={search.trim().length > 0}
serverReachable={folders.serverReachable}
selectedFileIds={selectedFileIds}
activeWorkspaceFileIds={activeWorkspaceFileIdSet}
@@ -1656,39 +1692,6 @@ export default function FileManagerView() {
);
}
const SearchField = React.forwardRef<
HTMLInputElement,
{ value: string; onChange: (v: string) => void }
>(function SearchField({ value, onChange }, ref) {
const { t } = useTranslation();
return (
<div className="files-page-search">
<SearchIcon fontSize="small" style={{ color: "var(--c-text-subtle)" }} />
<input
ref={ref}
type="text"
value={value}
onChange={(e) => onChange(e.currentTarget.value)}
placeholder={t(
"filesPage.searchPlaceholder",
"Search this folder & subfolders",
)}
aria-label={t("filesPage.search", "Search")}
/>
{value && (
<ActionIcon
variant="tertiary"
size="sm"
onClick={() => onChange("")}
aria-label={t("filesPage.clearSearch", "Clear search")}
>
&times;
</ActionIcon>
)}
</div>
);
});
function Breadcrumbs() {
const { t } = useTranslation();
const folders = useFolders();
@@ -34,6 +34,33 @@
gap: 0.5rem;
}
.files-page-header-search {
display: flex;
align-items: center;
justify-content: center;
min-width: 0;
}
.files-page-header-search .super-search {
flex: 0 1 24rem;
width: min(100%, 24rem);
max-width: 24rem;
}
.files-page-header-search .super-search input {
background-color: transparent;
padding-top: 4px;
padding-bottom: 4px;
font-size: 12.5px;
}
[data-mantine-color-scheme="dark"]
.files-page-header-search
.super-search
input {
background-color: transparent;
}
.files-page-breadcrumbs {
display: flex;
align-items: center;
@@ -79,28 +106,6 @@
flex-shrink: 0;
}
.files-page-search {
display: flex;
align-items: center;
gap: 0.35rem;
background: var(--c-surface);
border: 1px solid var(--c-border-subtle);
border-radius: 999px;
padding: 0.2rem 0.75rem;
/* Fills its grid cell; the cell's minmax(...) clamps to a sensible range. */
width: 100%;
min-width: 0;
}
.files-page-search input {
background: transparent;
border: none;
outline: none;
flex: 1;
color: var(--c-text);
font-size: 0.9rem;
}
.files-page-body {
display: flex;
flex: 1 1 auto;
@@ -1376,23 +1381,6 @@
overflow-x: auto;
min-width: 0;
}
.files-page-search {
/* Shrink hard so the search bar doesn't eat the whole action row.
Users still see the icon + a few chars of the placeholder.
`overflow: hidden` clips the input's natural intrinsic width so
placeholder text never leaks outside the rounded pill. */
min-width: 0;
flex: 0 1 5.5rem;
max-width: 6.5rem;
overflow: hidden;
}
.files-page-search input {
/* `min-width: 0` lets flex actually shrink the input below its
default ~20-char intrinsic size - without this, the placeholder
extends beyond the parent's clip box and bleeds onto neighbours. */
min-width: 0;
text-overflow: ellipsis;
}
/* Upload becomes an icon-only square button on mobile so the action
row stops getting clipped. Scoped to `.files-page-header-actions`
so the Back button at the header level keeps its visible "Back"
@@ -1,19 +1,48 @@
/* WorkbenchBar slide-in/out animation using CSS grid trick */
/* Positioning context for the viewer toolbar's reopen tab, which hangs below
the bar and so must sit outside the overflow-clipped wrapper below. */
.workbenchBarShell {
position: relative;
flex-shrink: 0;
z-index: 51;
}
/* Little pull-tab shown while the viewer tool row is retracted. Anchored to the
bar's bottom-right edge (where the retract handle sat). Small by design - it
just brings the row back. */
.workbenchBarReopenTab {
position: absolute;
top: 100%;
/* Right-align with the retract handle inside the bar: the bar's right
margin (--nav-gutter) + 1px border + 8px bar padding + the handle's own
6px inset. */
right: calc(var(--nav-gutter) + 15px);
display: flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 0.875rem;
border: 1px solid var(--c-border-subtle);
border-top: none;
border-radius: 0 0 7px 7px;
background: var(--c-bg-raised);
color: var(--c-text-subtle);
cursor: pointer;
z-index: 52;
transition:
color 0.15s ease,
background-color 0.15s ease;
}
.workbenchBarReopenTab:hover {
color: var(--c-text);
background: var(--c-hover);
}
.workbenchBarWrapper {
display: grid;
grid-template-rows: 1fr;
transition: grid-template-rows 280ms ease;
}
.workbenchBarWrapper[data-hidden="true"] {
grid-template-rows: 0fr;
}
.workbenchBarWrapper[data-no-transition="true"] {
transition: none;
}
/* Direct child must have min-height: 0 so the row can collapse below content size */
.workbenchBarInner {
min-height: 0;
overflow: hidden;
@@ -1,5 +1,8 @@
import { useEffect, useState, Suspense, lazy } from "react";
import { useState, Suspense, lazy } from "react";
import { useTranslation } from "react-i18next";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import { Box, Loader, Center } from "@mantine/core";
import { Button } from "@app/ui/Button";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import { useFileHandler } from "@app/hooks/useFileHandler";
import { useAllFiles } from "@app/contexts/FileContext";
@@ -66,18 +69,13 @@ export default function Workbench() {
const selectedTool = selectedToolId ? toolRegistry[selectedToolId] : null;
const { addFiles } = useFileHandler();
const hasFiles = activeFiles.length > 0;
// Custom workbench views (e.g. Watched Folders) manage their own content and may
// have no workbench files, but still need the bar's view switcher so users can
// navigate back out.
const isCustomViewActive = !isBaseWorkbench(currentView);
const { t } = useTranslation();
// Enable bar transitions after first paint so the initial hidden state shows
// without animating (landing page on load shouldn't animate the bar up).
const [barTransitionEnabled, setBarTransitionEnabled] = useState(false);
useEffect(() => {
const raf = requestAnimationFrame(() => setBarTransitionEnabled(true));
return () => cancelAnimationFrame(raf);
}, []);
// The viewer's tool row can be retracted to give the document more height.
// State lives here (not in WorkbenchBar) so the reopen tab can hang below the
// bar, outside the bar's overflow-clipped wrapper. Scoped to the viewer.
const [viewerToolbarCollapsed, setViewerToolbarCollapsed] = useState(false);
const showReopenTab = currentView === "viewer" && viewerToolbarCollapsed;
const handlePreviewClose = () => {
setPreviewFile(null);
@@ -219,22 +217,39 @@ export default function Workbench() {
data-tour="workbench"
style={{ backgroundColor: "var(--c-bg)", minWidth: 0 }}
>
{/* Workbench Bar - animates in/out based on file presence */}
{/* Workbench Bar always visible outside My Files (it hosts the
global search), even with no files loaded. */}
{currentView !== "myFiles" &&
!customWorkbenchViews.find((v) => v.workbenchId === currentView)
?.hideTopControls && (
<div
className={styles.workbenchBarWrapper}
data-hidden={String(!hasFiles && !isCustomViewActive)}
data-no-transition={String(!barTransitionEnabled)}
>
<div className={styles.workbenchBarInner}>
<WorkbenchBar
currentView={currentView}
setCurrentView={setCurrentView}
hasFiles={hasFiles}
/>
<div className={styles.workbenchBarShell}>
<div className={styles.workbenchBarWrapper}>
<div className={styles.workbenchBarInner}>
<WorkbenchBar
currentView={currentView}
setCurrentView={setCurrentView}
hasFiles={hasFiles}
viewerToolbarCollapsed={viewerToolbarCollapsed}
onCollapseViewerToolbar={setViewerToolbarCollapsed}
/>
</div>
</div>
{/* Reopen tab: a little handle hanging off the bar's bottom-right
while the viewer tool row is retracted. */}
{showReopenTab && (
<Button
type="button"
variant="quiet"
className={styles.workbenchBarReopenTab}
onClick={() => setViewerToolbarCollapsed(false)}
aria-expanded={false}
aria-label={t("workbenchBar.showToolbar", "Show toolbar")}
title={t("workbenchBar.showToolbar", "Show toolbar")}
leftSection={
<KeyboardArrowDownIcon sx={{ fontSize: "1rem" }} />
}
/>
)}
</div>
)}
@@ -1,4 +1,28 @@
/* AppConfigModal styles */
/* Deep-link highlight: pulses the control the super search jumped to
(navigated via /settings/{section}?focus={anchor}). */
@keyframes settings-focus-pulse {
0% {
box-shadow: 0 0 0 3px var(--mantine-color-blue-5);
background: color-mix(
in srgb,
var(--mantine-color-blue-5) 16%,
transparent
);
}
100% {
box-shadow: 0 0 0 6px transparent;
background: transparent;
}
}
.settings-focus-target {
animation: settings-focus-pulse 1.8s ease-out;
border-radius: 8px;
scroll-margin: 1rem;
}
.modal-container {
display: flex;
gap: 0;
@@ -173,16 +197,6 @@
padding-top: 1rem;
}
.settings-search-select {
min-width: 10rem;
}
.settings-search-option {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.confirm-modal-content {
display: flex;
flex-direction: column;
@@ -29,7 +29,6 @@ import {
UnsavedChangesProvider,
useUnsavedChanges,
} from "@app/contexts/UnsavedChangesContext";
import { SettingsSearchBar } from "@app/components/shared/config/SettingsSearchBar";
import { stripBasePath, withBasePath } from "@app/constants/app";
import { EDITOR_BASENAME } from "@app/routes/editorBasename";
@@ -46,6 +45,8 @@ interface AppConfigModalProps {
/** Section to land on when opening. Only honoured when urlSync is off (URL
* deep links win otherwise). */
initialSection?: NavKey | null;
/** Row anchor to focus when opening on a non-URL host. */
initialFocus?: string | null;
/** Host-specific sections appended after the build's registry sections. */
extraSections?: ConfigNavSection[];
/** Registry section keys to drop, for hosts a section can't run in. */
@@ -67,6 +68,7 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
onClose,
urlSync = true,
initialSection,
initialFocus,
extraSections,
hiddenSectionKeys,
}) => {
@@ -150,6 +152,35 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
[navigate, urlSync],
);
// Deep-link: /settings/{section}?focus={anchor} scrolls to and briefly
// highlights the matching control (used by the global super search to jump
// straight to an individual setting row).
useEffect(() => {
if (!opened) return;
const focus = urlSync
? new URLSearchParams(location.search).get("focus")
: initialFocus;
if (!focus) return;
let raf = 0;
// Wait for the (possibly just-switched) section to render before scrolling.
const timer = window.setTimeout(() => {
raf = window.requestAnimationFrame(() => {
const el = document.getElementById(focus);
if (!el) return;
el.scrollIntoView({ behavior: "smooth", block: "center" });
el.classList.add("settings-focus-target");
window.setTimeout(
() => el.classList.remove("settings-focus-target"),
1800,
);
});
}, 150);
return () => {
window.clearTimeout(timer);
if (raf) window.cancelAnimationFrame(raf);
};
}, [opened, active, initialFocus, location.search, urlSync]);
// Backwards-compat: external `appConfig:navigate` events route through the
// same switchSection path so they get the no-flash treatment too.
useEffect(() => {
@@ -185,9 +216,10 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
const runningEE = config?.runningEE ?? false;
const loginEnabled = config?.enableLogin ?? false;
/** Resolves false when a dirty-state confirm kept the modal open. */
const handleClose = useCallback(async () => {
const canProceed = await confirmIfDirty();
if (!canProceed) return;
if (!canProceed) return false;
// Only unwind history if settings was opened via the URL; opened via state
// there's no /settings entry to pop and navigate(-1) would jump to /files.
@@ -200,6 +232,7 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
}
}
onClose();
return true;
}, [
confirmIfDirty,
location.key,
@@ -214,6 +247,24 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
void handleClose();
}, [handleClose]);
// Cmd/Ctrl+K: hand over to the global super search. The bar's own shortcut
// is inert while a dialog traps focus, so the modal closes itself (through
// the same dirty-check as any other close) and asks the bar to take focus.
// Settings results deep-link straight back into this modal.
useEffect(() => {
if (!opened) return;
const onKey = (e: KeyboardEvent) => {
const combo = (e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey;
if (!combo || e.code !== "KeyK") return;
e.preventDefault();
void handleClose().then((closed) => {
if (closed) window.dispatchEvent(new Event("superSearch:focus"));
});
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [opened, handleClose]);
// Left navigation structure and icons
const registrySections = useConfigNavSections(
isAdmin,
@@ -413,11 +464,6 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
{activeLabel}
</Text>
<Group gap="xs" wrap="nowrap">
<SettingsSearchBar
configNavSections={configNavSections}
onNavigate={handleNavigation}
isMobile={isMobile}
/>
<ActionIcon
ref={closeButtonRef}
variant="tertiary"
@@ -18,6 +18,8 @@ interface AppConfigModalLazyProps {
urlSync?: boolean;
/** Section to land on when opening (non-URL hosts). */
initialSection?: NavKey | null;
/** Row anchor to highlight when opening (non-URL hosts). */
initialFocus?: string | null;
/** Host-specific sections appended after the build's registry sections. */
extraSections?: ConfigNavSection[];
/** Registry section keys to drop, for hosts a section can't run in. */
@@ -29,6 +31,7 @@ export default function AppConfigModalLazy({
onClose,
urlSync,
initialSection,
initialFocus,
extraSections,
hiddenSectionKeys,
}: AppConfigModalLazyProps) {
@@ -46,6 +49,7 @@ export default function AppConfigModalLazy({
onClose={onClose}
urlSync={urlSync}
initialSection={initialSection}
initialFocus={initialFocus}
extraSections={extraSections}
hiddenSectionKeys={hiddenSectionKeys}
/>
@@ -95,7 +95,6 @@
}
/* Centre each row's icon in the narrow rail (no side padding/margin to shove
it off the edge). */
.file-sidebar[data-collapsed="true"] .file-sidebar-search-row,
.file-sidebar[data-collapsed="true"] .file-sidebar-action-row,
.file-sidebar[data-collapsed="true"] .file-sidebar-cloud-row {
justify-content: center;
@@ -139,55 +138,6 @@
color: var(--c-accent-text);
}
/* ---- Search row ---- */
.file-sidebar-search-row {
display: flex;
align-items: center;
min-height: 32px;
padding: 0 8px;
gap: 0;
cursor: pointer;
border-radius: 4px;
margin: 0;
flex-shrink: 0;
transition: background-color 0.15s ease;
}
.file-sidebar-search-row:not(.active):hover {
background-color: var(--c-hover);
}
.file-sidebar-search-icon {
color: var(--c-text-subtle) !important;
font-size: 18px !important;
flex-shrink: 0;
}
.file-sidebar-search-close {
cursor: pointer;
}
.file-sidebar-search-input {
flex: 1;
background: transparent;
border: none;
outline: none;
font-size: 14px;
color: var(--c-text);
margin-left: 12px;
min-width: 0;
}
.file-sidebar-search-input::placeholder {
color: var(--c-text-subtle);
}
.file-sidebar-search-label {
margin-left: 12px;
font-size: 14px;
color: var(--c-text);
}
/* ---- Scrollable content ---- */
/* This is a flex column - action rows are fixed, only the file list scrolls */
.file-sidebar-scroll {
@@ -33,11 +33,9 @@ import { GoogleDriveIcon } from "@app/components/shared/CloudStorageIcons";
import { AppSwitcher } from "@app/components/shared/AppSwitcher";
import { SidebarToggleIcon } from "@app/components/shared/SidebarToggleIcon";
import type { StirlingFileStub } from "@app/types/fileContext";
import SearchIcon from "@mui/icons-material/Search";
import FolderOpenIcon from "@mui/icons-material/FolderOpen";
import FolderSpecialIcon from "@mui/icons-material/FolderSpecial";
import UploadFileIcon from "@mui/icons-material/UploadFile";
import CloseIcon from "@mui/icons-material/Close";
import AddIcon from "@mui/icons-material/Add";
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
import SettingsIcon from "@mui/icons-material/Settings";
@@ -102,8 +100,6 @@ export interface FileSidebarProps {
onUploadFiles?: (files: File[]) => void | Promise<void>;
/** Override the Google Drive handler. */
onPickGoogleDriveFiles?: (files: File[]) => void | Promise<void>;
/** Override the Search row click (e.g. focus the /files search input). */
onSearchClick?: () => void;
/** Extra action row inserted under Open-from-computer (e.g. New folder). */
extraAction?: {
icon: React.ReactNode;
@@ -155,7 +151,6 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
onOpenSettings,
onUploadFiles,
onPickGoogleDriveFiles,
onSearchClick,
extraAction,
toggleAriaLabel,
toggleIcon,
@@ -168,9 +163,6 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
// Classification off (non-SaaS / AI-off) → never show the per-row label chip,
// even if a stub carries labels from an imported PDF; keeps the row plain.
const classificationEnabled = useClassificationEnabled();
const [searchActive, setSearchActive] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const searchInputRef = useRef<HTMLInputElement>(null);
const nativeFileInputRef = useRef<HTMLInputElement>(null);
// State (not ref) so setting it triggers a re-render - avoids racing addFiles state updates.
const [pendingViewFileId, setPendingViewFileId] = useState<string | null>(
@@ -439,17 +431,8 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
}
}, [pendingViewFileId, state.files.ids, setActiveFileId, navActions]);
// Memoized so an unrelated re-render (e.g. a policy-run store tick) keeps a
// stable array identity — avoids re-running the grouping memo + backfill effect.
const filteredFileStubs = useMemo(() => {
const q = searchQuery.trim().toLowerCase();
return q
? allFileStubs.filter((stub) => stub.name.toLowerCase().includes(q))
: allFileStubs;
}, [allFileStubs, searchQuery]);
// SaaS groups by classification label; core returns null → one flat, recency-sorted list.
const fileGroups = useFileSidebarGroups(filteredFileStubs);
const fileGroups = useFileSidebarGroups(allFileStubs);
// Workbench membership as a Set for O(1) per-row lookups (see renderFileRow).
const workbenchIds = useMemo(
() => new Set(state.files.ids.map((id) => id as string)),
@@ -459,12 +442,12 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
// must key by their unique leaf id rather than the shared lineage (see renderFileRow).
const lineageCounts = useMemo(() => {
const counts = new Map<string, number>();
for (const s of filteredFileStubs) {
for (const s of allFileStubs) {
const k = (s.originalFileId ?? s.id) as string;
counts.set(k, (counts.get(k) ?? 0) + 1);
}
return counts;
}, [filteredFileStubs]);
}, [allFileStubs]);
// Per-group expand/collapse, falling back to each group's default until toggled.
const [groupOpen, setGroupOpen] = useState<Record<string, boolean>>({});
const setGroupOpenState = useCallback(
@@ -473,29 +456,6 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
[],
);
// Handle search activation
const handleSearchClick = useCallback(() => {
if (onSearchClick) {
onSearchClick();
return;
}
if (collapsed && onToggleCollapse) {
onToggleCollapse();
}
setSearchActive(true);
}, [collapsed, onToggleCollapse, onSearchClick]);
const handleSearchClose = useCallback(() => {
setSearchActive(false);
setSearchQuery("");
}, []);
useEffect(() => {
if (searchActive && searchInputRef.current) {
searchInputRef.current.focus();
}
}, [searchActive]);
// Handle Google Drive
const handleGoogleDriveClick = useCallback(async () => {
if (!isGoogleDriveEnabled) return;
@@ -854,58 +814,9 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
)}
</div>
{/* Box 1 — top controls (search + open / my files / cloud). No title. */}
{/* Box 1 — top controls (open / my files / cloud). No title. File
search lives in the global super search (top bar), not here. */}
<NavSurface className="file-sidebar-controls">
{/* Search row */}
<Tooltip
label={t("fileSidebar.search", "Search")}
position="right"
withinPortal
disabled={!collapsed}
>
<div
className={`file-sidebar-search-row${searchActive && !collapsed ? " active" : ""}`}
onClick={!searchActive ? handleSearchClick : undefined}
role={!searchActive ? "button" : undefined}
tabIndex={!searchActive ? 0 : undefined}
onKeyDown={
!searchActive
? (e) => e.key === "Enter" && handleSearchClick()
: undefined
}
>
{searchActive && !collapsed ? (
<CloseIcon
className="file-sidebar-search-icon"
onClick={(e) => {
e.stopPropagation();
handleSearchClose();
}}
/>
) : (
<SearchIcon className="file-sidebar-search-icon" />
)}
{!collapsed &&
(searchActive ? (
<input
ref={searchInputRef}
className="file-sidebar-search-input sidebar-content-fade"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t(
"fileSidebar.searchPlaceholder",
"Search files...",
)}
onClick={(e) => e.stopPropagation()}
/>
) : (
<span className="file-sidebar-search-label sidebar-content-fade">
{t("fileSidebar.search", "Search")}
</span>
))}
</div>
</Tooltip>
{/* Hidden native file input - kept outside the !collapsed gate so
the "Open from computer" row below (always rendered) can fire
it in either sidebar state without a silent no-op. */}
@@ -1138,7 +1049,7 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
<span className="file-sidebar-section-label">
{t("fileSidebar.library", "PDF Library")}
</span>
<FileSidebarGroupControls stubs={filteredFileStubs} />
<FileSidebarGroupControls stubs={allFileStubs} />
<ActionIcon
variant="quiet"
className="file-sidebar-section-btn file-sidebar-section-btn-external"
@@ -1172,7 +1083,7 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
<div className="file-sidebar-loading">
<Loader size="sm" color="var(--c-text-subtle)" />
</div>
) : filteredFileStubs.length > 0 ? (
) : allFileStubs.length > 0 ? (
<div className="file-sidebar-file-list">
{fileGroups ? (
<>
@@ -1250,29 +1161,24 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
"fileSidebar.viewAll",
"View all {{count}} files",
{
count: filteredFileStubs.length,
count: allFileStubs.length,
},
)}
</Button>
</>
) : (
filteredFileStubs.map(renderFileRow)
allFileStubs.map(renderFileRow)
)}
</div>
) : (
!searchActive && (
<div className="file-sidebar-empty">
<p className="file-sidebar-empty-text">
{t("fileSidebar.noFiles", "No files yet")}
</p>
<p className="file-sidebar-empty-hint">
{t(
"fileSidebar.dropHint",
"Open files to get started",
)}
</p>
</div>
)
<div className="file-sidebar-empty">
<p className="file-sidebar-empty-text">
{t("fileSidebar.noFiles", "No files yet")}
</p>
<p className="file-sidebar-empty-hint">
{t("fileSidebar.dropHint", "Open files to get started")}
</p>
</div>
)}
</div>
)}
@@ -36,6 +36,13 @@ export interface TextInputProps {
readOnly?: boolean;
/** Accessibility label */
"aria-label"?: string;
/** ARIA role override (e.g. "combobox" for inputs driving a listbox). */
role?: React.AriaRole;
/** Combobox wiring — forwarded to the native input. */
"aria-expanded"?: boolean;
"aria-controls"?: string;
"aria-activedescendant"?: string;
"aria-autocomplete"?: React.AriaAttributes["aria-autocomplete"];
/** Focus event handler */
onFocus?: () => void;
/** Allow the icon to receive pointer events (e.g. when icon is a clickable button) */
@@ -2,11 +2,17 @@
/* Horizontal toolbar at the top of the workbench area. */
.workbench-bar {
/* Flex-wrap layout that reflows by content width (measured in JS, which sets
data-wrapped). Roomy: row 1 is [views | search | globals] and the tool
icons sit on their own full-width row below. Crowded (data-wrapped): the
search bumps to its own row, leaving [views | globals] on row 1 and the
tools on row 3 — three rows total. `order` drives the reflow. */
display: flex;
flex-wrap: wrap;
align-items: center;
align-content: flex-start;
min-height: 40px;
column-gap: 8px;
min-height: 38px;
padding: 0 8px;
/* No left margin: the file sidebar's own 0.5rem padding already provides the
gutter on that side, so adding one here would double it and leave the bar
@@ -21,12 +27,16 @@
/* ---- View switcher (left) ---- */
.workbench-bar-views {
order: 1;
flex-shrink: 0;
display: flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
order: 1;
height: 40px;
/* min-width:0 lets it shrink instead of forcing a wrap; overflow clips labels
on very narrow viewports. */
min-width: 0;
overflow: hidden;
height: 38px;
z-index: 0;
}
@@ -34,7 +44,7 @@
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
padding: 2px 8px;
border: none;
border-radius: 8px;
background: transparent;
@@ -86,27 +96,47 @@
height: 16px;
}
/* ---- Center: tool buttons ---- */
/* Single-row: center sits between views and globals */
.workbench-bar-center {
/* ---- Super search ---- */
/* Roomy: grows to fill the centre between views and globals. */
.workbench-bar-search {
order: 2;
flex: 1 1 auto;
min-width: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 2px;
height: 40px;
overflow: hidden;
height: 38px;
transform: translateX(var(--workbench-bar-search-offset, 0px));
}
/* Two-row: center drops below views+globals */
.workbench-bar[data-wrapped="true"] .workbench-bar-center {
.workbench-bar-search .super-search {
flex: 0 1 24rem;
width: min(100%, 24rem);
max-width: 24rem;
}
.workbench-bar-search .super-search input {
background-color: transparent;
padding-top: 4px;
padding-bottom: 4px;
font-size: 12.5px;
}
[data-mantine-color-scheme="dark"] .workbench-bar-search .super-search input {
background-color: transparent;
}
/* Crowded: drops to its own full-width row between the top row and the tools. */
.workbench-bar[data-wrapped="true"] .workbench-bar-search {
order: 3;
flex: 0 0 100%;
height: auto;
padding: 4px 0;
transform: none;
}
/* Two-row: the tool row scrolls sideways instead of wrapping. */
.workbench-bar[data-wrapped="true"] .workbench-bar-center {
border-top: 1px solid var(--c-border-subtle);
justify-content: flex-start;
overflow-x: auto;
@@ -118,21 +148,68 @@
flex-shrink: 0;
}
/* ---- Right: global buttons (theme / language / download) ---- */
/* ---- Tool buttons (own full-width row, always below the top row) ---- */
.workbench-bar-center {
order: 4;
flex: 0 0 100%;
position: relative;
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 2px;
/* Symmetric side padding leaves room for the retract handle pinned right
without knocking the centred tool icons off-centre. */
padding: 4px 36px;
border-top: 1px solid var(--c-border-subtle);
}
/* Retract / reopen handle for the viewer tool row. */
.workbench-bar-toolbar-handle {
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 6px;
background: transparent;
color: var(--c-text-subtle);
cursor: pointer;
padding: 0;
transition:
color 0.15s ease,
background-color 0.15s ease;
}
.workbench-bar-toolbar-handle:hover {
color: var(--c-text);
background: var(--c-hover);
}
/* Pinned to the right edge of the tool row. */
.workbench-bar-toolbar-handle-retract {
position: absolute;
right: 6px;
top: 50%;
transform: translateY(-50%);
width: 28px;
height: 28px;
}
/* ---- Right: global buttons (print / download / save / close), row 1 ---- */
.workbench-bar-globals {
order: 3;
flex-shrink: 0;
display: flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
order: 3;
height: 40px;
min-width: 0;
height: 38px;
margin-left: auto;
}
/* In two-row mode globals moves to row 1 right side */
/* Crowded: globals stays on row 1, moving up beside the views. */
.workbench-bar[data-wrapped="true"] .workbench-bar-globals {
order: 2;
margin-left: auto;
}
/* Shared action icon style - applies to both center and global buttons.
@@ -1,6 +1,6 @@
import React, {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useSyncExternalStore,
@@ -33,6 +33,8 @@ import { ViewerContext, useViewer } from "@app/contexts/ViewerContext";
import { WorkbenchType, isBaseWorkbench } from "@app/types/workbench";
import { Tooltip } from "@app/components/shared/Tooltip";
import LocalIcon from "@app/components/shared/LocalIcon";
import SuperSearch from "@app/components/shared/superSearch/SuperSearch";
import { useEditorSearchScopes } from "@app/hooks/useSuperSearch";
import ViewerShareButton from "@app/components/viewer/ViewerShareButton";
import { useSharingEnabled } from "@app/hooks/useSharingEnabled";
import { usePolicyFileBadges } from "@app/hooks/usePolicyFileBadges";
@@ -53,6 +55,7 @@ import InsertDriveFileOutlinedIcon from "@mui/icons-material/InsertDriveFileOutl
import FolderOutlinedIcon from "@mui/icons-material/FolderOutlined";
import CloseIcon from "@mui/icons-material/Close";
import PrintIcon from "@mui/icons-material/Print";
import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp";
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
import "@app/components/shared/WorkbenchBar.css";
@@ -68,6 +71,10 @@ interface WorkbenchBarProps {
currentView: WorkbenchType;
setCurrentView: (view: WorkbenchType) => void;
hasFiles: boolean;
/** Whether the viewer's tool row is currently retracted. */
viewerToolbarCollapsed?: boolean;
/** Setter for the viewer tool-row retract state (owned by Workbench). */
onCollapseViewerToolbar?: (collapsed: boolean) => void;
}
function renderWithTooltip(
@@ -92,9 +99,12 @@ export default function WorkbenchBar({
currentView,
setCurrentView,
hasFiles,
viewerToolbarCollapsed = false,
onCollapseViewerToolbar,
}: WorkbenchBarProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const searchScopes = useEditorSearchScopes();
const returnRoute = useSyncExternalStore(
subscribeFilesPageReturnRoute,
getFilesPageReturnRoute,
@@ -115,6 +125,7 @@ export default function WorkbenchBar({
} = useToolWorkflow();
const { selectedTool } = useNavigationState();
const isCustomView = !isBaseWorkbench(currentView);
const isViewer = currentView === "viewer";
const disableForFullscreen =
toolPanelMode === "fullscreen" && leftPanelView === "toolPicker";
const terminology = useFileActionTerminology();
@@ -435,34 +446,47 @@ export default function WorkbenchBar({
})),
];
// Reflow the top row by content width: when the views + globals leave too
// little room for a usable search, bump the search to its own row
const barRef = useRef<HTMLDivElement>(null);
useEffect(() => {
useLayoutEffect(() => {
const bar = barRef.current;
if (!bar) return;
const MIN_SEARCH_WIDTH = 320;
const measure = () => {
const viewsEl = bar.querySelector<HTMLElement>(".workbench-bar-views");
const globalsEl = bar.querySelector<HTMLElement>(
".workbench-bar-globals",
);
const centerEl = bar.querySelector<HTMLElement>(".workbench-bar-center");
const viewsWidth = viewsEl?.offsetWidth ?? 0;
const globalsWidth = globalsEl?.offsetWidth ?? 0;
const centerChildren = centerEl
? (Array.from(centerEl.children) as HTMLElement[])
: [];
const centerWidth =
centerChildren.reduce((sum, el) => sum + el.offsetWidth, 0) +
Math.max(0, centerChildren.length - 1) * 2; // gap: 2px
const needed = viewsWidth + centerWidth + globalsWidth + 24; // 24px bar padding
bar.dataset.wrapped = String(needed > bar.clientWidth);
// clientWidth minus the two side clusters, the bar's 16px h-padding and
// the two 8px column gaps flanking the search.
const available = bar.clientWidth - viewsWidth - globalsWidth - 16 - 16;
const wrapped = available < MIN_SEARCH_WIDTH;
bar.dataset.wrapped = String(wrapped);
// Centre the search on the bar rather than its slot — clamped to the
// slot's spare width, because the shift is a transform (no layout) and
// an unclamped value would paint the pill over the adjacent cluster.
const slotEl = bar.querySelector<HTMLElement>(".workbench-bar-search");
const pillEl = slotEl?.querySelector<HTMLElement>(".super-search");
const slack = Math.max(
0,
((slotEl?.offsetWidth ?? 0) - (pillEl?.offsetWidth ?? 0)) / 2,
);
const centred = (globalsWidth - viewsWidth) / 2;
const offset = Math.min(slack, Math.max(-slack, centred));
bar.style.setProperty(
"--workbench-bar-search-offset",
wrapped ? "0px" : `${offset}px`,
);
};
const ro = new ResizeObserver(measure);
ro.observe(bar);
const viewsEl = bar.querySelector<HTMLElement>(".workbench-bar-views");
const globalsEl = bar.querySelector<HTMLElement>(".workbench-bar-globals");
if (viewsEl) ro.observe(viewsEl);
if (globalsEl) ro.observe(globalsEl);
measure();
return () => ro.disconnect();
}, []);
@@ -471,7 +495,7 @@ export default function WorkbenchBar({
<div
ref={barRef}
className="workbench-bar"
data-wrapped="true"
data-wrapped="false"
data-tour="workbench-bar"
>
{/* Left: optional "Back to My Files" + view switcher */}
@@ -524,27 +548,50 @@ export default function WorkbenchBar({
)}
</div>
{/* Tool buttons - second row, only rendered when buttons exist */}
{sectionsWithButtons.length > 0 && (
<div className="workbench-bar-center">
{sectionsWithButtons.map(
({ section, buttons: sectionButtons }, idx) => (
<React.Fragment key={section}>
{idx > 0 && <div className="workbench-bar-divider" />}
{sectionButtons.map((btn) => {
const content = renderButton(btn);
if (!content) return null;
return (
<div key={btn.id} className="workbench-bar-action-wrapper">
{content}
</div>
);
})}
</React.Fragment>
),
)}
</div>
)}
{/* Global super search - always present, even on the homepage */}
<div className="workbench-bar-search">
<SuperSearch scopes={searchScopes} />
</div>
{/* Tool buttons - second row, only rendered when buttons exist. In the
viewer the row is retractable: a handle on its right edge hides the
whole row; Workbench then shows a tab below the bar to bring it back. */}
{sectionsWithButtons.length > 0 &&
!(isViewer && viewerToolbarCollapsed) && (
<div className="workbench-bar-center">
{sectionsWithButtons.map(
({ section, buttons: sectionButtons }, idx) => (
<React.Fragment key={section}>
{idx > 0 && <div className="workbench-bar-divider" />}
{sectionButtons.map((btn) => {
const content = renderButton(btn);
if (!content) return null;
return (
<div
key={btn.id}
className="workbench-bar-action-wrapper"
>
{content}
</div>
);
})}
</React.Fragment>
),
)}
{isViewer && onCollapseViewerToolbar && (
<Button
type="button"
variant="quiet"
className="workbench-bar-toolbar-handle workbench-bar-toolbar-handle-retract"
onClick={() => onCollapseViewerToolbar(true)}
aria-expanded
aria-label={t("workbenchBar.hideToolbar", "Hide toolbar")}
title={t("workbenchBar.hideToolbar", "Hide toolbar")}
leftSection={<KeyboardArrowUpIcon sx={{ fontSize: "1rem" }} />}
/>
)}
</div>
)}
{/* Right: Global buttons - export group left, close anchored right */}
<div className="workbench-bar-globals">
@@ -1,62 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { SettingsSearchBar } from "@app/components/shared/config/SettingsSearchBar";
import type { ConfigNavSection } from "@app/components/shared/config/configNavSections";
const mockConfigNavSections: ConfigNavSection[] = [
{
title: "Preferences",
items: [
{
key: "general",
label: "General",
icon: "settings-rounded",
component: null,
},
{
key: "hotkeys",
label: "Keyboard Shortcuts",
icon: "keyboard-rounded",
component: null,
},
],
},
{
title: "Workspace",
items: [
{
key: "people",
label: "People",
icon: "group-rounded",
component: null,
},
{
key: "teams",
label: "Teams",
icon: "groups-rounded",
component: null,
disabled: true,
},
],
},
];
const meta = {
title: "Shared/Config/SettingsSearchBar",
component: SettingsSearchBar,
parameters: { layout: "padded" },
args: {
configNavSections: mockConfigNavSections,
onNavigate: async () => {},
isMobile: false,
},
} satisfies Meta<typeof SettingsSearchBar>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const Mobile: Story = {
args: {
isMobile: true,
},
};
@@ -1,240 +0,0 @@
import React, { useMemo, useState, useCallback } from "react";
import { Select, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
import { NavKey, VALID_NAV_KEYS } from "@app/components/shared/config/types";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
import type {
ConfigNavSection,
ConfigNavItem,
} from "@app/components/shared/config/configNavSections";
interface SettingsSearchBarProps {
configNavSections: ConfigNavSection[];
onNavigate: (key: NavKey) => Promise<void>;
isMobile: boolean;
}
interface SettingsSearchOption {
value: NavKey;
label: string;
sectionTitle: string;
destinationPath: string;
searchableContent: string[];
matchedContext?: string;
}
const SETTINGS_SEARCH_TRANSLATION_PREFIXES: Partial<Record<string, string[]>> =
{
general: ["settings.general"],
hotkeys: ["settings.hotkeys"],
account: ["account"],
people: ["settings.workspace"],
teams: ["settings.workspace", "settings.team"],
"api-keys": ["settings.developer"],
connectionMode: ["settings.connection"],
planBilling: ["settings.planBilling"],
adminGeneral: ["admin.settings.general"],
adminFeatures: ["admin.settings.features"],
adminEndpoints: ["admin.settings.endpoints"],
adminDatabase: ["admin.settings.database"],
adminAdvanced: ["admin.settings.advanced"],
adminSecurity: ["admin.settings.security"],
adminMcp: ["admin.settings.mcp"],
adminConnections: [
"admin.settings.connections",
"admin.settings.mail",
"admin.settings.security",
"admin.settings.telegram",
"admin.settings.premium",
"admin.settings.general",
"settings.securityAuth",
"settings.connection",
],
adminPlan: [
"settings.planBilling",
"admin.settings.premium",
"settings.licensingAnalytics",
],
adminAudit: ["settings.licensingAnalytics"],
adminUsage: ["settings.licensingAnalytics"],
adminLegal: ["admin.settings.legal"],
adminPrivacy: ["admin.settings.privacy"],
};
const getTranslationPrefixesForNavKey = (key: string): string[] => {
const explicitPrefixes = SETTINGS_SEARCH_TRANSLATION_PREFIXES[key] ?? [];
const inferredPrefixes: string[] = [];
if (key.startsWith("admin")) {
const adminSuffix = key.replace(/^admin/, "");
const normalizedAdminSuffix =
adminSuffix.charAt(0).toLowerCase() + adminSuffix.slice(1);
inferredPrefixes.push(`admin.settings.${normalizedAdminSuffix}`);
} else {
inferredPrefixes.push(`settings.${key}`);
}
return Array.from(new Set([...explicitPrefixes, ...inferredPrefixes]));
};
const flattenTranslationStrings = (value: unknown): string[] => {
if (typeof value === "string") {
const trimmed = value.trim();
return trimmed ? [trimmed] : [];
}
if (Array.isArray(value)) {
return value.flatMap(flattenTranslationStrings);
}
if (value && typeof value === "object") {
return Object.values(value as Record<string, unknown>).flatMap(
flattenTranslationStrings,
);
}
return [];
};
const buildMatchSnippet = (text: string, query: string): string => {
const normalizedText = text.toLocaleLowerCase();
const normalizedQuery = query.toLocaleLowerCase();
const matchIndex = normalizedText.indexOf(normalizedQuery);
if (matchIndex === -1) {
return text;
}
const maxLength = 84;
const contextPadding = 28;
const start = Math.max(0, matchIndex - contextPadding);
const end = Math.min(text.length, matchIndex + query.length + contextPadding);
const snippet = text.slice(start, end);
if (snippet.length <= maxLength) {
return `${start > 0 ? "…" : ""}${snippet}${end < text.length ? "…" : ""}`;
}
return `${start > 0 ? "…" : ""}${snippet.slice(0, maxLength)}${end < text.length ? "…" : ""}`;
};
export const SettingsSearchBar: React.FC<SettingsSearchBarProps> = ({
configNavSections,
onNavigate,
isMobile,
}) => {
const { t } = useTranslation();
const [searchValue, setSearchValue] = useState("");
// Build a global index from every accessible settings tab in the modal navigation.
// This does not render section components, so API calls still happen only when a tab is opened.
const searchableSections = useMemo<SettingsSearchOption[]>(() => {
return configNavSections.flatMap((section) =>
section.items
.filter((item: ConfigNavItem) => !item.disabled)
.map((item: ConfigNavItem) => {
const translationPrefixes = getTranslationPrefixesForNavKey(item.key);
const translationContent = translationPrefixes.flatMap((prefix) =>
flattenTranslationStrings(
t(prefix, { returnObjects: true, defaultValue: {} }),
),
);
const searchableContent = Array.from(
new Set([
item.label,
section.title,
`/settings/${item.key}`,
...translationContent,
]),
);
return {
value: item.key,
label: item.label,
sectionTitle: section.title,
destinationPath: `/settings/${item.key}`,
searchableContent,
};
}),
);
}, [configNavSections, t]);
const filteredSearchableSections = useMemo<SettingsSearchOption[]>(() => {
const query = searchValue.trim();
if (!query) {
return searchableSections;
}
const normalizedQuery = query.toLocaleLowerCase();
return searchableSections.reduce<SettingsSearchOption[]>(
(accumulator, option) => {
const matchedEntry = option.searchableContent.find((entry) =>
entry.toLocaleLowerCase().includes(normalizedQuery),
);
if (!matchedEntry) {
return accumulator;
}
accumulator.push({
...option,
matchedContext: buildMatchSnippet(matchedEntry, query),
});
return accumulator;
},
[],
);
}, [searchValue, searchableSections]);
const handleSearchNavigation = useCallback(
async (value: string | null) => {
if (!value) return;
if (!VALID_NAV_KEYS.includes(value as NavKey)) return;
await onNavigate(value as NavKey);
setSearchValue("");
},
[onNavigate],
);
return (
<Select
className="settings-search-select"
data={filteredSearchableSections}
value={null}
searchValue={searchValue}
onSearchChange={setSearchValue}
onChange={handleSearchNavigation}
placeholder={t("settings.search.placeholder", "Search settings pages...")}
leftSection={<LocalIcon icon="search-rounded" width={16} height={16} />}
aria-label={t("navbar.search", "Search")}
nothingFoundMessage={t("search.noResults", "No results found")}
searchable
clearable={false}
w={isMobile ? 170 : 320}
filter={({ options }) => options}
renderOption={({ option }) => {
const searchOption = option as unknown as SettingsSearchOption;
return (
<div className="settings-search-option">
<Text size="sm" fw={600}>
{searchOption.label}
</Text>
<Text size="xs" c="dimmed">
{searchOption.sectionTitle} ·{" "}
{searchOption.matchedContext || searchOption.destinationPath}
</Text>
</div>
);
}}
comboboxProps={{
withinPortal: true,
zIndex: Z_INDEX_OVER_CONFIG_MODAL,
}}
/>
);
};
@@ -478,6 +478,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<div
id="setting-theme"
style={{
display: "flex",
alignItems: "center",
@@ -520,6 +521,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
{/* Language */}
<Paper withBorder p="md" radius="md">
<div
id="setting-language"
style={{
display: "flex",
alignItems: "center",
@@ -544,6 +546,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<div
id="setting-tool-picker-mode"
style={{
display: "flex",
alignItems: "center",
@@ -582,6 +585,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
/>
</div>
<div
id="setting-startup-view"
style={{
display: "flex",
alignItems: "center",
@@ -624,6 +628,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
/>
</div>
<div
id="setting-reader-zoom"
style={{
display: "flex",
alignItems: "center",
@@ -680,6 +685,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
/>
</div>
<div
id="setting-hide-unavailable-tools"
style={{
display: "flex",
alignItems: "center",
@@ -712,6 +718,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
/>
</div>
<div
id="setting-hide-unavailable-conversions"
style={{
display: "flex",
alignItems: "center",
@@ -753,6 +760,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
withArrow
>
<div
id="setting-auto-unzip"
style={{
display: "flex",
alignItems: "center",
@@ -791,6 +799,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
withArrow
>
<div
id="setting-auto-unzip-file-limit"
style={{
display: "flex",
alignItems: "center",
@@ -146,7 +146,7 @@ const HotkeysSection: React.FC = () => {
return (
<Stack gap="lg">
<div>
<div id="setting-hotkeys-search">
<Text fw={600} size="lg">
{t("settings.hotkeys.title", "Keyboard Shortcuts")}
</Text>
@@ -0,0 +1,251 @@
.super-search {
position: relative;
flex: 1 1 auto;
min-width: 0;
max-width: 560px;
--super-search-input-border: color-mix(
in srgb,
var(--c-text) 14%,
var(--c-border-subtle)
);
--super-search-input-outline: color-mix(
in srgb,
var(--c-text) 5%,
transparent
);
}
.super-search-input-row {
position: relative;
}
.super-search input {
box-sizing: border-box;
border: 1px solid var(--super-search-input-border);
border-radius: var(--radius-md);
background-color: var(--c-input-bg);
box-shadow: 0 0 0 1px var(--super-search-input-outline);
/* Tighter than the shared TextInput default (8px/14px) for a compact,
professional bar — height lands in line with the 28px action icons. */
padding-top: 6px;
padding-bottom: 6px;
font-size: 13px;
}
.super-search input:focus {
border-color: var(--c-primary);
}
/* Keyboard-shortcut hint pinned to the right of the input. */
.super-search-kbd {
position: absolute;
right: 0.5rem;
top: 50%;
transform: translateY(-50%);
pointer-events: none;
font-size: 0.7rem;
line-height: 1;
padding: 0.15rem 0.35rem;
border-radius: var(--radius-sm);
color: var(--c-text-subtle);
background: var(--c-hover);
border: 1px solid var(--c-border-subtle);
/* Tiny breathing room between the ⌘ glyph and the K. */
letter-spacing: 0.06em;
}
/* Portalled to <body>; coords are set inline from the input's viewport rect. */
.super-search-dropdown {
position: fixed;
z-index: 1000;
max-height: 60vh;
overflow-y: auto;
padding: 0.35rem;
background: var(--c-surface);
border: 1px solid var(--c-border-subtle);
border-radius: var(--radius-lg);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
}
.super-search-empty {
padding: 0.75rem 0.6rem;
font-size: 0.85rem;
color: var(--c-text-subtle);
}
.super-search-filters {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
padding: 0.35rem 0.35rem 0.5rem;
margin-bottom: 0.15rem;
border-bottom: 1px solid var(--c-border-subtle);
}
.super-search-filters .sui-chip {
user-select: none;
}
.super-search-section + .super-search-section {
margin-top: 0.5rem;
}
.super-search-section {
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.super-search-section-header {
display: flex;
align-items: center;
gap: 0.45rem;
margin-left: -0.65rem;
padding: 0.15rem 0.25rem 0.05rem 0;
}
.super-search-section-header::after {
content: "";
flex: 1 1 auto;
min-width: 1.5rem;
height: 1px;
background: color-mix(in srgb, var(--c-text) 12%, var(--c-border-subtle));
}
.super-search-section-body {
padding: 0.05rem 0.05rem 0.2rem 0;
}
.super-search-section-toggle.mantine-Button-root {
--button-height: auto;
--button-padding-x: 0;
min-height: 0;
padding-block: 0;
flex: 0 0 auto;
}
.super-search-section-toggle.mantine-Button-root:hover,
.super-search-section-toggle.mantine-Button-root:focus-visible {
background: transparent;
}
.super-search-section-toggle:hover .super-search-section-label {
color: var(--c-text);
}
.super-search-section-toggle .mantine-Button-inner {
align-items: center;
}
/* Per-group "show N more" / "show less" toggle. */
.super-search-show-more.mantine-Button-root {
--button-height: auto;
min-height: 0;
padding: 0.25rem 0.5rem 0.35rem;
font-size: 0.78rem;
color: var(--c-text-subtle);
}
.super-search-show-more.mantine-Button-root:hover,
.super-search-show-more.mantine-Button-root:focus-visible {
background: transparent;
color: var(--c-text);
}
.super-search-section-toggle .mantine-Button-label {
text-align: left;
}
.super-search-section-toggle .mantine-Button-section {
margin-inline-start: 0.2rem;
}
.super-search-section-chevron {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 1rem;
height: 1rem;
color: var(--c-text-subtle);
transition:
transform 140ms ease,
color 140ms ease;
}
.super-search-section-toggle:hover .super-search-section-chevron {
color: var(--c-text-muted);
}
.super-search-section-chevron--collapsed {
transform: rotate(-90deg);
}
.super-search-group + .super-search-group {
margin-top: 0.45rem;
padding-top: 0.35rem;
border-top: 1px solid color-mix(in srgb, var(--c-text) 8%, transparent);
}
.super-search-section-label {
padding: 0.18rem 0;
font-size: 0.74rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--c-text-muted);
}
.super-search-group-label {
padding: 0.35rem 0.15rem 0.18rem 0.85rem;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--c-text-subtle);
}
.super-search-group-results {
padding-left: 1.25rem;
}
.super-search-item {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.45rem 0.5rem;
border: none;
border-radius: var(--radius-md);
background: transparent;
text-align: left;
cursor: pointer;
color: var(--c-text);
}
.super-search-item.active,
.super-search-item:hover {
background: var(--c-hover);
}
.super-search-item-icon {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 1.4rem;
height: 1.4rem;
color: var(--c-text-muted);
}
.super-search-item-text {
display: flex;
flex-direction: column;
min-width: 0;
}
.super-search-item-title {
font-size: 0.875rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@@ -0,0 +1,537 @@
import { useEffect, useState } from "react";
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
vi.mock("@app/hooks/useSuperSearch", () => ({
useSuperSearch: vi.fn(() => ({
groups: [],
flatResults: [],
loadingFiles: false,
})),
}));
vi.mock("@app/utils/hotkeys", () => ({
isMacLike: () => false,
}));
import SuperSearch from "@app/components/shared/superSearch/SuperSearch";
import type {
SuperSearchQueryOptions,
SuperSearchScope,
} from "@app/hooks/useSuperSearch";
interface TestResult {
key: string;
group: string;
title: string;
subtitle?: string;
score: number;
onSelect: () => void | Promise<void>;
}
interface TestGroup {
id: string;
label: string;
sectionLabel?: string;
results: TestResult[];
}
interface TestUseResultsResult {
groups: TestGroup[];
flatResults: TestResult[];
loadingFiles: boolean;
}
type TestUseResultsHook = (
query: string,
active: boolean,
options?: SuperSearchQueryOptions,
) => TestUseResultsResult;
const TEST_SCOPES: SuperSearchScope[] = [
{
id: "portal-policies",
label: "Policies",
aliases: ["policy", "policies"],
},
{
id: "portal-pipelines",
label: "Pipelines",
aliases: ["pipeline", "pipelines"],
},
];
function makeResult(
key: string,
title: string,
onSelect = vi.fn(),
): TestResult {
return {
key,
group: "tools",
title,
score: 100,
onSelect,
};
}
function renderSearch(
useResults?: TestUseResultsHook,
scopes?: readonly SuperSearchScope[],
) {
return render(
<MantineProvider>
<SuperSearch
inputId="test-super-search"
useResults={useResults as TestUseResultsHook | undefined}
scopes={scopes}
/>
</MantineProvider>,
);
}
describe("SuperSearch", () => {
beforeEach(() => {
vi.spyOn(Element.prototype, "getBoundingClientRect").mockReturnValue({
x: 0,
y: 0,
top: 0,
left: 0,
right: 320,
bottom: 40,
width: 320,
height: 40,
toJSON: () => "",
} as DOMRect);
Object.defineProperty(Element.prototype, "scrollIntoView", {
value: vi.fn(),
writable: true,
configurable: true,
});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("opens from Ctrl+K based on e.code and exposes combobox wiring", async () => {
const useResults: TestUseResultsHook = () => ({
groups: [],
flatResults: [],
loadingFiles: false,
});
renderSearch(useResults);
const input = screen.getByRole("combobox");
expect(input).toHaveAttribute("aria-expanded", "false");
fireEvent.keyDown(document.body, {
ctrlKey: true,
code: "KeyK",
key: "x",
});
await waitFor(() => {
expect(input).toHaveFocus();
expect(input).toHaveAttribute("aria-expanded", "true");
});
expect(input).toHaveAttribute("aria-controls", "test-super-search-listbox");
expect(screen.getByRole("listbox")).toBeInTheDocument();
});
it("keeps the highlight on the same result key when async results reorder", async () => {
const alphaSelect = vi.fn();
const betaSelect = vi.fn();
const useAsyncResults: TestUseResultsHook = (query, active) => {
const [swapped, setSwapped] = useState(false);
useEffect(() => {
if (!active || !query.trim()) {
setSwapped(false);
return;
}
const timeoutId = window.setTimeout(() => setSwapped(true), 50);
return () => window.clearTimeout(timeoutId);
}, [active, query]);
const results = swapped
? [
makeResult("beta", "Beta", betaSelect),
makeResult("alpha", "Alpha", alphaSelect),
]
: [
makeResult("alpha", "Alpha", alphaSelect),
makeResult("beta", "Beta", betaSelect),
];
const groups = query.trim()
? [{ id: "tools", label: "Tools", results }]
: [];
return {
groups,
flatResults: groups.flatMap((group) => group.results),
loadingFiles: false,
};
};
renderSearch(useAsyncResults);
const input = screen.getByRole("combobox");
fireEvent.focus(input);
fireEvent.change(input, { target: { value: "a" } });
await screen.findByText("Alpha");
fireEvent.keyDown(input, { key: "ArrowDown" });
expect(input).toHaveAttribute(
"aria-activedescendant",
"test-super-search-option-1",
);
await waitFor(() => {
expect(input).toHaveAttribute(
"aria-activedescendant",
"test-super-search-option-0",
);
});
expect(screen.getByText("Beta").closest('[role="option"]')).toHaveAttribute(
"aria-selected",
"true",
);
fireEvent.keyDown(input, { key: "Enter" });
expect(betaSelect).toHaveBeenCalledTimes(1);
expect(alphaSelect).not.toHaveBeenCalled();
});
it("supports multi-select scope chips and clears manual filters on close", async () => {
const useScopedResults: TestUseResultsHook = (query, active, options) => {
if (!active || !query.trim()) {
return { groups: [], flatResults: [], loadingFiles: false };
}
const enabledScopes = new Set(options?.scopeIds ?? []);
const allGroups: TestGroup[] = [
{
id: "portal-policies",
label: "Policies",
results: [makeResult("policy", "Policy Match")],
},
{
id: "portal-pipelines",
label: "Pipelines",
results: [makeResult("pipeline", "Pipeline Match")],
},
];
const groups =
enabledScopes.size === 0
? allGroups
: allGroups.filter((group) => enabledScopes.has(group.id));
return {
groups,
flatResults: groups.flatMap((group) => group.results),
loadingFiles: false,
};
};
renderSearch(useScopedResults, TEST_SCOPES);
const input = screen.getByRole("combobox");
fireEvent.focus(input);
fireEvent.change(input, { target: { value: "invoice" } });
await screen.findByText("Policy Match");
expect(screen.getByText("Pipeline Match")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Policies" }));
await waitFor(() => {
expect(screen.getByText("Policy Match")).toBeInTheDocument();
expect(screen.queryByText("Pipeline Match")).not.toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: "Pipelines" }));
await waitFor(() => {
expect(screen.getByText("Policy Match")).toBeInTheDocument();
expect(screen.getByText("Pipeline Match")).toBeInTheDocument();
});
fireEvent.mouseDown(document.body);
await waitFor(() => {
expect(input).toHaveAttribute("aria-expanded", "false");
});
fireEvent.focus(input);
await waitFor(() => {
expect(screen.getByText("Policy Match")).toBeInTheDocument();
expect(screen.getByText("Pipeline Match")).toBeInTheDocument();
});
});
it("maps scope prefixes onto active chips and lets the chip remove them", async () => {
const useScopedResults: TestUseResultsHook = (query, active, options) => {
if (!active || !query.trim()) {
return { groups: [], flatResults: [], loadingFiles: false };
}
const enabledScopes = new Set(options?.scopeIds ?? []);
const groups: TestGroup[] = [
{
id: "portal-policies",
label: "Policies",
results: [makeResult("policy", "Policy Match")],
},
{
id: "portal-pipelines",
label: "Pipelines",
results: [makeResult("pipeline", "Pipeline Match")],
},
].filter(
(group) => enabledScopes.size === 0 || enabledScopes.has(group.id),
);
return {
groups,
flatResults: groups.flatMap((group) => group.results),
loadingFiles: false,
};
};
renderSearch(useScopedResults, TEST_SCOPES);
const input = screen.getByRole("combobox");
fireEvent.focus(input);
fireEvent.change(input, { target: { value: "policy: invoice" } });
await waitFor(() => {
expect(screen.getByText("Policy Match")).toBeInTheDocument();
expect(screen.queryByText("Pipeline Match")).not.toBeInTheDocument();
});
const policyChip = screen.getByRole("button", { name: "Policies" });
expect(policyChip).toHaveAttribute("aria-pressed", "true");
fireEvent.click(policyChip);
await waitFor(() => {
expect(input).toHaveValue("invoice");
expect(screen.getByText("Policy Match")).toBeInTheDocument();
expect(screen.getByText("Pipeline Match")).toBeInTheDocument();
});
});
it("shows no-results below scoped filters before typing", async () => {
const useResults: TestUseResultsHook = () => ({
groups: [],
flatResults: [],
loadingFiles: false,
});
renderSearch(useResults, TEST_SCOPES);
const input = screen.getByRole("combobox");
fireEvent.focus(input);
await waitFor(() => {
expect(
screen.getByRole("button", { name: "Policies" }),
).toBeInTheDocument();
});
expect(screen.queryByText("Type to search")).not.toBeInTheDocument();
expect(screen.getByText("search.noResults")).toBeInTheDocument();
});
it("renders shared section headers once for consecutive groups", async () => {
const useResults: TestUseResultsHook = (query) => {
if (!query.trim()) {
return { groups: [], flatResults: [], loadingFiles: false };
}
const groups: TestGroup[] = [
{
id: "portal-users",
label: "Users",
sectionLabel: "Processor",
results: [makeResult("user", "Alice")],
},
{
id: "portal-policies",
label: "Policies",
sectionLabel: "Processor",
results: [makeResult("policy", "Security Policy")],
},
{
id: "tools",
label: "Tools",
sectionLabel: "Editor",
results: [makeResult("tool", "Merge PDFs")],
},
];
return {
groups,
flatResults: groups.flatMap((group) => group.results),
loadingFiles: false,
};
};
renderSearch(useResults);
const input = screen.getByRole("combobox");
fireEvent.focus(input);
fireEvent.change(input, { target: { value: "a" } });
await screen.findByText("Alice");
expect(screen.getAllByText("Processor")).toHaveLength(1);
expect(screen.getAllByText("Editor")).toHaveLength(1);
});
it("caps a large group behind show-more and expands/collapses it", async () => {
const titles = Array.from({ length: 8 }, (_, i) => `Tool ${i + 1}`);
const useResults: TestUseResultsHook = (query) => {
if (!query.trim()) {
return { groups: [], flatResults: [], loadingFiles: false };
}
const groups: TestGroup[] = [
{
id: "tools",
label: "Tools",
results: titles.map((title, i) => makeResult(`tool-${i}`, title)),
},
];
return {
groups,
flatResults: groups.flatMap((group) => group.results),
loadingFiles: false,
};
};
renderSearch(useResults);
const input = screen.getByRole("combobox");
fireEvent.focus(input);
fireEvent.change(input, { target: { value: "tool" } });
await screen.findByText("Tool 1");
expect(screen.getByText("Tool 5")).toBeInTheDocument();
expect(screen.queryByText("Tool 6")).not.toBeInTheDocument();
const toggle = screen.getByRole("button", {
name: /superSearch\.showMore/,
});
fireEvent.click(toggle);
await screen.findByText("Tool 8");
expect(
screen.getByRole("button", { name: /superSearch\.showLess/ }),
).toBeInTheDocument();
fireEvent.click(
screen.getByRole("button", { name: /superSearch\.showLess/ }),
);
await waitFor(() => {
expect(screen.queryByText("Tool 6")).not.toBeInTheDocument();
expect(screen.getByText("Tool 5")).toBeInTheDocument();
});
});
it("shows a cap+1 group in full with no show-more toggle", async () => {
const useResults: TestUseResultsHook = (query) => {
if (!query.trim()) {
return { groups: [], flatResults: [], loadingFiles: false };
}
const groups: TestGroup[] = [
{
id: "tools",
label: "Tools",
results: Array.from({ length: 6 }, (_, i) =>
makeResult(`tool-${i}`, `Tool ${i + 1}`),
),
},
];
return {
groups,
flatResults: groups.flatMap((group) => group.results),
loadingFiles: false,
};
};
renderSearch(useResults);
const input = screen.getByRole("combobox");
fireEvent.focus(input);
fireEvent.change(input, { target: { value: "tool" } });
await screen.findByText("Tool 6");
expect(
screen.queryByRole("button", { name: /superSearch\.show/ }),
).not.toBeInTheDocument();
});
it("lets a section collapse and reopen without hiding the other section", async () => {
const useResults: TestUseResultsHook = (query) => {
if (!query.trim()) {
return { groups: [], flatResults: [], loadingFiles: false };
}
const groups: TestGroup[] = [
{
id: "portal-users",
label: "Users",
sectionLabel: "Processor",
results: [makeResult("user", "Alice")],
},
{
id: "tools",
label: "Tools",
sectionLabel: "Editor",
results: [makeResult("tool", "Merge PDFs")],
},
];
return {
groups,
flatResults: groups.flatMap((group) => group.results),
loadingFiles: false,
};
};
renderSearch(useResults);
const input = screen.getByRole("combobox");
fireEvent.focus(input);
fireEvent.change(input, { target: { value: "a" } });
await screen.findByText("Alice");
const processorToggle = screen.getByRole("button", { name: "Processor" });
expect(processorToggle).toHaveAttribute("aria-expanded", "true");
expect(screen.getByText("Alice")).toBeInTheDocument();
expect(screen.getByText("Merge PDFs")).toBeInTheDocument();
fireEvent.click(processorToggle);
await waitFor(() => {
expect(processorToggle).toHaveAttribute("aria-expanded", "false");
expect(screen.queryByText("Alice")).not.toBeInTheDocument();
expect(screen.getByText("Merge PDFs")).toBeInTheDocument();
});
fireEvent.click(processorToggle);
await waitFor(() => {
expect(processorToggle).toHaveAttribute("aria-expanded", "true");
expect(screen.getByText("Alice")).toBeInTheDocument();
});
});
});
@@ -0,0 +1,656 @@
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { Text } from "@mantine/core";
import { Button } from "@app/ui/Button";
import { Chip } from "@app/ui/Chip";
import { TextInput } from "@app/components/shared/TextInput";
import LocalIcon from "@app/components/shared/LocalIcon";
import { isMacLike } from "@app/utils/hotkeys";
import {
useSuperSearch,
SuperSearchResult,
type SuperSearchQueryOptions,
type SuperSearchScope,
type UseSuperSearchResult,
} from "@app/hooks/useSuperSearch";
import {
parseSuperSearchQuery,
rebuildSuperSearchQuery,
} from "@app/components/shared/superSearch/superSearchFilters";
import "@app/components/shared/superSearch/SuperSearch.css";
/** Rows shown per group before a "show more" toggle reveals the rest. */
const COLLAPSED_GROUP_SIZE = 5;
interface DropdownRect {
top: number;
left: number;
width: number;
}
interface SuperSearchSection {
key: string;
label?: string;
groups: UseSuperSearchResult["groups"];
}
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,
options?: SuperSearchQueryOptions,
) => UseSuperSearchResult;
/**
* DOM id for the input — external focus helpers target the default. A host
* whose bar can coexist with another instance must pass a distinct id.
*/
inputId?: string;
/** Optional scope chips + `scope:` prefixes for host-specific filters. */
scopes?: readonly SuperSearchScope[];
/** Dropdown width floor — the results panel needs more room than the
* compact input. Defaults shared by every bar; override per host if a
* layout can't fit it. */
dropdownMinWidth?: number;
/**
* Extra class for the dropdown. The dropdown is portalled to <body>, so a
* host's descendant selectors can't reach it — this is the styling hook.
*/
dropdownClassName?: string;
}
/**
* 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 <body>: the host bar's inner wrapper may set
* `overflow: hidden`, which would otherwise clip it.
*/
export default function SuperSearch({
useResults = useSuperSearch,
inputId = "super-search-input",
scopes = [],
dropdownMinWidth = 760,
dropdownClassName,
}: SuperSearchProps = {}) {
const { t } = useTranslation();
const [query, setQuery] = useState("");
const [open, setOpen] = useState(false);
const [highlight, setHighlight] = useState(0);
const [rect, setRect] = useState<DropdownRect | null>(null);
const [manualScopeIds, setManualScopeIds] = useState<string[]>([]);
const [collapsedSectionKeys, setCollapsedSectionKeys] = useState<string[]>(
[],
);
// Groups the user has expanded past the initial per-group cap ("show more").
const [expandedGroupKeys, setExpandedGroupKeys] = useState<string[]>([]);
const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
// The key of the highlighted result, so async pop-in (entities, file stubs)
// can't silently shift the highlight onto a different row.
const highlightKeyRef = useRef<string | null>(null);
// A hook received as a prop is invisible to the rules-of-hooks lint; pinning
// the first value makes swapping it mid-life structurally impossible.
const useResultsHook = useRef(useResults).current;
const parsedQuery = useMemo(
() => parseSuperSearchQuery(query, scopes),
[query, scopes],
);
const effectiveScopeIds = useMemo(
() =>
Array.from(new Set([...parsedQuery.prefixedScopeIds, ...manualScopeIds])),
[manualScopeIds, parsedQuery.prefixedScopeIds],
);
const activeScopeIds = useMemo(
() => new Set(effectiveScopeIds),
[effectiveScopeIds],
);
const { groups, flatResults, loadingFiles } = useResultsHook(
parsedQuery.query,
open,
effectiveScopeIds.length > 0 ? { scopeIds: effectiveScopeIds } : undefined,
);
const trimmed = parsedQuery.query.trim();
const hasQuery = trimmed.length > 0;
const listboxId = `${inputId}-listbox`;
const optionId = useCallback(
(index: number) => `${inputId}-option-${index}`,
[inputId],
);
const sections = useMemo<SuperSearchSection[]>(() => {
const out: SuperSearchSection[] = [];
for (const group of groups) {
const current = out[out.length - 1];
if (current && current.label === group.sectionLabel) {
current.groups.push(group);
continue;
}
out.push({
key: group.sectionLabel ?? group.id,
label: group.sectionLabel,
groups: [group],
});
}
return out;
}, [groups]);
const visibleSections = useMemo(
() =>
sections.map((section) => ({
...section,
collapsed:
section.label != null && collapsedSectionKeys.includes(section.key),
})),
[collapsedSectionKeys, sections],
);
// How many rows a group currently shows: its full set when expanded, else
// the initial cap. A group at exactly cap+1 just shows the extra row rather
// than a "show 1 more" toggle.
const visibleCountFor = useCallback(
(group: UseSuperSearchResult["groups"][number]) =>
expandedGroupKeys.includes(group.id) ||
group.results.length <= COLLAPSED_GROUP_SIZE + 1
? group.results.length
: COLLAPSED_GROUP_SIZE,
[expandedGroupKeys],
);
const visibleFlatResults = useMemo(
() =>
visibleSections.flatMap((section) =>
section.collapsed
? []
: section.groups.flatMap((group) =>
group.results.slice(0, visibleCountFor(group)),
),
),
[visibleSections, visibleCountFor],
);
// Row index per result key — the render below would otherwise indexOf()
// every row against the flat list (quadratic in visible results).
const flatIndexByKey = useMemo(
() => new Map(visibleFlatResults.map((result, i) => [result.key, i])),
[visibleFlatResults],
);
const moveHighlight = useCallback(
(index: number) => {
setHighlight(index);
highlightKeyRef.current = visibleFlatResults[index]?.key ?? null;
},
[visibleFlatResults],
);
// When results change, follow the highlighted result to its new index; if
// it's gone, reset to the top.
useEffect(() => {
const key = highlightKeyRef.current;
if (key) {
const index = visibleFlatResults.findIndex((r) => r.key === key);
if (index >= 0) {
setHighlight(index);
return;
}
}
highlightKeyRef.current = null;
setHighlight(0);
}, [visibleFlatResults]);
const close = useCallback(() => {
setOpen(false);
setHighlight(0);
setManualScopeIds([]);
setCollapsedSectionKeys([]);
setExpandedGroupKeys([]);
setQuery((current) => parseSuperSearchQuery(current, scopes).query);
}, [scopes]);
// Position the portalled dropdown directly under the input while open.
const updateRect = useCallback(() => {
const el = containerRef.current;
if (!el) return;
const r = el.getBoundingClientRect();
const viewportPadding = 8;
const width = Math.min(
Math.max(r.width, dropdownMinWidth ?? r.width),
window.innerWidth - viewportPadding * 2,
);
const centeredLeft = r.left - (width - r.width) / 2;
const left = Math.min(
Math.max(viewportPadding, centeredLeft),
window.innerWidth - width - viewportPadding,
);
setRect({ top: r.bottom + 6, left, width });
}, [dropdownMinWidth]);
useLayoutEffect(() => {
if (!open) return;
updateRect();
window.addEventListener("resize", updateRect);
window.addEventListener("scroll", updateRect, true);
return () => {
window.removeEventListener("resize", updateRect);
window.removeEventListener("scroll", updateRect, true);
};
}, [open, updateRect]);
const selectResult = useCallback(
(result: SuperSearchResult | undefined) => {
if (!result) return;
void result.onSelect();
setQuery("");
close();
inputRef.current?.blur();
},
[close],
);
// Global Cmd/Ctrl+K to focus + open the search. Matched on e.code so it
// works on non-Latin keyboard layouts, where e.key isn't "k".
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
// Accept both modifiers everywhere: Macs have a Control key too, and
// muscle memory from other platforms expects Ctrl+K to keep working.
const combo = (e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey;
if (!combo || e.code !== "KeyK") return;
// Leave the shortcut alone while a modal owns the screen — focusing an
// input underneath the overlay would strand keyboard focus. Modals that
// want to cede to the search (the settings modal does) close themselves
// and dispatch "superSearch:focus" instead.
const target = e.target as HTMLElement | null;
if (target?.closest('[role="dialog"]')) return;
e.preventDefault();
setOpen(true);
inputRef.current?.focus();
inputRef.current?.select();
};
// Focus handover from a closing dialog. Only the on-screen instance
// responds (offsetParent is null while display:none / unmounted hosts),
// and focus waits two frames so the dialog's own return-focus runs first.
const onFocusRequest = () => {
const input = inputRef.current;
if (!input || input.offsetParent === null) return;
setOpen(true);
requestAnimationFrame(() =>
requestAnimationFrame(() => {
input.focus();
input.select();
}),
);
};
window.addEventListener("keydown", onKeyDown);
window.addEventListener("superSearch:focus", onFocusRequest);
return () => {
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("superSearch:focus", onFocusRequest);
};
}, []);
// Keep the highlighted row visible when keyboard navigation moves it past
// the dropdown's scroll fold.
useEffect(() => {
if (!open) return;
document
.getElementById(optionId(highlight))
?.scrollIntoView({ block: "nearest" });
}, [open, highlight, optionId]);
// Close on click outside the input or the (portalled) dropdown.
useEffect(() => {
if (!open) return;
const onMouseDown = (e: MouseEvent) => {
const target = e.target as Node;
if (
containerRef.current?.contains(target) ||
dropdownRef.current?.contains(target)
) {
return;
}
close();
};
document.addEventListener("mousedown", onMouseDown);
return () => document.removeEventListener("mousedown", onMouseDown);
}, [open, close]);
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
// During IME composition (CJK input), Enter commits the composition and
// arrows pick candidates — those keystrokes are not for us.
if (e.nativeEvent.isComposing) return;
if (e.key === "ArrowDown") {
e.preventDefault();
if (!open) setOpen(true);
moveHighlight(
visibleFlatResults.length === 0
? 0
: (highlight + 1) % visibleFlatResults.length,
);
} else if (e.key === "ArrowUp") {
e.preventDefault();
moveHighlight(
visibleFlatResults.length === 0
? 0
: (highlight - 1 + visibleFlatResults.length) %
visibleFlatResults.length,
);
} else if (e.key === "Enter") {
e.preventDefault();
selectResult(visibleFlatResults[highlight]);
} else if (e.key === "Escape") {
e.preventDefault();
// Consume the event: hosts with their own window-level Escape handling
// (e.g. the Files page closing itself) must not also act on this press —
// especially since the blur below makes their focus guards pass.
e.stopPropagation();
if (hasQuery) {
setQuery("");
} else {
close();
inputRef.current?.blur();
}
}
};
const shortcutHint = useMemo(() => (isMacLike() ? "⌘K" : "Ctrl+K"), []);
const toggleScope = useCallback(
(scopeId: string) => {
if (scopeId === "__all__") {
setManualScopeIds([]);
if (parsedQuery.prefixTokens.length > 0) {
setQuery(parsedQuery.query);
}
inputRef.current?.focus();
return;
}
const hasPrefix = parsedQuery.prefixedScopeIds.includes(scopeId);
if (hasPrefix) {
setManualScopeIds((current) => current.filter((id) => id !== scopeId));
const remainingPrefixScopeIds = new Set(
parsedQuery.prefixedScopeIds.filter((id) => id !== scopeId),
);
setQuery(rebuildSuperSearchQuery(parsedQuery, remainingPrefixScopeIds));
inputRef.current?.focus();
return;
}
setManualScopeIds((current) =>
current.includes(scopeId)
? current.filter((id) => id !== scopeId)
: [...current, scopeId],
);
inputRef.current?.focus();
},
[parsedQuery],
);
const showNoResults =
open &&
!loadingFiles &&
flatResults.length === 0 &&
(hasQuery || scopes.length > 0);
const toggleSection = useCallback((sectionKey: string) => {
setCollapsedSectionKeys((current) =>
current.includes(sectionKey)
? current.filter((key) => key !== sectionKey)
: [...current, sectionKey],
);
inputRef.current?.focus();
}, []);
const toggleGroupExpanded = useCallback((groupId: string) => {
setExpandedGroupKeys((current) =>
current.includes(groupId)
? current.filter((id) => id !== groupId)
: [...current, groupId],
);
inputRef.current?.focus();
}, []);
const scopeFilters =
scopes.length > 0 ? (
<div
className="super-search-filters"
aria-label={t("superSearch.filtersAriaLabel", "Search filters")}
>
<Chip
size="sm"
accent={activeScopeIds.size === 0 ? "default" : "neutral"}
variant={activeScopeIds.size === 0 ? "primary" : "secondary"}
aria-pressed={activeScopeIds.size === 0}
onClick={() => toggleScope("__all__")}
>
{t("superSearch.all", "All")}
</Chip>
{scopes.map((scope) => {
const active = activeScopeIds.has(scope.id);
return (
<Chip
key={scope.id}
size="sm"
accent={active ? "default" : "neutral"}
variant={active ? "primary" : "secondary"}
aria-pressed={active}
onClick={() => toggleScope(scope.id)}
>
{scope.label}
</Chip>
);
})}
</div>
) : null;
const dropdown =
open && rect ? (
<div
ref={dropdownRef}
id={listboxId}
className={`super-search-dropdown${
dropdownClassName ? ` ${dropdownClassName}` : ""
}`}
role="listbox"
style={{ top: rect.top, left: rect.left, width: rect.width }}
// Keep focus on the input when clicking inside the dropdown.
onMouseDown={(e) => e.preventDefault()}
>
{scopeFilters}
{!hasQuery && scopes.length === 0 && (
<div className="super-search-empty">
{t("superSearch.hint", "Type to search")}
</div>
)}
{showNoResults && (
<div className="super-search-empty">
{t("search.noResults", "No results found")}
</div>
)}
{hasQuery &&
visibleSections.map((section) => (
<div key={section.key} className="super-search-section">
{section.label && (
<div className="super-search-section-header">
<Button
className="super-search-section-toggle"
variant="quiet"
aria-expanded={!section.collapsed}
onClick={() => toggleSection(section.key)}
rightSection={
<span
className={`super-search-section-chevron${
section.collapsed
? " super-search-section-chevron--collapsed"
: ""
}`}
aria-hidden="true"
>
<LocalIcon
icon="expand-more-rounded"
width="1rem"
height="1rem"
/>
</span>
}
>
<span className="super-search-section-label">
{section.label}
</span>
</Button>
</div>
)}
{!section.collapsed && (
<div className="super-search-section-body">
{section.groups.map((group) => {
const shownCount = visibleCountFor(group);
const hiddenCount = group.results.length - shownCount;
// Groups within one row of the cap always show everything
// (visibleCountFor's cap+1 exception) — no toggle for them.
const expandable =
group.results.length > COLLAPSED_GROUP_SIZE + 1;
return (
<div
key={group.id}
className="super-search-group"
role="group"
aria-label={group.label}
>
{/* A lone group repeating its section's name reads as a
duplicate header — the section title covers it. */}
{group.label !== section.label && (
<div
className="super-search-group-label"
aria-hidden="true"
>
{group.label}
</div>
)}
<div className="super-search-group-results">
{group.results.slice(0, shownCount).map((result) => {
const index = flatIndexByKey.get(result.key) ?? -1;
const active = index === highlight;
return (
<Button
key={result.key}
id={optionId(index)}
type="button"
variant="quiet"
justify="start"
fullWidth
role="option"
aria-selected={active}
className={`super-search-item${active ? " active" : ""}`}
onMouseEnter={() => moveHighlight(index)}
onClick={() => selectResult(result)}
>
<span className="super-search-item-icon">
{result.icon ?? (
<LocalIcon
icon={result.iconName ?? "search-rounded"}
width="1.1rem"
height="1.1rem"
/>
)}
</span>
<span className="super-search-item-text">
<span className="super-search-item-title">
{result.title}
</span>
{result.subtitle && (
<Text size="xs" c="dimmed" lineClamp={1}>
{result.subtitle}
</Text>
)}
</span>
</Button>
);
})}
</div>
{expandable && (
<Button
type="button"
variant="quiet"
justify="start"
className="super-search-show-more"
onClick={() => toggleGroupExpanded(group.id)}
>
{hiddenCount > 0
? t(
"superSearch.showMore",
"Show {{count}} more",
{ count: hiddenCount },
)
: t("superSearch.showLess", "Show less")}
</Button>
)}
</div>
);
})}
</div>
)}
</div>
))}
</div>
) : null;
return (
<div className="super-search" ref={containerRef} onKeyDown={handleKeyDown}>
<div className="super-search-input-row">
<TextInput
id={inputId}
name={inputId}
ref={inputRef}
value={query}
onChange={setQuery}
placeholder={t("superSearch.placeholder", "Search Stirling")}
icon={
<LocalIcon icon="search-rounded" width="1.1rem" height="1.1rem" />
}
autoComplete="off"
role="combobox"
aria-label={t("superSearch.ariaLabel", "Super search")}
aria-expanded={open}
aria-controls={open ? listboxId : undefined}
aria-activedescendant={
open && visibleFlatResults[highlight]
? optionId(highlight)
: undefined
}
aria-autocomplete="list"
onFocus={() => setOpen(true)}
/>
{!open && !hasQuery && (
<kbd className="super-search-kbd" aria-hidden="true">
{shortcutHint}
</kbd>
)}
</div>
{dropdown && createPortal(dropdown, document.body)}
</div>
);
}
@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import {
parseSuperSearchQuery,
rebuildSuperSearchQuery,
} from "@app/components/shared/superSearch/superSearchFilters";
import type { SuperSearchScope } from "@app/hooks/useSuperSearch";
const SCOPES: SuperSearchScope[] = [
{ id: "portal-policies", label: "Policies", aliases: ["policy", "policies"] },
{ id: "tools", label: "Tools", aliases: ["tool", "tools"] },
];
describe("parseSuperSearchQuery", () => {
it("returns the trimmed query untouched when no scopes exist", () => {
const parsed = parseSuperSearchQuery(" policy: invoice ", []);
expect(parsed).toEqual({
query: "policy: invoice",
prefixTokens: [],
prefixedScopeIds: [],
});
});
it("maps a known prefix to its scope and strips it from the query", () => {
const parsed = parseSuperSearchQuery("policy: invoice", SCOPES);
expect(parsed.query).toBe("invoice");
expect(parsed.prefixedScopeIds).toEqual(["portal-policies"]);
expect(parsed.prefixTokens).toEqual([
{ scopeId: "portal-policies", token: "policy:" },
]);
});
it("accepts chained prefixes and aliases case-insensitively", () => {
const parsed = parseSuperSearchQuery("Policy:TOOLS: merge", SCOPES);
expect(parsed.query).toBe("merge");
expect(parsed.prefixedScopeIds).toEqual(["portal-policies", "tools"]);
});
it("dedupes repeated prefixes for the same scope", () => {
const parsed = parseSuperSearchQuery("policy: policies: invoice", SCOPES);
expect(parsed.prefixedScopeIds).toEqual(["portal-policies"]);
expect(parsed.prefixTokens).toHaveLength(2);
});
it("stops at the first unknown token so colon text stays in the query", () => {
const parsed = parseSuperSearchQuery("chapter: 1 policy: x", SCOPES);
expect(parsed.query).toBe("chapter: 1 policy: x");
expect(parsed.prefixedScopeIds).toEqual([]);
});
it("leaves colons after the first word alone", () => {
const parsed = parseSuperSearchQuery("policy: name: value", SCOPES);
expect(parsed.query).toBe("name: value");
expect(parsed.prefixedScopeIds).toEqual(["portal-policies"]);
});
});
describe("rebuildSuperSearchQuery", () => {
it("keeps only the requested scopes' tokens, in original order", () => {
const parsed = parseSuperSearchQuery("policy: tools: merge", SCOPES);
expect(rebuildSuperSearchQuery(parsed, new Set(["tools"]))).toBe(
"tools: merge",
);
expect(rebuildSuperSearchQuery(parsed, new Set())).toBe("merge");
expect(
rebuildSuperSearchQuery(parsed, new Set(["portal-policies", "tools"])),
).toBe("policy: tools: merge");
});
it("drops the trailing space when the free text is empty", () => {
const parsed = parseSuperSearchQuery("policy:", SCOPES);
expect(rebuildSuperSearchQuery(parsed, new Set())).toBe("");
});
});
@@ -0,0 +1,77 @@
import type { SuperSearchScope } from "@app/hooks/useSuperSearch";
export interface SuperSearchPrefixToken {
scopeId: string;
token: string;
}
export interface ParsedSuperSearchQuery {
query: string;
prefixTokens: SuperSearchPrefixToken[];
prefixedScopeIds: string[];
}
function buildScopeLookup(scopes: readonly SuperSearchScope[]) {
const lookup = new Map<string, string>();
for (const scope of scopes) {
lookup.set(scope.id.toLowerCase(), scope.id);
for (const alias of scope.aliases ?? []) {
lookup.set(alias.toLowerCase(), scope.id);
}
}
return lookup;
}
/**
* Parses leading `scope:` tokens into active scopes and returns the remaining
* free-text query. Prefixes only count at the start so normal colon text in
* the search term is left alone.
*/
export function parseSuperSearchQuery(
rawQuery: string,
scopes: readonly SuperSearchScope[],
): ParsedSuperSearchQuery {
if (scopes.length === 0) {
return {
query: rawQuery.trim(),
prefixTokens: [],
prefixedScopeIds: [],
};
}
const scopeLookup = buildScopeLookup(scopes);
const prefixTokens: SuperSearchPrefixToken[] = [];
let remainder = rawQuery.trimStart();
while (remainder.length > 0) {
const match = remainder.match(/^([^\s:]+):/);
if (!match) break;
const token = match[1];
const scopeId = scopeLookup.get(token.toLowerCase());
if (!scopeId) break;
prefixTokens.push({ scopeId, token: `${token}:` });
remainder = remainder.slice(match[0].length).trimStart();
}
return {
query: remainder.trim(),
prefixTokens,
prefixedScopeIds: [...new Set(prefixTokens.map((token) => token.scopeId))],
};
}
export function rebuildSuperSearchQuery(
parsed: ParsedSuperSearchQuery,
keepScopeIds: ReadonlySet<string>,
): string {
return [
...parsed.prefixTokens
.filter((token) => keepScopeIds.has(token.scopeId))
.map((token) => token.token),
parsed.query,
]
.filter(Boolean)
.join(" ");
}
@@ -4,7 +4,6 @@ import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import { useSidebarContext } from "@app/contexts/SidebarContext";
import { useIsMobile } from "@app/hooks/useIsMobile";
import ToolPanel from "@app/components/tools/ToolPanel";
import ToolSearch from "@app/components/tools/toolPicker/ToolSearch";
import { usePoliciesEnabled } from "@app/components/policies/usePoliciesEnabled";
import { PolicyAutoRunController } from "@app/components/policies/PolicyAutoRunController";
import { useFavoriteToolItems } from "@app/hooks/tools/useFavoriteToolItems";
@@ -17,7 +16,6 @@ import { ActionIcon } from "@app/ui/ActionIcon";
import { withViewTransition } from "@app/utils/viewTransition";
import { SidebarToggleIcon } from "@app/components/shared/SidebarToggleIcon";
import CloseIcon from "@mui/icons-material/Close";
import SearchIcon from "@mui/icons-material/Search";
import { ToolId } from "@app/types/toolId";
import type { ToolRegistryEntry } from "@app/data/toolsTaxonomy";
import {
@@ -43,7 +41,6 @@ export default function RightSidebar() {
const {
leftPanelView,
isPanelVisible,
searchQuery,
filteredTools,
toolRegistry,
setSearchQuery,
@@ -79,7 +76,6 @@ export default function RightSidebar() {
};
const [allToolsView, setAllToolsView] = useState(false);
const [headerSearchOpen, setHeaderSearchOpen] = useState(false);
const handleShowAllTools = () => {
withViewTransition(() => setAllToolsView(true));
@@ -97,7 +93,6 @@ export default function RightSidebar() {
const inToolView = leftPanelView !== "toolPicker";
// Show X (close) button only when there's somewhere to go back to.
const showCloseButton = inToolView || allToolsView;
const showHeaderSearch = showCloseButton || headerSearchOpen;
const handleHeaderBack = () => {
if (inToolView) {
@@ -111,20 +106,6 @@ export default function RightSidebar() {
withViewTransition(() => handleToolSelect(id));
};
// Typing in the header search while inside a tool exits the tool and lifts the
// panel into the all-tools view so the user immediately sees search results.
const handleHeaderSearchChange = (value: string) => {
if (inToolView) {
withViewTransition(() => {
handleBackToTools();
setAllToolsView(true);
setSearchQuery(value);
});
return;
}
setSearchQuery(value);
};
const activeTool: ToolRegistryEntry | null =
inToolView && selectedToolKey
? (toolRegistry[selectedToolKey as ToolId] ?? null)
@@ -235,6 +216,7 @@ export default function RightSidebar() {
flexShrink: 0,
display: "flex",
flexDirection: "column",
position: "relative",
}}
>
<>
@@ -257,41 +239,10 @@ export default function RightSidebar() {
/>
) : (
<div className="tool-panel__compact-header">
{showHeaderSearch ? (
<div className="tool-panel__compact-header-search">
<ToolSearch
value={searchQuery}
onChange={handleHeaderSearchChange}
toolRegistry={toolRegistry}
mode="filter"
autoFocus
/>
</div>
) : (
<span className="tool-panel__compact-title">
{t("toolPanel.pdfTools", "PDF Tools")}
</span>
)}
<span className="tool-panel__compact-title">
{t("toolPanel.pdfTools", "PDF Tools")}
</span>
<div className="tool-panel__compact-header-actions">
{!showCloseButton && (
<ActionIcon
variant="tertiary"
size="md"
shape="circle"
onClick={() => {
if (headerSearchOpen) handleHeaderSearchChange("");
setHeaderSearchOpen((open) => !open);
}}
aria-label={t("toolPanel.searchTools", "Search tools")}
className="tool-panel__expand-btn"
>
{headerSearchOpen ? (
<CloseIcon sx={{ fontSize: "1.1rem" }} />
) : (
<SearchIcon sx={{ fontSize: "1.1rem" }} />
)}
</ActionIcon>
)}
{showCloseButton ? (
<ActionIcon
variant="tertiary"
@@ -177,15 +177,6 @@
flex-shrink: 0;
}
.tool-panel__compact-header-search {
flex: 1 1 auto;
min-width: 0;
}
.tool-panel__compact-header-search .search-input-container {
width: 100%;
}
/* Search that separates the Policies section from the Tools list below it. */
.tool-panel__between-search {
padding: 0.5rem 0.75rem 0.25rem;
@@ -209,7 +209,11 @@ export function useViewerWorkbenchBarButtons(
aria-label={searchLabel}
onClick={viewer.searchInterfaceActions.toggle}
>
<LocalIcon icon="search" width="1rem" height="1rem" />
<LocalIcon
icon="find-in-page-rounded"
width="1.25rem"
height="1.25rem"
/>
</ActionIcon>
</div>
</Popover.Target>
@@ -0,0 +1,21 @@
import type { TFunction } from "i18next";
import type { SuperSearchGroup } from "@app/types/superSearch";
const NO_GROUPS: SuperSearchGroup[] = [];
/**
* Processor entity results (users, policies, pipelines, sources) for the
* editor's super search. Core and desktop builds ship no portal, so this stub
* returns nothing; the proprietary build shadows it with an implementation
* that lazily loads the portal's entity-search module.
*/
export function useProcessorEntityGroups(
_trimmed: string,
_enabled: boolean,
_t: TFunction,
_navigate: (path: string) => void,
_scopeEnabled?: (scopeId: string) => boolean,
_isAdmin?: boolean,
): SuperSearchGroup[] {
return NO_GROUPS;
}
@@ -0,0 +1,30 @@
/**
* Search index for the admin portal ("Processor") destinations offered by the
* global super search. Core and desktop builds ship no portal, so the index is
* empty and the Processor group never renders; the proprietary build (which
* mounts the portal as a route-set) shadows this with the real view list.
*/
export interface ProcessorSearchEntry {
/** Portal view id — stable key for the result row. */
id: string;
/** i18n key for the view's display name (shared with the portal sidebar). */
labelKey: string;
labelFallback: string;
/** In-app path to navigate to. Empty when externalUrl is set. */
path: string;
/** Opens in a new tab instead of navigating (e.g. hosted docs). */
externalUrl?: string;
/** Extra fuzzy-match terms beyond the label. */
keywords?: string[];
}
export const PROCESSOR_SEARCH_INDEX: ProcessorSearchEntry[] = [];
/** No portal → no entity scopes; trivially accessible. Real logic lives in
* the proprietary shadow, keyed on the flavor's users capabilities. */
export function isPortalEntityScopeAccessible(
_scopeId: string,
_isAdmin: boolean,
): boolean {
return true;
}
@@ -0,0 +1,174 @@
import i18n from "i18next";
import type { TFunction } from "i18next";
/**
* Content-level settings search: matches a query against every translation
* string a settings section renders, so a term that appears anywhere on a
* settings page ("SMTP", "OCR", a field label…) finds that section without a
* curated keyword.
*
* Component-free (translation subtrees only) so the always-mounted super
* search can use it without pulling the lazy settings modal into the main
* bundle.
*/
/**
* Translation subtrees whose strings appear on each settings section, for the
* sections where the nav key doesn't map 1:1 onto a toml prefix. Keys missing
* here fall back to the inferred `settings.<key>` / `admin.settings.<key>`
* prefix.
*/
const SECTION_TRANSLATION_PREFIXES: Partial<Record<string, string[]>> = {
general: ["settings.general"],
hotkeys: ["settings.hotkeys"],
account: ["account"],
people: ["settings.workspace"],
teams: ["settings.workspace", "settings.team"],
"api-keys": ["settings.developer"],
connectionMode: ["settings.connection"],
planBilling: ["settings.planBilling"],
adminGeneral: ["admin.settings.general"],
adminFeatures: ["admin.settings.features"],
adminEndpoints: ["admin.settings.endpoints"],
adminDatabase: ["admin.settings.database"],
adminAdvanced: ["admin.settings.advanced"],
adminFolderAccess: ["admin.settings.folderAccess"],
// The four AI tabs all render slices of the one admin.settings.ai subtree.
adminAiGeneral: ["admin.settings.ai"],
adminAiModels: ["admin.settings.ai"],
adminAiDocuments: ["admin.settings.ai"],
adminAiLimits: ["admin.settings.ai"],
adminSecurity: ["admin.settings.security"],
adminMcp: ["admin.settings.mcp"],
adminConnections: [
"admin.settings.connections",
"admin.settings.mail",
"admin.settings.security",
"admin.settings.telegram",
"admin.settings.premium",
"admin.settings.general",
"settings.securityAuth",
"settings.connection",
],
adminPlan: [
"settings.planBilling",
"admin.settings.premium",
"settings.licensingAnalytics",
],
adminAudit: ["settings.licensingAnalytics"],
adminUsage: ["settings.licensingAnalytics"],
adminLegal: ["admin.settings.legal"],
adminPrivacy: ["admin.settings.privacy"],
};
export const getTranslationPrefixesForNavKey = (key: string): string[] => {
const explicitPrefixes = SECTION_TRANSLATION_PREFIXES[key] ?? [];
const inferredPrefixes: string[] = [];
if (key.startsWith("admin")) {
const adminSuffix = key.replace(/^admin/, "");
const normalizedAdminSuffix =
adminSuffix.charAt(0).toLowerCase() + adminSuffix.slice(1);
inferredPrefixes.push(`admin.settings.${normalizedAdminSuffix}`);
} else {
inferredPrefixes.push(`settings.${key}`);
}
return Array.from(new Set([...explicitPrefixes, ...inferredPrefixes]));
};
export const flattenTranslationStrings = (value: unknown): string[] => {
if (typeof value === "string") {
const trimmed = value.trim();
return trimmed ? [trimmed] : [];
}
if (Array.isArray(value)) {
return value.flatMap(flattenTranslationStrings);
}
if (value && typeof value === "object") {
return Object.values(value as Record<string, unknown>).flatMap(
flattenTranslationStrings,
);
}
return [];
};
/** Trims a matched string to a short snippet centred on the query hit. */
export const buildMatchSnippet = (text: string, query: string): string => {
const normalizedText = text.toLocaleLowerCase();
const normalizedQuery = query.toLocaleLowerCase();
const matchIndex = normalizedText.indexOf(normalizedQuery);
if (matchIndex === -1) {
return text;
}
// Lowercasing can change string length in some locales (Turkish İ, ß), so
// indices computed on the copy only align with the original when the
// lengths match; otherwise snippet the copy itself.
const source = normalizedText.length === text.length ? text : normalizedText;
const maxLength = 84;
const contextPadding = 28;
const start = Math.max(0, matchIndex - contextPadding);
const end = Math.min(
source.length,
matchIndex + query.length + contextPadding,
);
const snippet = source.slice(start, end);
if (snippet.length <= maxLength) {
return `${start > 0 ? "…" : ""}${snippet}${end < source.length ? "…" : ""}`;
}
return `${start > 0 ? "…" : ""}${snippet.slice(0, maxLength)}${end < source.length ? "…" : ""}`;
};
// Flattening every subtree on each keystroke would be wasteful; sections'
// content is static per language, so cache it and drop the cache on switch.
const contentCache = new Map<string, string[]>();
let contentCacheLanguage: string | undefined;
// Locale files load over HTTP after boot, so content computed before the
// bundle resolves is empty — without this, an early query would cache empty
// content for the whole session. Cleared whenever a resource bundle lands.
i18n.on("loaded", () => contentCache.clear());
export function getSettingsSectionContent(key: string, t: TFunction): string[] {
if (contentCacheLanguage !== i18n.language) {
contentCache.clear();
contentCacheLanguage = i18n.language;
}
const cached = contentCache.get(key);
if (cached) return cached;
const content = getTranslationPrefixesForNavKey(key).flatMap((prefix) =>
flattenTranslationStrings(
t(prefix, { returnObjects: true, defaultValue: {} }),
),
);
contentCache.set(key, content);
return content;
}
/**
* First content string of the section containing the query
* (case-insensitive), or null. Substring only — fuzzy matching across whole
* paragraphs of copy produces junk hits.
*/
export function findSettingsContentMatch(
key: string,
query: string,
t: TFunction,
): string | null {
const normalizedQuery = query.toLocaleLowerCase();
return (
getSettingsSectionContent(key, t).find((text) =>
text.toLocaleLowerCase().includes(normalizedQuery),
) ?? null
);
}
@@ -0,0 +1,105 @@
import { NavKey } from "@app/components/shared/config/types";
/**
* A single, searchable setting *row* inside the settings modal.
*
* Section-level content matching (settingsContentSearch) only navigates to a
* whole section; this index lets the global super search deep-link to an
* individual control: navigating to `/settings/{section}?focus={anchor}`,
* where `anchor` is the DOM `id` placed on that control's row (see
* AppConfigModal's focus-scroll effect and the `id=` attributes added to the
* matching section components).
*/
export interface SettingsSearchEntry {
/** Settings section this row lives in (nav key, e.g. "general"). */
section: NavKey;
/** DOM id on the control's row; used as the `?focus=` anchor. */
anchor: string;
/** i18n key for the display label. */
labelKey: string;
/** English fallback / default for the label. */
labelFallback: string;
/** Extra English terms to match against (synonyms, related words). */
keywords?: string[];
}
/**
* Curated row-level entries for the high-value, user-facing settings sections.
* Section-level results (every other tab) come from the nav sections directly,
* so this list only needs the rows worth jumping straight to.
*/
export const SETTINGS_SEARCH_INDEX: SettingsSearchEntry[] = [
// --- General > Appearance ---
{
section: "general",
anchor: "setting-theme",
labelKey: "settings.general.theme",
labelFallback: "Theme",
keywords: ["dark", "light", "mode", "appearance", "colour", "color"],
},
{
section: "general",
anchor: "setting-language",
labelKey: "settings.general.language",
labelFallback: "Language",
keywords: ["locale", "translation", "i18n"],
},
// --- General > Behaviour ---
{
section: "general",
anchor: "setting-tool-picker-mode",
labelKey: "settings.general.defaultToolPickerMode",
labelFallback: "Default tool picker mode",
keywords: ["sidebar", "fullscreen", "tools", "panel"],
},
{
section: "general",
anchor: "setting-startup-view",
labelKey: "settings.general.defaultStartupView",
labelFallback: "Default view on launch",
keywords: ["startup", "launch", "home", "reader", "automate"],
},
{
section: "general",
anchor: "setting-reader-zoom",
labelKey: "settings.general.defaultViewerZoom",
labelFallback: "Default reader zoom",
keywords: ["zoom", "viewer", "fit width", "fit page", "magnification"],
},
{
section: "general",
anchor: "setting-hide-unavailable-tools",
labelKey: "settings.general.hideUnavailableTools",
labelFallback: "Hide unavailable tools",
keywords: ["disabled", "greyed", "tools"],
},
{
section: "general",
anchor: "setting-hide-unavailable-conversions",
labelKey: "settings.general.hideUnavailableConversions",
labelFallback: "Hide unavailable conversions",
keywords: ["disabled", "convert", "conversions"],
},
{
section: "general",
anchor: "setting-auto-unzip",
labelKey: "settings.general.autoUnzip",
labelFallback: "Auto-unzip API responses",
keywords: ["zip", "extract", "unzip", "archive"],
},
{
section: "general",
anchor: "setting-auto-unzip-file-limit",
labelKey: "settings.general.autoUnzipFileLimit",
labelFallback: "Auto-unzip file limit",
keywords: ["zip", "limit", "files", "extract"],
},
// --- Keyboard Shortcuts ---
{
section: "hotkeys",
anchor: "setting-hotkeys-search",
labelKey: "settings.hotkeys.title",
labelFallback: "Keyboard Shortcuts",
keywords: ["hotkey", "shortcut", "keybinding", "keyboard"],
},
];
@@ -0,0 +1,88 @@
import { NavKey } from "@app/components/shared/config/types";
/**
* A whole settings section (nav tab), described as pure data so the global
* super search can offer section-level results.
*
* This is the single source of truth for *which settings sections are
* searchable* in a given build. It is intentionally **component-free**: the
* always-mounted top bar imports it (via `@app/data/settingsSectionRegistry`)
* to feed the super search, and pulling the heavy settings component tree in
* here would defeat the lazy-loaded settings modal (AppConfigModalLazy).
*
* Layering mirrors the nav builders (`configNavSections`): core lists the
* always-present sections; higher layers shadow this module to add their own
* (proprietary admin sections, saas cloud sections, …). A section only belongs
* in a layer's registry if that layer's settings modal can actually render it —
* otherwise search would deep-link to a tab that doesn't exist.
*
* Per-user visibility (admin / login) is expressed by the flags below and
* applied by the super search at query time, mirroring the modal's own gating.
*/
export interface SettingsSectionEntry {
/** Nav key; used for the `/settings/{key}` deep link. */
key: NavKey;
/** i18n key for the display label. */
labelKey: string;
/** English fallback / default for the label. */
labelFallback: string;
/**
* The modal nav group this section sits under ("Preferences",
* "Configuration", …) — shown as result context ("Group · match").
* Same keys the nav builder uses.
*/
groupLabelKey?: string;
groupLabelFallback?: string;
/** Extra English terms to match against (synonyms, related words). */
keywords?: string[];
/** Surface only when login mode is on (e.g. account, API keys). */
requiresLogin?: boolean;
/**
* Admin-area section: the self-hosted modal surfaces it when the user is an
* admin OR login mode is off (single-user self-host). Mirrors the proprietary
* nav builder's `isAdmin || !loginEnabled` gate.
*/
adminArea?: boolean;
/**
* Section only mounts for a signed-in (non-anonymous) account — mirrors the
* SaaS nav builder's `!isAnonymous` gate. Distinct from `requiresLogin`,
* which keys off the local backend's login mode rather than auth state.
*/
requiresAccount?: boolean;
}
/** Core (OSS) sections — always present in every build. */
export const SETTINGS_SECTION_REGISTRY: SettingsSectionEntry[] = [
{
key: "general",
labelKey: "settings.general.title",
labelFallback: "General",
keywords: ["theme", "language", "appearance", "preferences", "startup"],
groupLabelKey: "settings.preferences.title",
groupLabelFallback: "Preferences",
},
{
key: "hotkeys",
labelKey: "settings.hotkeys.title",
labelFallback: "Keyboard Shortcuts",
keywords: ["hotkey", "shortcut", "keybinding", "keyboard"],
groupLabelKey: "settings.preferences.title",
groupLabelFallback: "Preferences",
},
{
key: "help",
labelKey: "settings.help.label",
labelFallback: "Tours",
keywords: ["help", "tour", "guide", "support", "docs"],
groupLabelKey: "settings.help.title",
groupLabelFallback: "Help",
},
{
key: "legal",
labelKey: "settings.legal.label",
labelFallback: "Legal",
keywords: ["legal", "terms", "privacy", "licenses"],
groupLabelKey: "settings.legal.title",
groupLabelFallback: "Legal",
},
];
@@ -216,6 +216,17 @@ export const isValidToolId = (
return toolId in registry;
};
/**
* A "coming soon" placeholder: listed in the catalogue but not openable — no
* UI component and no external link. "read" and "multiTool" are exempt as
* workbench-only tools that render without a component.
*/
export const isComingSoonTool = (
toolId: string,
tool: ToolRegistryEntry,
): boolean =>
!tool.component && !tool.link && toolId !== "read" && toolId !== "multiTool";
/**
* Check if a tool supports automation (defaults to true)
*/
@@ -0,0 +1,63 @@
import { describe, expect, it, vi } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { useScopedFetchCache } from "@app/hooks/useScopedFetchCache";
const TTL = 30_000;
describe("useScopedFetchCache", () => {
it("exposes fetched values and clears loading", async () => {
const fetcher = vi.fn(async (key: string) => `value-${key}`);
const { result, rerender } = renderHook(
({ keys }: { keys: readonly string[] }) =>
useScopedFetchCache(keys, fetcher, TTL),
{ initialProps: { keys: ["a", "b"] as readonly string[] } },
);
expect(result.current.loading).toBe(true);
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.values).toEqual({ a: "value-a", b: "value-b" });
expect(fetcher).toHaveBeenCalledTimes(2);
// Fresh keys within the TTL are served from cache, not refetched.
rerender({ keys: ["a", "b"] });
await waitFor(() => expect(result.current.loading).toBe(false));
expect(fetcher).toHaveBeenCalledTimes(2);
});
it("stamps failed keys so they are not retried within the TTL", async () => {
const debug = vi.spyOn(console, "debug").mockImplementation(() => {});
const fetcher = vi.fn(async () => {
throw new Error("source unavailable");
});
const { result, rerender } = renderHook(
({ keys }: { keys: readonly string[] }) =>
useScopedFetchCache(keys, fetcher, TTL),
{ initialProps: { keys: ["a"] as readonly string[] } },
);
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.values).toEqual({});
expect(fetcher).toHaveBeenCalledTimes(1);
// Re-requesting the failed key (a keystroke re-render) must not refire.
rerender({ keys: ["a"] });
await waitFor(() => expect(result.current.loading).toBe(false));
expect(fetcher).toHaveBeenCalledTimes(1);
debug.mockRestore();
});
it("keeps successes when a sibling key fails", async () => {
const fetcher = vi.fn(async (key: string) => {
if (key === "bad") throw new Error("nope");
return `value-${key}`;
});
const debug = vi.spyOn(console, "debug").mockImplementation(() => {});
const { result } = renderHook(() =>
useScopedFetchCache(["good", "bad"], fetcher, TTL),
);
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.values).toEqual({ good: "value-good" });
debug.mockRestore();
});
});
@@ -0,0 +1,131 @@
import { useEffect, useRef, useState } from "react";
interface FetchSuccess<K extends string, V> {
key: K;
status: "fulfilled";
value: V;
}
interface FetchFailure<K extends string> {
key: K;
status: "rejected";
reason: unknown;
}
type FetchOutcome<K extends string, V> = FetchSuccess<K, V> | FetchFailure<K>;
export interface ScopedFetchCache<K extends string, V> {
/** Latest successfully fetched value per key; absent until first success. */
values: Partial<Record<K, V>>;
/** True while any requested key is being (re)fetched. */
loading: boolean;
}
/**
* A keyed async cache for search sources: each requested key is fetched at
* most once per TTL window, concurrent requests for the same key share one
* in-flight promise, and results from a superseded request generation are
* dropped (though their in-flight promises are still awaited by the next
* generation rather than re-fired).
*
* Failed keys are stamped like successes — a deployment without that endpoint
* answers the same way on every keystroke, so hammering it per keypress buys
* nothing. The failure is logged at debug level and retried after the TTL.
*
* `fetchKey` identity is the cache's world-view: when it changes (e.g. a tier
* change producing a different fetcher), every key is considered stale.
*/
export function useScopedFetchCache<K extends string, V>(
requestedKeys: readonly K[],
fetchKey: (key: K) => Promise<V>,
ttlMs: number,
): ScopedFetchCache<K, V> {
const [values, setValues] = useState<Partial<Record<K, V>>>({});
const [loading, setLoading] = useState(false);
const fetchedAtRef = useRef(new Map<K, number>());
const inFlightRef = useRef(new Map<K, Promise<FetchOutcome<K, V>>>());
const requestIdRef = useRef(0);
const fetcherRef = useRef(fetchKey);
useEffect(() => {
if (fetcherRef.current !== fetchKey) {
fetcherRef.current = fetchKey;
fetchedAtRef.current.clear();
inFlightRef.current.clear();
// Values from the old world-view must not surface under the new one
// (e.g. a tier change altering what a payload contains).
setValues({});
}
if (requestedKeys.length === 0) {
requestIdRef.current += 1;
setLoading(false);
return;
}
const now = Date.now();
const staleKeys = requestedKeys.filter(
(key) => now - (fetchedAtRef.current.get(key) ?? 0) >= ttlMs,
);
if (staleKeys.length === 0) {
setLoading(false);
return;
}
const requestId = requestIdRef.current + 1;
requestIdRef.current = requestId;
setLoading(true);
void Promise.all(
staleKeys.map((key) => {
const existing = inFlightRef.current.get(key);
if (existing) return existing;
const request = fetchKey(key)
.then((value) => ({ key, status: "fulfilled" as const, value }))
.catch((reason) => ({ key, status: "rejected" as const, reason }))
.finally(() => {
if (inFlightRef.current.get(key) === request) {
inFlightRef.current.delete(key);
}
});
inFlightRef.current.set(key, request);
return request;
}),
).then((results) => {
if (requestIdRef.current !== requestId) return;
const fetchedAt = Date.now();
for (const result of results) {
// Stamp failures too — see the hook doc.
fetchedAtRef.current.set(result.key, fetchedAt);
if (result.status === "rejected") {
console.debug(
"[useScopedFetchCache] source unavailable:",
result.key,
result.reason,
);
}
}
const fulfilled = results.filter(
(result): result is FetchSuccess<K, V> => result.status === "fulfilled",
);
if (fulfilled.length > 0) {
setValues((current) => {
const next = { ...current };
for (const result of fulfilled) next[result.key] = result.value;
return next;
});
}
setLoading(false);
});
return () => {
if (requestIdRef.current === requestId) {
requestIdRef.current += 1;
}
};
}, [requestedKeys, fetchKey, ttlMs]);
return { values, loading };
}
@@ -0,0 +1,286 @@
import React from "react";
import { describe, expect, it, vi } from "vitest";
vi.mock("react-router-dom", () => ({
useNavigate: vi.fn(),
}));
vi.mock("@app/contexts/ToolWorkflowContext", () => ({
useToolWorkflow: vi.fn(),
}));
vi.mock("@app/contexts/NavigationContext", () => ({
useNavigationActions: vi.fn(),
}));
vi.mock("@app/contexts/ViewerContext", () => ({
ViewerContext: React.createContext(null),
}));
vi.mock("@app/contexts/AppConfigContext", () => ({
useAppConfig: vi.fn(),
}));
vi.mock("@app/contexts/file/fileHooks", () => ({
useFileActions: vi.fn(),
}));
vi.mock("@app/auth/UseSession", () => ({
useAuth: vi.fn(() => ({
portalAccess: false,
isAdmin: false,
role: null,
})),
}));
vi.mock("@app/services/fileStorage", () => ({
fileStorage: {
getLeafStirlingFileStubs: vi.fn(),
},
}));
vi.mock("@app/data/settingsSearchIndex", () => ({
SETTINGS_SEARCH_INDEX: [
{
section: "email",
anchor: "smtp-host",
labelKey: "settings.email.smtpHost",
labelFallback: "SMTP host",
keywords: ["smtp"],
},
],
}));
vi.mock("@app/data/settingsSectionRegistry", () => ({
SETTINGS_SECTION_REGISTRY: [
{
key: "general",
labelKey: "settings.general.title",
labelFallback: "General",
keywords: ["general"],
},
{
key: "email",
labelKey: "settings.email.title",
labelFallback: "Email",
keywords: ["mail"],
requiresLogin: true,
},
{
key: "admin",
labelKey: "settings.admin.title",
labelFallback: "Admin",
keywords: ["admin"],
adminArea: true,
},
{
key: "teams",
labelKey: "settings.teams.title",
labelFallback: "Team",
keywords: ["team"],
requiresAccount: true,
},
],
}));
vi.mock("@app/data/settingsContentSearch", () => ({
findSettingsContentMatch: vi.fn((section: string, query: string) => {
if (query === "smtp" && (section === "general" || section === "email")) {
return { section, query };
}
return null;
}),
buildMatchSnippet: vi.fn(
(_match: unknown, query: string) => `Match: ${query}`,
),
}));
vi.mock("@app/data/processorSearchIndex", () => ({
PROCESSOR_SEARCH_INDEX: [
{
id: "users",
labelKey: "superSearch.processor.users",
labelFallback: "Users",
path: "/users",
keywords: ["members"],
},
{
id: "docs",
labelKey: "superSearch.processor.docs",
labelFallback: "Docs",
path: "",
externalUrl: "https://example.com/docs",
keywords: ["manual"],
},
],
}));
import type { TFunction } from "i18next";
import {
assembleSuperSearchGroups,
rankProcessorResults,
rankSettingsResults,
} from "@app/hooks/useSuperSearch";
const t = ((key: string, fallback?: string) =>
fallback ?? key) as unknown as TFunction;
describe("useSuperSearch helpers", () => {
it("drops empty groups and preserves the requested group order", () => {
const groups = assembleSuperSearchGroups(
{
tools: [
{
key: "tool:rotate",
group: "tools",
title: "Rotate",
score: 60,
onSelect: vi.fn(),
},
],
processor: [
{
key: "processor:users",
group: "processor",
title: "Users",
score: 70,
onSelect: vi.fn(),
},
],
},
t,
["processor", "settings", "tools", "files"],
);
expect(groups.map((group) => group.id)).toEqual(["processor", "tools"]);
expect(groups.map((group) => group.label)).toEqual(["Processor", "Tools"]);
});
it("prefers row-level setting hits and skips duplicate content matches", () => {
const openSettings = vi.fn();
const results = rankSettingsResults(
"smtp",
t,
{
isAdmin: false,
loginEnabled: true,
},
openSettings,
);
expect(results.map((result) => result.key)).toEqual([
"setting:email:smtp-host",
"setting-content:general",
]);
void results[0]?.onSelect();
expect(openSettings).toHaveBeenCalledWith("email", "smtp-host");
});
it("keeps gated settings hidden while app config is unresolved", () => {
const results = rankSettingsResults("admin", t, null, vi.fn());
expect(results).toEqual([]);
expect(rankSettingsResults("team", t, null, vi.fn())).toEqual([]);
});
it("respects showSettingsWhenNoLogin for the no-login admin preview", () => {
const noLoginGates = { isAdmin: false, loginEnabled: false };
// Default (flag unset / true): no-login mode keeps the admin preview.
const shown = rankSettingsResults("admin", t, noLoginGates, vi.fn());
expect(shown.map((result) => result.key)).toEqual([
"setting-section:admin",
]);
// Flag off: the modal hides admin sections, so search must too.
const hidden = rankSettingsResults(
"admin",
t,
{ ...noLoginGates, showSettingsWhenNoLogin: false },
vi.fn(),
);
expect(hidden).toEqual([]);
// Admins keep admin sections regardless of the flag.
const admin = rankSettingsResults(
"admin",
t,
{ isAdmin: true, loginEnabled: true, showSettingsWhenNoLogin: false },
vi.fn(),
);
expect(admin.map((result) => result.key)).toEqual([
"setting-section:admin",
]);
});
it("hides account-bound settings from anonymous sessions", () => {
const anonymous = rankSettingsResults(
"team",
t,
{ isAdmin: false, loginEnabled: true, isAnonymous: true },
vi.fn(),
);
expect(anonymous).toEqual([]);
const signedIn = rankSettingsResults(
"team",
t,
{ isAdmin: false, loginEnabled: true, isAnonymous: false },
vi.fn(),
);
expect(signedIn.map((result) => result.key)).toEqual([
"setting-section:teams",
]);
// Hosts that omit the flag (portal, always signed-in) keep the section.
const flagless = rankSettingsResults(
"team",
t,
{ isAdmin: false, loginEnabled: true },
vi.fn(),
);
expect(flagless.map((result) => result.key)).toEqual([
"setting-section:teams",
]);
});
it("keeps the Processor group closed until gates resolve, then opens it for single-user mode", () => {
const selectEntry = vi.fn();
expect(rankProcessorResults("members", t, null, selectEntry)).toEqual([]);
const results = rankProcessorResults(
"members",
t,
{
isAdmin: false,
loginEnabled: false,
},
selectEntry,
);
expect(results.map((result) => result.key)).toEqual(["processor:users"]);
void results[0]?.onSelect();
expect(selectEntry).toHaveBeenCalledWith(
expect.objectContaining({ id: "users", path: "/users" }),
);
});
it("opens the Processor group for a user with explicit portal access", () => {
const results = rankProcessorResults(
"members",
t,
{
isAdmin: false,
loginEnabled: true,
portalAccessible: true,
},
vi.fn(),
);
expect(results.map((result) => result.key)).toEqual(["processor:users"]);
});
});
@@ -0,0 +1,663 @@
import {
createElement,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { useLocation, useNavigate } from "react-router-dom";
import { useAuth } from "@app/auth/UseSession";
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 { useFileActions } from "@app/contexts/file/fileHooks";
import { fileStorage } from "@app/services/fileStorage";
import { FileDocIcon } from "@app/components/shared/FileDocIcon";
import { getFileDocVariant } from "@app/components/shared/filePreview/getFileTypeIcon";
import { detectFileExtension } from "@app/utils/fileUtils";
import { openExternalUrl } from "@app/utils/safeNavigation";
import { EDITOR_BASENAME } from "@app/routes/editorBasename";
import {
rankByFuzzy,
idToWords,
FUZZY_MIN_SCORE,
} from "@app/utils/fuzzySearch";
import type { StirlingFileStub } from "@app/types/fileContext";
import type { ToolId } from "@app/types/toolId";
import { isComingSoonTool, type ToolRegistry } from "@app/data/toolsTaxonomy";
import { SETTINGS_SEARCH_INDEX } from "@app/data/settingsSearchIndex";
import { SETTINGS_SECTION_REGISTRY } from "@app/data/settingsSectionRegistry";
import {
buildMatchSnippet,
findSettingsContentMatch,
} from "@app/data/settingsContentSearch";
import {
PROCESSOR_SEARCH_INDEX,
isPortalEntityScopeAccessible,
type ProcessorSearchEntry,
} from "@app/data/processorSearchIndex";
import { useProcessorEntityGroups } from "@app/data/processorEntitySearch";
import {
PORTAL_ENTITY_SCOPE_DEFS,
PORTAL_DOCS_SCOPE_ID,
type SuperSearchGates,
type SuperSearchGroup,
type SuperSearchGroupId,
type SuperSearchQueryOptions,
type SuperSearchResult,
type SuperSearchScope,
type UseSuperSearchResult,
} from "@app/types/superSearch";
// Re-exported so existing consumers keep one import site; the definitions
// live in the types leaf (see that module for why).
export type {
SuperSearchGates,
SuperSearchGroup,
SuperSearchGroupId,
SuperSearchQueryOptions,
SuperSearchResult,
SuperSearchScope,
UseSuperSearchResult,
};
/**
* How many results a ranker computes per group. This is the ceiling the
* dropdown can reveal via "show more" — the component shows a small initial
* slice per group and expands to the rest on demand.
*/
export const GROUP_RESULT_CEILING = 24;
/** Group display order in the dropdown. */
const GROUP_ORDER: SuperSearchGroupId[] = [
"files",
"tools",
"settings",
"processor",
];
/**
* Whether the current user can enter the Processor at all: explicit portal
* access, admin, or single-user mode with login disabled. Null gates (config
* still loading) stay closed.
*/
export function isProcessorGateOpen(gates: SuperSearchGates | null): boolean {
return (
!!gates &&
(gates.portalAccessible === true || gates.isAdmin || !gates.loginEnabled)
);
}
/** The editor's visibility gates, from app config + the session's flags. */
export function useSuperSearchGates(): SuperSearchGates | null {
const authState = useAuth();
const { config } = useAppConfig();
return useMemo(
() =>
config
? {
isAdmin: authState.isAdmin ?? config.isAdmin ?? false,
loginEnabled: config.enableLogin ?? false,
portalAccessible: authState.portalAccess ?? false,
isAnonymous: authState.isAnonymous,
showSettingsWhenNoLogin: config.showSettingsWhenNoLogin ?? true,
}
: null,
[authState.isAdmin, authState.isAnonymous, authState.portalAccess, config],
);
}
/**
* The editor bar's filter chips — one per source lane, granular over the
* Processor's contents (pages plus each entity type). Processor lanes only
* show when this build ships the portal and the user can enter it.
*/
export function useEditorSearchScopes(): SuperSearchScope[] {
const { t } = useTranslation();
const gates = useSuperSearchGates();
const processorAvailable =
PROCESSOR_SEARCH_INDEX.length > 0 && isProcessorGateOpen(gates);
return useMemo(() => {
const visibleViewIds = new Set(
PROCESSOR_SEARCH_INDEX.map((entry) => entry.id),
);
return [
{
id: "files",
label: t("superSearch.group.files", "Files"),
aliases: ["file", "files"],
},
{
id: "tools",
label: t("superSearch.group.tools", "Tools"),
aliases: ["tool", "tools"],
},
{
id: "settings",
label: t("superSearch.group.settings", "Settings"),
aliases: ["setting", "settings"],
},
...(processorAvailable
? [
{
id: "processor",
label: t("superSearch.group.pages", "Pages"),
aliases: ["page", "pages", "processor", "portal"],
},
...PORTAL_ENTITY_SCOPE_DEFS.filter(
(def) =>
visibleViewIds.has(def.viewId) &&
isPortalEntityScopeAccessible(def.id, gates?.isAdmin ?? false),
).map((def) => ({
id: def.id,
label: t(def.labelKey, def.labelFallback),
aliases: [...def.aliases],
})),
...(visibleViewIds.has("docs")
? [
{
id: PORTAL_DOCS_SCOPE_ID,
label: t("superSearch.group.docs", "Docs"),
aliases: ["doc", "docs", "documentation"],
},
]
: []),
]
: []),
];
}, [t, processorAvailable, gates?.isAdmin]);
}
/**
* Shared scope handling for hosts that accept SuperSearchQueryOptions:
* which source lanes are enabled for the current chip/prefix selection.
*/
export function useSearchScopeFilter(options?: SuperSearchQueryOptions): {
scopeEnabled: (scopeId: string) => boolean;
} {
const scopedIds = useMemo(
() => new Set(options?.scopeIds ?? []),
[options?.scopeIds],
);
const hasScopedSearch = scopedIds.size > 0;
const scopeEnabled = useCallback(
(scopeId: string) => !hasScopedSearch || scopedIds.has(scopeId),
[hasScopedSearch, scopedIds],
);
return { scopeEnabled };
}
// ---------------------------------------------------------------------------
// 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).
// ---------------------------------------------------------------------------
/** 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);
useEffect(() => {
if (!active) return;
// Refresh whenever the surface opens so newly added files appear.
let cancelled = false;
if (!loadedOnceRef.current) setLoadingFiles(true);
fileStorage
.getLeafStirlingFileStubs()
.then((all) => {
if (cancelled) return;
setStubs(all);
loadedOnceRef.current = true;
})
.catch((err) => {
console.error("[SuperSearch] Failed to load file stubs:", err);
})
.finally(() => {
if (!cancelled) setLoadingFiles(false);
});
return () => {
cancelled = true;
};
}, [active]);
return { stubs, loadingFiles };
}
export function rankFileResults(
stubs: StirlingFileStub[],
trimmed: string,
openFile: (stub: StirlingFileStub) => void | Promise<void>,
limit = GROUP_RESULT_CEILING,
): SuperSearchResult[] {
if (!trimmed) return [];
return rankByFuzzy(stubs, trimmed, [(s) => s.name])
.slice(0, limit)
.map(({ item, score }) => ({
key: `file:${item.id}`,
group: "files",
title: item.name,
// The file-type doc icon (PDF/image/doc/…) the sidebar and grid use,
// rather than a flat generic file glyph. Sized by height so the portrait
// doc shape sits level with the square tool/settings icons instead of
// overflowing the row.
icon: createElement(FileDocIcon, {
variant: getFileDocVariant(
detectFileExtension(item.name.toLowerCase()),
(item.type ?? "").toLowerCase(),
),
style: { height: "1.15rem", width: "auto" },
}),
score,
onSelect: () => openFile(item),
}));
}
export function rankToolResults(
registry: Partial<ToolRegistry>,
trimmed: string,
openTool: (id: ToolId) => void,
limit = GROUP_RESULT_CEILING,
): SuperSearchResult[] {
if (!trimmed) return [];
// Coming-soon placeholders are listed in the catalogue but can't open —
// selecting one would land on the "tool not found" panel.
const entries = (
Object.entries(registry) as [ToolId, ToolRegistry[ToolId] | undefined][]
).filter(([id, tool]) => tool && !isComingSoonTool(id, tool));
return rankByFuzzy(entries, trimmed, [
([id]) => idToWords(id),
([, v]) => v?.name ?? "",
([, v]) => v?.description ?? "",
([, v]) => v?.synonyms?.join(" ") ?? "",
])
.slice(0, 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 | null,
openSettings: (section: string, anchor?: string) => void,
limit = GROUP_RESULT_CEILING,
/** Sections the host's settings modal refuses to show (e.g. the portal's
* hiddenSectionKeys) — offering them would deep-link into a blank modal. */
excludeSections?: readonly string[],
): SuperSearchResult[] {
if (!trimmed) return [];
// Sections 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 (excludeSections?.includes(s.key)) return false;
// Null gates (config still loading): hide every gated section.
// requiresLogin keys off the deployment's login *mode*, mirroring the nav
// builder: with login on the editor is login-walled (an unauthenticated
// visitor never reaches this code), and with login off those sections
// (account, API keys) have no meaning. Actual per-session auth state only
// matters on SaaS, where requiresAccount/isAnonymous carries it.
if (s.requiresLogin && !(gates?.loginEnabled ?? false)) return false;
// Admin-area sections mirror the builder's gate: admins always; no-login
// mode only while system.showSettingsWhenNoLogin keeps the admin preview.
const adminGateOpen =
!!gates &&
(gates.isAdmin ||
(!gates.loginEnabled && (gates.showSettingsWhenNoLogin ?? true)));
if (s.adminArea && !adminGateOpen) return false;
// Account-bound sections mirror the SaaS builder's `!isAnonymous` gate.
if (s.requiresAccount && (gates ? (gates.isAnonymous ?? false) : true))
return false;
return true;
});
// Row context: the display label of the section the row lives in.
const sectionLabelFor = new Map(
visibleSections.map((s) => [s.key, t(s.labelKey, s.labelFallback)]),
);
// Row-level entries (deep-link with ?focus=) take priority. Rows for
// sections this build/user can't open are dropped with them.
const rowMatches = rankByFuzzy(
SETTINGS_SEARCH_INDEX.filter((e) => sectionLabelFor.has(e.section)),
trimmed,
[
(e) => t(e.labelKey, e.labelFallback),
(e) => e.labelFallback,
(e) => e.keywords?.join(" ") ?? "",
],
);
const rows = rowMatches.map(({ item, score }) => ({
key: `setting:${item.section}:${item.anchor}`,
group: "settings",
title: t(item.labelKey, item.labelFallback),
subtitle: sectionLabelFor.get(item.section),
iconName: "settings-rounded",
score: score + 1, // nudge rows above bare section matches
onSelect: () => openSettings(item.section, item.anchor),
}));
// The nav group a section lives under, shown as result context (joined to a
// content-match snippet with " · ").
const groupTitle = (s: (typeof SETTINGS_SECTION_REGISTRY)[number]) =>
s.groupLabelKey
? t(s.groupLabelKey, s.groupLabelFallback ?? "")
: undefined;
const sectionMatches = rankByFuzzy(visibleSections, trimmed, [
(s) => t(s.labelKey, s.labelFallback),
(s) => s.labelFallback,
(s) => s.keywords?.join(" ") ?? "",
]);
const sections = sectionMatches.map(({ item, score }) => ({
key: `setting-section:${item.key}`,
group: "settings",
title: t(item.labelKey, item.labelFallback),
subtitle: groupTitle(item),
iconName: "settings-rounded",
score,
onSelect: () => openSettings(item.key),
}));
// Content matches: sections whose rendered copy contains the query, so terms
// with no curated keyword ("SMTP", a field label) still find their section.
// Ranked below every label/keyword match; 3+ chars so a single letter doesn't
// match half the modal. Sections already surfaced by a label match — their
// own or a row's — are skipped so the same hit isn't listed twice.
const labelMatchedKeys = new Set<string>([
...sectionMatches.map(({ item }) => item.key),
...rowMatches.map(({ item }) => item.section),
]);
const contentMatches =
trimmed.length < 3
? []
: visibleSections
.filter((s) => !labelMatchedKeys.has(s.key))
.flatMap((s) => {
const match = findSettingsContentMatch(s.key, trimmed, t);
if (!match) return [];
const snippet = buildMatchSnippet(match, trimmed);
const group = groupTitle(s);
return [
{
key: `setting-content:${s.key}`,
group: "settings",
title: t(s.labelKey, s.labelFallback),
subtitle: group ? `${group} · ${snippet}` : snippet,
iconName: "settings-rounded",
// Always below the weakest possible label/keyword match.
score: FUZZY_MIN_SCORE - 10,
onSelect: () => openSettings(s.key),
},
];
});
return [...rows, ...sections, ...contentMatches]
.sort((a, b) => b.score - a.score)
.slice(0, limit);
}
export function rankProcessorResults(
trimmed: string,
t: TFunction,
gates: SuperSearchGates | null,
selectEntry: (entry: ProcessorSearchEntry) => void,
limit = GROUP_RESULT_CEILING,
): SuperSearchResult[] {
if (!trimmed || PROCESSOR_SEARCH_INDEX.length === 0) return [];
// Only offer Processor pages to users who can actually enter that app.
if (!isProcessorGateOpen(gates)) return [];
return rankByFuzzy(PROCESSOR_SEARCH_INDEX, trimmed, [
(e) => t(e.labelKey, e.labelFallback),
(e) => e.labelFallback,
(e) => e.keywords?.join(" ") ?? "",
])
.slice(0, limit)
.map(({ item, score }) => ({
key: `processor:${item.id}`,
group: "processor",
title: t(item.labelKey, item.labelFallback),
// Must exist in the bundled Material Symbols set (LocalIcon falls back
// to a network fetch for unknown names — blank when self-hosted offline).
iconName: "grid-view",
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,
options?: SuperSearchQueryOptions,
): UseSuperSearchResult {
const { t } = useTranslation();
const navigate = useNavigate();
const {
toolRegistry,
handleToolSelect,
handleToolSelectForced,
toolAvailability,
} = useToolWorkflow();
const { actions: navActions } = useNavigationActions();
const { actions: fileActions } = useFileActions();
// ViewerContext is only present once the viewer subtree mounts; treat as
// optional. Read through a ref: the provider value is rebuilt every viewer
// render, and a direct dependency would re-rank every result lane on each
// page turn — openFile only needs the value at click time.
const viewer = useContext(ViewerContext);
const viewerRef = useRef(viewer);
useEffect(() => {
viewerRef.current = viewer;
}, [viewer]);
const trimmed = query.trim();
const { stubs, loadingFiles } = useMyFilesStubs(active);
const { scopeEnabled } = useSearchScopeFilter(options);
const { pathname } = useLocation();
// Workbench-bound selections must leave the file manager through the router.
// Tool/file selection pins its URL via raw history.pushState, which the
// router never observes — so on /files the route keeps re-asserting the
// "myFiles" workbench and the selection appears to do nothing. Exit to the
// editor's home path: on processor-shipping builds "/" is a role router,
// not the editor.
const leaveFileManager = useCallback(() => {
if (pathname.startsWith("/files")) {
navigate(EDITOR_BASENAME);
}
}, [pathname, navigate]);
// --- Actions -----------------------------------------------------------
const openFile = useCallback(
async (stub: StirlingFileStub) => {
try {
// 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");
viewerRef.current?.setActiveFileId?.(stub.id);
leaveFileManager();
} catch (err) {
console.error("[SuperSearch] Failed to open file:", stub.name, err);
}
},
[fileActions, navActions, leaveFileManager],
);
const openTool = useCallback(
(id: ToolId) => {
// Link tools have no in-editor UI — selecting one shows a "tool not
// found" panel. Open their destination directly, matching how the
// editor's tool lists treat them.
const link = toolRegistry[id]?.link;
if (link) {
openExternalUrl(link);
return;
}
// Tools whose backend endpoint isn't served in this environment are
// flagged unavailable; handleToolSelect silently no-ops them. For a
// search ("take me to Repair") we still want the click to open the
// tool's UI, so fall back to the forced path for those. Available tools
// keep the normal path so the unsaved-changes guard still applies.
const available = toolAvailability[id]?.available !== false;
if (available) {
handleToolSelect(id);
} else {
handleToolSelectForced(id);
}
leaveFileManager();
},
[
handleToolSelect,
handleToolSelectForced,
toolAvailability,
toolRegistry,
leaveFileManager,
],
);
const openSettings = useCallback(
(section: string, anchor?: string) => {
const path = anchor
? `/settings/${section}?focus=${encodeURIComponent(anchor)}`
: `/settings/${section}`;
navigate(path);
},
[navigate],
);
const selectProcessorEntry = useCallback(
(item: ProcessorSearchEntry) => {
if (item.externalUrl) {
openExternalUrl(item.externalUrl);
} else {
navigate(item.path);
}
},
[navigate],
);
// --- Assemble ----------------------------------------------------------
const gates = useSuperSearchGates();
// Processor entities (users, policies, pipelines, sources) join the pages
// under the Processor section — same access gate as the pages group, each
// entity type filterable by its own scope.
const entityGroups = useProcessorEntityGroups(
trimmed,
active && trimmed.length > 0 && isProcessorGateOpen(gates),
t,
navigate,
scopeEnabled,
gates?.isAdmin ?? false,
);
const groups = useMemo<SuperSearchGroup[]>(() => {
const assembledGroups = assembleSuperSearchGroups(
{
files: scopeEnabled("files")
? rankFileResults(stubs, trimmed, openFile)
: [],
tools: scopeEnabled("tools")
? rankToolResults(toolRegistry, trimmed, openTool)
: [],
settings: scopeEnabled("settings")
? rankSettingsResults(trimmed, t, gates, openSettings)
: [],
processor: scopeEnabled("processor")
? rankProcessorResults(trimmed, t, gates, selectProcessorEntry)
: [],
},
t,
);
// Section order: Editor first, Settings second, Processor last.
const processorSection = t("superSearch.group.processor", "Processor");
const sectionFor = (groupId: string): string => {
if (groupId === "processor") return processorSection;
if (groupId === "settings")
return t("superSearch.group.settings", "Settings");
return t("portal.nav.editor", "Editor");
};
return [
...assembledGroups.map((group) => ({
...group,
label:
group.id === "processor"
? t("superSearch.group.pages", "Pages")
: group.label,
sectionLabel: sectionFor(group.id),
})),
...entityGroups.map((group) => ({
...group,
sectionLabel: processorSection,
})),
];
}, [
stubs,
trimmed,
openFile,
toolRegistry,
openTool,
gates,
openSettings,
selectProcessorEntry,
scopeEnabled,
entityGroups,
t,
]);
const flatResults = useMemo(() => groups.flatMap((g) => g.results), [groups]);
return { groups, flatResults, loadingFiles };
}
@@ -3,6 +3,7 @@ import { useToolRegistry } from "@app/contexts/ToolRegistryContext";
import { usePreferences } from "@app/contexts/PreferencesContext";
import {
getAllEndpoints,
isComingSoonTool,
type ToolRegistryEntry,
type ToolRegistry,
} from "@app/data/toolsTaxonomy";
@@ -167,12 +168,7 @@ export const useToolManagement = (): ToolManagementResult => {
? availabilityInfo.available !== false
: true;
// Check if tool is "coming soon" (has no component and no link)
const isComingSoon =
!baseTool.component &&
!baseTool.link &&
toolKey !== "read" &&
toolKey !== "multiTool";
const isComingSoon = isComingSoonTool(toolKey, baseTool);
if (preferences.hideUnavailableTools && (!isAvailable || isComingSoon)) {
return;
@@ -79,7 +79,8 @@ export const I18N_PROJECTS: TranslationProject[] = [
// SignSettings / SavedSignaturesSection resolve every key as
// t(`${scope}.${key}`); scope and leaf only ever exist as separate literals.
/^(sign|addText|addImage)\./,
// SettingsSearchBar indexes whole subtrees via t(prefix, { returnObjects }).
// Super search's settings content matching (settingsContentSearch)
// indexes whole subtrees via t(prefix, { returnObjects }).
/^admin\.settings\./,
/^settings\./,
/^account\./,
@@ -599,11 +599,6 @@ const MyFilesSidebarOverrides = forwardRef<HTMLDivElement, FileSidebarProps>(
<FileSidebar
ref={ref}
{...props}
onSearchClick={() => {
// Just focus the central search field; don't toggle collapse
// (which on /files navigates back home).
window.dispatchEvent(new Event("files-page:focus-search"));
}}
onUploadFiles={handleUpload}
onPickGoogleDriveFiles={handleUpload}
extraAction={{
@@ -126,6 +126,10 @@ export interface MockAppApiOptions {
username?: string;
email?: string;
roles?: string[];
/** Spring role string (e.g. "ROLE_ADMIN") — drives `isAdmin` in the auth seam. */
role?: string;
/** Portal (Processor) access flag — gates the super search's Processor lanes. */
portalAccess?: boolean;
} | null;
/** Languages advertised by `/config/app-config`. */
languages?: string[];
@@ -12,10 +12,9 @@ test.describe("20. Edge Cases and Security", () => {
test("should prevent XSS via search input", async ({ page }) => {
await loginAndSetup(page);
// Step 1: Open the search box (the tool panel header shows a search
// toggle; the field only mounts once it's pressed) and enter the payload
await page.getByRole("button", { name: /search tools/i }).click();
const searchBox = page.getByPlaceholder(/search|cari/i).first();
// Step 1: Enter the payload into the always-mounted super search bar
const searchBox = page.locator("#super-search-input");
await searchBox.click();
await searchBox.fill('"><img src=x onerror=alert(1)>');
// Step 2: Verify no script execution or image error handler fires
@@ -565,25 +565,6 @@ test.describe("Files page", () => {
test.describe("Side-rail integration with /files", () => {
test.use({ autoGoto: false });
test("Rail Search focuses the central search field, no navigation", async ({
page,
}) => {
await stubStorageApis(page);
await seedFiles(page, [
{ id: "alpha", name: "alpha.pdf", remoteStorageId: null },
]);
await gotoFilesPage(page);
// Click the search row in the rail.
await page.locator(".file-sidebar-search-row").click();
// The central search input should be focused.
const focused = await page.evaluate(
() => document.activeElement?.getAttribute("aria-label") ?? "",
);
expect(focused).toMatch(/Search/i);
// And we must still be on /files (i.e. didn't navigate home).
await expect(page).toHaveURL(/\/files/);
});
test("Rail New folder button visible on /files", async ({ page }) => {
await stubStorageApis(page);
await seedFiles(page, [
@@ -54,12 +54,11 @@ test.describe("13. Language / Localization", () => {
// Step 5: Wait for page reload (language change triggers window.location.reload())
await page.waitForLoadState("domcontentloaded");
// Step 6: Verify the UI text is in English. The tool search is a
// header toggle, so assert its English label rather than the field,
// which only mounts once the toggle is pressed.
await expect(
page.getByRole("button", { name: /search tools/i }).first(),
).toBeVisible({ timeout: 10000 });
// Step 6: Verify the UI text is in English via the always-mounted
// super search bar's placeholder.
await expect(page.getByPlaceholder(/search/i).first()).toBeVisible({
timeout: 10000,
});
}
});
});
@@ -17,14 +17,7 @@ test.describe("2. Main Dashboard / Home Page", () => {
page.locator('[data-testid="config-button"]').first(),
).toBeVisible();
// Tool search sits behind a header toggle now, so assert the affordance
// AND that pressing it actually mounts a usable search field — dropping
// the second half would stop covering the input entirely.
const searchToggle = page
.getByRole("button", { name: /search tools/i })
.first();
await expect(searchToggle).toBeVisible();
await searchToggle.click();
// Tool search lives in the global super search bar, always mounted.
await expect(page.getByPlaceholder(/search/i).first()).toBeVisible();
await expect(
@@ -82,10 +75,8 @@ test.describe("2. Main Dashboard / Home Page", () => {
await page.goto("/editor");
// Tool search is a header toggle; the field mounts only once pressed.
await expect(
page.getByRole("button", { name: /search tools/i }).first(),
).toBeVisible();
// Tool search lives in the global super search bar, always mounted.
await expect(page.getByPlaceholder(/search/i).first()).toBeVisible();
});
});
@@ -0,0 +1,443 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import { openSettings } from "@app/tests/helpers/ui-helpers";
import type { Page } from "@playwright/test";
/**
* Super search E2E: the bar itself (open, filter, select, show-more, XSS
* hygiene) plus the access gating — a user without Processor access must see
* no Processor chips, no Processor results, and trigger no entity fetches.
*
* Portal-lane presence differs by build: `vite dev` ships the portal, the CI
* preview build does not. Gate-closed assertions hold in both (closed lanes
* look identical to absent ones); gate-open lane assertions skip themselves
* when the build ships no portal.
*/
const INPUT = "#super-search-input";
/**
* URLs only the search's Processor entity fetch hits. `/api/v1/policies`
* itself is deliberately absent: the editor's policy auto-run also reads it at
* boot for every user, so it can't distinguish a search leak.
*/
const ENTITY_API_PATTERN =
/\/api\/v1\/policies\/overview|\/api\/v1\/sources|\/api\/v1\/proprietary\/ui-data\/admin-settings/;
async function openSearch(page: Page) {
const input = page.locator(INPUT);
await input.click();
await expect(input).toHaveAttribute("aria-expanded", "true");
return input;
}
test.describe("Super search — bar basics", () => {
test("Ctrl+K opens the bar and results filter as you type", async ({
page,
}) => {
const input = page.locator(INPUT);
// The shortcut listener mounts with the bar — wait for it before pressing.
await expect(input).toBeVisible();
await page.keyboard.press("Control+KeyK");
await expect(input).toBeFocused();
await expect(input).toHaveAttribute("aria-expanded", "true");
await input.fill("merge");
await expect(
page.getByRole("option", { name: /Merge/ }).first(),
).toBeVisible();
// A different query replaces the results in place.
await input.fill("compress");
await expect(
page.getByRole("option", { name: /Compress/ }).first(),
).toBeVisible();
await expect(page.getByRole("option", { name: /^Merge/ })).toHaveCount(0);
});
test("selecting a tool result opens that tool", async ({ page }) => {
const input = await openSearch(page);
await input.fill("merge");
await page
.getByRole("option", { name: /^Merge/ })
.first()
.click();
await page.waitForURL("**/merge**");
});
test("shows the empty state for a query with no matches", async ({
page,
}) => {
const input = await openSearch(page);
await input.fill("xyznonexistent123");
await expect(page.getByText("No results found")).toBeVisible();
});
test("treats markup in the query as plain text", async ({ page }) => {
let dialogFired = false;
page.on("dialog", (dialog) => {
dialogFired = true;
void dialog.dismiss();
});
const input = await openSearch(page);
const payload =
'<img src=x onerror="window.__xss=1"><script>alert(1)</script>';
await input.fill(payload);
await expect(input).toHaveValue(payload);
await expect(page.getByText("No results found")).toBeVisible();
expect(dialogFired).toBe(false);
expect(
await page.evaluate(
() => (window as unknown as { __xss?: number }).__xss,
),
).toBeUndefined();
});
test("show more reveals the rest of a large group and collapses again", async ({
page,
}) => {
const input = await openSearch(page);
// Broad query — the tools lane alone exceeds the 5-row collapsed slice.
// Assertions scope to the Tools group: other groups (docs, entities)
// pop in asynchronously, so page-wide option counts are racy.
await input.fill("pdf");
const tools = page.getByRole("group", { name: "Tools" });
await expect(tools.getByRole("option").first()).toBeVisible();
const collapsedCount = await tools.getByRole("option").count();
const showMore = tools.getByRole("button", { name: /Show \d+ more/ });
await expect(showMore).toBeVisible();
const hidden = Number(
(await showMore.innerText()).match(/\d+/)?.[0] ?? "0",
);
await showMore.click();
await expect(tools.getByRole("option")).toHaveCount(
collapsedCount + hidden,
);
const showLess = tools.getByRole("button", { name: "Show less" });
await expect(showLess).toBeVisible();
await showLess.click();
await expect(tools.getByRole("option")).toHaveCount(collapsedCount);
});
test("selecting a tool from the /files page leaves the file manager", async ({
page,
}) => {
await page.goto("/files");
const input = page.locator(INPUT).first();
await expect(input).toBeVisible({ timeout: 15000 });
await input.click();
await input.fill("merge");
await page
.getByRole("option", { name: /^Merge/ })
.first()
.click();
// The selection pins its tool URL via raw history.pushState, which the
// router never sees — the sink must leave /files through the router or
// the myFiles workbench swallows the selection (and "/" is a role router
// on processor-shipping builds, so the exit must target the editor home).
await expect(page.locator(".files-page-header-search")).not.toBeVisible({
timeout: 5000,
});
await expect(page).toHaveURL(/\/merge/);
});
test("Ctrl+K inside the settings modal closes it and focuses the bar", async ({
page,
}) => {
const input = page.locator(INPUT);
await expect(input).toBeVisible();
await openSettings(page);
// The modal traps focus, so the bar's own shortcut is inert; the modal
// cedes: it closes itself and hands focus to the bar.
await page.keyboard.press("Control+KeyK");
await expect(page.locator(".modal-container")).not.toBeVisible();
await expect(input).toBeFocused();
await expect(input).toHaveAttribute("aria-expanded", "true");
// Full loop: a settings result deep-links straight back into the modal.
await input.fill("general");
await page
.getByRole("option", { name: /General/ })
.first()
.click();
await expect(page.locator(".modal-container")).toBeVisible();
});
});
test.describe("Super search — user without Processor access", () => {
// Login enabled, signed in as a plain member: no admin role, no portal
// access. The Processor gate must stay closed.
test.use({
stubOptions: {
enableLogin: true,
user: {
id: 33,
username: "bob",
email: "bob@example.com",
role: "ROLE_USER",
portalAccess: false,
},
},
seedJwt: true,
});
test("sees no Processor chips, results or entity fetches", async ({
page,
}) => {
const entityRequests: string[] = [];
page.on("request", (request) => {
if (ENTITY_API_PATTERN.test(request.url())) {
entityRequests.push(request.url());
}
});
const input = await openSearch(page);
// Chip row: the editor lanes only. No Processor lanes of any kind.
await expect(page.getByRole("button", { name: "Tools" })).toBeVisible();
for (const lane of [
"Pages",
"Users",
"Policies",
"Pipelines",
"Sources",
"Docs",
]) {
await expect(
page.getByRole("button", { name: lane, exact: true }),
).toHaveCount(0);
}
// A query that would hit policies/docs/admin settings when the gate is
// open must yield no Processor section for this user.
await input.fill("security");
await expect(page.getByRole("option").first()).toBeVisible();
await expect(
page.locator(".super-search-section-label", { hasText: "Processor" }),
).toHaveCount(0);
// And the search must not have fetched any entity data on their behalf.
await page.waitForTimeout(750);
expect(entityRequests).toEqual([]);
});
test("sees login-gated settings but no admin settings", async ({ page }) => {
const input = await openSearch(page);
// Positive control — requiresLogin sections are visible to a signed-in
// user, proving the settings lane itself works for this account.
await input.fill("account");
await expect(
page.getByRole("option", { name: /Account Settings/ }).first(),
).toBeVisible();
// Admin-only sections stay hidden (label and content matches alike).
await input.fill("endpoints");
await expect(page.getByRole("option", { name: /^Endpoints/ })).toHaveCount(
0,
);
});
});
test.describe("Super search — portal access without admin", () => {
// Self-hosted grants portal access beyond admins (team owners, ACL
// grantees), but the users roster endpoint is admin-only — the Users lane
// must not be offered to a session the endpoint would always refuse.
test.use({
stubOptions: {
enableLogin: true,
user: {
id: 44,
username: "owner",
email: "owner@example.com",
role: "ROLE_USER",
portalAccess: true,
},
},
seedJwt: true,
});
test("sees Processor lanes but no Users chip and no roster fetch", async ({
page,
}) => {
const rosterRequests: string[] = [];
page.on("request", (request) => {
if (
request.url().includes("/api/v1/proprietary/ui-data/admin-settings")
) {
rosterRequests.push(request.url());
}
});
for (const [pattern, json] of [
["**/api/v1/policies", []],
["**/api/v1/policies/runs", []],
["**/api/v1/policies/overview", { pipelines: [] }],
["**/api/v1/sources", { sources: [] }],
] as const) {
await page.route(pattern, (route) => route.fulfill({ json }));
}
const input = await openSearch(page);
const portalShips =
(await page.getByRole("button", { name: "Pages", exact: true }).count()) >
0;
test.skip(!portalShips, "this build ships no portal — no lanes to gate");
for (const lane of ["Policies", "Pipelines", "Sources"]) {
await expect(
page.getByRole("button", { name: lane, exact: true }),
).toBeVisible();
}
await expect(
page.getByRole("button", { name: "Users", exact: true }),
).toHaveCount(0);
// A query that used to fire the doomed roster fetch once per TTL.
await input.fill("admin");
await expect(page.locator(".super-search-dropdown").first()).toBeVisible();
await page.waitForTimeout(1500);
expect(rosterRequests).toEqual([]);
});
});
test.describe("Super search — admin with Processor access", () => {
test.use({
stubOptions: {
enableLogin: true,
isAdmin: true,
user: {
id: 1,
username: "admin",
email: "admin@example.com",
role: "ROLE_ADMIN",
portalAccess: true,
},
},
seedJwt: true,
});
test("sees Processor lanes and live entity results", async ({ page }) => {
// Entity data the gate-open bar fetches, stubbed with one source row.
await page.route("**/api/v1/policies", (route) =>
route.fulfill({ json: [] }),
);
await page.route("**/api/v1/policies/runs", (route) =>
route.fulfill({ json: [] }),
);
await page.route("**/api/v1/policies/overview", (route) =>
route.fulfill({ json: { pipelines: [] } }),
);
await page.route("**/api/v1/sources", (route) =>
route.fulfill({
json: {
sources: [{ id: "src-1", name: "Contract Intake", type: "email" }],
},
}),
);
await openSearch(page);
// The Processor lanes only exist in builds that ship the portal (dev,
// VITE_INCLUDE_PORTAL) — the CI preview build has none to show.
const portalShips =
(await page.getByRole("button", { name: "Pages", exact: true }).count()) >
0;
test.skip(!portalShips, "this build ships no portal — no lanes to gate");
for (const lane of ["Users", "Policies", "Pipelines", "Sources"]) {
await expect(
page.getByRole("button", { name: lane, exact: true }),
).toBeVisible();
}
const input = page.locator(INPUT);
await input.fill("contract intake");
await expect(
page.getByRole("option", { name: /Contract Intake/ }).first(),
).toBeVisible();
await expect(
page
.locator(".super-search-section-label", { hasText: "Processor" })
.first(),
).toBeVisible();
});
});
test.describe("Portal bar — tool results hop into the editor", () => {
test.use({
stubOptions: {
enableLogin: true,
isAdmin: true,
user: {
id: 1,
username: "admin",
email: "admin@example.com",
role: "ROLE_ADMIN",
portalAccess: true,
},
},
seedJwt: true,
autoGoto: false,
});
test("selecting a tool routes client-side, not via a full page load", async ({
page,
}) => {
for (const [pattern, json] of [
["**/api/v1/policies", []],
["**/api/v1/policies/runs", []],
["**/api/v1/policies/overview", { pipelines: [] }],
["**/api/v1/sources", { sources: [] }],
["**/api/v1/team/my", []],
] as const) {
await page.route(pattern, (route) => route.fulfill({ json }));
}
await page.goto("/processor");
const input = page.locator("#portal-search-input");
// The portal only ships in dev / VITE_INCLUDE_PORTAL builds — on the CI
// preview build /processor falls through to the editor and there is no
// portal bar to hop from.
const portalShips = await input
.waitFor({ state: "visible", timeout: 20000 })
.then(() => true)
.catch(() => false);
test.skip(!portalShips, "this build ships no portal — no bar to hop from");
// A full page load would drop this marker — and on bundled deploys it
// would also 401: document GETs carry no Authorization header, so the
// backend bounces them to /login even with a live session.
await page.evaluate(() => {
(window as unknown as { __spaMarker?: boolean }).__spaMarker = true;
});
await input.click();
await input.fill("merge");
await page
.getByRole("option", { name: /^Merge/ })
.first()
.click();
await expect(page).toHaveURL(/\/merge/);
// Editor mounted: its own search bar replaces the portal's.
await expect(page.locator("#super-search-input")).toBeVisible({
timeout: 15000,
});
// The editor's URL-driven tool init must run on this route-swap mount,
// not just on a cold page load: the Merge tool panel actually opens.
await expect(
page.getByRole("button", { name: "Merge", exact: true }),
).toBeVisible({ timeout: 10000 });
expect(
await page.evaluate(
() => (window as unknown as { __spaMarker?: boolean }).__spaMarker,
),
).toBe(true);
});
});
@@ -1,88 +0,0 @@
import type { Page } from "@playwright/test";
import { test, expect } from "@app/tests/helpers/stub-test-base";
/**
* The tool panel header shows a search *toggle*; the field only mounts once
* it's pressed. Open it and hand back the focused input.
*/
async function openToolSearch(page: Page) {
await page.getByRole("button", { name: /search tools/i }).click();
const searchBox = page.getByPlaceholder(/search|cari/i).first();
await expect(searchBox).toBeVisible({ timeout: 5000 });
return searchBox;
}
test.describe("3. Tool Search", () => {
test.describe("3.1 Search - Happy Path", () => {
test("should filter tools in real time based on search input", async ({
page,
}) => {
// Step 1: Open the search box from the header toggle
const searchBox = await openToolSearch(page);
// Step 2: Type "merge"
await searchBox.fill("merge");
// Step 3: Verify search results filter to show relevant tools
await expect(
page.locator("text=/merge|menggabungkan/i").first(),
).toBeVisible({ timeout: 5000 });
// Step 5: Clear the search field
await searchBox.clear();
// Step 6: Verify all tools reappear (check for multiple categories)
await expect(
page.locator("text=/recommended|direkomendasikan/i").first(),
).toBeVisible({ timeout: 5000 });
});
});
test.describe("3.2 Search - No Results", () => {
test("should handle queries with no matching tools gracefully", async ({
page,
}) => {
// Step 1: Open the search box from the header toggle
const searchBox = await openToolSearch(page);
// Step 2: Type xyznonexistent123
await searchBox.fill("xyznonexistent123");
// Step 3: Verify the search field accepted the input
await expect(searchBox).toHaveValue("xyznonexistent123");
// The app uses fuzzy search with a fallback that shows all tools when nothing
// matches, so we verify the search state is active (no "recommended" section)
// and the page remains functional without errors.
await expect(
page.locator("text=/recommended|direkomendasikan/i"),
).toHaveCount(0, { timeout: 5000 });
// Step 4: Clear the search field
await searchBox.clear();
// Step 5: Verify all tools reappear (recommended section comes back)
await expect(
page.locator("text=/recommended|direkomendasikan/i").first(),
).toBeVisible({ timeout: 5000 });
});
});
test.describe("3.3 Search - Special Characters", () => {
test("should sanitize search input against XSS", async ({ page }) => {
// Step 1: Type XSS payload into the search box
const searchBox = await openToolSearch(page);
await searchBox.fill("<script>alert(1)</script>");
// Step 2: Verify no script execution occurs (no alert dialog)
// If an alert appeared, Playwright would throw an unhandled dialog error
await page.waitForTimeout(1000);
// Step 3: Verify the search treats the input as plain text
await expect(searchBox).toHaveValue("<script>alert(1)</script>");
// Step 4: Clear the search field
await searchBox.clear();
});
});
});
@@ -0,0 +1,134 @@
import type React from "react";
/**
* The super search's shared contract: what a results provider returns and the
* shapes hosts exchange with the shared rankers. Pure types, kept apart from
* the provider implementations so leaf modules (flavor seams, the portal's
* entity search) can depend on the contract without importing a provider —
* which would be a circular import.
*/
export type SuperSearchGroupId = "files" | "tools" | "settings" | "processor";
export interface SuperSearchResult {
/** Stable unique key across all groups. */
key: string;
/** 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`. */
iconName?: string;
icon?: React.ReactNode;
score: number;
onSelect: () => void | Promise<void>;
}
export interface SuperSearchGroup {
id: string;
label: string;
/** Optional higher-level section label rendered above consecutive groups. */
sectionLabel?: string;
results: SuperSearchResult[];
}
export interface SuperSearchScope {
id: string;
label: string;
aliases?: string[];
}
export interface UseSuperSearchResult {
/** Non-empty groups, in display order. */
groups: SuperSearchGroup[];
/** All results flattened in display order (for keyboard navigation). */
flatResults: SuperSearchResult[];
/** True while the My Files store is loading for the first time. */
loadingFiles: boolean;
}
export interface SuperSearchQueryOptions {
scopeIds?: readonly string[];
}
/**
* Visibility gates shared by the settings and Processor sources. Hosts pass
* `null` while the app config is still loading — the rankers treat that as
* "most restrictive" so gated results can appear once config lands but never
* flash open before it.
*/
export interface SuperSearchGates {
isAdmin: boolean;
loginEnabled: boolean;
portalAccessible?: boolean;
/**
* Whether no-login mode keeps the read-only admin settings preview
* (`system.showSettingsWhenNoLogin`, default true). Mirrors the settings
* nav builder's admin gate: `isAdmin || (!loginEnabled && this)`.
*/
showSettingsWhenNoLogin?: boolean;
/**
* True for a signed-out (anonymous) session. Gates account-bound sections
* on hosts where auth state, not the local backend config, is the truth
* (SaaS). Hosts whose users are always signed in may omit it.
*/
isAnonymous?: boolean;
}
/**
* The Processor's entity lanes as scope definitions — ids, the portal view
* each targets, chip labels and typed-prefix aliases. Both hosts' chip lists
* and the portal's entity module derive from this one list, so the chips
* can't drift apart; it lives in the types leaf because the editor's chip
* list must not import portal code.
*/
export interface PortalEntityScopeDef {
id:
| "portal-users"
| "portal-policies"
| "portal-pipelines"
| "portal-sources";
/** Portal view id the scope targets (visibility check vs the page index). */
viewId: string;
labelKey: string;
labelFallback: string;
aliases: readonly string[];
}
/**
* The developer-docs scope. Separate from the entity defs above: docs are a
* bundled full-text manifest, not a fetched entity list, so they don't ride
* the fetch cache — but they get a chip and a results group like the rest.
*/
export const PORTAL_DOCS_SCOPE_ID = "portal-docs";
export const PORTAL_ENTITY_SCOPE_DEFS: readonly PortalEntityScopeDef[] = [
{
id: "portal-users",
viewId: "users",
labelKey: "portal.nav.users",
labelFallback: "Users",
aliases: ["user", "users", "member", "members"],
},
{
id: "portal-policies",
viewId: "policies",
labelKey: "portal.nav.policies",
labelFallback: "Policies",
aliases: ["policy", "policies"],
},
{
id: "portal-pipelines",
viewId: "pipelines",
labelKey: "portal.nav.pipelines",
labelFallback: "Pipelines",
aliases: ["pipeline", "pipelines"],
},
{
id: "portal-sources",
viewId: "sources",
labelKey: "portal.nav.sources",
labelFallback: "Sources",
aliases: ["source", "sources"],
},
];
@@ -81,9 +81,14 @@ export function scoreMatch(queryRaw: string, targetRaw: string): number {
return best;
}
/** Lowest score any fuzzy match can pass with (see minScoreForQuery). Rankers
* that mix fuzzy results with fixed-score entries key off this to stay below
* every real match. */
export const FUZZY_MIN_SCORE = 30;
export function minScoreForQuery(query: string): number {
const len = normalizeText(query).length;
return len <= 3 ? 40 : 30;
return len <= 3 ? 40 : FUZZY_MIN_SCORE;
}
// Decide if a target matches a query based on a threshold
@@ -0,0 +1,39 @@
/**
* Guarded client-side navigation for URLs that arrive as data (tool registry
* links, search index entries, flavor config) rather than literals. The
* values are trusted today, but funnelling them through a scheme allowlist
* means a poisoned or mistyped entry (`javascript:…`) can never become a
* script-execution sink.
*/
const SAFE_PROTOCOLS = new Set(["http:", "https:"]);
/** The URL resolved against the current origin, or null if unparseable or a
* non-web scheme (javascript:, data:, …). */
export function toSafeWebUrl(url: string): URL | null {
let parsed: URL;
try {
parsed = new URL(url, window.location.origin);
} catch {
return null;
}
return SAFE_PROTOCOLS.has(parsed.protocol) ? parsed : null;
}
/** window.open in a new tab, dropped silently for non-web schemes. */
export function openExternalUrl(url: string): void {
const safe = toSafeWebUrl(url);
if (safe) window.open(safe.href, "_blank", "noopener,noreferrer");
}
/**
* Full-page navigation (window.location), dropped for non-web schemes. The
* guard is scheme-only: cross-origin http(s) targets pass by design (a
* separately-hosted editor is configured build-time via VITE_EDITOR_URL), so
* callers must pass build/config values — never user-supplied input, which
* could redirect anywhere on the web.
*/
export function assignLocation(url: string): void {
const safe = toSafeWebUrl(url);
if (safe) window.location.assign(safe.href);
}
@@ -0,0 +1,6 @@
/**
* Desktop inherits proprietary's app but must NOT ship the portal (see the
* admin-route seam) — shadow the entity search back to core's empty stub so
* the super search never fetches or offers Processor entities on desktop.
*/
export { useProcessorEntityGroups } from "@core/data/processorEntitySearch";
@@ -0,0 +1,10 @@
/**
* Desktop inherits proprietary's app but must NOT ship the portal (see the
* admin-route seam) — shadow the index back to core's empty list so the super
* search never offers Processor destinations that don't exist on desktop.
*/
export type { ProcessorSearchEntry } from "@core/data/processorSearchIndex";
export {
PROCESSOR_SEARCH_INDEX,
isPortalEntityScopeAccessible,
} from "@core/data/processorSearchIndex";
@@ -0,0 +1,50 @@
import { type SettingsSectionEntry } from "@core/data/settingsSectionRegistry";
export type { SettingsSectionEntry };
/**
* Desktop settings sections. The desktop modal reflows heavily by connection
* mode (`configNavSections`): local mode shows only Preferences + Connection
* Mode + Legal, while SaaS mode swaps in the cloud Plan/Team sections and hides
* the self-hosted admin area. To stay safe across both modes without threading
* the (async) connection state into the always-mounted search, this lists only
* the sections guaranteed to render in every desktop mode.
*
* Cloud Plan/Team search on desktop-SaaS is a deliberate Tier-0 gap, not a
* regression — better to omit them than to deep-link to a dead tab in local
* mode.
*/
export const SETTINGS_SECTION_REGISTRY: SettingsSectionEntry[] = [
{
key: "general",
labelKey: "settings.general.title",
labelFallback: "General",
keywords: ["theme", "language", "appearance", "preferences", "startup"],
groupLabelKey: "settings.preferences.title",
groupLabelFallback: "Preferences",
},
{
key: "hotkeys",
labelKey: "settings.hotkeys.title",
labelFallback: "Keyboard Shortcuts",
keywords: ["hotkey", "shortcut", "keybinding", "keyboard"],
groupLabelKey: "settings.preferences.title",
groupLabelFallback: "Preferences",
},
{
key: "connectionMode",
labelKey: "settings.connection.title",
labelFallback: "Connection Mode",
keywords: ["connection", "local", "cloud", "server", "sign in"],
groupLabelKey: "settings.connection.title",
groupLabelFallback: "Connection Mode",
},
{
key: "legal",
labelKey: "settings.legal.label",
labelFallback: "Legal",
keywords: ["legal", "terms", "privacy", "licenses"],
groupLabelKey: "settings.legal.title",
groupLabelFallback: "Legal",
},
];
-13
View File
@@ -1,13 +0,0 @@
import { apiClient } from "@portal/api/http";
export interface QuickAction {
group: "Jump to" | "Create" | "Theme";
label: string;
/** Keyboard hint shown to the right. */
hint: string;
}
/** GET /v1/search/quick-actions */
export async function fetchQuickActions(): Promise<QuickAction[]> {
return apiClient.local.json<QuickAction[]>("/v1/search/quick-actions");
}
@@ -56,4 +56,12 @@ export interface UsersCapabilities {
manageGrants: boolean;
/** Whether "remove" takes the member out of the whole org or just the team. */
removeScope: "org" | "team";
/**
* Listing the roster needs the org-admin role. Self-hosted reads the
* admin-only endpoints (`@PreAuthorize(hasRole('ADMIN'))`), which refuse
* the non-admin sessions portal access also admits (team owners, ACL
* grantees). SaaS lists through the team-leader endpoints, which every
* portal-eligible session can call.
*/
listingRequiresAdmin: boolean;
}
@@ -42,8 +42,8 @@
align-items: center;
gap: 0.375rem;
padding: 0 0.625rem;
background: var(--color-sidebar-bg);
border-bottom: 1px solid var(--color-sidebar-border);
background: var(--c-bg-raised);
border-bottom: 1px solid var(--c-border-subtle);
}
.portal-shell__topbar-wordmark {
@@ -1,4 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider";
import { AppShell } from "@portal/components/AppShell";
import { Home } from "@portal/views/Home";
@@ -12,6 +13,14 @@ const meta: Meta<typeof AppShell> = {
title: "Portal/Shell/AppShell",
component: AppShell,
parameters: { layout: "fullscreen" },
decorators: [
// The shell hosts the portal search bar, which reads the tool registry.
(Story) => (
<ToolRegistryProvider>
<Story />
</ToolRegistryProvider>
),
],
};
export default meta;
type Story = StoryObj<typeof AppShell>;
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
import { ActionIcon } from "@app/ui";
import { Sidebar } from "@portal/components/Sidebar";
import { PortalSearchBar } from "@portal/components/PortalSearchBar";
import { useUI } from "@portal/contexts/UIContext";
import { MenuIcon, SearchIcon } from "@portal/components/icons";
import { Logo } from "@app/ui/Logo";
@@ -10,12 +11,12 @@ import "@portal/components/AppShell.css";
/**
* Compact header shown only under the mobile breakpoint (CSS-hidden on
* desktop): hamburger opens the sidebar drawer, search opens the palette
* (there's no ⌘K on a phone).
* desktop): hamburger opens the sidebar drawer, search focuses the global
* search bar below (there's no ⌘K on a phone).
*/
function MobileTopbar() {
const { t } = useTranslation();
const { mobileNavOpen, toggleMobileNav, openSearch } = useUI();
const { mobileNavOpen, toggleMobileNav, closeMobileNav } = useUI();
return (
<header className="portal-shell__topbar">
<ActionIcon
@@ -37,7 +38,10 @@ function MobileTopbar() {
variant="tertiary"
size="lg"
aria-label={t("portal.shell.topbar.search")}
onClick={openSearch}
onClick={() => {
closeMobileNav();
document.getElementById("portal-search-input")?.focus();
}}
>
<SearchIcon size={19} />
</ActionIcon>
@@ -47,9 +51,10 @@ function MobileTopbar() {
/**
* Two-column layout: fixed-width sidebar on the left, a scrolling main column on
* the right. Under the mobile breakpoint the sidebar becomes an off-canvas
* drawer behind a scrim, opened from the topbar hamburger. The Sidebar reads
* its state from context, so this shell stays prop-free.
* the right (topped by the global search bar). Under the mobile breakpoint the
* sidebar becomes an off-canvas drawer behind a scrim, opened from the topbar
* hamburger. The Sidebar reads its state from context, so this shell stays
* prop-free.
*/
export function AppShell({ children }: { children: ReactNode }) {
const { mobileNavOpen, closeMobileNav } = useUI();
@@ -83,6 +88,7 @@ export function AppShell({ children }: { children: ReactNode }) {
)}
<div className="portal-shell__main">
<MobileTopbar />
<PortalSearchBar />
<main className="portal-shell__view">{children}</main>
</div>
</div>
@@ -1,39 +1,11 @@
import { useEffect } from "react";
import { useLocation } from "react-router-dom";
import { AppConfigProvider } from "@app/contexts/AppConfigContext";
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,24 +21,24 @@ 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 (
<>
<GlobalShortcuts />
// One app-config instance for every portal consumer (search gates, the
// settings modal) so they can't fetch twice or disagree.
<AppConfigProvider bootstrapMode="non-blocking">
{/* The pipeline builder reads the tool registry to list and configure operations. */}
<ToolRegistryProvider>
<AppShell>
<RoutedContent />
</AppShell>
</ToolRegistryProvider>
<SearchModal />
<PortalSettingsHost />
</>
</AppConfigProvider>
);
}
@@ -0,0 +1,30 @@
/* Slim strip at the top of the main column hosting the shared search bar.
Deliberately unpainted: only the input itself shows, on the page ground. */
.portal-searchbar {
display: flex;
align-items: center;
justify-content: center;
padding: 0.22rem 1rem;
flex-shrink: 0;
}
.portal-searchbar .super-search {
flex: 0 1 24rem;
width: min(100%, 24rem);
max-width: 24rem;
}
.portal-searchbar .super-search input {
background-color: transparent;
padding-top: 4px;
padding-bottom: 4px;
font-size: 12.5px;
}
/* The dropdown is portalled to <body>, so it can't inherit portal styling by
nesting — this class (passed via dropdownClassName) is the portal's hook.
Colours come from the shared semantic tokens; only the roomier padding
differs from the editor dropdown. */
.portal-search-dropdown.super-search-dropdown {
padding: 0.5rem;
}
@@ -0,0 +1,29 @@
import SuperSearch from "@app/components/shared/superSearch/SuperSearch";
import {
usePortalSearchResults,
usePortalSearchScopes,
} 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-wired results provider. Cmd/Ctrl+K
* focuses it (the bar registers its own shortcut). App config (the
* admin/login gates) comes from PortalChrome's shared provider. The distinct
* input id keeps this instance clear of the editor bar's stable id, which
* external focus helpers target.
*/
export function PortalSearchBar() {
const scopes = usePortalSearchScopes();
return (
<div className="portal-searchbar">
<SuperSearch
useResults={usePortalSearchResults}
inputId="portal-search-input"
scopes={scopes}
dropdownClassName="portal-search-dropdown"
/>
</div>
);
}
@@ -13,22 +13,38 @@ import {
import { accountLinkSettings } from "@portal/components/settings/accountLinkSettings";
import { useUI } from "@portal/contexts/UIContext";
/**
* Settings sections the portal cannot host. Shared with the portal's search
* provider so these never surface as search results either.
*
* TODO: opening Keyboard Shortcuts in the portal white-screens the app (the
* section expects editor-only context). Hidden here as a stopgap; fix the
* section properly and drop this.
*/
export const PORTAL_HIDDEN_SECTION_KEYS: NavKey[] = ["hotkeys"];
/**
* Mounts the editor's settings modal (the app-wide settings surface) inside the
* portal. The portal deliberately lives outside the editor's AppProviders, so
* this host supplies the contexts the settings tree needs: app config, user
* preferences, the session provider the account sections read (flavor-resolved:
* Spring on self-hosted, Supabase on SaaS — same underlying session the portal
* is already signed in with), and the editor ThemeProvider (which also carries
* the Mantine theme + toasts the sections expect). URL sync is off — the portal
* owns its own route subtree, so the modal keeps its section purely in state.
* this host supplies the contexts the settings tree needs: user preferences,
* the session provider the account sections read (flavor-resolved: Spring on
* self-hosted, Supabase on SaaS — same underlying session the portal is
* already signed in with), and the editor ThemeProvider (which also carries
* the Mantine theme + toasts the sections expect). App config comes from
* PortalChrome's shared provider. URL sync is off — the portal owns its own
* route subtree, so the modal keeps its section purely in state.
*
* Everything (providers included) mounts on first open and stays mounted, so
* the editor theme wiring never runs for portal sessions that never open
* settings.
*/
export function PortalSettingsHost() {
const { settingsOpen, settingsInitialSection, closeSettings } = useUI();
const {
settingsOpen,
settingsInitialSection,
settingsInitialFocus,
closeSettings,
} = useUI();
const { t } = useTranslation();
const [everOpened, setEverOpened] = useState(false);
@@ -74,11 +90,9 @@ export function PortalSettingsHost() {
onClose={closeSettings}
urlSync={false}
initialSection={initialSection}
initialFocus={settingsInitialFocus}
extraSections={extraSections}
// TODO: opening Keyboard Shortcuts in the portal white-screens
// the app (the section expects editor-only context). Hidden here
// as a stopgap; fix the section properly and drop this.
hiddenSectionKeys={["hotkeys"]}
hiddenSectionKeys={PORTAL_HIDDEN_SECTION_KEYS}
/>
</ThemeProvider>
</PreferencesProvider>
@@ -1,70 +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(--c-border-subtle);
color: var(--c-text-subtle);
}
.portal-search__input {
flex: 1 1 auto;
font: inherit;
font-size: 0.9375rem;
background: transparent;
border: none;
outline: none;
color: var(--c-text);
}
.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(--c-surface-sunken);
color: var(--c-text-subtle);
}
.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(--c-text-muted);
font-size: 0.8125rem;
text-align: left;
}
.portal-search__item:hover {
background: var(--c-hover);
}
.portal-search__item-hint {
font-family: var(--font-mono);
font-size: 0.6875rem;
color: var(--c-text-subtle);
}
@@ -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<typeof SearchModal> = {
title: "Portal/Header/SearchModal",
component: SearchModal,
parameters: { layout: "fullscreen" },
decorators: [
(S) => (
<div style={{ minHeight: "100vh", background: "var(--c-bg)" }}>
<ForceOpen />
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof SearchModal>;
export const Default: Story = {};
export const EmptyCatalogue: Story = {
parameters: {
msw: {
handlers: [
http.get("/v1/search/quick-actions", () => HttpResponse.json([])),
],
},
},
};
@@ -1,125 +0,0 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Button, EmptyState, Modal, Skeleton } from "@app/ui";
import { useTranslation } from "react-i18next";
import { useUI } from "@portal/contexts/UIContext";
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
import { fetchQuickActions, type QuickAction } from "@portal/api/search";
import { SearchIcon } from "@portal/components/icons";
import "@portal/components/SearchModal.css";
export function SearchModal() {
const { t } = useTranslation();
const { searchOpen, closeSearch } = useUI();
const inputRef = useRef<HTMLInputElement | null>(null);
const [query, setQuery] = useState("");
const state = useAsync<QuickAction[]>(() => fetchQuickActions(), []);
const { data: actions } = state;
const { isLoading } = useSectionFlags(state);
useEffect(() => {
if (searchOpen) {
setQuery("");
const t = setTimeout(() => inputRef.current?.focus(), 0);
return () => clearTimeout(t);
}
return undefined;
}, [searchOpen]);
const filtered = useMemo(() => {
if (!actions) return [] as QuickAction[];
if (!query.trim()) return actions;
const needle = query.trim().toLowerCase();
return actions.filter((item) => item.label.toLowerCase().includes(needle));
}, [actions, query]);
const groups = useMemo(
() =>
filtered.reduce<Record<string, QuickAction[]>>((acc, item) => {
if (!acc[item.group]) acc[item.group] = [];
acc[item.group].push(item);
return acc;
}, {}),
[filtered],
);
const groupKeys = Object.keys(groups);
const isEmpty = !isLoading && groupKeys.length === 0;
return (
<Modal
open={searchOpen}
onClose={closeSearch}
width="lg"
ariaLabel={t("portal.search.ariaLabel")}
>
<div className="portal-search">
<div className="portal-search__input-row">
<SearchIcon size={16} />
<input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("portal.search.placeholder")}
aria-label={t("portal.search.ariaLabel")}
className="portal-search__input"
autoComplete="off"
spellCheck={false}
/>
<span className="portal-search__esc" aria-hidden>
ESC
</span>
</div>
<div className="portal-search__results">
{isLoading && (
<div className="portal-search__loading">
<Skeleton height="1rem" width="40%" />
<Skeleton height="0.875rem" />
<Skeleton height="0.875rem" width="80%" />
<Skeleton height="0.875rem" width="65%" />
</div>
)}
{isEmpty && (
<EmptyState
size="compact"
title={
query.trim()
? t("portal.search.empty.noMatches", { query: query.trim() })
: t("portal.search.empty.noActionsTitle")
}
description={
query.trim()
? t("portal.search.empty.noMatchesDescription")
: t("portal.search.empty.noActionsDescription")
}
/>
)}
{!isLoading &&
!isEmpty &&
Object.entries(groups).map(([group, items]) => (
<div key={group} className="portal-search__group">
<div className="portal-search__group-label">{group}</div>
{items.map((item) => (
<Button
key={item.label}
variant="tertiary"
justify="start"
fullWidth
className="portal-search__item"
onClick={closeSearch}
>
<span className="portal-search__item-label">
{item.label}
</span>
<span className="portal-search__item-hint">
{item.hint}
</span>
</Button>
))}
</div>
))}
</div>
</div>
</Modal>
);
}
@@ -8,7 +8,8 @@ import type { DocsNavSection } from "@portal/api/docs";
* path ("functionality/security" is a child of "functionality"), so sub-sections
* nest under their parent. The root "Overview" section is static (always open, no
* toggle); every other section collapses, and only the branch leading to the
* active doc opens by default. (Search lives in DocsSearch above this.)
* active doc opens by default. (Full-text docs search lives in the global
* super search.)
*/
// Matches the generator's ROOT_SECTION_ID: the intro section is never collapsible.
@@ -1,137 +0,0 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@app/ui";
import type { SearchResult, Segment } from "@portal/docs/search";
/** Render highlighted segments, wrapping matched runs in <mark>. */
function Highlighted({ segments }: { segments: Segment[] }) {
return (
<>
{segments.map((s, i) =>
s.hit ? (
<mark key={i} className="portal-docs__hl">
{s.text}
</mark>
) : (
<span key={i}>{s.text}</span>
),
)}
</>
);
}
/**
* Docs search box + results. While a query is active it shows a ranked list of
* matching docs — each with its section, a highlighted title, and a content
* snippet — that navigates on click (or Enter). Arrow keys move the selection.
*/
export function DocsSearch({
query,
onQueryChange,
results,
onSelect,
}: {
query: string;
onQueryChange: (q: string) => void;
results: SearchResult[];
onSelect: (docId: string) => void;
}) {
const { t } = useTranslation();
// -1 = nothing pre-selected; arrow keys drive this, the mouse uses CSS :hover.
const [activeIndex, setActiveIndex] = useState(-1);
const listRef = useRef<HTMLUListElement>(null);
const hasQuery = query.trim().length > 0;
useEffect(() => setActiveIndex(-1), [query]);
useEffect(() => {
listRef.current
?.querySelector<HTMLElement>('[data-active="true"]')
?.scrollIntoView?.({ block: "nearest" });
}, [activeIndex]);
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Escape") {
onQueryChange("");
return;
}
if (!results.length) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setActiveIndex((i) => Math.min(i + 1, results.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActiveIndex((i) => Math.max(i - 1, 0));
} else if (e.key === "Enter") {
e.preventDefault();
const hit = results[activeIndex >= 0 ? activeIndex : 0];
if (hit) onSelect(hit.id);
}
};
return (
<div className="portal-docs__search">
<div className="portal-docs__search-box">
<span className="portal-docs__search-icon" aria-hidden>
</span>
<input
type="search"
className="portal-docs__search-input"
placeholder={t("portal.docs.search.placeholder")}
value={query}
onChange={(e) => onQueryChange(e.target.value)}
onKeyDown={onKeyDown}
aria-label={t("portal.docs.search.placeholder")}
/>
</div>
{hasQuery && (
<div className="portal-docs__results">
{results.length === 0 ? (
<p className="portal-docs__nav-empty">
{t("portal.docs.search.empty")}
</p>
) : (
<>
<div className="portal-docs__results-count">
{t("portal.docs.search.results", { count: results.length })}
</div>
<ul ref={listRef} className="portal-docs__results-list">
{results.map((r, i) => (
<li key={r.id}>
<Button
variant="tertiary"
fullWidth
justify="start"
className={
"portal-docs__result" +
(i === activeIndex ? " is-active" : "")
}
data-active={i === activeIndex}
onClick={() => onSelect(r.id)}
>
<span className="portal-docs__result-body">
<span className="portal-docs__result-head">
<span className="portal-docs__result-title">
<Highlighted segments={r.titleSegments} />
</span>
<span className="portal-docs__result-section">
{r.sectionLabel}
</span>
</span>
<span className="portal-docs__result-snippet">
<Highlighted segments={r.snippet} />
</span>
</span>
</Button>
</li>
))}
</ul>
</>
)}
</div>
)}
</div>
);
}
@@ -23,6 +23,7 @@ const FULL_CAPS: UsersCapabilities = {
seats: false,
manageGrants: true,
removeScope: "org",
listingRequiresAdmin: true,
};
/** SaaS team-leader: invite / rename / remove-member only, no org group. */
@@ -44,6 +45,7 @@ const SAAS_CAPS: UsersCapabilities = {
seats: true,
manageGrants: false,
removeScope: "team",
listingRequiresAdmin: false,
};
/** A full org: one org owner and two teams, each with a leader. */
@@ -215,7 +215,8 @@ export function UsersDirectory({
function renderRow(m: Member) {
const access = m.portalAccess ?? "none";
return (
<div className="portal-users__row" key={m.id}>
// data-member-id lets deep links (?member=<id>) scroll to and flash a row.
<div className="portal-users__row" key={m.id} data-member-id={m.id}>
<div className="portal-users__row-main">
<Avatar name={m.name} size="sm" tone={avatarToneForMember(m)} />
<div className="portal-users__row-id">
@@ -7,11 +7,6 @@ import {
} from "react";
interface UIContextValue {
searchOpen: boolean;
openSearch: () => void;
closeSearch: () => void;
toggleSearch: () => void;
/** Off-canvas sidebar drawer on small screens (no-op chrome on desktop). */
mobileNavOpen: boolean;
openMobileNav: () => void;
@@ -32,7 +27,8 @@ interface UIContextValue {
* modal pick its own default. Cleared back to `null` on close.
*/
settingsInitialSection: string | null;
openSettings: (section?: string) => void;
settingsInitialFocus: string | null;
openSettings: (section?: string, focus?: string) => void;
closeSettings: () => void;
/**
@@ -81,7 +77,6 @@ function writeSidebarCollapsed(collapsed: boolean): void {
}
export function UIProvider({ children }: { children: ReactNode }) {
const [searchOpen, setSearchOpen] = useState(false);
const [mobileNavOpen, setMobileNavOpen] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] =
useState(readSidebarCollapsed);
@@ -90,6 +85,9 @@ export function UIProvider({ children }: { children: ReactNode }) {
const [settingsInitialSection, setSettingsInitialSection] = useState<
string | null
>(null);
const [settingsInitialFocus, setSettingsInitialFocus] = useState<
string | null
>(null);
const [linkModalOpen, setLinkModalOpen] = useState(false);
const [trialSetupRequested, setTrialSetupRequested] = useState(false);
const [linkModalMode, setLinkModalMode] = useState<"link" | "reauth">("link");
@@ -101,16 +99,8 @@ export function UIProvider({ children }: { children: ReactNode }) {
const value = useMemo<UIContextValue>(
() => ({
// Opening any overlay (search, settings, link modal) dismisses the mobile
// nav drawer so overlays never stack on top of it.
searchOpen,
openSearch: () => {
setMobileNavOpen(false);
setSearchOpen(true);
},
closeSearch: () => setSearchOpen(false),
toggleSearch: () => setSearchOpen((o) => !o),
// Opening any overlay (settings, link modal) dismisses the mobile nav
// drawer so overlays never stack on top of it.
mobileNavOpen,
openMobileNav: () => setMobileNavOpen(true),
closeMobileNav: () => setMobileNavOpen(false),
@@ -131,14 +121,17 @@ export function UIProvider({ children }: { children: ReactNode }) {
settingsOpen,
settingsInitialSection,
openSettings: (section?: string) => {
settingsInitialFocus,
openSettings: (section?: string, focus?: string) => {
setMobileNavOpen(false);
setSettingsInitialSection(section ?? null);
setSettingsInitialFocus(focus ?? null);
setSettingsOpen(true);
},
closeSettings: () => {
setSettingsOpen(false);
setSettingsInitialSection(null);
setSettingsInitialFocus(null);
},
linkModalOpen,
@@ -152,6 +145,7 @@ export function UIProvider({ children }: { children: ReactNode }) {
setReopenSettingsAfterLink("account-link");
setSettingsOpen(false);
setSettingsInitialSection(null);
setSettingsInitialFocus(null);
}
setLinkModalOpen(true);
},
@@ -166,18 +160,19 @@ export function UIProvider({ children }: { children: ReactNode }) {
setLinkModalMode("link");
if (reopenSettingsAfterLink) {
setSettingsInitialSection(reopenSettingsAfterLink);
setSettingsInitialFocus(null);
setSettingsOpen(true);
setReopenSettingsAfterLink(null);
}
},
}),
[
searchOpen,
mobileNavOpen,
sidebarCollapsed,
assistantOpen,
settingsOpen,
settingsInitialSection,
settingsInitialFocus,
linkModalOpen,
linkModalMode,
reopenSettingsAfterLink,
@@ -0,0 +1,442 @@
import { createElement, type ReactNode } from "react";
import { renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({
t: (
key: string,
fallbackOrOptions?: string | Record<string, unknown>,
) => {
if (key === "portal.policies.defaultName") {
return `${(fallbackOrOptions as Record<string, unknown>)?.category as string} Policy`;
}
if (typeof fallbackOrOptions === "string") return fallbackOrOptions;
const labels: Record<string, string> = {
"portal.nav.users": "Users",
"portal.nav.policies": "Policies",
"portal.nav.pipelines": "Pipelines",
"portal.nav.sources": "Sources",
"portal.nav.editor": "Editor",
"superSearch.group.processor": "Processor",
"superSearch.group.settings": "Settings",
"superSearch.group.tools": "Tools",
"settings.email.smtpHost": "SMTP host",
"settings.email.title": "Email",
};
return labels[key] ?? key;
},
}),
};
});
vi.mock("react-router-dom", () => ({
useNavigate: vi.fn(() => vi.fn()),
}));
vi.mock("@app/contexts/AppConfigContext", () => ({
useAppConfig: vi.fn(() => ({
config: {
isAdmin: false,
enableLogin: true,
},
})),
}));
vi.mock("@app/contexts/ToolRegistryContext", () => ({
useToolRegistry: vi.fn(() => ({
allTools: {},
})),
}));
vi.mock("@portal/contexts/TierContext", () => ({
useTier: vi.fn(() => ({
tier: "pro",
})),
}));
const mockOpenSettings = vi.fn();
vi.mock("@portal/contexts/UIContext", () => ({
useUI: vi.fn(() => ({
openSettings: mockOpenSettings,
})),
}));
vi.mock("@app/data/toolsTaxonomy", () => ({
getToolUrlPath: vi.fn((id: string) => `/tools/${id}`),
isComingSoonTool: vi.fn(() => false),
}));
vi.mock("@app/data/settingsSearchIndex", () => ({
SETTINGS_SEARCH_INDEX: [
{
section: "email",
anchor: "smtp-host",
labelKey: "settings.email.smtpHost",
labelFallback: "SMTP host",
keywords: ["smtp"],
},
],
}));
vi.mock("@app/data/settingsSectionRegistry", () => ({
SETTINGS_SECTION_REGISTRY: [
{
key: "email",
labelKey: "settings.email.title",
labelFallback: "Email",
keywords: ["smtp", "email"],
requiresLogin: true,
},
],
}));
vi.mock("@app/data/settingsContentSearch", () => ({
findSettingsContentMatch: vi.fn(() => null),
buildMatchSnippet: vi.fn(() => ""),
}));
vi.mock("@app/data/processorSearchIndex", () => ({
PROCESSOR_SEARCH_INDEX: [
{
id: "users",
labelKey: "portal.nav.users",
labelFallback: "Users",
path: "/portal/users",
keywords: ["members"],
},
{
id: "policies",
labelKey: "portal.nav.policies",
labelFallback: "Policies",
path: "/portal/policies",
keywords: ["rules"],
},
{
id: "pipelines",
labelKey: "portal.nav.pipelines",
labelFallback: "Pipelines",
path: "/portal/pipelines",
keywords: ["automation"],
},
{
id: "sources",
labelKey: "portal.nav.sources",
labelFallback: "Sources",
path: "/portal/sources",
keywords: ["connectors"],
},
{
id: "docs",
labelKey: "portal.nav.docs",
labelFallback: "Documentation",
path: "/portal/docs",
keywords: ["docs"],
},
],
// Tests run as an org admin; per-scope access gating has its own coverage
// in the stubbed suite.
isPortalEntityScopeAccessible: () => true,
}));
// The roster is fetched through the flavor-resolved usersBackend (the same
// path the shared users query uses), not @portal/api/users directly.
vi.mock("@app/portal/usersBackend", () => ({
usersBackend: {
fetchUsers: vi.fn(),
},
}));
// Keep the real (pure) assemblePolicies; only the network fetchers are mocked.
vi.mock("@portal/api/policies", async (importOriginal) => ({
...(await importOriginal<typeof import("@portal/api/policies")>()),
fetchPoliciesList: vi.fn(),
fetchPolicyRuns: vi.fn(),
}));
vi.mock("@portal/api/pipelines", () => ({
fetchPipelines: vi.fn(),
}));
vi.mock("@portal/api/sources", () => ({
fetchSources: vi.fn(),
}));
import type { CatalogueEntry } from "@portal/api/policies";
import { fetchPoliciesList, fetchPolicyRuns } from "@portal/api/policies";
import type { PipelineView } from "@portal/api/pipelines";
import { fetchPipelines } from "@portal/api/pipelines";
import { fetchSources } from "@portal/api/sources";
import type { Member, UsersResponse } from "@portal/api/users";
import { usersBackend } from "@app/portal/usersBackend";
import {
rankDocsResults,
rankPortalPipelineResults,
rankPortalPolicyResults,
} from "@portal/search/entitySearch";
import { usePortalSearchResults } from "@portal/hooks/usePortalSearchResults";
function makePolicyEntry(overrides?: Partial<CatalogueEntry>): CatalogueEntry {
return {
category: {
id: "security",
label: "Security",
tone: "purple",
desc: "Protect sensitive documents",
},
config: {
summary: "",
rules: [],
scopeLabel: "",
fields: [],
defaultOperations: [],
},
policy: {
category: {
id: "security",
label: "Security",
tone: "purple",
desc: "Protect sensitive documents",
},
config: {
summary: "",
rules: [],
scopeLabel: "",
fields: [],
defaultOperations: [],
},
state: {
configured: true,
status: "active",
sources: [],
scopeTypes: [],
reviewerEmail: "",
fieldValues: {},
backendId: "policy-security",
},
steps: [],
stats: {
enforced: 0,
dataProcessed: "0 B",
activeFor: "0d",
},
activity: [],
},
...overrides,
};
}
function makePipelineView(
id: string,
name: string,
trigger = "manual",
): PipelineView {
return {
id,
name,
enabled: true,
status: "active",
trigger,
sources: [],
steps: [],
output: "inline",
owner: "alice",
};
}
function makeMember(overrides?: Partial<Member>): Member {
return {
id: "member-1",
name: "Alice Admin",
email: "alice@example.com",
role: "admin",
status: "active",
lastActive: "1m ago",
...overrides,
};
}
function makeUsersResponse(members: Member[]): UsersResponse {
return {
summary: {
totalMembers: members.length,
pendingInvites: 0,
seatsUsed: members.length,
seatLimit: null,
},
members,
roles: [],
access: {
tier: "pro",
seatsUsed: members.length,
seatLimit: null,
},
mailEnabled: true,
emailInvitesEnabled: true,
};
}
function createDeferred<T>() {
let resolve: ((value: T) => void) | undefined;
let reject: ((reason?: unknown) => void) | undefined;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return {
promise,
resolve: (value: T) => resolve?.(value),
reject: (reason?: unknown) => reject?.(reason),
};
}
function queryWrapper() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return ({ children }: { children: ReactNode }) =>
createElement(QueryClientProvider, { client }, children);
}
describe("usePortalSearchResults helpers", () => {
beforeEach(() => {
vi.clearAllMocks();
mockOpenSettings.mockReset();
vi.mocked(usersBackend.fetchUsers).mockResolvedValue(makeUsersResponse([]));
vi.mocked(fetchPoliciesList).mockResolvedValue([]);
vi.mocked(fetchPolicyRuns).mockResolvedValue([]);
vi.mocked(fetchPipelines).mockResolvedValue({ kpis: [], pipelines: [] });
vi.mocked(fetchSources).mockResolvedValue({ kpis: [], sources: [] });
});
it("ranks configured policies under the policies group", () => {
const openPolicy = vi.fn();
const results = rankPortalPolicyResults(
[makePolicyEntry()],
"security policy",
(key: string, options?: Record<string, unknown>) =>
key === "portal.policies.defaultName"
? `${options?.category as string} Policy`
: key,
openPolicy,
);
expect(results).toHaveLength(1);
expect(results[0]).toMatchObject({
key: "portal-policy:security",
group: "portal-policies",
title: "Security Policy",
});
void results[0]?.onSelect();
expect(openPolicy).toHaveBeenCalledWith("security");
});
it("filters policy-backed records out of the pipelines group", () => {
const openPipeline = vi.fn();
const results = rankPortalPipelineResults(
[
makePipelineView("policy-security", "Security Policy"),
makePipelineView("custom-pipeline", "Nightly OCR"),
],
"nightly",
new Set(["policy-security"]),
openPipeline,
);
expect(results.map((result) => result.key)).toEqual([
"portal-pipeline:custom-pipeline",
]);
});
it("full-text searches the bundled docs, not just their titles", () => {
const navigate = vi.fn();
// "Tesseract" appears in the OCR doc body but in no doc title — a hit
// whose snippet contains it proves content search.
const results = rankDocsResults("Tesseract", navigate);
expect(results.length).toBeGreaterThan(0);
expect(results[0]?.subtitle).toMatch(/tesseract/i);
void results[0]?.onSelect();
expect(navigate).toHaveBeenCalledWith(expect.stringMatching(/\/docs#./));
});
it("forwards portal settings row hits with their focus anchor", () => {
const { result } = renderHook(
() => usePortalSearchResults("smtp", true, { scopeIds: ["settings"] }),
{ wrapper: queryWrapper() },
);
const settingHit = result.current.flatResults[0];
expect(settingHit?.key).toBe("setting:email:smtp-host");
void settingHit?.onSelect();
expect(mockOpenSettings).toHaveBeenCalledWith("email", "smtp-host");
expect(usersBackend.fetchUsers).not.toHaveBeenCalled();
});
it("fetches only the requested entity scope", async () => {
vi.mocked(usersBackend.fetchUsers).mockResolvedValue(
makeUsersResponse([makeMember({ id: "member-2", name: "Alice" })]),
);
const { result } = renderHook(
() =>
usePortalSearchResults("alice", true, { scopeIds: ["portal-users"] }),
{ wrapper: queryWrapper() },
);
await waitFor(() =>
expect(usersBackend.fetchUsers).toHaveBeenCalledTimes(1),
);
await waitFor(() => expect(result.current.loadingFiles).toBe(false));
expect(fetchPoliciesList).not.toHaveBeenCalled();
expect(fetchPipelines).not.toHaveBeenCalled();
expect(fetchSources).not.toHaveBeenCalled();
expect(result.current.groups.map((group) => group.id)).toEqual([
"portal-users",
]);
});
it("reuses the in-flight query after close/reopen instead of sticking in loading", async () => {
const firstUsers = createDeferred<UsersResponse>();
vi.mocked(usersBackend.fetchUsers).mockImplementationOnce(
() => firstUsers.promise,
);
const { result, rerender } = renderHook(
({ query }) =>
usePortalSearchResults(query, true, { scopeIds: ["portal-users"] }),
{
initialProps: { query: "alice" },
wrapper: queryWrapper(),
},
);
await waitFor(() => expect(result.current.loadingFiles).toBe(true));
expect(usersBackend.fetchUsers).toHaveBeenCalledTimes(1);
rerender({ query: "" });
await waitFor(() => expect(result.current.loadingFiles).toBe(false));
rerender({ query: "alice" });
await waitFor(() => expect(result.current.loadingFiles).toBe(true));
expect(usersBackend.fetchUsers).toHaveBeenCalledTimes(1);
firstUsers.resolve(
makeUsersResponse([
makeMember({ id: "member-3", name: "Alice Reloaded" }),
]),
);
await waitFor(() => expect(result.current.loadingFiles).toBe(false));
expect(result.current.groups.map((group) => group.id)).toEqual([
"portal-users",
]);
});
});
@@ -0,0 +1,321 @@
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { getToolUrlPath } from "@app/data/toolsTaxonomy";
import { isPortalEntityScopeAccessible } from "@app/data/processorSearchIndex";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { PORTAL_HIDDEN_SECTION_KEYS } from "@portal/components/PortalSettingsHost";
import { useToolRegistry } from "@app/contexts/ToolRegistryContext";
import {
assembleSuperSearchGroups,
rankSettingsResults,
rankToolResults,
useSearchScopeFilter,
} from "@app/hooks/useSuperSearch";
import {
PORTAL_ENTITY_SCOPE_DEFS,
PORTAL_DOCS_SCOPE_ID,
type SuperSearchGates,
type SuperSearchGroup,
type SuperSearchGroupId,
type SuperSearchQueryOptions,
type SuperSearchScope,
type UseSuperSearchResult,
} from "@app/types/superSearch";
import type { ToolId } from "@app/types/toolId";
import { assignLocation, openExternalUrl } from "@app/utils/safeNavigation";
import { usersBackend } from "@app/portal/usersBackend";
import {
assemblePolicies,
fetchPoliciesList,
fetchPolicyRuns,
} from "@portal/api/policies";
import { fetchPipelines } from "@portal/api/pipelines";
import { fetchSources } from "@portal/api/sources";
import { EDITOR_IS_SAME_APP, EDITOR_URL } from "@portal/auth/editorUrl";
import { useTier } from "@portal/contexts/TierContext";
import { useUI } from "@portal/contexts/UIContext";
import { qk } from "@portal/queries/keys";
import {
buildProcessorEntityGroups,
defaultPortalEntityScopes,
isDocsSearchable,
isVisiblePortalScope,
withPortalEntityDependencies,
type ProcessorEntities,
} from "@portal/search/entitySearch";
const EDITOR_GROUP_ORDER: SuperSearchGroupId[] = ["tools"];
const SETTINGS_GROUP_ORDER: SuperSearchGroupId[] = ["settings"];
const PROCESSOR_SECTION_LABEL_KEY = "superSearch.group.processor";
const PROCESSOR_SECTION_LABEL_FALLBACK = "Processor";
const SETTINGS_SECTION_LABEL_KEY = "superSearch.group.settings";
const SETTINGS_SECTION_LABEL_FALLBACK = "Settings";
const EDITOR_SECTION_LABEL_KEY = "portal.nav.editor";
const EDITOR_SECTION_LABEL_FALLBACK = "Editor";
/**
* Cross-origin editor URL for a tool. Only used when a separately-hosted
* editor is configured — the same-app case routes client-side instead: on
* bundled deploys the backend serves the frontend and 401s unauthenticated
* document GETs (the JWT lives in localStorage, so a full page load carries
* no credentials), which would bounce every tool hop to /login.
*/
function externalEditorHref(path: string): string {
return EDITOR_URL.replace(/\/$/, "") + path;
}
/**
* The portal bar's filter chips — every lane the editor offers except Files
* (files only open in the editor) and Pages (the sidebar covers navigation).
* Ordered to match the dropdown's section priority. Lanes whose data source
* refuses this session (the users roster for non-admins on self-hosted) get
* no chip — an offered lane must be able to return results.
*/
export function usePortalSearchScopes(): SuperSearchScope[] {
const { t } = useTranslation();
const { config } = useAppConfig();
const isAdmin = config?.isAdmin ?? false;
return useMemo(
() => [
...PORTAL_ENTITY_SCOPE_DEFS.filter(
(def) =>
isVisiblePortalScope(def.id) &&
isPortalEntityScopeAccessible(def.id, isAdmin),
).map((def) => ({
id: def.id,
label: t(def.labelKey, def.labelFallback),
aliases: [...def.aliases],
})),
...(isDocsSearchable()
? [
{
id: PORTAL_DOCS_SCOPE_ID,
label: t("superSearch.group.docs", "Docs"),
aliases: ["doc", "docs", "documentation"],
},
]
: []),
{
id: "settings",
label: t("superSearch.group.settings", "Settings"),
aliases: ["setting", "settings"],
},
{
id: "tools",
label: t("superSearch.group.tools", "Tools"),
aliases: ["tool", "tools"],
},
],
[t, isAdmin],
);
}
/**
* The portal's results provider for the shared super search bar: files stay
* editor-only, portal entity results are grouped under a Processor section,
* and the shared tools/settings lanes sit under an Editor section. Portal page
* routes themselves stay out of the portal search — once you're in the portal,
* the entities are the useful targets.
*/
export function usePortalSearchResults(
query: string,
active: boolean,
options?: SuperSearchQueryOptions,
): UseSuperSearchResult {
const { t } = useTranslation();
const navigate = useNavigate();
const { openSettings } = useUI();
const { allTools } = useToolRegistry();
const { config } = useAppConfig();
const { tier } = useTier();
const trimmed = query.trim();
const { scopeEnabled } = useSearchScopeFilter(options);
const requestedEntityScopes = useMemo(() => {
if (!active || trimmed.length === 0) return new Set<string>();
const enabled = defaultPortalEntityScopes(config?.isAdmin ?? false).filter(
(scopeId) => scopeEnabled(scopeId),
);
return new Set<string>(withPortalEntityDependencies(enabled));
}, [active, scopeEnabled, trimmed, config?.isAdmin]);
// Entity data rides the portal's shared query layer — the same keys the
// views use, so searching warms the view (and vice versa) and the client's
// staleTime/retry policy replaces bespoke fetch discipline. `enabled` keeps
// each lane's fetch behind its scope chip and the active-query gate.
const usersQuery = useQuery({
queryKey: qk.usersRoster(tier),
queryFn: () => usersBackend.fetchUsers(tier),
enabled: requestedEntityScopes.has("portal-users"),
});
const policiesListQuery = useQuery({
queryKey: qk.policiesList(),
queryFn: fetchPoliciesList,
enabled: requestedEntityScopes.has("portal-policies"),
});
const policyRunsQuery = useQuery({
queryKey: qk.policyRuns(),
queryFn: fetchPolicyRuns,
enabled: requestedEntityScopes.has("portal-policies"),
});
const pipelinesQuery = useQuery({
queryKey: qk.pipelines(),
queryFn: fetchPipelines,
enabled: requestedEntityScopes.has("portal-pipelines"),
});
const sourcesQuery = useQuery({
queryKey: qk.sources(),
queryFn: fetchSources,
enabled: requestedEntityScopes.has("portal-sources"),
});
// Loading only counts for lanes the current search actually requests — a
// fetch left in flight after its lane was deselected (or the bar closed)
// must not hold the dropdown's no-results gate open.
const loadingEntities =
(requestedEntityScopes.has("portal-users") && usersQuery.isLoading) ||
(requestedEntityScopes.has("portal-policies") &&
(policiesListQuery.isLoading || policyRunsQuery.isLoading)) ||
(requestedEntityScopes.has("portal-pipelines") &&
pipelinesQuery.isLoading) ||
(requestedEntityScopes.has("portal-sources") && sourcesQuery.isLoading);
const entities = useMemo<ProcessorEntities>(
() => ({
users: usersQuery.data?.members ?? [],
policies: policiesListQuery.data
? assemblePolicies(policiesListQuery.data, policyRunsQuery.data ?? [])
.catalogue
: [],
pipelines: pipelinesQuery.data?.pipelines ?? [],
sources: sourcesQuery.data?.sources ?? [],
}),
[
usersQuery.data,
policiesListQuery.data,
policyRunsQuery.data,
pipelinesQuery.data,
sourcesQuery.data,
],
);
const openTool = useCallback(
(id: ToolId) => {
// Link tools have no in-editor UI — navigating to a tool URL for one
// lands on a "tool not found" panel. Open their destination directly,
// matching how the editor's tool lists treat them.
const tool = allTools[id];
if (tool?.link) {
openExternalUrl(tool.link);
return;
}
const path = getToolUrlPath(id);
if (EDITOR_IS_SAME_APP) {
// One SPA: swap route-sets through the router. The portal tree
// unmounts and the editor mounts fresh at the tool URL, so its
// URL-driven tool init runs exactly as it does on a cold load.
navigate(path);
} else {
assignLocation(externalEditorHref(path));
}
},
[allTools, navigate],
);
const openSettingsSection = useCallback(
(section: string, anchor?: string) => openSettings(section, anchor),
[openSettings],
);
const gates = useMemo<SuperSearchGates | null>(
() =>
config
? {
isAdmin: config.isAdmin ?? false,
loginEnabled: config.enableLogin ?? false,
showSettingsWhenNoLogin: config.showSettingsWhenNoLogin ?? true,
}
: null,
[config],
);
const entityGroups = useMemo<SuperSearchGroup[]>(
() =>
buildProcessorEntityGroups(entities, trimmed, t, navigate, {
scopeEnabled,
}),
[entities, trimmed, t, navigate, scopeEnabled],
);
const groups = useMemo(() => {
// Section order: Processor first, Settings second, Editor last.
const settingsGroups = assembleSuperSearchGroups(
{
settings: scopeEnabled("settings")
? rankSettingsResults(
trimmed,
t,
gates,
openSettingsSection,
undefined,
PORTAL_HIDDEN_SECTION_KEYS,
)
: [],
},
t,
SETTINGS_GROUP_ORDER,
).map((group) => ({
...group,
sectionLabel: t(
SETTINGS_SECTION_LABEL_KEY,
SETTINGS_SECTION_LABEL_FALLBACK,
),
}));
const editorGroups = assembleSuperSearchGroups(
{
tools: scopeEnabled("tools")
? rankToolResults(allTools, trimmed, openTool)
: [],
},
t,
EDITOR_GROUP_ORDER,
).map((group) => ({
...group,
sectionLabel: t(EDITOR_SECTION_LABEL_KEY, EDITOR_SECTION_LABEL_FALLBACK),
}));
return [
...entityGroups.map((group) => ({
...group,
sectionLabel: t(
PROCESSOR_SECTION_LABEL_KEY,
PROCESSOR_SECTION_LABEL_FALLBACK,
),
})),
...settingsGroups,
...editorGroups,
];
}, [
entityGroups,
gates,
openSettingsSection,
openTool,
scopeEnabled,
allTools,
t,
trimmed,
]);
const flatResults = useMemo(
() => groups.flatMap((group) => group.results),
[groups],
);
// loadingFiles doubles as "an async source is still loading" for the
// dropdown's no-results gate — here that's the entity fetch.
return { groups, flatResults, loadingFiles: loadingEntities };
}
@@ -1,7 +1,6 @@
import { assistantHandlers } from "@portal/mocks/handlers/assistant";
import { authHandlers } from "@portal/mocks/handlers/auth";
import { notificationsHandlers } from "@portal/mocks/handlers/notifications";
import { searchHandlers } from "@portal/mocks/handlers/search";
import { pipelinesHandlers } from "@portal/mocks/handlers/pipelines";
import { sourcesHandlers } from "@portal/mocks/handlers/sources";
import { infrastructureHandlers } from "@portal/mocks/handlers/infrastructure";
@@ -21,7 +20,6 @@ export const handlers = [
...authHandlers,
...notificationsHandlers,
...assistantHandlers,
...searchHandlers,
...pipelinesHandlers,
...sourcesHandlers,
...infrastructureHandlers,
@@ -1,8 +0,0 @@
import { http, HttpResponse } from "msw";
import { QUICK_ACTIONS } from "@portal/mocks/search";
export const searchHandlers = [
http.get("/v1/search/quick-actions", () => {
return HttpResponse.json(QUICK_ACTIONS);
}),
];
@@ -1,17 +0,0 @@
/**
* Mock quick-action catalogue for the ⌘K search palette. The QuickAction type
* lives in api/search.ts (the backend contract); this module only builds fake
* data for Storybook and tests.
*/
import type { QuickAction } from "@portal/api/search";
export const QUICK_ACTIONS: QuickAction[] = [
{ group: "Jump to", label: "Home", hint: "G H" },
{ group: "Jump to", label: "Pipelines", hint: "G P" },
{ group: "Jump to", label: "Sources", hint: "G S" },
{ group: "Jump to", label: "Documents", hint: "G D" },
{ group: "Create", label: "New pipeline", hint: "N P" },
{ group: "Create", label: "New API key", hint: "N K" },
{ group: "Theme", label: "Toggle dark / light", hint: "T" },
];
@@ -0,0 +1,393 @@
import {
PROCESSOR_SEARCH_INDEX,
isPortalEntityScopeAccessible,
} from "@app/data/processorSearchIndex";
import {
PORTAL_ENTITY_SCOPE_DEFS,
PORTAL_DOCS_SCOPE_ID,
type SuperSearchGroup,
type SuperSearchResult,
} from "@app/types/superSearch";
import { rankByFuzzy } from "@app/utils/fuzzySearch";
import {
assemblePolicies,
fetchPoliciesList,
fetchPolicyRuns,
type CatalogueEntry,
} from "@portal/api/policies";
import { fetchPipelines, type PipelineView } from "@portal/api/pipelines";
import { fetchSources, type SourceView } from "@portal/api/sources";
import type { Member } from "@portal/api/users";
// Flavor-resolved users backend: self-hosted reads the proprietary admin
// endpoints, SaaS the invitation-based team endpoints (the admin ones 403
// there for the always-ROLE_USER sessions).
import { usersBackend } from "@app/portal/usersBackend";
import {
DocsIcon,
PipelinesIcon,
PoliciesIcon,
SourcesIcon,
UsersIcon,
} from "@portal/components/icons";
import type { Tier } from "@portal/contexts/TierContext";
import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext";
import { allDocs, loadDocsNav } from "@portal/docs/manifest/registry";
import { searchDocs, toPlainText, type SearchDoc } from "@portal/docs/search";
/**
* The Processor's entity search: users, policies, pipelines and sources,
* fetched per scope and fuzzy-ranked client-side. Shared by both super search
* hosts — the portal bar imports it statically, the editor bar loads it on
* demand through the processorEntitySearch seam (a static import there would
* pull the portal into the main bundle).
*/
export const PORTAL_ENTITY_SCOPE_IDS = PORTAL_ENTITY_SCOPE_DEFS.map(
(def) => def.id,
);
export type PortalEntityScopeId =
(typeof PORTAL_ENTITY_SCOPE_DEFS)[number]["id"];
export type PortalEntityItems =
| Member[]
| CatalogueEntry[]
| PipelineView[]
| SourceView[];
export interface ProcessorEntities {
users: Member[];
policies: CatalogueEntry[];
pipelines: PipelineView[];
sources: SourceView[];
}
/**
* How many results each entity ranker computes — the ceiling the dropdown's
* "show more" can reveal (the component shows a small initial slice).
*/
export const ENTITY_GROUP_LIMIT = 24;
/** How long a fetched entity scope stays fresh before a search refetches it. */
export const ENTITY_REFRESH_MS = 30_000;
const PORTAL_VIEW_BY_SCOPE_ID = Object.fromEntries(
PORTAL_ENTITY_SCOPE_DEFS.map((def) => [def.id, def.viewId]),
) as Record<PortalEntityScopeId, string>;
const VISIBLE_PORTAL_VIEW_IDS = new Set(
PROCESSOR_SEARCH_INDEX.map((entry) => entry.id),
);
/** Whether the flavor's portal nav ships the view an entity scope targets. */
export function isVisiblePortalScope(scopeId: PortalEntityScopeId): boolean {
return VISIBLE_PORTAL_VIEW_IDS.has(PORTAL_VIEW_BY_SCOPE_ID[scopeId]);
}
/** Whether this build ships the in-app developer docs (so they're searchable). */
export function isDocsSearchable(): boolean {
return VISIBLE_PORTAL_VIEW_IDS.has("docs");
}
// The docs manifest is static (bundled JSON), so the full-text index — the
// plaintext strip over every doc — is built once and reused across queries.
let docsSearchIndex: SearchDoc[] | null = null;
function getDocsSearchIndex(): SearchDoc[] {
if (!docsSearchIndex) {
const sectionLabels = new Map(loadDocsNav().map((s) => [s.id, s.label]));
docsSearchIndex = allDocs().map((doc) => ({
id: doc.id,
title: doc.title,
sectionLabel: sectionLabels.get(doc.section) ?? "",
text: toPlainText(doc.markdown),
}));
}
return docsSearchIndex;
}
export function rankDocsResults(
trimmed: string,
navigate: (path: string) => void,
limit = ENTITY_GROUP_LIMIT,
): SuperSearchResult[] {
if (!isDocsSearchable()) return [];
return searchDocs(getDocsSearchIndex(), trimmed, limit).map((result) => ({
key: `portal-doc:${result.id}`,
group: PORTAL_DOCS_SCOPE_ID,
title: result.title,
// The matched-content snippet (full text is what makes docs worth
// searching), falling back to the doc's section when the hit is title-only.
subtitle:
result.snippet
.map((seg) => seg.text)
.join("")
.trim() || result.sectionLabel,
icon: <DocsIcon />,
score: result.score,
onSelect: () => navigate(`${toPortalPath(VIEW_PATHS.docs)}#${result.id}`),
}));
}
export function withPortalEntityDependencies(
scopes: readonly PortalEntityScopeId[],
): readonly PortalEntityScopeId[] {
// Pipeline rows must exclude policy-backed records, so they depend on the
// policy catalogue even when the user only scoped into pipelines.
if (
!scopes.includes("portal-pipelines") ||
scopes.includes("portal-policies")
) {
return scopes;
}
return [...scopes, "portal-policies"];
}
/** Every entity scope the flavor ships AND the session can actually query
* (see isPortalEntityScopeAccessible), dependencies included — the request
* set for an unscoped search. */
export function defaultPortalEntityScopes(
isAdmin: boolean,
): readonly PortalEntityScopeId[] {
return withPortalEntityDependencies(
PORTAL_ENTITY_SCOPE_IDS.filter(
(scopeId) =>
isVisiblePortalScope(scopeId) &&
isPortalEntityScopeAccessible(scopeId, isAdmin),
),
);
}
/** One entity scope's fetch, for the editor seam (which has no QueryClient —
* the portal bar reads the shared query layer instead). `tier` shapes only
* presentational fields on the users payload, never the lists — hosts without
* a TierContext pass "free". */
export async function fetchPortalEntityScope(
scopeId: PortalEntityScopeId,
tier: Tier,
): Promise<PortalEntityItems> {
switch (scopeId) {
case "portal-users":
return (await usersBackend.fetchUsers(tier)).members;
case "portal-policies": {
const [list, runs] = await Promise.all([
fetchPoliciesList(),
fetchPolicyRuns(),
]);
return assemblePolicies(list, runs).catalogue;
}
case "portal-pipelines":
return (await fetchPipelines()).pipelines;
case "portal-sources":
return (await fetchSources()).sources;
}
}
/** Assembles per-scope cache values into the typed entity sets. The casts are
* sound because fetchPortalEntityScope keys each item type to its scope. */
export function toProcessorEntities(
values: Partial<Record<PortalEntityScopeId, PortalEntityItems>>,
): ProcessorEntities {
return {
users: (values["portal-users"] as Member[] | undefined) ?? [],
policies: (values["portal-policies"] as CatalogueEntry[] | undefined) ?? [],
pipelines: (values["portal-pipelines"] as PipelineView[] | undefined) ?? [],
sources: (values["portal-sources"] as SourceView[] | undefined) ?? [],
};
}
type Translate = (key: string, options?: Record<string, unknown>) => string;
function policyResultTitle(entry: CatalogueEntry, t: Translate) {
const category = t(entry.category.label);
return entry.policy
? t("portal.policies.defaultName", { category })
: category;
}
export function rankPortalPolicyResults(
entries: CatalogueEntry[],
trimmed: string,
t: Translate,
openPolicy: (categoryId: string) => void,
limit = ENTITY_GROUP_LIMIT,
): SuperSearchResult[] {
return rankByFuzzy(
entries.filter((entry) => !entry.category.comingSoon),
trimmed,
[
(entry) => policyResultTitle(entry, t),
(entry) => t(entry.category.label),
(entry) => t(entry.category.desc),
],
)
.slice(0, limit)
.map(({ item, score }) => ({
key: `portal-policy:${item.category.id}`,
group: "portal-policies",
title: policyResultTitle(item, t),
subtitle: t(item.category.desc),
icon: <PoliciesIcon />,
score,
onSelect: () => openPolicy(item.category.id),
}));
}
export function rankPortalPipelineResults(
entries: PipelineView[],
trimmed: string,
excludedIds: ReadonlySet<string>,
openPipeline: (pipelineId: string) => void,
limit = ENTITY_GROUP_LIMIT,
): SuperSearchResult[] {
return rankByFuzzy(
entries.filter((entry) => !excludedIds.has(entry.id)),
trimmed,
[(entry) => entry.name, (entry) => entry.trigger],
)
.slice(0, limit)
.map(({ item, score }) => ({
key: `portal-pipeline:${item.id}`,
group: "portal-pipelines",
title: item.name,
subtitle: item.trigger,
icon: <PipelinesIcon />,
score,
onSelect: () => openPipeline(item.id),
}));
}
export interface BuildEntityGroupsOptions {
/** Host scope filter; defaults to every scope enabled. */
scopeEnabled?: (scopeId: string) => boolean;
}
/**
* Ranks the entity sets into display groups. Selects navigate to the entity's
* portal route (deep links where the views support them) — the portal is a
* route-set of the same SPA, so this works from either app.
*/
export function buildProcessorEntityGroups(
entities: ProcessorEntities,
trimmed: string,
t: Translate,
navigate: (path: string) => void,
options: BuildEntityGroupsOptions = {},
): SuperSearchGroup[] {
if (!trimmed) return [];
const scopeEnabled = options.scopeEnabled ?? (() => true);
const groups: SuperSearchGroup[] = [];
const includeScope = (scopeId: PortalEntityScopeId) =>
isVisiblePortalScope(scopeId) && scopeEnabled(scopeId);
const users = includeScope("portal-users")
? rankByFuzzy(entities.users, trimmed, [
(member) => member.name,
(member) => member.email,
])
.slice(0, ENTITY_GROUP_LIMIT)
.map(({ item, score }) => ({
key: `portal-user:${item.id}`,
group: "portal-users",
title: item.name,
subtitle: item.email,
icon: <UsersIcon />,
score,
onSelect: () =>
navigate(
`${toPortalPath(VIEW_PATHS.users)}?member=${encodeURIComponent(item.id)}`,
),
}))
: [];
if (users.length > 0) {
groups.push({
id: "portal-users",
label: t("portal.nav.users"),
results: users,
});
}
const policies = includeScope("portal-policies")
? rankPortalPolicyResults(
entities.policies,
trimmed,
t,
(categoryId) =>
navigate(
`${toPortalPath(VIEW_PATHS.policies)}?category=${encodeURIComponent(categoryId)}`,
),
ENTITY_GROUP_LIMIT,
)
: [];
if (policies.length > 0) {
groups.push({
id: "portal-policies",
label: t("portal.nav.policies"),
results: policies,
});
}
// Policy-backed pipelines already surface as policies; listing them twice
// under different names would read as duplicates.
const policyPipelineIds = new Set(
entities.policies.flatMap((entry) =>
entry.policy?.state.backendId ? [entry.policy.state.backendId] : [],
),
);
const pipelines = includeScope("portal-pipelines")
? rankPortalPipelineResults(
entities.pipelines,
trimmed,
policyPipelineIds,
(pipelineId) =>
navigate(`${toPortalPath(VIEW_PATHS.pipelines)}/${pipelineId}`),
ENTITY_GROUP_LIMIT,
)
: [];
if (pipelines.length > 0) {
groups.push({
id: "portal-pipelines",
label: t("portal.nav.pipelines"),
results: pipelines,
});
}
const sources = includeScope("portal-sources")
? rankByFuzzy(entities.sources, trimmed, [
(source) => source.name,
(source) => source.type,
])
.slice(0, ENTITY_GROUP_LIMIT)
.map(({ item, score }) => ({
key: `portal-source:${item.id}`,
group: "portal-sources",
title: item.name,
subtitle: item.type,
icon: <SourcesIcon />,
score,
onSelect: () =>
navigate(`${toPortalPath(VIEW_PATHS.sources)}/${item.id}`),
}))
: [];
if (sources.length > 0) {
groups.push({
id: "portal-sources",
label: t("portal.nav.sources"),
results: sources,
});
}
const docs =
isDocsSearchable() && scopeEnabled(PORTAL_DOCS_SCOPE_ID)
? rankDocsResults(trimmed, navigate, ENTITY_GROUP_LIMIT)
: [];
if (docs.length > 0) {
groups.push({
id: PORTAL_DOCS_SCOPE_ID,
label: t("superSearch.group.docs"),
results: docs,
});
}
return groups;
}
@@ -45,137 +45,6 @@
gap: 0.25rem;
}
/* Search */
.portal-docs__search {
margin-bottom: 0.75rem;
padding: 0 0.75rem;
}
.portal-docs__search-box {
position: relative;
}
.portal-docs__search-icon {
position: absolute;
left: 0.625rem;
top: 50%;
transform: translateY(-50%);
font-size: 0.9375rem;
color: var(--c-text-subtle);
pointer-events: none;
}
.portal-docs__search-input {
width: 100%;
padding: 0.4rem 0.6rem 0.4rem 1.9rem;
font-size: 0.8125rem;
color: var(--c-text);
background: var(--color-bg-subtle);
border: 1px solid var(--c-border-subtle);
border-radius: var(--radius-md);
outline: none;
}
.portal-docs__search-input:focus {
border-color: var(--c-primary);
}
.portal-docs__nav-empty {
font-size: 0.8125rem;
color: var(--c-text-subtle);
padding: 0.5rem 0.75rem;
margin: 0;
}
/* ── Search results ────────────────────────────────────────────────────── */
.portal-docs__results {
margin-top: 0.5rem;
}
.portal-docs__results-count {
font-size: 0.6875rem;
font-weight: 500;
color: var(--c-text-subtle);
padding: 0 0.75rem 0.5rem;
}
.portal-docs__results-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
}
/* Hairline divider between results for clear, calm separation. */
.portal-docs__results-list li + li {
border-top: 1px solid var(--c-border-subtle);
}
.portal-docs__result {
height: auto;
padding: 0.5rem 0.75rem;
border-radius: 0;
}
.portal-docs__result:hover,
.portal-docs__result.is-active {
background: var(--c-hover);
}
.portal-docs__result-body {
display: flex;
flex-direction: column;
gap: 0.125rem;
width: 100%;
min-width: 0;
text-align: left;
white-space: normal;
}
/* Title + section share one line; the title truncates before the section. */
.portal-docs__result-head {
display: flex;
align-items: baseline;
gap: 0.4rem;
min-width: 0;
}
.portal-docs__result-title {
font-size: 0.8125rem;
font-weight: 600;
color: var(--c-text);
line-height: 1.3;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.portal-docs__result-section {
flex-shrink: 0;
font-size: 0.6875rem;
color: var(--c-text-subtle);
}
.portal-docs__result-snippet {
font-size: 0.75rem;
line-height: 1.4;
color: var(--c-text-subtle);
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Subtle match emphasis — coloured text, not a filled block. */
.portal-docs__hl {
color: var(--c-accent-text);
font-weight: 600;
background: none;
}
.portal-docs__nav-group {
display: flex;
flex-direction: column;
@@ -25,7 +25,6 @@ const renderDocs = (ui: ReactElement) =>
describe("DeveloperDocs — markdown browser over the generated manifest", () => {
it("keeps Overview static (open, no toggle) and other sections collapsed", () => {
renderDocs(<DeveloperDocs />);
expect(screen.getByRole("searchbox")).toBeInTheDocument();
// Overview is static: its items show, and it has no toggle button.
expect(
screen.getByRole("button", { name: "Production Deployment Guide" }),
@@ -50,23 +49,6 @@ describe("DeveloperDocs — markdown browser over the generated manifest", () =>
).toBeInTheDocument();
});
it("searches doc content (not just titles), shows a snippet, and navigates", async () => {
renderDocs(<DeveloperDocs />);
// "Tesseract" appears in the OCR doc body but in no doc title — a result
// whose snippet contains it proves full-text (content) search.
fireEvent.change(screen.getByRole("searchbox"), {
target: { value: "Tesseract" },
});
const hits = await screen.findAllByRole("button", { name: /Tesseract/i });
expect(hits.length).toBeGreaterThan(0);
fireEvent.click(hits[0]);
await waitFor(() =>
expect(
screen.queryByText(/locally hosted web application/i),
).not.toBeInTheDocument(),
);
});
it("follows an internal doc: link inside the rendered markdown", async () => {
renderDocs(<DeveloperDocs />);
// The Getting Started body links to the Migration guide via the doc: scheme.
@@ -3,25 +3,22 @@ import { useTranslation } from "react-i18next";
import { useLocation, useNavigate } from "react-router-dom";
import { Button, EmptyState } from "@app/ui";
import { DocsNav } from "@portal/components/docs/DocsNav";
import { DocsSearch } from "@portal/components/docs/DocsSearch";
import { DocsSection } from "@portal/components/docs/DocsSection";
import { DocsToc } from "@portal/components/docs/DocsToc";
import { MarkdownDoc } from "@portal/components/docs/MarkdownDoc";
import { extractHeadings } from "@portal/docs/headings";
import {
allDocs,
firstDocId,
loadDoc,
loadDocsNav,
} from "@portal/docs/manifest/registry";
import { searchDocs, toPlainText, type SearchDoc } from "@portal/docs/search";
import "@portal/views/DeveloperDocs.css";
/**
* Developer Docs — a markdown browser over the docs manifest generated from the
* Stirling docs repo (see scripts/sync-portal-docs.mts). The nav is auto-sorted
* from the repo's folders + frontmatter; content is the repo markdown, and the
* search box does full-text search across every doc.
* from the repo's folders + frontmatter; content is the repo markdown. Full-text
* search across docs lives in the global super search (Cmd/Ctrl+K).
*/
export function DeveloperDocs() {
const { t } = useTranslation();
@@ -29,24 +26,10 @@ export function DeveloperDocs() {
const navigate = useNavigate();
const contentRef = useRef<HTMLElement>(null);
const [navOpen, setNavOpen] = useState(false);
const [query, setQuery] = useState("");
const nav = useMemo(() => loadDocsNav(), []);
const fallback = useMemo(() => firstDocId(), []);
// Full-text index over every doc's plaintext body (built once).
const index = useMemo<SearchDoc[]>(() => {
const labels = new Map(nav.map((s) => [s.id, s.label]));
return allDocs().map((d) => ({
id: d.id,
title: d.title,
sectionLabel: labels.get(d.section) ?? "",
text: toPlainText(d.markdown),
}));
}, [nav]);
const results = useMemo(() => searchDocs(index, query), [index, query]);
const searching = query.trim().length > 0;
// Deep-link support: the active doc id lives in the URL hash.
const hashId = decodeURIComponent(hash.replace(/^#/, ""));
const activeId = hashId && loadDoc(hashId) ? hashId : fallback;
@@ -61,12 +44,11 @@ export function DeveloperDocs() {
[doc],
);
// Navigating closes the mobile drawer, clears the search, and resets the pane.
// Navigating closes the mobile drawer and resets the pane.
const onSelect = useCallback(
(id: string) => {
navigate({ hash: id });
setNavOpen(false);
setQuery("");
},
[navigate],
);
@@ -100,19 +82,11 @@ export function DeveloperDocs() {
{t("portal.docs.browse")}
</Button>
{/* Layout columns, not landmarks: the search and the two <nav>s inside them
already carry their own named landmarks, and a pair of unlabelled
complementary regions is indistinguishable to assistive tech. */}
{/* Layout column, not a landmark: the <nav> inside already carries its
own named landmark, and an unlabelled complementary region would be
indistinguishable to assistive tech. */}
<div className={"portal-docs__sidebar" + (navOpen ? " is-open" : "")}>
<DocsSearch
query={query}
onQueryChange={setQuery}
results={results}
onSelect={onSelect}
/>
{!searching && (
<DocsNav sections={nav} active={activeId ?? ""} onSelect={onSelect} />
)}
<DocsNav sections={nav} active={activeId ?? ""} onSelect={onSelect} />
</div>
<main className="portal-docs__content" ref={contentRef}>
+52 -24
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
@@ -75,30 +75,58 @@ export function Policies() {
// is configured so a fresh workspace doesn't show a row of zeros.
const hasPolicies = !!data && data.summary.active + data.summary.paused > 0;
const displayCatalogue: CatalogueEntry[] =
catalogue.length > 0
? catalogue
: POLICY_CATEGORIES.map((cat) => ({
category: cat,
config: POLICY_CONFIG[cat.id] ?? {
summary: "",
rules: [],
scopeLabel: "",
fields: [],
defaultOperations: [],
},
policy: null,
}));
const displayCatalogue: CatalogueEntry[] = useMemo(
() =>
catalogue.length > 0
? catalogue
: POLICY_CATEGORIES.map((cat) => ({
category: cat,
config: POLICY_CONFIG[cat.id] ?? {
summary: "",
rules: [],
scopeLabel: "",
fields: [],
defaultOperations: [],
},
policy: null,
})),
[catalogue],
);
function openEntry(entry: CatalogueEntry) {
// Block setup of an AI-required policy until the engine is confirmed on (so a
// click during the app-config load can't open a wizard for a disabled
// feature); a configured policy stays openable so it can be paused/deleted.
if (entry.category.requiresAiEngine && !aiEngineEnabled && !entry.policy)
return;
if (entry.policy) setDetail(entry);
else setWizard(entry);
}
const openEntry = useCallback(
(entry: CatalogueEntry) => {
// Block setup of an AI-required policy until the engine is confirmed on (so a
// click during the app-config load can't open a wizard for a disabled
// feature); a configured policy stays openable so it can be paused/deleted.
if (entry.category.requiresAiEngine && !aiEngineEnabled && !entry.policy)
return;
if (entry.policy) setDetail(entry);
else setWizard(entry);
},
[aiEngineEnabled],
);
// Open a category passed as ?category=<id> (deep link from the super
// search), then strip the param so back/reload doesn't re-open it. Waits for
// the AI-engine flag too: openEntry refuses AI-gated categories until the
// flag is confirmed, and stripping the param before that decision would
// drop the deep link silently.
useEffect(() => {
const categoryId = searchParams.get("category");
if (categoryId === null || loading || aiEngineLoading) return;
const entry = displayCatalogue.find((e) => e.category.id === categoryId);
if (entry) openEntry(entry);
const next = new URLSearchParams(searchParams);
next.delete("category");
setSearchParams(next, { replace: true });
}, [
searchParams,
setSearchParams,
loading,
aiEngineLoading,
displayCatalogue,
openEntry,
]);
async function handleSubmit(
entry: CatalogueEntry,
@@ -355,6 +355,18 @@
.portal-users__row + .portal-users__row {
border-top: 1px solid var(--c-border-subtle);
}
/* Deep-link landing flash (?member=<id>). */
.portal-users__row--flash {
animation: portal-users-row-flash 1.6s ease-out;
}
@keyframes portal-users-row-flash {
from {
background: var(--c-hover);
}
to {
background: transparent;
}
}
.portal-users__row-main {
display: flex;
align-items: center;
@@ -70,6 +70,27 @@ export function Users() {
setSearchParams(next, { replace: true });
}, [searchParams, setSearchParams]);
// Scroll to and flash the row for ?member=<id> (deep link from the super
// search), once the roster has rendered; then strip the param.
useEffect(() => {
const memberId = searchParams.get("member");
if (memberId === null || usersState.loading) return;
const row = document.querySelector(
`[data-member-id="${CSS.escape(memberId)}"]`,
);
if (row) {
row.scrollIntoView({ block: "center" });
row.classList.add("portal-users__row--flash");
window.setTimeout(
() => row.classList.remove("portal-users__row--flash"),
1600,
);
}
const next = new URLSearchParams(searchParams);
next.delete("member");
setSearchParams(next, { replace: true });
}, [searchParams, setSearchParams, usersState.loading]);
// PORTAL grants held by a whole team (principalId = teamId); members inherit these.
const grantByTeam = useMemo(() => {
const m = new Map<number, ResourceGrant>();
@@ -0,0 +1,97 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { TFunction } from "i18next";
import { useScopedFetchCache } from "@app/hooks/useScopedFetchCache";
import type { SuperSearchGroup } from "@app/types/superSearch";
import type {
PortalEntityItems,
PortalEntityScopeId,
} from "@portal/search/entitySearch";
type EntitySearchModule = typeof import("@portal/search/entitySearch");
// Mirrors the admin-route seam's gate: the portal route-set is only mounted in
// dev and in builds made with VITE_INCLUDE_PORTAL=true, so the search must not
// fetch or offer entities that have nowhere to open.
const includePortal =
import.meta.env.VITE_INCLUDE_PORTAL === "true" || import.meta.env.DEV;
const NO_GROUPS: SuperSearchGroup[] = [];
const NO_SCOPES: readonly PortalEntityScopeId[] = [];
/**
* Processor entity results for the editor's super search. The portal's
* entity-search module is imported on demand (first search keystroke) — a
* static value import here would pull the portal into the main bundle, the
* same constraint the static page index (processorSearchIndex) lives under.
* Fetch discipline (TTL, in-flight dedupe, generation guard) comes from
* useScopedFetchCache; the scope list is dynamic and the fetcher lives in the
* lazily loaded module, which useQuery's static-key shape handles awkwardly.
*
* `tier` shapes only presentational fields on the users payload, never the
* lists (see fetchPortalEntityScope), so the editor passes "free" rather than
* mounting the portal's TierContext.
*/
export function useProcessorEntityGroups(
trimmed: string,
enabled: boolean,
t: TFunction,
navigate: (path: string) => void,
scopeEnabled: (scopeId: string) => boolean = () => true,
isAdmin = false,
): SuperSearchGroup[] {
const [mod, setMod] = useState<EntitySearchModule | null>(null);
const modRef = useRef<EntitySearchModule | null>(null);
const active = enabled && includePortal;
const hasQuery = trimmed.length > 0;
useEffect(() => {
if (!active || modRef.current) return;
let cancelled = false;
void import("@portal/search/entitySearch").then((loaded) => {
if (cancelled) return;
modRef.current = loaded;
setMod(loaded);
});
return () => {
cancelled = true;
};
}, [active]);
const requestedScopes = useMemo<readonly PortalEntityScopeId[]>(() => {
if (!active || !hasQuery || !mod) return NO_SCOPES;
return mod.withPortalEntityDependencies(
mod
.defaultPortalEntityScopes(isAdmin)
.filter((scopeId) => scopeEnabled(scopeId)),
);
}, [active, hasQuery, mod, scopeEnabled, isAdmin]);
const fetchScope = useCallback(
async (scopeId: PortalEntityScopeId): Promise<PortalEntityItems> => {
const loaded =
modRef.current ?? (await import("@portal/search/entitySearch"));
return loaded.fetchPortalEntityScope(scopeId, "free");
},
[],
);
const { values } = useScopedFetchCache(
requestedScopes,
fetchScope,
// The TTL lives in the (lazily loaded) module; until it arrives no scope
// is requested, so the placeholder never gates a real fetch.
mod?.ENTITY_REFRESH_MS ?? Number.MAX_SAFE_INTEGER,
);
const entities = useMemo(
() => (mod ? mod.toProcessorEntities(values) : null),
[mod, values],
);
return useMemo(() => {
if (!mod || !entities || !active || !hasQuery) return NO_GROUPS;
return mod.buildProcessorEntityGroups(entities, trimmed, t, navigate, {
scopeEnabled,
});
}, [mod, entities, active, hasQuery, trimmed, t, navigate, scopeEnabled]);
}
@@ -0,0 +1,111 @@
import { PORTAL_BASENAME } from "@app/routes/portalBasename";
// A static leaf module (its portal import is type-only), so it doesn't pull
// the lazy portal chunk into the main bundle the way @portal/* values would.
import { usersCapabilities } from "@app/portal/usersCapabilities";
import type { ProcessorSearchEntry } from "@core/data/processorSearchIndex";
export type { ProcessorSearchEntry };
// Mirrors the admin-route seam's gate: the portal route-set is only mounted in
// dev and in builds made with VITE_INCLUDE_PORTAL=true, so the search must not
// offer destinations that would 404 elsewhere.
const includePortal =
import.meta.env.VITE_INCLUDE_PORTAL === "true" || import.meta.env.DEV;
/**
* The portal's in-app views. Deliberately a static mirror of the portal's nav
* (labels via the same portal.nav.* keys its sidebar uses) rather than an
* import from @portal/* — referencing the portal package here would pull the
* lazy portal chunk into the main bundle.
*/
const VIEWS: ProcessorSearchEntry[] = [
{
id: "home",
labelKey: "portal.nav.home",
labelFallback: "Home",
path: PORTAL_BASENAME,
keywords: ["portal", "processor", "admin"],
},
{
id: "users",
labelKey: "portal.nav.users",
labelFallback: "Users",
path: `${PORTAL_BASENAME}/users`,
keywords: ["team", "members", "roles", "admin"],
},
{
id: "sources",
labelKey: "portal.nav.sources",
labelFallback: "Sources",
path: `${PORTAL_BASENAME}/sources`,
keywords: ["s3", "connections", "webhooks", "folders"],
},
{
id: "policies",
labelKey: "portal.nav.policies",
labelFallback: "Policies",
path: `${PORTAL_BASENAME}/policies`,
keywords: ["enforcement", "redact", "compliance"],
},
{
id: "pipelines",
labelKey: "portal.nav.pipelines",
labelFallback: "Pipelines",
path: `${PORTAL_BASENAME}/pipelines`,
keywords: ["automation", "workflows", "operations"],
},
{
id: "documents",
labelKey: "portal.nav.documents",
labelFallback: "Documents",
path: `${PORTAL_BASENAME}/documents`,
keywords: ["audit", "files"],
},
{
id: "integrations",
labelKey: "portal.nav.integrations",
labelFallback: "Integrations",
path: `${PORTAL_BASENAME}/integrations`,
keywords: ["connections", "external", "api", "webhooks"],
},
{
id: "infrastructure",
labelKey: "portal.nav.infrastructure",
labelFallback: "Infrastructure",
path: `${PORTAL_BASENAME}/infrastructure`,
keywords: ["deployment", "instances", "health"],
},
{
id: "usage",
labelKey: "portal.nav.usage",
labelFallback: "Usage & Billing",
path: `${PORTAL_BASENAME}/usage`,
keywords: ["billing", "invoices", "plan", "wallet", "payg", "bundles"],
},
{
id: "docs",
labelKey: "portal.nav.docs",
labelFallback: "Documentation",
path: `${PORTAL_BASENAME}/docs`,
keywords: ["api", "documentation", "reference", "guides"],
},
];
export const PROCESSOR_SEARCH_INDEX: ProcessorSearchEntry[] = includePortal
? VIEWS
: [];
/**
* Whether an entity scope's data source will serve the current session at
* all. The users roster is the one divergent case: portal access alone
* doesn't imply the roster endpoint will answer (see
* UsersCapabilities.listingRequiresAdmin) — offering the lane anyway renders
* a permanently-empty chip that fires a doomed request on every search.
*/
export function isPortalEntityScopeAccessible(
scopeId: string,
isAdmin: boolean,
): boolean {
if (scopeId !== "portal-users") return true;
return isAdmin || !usersCapabilities.listingRequiresAdmin;
}
@@ -0,0 +1,233 @@
import {
SETTINGS_SECTION_REGISTRY as CORE_SETTINGS_SECTION_REGISTRY,
type SettingsSectionEntry,
} from "@core/data/settingsSectionRegistry";
export type { SettingsSectionEntry };
/**
* Self-hosted (proprietary) settings sections. Extends the core list with the
* account tab, the developer API keys tab, and the admin area — mirroring the
* sections added by the proprietary nav builder (`configNavSections`). Keep the
* two in sync: a section here that the modal can't render would deep-link to a
* dead tab, and a modal section missing here isn't searchable.
*
* Gating flags mirror the builder: `account`/`api-keys` need login; the admin
* sections are surfaced when the user is an admin OR login mode is off.
*/
export const SETTINGS_SECTION_REGISTRY: SettingsSectionEntry[] = [
...CORE_SETTINGS_SECTION_REGISTRY,
{
key: "account",
labelKey: "account.accountSettings",
labelFallback: "Account",
keywords: ["profile", "email", "password", "user"],
requiresLogin: true,
groupLabelKey: "settings.preferences.title",
groupLabelFallback: "Preferences",
},
{
key: "api-keys",
labelKey: "settings.developer.apiKeys",
labelFallback: "API Keys",
keywords: ["api", "token", "developer", "key"],
requiresLogin: true,
groupLabelKey: "settings.developer.title",
groupLabelFallback: "Developer",
},
// --- Workspace ---
{
key: "people",
labelKey: "settings.workspace.people",
labelFallback: "People",
keywords: ["members", "users", "invite", "add member", "roles"],
adminArea: true,
groupLabelKey: "settings.workspace.title",
groupLabelFallback: "Workspace",
},
{
key: "teams",
labelKey: "settings.workspace.teams",
labelFallback: "Teams",
keywords: ["team", "group", "create team"],
adminArea: true,
groupLabelKey: "settings.workspace.title",
groupLabelFallback: "Workspace",
},
// --- Configuration ---
{
key: "adminGeneral",
labelKey: "settings.configuration.systemSettings",
labelFallback: "System Settings",
keywords: ["system", "config", "server"],
adminArea: true,
groupLabelKey: "settings.configuration.title",
groupLabelFallback: "Configuration",
},
{
key: "adminFeatures",
labelKey: "settings.configuration.features",
labelFallback: "Features",
keywords: ["features", "toggles", "flags"],
adminArea: true,
groupLabelKey: "settings.configuration.title",
groupLabelFallback: "Configuration",
},
{
key: "adminStorageSharing",
labelKey: "settings.configuration.storageSharing",
labelFallback: "File Storage & Sharing",
keywords: ["storage", "sharing", "files"],
adminArea: true,
groupLabelKey: "settings.configuration.title",
groupLabelFallback: "Configuration",
},
{
key: "adminEndpoints",
labelKey: "settings.configuration.endpoints",
labelFallback: "Endpoints",
keywords: ["endpoints", "api", "routes"],
adminArea: true,
groupLabelKey: "settings.configuration.title",
groupLabelFallback: "Configuration",
},
{
key: "adminMcp",
labelKey: "settings.configuration.mcp",
labelFallback: "MCP Server",
keywords: ["mcp", "server", "ai", "model context protocol"],
adminArea: true,
groupLabelKey: "settings.configuration.title",
groupLabelFallback: "Configuration",
},
{
key: "adminDatabase",
labelKey: "settings.configuration.database",
labelFallback: "Database",
keywords: ["database", "db", "backup"],
adminArea: true,
groupLabelKey: "settings.configuration.title",
groupLabelFallback: "Configuration",
},
{
key: "adminFolderAccess",
labelKey: "settings.configuration.folderAccess",
labelFallback: "Folder Access",
keywords: ["folders", "access", "permissions", "scanning"],
adminArea: true,
groupLabelKey: "settings.configuration.title",
groupLabelFallback: "Configuration",
},
{
key: "adminAdvanced",
labelKey: "settings.configuration.advanced",
labelFallback: "Advanced",
keywords: ["advanced", "expert"],
adminArea: true,
groupLabelKey: "settings.configuration.title",
groupLabelFallback: "Configuration",
},
// --- AI ---
{
key: "adminAiGeneral",
labelKey: "settings.ai.general",
labelFallback: "General",
keywords: ["ai", "assistant", "enable"],
adminArea: true,
groupLabelKey: "settings.ai.title",
groupLabelFallback: "AI",
},
{
key: "adminAiModels",
labelKey: "settings.ai.models",
labelFallback: "Models & Providers",
keywords: ["ai", "models", "providers", "llm", "openai", "anthropic"],
adminArea: true,
groupLabelKey: "settings.ai.title",
groupLabelFallback: "AI",
},
{
key: "adminAiDocuments",
labelKey: "settings.ai.documents",
labelFallback: "Documents & RAG",
keywords: ["ai", "rag", "embedding", "retrieval", "documents"],
adminArea: true,
groupLabelKey: "settings.ai.title",
groupLabelFallback: "AI",
},
{
key: "adminAiLimits",
labelKey: "settings.ai.limits",
labelFallback: "Limits & Performance",
keywords: ["ai", "limits", "tokens", "rate", "performance"],
adminArea: true,
groupLabelKey: "settings.ai.title",
groupLabelFallback: "AI",
},
// --- Security & Authentication ---
{
key: "adminSecurity",
labelKey: "settings.securityAuth.security",
labelFallback: "Security",
keywords: ["security", "authentication", "auth", "password", "sessions"],
adminArea: true,
groupLabelKey: "settings.securityAuth.title",
groupLabelFallback: "Security & Authentication",
},
{
key: "adminConnections",
labelKey: "settings.securityAuth.connections",
labelFallback: "Connections",
keywords: ["connections", "oauth", "sso", "integrations"],
adminArea: true,
groupLabelKey: "settings.securityAuth.title",
groupLabelFallback: "Security & Authentication",
},
// --- Licensing & Analytics ---
{
key: "adminPlan",
labelKey: "settings.licensingAnalytics.plan",
labelFallback: "Plan",
keywords: ["plan", "license", "billing", "subscription", "enterprise"],
adminArea: true,
groupLabelKey: "settings.licensingAnalytics.title",
groupLabelFallback: "Licensing & Analytics",
},
{
key: "adminAudit",
labelKey: "settings.licensingAnalytics.audit",
labelFallback: "Audit",
keywords: ["audit", "logs", "events", "history"],
adminArea: true,
groupLabelKey: "settings.licensingAnalytics.title",
groupLabelFallback: "Licensing & Analytics",
},
{
key: "adminUsage",
labelKey: "settings.licensingAnalytics.usageAnalytics",
labelFallback: "Usage Analytics",
keywords: ["usage", "analytics", "stats", "metrics"],
adminArea: true,
groupLabelKey: "settings.licensingAnalytics.title",
groupLabelFallback: "Licensing & Analytics",
},
// --- Policies & Privacy ---
{
key: "adminLegal",
labelKey: "settings.policiesPrivacy.legal",
labelFallback: "Legal",
keywords: ["legal", "terms", "agreement"],
adminArea: true,
groupLabelKey: "settings.policiesPrivacy.title",
groupLabelFallback: "Policies & Privacy",
},
{
key: "adminPrivacy",
labelKey: "settings.policiesPrivacy.privacy",
labelFallback: "Privacy",
keywords: ["privacy", "gdpr", "data"],
adminArea: true,
groupLabelKey: "settings.policiesPrivacy.title",
groupLabelFallback: "Policies & Privacy",
},
];
@@ -22,4 +22,5 @@ export const usersCapabilities: UsersCapabilities = {
seats: false,
manageGrants: true,
removeScope: "org",
listingRequiresAdmin: true,
};
@@ -0,0 +1,6 @@
/**
* Prototypes builds ship no portal (and have no @portal alias) — shadow the
* entity search back to core's empty stub so the super search never resolves
* the proprietary implementation's portal imports here.
*/
export { useProcessorEntityGroups } from "@core/data/processorEntitySearch";
@@ -0,0 +1,10 @@
/**
* Prototypes builds ship no portal (and have no @portal alias) — shadow the
* index back to core's empty list so the super search never resolves the
* proprietary version's portal-flavored imports here.
*/
export type { ProcessorSearchEntry } from "@core/data/processorSearchIndex";
export {
PROCESSOR_SEARCH_INDEX,
isPortalEntityScopeAccessible,
} from "@core/data/processorSearchIndex";
@@ -73,6 +73,13 @@ interface AuthContextType {
refreshProStatus: () => Promise<void>;
refreshProfilePicture: () => Promise<void>;
refreshProfilePictureMetadata: () => Promise<void>;
/**
* Session-level permission flags (see the core auth seam). Supabase
* sessions don't carry them — consumers treat undefined as "not granted"
* and fall back to app-config gates.
*/
isAdmin?: boolean;
portalAccess?: boolean;
}
const AuthContext = createContext<AuthContextType>({
@@ -31,6 +31,8 @@ interface AppConfigModalProps {
urlSync?: boolean;
/** Section to land on when opening (used by non-URL hosts like the portal). */
initialSection?: NavKey | null;
/** Accepted for interface parity with the core shell; this shell ignores it. */
initialFocus?: string | null;
/** Host-specific sections appended after the saas registry sections. */
extraSections?: ConfigNavSection[];
/** Registry section keys to drop, for hosts a section can't run in. */
@@ -0,0 +1,99 @@
import { type SettingsSectionEntry } from "@core/data/settingsSectionRegistry";
export type { SettingsSectionEntry };
/**
* SaaS web settings sections. The SaaS modal is composed differently from
* self-hosted (see `saasConfigNavSections`): an Account area (Overview,
* Passwords & Security, Team), Preferences, a Developer area (API Keys, MCP),
* cloud Billing (Plan) and Legal. It deliberately does NOT list the self-hosted
* admin sections, so search never deep-links to a tab the SaaS modal can't show.
*
* The local backend's login/admin flags don't reflect SaaS auth, so entries
* avoid `requiresLogin`/`adminArea`. Sections the SaaS nav only mounts for a
* signed-in account (Team, Plan) carry `requiresAccount`, which the search
* gates on the auth seam's `isAnonymous` — the signal the nav itself uses.
*/
export const SETTINGS_SECTION_REGISTRY: SettingsSectionEntry[] = [
{
key: "overview",
labelKey: "config.account.overview.label",
labelFallback: "Overview",
keywords: ["account", "profile", "email", "avatar", "logout"],
groupLabelKey: "config.account.overview.title",
groupLabelFallback: "Account Settings",
},
{
key: "security",
labelKey: "config.account.security.title",
labelFallback: "Passwords & Security",
keywords: ["password", "2fa", "authentication", "sessions", "security"],
groupLabelKey: "config.account.overview.title",
groupLabelFallback: "Account Settings",
},
{
key: "teams",
labelKey: "config.team",
labelFallback: "Team",
keywords: ["team", "members", "invite", "seats"],
requiresAccount: true,
groupLabelKey: "config.account.overview.title",
groupLabelFallback: "Account Settings",
},
{
key: "general",
labelKey: "settings.general.title",
labelFallback: "General",
keywords: ["theme", "language", "appearance", "preferences", "startup"],
groupLabelKey: "settings.preferences.title",
groupLabelFallback: "Preferences",
},
{
key: "hotkeys",
labelKey: "settings.hotkeys.title",
labelFallback: "Keyboard Shortcuts",
keywords: ["hotkey", "shortcut", "keybinding", "keyboard"],
groupLabelKey: "settings.preferences.title",
groupLabelFallback: "Preferences",
},
{
key: "api-keys",
labelKey: "settings.developer.apiKeys",
labelFallback: "API Keys",
keywords: ["api", "token", "developer", "key"],
groupLabelKey: "settings.developer.title",
groupLabelFallback: "Developer",
},
{
key: "mcp",
labelKey: "config.mcp.navLabel",
labelFallback: "MCP Server",
keywords: ["mcp", "server", "ai", "model context protocol"],
groupLabelKey: "settings.developer.title",
groupLabelFallback: "Developer",
},
{
key: "plan",
labelKey: "config.plan",
labelFallback: "Plan",
keywords: [
"billing",
"subscription",
"upgrade",
"payment",
"invoice",
"wallet",
],
requiresAccount: true,
groupLabelKey: "settings.planBilling.title",
groupLabelFallback: "Plan & Billing",
},
{
key: "legal",
labelKey: "settings.legal.label",
labelFallback: "Legal",
keywords: ["legal", "terms", "privacy", "licenses"],
groupLabelKey: "settings.legal.title",
groupLabelFallback: "Legal",
},
];
@@ -23,4 +23,5 @@ export const usersCapabilities: UsersCapabilities = {
seats: true,
manageGrants: false,
removeScope: "team",
listingRequiresAdmin: false,
};