From f06defaa1412f9383b2e65d1b907b5208a447aa4 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Thu, 21 May 2026 17:42:04 -0400 Subject: [PATCH] feat: Add breadcrumb to docs in command menu (#12403) --- app/actions/definitions/documents.tsx | 13 ++-- .../CommandBar/useRecentDocumentActions.tsx | 6 +- app/components/DocumentBreadcrumb.tsx | 75 ++++++++++++++++--- 3 files changed, 77 insertions(+), 17 deletions(-) diff --git a/app/actions/definitions/documents.tsx b/app/actions/definitions/documents.tsx index 091903661f..57c7990151 100644 --- a/app/actions/definitions/documents.tsx +++ b/app/actions/definitions/documents.tsx @@ -81,6 +81,7 @@ import { trashPath, documentEditPath, } from "~/utils/routeHelpers"; +import { documentBreadcrumbText } from "~/components/DocumentBreadcrumb"; import CollectionIcon from "~/components/Icons/CollectionIcon"; import type { Action, @@ -108,19 +109,21 @@ export const openDocument = createActionWithChildren({ shortcut: ["o", "d"], keywords: "go to", icon: , - children: ({ stores }) => { + children: ({ stores, t }) => { const nodes = stores.collections.navigationNodes.reduce( (acc, node) => [...acc, ...node.children], [] as NavigationNode[] ); const documents = stores.documents.orderedData; - return uniqBy([...documents, ...nodes], "id").map((item) => - createInternalLinkAction({ + return uniqBy([...documents, ...nodes], "id").map((item) => { + const document = stores.documents.get(item.id); + return createInternalLinkAction({ // Note: using url which includes the slug rather than id here to bust // cache if the document is renamed id: item.url, name: item.title, + description: document ? documentBreadcrumbText(document, t) : undefined, icon: item.icon ? ( { const { documents, ui } = useStores(); + const { t } = useTranslation(); return useMemo( () => @@ -19,6 +22,7 @@ const useRecentDocumentActions = (count = 6) => { name: item.titleWithDefault, analyticsName: "Recently viewed document", section: RecentSection, + description: documentBreadcrumbText(item, t), icon: item.icon ? ( { to: documentPath(item), }) ), - [count, ui.activeDocumentId, documents.recentlyViewed] + [count, ui.activeDocumentId, documents.recentlyViewed, t] ); }; diff --git a/app/components/DocumentBreadcrumb.tsx b/app/components/DocumentBreadcrumb.tsx index c3da07a972..aa53d86efc 100644 --- a/app/components/DocumentBreadcrumb.tsx +++ b/app/components/DocumentBreadcrumb.tsx @@ -1,10 +1,10 @@ +import type { TFunction } from "i18next"; import { observer } from "mobx-react"; import { ArchiveIcon, GoToIcon, TrashIcon } from "outline-icons"; import * as React from "react"; import { useTranslation } from "react-i18next"; import styled from "styled-components"; import Icon from "@shared/components/Icon"; -import type { NavigationNode } from "@shared/types"; import type Collection from "~/models/Collection"; import type Document from "~/models/Document"; import Breadcrumb from "~/components/Breadcrumb"; @@ -20,6 +20,56 @@ import { archivePath, trashPath } from "~/utils/routeHelpers"; import { createInternalLinkAction } from "~/actions"; import { ActiveDocumentSection } from "~/actions/sections"; +/** + * Returns the breadcrumb parts leading up to a document, separating the + * (possibly deleted) collection label from ancestor document titles. The + * document itself is not included. + * + * @param document - the document to compute the breadcrumb for. + * @param t - translation function for fallback titles. + * @returns the collection label and ancestor titles. + */ +export function documentBreadcrumbParts( + document: Document, + t: TFunction +): { collection: string | undefined; ancestors: string[] } { + let collectionLabel: string | undefined; + if (document.isCollectionDeleted) { + collectionLabel = t("Deleted Collection"); + } else if (document.collection?.name) { + collectionLabel = document.collection.name; + } + + return { + collection: collectionLabel, + ancestors: document.pathTo + .slice(0, -1) + .map((node) => node.title || t("Untitled")), + }; +} + +/** + * Returns the breadcrumb path leading up to a document as a plain text + * string. Includes the collection name (or "Deleted Collection" fallback) + * and any ancestor document titles, slash-separated. + * + * @param document - the document to compute the breadcrumb for. + * @param t - translation function for fallback titles. + * @returns the breadcrumb as a slash-separated string, or undefined if the + * document has no resolvable parent context. + */ +export function documentBreadcrumbText( + document: Document, + t: TFunction +): string | undefined { + const parts = documentBreadcrumbParts(document, t); + const segments = [ + ...(parts.collection ? [parts.collection] : []), + ...parts.ancestors, + ]; + return segments.length ? segments.join(" / ") : undefined; +} + type Props = { children?: React.ReactNode; document: Document; @@ -147,22 +197,25 @@ function DocumentBreadcrumb( return <>; } - const slicedPath = reverse - ? path.slice(depth && -depth) - : path.slice(0, depth); + const { collection: collectionLabel, ancestors: ancestorLabels } = + documentBreadcrumbParts(document, t); + + const slicedAncestors = reverse + ? ancestorLabels.slice(depth && -depth) + : ancestorLabels.slice(0, depth); const showCollection = - collection && - (!reverse || depth === undefined || slicedPath.length < depth); + !!collectionLabel && + (!reverse || depth === undefined || slicedAncestors.length < depth); return ( <> - {showCollection && collection.name} - {slicedPath.map((node: NavigationNode, index: number) => ( - + {showCollection && collectionLabel} + {slicedAncestors.map((label, index) => ( + {showCollection && } - {node.title || t("Untitled")} - {!showCollection && index !== slicedPath.length - 1 && ( + {label} + {!showCollection && index !== slicedAncestors.length - 1 && ( )}