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 }) // eslint-disable-next-line react-refresh/only-export-components export const NavigationTabIdContext = createContext(null) 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 pathToEntry = (url) => { if (!url) return locationToEntry({ pathname: '/' }) if (typeof url !== 'string') return locationToEntry(url) try { const parsed = new URL(url, window.location.origin) return { pathname: parsed.pathname || '/', search: parsed.search || '', hash: parsed.hash || '' } } catch { return locationToEntry({ pathname: url }) } } 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 EMPTY_LIST_STATE_ENTRY = { viewId: null, viewName: null, filter: {}, sort: {}, listFilter: {}, listSort: {} } const parseSearchJson = (raw) => { if (!raw) return null try { const parsed = JSON.parse(raw) return parsed && typeof parsed === 'object' ? parsed : null } catch { return null } } const normalizeSorterValue = (sorter) => sorter?.field && sorter?.order ? { field: sorter.field, order: sorter.order } : {} const normalizeListStateEntry = (value) => { if (!value || typeof value !== 'object') { return { ...EMPTY_LIST_STATE_ENTRY } } return { viewId: value.viewId ? String(value.viewId) : null, viewName: value.viewName || null, filter: value.filter && typeof value.filter === 'object' ? value.filter : {}, sort: normalizeSorterValue(value.sort), listFilter: value.listFilter && typeof value.listFilter === 'object' ? value.listFilter : {}, listSort: normalizeSorterValue(value.listSort) } } const normalizeListState = (listState) => { if (!listState || typeof listState !== 'object' || Array.isArray(listState)) { return {} } return Object.fromEntries( Object.entries(listState).map(([scope, value]) => [ scope, normalizeListStateEntry(value) ]) ) } const cloneListState = (listState) => JSON.parse(JSON.stringify(normalizeListState(listState))) const getTabListStateEntry = (tab, scope) => { if (!scope) return { ...EMPTY_LIST_STATE_ENTRY } return tab?.listState?.[scope] ? normalizeListStateEntry(tab.listState[scope]) : { ...EMPTY_LIST_STATE_ENTRY } } const listStateEntriesEqual = (left, right) => JSON.stringify(normalizeListStateEntry(left)) === JSON.stringify(normalizeListStateEntry(right)) const applyListStateToTab = (tab, scope, patch = {}) => { if (!tab || !scope) return tab const current = getTabListStateEntry(tab, scope) const next = normalizeListStateEntry({ ...current, ...patch, viewId: patch.viewId !== undefined ? patch.viewId : current.viewId, viewName: patch.viewName !== undefined ? patch.viewName : current.viewName, filter: patch.filter !== undefined ? patch.filter : current.filter, sort: patch.sort !== undefined ? patch.sort : current.sort, listFilter: patch.listFilter !== undefined ? patch.listFilter : current.listFilter, listSort: patch.listSort !== undefined ? patch.listSort : current.listSort }) if (listStateEntriesEqual(current, next) && tab.listState?.[scope]) { return tab } return { ...tab, listState: { ...(tab.listState || {}), [scope]: next } } } const seedListStateFromHistory = (tab) => { if (!tab?.modelName) return normalizeListState(tab?.listState) if (tab.listState?.[tab.modelName]) { return normalizeListState(tab.listState) } const entry = getTabCurrentEntry(tab) const params = new URLSearchParams(entry?.search || '') const viewId = params.get('viewId') const filter = parseSearchJson(params.get('filter')) const sort = parseSearchJson(params.get('sort')) if (!viewId && !filter && !sort?.field) { return normalizeListState(tab.listState) } return applyListStateToTab(tab, tab.modelName, { viewId, filter: filter || {}, sort: sort || {} }).listState } const createTabFromLocation = (location, extras = {}) => ({ id: createTabId(), title: extras.title || 'Farm Control', modelName: extras.modelName || null, iconKey: extras.iconKey || null, history: [locationToEntry(location)], historyIndex: 0, listState: cloneListState(extras.listState) }) const cloneCurrentPageTab = (tab, location) => ({ id: createTabId(), title: tab?.title || 'Farm Control', modelName: tab?.modelName || null, iconKey: tab?.iconKey || null, history: [locationToEntry(location)], historyIndex: 0, listState: cloneListState(tab?.listState) }) const getTabCurrentEntry = (tab) => tab?.history?.[tab.historyIndex] || tab?.history?.[tab.history.length - 1] const normalizeHistory = (history, fallbackEntry) => { if (Array.isArray(history) && history.length > 0) { return history.map((item) => locationToEntry(item)) } return [fallbackEntry || { pathname: '/', search: '', hash: '' }] } const normalizeTab = (tab, fallbackLocation) => { const fallbackEntry = locationToEntry(fallbackLocation) const history = normalizeHistory(tab?.history, fallbackEntry) const rawIndex = Number.isInteger(tab?.historyIndex) ? tab.historyIndex : history.length - 1 const normalized = { id: tab?.id || createTabId(), title: tab?.title || 'Farm Control', modelName: tab?.modelName || null, iconKey: tab?.iconKey || null, history, historyIndex: Math.min(Math.max(rawIndex, 0), history.length - 1), listState: normalizeListState(tab?.listState) } return { ...normalized, listState: seedListStateFromHistory(normalized) } } const applyLocationToTab = (tab, entry) => { if (!tab || !entry) return tab const currentEntry = getTabCurrentEntry(tab) if (entriesEqual(currentEntry, entry)) return tab if (!tab.history?.length) { return { ...tab, history: [entry], historyIndex: 0 } } const currentPath = currentEntry?.pathname || '/' const nextPath = entry.pathname || '/' if (currentPath === nextPath) { const history = [...tab.history] history[tab.historyIndex] = entry return { ...tab, history } } const truncated = tab.history.slice(0, tab.historyIndex + 1) return { ...tab, history: [...truncated, entry], historyIndex: truncated.length } } const applyPageMetaToTab = (tab, { title, modelName, iconKey } = {}) => { const nextModelName = modelName === undefined ? iconKey !== undefined ? null : tab.modelName : modelName || null const nextIconKey = iconKey === undefined ? modelName !== undefined ? null : tab.iconKey : iconKey || null const nextTitle = title || tab.title if ( nextTitle === tab.title && nextModelName === tab.modelName && nextIconKey === tab.iconKey ) { return tab } return { ...tab, title: nextTitle, modelName: nextModelName, iconKey: nextIconKey } } 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 locationRef = useRef(location) const isRestoringRef = useRef(null) const tabPaneElsRef = useRef(new Map()) const captureQueueRef = useRef(Promise.resolve()) const captureInFlightRef = useRef(new Map()) const previewThemeRef = useRef(isDarkMode ? 'dark' : 'light') const tabStripRootRef = useRef(null) const scrollTabStripToEndIntervalRef = useRef(null) // Late URL writes from the tab we just left (e.g. persistView) must not // attach to the newly selected tab. const foreignLocationRef = useRef(null) tabsRef.current = tabs activeTabIdRef.current = activeTabId locationRef.current = location previewThemeRef.current = isDarkMode ? 'dark' : 'light' const markForeignLocation = useCallback((previousTabId) => { if (!previousTabId) { foreignLocationRef.current = null return } foreignLocationRef.current = { tabId: previousTabId, entry: locationToEntry(locationRef.current), expiresAt: Date.now() + 250 } }, []) const applyEntryToTabId = useCallback((tabId, entry) => { if (!tabId || !entry) return setTabs((current) => { let changed = false const next = current.map((tab) => { if (tab.id !== tabId) return tab const updated = applyLocationToTab(tab, entry) if (updated !== tab) changed = true return updated }) return changed ? next : current }) }, []) const shouldIgnoreRestoredLocation = useCallback((entry) => { const restoring = isRestoringRef.current if (!restoring) return false if (restoring === true) { isRestoringRef.current = null return true } if (entriesEqual(restoring.target, entry)) { isRestoringRef.current = null return true } if (entriesEqual(restoring.from, entry)) { return true } isRestoringRef.current = null return false }, []) const restoreToEntry = useCallback( (entry, { previousTabId } = {}) => { if (!entry) return isRestoringRef.current = { target: locationToEntry(entry), from: locationToEntry(locationRef.current), previousTabId: previousTabId === undefined ? activeTabIdRef.current : previousTabId } navigate(entryToPath(entry), { replace: true }) }, [navigate] ) const registerTabStripRoot = useCallback((element) => { tabStripRootRef.current = element }, []) const scrollTabIntoView = useCallback((tabId) => { if (!tabId) return const tabs = tabsRef.current const isLastTab = tabs[tabs.length - 1]?.id === tabId const leftInset = 24 const rightInset = isLastTab ? 72 : 28 const run = () => { const root = tabStripRootRef.current const scrollEl = root?.querySelector('.simplebar-content-wrapper') if (!root || !scrollEl) return const tabItem = root.querySelector( `#dashboard-tab-item-${CSS.escape(String(tabId))}` ) if (!tabItem) return const tabRect = tabItem.getBoundingClientRect() const scrollRect = scrollEl.getBoundingClientRect() if (tabRect.left < scrollRect.left + leftInset) { scrollEl.scrollLeft += tabRect.left - scrollRect.left - leftInset } else if (tabRect.right > scrollRect.right - rightInset) { scrollEl.scrollLeft += tabRect.right - scrollRect.right + rightInset } } requestAnimationFrame(() => { run() requestAnimationFrame(run) }) }, []) const selectTab = useCallback( (tabId) => { if (!tabId || tabId === activeTabIdRef.current) return const nextTab = tabsRef.current.find((tab) => tab.id === tabId) if (!nextTab) return const previousTabId = activeTabIdRef.current const nextEntry = getTabCurrentEntry(nextTab) markForeignLocation(previousTabId) setActiveTabId(tabId) activeTabIdRef.current = tabId scrollTabIntoView(tabId) if (entriesEqual(nextEntry, locationRef.current)) { return } restoreToEntry(nextEntry, { previousTabId }) }, [markForeignLocation, restoreToEntry, scrollTabIntoView] ) useEffect(() => { return () => { if (scrollTabStripToEndIntervalRef.current) { clearInterval(scrollTabStripToEndIntervalRef.current) scrollTabStripToEndIntervalRef.current = null } } }, []) const scrollTabStripToEnd = useCallback(() => { if (scrollTabStripToEndIntervalRef.current) { clearInterval(scrollTabStripToEndIntervalRef.current) scrollTabStripToEndIntervalRef.current = null } const scrollEl = tabStripRootRef.current?.querySelector( '.simplebar-content-wrapper' ) if (!scrollEl) return let lastMaxScrollLeft = scrollEl.scrollWidth - scrollEl.clientWidth let unchangedChecks = 0 scrollTabStripToEndIntervalRef.current = setInterval(() => { const maxScrollLeft = scrollEl.scrollWidth - scrollEl.clientWidth if (maxScrollLeft !== lastMaxScrollLeft) { scrollEl.scrollLeft = maxScrollLeft lastMaxScrollLeft = maxScrollLeft unchangedChecks = 0 } else { unchangedChecks++ } if (unchangedChecks >= 15) { clearInterval(scrollTabStripToEndIntervalRef.current) scrollTabStripToEndIntervalRef.current = null } }, 10) }, []) const addTab = useCallback( (url) => { const targetUrl = typeof url === 'string' ? url : null const currentTab = tabsRef.current.find( (tab) => tab.id === activeTabIdRef.current ) const entry = targetUrl ? pathToEntry(targetUrl) : locationToEntry(location) const nextTab = targetUrl ? createTabFromLocation(entry) : cloneCurrentPageTab(currentTab, location) const previousTabId = activeTabIdRef.current setTabs((current) => [...current, nextTab]) markForeignLocation(previousTabId) setActiveTabId(nextTab.id) activeTabIdRef.current = nextTab.id if (targetUrl && !entriesEqual(entry, locationRef.current)) { restoreToEntry(entry, { previousTabId }) } scrollTabStripToEnd() }, [location, markForeignLocation, restoreToEntry, scrollTabStripToEnd] ) 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) { const previousTabId = activeTabIdRef.current markForeignLocation(previousTabId) setActiveTabId(nextActive.id) activeTabIdRef.current = nextActive.id const nextEntry = getTabCurrentEntry(nextActive) if (!entriesEqual(nextEntry, locationRef.current)) { restoreToEntry(nextEntry, { previousTabId }) } scrollTabIntoView(nextActive.id) } return true }, [ handleWindowControl, markForeignLocation, restoreToEntry, scrollTabIntoView ] ) const closeTab = useCallback( (tabId) => { removeTab(tabId, { closeWindowIfLast: true }) }, [removeTab] ) const acceptIncomingTab = useCallback( (incomingTab, targetId, insertBefore = false) => { if (!incomingTab?.id) return const tab = normalizeTab(incomingTab, locationRef.current) setTabs((current) => { if (current.some((item) => item.id === tab.id)) { return current } const next = [...current] const targetIndex = next.findIndex((item) => item.id === targetId) if (targetIndex === -1) { next.push(tab) return next } next.splice(insertBefore ? targetIndex : targetIndex + 1, 0, tab) return next }) const previousTabId = activeTabIdRef.current markForeignLocation(previousTabId) setActiveTabId(tab.id) activeTabIdRef.current = tab.id const nextEntry = getTabCurrentEntry(tab) if (!entriesEqual(nextEntry, locationRef.current)) { restoreToEntry(nextEntry, { previousTabId }) } scrollTabIntoView(tab.id) }, [markForeignLocation, restoreToEntry, scrollTabIntoView] ) 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 }, []) const setTabPage = useCallback(({ title, modelName, iconKey } = {}) => { const activeId = activeTabIdRef.current if (!activeId) return setTabs((current) => { if (current.length === 0) return current let changed = false const next = current.map((tab) => { if (tab.id !== activeId) return tab const updated = applyPageMetaToTab(tab, { title, modelName, iconKey }) if (updated !== tab) changed = true return updated }) return changed ? next : current }) }, []) const getTabListState = useCallback((tabId, scope) => { const tab = tabsRef.current.find((item) => item.id === tabId) return getTabListStateEntry(tab, scope) }, []) const setTabListState = useCallback((tabId, scope, patch) => { if (!tabId || !scope) return setTabs((current) => { let changed = false const next = current.map((tab) => { if (tab.id !== tabId) return tab const updated = applyListStateToTab(tab, scope, patch) if (updated !== tab) changed = true return updated }) return changed ? next : current }) }, []) 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) { const restoredTabs = session.tabs.map((tab) => normalizeTab(tab, location) ) setTabs(restoredTabs) const nextActiveId = session.activeTabId && restoredTabs.some((tab) => tab.id === session.activeTabId) ? session.activeTabId : restoredTabs[0].id setActiveTabId(nextActiveId) const activeTab = restoredTabs.find((tab) => tab.id === nextActiveId) || restoredTabs[0] const entry = getTabCurrentEntry(activeTab) if (!entriesEqual(entry, location)) { restoreToEntry(entry) } else { isRestoringRef.current = null } } 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 const entry = locationToEntry(location) const restoring = isRestoringRef.current const foreign = foreignLocationRef.current if (restoring && restoring !== true) { if (entriesEqual(restoring.target, entry)) { isRestoringRef.current = null return } if (entriesEqual(restoring.from, entry)) { return } // Same-path query cleanup on the tab we just opened (action params, etc.) if (entry.pathname === restoring.target?.pathname) { isRestoringRef.current = null } else if (restoring.previousTabId) { // A delayed write from the previous tab (viewId, filters, etc.) applyEntryToTabId(restoring.previousTabId, entry) if (foreign) foreign.entry = entry return } else { return } } if (shouldIgnoreRestoredLocation(entry)) return if ( foreign && foreign.tabId && foreign.tabId !== activeTabIdRef.current && Date.now() < foreign.expiresAt ) { if (entriesEqual(entry, foreign.entry)) { return } const activeEntry = getTabCurrentEntry( tabsRef.current.find((tab) => tab.id === activeTabIdRef.current) ) if ( entry.pathname === foreign.entry.pathname && entry.pathname !== activeEntry?.pathname ) { applyEntryToTabId(foreign.tabId, entry) foreign.entry = entry return } } if (foreign && Date.now() >= foreign.expiresAt) { foreignLocationRef.current = null } setTabs((current) => { if (current.length === 0) { const initialTab = createTabFromLocation(location) setActiveTabId(initialTab.id) return [initialTab] } let changed = false const next = current.map((tab) => { if (tab.id !== activeTabIdRef.current) return tab const updated = applyLocationToTab(tab, entry) if (updated !== tab) changed = true return updated }) return changed ? next : current }) }, [ applyEntryToTabId, hydrated, isElectron, location, shouldIgnoreRestoredLocation ]) 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]) useEffect(() => { if (!isElectron) return undefined const digitIndexFromEvent = (event) => { const code = event.code || '' if (code.startsWith('Digit')) { const digit = Number(code.slice(5)) if (digit >= 1 && digit <= 9) return digit - 1 if (digit === 0) return 9 } const key = event.key if (key >= '1' && key <= '9') return Number(key) - 1 if (key === '0') return 9 return null } const selectTabAtIndex = (index) => { const nextTab = tabsRef.current[index] if (!nextTab) return selectTab(nextTab.id) } const cycleTab = (delta) => { const current = tabsRef.current if (current.length < 2) return const activeIndex = current.findIndex( (tab) => tab.id === activeTabIdRef.current ) const fromIndex = activeIndex === -1 ? 0 : activeIndex const nextIndex = (fromIndex + delta + current.length) % current.length selectTab(current[nextIndex].id) } const handleKeyDown = (event) => { if (event.ctrlKey && event.key === 'Tab') { event.preventDefault() cycleTab(event.shiftKey ? -1 : 1) return } if (!(event.metaKey || event.ctrlKey) || event.repeat) return const index = digitIndexFromEvent(event) if (index == null) return event.preventDefault() selectTabAtIndex(index) } window.addEventListener('keydown', handleKeyDown, true) return () => { window.removeEventListener('keydown', handleKeyDown, true) } }, [isElectron, selectTab]) 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, registerTabStripRoot, captureTabPreview, getTabListState, setTabListState }), [ activeTab, activeTabId, addTab, canGoBack, canGoForward, captureTabPreview, closeTab, createNewWindow, goBack, goForward, handleExternalTabDrop, handleTabDragEnd, handleTabDragStart, acceptIncomingTab, hydrated, isElectron, registerTabPane, registerTabStripRoot, reorderTabs, selectTab, setTabPage, getTabListState, setTabListState, 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, iconKey } = {}) => { const { setTabPage, isElectron } = useContext(NavigationTabMetaContext) const store = useContext(NavigationTabActiveContext) useEffect(() => { if (!isElectron || !title) return undefined const sync = () => { if (!store.getSnapshot()) return setTabPage({ title, modelName, iconKey }) } sync() return store.subscribe(sync) }, [iconKey, 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