diff --git a/apps/obsidian/src/components/NodeSearchModal.tsx b/apps/obsidian/src/components/NodeSearchModal.tsx index 7ef5bbadd..7afd2759d 100644 --- a/apps/obsidian/src/components/NodeSearchModal.tsx +++ b/apps/obsidian/src/components/NodeSearchModal.tsx @@ -21,8 +21,10 @@ import { import { createRoot, Root } from "react-dom/client"; import type DiscourseGraphPlugin from "~/index"; import { NodeSearchFooter } from "~/components/NodeSearchFooter"; +import { NodeSortMenu } from "~/components/NodeSortMenu"; import { NodeTypeChipsSearchInput } from "~/components/NodeTypeChipsSearchInput"; import { NodeTypeFilterMenu } from "~/components/NodeTypeFilterMenu"; +import type { SearchDropdownId } from "~/components/SearchDropdown"; import { openFileInNewLeaf, openFileInNewTab, @@ -43,8 +45,18 @@ import { getFallbackNodeTypeBadge, type NodeTypeBadge, } from "~/utils/nodeTypeBadge"; -import { fetchUserNames } from "~/utils/importNodes"; -import { getLoggedInClient } from "~/utils/supabaseContext"; +import { + buildAuthorNameByPath, + resolveAuthorName, + useAuthorNames, +} from "~/utils/discourseNodeAuthor"; +import { + DEFAULT_SORT_DIRECTION, + DEFAULT_SORT_KEY, + sortSearchResults, + type SortDirection, + type SortKey, +} from "~/utils/discourseNodeSort"; const MAX_VISIBLE_RESULTS = 50; const SEARCH_DEBOUNCE_MS = 250; @@ -64,80 +76,6 @@ type SearchResultRow = RankedDiscourseNode & { nodeType: NodeTypeDisplay; }; -const LOCAL_AUTHOR_NAME = "You"; -const UNRESOLVED_AUTHOR_NAME = "Unknown"; - -/** Frontmatter is untyped, so the raw value is narrowed by each caller. */ -const getFrontmatterAuthorId = (app: App, file: TFile): unknown => { - const frontmatter: Record | undefined = - app.metadataCache.getFileCache(file)?.frontmatter; - return frontmatter?.authorId; -}; - -/** - * "You" belongs only to a note with no `authorId` at all — every note in an - * unsynced vault. An id that is present but unresolvable stays "Unknown" rather - * than claiming local authorship. `useAuthorNames` has already cached the - * names, so this stays synchronous. - */ -const resolveAuthorName = ({ - app, - file, - userNames, -}: { - app: App; - file: TFile; - userNames: Record; -}): string => { - const authorId = getFrontmatterAuthorId(app, file); - if (authorId === undefined || authorId === null) return LOCAL_AUTHOR_NAME; - if (typeof authorId !== "number") return UNRESOLVED_AUTHOR_NAME; - return userNames[authorId] ?? UNRESOLVED_AUTHOR_NAME; -}; - -/** - * `fetchUserNames` returns every person in the vault's spaces in one query, so - * this refreshes once per open when a name is missing rather than querying per - * author. - */ -const useAuthorNames = ({ - app, - plugin, - candidateState, -}: { - app: App; - plugin: DiscourseGraphPlugin; - candidateState: CandidateState; -}): Record => { - const [userNames, setUserNames] = useState(plugin.settings.userNames ?? {}); - - useEffect(() => { - if (candidateState.status !== "ready") return; - if (!plugin.settings.syncModeEnabled) return; - - const isMissingName = (candidate: DiscourseNodeCandidate): boolean => { - const authorId = getFrontmatterAuthorId(app, candidate.file); - return ( - typeof authorId === "number" && !plugin.settings.userNames?.[authorId] - ); - }; - if (!candidateState.candidates.some(isMissingName)) return; - - let cancelled = false; - void (async () => { - const client = await getLoggedInClient(plugin); - if (!client || cancelled) return; - await fetchUserNames(plugin, client); - if (!cancelled) setUserNames(plugin.settings.userNames ?? {}); - })(); - return () => { - cancelled = true; - }; - }, [app, plugin, candidateState]); - - return userNames; -}; - const formatTimestamp = (epochMs: number): string => new Date(epochMs).toLocaleString(undefined, { dateStyle: "medium", @@ -339,9 +277,20 @@ const NodeSearch = ({ const [activeIndex, setActiveIndex] = useState(0); // Single source of truth: ENG-2111's tag chips will read and write this too. const [selectedNodeTypeIds, setSelectedNodeTypeIds] = useState([]); - const [isTypeFilterOpen, setIsTypeFilterOpen] = useState(false); + // One value per toolbar, so two panels can never be open at once. + const [openDropdown, setOpenDropdown] = useState(null); + const [sortKey, setSortKey] = useState(DEFAULT_SORT_KEY); + const [sortDirection, setSortDirection] = useState( + DEFAULT_SORT_DIRECTION, + ); + // An editable span, so the query shares its line boxes with the filter chips. const inputRef = useRef(null); - const userNames = useAuthorNames({ app, plugin, candidateState }); + const userNames = useAuthorNames({ + app, + plugin, + candidates: + candidateState.status === "ready" ? candidateState.candidates : null, + }); const nodeTypesById = useMemo(() => { const byId = new Map(); @@ -381,12 +330,27 @@ const NodeSearch = ({ return () => window.clearTimeout(timeout); }, [query]); + // Sort before truncating, so a date or alphabetical sort covers every match. const results = useMemo(() => { if (candidateState.status !== "ready") return []; - return rankDiscourseNodesByTitle({ + const ranked = rankDiscourseNodesByTitle({ candidates: candidateState.candidates, query: debouncedQuery, nodeTypeIds: selectedNodeTypeIds, + }); + const authorNameByPath = + sortKey === "author" + ? buildAuthorNameByPath({ + app, + files: ranked.map((result) => result.file), + userNames, + }) + : undefined; + return sortSearchResults({ + results: ranked, + sortKey, + direction: sortDirection, + authorNameByPath, }) .slice(0, MAX_VISIBLE_RESULTS) .map((result) => ({ @@ -396,7 +360,16 @@ const NodeSearch = ({ badge: getFallbackNodeTypeBadge(result.title), }, })); - }, [candidateState, debouncedQuery, nodeTypesById, selectedNodeTypeIds]); + }, [ + app, + candidateState, + debouncedQuery, + nodeTypesById, + selectedNodeTypeIds, + sortDirection, + sortKey, + userNames, + ]); // A narrowing query rebuilds `results` before the effect below can reset the // state, so the old index can point past the new list for one render. Clamping @@ -441,10 +414,16 @@ const NodeSearch = ({ }); }; - const handleTypeFilterOpenChange = (nextOpen: boolean): void => { - setIsTypeFilterOpen(nextOpen); + const handleDropdownOpenChange = ({ + id, + isOpen, + }: { + id: NonNullable; + isOpen: boolean; + }): void => { + setOpenDropdown(isOpen ? id : null); // Returns the keyboard path to the results the moment the panel closes. - if (!nextOpen) inputRef.current?.focus(); + if (!isOpen) inputRef.current?.focus(); }; // Closes before inserting, like `openActiveResult`. @@ -502,9 +481,8 @@ const NodeSearch = ({ // Bound here rather than on the input so navigation survives focus moving // elsewhere in the modal, and so result actions have one place to live.
- {/* Padded so the filter trigger's count badge, which sits outside the - button box, is not clipped by the modal's overflow-hidden content. */} - {/* Top-aligned: the field grows downwards, so the trigger stays on its first line. */} + {/* Padded so a trigger's count badge is not clipped by the modal's overflow-hidden content. */} + {/* Top-aligned: the field grows downwards, so the triggers stay on its first line. */}
+ handleDropdownOpenChange({ id: "type-filter", isOpen }) + } onSelectedNodeTypeIdsChange={setSelectedNodeTypeIds} selectedNodeTypeIds={selectedNodeTypeIds} /> + + handleDropdownOpenChange({ id: "sort", isOpen }) + } + onSortChange={({ sortKey: nextKey, direction }) => { + setSortKey(nextKey); + setSortDirection(direction); + }} + sortDirection={sortDirection} + sortKey={sortKey} + />
diff --git a/apps/obsidian/src/components/NodeSortMenu.tsx b/apps/obsidian/src/components/NodeSortMenu.tsx new file mode 100644 index 000000000..ade1326ec --- /dev/null +++ b/apps/obsidian/src/components/NodeSortMenu.tsx @@ -0,0 +1,160 @@ +import { App, setIcon } from "obsidian"; +import type { KeyboardEvent, ReactElement } from "react"; +import { SearchDropdown } from "~/components/SearchDropdown"; +import { + SORT_OPTIONS, + getDefaultDirectionForKey, + getSortDirectionLabel, + getSortOptionLabel, + isDefaultSort, + type SortDirection, + type SortKey, +} from "~/utils/discourseNodeSort"; + +const DIRECTIONS: { direction: SortDirection; label: string }[] = [ + { direction: "asc", label: "Asc" }, + { direction: "desc", label: "Desc" }, +]; + +// Rows are divs, so Enter and Space have to be wired up the way a button gets them free. +const activateOnKey = ( + event: KeyboardEvent, + activate: () => void, +): void => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + activate(); +}; + +const getDirectionIconName = (direction: SortDirection): string => + direction === "asc" ? "arrow-up-narrow-wide" : "arrow-down-wide-narrow"; + +/** Rows are divs, not buttons: Obsidian's button chrome reads as separate widgets rather than a menu. */ +const SortOptionRow = ({ + isSelected, + label, + onSelect, +}: { + isSelected: boolean; + label: string; + onSelect: () => void; +}): ReactElement => ( +
activateOnKey(event, onSelect)} + onMouseDown={(event) => event.preventDefault()} + className={`flex cursor-pointer items-center gap-2 px-3 py-1.5 text-sm ${ + isSelected + ? "bg-accent text-on-accent" + : "text-normal hover:bg-modifier-hover" + }`} + > + {/* Always occupies its slot, so selecting an option does not shift the labels. */} + + {isSelected && ( + (el && setIcon(el, "check")) || undefined} /> + )} + + {label} +
+); + +const DirectionToggle = ({ + onSelect, + sortDirection, + sortKey, +}: { + onSelect: (direction: SortDirection) => void; + sortDirection: SortDirection; + sortKey: SortKey; +}): ReactElement => ( +
+ {DIRECTIONS.map(({ direction, label }) => ( +
onSelect(direction)} + onKeyDown={(event) => activateOnKey(event, () => onSelect(direction))} + onMouseDown={(event) => event.preventDefault()} + className={`flex flex-1 cursor-pointer items-center justify-center gap-1 rounded px-2 py-1 text-sm ${ + direction === sortDirection + ? "bg-accent text-on-accent" + : "text-normal hover:bg-modifier-hover" + }`} + > + + (el && setIcon(el, getDirectionIconName(direction))) || undefined + } + /> + {label} +
+ ))} +
+); + +export const NodeSortMenu = ({ + app, + isOpen, + onOpenChange, + onSortChange, + sortDirection, + sortKey, +}: { + app: App; + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + onSortChange: (next: { sortKey: SortKey; direction: SortDirection }) => void; + sortDirection: SortDirection; + sortKey: SortKey; +}): ReactElement => { + const directionLabel = getSortDirectionLabel({ + sortKey, + direction: sortDirection, + }); + + return ( + +
+
Sort by
+ {SORT_OPTIONS.map((option) => ( + + onSortChange({ + sortKey: option.key, + direction: + option.key === sortKey + ? sortDirection + : getDefaultDirectionForKey(option.key), + }) + } + /> + ))} +
+ onSortChange({ sortKey, direction })} + sortDirection={sortDirection} + sortKey={sortKey} + /> +
+ ); +}; diff --git a/apps/obsidian/src/components/NodeTypeFilterMenu.tsx b/apps/obsidian/src/components/NodeTypeFilterMenu.tsx index 1c166ed41..e6f9e95a6 100644 --- a/apps/obsidian/src/components/NodeTypeFilterMenu.tsx +++ b/apps/obsidian/src/components/NodeTypeFilterMenu.tsx @@ -1,5 +1,6 @@ -import { App, Scope, setIcon } from "obsidian"; +import { App } from "obsidian"; import { useEffect, useMemo, useRef, useState, type ReactElement } from "react"; +import { SearchDropdown } from "~/components/SearchDropdown"; import { DiscourseNode } from "~/types"; import { getAllDiscourseNodeColors } from "~/utils/colorUtils"; import { @@ -10,19 +11,6 @@ import { toPanelSelectedIds, } from "~/utils/discourseNodeTypeFilter"; -const FilterIcon = ({ name }: { name: string }): ReactElement => ( - // Emptied first because React reuses the node across renders and `setIcon` - // appends rather than replaces. - { - if (!el) return; - el.empty(); - setIcon(el, name); - }} - /> -); - const NodeTypeFilterRow = ({ color, isChecked, @@ -112,7 +100,7 @@ const NodeTypeFilterPanel = ({ }; return ( -
+ <> {/* Clearing is the only thing this control ever does, so it says so and appears only when there is a filter to clear. A "select all" checkbox would sit checked-and-inert whenever no filter is active, since an empty @@ -159,7 +147,7 @@ const NodeTypeFilterPanel = ({ )) )}
-
+ ); }; @@ -178,8 +166,6 @@ export const NodeTypeFilterMenu = ({ onSelectedNodeTypeIdsChange: (ids: string[]) => void; selectedNodeTypeIds: string[]; }): ReactElement => { - const containerRef = useRef(null); - const allTypeIds = useMemo( () => nodeTypes.map((nodeType) => nodeType.id), [nodeTypes], @@ -196,96 +182,39 @@ export const NodeTypeFilterMenu = ({ [allTypeIds, selectedNodeTypeIds], ); - // Escape cannot be intercepted from the DOM. Obsidian registers the Modal's - // close-on-Escape before any plugin React tree exists, so a listener added - // later always runs second — preventDefault plus stopImmediatePropagation in a - // React handler, a capture-phase window listener, and registering on the - // Modal's own scope all fail, the last because Scope resolves in registration - // order. A pushed scope is the only thing that lands above the modal. - useEffect(() => { - if (!isOpen) return; - const scope = new Scope(); - scope.register([], "Escape", () => { - onOpenChange(false); - return false; - }); - app.keymap.pushScope(scope); - return () => app.keymap.popScope(scope); - }, [app, isOpen, onOpenChange]); - - // `activeDocument` rather than `document`, so the listener lands in whichever - // window holds the modal when Obsidian is running a popout. - useEffect(() => { - if (!isOpen) return; - const handlePointerDown = (event: MouseEvent) => { - if (containerRef.current?.contains(event.target as Node)) return; - onOpenChange(false); - }; - activeDocument.addEventListener("mousedown", handlePointerDown, true); - return () => - activeDocument.removeEventListener("mousedown", handlePointerDown, true); - }, [isOpen, onOpenChange]); - const activeFilterCount = isFilterActive ? selectedNodeTypeIds.length : 0; return ( -
{ - if (!isOpen) return; - // Every keystroke stops here while the panel is open. The modal's handler - // is an ancestor and reads Enter as "open the highlighted result" and the - // arrows as "move the selection", so typing in the type search would - // otherwise open a note and close the whole modal. Escape is not handled - // here because it never reaches the DOM — see the pushed scope above. - event.stopPropagation(); - }} + 0 + ? `Filter by type, ${activeFilterCount} selected` + : "Filter by type" + } + badgeCount={activeFilterCount} + iconName="filter" + isActive={isFilterActive} + isDisabled={nodeTypes.length === 0} + isOpen={isOpen} + onOpenChange={onOpenChange} + panelClassName="w-64" + title={ + nodeTypes.length === 0 + ? "No discourse node types configured" + : "Filter by type" + } > - - {isOpen && ( - - onSelectedNodeTypeIdsChange( - fromPanelSelectedIds({ panelSelectedIds: panelIds, allTypeIds }), - ) - } - selectedIds={panelSelectedIds} - /> - )} -
+ selectedIds={panelSelectedIds} + /> + ); }; diff --git a/apps/obsidian/src/components/SearchDropdown.tsx b/apps/obsidian/src/components/SearchDropdown.tsx new file mode 100644 index 000000000..d4030164e --- /dev/null +++ b/apps/obsidian/src/components/SearchDropdown.tsx @@ -0,0 +1,101 @@ +import { App, Scope, setIcon } from "obsidian"; +import { useEffect, useRef, type ReactElement, type ReactNode } from "react"; + +/** Which toolbar panel is open, so two can never be open at once. */ +export type SearchDropdownId = "type-filter" | "sort" | null; + +export const SearchDropdown = ({ + app, + ariaLabel, + badgeCount = 0, + children, + iconName, + isActive, + isDisabled = false, + isOpen, + onOpenChange, + panelClassName = "w-64", + title, +}: { + app: App; + ariaLabel: string; + badgeCount?: number; + children: ReactNode; + iconName: string; + isActive: boolean; + isDisabled?: boolean; + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + panelClassName?: string; + title: string; +}): ReactElement => { + const containerRef = useRef(null); + + // Obsidian's modal Escape is registered before React exists, and wins on its own scope too, so only a pushed scope gets it first. + useEffect(() => { + if (!isOpen) return; + const scope = new Scope(); + scope.register([], "Escape", () => { + onOpenChange(false); + return false; + }); + app.keymap.pushScope(scope); + return () => app.keymap.popScope(scope); + }, [app, isOpen, onOpenChange]); + + // `activeDocument`, so the listener lands in the popout window holding the modal. + useEffect(() => { + if (!isOpen) return; + const handlePointerDown = (event: MouseEvent) => { + if (containerRef.current?.contains(event.target as Node)) return; + onOpenChange(false); + }; + activeDocument.addEventListener("mousedown", handlePointerDown, true); + return () => + activeDocument.removeEventListener("mousedown", handlePointerDown, true); + }, [isOpen, onOpenChange]); + + return ( +
{ + if (!isOpen) return; + // Panel keystrokes must not reach the modal's Enter and arrow result navigation; Escape never arrives here at all. + event.stopPropagation(); + }} + > + + {isOpen && ( +
+ {children} +
+ )} +
+ ); +}; diff --git a/apps/obsidian/src/utils/discourseNodeAuthor.ts b/apps/obsidian/src/utils/discourseNodeAuthor.ts new file mode 100644 index 000000000..57efa0fb9 --- /dev/null +++ b/apps/obsidian/src/utils/discourseNodeAuthor.ts @@ -0,0 +1,93 @@ +import { App, TFile } from "obsidian"; +import { useEffect, useState } from "react"; +import type DiscourseGraphPlugin from "~/index"; +import type { DiscourseNodeCandidate } from "~/services/QueryEngine"; +import { fetchUserNames } from "~/utils/importNodes"; +import { getLoggedInClient } from "~/utils/supabaseContext"; + +/** Single source for a note's author name. Obsidian exposes no Sync user API, so names come from `authorId` frontmatter. */ + +export const LOCAL_AUTHOR_NAME = "You"; +export const UNRESOLVED_AUTHOR_NAME = "Unknown"; + +/** Frontmatter is untyped, so the raw value is narrowed by each caller. */ +const getFrontmatterAuthorId = (app: App, file: TFile): unknown => { + // Annotated rather than asserted: `FrontMatterCache` indexes to `any`, so a cast is a no-op. + const frontmatter: Record | undefined = + app.metadataCache.getFileCache(file)?.frontmatter; + return frontmatter?.authorId; +}; + +/** "You" only when there is no `authorId`; a present but unresolvable id stays "Unknown". */ +export const resolveAuthorName = ({ + app, + file, + userNames, +}: { + app: App; + file: TFile; + userNames: Record; +}): string => { + const authorId = getFrontmatterAuthorId(app, file); + if (authorId === undefined || authorId === null) return LOCAL_AUTHOR_NAME; + if (typeof authorId !== "number") return UNRESOLVED_AUTHOR_NAME; + return userNames[authorId] ?? UNRESOLVED_AUTHOR_NAME; +}; + +export const isUnattributedAuthorName = (authorName: string): boolean => + authorName === UNRESOLVED_AUTHOR_NAME; + +export const buildAuthorNameByPath = ({ + app, + files, + userNames, +}: { + app: App; + files: TFile[]; + userNames: Record; +}): Map => { + const byPath = new Map(); + files.forEach((file) => { + byPath.set(file.path, resolveAuthorName({ app, file, userNames })); + }); + return byPath; +}; + +/** One query returns every person, so this refreshes once per open when a name is missing. */ +export const useAuthorNames = ({ + app, + plugin, + candidates, +}: { + app: App; + plugin: DiscourseGraphPlugin; + candidates: DiscourseNodeCandidate[] | null; +}): Record => { + const [userNames, setUserNames] = useState(plugin.settings.userNames ?? {}); + + useEffect(() => { + if (!candidates) return; + if (!plugin.settings.syncModeEnabled) return; + + const isMissingName = (candidate: DiscourseNodeCandidate): boolean => { + const authorId = getFrontmatterAuthorId(app, candidate.file); + return ( + typeof authorId === "number" && !plugin.settings.userNames?.[authorId] + ); + }; + if (!candidates.some(isMissingName)) return; + + let cancelled = false; + void (async () => { + const client = await getLoggedInClient(plugin); + if (!client || cancelled) return; + await fetchUserNames(plugin, client); + if (!cancelled) setUserNames(plugin.settings.userNames ?? {}); + })(); + return () => { + cancelled = true; + }; + }, [app, plugin, candidates]); + + return userNames; +}; diff --git a/apps/obsidian/src/utils/discourseNodeSort.ts b/apps/obsidian/src/utils/discourseNodeSort.ts new file mode 100644 index 000000000..cd1b9b737 --- /dev/null +++ b/apps/obsidian/src/utils/discourseNodeSort.ts @@ -0,0 +1,140 @@ +import { TFile } from "obsidian"; +import { isUnattributedAuthorName } from "~/utils/discourseNodeAuthor"; + +/** Client-side result ordering, over the full ranked list before display truncation. Mirrors Roam's advanced search. */ + +export type SortKey = + | "relevance" + | "title" + | "dateCreated" + | "dateModified" + | "author"; + +export type SortDirection = "asc" | "desc"; + +export const SORT_OPTIONS: { key: SortKey; label: string }[] = [ + { key: "relevance", label: "Relevance" }, + { key: "title", label: "Alphabetical" }, + { key: "dateCreated", label: "Date created" }, + { key: "dateModified", label: "Date modified" }, + { key: "author", label: "Author" }, +]; + +export const DEFAULT_SORT_KEY: SortKey = "relevance"; +export const DEFAULT_SORT_DIRECTION: SortDirection = "desc"; + +/** Structural, so the sort runs on ranked results or decorated rows alike. */ +export type SortableSearchResult = { + file: TFile; + title: string; + match: { score: number }; +}; + +const DIRECTION_LABELS: Record> = { + relevance: { desc: "Best match first", asc: "Worst match first" }, + title: { asc: "A to Z", desc: "Z to A" }, + dateCreated: { desc: "Newest first", asc: "Oldest first" }, + dateModified: { desc: "Newest first", asc: "Oldest first" }, + author: { asc: "A to Z", desc: "Z to A" }, +}; + +export const getSortDirectionLabel = ({ + sortKey, + direction, +}: { + sortKey: SortKey; + direction: SortDirection; +}): string => DIRECTION_LABELS[sortKey][direction]; + +/** "Descending" means something different per dimension, so switching resets it. */ +export const getDefaultDirectionForKey = (sortKey: SortKey): SortDirection => + sortKey === "title" || sortKey === "author" ? "asc" : "desc"; + +export const isDefaultSort = ({ + sortKey, + direction, +}: { + sortKey: SortKey; + direction: SortDirection; +}): boolean => + sortKey === DEFAULT_SORT_KEY && direction === DEFAULT_SORT_DIRECTION; + +export const getSortOptionLabel = (sortKey: SortKey): string => + SORT_OPTIONS.find((option) => option.key === sortKey)?.label ?? ""; + +const getAuthorName = ({ + result, + authorNameByPath, +}: { + result: SortableSearchResult; + authorNameByPath: Map | undefined; +}): string => authorNameByPath?.get(result.file.path) ?? ""; + +/** Unattributed notes sit after every named author, in both directions. */ +const compareUnattributedLast = ({ + a, + b, + authorNameByPath, +}: { + a: SortableSearchResult; + b: SortableSearchResult; + authorNameByPath: Map | undefined; +}): number => { + const isAUnattributed = isUnattributedAuthorName( + getAuthorName({ result: a, authorNameByPath }), + ); + const isBUnattributed = isUnattributedAuthorName( + getAuthorName({ result: b, authorNameByPath }), + ); + if (isAUnattributed === isBUnattributed) return 0; + return isAUnattributed ? 1 : -1; +}; + +const compareAscending = ({ + a, + b, + sortKey, + authorNameByPath, +}: { + a: SortableSearchResult; + b: SortableSearchResult; + sortKey: SortKey; + authorNameByPath: Map | undefined; +}): number => { + if (sortKey === "relevance") return a.match.score - b.match.score; + if (sortKey === "title") return a.title.localeCompare(b.title); + if (sortKey === "dateCreated") return a.file.stat.ctime - b.file.stat.ctime; + if (sortKey === "dateModified") return a.file.stat.mtime - b.file.stat.mtime; + + const authorDelta = getAuthorName({ + result: a, + authorNameByPath, + }).localeCompare(getAuthorName({ result: b, authorNameByPath })); + return authorDelta !== 0 ? authorDelta : a.title.localeCompare(b.title); +}; + +/** `authorNameByPath` is only needed for the author sort. */ +export const sortSearchResults = ({ + results, + sortKey, + direction, + authorNameByPath, +}: { + results: T[]; + sortKey: SortKey; + direction: SortDirection; + authorNameByPath?: Map; +}): T[] => + [...results].sort((a, b) => { + if (sortKey === "author") { + // Outside the direction flip on purpose: this partition is not reversible. + const unattributedDelta = compareUnattributedLast({ + a, + b, + authorNameByPath, + }); + if (unattributedDelta !== 0) return unattributedDelta; + } + const delta = compareAscending({ a, b, sortKey, authorNameByPath }); + return direction === "asc" ? delta : -delta; + });