| @@ -1,31 +1,9 @@ |
| 1 | import { useState, useMemo } from "react"; | | |
| 2 | import { Link, Outlet, useMatchRoute } from "@tanstack/react-router"; | 1 | import { Link, Outlet, useMatchRoute } from "@tanstack/react-router"; |
| 3 | import { useSources, type Source } from "@executor-v3/react"; | 2 | import { useSources, type Source } from "@executor-v3/react"; |
| 4 | import { cn } from "../lib/utils"; | 3 | import { cn } from "../lib/utils"; |
| 5 | import { IconSources, IconChevron } from "./icons"; | 4 | import { IconSources } from "./icons"; |
| 6 | import { LoadableBlock } from "./loadable"; | 5 | import { LoadableBlock } from "./loadable"; |
| 7 | | 6 | |
| 8 | // ── Kind labels + ordering ─────────────────────────────────────────────── | | |
| 9 | | | |
| 10 | const kindMeta: Record<string, { label: string; order: number }> = { | | |
| 11 | mcp: { label: "MCP", order: 0 }, | | |
| 12 | openapi: { label: "OpenAPI", order: 1 }, | | |
| 13 | graphql: { label: "GraphQL", order: 2 }, | | |
| 14 | internal: { label: "Internal", order: 3 }, | | |
| 15 | }; | | |
| 16 | | | |
| 17 | function groupByKind(sources: ReadonlyArray<Source>) { | | |
| 18 | const groups = new Map<string, Source[]>(); | | |
| 19 | for (const s of sources) { | | |
| 20 | const list = groups.get(s.kind) ?? []; | | |
| 21 | list.push(s); | | |
| 22 | groups.set(s.kind, list); | | |
| 23 | } | | |
| 24 | return [...groups.entries()].sort( | | |
| 25 | (a, b) => (kindMeta[a[0]]?.order ?? 99) - (kindMeta[b[0]]?.order ?? 99), | | |
| 26 | ); | | |
| 27 | } | | |
| 28 | | | |
| 29 | // ── Status dot color ───────────────────────────────────────────────────── | 7 | // ── Status dot color ───────────────────────────────────────────────────── |
| 30 | | 8 | |
| 31 | const statusColor: Record<string, string> = { | 9 | const statusColor: Record<string, string> = { |
| @@ -63,7 +41,7 @@ export function AppShell() { · 31 unmodified lines |
| 63 | <nav className="flex flex-1 flex-col p-2 overflow-y-auto"> | 41 | <nav className="flex flex-1 flex-col p-2 overflow-y-auto"> |
| 64 | <NavItem to="/" label="Dashboard" active={!!isHome} /> | 42 | <NavItem to="/" label="Dashboard" active={!!isHome} /> |
| 65 | | 43 | |
| 66 | {/* Sources tree */} | 44 | {/* Sources */} |
| 67 | <div className="mt-5 mb-1 px-2.5 text-[10px] font-medium uppercase tracking-widest text-muted-foreground/50"> | 45 | <div className="mt-5 mb-1 px-2.5 text-[10px] font-medium uppercase tracking-widest text-muted-foreground/50"> |
| 68 | Sources | 46 | Sources |
| 69 | </div> | 47 | </div> |
| @@ -74,7 +52,11 @@ export function AppShell() { · 4 unmodified lines |
| 74 | No sources yet | 52 | No sources yet |
| 75 | </div> | 53 | </div> |
| 76 | ) : ( | 54 | ) : ( |
| 77 | <SourceTree sources={items} matchRoute={matchRoute} /> | 55 | <div className="flex flex-col gap-px"> |
| | 56 | {items.map((source) => ( |
| | 57 | <SourceItem key={source.id} source={source} matchRoute={matchRoute} /> |
| | 58 | ))} |
| | 59 | </div> |
| 78 | ) | 60 | ) |
| 79 | } | 61 | } |
| 80 | </LoadableBlock> | 62 | </LoadableBlock> |
| @@ -96,87 +78,13 @@ export function AppShell() { · 15 unmodified lines |
| 96 | ); | 78 | ); |
| 97 | } | 79 | } |
| 98 | | 80 | |
| 99 | // ── SourceTree ─────────────────────────────────────────────────────────── | | |
| 100 | | | |
| 101 | function SourceTree(props: { | | |
| 102 | sources: ReadonlyArray<Source>; | | |
| 103 | matchRoute: ReturnType<typeof useMatchRoute>; | | |
| 104 | }) { | | |
| 105 | const groups = useMemo(() => groupByKind(props.sources), [props.sources]); | | |
| 106 | | | |
| 107 | // If there's only one kind, skip the group headers | | |
| 108 | if (groups.length === 1) { | | |
| 109 | return ( | | |
| 110 | <div className="flex flex-col gap-px"> | | |
| 111 | {groups[0]![1].map((source) => ( | | |
| 112 | <SourceItem key={source.id} source={source} matchRoute={props.matchRoute} depth={0} /> | | |
| 113 | ))} | | |
| 114 | </div> | | |
| 115 | ); | | |
| 116 | } | | |
| 117 | | | |
| 118 | return ( | | |
| 119 | <div className="flex flex-col gap-0.5"> | | |
| 120 | {groups.map(([kind, sources]) => ( | | |
| 121 | <KindGroup key={kind} kind={kind} sources={sources} matchRoute={props.matchRoute} /> | | |
| 122 | ))} | | |
| 123 | </div> | | |
| 124 | ); | | |
| 125 | } | | |
| 126 | | | |
| 127 | // ── KindGroup (collapsible) ────────────────────────────────────────────── | | |
| 128 | | | |
| 129 | function KindGroup(props: { | | |
| 130 | kind: string; | | |
| 131 | sources: Source[]; | | |
| 132 | matchRoute: ReturnType<typeof useMatchRoute>; | | |
| 133 | }) { | | |
| 134 | // Auto-expand if any source in this group is active | | |
| 135 | const hasActive = props.sources.some((s) => | | |
| 136 | props.matchRoute({ to: "/sources/$sourceId", params: { sourceId: s.id }, fuzzy: true }), | | |
| 137 | ); | | |
| 138 | const [open, setOpen] = useState(true); | | |
| 139 | | | |
| 140 | return ( | | |
| 141 | <div> | | |
| 142 | {/* Group header */} | | |
| 143 | <button | | |
| 144 | type="button" | | |
| 145 | onClick={() => setOpen((o) => !o)} | | |
| 146 | className="group flex w-full items-center gap-1.5 rounded-md px-2 py-1 text-[11px] font-medium text-muted-foreground/60 transition-colors hover:text-muted-foreground" | | |
| 147 | > | | |
| 148 | <IconChevron | | |
| 149 | className={cn( | | |
| 150 | "size-2.5 shrink-0 text-muted-foreground/30 transition-transform duration-150", | | |
| 151 | open && "rotate-90", | | |
| 152 | )} | | |
| 153 | /> | | |
| 154 | <span className="uppercase tracking-wider">{kindMeta[props.kind]?.label ?? props.kind}</span> | | |
| 155 | <span className="ml-auto tabular-nums text-[10px] text-muted-foreground/30"> | | |
| 156 | {props.sources.length} | | |
| 157 | </span> | | |
| 158 | </button> | | |
| 159 | | | |
| 160 | {/* Children */} | | |
| 161 | {open && ( | | |
| 162 | <div className="flex flex-col gap-px mt-px"> | | |
| 163 | {props.sources.map((source) => ( | | |
| 164 | <SourceItem key={source.id} source={source} matchRoute={props.matchRoute} depth={1} /> | | |
| 165 | ))} | | |
| 166 | </div> | | |
| 167 | )} | | |
| 168 | </div> | | |
| 169 | ); | | |
| 170 | } | | |
| 171 | | | |
| 172 | // ── SourceItem ─────────────────────────────────────────────────────────── | 81 | // ── SourceItem ─────────────────────────────────────────────────────────── |
| 173 | | 82 | |
| 174 | function SourceItem(props: { | 83 | function SourceItem(props: { |
| 175 | source: Source; | 84 | source: Source; |
| 176 | matchRoute: ReturnType<typeof useMatchRoute>; | 85 | matchRoute: ReturnType<typeof useMatchRoute>; |
| 177 | depth: number; | | |
| 178 | }) { | 86 | }) { |
| 179 | const { source, matchRoute, depth } = props; | 87 | const { source, matchRoute } = props; |
| 180 | const active = matchRoute({ | 88 | const active = matchRoute({ |
| 181 | to: "/sources/$sourceId", | 89 | to: "/sources/$sourceId", |
| 182 | params: { sourceId: source.id }, | 90 | params: { sourceId: source.id }, |
| @@ -189,22 +97,14 @@ function SourceItem(props: { · 6 unmodified lines |
| 189 | params={{ sourceId: source.id }} | 97 | params={{ sourceId: source.id }} |
| 190 | search={{ tab: "model" }} | 98 | search={{ tab: "model" }} |
| 191 | className={cn( | 99 | className={cn( |
| 192 | "group flex items-center gap-2 rounded-md py-1.5 text-[12px] transition-colors", | 100 | "group flex items-center gap-2 rounded-md px-2.5 py-1.5 text-[13px] transition-colors", |
| 193 | depth === 0 ? "px-2.5" : "pl-6 pr-2.5", | | |
| 194 | active | 101 | active |
| 195 | ? "bg-sidebar-active text-foreground font-medium" | 102 | ? "bg-sidebar-active text-foreground font-medium" |
| 196 | : "text-sidebar-foreground hover:bg-sidebar-active/60 hover:text-foreground", | 103 | : "text-sidebar-foreground hover:bg-sidebar-active/60 hover:text-foreground", |
| 197 | )} | 104 | )} |
| 198 | > | 105 | > |
| 199 | {/* Tree connector line for nested items */} | | |
| 200 | {depth > 0 && ( | | |
| 201 | <span className="absolute left-[18px] h-full w-px bg-sidebar-border" aria-hidden /> | | |
| 202 | )} | | |
| 203 | | | |
| 204 | <IconSources className="size-3 shrink-0 text-muted-foreground/40" /> | 106 | <IconSources className="size-3 shrink-0 text-muted-foreground/40" /> |
| 205 | <span className="flex-1 truncate">{source.name}</span> | 107 | <span className="flex-1 truncate">{source.name}</span> |
| 206 | | | |
| 207 | {/* Status dot + tooltip */} | | |
| 208 | <span | 108 | <span |
| 209 | className={cn("size-1.5 shrink-0 rounded-full", statusColor[source.status] ?? "bg-muted-foreground/30")} | 109 | className={cn("size-1.5 shrink-0 rounded-full", statusColor[source.status] ?? "bg-muted-foreground/30")} |
| 210 | title={source.status} | 110 | title={source.status} |