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 <bj@techport.io>
This commit is contained in:
Bernhard Jackiewicz
2026-10-02 20:52:05 +02:00
parent 82a761f46e
commit 70e0513a5f
2 changed files with 13 additions and 3 deletions
@@ -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/');
});
+5 -2
View File
@@ -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: [] });
}