farmcontrol-ui/src/components/Dashboard/context/NavigationTabsContext.jsx
Tom Butcher d5096dbba3
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
Refactor Dashboard Tabs and Enhance CSS Styles
- Updated CSS for dashboard tabs to improve layout and overflow handling, ensuring better visibility and usability.
- Replaced Typography's Text component with a custom ElipsisText component for improved text overflow management.
- Simplified tab selection logic in NavigationTabsContext, enhancing scrolling behavior when selecting tabs.
- Introduced a new utility function to scroll tabs into view, improving user experience during navigation.
2026-09-21 01:58:03 +01:00

1209 lines
34 KiB
JavaScript

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 (
<NavigationTabsContext.Provider value={value}>
<NavigationTabMetaContext.Provider value={metaValue}>
<NavigationTabPreviewsContext.Provider value={previewsValue}>
{children}
</NavigationTabPreviewsContext.Provider>
</NavigationTabMetaContext.Provider>
</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 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