| @@ -1,31 +1,9 @@ |
| 1 | | −import { useState, useMemo } from "react"; |
| 2 | 1 | import { Link, Outlet, useMatchRoute } from "@tanstack/react-router"; |
| 3 | 2 | import { useSources, type Source } from "@executor-v3/react"; |
| 4 | 3 | import { cn } from "../lib/utils"; |
| 5 | | −import { IconSources, IconChevron } from "./icons"; |
| 4 | +import { IconSources } from "./icons"; |
| 6 | 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 | 7 | // ── Status dot color ───────────────────────────────────────────────────── |
| 30 | 8 | |
| 31 | 9 | const statusColor: Record<string, string> = { |
| @@ -63,7 +41,7 @@ export function AppShell() { · 31 unmodified lines |
| 63 | 41 | <nav className="flex flex-1 flex-col p-2 overflow-y-auto"> |
| 64 | 42 | <NavItem to="/" label="Dashboard" active={!!isHome} /> |
| 65 | 43 | |
| 66 | | − {/* Sources tree */} |
| 44 | + {/* Sources */} |
| 67 | 45 | <div className="mt-5 mb-1 px-2.5 text-[10px] font-medium uppercase tracking-widest text-muted-foreground/50"> |
| 68 | 46 | Sources |
| 69 | 47 | </div> |
| @@ -74,7 +52,11 @@ export function AppShell() { · 4 unmodified lines |
| 74 | 52 | No sources yet |
| 75 | 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 | 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 | 81 | // ── SourceItem ─────────────────────────────────────────────────────────── |
| 173 | 82 | |
| 174 | 83 | function SourceItem(props: { |
| 175 | 84 | source: Source; |
| 176 | 85 | matchRoute: ReturnType<typeof useMatchRoute>; |
| 177 | | − depth: number; |
| 178 | 86 | }) { |
| 179 | | − const { source, matchRoute, depth } = props; |
| 87 | + const { source, matchRoute } = props; |
| 180 | 88 | const active = matchRoute({ |
| 181 | 89 | to: "/sources/$sourceId", |
| 182 | 90 | params: { sourceId: source.id }, |
| @@ -189,22 +97,14 @@ function SourceItem(props: { · 6 unmodified lines |
| 189 | 97 | params={{ sourceId: source.id }} |
| 190 | 98 | search={{ tab: "model" }} |
| 191 | 99 | className={cn( |
| 192 | | − "group flex items-center gap-2 rounded-md py-1.5 text-[12px] transition-colors", |
| 193 | | − depth === 0 ? "px-2.5" : "pl-6 pr-2.5", |
| 100 | + "group flex items-center gap-2 rounded-md px-2.5 py-1.5 text-[13px] transition-colors", |
| 194 | 101 | active |
| 195 | 102 | ? "bg-sidebar-active text-foreground font-medium" |
| 196 | 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 | 106 | <IconSources className="size-3 shrink-0 text-muted-foreground/40" /> |
| 205 | 107 | <span className="flex-1 truncate">{source.name}</span> |
| 206 | | − |
| 207 | | − {/* Status dot + tooltip */} |
| 208 | 108 | <span |
| 209 | 109 | className={cn("size-1.5 shrink-0 rounded-full", statusColor[source.status] ?? "bg-muted-foreground/30")} |
| 210 | 110 | title={source.status} |