From 70e0513a5ff744c025cd6cce5f7bf12449b5818e Mon Sep 17 00:00:00 2001 From: Bernhard Jackiewicz Date: Fri, 2 Oct 2026 20:52:05 +0200 Subject: [PATCH] fix(ui): show percent-encoded route paths as people read them Route labels showed the indexed form, e.g. '/%C3%A9dit'. Labels now decode with decodeURI, which keeps reserved characters such as %2F encoded and leaves invalid encodings as indexed; grouping and the route filter work on the same readable form. Signed-off-by: Bernhard Jackiewicz --- graph-ui/src/architecture/semantic-graph.test.ts | 9 ++++++++- graph-ui/src/architecture/semantic-graph.ts | 7 +++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/graph-ui/src/architecture/semantic-graph.test.ts b/graph-ui/src/architecture/semantic-graph.test.ts index 08077f38f..0a793cafa 100644 --- a/graph-ui/src/architecture/semantic-graph.test.ts +++ b/graph-ui/src/architecture/semantic-graph.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { buildSemanticGraph, layoutFolderHierarchy, semanticEntryPoints, type SemanticNode, type SemanticPlatform } from './semantic-graph'; +import { buildSemanticGraph, layoutFolderHierarchy, readableRoute, semanticEntryPoints, type SemanticNode, type SemanticPlatform } from './semantic-graph'; import type { GraphData, GraphNode } from '../galaxy/types'; const node = (id: number, file: string, name = `symbol${id}`, status: GraphNode['status'] = 'normal'): GraphNode => ({ @@ -422,3 +422,10 @@ it('packs entry-point siblings while preserving depth order between bands', () = expect(Math.min(...siblings.map(item => item.position[0]))).toBeGreaterThan(model.nodes.find(item => item.depth === 0)!.position[0]); expect(Math.max(...siblings.map(item => item.position[0]))).toBeLessThan(model.nodes.find(item => item.depth === 2)!.position[0]); }); + +it('shows percent-encoded route paths as people read them and keeps reserved characters encoded', () => { + expect(readableRoute('/%C3%A9dit/')).toBe('/\u00e9dit/'); + expect(readableRoute('/files/a%2Fb')).toBe('/files/a%2Fb'); + expect(readableRoute('/broken/%E0%A4%A')).toBe('/broken/%E0%A4%A'); + expect(readableRoute('/plain/')).toBe('/plain/'); +}); diff --git a/graph-ui/src/architecture/semantic-graph.ts b/graph-ui/src/architecture/semantic-graph.ts index c5f09d322..2150a348b 100644 --- a/graph-ui/src/architecture/semantic-graph.ts +++ b/graph-ui/src/architecture/semantic-graph.ts @@ -106,6 +106,9 @@ const symbolId = (node: GraphNode) => `symbol:${identity(node)}`; */ const TEST_SOURCE = /(^|\/)(tests|__tests__|specs?)\/|^(src\/)?test\/|(^|\/)(test_[^/]*|tests?\.[^/.]+|[^/]+[._-](test|spec)\.[^/.]+)$/i; /** The first path segment of a route label, without its method: '/accounts' for 'GET /accounts/login/'. */ +/** Route paths as people read them: '/%C3%A9dit' reads '/édit'. Reserved characters stay encoded + * (decodeURI), and a path that is not valid percent-encoding stays as indexed. */ +export const readableRoute = (path: string): string => { try { return decodeURI(path); } catch { return path; } }; const routePrefixOf = (label: string) => `/${label.replace(/^[A-Z]+\s+/, '').split('/').filter(Boolean)[0] ?? ''}`; const sourceNode = (node: GraphNode) => Boolean(node.file_path && node.file_path !== '{}' && !['Project', 'Folder', 'Package', 'Branch', 'Route'].includes(node.label)); @@ -374,7 +377,7 @@ export function buildSemanticGraph(graph: GraphData, options: SemanticGraphOptio const id = `route:${identity(node)}`; if (!routeNodes.has(id)) routeNodes.set(id, { ...symbolNode(node), id, kind: 'route', graphNode: currentNodes.has(node) ? node : undefined, - label: edge?.routePath ?? node.name, detail: node.file_path ?? 'Indexed route; source location unavailable' }); + label: readableRoute(edge?.routePath ?? node.name), detail: node.file_path ?? 'Indexed route; source location unavailable' }); return id; } const area = node.file_path ? areaOf(node.file_path) : undefined; @@ -401,7 +404,7 @@ export function buildSemanticGraph(graph: GraphData, options: SemanticGraphOptio // A textual registration is useful navigation, but cannot establish an edge or handler identity. const id = `registration:${route.method ?? ''}:${route.path}@${route.filePath ?? ''}:${route.line ?? ''}`; if (options.hideTestRoutes && route.filePath && TEST_SOURCE.test(route.filePath)) { hidden.add(id); continue; } - routeNodes.set(id, { id, kind: 'route', label: `${route.method ? `${route.method} ` : ''}${route.path}`, + routeNodes.set(id, { id, kind: 'route', label: `${route.method ? `${route.method} ` : ''}${readableRoute(route.path)}`, detail: `${route.origin === 'source' ? 'Source' : 'Index'} registration · handler relationship not resolved`, position: [0, 0, 0], count: 1, filePath: route.filePath, line: route.line, members: [] }); }