Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/wicked-hornets-crash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"gitbook": minor
---

Scope search across sections and variants
3 changes: 2 additions & 1 deletion packages/gitbook/src/components/AI/useAI.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,8 @@ export function useAI(): AIContext {
setSearchState((prev) => ({
ask: null, // Reset ask as we assume the assistant will handle it
query: prev?.query ?? null,
global: prev?.global ?? false,
scope: prev?.scope ?? 'all',
depth: prev?.depth ?? 'single',
open: false,
}));
assistant.open(query);
Expand Down
12 changes: 8 additions & 4 deletions packages/gitbook/src/components/AI/useAIChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,8 @@ export function AIChatProvider(props: {
setSearchState((prev) => ({
ask: prev?.ask ?? initialQuery ?? '',
query: prev?.query ?? null,
global: prev?.global ?? false,
scope: prev?.scope ?? 'all',
depth: prev?.depth ?? 'single',
open: false, // Close search popover when opening chat
}));
}, [setSearchState]);
Expand All @@ -159,7 +160,8 @@ export function AIChatProvider(props: {
setSearchState((prev) => ({
ask: null,
query: prev?.query ?? null,
global: prev?.global ?? false,
scope: prev?.scope ?? 'all',
depth: prev?.depth ?? 'single',
open: false,
}));
}, [setSearchState]);
Expand Down Expand Up @@ -374,7 +376,8 @@ export function AIChatProvider(props: {
setSearchState((prev) => ({
ask: input.message,
query: prev?.query ?? null,
global: prev?.global ?? false,
scope: prev?.scope ?? 'all',
depth: prev?.depth ?? 'single',
open: false,
}));
}
Expand Down Expand Up @@ -435,7 +438,8 @@ export function AIChatProvider(props: {
setSearchState((prev) => ({
ask: '',
query: prev?.query ?? null,
global: prev?.global ?? false,
scope: prev?.scope ?? 'all',
depth: prev?.depth ?? 'single',
open: false,
}));
}, [setSearchState]);
Expand Down
21 changes: 20 additions & 1 deletion packages/gitbook/src/components/Header/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,9 +120,28 @@ export function Header(props: {
>
<SearchContainer
style={customization.styling.search}
isMultiVariants={siteSpaces.length > 1}
withVariants={withVariants === 'generic'}
withSiteVariants={
sections?.list.some(
(s) =>
s.object === 'site-section' &&
s.siteSpaces.filter(
(s) => s.space.language === siteSpace.space.language
).length > 1
) ?? false
}
withSections={!!sections}
section={
sections
? // Client-encode to avoid a serialisation issue that was causing the language selector to disappear
encodeClientSiteSections(context, sections).current
: undefined
}
spaceTitle={siteSpace.title}
siteSpaceId={siteSpace.id}
siteSpaceIds={siteSpaces
.filter((s) => s.space.language === siteSpace.space.language)
.map((s) => s.id)}
viewport={!withTopHeader ? 'mobile' : undefined}
/>
</div>
Expand Down
57 changes: 48 additions & 9 deletions packages/gitbook/src/components/Search/SearchContainer.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import { CustomizationSearchStyle } from '@gitbook/api';
import { CustomizationSearchStyle, type SiteSection } from '@gitbook/api';
import { useRouter } from 'next/navigation';
import React, { useRef } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
Expand All @@ -18,9 +18,27 @@ import { SearchScopeToggle } from './SearchScopeToggle';
import { useSearch } from './useSearch';

interface SearchContainerProps {
/** The current site space id. */
siteSpaceId: string;

/** The title of the current space. */
spaceTitle: string;
isMultiVariants: boolean;

/** The ids of all spaces in the current section. */
siteSpaceIds: string[];

/** Whether there are sections on the site. */
withSections: boolean;

/** The current section, displayed in search scope toggle. */
section?: Pick<SiteSection, 'title' | 'icon'>;

/** Whether the current section has variants. */
withVariants: boolean;

/** Whether any section on the site has variants. */
withSiteVariants: boolean;

style: CustomizationSearchStyle;
className?: string;
viewport?: 'desktop' | 'mobile';
Expand All @@ -30,7 +48,18 @@ interface SearchContainerProps {
* Client component to render the search input and results.
*/
export function SearchContainer(props: SearchContainerProps) {
const { siteSpaceId, spaceTitle, isMultiVariants, style, className, viewport } = props;
const {
siteSpaceId,
spaceTitle,
section,
withVariants,
withSiteVariants,
withSections,
style,
className,
viewport,
siteSpaceIds,
} = props;

const { assistants } = useAI();

Expand Down Expand Up @@ -108,7 +137,8 @@ export function SearchContainer(props: SearchContainerProps) {
}
setSearchState((prev) => ({
ask: withAI ? (prev?.ask ?? null) : null,
global: prev?.global ?? false,
scope: prev?.scope ?? 'all',
depth: prev?.depth ?? 'single',
query: prev?.query ?? (withSearchAI || !withAI ? prev?.ask : null) ?? '',
open: true,
}));
Expand Down Expand Up @@ -148,7 +178,8 @@ export function SearchContainer(props: SearchContainerProps) {
setSearchState((prev) => ({
ask: withAI && !withSearchAI ? (prev?.ask ?? null) : null, // When typing, we reset ask to get back to normal search (unless non-search assistants are defined)
query: value,
global: prev?.global ?? false,
scope: prev?.scope ?? 'all',
depth: prev?.depth ?? 'single',
open: true,
}));
};
Expand All @@ -168,15 +199,23 @@ export function SearchContainer(props: SearchContainerProps) {
// Only show content if there's a query or Ask is enabled
state?.query || withAI ? (
<React.Suspense fallback={null}>
{isMultiVariants && !showAsk ? (
<SearchScopeToggle spaceTitle={spaceTitle} />
{(withVariants || withSections) && !showAsk ? (
<SearchScopeToggle
section={section}
spaceTitle={spaceTitle}
withVariants={withVariants}
withSiteVariants={withSiteVariants}
withSections={withSections}
/>
) : null}
{state !== null && !showAsk ? (
<SearchResults
ref={resultsRef}
query={normalizedQuery}
global={state?.global ?? false}
scope={state?.scope ?? 'all'}
depth={state?.depth ?? 'single'}
siteSpaceId={siteSpaceId}
siteSpaceIds={siteSpaceIds}
/>
) : null}
{showAsk ? <SearchAskAnswer query={normalizedAsk} /> : null}
Expand All @@ -194,7 +233,7 @@ export function SearchContainer(props: SearchContainerProps) {
onOpenAutoFocus: (event) => event.preventDefault(),
align: 'start',
className:
'bg-tint-base has-[.empty]:hidden gutter-stable scroll-py-2 w-128 p-2 pr-1 max-h-[min(32rem,var(--radix-popover-content-available-height))] max-w-[min(var(--radix-popover-content-available-width),32rem)]',
'@container bg-tint-base has-[.empty]:hidden gutter-stable scroll-py-2 w-128 p-2 pr-1 max-h-[min(32rem,var(--radix-popover-content-available-height))] max-w-[min(var(--radix-popover-content-available-width),32rem)]',
onInteractOutside: (event) => {
// Don't close if clicking on the search input itself
if (searchInputRef.current?.contains(event.target as Node)) {
Expand Down
32 changes: 25 additions & 7 deletions packages/gitbook/src/components/Search/SearchResults.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@ import { SearchSectionResultItem } from './SearchSectionResultItem';
import {
type OrderedComputedResult,
searchAllSiteContent,
searchSiteSpaceContent,
searchCurrentSiteSpaceContent,
searchSpecificSiteSpaceContent,
streamRecommendedQuestions,
} from './server-actions';
import type { SearchDepth, SearchScope } from './useSearch';

export interface SearchResultsRef {
moveUp(): void;
Expand Down Expand Up @@ -50,12 +52,14 @@ export const SearchResults = React.forwardRef(function SearchResults(
props: {
children?: React.ReactNode;
query: string;
global: boolean;
scope: SearchScope;
depth: SearchDepth;
siteSpaceId: string;
siteSpaceIds: string[];
},
ref: React.Ref<SearchResultsRef>
) {
const { children, query, global, siteSpaceId } = props;
const { children, query, scope, depth, siteSpaceId, siteSpaceIds } = props;

const language = useLanguage();
const trackEvent = useTrackEvent();
Expand Down Expand Up @@ -133,9 +137,23 @@ export const SearchResults = React.forwardRef(function SearchResults(
setResultsState((prev) => ({ results: prev.results, fetching: true }));
let cancelled = false;
const timeout = setTimeout(async () => {
const results = await (global
? searchAllSiteContent(query)
: searchSiteSpaceContent(query));
const results = await (() => {
if (scope === 'all' && depth === 'single') {
return searchCurrentSiteSpaceContent(query, siteSpaceId);
}
if (scope === 'all' && depth === 'full') {
return searchAllSiteContent(query);
}
if (scope === 'current' && depth === 'single') {
return searchSpecificSiteSpaceContent(query, [siteSpaceId]);
}
if (scope === 'current' && depth === 'full') {
return searchSpecificSiteSpaceContent(query, siteSpaceIds);
}
throw new Error(
`Unhandled search scope/depth combination: scope=${scope}, depth=${depth}`
);
})();

if (cancelled) {
return;
Expand All @@ -158,7 +176,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
cancelled = true;
clearTimeout(timeout);
};
}, [query, global, trackEvent, withAI, siteSpaceId]);
}, [query, scope, depth, trackEvent, withAI, siteSpaceId, siteSpaceIds]);

const results: ResultType[] = React.useMemo(() => {
if (!withAI) {
Expand Down
99 changes: 62 additions & 37 deletions packages/gitbook/src/components/Search/SearchScopeToggle.tsx
Original file line number Diff line number Diff line change
@@ -1,52 +1,77 @@
import { tString, useLanguage } from '@/intl/client';
import { Button } from '../primitives';
'use client';

import type { SiteSection } from '@gitbook/api';
import { SegmentedControl, SegmentedControlItem } from '../primitives/SegmentedControl';
import { useSearch } from './useSearch';

/**
* Toolbar to toggle between search modes (global or scoped to a space).
* Only visible when the space is in a collection.
*/
export function SearchScopeToggle(props: { spaceTitle: string }) {
const { spaceTitle } = props;
export function SearchScopeToggle(props: {
spaceTitle: string;
section?: Pick<SiteSection, 'title' | 'icon'>;
withVariants: boolean;
withSiteVariants: boolean;
withSections: boolean;
}) {
const { spaceTitle, section, withVariants, withSections, withSiteVariants } = props;
const [state, setSearchState] = useSearch();
const language = useLanguage();

if (!state) {
return null;
}

return (
<div
role="toolbar"
aria-orientation="horizontal"
className="mb-2 flex flex-row flex-wrap gap-1 circular-corners:rounded-3xl rounded-corners:rounded-lg bg-tint-subtle p-1"
>
<Button
variant="blank"
size="medium"
className="shrink grow justify-center whitespace-normal"
active={!state.global}
label={tString(language, 'search_scope_space', spaceTitle)}
onClick={() => {
setSearchState({
...state,
global: false,
});
}}
/>
<Button
variant="blank"
size="medium"
className="shrink grow justify-center whitespace-normal"
active={state.global}
label={tString(language, 'search_scope_all')}
onClick={() => {
setSearchState({
...state,
global: true,
});
}}
/>
</div>
<>
{withSections ? (
<SegmentedControl className="animate-scale-in @max-md:flex-col">
<SegmentedControlItem
active={state.scope === 'current'}
icon={section?.icon}
label={section?.title}
onClick={() =>
setSearchState({ ...state, scope: 'current', depth: 'single' })
}
/>
<SegmentedControlItem
active={state.scope === 'all' && state.depth === 'single'}
label={withSiteVariants ? 'Most relevant' : 'Entire site'}
icon={withSiteVariants ? 'bullseye-arrow' : 'infinity'}
onClick={() => setSearchState({ ...state, scope: 'all', depth: 'single' })}
/>
{withSiteVariants ? (
<SegmentedControlItem
active={state.scope === 'all' && state.depth === 'full'}
label="Entire site"
icon="infinity"
onClick={() =>
setSearchState({ ...state, scope: 'all', depth: 'full' })
}
/>
) : null}
</SegmentedControl>
) : null}
{withVariants && (!withSections || state.scope === 'current') ? (
<SegmentedControl className="animate-scale-in">
<SegmentedControlItem
size={state.scope === 'current' ? 'small' : 'medium'}
active={state.depth === 'single'}
className="py-1"
label={spaceTitle}
icon="crosshairs"
onClick={() => setSearchState({ ...state, depth: 'single' })}
/>
<SegmentedControlItem
size={state.scope === 'current' ? 'small' : 'medium'}
active={state.depth === 'full'}
className="py-1"
label="All content"
icon="rectangle-vertical-history"
onClick={() => setSearchState({ ...state, depth: 'full' })}
/>
</SegmentedControl>
) : null}
</>
);
}
Loading