Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
2 changes: 1 addition & 1 deletion packages/gitbook/src/components/AI/useAI.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ 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 ?? 'default',
open: false,
}));
assistant.open(query);
Expand Down
8 changes: 4 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,7 @@ export function AIChatProvider(props: {
setSearchState((prev) => ({
ask: prev?.ask ?? initialQuery ?? '',
query: prev?.query ?? null,
global: prev?.global ?? false,
scope: prev?.scope ?? 'default',
open: false, // Close search popover when opening chat
}));
}, [setSearchState]);
Expand All @@ -159,7 +159,7 @@ export function AIChatProvider(props: {
setSearchState((prev) => ({
ask: null,
query: prev?.query ?? null,
global: prev?.global ?? false,
scope: prev?.scope ?? 'default',
open: false,
}));
}, [setSearchState]);
Expand Down Expand Up @@ -374,7 +374,7 @@ export function AIChatProvider(props: {
setSearchState((prev) => ({
ask: input.message,
query: prev?.query ?? null,
global: prev?.global ?? false,
scope: prev?.scope ?? 'default',
open: false,
}));
}
Expand Down Expand Up @@ -435,7 +435,7 @@ export function AIChatProvider(props: {
setSearchState((prev) => ({
ask: '',
query: prev?.query ?? null,
global: prev?.global ?? false,
scope: prev?.scope ?? 'default',
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
54 changes: 45 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,7 @@ export function SearchContainer(props: SearchContainerProps) {
}
setSearchState((prev) => ({
ask: withAI ? (prev?.ask ?? null) : null,
global: prev?.global ?? false,
scope: prev?.scope ?? 'default',
query: prev?.query ?? (withSearchAI || !withAI ? prev?.ask : null) ?? '',
open: true,
}));
Expand Down Expand Up @@ -148,7 +177,7 @@ 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 ?? 'default',
open: true,
}));
};
Expand All @@ -168,15 +197,22 @@ 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 ?? 'default'}
siteSpaceId={siteSpaceId}
siteSpaceIds={siteSpaceIds}
/>
) : null}
{showAsk ? <SearchAskAnswer query={normalizedAsk} /> : null}
Expand All @@ -194,7 +230,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 scroll-py-2 w-128 p-2 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
33 changes: 26 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 { SearchScope } from './useSearch';

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

const language = useLanguage();
const trackEvent = useTrackEvent();
Expand Down Expand Up @@ -133,9 +136,25 @@ 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') {
// Search all content on the site
return searchAllSiteContent(query);
}
if (scope === 'default') {
// Search the current section's variant + matched/default variant for other sections
return searchCurrentSiteSpaceContent(query, siteSpaceId);
}
if (scope === 'extended') {
// Search all variants of the current section
return searchSpecificSiteSpaceContent(query, siteSpaceIds);
}
if (scope === 'current') {
// Search only the current section's current variant
return searchSpecificSiteSpaceContent(query, [siteSpaceId]);
}
throw new Error(`Unhandled search scope: ${scope}`);
})();

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

const results: ResultType[] = React.useMemo(() => {
if (!withAI) {
Expand Down
124 changes: 89 additions & 35 deletions packages/gitbook/src/components/Search/SearchScopeToggle.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
'use client';

import { tString, useLanguage } from '@/intl/client';
import { Button } from '../primitives';
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();

Expand All @@ -16,37 +25,82 @@ export function SearchScopeToggle(props: { spaceTitle: string }) {
}

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">
{/* `Default` scope = current section's current variant + best match in other sections */}
<SegmentedControlItem
active={
withSiteVariants
? state.scope === 'default'
: ['default', 'all'].includes(state.scope)
}
label={
withSiteVariants
? tString(language, 'search_scope_default')
: tString(language, 'search_scope_all')
}
className={withSiteVariants ? '@max-md:basis-full' : ''}
icon={withSiteVariants ? 'bullseye-arrow' : 'infinity'}
onClick={() => setSearchState({ ...state, scope: 'default' })}
/>

{/* `Current` scope = current section's current variant (with further variant scope selection if necessary) */}
<SegmentedControlItem
active={state.scope === 'current' || state.scope === 'extended'}
icon={section?.icon ?? 'crosshairs'}
label={tString(language, 'search_scope_current', section?.title)}
onClick={() => setSearchState({ ...state, scope: 'current' })}
/>

{/* `All` scope = all content on the site. Only visible if site has variants, otherwise it's the same as default */}
{withSiteVariants ? (
<SegmentedControlItem
active={state.scope === 'all'}
label={tString(language, 'search_scope_all')}
icon="infinity"
onClick={() => setSearchState({ ...state, scope: 'all' })}
/>
) : null}
</SegmentedControl>
) : null}
{withVariants &&
(!withSections || state.scope === 'current' || state.scope === 'extended') ? (
<SegmentedControl className="animate-scale-in">
{/* `Current` scope = current section's current variant. `Default` on sites without sections. */}
<SegmentedControlItem
size={withSections ? 'small' : 'medium'}
active={
withSections
? state.scope === 'current'
: ['default', 'current'].includes(state.scope)
}
className="py-1"
label={tString(language, 'search_scope_current', spaceTitle)}
onClick={() =>
setSearchState({
...state,
scope: withSections ? 'current' : 'default',
})
}
/>

{/* `Extended` scope = all variants of the current section. `All` on sites without sections. */}
<SegmentedControlItem
size={withSections ? 'small' : 'medium'}
active={
withSections
? state.scope === 'extended'
: ['extended', 'all'].includes(state.scope)
}
className="py-1"
label={tString(language, 'search_scope_extended')}
onClick={() =>
setSearchState({ ...state, scope: withSections ? 'extended' : 'all' })
}
/>
</SegmentedControl>
) : null}
</>
);
}
Loading