| … | … | @@ -1203,6 +1203,7 @@ window.descendIntoDir = function (dirName) { |
| 1203 | 1203 | graphStack.push({ type: 'path', name: dirName, label: dirName }) |
| 1204 | 1204 | rebuildGraph() |
| 1205 | 1205 | syncTreeWithGraph() |
| 1206 | if (typeof pushStateForStack === 'function') pushStateForStack() |
| 1206 | 1207 | } |
| 1207 | 1208 | |
| 1208 | 1209 | function initGraph () { |
| … | … | @@ -1882,18 +1883,173 @@ function moduleColor (name) { |
| 1882 | 1883 | let graphLevel = 'system' // 'system' | 'module' | 'file' |
| 1883 | 1884 | let graphStack = [] // breadcrumb trail |
| 1884 | 1885 | |
| 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 | |
| 1899 | function _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 | |
| 1905 | function 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 | |
| 1929 | function 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. |
| 1969 | let _suppressPushState = false |
| 1970 | |
| 1971 | function 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 | |
| 1983 | function 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). |
| 1992 | function 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 | |
| 1885 | 2019 | function updateBreadcrumb () { |
| 1886 | 2020 | 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>' |
| 1888 | 2026 | for (let i = 0; i < graphStack.length; i++) { |
| 2027 | const segmentStack = graphStack.slice(0, i + 1) |
| 2028 | const href = urlFromGraphStack(segmentStack) |
| 1889 | 2029 | html += '<span class="sep">></span>' |
| 1890 | 2030 | 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>' |
| 1892 | 2032 | } |
| 1893 | 2033 | 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 | } |
| 1894 | 2044 | } |
| 1895 | 2045 | |
| 1896 | | // Expose for onclick |
| 2046 | function escapeHtml (s) { |
| 2047 | return String(s).replace(/[&<>"']/g, ch => ({ |
| 2048 | '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' |
| 2049 | }[ch])) |
| 2050 | } |
| 2051 | |
| 2052 | // Expose for onclick / direct calls |
| 1897 | 2053 | window.navigateGraph = function (level) { |
| 1898 | 2054 | if (level === 0) { |
| 1899 | 2055 | graphStack = [] |
| … | … | @@ -1907,6 +2063,7 @@ window.navigateGraph = function (level) { |
| 1907 | 2063 | else if (entry.type === 'file') drillIntoFile(entry.moduleName, entry.name, true) |
| 1908 | 2064 | else if (entry.type === 'path') { rebuildGraph(); syncTreeWithGraph() } |
| 1909 | 2065 | } |
| 2066 | pushStateForStack() |
| 1910 | 2067 | } |
| 1911 | 2068 | |
| 1912 | 2069 | function rebuildGraph () { |
| … | … | @@ -2045,6 +2202,7 @@ function drillIntoModule (moduleName, fromNav) { |
| 2045 | 2202 | |
| 2046 | 2203 | if (!fromNav) { |
| 2047 | 2204 | graphStack = [{ type: 'module', name: moduleName, label: moduleName }] |
| 2205 | if (typeof pushStateForStack === 'function') pushStateForStack() |
| 2048 | 2206 | } |
| 2049 | 2207 | graphLevel = 'module' |
| 2050 | 2208 | expandedNodes.clear() |
| … | … | @@ -2337,6 +2495,7 @@ function drillIntoFile (moduleName, filePath, fromNav) { |
| 2337 | 2495 | graphStack = [{ type: 'module', name: moduleName, label: moduleName }] |
| 2338 | 2496 | } |
| 2339 | 2497 | graphStack.push({ type: 'file', name: filePath, moduleName, label: filePath.split('/').pop() }) |
| 2498 | if (typeof pushStateForStack === 'function') pushStateForStack() |
| 2340 | 2499 | } |
| 2341 | 2500 | graphLevel = 'file' |
| 2342 | 2501 | |
| … | … | @@ -3519,6 +3678,48 @@ function makeResizable (handleId, panelId, side) { |
| 3519 | 3678 | // ====== INIT ====== |
| 3520 | 3679 | buildTree() |
| 3521 | 3680 | initResizable() |
| 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 | })() |
| 3522 | 3723 | </script> |
| 3523 | 3724 | </body> |
| 3524 | 3725 | </html>` |