Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
195 changes: 159 additions & 36 deletions apps/web/src/components/GlobalSearch.tsx
Original file line number Diff line number Diff line change
@@ -1,35 +1,90 @@
/**
* Global search component.
*
* Provides a Cmd+K searchable dialog for finding documents.
* The unified Cmd+K palette: fuzzy document search, workspace commands
* from the CommandRegistry (with their keyboard hints), and quick task
* capture — one keyboard surface for "find or do anything"
* (exploration 0161, phase 3).
*/
import type { SearchResult } from '@xnetjs/sdk'
import { useNavigate } from '@tanstack/react-router'
import { TaskSchema } from '@xnetjs/data'
import { getCommandRegistry, type WorkspaceCommand } from '@xnetjs/plugins'
import { useMutate } from '@xnetjs/react'
import { CheckSquare2, CornerDownLeft, FileText, Terminal } from 'lucide-react'
import { startTransition, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
import { usePageSearchSurface } from '../hooks/usePageSearchSurface'

type PaletteEntry =
| { kind: 'command'; command: WorkspaceCommand }
| { kind: 'page'; result: SearchResult }
| { kind: 'create-task'; title: string }

function generateTaskId(): string {
if (typeof globalThis.crypto?.randomUUID === 'function') {
return `task_${globalThis.crypto.randomUUID()}`
}

return `task_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`
}

export function GlobalSearch() {
const [isOpen, setIsOpen] = useState(false)
const [query, setQuery] = useState('')
const [selectedIndex, setSelectedIndex] = useState(0)
const inputRef = useRef<HTMLInputElement>(null)
const navigate = useNavigate()
const { create } = useMutate()
const deferredQuery = useDeferredValue(query)
const { indexedPages, loading, search, totalPages } = usePageSearchSurface({ enabled: isOpen })

const results = useMemo<SearchResult[]>(() => {
if (!deferredQuery.trim()) return []
return search(deferredQuery, 10)
}, [deferredQuery, search])

const commandMatches = useMemo<WorkspaceCommand[]>(() => {
if (!isOpen) return []
const registry = getCommandRegistry()
const needle = deferredQuery.trim().toLowerCase()
return registry
.getAvailableCommands()
.filter((command) => !needle || command.title.toLowerCase().includes(needle))
.slice(0, needle ? 5 : 4)
}, [isOpen, deferredQuery])

const entries = useMemo<PaletteEntry[]>(() => {
const list: PaletteEntry[] = commandMatches.map((command) => ({ kind: 'command', command }))
for (const result of results) list.push({ kind: 'page', result })
if (deferredQuery.trim()) {
list.push({ kind: 'create-task', title: deferredQuery.trim() })
}
return list
}, [commandMatches, results, deferredQuery])

// Cmd+K is a workspace command so it appears in the shortcut help and
// can be re-bound centrally; allowInInput keeps it reachable mid-edit.
useEffect(() => {
const handler = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key === 'k') {
event.preventDefault()
const registry = getCommandRegistry()
const disposable = registry.register({
id: 'search.open',
title: 'Search & commands',
key: 'Mod-K',
allowInInput: true,
run: () => {
setIsOpen(true)
setTimeout(() => inputRef.current?.focus(), 10)
}
})

return () => disposable.dispose()
}, [])

useEffect(() => {
if (!isOpen) return

if (event.key === 'Escape' && isOpen) {
const handler = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault()
setIsOpen(false)
setQuery('')
Expand All @@ -42,33 +97,52 @@ export function GlobalSearch() {

useEffect(() => {
setSelectedIndex(0)
}, [results])
}, [entries.length])

const handleSelect = (result: SearchResult) => {
const close = () => {
setIsOpen(false)
setQuery('')
navigate({ to: '/doc/$docId', params: { docId: result.id } })
}

const handleSelect = (entry: PaletteEntry) => {
if (entry.kind === 'command') {
close()
void getCommandRegistry().runCommand(entry.command.id)
return
}

if (entry.kind === 'page') {
close()
navigate({ to: '/doc/$docId', params: { docId: entry.result.id } })
return
}

close()
void create(
TaskSchema,
{ title: entry.title, completed: false, status: 'todo', source: 'api' },
generateTaskId()
).then(() => navigate({ to: '/tasks' }))
}

const handleKeyDown = (event: React.KeyboardEvent) => {
if (event.key === 'ArrowDown') {
event.preventDefault()
setSelectedIndex((index) => Math.min(index + 1, results.length - 1))
setSelectedIndex((index) => Math.min(index + 1, entries.length - 1))
} else if (event.key === 'ArrowUp') {
event.preventDefault()
setSelectedIndex((index) => Math.max(index - 1, 0))
} else if (event.key === 'Enter' && results[selectedIndex]) {
} else if (event.key === 'Enter' && entries[selectedIndex]) {
event.preventDefault()
handleSelect(results[selectedIndex])
handleSelect(entries[selectedIndex])
}
}

const handleCreate = () => {
const handleCreatePage = () => {
if (!query.trim()) return

const newId = `default/${query.toLowerCase().replace(/\s+/g, '-')}`
setIsOpen(false)
setQuery('')
close()
navigate({ to: '/doc/$docId', params: { docId: newId } })
}

Expand All @@ -90,10 +164,74 @@ export function GlobalSearch() {
)
}

const registry = getCommandRegistry()

const renderEntry = (entry: PaletteEntry, index: number) => {
const isSelected = index === selectedIndex
const baseClass = `flex items-center gap-3 px-5 py-2.5 cursor-pointer transition-colors ${
isSelected ? 'bg-secondary' : 'hover:bg-secondary'
}`

if (entry.kind === 'command') {
return (
<li
key={`command-${entry.command.id}`}
className={baseClass}
onClick={() => handleSelect(entry)}
onMouseEnter={() => setSelectedIndex(index)}
>
<Terminal size={14} className="shrink-0 text-muted-foreground" />
<span className="flex-1 truncate text-sm text-foreground">{entry.command.title}</span>
{entry.command.key && (
<kbd className="rounded border border-border bg-background px-1.5 py-0.5 text-xs text-muted-foreground">
{registry.formatForDisplay(entry.command.key)}
</kbd>
)}
</li>
)
}

if (entry.kind === 'page') {
return (
<li
key={`page-${entry.result.id}`}
className={baseClass}
onClick={() => handleSelect(entry)}
onMouseEnter={() => setSelectedIndex(index)}
>
<FileText size={14} className="mt-0.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1">
<strong className="block truncate text-sm font-medium text-foreground">
{entry.result.title}
</strong>
<span className="block truncate text-xs text-muted-foreground">
{entry.result.snippet || entry.result.title}
</span>
</span>
</li>
)
}

return (
<li
key="create-task"
className={baseClass}
onClick={() => handleSelect(entry)}
onMouseEnter={() => setSelectedIndex(index)}
>
<CheckSquare2 size={14} className="shrink-0 text-muted-foreground" />
<span className="flex-1 truncate text-sm text-foreground">
Create task &ldquo;{entry.title}&rdquo;
</span>
<CornerDownLeft size={12} className="shrink-0 text-muted-foreground" />
</li>
)
}

return (
<div
className="fixed inset-0 bg-black/50 flex items-start justify-center pt-24 z-50"
onClick={() => setIsOpen(false)}
onClick={close}
>
<div
className="w-full max-w-xl bg-background rounded-xl shadow-2xl overflow-hidden"
Expand All @@ -102,7 +240,7 @@ export function GlobalSearch() {
<input
ref={inputRef}
type="text"
placeholder="Search documents..."
placeholder="Search or run a command..."
value={query}
onChange={(event) => {
const value = event.target.value
Expand All @@ -121,35 +259,20 @@ export function GlobalSearch() {
</div>
)}

{results.length > 0 && (
{entries.length > 0 && (
<ul className="list-none max-h-96 overflow-y-auto border-t border-border">
{results.map((result, index) => (
<li
key={result.id}
className={`px-5 py-3 cursor-pointer border-b border-border last:border-b-0 transition-colors ${
index === selectedIndex ? 'bg-secondary' : 'hover:bg-secondary'
}`}
onClick={() => handleSelect(result)}
onMouseEnter={() => setSelectedIndex(index)}
>
<strong className="block font-medium mb-1">{result.title}</strong>
<p className="text-sm text-muted-foreground m-0 truncate">
{result.snippet || result.title}
</p>
</li>
))}
{entries.map((entry, index) => renderEntry(entry, index))}
</ul>
)}

{query && !loading && results.length === 0 && (
<div className="px-5 py-6 text-center border-t border-border">
<p className="text-muted-foreground mb-3">No results found</p>
<div className="px-5 py-3 text-center border-t border-border">
<button
className="px-4 py-2 bg-primary text-white border-none rounded-md cursor-pointer text-sm hover:bg-primary-hover transition-colors"
onClick={handleCreate}
onClick={handleCreatePage}
type="button"
>
Create &ldquo;{query}&rdquo;
Create page &ldquo;{query}&rdquo;
</button>
</div>
)}
Expand Down
12 changes: 12 additions & 0 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
Layout,
Plus,
Trash2,
CheckSquare2,
ChevronDown,
ChevronRight,
Settings,
Expand Down Expand Up @@ -372,6 +373,17 @@ export function Sidebar() {

{/* Settings */}
<div className="p-2 border-t border-border">
<Link
to="/tasks"
className={`mb-1 w-full flex items-center gap-2 px-2 py-1.5 rounded-md cursor-pointer no-underline hover:no-underline transition-colors ${
location.pathname === '/tasks'
? 'bg-accent text-foreground'
: 'text-foreground hover:bg-accent/50'
}`}
>
<CheckSquare2 size={14} className="text-muted-foreground" />
<span className="text-sm">Tasks</span>
</Link>
<Link
to="/data"
className={`mb-1 w-full flex items-center gap-2 px-2 py-1.5 rounded-md cursor-pointer no-underline hover:no-underline transition-colors ${
Expand Down
Loading
Loading