@@ -50,10 +50,26 @@ const markup = `
5050.tree-scroll{overflow:auto; padding:8px}
5151.node{display:flex; align-items:center; gap:8px; padding:6px 8px; border-radius:8px; cursor:pointer; color:var(--rose-100)}
5252.node:hover{background:var(--tile-hover)}
53+ .node.active{background:var(--tile-hover)}
5354.node .kind{font-size:11px; color:var(--accent-2); padding:2px 6px; border:1px solid var(--border-2); border-radius:999px; background:var(--tile-bg)}
5455.node .name{font-weight:600}
5556.node .meta{margin-left:auto; display:flex; align-items:center; gap:6px; font-variant-numeric:tabular-nums; color:var(--rose-300)}
5657.node .meta span{display:inline-flex; align-items:center; gap:4px; padding:2px 8px; border-radius:999px; border:1px solid var(--tile-border); background:var(--tile-bg)}
58+ .route-action{padding:4px 10px; border-radius:8px; border:1px solid var(--tile-border); background:var(--tile-bg); color:var(--rose-50); font-size:12px; cursor:pointer; transition:background .12s ease, border-color .12s ease}
59+ .route-action:hover{background:var(--tile-hover); border-color:var(--border-2)}
60+ .rfw-route-popup{position:absolute; right:18px; bottom:70px; width:280px; background:var(--panel); border:1px solid var(--border); border-radius:12px; box-shadow:var(--shadow); display:flex; flex-direction:column; padding:12px; gap:12px; z-index:2147483650}
61+ .rfw-route-popup.hidden{display:none !important}
62+ .rfw-route-popup h3{margin:0; font-size:16px; color:var(--rose-50)}
63+ .rfw-route-popup p{margin:0; font-size:12px; color:var(--rose-200)}
64+ .route-fields{display:flex; flex-direction:column; gap:8px}
65+ .route-field{display:flex; flex-direction:column; gap:4px; font-size:12px; color:var(--rose-200)}
66+ .route-field input{padding:6px 8px; border-radius:8px; border:1px solid var(--tile-border); background:var(--bg-2); color:var(--rose-50); font:inherit; outline:none}
67+ .route-actions{display:flex; gap:8px; justify-content:flex-end}
68+ .route-actions button{padding:6px 12px; border-radius:8px; border:1px solid var(--tile-border); background:var(--tile-bg); color:var(--rose-50); cursor:pointer; font:inherit}
69+ .route-actions button.primary{background:var(--accent); border-color:var(--accent); color:#fff}
70+ .route-actions button.primary:hover{background:var(--accent-2); border-color:var(--accent-2)}
71+ .route-actions button:hover{background:var(--tile-hover)}
72+ .route-error{color:var(--bad); font-size:12px; min-height:16px}
5773
5874.rfw-detail{overflow-y:auto; flex:1;background:var(--chip-bg);display:flex;flex-direction:column}
5975.rfw-detail .rfw-subheader{display:flex;align-items:center;gap:10px;padding:8px 12px;border-bottom:1px solid var(--border)}
@@ -129,6 +145,7 @@ const markup = `
129145
130146 <nav class="rfw-tabs" role="tablist" aria-label="Tabs">
131147 <button class="rfw-button rfw-tab" role="tab" aria-selected="true" aria-controls="tab-components" id="tabbtn-components">Components</button>
148+ <button class="rfw-button rfw-tab" role="tab" aria-selected="false" aria-controls="tab-routes" id="tabbtn-routes">Routes</button>
132149 <button class="rfw-button rfw-tab" role="tab" aria-selected="false" aria-controls="tab-store" id="tabbtn-store">Store</button>
133150 <button class="rfw-button rfw-tab" role="tab" aria-selected="false" aria-controls="tab-signals" id="tabbtn-signals">Signals</button>
134151 <button class="rfw-button rfw-tab" role="tab" aria-selected="false" aria-controls="tab-plugins" id="tabbtn-plugins">Plugins</button>
@@ -164,6 +181,29 @@ const markup = `
164181 </div>
165182 </section>
166183
184+ <!-- Routes -->
185+ <section id="tab-routes" role="tabpanel" aria-labelledby="tabbtn-routes" class="hidden" style="display:flex;flex:1">
186+ <div class="rfw-split">
187+ <aside class="rfw-tree">
188+ <div class="rfw-search">
189+ <input id="routeFilter" class="rfw-input" type="search" placeholder="Filter routes…" />
190+ <button class="rfw-button rfw-iconbtn" id="refreshRoutes" title="Refresh routes">
191+ <svg viewBox="0 0 24 24" fill="none"><path d="M4 4v6h6M20 20v-6h-6M5 19a9 9 0 0 1 14-7M19 5a9 9 0 0 0-14 7" stroke="var(--rose-400)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
192+ </button>
193+ </div>
194+ <div class="tree-scroll" id="routeTree"></div>
195+ </aside>
196+ <article class="rfw-detail">
197+ <div class="rfw-subheader">
198+ <span style="font-weight:600" id="routeTitle">Select a route</span>
199+ <span class="rfw-spacer"></span>
200+ <button class="rfw-button route-action" id="routeDetailGo" title="Navigate to route">Open</button>
201+ </div>
202+ <div class="kv" id="routeDetail"></div>
203+ </article>
204+ </div>
205+ </section>
206+
167207 <!-- Store -->
168208 <section id="tab-store" role="tabpanel" aria-labelledby="tabbtn-store" class="hidden" style="display:flex;flex:1">
169209 <div class="rfw-split">
@@ -295,6 +335,16 @@ const markup = `
295335 </section>
296336
297337 </div>
338+ <div id="routePopup" class="rfw-route-popup hidden" data-rfw-ignore role="dialog" aria-modal="false" aria-labelledby="routePopupTitle">
339+ <h3 id="routePopupTitle">Configure route</h3>
340+ <p id="routePopupInfo">Provide values for dynamic parameters.</p>
341+ <div class="route-fields" id="routePopupFields"></div>
342+ <div class="route-error" id="routePopupError"></div>
343+ <div class="route-actions">
344+ <button class="rfw-button" id="routePopupCancel" type="button">Cancel</button>
345+ <button class="rfw-button primary" id="routePopupConfirm" type="button">Navigate</button>
346+ </div>
347+ </div>
298348</section>
299349` ;
300350
@@ -309,9 +359,31 @@ const fab = $("#rfwDevtoolsToggle");
309359const minBtn = $ ( "#minBtn" ) ;
310360const closeBtn = $ ( "#closeBtn" ) ;
311361const hHandle = $ ( '[data-resize="h"]' ) ;
362+ const routeTree = $ ( "#routeTree" ) ;
363+ const routeTitle = $ ( "#routeTitle" ) ;
364+ const routeDetail = $ ( "#routeDetail" ) ;
365+ const routeDetailGo = $ ( "#routeDetailGo" ) ;
366+ const routeFilter = $ ( "#routeFilter" ) ;
367+ const routePopup = $ ( "#routePopup" ) ;
368+ const routePopupTitle = $ ( "#routePopupTitle" ) ;
369+ const routePopupInfo = $ ( "#routePopupInfo" ) ;
370+ const routePopupFields = $ ( "#routePopupFields" ) ;
371+ const routePopupError = $ ( "#routePopupError" ) ;
372+ const routePopupConfirm = $ ( "#routePopupConfirm" ) ;
373+ const routePopupCancel = $ ( "#routePopupCancel" ) ;
374+ let routeSnapshot = [ ] ;
375+ let selectedRoutePath = "" ;
376+ let selectedRoute = null ;
377+ let popupRoute = null ;
378+ routeDetailGo ?. setAttribute ( "disabled" , "true" ) ;
312379
313380const tabs = [
314381 { btn : $ ( "#tabbtn-components" ) , panel : $ ( "#tab-components" ) } ,
382+ {
383+ btn : $ ( "#tabbtn-routes" ) ,
384+ panel : $ ( "#tab-routes" ) ,
385+ onShow : refreshRoutes ,
386+ } ,
315387 { btn : $ ( "#tabbtn-store" ) , panel : $ ( "#tab-store" ) , onShow : refreshStore } ,
316388 {
317389 btn : $ ( "#tabbtn-signals" ) ,
@@ -354,12 +426,14 @@ function openDevtools() {
354426 setTimeout ( ( ) => ( fab . style . transform = "" ) , 120 ) ;
355427 }
356428 refreshTree ( ) ;
429+ refreshRoutes ( ) ;
357430 refreshStore ( ) ;
358431 refreshSignals ( ) ;
359432 refreshPlugins ( ) ;
360433}
361434function closeDevtools ( ) {
362435 overlay ?. classList . add ( "hidden" ) ;
436+ closeRoutePopup ( ) ;
363437}
364438function toggleDevtools ( ) {
365439 overlay ?. classList . contains ( "hidden" ) ? openDevtools ( ) : closeDevtools ( ) ;
@@ -376,6 +450,11 @@ document.addEventListener("keydown", (e) => {
376450 if ( ( e . ctrlKey || e . metaKey ) && e . shiftKey && e . key . toLowerCase ( ) === "d" ) {
377451 e . preventDefault ( ) ;
378452 toggleDevtools ( ) ;
453+ return ;
454+ }
455+ if ( e . key === "Escape" && ! routePopup ?. classList . contains ( "hidden" ) ) {
456+ e . preventDefault ( ) ;
457+ closeRoutePopup ( ) ;
379458 }
380459} ) ;
381460
@@ -560,6 +639,232 @@ function renderJSONRow(label, value) {
560639 return renderRow ( label , formatJSON ( value ) , true ) ;
561640}
562641
642+ function renderRoutes ( list ) {
643+ if ( ! routeTree ) return ;
644+ routeTree . innerHTML = "" ;
645+ const frag = document . createDocumentFragment ( ) ;
646+ const walk = ( nodes , depth ) => {
647+ nodes . forEach ( ( route ) => {
648+ const el = document . createElement ( "div" ) ;
649+ el . className = "node" ;
650+ if ( depth ) el . style . paddingLeft = `${ depth * 12 } px` ;
651+ const kind = document . createElement ( "span" ) ;
652+ kind . className = "kind" ;
653+ const dynamic = Array . isArray ( route . params ) && route . params . length > 0 ;
654+ kind . textContent = dynamic ? "dynamic" : "static" ;
655+ el . appendChild ( kind ) ;
656+
657+ const name = document . createElement ( "span" ) ;
658+ name . className = "name" ;
659+ name . textContent = route . path || route . template || "/" ;
660+ el . appendChild ( name ) ;
661+
662+ const meta = document . createElement ( "span" ) ;
663+ meta . className = "meta" ;
664+ if ( dynamic ) {
665+ const badge = document . createElement ( "span" ) ;
666+ const count = route . params . length ;
667+ badge . textContent = `${ count } param${ count > 1 ? "s" : "" } ` ;
668+ meta . appendChild ( badge ) ;
669+ }
670+ const btn = document . createElement ( "button" ) ;
671+ btn . className = "route-action" ;
672+ btn . type = "button" ;
673+ btn . textContent = "Open" ;
674+ btn . title = `Navigate to ${ route . path || route . template || "/" } ` ;
675+ btn . addEventListener ( "click" , ( evt ) => {
676+ evt . stopPropagation ( ) ;
677+ navigateRoute ( route ) ;
678+ } ) ;
679+ meta . appendChild ( btn ) ;
680+ el . appendChild ( meta ) ;
681+
682+ el . dataset . path = ( route . path || route . template || "/" ) . toLowerCase ( ) ;
683+ el . addEventListener ( "click" , ( ) => selectRoute ( route ) ) ;
684+ frag . appendChild ( el ) ;
685+ if ( Array . isArray ( route . children ) && route . children . length ) {
686+ walk ( route . children , depth + 1 ) ;
687+ }
688+ } ) ;
689+ } ;
690+ walk ( list , 0 ) ;
691+ routeTree . replaceChildren ( frag ) ;
692+ highlightSelectedRoute ( ) ;
693+ }
694+
695+ function highlightSelectedRoute ( ) {
696+ if ( ! routeTree ) return ;
697+ const nodes = $$ ( ".node" , routeTree ) ;
698+ nodes . forEach ( ( n ) => {
699+ if ( selectedRoutePath ) {
700+ n . classList . toggle (
701+ "active" ,
702+ n . dataset . path === selectedRoutePath . toLowerCase ( ) ,
703+ ) ;
704+ } else {
705+ n . classList . remove ( "active" ) ;
706+ }
707+ } ) ;
708+ }
709+
710+ function selectRoute ( route ) {
711+ selectedRoute = route ;
712+ selectedRoutePath = route ?. path || route ?. template || "" ;
713+ highlightSelectedRoute ( ) ;
714+ if ( ! routeTitle || ! routeDetail || ! routeDetailGo ) return ;
715+ if ( ! route ) {
716+ routeTitle . textContent = "Select a route" ;
717+ routeDetail . innerHTML = "" ;
718+ routeDetailGo . setAttribute ( "disabled" , "true" ) ;
719+ return ;
720+ }
721+ routeTitle . textContent = route . path || route . template || "/" ;
722+ const rows = [ ] ;
723+ rows . push ( renderRow ( "Path" , route . path || "/" ) ) ;
724+ if ( route . template && route . template !== route . path )
725+ rows . push ( renderRow ( "Template" , route . template ) ) ;
726+ if ( Array . isArray ( route . params ) && route . params . length ) {
727+ rows . push (
728+ renderRow (
729+ "Parameters" ,
730+ route . params . join ( ", " ) || "-" ,
731+ ) ,
732+ ) ;
733+ }
734+ rows . push (
735+ renderRow (
736+ "Children" ,
737+ Array . isArray ( route . children ) ? String ( route . children . length ) : "0" ,
738+ ) ,
739+ ) ;
740+ routeDetail . innerHTML = rows . join ( "" ) ;
741+ routeDetailGo . removeAttribute ( "disabled" ) ;
742+ }
743+
744+ function findRouteByPath ( path , nodes ) {
745+ for ( const route of nodes ) {
746+ const value = route . path || route . template || "" ;
747+ if ( value === path ) return route ;
748+ if ( Array . isArray ( route . children ) ) {
749+ const found = findRouteByPath ( path , route . children ) ;
750+ if ( found ) return found ;
751+ }
752+ }
753+ return null ;
754+ }
755+
756+ function refreshRoutes ( ) {
757+ if ( ! routeTree ) return ;
758+ try {
759+ if ( typeof globalThis . RFW_DEVTOOLS_ROUTES === "function" ) {
760+ const data = JSON . parse ( globalThis . RFW_DEVTOOLS_ROUTES ( ) ) ;
761+ routeSnapshot = Array . isArray ( data ) ? data : [ ] ;
762+ renderRoutes ( routeSnapshot ) ;
763+ if ( selectedRoutePath ) {
764+ const current = findRouteByPath ( selectedRoutePath , routeSnapshot ) ;
765+ selectRoute ( current ) ;
766+ }
767+ return ;
768+ }
769+ } catch ( err ) {
770+ console . warn ( "DevTools routes refresh error" , err ) ;
771+ }
772+ routeSnapshot = [ ] ;
773+ if ( routeTree ) routeTree . textContent = "" ;
774+ selectRoute ( null ) ;
775+ }
776+
777+ function navigateRoute ( route ) {
778+ if ( ! route ) return ;
779+ const dynamic = Array . isArray ( route . params ) && route . params . length > 0 ;
780+ if ( dynamic ) {
781+ openRoutePopup ( route ) ;
782+ return ;
783+ }
784+ goToPath ( route . path || route . template || "/" ) ;
785+ }
786+
787+ function goToPath ( path ) {
788+ if ( ! path ) return ;
789+ if ( typeof globalThis . goNavigate === "function" ) {
790+ globalThis . goNavigate ( path ) ;
791+ } else {
792+ window . location . assign ( path ) ;
793+ }
794+ }
795+
796+ function openRoutePopup ( route ) {
797+ if ( ! routePopup || ! routePopupFields ) return ;
798+ popupRoute = route ;
799+ routePopup . classList . remove ( "hidden" ) ;
800+ if ( routePopupTitle ) routePopupTitle . textContent = route . path || route . template || "/" ;
801+ if ( routePopupInfo ) {
802+ const template = route . template && route . template !== route . path ? route . template : "Provide values for dynamic parameters." ;
803+ routePopupInfo . textContent = template ;
804+ }
805+ routePopupFields . innerHTML = "" ;
806+ ( route . params || [ ] ) . forEach ( ( name ) => {
807+ const field = document . createElement ( "label" ) ;
808+ field . className = "route-field" ;
809+ field . innerHTML = `<span>${ escapeHTML ( name ) } </span>` ;
810+ const input = document . createElement ( "input" ) ;
811+ input . type = "text" ;
812+ input . dataset . key = name ;
813+ input . placeholder = name ;
814+ field . appendChild ( input ) ;
815+ routePopupFields . appendChild ( field ) ;
816+ } ) ;
817+ if ( routePopupError ) routePopupError . textContent = "" ;
818+ const first = routePopupFields . querySelector ( "input" ) ;
819+ if ( first ) first . focus ( ) ;
820+ }
821+
822+ function closeRoutePopup ( ) {
823+ if ( ! routePopup ) return ;
824+ routePopup . classList . add ( "hidden" ) ;
825+ popupRoute = null ;
826+ routePopupFields ?. replaceChildren ( ) ;
827+ if ( routePopupError ) routePopupError . textContent = "" ;
828+ }
829+
830+ function confirmRoutePopup ( ) {
831+ if ( ! popupRoute || ! routePopupFields ) return ;
832+ let target = popupRoute . path || popupRoute . template || "/" ;
833+ const inputs = Array . from ( routePopupFields . querySelectorAll ( "input[data-key]" ) ) ;
834+ for ( const input of inputs ) {
835+ const key = input . dataset . key ;
836+ const value = input . value . trim ( ) ;
837+ if ( ! value ) {
838+ if ( routePopupError ) {
839+ routePopupError . textContent = `Missing value for ${ key } ` ;
840+ }
841+ input . focus ( ) ;
842+ return ;
843+ }
844+ const encoded = encodeURIComponent ( value ) ;
845+ const pattern = new RegExp ( `:${ key } (?=/|$)` , "g" ) ;
846+ target = target . replace ( pattern , encoded ) ;
847+ }
848+ closeRoutePopup ( ) ;
849+ goToPath ( target ) ;
850+ }
851+
852+ $ ( "#refreshRoutes" ) ?. addEventListener ( "click" , refreshRoutes ) ;
853+ routeFilter ?. addEventListener ( "input" , ( e ) => {
854+ const q = e . target . value . trim ( ) . toLowerCase ( ) ;
855+ const nodes = $$ ( ".node" , routeTree ) ;
856+ nodes . forEach ( ( n ) => {
857+ const text = n . textContent . toLowerCase ( ) ;
858+ n . style . display = text . includes ( q ) ? "" : "none" ;
859+ } ) ;
860+ } ) ;
861+ routeDetailGo ?. addEventListener ( "click" , ( ) => {
862+ const current = selectedRoute || findRouteByPath ( selectedRoutePath , routeSnapshot ) ;
863+ navigateRoute ( current ) ;
864+ } ) ;
865+ routePopupCancel ?. addEventListener ( "click" , closeRoutePopup ) ;
866+ routePopupConfirm ?. addEventListener ( "click" , confirmRoutePopup ) ;
867+
563868$ ( "#treeFilter" ) ?. addEventListener ( "input" , ( e ) => {
564869 const q = e . target . value . trim ( ) . toLowerCase ( ) ;
565870 const nodes = $$ ( ".node" , treeContainer ) ;
@@ -872,6 +1177,7 @@ $("#pluginFilter")?.addEventListener("input", (e) => {
8721177window . RFW_DEVTOOLS_REFRESH_STORES = refreshStore ;
8731178window . RFW_DEVTOOLS_REFRESH_SIGNALS = refreshSignals ;
8741179window . RFW_DEVTOOLS_REFRESH_PLUGINS = refreshPlugins ;
1180+ window . RFW_DEVTOOLS_REFRESH_ROUTES = refreshRoutes ;
8751181
8761182const varsTree = $ ( "#varsTree" ) ;
8771183const varsTitle = $ ( "#varsTitle" ) ;
0 commit comments