Allow quickly viewing which pages have changed in a CR/revision (#4348)

This commit is contained in:
Viktor Renkema
2026-06-30 12:49:31 +02:00
committed by GitHub
parent af259fbe86
commit 77efd449ae
11 changed files with 515 additions and 10 deletions
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Show changed pages in preview toolbars for change requests and revisions.
@@ -1,5 +1,6 @@
import type { GitBookSiteContext } from '@/lib/context';
import { AdminToolbarClient } from './AdminToolbarClient';
import { getToolbarChangedPages } from './changedPages';
import type { AdminToolbarContext } from './types';
export interface AdminToolbarProps {
@@ -11,6 +12,7 @@ export interface AdminToolbarProps {
*/
export async function AdminToolbar(props: AdminToolbarProps) {
const { context } = props;
const changedPages = await getToolbarChangedPages(context);
// Create a minimal context to avoid serializing and passing too many data to the client
const minimalContext: AdminToolbarContext = {
@@ -57,6 +59,7 @@ export async function AdminToolbar(props: AdminToolbarProps) {
published: context.site.urls.published,
},
},
changedPages,
};
return <AdminToolbarClient context={minimalContext} />;
@@ -4,6 +4,7 @@ import { MotionConfig, motion } from 'motion/react';
import { useCheckForContentUpdate } from '../AutoRefreshContent';
import { useVisitor } from '../Insights';
import { useCurrentPagePath } from '../hooks';
import { ChangedPagesButton } from './ChangedPagesButton';
import { HideToolbarButton } from './HideToolbarButton';
import { IframeWrapper } from './IframeWrapper';
import { RefreshContentButton } from './RefreshContentButton';
@@ -150,6 +151,8 @@ function ChangeRequestToolbar(props: ToolbarViewProps) {
<ToolbarActions>
{/* Refresh to retrieve latest changes */}
{updated ? <RefreshContentButton refreshForUpdates={refreshForUpdates} /> : null}
{/* View a popover with quick links to the changed pages */}
<ChangedPagesButton changedPages={context.changedPages} />
{/* Edit in GitBook */}
<EditPageButton href={changeRequest.urls.app} siteId={site.id} />
@@ -211,6 +214,9 @@ function RevisionToolbar(props: ToolbarViewProps) {
<ToolbarSubtitle subtitle={<ToolbarDate value={revision.createdAt} />} />
</ToolbarBody>
<ToolbarActions>
{/* View a popover with quick links to the changed pages */}
<ChangedPagesButton changedPages={context.changedPages} />
{/* Open commit in Git client */}
<ToolbarButton
title={
@@ -0,0 +1,172 @@
'use client';
import { tcls } from '@/lib/tailwind';
import { Icon, type IconName, IconStyle } from '@gitbook/icons';
import { motion, useReducedMotion } from 'motion/react';
import type { SVGProps } from 'react';
import { DropdownMenu, DropdownMenuItem, DropdownMenuSeparator } from '../primitives';
import type { ToolbarButtonProps } from './Toolbar';
import type { MinimalChangedPage, MinimalChangedPages } from './types';
const STATUS_ICON_STYLES: Record<
MinimalChangedPage['status'],
{ chip: string; icon: IconName; iconClassName: string; iconStyle?: IconStyle }
> = {
created: {
chip: 'border-green-200 bg-green-50',
icon: 'plus',
iconClassName: 'size-3.5 text-green-600',
},
edited: {
chip: 'border-blue-200 bg-blue-50',
icon: 'pencil',
iconClassName: 'size-3 text-blue-600',
iconStyle: IconStyle.Regular,
},
moved: {
chip: 'border-blue-200 bg-blue-50',
icon: 'arrow-right',
iconClassName: 'size-3 text-blue-600',
},
deleted: {
chip: 'border-red-200 bg-red-50',
icon: 'minus',
iconClassName: 'size-3 text-red-600',
},
};
const STATUS_ICON_CHIP_CLASS = 'flex size-5 shrink-0 items-center justify-center rounded-md border';
/**
* A button that opens a popover with quick links to the _changed_ pages.
*/
export function ChangedPagesButton(props: {
changedPages: MinimalChangedPages | null;
motionValues?: ToolbarButtonProps['motionValues'];
}) {
const { changedPages, motionValues } = props;
const reduceMotion = useReducedMotion();
if (!changedPages?.pages.length) {
return null;
}
const changedPagesCount = changedPages.pages.length + changedPages.more;
const changedPagesLabel = `${changedPagesCount} changed ${
changedPagesCount === 1 ? 'page' : 'pages'
}`;
const trigger = (
<button
type="button"
aria-label="View changed pages"
className="relative size-8 cursor-pointer border-0 bg-transparent p-0"
>
<motion.span
aria-hidden="true"
style={
reduceMotion
? undefined
: {
scale: motionValues?.scale,
x: motionValues?.x,
transformOrigin: 'bottom center',
zIndex: motionValues?.scale ? 10 : 'auto',
}
}
transition={{
type: 'spring',
stiffness: 400,
damping: 30,
}}
className={tcls(
'toolbar-button',
'relative flex size-8 items-center justify-center gap-1 truncate rounded-full text-sm transition-colors',
'text-tint-7 hover:text-tint-1',
'dark:text-tint-12',
'bg-[var(--toolbar-bg)]',
'hover:bg-[color-mix(in_srgb,var(--toolbar-bg)_90%,white)]'
)}
>
<DiffIcon className="size-4 shrink-0" />
</motion.span>
</button>
);
return (
<DropdownMenu
button={trigger}
buttonTooltip="View changed pages"
side="top"
align="end"
sideOffset={12}
className="w-80 max-w-[calc(100vw-2rem)] gap-0 overflow-hidden p-0"
>
<div className="px-3 py-2 font-medium text-sm text-tint">{changedPagesLabel}</div>
<DropdownMenuSeparator className="m-0" />
<div className="flex max-h-80 flex-col overflow-y-auto">
{changedPages.pages.map((page) => (
<ChangedPageMenuItem key={page.id} page={page} />
))}
</div>
{changedPages.more > 0 ? (
<>
<DropdownMenuSeparator />
<div className="px-3 py-1 text-tint-subtle text-xs">
{changedPages.more} more changes not shown
</div>
</>
) : null}
</DropdownMenu>
);
}
function ChangedPageMenuItem(props: { page: MinimalChangedPage }) {
const { page } = props;
const actionLabel = page.action === 'editor' ? 'Open in editor' : 'Open';
return (
<DropdownMenuItem
href={page.href}
className="group/changed-page relative w-full min-w-0 items-center rounded-none px-3 py-1.5"
>
<span className="flex min-w-0 flex-1 items-center gap-2">
<ChangedPageStatusIcon status={page.status} />
<span className="min-w-0 flex-1 transition-[padding] group-hover/changed-page:pr-24 group-focus-visible/changed-page:pr-24 group-data-[highlighted]/changed-page:pr-24">
<span className="block truncate font-medium text-sm leading-4">
{page.title}
</span>
<span className="block truncate text-tint-subtle text-xs leading-4">
/{page.path || ''}
</span>
</span>
<span className="pointer-events-none absolute right-2 flex items-center gap-0.5 bg-tint-hover pl-3 text-tint-subtle text-xs opacity-0 transition-opacity group-hover/changed-page:opacity-100 group-focus-visible/changed-page:opacity-100 group-data-[highlighted]/changed-page:opacity-100">
{actionLabel}
<Icon icon="chevron-right" className="size-3 shrink-0" />
</span>
</span>
</DropdownMenuItem>
);
}
function ChangedPageStatusIcon(props: { status: MinimalChangedPage['status'] }) {
const { status } = props;
const style = STATUS_ICON_STYLES[status];
return (
<span className={tcls(STATUS_ICON_CHIP_CLASS, style.chip)}>
<Icon icon={style.icon} iconStyle={style.iconStyle} className={style.iconClassName} />
</span>
);
}
function DiffIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg fill="none" viewBox="0 0 16 16" aria-hidden="true" {...props}>
<path
fill="currentColor"
d="M8 2.4a.6.6 0 0 1 .6.6v2.9h2.9a.6.6 0 1 1 0 1.2H8.6V10a.6.6 0 1 1-1.2 0V7.1H4.5a.6.6 0 0 1 0-1.2h2.9V3a.6.6 0 0 1 .6-.6M4.5 12.4a.6.6 0 1 0 0 1.2h7a.6.6 0 1 0 0-1.2z"
/>
</svg>
);
}
@@ -0,0 +1,57 @@
import type { GitBookSiteContext } from '@/lib/context';
import { getDataOrNull, ignoreAllThrownError } from '@/lib/data';
import { getToolbarChangedPagesFromChanges } from './changedPagesMapper';
import type { MinimalChangedPages } from './types';
export const TOOLBAR_CHANGED_PAGES_LIMIT = 100;
/**
* Fetch and reduce semantic changes to the minimal list needed by the toolbar client.
*/
export async function getToolbarChangedPages(
context: GitBookSiteContext
): Promise<MinimalChangedPages | null> {
// The changed-pages list is a non-critical toolbar enhancement. Any failure to fetch or
// reduce the changes should simply hide the button, never break rendering of the site page.
return ignoreAllThrownError(fetchToolbarChangedPages(context));
}
async function fetchToolbarChangedPages(
context: GitBookSiteContext
): Promise<MinimalChangedPages | null> {
const changes = context.changeRequest
? await getDataOrNull(
context.dataFetcher.getChangeRequestChanges({
spaceId: context.space.id,
changeRequestId: context.changeRequest.id,
limit: TOOLBAR_CHANGED_PAGES_LIMIT,
})
)
: context.revisionId !== context.space.revision
? await getDataOrNull(
context.dataFetcher.getRevisionSemanticChanges({
spaceId: context.space.id,
revisionId: context.revisionId,
limit: TOOLBAR_CHANGED_PAGES_LIMIT,
})
)
: null;
if (!changes) {
return null;
}
const pages = getToolbarChangedPagesFromChanges({
changes: changes.changes,
editorBaseURL: context.changeRequest?.urls.app ?? context.revision.urls.app,
linker: context.linker,
pages: context.revision.pages,
});
return pages.length > 0
? {
pages,
more: changes.more ?? 0,
}
: null;
}
@@ -0,0 +1,114 @@
import type { GitBookLinker } from '@/lib/links';
import { getPagePath, resolvePageId } from '@/lib/pages';
import { joinPathWithBaseURL } from '@/lib/paths';
import {
type ChangedRevisionPage,
type Revision,
RevisionPageType,
type RevisionSemanticChange,
} from '@gitbook/api';
import type { MinimalChangedPage } from './types';
// When one page has multiple semantic changes, show the label that best summarizes its final state.
const CHANGE_STATUS_SUMMARY_ORDER: Array<MinimalChangedPage['status']> = [
'edited',
'moved',
'created',
'deleted',
];
export function getToolbarChangedPagesFromChanges(input: {
changes: RevisionSemanticChange[];
linker: Pick<GitBookLinker, 'toPathForPage'>;
pages: Revision['pages'];
editorBaseURL: string;
}): MinimalChangedPage[] {
const changedPages = new Map<string, MinimalChangedPage>();
for (const change of input.changes) {
// Only real document page changes can become toolbar rows; computed/link/group pages are ignored.
const pageChange = getChangedPageChange(change);
if (!pageChange || pageChange.page.type !== RevisionPageType.Document) {
continue;
}
// Resolve against the current revision so preview links use the current title and path.
const resolved = resolvePageId(input.pages, pageChange.page.id);
const isDeleted = pageChange.status === 'deleted';
const currentPage = resolved?.page;
const path = currentPage ? getPagePath(input.pages, currentPage) : pageChange.page.path;
if (!path && !currentPage) {
continue;
}
// Missing non-deleted pages cannot be linked reliably from the preview.
if (!currentPage && !isDeleted) {
continue;
}
// Deleted pages may no longer exist in the preview, so link to their editor change.
if (!currentPage) {
changedPages.set(pageChange.page.id, {
id: pageChange.page.id,
title: pageChange.page.title,
path: path ?? '',
href: joinPathWithBaseURL(input.editorBaseURL, path ?? ''),
status: pageChange.status,
action: 'editor',
});
continue;
}
const pageId = currentPage.id;
const existing = changedPages.get(pageId);
// The API can return multiple changes for one page; keep the clearest one-row summary.
if (existing && shouldKeepExistingPageChange(existing.status, pageChange.status)) {
continue;
}
changedPages.set(pageId, {
id: pageId,
title: currentPage.title,
path: path ?? '',
href: isDeleted
? joinPathWithBaseURL(input.editorBaseURL, path ?? '')
: input.linker.toPathForPage({
pages: input.pages,
page: currentPage,
}),
status: pageChange.status,
action: isDeleted ? 'editor' : 'preview',
});
}
return Array.from(changedPages.values());
}
function getChangedPageChange(
change: RevisionSemanticChange
): { page: ChangedRevisionPage; status: MinimalChangedPage['status'] } | null {
switch (change.type) {
case 'page_created':
return { page: change.page, status: 'created' };
case 'page_edited':
return { page: change.page, status: 'edited' };
case 'page_moved':
return { page: change.page, status: 'moved' };
case 'page_deleted':
return { page: change.page, status: 'deleted' };
default:
return null;
}
}
function shouldKeepExistingPageChange(
existing: MinimalChangedPage['status'],
next: MinimalChangedPage['status']
) {
return getChangeStatusSummaryIndex(existing) >= getChangeStatusSummaryIndex(next);
}
function getChangeStatusSummaryIndex(status: MinimalChangedPage['status']) {
return CHANGE_STATUS_SUMMARY_ORDER.indexOf(status);
}
@@ -43,6 +43,20 @@ export type MinimalSite = {
};
};
export type MinimalChangedPage = {
id: string;
title: string;
path: string;
href: string;
status: 'created' | 'edited' | 'moved' | 'deleted';
action: 'preview' | 'editor';
};
export type MinimalChangedPages = {
pages: MinimalChangedPage[];
more: number;
};
export type AdminToolbarContext = {
organizationId: string;
revisionId: string;
@@ -50,6 +64,7 @@ export type AdminToolbarContext = {
changeRequest: MinimalChangeRequest | null;
revision: MinimalRevision;
site: MinimalSite;
changedPages: MinimalChangedPages | null;
};
export interface AdminToolbarClientProps {
@@ -11,6 +11,7 @@ import * as RadixDropdownMenu from '@radix-ui/react-dropdown-menu';
import { assert } from 'ts-essentials';
import { Link, type LinkInsightsProps } from '.';
import { ToggleChevron } from './ToggleChevron';
import { Tooltip } from './Tooltip';
export type DropdownButtonProps<E extends HTMLElement = HTMLElement> = Omit<
Partial<DetailedHTMLProps<HTMLAttributes<E>, E>>,
@@ -36,6 +37,8 @@ const DROPDOWN_CONTENT_INNER_CLASS =
export function DropdownMenu(props: {
/** Content of the button */
button: React.ReactNode;
/** Tooltip label for the button */
buttonTooltip?: React.ReactNode;
/** Content of the dropdown */
children: React.ReactNode;
/** Custom styles */
@@ -52,32 +55,53 @@ export function DropdownMenu(props: {
* @default "start"
*/
align?: RadixDropdownMenu.DropdownMenuContentProps['align'];
/**
* Distance between the trigger and the dropdown.
* @default 0
*/
sideOffset?: RadixDropdownMenu.DropdownMenuContentProps['sideOffset'];
}) {
const {
button,
buttonTooltip,
children,
className,
openOnHover = false,
side = 'bottom',
align = 'start',
sideOffset = 0,
} = props;
const [hovered, setHovered] = useState(false);
const [open, setOpen] = useState(false);
const isOpen = openOnHover ? open || hovered : open;
const trigger = (
<RadixDropdownMenu.Trigger
asChild
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onClick={() => (openOnHover ? setOpen(!open) : null)}
className="group/dropdown"
>
{button}
</RadixDropdownMenu.Trigger>
);
return (
<DropdownMenuContext.Provider value={{ open: isOpen, setOpen }}>
<RadixDropdownMenu.Root modal={false} open={isOpen} onOpenChange={setOpen}>
<RadixDropdownMenu.Trigger
asChild
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onClick={() => (openOnHover ? setOpen(!open) : null)}
className="group/dropdown"
>
{button}
</RadixDropdownMenu.Trigger>
{buttonTooltip ? (
<Tooltip
label={buttonTooltip}
pinOnClick={false}
rootProps={{ disableHoverableContent: true }}
>
{trigger}
</Tooltip>
) : (
trigger
)}
<RadixDropdownMenu.Portal>
<RadixDropdownMenu.Content
@@ -88,6 +112,7 @@ export function DropdownMenu(props: {
onMouseLeave={() => setHovered(false)}
align={align}
side={side}
sideOffset={sideOffset}
className={DROPDOWN_CONTENT_OUTER_CLASS}
>
<div className={tcls(DROPDOWN_CONTENT_INNER_CLASS, className)}>
@@ -20,6 +20,7 @@ export function Tooltip(props: {
arrowProps?: RadixTooltip.TooltipArrowProps;
arrow?: boolean;
className?: string;
pinOnClick?: boolean;
}) {
const {
children,
@@ -31,6 +32,7 @@ export function Tooltip(props: {
arrowProps,
arrow = false,
className,
pinOnClick = true,
} = props;
const [open, setOpen] = useState(false);
@@ -56,7 +58,11 @@ export function Tooltip(props: {
return (
<RadixTooltip.Root open={open || clicked} onOpenChange={setOpen} {...rootProps}>
<RadixTooltip.Trigger asChild onClick={() => setClicked(true)} {...triggerProps}>
<RadixTooltip.Trigger
asChild
onClick={pinOnClick ? () => setClicked(true) : undefined}
{...triggerProps}
>
{children}
</RadixTooltip.Trigger>
<RadixTooltip.Portal {...portalProps}>
+84
View File
@@ -139,6 +139,20 @@ export function createDataFetcher(
changeRequestId: params.changeRequestId,
});
},
getChangeRequestChanges(params) {
return getChangeRequestChanges(input, {
spaceId: params.spaceId,
changeRequestId: params.changeRequestId,
limit: params.limit,
});
},
getRevisionSemanticChanges(params) {
return getRevisionSemanticChanges(input, {
spaceId: params.spaceId,
revisionId: params.revisionId,
limit: params.limit,
});
},
getDocument(params) {
return getDocument(input, {
spaceId: params.spaceId,
@@ -323,6 +337,76 @@ const getRevision = cache(
}
);
const getChangeRequestChanges = cache(
async (
input: DataFetcherInput,
params: { spaceId: string; changeRequestId: string; limit?: number }
) => {
'use cache: remote';
cacheTag(
getCacheTag({
tag: 'change-request',
space: params.spaceId,
changeRequest: params.changeRequestId,
})
);
return wrapDataFetcherError(async () => {
return trace(
`getChangeRequestChanges(${params.spaceId}, ${params.changeRequestId})`,
async () => {
const api = apiClient(input);
const res = await api.spaces.getChangeRequestChanges(
params.spaceId,
params.changeRequestId,
{
limit: params.limit,
},
{
...noCacheFetchOptions,
}
);
cacheTag(...getCacheTagsFromResponse(res));
cacheLife('minutes');
return res.data;
}
);
});
}
);
const getRevisionSemanticChanges = cache(
async (
input: DataFetcherInput,
params: { spaceId: string; revisionId: string; limit?: number }
) => {
'use cache: remote';
return wrapDataFetcherError(async () => {
return trace(
`getRevisionSemanticChanges(${params.spaceId}, ${params.revisionId})`,
async () => {
const api = apiClient(input);
const res = await api.spaces.getRevisionSemanticChanges(
params.spaceId,
params.revisionId,
{
computed: false,
limit: params.limit,
metadata: false,
},
{
...noCacheFetchOptions,
}
);
cacheTag(...getCacheTagsFromResponse(res));
cacheLife('max');
return res.data;
}
);
});
}
);
const getRevisionPageMarkdown = cache(
async (
input: DataFetcherInput,
+18
View File
@@ -65,6 +65,15 @@ export interface GitBookDataFetcher {
changeRequestId: string;
}): Promise<DataFetcherResponse<api.ChangeRequest>>;
/**
* Get the semantic changes for a change request.
*/
getChangeRequestChanges(params: {
spaceId: string;
changeRequestId: string;
limit?: number;
}): Promise<DataFetcherResponse<api.RevisionSemanticChanges>>;
/**
* Get the revision by its space ID and revision ID.
*/
@@ -73,6 +82,15 @@ export interface GitBookDataFetcher {
revisionId: string;
}): Promise<DataFetcherResponse<api.Revision>>;
/**
* Get the semantic changes for a revision.
*/
getRevisionSemanticChanges(params: {
spaceId: string;
revisionId: string;
limit?: number;
}): Promise<DataFetcherResponse<api.RevisionSemanticChanges>>;
/**
* Get a revision page by its path.
*/