@schlessera/brain-ui-react 0.6.3 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/graph/graph-canvas.d.ts +20 -0
- package/dist/components/graph/graph-canvas.d.ts.map +1 -0
- package/dist/components/graph/graph-canvas.js +387 -0
- package/dist/components/graph/graph-canvas.js.map +1 -0
- package/dist/components/graph/graph-controls.d.ts +8 -0
- package/dist/components/graph/graph-controls.d.ts.map +1 -0
- package/dist/components/graph/graph-controls.js +132 -0
- package/dist/components/graph/graph-controls.js.map +1 -0
- package/dist/components/graph/graph-empty-state.d.ts +15 -0
- package/dist/components/graph/graph-empty-state.d.ts.map +1 -0
- package/dist/components/graph/graph-empty-state.js +21 -0
- package/dist/components/graph/graph-empty-state.js.map +1 -0
- package/dist/components/graph/graph-page.d.ts +7 -0
- package/dist/components/graph/graph-page.d.ts.map +1 -0
- package/dist/components/graph/graph-page.js +400 -0
- package/dist/components/graph/graph-page.js.map +1 -0
- package/dist/components/graph/lib/graph-helpers.d.ts +98 -0
- package/dist/components/graph/lib/graph-helpers.d.ts.map +1 -0
- package/dist/components/graph/lib/graph-helpers.js +240 -0
- package/dist/components/graph/lib/graph-helpers.js.map +1 -0
- package/dist/components/graph/node-popover.d.ts +11 -0
- package/dist/components/graph/node-popover.d.ts.map +1 -0
- package/dist/components/graph/node-popover.js +39 -0
- package/dist/components/graph/node-popover.js.map +1 -0
- package/dist/components/graph/use-graph-theme.d.ts +19 -0
- package/dist/components/graph/use-graph-theme.d.ts.map +1 -0
- package/dist/components/graph/use-graph-theme.js +38 -0
- package/dist/components/graph/use-graph-theme.js.map +1 -0
- package/dist/components/layout/mobile-tab-bar.d.ts.map +1 -1
- package/dist/components/layout/mobile-tab-bar.js +16 -5
- package/dist/components/layout/mobile-tab-bar.js.map +1 -1
- package/dist/components/layout/side-rail.d.ts.map +1 -1
- package/dist/components/layout/side-rail.js +14 -4
- package/dist/components/layout/side-rail.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/stores/graph-store.d.ts +85 -0
- package/dist/stores/graph-store.d.ts.map +1 -0
- package/dist/stores/graph-store.js +236 -0
- package/dist/stores/graph-store.js.map +1 -0
- package/dist/stores/ui-store.d.ts +5 -0
- package/dist/stores/ui-store.d.ts.map +1 -1
- package/dist/stores/ui-store.js +2 -0
- package/dist/stores/ui-store.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +5 -2
- package/src/components/graph/graph-canvas.tsx +450 -0
- package/src/components/graph/graph-controls.tsx +436 -0
- package/src/components/graph/graph-empty-state.tsx +45 -0
- package/src/components/graph/graph-page.tsx +1045 -0
- package/src/components/graph/lib/graph-helpers.ts +311 -0
- package/src/components/graph/node-popover.tsx +135 -0
- package/src/components/graph/use-graph-theme.ts +51 -0
- package/src/components/layout/mobile-tab-bar.tsx +26 -3
- package/src/components/layout/side-rail.tsx +28 -4
- package/src/index.ts +3 -1
- package/src/stores/graph-store.ts +345 -0
- package/src/stores/ui-store.ts +8 -0
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { Loader2, Search, SlidersHorizontal, X } from "lucide-react";
|
|
3
|
+
import { api, type BrainSearchHit } from "../../lib/api-client.js";
|
|
4
|
+
import {
|
|
5
|
+
useGraphStore,
|
|
6
|
+
type DiscoveryColorBy,
|
|
7
|
+
type LocalDirection,
|
|
8
|
+
type SizeBy,
|
|
9
|
+
} from "../../stores/graph-store.js";
|
|
10
|
+
import { cn } from "../../lib/utils.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Per-mode option forms. Desktop: a floating card in the top-right of the
|
|
14
|
+
* canvas. Mobile: a slide-up sheet behind a filter button (tap targets kept
|
|
15
|
+
* at ≥44px). Only the options that matter per mode — everything else stays
|
|
16
|
+
* out until a later phase earns it.
|
|
17
|
+
*/
|
|
18
|
+
export function GraphControls() {
|
|
19
|
+
const [mobileOpen, setMobileOpen] = useState(false);
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<>
|
|
23
|
+
{/* Mobile: floating filter button */}
|
|
24
|
+
<button
|
|
25
|
+
onClick={() => setMobileOpen(true)}
|
|
26
|
+
className="absolute right-3 top-3 z-10 flex h-11 w-11 items-center justify-center rounded-full border border-border bg-surface-overlay text-foreground shadow-lg md:hidden"
|
|
27
|
+
title="Graph options"
|
|
28
|
+
>
|
|
29
|
+
<SlidersHorizontal className="h-4.5 w-4.5" />
|
|
30
|
+
</button>
|
|
31
|
+
|
|
32
|
+
{/* Desktop: docked card */}
|
|
33
|
+
<div className="absolute right-4 top-4 z-10 hidden w-64 rounded-xl border border-border bg-surface-overlay/95 p-4 shadow-xl backdrop-blur md:block">
|
|
34
|
+
<ControlsBody />
|
|
35
|
+
</div>
|
|
36
|
+
|
|
37
|
+
{/* Mobile: bottom sheet */}
|
|
38
|
+
{mobileOpen && (
|
|
39
|
+
<div className="fixed inset-0 z-40 md:hidden">
|
|
40
|
+
<div
|
|
41
|
+
className="absolute inset-0 bg-background/60"
|
|
42
|
+
onClick={() => setMobileOpen(false)}
|
|
43
|
+
/>
|
|
44
|
+
<div className="absolute inset-x-0 bottom-0 max-h-[70dvh] overflow-y-auto rounded-t-2xl border-t border-border bg-surface p-5 pb-[calc(1.25rem+env(safe-area-inset-bottom))]">
|
|
45
|
+
<div className="mb-3 flex items-center justify-between">
|
|
46
|
+
<h3 className="text-sm font-medium text-foreground">Graph options</h3>
|
|
47
|
+
<button
|
|
48
|
+
onClick={() => setMobileOpen(false)}
|
|
49
|
+
className="flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground hover:text-foreground"
|
|
50
|
+
title="Close"
|
|
51
|
+
>
|
|
52
|
+
<X className="h-4 w-4" />
|
|
53
|
+
</button>
|
|
54
|
+
</div>
|
|
55
|
+
<ControlsBody />
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
)}
|
|
59
|
+
</>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function ControlsBody() {
|
|
64
|
+
const mode = useGraphStore((s) => s.mode);
|
|
65
|
+
return (
|
|
66
|
+
<div className="flex flex-col gap-4">
|
|
67
|
+
<SceneSearch />
|
|
68
|
+
{mode === "local" && <LocalControls />}
|
|
69
|
+
{mode === "discovery" && <DiscoveryControls />}
|
|
70
|
+
{mode === "clusters" && <ClustersControls />}
|
|
71
|
+
{mode === "maintenance" && <MaintenanceControls />}
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Highlights matches in the current scene — no refetch. */
|
|
77
|
+
function SceneSearch() {
|
|
78
|
+
const sceneQuery = useGraphStore((s) => s.sceneQuery);
|
|
79
|
+
const setSceneQuery = useGraphStore((s) => s.setSceneQuery);
|
|
80
|
+
return (
|
|
81
|
+
<label className="flex items-center gap-2 rounded-lg border border-border bg-surface-raised px-2.5 py-2 focus-within:border-primary/40">
|
|
82
|
+
<Search className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
83
|
+
<input
|
|
84
|
+
value={sceneQuery}
|
|
85
|
+
onChange={(e) => setSceneQuery(e.target.value)}
|
|
86
|
+
placeholder="Highlight in graph…"
|
|
87
|
+
className="min-w-0 flex-1 bg-transparent text-xs text-foreground outline-none placeholder:text-muted-foreground"
|
|
88
|
+
autoComplete="off"
|
|
89
|
+
spellCheck={false}
|
|
90
|
+
/>
|
|
91
|
+
{sceneQuery && (
|
|
92
|
+
<button
|
|
93
|
+
onClick={() => setSceneQuery("")}
|
|
94
|
+
className="shrink-0 text-muted-foreground hover:text-foreground"
|
|
95
|
+
title="Clear"
|
|
96
|
+
>
|
|
97
|
+
<X className="h-3 w-3" />
|
|
98
|
+
</button>
|
|
99
|
+
)}
|
|
100
|
+
</label>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function LocalControls() {
|
|
105
|
+
const local = useGraphStore((s) => s.local);
|
|
106
|
+
const setLocalParams = useGraphStore((s) => s.setLocalParams);
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<>
|
|
110
|
+
<Field label="Center note">
|
|
111
|
+
<NotePicker
|
|
112
|
+
value={local.center}
|
|
113
|
+
onPick={(path) => setLocalParams({ center: path })}
|
|
114
|
+
/>
|
|
115
|
+
</Field>
|
|
116
|
+
<Field label="Depth">
|
|
117
|
+
<Segmented
|
|
118
|
+
options={[
|
|
119
|
+
{ value: "1", label: "1" },
|
|
120
|
+
{ value: "2", label: "2" },
|
|
121
|
+
{ value: "3", label: "3" },
|
|
122
|
+
]}
|
|
123
|
+
value={String(local.depth)}
|
|
124
|
+
onChange={(v) => setLocalParams({ depth: Number(v) as 1 | 2 | 3 })}
|
|
125
|
+
/>
|
|
126
|
+
</Field>
|
|
127
|
+
<Field label="Direction">
|
|
128
|
+
<Segmented
|
|
129
|
+
options={[
|
|
130
|
+
{ value: "in", label: "In" },
|
|
131
|
+
{ value: "out", label: "Out" },
|
|
132
|
+
{ value: "both", label: "Both" },
|
|
133
|
+
]}
|
|
134
|
+
value={local.direction}
|
|
135
|
+
onChange={(v) => setLocalParams({ direction: v as LocalDirection })}
|
|
136
|
+
/>
|
|
137
|
+
</Field>
|
|
138
|
+
</>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function DiscoveryControls() {
|
|
143
|
+
const discovery = useGraphStore((s) => s.discovery);
|
|
144
|
+
const setDiscoveryParams = useGraphStore((s) => s.setDiscoveryParams);
|
|
145
|
+
const colorBy = useGraphStore((s) => s.discoveryColorBy);
|
|
146
|
+
const setColorBy = useGraphStore((s) => s.setDiscoveryColorBy);
|
|
147
|
+
const defaultRoot = useGraphStore((s) => s.meta?.defaultRoot ?? null);
|
|
148
|
+
|
|
149
|
+
return (
|
|
150
|
+
<>
|
|
151
|
+
<Field label="Root">
|
|
152
|
+
<NotePicker
|
|
153
|
+
value={discovery.root ?? defaultRoot?.path ?? null}
|
|
154
|
+
placeholder={defaultRoot ? defaultRoot.path : "Pick a root note…"}
|
|
155
|
+
onPick={(path) => setDiscoveryParams({ root: path })}
|
|
156
|
+
/>
|
|
157
|
+
{discovery.root && defaultRoot && (
|
|
158
|
+
<button
|
|
159
|
+
onClick={() => setDiscoveryParams({ root: null })}
|
|
160
|
+
className="mt-1 text-[11px] text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
|
|
161
|
+
>
|
|
162
|
+
Reset to default root
|
|
163
|
+
</button>
|
|
164
|
+
)}
|
|
165
|
+
</Field>
|
|
166
|
+
<Field label={`Max depth: ${discovery.maxDepth}`}>
|
|
167
|
+
<input
|
|
168
|
+
type="range"
|
|
169
|
+
min={1}
|
|
170
|
+
max={8}
|
|
171
|
+
value={discovery.maxDepth}
|
|
172
|
+
onChange={(e) => setDiscoveryParams({ maxDepth: Number(e.target.value) })}
|
|
173
|
+
className="w-full accent-(--color-primary)"
|
|
174
|
+
/>
|
|
175
|
+
</Field>
|
|
176
|
+
<Field label="Color by">
|
|
177
|
+
<Segmented
|
|
178
|
+
options={[
|
|
179
|
+
{ value: "distance", label: "Distance" },
|
|
180
|
+
{ value: "folder", label: "Folder" },
|
|
181
|
+
]}
|
|
182
|
+
value={colorBy}
|
|
183
|
+
onChange={(v) => setColorBy(v as DiscoveryColorBy)}
|
|
184
|
+
/>
|
|
185
|
+
</Field>
|
|
186
|
+
{/* Direction is only meaningful with an explicit root: the default-root
|
|
187
|
+
distances were walked once at index time. */}
|
|
188
|
+
</>
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function ClustersControls() {
|
|
193
|
+
const clusters = useGraphStore((s) => s.clusters);
|
|
194
|
+
const setClustersParams = useGraphStore((s) => s.setClustersParams);
|
|
195
|
+
const sizeBy = useGraphStore((s) => s.clustersSizeBy);
|
|
196
|
+
const setSizeBy = useGraphStore((s) => s.setClustersSizeBy);
|
|
197
|
+
return (
|
|
198
|
+
<>
|
|
199
|
+
<Field label="Node size">
|
|
200
|
+
<Segmented
|
|
201
|
+
options={[
|
|
202
|
+
{ value: "degree", label: "Links" },
|
|
203
|
+
{ value: "pagerank", label: "PageRank" },
|
|
204
|
+
]}
|
|
205
|
+
value={sizeBy}
|
|
206
|
+
onChange={(v) => setSizeBy(v as SizeBy)}
|
|
207
|
+
/>
|
|
208
|
+
</Field>
|
|
209
|
+
<Field label="Isolated notes">
|
|
210
|
+
<Toggle
|
|
211
|
+
checked={clusters.isolates}
|
|
212
|
+
onChange={(v) => setClustersParams({ isolates: v })}
|
|
213
|
+
label="Include notes without links"
|
|
214
|
+
/>
|
|
215
|
+
</Field>
|
|
216
|
+
</>
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function MaintenanceControls() {
|
|
221
|
+
const maintenance = useGraphStore((s) => s.maintenance);
|
|
222
|
+
const setMaintenanceParams = useGraphStore((s) => s.setMaintenanceParams);
|
|
223
|
+
const filters = useGraphStore((s) => s.maintenanceFilters);
|
|
224
|
+
const setFilters = useGraphStore((s) => s.setMaintenanceFilters);
|
|
225
|
+
return (
|
|
226
|
+
<>
|
|
227
|
+
<Field label="Findings">
|
|
228
|
+
<div className="flex flex-col">
|
|
229
|
+
<Toggle
|
|
230
|
+
checked={filters.orphans}
|
|
231
|
+
onChange={(v) => setFilters({ orphans: v })}
|
|
232
|
+
label="Orphans"
|
|
233
|
+
/>
|
|
234
|
+
<Toggle
|
|
235
|
+
checked={filters.unreachable}
|
|
236
|
+
onChange={(v) => setFilters({ unreachable: v })}
|
|
237
|
+
label="Unreachable"
|
|
238
|
+
/>
|
|
239
|
+
<Toggle
|
|
240
|
+
checked={filters.broken}
|
|
241
|
+
onChange={(v) => setFilters({ broken: v })}
|
|
242
|
+
label="Broken links"
|
|
243
|
+
/>
|
|
244
|
+
<Toggle
|
|
245
|
+
checked={filters.stale}
|
|
246
|
+
onChange={(v) => setFilters({ stale: v })}
|
|
247
|
+
label="Stale notes"
|
|
248
|
+
/>
|
|
249
|
+
</div>
|
|
250
|
+
</Field>
|
|
251
|
+
<Field label={`Stale after ${maintenance.staleDays} days`}>
|
|
252
|
+
<input
|
|
253
|
+
type="range"
|
|
254
|
+
min={30}
|
|
255
|
+
max={720}
|
|
256
|
+
step={30}
|
|
257
|
+
value={maintenance.staleDays}
|
|
258
|
+
onChange={(e) => setMaintenanceParams({ staleDays: Number(e.target.value) })}
|
|
259
|
+
className="w-full accent-(--color-primary)"
|
|
260
|
+
/>
|
|
261
|
+
</Field>
|
|
262
|
+
</>
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// --- Small form primitives ---------------------------------------------------
|
|
267
|
+
|
|
268
|
+
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
|
269
|
+
return (
|
|
270
|
+
<div>
|
|
271
|
+
<div className="mb-1.5 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
|
272
|
+
{label}
|
|
273
|
+
</div>
|
|
274
|
+
{children}
|
|
275
|
+
</div>
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function Segmented({
|
|
280
|
+
options,
|
|
281
|
+
value,
|
|
282
|
+
onChange,
|
|
283
|
+
}: {
|
|
284
|
+
options: { value: string; label: string }[];
|
|
285
|
+
value: string;
|
|
286
|
+
onChange: (value: string) => void;
|
|
287
|
+
}) {
|
|
288
|
+
return (
|
|
289
|
+
<div className="flex rounded-lg border border-border bg-surface-raised p-0.5">
|
|
290
|
+
{options.map((opt) => (
|
|
291
|
+
<button
|
|
292
|
+
key={opt.value}
|
|
293
|
+
onClick={() => onChange(opt.value)}
|
|
294
|
+
className={cn(
|
|
295
|
+
"min-h-9 flex-1 rounded-md px-2 py-1.5 text-xs font-medium transition-colors",
|
|
296
|
+
value === opt.value
|
|
297
|
+
? "bg-surface-overlay text-foreground"
|
|
298
|
+
: "text-muted-foreground hover:text-foreground"
|
|
299
|
+
)}
|
|
300
|
+
>
|
|
301
|
+
{opt.label}
|
|
302
|
+
</button>
|
|
303
|
+
))}
|
|
304
|
+
</div>
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function Toggle({
|
|
309
|
+
checked,
|
|
310
|
+
onChange,
|
|
311
|
+
label,
|
|
312
|
+
}: {
|
|
313
|
+
checked: boolean;
|
|
314
|
+
onChange: (checked: boolean) => void;
|
|
315
|
+
label: string;
|
|
316
|
+
}) {
|
|
317
|
+
return (
|
|
318
|
+
<button
|
|
319
|
+
role="switch"
|
|
320
|
+
aria-checked={checked}
|
|
321
|
+
onClick={() => onChange(!checked)}
|
|
322
|
+
className="flex min-h-9 w-full items-center justify-between gap-2 text-left text-xs text-foreground"
|
|
323
|
+
>
|
|
324
|
+
{label}
|
|
325
|
+
<span
|
|
326
|
+
className={cn(
|
|
327
|
+
"relative h-5 w-9 shrink-0 rounded-full transition-colors",
|
|
328
|
+
checked ? "bg-primary" : "bg-surface-raised border border-border"
|
|
329
|
+
)}
|
|
330
|
+
>
|
|
331
|
+
<span
|
|
332
|
+
className={cn(
|
|
333
|
+
"absolute top-0.5 h-4 w-4 rounded-full bg-foreground transition-transform",
|
|
334
|
+
checked ? "translate-x-4" : "translate-x-0.5"
|
|
335
|
+
)}
|
|
336
|
+
/>
|
|
337
|
+
</span>
|
|
338
|
+
</button>
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Debounced note search backed by /api/brain/search — the graph twin of the
|
|
344
|
+
* search modal's flow, inlined so picking a center/root never leaves the view.
|
|
345
|
+
*/
|
|
346
|
+
function NotePicker({
|
|
347
|
+
value,
|
|
348
|
+
placeholder = "Search notes…",
|
|
349
|
+
onPick,
|
|
350
|
+
}: {
|
|
351
|
+
value: string | null;
|
|
352
|
+
placeholder?: string;
|
|
353
|
+
onPick: (path: string) => void;
|
|
354
|
+
}) {
|
|
355
|
+
const [query, setQuery] = useState("");
|
|
356
|
+
const [editing, setEditing] = useState(false);
|
|
357
|
+
const [results, setResults] = useState<BrainSearchHit[]>([]);
|
|
358
|
+
const [loading, setLoading] = useState(false);
|
|
359
|
+
const controllerRef = useRef<AbortController | null>(null);
|
|
360
|
+
|
|
361
|
+
const trimmed = query.trim();
|
|
362
|
+
|
|
363
|
+
useEffect(() => {
|
|
364
|
+
if (!editing || trimmed.length < 2) {
|
|
365
|
+
controllerRef.current?.abort();
|
|
366
|
+
setResults([]);
|
|
367
|
+
setLoading(false);
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
const t = setTimeout(async () => {
|
|
371
|
+
controllerRef.current?.abort();
|
|
372
|
+
const controller = new AbortController();
|
|
373
|
+
controllerRef.current = controller;
|
|
374
|
+
setLoading(true);
|
|
375
|
+
try {
|
|
376
|
+
const res = await api.brainSearch(trimmed, {
|
|
377
|
+
limit: 8,
|
|
378
|
+
signal: controller.signal,
|
|
379
|
+
});
|
|
380
|
+
if (!controller.signal.aborted) setResults(res.results);
|
|
381
|
+
} catch {
|
|
382
|
+
// Aborted or failed — the next keystroke retries.
|
|
383
|
+
} finally {
|
|
384
|
+
if (!controller.signal.aborted) setLoading(false);
|
|
385
|
+
}
|
|
386
|
+
}, 250);
|
|
387
|
+
return () => clearTimeout(t);
|
|
388
|
+
}, [editing, trimmed]);
|
|
389
|
+
|
|
390
|
+
return (
|
|
391
|
+
<div className="relative">
|
|
392
|
+
<label className="flex items-center gap-2 rounded-lg border border-border bg-surface-raised px-2.5 py-2 focus-within:border-primary/40">
|
|
393
|
+
<Search className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
394
|
+
<input
|
|
395
|
+
value={editing ? query : (value ?? "")}
|
|
396
|
+
onFocus={() => {
|
|
397
|
+
setEditing(true);
|
|
398
|
+
setQuery("");
|
|
399
|
+
}}
|
|
400
|
+
onBlur={() => {
|
|
401
|
+
// Delay so a click on a result lands before the list unmounts.
|
|
402
|
+
setTimeout(() => setEditing(false), 150);
|
|
403
|
+
}}
|
|
404
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
405
|
+
placeholder={placeholder}
|
|
406
|
+
className="min-w-0 flex-1 bg-transparent font-mono text-[11px] text-foreground outline-none placeholder:font-body placeholder:text-xs placeholder:text-muted-foreground"
|
|
407
|
+
autoComplete="off"
|
|
408
|
+
spellCheck={false}
|
|
409
|
+
/>
|
|
410
|
+
{loading && <Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-primary" />}
|
|
411
|
+
</label>
|
|
412
|
+
{editing && results.length > 0 && (
|
|
413
|
+
<div className="absolute inset-x-0 top-full z-20 mt-1 max-h-56 overflow-y-auto rounded-lg border border-border bg-surface-overlay shadow-2xl">
|
|
414
|
+
{results.map((hit) => (
|
|
415
|
+
<button
|
|
416
|
+
key={hit.path}
|
|
417
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
418
|
+
onClick={() => {
|
|
419
|
+
onPick(hit.path);
|
|
420
|
+
setEditing(false);
|
|
421
|
+
}}
|
|
422
|
+
className="flex w-full flex-col px-3 py-2 text-left transition-colors hover:bg-surface-raised"
|
|
423
|
+
>
|
|
424
|
+
<span className="truncate text-xs text-foreground">
|
|
425
|
+
{hit.title || hit.path}
|
|
426
|
+
</span>
|
|
427
|
+
<span className="truncate font-mono text-[10px] text-muted-foreground/60">
|
|
428
|
+
{hit.path}
|
|
429
|
+
</span>
|
|
430
|
+
</button>
|
|
431
|
+
))}
|
|
432
|
+
</div>
|
|
433
|
+
)}
|
|
434
|
+
</div>
|
|
435
|
+
);
|
|
436
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Waypoints, RefreshCw, ServerCrash, TriangleAlert } from "lucide-react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Full-canvas cards for the states where there is no scene to draw:
|
|
6
|
+
* server too old, repo indexed by a pre-graph CLI, graph not yet computed,
|
|
7
|
+
* plain fetch failures, and mode-specific "nothing here" messages.
|
|
8
|
+
*/
|
|
9
|
+
export function GraphEmptyState({
|
|
10
|
+
icon = "graph",
|
|
11
|
+
title,
|
|
12
|
+
children,
|
|
13
|
+
}: {
|
|
14
|
+
icon?: "graph" | "sync" | "server" | "warn";
|
|
15
|
+
title: string;
|
|
16
|
+
children?: ReactNode;
|
|
17
|
+
}) {
|
|
18
|
+
const Icon =
|
|
19
|
+
icon === "sync"
|
|
20
|
+
? RefreshCw
|
|
21
|
+
: icon === "server"
|
|
22
|
+
? ServerCrash
|
|
23
|
+
: icon === "warn"
|
|
24
|
+
? TriangleAlert
|
|
25
|
+
: Waypoints;
|
|
26
|
+
return (
|
|
27
|
+
<div className="flex h-full items-center justify-center p-6">
|
|
28
|
+
<div className="flex max-w-sm flex-col items-center gap-3 rounded-xl border border-border bg-surface px-8 py-10 text-center">
|
|
29
|
+
<Icon className="h-8 w-8 text-muted-foreground" />
|
|
30
|
+
<h2 className="font-display text-lg text-foreground">{title}</h2>
|
|
31
|
+
{children && (
|
|
32
|
+
<div className="text-xs leading-relaxed text-muted-foreground">{children}</div>
|
|
33
|
+
)}
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function Mono({ children }: { children: ReactNode }) {
|
|
40
|
+
return (
|
|
41
|
+
<code className="rounded bg-surface-raised px-1.5 py-0.5 font-mono text-[11px] text-foreground">
|
|
42
|
+
{children}
|
|
43
|
+
</code>
|
|
44
|
+
);
|
|
45
|
+
}
|