import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react' import PropTypes from 'prop-types' import { useLocation, useNavigate } from 'react-router-dom' import { ElectronContext } from './ElectronContext' import { getDesktopWindowId } from '../../../electrobun-bridge.js' import { captureTabPaneImage } from './tabPreviewCapture' import { useThemeContext } from './ThemeContext' const NavigationTabsContext = createContext() const NavigationTabMetaContext = createContext({ setTabPage: () => {}, isElectron: false }) const NavigationTabPreviewsContext = createContext({ tabPreviews: {}, tabPreviewCapturing: {} }) // eslint-disable-next-line react-refresh/only-export-components export const createNavigationTabActiveStore = (initial = true) => { let isActive = Boolean(initial) const listeners = new Set() return { getSnapshot: () => isActive, setActive: (next) => { const value = Boolean(next) if (isActive === value) return isActive = value listeners.forEach((listener) => listener()) }, subscribe: (listener) => { listeners.add(listener) return () => listeners.delete(listener) } } } const defaultNavigationTabActiveStore = createNavigationTabActiveStore(true) // eslint-disable-next-line react-refresh/only-export-components export const NavigationTabActiveContext = createContext( defaultNavigationTabActiveStore ) 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 { isDarkMode } = useThemeContext() const [tabs, setTabs] = useState([]) const [activeTabId, setActiveTabId] = useState(null) const [hydrated, setHydrated] = useState(!isElectron) const [tabPreviews, setTabPreviews] = useState({}) const [tabPreviewCapturing, setTabPreviewCapturing] = useState({}) const tabsRef = useRef(tabs) const activeTabIdRef = useRef(activeTabId) const previousActiveTabIdRef = useRef(null) const isRestoringRef = useRef(false) const tabPaneElsRef = useRef(new Map()) const captureQueueRef = useRef(Promise.resolve()) const captureInFlightRef = useRef(new Map()) const previewThemeRef = useRef(isDarkMode ? 'dark' : 'light') tabsRef.current = tabs activeTabIdRef.current = activeTabId previewThemeRef.current = isDarkMode ? 'dark' : 'light' 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) setTabPreviews((currentPreviews) => { if (!(tabId in currentPreviews)) return currentPreviews const nextPreviews = { ...currentPreviews } delete nextPreviews[tabId] return nextPreviews }) setTabPreviewCapturing((currentCapturing) => { if (!(tabId in currentCapturing)) return currentCapturing const nextCapturing = { ...currentCapturing } delete nextCapturing[tabId] return nextCapturing }) 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 registerTabPane = useCallback((tabId, element) => { if (!tabId) return if (!element) { tabPaneElsRef.current.delete(tabId) return } tabPaneElsRef.current.set(tabId, element) }, []) const captureTabPreview = useCallback((tabId) => { if (!tabId || tabId === activeTabIdRef.current) { return Promise.resolve(null) } const inFlight = captureInFlightRef.current.get(tabId) if (inFlight) return inFlight setTabPreviewCapturing((current) => current[tabId] ? current : { ...current, [tabId]: true } ) const run = async () => { if (tabId === activeTabIdRef.current) { return null } const pane = tabPaneElsRef.current.get(tabId) || document.querySelector( `.dashboard-tab-pane[data-tab-id="${CSS.escape(tabId)}"]` ) if (!pane) return null try { const dataUrl = await captureTabPaneImage(pane) if (dataUrl) { const theme = previewThemeRef.current setTabPreviews((current) => { const existing = current[tabId] if (existing?.src === dataUrl && existing?.theme === theme) { return current } return { ...current, [tabId]: { src: dataUrl, theme } } }) } return dataUrl } catch (error) { console.error('Failed to capture tab preview', error) return null } } const next = captureQueueRef.current.then(run, run) captureInFlightRef.current.set(tabId, next) captureQueueRef.current = next.then( () => undefined, () => undefined ) next.finally(() => { if (captureInFlightRef.current.get(tabId) === next) { captureInFlightRef.current.delete(tabId) } setTabPreviewCapturing((current) => { if (!current[tabId]) return current const nextCapturing = { ...current } delete nextCapturing[tabId] return nextCapturing }) }) return next }, []) useEffect(() => { const previousId = previousActiveTabIdRef.current previousActiveTabIdRef.current = activeTabId if (!hydrated || !previousId || !activeTabId || previousId === activeTabId) { return } if (!tabsRef.current.some((tab) => tab.id === previousId)) return void captureTabPreview(previousId) }, [activeTabId, captureTabPreview, hydrated]) 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, registerTabPane, captureTabPreview }), [ activeTab, activeTabId, addTab, canGoBack, canGoForward, captureTabPreview, closeTab, createNewWindow, goBack, goForward, handleExternalTabDrop, handleTabDragEnd, handleTabDragStart, acceptIncomingTab, hydrated, isElectron, registerTabPane, reorderTabs, selectTab, setTabPage, tabs ] ) const previewsValue = useMemo( () => ({ tabPreviews, tabPreviewCapturing }), [tabPreviewCapturing, tabPreviews] ) const metaValue = useMemo( () => ({ setTabPage, isElectron }), [isElectron, setTabPage] ) 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 useTabPreview = (tabId) => { const { tabPreviews, tabPreviewCapturing } = useContext( NavigationTabPreviewsContext ) const preview = tabId ? tabPreviews[tabId] : null return { src: preview?.src || null, theme: preview?.theme || null, capturing: Boolean(tabId && tabPreviewCapturing[tabId]) } } // eslint-disable-next-line react-refresh/only-export-components export const useNavigationTabPage = ({ title, modelName } = {}) => { const { setTabPage, isElectron } = useContext(NavigationTabMetaContext) const store = useContext(NavigationTabActiveContext) useEffect(() => { if (!isElectron || !title) return undefined const sync = () => { if (!store.getSnapshot()) return setTabPage({ title, modelName }) } sync() return store.subscribe(sync) }, [isElectron, modelName, setTabPage, store, title]) } // eslint-disable-next-line react-refresh/only-export-components export const useIsNavigationTabActive = () => { const store = useContext(NavigationTabActiveContext) return useSyncExternalStore( store.subscribe, store.getSnapshot, store.getSnapshot ) } NavigationTabsProvider.propTypes = { children: PropTypes.node.isRequired } export default NavigationTabsContext