farmcontrol-ui/src/components/Dashboard/context/NavigationTabsContext.jsx
Tom Butcher 2f9f42c9e2
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
Refactor tab management in NavigationTabsContext
- Simplified the logic for adding new tabs by directly appending the next tab to the current tabs array, improving code readability and maintainability.
- Removed unnecessary calculations for determining the insertion index, streamlining the tab addition process.
2026-09-17 20:46:26 +01:00

519 lines
14 KiB
JavaScript

import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState
} from 'react'
import PropTypes from 'prop-types'
import { useLocation, useNavigate } from 'react-router-dom'
import { ElectronContext } from './ElectronContext'
import { getDesktopWindowId } from '../../../electrobun-bridge.js'
const NavigationTabsContext = createContext()
// eslint-disable-next-line react-refresh/only-export-components
export const NavigationTabActiveContext = createContext(true)
const createTabId = () =>
`tab-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const locationToEntry = (location) => ({
pathname: location?.pathname || '/',
search: location?.search || '',
hash: location?.hash || ''
})
const entryToPath = (entry) => {
if (!entry) return '/'
if (typeof entry === 'string') return entry
return `${entry.pathname || '/'}${entry.search || ''}${entry.hash || ''}`
}
const entriesEqual = (left, right) => entryToPath(left) === entryToPath(right)
const createTabFromLocation = (location, extras = {}) => ({
id: createTabId(),
title: extras.title || 'Farm Control',
modelName: extras.modelName || null,
history: [locationToEntry(location)],
historyIndex: 0
})
const cloneCurrentPageTab = (tab, location) => ({
id: createTabId(),
title: tab?.title || 'Farm Control',
modelName: tab?.modelName || null,
history: [locationToEntry(location)],
historyIndex: 0
})
const getTabCurrentEntry = (tab) =>
tab?.history?.[tab.historyIndex] || tab?.history?.[tab.history.length - 1]
export const NavigationTabsProvider = ({ children }) => {
const navigate = useNavigate()
const location = useLocation()
const {
isElectron,
getWindowSession,
syncWindowTabs,
createAppWindow,
handleWindowControl,
registerTabHistoryHandler,
onNewTabRequest,
onNewWindowRequest,
onTabMovedAway,
beginTabDrag,
completeTabDrop,
cancelTabDrag
} = useContext(ElectronContext)
const [tabs, setTabs] = useState([])
const [activeTabId, setActiveTabId] = useState(null)
const [hydrated, setHydrated] = useState(!isElectron)
const tabsRef = useRef(tabs)
const activeTabIdRef = useRef(activeTabId)
const isRestoringRef = useRef(false)
tabsRef.current = tabs
activeTabIdRef.current = activeTabId
const restoreToEntry = useCallback(
(entry) => {
if (!entry) return
isRestoringRef.current = true
navigate(entryToPath(entry), { replace: true })
},
[navigate]
)
const selectTab = useCallback(
(tabId) => {
if (!tabId || tabId === activeTabIdRef.current) return
const nextTab = tabsRef.current.find((tab) => tab.id === tabId)
if (!nextTab) return
const nextEntry = getTabCurrentEntry(nextTab)
setActiveTabId(tabId)
if (entriesEqual(nextEntry, location)) {
return
}
restoreToEntry(nextEntry)
},
[location, restoreToEntry]
)
const addTab = useCallback(() => {
const currentTab = tabsRef.current.find(
(tab) => tab.id === activeTabIdRef.current
)
const nextTab = cloneCurrentPageTab(currentTab, location)
setTabs((current) => [...current, nextTab])
setActiveTabId(nextTab.id)
}, [location])
const removeTab = useCallback(
(tabId, { closeWindowIfLast = true } = {}) => {
const current = tabsRef.current
const index = current.findIndex((tab) => tab.id === tabId)
if (index === -1) return false
if (current.length <= 1) {
if (closeWindowIfLast) {
handleWindowControl?.('close')
}
return true
}
const remaining = current.filter((tab) => tab.id !== tabId)
const nextActive =
tabId === activeTabIdRef.current
? remaining[Math.max(0, index - 1)]
: remaining.find((tab) => tab.id === activeTabIdRef.current)
setTabs(remaining)
if (nextActive) {
setActiveTabId(nextActive.id)
const nextEntry = getTabCurrentEntry(nextActive)
if (!entriesEqual(nextEntry, location)) {
restoreToEntry(nextEntry)
}
}
return true
},
[handleWindowControl, location, restoreToEntry]
)
const closeTab = useCallback(
(tabId) => {
removeTab(tabId, { closeWindowIfLast: true })
},
[removeTab]
)
const acceptIncomingTab = useCallback(
(incomingTab, targetId, insertBefore = false) => {
if (!incomingTab?.id) return
setTabs((current) => {
if (current.some((tab) => tab.id === incomingTab.id)) {
return current
}
const next = [...current]
const targetIndex = next.findIndex((tab) => tab.id === targetId)
if (targetIndex === -1) {
next.push(incomingTab)
return next
}
next.splice(insertBefore ? targetIndex : targetIndex + 1, 0, incomingTab)
return next
})
setActiveTabId(incomingTab.id)
const nextEntry = getTabCurrentEntry(incomingTab)
if (!entriesEqual(nextEntry, location)) {
restoreToEntry(nextEntry)
}
},
[location, restoreToEntry]
)
const handleTabDragStart = useCallback(
(tabId) => {
const tab = tabsRef.current.find((item) => item.id === tabId)
if (!tab) return
void beginTabDrag?.({
windowId: getDesktopWindowId(),
tab
})
},
[beginTabDrag]
)
const handleTabDragEnd = useCallback(() => {
window.setTimeout(() => {
void cancelTabDrag?.({ windowId: getDesktopWindowId() })
}, 150)
}, [cancelTabDrag])
const handleExternalTabDrop = useCallback(
async (targetId, insertBefore) => {
const result = await completeTabDrop?.({
windowId: getDesktopWindowId(),
beforeTabId: targetId,
insertBefore
})
if (!result?.ok || result.sameWindow || !result.tab) {
return
}
acceptIncomingTab(result.tab, targetId, insertBefore)
},
[acceptIncomingTab, completeTabDrop]
)
const reorderTabs = useCallback((draggedId, targetId, insertBefore) => {
setTabs((current) => {
const fromIndex = current.findIndex(
(tab) => String(tab.id) === String(draggedId)
)
const toIndex = current.findIndex(
(tab) => String(tab.id) === String(targetId)
)
if (fromIndex === -1 || toIndex === -1 || fromIndex === toIndex) {
return current
}
const next = [...current]
const [moved] = next.splice(fromIndex, 1)
let insertIndex = next.findIndex(
(tab) => String(tab.id) === String(targetId)
)
if (!insertBefore) {
insertIndex += 1
}
next.splice(insertIndex, 0, moved)
return next
})
}, [])
const setTabPage = useCallback(({ title, modelName } = {}) => {
const activeId = activeTabIdRef.current
if (!activeId) return
setTabs((current) =>
current.map((tab) => {
if (tab.id !== activeId) return tab
return {
...tab,
title: title || tab.title,
modelName:
modelName === undefined ? tab.modelName : modelName || null
}
})
)
}, [])
const goBack = useCallback(() => {
const currentTab = tabsRef.current.find(
(tab) => tab.id === activeTabIdRef.current
)
if (!currentTab || currentTab.historyIndex <= 0) return
const nextIndex = currentTab.historyIndex - 1
const nextEntry = currentTab.history[nextIndex]
setTabs((current) =>
current.map((tab) =>
tab.id === currentTab.id ? { ...tab, historyIndex: nextIndex } : tab
)
)
restoreToEntry(nextEntry)
}, [restoreToEntry])
const goForward = useCallback(() => {
const currentTab = tabsRef.current.find(
(tab) => tab.id === activeTabIdRef.current
)
if (!currentTab) return
if (currentTab.historyIndex >= currentTab.history.length - 1) return
const nextIndex = currentTab.historyIndex + 1
const nextEntry = currentTab.history[nextIndex]
setTabs((current) =>
current.map((tab) =>
tab.id === currentTab.id ? { ...tab, historyIndex: nextIndex } : tab
)
)
restoreToEntry(nextEntry)
}, [restoreToEntry])
const createNewWindow = useCallback(() => {
const currentTab = tabsRef.current.find(
(tab) => tab.id === activeTabIdRef.current
)
const nextTab = cloneCurrentPageTab(currentTab, location)
void createAppWindow?.({
tabs: [nextTab],
activeTabId: nextTab.id
})
}, [createAppWindow, location])
useEffect(() => {
if (!isElectron) {
setHydrated(true)
return undefined
}
let cancelled = false
const hydrate = async () => {
const deadline = Date.now() + 2000
while (!getDesktopWindowId() && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 20))
}
const session = await getWindowSession?.()
if (cancelled) return
if (session?.tabs?.length) {
isRestoringRef.current = true
setTabs(session.tabs)
const nextActiveId =
session.activeTabId &&
session.tabs.some((tab) => tab.id === session.activeTabId)
? session.activeTabId
: session.tabs[0].id
setActiveTabId(nextActiveId)
const activeTab =
session.tabs.find((tab) => tab.id === nextActiveId) ||
session.tabs[0]
const entry = getTabCurrentEntry(activeTab)
if (!entriesEqual(entry, location)) {
restoreToEntry(entry)
} else {
isRestoringRef.current = false
}
} else {
const initialTab = createTabFromLocation(location)
setTabs([initialTab])
setActiveTabId(initialTab.id)
}
setHydrated(true)
}
void hydrate()
return () => {
cancelled = true
}
// Hydrate once per window on mount.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isElectron])
useEffect(() => {
if (!isElectron || !hydrated) return
if (isRestoringRef.current) {
isRestoringRef.current = false
return
}
const entry = locationToEntry(location)
setTabs((current) => {
if (current.length === 0) {
const initialTab = createTabFromLocation(location)
setActiveTabId(initialTab.id)
return [initialTab]
}
return current.map((tab) => {
if (tab.id !== activeTabIdRef.current) return tab
const currentEntry = getTabCurrentEntry(tab)
if (entriesEqual(currentEntry, entry)) return tab
const truncated = tab.history.slice(0, tab.historyIndex + 1)
return {
...tab,
history: [...truncated, entry],
historyIndex: truncated.length
}
})
})
}, [hydrated, isElectron, location])
useEffect(() => {
if (!isElectron || !hydrated || !syncWindowTabs) return undefined
const timeoutId = setTimeout(() => {
void syncWindowTabs({
windowId: getDesktopWindowId(),
activeTabId,
tabs
})
}, 200)
return () => clearTimeout(timeoutId)
}, [activeTabId, hydrated, isElectron, syncWindowTabs, tabs])
useEffect(() => {
if (!isElectron || !registerTabHistoryHandler) return undefined
registerTabHistoryHandler((direction) => {
if (direction === 'back') goBack()
if (direction === 'forward') goForward()
})
return () => registerTabHistoryHandler(null)
}, [goBack, goForward, isElectron, registerTabHistoryHandler])
useEffect(() => {
if (!isElectron || !onNewTabRequest) return undefined
return onNewTabRequest(() => addTab())
}, [addTab, isElectron, onNewTabRequest])
useEffect(() => {
if (!isElectron || !onNewWindowRequest) return undefined
return onNewWindowRequest(() => createNewWindow())
}, [createNewWindow, isElectron, onNewWindowRequest])
useEffect(() => {
if (!isElectron || !onTabMovedAway) return undefined
return onTabMovedAway(({ tabId } = {}) => {
if (!tabId) return
removeTab(tabId, { closeWindowIfLast: true })
})
}, [isElectron, onTabMovedAway, removeTab])
const activeTab = useMemo(
() => tabs.find((tab) => tab.id === activeTabId) || null,
[activeTabId, tabs]
)
const canGoBack = (activeTab?.historyIndex || 0) > 0
const canGoForward =
(activeTab?.historyIndex || 0) < (activeTab?.history?.length || 1) - 1
const value = useMemo(
() => ({
tabs,
activeTabId,
activeTab,
hydrated,
isElectron,
selectTab,
addTab,
closeTab,
reorderTabs,
acceptIncomingTab,
handleTabDragStart,
handleTabDragEnd,
handleExternalTabDrop,
setTabPage,
goBack,
goForward,
canGoBack,
canGoForward,
createNewWindow
}),
[
activeTab,
activeTabId,
addTab,
canGoBack,
canGoForward,
closeTab,
createNewWindow,
goBack,
goForward,
handleExternalTabDrop,
handleTabDragEnd,
handleTabDragStart,
acceptIncomingTab,
hydrated,
isElectron,
reorderTabs,
selectTab,
setTabPage,
tabs
]
)
return (
<NavigationTabsContext.Provider value={value}>
{children}
</NavigationTabsContext.Provider>
)
}
// eslint-disable-next-line react-refresh/only-export-components
export const useNavigationTabs = () => {
const context = useContext(NavigationTabsContext)
if (!context) {
throw new Error(
'useNavigationTabs must be used within a NavigationTabsProvider'
)
}
return context
}
// eslint-disable-next-line react-refresh/only-export-components
export const useNavigationTabPage = ({ title, modelName } = {}) => {
const { setTabPage, isElectron } = useNavigationTabs()
const isTabActive = useContext(NavigationTabActiveContext)
useEffect(() => {
if (!isElectron || !title || !isTabActive) return
setTabPage({ title, modelName })
}, [isElectron, isTabActive, modelName, setTabPage, title])
}
// eslint-disable-next-line react-refresh/only-export-components
export const useIsNavigationTabActive = () =>
useContext(NavigationTabActiveContext)
NavigationTabsProvider.propTypes = {
children: PropTypes.node.isRequired
}
export default NavigationTabsContext