Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 46 additions & 1 deletion src/components/layout/Toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,9 @@ export function Toolbar() {
disconnect: disconnectStore
} = useConnectionStore()

const { setNamespaces, setObjectTypes, setAllObjects, setHierarchicalRoots, setLoading, reset: resetExplorer, pollIntervalMs, setPollIntervalMs, triggerManualRefresh, sidebarCollapsed, toggleSidebar } = useExplorerStore()
const { setNamespaces, setObjectTypes, setAllObjects, setHierarchicalRoots, setLoading, reset: resetExplorer, pollIntervalMs, setPollIntervalMs, triggerManualRefresh, sidebarCollapsed, toggleSidebar, goBack, goForward } = useExplorerStore()
const canGoBack = useExplorerStore(s => s.historyIndex > 0)
const canGoForward = useExplorerStore(s => s.historyIndex < s.history.length - 1)
const { clearAll: clearSubscriptions } = useSubscriptionsStore()

useEffect(() => {
Expand All @@ -67,6 +69,23 @@ export function Toolbar() {
return () => window.removeEventListener('keydown', handleKeyDown)
}, [isConnected])

// X1/X2, the side buttons on most mice. Electron only — in the web build they
// drive the browser's own history and taking them over would strand the SPA.
useEffect(() => {
if (!window.electronAPI) return
const handleMouseUp = (e: MouseEvent) => {
if (e.button === 3) {
e.preventDefault()
goBack()
} else if (e.button === 4) {
e.preventDefault()
goForward()
}
}
window.addEventListener('mouseup', handleMouseUp)
return () => window.removeEventListener('mouseup', handleMouseUp)
}, [goBack, goForward])

const handleConnect = async () => {
setConnecting(true)
setError(null)
Expand Down Expand Up @@ -172,6 +191,32 @@ export function Toolbar() {
</svg>
</button>

<button
onClick={goBack}
disabled={!canGoBack}
title="Back"
aria-label="Back"
className="no-drag p-1.5 rounded text-i3x-text-muted hover:text-i3x-text hover:bg-i3x-bg transition-colors disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:text-i3x-text-muted"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="19" y1="12" x2="5" y2="12" />
<polyline points="12 19 5 12 12 5" />
</svg>
</button>

<button
onClick={goForward}
disabled={!canGoForward}
title="Forward"
aria-label="Forward"
className="no-drag p-1.5 rounded text-i3x-text-muted hover:text-i3x-text hover:bg-i3x-bg transition-colors disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:text-i3x-text-muted"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="5" y1="12" x2="19" y2="12" />
<polyline points="12 5 19 12 12 19" />
</svg>
</button>

<div className="flex-1 flex items-center gap-2">
<button
onClick={() => setShowConnectionDialog(true)}
Expand Down
55 changes: 54 additions & 1 deletion src/components/tree/TreeView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useConnectionStore } from '../../stores/connection'
import { getClient } from '../../api/client'
import type { ObjectType, ObjectInstance } from '../../api/types'
import { TreeNode } from './TreeNode'
import { VirtualObjectRows } from './VirtualObjectRows'
import { VirtualObjectRows, type VirtualObjectRowsHandle } from './VirtualObjectRows'
import {
resolveCompositionFlags,
refreshAllObjects,
Expand All @@ -18,6 +18,20 @@ import {

const BACKGROUND_POLL_ENABLED = true

function isFullyVisible(scrollEl: HTMLElement, el: HTMLElement) {
const view = scrollEl.getBoundingClientRect()
const row = el.getBoundingClientRect()
return row.top >= view.top && row.bottom <= view.bottom
}

// Deliberately not scrollIntoView(): the tree scrolls both axes, and
// scrollIntoView would also yank it sideways on deeply-nested or long labels.
function centerVertically(scrollEl: HTMLElement, el: HTMLElement) {
const view = scrollEl.getBoundingClientRect()
const row = el.getBoundingClientRect()
scrollEl.scrollTop += (row.top - view.top) - (scrollEl.clientHeight - row.height) / 2
}

// Recursive component for rendering objects with their children
function ObjectNode({
obj,
Expand Down Expand Up @@ -165,13 +179,51 @@ export function TreeView() {
// Only the flat Objects folder needs to know its own expansion state here, so
// its (potentially huge) child list is filtered/built only while it is open.
const objectsExpanded = useExplorerStore(s => s.expandedNodes.has(OBJECTS_FOLDER_ID))
const selectedId = useExplorerStore(s => s.selectedItem?.id ?? null)
const isConnected = useConnectionStore(state => state.isConnected)

// Shared scroll container + content wrapper, referenced by the virtualized
// Objects list to window its rows and track its offset within the scroll area.
const scrollRef = useRef<HTMLDivElement>(null)
const contentRef = useRef<HTMLDivElement>(null)

// Bring the selected node into view. Reveal is owned entirely here: an `obj:`
// id identifies an object, not a tree, and objects appear both under
// Namespaces → ObjectType (plain DOM) and in the windowed Objects list, so a
// prefix can't say which one to scroll to. Ask the DOM first — whichever copy
// is mounted is the one styled selected — and fall back to the virtualized
// list only when the row isn't mounted at all. Runs after VirtualObjectRows'
// layout effects, so its measurements are settled by the time we call it.
const virtualRowsRef = useRef<VirtualObjectRowsHandle>(null)
const lastRevealedRef = useRef<string | null>(null)
useEffect(() => {
if (!selectedId) return
// Selection unchanged: data moved, the user didn't. Don't fight their scroll.
if (lastRevealedRef.current === selectedId) return
const scrollEl = scrollRef.current
if (!scrollEl) return

// An object can be rendered in more than one place (the flat Objects list
// shows it at top level and again under its composition parent), and every
// copy shares the selected style. If any copy is already on screen there is
// nothing to do; otherwise bring the first one into view.
const mounted = scrollEl.querySelectorAll<HTMLElement>('.tree-node.selected')
if (mounted.length > 0) {
lastRevealedRef.current = selectedId
if (!Array.from(mounted).some(el => isFullyVisible(scrollEl, el))) {
centerVertically(scrollEl, mounted[0])
}
return
}
// Only the flat Objects list windows its rows, so it is the only place a
// selected node can exist without a DOM element.
if (selectedId.startsWith('obj:') &&
virtualRowsRef.current?.revealElementId(selectedId.slice('obj:'.length))) {
lastRevealedRef.current = selectedId
}
// Otherwise leave the ref unset so a later data change retries.
}, [selectedId, namespaces, objectTypes, objects, allObjects, hierarchicalRoots, childrenByParent])

const refreshTree = useCallback(async () => {
const client = getClient()
if (!client) return
Expand Down Expand Up @@ -454,6 +506,7 @@ export function TreeView() {
hasChildren={true}
>
<VirtualObjectRows
ref={virtualRowsRef}
roots={filteredAllObjects}
scrollRef={scrollRef}
contentRef={contentRef}
Expand Down
53 changes: 44 additions & 9 deletions src/components/tree/VirtualObjectRows.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
import { useExplorerStore } from '../../stores/explorer'
import { getClient } from '../../api/client'
Expand All @@ -21,17 +21,22 @@ const TREE_WIDTH_SAFETY_PX = 32
// linear array and windowed, so only rows in (or near) the viewport are mounted
// instead of rendering the whole catalog at once.

export function VirtualObjectRows({
roots,
scrollRef,
contentRef,
filterText,
}: {
export interface VirtualObjectRowsHandle {
/** Scroll a row into view by elementId. False when it isn't in the visible forest. */
revealElementId: (elementId: string) => boolean
}

export const VirtualObjectRows = forwardRef<VirtualObjectRowsHandle, {
roots: ObjectInstance[]
scrollRef: React.RefObject<HTMLDivElement>
contentRef: React.RefObject<HTMLDivElement>
filterText: string
}) {
}>(function VirtualObjectRows({
roots,
scrollRef,
contentRef,
filterText,
}, ref) {
const expandedNodes = useExplorerStore(s => s.expandedNodes)
const childObjects = useExplorerStore(s => s.childObjects)
const compositionCache = useExplorerStore(s => s.compositionCache)
Expand All @@ -48,6 +53,12 @@ export function VirtualObjectRows({
const [scrollMargin, setScrollMargin] = useState(0)
const [listMinWidth, setListMinWidth] = useState(0)

// Mirrors of the two measurements above. Reveal is driven imperatively from
// TreeView's effect, which runs after this component's layout effects but
// still sees the *previous* render's state — the refs hold the fresh values.
const scrollMarginRef = useRef(0)
const rowHeightRef = useRef(ESTIMATED_ROW_HEIGHT)

// Offset of this list within the shared scroll container. It shifts whenever
// content above it (the Namespaces folder) grows or collapses, so recompute on
// any size change of the tree body. The +scrollTop term makes it independent
Expand All @@ -62,6 +73,7 @@ export function VirtualObjectRows({
listEl.getBoundingClientRect().top -
scrollEl.getBoundingClientRect().top +
scrollEl.scrollTop
scrollMarginRef.current = margin
setScrollMargin(prev => (Math.abs(prev - margin) > 0.5 ? margin : prev))
}
measure()
Expand Down Expand Up @@ -139,6 +151,28 @@ export function VirtualObjectRows({
return () => clearTimeout(handle)
}, [virtualItems, rows, compositionCache])

// Rows are windowed, so a row outside the viewport has no DOM node for
// TreeView to scroll to. Reach it by index instead: uniform row heights mean
// the virtualizer has a measurement for every index, mounted or not.
useImperativeHandle(ref, () => ({
revealElementId: (elementId: string) => {
const scrollEl = scrollRef.current
if (!scrollEl) return false
const index = rows.findIndex(r => r.kind === 'object' && r.obj.elementId === elementId)
// Not in the visible forest — an ancestor is collapsed, or the filter hides it.
if (index === -1) return false

const height = rowHeightRef.current
const rowTop = scrollMarginRef.current + index * height
const viewTop = scrollEl.scrollTop
if (rowTop >= viewTop && rowTop + height <= viewTop + scrollEl.clientHeight) return true
// Vertical-only: scrollToIndex writes scrollTop and never scrollLeft, so a
// horizontally-scrolled tree stays put.
virtualizer.scrollToIndex(index, { align: 'center' })
return true
},
}), [ref, rows, virtualizer, scrollRef])

// Measure the true row height once from the first mounted object row so the
// spacer math matches the DOM regardless of platform/theme (emoji glyph
// heights differ across OSes).
Expand All @@ -148,6 +182,7 @@ export function VirtualObjectRows({
const h = el.getBoundingClientRect().height
if (h > 0) {
measuredRef.current = true
rowHeightRef.current = h
if (Math.abs(h - rowHeight) > 0.5) setRowHeight(h)
}
}, [rowHeight])
Expand Down Expand Up @@ -186,4 +221,4 @@ export function VirtualObjectRows({
</div>
</div>
)
}
})
91 changes: 90 additions & 1 deletion src/stores/explorer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,43 @@ export interface SelectedItem {
data: Namespace | ObjectType | ObjectInstance
}

// Bounded so a long browsing session can't grow the stack without limit.
const MAX_HISTORY = 50

// A node is only visible once every folder/ancestor above it is expanded, so
// restoring a selection means restoring that path too. Mirrors the expansion
// SearchModal and RelationshipGraph perform before they call selectItem.
function expandedPathTo(
item: SelectedItem,
expandedNodes: Set<string>,
allObjects: ObjectInstance[]
): Set<string> {
const expanded = new Set(expandedNodes)

if (item.id.startsWith('hier:')) {
expanded.add('folder:hierarchical')
const visited = new Set<string>()
let current = item.data as ObjectInstance
while (current.parentId && current.parentId !== '/' && !visited.has(current.elementId)) {
visited.add(current.elementId)
const parent = allObjects.find(o => o.elementId === current.parentId)
if (!parent) break
expanded.add(`hier:${parent.elementId}`)
current = parent
}
} else if (item.id.startsWith('obj:')) {
// The Objects folder lists every object at the top level, so no ancestor walk.
expanded.add('folder:objects')
} else if (item.id.startsWith('ns:')) {
expanded.add('folder:namespaces')
} else if (item.id.startsWith('type:')) {
expanded.add('folder:namespaces')
expanded.add(`ns:${(item.data as ObjectType).namespaceUri}`)
}

return expanded
}

interface ExplorerState {
namespaces: Namespace[]
objectTypes: ObjectType[]
Expand All @@ -41,6 +78,10 @@ interface ExplorerState {
childrenByParent: Map<string, ObjectInstance[]>
expandedNodes: Set<string>
selectedItem: SelectedItem | null
// Visited selections, oldest first. historyIndex is the cursor into it, or -1
// when nothing has been selected yet.
history: SelectedItem[]
historyIndex: number
isLoading: boolean
searchQuery: string
pollIntervalMs: number
Expand All @@ -58,6 +99,8 @@ interface ExplorerState {
expandNode: (nodeId: string) => void
collapseNode: (nodeId: string) => void
selectItem: (item: SelectedItem | null) => void
goBack: () => void
goForward: () => void
setLoading: (loading: boolean) => void
setSearchQuery: (query: string) => void
setPollIntervalMs: (ms: number) => void
Expand All @@ -78,6 +121,8 @@ export const useExplorerStore = create<ExplorerState>((set, get) => ({
childrenByParent: new Map(),
expandedNodes: new Set(),
selectedItem: null,
history: [],
historyIndex: -1,
isLoading: false,
searchQuery: '',
pollIntervalMs: 30_000,
Expand Down Expand Up @@ -152,7 +197,49 @@ export const useExplorerStore = create<ExplorerState>((set, get) => ({
set({ expandedNodes: updated })
},

selectItem: (item) => set({ selectedItem: item }),
selectItem: (item) => {
if (!item) {
set({ selectedItem: item })
return
}
const { history, historyIndex } = get()
// Re-selecting the current node (clicking an already-selected row) must not
// add a second entry, but still refreshes selectedItem with the newer data.
if (historyIndex >= 0 && history[historyIndex].id === item.id) {
set({ selectedItem: item })
return
}
// Navigating after going back discards the forward entries, like a browser.
const entries = history.slice(0, historyIndex + 1)
entries.push(item)
const trimmed = entries.length > MAX_HISTORY ? entries.slice(-MAX_HISTORY) : entries
set({ selectedItem: item, history: trimmed, historyIndex: trimmed.length - 1 })
},

// goBack/goForward restore a selection directly rather than calling selectItem,
// which would push the entry back onto the stack and trap the cursor at the end.
goBack: () => {
const { history, historyIndex, expandedNodes, allObjects } = get()
if (historyIndex <= 0) return
const target = history[historyIndex - 1]
set({
selectedItem: target,
historyIndex: historyIndex - 1,
expandedNodes: expandedPathTo(target, expandedNodes, allObjects),
})
},

goForward: () => {
const { history, historyIndex, expandedNodes, allObjects } = get()
if (historyIndex >= history.length - 1) return
const target = history[historyIndex + 1]
set({
selectedItem: target,
historyIndex: historyIndex + 1,
expandedNodes: expandedPathTo(target, expandedNodes, allObjects),
})
},

setLoading: (loading) => set({ isLoading: loading }),
setSearchQuery: (query) => set({ searchQuery: query }),
setPollIntervalMs: (ms) => set({ pollIntervalMs: ms }),
Expand All @@ -171,6 +258,8 @@ export const useExplorerStore = create<ExplorerState>((set, get) => ({
childrenByParent: new Map(),
expandedNodes: new Set(),
selectedItem: null,
history: [],
historyIndex: -1,
isLoading: false,
searchQuery: ''
})
Expand Down