11"use client" ;
22
3- import { useState , useMemo , useCallback } from "react" ;
3+ import { useState , useMemo , useCallback , useEffect , useRef } from "react" ;
44import {
55 getIntegrations ,
66 getFeatureCategories ,
@@ -138,6 +138,41 @@ export default function DojoPage() {
138138 setSelectedFileIndex ( 0 ) ;
139139 } , [ ] ) ;
140140
141+ // URL ↔ selection sync. On mount we hydrate from ?integration=&demo=; after
142+ // mount, every selection change is written back via history.replaceState so
143+ // refreshing the page lands on the same integration + demo.
144+ const urlHydratedRef = useRef ( false ) ;
145+
146+ useEffect ( ( ) => {
147+ if ( ! urlHydratedRef . current ) {
148+ const params = new URLSearchParams ( window . location . search ) ;
149+ const urlSlug = params . get ( "integration" ) ;
150+ const urlDemo = params . get ( "demo" ) ;
151+ if ( urlSlug ) {
152+ const found = integrations . find ( ( i ) => i . slug === urlSlug ) ;
153+ if ( found ) {
154+ setSelectedSlug ( urlSlug ) ;
155+ if ( urlDemo && found . demos . some ( ( d ) => d . id === urlDemo ) ) {
156+ setSelectedDemoId ( urlDemo ) ;
157+ } else if ( found . demos . length > 0 ) {
158+ setSelectedDemoId ( found . demos [ 0 ] . id ) ;
159+ }
160+ }
161+ }
162+ urlHydratedRef . current = true ;
163+ return ;
164+ }
165+ if ( ! selectedSlug ) return ;
166+ const params = new URLSearchParams ( ) ;
167+ params . set ( "integration" , selectedSlug ) ;
168+ if ( selectedDemoId ) params . set ( "demo" , selectedDemoId ) ;
169+ window . history . replaceState (
170+ null ,
171+ "" ,
172+ `${ window . location . pathname } ?${ params . toString ( ) } ` ,
173+ ) ;
174+ } , [ integrations , selectedSlug , selectedDemoId ] ) ;
175+
141176 const previewUrl =
142177 integration && selectedDemo
143178 ? `${ integration . backend_url } ${ selectedDemo . route } `
@@ -601,60 +636,46 @@ export default function DojoPage() {
601636 < div
602637 style = { {
603638 display : "flex" ,
604- flexDirection : "column " ,
639+ flexDirection : "row " ,
605640 height : "100%" ,
606641 borderRadius : 8 ,
607642 overflow : "hidden" ,
608643 background : "#ffffff" ,
609644 border : "2px solid var(--border-default)" ,
610645 } }
611646 >
612- { /* File tabs */ }
613647 < div
614648 style = { {
649+ flex : 1 ,
650+ minWidth : 0 ,
615651 display : "flex" ,
616- gap : 0 ,
617- borderBottom : "1px solid var(--border-container)" ,
618- background : "#f8f8fb" ,
619- overflowX : "auto" ,
620- flexShrink : 0 ,
652+ flexDirection : "column" ,
653+ overflow : "hidden" ,
621654 } }
622655 >
623- { allFiles . map ( ( file , idx ) => (
624- < button
625- key = { file . filename }
626- onClick = { ( ) => setSelectedFileIndex ( idx ) }
627- style = { {
628- padding : "10px 18px" ,
629- fontSize : 13 ,
630- border : "none" ,
631- borderBottom :
632- idx === selectedFileIndex
633- ? "2px solid var(--text-primary)"
634- : "2px solid transparent" ,
635- cursor : "pointer" ,
636- background :
637- idx === selectedFileIndex ? "#ffffff" : "transparent" ,
638- color :
639- idx === selectedFileIndex
640- ? "var(--text-primary)"
641- : "var(--text-disabled)" ,
642- fontWeight : idx === selectedFileIndex ? 500 : 400 ,
643- whiteSpace : "nowrap" ,
644- fontFamily :
645- "'Spline Sans Mono', ui-monospace, SFMono-Regular, monospace" ,
646- } }
647- >
648- { file . filename }
649- </ button >
650- ) ) }
656+ { allFiles [ selectedFileIndex ] && (
657+ < CodeBlock
658+ code = { allFiles [ selectedFileIndex ] . content }
659+ language = { allFiles [ selectedFileIndex ] . language }
660+ />
661+ ) }
651662 </ div >
652- { allFiles [ selectedFileIndex ] && (
653- < CodeBlock
654- code = { allFiles [ selectedFileIndex ] . content }
655- language = { allFiles [ selectedFileIndex ] . language }
663+ < aside
664+ style = { {
665+ width : 248 ,
666+ flexShrink : 0 ,
667+ borderLeft : "1px solid var(--border-container)" ,
668+ background : "#fafbfd" ,
669+ overflow : "auto" ,
670+ padding : "12px 8px" ,
671+ } }
672+ >
673+ < FileTree
674+ files = { allFiles }
675+ selectedFileIndex = { selectedFileIndex }
676+ onSelect = { setSelectedFileIndex }
656677 />
657- ) }
678+ </ aside >
658679 </ div >
659680 ) : (
660681 < div
@@ -677,6 +698,177 @@ export default function DojoPage() {
677698 ) ;
678699}
679700
701+ type FileTreeNode = {
702+ name : string ;
703+ fileIndex : number | null ;
704+ children : Map < string , FileTreeNode > ;
705+ } ;
706+
707+ function buildFileTree ( files : { filename : string } [ ] ) : FileTreeNode {
708+ const root : FileTreeNode = {
709+ name : "" ,
710+ fileIndex : null ,
711+ children : new Map ( ) ,
712+ } ;
713+ files . forEach ( ( file , idx ) => {
714+ const parts = file . filename . split ( "/" ) . filter ( Boolean ) ;
715+ let current = root ;
716+ parts . forEach ( ( part , i ) => {
717+ const isFile = i === parts . length - 1 ;
718+ const existing = current . children . get ( part ) ;
719+ if ( existing ) {
720+ if ( isFile ) existing . fileIndex = idx ;
721+ current = existing ;
722+ } else {
723+ const node : FileTreeNode = {
724+ name : part ,
725+ fileIndex : isFile ? idx : null ,
726+ children : new Map ( ) ,
727+ } ;
728+ current . children . set ( part , node ) ;
729+ current = node ;
730+ }
731+ } ) ;
732+ } ) ;
733+ return root ;
734+ }
735+
736+ function sortedChildren ( node : FileTreeNode ) : FileTreeNode [ ] {
737+ return Array . from ( node . children . values ( ) ) . sort ( ( a , b ) => {
738+ const aIsFolder = a . fileIndex === null ;
739+ const bIsFolder = b . fileIndex === null ;
740+ if ( aIsFolder !== bIsFolder ) return aIsFolder ? - 1 : 1 ;
741+ return a . name . localeCompare ( b . name ) ;
742+ } ) ;
743+ }
744+
745+ function FileTree ( {
746+ files,
747+ selectedFileIndex,
748+ onSelect,
749+ } : {
750+ files : { filename : string } [ ] ;
751+ selectedFileIndex : number ;
752+ onSelect : ( idx : number ) => void ;
753+ } ) {
754+ const root = useMemo ( ( ) => buildFileTree ( files ) , [ files ] ) ;
755+ return (
756+ < div style = { { fontSize : 13 } } >
757+ < div style = { { padding : "0 6px 8px" } } >
758+ < span
759+ style = { {
760+ fontSize : 11 ,
761+ fontWeight : 600 ,
762+ textTransform : "uppercase" ,
763+ letterSpacing : "0.06em" ,
764+ color : "var(--text-primary)" ,
765+ } }
766+ >
767+ Files
768+ </ span >
769+ </ div >
770+ { sortedChildren ( root ) . map ( ( node ) => (
771+ < FileTreeRow
772+ key = { node . name }
773+ node = { node }
774+ depth = { 0 }
775+ selectedFileIndex = { selectedFileIndex }
776+ onSelect = { onSelect }
777+ />
778+ ) ) }
779+ </ div >
780+ ) ;
781+ }
782+
783+ function FileTreeRow ( {
784+ node,
785+ depth,
786+ selectedFileIndex,
787+ onSelect,
788+ } : {
789+ node : FileTreeNode ;
790+ depth : number ;
791+ selectedFileIndex : number ;
792+ onSelect : ( idx : number ) => void ;
793+ } ) {
794+ const isFolder = node . fileIndex === null ;
795+ const isSelected = ! isFolder && node . fileIndex === selectedFileIndex ;
796+ const padLeft = 8 + depth * 12 ;
797+ if ( isFolder ) {
798+ return (
799+ < div >
800+ < div
801+ style = { {
802+ display : "flex" ,
803+ alignItems : "center" ,
804+ gap : 4 ,
805+ padding : `2px 6px 2px ${ padLeft } px` ,
806+ color : "var(--text-secondary)" ,
807+ fontFamily :
808+ "'Spline Sans Mono', ui-monospace, SFMono-Regular, monospace" ,
809+ fontSize : 12.5 ,
810+ lineHeight : 1.6 ,
811+ userSelect : "none" ,
812+ } }
813+ >
814+ < span style = { { opacity : 0.6 , fontSize : 11 } } > ▾</ span >
815+ < span > { node . name } </ span >
816+ </ div >
817+ { sortedChildren ( node ) . map ( ( child ) => (
818+ < FileTreeRow
819+ key = { child . name }
820+ node = { child }
821+ depth = { depth + 1 }
822+ selectedFileIndex = { selectedFileIndex }
823+ onSelect = { onSelect }
824+ />
825+ ) ) }
826+ </ div >
827+ ) ;
828+ }
829+ return (
830+ < button
831+ type = "button"
832+ onClick = { ( ) => onSelect ( node . fileIndex ! ) }
833+ style = { {
834+ display : "flex" ,
835+ alignItems : "center" ,
836+ gap : 4 ,
837+ width : "100%" ,
838+ padding : `2px 6px 2px ${ padLeft + 14 } px` ,
839+ border : "none" ,
840+ background : isSelected ? "rgba(0, 0, 0, 0.05)" : "transparent" ,
841+ color : isSelected ? "var(--text-primary)" : "var(--text-secondary)" ,
842+ fontWeight : isSelected ? 500 : 400 ,
843+ textAlign : "left" ,
844+ cursor : "pointer" ,
845+ borderRadius : 4 ,
846+ fontFamily :
847+ "'Spline Sans Mono', ui-monospace, SFMono-Regular, monospace" ,
848+ fontSize : 12.5 ,
849+ lineHeight : 1.6 ,
850+ } }
851+ onMouseEnter = { ( e ) => {
852+ if ( ! isSelected )
853+ e . currentTarget . style . background = "rgba(0, 0, 0, 0.03)" ;
854+ } }
855+ onMouseLeave = { ( e ) => {
856+ if ( ! isSelected ) e . currentTarget . style . background = "transparent" ;
857+ } }
858+ >
859+ < span
860+ style = { {
861+ whiteSpace : "nowrap" ,
862+ overflow : "hidden" ,
863+ textOverflow : "ellipsis" ,
864+ } }
865+ >
866+ { node . name }
867+ </ span >
868+ </ button >
869+ ) ;
870+ }
871+
680872function SectionTitle ( { title } : { title : string } ) {
681873 return (
682874 < div
0 commit comments