1- import { useCallback , useEffect , useLayoutEffect , useMemo , useState } from 'react' ;
1+ import { useCallback , useEffect , useLayoutEffect , useMemo , useRef , useState } from 'react' ;
22import { useLocation , useMatch , useNavigate } from 'react-router-dom' ;
33import { IoChevronForwardOutline , IoCompassOutline } from 'react-icons/io5' ;
44import { ExploreRail } from './components/explore-rail/ExploreRail.js' ;
@@ -8,7 +8,7 @@ import { DomainPage } from './components/domain-page/DomainPage.js';
88import { FirstRunLanding } from './components/first-run-landing/FirstRunLanding.js' ;
99import { useResourceFromRoute } from './hooks/useResourceFromRoute.js' ;
1010import { useIsMobile } from '../hooks/useMediaQuery.js' ;
11- import { Data , Adr } from '../model/calm.js' ;
11+ import { BreadcrumbItem , Data , Adr } from '../model/calm.js' ;
1212import { ControlData } from '../model/control.js' ;
1313import { InterfaceData } from '../model/interface.js' ;
1414import { NamespaceCounts , DomainControlCount } from '../model/counts.js' ;
@@ -21,19 +21,46 @@ import { InterfaceDetailSection } from './components/interface-detail-section/In
2121import { DiagramSection } from './components/diagram-section/DiagramSection.js' ;
2222import { Sidebar } from '../visualizer/components/sidebar/Sidebar.js' ;
2323import { NodeSheet } from '../visualizer/components/sidebar/NodeSheet.js' ;
24+ import { DiagramActionsContext } from '../visualizer/context/DiagramActionsContext.js' ;
25+ import { ResourceNotFound } from './components/resource-not-found/ResourceNotFound.js' ;
26+ import { parseCALMHubPath } from '../visualizer/components/reactflow/utils/calmHelpers.js' ;
2427import type { SelectedItem } from '../visualizer/contracts/contracts.js' ;
2528import type { CalmNodeSchema } from '@finos/calm-models/types' ;
2629import { authStore } from '../service/utils/auth-store.js' ;
2730import './Hub.css' ;
2831
32+ // Breadcrumbs live in history state so Back restores the parent's trail for free,
33+ // at the documented cost that a hard refresh or shared deep link starts with an
34+ // empty trail. History state is untyped and can be stale (older sessions) or set
35+ // by other code, so validate the shape instead of trusting a cast.
36+ function readBreadcrumbs ( state : unknown ) : BreadcrumbItem [ ] {
37+ const crumbs = ( state as { breadcrumbs ?: unknown } | null ) ?. breadcrumbs ;
38+ if ( ! Array . isArray ( crumbs ) ) return [ ] ;
39+ return crumbs . filter (
40+ ( c ) : c is BreadcrumbItem =>
41+ typeof c === 'object' &&
42+ c !== null &&
43+ typeof ( c as BreadcrumbItem ) . namespace === 'string' &&
44+ ( ( c as BreadcrumbItem ) . type === 'architectures' || ( c as BreadcrumbItem ) . type === 'patterns' ) &&
45+ typeof ( c as BreadcrumbItem ) . id === 'string' &&
46+ typeof ( c as BreadcrumbItem ) . version === 'string'
47+ ) ;
48+ }
49+
2950export default function Hub ( ) {
51+ const navigate = useNavigate ( ) ;
52+ const location = useLocation ( ) ;
53+ const currentDisplayNameRef = useRef < string | undefined > ( undefined ) ;
3054 const [ data , setData ] = useState < Data | undefined > ( ) ;
3155 const [ adrData , setAdrData ] = useState < Adr | undefined > ( ) ;
3256 const [ controlData , setControlData ] = useState < ControlData | undefined > ( ) ;
3357 const [ interfaceData , setInterfaceData ] = useState < InterfaceData | undefined > ( ) ;
3458 const [ isSidebarOpen , setIsSidebarOpen ] = useState ( true ) ;
3559 const [ isMobileNavOpen , setIsMobileNavOpen ] = useState ( true ) ;
3660 const [ selectedItem , setSelectedItem ] = useState < SelectedItem > ( null ) ;
61+ // A detail-route load rejected (missing resource, dangling reference). Cleared
62+ // on every navigation; renders ResourceNotFound instead of an empty pane.
63+ const [ resourceLoadFailed , setResourceLoadFailed ] = useState ( false ) ;
3764 const [ namespaceCounts , setNamespaceCounts ] = useState < NamespaceCounts [ ] > ( [ ] ) ;
3865 const [ namespaceCountsLoaded , setNamespaceCountsLoaded ] = useState ( false ) ;
3966 // Distinct from "loaded": a failed counts fetch means counts are unknown, not
@@ -46,8 +73,6 @@ export default function Hub() {
4673 // Route-first content selection (redesign problem #4): the same <Hub/> element
4774 // is reused across `/`, `/namespace/:ns`, `/domain/:domain` and the detail
4875 // route, so the URL — not residual state — decides what renders.
49- const { key : locationKey } = useLocation ( ) ;
50- const navigate = useNavigate ( ) ;
5176 const namespaceMatch = useMatch ( '/namespace/:ns' ) ;
5277 const domainMatch = useMatch ( '/domain/:domain' ) ;
5378 const detailMatch = useMatch ( '/:namespace/:type/:id/:version' ) ;
@@ -107,7 +132,8 @@ export default function Hub() {
107132 setControlData ( undefined ) ;
108133 setInterfaceData ( undefined ) ;
109134 setSelectedItem ( null ) ;
110- } , [ locationKey ] ) ;
135+ setResourceLoadFailed ( false ) ;
136+ } , [ location . key ] ) ;
111137
112138 const handleDataLoad = useCallback ( ( loaded : Data ) => {
113139 setData ( loaded ) ;
@@ -116,6 +142,7 @@ export default function Hub() {
116142 setInterfaceData ( undefined ) ;
117143 setSelectedItem ( null ) ;
118144 setIsMobileNavOpen ( false ) ;
145+ currentDisplayNameRef . current = undefined ;
119146 } , [ ] ) ;
120147
121148 const handleAdrLoad = useCallback ( ( adr : Adr ) => {
@@ -145,12 +172,18 @@ export default function Hub() {
145172 setIsMobileNavOpen ( false ) ;
146173 } , [ ] ) ;
147174
175+ const handleResourceLoadError = useCallback ( ( error : unknown ) => {
176+ console . error ( '%s' , 'Failed to load routed resource:' , error ) ;
177+ setResourceLoadFailed ( true ) ;
178+ } , [ ] ) ;
179+
148180 // Single owner of deep-link / external-navigation loading for the detail route.
149181 useResourceFromRoute ( {
150182 onDataLoad : handleDataLoad ,
151183 onAdrLoad : handleAdrLoad ,
152184 onControlLoad : handleControlLoad ,
153185 onInterfaceLoad : handleInterfaceLoad ,
186+ onLoadError : handleResourceLoadError ,
154187 } ) ;
155188
156189 const handleItemSelect = useCallback ( ( item : SelectedItem ) => {
@@ -192,6 +225,41 @@ export default function Hub() {
192225 [ isDetailRoute , navigate , handleControlLoad ]
193226 ) ;
194227
228+ // The resource's display name is fetched by DiagramSection (it owns the
229+ // summaries fetch) and mirrored here so the crumb pushed on navigation can
230+ // carry it. A ref, not state: it is only read imperatively at navigate time,
231+ // so Hub need not re-render when it resolves. This handler MUST stay memoised
232+ // (identity-stable) — DiagramSection lists it in a fetch effect's deps.
233+ const handleDisplayNameChange = useCallback ( ( name : string | undefined ) => {
234+ currentDisplayNameRef . current = name ;
235+ } , [ ] ) ;
236+
237+ const handleNavigateToDetailedArch = useCallback ( ( ref : string ) => {
238+ const parsed = parseCALMHubPath ( ref ) ;
239+ if ( ! parsed || ! data ) return ;
240+ const currentCrumb : BreadcrumbItem = {
241+ namespace : data . name ,
242+ type : data . calmType === 'Architectures' ? 'architectures' : 'patterns' ,
243+ id : data . id ,
244+ version : data . version ,
245+ name : currentDisplayNameRef . current ,
246+ } ;
247+ navigate ( `/${ parsed . namespace } /${ parsed . type } /${ parsed . id } /${ parsed . version } ` , {
248+ state : { breadcrumbs : [ ...readBreadcrumbs ( location . state ) , currentCrumb ] }
249+ } ) ;
250+ } , [ navigate , data , location . state ] ) ;
251+
252+ const breadcrumbs = useMemo ( ( ) => readBreadcrumbs ( location . state ) , [ location . state ] ) ;
253+
254+ // Single, memoised provider value for every detailed-architecture navigation
255+ // consumer: CustomNode (which ReactFlow instantiates internally, out of reach
256+ // of props) and NodeDetails (rendered in both the Sidebar and the NodeSheet).
257+ // Memoised so consumers don't re-render on unrelated Hub renders.
258+ const diagramActions = useMemo (
259+ ( ) => ( { onNavigateToDetailedArch : handleNavigateToDetailedArch } ) ,
260+ [ handleNavigateToDetailedArch ]
261+ ) ;
262+
195263 const isDiagramView = data ?. calmType === 'Architectures' || data ?. calmType === 'Patterns' ;
196264
197265 // Mobile node bottom-sheet prev/next steppers (Frame G). The ordered node list
@@ -269,7 +337,13 @@ export default function Hub() {
269337 ) : adrData ? (
270338 < AdrRenderer adrDetails = { adrData } />
271339 ) : isDiagramView ? (
272- < DiagramSection data = { data } onItemSelect = { handleItemSelect } hasDetailsPanel = { ! ! selectedItem } />
340+ < DiagramSection
341+ data = { data }
342+ onItemSelect = { handleItemSelect }
343+ hasDetailsPanel = { ! ! selectedItem }
344+ breadcrumbs = { breadcrumbs }
345+ onDisplayNameChange = { handleDisplayNameChange }
346+ />
273347 ) : (
274348 < DocumentDetailSection data = { data } />
275349 ) ;
@@ -291,6 +365,16 @@ export default function Hub() {
291365 onControlLoad = { handleControlActivate }
292366 selectedControlId = { controlData . controlId }
293367 />
368+ ) : isDetailRoute && resourceLoadFailed && ! interfaceData && ! adrData && ! data ? (
369+ // The routed resource failed to load: show a recoverable not-found state
370+ // (never a blank pane). Loaded data wins if a late success ever lands.
371+ < ResourceNotFound
372+ namespace = { detailMatch . params . namespace ! }
373+ id = { detailMatch . params . id ! }
374+ version = { detailMatch . params . version ! }
375+ type = { detailMatch . params . type }
376+ breadcrumbs = { breadcrumbs }
377+ />
294378 ) : isDetailRoute || interfaceData || adrData || data ? (
295379 detailContent
296380 ) : activeNamespace ? (
@@ -313,6 +397,7 @@ export default function Hub() {
313397 ) ;
314398
315399 return (
400+ < DiagramActionsContext . Provider value = { diagramActions } >
316401 < div className = "flex flex-col h-screen overflow-hidden" >
317402 < Navbar />
318403 { isMobile && ! isMobileNavOpen && (
@@ -415,5 +500,6 @@ export default function Hub() {
415500 ) }
416501 </ div >
417502 </ div >
503+ </ DiagramActionsContext . Provider >
418504 ) ;
419505}
0 commit comments