| … | … | @@ -67,7 +67,10 @@ |
| 67 | 67 | <div class="hud" id="head"> |
| 68 | 68 | <div> |
| 69 | 69 | <div id="levelname"><b>file level</b></div> |
| 70 | <div style="margin-top:4px" id="crumb"></div> | |
| 70 | <div style="margin-top:4px"> | |
| 71 | <span id="crumb"></span> | |
| 72 | <a id="other-surface" href="/" style="color:#58a6ff;margin-left:10px">open in the panel view ›</a> | |
| 73 | </div> | |
| 71 | 74 | </div> |
| 72 | 75 | <div style="flex:1"> |
| 73 | 76 | <input id="path" spellcheck="false" placeholder="open a file by path"> |
| … | … | @@ -13,6 +13,9 @@ |
| 13 | 13 | */ |
| 14 | 14 | |
| 15 | 15 | import { layout } from '/floor/layout.js' |
| 16 | import { | |
| 17 | parseLocation, locationToUrl, otherSurface, splitFilePath, joinFilePath | |
| 18 | } from '/floor/location.js' | |
| 16 | 19 | import { |
| 17 | 20 | buildSystemLevel, buildModuleLevel, buildFileLevel, buildSymbolLevel, |
| 18 | 21 | resolveCode, lineDepths, spanOnLine, marksOwnSpan, bundleLinks, |
| … | … | @@ -87,7 +90,7 @@ async function loadTree () { |
| 87 | 90 | return view.tree |
| 88 | 91 | } |
| 89 | 92 | |
| 90 | async function load (path) { | |
| 93 | async function load (path, symbolName) { | |
| 91 | 94 | const res = await fetch('/api/derive?path=' + encodeURIComponent(path)) |
| 92 | 95 | const data = await res.json() |
| 93 | 96 | if (data.error) { |
| … | … | @@ -101,7 +104,7 @@ async function load (path) { |
| 101 | 104 | view.lineDepth = lineDepths(data.model, view.lines.length) |
| 102 | 105 | view.expanded = new Set() |
| 103 | 106 | |
| 104 | const wantSymbol = new URLSearchParams(location.search).get('symbol') | |
| 107 | const wantSymbol = symbolName || parseLocation(location.pathname, '').symbol | |
| 105 | 108 | const sym = wantSymbol ? data.model.symbols.find(s => s.name === wantSymbol) : null |
| 106 | 109 | view.symbolId = sym ? sym.id : null |
| 107 | 110 | |
| … | … | @@ -598,14 +601,37 @@ function applyExpandParam () { |
| 598 | 601 | for (const s of view.model.symbols) if (names.has(s.name)) view.expanded.add(s.id) |
| 599 | 602 | } |
| 600 | 603 | |
| 604 | // The URL is the canonical scheme the SPA defined, under /floor. Anything that | |
| 605 | // is a VIEW PREFERENCE rather than a place — bundled edges, a selection — stays | |
| 606 | // a query param, so the path itself means the same thing on either surface. | |
| 601 | 607 | function syncUrl () { |
| 602 | const sym = currentSymbol() | |
| 608 | const loc = currentLocation() | |
| 603 | 609 | const q = [] |
| 604 | 610 | if (view.bundled) q.push('edges=bundled') |
| 605 | if (view.module) q.push('module=' + encodeURIComponent(view.module)) | |
| 606 | if (view.path) q.push('path=' + encodeURIComponent(view.path)) | |
| 607 | if (sym) q.push('symbol=' + encodeURIComponent(sym.name)) | |
| 608 | history.replaceState(null, '', '/floor' + (q.length > 0 ? '?' + q.join('&') : '')) | |
| 611 | if (view.selected) { | |
| 612 | const card = view.cards.find(c => c.id === view.selected) | |
| 613 | if (card) q.push('select=' + encodeURIComponent(card.title)) | |
| 614 | } | |
| 615 | history.replaceState(null, '', locationToUrl(loc, '') + (q.length > 0 ? '?' + q.join('&') : '')) | |
| 616 | ||
| 617 | const link = document.getElementById('other-surface') | |
| 618 | if (link) link.setAttribute('href', otherSurface(loc, '')) | |
| 619 | } | |
| 620 | ||
| 621 | // Where we are, in the shared model rather than in this view's private fields. | |
| 622 | function currentLocation () { | |
| 623 | const sym = currentSymbol() | |
| 624 | const split = view.path && view.tree | |
| 625 | ? splitFilePath(view.tree, view.path) | |
| 626 | : { module: null, file: null } | |
| 627 | const base = { surface: 'floor', module: null, file: null, symbol: null, path: [], extra: {} } | |
| 628 | ||
| 629 | if (split.module && sym) { | |
| 630 | return { ...base, kind: 'symbol', module: split.module, file: split.file, symbol: sym.name } | |
| 631 | } | |
| 632 | if (split.module) return { ...base, kind: 'file', module: split.module, file: split.file } | |
| 633 | if (view.module) return { ...base, kind: 'module', module: view.module } | |
| 634 | return { ...base, kind: 'system' } | |
| 609 | 635 | } |
| 610 | 636 | |
| 611 | 637 | function applyTransform () { |
| … | … | @@ -686,18 +712,25 @@ function wire () { |
| 686 | 712 | async function start () { |
| 687 | 713 | wire() |
| 688 | 714 | await loadTree() |
| 689 | const params = new URLSearchParams(location.search) | |
| 690 | view.module = params.get('module') | |
| 691 | view.bundled = params.get('edges') === 'bundled' | |
| 692 | const path = params.get('path') | |
| 693 | 715 | |
| 694 | if (path) { | |
| 695 | await load(path) | |
| 696 | syncUrl() | |
| 697 | return | |
| 716 | const loc = parseLocation(location.pathname, '') | |
| 717 | view.bundled = new URLSearchParams(location.search).get('edges') === 'bundled' | |
| 718 | ||
| 719 | if (loc.kind === 'file' || loc.kind === 'symbol') { | |
| 720 | const rootRel = joinFilePath(view.tree, loc.module, loc.file) | |
| 721 | view.module = loc.module | |
| 722 | if (rootRel) { | |
| 723 | await load(rootRel, loc.symbol) | |
| 724 | syncUrl() | |
| 725 | return | |
| 726 | } | |
| 698 | 727 | } |
| 699 | // No path: the system level is the top of the ladder and the honest default. | |
| 728 | if (loc.kind === 'module') view.module = loc.module | |
| 729 | ||
| 730 | // Anything else is the system level: the top of the ladder, and the honest | |
| 731 | // default rather than guessing a file out of the scan. | |
| 700 | 732 | rebuild() |
| 733 | syncUrl() | |
| 701 | 734 | } |
| 702 | 735 | |
| 703 | 736 | start() |
| … | … | @@ -0,0 +1,167 @@ |
| 1 | /** | |
| 2 | * Where you are, as one object, shared by every surface. | |
| 3 | * | |
| 4 | * There were two location models: the SPA's `graphStack` serialized to PATH | |
| 5 | * segments, and the floor's { module, path, symbolId } serialized to QUERY | |
| 6 | * params. Two schemes for the same idea meant opening a file in one view told | |
| 7 | * the other nothing. | |
| 8 | * | |
| 9 | * The SPA's path scheme is canonical, because it is the one with existing deep | |
| 10 | * links and history entries. The floor adopts it under a /floor prefix, and the | |
| 11 | * symbol rung extends it: | |
| 12 | * | |
| 13 | * / system | |
| 14 | * /module/:name a module | |
| 15 | * /module/:name/file/:rel/:path a file in it | |
| 16 | * /module/:name/file/:rel/symbol/:symbol a symbol in that file | |
| 17 | * /path/:a/:b a directory in the scan | |
| 18 | * /validation /docs/* /suggestions/* the SPA's other views | |
| 19 | * | |
| 20 | * /floor/... the same places, drawn as the floor | |
| 21 | * | |
| 22 | * Pure: strings in, strings out, no tree and no DOM, so both surfaces and the | |
| 23 | * tests share one implementation. | |
| 24 | */ | |
| 25 | ||
| 26 | const SURFACE = 'floor' | |
| 27 | ||
| 28 | /** | |
| 29 | * @returns {object} { surface, kind, module, file, symbol, path, extra } | |
| 30 | * surface: 'spa' | 'floor' | |
| 31 | * kind: 'system' | 'module' | 'file' | 'symbol' | 'path' | | |
| 32 | * 'validation' | 'docs' | 'suggestions' | |
| 33 | * path: array of directory segments, for kind 'path' | |
| 34 | */ | |
| 35 | export function parseLocation (pathname, basePrefix = '') { | |
| 36 | const loc = { | |
| 37 | surface: 'spa', | |
| 38 | kind: 'system', | |
| 39 | module: null, | |
| 40 | file: null, | |
| 41 | symbol: null, | |
| 42 | path: [], | |
| 43 | extra: {} | |
| 44 | } | |
| 45 | ||
| 46 | let p = pathname || '/' | |
| 47 | if (basePrefix && p.startsWith(basePrefix)) p = p.slice(basePrefix.length) || '/' | |
| 48 | if (p.length > 1 && p.endsWith('/')) p = p.slice(0, -1) | |
| 49 | ||
| 50 | let segs = p.split('/').filter(Boolean).map(decodeURIComponent) | |
| 51 | ||
| 52 | if (segs[0] === SURFACE) { | |
| 53 | loc.surface = SURFACE | |
| 54 | segs = segs.slice(1) | |
| 55 | } | |
| 56 | if (segs.length === 0 || segs[0] === 'system') return loc | |
| 57 | ||
| 58 | if (segs[0] === 'validation') { | |
| 59 | loc.kind = 'validation' | |
| 60 | if (segs[1] === 'module' && segs[2]) loc.extra.moduleName = segs[2] | |
| 61 | return loc | |
| 62 | } | |
| 63 | if (segs[0] === 'docs') { | |
| 64 | loc.kind = 'docs' | |
| 65 | if (segs.length > 1) loc.extra.docPath = segs.slice(1).join('/') | |
| 66 | return loc | |
| 67 | } | |
| 68 | if (segs[0] === 'suggestions') { | |
| 69 | loc.kind = 'suggestions' | |
| 70 | if (segs.length > 1) loc.extra.suggestPath = segs.slice(1).join('/') | |
| 71 | return loc | |
| 72 | } | |
| 73 | ||
| 74 | if (segs[0] === 'path') { | |
| 75 | loc.kind = 'path' | |
| 76 | loc.path = segs.slice(1) | |
| 77 | return loc | |
| 78 | } | |
| 79 | ||
| 80 | if (segs[0] === 'module' && segs[1]) { | |
| 81 | loc.kind = 'module' | |
| 82 | loc.module = segs[1] | |
| 83 | ||
| 84 | const fileAt = segs.indexOf('file', 2) | |
| 85 | if (fileAt === -1) return loc | |
| 86 | ||
| 87 | // A file path has slashes of its own, so it runs until 'symbol' or the end. | |
| 88 | const symbolAt = segs.indexOf('symbol', fileAt + 1) | |
| 89 | const fileEnd = symbolAt === -1 ? segs.length : symbolAt | |
| 90 | const fileParts = segs.slice(fileAt + 1, fileEnd) | |
| 91 | if (fileParts.length > 0) { | |
| 92 | loc.kind = 'file' | |
| 93 | loc.file = fileParts.join('/') | |
| 94 | } | |
| 95 | if (symbolAt !== -1 && segs[symbolAt + 1]) { | |
| 96 | loc.kind = 'symbol' | |
| 97 | loc.symbol = segs[symbolAt + 1] | |
| 98 | } | |
| 99 | return loc | |
| 100 | } | |
| 101 | ||
| 102 | return loc | |
| 103 | } | |
| 104 | ||
| 105 | export function locationToUrl (loc, basePrefix = '') { | |
| 106 | const base = basePrefix + (loc.surface === SURFACE ? '/' + SURFACE : '') | |
| 107 | const enc = encodeURIComponent | |
| 108 | ||
| 109 | switch (loc.kind) { | |
| 110 | case 'module': | |
| 111 | case 'file': | |
| 112 | case 'symbol': { | |
| 113 | if (!loc.module) return base + '/' | |
| 114 | let url = base + '/module/' + enc(loc.module) | |
| 115 | if (loc.file) url += '/file/' + loc.file.split('/').map(enc).join('/') | |
| 116 | if (loc.symbol && loc.kind === 'symbol') url += '/symbol/' + enc(loc.symbol) | |
| 117 | return url | |
| 118 | } | |
| 119 | case 'path': | |
| 120 | return loc.path.length > 0 ? base + '/path/' + loc.path.map(enc).join('/') : base + '/' | |
| 121 | case 'validation': | |
| 122 | return base + '/validation' + | |
| 123 | (loc.extra && loc.extra.moduleName ? '/module/' + enc(loc.extra.moduleName) : '') | |
| 124 | case 'docs': | |
| 125 | return base + '/docs' + | |
| 126 | (loc.extra && loc.extra.docPath ? '/' + loc.extra.docPath.split('/').map(enc).join('/') : '') | |
| 127 | case 'suggestions': | |
| 128 | return base + '/suggestions' + | |
| 129 | (loc.extra && loc.extra.suggestPath ? '/' + loc.extra.suggestPath.split('/').map(enc).join('/') : '') | |
| 130 | default: | |
| 131 | return base + '/' | |
| 132 | } | |
| 133 | } | |
| 134 | ||
| 135 | /** The same place, on the other surface — which is what makes the views agree. */ | |
| 136 | export function otherSurface (loc, basePrefix = '') { | |
| 137 | return locationToUrl({ ...loc, surface: loc.surface === SURFACE ? 'spa' : SURFACE }, basePrefix) | |
| 138 | } | |
| 139 | ||
| 140 | /** | |
| 141 | * A root-relative file path split into the module that owns it and the path | |
| 142 | * within that module, because the canonical scheme addresses a file as | |
| 143 | * module + relative path while the floor loads it by root-relative path. | |
| 144 | */ | |
| 145 | export function splitFilePath (tree, rootRelPath) { | |
| 146 | if (!rootRelPath) return { module: null, file: null } | |
| 147 | let best = null | |
| 148 | for (const [name, mod] of Object.entries(tree.modules || {})) { | |
| 149 | const prefix = mod.path && mod.path !== '.' ? mod.path + '/' : '' | |
| 150 | if (prefix && !rootRelPath.startsWith(prefix)) continue | |
| 151 | const rel = prefix ? rootRelPath.slice(prefix.length) : rootRelPath | |
| 152 | if (!mod.files || !mod.files[rel]) continue | |
| 153 | // The deepest module path wins: a nested package inside another one owns | |
| 154 | // its own files. | |
| 155 | if (!best || prefix.length > best.prefixLength) { | |
| 156 | best = { module: name, file: rel, prefixLength: prefix.length } | |
| 157 | } | |
| 158 | } | |
| 159 | return best ? { module: best.module, file: best.file } : { module: null, file: null } | |
| 160 | } | |
| 161 | ||
| 162 | /** The inverse: module + relative path back to the root-relative path. */ | |
| 163 | export function joinFilePath (tree, moduleName, file) { | |
| 164 | const mod = (tree.modules || {})[moduleName] | |
| 165 | if (!mod || !file) return file || null | |
| 166 | return mod.path && mod.path !== '.' ? mod.path + '/' + file : file | |
| 167 | } |
| … | … | @@ -1947,28 +1947,54 @@ function _basePrefix () { |
| 1947 | 1947 | return API || '' |
| 1948 | 1948 | } |
| 1949 | 1949 | |
| 1950 | // The URL is built by the SHARED serializer, so this view and the floor cannot | |
| 1951 | // disagree about what a place is called. | |
| 1950 | 1952 | function urlFromGraphStack (stack) { |
| 1951 | const base = _basePrefix() | |
| 1952 | if (!stack || stack.length === 0) return base + '/' | |
| 1953 | return window.__ctLoc | |
| 1954 | ? window.__ctLoc.locationToUrl(locationFromStack(stack), _basePrefix()) | |
| 1955 | : _basePrefix() + '/' | |
| 1956 | } | |
| 1957 | ||
| 1958 | // graphStack stays as this view's working state; it converts to the shared | |
| 1959 | // location on the way out. | |
| 1960 | function locationFromStack (stack) { | |
| 1961 | const loc = { | |
| 1962 | surface: 'spa', kind: 'system', module: null, file: null, symbol: null, path: [], extra: {} | |
| 1963 | } | |
| 1964 | if (!stack || stack.length === 0) return loc | |
| 1953 | 1965 | |
| 1954 | 1966 | const first = stack[0] |
| 1955 | 1967 | if (first.type === 'module') { |
| 1956 | let url = base + '/module/' + encodeURIComponent(first.name) | |
| 1968 | loc.kind = 'module' | |
| 1969 | loc.module = first.name | |
| 1957 | 1970 | const second = stack[1] |
| 1958 | 1971 | if (second && second.type === 'file') { |
| 1959 | url += '/file/' + second.name.split('/').map(encodeURIComponent).join('/') | |
| 1972 | loc.kind = 'file' | |
| 1973 | loc.file = second.name | |
| 1960 | 1974 | } |
| 1961 | return url | |
| 1975 | return loc | |
| 1962 | 1976 | } |
| 1963 | ||
| 1964 | 1977 | if (first.type === 'path') { |
| 1965 | const parts = stack | |
| 1966 | .filter(e => e.type === 'path') | |
| 1967 | .map(e => encodeURIComponent(e.name)) | |
| 1968 | return base + '/path/' + parts.join('/') | |
| 1978 | loc.kind = 'path' | |
| 1979 | loc.path = stack.filter(e => e.type === 'path').map(e => e.name) | |
| 1969 | 1980 | } |
| 1981 | return loc | |
| 1982 | } | |
| 1970 | 1983 | |
| 1971 | return base + '/' | |
| 1984 | // And the inverse, so a URL becomes this view's stack. | |
| 1985 | function stackFromLocation (loc) { | |
| 1986 | if (!loc) return [] | |
| 1987 | if (loc.kind === 'module' || loc.kind === 'file' || loc.kind === 'symbol') { | |
| 1988 | const stack = [{ type: 'module', name: loc.module, label: loc.module }] | |
| 1989 | if (loc.file) { | |
| 1990 | stack.push({ type: 'file', name: loc.file, label: loc.file, moduleName: loc.module }) | |
| 1991 | } | |
| 1992 | return stack | |
| 1993 | } | |
| 1994 | if (loc.kind === 'path') { | |
| 1995 | return loc.path.map(seg => ({ type: 'path', name: seg, label: seg })) | |
| 1996 | } | |
| 1997 | return [] | |
| 1972 | 1998 | } |
| 1973 | 1999 | |
| 1974 | 2000 | function graphStackFromUrl (pathname) { |
| … | … | @@ -1980,61 +2006,18 @@ function graphStackFromUrl (pathname) { |
| 1980 | 2006 | // stack: graph stack (only meaningful for *-graph kinds) |
| 1981 | 2007 | // extra: { moduleName (validation), docPath (docs), suggestPath (suggestions) } |
| 1982 | 2008 | function routeFromUrl (pathname) { |
| 1983 | const base = _basePrefix() | |
| 1984 | let p = pathname || '/' | |
| 1985 | if (base && p.startsWith(base)) p = p.slice(base.length) || '/' | |
| 1986 | ||
| 1987 | // Normalize: drop trailing slash (except root) | |
| 1988 | if (p.length > 1 && p.endsWith('/')) p = p.slice(0, -1) | |
| 1989 | ||
| 1990 | if (p === '/' || p === '/system' || p === '') return { kind: 'system', stack: [], extra: {} } | |
| 1991 | ||
| 1992 | const segs = p.split('/').filter(Boolean) | |
| 1993 | ||
| 1994 | if (segs[0] === 'validation') { | |
| 1995 | const moduleName = (segs[1] === 'module' && segs[2]) ? decodeURIComponent(segs[2]) : null | |
| 1996 | return { kind: 'validation', stack: [], extra: { moduleName } } | |
| 1997 | } | |
| 1998 | ||
| 1999 | if (segs[0] === 'docs') { | |
| 2000 | const docPath = segs.length > 1 ? segs.slice(1).map(decodeURIComponent).join('/') : null | |
| 2001 | return { kind: 'docs', stack: [], extra: { docPath } } | |
| 2002 | } | |
| 2003 | ||
| 2004 | if (segs[0] === 'suggestions' && segs.length > 1) { | |
| 2005 | const suggestPath = segs.slice(1).map(decodeURIComponent).join('/') | |
| 2006 | return { kind: 'suggestions', stack: [], extra: { suggestPath } } | |
| 2007 | } | |
| 2008 | ||
| 2009 | if (segs[0] === 'module' && segs[1]) { | |
| 2010 | const moduleName = decodeURIComponent(segs[1]) | |
| 2011 | const stack = [{ type: 'module', name: moduleName, label: moduleName }] | |
| 2012 | if (segs[2] === 'file' && segs.length > 3) { | |
| 2013 | const filePath = segs.slice(3).map(decodeURIComponent).join('/') | |
| 2014 | stack.push({ | |
| 2015 | type: 'file', | |
| 2016 | name: filePath, | |
| 2017 | moduleName, | |
| 2018 | label: filePath.split('/').pop() | |
| 2019 | }) | |
| 2020 | } | |
| 2021 | return { kind: 'module-graph', stack, extra: {} } | |
| 2022 | } | |
| 2009 | // Parsed by the SHARED location module: the same string means the same place | |
| 2010 | // here and on the floor. The graph-shaped kinds are translated back into this | |
| 2011 | // view's stack; validation/docs/suggestions pass their extras through. | |
| 2012 | if (!window.__ctLoc) return { kind: 'system', stack: [], extra: {} } | |
| 2013 | const loc = window.__ctLoc.parseLocation(pathname, _basePrefix()) | |
| 2023 | 2014 | |
| 2024 | if (segs[0] === 'path' && segs.length > 1) { | |
| 2025 | const stack = segs.slice(1).map(s => { | |
| 2026 | const name = decodeURIComponent(s) | |
| 2027 | return { type: 'path', name, label: name } | |
| 2028 | }) | |
| 2029 | return { kind: 'path-graph', stack, extra: {} } | |
| 2030 | } | |
| 2015 | const kind = loc.kind === 'module' || loc.kind === 'file' || loc.kind === 'symbol' | |
| 2016 | ? 'module-graph' | |
| 2017 | : loc.kind === 'path' ? 'path-graph' : loc.kind | |
| 2031 | 2018 | |
| 2032 | return { kind: 'system', stack: [], extra: {} } | |
| 2019 | return { kind, stack: stackFromLocation(loc), extra: loc.extra || {} } | |
| 2033 | 2020 | } |
| 2034 | ||
| 2035 | // Set to true inside popstate handling so nav functions skip pushing again. | |
| 2036 | let _suppressPushState = false | |
| 2037 | ||
| 2038 | 2021 | function pushStateForStack () { |
| 2039 | 2022 | if (_suppressPushState) return |
| 2040 | 2023 | const url = urlFromGraphStack(graphStack) |
| … | … | @@ -2182,9 +2165,17 @@ function updateBreadcrumb () { |
| 2182 | 2165 | const isFinal = i === graphStack.length - 1 |
| 2183 | 2166 | html += '<a href="' + href + '" class="' + (isFinal ? 'current' : '') + '" data-nav-level="' + (i + 1) + '">' + escapeHtml(graphStack[i].label) + '</a>' |
| 2184 | 2167 | } |
| 2168 | // The SAME place, drawn as the floor. This is what makes the two surfaces | |
| 2169 | // co-related rather than merely similar: one location, two renderings. | |
| 2170 | if (window.__ctLoc) { | |
| 2171 | const floorHref = window.__ctLoc.otherSurface(locationFromStack(graphStack), _basePrefix()) | |
| 2172 | html += '<a href="' + floorHref + '" class="other-surface" ' + | |
| 2173 | 'style="margin-left:12px;color:#58a6ff">open in the floor view ›</a>' | |
| 2174 | } | |
| 2185 | 2175 | bc.innerHTML = html |
| 2186 | 2176 | |
| 2187 | 2177 | for (const a of bc.querySelectorAll('a')) { |
| 2178 | if (a.classList.contains('other-surface')) continue // a real navigation | |
| 2188 | 2179 | a.addEventListener('click', (e) => { |
| 2189 | 2180 | if (e.button !== 0) return |
| 2190 | 2181 | if (e.metaKey || e.ctrlKey || e.shiftKey) return |
| … | … | @@ -3930,12 +3921,14 @@ ${NODE_VIEW_JS} |
| 3930 | 3921 | ;(async function boot () { |
| 3931 | 3922 | if (!window.__ct) { |
| 3932 | 3923 | try { |
| 3933 | const [cards, layoutMod] = await Promise.all([ | |
| 3924 | const [cards, layoutMod, locationMod] = await Promise.all([ | |
| 3934 | 3925 | import(API + '/floor/cards.js'), |
| 3935 | import(API + '/floor/layout.js') | |
| 3926 | import(API + '/floor/layout.js'), | |
| 3927 | import(API + '/floor/location.js') | |
| 3936 | 3928 | ]) |
| 3937 | 3929 | window.__ct = cards |
| 3938 | 3930 | window.__ctLayout = layoutMod.layout |
| 3931 | window.__ctLoc = locationMod | |
| 3939 | 3932 | } catch (err) { |
| 3940 | 3933 | document.getElementById('detail').innerHTML = |
| 3941 | 3934 | '<div style="padding:20px;color:#ff7b72">Could not load the shared view modules: ' + |
| … | … | @@ -276,7 +276,12 @@ export function startServer (tree, opts = {}) { |
| 276 | 276 | } |
| 277 | 277 | |
| 278 | 278 | // --- the floor: a level of the node ladder, drawn from the derivation. |
| 279 | if (url === '/floor' || url === '/floor/') { | |
| 279 | // /floor and every place under it serve the same shell; the client reads | |
| 280 | // location.pathname, exactly as the SPA does. One scheme, two surfaces. | |
| 281 | if (url === '/floor' || url === '/floor/' || | |
| 282 | (url.startsWith('/floor/') && !url.startsWith('/floor/level.js') && | |
| 283 | !url.startsWith('/floor/cards.js') && !url.startsWith('/floor/layout.js') && | |
| 284 | !url.startsWith('/floor/location.js'))) { | |
| 280 | 285 | sendFile(req, res, 'floor/level.html', 'text/html; charset=utf-8') |
| 281 | 286 | return |
| 282 | 287 | } |
| … | … | @@ -286,6 +291,10 @@ export function startServer (tree, opts = {}) { |
| 286 | 291 | } |
| 287 | 292 | // The view imports the SAME layout module the tests run against, rather than |
| 288 | 293 | // a copy of it that can drift. |
| 294 | if (url === '/floor/location.js') { | |
| 295 | sendFile(req, res, 'floor/location.js', 'application/javascript; charset=utf-8') | |
| 296 | return | |
| 297 | } | |
| 289 | 298 | if (url === '/floor/cards.js') { |
| 290 | 299 | sendFile(req, res, 'floor/cards.js', 'application/javascript; charset=utf-8') |
| 291 | 300 | return |
| … | … | @@ -0,0 +1,93 @@ |
| 1 | import { test } from 'brittle' | |
| 2 | import { resolve, dirname } from 'path' | |
| 3 | import { fileURLToPath } from 'url' | |
| 4 | import { scan } from '../lib/scanner.js' | |
| 5 | import { | |
| 6 | parseLocation, locationToUrl, otherSurface, splitFilePath, joinFilePath | |
| 7 | } from '../lib/floor/location.js' | |
| 8 | ||
| 9 | const HERE = dirname(fileURLToPath(import.meta.url)) | |
| 10 | const WORKSPACE = resolve(HERE, 'fixtures/workspace') | |
| 11 | ||
| 12 | // One location model for both surfaces. The SPA's PATH scheme is canonical | |
| 13 | // because it is the one with existing deep links; the floor adopts it under a | |
| 14 | // /floor prefix and the symbol rung extends it. | |
| 15 | ||
| 16 | test('location: the canonical SPA scheme round-trips', (t) => { | |
| 17 | const cases = [ | |
| 18 | ['/', { surface: 'spa', kind: 'system' }], | |
| 19 | ['/module/hyper-overlay', { surface: 'spa', kind: 'module', module: 'hyper-overlay' }], | |
| 20 | ['/module/hyper-overlay/file/lib/tree.js', | |
| 21 | { surface: 'spa', kind: 'file', module: 'hyper-overlay', file: 'lib/tree.js' }], | |
| 22 | ['/path/hyper-fleeky/codetree', { surface: 'spa', kind: 'path', path: ['hyper-fleeky', 'codetree'] }] | |
| 23 | ] | |
| 24 | for (const [url, expected] of cases) { | |
| 25 | const loc = parseLocation(url) | |
| 26 | for (const k of Object.keys(expected)) t.alike(loc[k], expected[k], url + ' -> ' + k) | |
| 27 | t.is(locationToUrl(loc), url, url + ' round-trips') | |
| 28 | } | |
| 29 | }) | |
| 30 | ||
| 31 | test('location: the symbol rung extends the same scheme', (t) => { | |
| 32 | const url = '/module/codetree/file/lib/floor/cards.js/symbol/buildFileLevel' | |
| 33 | const loc = parseLocation(url) | |
| 34 | ||
| 35 | t.is(loc.kind, 'symbol', 'a symbol location') | |
| 36 | t.is(loc.module, 'codetree', 'module') | |
| 37 | t.is(loc.file, 'lib/floor/cards.js', 'the file path keeps its own slashes') | |
| 38 | t.is(loc.symbol, 'buildFileLevel', 'symbol') | |
| 39 | t.is(locationToUrl(loc), url, 'round-trips') | |
| 40 | }) | |
| 41 | ||
| 42 | test('location: the floor is the same place under a prefix', (t) => { | |
| 43 | const spa = parseLocation('/module/codetree/file/index.js') | |
| 44 | const floor = parseLocation('/floor/module/codetree/file/index.js') | |
| 45 | ||
| 46 | t.is(floor.surface, 'floor', 'surface is read from the prefix') | |
| 47 | t.is(spa.surface, 'spa', 'and defaults to the SPA') | |
| 48 | for (const k of ['kind', 'module', 'file', 'symbol']) { | |
| 49 | t.alike(floor[k], spa[k], 'same ' + k) | |
| 50 | } | |
| 51 | t.is(locationToUrl(floor), '/floor/module/codetree/file/index.js', 'round-trips with the prefix') | |
| 52 | }) | |
| 53 | ||
| 54 | test('location: otherSurface is the same place on the other view', (t) => { | |
| 55 | const loc = parseLocation('/module/codetree/file/lib/layout.js') | |
| 56 | t.is(otherSurface(loc), '/floor/module/codetree/file/lib/layout.js', 'spa -> floor') | |
| 57 | ||
| 58 | const back = parseLocation(otherSurface(loc)) | |
| 59 | t.is(otherSurface(back), '/module/codetree/file/lib/layout.js', 'floor -> spa') | |
| 60 | t.alike({ ...back, surface: 'spa' }, loc, 'and nothing else changes') | |
| 61 | }) | |
| 62 | ||
| 63 | test('location: a base prefix is honoured on the way in and out', (t) => { | |
| 64 | const loc = parseLocation('/codetree/module/x', '/codetree') | |
| 65 | t.is(loc.kind, 'module', 'parsed under the prefix') | |
| 66 | t.is(locationToUrl(loc, '/codetree'), '/codetree/module/x', 'and written back with it') | |
| 67 | }) | |
| 68 | ||
| 69 | test('location: the SPA-only views survive', (t) => { | |
| 70 | t.is(parseLocation('/validation').kind, 'validation', 'validation') | |
| 71 | t.is(parseLocation('/validation/module/x').extra.moduleName, 'x', 'with its module') | |
| 72 | t.is(parseLocation('/docs/spec/SYSTEM.md').extra.docPath, 'spec/SYSTEM.md', 'docs path') | |
| 73 | t.is(locationToUrl(parseLocation('/docs/spec/SYSTEM.md')), '/docs/spec/SYSTEM.md', 'round-trips') | |
| 74 | t.is(parseLocation('/suggestions/spec/x.md').kind, 'suggestions', 'suggestions') | |
| 75 | }) | |
| 76 | ||
| 77 | test('location: nonsense is the system level, not a throw', (t) => { | |
| 78 | for (const url of ['', '/nope', '/module', '/module//file//', '///']) { | |
| 79 | const loc = parseLocation(url) | |
| 80 | t.ok(loc.kind, url + ' parses to something: ' + loc.kind) | |
| 81 | } | |
| 82 | }) | |
| 83 | ||
| 84 | test('location: a root-relative path splits into module + file, and back', async (t) => { | |
| 85 | const tree = await scan(WORKSPACE, { maxDepth: 1 }) | |
| 86 | ||
| 87 | const split = splitFilePath(tree, 'widget-store/lib/widget-store.js') | |
| 88 | t.is(split.module, 'widget-store', 'module found') | |
| 89 | t.is(split.file, 'lib/widget-store.js', 'path within it') | |
| 90 | t.is(joinFilePath(tree, split.module, split.file), 'widget-store/lib/widget-store.js', 'and back') | |
| 91 | ||
| 92 | t.alike(splitFilePath(tree, 'nope/nothing.js'), { module: null, file: null }, 'unknown is null') | |
| 93 | }) |