@cantera/finderblock
Query box over consumer-supplied result groups — recents, pins, the current level, an async deep search — with entries that carry their location so finding can reveal where a file lives.
npx shadcn@latest add @cantera/finderSearching in Summit Tower
Fully controlled and data-agnostic: query out through onQueryChange, groups in as props; the consumer owns fetching, debouncing, and persistence. APS has no cross-hub search API, so label each group after the scope actually searched (folders/{id}/search is recursive within one project) and pass the scoped project's name as scope — it renders as a persistent "Searching in" notice under the input, wired to it with aria-describedby. Entries carry path: BrowsePathSegment[] — onReveal hands it back so a hub-tree (expandedIds + selectedId) or hub-browser (a location change) unfolds to the entry. Three surfaces share the contract: Finder renders inline, FinderDialog is the ⌘K palette (shortcut built in, closes on select or reveal), and FinderTrigger is the input-shaped button that opens it.
| Prop | Type | Default | Description |
|---|---|---|---|
| query / onQueryChange | string / (query: string) => void | — | Controlled query. The finder never fetches: the consumer owns the search call, the debounce, and the scope. |
| groups | FinderGroup[] | — | Result groups in render order — recents, pins, the current level, an async deep search. Each carries id, label, status (ready | loading | error), error, and entries; loading keeps existing entries visible under a spinner-labeled heading, and error renders in warning ink because retyping retries. |
| entries (FinderEntry) | { item: Item; version?: ItemVersion; path?: BrowsePathSegment[]; caption?: string } | — | Entries carry their address: path renders as the location line and powers onReveal; caption replaces it for recents ("opened 5 minutes ago"). |
| onItemOpen | (entry: FinderEntry) => void | Promise<void> | — | Open the entry (tip, or the carried version). A returned promise drives the per-row pending spinner; the row keeps its label and never unmounts. |
| onReveal | (entry: FinderEntry) => void | — | Show the entry where it lives. Map entry.path to a hub-tree (expandedIds + selectedId) or a hub-browser location in one state update. The affordance renders only when the entry has a path. |
| pending | { openingId?: string } | — | Consumer-driven pending for server actions, keyed by finderEntryKey(entry). Promise-returning callbacks drive it automatically. |
| placeholder / label / emptyLabel | string | 'Find a file' / 'Find a file' / 'No matches.' | Input placeholder, the accessible name of the query box, and the no-matches line shown once a query has no entries anywhere. |
| scope | string | — | Name of what a search reaches (the scoped project). Renders as a persistent "Searching in" notice under the input, wired to it with aria-describedby, so the reach stays visible while typing. |
| Export | Type | Description |
|---|---|---|
| FinderDialog | FinderProps & { open; onOpenChange; shortcut?; title?; description? } | The ⌘K palette over the same controlled surface. shortcut (default true) binds ⌘K / Ctrl+K to toggle; selecting or revealing an entry closes it — the palette is a jump, not a workspace. |
| FinderTrigger | ComponentProps<'button'> & { placeholder?; showShortcut? } | Input-shaped button that opens the palette — the visible, tappable entry point with the shortcut as decoration. Compacts to an icon inside a sidebar collapsed to icon mode. |
| finderEntryKey | (entry: FinderEntry) => string | Stable key for an entry — item id, plus the version id when the entry means a specific version. Use it for pending.openingId and list keys. |
| FinderEntry / FinderGroup / FinderGroupStatus / FinderPending | types | The full controlled surface, importable for consumer wiring. |
This is the exact code the CLI installs into your project — you own it from there.
'use client'
import {
FileBoxIcon,
FolderSearchIcon,
LoaderCircleIcon,
LocateIcon,
SearchIcon,
} from 'lucide-react'
import { type ComponentProps, useEffect, useId, useState } from 'react'
import { Button } from '@/components/ui/button'
import {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import type { BrowsePathSegment, Item, ItemVersion } from '@/lib/project-types'
import { cn } from '@/lib/utils'
// APS has no cross-hub search API, so honest scoping is the consumer's job:
// name each group after what was actually searched ("In Summit Tower", never
// "Everywhere").
export interface FinderEntry {
item: Item
/** Version, when the entry means a specific one rather than the tip. */
version?: ItemVersion
/** Where it lives, root-first. Renders as the path line and powers onReveal. */
path?: BrowsePathSegment[]
/** Secondary line replacing the path (e.g. "opened 5 minutes ago"). */
caption?: string
}
export type FinderGroupStatus = 'ready' | 'loading' | 'error'
export interface FinderGroup {
id: string
label: string
/** `loading` keeps existing entries visible under a spinner-labeled heading. */
status?: FinderGroupStatus
error?: string
entries: FinderEntry[]
}
export interface FinderPending {
openingId?: string
}
export interface FinderProps extends Omit<ComponentProps<'div'>, 'onSelect'> {
query: string
onQueryChange: (query: string) => void
groups: FinderGroup[]
onItemOpen?: (entry: FinderEntry) => void | Promise<void>
/** Show the entry where it lives (expand a tree, navigate a browser). */
onReveal?: (entry: FinderEntry) => void
pending?: FinderPending
placeholder?: string
label?: string
emptyLabel?: string
/** Names what a search reaches; renders as a persistent notice under the input. */
scope?: string
}
export function finderEntryKey(entry: FinderEntry): string {
return entry.version ? `${entry.item.id}@${entry.version.id}` : entry.item.id
}
function pathLine(path: BrowsePathSegment[] | undefined): string | null {
if (!path || path.length === 0) return null
return path.map((segment) => segment.name).join(' › ')
}
function isPromiseLike(value: void | Promise<void>): value is Promise<void> {
return typeof (value as Promise<void> | undefined)?.then === 'function'
}
interface FinderSurfaceProps
extends Pick<
FinderProps,
| 'query'
| 'onQueryChange'
| 'groups'
| 'onItemOpen'
| 'onReveal'
| 'pending'
| 'placeholder'
| 'label'
| 'emptyLabel'
| 'scope'
> {
autoFocus?: boolean
}
function FinderSurface({
query,
onQueryChange,
groups,
onItemOpen,
onReveal,
pending,
placeholder = 'Find a file',
label = 'Find a file',
emptyLabel = 'No matches.',
scope,
autoFocus,
}: FinderSurfaceProps) {
const [openingId, setOpeningId] = useState<string>()
const scopeId = useId()
const anyLoading = groups.some((group) => group.status === 'loading')
const anyEntries = groups.some((group) => group.entries.length > 0)
const showEmpty = query.trim() !== '' && !anyEntries && !anyLoading
function open(entry: FinderEntry): void {
const key = finderEntryKey(entry)
if (pending?.openingId || openingId) return
const result = onItemOpen?.(entry)
if (!isPromiseLike(result)) return
setOpeningId(key)
result.finally(() => setOpeningId(undefined))
}
return (
<Command shouldFilter={false} className="bg-transparent">
<CommandInput
value={query}
onValueChange={onQueryChange}
placeholder={placeholder}
aria-label={label}
aria-describedby={scope ? scopeId : undefined}
autoFocus={autoFocus}
/>
{scope && (
<p
id={scopeId}
data-finder-scope=""
className="flex items-center gap-1.5 border-b px-3 py-2 text-muted-foreground text-xs"
>
<FolderSearchIcon aria-hidden="true" className="size-3.5 shrink-0" />
<span className="truncate">
Searching in <span className="font-medium text-foreground">{scope}</span>
</span>
</p>
)}
<CommandList>
{showEmpty && <CommandEmpty>{emptyLabel}</CommandEmpty>}
{groups.map((group) => {
if (group.status !== 'loading' && group.status !== 'error' && !group.entries.length) {
return null
}
return (
<CommandGroup
key={group.id}
data-finder-group={group.id}
heading={
group.status === 'loading' ? (
<span className="flex items-center gap-1.5">
{group.label}
<LoaderCircleIcon aria-hidden="true" className="size-3 animate-spin" />
<span className="sr-only">Searching</span>
</span>
) : (
group.label
)
}
>
{group.status === 'error' && (
<p role="status" className="px-2 py-1.5 text-status-warning text-xs">
{group.error ?? 'Search failed. Keep typing to retry.'}
</p>
)}
{group.entries.map((entry) => {
const key = finderEntryKey(entry)
const opening = (pending?.openingId ?? openingId) === key
const line = entry.caption ?? pathLine(entry.path)
return (
<CommandItem
key={key}
value={`${group.id}:${key}`}
aria-disabled={opening || undefined}
onSelect={() => open(entry)}
className="min-h-11"
>
{opening ? (
<LoaderCircleIcon aria-hidden="true" className="animate-spin" />
) : (
<FileBoxIcon aria-hidden="true" className="text-muted-foreground" />
)}
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate">
{entry.item.name}
{entry.version && (
<span className="text-muted-foreground">
{' '}
· v{entry.version.versionNumber}
</span>
)}
</span>
{line && (
<span className="truncate text-muted-foreground text-xs">{line}</span>
)}
</span>
{onReveal && entry.path && entry.path.length > 0 && (
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Show ${entry.item.name} location`}
onClick={(event) => {
event.stopPropagation()
onReveal(entry)
}}
>
<LocateIcon aria-hidden="true" className="size-3.5" />
</Button>
)}
</CommandItem>
)
})}
</CommandGroup>
)
})}
</CommandList>
</Command>
)
}
function Finder({
query,
onQueryChange,
groups,
onItemOpen,
onReveal,
pending,
placeholder,
label,
emptyLabel,
scope,
className,
...props
}: FinderProps) {
return (
<div data-finder="" className={cn('flex flex-col', className)} {...props}>
<FinderSurface
query={query}
onQueryChange={onQueryChange}
groups={groups}
onItemOpen={onItemOpen}
onReveal={onReveal}
pending={pending}
placeholder={placeholder}
label={label}
emptyLabel={emptyLabel}
scope={scope}
/>
</div>
)
}
export interface FinderDialogProps extends Omit<FinderProps, 'className'> {
open: boolean
onOpenChange: (open: boolean) => void
shortcut?: boolean
title?: string
description?: string
}
function FinderDialog({
open,
onOpenChange,
shortcut = true,
title = 'Find a file',
description = 'Search project files',
onItemOpen,
onReveal,
...surface
}: FinderDialogProps) {
useEffect(() => {
if (!shortcut) return
function onKeyDown(event: KeyboardEvent) {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
event.preventDefault()
onOpenChange(!open)
}
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [shortcut, open, onOpenChange])
return (
<CommandDialog open={open} onOpenChange={onOpenChange} title={title} description={description}>
<FinderSurface
{...surface}
autoFocus
onItemOpen={
onItemOpen
? async (entry) => {
await onItemOpen(entry)
onOpenChange(false)
}
: undefined
}
onReveal={
onReveal
? (entry) => {
onReveal(entry)
onOpenChange(false)
}
: undefined
}
/>
</CommandDialog>
)
}
export interface FinderTriggerProps extends ComponentProps<'button'> {
placeholder?: string
/** Render the ⌘K hint (hidden on coarse pointers). */
showShortcut?: boolean
}
function FinderTrigger({
placeholder = 'Find a file',
showShortcut = true,
className,
...props
}: FinderTriggerProps) {
return (
<button
type="button"
data-finder-trigger=""
// The visible placeholder is the first thing a collapsed rail hides, so
// the name is spelled out either way.
aria-label={placeholder}
className={cn(
'flex h-8 w-full min-w-0 items-center gap-2 rounded-lg border border-input bg-input/30 px-2.5 text-muted-foreground text-sm outline-none transition-[color,background-color,border-color,transform] duration-150 ease-out hover:text-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:scale-[0.98] dark:bg-input/30',
'group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:border-transparent group-data-[collapsible=icon]:bg-transparent group-data-[collapsible=icon]:px-0 group-data-[collapsible=icon]:hover:bg-sidebar-accent group-data-[collapsible=icon]:hover:text-sidebar-accent-foreground',
className,
)}
{...props}
>
<SearchIcon
aria-hidden="true"
className="size-4 shrink-0 opacity-50 group-data-[collapsible=icon]:opacity-100"
/>
<span className="flex-1 truncate text-left group-data-[collapsible=icon]:hidden">
{placeholder}
</span>
{showShortcut && (
<kbd className="pointer-fine:inline-flex hidden items-center rounded border border-border px-1.5 py-0.5 font-mono text-muted-foreground text-xs group-data-[collapsible=icon]:hidden!">
⌘K
</kbd>
)}
</button>
)
}
export { Finder, FinderDialog, FinderTrigger }