From 77efd449aeebc714e889b7f212b4754fe7dbfbfe Mon Sep 17 00:00:00 2001 From: Viktor Renkema <49148610+viktorrenkema@users.noreply.github.com> Date: Tue, 30 Jun 2026 12:49:31 +0200 Subject: [PATCH] Allow quickly viewing which pages have changed in a CR/revision (#4348) --- .changeset/rnd-11620-changed-pages-toolbar.md | 5 + .../components/AdminToolbar/AdminToolbar.tsx | 3 + .../AdminToolbar/AdminToolbarClient.tsx | 6 + .../AdminToolbar/ChangedPagesButton.tsx | 172 ++++++++++++++++++ .../components/AdminToolbar/changedPages.ts | 57 ++++++ .../AdminToolbar/changedPagesMapper.ts | 114 ++++++++++++ .../src/components/AdminToolbar/types.ts | 15 ++ .../components/primitives/DropdownMenu.tsx | 43 ++++- .../src/components/primitives/Tooltip.tsx | 8 +- packages/gitbook/src/lib/data/api.ts | 84 +++++++++ packages/gitbook/src/lib/data/types.ts | 18 ++ 11 files changed, 515 insertions(+), 10 deletions(-) create mode 100644 .changeset/rnd-11620-changed-pages-toolbar.md create mode 100644 packages/gitbook/src/components/AdminToolbar/ChangedPagesButton.tsx create mode 100644 packages/gitbook/src/components/AdminToolbar/changedPages.ts create mode 100644 packages/gitbook/src/components/AdminToolbar/changedPagesMapper.ts diff --git a/.changeset/rnd-11620-changed-pages-toolbar.md b/.changeset/rnd-11620-changed-pages-toolbar.md new file mode 100644 index 000000000..8847645f3 --- /dev/null +++ b/.changeset/rnd-11620-changed-pages-toolbar.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Show changed pages in preview toolbars for change requests and revisions. diff --git a/packages/gitbook/src/components/AdminToolbar/AdminToolbar.tsx b/packages/gitbook/src/components/AdminToolbar/AdminToolbar.tsx index fa406accd..a1c461f85 100644 --- a/packages/gitbook/src/components/AdminToolbar/AdminToolbar.tsx +++ b/packages/gitbook/src/components/AdminToolbar/AdminToolbar.tsx @@ -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 ; diff --git a/packages/gitbook/src/components/AdminToolbar/AdminToolbarClient.tsx b/packages/gitbook/src/components/AdminToolbar/AdminToolbarClient.tsx index 3b7a2636a..0988db421 100644 --- a/packages/gitbook/src/components/AdminToolbar/AdminToolbarClient.tsx +++ b/packages/gitbook/src/components/AdminToolbar/AdminToolbarClient.tsx @@ -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) { {/* Refresh to retrieve latest changes */} {updated ? : null} + {/* View a popover with quick links to the changed pages */} + {/* Edit in GitBook */} @@ -211,6 +214,9 @@ function RevisionToolbar(props: ToolbarViewProps) { } /> + {/* View a popover with quick links to the changed pages */} + + {/* Open commit in Git client */} = { + 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 = ( + + ); + + return ( + +
{changedPagesLabel}
+ +
+ {changedPages.pages.map((page) => ( + + ))} +
+ {changedPages.more > 0 ? ( + <> + +
+ {changedPages.more} more changes not shown +
+ + ) : null} +
+ ); +} + +function ChangedPageMenuItem(props: { page: MinimalChangedPage }) { + const { page } = props; + const actionLabel = page.action === 'editor' ? 'Open in editor' : 'Open'; + + return ( + + + + + + {page.title} + + + /{page.path || ''} + + + + {actionLabel} + + + + + ); +} + +function ChangedPageStatusIcon(props: { status: MinimalChangedPage['status'] }) { + const { status } = props; + const style = STATUS_ICON_STYLES[status]; + + return ( + + + + ); +} + +function DiffIcon(props: SVGProps) { + return ( + + ); +} diff --git a/packages/gitbook/src/components/AdminToolbar/changedPages.ts b/packages/gitbook/src/components/AdminToolbar/changedPages.ts new file mode 100644 index 000000000..82dfe1f7f --- /dev/null +++ b/packages/gitbook/src/components/AdminToolbar/changedPages.ts @@ -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 { + // 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 { + 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; +} diff --git a/packages/gitbook/src/components/AdminToolbar/changedPagesMapper.ts b/packages/gitbook/src/components/AdminToolbar/changedPagesMapper.ts new file mode 100644 index 000000000..74cd770d2 --- /dev/null +++ b/packages/gitbook/src/components/AdminToolbar/changedPagesMapper.ts @@ -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 = [ + 'edited', + 'moved', + 'created', + 'deleted', +]; + +export function getToolbarChangedPagesFromChanges(input: { + changes: RevisionSemanticChange[]; + linker: Pick; + pages: Revision['pages']; + editorBaseURL: string; +}): MinimalChangedPage[] { + const changedPages = new Map(); + + 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); +} diff --git a/packages/gitbook/src/components/AdminToolbar/types.ts b/packages/gitbook/src/components/AdminToolbar/types.ts index f3ec3fed6..705911271 100644 --- a/packages/gitbook/src/components/AdminToolbar/types.ts +++ b/packages/gitbook/src/components/AdminToolbar/types.ts @@ -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 { diff --git a/packages/gitbook/src/components/primitives/DropdownMenu.tsx b/packages/gitbook/src/components/primitives/DropdownMenu.tsx index 130a1e138..33ac845c3 100644 --- a/packages/gitbook/src/components/primitives/DropdownMenu.tsx +++ b/packages/gitbook/src/components/primitives/DropdownMenu.tsx @@ -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 = Omit< Partial, 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 = ( + setHovered(true)} + onMouseLeave={() => setHovered(false)} + onClick={() => (openOnHover ? setOpen(!open) : null)} + className="group/dropdown" + > + {button} + + ); + return ( - setHovered(true)} - onMouseLeave={() => setHovered(false)} - onClick={() => (openOnHover ? setOpen(!open) : null)} - className="group/dropdown" - > - {button} - + {buttonTooltip ? ( + + {trigger} + + ) : ( + trigger + )} setHovered(false)} align={align} side={side} + sideOffset={sideOffset} className={DROPDOWN_CONTENT_OUTER_CLASS} >
diff --git a/packages/gitbook/src/components/primitives/Tooltip.tsx b/packages/gitbook/src/components/primitives/Tooltip.tsx index d09aa0679..13a0994ee 100644 --- a/packages/gitbook/src/components/primitives/Tooltip.tsx +++ b/packages/gitbook/src/components/primitives/Tooltip.tsx @@ -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 ( - setClicked(true)} {...triggerProps}> + setClicked(true) : undefined} + {...triggerProps} + > {children} diff --git a/packages/gitbook/src/lib/data/api.ts b/packages/gitbook/src/lib/data/api.ts index 75a802639..fa5704904 100644 --- a/packages/gitbook/src/lib/data/api.ts +++ b/packages/gitbook/src/lib/data/api.ts @@ -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, diff --git a/packages/gitbook/src/lib/data/types.ts b/packages/gitbook/src/lib/data/types.ts index b01a823dc..878479f98 100644 --- a/packages/gitbook/src/lib/data/types.ts +++ b/packages/gitbook/src/lib/data/types.ts @@ -65,6 +65,15 @@ export interface GitBookDataFetcher { changeRequestId: string; }): Promise>; + /** + * Get the semantic changes for a change request. + */ + getChangeRequestChanges(params: { + spaceId: string; + changeRequestId: string; + limit?: number; + }): Promise>; + /** * Get the revision by its space ID and revision ID. */ @@ -73,6 +82,15 @@ export interface GitBookDataFetcher { revisionId: string; }): Promise>; + /** + * Get the semantic changes for a revision. + */ + getRevisionSemanticChanges(params: { + spaceId: string; + revisionId: string; + limit?: number; + }): Promise>; + /** * Get a revision page by its path. */