|
| 1 | +/** @jsxImportSource @opentui/react */ |
| 2 | +import { useState, useCallback, useMemo, type ReactNode } from "react"; |
| 3 | +import { useKeyboard } from "@opentui/react"; |
| 4 | +import type { CategoryInfo } from "../types"; |
| 5 | + |
| 6 | +const BG = "#1c1c1e"; |
| 7 | +const BORDER_COLOR = "#38383a"; |
| 8 | +const MUTED = "#636366"; |
| 9 | +const ACCENT = "#0a84ff"; |
| 10 | +const ACTIVE_BG = "#38383a"; |
| 11 | +const TEXT_COLOR = "#f5f5f7"; |
| 12 | +const DIM = "#98989d"; |
| 13 | + |
| 14 | +interface SearchItem { |
| 15 | + readonly type: "process" | "file"; |
| 16 | + readonly label: string; |
| 17 | + readonly sublabel: string; |
| 18 | + readonly categoryName: string; |
| 19 | + readonly tabId?: string; |
| 20 | + readonly filePath?: string; |
| 21 | + readonly status?: string; |
| 22 | + readonly color: string; |
| 23 | +} |
| 24 | + |
| 25 | +interface SearchOverlayProps { |
| 26 | + readonly categories: ReadonlyArray<CategoryInfo>; |
| 27 | + readonly onSelectCategory: (name: string) => void; |
| 28 | + readonly onSelectTab: (id: string) => void; |
| 29 | + readonly onOpenFile: (path: string) => void; |
| 30 | + readonly onClose: () => void; |
| 31 | +} |
| 32 | + |
| 33 | +const STATUS_CHARS: Record<string, { readonly char: string; readonly color: string }> = { |
| 34 | + running: { char: "\u25cf", color: "#30d158" }, |
| 35 | + idle: { char: "\u25cb", color: "#636366" }, |
| 36 | + stopped: { char: "\u25cf", color: "#8e8e93" }, |
| 37 | + failed: { char: "\u25cf", color: "#ff453a" }, |
| 38 | +}; |
| 39 | + |
| 40 | +const buildSearchItems = (categories: ReadonlyArray<CategoryInfo>): readonly SearchItem[] => |
| 41 | + categories.flatMap((cat): readonly SearchItem[] => [ |
| 42 | + ...( cat.type === "process" |
| 43 | + ? cat.tabs.map((tab): SearchItem => ({ |
| 44 | + type: "process", |
| 45 | + label: tab.name, |
| 46 | + sublabel: cat.name, |
| 47 | + categoryName: cat.name, |
| 48 | + tabId: tab.id, |
| 49 | + status: tab.status, |
| 50 | + color: cat.color, |
| 51 | + })) |
| 52 | + : []), |
| 53 | + ...( cat.type === "files" && cat.fileEntries |
| 54 | + ? cat.fileEntries |
| 55 | + .filter((e) => !e.isDir) |
| 56 | + .map((entry): SearchItem => ({ |
| 57 | + type: "file", |
| 58 | + label: entry.name, |
| 59 | + sublabel: entry.path, |
| 60 | + categoryName: cat.name, |
| 61 | + filePath: entry.path, |
| 62 | + color: cat.color, |
| 63 | + })) |
| 64 | + : []), |
| 65 | + ]); |
| 66 | + |
| 67 | +const matchesQuery = (item: SearchItem, lower: string): boolean => |
| 68 | + item.label.toLowerCase().includes(lower) || item.sublabel.toLowerCase().includes(lower); |
| 69 | + |
| 70 | +const filterItems = (items: readonly SearchItem[], query: string): readonly SearchItem[] => |
| 71 | + query === "" ? items : items.filter((item) => matchesQuery(item, query.toLowerCase())); |
| 72 | + |
| 73 | +const isPrintableChar = (key: { readonly sequence?: string; readonly ctrl: boolean; readonly meta: boolean }): boolean => |
| 74 | + Boolean(key.sequence) && key.sequence!.length === 1 && !key.ctrl && !key.meta && key.sequence!.charCodeAt(0) >= 0x20; |
| 75 | + |
| 76 | +const renderItemIcon = (item: SearchItem): ReactNode => { |
| 77 | + if (item.type !== "process") return <span fg={DIM}>{" "}</span>; |
| 78 | + const info = STATUS_CHARS[item.status ?? "idle"] ?? STATUS_CHARS["idle"]!; |
| 79 | + return <span fg={info.color}>{info.char} </span>; |
| 80 | +}; |
| 81 | + |
| 82 | +const renderItem = (item: SearchItem, isSelected: boolean): ReactNode => ( |
| 83 | + <box |
| 84 | + key={`${item.type}-${item.tabId ?? item.filePath ?? item.categoryName}`} |
| 85 | + height={1} |
| 86 | + paddingX={2} |
| 87 | + backgroundColor={isSelected ? ACTIVE_BG : undefined} |
| 88 | + > |
| 89 | + <text> |
| 90 | + <span fg={isSelected ? ACCENT : DIM}>{isSelected ? "\u25b8 " : " "}</span> |
| 91 | + {renderItemIcon(item)} |
| 92 | + <span fg={isSelected ? TEXT_COLOR : DIM}>{item.label}</span> |
| 93 | + <span fg={MUTED}>{" "}{item.sublabel}</span> |
| 94 | + </text> |
| 95 | + </box> |
| 96 | +); |
| 97 | + |
| 98 | +export const SearchOverlay = ({ |
| 99 | + categories, |
| 100 | + onSelectCategory, |
| 101 | + onSelectTab, |
| 102 | + onOpenFile, |
| 103 | + onClose, |
| 104 | +}: SearchOverlayProps): ReactNode => { |
| 105 | + const [query, setQuery] = useState(""); |
| 106 | + const [selectedIndex, setSelectedIndex] = useState(0); |
| 107 | + |
| 108 | + const allItems = useMemo(() => buildSearchItems(categories), [categories]); |
| 109 | + const filtered = useMemo(() => filterItems(allItems, query), [allItems, query]); |
| 110 | + |
| 111 | + const selectItem = useCallback((item: SearchItem) => { |
| 112 | + onSelectCategory(item.categoryName); |
| 113 | + if (item.tabId) onSelectTab(item.tabId); |
| 114 | + if (item.filePath) { |
| 115 | + onSelectTab(`file::${item.filePath}`); |
| 116 | + onOpenFile(item.filePath); |
| 117 | + } |
| 118 | + onClose(); |
| 119 | + }, [onSelectCategory, onSelectTab, onOpenFile, onClose]); |
| 120 | + |
| 121 | + useKeyboard((key) => { |
| 122 | + if (key.name === "escape") { onClose(); return; } |
| 123 | + |
| 124 | + if (key.name === "up" || (key.ctrl && key.name === "p")) { |
| 125 | + setSelectedIndex((i) => Math.max(0, i - 1)); |
| 126 | + return; |
| 127 | + } |
| 128 | + if (key.name === "down" || (key.ctrl && key.name === "n")) { |
| 129 | + setSelectedIndex((i) => Math.min(filtered.length - 1, i + 1)); |
| 130 | + return; |
| 131 | + } |
| 132 | + |
| 133 | + if (key.name === "enter" || key.name === "return") { |
| 134 | + const item = filtered[selectedIndex]; |
| 135 | + if (item) selectItem(item); |
| 136 | + return; |
| 137 | + } |
| 138 | + |
| 139 | + if (key.name === "backspace") { |
| 140 | + setQuery((q) => q.slice(0, -1)); |
| 141 | + setSelectedIndex(0); |
| 142 | + return; |
| 143 | + } |
| 144 | + |
| 145 | + if (isPrintableChar(key)) { |
| 146 | + setQuery((q) => q + key.sequence!); |
| 147 | + setSelectedIndex(0); |
| 148 | + } |
| 149 | + }); |
| 150 | + |
| 151 | + return ( |
| 152 | + <box flexGrow={1} flexDirection="column" backgroundColor={BG}> |
| 153 | + <box height={1} paddingX={1} backgroundColor="#2c2c2e"> |
| 154 | + <text> |
| 155 | + <span fg={ACCENT}>{"\u276f "}</span> |
| 156 | + <span fg={TEXT_COLOR}>{query}</span> |
| 157 | + <span fg={ACCENT}>{"\u258f"}</span> |
| 158 | + </text> |
| 159 | + </box> |
| 160 | + <box height={1} backgroundColor={BORDER_COLOR} /> |
| 161 | + <scrollbox flexGrow={1}> |
| 162 | + {filtered.length === 0 ? ( |
| 163 | + <box height={1} paddingX={2}> |
| 164 | + <text fg={MUTED}>No results</text> |
| 165 | + </box> |
| 166 | + ) : ( |
| 167 | + filtered.map((item, i) => renderItem(item, i === selectedIndex)) |
| 168 | + )} |
| 169 | + </scrollbox> |
| 170 | + <box height={1} paddingX={1} backgroundColor="#2c2c2e"> |
| 171 | + <text fg={MUTED}> |
| 172 | + <span fg={ACCENT}>{"\u2191\u2193"}</span>{" nav "} |
| 173 | + <span fg={ACCENT}>enter</span>{" select "} |
| 174 | + <span fg={ACCENT}>esc</span>{" close"} |
| 175 | + </text> |
| 176 | + </box> |
| 177 | + </box> |
| 178 | + ); |
| 179 | +}; |
0 commit comments