OverviewFilesCommitsIssuesDocsReleasesPeers

codetree commits / f0a05d2

Phase 0d — URL routing in codetree serve
Every navigable view in the SPA now has a shareable URL. Path-based (not hash), uses pushState / popstate, breadcrumb segments are real <a href> so middle-click and ctrl-click open in new tabs. Deep links work on cold page load. URL grammar: / → system overview /system → alias for / /module/:name → module detail /module/:name/file/:path → file drill-in (path can contain /) /path/:p1/:p2/... → directory navigation (per-segment) Phase 0e routes reserved (served by same shell but client-side rendering for them isn't implemented yet): /docs, /docs/:path /validation, /validation/module/:name /suggestions/:path Server (lib/server.js): - SPA shell served for all navigable routes - Unknown paths still 404 (verified: /nonexistent → 404) Client (lib/render.js): - urlFromGraphStack(stack) / graphStackFromUrl(pathname) — bidirectional helpers, handle apiPrefix mounting - pushStateForStack() / replaceStateForStack() — gated on !_suppressPushState so popstate doesn't push again - applyStackFromUrl(stack) — used by popstate + initial-URL handler to rebuild view from URL without pushing - updateBreadcrumb() rewritten: segments are <a href> with data-nav-level; plain left-click is intercepted for SPA nav, other clicks (middle/ctrl/shift) fall through to browser default (new tab) - navigateGraph / drillIntoModule / drillIntoFile / descendIntoDir all call pushStateForStack() when changing user-visible state - popstate listener derives stack from URL and applies - Initial page load: if URL is a deep link, switch to graph tab and apply stack (via microtask so initGraph runs first); otherwise replaceState with canonical URL Codetree remains a SPA — the interactive graph (SVG drag/zoom/ force-layout) is fundamentally client-side. URL routing is the deep-link layer on top. pear-git-v3's SSR model is different and doesn't apply here. Smoke-tested: `codetree serve pear-git-v3 9178 --no-watch` then curl each URL variant — all SPA-shell routes return 200, 604KB HTML containing the new routing code (19 grep matches on urlFromGraphStack / graphStackFromUrl / pushStateForStack / popstate). /nonexistent returns 404. 84/84 tests, 443/443 asserts pass. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
f0a05d22d225044d0f9062660fb36c5ad0d67780
by plankatron <fleeky@gmail.com> · 2026-04-16T17:21:30.000Z

Changed files (2) +220 -4

lib/render.js +204 -3
……@@ -1203,6 +1203,7 @@ window.descendIntoDir = function (dirName) {
12031203 graphStack.push({ type: 'path', name: dirName, label: dirName })
12041204 rebuildGraph()
12051205 syncTreeWithGraph()
1206 if (typeof pushStateForStack === 'function') pushStateForStack()
12061207}
12071208
12081209function initGraph () {
……@@ -1882,18 +1883,173 @@ function moduleColor (name) {
18821883let graphLevel = 'system' // 'system' | 'module' | 'file'
18831884let graphStack = [] // breadcrumb trail
18841885
1886// ====== URL ROUTING (Phase 0d) ======
1887// Every navigable view maps to a URL. Path-based, not hash-based. pushState
1888// on navigation, popstate re-derives the stack. Deep-link friendly: opening
1889// /module/hyper-orgs in a cold browser lands on the same view as clicking
1890// through to it from /.
1891//
1892// URL grammar:
1893// / → empty stack (system overview)
1894// /system → alias for /
1895// /module/<name> → module detail
1896// /module/<name>/file/<p> → file drill-in (path can contain slashes)
1897// /path/<p1>/<p2>/... → directory navigation (one entry per segment)
1898
1899function _basePrefix () {
1900 // API is set to the apiPrefix (possibly empty). Routes are pathed relative
1901 // to whatever prefix the server is mounted behind.
1902 return API || ''
1903}
1904
1905function urlFromGraphStack (stack) {
1906 const base = _basePrefix()
1907 if (!stack || stack.length === 0) return base + '/'
1908
1909 const first = stack[0]
1910 if (first.type === 'module') {
1911 let url = base + '/module/' + encodeURIComponent(first.name)
1912 const second = stack[1]
1913 if (second && second.type === 'file') {
1914 url += '/file/' + second.name.split('/').map(encodeURIComponent).join('/')
1915 }
1916 return url
1917 }
1918
1919 if (first.type === 'path') {
1920 const parts = stack
1921 .filter(e => e.type === 'path')
1922 .map(e => encodeURIComponent(e.name))
1923 return base + '/path/' + parts.join('/')
1924 }
1925
1926 return base + '/'
1927}
1928
1929function graphStackFromUrl (pathname) {
1930 const base = _basePrefix()
1931 let p = pathname || '/'
1932 if (base && p.startsWith(base)) p = p.slice(base.length) || '/'
1933
1934 // Normalize: drop trailing slash (except root)
1935 if (p.length > 1 && p.endsWith('/')) p = p.slice(0, -1)
1936
1937 if (p === '/' || p === '/system' || p === '') return []
1938
1939 const segs = p.split('/').filter(Boolean)
1940
1941 if (segs[0] === 'module' && segs[1]) {
1942 const moduleName = decodeURIComponent(segs[1])
1943 const stack = [{ type: 'module', name: moduleName, label: moduleName }]
1944 if (segs[2] === 'file' && segs.length > 3) {
1945 const filePath = segs.slice(3).map(decodeURIComponent).join('/')
1946 stack.push({
1947 type: 'file',
1948 name: filePath,
1949 moduleName,
1950 label: filePath.split('/').pop()
1951 })
1952 }
1953 return stack
1954 }
1955
1956 if (segs[0] === 'path' && segs.length > 1) {
1957 return segs.slice(1).map(s => {
1958 const name = decodeURIComponent(s)
1959 return { type: 'path', name, label: name }
1960 })
1961 }
1962
1963 // Unknown paths (e.g. /validation, /docs — Phase 0e) → leave stack empty,
1964 // let the respective panels handle their own routing.
1965 return []
1966}
1967
1968// Set to true inside popstate handling so nav functions skip pushing again.
1969let _suppressPushState = false
1970
1971function pushStateForStack () {
1972 if (_suppressPushState) return
1973 const url = urlFromGraphStack(graphStack)
1974 if (window.location.pathname === url) return
1975 try {
1976 window.history.pushState({ stack: JSON.parse(JSON.stringify(graphStack)) }, '', url)
1977 } catch {
1978 // Some browsers / environments reject pushState in niche contexts
1979 // (sandboxed iframe, file://). Fail open — navigation still works.
1980 }
1981}
1982
1983function replaceStateForStack () {
1984 const url = urlFromGraphStack(graphStack)
1985 try {
1986 window.history.replaceState({ stack: JSON.parse(JSON.stringify(graphStack)) }, '', url)
1987 } catch {}
1988}
1989
1990// Apply a graphStack derived from a URL. Used on initial page load and on
1991// popstate. Doesn't push state (popstate is already at the target URL).
1992function applyStackFromUrl (stack) {
1993 _suppressPushState = true
1994 try {
1995 if (stack.length === 0) {
1996 graphStack = []
1997 graphLevel = 'system'
1998 rebuildGraph()
1999 syncTreeWithGraph()
2000 } else {
2001 const first = stack[0]
2002 if (first.type === 'module') {
2003 graphStack = stack.slice()
2004 drillIntoModule(first.name, true)
2005 if (stack[1] && stack[1].type === 'file') {
2006 drillIntoFile(stack[1].moduleName, stack[1].name, true)
2007 }
2008 } else if (first.type === 'path') {
2009 graphStack = stack.slice()
2010 rebuildGraph()
2011 syncTreeWithGraph()
2012 }
2013 }
2014 } finally {
2015 _suppressPushState = false
2016 }
2017}
2018
18852019function updateBreadcrumb () {
18862020 const bc = document.getElementById('graph-breadcrumb')
1887 let html = '<span onclick="navigateGraph(0)">System</span>'
2021 // Render breadcrumb segments as real <a href> so middle-click /
2022 // ctrl-click open in a new tab (standard anchor semantics). JS
2023 // intercepts plain left-click for SPA navigation via pushState.
2024 const systemHref = urlFromGraphStack([])
2025 let html = '<a href="' + systemHref + '" data-nav-level="0">System</a>'
18882026 for (let i = 0; i < graphStack.length; i++) {
2027 const segmentStack = graphStack.slice(0, i + 1)
2028 const href = urlFromGraphStack(segmentStack)
18892029 html += '<span class="sep">&gt;</span>'
18902030 const isFinal = i === graphStack.length - 1
1891 html += '<span class="' + (isFinal ? 'current' : '') + '" onclick="navigateGraph(' + (i + 1) + ')">' + graphStack[i].label + '</span>'
2031 html += '<a href="' + href + '" class="' + (isFinal ? 'current' : '') + '" data-nav-level="' + (i + 1) + '">' + escapeHtml(graphStack[i].label) + '</a>'
18922032 }
18932033 bc.innerHTML = html
2034
2035 for (const a of bc.querySelectorAll('a')) {
2036 a.addEventListener('click', (e) => {
2037 if (e.button !== 0) return
2038 if (e.metaKey || e.ctrlKey || e.shiftKey) return
2039 e.preventDefault()
2040 const level = parseInt(a.dataset.navLevel, 10)
2041 if (!isNaN(level)) navigateGraph(level)
2042 })
2043 }
18942044}
18952045
1896// Expose for onclick
2046function escapeHtml (s) {
2047 return String(s).replace(/[&<>"']/g, ch => ({
2048 '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
2049 }[ch]))
2050}
2051
2052// Expose for onclick / direct calls
18972053window.navigateGraph = function (level) {
18982054 if (level === 0) {
18992055 graphStack = []
……@@ -1907,6 +2063,7 @@ window.navigateGraph = function (level) {
19072063 else if (entry.type === 'file') drillIntoFile(entry.moduleName, entry.name, true)
19082064 else if (entry.type === 'path') { rebuildGraph(); syncTreeWithGraph() }
19092065 }
2066 pushStateForStack()
19102067}
19112068
19122069function rebuildGraph () {
……@@ -2045,6 +2202,7 @@ function drillIntoModule (moduleName, fromNav) {
20452202
20462203 if (!fromNav) {
20472204 graphStack = [{ type: 'module', name: moduleName, label: moduleName }]
2205 if (typeof pushStateForStack === 'function') pushStateForStack()
20482206 }
20492207 graphLevel = 'module'
20502208 expandedNodes.clear()
……@@ -2337,6 +2495,7 @@ function drillIntoFile (moduleName, filePath, fromNav) {
23372495 graphStack = [{ type: 'module', name: moduleName, label: moduleName }]
23382496 }
23392497 graphStack.push({ type: 'file', name: filePath, moduleName, label: filePath.split('/').pop() })
2498 if (typeof pushStateForStack === 'function') pushStateForStack()
23402499 }
23412500 graphLevel = 'file'
23422501
……@@ -3519,6 +3678,48 @@ function makeResizable (handleId, panelId, side) {
35193678// ====== INIT ======
35203679buildTree()
35213680initResizable()
3681
3682// ====== URL ROUTING — popstate + initial URL ======
3683// On cold page load, parse location.pathname to derive the starting
3684// graphStack. If it's non-empty (deep link), switch to the graph tab
3685// and apply the stack. Otherwise stay on the default tree view.
3686//
3687// popstate (back/forward button) re-derives the stack from the URL
3688// and applies it without pushing new history.
3689(function initUrlRouting () {
3690 window.addEventListener('popstate', (ev) => {
3691 const stack = graphStackFromUrl(window.location.pathname)
3692 applyStackFromUrl(stack)
3693 // Make sure we're in the graph tab if we're displaying graph content
3694 if (stack.length > 0) ensureGraphTabActive()
3695 })
3696
3697 function ensureGraphTabActive () {
3698 const tabs = document.querySelectorAll('.main-tab')
3699 for (const t of tabs) {
3700 if (t.dataset.tab === 'graph' && !t.classList.contains('active')) {
3701 t.click()
3702 return
3703 }
3704 }
3705 }
3706
3707 const initialStack = graphStackFromUrl(window.location.pathname)
3708 if (initialStack.length > 0) {
3709 // Deep link — switch to graph tab and render the stack. Also replace
3710 // state (not push) so we start with a correct history entry.
3711 ensureGraphTabActive()
3712 // Let the graph tab initialize first, then apply the stack.
3713 // Using a microtask so initGraph() has run.
3714 Promise.resolve().then(() => {
3715 applyStackFromUrl(initialStack)
3716 replaceStateForStack()
3717 })
3718 } else {
3719 // System overview — seed history with a canonical URL.
3720 replaceStateForStack()
3721 }
3722})()
35223723</script>
35233724</body>
35243725</html>`
lib/server.js +16 -1
……@@ -64,7 +64,22 @@ export function startServer (tree, opts = {}) {
6464 }
6565 const [url, query] = reqUrl.split('?')
6666
67 if (url === '/' || url === '/index.html') {
67 // SPA shell — every navigable view serves the same HTML. The client
68 // reads location.pathname and builds the initial graphStack from it.
69 // Deep links (`/module/:name`, `/module/:name/file/:path`, `/path/:fsPath`,
70 // `/system`) all land on the same page and the JS takes over.
71 if (
72 url === '/' ||
73 url === '/index.html' ||
74 url === '/system' ||
75 url.startsWith('/module/') ||
76 url.startsWith('/path/') ||
77 url === '/docs' ||
78 url.startsWith('/docs/') ||
79 url === '/validation' ||
80 url.startsWith('/validation/') ||
81 url.startsWith('/suggestions/')
82 ) {
6883 send(req, res, 200, 'text/html; charset=utf-8', page)
6984 return
7085 }