mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-09 09:11:00 +08:00
feat(ui): flow merging, treemap bars, persistent galaxy (Field Guide D5)
- The galaxy stays mounted once visited: hidden with invisible (never display:none, which zeroes a canvas), WebGL context and layout kept, frameloop paused via the scene's active prop - switching tabs no longer drops and refetches the whole 3D scene. - Flows list merges journeys by entry point: one row per entry (richest first) with an 'x N journeys from <entry> - show all' expander, so ten main() walks stop drowning the list. - Modules gains a map|bars toggle (persisted): the same children as a sorted horizontal-bar list - region-colored, symbol counts - for when proportion matters less than ranking. - Filter chips strike through when disabled (excluded, not missing). - One-time in-place hint on the region scene; dismissing persists, leaving without dismissing does not. Tests: groupFlowsByEntry ordering/merge (vitest); suite green with the keep-mounted change. Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
+29
-11
@@ -74,6 +74,13 @@ export function App() {
|
||||
const t = useUiMessages();
|
||||
const [route, setRoute] = useState<RouteState>(readRoute);
|
||||
const [commandOpen, setCommandOpen] = useState(false);
|
||||
/* The galaxy keeps its WebGL context and layout across tab switches:
|
||||
* mounted once visited, hidden with `invisible` (never display:none, which
|
||||
* would zero the canvas), frameloop paused while hidden. */
|
||||
const [graphVisited, setGraphVisited] = useState(false);
|
||||
useEffect(() => {
|
||||
if (route.tab === "graph") setGraphVisited(true);
|
||||
}, [route.tab]);
|
||||
const { tab: activeTab, project: selectedProject } = route;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -217,7 +224,26 @@ export function App() {
|
||||
</header>
|
||||
|
||||
{/* Content */}
|
||||
<main className="flex-1 min-h-0">
|
||||
<main className="flex-1 min-h-0 relative">
|
||||
{graphVisited && (
|
||||
<div
|
||||
className={
|
||||
activeTab === "graph"
|
||||
? "h-full"
|
||||
: "invisible pointer-events-none absolute inset-0"
|
||||
}
|
||||
>
|
||||
<GraphTab
|
||||
active={activeTab === "graph"}
|
||||
project={selectedProject}
|
||||
routeNode={route.node}
|
||||
routeRegion={route.region}
|
||||
onRouteChange={(node, region) =>
|
||||
navigate({ tab: "graph", node, region })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{activeTab === "overview" && selectedProject ? (
|
||||
<OverviewTab
|
||||
project={selectedProject}
|
||||
@@ -235,16 +261,8 @@ export function App() {
|
||||
onOpenSymbol={openSymbol}
|
||||
onOpenRegion={openRegion}
|
||||
/>
|
||||
) : activeTab === "graph" ? (
|
||||
<GraphTab
|
||||
project={selectedProject}
|
||||
routeNode={route.node}
|
||||
routeRegion={route.region}
|
||||
onRouteChange={(node, region) =>
|
||||
navigate({ tab: "graph", node, region })
|
||||
}
|
||||
/>
|
||||
) : activeTab === "flows" && selectedProject ? (
|
||||
) : activeTab === "graph" ? null : activeTab === "flows" &&
|
||||
selectedProject ? (
|
||||
<FlowsTab
|
||||
project={selectedProject}
|
||||
flowId={route.flow !== null ? Number(route.flow) : null}
|
||||
|
||||
@@ -131,7 +131,7 @@ export function FilterPanel({
|
||||
key={label}
|
||||
onClick={() => onToggleLabel(label)}
|
||||
className={`inline-flex items-center gap-1 px-1.5 py-[3px] rounded-md text-[12px] font-medium transition-all border ${
|
||||
on ? "border-white/[0.08] bg-popover" : "border-transparent opacity-25"
|
||||
on ? "border-white/[0.08] bg-popover" : "border-transparent opacity-25 line-through"
|
||||
}`}
|
||||
>
|
||||
<span className="w-[5px] h-[5px] rounded-full" style={{ backgroundColor: on ? c : "#444" }} />
|
||||
@@ -156,7 +156,7 @@ export function FilterPanel({
|
||||
key={type}
|
||||
onClick={() => onToggleEdgeType(type)}
|
||||
className={`inline-flex items-center gap-1 px-1.5 py-[3px] rounded-md text-[12px] font-medium transition-all border ${
|
||||
on ? "border-border bg-popover text-foreground/60" : "border-transparent opacity-20 text-foreground/45"
|
||||
on ? "border-border bg-popover text-foreground/60" : "border-transparent opacity-20 text-foreground/45 line-through"
|
||||
}`}
|
||||
>
|
||||
{type.replace(/_/g, " ").toLowerCase()}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type FlowsPayload,
|
||||
type TracePayload,
|
||||
} from "../lib/atlas";
|
||||
import { groupFlowsByEntry } from "../lib/flowgroup";
|
||||
import { AddToPromptButton } from "./PromptBasket";
|
||||
|
||||
interface FlowsTabProps {
|
||||
@@ -125,6 +126,7 @@ export function FlowsTab({ project, flowId, onOpenFlow, onOpenSymbol }: FlowsTab
|
||||
const [traceTo, setTraceTo] = useState<string | null>(null);
|
||||
const [traceMode, setTraceMode] = useState<"calls" | "data">("calls");
|
||||
const [trace, setTrace] = useState<TracePayload | null>(null);
|
||||
const [expandedEntries, setExpandedEntries] = useState<Set<string>>(new Set());
|
||||
const [tracing, setTracing] = useState(false);
|
||||
|
||||
const runTrace = async () => {
|
||||
@@ -299,27 +301,53 @@ export function FlowsTab({ project, flowId, onOpenFlow, onOpenSymbol }: FlowsTab
|
||||
<p className="px-4 pt-2 pb-1 text-[12px] uppercase tracking-widest text-foreground/40">
|
||||
{groupKey === "cross" ? "across regions" : "within one region"}
|
||||
</p>
|
||||
{flows.map((flow) => (
|
||||
<button
|
||||
key={flow.id}
|
||||
onClick={() => onOpenFlow(flow.id)}
|
||||
className={`flex items-center gap-2 w-full text-left px-4 py-[6px] transition-colors ${
|
||||
flowId === flow.id
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-foreground/60 hover:text-foreground/85 hover:bg-surface-3"
|
||||
}`}
|
||||
>
|
||||
<span className="text-[13px] font-mono truncate flex-1">{flow.label}</span>
|
||||
<span className="text-[12px] tabular-nums text-foreground/40 shrink-0">
|
||||
{flow.steps}
|
||||
</span>
|
||||
{!flow.sink_terminated && (
|
||||
<span className="text-[12px] text-foreground/35 shrink-0" title="walk stopped at the depth cap, not at a sink">
|
||||
…
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{groupFlowsByEntry(flows).map((group) => {
|
||||
const groupKey2 = `${groupKey}:${group.entryName}`;
|
||||
const expanded = expandedEntries.has(groupKey2);
|
||||
const visible = expanded ? group.flows : group.flows.slice(0, 1);
|
||||
return (
|
||||
<div key={groupKey2}>
|
||||
{visible.map((flow) => (
|
||||
<button
|
||||
key={flow.id}
|
||||
onClick={() => onOpenFlow(flow.id)}
|
||||
className={`flex items-center gap-2 w-full text-left px-4 py-[6px] transition-colors ${
|
||||
flowId === flow.id
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-foreground/60 hover:text-foreground/85 hover:bg-surface-3"
|
||||
}`}
|
||||
>
|
||||
<span className="text-[13px] font-mono truncate flex-1">{flow.label}</span>
|
||||
<span className="text-[12px] tabular-nums text-foreground/40 shrink-0">
|
||||
{flow.steps}
|
||||
</span>
|
||||
{!flow.sink_terminated && (
|
||||
<span className="text-[12px] text-foreground/35 shrink-0" title="walk stopped at the depth cap, not at a sink">
|
||||
…
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{group.flows.length > 1 && (
|
||||
<button
|
||||
onClick={() =>
|
||||
setExpandedEntries((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(groupKey2)) next.delete(groupKey2);
|
||||
else next.add(groupKey2);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
className="w-full text-left px-4 py-[3px] text-[12px] text-foreground/40 hover:text-primary transition-colors"
|
||||
>
|
||||
{expanded
|
||||
? "− collapse"
|
||||
: `× ${group.flows.length} journeys from ${group.entryName} — show all`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -111,6 +111,8 @@ function IdleAutoRotate({
|
||||
/* ── Main scene ─────────────────────────────────────────── */
|
||||
|
||||
interface GraphSceneProps {
|
||||
/* False pauses the render loop (hidden-but-mounted tab). */
|
||||
active?: boolean;
|
||||
data: GraphData;
|
||||
/* Missed skeleton (#963): pre-offset, pre-painted white nodes + edges of
|
||||
* the not-fully-indexed files, rendered as a ghost cluster beside the
|
||||
@@ -131,6 +133,7 @@ interface GraphSceneProps {
|
||||
export type { CameraTarget };
|
||||
|
||||
export function GraphScene({
|
||||
active = true,
|
||||
data,
|
||||
missed = null,
|
||||
highlightedIds,
|
||||
@@ -155,6 +158,7 @@ export function GraphScene({
|
||||
|
||||
return (
|
||||
<Canvas
|
||||
frameloop={active ? "always" : "never"}
|
||||
camera={{ position: [0, 0, 800], fov: 50, near: 0.1, far: 100000 }}
|
||||
style={{ background: "#0D0F12" }}
|
||||
dpr={GRAPH_CANVAS_DPR}
|
||||
|
||||
@@ -35,6 +35,40 @@ import { ErrorBoundary } from "./ErrorBoundary";
|
||||
import type { GraphNode, GraphData, Region, RegionsPayload, RepoInfo } from "../lib/types";
|
||||
import { colorForStatus } from "../lib/colors";
|
||||
|
||||
/* One-time in-place orientation hint for the region scene. Dismissing
|
||||
* persists; navigating away without dismissing does not (skip != dismiss). */
|
||||
function GalaxyHint() {
|
||||
const [dismissed, setDismissed] = useState(() => {
|
||||
try {
|
||||
return localStorage.getItem("cbm-hint-galaxy") === "1";
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
if (dismissed) return null;
|
||||
return (
|
||||
<div className="absolute bottom-4 left-4 bg-card/90 backdrop-blur border border-border/50 rounded-md px-3 py-2.5 max-w-[300px] shadow-lg">
|
||||
<p className="text-[13px] text-foreground/70 leading-relaxed">
|
||||
Each sphere is a region of related code. Double-click one to enter it,
|
||||
type above to light up matches, and use ⌂ on the map to fit everything.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
try {
|
||||
localStorage.setItem("cbm-hint-galaxy", "1");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
setDismissed(true);
|
||||
}}
|
||||
className="mt-1.5 text-[12px] text-primary hover:text-primary/80 transition-colors"
|
||||
>
|
||||
Got it
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* Persist panel widths */
|
||||
function loadWidth(key: string, fallback: number): number {
|
||||
try {
|
||||
@@ -63,6 +97,8 @@ function saveNodeBudget(project: string, value: number) {
|
||||
}
|
||||
|
||||
interface GraphTabProps {
|
||||
/* False while the tab is hidden-but-mounted: the render loop pauses. */
|
||||
active?: boolean;
|
||||
project: string | null;
|
||||
/* Deep-link state from the URL (?node=®ion=) and the reporter back. */
|
||||
routeNode?: string | null;
|
||||
@@ -84,6 +120,7 @@ export function formatGraphLimitNotice(data: GraphData | null): string | null {
|
||||
}
|
||||
|
||||
export function GraphTab({
|
||||
active = true,
|
||||
project,
|
||||
routeNode = null,
|
||||
routeRegion = null,
|
||||
@@ -608,6 +645,7 @@ export function GraphTab({
|
||||
<div className="flex-1 relative overflow-hidden">
|
||||
<ErrorBoundary>
|
||||
<GraphScene
|
||||
active={active}
|
||||
data={regionGraph}
|
||||
missed={null}
|
||||
highlightedIds={
|
||||
@@ -666,6 +704,8 @@ export function GraphTab({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<GalaxyHint />
|
||||
|
||||
<div className="absolute top-4 right-4 flex gap-2 items-center">
|
||||
<Button variant="outline" size="sm" onClick={loadFullGalaxy}>
|
||||
Load full galaxy
|
||||
@@ -796,6 +836,7 @@ export function GraphTab({
|
||||
<>
|
||||
<ErrorBoundary>
|
||||
<GraphScene
|
||||
active={active}
|
||||
data={filteredData}
|
||||
missed={showMissedSkeleton ? missedSkeleton : null}
|
||||
highlightedIds={highlightedIds}
|
||||
|
||||
@@ -105,6 +105,22 @@ export function ModulesTab({
|
||||
[project],
|
||||
);
|
||||
|
||||
const [viewMode, setViewMode] = useState<"map" | "bars">(() => {
|
||||
try {
|
||||
return localStorage.getItem("cbm-modules-view") === "bars" ? "bars" : "map";
|
||||
} catch {
|
||||
return "map";
|
||||
}
|
||||
});
|
||||
const switchView = (mode: "map" | "bars") => {
|
||||
setViewMode(mode);
|
||||
try {
|
||||
localStorage.setItem("cbm-modules-view", mode);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
const crumbs = useMemo(() => {
|
||||
const parts = path ? path.split("/") : [];
|
||||
return [{ label: "root", path: "" }].concat(
|
||||
@@ -154,8 +170,23 @@ export function ModulesTab({
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<div className="ml-auto flex items-center gap-0.5 bg-popover rounded-md p-0.5 border border-border/40">
|
||||
{(["map", "bars"] as const).map((mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
onClick={() => switchView(mode)}
|
||||
className={`px-2 py-0.5 rounded text-[12px] transition-colors ${
|
||||
viewMode === mode
|
||||
? "bg-primary/20 text-primary"
|
||||
: "text-foreground/45 hover:text-foreground/70"
|
||||
}`}
|
||||
>
|
||||
{mode}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{tree && (
|
||||
<span className="ml-auto text-[13px] text-foreground/45 font-mono">
|
||||
<span className="text-[13px] text-foreground/45 font-mono">
|
||||
{tree.files.toLocaleString("en-US")} files ·{" "}
|
||||
{tree.symbols.toLocaleString("en-US")} symbols
|
||||
{tree.children_dropped ? ` · ${tree.children_dropped} children not shown` : ""}
|
||||
@@ -170,6 +201,47 @@ export function ModulesTab({
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<p className="text-foreground/45 text-sm">Measuring the territory…</p>
|
||||
</div>
|
||||
) : viewMode === "bars" ? (
|
||||
<div className="h-full overflow-y-auto pr-2">
|
||||
{[...tree.children]
|
||||
.sort((a, b) => b.symbols - a.symbols)
|
||||
.map((child) => {
|
||||
const meta =
|
||||
child.region !== undefined ? regionMeta.get(child.region) : undefined;
|
||||
const max = Math.max(...tree.children.map((c) => c.symbols), 1);
|
||||
return (
|
||||
<button
|
||||
key={child.path}
|
||||
onClick={() =>
|
||||
child.kind === "dir" ? onNavigatePath(child.path) : openFile(child)
|
||||
}
|
||||
className="flex items-center gap-2 w-full text-left px-2 py-[5px] rounded-md hover:bg-surface-3 transition-colors group"
|
||||
>
|
||||
<span
|
||||
className="w-[7px] h-[7px] rounded-full shrink-0"
|
||||
style={{ backgroundColor: meta?.color ?? NEUTRAL }}
|
||||
/>
|
||||
<span className="text-[13px] font-mono text-foreground/70 group-hover:text-foreground/90 truncate w-[220px] shrink-0 transition-colors">
|
||||
{child.name}
|
||||
{child.kind === "dir" ? "/" : ""}
|
||||
</span>
|
||||
<span className="flex-1 h-[10px] bg-surface-3/60 rounded-sm overflow-hidden">
|
||||
<span
|
||||
className="block h-full rounded-sm"
|
||||
style={{
|
||||
width: `${Math.max(1, (child.symbols / max) * 100)}%`,
|
||||
backgroundColor: meta?.color ?? NEUTRAL,
|
||||
opacity: 0.65,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
<span className="text-[12px] tabular-nums text-foreground/40 shrink-0 w-[70px] text-right">
|
||||
{child.symbols.toLocaleString("en-US")}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<svg
|
||||
width="100%"
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { groupFlowsByEntry } from "./flowgroup";
|
||||
import type { FlowSummary } from "./atlas";
|
||||
|
||||
function flow(id: number, entry: string, steps: number): FlowSummary {
|
||||
return {
|
||||
id,
|
||||
label: `${entry} → t${id}`,
|
||||
entry: { id: id * 10, name: entry },
|
||||
terminal: { id: id * 10 + 1, name: `t${id}` },
|
||||
steps,
|
||||
sink_terminated: true,
|
||||
cross_region: false,
|
||||
} as FlowSummary;
|
||||
}
|
||||
|
||||
describe("groupFlowsByEntry", () => {
|
||||
it("merges flows sharing an entry, richest first, order preserved", () => {
|
||||
const groups = groupFlowsByEntry([
|
||||
flow(1, "main", 5),
|
||||
flow(2, "serve", 9),
|
||||
flow(3, "main", 12),
|
||||
]);
|
||||
expect(groups.map((g) => g.entryName)).toEqual(["main", "serve"]);
|
||||
expect(groups[0].flows.map((f) => f.id)).toEqual([3, 1]);
|
||||
expect(groups[1].flows).toHaveLength(1);
|
||||
});
|
||||
it("handles empty input", () => {
|
||||
expect(groupFlowsByEntry([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { FlowSummary } from "./atlas";
|
||||
|
||||
/* Merge flows that share an entry point: the list shows one row per entry,
|
||||
* richest flow first, with the rest behind a x-N expander. */
|
||||
export interface FlowEntryGroup {
|
||||
entryName: string;
|
||||
flows: FlowSummary[];
|
||||
}
|
||||
|
||||
export function groupFlowsByEntry(flows: FlowSummary[]): FlowEntryGroup[] {
|
||||
const order: string[] = [];
|
||||
const byEntry = new Map<string, FlowSummary[]>();
|
||||
for (const flow of flows) {
|
||||
const key = flow.entry.name;
|
||||
const bucket = byEntry.get(key);
|
||||
if (bucket) {
|
||||
bucket.push(flow);
|
||||
} else {
|
||||
byEntry.set(key, [flow]);
|
||||
order.push(key);
|
||||
}
|
||||
}
|
||||
return order.map((entryName) => {
|
||||
const group = byEntry.get(entryName)!;
|
||||
return {
|
||||
entryName,
|
||||
flows: [...group].sort((a, b) => b.steps - a.steps),
|
||||
};
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user