refactor(search): extract search into workspace package

This commit is contained in:
Maofeng
2026-09-20 15:52:09 +08:00
parent e02c3dceb5
commit 1fc56b6982
34 changed files with 262 additions and 360 deletions
@@ -1,54 +0,0 @@
import * as React from "react"
import type { SearchAdapter, SearchSelectionHandler } from "./types"
export interface SearchContextValue {
adapter: SearchAdapter
historyLimit: number
historyStorageKey?: string
onSelect?: SearchSelectionHandler
}
const SearchContext = React.createContext<SearchContextValue | null>(null)
export interface SearchProviderProps {
adapter: SearchAdapter
children: React.ReactNode
/** Maximum number of recent queries persisted by the default history hook. */
historyLimit?: number
/** Set to false to keep search history only in memory. */
historyStorageKey?: string | false
onSelect?: SearchSelectionHandler
}
/** Supplies the host application's search implementation to the search blocks. */
export function SearchProvider({
adapter,
children,
historyLimit = 32,
historyStorageKey = "workspace-search-history",
onSelect,
}: SearchProviderProps) {
const value = React.useMemo<SearchContextValue>(
() => ({
adapter,
historyLimit: Math.max(1, Math.floor(historyLimit)),
historyStorageKey:
historyStorageKey === false ? undefined : historyStorageKey,
onSelect,
}),
[adapter, historyLimit, historyStorageKey, onSelect]
)
return (
<SearchContext.Provider value={value}>{children}</SearchContext.Provider>
)
}
export function useSearchContext(): SearchContextValue {
const value = React.useContext(SearchContext)
if (!value) {
throw new Error("Search blocks must be rendered inside a SearchProvider.")
}
return value
}
@@ -1,218 +0,0 @@
import * as React from "react"
import { Command } from "cmdk"
import { useHotkey } from "@tanstack/react-hotkeys"
import { useTranslate } from "@workspace/i18n"
import { Button } from "@workspace/ui/components/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@workspace/ui/components/dialog"
import { cn } from "@workspace/ui/lib/utils"
import { LoaderCircleIcon, SearchIcon, XIcon } from "lucide-react"
import { useSearchContext } from "./context"
import { searchDialogHandle } from "./handle"
import { SearchHistory } from "./history"
import { searchMessages } from "./messages"
import { SearchResults } from "./results"
import {
SearchEmpty,
SearchError,
SearchInitial,
SearchLoading,
} from "./states"
import type { SearchResultItem, SearchSelectionHandler } from "./types"
import { useSearch } from "./use-search"
import { useSearchHistory } from "./use-search-history"
export interface SearchDialogProps {
className?: string
/** Set to false when the host application registers its own shortcut. */
hotkey?: Parameters<typeof useHotkey>[0] | false
onOpenChange?: (open: boolean) => void
onSelect?: SearchSelectionHandler
placeholder?: string
title?: string
}
/**
* A command-style global search dialog. Data fetching and result selection are
* delegated to the nearest SearchProvider.
*/
export function SearchDialog({
className,
hotkey = "Mod+K",
onOpenChange,
onSelect,
placeholder,
title,
}: SearchDialogProps) {
const {
historyLimit,
historyStorageKey,
onSelect: contextOnSelect,
} = useSearchContext()
const t = useTranslate()
const [open, setOpen] = React.useState(false)
const [query, setQuery] = React.useState("")
const deferredQuery = React.useDeferredValue(query.trim())
const history = useSearchHistory({
limit: historyLimit,
storageKey: historyStorageKey,
})
const search = useSearch({ active: open, query: deferredQuery })
const hasResults = search.groups.some((group) => group.items.length > 0)
const setDialogOpen = React.useCallback(
(nextOpen: boolean) => {
setOpen(nextOpen)
onOpenChange?.(nextOpen)
},
[onOpenChange]
)
const toggle = React.useCallback(() => {
if (open) {
searchDialogHandle.close()
} else {
searchDialogHandle.open(null)
}
}, [open])
const selectResult = React.useCallback(
(item: SearchResultItem) => {
history.add(query)
setQuery("")
searchDialogHandle.close()
const selectionHandler = onSelect ?? contextOnSelect
selectionHandler?.({ item, query: query.trim() })
},
[contextOnSelect, history, onSelect, query]
)
return (
<Dialog handle={searchDialogHandle} onOpenChange={setDialogOpen}>
{hotkey && <SearchHotkey hotkey={hotkey} onToggle={toggle} />}
<DialogHeader className="sr-only">
<DialogTitle>{title ?? t(searchMessages.title)}</DialogTitle>
<DialogDescription>{t(searchMessages.description)}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn(
"top-20 flex max-h-[calc(100dvh-2rem)] min-h-0 translate-y-0 flex-col gap-0 overflow-hidden bg-popover p-0 sm:max-w-[min(94vw,48rem)]",
className
)}
showCloseButton={false}
>
<Command
className="group/search flex min-h-0 flex-col overflow-hidden"
shouldFilter={false}
>
<div className="flex h-14 items-center gap-2 border-b bg-popover px-3 py-1 text-popover-foreground">
<label className="flex h-full min-w-0 flex-1 items-center gap-2">
<span className="inline-flex size-8 shrink-0 items-center justify-center">
{search.isLoading || search.isLoadingMore ? (
<LoaderCircleIcon className="animate-spin text-primary" />
) : (
<SearchIcon className="opacity-50" />
)}
</span>
<Command.Input
autoFocus
value={query}
onValueChange={setQuery}
className="min-w-0 flex-1 border-none bg-transparent text-lg outline-hidden disabled:cursor-not-allowed disabled:opacity-50"
placeholder={placeholder ?? t(searchMessages.placeholder)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.nativeEvent.isComposing) {
history.add(query)
}
}}
/>
</label>
{query.trim() && (
<>
<button
type="button"
className="shrink-0 appearance-none bg-transparent p-2 text-foreground underline underline-offset-4 transition-colors outline-none hover:text-destructive"
onClick={() => setQuery("")}
>
{t(searchMessages.clear)}
</button>
<div className="h-4 w-px shrink-0 bg-border" />
</>
)}
<Button
type="button"
size="icon"
variant="ghost"
className="shrink-0 text-foreground/75 hover:text-destructive"
onClick={() => searchDialogHandle.close()}
aria-label={t(searchMessages.close)}
>
<XIcon className="size-5" />
</Button>
</div>
{search.error && !hasResults ? (
<div className="flex min-h-0 flex-1 flex-col">
<SearchError query={deferredQuery} />
<div className="pb-6 text-center">
<Button variant="outline" onClick={search.retry}>
{t(searchMessages.retry)}
</Button>
</div>
</div>
) : search.isLoading ? (
<SearchLoading />
) : hasResults ? (
<SearchResults
groups={search.groups}
query={deferredQuery}
onSelect={selectResult}
onLoadMore={search.loadMore}
hasMore={search.hasMore}
isLoadingMore={search.isLoadingMore}
/>
) : deferredQuery ? (
<SearchEmpty query={deferredQuery} />
) : history.items.length > 0 ? (
<SearchHistory
items={history.items}
onSelect={setQuery}
onRemove={history.remove}
onClear={history.clear}
/>
) : (
<SearchInitial />
)}
</Command>
</DialogContent>
</Dialog>
)
}
function SearchHotkey({
hotkey,
onToggle,
}: {
hotkey: Parameters<typeof useHotkey>[0]
onToggle: VoidFunction
}) {
const t = useTranslate()
useHotkey(hotkey, onToggle, {
ignoreInputs: true,
preventDefault: true,
stopPropagation: false,
meta: {
name: t(searchMessages.title),
description: t(searchMessages.commandDescription),
},
})
return null
}
@@ -1,4 +0,0 @@
import { Dialog } from "@base-ui/react/dialog"
/** A shared handle for the optional global-search trigger. */
export const searchDialogHandle = Dialog.createHandle<void>()
@@ -1,35 +0,0 @@
export interface HighlightSegment {
highlighted: boolean
text: string
}
function escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
}
export function splitHighlightSegments(
text: string,
query: string
): readonly HighlightSegment[] {
const normalizedQuery = query.trim()
if (!normalizedQuery) return [{ highlighted: false, text }]
const matcher = new RegExp(escapeRegExp(normalizedQuery), "giu")
const segments: HighlightSegment[] = []
let cursor = 0
for (const match of text.matchAll(matcher)) {
const index = match.index ?? 0
if (index > cursor) {
segments.push({ highlighted: false, text: text.slice(cursor, index) })
}
segments.push({ highlighted: true, text: match[0] })
cursor = index + match[0].length
}
if (cursor < text.length) {
segments.push({ highlighted: false, text: text.slice(cursor) })
}
return segments.length > 0 ? segments : [{ highlighted: false, text }]
}
@@ -1,79 +0,0 @@
import { Command } from "cmdk"
import { useTranslate } from "@workspace/i18n"
import { Button } from "@workspace/ui/components/button"
import { ScrollArea } from "@workspace/ui/components/scroll-area"
import { cn } from "@workspace/ui/lib/utils"
import { HistoryIcon, Trash2Icon, XIcon } from "lucide-react"
import { searchMessages } from "./messages"
export interface SearchHistoryProps {
className?: string
items: readonly string[]
onSelect: (keyword: string) => void
onRemove: (keyword: string) => void
onClear: () => void
}
export function SearchHistory({
className,
items,
onClear,
onRemove,
onSelect,
}: SearchHistoryProps) {
const t = useTranslate()
return (
<div
className={cn("flex h-[min(65dvh,36rem)] min-h-0 flex-col", className)}
>
<ScrollArea className="min-h-0 flex-1 p-4">
<div className="sticky top-0 z-1 flex items-center justify-between bg-sidebar/90 ps-2 pb-2 backdrop-blur-xs">
<span className="flex items-center gap-2 text-sm font-medium">
{t(searchMessages.history)}
</span>
<Button
type="button"
variant="ghost"
className="hover:bg-destructive/10 hover:text-destructive"
size="xs"
onClick={onClear}
>
<Trash2Icon />
{t(searchMessages.clearHistory)}
</Button>
</div>
<Command.List
data-slot="command-list"
className="gap-1 px-1 pb-1 **:[[cmdk-list-sizer]]:space-y-1"
>
{items.map((item) => (
<Command.Item
key={item}
value={item}
onSelect={() => onSelect(item)}
className="flex cursor-pointer items-center gap-2 rounded-xl border border-sidebar-border/55 bg-popover p-2 shadow-xs transition-colors hover:bg-secondary [&>svg]:last:hidden"
>
<HistoryIcon className="size-4 text-muted-foreground opacity-65" />
<span className="min-w-0 flex-1 truncate text-left">{item}</span>
<Button
type="button"
variant="destructive"
size="icon-xs"
className="translate-x-1 opacity-100 group-hover/command-item:opacity-70 hover:opacity-100"
aria-label={t(searchMessages.removeHistory, { query: item })}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
onRemove(item)
}}
>
<XIcon />
</Button>
</Command.Item>
))}
</Command.List>
</ScrollArea>
</div>
)
}
@@ -1,16 +0,0 @@
export { SearchProvider, useSearchContext } from "./context"
export type { SearchContextValue, SearchProviderProps } from "./context"
export { SearchDialog, type SearchDialogProps } from "./dialog"
export { searchDialogHandle } from "./handle"
export { splitHighlightSegments, type HighlightSegment } from "./highlight"
export { SearchTrigger, type SearchTriggerProps } from "./trigger"
export type {
SearchAdapter,
SearchCursor,
SearchPage,
SearchRequest,
SearchResultGroup,
SearchResultItem,
SearchSelectionEvent,
SearchSelectionHandler,
} from "./types"
@@ -1,34 +0,0 @@
import { describe, it } from "vitest"
import { expectCompleteCatalogs } from "../../../i18n/test-catalogs"
import { searchMessages } from "../messages"
import { searchCatalogLocales } from "./catalogs"
import { messages as de } from "./de"
import { messages as en } from "./en"
import { messages as es } from "./es"
import { messages as fr } from "./fr"
import { messages as ja } from "./ja"
import { messages as ko } from "./ko"
import { messages as zhHans } from "./zh-Hans"
import { messages as zhHant } from "./zh-Hant"
describe("search locale catalogs", () => {
it("ship every search message in every built-in locale", () => {
expectCompleteCatalogs({
catalogs: {
de,
en,
es,
fr,
ja,
ko,
"zh-Hans": zhHans,
"zh-Hant": zhHant,
},
locales: searchCatalogLocales,
messageIds: Object.values(searchMessages).map(
(descriptor) => descriptor.id
),
})
})
})
@@ -1,10 +0,0 @@
import type { searchMessages } from "../messages"
import {
blockCatalogLocales,
type BlockCatalogLocale,
type BlockMessageCatalog,
} from "../../../i18n/catalogs"
export { blockCatalogLocales as searchCatalogLocales }
export type SearchCatalogLocale = BlockCatalogLocale
export type SearchMessageCatalog = BlockMessageCatalog<typeof searchMessages>
@@ -1,29 +0,0 @@
import type { SearchMessageCatalog } from "./catalogs"
export const locale = "de"
export const languageTag = "de-DE"
export const messages = {
"blocks.search.actions.clear": "Suche löschen",
"blocks.search.actions.clearHistory": "Verlauf löschen",
"blocks.search.actions.close": "Suche schließen",
"blocks.search.actions.loadMore": "Mehr laden",
"blocks.search.actions.loadingMore": "Wird geladen…",
"blocks.search.actions.retry": "Erneut versuchen",
"blocks.search.actions.selectResult": "{title} öffnen",
"blocks.search.command.description": "Im gesamten Arbeitsbereich suchen",
"blocks.search.description": "Inhalte im gesamten Arbeitsbereich finden",
"blocks.search.empty.description": "Keine Ergebnisse für „{query}“",
"blocks.search.empty.title": "Keine Ergebnisse gefunden",
"blocks.search.error.description":
"Prüfe deine Verbindung und versuche es erneut.",
"blocks.search.error.title": "Suche nach „{query}“ fehlgeschlagen",
"blocks.search.history.remove": "„{query}“ aus den letzten Suchen entfernen",
"blocks.search.history.title": "Letzte Suchen",
"blocks.search.initial.description":
"Gib ein Stichwort ein, um den Arbeitsbereich zu durchsuchen.",
"blocks.search.initial.title": "Suche starten",
"blocks.search.loading": "Suche läuft…",
"blocks.search.placeholder": "Arbeitsbereich durchsuchen…",
"blocks.search.results.count": "{count} Ergebnisse",
"blocks.search.title": "Suche",
} as const satisfies SearchMessageCatalog
@@ -1,28 +0,0 @@
import type { SearchMessageCatalog } from "./catalogs"
export const locale = "en"
export const languageTag = "en-US"
export const messages = {
"blocks.search.actions.clear": "Clear query",
"blocks.search.actions.clearHistory": "Clear history",
"blocks.search.actions.close": "Close search",
"blocks.search.actions.loadMore": "Load more",
"blocks.search.actions.loadingMore": "Loading…",
"blocks.search.actions.retry": "Try again",
"blocks.search.actions.selectResult": "Open {title}",
"blocks.search.command.description": "Search across your workspace",
"blocks.search.description": "Find content across your workspace",
"blocks.search.empty.description": "No results for “{query}”",
"blocks.search.empty.title": "No results found",
"blocks.search.error.description": "Check your connection and try again.",
"blocks.search.error.title": "Could not search for “{query}”",
"blocks.search.history.remove": "Remove “{query}” from recent searches",
"blocks.search.history.title": "Recent searches",
"blocks.search.initial.description":
"Enter a keyword to search your workspace.",
"blocks.search.initial.title": "Start searching",
"blocks.search.loading": "Searching…",
"blocks.search.placeholder": "Search your workspace…",
"blocks.search.results.count": "{count} results",
"blocks.search.title": "Search",
} as const satisfies SearchMessageCatalog
@@ -1,30 +0,0 @@
import type { SearchMessageCatalog } from "./catalogs"
export const locale = "es"
export const languageTag = "es-ES"
export const messages = {
"blocks.search.actions.clear": "Borrar búsqueda",
"blocks.search.actions.clearHistory": "Borrar historial",
"blocks.search.actions.close": "Cerrar búsqueda",
"blocks.search.actions.loadMore": "Cargar más",
"blocks.search.actions.loadingMore": "Cargando…",
"blocks.search.actions.retry": "Intentar de nuevo",
"blocks.search.actions.selectResult": "Abrir {title}",
"blocks.search.command.description": "Buscar en todo tu espacio de trabajo",
"blocks.search.description":
"Encuentra contenido en todo tu espacio de trabajo",
"blocks.search.empty.description": "No hay resultados para «{query}»",
"blocks.search.empty.title": "No se encontraron resultados",
"blocks.search.error.description":
"Comprueba tu conexión e inténtalo de nuevo.",
"blocks.search.error.title": "No se pudo buscar «{query}»",
"blocks.search.history.remove": "Quitar «{query}» de las búsquedas recientes",
"blocks.search.history.title": "Búsquedas recientes",
"blocks.search.initial.description":
"Introduce una palabra clave para buscar en tu espacio de trabajo.",
"blocks.search.initial.title": "Empezar a buscar",
"blocks.search.loading": "Buscando…",
"blocks.search.placeholder": "Buscar en tu espacio de trabajo…",
"blocks.search.results.count": "{count} resultados",
"blocks.search.title": "Buscar",
} as const satisfies SearchMessageCatalog
@@ -1,30 +0,0 @@
import type { SearchMessageCatalog } from "./catalogs"
export const locale = "fr"
export const languageTag = "fr-FR"
export const messages = {
"blocks.search.actions.clear": "Effacer la recherche",
"blocks.search.actions.clearHistory": "Effacer lhistorique",
"blocks.search.actions.close": "Fermer la recherche",
"blocks.search.actions.loadMore": "Charger plus",
"blocks.search.actions.loadingMore": "Chargement…",
"blocks.search.actions.retry": "Réessayer",
"blocks.search.actions.selectResult": "Ouvrir {title}",
"blocks.search.command.description":
"Rechercher dans tout lespace de travail",
"blocks.search.description":
"Trouver du contenu dans votre espace de travail",
"blocks.search.empty.description": "Aucun résultat pour « {query} »",
"blocks.search.empty.title": "Aucun résultat trouvé",
"blocks.search.error.description": "Vérifiez votre connexion et réessayez.",
"blocks.search.error.title": "Impossible de rechercher « {query} »",
"blocks.search.history.remove": "Retirer « {query} » des recherches récentes",
"blocks.search.history.title": "Recherches récentes",
"blocks.search.initial.description":
"Saisissez un mot-clé pour rechercher dans votre espace de travail.",
"blocks.search.initial.title": "Commencer la recherche",
"blocks.search.loading": "Recherche…",
"blocks.search.placeholder": "Rechercher dans lespace de travail…",
"blocks.search.results.count": "{count} résultats",
"blocks.search.title": "Rechercher",
} as const satisfies SearchMessageCatalog
@@ -1,28 +0,0 @@
import type { SearchMessageCatalog } from "./catalogs"
export const locale = "ja"
export const languageTag = "ja-JP"
export const messages = {
"blocks.search.actions.clear": "検索をクリア",
"blocks.search.actions.clearHistory": "履歴を削除",
"blocks.search.actions.close": "検索を閉じる",
"blocks.search.actions.loadMore": "さらに読み込む",
"blocks.search.actions.loadingMore": "読み込み中…",
"blocks.search.actions.retry": "再試行",
"blocks.search.actions.selectResult": "{title} を開く",
"blocks.search.command.description": "ワークスペース全体を検索",
"blocks.search.description": "ワークスペース全体からコンテンツを探す",
"blocks.search.empty.description": "「{query}」の結果はありません",
"blocks.search.empty.title": "結果が見つかりません",
"blocks.search.error.description": "接続を確認して、もう一度お試しください。",
"blocks.search.error.title": "「{query}」を検索できませんでした",
"blocks.search.history.remove": "最近の検索から「{query}」を削除",
"blocks.search.history.title": "最近の検索",
"blocks.search.initial.description":
"キーワードを入力してワークスペースを検索します。",
"blocks.search.initial.title": "検索を始める",
"blocks.search.loading": "検索中…",
"blocks.search.placeholder": "ワークスペースを検索…",
"blocks.search.results.count": "{count} 件の結果",
"blocks.search.title": "検索",
} as const satisfies SearchMessageCatalog
@@ -1,28 +0,0 @@
import type { SearchMessageCatalog } from "./catalogs"
export const locale = "ko"
export const languageTag = "ko-KR"
export const messages = {
"blocks.search.actions.clear": "검색어 지우기",
"blocks.search.actions.clearHistory": "기록 지우기",
"blocks.search.actions.close": "검색 닫기",
"blocks.search.actions.loadMore": "더 불러오기",
"blocks.search.actions.loadingMore": "불러오는 중…",
"blocks.search.actions.retry": "다시 시도",
"blocks.search.actions.selectResult": "{title} 열기",
"blocks.search.command.description": "작업 공간 전체 검색",
"blocks.search.description": "작업 공간 전체에서 콘텐츠 찾기",
"blocks.search.empty.description": "“{query}”에 대한 결과가 없습니다",
"blocks.search.empty.title": "결과를 찾을 수 없습니다",
"blocks.search.error.description": "연결을 확인한 후 다시 시도하세요.",
"blocks.search.error.title": "“{query}”을(를) 검색할 수 없습니다",
"blocks.search.history.remove": "최근 검색에서 “{query}” 제거",
"blocks.search.history.title": "최근 검색",
"blocks.search.initial.description":
"키워드를 입력하여 작업 공간을 검색하세요.",
"blocks.search.initial.title": "검색 시작",
"blocks.search.loading": "검색 중…",
"blocks.search.placeholder": "작업 공간 검색…",
"blocks.search.results.count": "결과 {count}개",
"blocks.search.title": "검색",
} as const satisfies SearchMessageCatalog
@@ -1,27 +0,0 @@
import type { SearchMessageCatalog } from "./catalogs"
export const locale = "zh-Hans"
export const languageTag = "zh-CN"
export const messages = {
"blocks.search.actions.clear": "清除查询",
"blocks.search.actions.clearHistory": "清空历史",
"blocks.search.actions.close": "关闭搜索",
"blocks.search.actions.loadMore": "加载更多",
"blocks.search.actions.loadingMore": "正在加载…",
"blocks.search.actions.retry": "重试",
"blocks.search.actions.selectResult": "打开 {title}",
"blocks.search.command.description": "搜索整个工作区",
"blocks.search.description": "在工作区中查找内容",
"blocks.search.empty.description": "没有找到与“{query}”相关的内容",
"blocks.search.empty.title": "未找到结果",
"blocks.search.error.description": "请检查网络连接后重试。",
"blocks.search.error.title": "无法搜索“{query}”",
"blocks.search.history.remove": "从搜索历史中删除“{query}”",
"blocks.search.history.title": "搜索历史",
"blocks.search.initial.description": "输入关键词以搜索工作区。",
"blocks.search.initial.title": "开始搜索",
"blocks.search.loading": "正在搜索…",
"blocks.search.placeholder": "搜索工作区…",
"blocks.search.results.count": "{count} 条结果",
"blocks.search.title": "搜索",
} as const satisfies SearchMessageCatalog
@@ -1,27 +0,0 @@
import type { SearchMessageCatalog } from "./catalogs"
export const locale = "zh-Hant"
export const languageTag = "zh-TW"
export const messages = {
"blocks.search.actions.clear": "清除搜尋",
"blocks.search.actions.clearHistory": "清除記錄",
"blocks.search.actions.close": "關閉搜尋",
"blocks.search.actions.loadMore": "載入更多",
"blocks.search.actions.loadingMore": "載入中…",
"blocks.search.actions.retry": "再試一次",
"blocks.search.actions.selectResult": "開啟 {title}",
"blocks.search.command.description": "搜尋整個工作區",
"blocks.search.description": "在工作區中尋找內容",
"blocks.search.empty.description": "沒有「{query}」的結果",
"blocks.search.empty.title": "找不到結果",
"blocks.search.error.description": "請檢查網絡連線後再試一次。",
"blocks.search.error.title": "無法搜尋「{query}」",
"blocks.search.history.remove": "從最近搜尋中移除「{query}」",
"blocks.search.history.title": "最近搜尋",
"blocks.search.initial.description": "輸入關鍵字以搜尋工作區。",
"blocks.search.initial.title": "開始搜尋",
"blocks.search.loading": "搜尋中…",
"blocks.search.placeholder": "搜尋工作區…",
"blocks.search.results.count": "{count} 項結果",
"blocks.search.title": "搜尋",
} as const satisfies SearchMessageCatalog
@@ -1,64 +0,0 @@
import type { MessageDescriptor } from "@workspace/i18n"
export const searchMessages = {
clear: { id: "blocks.search.actions.clear", message: "Clear query" },
clearHistory: {
id: "blocks.search.actions.clearHistory",
message: "Clear history",
},
close: { id: "blocks.search.actions.close", message: "Close search" },
loadMore: { id: "blocks.search.actions.loadMore", message: "Load more" },
loadingMore: {
id: "blocks.search.actions.loadingMore",
message: "Loading…",
},
retry: { id: "blocks.search.actions.retry", message: "Try again" },
selectResult: {
id: "blocks.search.actions.selectResult",
message: "Open {title}",
},
commandDescription: {
id: "blocks.search.command.description",
message: "Search across your workspace",
},
description: {
id: "blocks.search.description",
message: "Find content across your workspace",
},
emptyDescription: {
id: "blocks.search.empty.description",
message: "No results for “{query}”",
},
emptyTitle: { id: "blocks.search.empty.title", message: "No results found" },
errorDescription: {
id: "blocks.search.error.description",
message: "Check your connection and try again.",
},
errorTitle: {
id: "blocks.search.error.title",
message: "Could not search for “{query}”",
},
history: { id: "blocks.search.history.title", message: "Recent searches" },
initialDescription: {
id: "blocks.search.initial.description",
message: "Enter a keyword to search your workspace.",
},
initialTitle: {
id: "blocks.search.initial.title",
message: "Start searching",
},
loading: { id: "blocks.search.loading", message: "Searching…" },
placeholder: {
id: "blocks.search.placeholder",
message: "Search your workspace…",
},
removeHistory: {
id: "blocks.search.history.remove",
message: "Remove “{query}” from recent searches",
},
resultCount: {
id: "blocks.search.results.count",
message: "{count} results",
},
title: { id: "blocks.search.title", message: "Search" },
} as const satisfies Record<string, MessageDescriptor>
@@ -1,173 +0,0 @@
import * as React from "react"
import { Command } from "cmdk"
import { useTranslate } from "@workspace/i18n"
import { Button } from "@workspace/ui/components/button"
import { ScrollArea } from "@workspace/ui/components/scroll-area"
import {
CornerDownLeftIcon,
FileTextIcon,
LoaderCircleIcon,
} from "lucide-react"
import { splitHighlightSegments } from "./highlight"
import { searchMessages } from "./messages"
import type { SearchResultGroup, SearchResultItem } from "./types"
export interface SearchResultsProps {
groups: readonly SearchResultGroup[]
hasMore: boolean
isLoadingMore: boolean
onLoadMore: VoidFunction
onSelect: (item: SearchResultItem) => void
query: string
}
export function SearchResults({
groups,
hasMore,
isLoadingMore,
onLoadMore,
onSelect,
query,
}: SearchResultsProps) {
const t = useTranslate()
return (
<ScrollArea className="h-[min(65dvh,36rem)] p-4">
<Command.List className="**:[[cmdk-list-sizer]]:space-y-6">
{groups.map((group) => {
if (group.items.length === 0) return null
return (
<Command.Group
key={group.id}
className="space-y-2 **:[[cmdk-group-heading]]:sticky **:[[cmdk-group-heading]]:top-0 **:[[cmdk-group-heading]]:z-2 **:[[cmdk-group-heading]]:flex **:[[cmdk-group-heading]]:items-center **:[[cmdk-group-heading]]:gap-2 **:[[cmdk-group-heading]]:bg-popover/90 **:[[cmdk-group-heading]]:py-2 **:[[cmdk-group-heading]]:backdrop-blur-xs **:[[cmdk-group-items]]:space-y-1"
heading={
<>
{group.icon}
<span className="font-semibold">{group.label}</span>
<span className="ms-auto text-xs text-muted-foreground">
{t(searchMessages.resultCount, {
count: group.total ?? group.items.length,
})}
</span>
</>
}
>
{group.items.map((item) => (
<SearchResultRow
key={`${group.id}:${item.id}`}
item={item}
onSelect={onSelect}
query={query}
/>
))}
</Command.Group>
)
})}
{hasMore && (
<div className="flex justify-center p-2">
<Button
type="button"
variant="ghost"
size="sm"
disabled={isLoadingMore}
onClick={onLoadMore}
>
{isLoadingMore && <LoaderCircleIcon className="animate-spin" />}
{isLoadingMore
? t(searchMessages.loadingMore)
: t(searchMessages.loadMore)}
</Button>
</div>
)}
</Command.List>
</ScrollArea>
)
}
function SearchResultRow({
item,
onSelect,
query,
}: {
item: SearchResultItem
onSelect: (item: SearchResultItem) => void
query: string
}) {
const t = useTranslate()
const value = [
item.id,
item.title,
item.description,
...(item.meta ?? []),
...(item.keywords ?? []),
]
.filter(Boolean)
.join(" ")
return (
<Command.Item
value={value}
disabled={item.disabled}
onSelect={() => onSelect(item)}
className="group/search-result flex items-center gap-3 rounded-xl border border-border/55 bg-background p-3 shadow-xs transition-colors hover:bg-muted"
aria-label={t(searchMessages.selectResult, { title: item.title })}
>
<ResultMedia item={item} />
<span className="min-w-0 flex-1 text-left">
<span className="block truncate">
<HighlightedText text={item.title} query={query} />
</span>
{item.description && (
<span className="block truncate text-sm text-muted-foreground">
<HighlightedText text={item.description} query={query} />
</span>
)}
{item.meta?.map((line, index) => (
<span
key={`${line}:${index}`}
className="block truncate text-xs text-muted-foreground"
>
<HighlightedText text={line} query={query} />
</span>
))}
</span>
<CornerDownLeftIcon className="shrink-0 text-primary opacity-0 transition-opacity group-hover/search-result:opacity-100 group-data-selected/search-result:opacity-100" />
</Command.Item>
)
}
function ResultMedia({ item }: { item: SearchResultItem }) {
if (item.image) {
return (
<img
src={item.image.src}
alt={item.image.alt ?? ""}
loading="lazy"
className="size-10 shrink-0 self-start rounded-xl border bg-muted object-cover"
/>
)
}
return (
<span className="grid size-10 shrink-0 place-items-center self-start rounded-xl bg-muted text-muted-foreground">
{item.icon ?? <FileTextIcon className="size-5" />}
</span>
)
}
function HighlightedText({ text, query }: { text: string; query: string }) {
return splitHighlightSegments(text, query).map((segment, index) =>
segment.highlighted ? (
<mark
key={index}
className="rounded-sm bg-primary/15 px-0.5 text-primary"
>
{segment.text}
</mark>
) : (
<React.Fragment key={index}>{segment.text}</React.Fragment>
)
)
}
@@ -1,140 +0,0 @@
// @vitest-environment jsdom
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react"
import { I18nProvider } from "@workspace/i18n"
import { afterEach, describe, expect, it, vi } from "vitest"
import { SearchProvider } from "./context"
import { SearchDialog } from "./dialog"
import { searchDialogHandle } from "./handle"
import { splitHighlightSegments } from "./highlight"
import { messages as englishMessages } from "./locales/en"
import type { SearchAdapter } from "./types"
class ResizeObserverStub {
disconnect() {}
observe() {}
unobserve() {}
}
vi.stubGlobal("ResizeObserver", ResizeObserverStub)
Object.defineProperty(Element.prototype, "getAnimations", {
configurable: true,
value: () => [],
})
function renderSearch({
adapter,
onSelect,
}: {
adapter: SearchAdapter
onSelect?: (event: { item: { id: string }; query: string }) => void
}) {
return render(
<I18nProvider locale="en" catalogs={{ en: englishMessages }}>
<SearchProvider
adapter={adapter}
historyStorageKey={false}
onSelect={onSelect}
>
<SearchDialog hotkey={false} />
</SearchProvider>
</I18nProvider>
)
}
describe("search blocks", () => {
afterEach(() => {
cleanup()
window.localStorage.clear()
})
it("delegates searching, pagination, and selection to the provider", async () => {
const adapter: SearchAdapter = {
search: vi.fn(async ({ cursor, query }) => {
if (cursor) {
return {
groups: [
{
id: "people",
label: "People",
items: [{ id: "2", title: "Grace Hopper" }],
},
],
nextCursor: null,
}
}
return {
groups: [
{
id: "people",
label: "People",
total: 2,
items: [
{
id: "1",
title: "Ada Lovelace",
description: `Matched ${query}`,
},
],
},
],
nextCursor: "next-page",
}
}),
}
const onSelect = vi.fn()
renderSearch({ adapter, onSelect })
searchDialogHandle.open(null)
const input = await screen.findByPlaceholderText("Search your workspace…")
fireEvent.change(input, { target: { value: "ada" } })
await waitFor(() =>
expect(adapter.search).toHaveBeenCalledWith(
expect.objectContaining({ query: "ada" })
)
)
await waitFor(() =>
expect(screen.getByRole("dialog").textContent).toContain("Ada Lovelace")
)
fireEvent.click(screen.getByRole("button", { name: "Load more" }))
await waitFor(() =>
expect(screen.getByRole("dialog").textContent).toContain("Grace Hopper")
)
expect(adapter.search).toHaveBeenLastCalledWith(
expect.objectContaining({ cursor: "next-page", query: "ada" })
)
fireEvent.click(screen.getByRole("option", { name: "Open Ada Lovelace" }))
await waitFor(() =>
expect(onSelect).toHaveBeenCalledWith(
expect.objectContaining({
item: expect.objectContaining({ id: "1" }),
query: "ada",
})
)
)
})
it("splits case-insensitive literal query matches for highlighting", () => {
expect(splitHighlightSegments("Ada ADA Lovelace", "ada")).toEqual([
{ highlighted: true, text: "Ada" },
{ highlighted: false, text: " " },
{ highlighted: true, text: "ADA" },
{ highlighted: false, text: " Lovelace" },
])
expect(splitHighlightSegments("price [usd]", "[usd]")).toEqual([
{ highlighted: false, text: "price " },
{ highlighted: true, text: "[usd]" },
])
})
})
@@ -1,83 +0,0 @@
import * as React from "react"
import { useTranslate } from "@workspace/i18n"
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@workspace/ui/components/empty"
import { CircleAlertIcon, LoaderCircleIcon, SearchIcon } from "lucide-react"
import { searchMessages } from "./messages"
export function SearchInitial() {
const t = useTranslate()
return (
<EmptyState
icon={<SearchIcon />}
title={t(searchMessages.initialTitle)}
description={t(searchMessages.initialDescription)}
/>
)
}
export function SearchLoading() {
const t = useTranslate()
return (
<EmptyState
icon={<LoaderCircleIcon className="animate-spin" />}
description={t(searchMessages.loading)}
/>
)
}
export function SearchEmpty({ query }: { query: string }) {
const t = useTranslate()
return (
<EmptyState
icon={<SearchIcon />}
title={t(searchMessages.emptyTitle)}
description={t(searchMessages.emptyDescription, { query })}
/>
)
}
export function SearchError({ query }: { query: string }) {
const t = useTranslate()
return (
<EmptyState
icon={<CircleAlertIcon />}
title={t(searchMessages.errorTitle, { query })}
description={t(searchMessages.errorDescription)}
destructive
/>
)
}
function EmptyState({
description,
destructive = false,
icon,
title,
}: {
description: React.ReactNode
destructive?: boolean
icon: React.ReactNode
title?: React.ReactNode
}) {
return (
<Empty className="min-h-72 border-0 py-16">
<EmptyHeader>
<EmptyMedia
variant="icon"
className={destructive ? "text-destructive" : undefined}
>
{icon}
</EmptyMedia>
{title && <EmptyTitle>{title}</EmptyTitle>}
<EmptyDescription>{description}</EmptyDescription>
</EmptyHeader>
</Empty>
)
}
@@ -1,14 +0,0 @@
import * as React from "react"
import { DialogTrigger } from "@workspace/ui/components/dialog"
import { searchDialogHandle } from "./handle"
export type SearchTriggerProps = Omit<
React.ComponentProps<typeof DialogTrigger>,
"handle"
>
/** Opens the nearest mounted SearchDialog through its shared dialog handle. */
export function SearchTrigger(props: SearchTriggerProps) {
return <DialogTrigger {...props} handle={searchDialogHandle} />
}
@@ -1,61 +0,0 @@
import type * as React from "react"
export type SearchCursor = number | string
/** A resource returned by a host application's global search implementation. */
export interface SearchResultItem {
/** Stable only within its group; used to merge paginated results. */
id: string
/** Primary text shown in the result row. */
title: string
description?: string
disabled?: boolean
/** Optional small visual. An image takes precedence when both are supplied. */
icon?: React.ReactNode
image?: {
alt?: string
src: string
}
/** Extra searchable text, not rendered by the default result row. */
keywords?: readonly string[]
/** Secondary lines rendered below the title. */
meta?: readonly string[]
/** Opaque application data consumed by the selection handler. */
payload?: unknown
}
/** A logical result source, such as products, projects, users, or documentation. */
export interface SearchResultGroup {
icon?: React.ReactNode
id: string
items: readonly SearchResultItem[]
label: string
total?: number
}
export interface SearchPage {
groups: readonly SearchResultGroup[]
/** Omit or return null when there are no more results to load. */
nextCursor?: SearchCursor | null
}
export interface SearchRequest {
cursor?: SearchCursor
query: string
signal: AbortSignal
}
/**
* The only data dependency of the search blocks. Implement this in the host
* application with its own database, HTTP client, command registry, or index.
*/
export interface SearchAdapter {
search(request: SearchRequest): Promise<SearchPage>
}
export interface SearchSelectionEvent {
item: SearchResultItem
query: string
}
export type SearchSelectionHandler = (event: SearchSelectionEvent) => void
@@ -1,101 +0,0 @@
import * as React from "react"
interface UseSearchHistoryOptions {
limit: number
storageKey?: string
}
export function useSearchHistory({
limit,
storageKey,
}: UseSearchHistoryOptions) {
const [items, setItems] = React.useState<readonly string[]>(() =>
readSearchHistory(storageKey, limit)
)
React.useEffect(() => {
const syncFromStorage = () => setItems(readSearchHistory(storageKey, limit))
const handleStorage = (event: StorageEvent) => {
if (event.key === storageKey) syncFromStorage()
}
syncFromStorage()
if (!storageKey || typeof window === "undefined") return
window.addEventListener("storage", handleStorage)
return () => window.removeEventListener("storage", handleStorage)
}, [limit, storageKey])
const update = React.useCallback(
(updater: (current: readonly string[]) => readonly string[]) => {
setItems((current) => {
const next = updater(current).slice(0, limit)
writeSearchHistory(storageKey, next)
return next
})
},
[limit, storageKey]
)
const add = React.useCallback(
(query: string) => {
const normalizedQuery = query.trim()
if (!normalizedQuery) return
update((current) => [
normalizedQuery,
...current.filter((item) => item !== normalizedQuery),
])
},
[update]
)
const remove = React.useCallback(
(query: string) =>
update((current) => current.filter((item) => item !== query)),
[update]
)
const clear = React.useCallback(() => update(() => []), [update])
return { add, clear, items, remove }
}
function readSearchHistory(storageKey: string | undefined, limit: number) {
if (!storageKey || typeof window === "undefined") return []
try {
const stored = window.localStorage.getItem(storageKey)
if (!stored) return []
const parsed: unknown = JSON.parse(stored)
if (!Array.isArray(parsed)) return []
return Array.from(
new Set(
parsed
.filter((item): item is string => typeof item === "string")
.map((item) => item.trim())
.filter(Boolean)
)
).slice(0, limit)
} catch {
return []
}
}
function writeSearchHistory(
storageKey: string | undefined,
items: readonly string[]
) {
if (!storageKey || typeof window === "undefined") return
try {
if (items.length === 0) {
window.localStorage.removeItem(storageKey)
} else {
window.localStorage.setItem(storageKey, JSON.stringify(items))
}
} catch {
// Storage can be disabled or unavailable in private browsing contexts.
}
}
@@ -1,135 +0,0 @@
import * as React from "react"
import type { SearchCursor, SearchPage, SearchResultGroup } from "./types"
import { useSearchContext } from "./context"
interface UseSearchOptions {
active: boolean
query: string
}
interface SearchState {
error: unknown
isLoading: boolean
isLoadingMore: boolean
pages: readonly SearchPage[]
}
const INITIAL_STATE: SearchState = {
error: null,
isLoading: false,
isLoadingMore: false,
pages: [],
}
export function useSearch({ active, query }: UseSearchOptions) {
const { adapter } = useSearchContext()
const normalizedQuery = query.trim()
const [state, setState] = React.useState<SearchState>(INITIAL_STATE)
const [retryKey, setRetryKey] = React.useState(0)
const requestId = React.useRef(0)
React.useEffect(() => {
if (!active || !normalizedQuery) {
setState(INITIAL_STATE)
return
}
const controller = new AbortController()
const currentRequest = ++requestId.current
setState({ error: null, isLoading: true, isLoadingMore: false, pages: [] })
void adapter
.search({ query: normalizedQuery, signal: controller.signal })
.then((page) => {
if (currentRequest !== requestId.current || controller.signal.aborted)
return
setState({
error: null,
isLoading: false,
isLoadingMore: false,
pages: [page],
})
})
.catch((error: unknown) => {
if (controller.signal.aborted || currentRequest !== requestId.current)
return
setState({ error, isLoading: false, isLoadingMore: false, pages: [] })
})
return () => controller.abort()
}, [active, adapter, normalizedQuery, retryKey])
const groups = React.useMemo(() => mergeGroups(state.pages), [state.pages])
const cursor = state.pages.at(-1)?.nextCursor ?? null
const loadMore = React.useCallback(() => {
if (!active || !normalizedQuery || cursor === null || state.isLoadingMore)
return
const controller = new AbortController()
const currentRequest = ++requestId.current
setState((current) => ({ ...current, error: null, isLoadingMore: true }))
void adapter
.search({
cursor,
query: normalizedQuery,
signal: controller.signal,
})
.then((page) => {
if (currentRequest !== requestId.current || controller.signal.aborted)
return
setState((current) => ({
error: null,
isLoading: false,
isLoadingMore: false,
pages: [...current.pages, page],
}))
})
.catch((error: unknown) => {
if (currentRequest !== requestId.current || controller.signal.aborted)
return
setState((current) => ({ ...current, error, isLoadingMore: false }))
})
}, [active, adapter, cursor, normalizedQuery, state.isLoadingMore])
const retry = React.useCallback(() => setRetryKey((value) => value + 1), [])
return {
error: state.error,
groups,
hasMore: cursor !== null,
isLoading: state.isLoading,
isLoadingMore: state.isLoadingMore,
retry,
loadMore,
}
}
function mergeGroups(
pages: readonly SearchPage[]
): readonly SearchResultGroup[] {
const groups = new Map<string, SearchResultGroup>()
for (const page of pages) {
for (const group of page.groups) {
const existing = groups.get(group.id)
if (!existing) {
groups.set(group.id, { ...group, items: [...group.items] })
continue
}
const items = new Map(existing.items.map((item) => [item.id, item]))
for (const item of group.items) items.set(item.id, item)
groups.set(group.id, {
...existing,
...group,
items: [...items.values()],
total: group.total ?? existing.total,
})
}
}
return [...groups.values()]
}