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 ( {children} ) } // 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