From e072a2a68cf36688fcaf11cce1958fc54ee605ad Mon Sep 17 00:00:00 2001 From: Zadak Date: Wed, 1 Jul 2026 17:12:57 +0200 Subject: [PATCH 1/4] feat(graph-ui): scrollable filter panel with clearer grouping Cap the filter panel height and move the node and edge chips into a scroll area so a long type list no longer pushes the folder tree off screen. Regroup the chips under Node types and Relationships, pin the show-labels toggle as a footer, and add a Folders heading (en and zh) above the folder tree so the section is clearly labelled. Signed-off-by: Zadak --- graph-ui/src/components/FilterPanel.tsx | 132 +++++++++++++----------- graph-ui/src/components/Sidebar.tsx | 7 +- graph-ui/src/lib/i18n.ts | 2 + 3 files changed, 80 insertions(+), 61 deletions(-) diff --git a/graph-ui/src/components/FilterPanel.tsx b/graph-ui/src/components/FilterPanel.tsx index 25d03c1e6..6f1c7ca0e 100644 --- a/graph-ui/src/components/FilterPanel.tsx +++ b/graph-ui/src/components/FilterPanel.tsx @@ -1,4 +1,5 @@ import { useMemo } from "react"; +import { ScrollArea } from "@/components/ui/scroll-area"; import { colorForLabel } from "../lib/colors"; import type { GraphData } from "../lib/types"; @@ -37,9 +38,9 @@ export function FilterPanel({ }, [data]); return ( -
- {/* Header row */} -
+
+ {/* Header row — always visible */} +
Filters @@ -50,66 +51,77 @@ export function FilterPanel({
- {/* Node labels */} -
-

Nodes

-
- {labelCounts.map(([label, count]) => { - const on = enabledLabels.has(label); - const c = colorForLabel(label); - return ( - - ); - })} -
-
+ {/* Scrollable filter groups */} + +
+ {/* Node types */} + {labelCounts.length > 0 && ( +
+

Node types

+
+ {labelCounts.map(([label, count]) => { + const on = enabledLabels.has(label); + const c = colorForLabel(label); + return ( + + ); + })} +
+
+ )} - {/* Edge types */} -
-

Edges

-
- {edgeTypeCounts.map(([type, count]) => { - const on = enabledEdgeTypes.has(type); - return ( - - ); - })} + {/* Relationships */} + {edgeTypeCounts.length > 0 && ( +
+

Relationships

+
+ {edgeTypeCounts.map(([type, count]) => { + const on = enabledEdgeTypes.has(type); + return ( + + ); + })} +
+
+ )}
-
+ - {/* Show labels toggle */} - + {/* Display options — pinned footer */} +
+ +
); } diff --git a/graph-ui/src/components/Sidebar.tsx b/graph-ui/src/components/Sidebar.tsx index 73c3f898f..0343940a4 100644 --- a/graph-ui/src/components/Sidebar.tsx +++ b/graph-ui/src/components/Sidebar.tsx @@ -120,7 +120,12 @@ export function Sidebar({ nodes, onSelectPath, selectedPath }: SidebarProps) { return (
-
+
+ + {t.graph.folders} + +
+
Date: Wed, 1 Jul 2026 17:13:09 +0200 Subject: [PATCH 2/4] feat(graph-ui): sync active tab and project to the URL Store the active tab and selected project in the URL query string so the view survives refreshes and can be bookmarked or shared, syncing back on browser back and forward via popstate. Query params are used rather than path segments because the embedded server only serves index.html at the root path. Opening the Projects tab now clears the active project, and the Graph tab stays disabled until a project is selected. Signed-off-by: Zadak --- graph-ui/src/App.tsx | 94 ++++++++++++++++++++++++++++++++++---------- 1 file changed, 73 insertions(+), 21 deletions(-) diff --git a/graph-ui/src/App.tsx b/graph-ui/src/App.tsx index 732a9d18a..18392667c 100644 --- a/graph-ui/src/App.tsx +++ b/graph-ui/src/App.tsx @@ -1,14 +1,62 @@ -import { useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { GraphTab } from "./components/GraphTab"; import { StatsTab } from "./components/StatsTab"; import { ControlTab } from "./components/ControlTab"; import type { TabId } from "./lib/types"; import { useUiMessages } from "./lib/i18n"; +const TAB_IDS: TabId[] = ["graph", "stats", "control"]; + +interface RouteState { + tab: TabId; + project: string | null; +} + +/* Read the active tab + selected project from the URL query string so the + * current view survives refreshes and can be bookmarked or shared. */ +function readRoute(): RouteState { + const params = new URLSearchParams(window.location.search); + const rawTab = params.get("tab"); + const tab = TAB_IDS.includes(rawTab as TabId) ? (rawTab as TabId) : "stats"; + const project = params.get("project"); + return { tab, project: project ? project : null }; +} + +/* Build the canonical URL for a route, preserving the path and hash. */ +function routeUrl(tab: TabId, project: string | null): string { + const params = new URLSearchParams(); + params.set("tab", tab); + if (project) params.set("project", project); + return `${window.location.pathname}?${params.toString()}${window.location.hash}`; +} + export function App() { const t = useUiMessages(); - const [activeTab, setActiveTab] = useState("stats"); - const [selectedProject, setSelectedProject] = useState(null); + const [route, setRoute] = useState(readRoute); + const { tab: activeTab, project: selectedProject } = route; + + /* Normalize the URL on first load so it always carries the current route. */ + useEffect(() => { + const initial = readRoute(); + window.history.replaceState(null, "", routeUrl(initial.tab, initial.project)); + }, []); + + /* Sync state when the user navigates with the browser back/forward buttons. */ + useEffect(() => { + const onPopState = () => setRoute(readRoute()); + window.addEventListener("popstate", onPopState); + return () => window.removeEventListener("popstate", onPopState); + }, []); + + /* Change the route and push a history entry (skips no-op navigations). */ + const navigate = useCallback((tab: TabId, project: string | null) => { + const url = routeUrl(tab, project); + const current = `${window.location.pathname}${window.location.search}${window.location.hash}`; + if (url === current) return; + window.history.pushState(null, "", url); + setRoute({ tab, project }); + }, []); + const tabs: { id: TabId; label: string }[] = [ { id: "graph", label: t.tabs.graph }, { id: "stats", label: t.tabs.projects }, @@ -29,19 +77,26 @@ export function App() { {/* Tabs inline in header */}
@@ -54,7 +109,7 @@ export function App() { {selectedProject} )}