Big performance increase.
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good

This commit is contained in:
Tom Butcher 2026-09-19 13:07:30 +01:00
parent 24a3a03c65
commit e446098a76
18 changed files with 974 additions and 3382 deletions

View File

@ -4053,6 +4053,7 @@ body.objectKanbanColumnResizing * {
visibility: hidden;
opacity: 0;
pointer-events: none;
content-visibility: hidden;
}
.dashboard-tab-pane-inactive.dashboard-tab-pane-capturing {
@ -4062,6 +4063,7 @@ body.objectKanbanColumnResizing * {
pointer-events: none;
transform: translate(-100%, 0);
z-index: 0;
content-visibility: visible;
}
.dashboard-tab-preview-capture-frame {

545
bun.lock

File diff suppressed because it is too large Load Diff

View File

@ -128,9 +128,6 @@
"@vitejs/plugin-react": "^5.0.2",
"concurrently": "^9.2.1",
"electrobun": "1.18.1",
"electron": "^38.7.1",
"electron-builder": "^26.0.12",
"electron-packager": "^17.1.2",
"eslint": "^9.34.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.4",

2153
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -65,14 +65,7 @@ const getRouter = () => {
return BrowserRouter
}
const AppContent = () => {
const { themeConfig } = useThemeContext()
const Router = getRouter()
return (
<ConfigProvider
theme={themeConfig}
renderEmpty={() => (
const renderEmpty = () => (
<div style={{ margin: '32px' }}>
<MissingPlaceholder
message='No data.'
@ -80,8 +73,14 @@ const AppContent = () => {
hasBorder={false}
/>
</div>
)}
>
)
const AppContent = () => {
const { themeConfig } = useThemeContext()
const Router = getRouter()
return (
<ConfigProvider theme={themeConfig} renderEmpty={renderEmpty}>
<App>
<Router>
<ElectronProvider>

View File

@ -14,7 +14,6 @@ import DeveloperSidebar from './Developer/DeveloperSidebar'
import DashboardSidebarSplitter from './common/DashboardSidebarSplitter'
import MobileFooterNavigation from './common/MobileFooterNavigation'
import { useThemeContext } from './context/ThemeContext'
import { MessageProvider } from './context/MessageContext'
import { useDashboardObjectToolsContext } from './context/DashboardObjectToolsContext'
import { useMediaQuery } from 'react-responsive'
import { ElectronContext } from './context/ElectronContext'
@ -52,7 +51,6 @@ const DashboardLayout = ({ children }) => {
)
return (
<MessageProvider>
<Layout
style={{ height: 'var(--unit-100vh)' }}
className={`${isDarkMode ? 'dark-mode' : 'light-mode'} main-layout${isMobile ? ' is-mobile' : ''}`}
@ -80,7 +78,6 @@ const DashboardLayout = ({ children }) => {
</DashboardSidebarSplitter>
{isMobile ? <MobileFooterNavigation /> : null}
</Layout>
</MessageProvider>
)
}

View File

@ -1,5 +1,5 @@
import PropTypes from 'prop-types'
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useLayoutEffect, useRef, useState, memo } from 'react'
import { Button, Flex, Popover, Typography } from 'antd'
import DashboardTabPreview from './DashboardTabPreview'
import classNames from 'classnames'
@ -66,7 +66,7 @@ DashboardTabLabel.propTypes = {
onClose: PropTypes.func
}
const DashboardTabItem = ({
const DashboardTabItem = memo(function DashboardTabItem({
tab,
tabIndex,
isSelected,
@ -78,9 +78,9 @@ const DashboardTabItem = ({
onKeyDown,
onDragOver,
onDrop,
onClose
}) => {
const { captureTabPreview } = useNavigationTabs()
onClose,
captureTabPreview
}) {
const {
src: previewSrc,
theme: previewTheme,
@ -174,7 +174,9 @@ const DashboardTabItem = ({
{tabButton}
</Popover>
)
}
})
DashboardTabItem.displayName = 'DashboardTabItem'
DashboardTabItem.propTypes = {
tab: PropTypes.shape({
@ -193,7 +195,8 @@ DashboardTabItem.propTypes = {
onKeyDown: PropTypes.func.isRequired,
onDragOver: PropTypes.func.isRequired,
onDrop: PropTypes.func.isRequired,
onClose: PropTypes.func
onClose: PropTypes.func,
captureTabPreview: PropTypes.func
}
const STICKY_EDGE_INSET = 20
@ -546,7 +549,8 @@ const DashboardTabs = () => {
reorderTabs,
handleTabDragStart,
handleTabDragEnd,
handleExternalTabDrop
handleExternalTabDrop,
captureTabPreview
} = useNavigationTabs()
const { rootRef, listRef, cloneRef, lineRef } = useActiveTabStickyScroll(
activeTabId,
@ -720,7 +724,8 @@ const DashboardTabs = () => {
onKeyDown: handleKeyDown,
onDragOver: handleItemDragOver,
onDrop: handleItemDrop,
onClose: closeTab
onClose: closeTab,
captureTabPreview
}
return (

View File

@ -1,11 +1,11 @@
import { Descriptions, Card, Flex, Divider, Skeleton } from 'antd'
import PropTypes from 'prop-types'
import ObjectProperty from './ObjectProperty'
import { createElement } from 'react'
import { createElement, memo } from 'react'
import Thumbnail from './Thumbnail'
import { LoadingOutlined } from '@ant-design/icons'
const ObjectCard = ({
const ObjectCard = memo(function ObjectCard({
isSkeleton = false,
model,
modelProperties,
@ -16,7 +16,7 @@ const ObjectCard = ({
renderActions,
lazyLoading = false,
cardStyle = 'borderless'
}) => {
}) {
const descriptionItems = []
const modelIcon = createElement(model.icon, { style: { fontSize: 24 } })
@ -169,7 +169,9 @@ const ObjectCard = ({
</Flex>
</Card>
)
}
})
ObjectCard.displayName = 'ObjectCard'
ObjectCard.propTypes = {
model: PropTypes.object.isRequired,

View File

@ -14,6 +14,7 @@ import PropTypes from 'prop-types'
import classNames from 'classnames'
import { ApiServerContext } from '../context/ApiServerContext'
import { AuthContext } from '../context/AuthContext'
import { useIsNavigationTabActive } from '../context/NavigationTabsContext'
import ObjectCard from './ObjectCard'
import ObjectKanbanColumn from './ObjectKanbanColumn'
import ObjectKanbanHeader from './ObjectKanbanHeader'
@ -287,6 +288,7 @@ const ObjectKanban = forwardRef(
subscribeToObjectTypeUpdates
} = useContext(ApiServerContext)
const { token } = useContext(AuthContext)
const isTabActive = useIsNavigationTabActive()
const columnRefs = useRef({})
const headerTrackRef = useRef(null)
const skeletonTrackRef = useRef(null)
@ -720,7 +722,7 @@ const ObjectKanban = forwardRef(
}, [connected, token])
useEffect(() => {
if (connected !== true || !type || !token) return
if (connected !== true || !type || !token || !isTabActive) return
const unsubscribe = subscribeToAllObjectUpdates(
type,
@ -735,10 +737,10 @@ const ObjectKanban = forwardRef(
subscribeToAllObjectUpdatesRef.current = null
}
}
}, [type, connected, subscribeToAllObjectUpdates, token])
}, [type, connected, subscribeToAllObjectUpdates, token, isTabActive])
useEffect(() => {
if (connected !== true || !token) return
if (connected !== true || !token || !isTabActive) return
if (subscribedTypeRef.current === type) return
const unsubscribe = subscribeToObjectTypeUpdatesFnRef.current(
@ -757,7 +759,7 @@ const ObjectKanban = forwardRef(
subscribedTypeRef.current = null
}
}
}, [type, connected, token])
}, [type, connected, token, isTabActive])
useImperativeHandle(
ref,

View File

@ -1,5 +1,6 @@
import {
forwardRef,
memo,
useImperativeHandle,
useRef,
useEffect,
@ -45,6 +46,7 @@ import QuestionCircleIcon from '../../Icons/QuestionCircleIcon'
import { AuthContext } from '../context/AuthContext'
import { ElectronContext } from '../context/ElectronContext'
import { useActions } from '../context/ActionsContext'
import { useIsNavigationTabActive } from '../context/NavigationTabsContext'
import ActionsIcon from '../../Icons/ActionsIcon'
import FilterIcon from '../../Icons/FilterIcon'
import ScrollBox from './ScrollBox'
@ -80,6 +82,7 @@ logger.setLevel(config.logLevel)
const SCROLL_THRESHOLD = 50
const SKELETON_HEIGHT = 49.5
const EMPTY_MASTER_FILTER = {}
const EMPTY_VISIBLE_COLUMNS = {}
const getCardColSpan = (containerWidth) => {
if (containerWidth >= 2980) return 2
@ -291,7 +294,8 @@ EditableRow.propTypes = {
onRegister: PropTypes.func
}
const ObjectTable = forwardRef(
const ObjectTable = memo(
forwardRef(
(
{
type,
@ -301,7 +305,7 @@ const ObjectTable = forwardRef(
initialPage = 1,
viewMode: viewModeProp,
cards = false,
visibleColumns = {},
visibleColumns = EMPTY_VISIBLE_COLUMNS,
masterFilter,
size = 'middle',
onStateChange,
@ -335,6 +339,7 @@ const ObjectTable = forwardRef(
const { token, userProfile } = useContext(AuthContext)
const { isElectron } = useContext(ElectronContext)
const { callAction } = useActions()
const isTabActive = useIsNavigationTabActive()
const resolvedMasterFilter = masterFilter ?? EMPTY_MASTER_FILTER
const listViewId = objectListView?.listViewId ?? null
const listViewFilter = objectListView?.listViewFilter ?? null
@ -517,8 +522,68 @@ const ObjectTable = forwardRef(
return {}
}, [])
const rowActions =
model.actions?.filter((action) => action.row == true) || []
const rowActions = useMemo(
() => model.actions?.filter((action) => action.row == true) || [],
[model]
)
const renderActions = useCallback(
(objectData, actionsDisabled = false) => {
return (
<Flex gap='small' align='center' justify='center'>
{rowActions.map((action, index) => {
const denied = !hasActionPermission(userProfile, model, action.name)
var disabled = denied
if (action.disabled) {
if (typeof action.disabled === 'function') {
disabled =
denied ||
action.disabled({
...objectData,
_user: userProfile
}) ||
actionsDisabled
} else {
disabled = denied || action.disabled || actionsDisabled
}
}
return (
<Tooltip key={index} title={action.label}>
<Button
icon={
action.icon ? (
createElement(action.icon)
) : (
<QuestionCircleIcon />
)
}
disabled={disabled || objectData?.isSkeleton}
type={'text'}
size={'small'}
danger={action?.danger || false}
onClick={() => {
if (denied) return
if (
onRowAction &&
onRowAction(action, objectData) !== false
) {
return
}
callAction(
action,
{ ...objectData, _user: userProfile },
type
)
}}
/>
</Tooltip>
)
})}
</Flex>
)
},
[callAction, model, onRowAction, rowActions, type, userProfile]
)
const createSkeletonData = useCallback(
(pageNum) => {
@ -602,61 +667,6 @@ const ObjectTable = forwardRef(
)
}, [])
const renderActions = (objectData, actionsDisabled = false) => {
return (
<Flex gap='small' align='center' justify='center'>
{rowActions.map((action, index) => {
const denied = !hasActionPermission(userProfile, model, action.name)
var disabled = denied
if (action.disabled) {
if (typeof action.disabled === 'function') {
disabled =
denied ||
action.disabled({
...objectData,
_user: userProfile
}) ||
actionsDisabled
} else {
disabled = denied || action.disabled || actionsDisabled
}
}
return (
<Tooltip key={index} title={action.label}>
<Button
icon={
action.icon ? (
createElement(action.icon)
) : (
<QuestionCircleIcon />
)
}
disabled={disabled || objectData?.isSkeleton}
type={'text'}
size={'small'}
danger={action?.danger || false}
onClick={() => {
if (denied) return
if (
onRowAction &&
onRowAction(action, objectData) !== false
) {
return
}
callAction(
action,
{ ...objectData, _user: userProfile },
type
)
}}
/>
</Tooltip>
)
})}
</Flex>
)
}
const fetchPage = useCallback(
async (pageNum = 1, filter = null, sorter = null) => {
if (filter == null) {
@ -1254,7 +1264,7 @@ const ObjectTable = forwardRef(
// Subscribe to all object updates for this type (list/cards only)
useEffect(() => {
if (isKanban || connected !== true || !type) return
if (isKanban || connected !== true || !type || !isTabActive) return
const unsubscribe = subscribeToAllObjectUpdates(
type,
@ -1269,10 +1279,10 @@ const ObjectTable = forwardRef(
subscribeToAllObjectUpdatesRef.current = null
}
}
}, [type, connected, subscribeToAllObjectUpdates, isKanban])
}, [type, connected, subscribeToAllObjectUpdates, isKanban, isTabActive])
useEffect(() => {
if (isKanban || connected !== true) return
if (isKanban || connected !== true || !isTabActive) return
if (subscribedTypeRef.current === type) return
const unsubscribe = subscribeToObjectTypeUpdatesFnRef.current(
@ -1291,7 +1301,7 @@ const ObjectTable = forwardRef(
subscribedTypeRef.current = null
}
}
}, [type, connected, isKanban])
}, [type, connected, isKanban, isTabActive])
const updateData = useCallback(
(id, updatedData) => {
@ -1706,7 +1716,8 @@ const ObjectTable = forwardRef(
return () => registerPageSorter({})
}, [effectiveSorter, registerPageSorter, registerObjectListSorter])
const getFilterDropdown = ({
const getFilterDropdown = useCallback(
({
setSelectedKeys,
selectedKeys,
confirm,
@ -1727,6 +1738,8 @@ const ObjectTable = forwardRef(
filter={effectiveFilter}
masterFilter={resolvedMasterFilter}
/>
),
[effectiveFilter, resolvedMasterFilter, type]
)
const handleTableChange = (pagination, filters, sorter) => {
@ -1858,9 +1871,47 @@ const ObjectTable = forwardRef(
]
)
const modelProperties = getModelProperties(type)
// Table columns from model properties
const columnsWithSkeleton = [
const modelProperties = useMemo(() => getModelProperties(type), [type])
useEffect(() => {
pagesRef.current = pages
}, [pages])
useEffect(() => {
if (!expandHeight || isCards || isKanban) return
const findAntTableBody = (element) => {
if (!element) return null
if (element.classList?.contains('ant-table-body')) return element
for (const child of element.children) {
const found = findAntTableBody(child)
if (found) return found
}
return null
}
const root = tableRef.current?.nativeElement ?? tableRef.current
const tableBody = findAntTableBody(root)
if (!tableBody) return
tableBody.style.minHeight = adjustedScrollHeight
return () => {
tableBody.style.minHeight = ''
}
}, [
expandHeight,
adjustedScrollHeight,
isCards,
isKanban,
loading,
tableData
])
const hasRealTableRows = tableData.some((item) => !item?.isSkeleton)
const columnsWithSkeleton = useMemo(() => {
const columns = [
{
title:
loading || lazyLoading ? (
@ -1919,51 +1970,14 @@ const ObjectTable = forwardRef(
}
]
useEffect(() => {
pagesRef.current = pages
}, [pages])
useEffect(() => {
if (!expandHeight || isCards || isKanban) return
const findAntTableBody = (element) => {
if (!element) return null
if (element.classList?.contains('ant-table-body')) return element
for (const child of element.children) {
const found = findAntTableBody(child)
if (found) return found
}
return null
}
const root = tableRef.current?.nativeElement ?? tableRef.current
const tableBody = findAntTableBody(root)
if (!tableBody) return
tableBody.style.minHeight = adjustedScrollHeight
return () => {
tableBody.style.minHeight = ''
}
}, [
expandHeight,
adjustedScrollHeight,
isCards,
isKanban,
loading,
tableData
])
// Add columns in the order specified by model.columns
model.columns.forEach((colName) => {
const prop = modelProperties.find((p) => p.name === colName)
if (prop) {
// Check if column should be visible based on visibleColumns prop
if (
Object.keys(visibleColumns).length > 0 &&
visibleColumns[prop.name] === false
) {
return // Skip this column if it's not visible
return
}
var fixed = prop.columnFixed || undefined
@ -1988,10 +2002,8 @@ const ObjectTable = forwardRef(
default:
break
}
// Check if this property should be filterable based on model.filters
const isFilterable = model.filters && model.filters.includes(prop.name)
// Check if this property should be sortable based on model.sorters
const isFilterable = model.filters && model.filters.includes(prop.name)
const isSortable = model.sorters && model.sorters.includes(prop.name)
const columnConfig = {
@ -2056,16 +2068,12 @@ const ObjectTable = forwardRef(
)
}
columnsWithSkeleton.push(columnConfig)
columns.push(columnConfig)
}
})
if (
showActions &&
rowActions.length > 0 &&
tableData.some((item) => !item?.isSkeleton)
) {
columnsWithSkeleton.push({
if (showActions && rowActions.length > 0 && hasRealTableRows) {
columns.push({
title: (
<Flex gap='small' align='center' justify='center'>
<ActionsIcon />
@ -2073,13 +2081,39 @@ const ObjectTable = forwardRef(
),
key: 'actions',
fixed: 'right',
width: 20 + rowActions.length * 30, // Adjust width based on number of actions
width: 20 + rowActions.length * 30,
render: (record) => {
return renderActions(record)
}
})
}
return columns
}, [
bulkSelection,
canBulkSelect,
effectiveFilter,
getFilterDropdown,
hasRealTableRows,
isCards,
isEditing,
isMobile,
lazyLoading,
loading,
model,
modelProperties,
renderActions,
resolvedMasterFilter,
rowActions,
selectedRowIdSet,
selectedRowIds,
showActions,
tableSorter,
toggleRowSelection,
toggleSelectAll,
visibleColumns
])
// Card view rendering
const [cardColSpan, setCardColSpan] = useState(24)
const cardsContainerNodeRef = useRef(null)
@ -2466,6 +2500,7 @@ const ObjectTable = forwardRef(
)
}
)
)
ObjectTable.displayName = 'ObjectTable'

View File

@ -4,6 +4,7 @@ import {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState
} from 'react'
@ -26,6 +27,8 @@ const ActionsContext = createContext()
const ActionsProvider = ({ children }) => {
const navigate = useNavigate()
const location = useLocation()
const locationRef = useRef(location)
locationRef.current = location
const { userProfile } = useContext(AuthContext)
const [currentObject, setCurrentObject] = useState(null)
const [currentObjectType, setCurrentObjectType] = useState(null)
@ -38,13 +41,14 @@ const ActionsProvider = ({ children }) => {
const actionObjectType = searchParams.get('actionObjectType')
const clearAction = useCallback(() => {
const nextUrl = stripModalActionParams(location.pathname, location.search)
if (nextUrl !== location.pathname + location.search) {
const loc = locationRef.current
const nextUrl = stripModalActionParams(loc.pathname, loc.search)
if (nextUrl !== loc.pathname + loc.search) {
navigate(nextUrl, { replace: true })
}
setModalAction(null)
lastHandledAction.current = null
}, [location.pathname, location.search, navigate])
}, [navigate])
const handleModalOk = useCallback(() => {
onModalOk?.()
@ -56,16 +60,17 @@ const ActionsProvider = ({ children }) => {
setCurrentObject(objectData)
setCurrentObjectType(objectType)
const model = getModelByName(objectType)
const loc = locationRef.current
const url = buildActionUrl(
model,
action,
objectData._id,
location.pathname,
location.search
loc.pathname,
loc.search
)
navigate(url)
},
[location.pathname, location.search, navigate]
[navigate]
)
useEffect(() => {
@ -187,9 +192,8 @@ const ActionsProvider = ({ children }) => {
? {}
: modalObjectData
return (
<ActionsContext.Provider
value={{
const value = useMemo(
() => ({
currentObject,
currentObjectType,
setCurrentObject,
@ -197,8 +201,12 @@ const ActionsProvider = ({ children }) => {
callAction,
clearAction,
setOnModalOk
}}
>
}),
[callAction, clearAction, currentObject, currentObjectType]
)
return (
<ActionsContext.Provider value={value}>
<Modal
open={resolvedModalAction != null}
destroyOnHidden={true}

View File

@ -5,7 +5,8 @@ import {
useState,
useContext,
useRef,
useCallback
useCallback,
useMemo
} from 'react'
import io from 'socket.io-client'
import { message, Modal, Space, Button, Typography, Flex } from 'antd'
@ -133,10 +134,244 @@ const getObjectTypeSubscriptionArgs = (filterOrCallback, callback) => {
const getObjectEndpoint = (type) =>
getModelByName(type)?.endpoint || `${type.toLowerCase()}s`
const formatFileName = (name) => {
if (!name || typeof name !== 'string') {
return ''
}
const cleaned = name.replace(/[^a-zA-Z0-9.\-_\s]/g, '')
const normalized = cleaned.trim().replace(/\s+/g, '_')
return normalized.slice(0, 255)
}
const API_METHOD_KEYS = [
'getUserSettings',
'updateUserSettings',
'setObjectActivity',
'clearObjectActivity',
'fetchObjectActivities',
'updateObject',
'updateMultipleObjects',
'createObject',
'getObjectFunction',
'sendObjectFunction',
'deleteObject',
'deleteObjects',
'subscribeToObjectUpdates',
'subscribeToAllObjectUpdates',
'subscribeToObjectEvent',
'subscribeToObjectTypeUpdates',
'subscribeToObjectActivity',
'subscribeToModelStats',
'fetchObject',
'fetchObjects',
'getObjectNeighbors',
'fetchObjectsByProperty',
'searchObjects',
'fetchSpotlightData',
'getModelStats',
'getModelPropertyValues',
'getModelHistory',
'showError',
'fetchFileContent',
'fetchFileThumbnail',
'exportToExcel',
'exportToCsv',
'fetchTemplatePreview',
'fetchTemplateIntellisense',
'fetchTemplateFormat',
'fetchTemplatePDF',
'fetchTemplateDownload',
'fetchNotes',
'downloadTemplate',
'downloadTemplatePDF',
'fetchHostOTP',
'sendObjectAction',
'uploadFile',
'flushFile',
'formatFileName',
'createUserNotifier',
'deleteUserNotifier',
'fetchUserNotifiersForObject',
'fetchAllUserNotifiersForObject',
'toggleUserNotifier',
'editUserNotifier',
'fetchObjectViews',
'createObjectView',
'updateObjectView',
'deleteObjectView',
'fetchNotificationsApi',
'markNotificationAsReadApi',
'markAllNotificationsAsReadApi',
'deleteNotificationApi',
'deleteAllNotificationsApi',
'registerNotificationListener',
'unregisterNotificationListener',
'getMarketplaceAuthUrl',
'refreshMarketplaceAuth',
'completeAppLaunchSession',
'getAppLaunchSession',
'fetchAppUpdateBranches',
'fetchAppUpdateCurrent',
'fetchWsServerVersion',
'fetchApiServerVersion'
]
const createStableApiMethods = (methodsRef) => {
const bound = {}
for (const key of API_METHOD_KEYS) {
bound[key] = (...args) => methodsRef.current[key]?.(...args)
}
return bound
}
const LaunchSessionCompleter = ({ completeAppLaunchSession }) => {
const location = useLocation()
const navigate = useNavigate()
const { token, authenticated } = useContext(AuthContext)
const completedLaunchSessionsRef = useRef(new Set())
useEffect(() => {
const launchSession = new URLSearchParams(location.search).get(
'launchSession'
)
if (
authenticated !== true ||
!token ||
!launchSession ||
completedLaunchSessionsRef.current.has(launchSession)
) {
return
}
completedLaunchSessionsRef.current.add(launchSession)
completeAppLaunchSession(launchSession)
.then(() => {
const searchParams = new URLSearchParams(location.search)
searchParams.delete('launchSession')
const newSearch = searchParams.toString()
const newPath = location.pathname + (newSearch ? `?${newSearch}` : '')
navigate(newPath, { replace: true })
})
.catch((err) => {
logger.error('Failed to complete app launch session:', err)
completedLaunchSessionsRef.current.delete(launchSession)
})
}, [
token,
authenticated,
completeAppLaunchSession,
location.search,
location.pathname,
navigate
])
return null
}
LaunchSessionCompleter.propTypes = {
completeAppLaunchSession: PropTypes.func.isRequired
}
const ConnectionIssueModal = ({
open,
delayMs,
cycle,
isReconnecting,
onReconnect
}) => {
const [reconnectRemainingMs, setReconnectRemainingMs] = useState(delayMs)
const [reconnectProgress, setReconnectProgress] = useState(0)
useEffect(() => {
if (!open || isReconnecting) return undefined
const startedAt = Date.now()
setReconnectRemainingMs(delayMs)
setReconnectProgress(0)
const intervalId = setInterval(() => {
const elapsed = Date.now() - startedAt
const remaining = Math.max(0, delayMs - elapsed)
setReconnectRemainingMs(remaining)
setReconnectProgress(Math.min(100, (elapsed / delayMs) * 100))
}, 100)
return () => clearInterval(intervalId)
}, [open, isReconnecting, delayMs, cycle])
const reconnectSecondsRemaining = Math.max(
1,
Math.ceil(reconnectRemainingMs / 1000)
)
return (
<Modal
title={
!isReconnecting ? (
<Space size={'middle'}>
<ExclamationOctagonIcon />
Connection Lost
</Space>
) : (
false
)
}
open={open}
style={{ maxWidth: !isReconnecting ? 480 : 260 }}
zIndex={3000}
closable={false}
height={isReconnecting ? 20 : undefined}
centered
className={isReconnecting ? 'loading-modal' : undefined}
maskClosable={false}
getContainer={() => document.body}
footer={
!isReconnecting
? [
<Button key='reconnect' loading={isReconnecting} onClick={onReconnect}>
Reconnect
</Button>
]
: false
}
>
{!isReconnecting ? (
<Flex vertical gap='middle'>
<Text>
{`Lost connection to the API server. Reconnecting in ${reconnectSecondsRemaining} second${
reconnectSecondsRemaining === 1 ? '' : 's'
}...`}
</Text>
<ProgressDisplay
percent={100 - reconnectProgress}
showInfo={false}
status={'exception'}
/>
</Flex>
) : (
<Space size={'middle'}>
<LoadingOutlined />
<Text style={{ margin: 0 }}>Reconnecting, please wait...</Text>
</Space>
)}
</Modal>
)
}
ConnectionIssueModal.propTypes = {
open: PropTypes.bool,
delayMs: PropTypes.number,
cycle: PropTypes.number,
isReconnecting: PropTypes.bool,
onReconnect: PropTypes.func
}
const ApiServerContext = createContext()
const ApiServerProvider = ({ children }) => {
const location = useLocation()
const navigate = useNavigate()
const {
token,
@ -162,16 +397,12 @@ const ApiServerProvider = ({ children }) => {
const subscribedActivityCallbacksRef = useRef(new Map())
const subscribedActivityServerSubscriptionsRef = useRef(new Set())
const notificationListenersRef = useRef(new Set())
const completedLaunchSessionsRef = useRef(new Set())
const [connectionIssue, setConnectionIssue] = useState(false)
const [reconnectProgress, setReconnectProgress] = useState(0)
const [reconnectRemainingMs, setReconnectRemainingMs] = useState(
RECONNECT_DELAYS_MS[0]
)
const [reconnectDelayMs, setReconnectDelayMs] = useState(RECONNECT_DELAYS_MS[0])
const [reconnectCycle, setReconnectCycle] = useState(0)
const [isReconnecting, setIsReconnecting] = useState(false)
const reconnectAttemptRef = useRef(0)
const reconnectTimerRef = useRef(null)
const reconnectProgressTimerRef = useRef(null)
const reconnectScheduledRef = useRef(false)
const isReconnectingRef = useRef(false)
const hasConnectedOnceRef = useRef(false)
@ -349,10 +580,6 @@ const ApiServerProvider = ({ children }) => {
clearTimeout(reconnectTimerRef.current)
reconnectTimerRef.current = null
}
if (reconnectProgressTimerRef.current) {
clearInterval(reconnectProgressTimerRef.current)
reconnectProgressTimerRef.current = null
}
}, [])
const resetReconnectState = useCallback(() => {
@ -361,25 +588,16 @@ const ApiServerProvider = ({ children }) => {
reconnectScheduledRef.current = false
isReconnectingRef.current = false
setIsReconnecting(false)
setReconnectProgress(0)
setReconnectRemainingMs(RECONNECT_DELAYS_MS[0])
setReconnectDelayMs(RECONNECT_DELAYS_MS[0])
}, [clearReconnectTimers])
const startReconnectCountdown = useCallback(
(delayMs) => {
clearReconnectTimers()
const startedAt = Date.now()
setReconnectRemainingMs(delayMs)
setReconnectProgress(0)
setReconnectDelayMs(delayMs)
setReconnectCycle((current) => current + 1)
setIsReconnecting(false)
reconnectProgressTimerRef.current = setInterval(() => {
const elapsed = Date.now() - startedAt
const remaining = Math.max(0, delayMs - elapsed)
setReconnectRemainingMs(remaining)
setReconnectProgress(Math.min(100, (elapsed / delayMs) * 100))
}, 100)
reconnectTimerRef.current = setTimeout(() => {
attemptReconnectRef.current?.()
}, delayMs)
@ -429,7 +647,6 @@ const ApiServerProvider = ({ children }) => {
isReconnectingRef.current = true
reconnectAttemptRef.current += 1
setIsReconnecting(true)
setReconnectProgress(100)
setConnecting(true)
setConnectionIssue(true)
@ -2769,43 +2986,6 @@ const ApiServerProvider = ({ children }) => {
[token]
)
useEffect(() => {
const launchSession = new URLSearchParams(location.search).get(
'launchSession'
)
if (
authenticated !== true ||
!token ||
!launchSession ||
completedLaunchSessionsRef.current.has(launchSession)
) {
return
}
completedLaunchSessionsRef.current.add(launchSession)
completeAppLaunchSession(launchSession)
.then(() => {
const searchParams = new URLSearchParams(location.search)
searchParams.delete('launchSession')
const newSearch = searchParams.toString()
const newPath = location.pathname + (newSearch ? `?${newSearch}` : '')
navigate(newPath, { replace: true })
})
.catch((err) => {
logger.error('Failed to complete app launch session:', err)
completedLaunchSessionsRef.current.delete(launchSession)
})
}, [
token,
authenticated,
completeAppLaunchSession,
location.search,
location.pathname,
navigate
])
const getAppLaunchSession = useCallback(async (launchSession) => {
const response = await axios.get(
`${config.backendUrl}/applaunch/${launchSession}`,
@ -2924,36 +3104,8 @@ const ApiServerProvider = ({ children }) => {
}
}
const reconnectSecondsRemaining = Math.max(
1,
Math.ceil(reconnectRemainingMs / 1000)
)
// Sanitize a string so it is safe to use as a filename on most file systems
const formatFileName = (name) => {
if (!name || typeof name !== 'string') {
return ''
}
// Remove characters that are problematic on most common file systems
const cleaned = name.replace(/[^a-zA-Z0-9.\-_\s]/g, '')
// Normalize whitespace to single underscores
const normalized = cleaned.trim().replace(/\s+/g, '_')
// Most file systems limit filenames to 255 characters
return normalized.slice(0, 255)
}
return (
<ApiServerContext.Provider
value={{
apiServer: socketRef.current,
error,
connecting,
connected,
userSettings,
userSettingsLoaded,
const apiMethodsRef = useRef({})
apiMethodsRef.current = {
getUserSettings,
updateUserSettings,
setObjectActivity,
@ -2981,7 +3133,6 @@ const ApiServerProvider = ({ children }) => {
getModelStats,
getModelPropertyValues,
getModelHistory,
fetchLoading,
showError,
fetchFileContent,
fetchFileThumbnail,
@ -3025,66 +3176,48 @@ const ApiServerProvider = ({ children }) => {
fetchAppUpdateCurrent,
fetchWsServerVersion,
fetchApiServerVersion
}}
>
{contextHolder}
{children}
<Modal
title={
!isReconnecting ? (
<Space size={'middle'}>
<ExclamationOctagonIcon />
Connection Lost
</Space>
) : (
false
)
}
open={Boolean(token) && authenticated == true && connectionIssue}
style={{ maxWidth: !isReconnecting ? 480 : 260 }}
zIndex={3000}
closable={false}
height={isReconnecting ? 20 : undefined}
centered
className={isReconnecting ? 'loading-modal' : undefined}
maskClosable={false}
getContainer={() => document.body}
footer={
!isReconnecting
? [
<Button
key='reconnect'
loading={isReconnecting}
onClick={() => attemptReconnect()}
>
Reconnect
</Button>
const stableApiRef = useRef(null)
if (stableApiRef.current == null) {
stableApiRef.current = createStableApiMethods(apiMethodsRef)
}
const value = useMemo(
() => ({
...stableApiRef.current,
apiServer: socketRef.current,
error,
connecting,
connected,
userSettings,
userSettingsLoaded,
fetchLoading
}),
[
connecting,
connected,
error,
fetchLoading,
userSettings,
userSettingsLoaded
]
: false
}
>
{!isReconnecting ? (
<Flex vertical gap='middle'>
<Text>
{isReconnecting
? 'Reconnecting to the API server...'
: `Lost connection to the API server. Reconnecting in ${reconnectSecondsRemaining} second${
reconnectSecondsRemaining === 1 ? '' : 's'
}...`}
</Text>
<ProgressDisplay
percent={isReconnecting ? 0 : 100 - reconnectProgress}
showInfo={false}
status={'exception'}
)
return (
<ApiServerContext.Provider value={value}>
{contextHolder}
<LaunchSessionCompleter
completeAppLaunchSession={completeAppLaunchSession}
/>
{children}
<ConnectionIssueModal
open={Boolean(token) && authenticated == true && connectionIssue}
delayMs={reconnectDelayMs}
cycle={reconnectCycle}
isReconnecting={isReconnecting}
onReconnect={attemptReconnect}
/>
</Flex>
) : (
<Space size={'middle'}>
<LoadingOutlined />
<Text style={{ margin: 0 }}>Reconnecting, please wait...</Text>
</Space>
)}
</Modal>
<Modal
title={
<Space size={'middle'}>

View File

@ -5,7 +5,8 @@ import {
useCallback,
useEffect,
useContext,
useRef
useRef,
useMemo
} from 'react'
import axios from 'axios'
import {
@ -112,6 +113,8 @@ const AuthProvider = ({ children }) => {
} = useContext(ElectronContext)
const location = useLocation()
const navigate = useNavigate()
const locationRef = useRef(location)
locationRef.current = location
const sessionRef = useRef({
token,
expiresAt,
@ -432,9 +435,11 @@ const AuthProvider = ({ children }) => {
// Login using query parameters
const loginWithSSO = useCallback(
(redirectUri = location.pathname + location.search) => {
(redirectUri) => {
const loc = locationRef.current
const nextRedirectUri = redirectUri || loc.pathname + loc.search
messageApi.info('Logging in with tombutcher.work')
const loginUrl = `${config.backendUrl}/auth/${redirectType}/login?redirect_uri=${encodeURIComponent(redirectUri)}`
const loginUrl = `${config.backendUrl}/auth/${redirectType}/login?redirect_uri=${encodeURIComponent(nextRedirectUri)}`
if (isElectron) {
logger.debug('Opening external url...')
openExternalUrl(loginUrl)
@ -444,14 +449,7 @@ const AuthProvider = ({ children }) => {
window.location.href = loginUrl
}
},
[
redirectType,
messageApi,
openExternalUrl,
isElectron,
location.search,
location.pathname
]
[redirectType, messageApi, openExternalUrl, isElectron]
)
const getLoginToken = useCallback(
@ -461,7 +459,6 @@ const AuthProvider = ({ children }) => {
setShowSessionExpiredModal(false)
setAuthError(null)
try {
// Make a call to your backend to check auth status
const response = await axios.get(
`${config.backendUrl}/auth/${redirectType}/token?code=${code}`
)
@ -480,7 +477,6 @@ const AuthProvider = ({ children }) => {
setAuthenticated(true)
setShowUnauthorizedModal(false)
// Persist session (cookies on web, electron storage on desktop)
const persisted = await persistSession({
token: nextToken,
expiresAt: nextExpiresAt,
@ -492,10 +488,11 @@ const AuthProvider = ({ children }) => {
)
}
const searchParams = new URLSearchParams(location.search)
const loc = locationRef.current
const searchParams = new URLSearchParams(loc.search)
searchParams.delete('authCode')
const newSearch = searchParams.toString()
const newPath = location.pathname + (newSearch ? `?${newSearch}` : '')
const newPath = loc.pathname + (newSearch ? `?${newSearch}` : '')
navigate(newPath, { replace: true })
} else {
setAuthenticated(false)
@ -520,14 +517,7 @@ const AuthProvider = ({ children }) => {
setRetreivedTokenFromCookies(true)
}
},
[
navigate,
location.search,
location.pathname,
messageApi,
persistSession,
redirectType
]
[navigate, messageApi, persistSession, redirectType]
)
// Function to check if the user is logged in
@ -805,12 +795,8 @@ const AuthProvider = ({ children }) => {
retreivedTokenFromCookies
])
return (
<>
{contextHolder}
{notificationContextHolder}
<AuthContext.Provider
value={{
const value = useMemo(
() => ({
authenticated,
authInitialized: retreivedTokenFromCookies,
setUnauthenticated,
@ -822,8 +808,26 @@ const AuthProvider = ({ children }) => {
setUserProfile,
profileImageUrl,
logout
}}
>
}),
[
authenticated,
getLoginToken,
loading,
loginWithSSO,
logout,
profileImageUrl,
retreivedTokenFromCookies,
setUnauthenticated,
token,
userProfile
]
)
return (
<>
{contextHolder}
{notificationContextHolder}
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
<Modal

View File

@ -3,6 +3,7 @@ import {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState
} from 'react'
@ -28,14 +29,17 @@ export const DashboardObjectToolsProvider = ({ children }) => {
setCurrentObjectToolsState(null)
}, [])
return (
<DashboardObjectToolsContext.Provider
value={{
const value = useMemo(
() => ({
currentObjectTools,
setCurrentObjectTools,
clearCurrentObjectTools
}}
>
}),
[clearCurrentObjectTools, currentObjectTools, setCurrentObjectTools]
)
return (
<DashboardObjectToolsContext.Provider value={value}>
{children}
</DashboardObjectToolsContext.Provider>
)

View File

@ -1,4 +1,4 @@
import { createContext, useContext } from 'react'
import { createContext, useCallback, useContext, useMemo } from 'react'
import PropTypes from 'prop-types'
import { message } from 'antd'
import { isElectrobunDesktop } from '../../../electrobun-bridge'
@ -12,36 +12,52 @@ const MESSAGE_TOP = isElectrobunDesktop() ? 48 : undefined
export const MessageProvider = ({ children }) => {
const [msgApi, contextHolder] = message.useMessage({ top: MESSAGE_TOP })
const showMessage = (type, content, options = {}) => {
const showMessage = useCallback(
(type, content, options = {}) => {
return msgApi.open({
type,
content,
...options
})
}
},
[msgApi]
)
const showSuccess = (content, options = {}) =>
showMessage('success', content, options)
const showInfo = (content, options = {}) =>
showMessage('info', content, options)
const showWarning = (content, options = {}) =>
showMessage('warning', content, options)
const showError = (content, options = {}) =>
showMessage('error', content, options)
const showLoading = (content, options = {}) =>
showMessage('loading', content, options)
const showSuccess = useCallback(
(content, options = {}) => showMessage('success', content, options),
[showMessage]
)
const showInfo = useCallback(
(content, options = {}) => showMessage('info', content, options),
[showMessage]
)
const showWarning = useCallback(
(content, options = {}) => showMessage('warning', content, options),
[showMessage]
)
const showError = useCallback(
(content, options = {}) => showMessage('error', content, options),
[showMessage]
)
const showLoading = useCallback(
(content, options = {}) => showMessage('loading', content, options),
[showMessage]
)
return (
<MessageContext.Provider
value={{
const value = useMemo(
() => ({
msgApi,
showSuccess,
showInfo,
showWarning,
showError,
showLoading
}}
>
}),
[msgApi, showError, showInfo, showLoading, showSuccess, showWarning]
)
return (
<MessageContext.Provider value={value}>
{contextHolder}
{children}
</MessageContext.Provider>

View File

@ -183,7 +183,6 @@ export const NavigationTabsProvider = ({ children }) => {
const tabsRef = useRef(tabs)
const activeTabIdRef = useRef(activeTabId)
const locationRef = useRef(location)
const previousActiveTabIdRef = useRef(null)
const isRestoringRef = useRef(null)
const tabPaneElsRef = useRef(new Map())
const captureQueueRef = useRef(Promise.resolve())
@ -471,16 +470,6 @@ export const NavigationTabsProvider = ({ children }) => {
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, iconKey, location: pageLocation } = {}) => {
const activeId = activeTabIdRef.current

View File

@ -4,6 +4,7 @@ import {
useContext,
useCallback,
useEffect,
useMemo,
useRef
} from 'react'
import { useLocation } from 'react-router-dom'
@ -121,7 +122,14 @@ const NotificationProvider = ({ children }) => {
const unreadCount = notifications.filter((n) => !n.read).length
// Initial load / when we become authenticated and connected
const closeNotificationCenter = useCallback(() => {
setNotificationCenterVisible(false)
}, [])
useEffect(() => {
closeNotificationCenter()
}, [location.pathname, closeNotificationCenter])
useEffect(() => {
if (!authenticated || !connected) return
fetchNotificationsRef.current()
@ -134,10 +142,6 @@ const NotificationProvider = ({ children }) => {
fetchNotificationsRef.current()
}, [notificationCenterVisible])
useEffect(() => {
setNotificationCenterVisible(false)
}, [location.pathname])
useEffect(() => {
if (!authenticated || !registerNotificationListener) return
const handleNotification = (notif) => {
@ -169,9 +173,8 @@ const NotificationProvider = ({ children }) => {
return unregister
}, [authenticated, registerNotificationListener, api, deleteNotification])
return (
<NotificationContext.Provider
value={{
const value = useMemo(
() => ({
notificationCenterVisible,
toggleNotificationCenter,
notifications,
@ -182,15 +185,30 @@ const NotificationProvider = ({ children }) => {
deleteAllNotifications,
unreadCount,
notificationsLoading
}}
>
}),
[
deleteAllNotifications,
deleteNotification,
fetchNotifications,
markAllNotificationsAsRead,
markNotificationAsRead,
notificationCenterVisible,
notifications,
notificationsLoading,
toggleNotificationCenter,
unreadCount
]
)
return (
<NotificationContext.Provider value={value}>
{contextHolder}
{children}
<Drawer
title='Notifications'
placement='right'
width={isMobile ? '100%' : 460}
onClose={() => setNotificationCenterVisible(false)}
onClose={closeNotificationCenter}
open={notificationCenterVisible}
>
<NotificationCenter

View File

@ -2,6 +2,7 @@ import {
createContext,
useCallback,
useContext,
useMemo,
useState,
useEffect
} from 'react'
@ -88,24 +89,27 @@ export const ThemeProvider = ({ children }) => {
)
}, [showNavigationLabels])
const toggleTheme = () => {
if (isSystem) {
setIsSystem(false)
}
setIsDarkMode(!isDarkMode)
}
const toggleTheme = useCallback(() => {
setIsSystem((current) => {
if (current) return false
return current
})
setIsDarkMode((current) => !current)
}, [])
const toggleSystem = () => {
setIsSystem(!isSystem)
if (!isSystem) {
// When enabling system theme, update to match system preference
const toggleSystem = useCallback(() => {
setIsSystem((current) => {
const next = !current
if (next) {
setIsDarkMode(window.matchMedia('(prefers-color-scheme: dark)').matches)
}
}
return next
})
}, [])
const toggleCompact = () => {
setIsCompact(!isCompact)
}
const toggleCompact = useCallback(() => {
setIsCompact((current) => !current)
}, [])
const setThemeMode = useCallback((value) => {
if (value === 'system') {
@ -126,19 +130,63 @@ export const ThemeProvider = ({ children }) => {
setShowNavigationLabelsState(Boolean(value))
}, [])
const getThemeAlgorithm = () => {
var baseAlgorithm
if (isDarkMode == true) {
baseAlgorithm = theme.darkAlgorithm
} else {
baseAlgorithm = theme.defaultAlgorithm
}
return isCompact ? [theme.compactAlgorithm, baseAlgorithm] : [baseAlgorithm]
}
const getColors = useCallback(() => COLORS, [])
const getColors = () => {
return COLORS
const themeConfig = useMemo(() => {
const baseAlgorithm = isDarkMode
? theme.darkAlgorithm
: theme.defaultAlgorithm
return {
algorithm: isCompact
? [theme.compactAlgorithm, baseAlgorithm]
: [baseAlgorithm],
token: {
...COLORS,
colorPrimary:
primaryColorOverride == null
? COLORS.colorPrimary
: primaryColorOverride,
borderRadius: '12px'
},
components: {
Layout: {
headerBg: isDarkMode ? '#141414' : '#ffffff'
}
}
}
}, [isDarkMode, isCompact, primaryColorOverride])
const value = useMemo(
() => ({
isDarkMode,
toggleTheme,
isCompact,
toggleCompact,
isSystem,
toggleSystem,
setThemeMode,
setDensityMode,
showNavigationLabels,
setShowNavigationLabels,
getColors,
setPrimaryColorOverride,
themeConfig
}),
[
getColors,
isCompact,
isDarkMode,
isSystem,
setDensityMode,
setShowNavigationLabels,
setThemeMode,
showNavigationLabels,
themeConfig,
toggleCompact,
toggleSystem,
toggleTheme
]
)
// Set CSS custom properties for theme colors
useEffect(() => {
@ -226,41 +274,8 @@ export const ThemeProvider = ({ children }) => {
)
}, [isDarkMode, primaryColorOverride])
const themeConfig = {
algorithm: getThemeAlgorithm(),
token: {
...COLORS,
colorPrimary:
primaryColorOverride == null
? COLORS.colorPrimary
: primaryColorOverride,
borderRadius: '12px'
},
components: {
Layout: {
headerBg: isDarkMode ? '#141414' : '#ffffff'
}
}
}
return (
<ThemeContext.Provider
value={{
isDarkMode,
toggleTheme,
isCompact,
toggleCompact,
isSystem,
toggleSystem,
setThemeMode,
setDensityMode,
showNavigationLabels,
setShowNavigationLabels,
getColors,
setPrimaryColorOverride,
themeConfig
}}
>
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
)