diff --git a/frontend/editor/public/locales/en-GB/translation.toml b/frontend/editor/public/locales/en-GB/translation.toml index a91d52cca0..bdf6a739ed 100644 --- a/frontend/editor/public/locales/en-GB/translation.toml +++ b/frontend/editor/public/locales/en-GB/translation.toml @@ -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" diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 69211b6360..d6b38bc2e8 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -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" diff --git a/frontend/editor/src/core/auth/UseSession.tsx b/frontend/editor/src/core/auth/UseSession.tsx index ee3314606c..207c4da786 100644 --- a/frontend/editor/src/core/auth/UseSession.tsx +++ b/frontend/editor/src/core/auth/UseSession.tsx @@ -26,6 +26,14 @@ export interface AuthContextType { error: Error | null; signOut: () => Promise; refreshSession: () => Promise; + /** + * 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; } /** diff --git a/frontend/editor/src/core/components/filesPage/FileGrid.tsx b/frontend/editor/src/core/components/filesPage/FileGrid.tsx index 7bed49bd0b..e54964c592 100644 --- a/frontend/editor/src/core/components/filesPage/FileGrid.tsx +++ b/frontend/editor/src/core/components/filesPage/FileGrid.tsx @@ -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 = ( ); + // 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 ( + <> + + {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 ( +
+ + + +
+ {t("filesPage.empty.noResults.title", "No matching files")} +
+
+ {t( + "filesPage.empty.noResults.hint", + "No files in this folder match your filter. Try a different term or clear the filter.", + )} +
+
+ ); + } + const { titleKey, titleFallback, hintKey, hintFallback } = (() => { switch (tab) { case "local": diff --git a/frontend/editor/src/core/components/filesPage/FileManagerView.tsx b/frontend/editor/src/core/components/filesPage/FileManagerView.tsx index 3ed5096efb..a0b0c6cfa1 100644 --- a/frontend/editor/src/core/components/filesPage/FileManagerView.tsx +++ b/frontend/editor/src/core/components/filesPage/FileManagerView.tsx @@ -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(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 ( <> - +
+ +
)} + setSearch(e.currentTarget.value)} + placeholder={t("filesPage.search.placeholder", "Filter files…")} + leftSection={} + rightSection={ + search ? ( + setSearch("")} + aria-label={t("filesPage.search.clear", "Clear filter")} + > + + + ) : null + } + aria-label={t("filesPage.search.label", "Filter files by name")} + style={{ width: 180 }} + /> onChange(e.currentTarget.value)} - placeholder={t( - "filesPage.searchPlaceholder", - "Search this folder & subfolders", - )} - aria-label={t("filesPage.search", "Search")} - /> - {value && ( - onChange("")} - aria-label={t("filesPage.clearSearch", "Clear search")} - > - × - - )} -
- ); -}); - function Breadcrumbs() { const { t } = useTranslation(); const folders = useFolders(); diff --git a/frontend/editor/src/core/components/filesPage/FilesPage.css b/frontend/editor/src/core/components/filesPage/FilesPage.css index 368bae2521..aa9d8248aa 100644 --- a/frontend/editor/src/core/components/filesPage/FilesPage.css +++ b/frontend/editor/src/core/components/filesPage/FilesPage.css @@ -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" diff --git a/frontend/editor/src/core/components/layout/Workbench.module.css b/frontend/editor/src/core/components/layout/Workbench.module.css index 51138d2ac3..fb73be8655 100644 --- a/frontend/editor/src/core/components/layout/Workbench.module.css +++ b/frontend/editor/src/core/components/layout/Workbench.module.css @@ -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; diff --git a/frontend/editor/src/core/components/layout/Workbench.tsx b/frontend/editor/src/core/components/layout/Workbench.tsx index 96eb6b8d6f..9f54e224e8 100644 --- a/frontend/editor/src/core/components/layout/Workbench.tsx +++ b/frontend/editor/src/core/components/layout/Workbench.tsx @@ -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 && ( -
-
- +
+
+
+ +
+ {/* Reopen tab: a little handle hanging off the bar's bottom-right + while the viewer tool row is retracted. */} + {showReopenTab && ( +
)} diff --git a/frontend/editor/src/core/components/shared/AppConfigModal.css b/frontend/editor/src/core/components/shared/AppConfigModal.css index 46033eaf97..2953632d98 100644 --- a/frontend/editor/src/core/components/shared/AppConfigModal.css +++ b/frontend/editor/src/core/components/shared/AppConfigModal.css @@ -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; diff --git a/frontend/editor/src/core/components/shared/AppConfigModal.tsx b/frontend/editor/src/core/components/shared/AppConfigModal.tsx index b5b3224985..12faf6eea5 100644 --- a/frontend/editor/src/core/components/shared/AppConfigModal.tsx +++ b/frontend/editor/src/core/components/shared/AppConfigModal.tsx @@ -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 = ({ onClose, urlSync = true, initialSection, + initialFocus, extraSections, hiddenSectionKeys, }) => { @@ -150,6 +152,35 @@ const AppConfigModalInner: React.FC = ({ [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 = ({ 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 = ({ } } onClose(); + return true; }, [ confirmIfDirty, location.key, @@ -214,6 +247,24 @@ const AppConfigModalInner: React.FC = ({ 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 = ({ {activeLabel} - diff --git a/frontend/editor/src/core/components/shared/FileSidebar.css b/frontend/editor/src/core/components/shared/FileSidebar.css index 126ec6fede..590f59fa7d 100644 --- a/frontend/editor/src/core/components/shared/FileSidebar.css +++ b/frontend/editor/src/core/components/shared/FileSidebar.css @@ -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 { diff --git a/frontend/editor/src/core/components/shared/FileSidebar.tsx b/frontend/editor/src/core/components/shared/FileSidebar.tsx index 742334015c..f74e96220f 100644 --- a/frontend/editor/src/core/components/shared/FileSidebar.tsx +++ b/frontend/editor/src/core/components/shared/FileSidebar.tsx @@ -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; /** Override the Google Drive handler. */ onPickGoogleDriveFiles?: (files: File[]) => void | Promise; - /** 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( onOpenSettings, onUploadFiles, onPickGoogleDriveFiles, - onSearchClick, extraAction, toggleAriaLabel, toggleIcon, @@ -168,9 +163,6 @@ const FileSidebar = forwardRef( // 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(null); const nativeFileInputRef = useRef(null); // State (not ref) so setting it triggers a re-render - avoids racing addFiles state updates. const [pendingViewFileId, setPendingViewFileId] = useState( @@ -439,17 +431,8 @@ const FileSidebar = forwardRef( } }, [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( // must key by their unique leaf id rather than the shared lineage (see renderFileRow). const lineageCounts = useMemo(() => { const counts = new Map(); - 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>({}); const setGroupOpenState = useCallback( @@ -473,29 +456,6 @@ const FileSidebar = forwardRef( [], ); - // 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( )}
- {/* 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. */} - {/* Search row */} - -
e.key === "Enter" && handleSearchClick() - : undefined - } - > - {searchActive && !collapsed ? ( - { - e.stopPropagation(); - handleSearchClose(); - }} - /> - ) : ( - - )} - {!collapsed && - (searchActive ? ( - setSearchQuery(e.target.value)} - placeholder={t( - "fileSidebar.searchPlaceholder", - "Search files...", - )} - onClick={(e) => e.stopPropagation()} - /> - ) : ( - - {t("fileSidebar.search", "Search")} - - ))} -
-
- {/* 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( {t("fileSidebar.library", "PDF Library")} - + (
- ) : filteredFileStubs.length > 0 ? ( + ) : allFileStubs.length > 0 ? (
{fileGroups ? ( <> @@ -1250,29 +1161,24 @@ const FileSidebar = forwardRef( "fileSidebar.viewAll", "View all {{count}} files", { - count: filteredFileStubs.length, + count: allFileStubs.length, }, )} ) : ( - filteredFileStubs.map(renderFileRow) + allFileStubs.map(renderFileRow) )}
) : ( - !searchActive && ( -
-

- {t("fileSidebar.noFiles", "No files yet")} -

-

- {t( - "fileSidebar.dropHint", - "Open files to get started", - )} -

-
- ) +
+

+ {t("fileSidebar.noFiles", "No files yet")} +

+

+ {t("fileSidebar.dropHint", "Open files to get started")} +

+
)}
)} diff --git a/frontend/editor/src/core/components/shared/TextInput.tsx b/frontend/editor/src/core/components/shared/TextInput.tsx index 06147fb99b..7d927bb011 100644 --- a/frontend/editor/src/core/components/shared/TextInput.tsx +++ b/frontend/editor/src/core/components/shared/TextInput.tsx @@ -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) */ diff --git a/frontend/editor/src/core/components/shared/WorkbenchBar.css b/frontend/editor/src/core/components/shared/WorkbenchBar.css index b324fd8222..f805f69721 100644 --- a/frontend/editor/src/core/components/shared/WorkbenchBar.css +++ b/frontend/editor/src/core/components/shared/WorkbenchBar.css @@ -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. diff --git a/frontend/editor/src/core/components/shared/WorkbenchBar.tsx b/frontend/editor/src/core/components/shared/WorkbenchBar.tsx index 55ca01d307..241d658bad 100644 --- a/frontend/editor/src/core/components/shared/WorkbenchBar.tsx +++ b/frontend/editor/src/core/components/shared/WorkbenchBar.tsx @@ -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(null); - - useEffect(() => { + useLayoutEffect(() => { const bar = barRef.current; if (!bar) return; - + const MIN_SEARCH_WIDTH = 320; const measure = () => { const viewsEl = bar.querySelector(".workbench-bar-views"); const globalsEl = bar.querySelector( ".workbench-bar-globals", ); - const centerEl = bar.querySelector(".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(".workbench-bar-search"); + const pillEl = slotEl?.querySelector(".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(".workbench-bar-views"); + const globalsEl = bar.querySelector(".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({
{/* Left: optional "Back to My Files" + view switcher */} @@ -524,27 +548,50 @@ export default function WorkbenchBar({ )}
- {/* Tool buttons - second row, only rendered when buttons exist */} - {sectionsWithButtons.length > 0 && ( -
- {sectionsWithButtons.map( - ({ section, buttons: sectionButtons }, idx) => ( - - {idx > 0 &&
} - {sectionButtons.map((btn) => { - const content = renderButton(btn); - if (!content) return null; - return ( -
- {content} -
- ); - })} - - ), - )} -
- )} + {/* Global super search - always present, even on the homepage */} +
+ +
+ + {/* 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) && ( +
+ {sectionsWithButtons.map( + ({ section, buttons: sectionButtons }, idx) => ( + + {idx > 0 &&
} + {sectionButtons.map((btn) => { + const content = renderButton(btn); + if (!content) return null; + return ( +
+ {content} +
+ ); + })} + + ), + )} + {isViewer && onCollapseViewerToolbar && ( +
+ )} {/* Right: Global buttons - export group left, close anchored right */}
diff --git a/frontend/editor/src/core/components/shared/config/SettingsSearchBar.stories.tsx b/frontend/editor/src/core/components/shared/config/SettingsSearchBar.stories.tsx deleted file mode 100644 index 28d5a1c3e0..0000000000 --- a/frontend/editor/src/core/components/shared/config/SettingsSearchBar.stories.tsx +++ /dev/null @@ -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; -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Mobile: Story = { - args: { - isMobile: true, - }, -}; diff --git a/frontend/editor/src/core/components/shared/config/SettingsSearchBar.tsx b/frontend/editor/src/core/components/shared/config/SettingsSearchBar.tsx deleted file mode 100644 index 397347dcf6..0000000000 --- a/frontend/editor/src/core/components/shared/config/SettingsSearchBar.tsx +++ /dev/null @@ -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; - isMobile: boolean; -} - -interface SettingsSearchOption { - value: NavKey; - label: string; - sectionTitle: string; - destinationPath: string; - searchableContent: string[]; - matchedContext?: string; -} - -const SETTINGS_SEARCH_TRANSLATION_PREFIXES: Partial> = - { - 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).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 = ({ - 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(() => { - 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(() => { - const query = searchValue.trim(); - if (!query) { - return searchableSections; - } - - const normalizedQuery = query.toLocaleLowerCase(); - - return searchableSections.reduce( - (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 ( - setQuery(e.target.value)} - placeholder={t("portal.search.placeholder")} - aria-label={t("portal.search.ariaLabel")} - className="portal-search__input" - autoComplete="off" - spellCheck={false} - /> - - ESC - -
- -
- {isLoading && ( -
- - - - -
- )} - {isEmpty && ( - - )} - {!isLoading && - !isEmpty && - Object.entries(groups).map(([group, items]) => ( -
-
{group}
- {items.map((item) => ( - - ))} -
- ))} -
-
- - ); -} diff --git a/frontend/editor/src/portal/components/docs/DocsNav.tsx b/frontend/editor/src/portal/components/docs/DocsNav.tsx index 1063d5e98f..2f6258fca9 100644 --- a/frontend/editor/src/portal/components/docs/DocsNav.tsx +++ b/frontend/editor/src/portal/components/docs/DocsNav.tsx @@ -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. diff --git a/frontend/editor/src/portal/components/docs/DocsSearch.tsx b/frontend/editor/src/portal/components/docs/DocsSearch.tsx deleted file mode 100644 index 49bdb3101f..0000000000 --- a/frontend/editor/src/portal/components/docs/DocsSearch.tsx +++ /dev/null @@ -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 . */ -function Highlighted({ segments }: { segments: Segment[] }) { - return ( - <> - {segments.map((s, i) => - s.hit ? ( - - {s.text} - - ) : ( - {s.text} - ), - )} - - ); -} - -/** - * 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(null); - const hasQuery = query.trim().length > 0; - - useEffect(() => setActiveIndex(-1), [query]); - - useEffect(() => { - listRef.current - ?.querySelector('[data-active="true"]') - ?.scrollIntoView?.({ block: "nearest" }); - }, [activeIndex]); - - const onKeyDown = (e: React.KeyboardEvent) => { - 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 ( -
-
- - ⌕ - - onQueryChange(e.target.value)} - onKeyDown={onKeyDown} - aria-label={t("portal.docs.search.placeholder")} - /> -
- - {hasQuery && ( -
- {results.length === 0 ? ( -

- {t("portal.docs.search.empty")} -

- ) : ( - <> -
- {t("portal.docs.search.results", { count: results.length })} -
-
    - {results.map((r, i) => ( -
  • - -
  • - ))} -
- - )} -
- )} -
- ); -} diff --git a/frontend/editor/src/portal/components/users/UsersDirectory.stories.tsx b/frontend/editor/src/portal/components/users/UsersDirectory.stories.tsx index ff4fb4b6f1..28e84691fb 100644 --- a/frontend/editor/src/portal/components/users/UsersDirectory.stories.tsx +++ b/frontend/editor/src/portal/components/users/UsersDirectory.stories.tsx @@ -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. */ diff --git a/frontend/editor/src/portal/components/users/UsersDirectory.tsx b/frontend/editor/src/portal/components/users/UsersDirectory.tsx index 2e78a6d4be..4367656d0b 100644 --- a/frontend/editor/src/portal/components/users/UsersDirectory.tsx +++ b/frontend/editor/src/portal/components/users/UsersDirectory.tsx @@ -215,7 +215,8 @@ export function UsersDirectory({ function renderRow(m: Member) { const access = m.portalAccess ?? "none"; return ( -
+ // data-member-id lets deep links (?member=) scroll to and flash a row. +
diff --git a/frontend/editor/src/portal/contexts/UIContext.tsx b/frontend/editor/src/portal/contexts/UIContext.tsx index ba0676ab6d..b5ebada257 100644 --- a/frontend/editor/src/portal/contexts/UIContext.tsx +++ b/frontend/editor/src/portal/contexts/UIContext.tsx @@ -7,11 +7,6 @@ import { } from "react"; interface UIContextValue { - searchOpen: boolean; - openSearch: () => void; - closeSearch: () => void; - toggleSearch: () => void; - /** 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( () => ({ - // 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, diff --git a/frontend/editor/src/portal/hooks/usePortalSearchResults.test.ts b/frontend/editor/src/portal/hooks/usePortalSearchResults.test.ts new file mode 100644 index 0000000000..4be356bab0 --- /dev/null +++ b/frontend/editor/src/portal/hooks/usePortalSearchResults.test.ts @@ -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(); + return { + ...actual, + useTranslation: () => ({ + t: ( + key: string, + fallbackOrOptions?: string | Record, + ) => { + if (key === "portal.policies.defaultName") { + return `${(fallbackOrOptions as Record)?.category as string} Policy`; + } + if (typeof fallbackOrOptions === "string") return fallbackOrOptions; + const labels: Record = { + "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()), + 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 { + 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 { + 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() { + let resolve: ((value: T) => void) | undefined; + let reject: ((reason?: unknown) => void) | undefined; + const promise = new Promise((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) => + 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(); + 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", + ]); + }); +}); diff --git a/frontend/editor/src/portal/hooks/usePortalSearchResults.tsx b/frontend/editor/src/portal/hooks/usePortalSearchResults.tsx new file mode 100644 index 0000000000..b839d980d6 --- /dev/null +++ b/frontend/editor/src/portal/hooks/usePortalSearchResults.tsx @@ -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(); + const enabled = defaultPortalEntityScopes(config?.isAdmin ?? false).filter( + (scopeId) => scopeEnabled(scopeId), + ); + return new Set(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( + () => ({ + 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( + () => + config + ? { + isAdmin: config.isAdmin ?? false, + loginEnabled: config.enableLogin ?? false, + showSettingsWhenNoLogin: config.showSettingsWhenNoLogin ?? true, + } + : null, + [config], + ); + + const entityGroups = useMemo( + () => + 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 }; +} diff --git a/frontend/editor/src/portal/mocks/handlers/index.ts b/frontend/editor/src/portal/mocks/handlers/index.ts index d5428b1853..13b22d6941 100644 --- a/frontend/editor/src/portal/mocks/handlers/index.ts +++ b/frontend/editor/src/portal/mocks/handlers/index.ts @@ -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, diff --git a/frontend/editor/src/portal/mocks/handlers/search.ts b/frontend/editor/src/portal/mocks/handlers/search.ts deleted file mode 100644 index b62e8bc910..0000000000 --- a/frontend/editor/src/portal/mocks/handlers/search.ts +++ /dev/null @@ -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); - }), -]; diff --git a/frontend/editor/src/portal/mocks/search.ts b/frontend/editor/src/portal/mocks/search.ts deleted file mode 100644 index 86d332de70..0000000000 --- a/frontend/editor/src/portal/mocks/search.ts +++ /dev/null @@ -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" }, -]; diff --git a/frontend/editor/src/portal/search/entitySearch.tsx b/frontend/editor/src/portal/search/entitySearch.tsx new file mode 100644 index 0000000000..1d0a7cc489 --- /dev/null +++ b/frontend/editor/src/portal/search/entitySearch.tsx @@ -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; + +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: , + 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 { + 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>, +): 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; + +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: , + score, + onSelect: () => openPolicy(item.category.id), + })); +} + +export function rankPortalPipelineResults( + entries: PipelineView[], + trimmed: string, + excludedIds: ReadonlySet, + 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: , + 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: , + 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: , + 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; +} diff --git a/frontend/editor/src/portal/views/DeveloperDocs.css b/frontend/editor/src/portal/views/DeveloperDocs.css index 314cf17eef..6487a9afff 100644 --- a/frontend/editor/src/portal/views/DeveloperDocs.css +++ b/frontend/editor/src/portal/views/DeveloperDocs.css @@ -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; diff --git a/frontend/editor/src/portal/views/DeveloperDocs.test.tsx b/frontend/editor/src/portal/views/DeveloperDocs.test.tsx index d2071011d8..354c6359c6 100644 --- a/frontend/editor/src/portal/views/DeveloperDocs.test.tsx +++ b/frontend/editor/src/portal/views/DeveloperDocs.test.tsx @@ -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(); - 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(); - // "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(); // The Getting Started body links to the Migration guide via the doc: scheme. diff --git a/frontend/editor/src/portal/views/DeveloperDocs.tsx b/frontend/editor/src/portal/views/DeveloperDocs.tsx index fb62b8566c..d32666e1dd 100644 --- a/frontend/editor/src/portal/views/DeveloperDocs.tsx +++ b/frontend/editor/src/portal/views/DeveloperDocs.tsx @@ -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(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(() => { - 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")} - {/* Layout columns, not landmarks: the search and the two