Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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