Implement Navigation Tabs and Dashboard Enhancements
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good

- Introduced NavigationTabsContext to manage tab state and navigation within the dashboard.
- Added DashboardTabs component for improved tabbed navigation, allowing users to add, close, and reorder tabs.
- Enhanced Dashboard layout to conditionally render tab panes based on the active tab, improving user experience.
- Updated various components to utilize navigation tab context, ensuring consistent title management and tab interactions.
- Implemented drag-and-drop functionality for tabs, enabling users to rearrange their workspace effectively.
- Refactored CSS styles for dashboard tabs and panes to ensure proper layout and responsiveness across devices.
This commit is contained in:
Tom Butcher 2026-09-17 20:39:50 +01:00
parent 7dfaef65a4
commit d16aa373be
30 changed files with 1963 additions and 185 deletions

View File

@ -3346,6 +3346,79 @@ body.objectKanbanColumnResizing * {
min-width: 20px; min-width: 20px;
} }
.dashboard-tabs {
flex: 1;
min-width: 0;
}
.dashboard-tabs .segmented-nav-item-label {
padding-inline: 0px;
padding-left: 8px;
}
.dashboard-tabs-segmented-wrap {
flex: 1;
min-width: 0;
}
.dashboard-tabs-segmented-wrap .simplebar-track.simplebar-vertical {
display: none;
}
.dashboard-tabs-segmented-inner {
width: max-content;
align-items: center;
}
.dashboard-tabs-label {
max-width: 200px;
margin-top: -2px;
}
.dashboard-tabs-close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
margin-left: 2px;
border-radius: 8px;
color: var(--color-text-secondary, inherit);
opacity: 0.7;
}
.dashboard-tabs-close:hover {
opacity: 1;
background: var(--color-button-background);
}
.dashboard-tabs-add-button {
padding-inline: 4px;
min-width: 20px;
margin-bottom: 2px;
}
.dashboard-tab-panes {
position: relative;
flex: 1 1 auto;
min-height: 0;
height: 100%;
}
.dashboard-tab-pane {
height: 100%;
min-height: 0;
}
.dashboard-tab-pane-active {
display: flex;
flex-direction: column;
}
.dashboard-tab-pane-inactive {
display: none;
}
.state-tag-loading svg path { .state-tag-loading svg path {
stroke: currentColor; stroke: currentColor;
stroke-width: 12px; stroke-width: 12px;

View File

@ -30,6 +30,7 @@ import AppError from './components/App/AppError'
import { ApiServerProvider } from './components/Dashboard/context/ApiServerContext.jsx' import { ApiServerProvider } from './components/Dashboard/context/ApiServerContext.jsx'
import { NotificationProvider } from './components/Dashboard/context/NotificationContext.jsx' import { NotificationProvider } from './components/Dashboard/context/NotificationContext.jsx'
import { ElectronProvider } from './components/Dashboard/context/ElectronContext.jsx' import { ElectronProvider } from './components/Dashboard/context/ElectronContext.jsx'
import { NavigationTabsProvider } from './components/Dashboard/context/NavigationTabsContext.jsx'
import { MessageProvider } from './components/Dashboard/context/MessageContext.jsx' import { MessageProvider } from './components/Dashboard/context/MessageContext.jsx'
import { TooltipProvider } from './components/Dashboard/context/TooltipContext.jsx' import { TooltipProvider } from './components/Dashboard/context/TooltipContext.jsx'
import { AppUpdateProvider } from './components/Dashboard/context/AppUpdateContext.jsx' import { AppUpdateProvider } from './components/Dashboard/context/AppUpdateContext.jsx'
@ -84,6 +85,7 @@ const AppContent = () => {
<App> <App>
<Router> <Router>
<ElectronProvider> <ElectronProvider>
<NavigationTabsProvider>
<AuthProvider> <AuthProvider>
<PrintServerProvider> <PrintServerProvider>
<ApiServerProvider> <ApiServerProvider>
@ -183,6 +185,7 @@ const AppContent = () => {
</ApiServerProvider> </ApiServerProvider>
</PrintServerProvider> </PrintServerProvider>
</AuthProvider> </AuthProvider>
</NavigationTabsProvider>
</ElectronProvider> </ElectronProvider>
</Router> </Router>
</App> </App>

View File

@ -22,22 +22,22 @@ const {
const { const {
createMainWindow, createMainWindow,
handleDeepLinkFromArgv, handleDeepLinkFromArgv,
persistSessionNow,
setupDevAuthServer, setupDevAuthServer,
setupNavigationGestures,
setupWindowsDeepLinkHandling setupWindowsDeepLinkHandling
} = await import('../desktop/window.js') } = await import('../desktop/window.js')
setupWindowsDeepLinkHandling() setupWindowsDeepLinkHandling()
const rpc = createAppRpc() const rpc = createAppRpc()
const mainWindow = await createMainWindow(rpc) await createMainWindow(rpc)
setupNavigationGestures(mainWindow)
registerGlobalShortcuts(rpc) registerGlobalShortcuts(rpc)
setupDevAuthServer() setupDevAuthServer()
handleDeepLinkFromArgv(launchUrl) handleDeepLinkFromArgv(launchUrl)
process.on('exit', () => { process.on('exit', () => {
persistSessionNow()
unregisterGlobalShortcuts() unregisterGlobalShortcuts()
closeSingleInstanceServer() closeSingleInstanceServer()
}) })

View File

@ -1,13 +1,12 @@
// Dashboard.js
import Layout from './Layout' import Layout from './Layout'
import { Outlet } from 'react-router-dom' import { Outlet } from 'react-router-dom'
import { useNavigationTabs } from './context/NavigationTabsContext'
import DashboardTabPanes from './common/DashboardTabPanes'
const Dashboard = () => { const Dashboard = () => {
return ( const { isElectron } = useNavigationTabs()
<Layout>
<Outlet /> return <Layout>{isElectron ? <DashboardTabPanes /> : <Outlet />}</Layout>
</Layout>
)
} }
export default Dashboard export default Dashboard

View File

@ -66,7 +66,7 @@ const DashboardLayout = ({ children }) => {
<DashboardBreadcrumb style={{ margin: '16px 0' }} /> <DashboardBreadcrumb style={{ margin: '16px 0' }} />
{currentObjectTools} {currentObjectTools}
</Flex> </Flex>
{children} <div style={{ flex: 1, minHeight: 0 }}>{children}</div>
</Flex> </Flex>
</Content> </Content>
</Layout> </Layout>

View File

@ -22,9 +22,11 @@ import { useMediaQuery } from 'react-responsive'
import FarmControlAppIcon from '../../Logos/FarmControlAppIcon' import FarmControlAppIcon from '../../Logos/FarmControlAppIcon'
import SoftwareUpdateIcon from '../../Icons/SoftwareUpdateIcon' import SoftwareUpdateIcon from '../../Icons/SoftwareUpdateIcon'
import ExternalLink from '../common/ExternalLink' import ExternalLink from '../common/ExternalLink'
import { useNavigationTabPage } from '../context/NavigationTabsContext'
const { Title, Text } = Typography const { Title, Text } = Typography
const About = () => { const About = () => {
useNavigationTabPage({ title: 'About' })
const [collapseState, updateCollapseState] = useCollapseState('About', { const [collapseState, updateCollapseState] = useCollapseState('About', {
updater: true updater: true
}) })

View File

@ -21,6 +21,7 @@ import ActivityIndicator from '../../common/ActivityIndicator.jsx'
import ActionHandler from '../../common/ActionHandler.jsx' import ActionHandler from '../../common/ActionHandler.jsx'
import ObjectActions from '../../common/ObjectActions.jsx' import ObjectActions from '../../common/ObjectActions.jsx'
import { useDashboardObjectTools } from '../../context/DashboardObjectToolsContext' import { useDashboardObjectTools } from '../../context/DashboardObjectToolsContext'
import { useNavigationTabPage } from '../../context/NavigationTabsContext'
import ObjectTable from '../../common/ObjectTable.jsx' import ObjectTable from '../../common/ObjectTable.jsx'
import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx' import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx'
import InfoActionButtons from '../../common/InfoActionButtons.jsx' import InfoActionButtons from '../../common/InfoActionButtons.jsx'
@ -32,6 +33,7 @@ const log = loglevel.getLogger('NoteInfo')
log.setLevel(config.logLevel) log.setLevel(config.logLevel)
const NoteInfo = () => { const NoteInfo = () => {
useNavigationTabPage({ title: 'Info - Note', modelName: 'note' })
const location = useLocation() const location = useLocation()
const objectFormRef = useRef(null) const objectFormRef = useRef(null)
const actionHandlerRef = useRef(null) const actionHandlerRef = useRef(null)

View File

@ -23,6 +23,7 @@ import settingsSchema, {
getVisibleSettingsSections, getVisibleSettingsSections,
pickSettings pickSettings
} from '../../../database/Settings' } from '../../../database/Settings'
import { useNavigationTabPage } from '../context/NavigationTabsContext'
const DEFAULT_UPDATE_BRANCH = 'main' const DEFAULT_UPDATE_BRANCH = 'main'
const DEFAULT_UPDATE_ENGINE = 'native' const DEFAULT_UPDATE_ENGINE = 'native'
@ -35,6 +36,7 @@ const LEGACY_ELECTRON_USER_KEYS = [
] ]
const Settings = () => { const Settings = () => {
useNavigationTabPage({ title: 'Settings' })
const { const {
isDarkMode, isDarkMode,
isCompact, isCompact,

View File

@ -1,11 +1,14 @@
// DashboardBreadcrumb.js // DashboardBreadcrumb.js
import { useContext } from 'react'
import { Breadcrumb, Button, Flex, Space } from 'antd' import { Breadcrumb, Button, Flex, Space } from 'antd'
import { Link, useLocation, useNavigate } from 'react-router-dom' import { Link, useLocation, useNavigate } from 'react-router-dom'
import ArrowLeftIcon from '../../Icons/ArrowLeftIcon' import ArrowLeftIcon from '../../Icons/ArrowLeftIcon'
import ArrowRightIcon from '../../Icons/ArrowRightIcon' import ArrowRightIcon from '../../Icons/ArrowRightIcon'
import FilterIcon from '../../Icons/FilterIcon' import FilterIcon from '../../Icons/FilterIcon'
import { getModelByPluralName } from '../../../database/ObjectModels' import { getModelByPluralName } from '../../../database/ObjectModels'
import { ElectronContext } from '../context/ElectronContext'
import { useTableState } from '../context/TableStateContext' import { useTableState } from '../context/TableStateContext'
import { useNavigationTabs } from '../context/NavigationTabsContext'
const breadcrumbNameMap = { const breadcrumbNameMap = {
production: 'Production', production: 'Production',
@ -28,6 +31,8 @@ const mainSections = ['production', 'inventory', 'management', 'developer']
const DashboardBreadcrumb = () => { const DashboardBreadcrumb = () => {
const location = useLocation() const location = useLocation()
const navigate = useNavigate() const navigate = useNavigate()
const { isElectron } = useContext(ElectronContext)
const { goBack, goForward } = useNavigationTabs()
const { hasPageFilter, hasStoredFilter, getObjectListView } = useTableState() const { hasPageFilter, hasStoredFilter, getObjectListView } = useTableState()
const pathSnippets = location.pathname.split('/').filter((i) => i) const pathSnippets = location.pathname.split('/').filter((i) => i)
@ -97,13 +102,13 @@ const DashboardBreadcrumb = () => {
<Button <Button
type='text' type='text'
icon={<ArrowLeftIcon style={{ fontSize: '14px' }} />} icon={<ArrowLeftIcon style={{ fontSize: '14px' }} />}
onClick={() => navigate(-1)} onClick={() => (isElectron ? goBack() : navigate(-1))}
style={{ padding: '0 2px', height: '22px' }} style={{ padding: '0 2px', height: '22px' }}
/> />
<Button <Button
type='text' type='text'
icon={<ArrowRightIcon style={{ fontSize: '14px' }} />} icon={<ArrowRightIcon style={{ fontSize: '14px' }} />}
onClick={() => navigate(1)} onClick={() => (isElectron ? goForward() : navigate(1))}
style={{ padding: '0 2px', height: '22px' }} style={{ padding: '0 2px', height: '22px' }}
/> />
</Space.Compact> </Space.Compact>

View File

@ -0,0 +1,21 @@
import { Route } from 'react-router-dom'
import ProductionRoutes from '../../../routes/ProductionRoutes'
import InventoryRoutes from '../../../routes/InventoryRoutes'
import FinanceRoutes from '../../../routes/FinanceRoutes'
import SalesRoutes from '../../../routes/SalesRoutes'
import ManagementRoutes from '../../../routes/ManagementRoutes'
import DeveloperRoutes from '../../../routes/DeveloperRoutes'
import ModelRoutes from '../../../routes/ModelRoutes'
export const wrapDashboardChildRoutes = (layoutElement) => (
<Route element={layoutElement}>
{ProductionRoutes}
{InventoryRoutes}
{FinanceRoutes}
{SalesRoutes}
{ManagementRoutes}
{ModelRoutes}
{DeveloperRoutes}
</Route>
)

View File

@ -40,6 +40,7 @@ import { ElectronContext } from '../context/ElectronContext'
import DashboardWindowButtons from './DashboardWindowButtons' import DashboardWindowButtons from './DashboardWindowButtons'
import WindowAppMenu from './WindowAppMenu' import WindowAppMenu from './WindowAppMenu'
import WebAppSwitcher from './WebAppSwitcher' import WebAppSwitcher from './WebAppSwitcher'
import DashboardTabs from './DashboardTabs'
import { import {
filterSidebarItemsByListPermission, filterSidebarItemsByListPermission,
getSidebarDefaultPath, getSidebarDefaultPath,
@ -263,7 +264,7 @@ const DashboardNavigation = () => {
items={headerMenuItems} items={headerMenuItems}
style={{ style={{
flexWrap: 'wrap', flexWrap: 'wrap',
flexGrow: 1, flexGrow: isElectron && !isMobile ? 0 : 1,
border: 0, border: 0,
minWidth: 0 minWidth: 0
}} }}
@ -287,7 +288,12 @@ const DashboardNavigation = () => {
<WindowAppMenu />{' '} <WindowAppMenu />{' '}
<Divider <Divider
type='vertical' type='vertical'
style={{ height: '14px', margin: '3px 3px 0 1.5px' }} style={{
height: '14px',
margin: showNavigationLabels
? '3px 3px 0 1.5px'
: '3px 0px 0 1.5px'
}}
/> />
</> </>
) : null} ) : null}
@ -308,8 +314,26 @@ const DashboardNavigation = () => {
}} }}
/> />
) : null} ) : null}
<div style={{ flexGrow: 1 }}> <div style={{ flexGrow: 1, minWidth: 0 }}>
{showControls && !isMobile && menu} {showControls && !isMobile ? (
<Flex align='center' style={{ width: '100%', minWidth: 0 }}>
{menu}
{isElectron ? (
<>
<Divider
type='vertical'
style={{
margin: showNavigationLabels
? '3px 16px 0 4px'
: '3px 16px 0 0',
height: '14px'
}}
/>
<DashboardTabs />
</>
) : null}
</Flex>
) : null}
{!showControls && ( {!showControls && (
<Text <Text
type='secondary' type='secondary'

View File

@ -25,6 +25,7 @@ import usePageLayout, {
normalizeColumnProportions normalizeColumnProportions
} from '../hooks/usePageLayout' } from '../hooks/usePageLayout'
import { OverviewLayoutContext } from '../context/OverviewLayoutContext' import { OverviewLayoutContext } from '../context/OverviewLayoutContext'
import { useNavigationTabPage } from '../context/NavigationTabsContext'
const flattenOverviewSections = (sectionList = []) => { const flattenOverviewSections = (sectionList = []) => {
const leaves = {} const leaves = {}
@ -124,6 +125,10 @@ const DashboardOverviewPage = ({
onOpenActionsModal, onOpenActionsModal,
sections = [] sections = []
}) => { }) => {
const overviewTitle = pageName.endsWith('Overview')
? `Overview - ${pageName.slice(0, -'Overview'.length)}`
: pageName
useNavigationTabPage({ title: overviewTitle })
const [savedCollapseState, updateCollapseState] = useCollapseState( const [savedCollapseState, updateCollapseState] = useCollapseState(
pageName, pageName,
collapseDefaults collapseDefaults

View File

@ -0,0 +1,126 @@
import { useLayoutEffect, useRef } from 'react'
import { Outlet, Routes, UNSAFE_LocationContext, useLocation } from 'react-router-dom'
import { TableStateProvider } from '../context/TableStateContext'
import {
NavigationTabActiveContext,
useNavigationTabs
} from '../context/NavigationTabsContext'
import { wrapDashboardChildRoutes } from './DashboardChildRoutes'
const locationsEqual = (left, right) =>
Boolean(
left &&
right &&
left.pathname === right.pathname &&
(left.search || '') === (right.search || '') &&
(left.hash || '') === (right.hash || '')
)
const entryToLocation = (entry, fallbackLocation, tabId) => ({
pathname: entry?.pathname || fallbackLocation.pathname,
search: entry?.search ?? fallbackLocation.search ?? '',
hash: entry?.hash ?? fallbackLocation.hash ?? '',
state: fallbackLocation.state,
key: `tab-${tabId}`
})
const getTabCurrentEntry = (tab) =>
tab?.history?.[tab.historyIndex] || tab?.history?.[tab.history?.length - 1]
const tabMatchesLocation = (tab, loc) => {
const entry = getTabCurrentEntry(tab)
if (!entry || !loc) return false
return locationsEqual(entry, loc)
}
const CachedTabRouteLayout = () => (
<TableStateProvider>
<Outlet />
</TableStateProvider>
)
const DashboardTabPanes = () => {
const location = useLocation()
const { tabs, activeTabId, isElectron } = useNavigationTabs()
const visitedRef = useRef(new Set())
const frozenRef = useRef(new Map())
const prevActiveIdRef = useRef(activeTabId)
const lastLocationRef = useRef(location)
const pendingRestoreRef = useRef(false)
const prevActiveId = prevActiveIdRef.current
const switchedTabs = Boolean(prevActiveId && activeTabId && prevActiveId !== activeTabId)
if (switchedTabs) {
frozenRef.current.set(prevActiveId, lastLocationRef.current)
pendingRestoreRef.current = true
}
const activeTab = tabs.find((tab) => tab.id === activeTabId)
if (activeTab && tabMatchesLocation(activeTab, location)) {
pendingRestoreRef.current = false
frozenRef.current.set(activeTabId, location)
}
if (activeTabId) {
visitedRef.current.add(activeTabId)
}
for (const tabId of [...visitedRef.current]) {
if (!tabs.some((tab) => tab.id === tabId)) {
visitedRef.current.delete(tabId)
frozenRef.current.delete(tabId)
}
}
useLayoutEffect(() => {
if (!isElectron) return
window.dispatchEvent(new Event('resize'))
}, [activeTabId, isElectron])
const visibleTabs = tabs.filter((tab) => visitedRef.current.has(tab.id))
prevActiveIdRef.current = activeTabId
lastLocationRef.current = location
if (visibleTabs.length === 0) {
return <Outlet />
}
return (
<div className='dashboard-tab-panes'>
{visibleTabs.map((tab) => {
const isActive = tab.id === activeTabId
const loc =
isActive && !pendingRestoreRef.current
? location
: frozenRef.current.get(tab.id) ||
entryToLocation(getTabCurrentEntry(tab), location, tab.id)
return (
<div
key={tab.id}
className={
isActive
? 'dashboard-tab-pane dashboard-tab-pane-active'
: 'dashboard-tab-pane dashboard-tab-pane-inactive'
}
aria-hidden={!isActive}
>
<NavigationTabActiveContext.Provider value={isActive}>
<UNSAFE_LocationContext.Provider
value={{ location: loc, navigationType: 'POP' }}
>
<Routes location={loc}>
{wrapDashboardChildRoutes(<CachedTabRouteLayout />)}
</Routes>
</UNSAFE_LocationContext.Provider>
</NavigationTabActiveContext.Provider>
</div>
)
})}
</div>
)
}
export default DashboardTabPanes

View File

@ -0,0 +1,139 @@
import PropTypes from 'prop-types'
import { useMemo } from 'react'
import { Button, Flex, Typography } from 'antd'
import classNames from 'classnames'
import SegmentedNav from './SegmentedNav'
import ScrollBox from './ScrollBox'
import PlusIcon from '../../Icons/PlusIcon'
import XMarkIcon from '../../Icons/XMarkIcon'
import HomeIcon from '../../Icons/HomeIcon'
import { getModelByName } from '../../../database/ObjectModels'
import { useNavigationTabs } from '../context/NavigationTabsContext'
import { getDesktopWindowId } from '../../../electrobun-bridge.js'
import { hasExternalTabDrag, writeTabDragData } from './tabDrag'
const { Text } = Typography
const DashboardTabLabel = ({ tab, onClose }) => (
<Flex align='center' gap={6} className='dashboard-tabs-label'>
<Text ellipsis style={{ maxWidth: 160 }}>
{tab.title || 'Farm Control'}
</Text>
<span
className='dashboard-tabs-close'
role='button'
tabIndex={-1}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
onClose?.(tab.id)
}}
onMouseDown={(event) => event.stopPropagation()}
>
<XMarkIcon style={{ fontSize: 10 }} />
</span>
</Flex>
)
DashboardTabLabel.propTypes = {
tab: PropTypes.shape({
id: PropTypes.string.isRequired,
title: PropTypes.string
}).isRequired,
onClose: PropTypes.func
}
const DashboardTabs = () => {
const {
tabs,
activeTabId,
selectTab,
addTab,
closeTab,
reorderTabs,
handleTabDragStart,
handleTabDragEnd,
handleExternalTabDrop
} = useNavigationTabs()
const options = useMemo(
() =>
tabs.map((tab) => {
const model = tab.modelName ? getModelByName(tab.modelName) : null
const Icon = model?.icon || HomeIcon
return {
value: tab.id,
reorderable: true,
icon: (
<Icon
style={{ fontSize: 14, marginTop: '-2px' }}
color='secondary'
/>
),
label: <DashboardTabLabel tab={tab} onClose={closeTab} />
}
}),
[closeTab, tabs]
)
const handleItemDragStart = (event, tabId) => {
const tab = tabs.find((item) => item.id === tabId)
if (!tab) return
writeTabDragData(event, {
tab,
sourceWindowId: getDesktopWindowId()
})
handleTabDragStart(tabId)
}
const handleStripDragOver = (event) => {
if (!hasExternalTabDrag(event)) return
event.preventDefault()
event.dataTransfer.dropEffect = 'move'
}
const handleStripDrop = (event) => {
if (!hasExternalTabDrag(event)) return
event.preventDefault()
const lastTab = tabs[tabs.length - 1]
void handleExternalTabDrop(lastTab?.id, false)
}
return (
<Flex
align='center'
gap={4}
className={classNames(
'dashboard-tabs',
'electrobun-webkit-app-region-no-drag'
)}
style={{ flex: 1, minWidth: 0 }}
onDragOver={handleStripDragOver}
onDrop={handleStripDrop}
>
<ScrollBox className='dashboard-tabs-segmented-wrap' horizontal>
<Flex align='center' gap={8} className='dashboard-tabs-segmented-inner'>
<SegmentedNav
className='dashboard-tabs-segmented'
value={activeTabId}
options={options}
onChange={selectTab}
onReorder={reorderTabs}
onExternalDrop={handleExternalTabDrop}
onItemDragStart={handleItemDragStart}
onItemDragEnd={handleTabDragEnd}
/>
<Button
type='text'
size='small'
className='dashboard-tabs-add-button'
onClick={addTab}
icon={<PlusIcon style={{ fontSize: 14, marginTop: 3 }} />}
/>
</Flex>
</ScrollBox>
</Flex>
)
}
export default DashboardTabs

View File

@ -6,11 +6,13 @@ import MinusIcon from '../../Icons/MinusIcon'
import ContractIcon from '../../Icons/ContractIcon' import ContractIcon from '../../Icons/ContractIcon'
import ExpandIcon from '../../Icons/ExpandIcon' import ExpandIcon from '../../Icons/ExpandIcon'
import MacOSTrafficLights from './MacOSTrafficLights' import MacOSTrafficLights from './MacOSTrafficLights'
import { useThemeContext } from '../context/ThemeContext'
const DashboardWindowButtons = () => { const DashboardWindowButtons = () => {
const { isMaximized, handleWindowControl, platform, isFullScreen } = const { isMaximized, handleWindowControl, platform, isFullScreen } =
useContext(ElectronContext) useContext(ElectronContext)
const { showNavigationLabels } = useThemeContext()
const closeButton = ( const closeButton = (
<Button <Button
icon={<XMarkIcon />} icon={<XMarkIcon />}
@ -70,7 +72,10 @@ const DashboardWindowButtons = () => {
<MacOSTrafficLights /> <MacOSTrafficLights />
<Divider <Divider
type='vertical' type='vertical'
style={{ height: '14px', margin: '3px 6px 0 0' }} style={{
height: '14px',
margin: showNavigationLabels ? '3px 6px 0 0' : '3px 0px 0 0'
}}
/> />
</> </>
) : ( ) : (

View File

@ -7,6 +7,7 @@ import { getModelByName } from '../../../database/ObjectModels'
import { getObjectIdFromSearch } from '../../../utils/modelActions' import { getObjectIdFromSearch } from '../../../utils/modelActions'
import { useActions } from '../context/ActionsContext' import { useActions } from '../context/ActionsContext'
import { AuthContext } from '../context/AuthContext' import { AuthContext } from '../context/AuthContext'
import { useNavigationTabPage, useIsNavigationTabActive } from '../context/NavigationTabsContext'
const ModelPage = ({ modelName, pageName }) => { const ModelPage = ({ modelName, pageName }) => {
const model = getModelByName(modelName) const model = getModelByName(modelName)
@ -15,8 +16,12 @@ const ModelPage = ({ modelName, pageName }) => {
const { setCurrentObject, setCurrentObjectType } = useActions() const { setCurrentObject, setCurrentObjectType } = useActions()
const { userProfile } = useContext(AuthContext) const { userProfile } = useContext(AuthContext)
const objectId = getObjectIdFromSearch(modelName, location.search) const objectId = getObjectIdFromSearch(modelName, location.search)
const pageTitle = `${pageName.charAt(0).toUpperCase()}${pageName.slice(1)} - ${model?.label || modelName}`
const isTabActive = useIsNavigationTabActive()
useNavigationTabPage({ title: pageTitle, modelName })
useEffect(() => { useEffect(() => {
if (!isTabActive) return undefined
setCurrentObjectType(modelName) setCurrentObjectType(modelName)
if (objectId) { if (objectId) {
setCurrentObject({ _id: objectId, _user: userProfile }) setCurrentObject({ _id: objectId, _user: userProfile })
@ -25,7 +30,14 @@ const ModelPage = ({ modelName, pageName }) => {
setCurrentObject(null) setCurrentObject(null)
setCurrentObjectType(null) setCurrentObjectType(null)
} }
}, [modelName, objectId, setCurrentObject, setCurrentObjectType, userProfile]) }, [
isTabActive,
modelName,
objectId,
setCurrentObject,
setCurrentObjectType,
userProfile
])
if (!page?.content) { if (!page?.content) {
return null return null

View File

@ -9,6 +9,7 @@ import {
} from 'react' } from 'react'
import { HolderOutlined } from '@ant-design/icons' import { HolderOutlined } from '@ant-design/icons'
import classNames from 'classnames' import classNames from 'classnames'
import { hasExternalTabDrag } from './tabDrag'
const SegmentedNavReorderContext = createContext(null) const SegmentedNavReorderContext = createContext(null)
@ -59,6 +60,9 @@ const SegmentedNav = ({
options = [], options = [],
onChange, onChange,
onReorder, onReorder,
onExternalDrop,
onItemDragStart,
onItemDragEnd,
className, className,
disabled = false, disabled = false,
animated = true animated = true
@ -75,7 +79,10 @@ const SegmentedNav = ({
const [dropTarget, setDropTarget] = useState(null) const [dropTarget, setDropTarget] = useState(null)
const enabledOptions = getEnabledOptions(options, disabled) const enabledOptions = getEnabledOptions(options, disabled)
const isReordering = Boolean(onReorder && draggedValue != null) const acceptsExternal = Boolean(onExternalDrop)
const isReordering = Boolean(
(onReorder || acceptsExternal) && (draggedValue != null || dropTarget)
)
const setItemRef = useCallback((optionValue, element) => { const setItemRef = useCallback((optionValue, element) => {
if (element) { if (element) {
@ -172,21 +179,32 @@ const SegmentedNav = ({
setDraggedValue(optionValue) setDraggedValue(optionValue)
event.dataTransfer.effectAllowed = 'move' event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('text/plain', String(optionValue)) if (!onItemDragStart) {
event.dataTransfer.setData('text/plain', String(optionValue))
}
onItemDragStart?.(event, optionValue)
}, },
[onReorder] [onItemDragStart, onReorder]
) )
const handleDragEnd = useCallback(() => { const handleDragEnd = useCallback(() => {
onItemDragEnd?.()
clearDragState() clearDragState()
}, [clearDragState]) }, [clearDragState, onItemDragEnd])
const handleItemDragOver = useCallback( const handleItemDragOver = useCallback(
(event, option) => { (event, option) => {
if (!onReorder || draggedValue == null || !option.reorderable) { const isExternal = acceptsExternal && hasExternalTabDrag(event)
if ((!onReorder && !isExternal) || !option.reorderable) {
return return
} }
if (String(option.value) === String(draggedValue)) { if (draggedValue == null && !isExternal) {
return
}
if (
draggedValue != null &&
String(option.value) === String(draggedValue)
) {
return return
} }
@ -200,12 +218,13 @@ const SegmentedNav = ({
insertBefore: event.clientX < rect.left + rect.width / 2 insertBefore: event.clientX < rect.left + rect.width / 2
}) })
}, },
[draggedValue, onReorder] [acceptsExternal, draggedValue, onReorder]
) )
const handleItemDrop = useCallback( const handleItemDrop = useCallback(
(event, option) => { (event, option) => {
if (!onReorder || draggedValue == null || !option.reorderable) { const isExternal = acceptsExternal && draggedValue == null
if ((!onReorder && !isExternal) || !option.reorderable) {
return return
} }
@ -217,18 +236,36 @@ const SegmentedNav = ({
insertBefore: true insertBefore: true
} }
if (isExternal) {
onExternalDrop?.(target.value, target.insertBefore, event)
clearDragState()
return
}
if (draggedValue == null) {
return
}
if (String(draggedValue) !== String(target.value)) { if (String(draggedValue) !== String(target.value)) {
onReorder(draggedValue, target.value, target.insertBefore) onReorder(draggedValue, target.value, target.insertBefore)
} }
clearDragState() clearDragState()
}, },
[clearDragState, draggedValue, dropTarget, onReorder] [
acceptsExternal,
clearDragState,
draggedValue,
dropTarget,
onExternalDrop,
onReorder
]
) )
const handleGroupDragOver = useCallback( const handleGroupDragOver = useCallback(
(event) => { (event) => {
if (!onReorder || draggedValue == null) { const isExternal = acceptsExternal && hasExternalTabDrag(event)
if ((!onReorder && !isExternal) || (draggedValue == null && !isExternal)) {
return return
} }
@ -253,12 +290,13 @@ const SegmentedNav = ({
insertBefore: false insertBefore: false
}) })
}, },
[draggedValue, onReorder, options] [acceptsExternal, draggedValue, onReorder, options]
) )
const handleGroupDrop = useCallback( const handleGroupDrop = useCallback(
(event) => { (event) => {
if (!onReorder || draggedValue == null) { const isExternal = acceptsExternal && draggedValue == null
if ((!onReorder && !isExternal) || (draggedValue == null && !isExternal)) {
return return
} }
@ -274,13 +312,31 @@ const SegmentedNav = ({
const reorderableOptions = options.filter((option) => option.reorderable) const reorderableOptions = options.filter((option) => option.reorderable)
const lastOption = reorderableOptions[reorderableOptions.length - 1] const lastOption = reorderableOptions[reorderableOptions.length - 1]
if (lastOption && String(draggedValue) !== String(lastOption.value)) { if (!lastOption) {
clearDragState()
return
}
if (isExternal) {
onExternalDrop?.(lastOption.value, false, event)
clearDragState()
return
}
if (String(draggedValue) !== String(lastOption.value)) {
onReorder(draggedValue, lastOption.value, false) onReorder(draggedValue, lastOption.value, false)
} }
clearDragState() clearDragState()
}, },
[clearDragState, draggedValue, onReorder, options] [
acceptsExternal,
clearDragState,
draggedValue,
onExternalDrop,
onReorder,
options
]
) )
const getDragHandleProps = useCallback( const getDragHandleProps = useCallback(
@ -381,6 +437,13 @@ const SegmentedNav = ({
isDropTarget && !dropTarget.insertBefore, isDropTarget && !dropTarget.insertBefore,
'segmented-nav-item-icon': option.icon 'segmented-nav-item-icon': option.icon
})} })}
draggable={Boolean(onReorder && option.reorderable)}
onDragStart={
option.reorderable
? (event) => handleDragStart(event, option.value)
: undefined
}
onDragEnd={option.reorderable ? handleDragEnd : undefined}
onClick={() => { onClick={() => {
if (!isDisabled) { if (!isDisabled) {
onChange?.(option.value) onChange?.(option.value)
@ -431,6 +494,9 @@ SegmentedNav.propTypes = {
), ),
onChange: PropTypes.func, onChange: PropTypes.func,
onReorder: PropTypes.func, onReorder: PropTypes.func,
onExternalDrop: PropTypes.func,
onItemDragStart: PropTypes.func,
onItemDragEnd: PropTypes.func,
className: PropTypes.string, className: PropTypes.string,
disabled: PropTypes.bool, disabled: PropTypes.bool,
animated: PropTypes.bool animated: PropTypes.bool

View File

@ -4,8 +4,10 @@ import { Button, Dropdown, Flex } from 'antd'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { ElectronContext } from '../context/ElectronContext' import { ElectronContext } from '../context/ElectronContext'
import { AuthContext } from '../context/AuthContext' import { AuthContext } from '../context/AuthContext'
import { useNavigationTabs } from '../context/NavigationTabsContext'
import { useAppUpdateContext } from '../context/AppUpdateContext' import { useAppUpdateContext } from '../context/AppUpdateContext'
import { getSidebarMenuSections } from '../../../database/Sidebars' import { getSidebarMenuSections } from '../../../database/Sidebars'
import KeyboardShortcut from './KeyboardShortcut'
import FarmControlLogoSmall from '../../Logos/FarmControlLogoSmall' import FarmControlLogoSmall from '../../Logos/FarmControlLogoSmall'
const runEditCommand = (command) => { const runEditCommand = (command) => {
@ -78,6 +80,7 @@ const WindowAppMenu = () => {
const navigate = useNavigate() const navigate = useNavigate()
const { userProfile } = useContext(AuthContext) const { userProfile } = useContext(AuthContext)
const { handleWindowControl } = useContext(ElectronContext) const { handleWindowControl } = useContext(ElectronContext)
const { addTab, createNewWindow } = useNavigationTabs()
const { checkForUpdates } = useAppUpdateContext() const { checkForUpdates } = useAppUpdateContext()
const includeDev = import.meta.env.DEV const includeDev = import.meta.env.DEV
@ -111,13 +114,24 @@ const WindowAppMenu = () => {
const fileMenuItems = useMemo( const fileMenuItems = useMemo(
() => [ () => [
{
key: 'new-window',
label: 'New Window',
onClick: () => createNewWindow()
},
{
key: 'new-tab',
label: 'New Tab',
onClick: () => addTab()
},
{ type: 'divider' },
{ {
key: 'close', key: 'close',
label: 'Close Window', label: 'Close Window',
onClick: () => handleWindowControl('close') onClick: () => handleWindowControl('close')
} }
], ],
[handleWindowControl] [addTab, createNewWindow, handleWindowControl]
) )
const editMenuItems = useMemo( const editMenuItems = useMemo(
@ -250,6 +264,19 @@ const WindowAppMenu = () => {
/> />
) )
} }
if (menu.key === 'file') {
return (
<KeyboardShortcut
key={menu.key}
shortcut='ctrl+t'
onTrigger={addTab}
>
<KeyboardShortcut shortcut='ctrl+n' onTrigger={createNewWindow}>
<MenuButton label={menu.label} items={menu.items} />
</KeyboardShortcut>
</KeyboardShortcut>
)
}
return ( return (
<MenuButton key={menu.key} label={menu.label} items={menu.items} /> <MenuButton key={menu.key} label={menu.label} items={menu.items} />
) )

View File

@ -0,0 +1,59 @@
export const TAB_DRAG_MIME = 'application/x-farmcontrol-tab'
export const TAB_DRAG_TEXT_PREFIX = 'farmcontrol-tab:'
export const hasExternalTabDrag = (event) => {
const types = event?.dataTransfer?.types
if (!types) return false
const list = Array.from(types)
return (
list.includes(TAB_DRAG_MIME) ||
list.includes('text/plain') ||
list.includes('text/uri-list')
)
}
export const writeTabDragData = (event, payload) => {
const serialized = JSON.stringify(payload)
try {
event.dataTransfer.setData(TAB_DRAG_MIME, serialized)
} catch {
// Some engines only allow text/plain during dragstart.
}
event.dataTransfer.setData('text/plain', `${TAB_DRAG_TEXT_PREFIX}${serialized}`)
}
export const readTabDragData = (event) => {
const transfer = event?.dataTransfer
if (!transfer) return null
const custom = (() => {
try {
return transfer.getData(TAB_DRAG_MIME)
} catch {
return ''
}
})()
const plain = (() => {
try {
return transfer.getData('text/plain')
} catch {
return ''
}
})()
const raw = custom
? custom
: plain.startsWith(TAB_DRAG_TEXT_PREFIX)
? plain.slice(TAB_DRAG_TEXT_PREFIX.length)
: ''
if (!raw) return null
try {
const parsed = JSON.parse(raw)
if (!parsed?.tab?.id) return null
return parsed
} catch {
return null
}
}

View File

@ -7,6 +7,7 @@ import {
useState useState
} from 'react' } from 'react'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { useIsNavigationTabActive } from './NavigationTabsContext'
const DashboardObjectToolsContext = createContext() const DashboardObjectToolsContext = createContext()
@ -59,6 +60,7 @@ export const useDashboardObjectToolsContext = () => {
export const useDashboardObjectTools = (tools) => { export const useDashboardObjectTools = (tools) => {
const { setCurrentObjectTools, clearCurrentObjectTools } = const { setCurrentObjectTools, clearCurrentObjectTools } =
useDashboardObjectToolsContext() useDashboardObjectToolsContext()
const isTabActive = useIsNavigationTabActive()
const ownerIdRef = useRef(null) const ownerIdRef = useRef(null)
if (ownerIdRef.current == null) { if (ownerIdRef.current == null) {
@ -66,8 +68,9 @@ export const useDashboardObjectTools = (tools) => {
} }
useEffect(() => { useEffect(() => {
if (!isTabActive) return
setCurrentObjectTools(tools, ownerIdRef.current) setCurrentObjectTools(tools, ownerIdRef.current)
}, [tools, setCurrentObjectTools]) }, [isTabActive, tools, setCurrentObjectTools])
useEffect(() => { useEffect(() => {
const ownerId = ownerIdRef.current const ownerId = ownerIdRef.current

View File

@ -35,6 +35,12 @@ const ElectronProvider = ({ children }) => {
const [electronAvailable] = useState(isElectron()) const [electronAvailable] = useState(isElectron())
const navigate = useNavigate() const navigate = useNavigate()
const lastNavigationAtRef = useRef(0) const lastNavigationAtRef = useRef(0)
const tabHistoryHandlerRef = useRef(null)
const registerTabHistoryHandler = useCallback((handler) => {
tabHistoryHandlerRef.current =
typeof handler === 'function' ? handler : null
}, [])
const navigateHistory = useCallback( const navigateHistory = useCallback(
(direction) => { (direction) => {
@ -42,6 +48,11 @@ const ElectronProvider = ({ children }) => {
if (now - lastNavigationAtRef.current < 300) return if (now - lastNavigationAtRef.current < 300) return
lastNavigationAtRef.current = now lastNavigationAtRef.current = now
if (tabHistoryHandlerRef.current) {
tabHistoryHandlerRef.current(direction)
return
}
if (direction === 'back') { if (direction === 'back') {
navigate(-1) navigate(-1)
} else if (direction === 'forward') { } else if (direction === 'forward') {
@ -158,6 +169,83 @@ const ElectronProvider = ({ children }) => {
void desktopBridge.windowControl(action) void desktopBridge.windowControl(action)
} }
const getWindowSession = useCallback(async () => {
if (!electronAvailable) return null
return await desktopBridge.getWindowSession()
}, [electronAvailable])
const syncWindowTabs = useCallback(
async (payload) => {
if (!electronAvailable) return false
const result = await desktopBridge.syncWindowTabs(payload)
return result?.ok ?? false
},
[electronAvailable]
)
const createAppWindow = useCallback(
async (payload) => {
if (!electronAvailable) return false
const result = await desktopBridge.createAppWindow(payload)
return result?.ok ?? false
},
[electronAvailable]
)
const onNewTabRequest = useCallback(
(handler) => {
if (!electronAvailable || typeof handler !== 'function') {
return () => {}
}
return desktopBridge.onMessage('newTab', handler)
},
[electronAvailable]
)
const onNewWindowRequest = useCallback(
(handler) => {
if (!electronAvailable || typeof handler !== 'function') {
return () => {}
}
return desktopBridge.onMessage('newWindow', handler)
},
[electronAvailable]
)
const onTabMovedAway = useCallback(
(handler) => {
if (!electronAvailable || typeof handler !== 'function') {
return () => {}
}
return desktopBridge.onMessage('tabMovedAway', handler)
},
[electronAvailable]
)
const beginTabDrag = useCallback(
async (payload) => {
if (!electronAvailable) return { ok: false }
return (await desktopBridge.beginTabDrag(payload)) || { ok: false }
},
[electronAvailable]
)
const completeTabDrop = useCallback(
async (payload) => {
if (!electronAvailable) return { ok: false }
return (await desktopBridge.completeTabDrop(payload)) || { ok: false }
},
[electronAvailable]
)
const cancelTabDrag = useCallback(
async (payload) => {
if (!electronAvailable) return { ok: false }
return (await desktopBridge.cancelTabDrag(payload)) || { ok: false }
},
[electronAvailable]
)
const getAuthSession = async () => { const getAuthSession = async () => {
if (!electronAvailable) return null if (!electronAvailable) return null
return await desktopBridge.getAuthSession() return await desktopBridge.getAuthSession()
@ -315,7 +403,17 @@ const ElectronProvider = ({ children }) => {
resizeSpotlightWindow, resizeSpotlightWindow,
setSidebarViewMenu, setSidebarViewMenu,
getElectronVersion, getElectronVersion,
getAppEngine getAppEngine,
registerTabHistoryHandler,
getWindowSession,
syncWindowTabs,
createAppWindow,
onNewTabRequest,
onNewWindowRequest,
onTabMovedAway,
beginTabDrag,
completeTabDrop,
cancelTabDrag
}} }}
> >
{children} {children}

View File

@ -0,0 +1,528 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState
} from 'react'
import PropTypes from 'prop-types'
import { useLocation, useNavigate } from 'react-router-dom'
import { ElectronContext } from './ElectronContext'
import { getDesktopWindowId } from '../../../electrobun-bridge.js'
const NavigationTabsContext = createContext()
// eslint-disable-next-line react-refresh/only-export-components
export const NavigationTabActiveContext = createContext(true)
const createTabId = () =>
`tab-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const locationToEntry = (location) => ({
pathname: location?.pathname || '/',
search: location?.search || '',
hash: location?.hash || ''
})
const entryToPath = (entry) => {
if (!entry) return '/'
if (typeof entry === 'string') return entry
return `${entry.pathname || '/'}${entry.search || ''}${entry.hash || ''}`
}
const entriesEqual = (left, right) => entryToPath(left) === entryToPath(right)
const createTabFromLocation = (location, extras = {}) => ({
id: createTabId(),
title: extras.title || 'Farm Control',
modelName: extras.modelName || null,
history: [locationToEntry(location)],
historyIndex: 0
})
const cloneCurrentPageTab = (tab, location) => ({
id: createTabId(),
title: tab?.title || 'Farm Control',
modelName: tab?.modelName || null,
history: [locationToEntry(location)],
historyIndex: 0
})
const getTabCurrentEntry = (tab) =>
tab?.history?.[tab.historyIndex] || tab?.history?.[tab.history.length - 1]
export const NavigationTabsProvider = ({ children }) => {
const navigate = useNavigate()
const location = useLocation()
const {
isElectron,
getWindowSession,
syncWindowTabs,
createAppWindow,
handleWindowControl,
registerTabHistoryHandler,
onNewTabRequest,
onNewWindowRequest,
onTabMovedAway,
beginTabDrag,
completeTabDrop,
cancelTabDrag
} = useContext(ElectronContext)
const [tabs, setTabs] = useState([])
const [activeTabId, setActiveTabId] = useState(null)
const [hydrated, setHydrated] = useState(!isElectron)
const tabsRef = useRef(tabs)
const activeTabIdRef = useRef(activeTabId)
const isRestoringRef = useRef(false)
tabsRef.current = tabs
activeTabIdRef.current = activeTabId
const restoreToEntry = useCallback(
(entry) => {
if (!entry) return
isRestoringRef.current = true
navigate(entryToPath(entry), { replace: true })
},
[navigate]
)
const selectTab = useCallback(
(tabId) => {
if (!tabId || tabId === activeTabIdRef.current) return
const nextTab = tabsRef.current.find((tab) => tab.id === tabId)
if (!nextTab) return
const nextEntry = getTabCurrentEntry(nextTab)
setActiveTabId(tabId)
if (entriesEqual(nextEntry, location)) {
return
}
restoreToEntry(nextEntry)
},
[location, restoreToEntry]
)
const addTab = useCallback(() => {
const currentTab = tabsRef.current.find(
(tab) => tab.id === activeTabIdRef.current
)
const nextTab = cloneCurrentPageTab(currentTab, location)
setTabs((current) => {
const activeIndex = current.findIndex(
(tab) => tab.id === activeTabIdRef.current
)
const insertAt = activeIndex === -1 ? current.length : activeIndex + 1
return [
...current.slice(0, insertAt),
nextTab,
...current.slice(insertAt)
]
})
setActiveTabId(nextTab.id)
}, [location])
const removeTab = useCallback(
(tabId, { closeWindowIfLast = true } = {}) => {
const current = tabsRef.current
const index = current.findIndex((tab) => tab.id === tabId)
if (index === -1) return false
if (current.length <= 1) {
if (closeWindowIfLast) {
handleWindowControl?.('close')
}
return true
}
const remaining = current.filter((tab) => tab.id !== tabId)
const nextActive =
tabId === activeTabIdRef.current
? remaining[Math.max(0, index - 1)]
: remaining.find((tab) => tab.id === activeTabIdRef.current)
setTabs(remaining)
if (nextActive) {
setActiveTabId(nextActive.id)
const nextEntry = getTabCurrentEntry(nextActive)
if (!entriesEqual(nextEntry, location)) {
restoreToEntry(nextEntry)
}
}
return true
},
[handleWindowControl, location, restoreToEntry]
)
const closeTab = useCallback(
(tabId) => {
removeTab(tabId, { closeWindowIfLast: true })
},
[removeTab]
)
const acceptIncomingTab = useCallback(
(incomingTab, targetId, insertBefore = false) => {
if (!incomingTab?.id) return
setTabs((current) => {
if (current.some((tab) => tab.id === incomingTab.id)) {
return current
}
const next = [...current]
const targetIndex = next.findIndex((tab) => tab.id === targetId)
if (targetIndex === -1) {
next.push(incomingTab)
return next
}
next.splice(insertBefore ? targetIndex : targetIndex + 1, 0, incomingTab)
return next
})
setActiveTabId(incomingTab.id)
const nextEntry = getTabCurrentEntry(incomingTab)
if (!entriesEqual(nextEntry, location)) {
restoreToEntry(nextEntry)
}
},
[location, restoreToEntry]
)
const handleTabDragStart = useCallback(
(tabId) => {
const tab = tabsRef.current.find((item) => item.id === tabId)
if (!tab) return
void beginTabDrag?.({
windowId: getDesktopWindowId(),
tab
})
},
[beginTabDrag]
)
const handleTabDragEnd = useCallback(() => {
window.setTimeout(() => {
void cancelTabDrag?.({ windowId: getDesktopWindowId() })
}, 150)
}, [cancelTabDrag])
const handleExternalTabDrop = useCallback(
async (targetId, insertBefore) => {
const result = await completeTabDrop?.({
windowId: getDesktopWindowId(),
beforeTabId: targetId,
insertBefore
})
if (!result?.ok || result.sameWindow || !result.tab) {
return
}
acceptIncomingTab(result.tab, targetId, insertBefore)
},
[acceptIncomingTab, completeTabDrop]
)
const reorderTabs = useCallback((draggedId, targetId, insertBefore) => {
setTabs((current) => {
const fromIndex = current.findIndex(
(tab) => String(tab.id) === String(draggedId)
)
const toIndex = current.findIndex(
(tab) => String(tab.id) === String(targetId)
)
if (fromIndex === -1 || toIndex === -1 || fromIndex === toIndex) {
return current
}
const next = [...current]
const [moved] = next.splice(fromIndex, 1)
let insertIndex = next.findIndex(
(tab) => String(tab.id) === String(targetId)
)
if (!insertBefore) {
insertIndex += 1
}
next.splice(insertIndex, 0, moved)
return next
})
}, [])
const setTabPage = useCallback(({ title, modelName } = {}) => {
const activeId = activeTabIdRef.current
if (!activeId) return
setTabs((current) =>
current.map((tab) => {
if (tab.id !== activeId) return tab
return {
...tab,
title: title || tab.title,
modelName:
modelName === undefined ? tab.modelName : modelName || null
}
})
)
}, [])
const goBack = useCallback(() => {
const currentTab = tabsRef.current.find(
(tab) => tab.id === activeTabIdRef.current
)
if (!currentTab || currentTab.historyIndex <= 0) return
const nextIndex = currentTab.historyIndex - 1
const nextEntry = currentTab.history[nextIndex]
setTabs((current) =>
current.map((tab) =>
tab.id === currentTab.id ? { ...tab, historyIndex: nextIndex } : tab
)
)
restoreToEntry(nextEntry)
}, [restoreToEntry])
const goForward = useCallback(() => {
const currentTab = tabsRef.current.find(
(tab) => tab.id === activeTabIdRef.current
)
if (!currentTab) return
if (currentTab.historyIndex >= currentTab.history.length - 1) return
const nextIndex = currentTab.historyIndex + 1
const nextEntry = currentTab.history[nextIndex]
setTabs((current) =>
current.map((tab) =>
tab.id === currentTab.id ? { ...tab, historyIndex: nextIndex } : tab
)
)
restoreToEntry(nextEntry)
}, [restoreToEntry])
const createNewWindow = useCallback(() => {
const currentTab = tabsRef.current.find(
(tab) => tab.id === activeTabIdRef.current
)
const nextTab = cloneCurrentPageTab(currentTab, location)
void createAppWindow?.({
tabs: [nextTab],
activeTabId: nextTab.id
})
}, [createAppWindow, location])
useEffect(() => {
if (!isElectron) {
setHydrated(true)
return undefined
}
let cancelled = false
const hydrate = async () => {
const deadline = Date.now() + 2000
while (!getDesktopWindowId() && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 20))
}
const session = await getWindowSession?.()
if (cancelled) return
if (session?.tabs?.length) {
isRestoringRef.current = true
setTabs(session.tabs)
const nextActiveId =
session.activeTabId &&
session.tabs.some((tab) => tab.id === session.activeTabId)
? session.activeTabId
: session.tabs[0].id
setActiveTabId(nextActiveId)
const activeTab =
session.tabs.find((tab) => tab.id === nextActiveId) ||
session.tabs[0]
const entry = getTabCurrentEntry(activeTab)
if (!entriesEqual(entry, location)) {
restoreToEntry(entry)
} else {
isRestoringRef.current = false
}
} else {
const initialTab = createTabFromLocation(location)
setTabs([initialTab])
setActiveTabId(initialTab.id)
}
setHydrated(true)
}
void hydrate()
return () => {
cancelled = true
}
// Hydrate once per window on mount.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isElectron])
useEffect(() => {
if (!isElectron || !hydrated) return
if (isRestoringRef.current) {
isRestoringRef.current = false
return
}
const entry = locationToEntry(location)
setTabs((current) => {
if (current.length === 0) {
const initialTab = createTabFromLocation(location)
setActiveTabId(initialTab.id)
return [initialTab]
}
return current.map((tab) => {
if (tab.id !== activeTabIdRef.current) return tab
const currentEntry = getTabCurrentEntry(tab)
if (entriesEqual(currentEntry, entry)) return tab
const truncated = tab.history.slice(0, tab.historyIndex + 1)
return {
...tab,
history: [...truncated, entry],
historyIndex: truncated.length
}
})
})
}, [hydrated, isElectron, location])
useEffect(() => {
if (!isElectron || !hydrated || !syncWindowTabs) return undefined
const timeoutId = setTimeout(() => {
void syncWindowTabs({
windowId: getDesktopWindowId(),
activeTabId,
tabs
})
}, 200)
return () => clearTimeout(timeoutId)
}, [activeTabId, hydrated, isElectron, syncWindowTabs, tabs])
useEffect(() => {
if (!isElectron || !registerTabHistoryHandler) return undefined
registerTabHistoryHandler((direction) => {
if (direction === 'back') goBack()
if (direction === 'forward') goForward()
})
return () => registerTabHistoryHandler(null)
}, [goBack, goForward, isElectron, registerTabHistoryHandler])
useEffect(() => {
if (!isElectron || !onNewTabRequest) return undefined
return onNewTabRequest(() => addTab())
}, [addTab, isElectron, onNewTabRequest])
useEffect(() => {
if (!isElectron || !onNewWindowRequest) return undefined
return onNewWindowRequest(() => createNewWindow())
}, [createNewWindow, isElectron, onNewWindowRequest])
useEffect(() => {
if (!isElectron || !onTabMovedAway) return undefined
return onTabMovedAway(({ tabId } = {}) => {
if (!tabId) return
removeTab(tabId, { closeWindowIfLast: true })
})
}, [isElectron, onTabMovedAway, removeTab])
const activeTab = useMemo(
() => tabs.find((tab) => tab.id === activeTabId) || null,
[activeTabId, tabs]
)
const canGoBack = (activeTab?.historyIndex || 0) > 0
const canGoForward =
(activeTab?.historyIndex || 0) < (activeTab?.history?.length || 1) - 1
const value = useMemo(
() => ({
tabs,
activeTabId,
activeTab,
hydrated,
isElectron,
selectTab,
addTab,
closeTab,
reorderTabs,
acceptIncomingTab,
handleTabDragStart,
handleTabDragEnd,
handleExternalTabDrop,
setTabPage,
goBack,
goForward,
canGoBack,
canGoForward,
createNewWindow
}),
[
activeTab,
activeTabId,
addTab,
canGoBack,
canGoForward,
closeTab,
createNewWindow,
goBack,
goForward,
handleExternalTabDrop,
handleTabDragEnd,
handleTabDragStart,
acceptIncomingTab,
hydrated,
isElectron,
reorderTabs,
selectTab,
setTabPage,
tabs
]
)
return (
<NavigationTabsContext.Provider value={value}>
{children}
</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 useNavigationTabPage = ({ title, modelName } = {}) => {
const { setTabPage, isElectron } = useNavigationTabs()
const isTabActive = useContext(NavigationTabActiveContext)
useEffect(() => {
if (!isElectron || !title || !isTabActive) return
setTabPage({ title, modelName })
}, [isElectron, isTabActive, modelName, setTabPage, title])
}
// eslint-disable-next-line react-refresh/only-export-components
export const useIsNavigationTabActive = () =>
useContext(NavigationTabActiveContext)
NavigationTabsProvider.propTypes = {
children: PropTypes.node.isRequired
}
export default NavigationTabsContext

View File

@ -14,6 +14,8 @@ import { AuthContext } from './AuthContext'
import { useTableState } from './TableStateContext' import { useTableState } from './TableStateContext'
import useViewMode from '../hooks/useViewMode' import useViewMode from '../hooks/useViewMode'
import { DEFAULT_VIEW_MODE, normalizeViewMode } from '../common/viewModeUtils' import { DEFAULT_VIEW_MODE, normalizeViewMode } from '../common/viewModeUtils'
import { getModelByName } from '../../../database/ObjectModels'
import { useNavigationTabPage } from './NavigationTabsContext'
const ObjectListViewContext = createContext() const ObjectListViewContext = createContext()
@ -127,6 +129,11 @@ export const ObjectListViewProvider = ({ children, objectType, tableRef }) => {
} = useContext(ApiServerContext) } = useContext(ApiServerContext)
const { token, authInitialized } = useContext(AuthContext) const { token, authInitialized } = useContext(AuthContext)
const { getViewFromUrl, persistView, setObjectListView } = useTableState() const { getViewFromUrl, persistView, setObjectListView } = useTableState()
const listModel = getModelByName(objectType)
useNavigationTabPage({
title: listModel?.label ? `List - ${listModel.label}` : 'List',
modelName: objectType
})
const [views, setViews] = useState(() => readCachedViews(objectType) || []) const [views, setViews] = useState(() => readCachedViews(objectType) || [])
const [draftViews, setDraftViews] = useState(() => { const [draftViews, setDraftViews] = useState(() => {

View File

@ -121,7 +121,8 @@ export const APP_SETTING_NAMES = [
'appTheme', 'appTheme',
'appShowNavigationLabels', 'appShowNavigationLabels',
'appUpdateBranch', 'appUpdateBranch',
'appUpdateEngine' 'appUpdateEngine',
'appResumeLastSession'
] ]
const themeOptions = [ const themeOptions = [
@ -208,6 +209,17 @@ const settings = {
{ label: 'Show', value: true }, { label: 'Show', value: true },
{ label: 'Hide', value: false } { label: 'Hide', value: false }
] ]
},
{
name: 'appResumeLastSession',
label: 'Resume Last Session',
type: 'select',
required: true,
defaultValue: true,
options: [
{ label: 'Resume last session', value: true },
{ label: 'Start fresh', value: false }
]
} }
] ]
}, },

View File

@ -51,7 +51,20 @@ function buildEditMenu() {
function buildFileMenu() { function buildFileMenu() {
return { return {
label: "File", label: "File",
submenu: [{ role: "close" }], submenu: [
{
label: "New Window",
accelerator: "CommandOrControl+N",
action: "new-window",
},
{
label: "New Tab",
accelerator: "CommandOrControl+T",
action: "new-tab",
},
{ type: "separator" },
{ role: "close" },
],
}; };
} }
@ -153,6 +166,8 @@ export function setupApplicationMenuEvents({
onNavigate, onNavigate,
onToggleDevTools, onToggleDevTools,
onCheckForUpdates, onCheckForUpdates,
onNewTab,
onNewWindow,
}) { }) {
if (process.platform !== "darwin") { if (process.platform !== "darwin") {
return; return;
@ -174,6 +189,16 @@ export function setupApplicationMenuEvents({
return; return;
} }
if (action === "new-tab") {
onNewTab?.();
return;
}
if (action === "new-window") {
onNewWindow?.();
return;
}
if (action.startsWith(SIDEBAR_MENU_ACTION_PREFIX)) { if (action.startsWith(SIDEBAR_MENU_ACTION_PREFIX)) {
const path = action.slice(SIDEBAR_MENU_ACTION_PREFIX.length); const path = action.slice(SIDEBAR_MENU_ACTION_PREFIX.length);
navigateHandler?.(path || "/"); navigateHandler?.(path || "/");

View File

@ -1,9 +1,37 @@
let sendMessage = () => false; let sendToFocusedImpl = () => false
let sendToWindowImpl = () => false
let sendToAllImpl = () => false
export function configureWindowMessaging({
sendToFocused,
sendToWindow,
sendToAll
}) {
if (typeof sendToFocused === 'function') {
sendToFocusedImpl = sendToFocused
}
if (typeof sendToWindow === 'function') {
sendToWindowImpl = sendToWindow
}
if (typeof sendToAll === 'function') {
sendToAllImpl = sendToAll
}
}
export function setMessageSender(sender) { export function setMessageSender(sender) {
sendMessage = sender; if (typeof sender === 'function') {
sendToFocusedImpl = sender
}
} }
export function sendToRenderer(channel, data) { export function sendToRenderer(channel, data) {
return sendMessage(channel, data); return sendToFocusedImpl(channel, data)
}
export function sendToWindow(windowId, channel, data) {
return sendToWindowImpl(windowId, channel, data)
}
export function sendToAllWindows(channel, data) {
return sendToAllImpl(channel, data)
} }

View File

@ -9,7 +9,7 @@ import {
removeDuplicateInstallations removeDuplicateInstallations
} from './check-duplicate-installations.js' } from './check-duplicate-installations.js'
import { setSidebarViewMenu } from './menu.js' import { setSidebarViewMenu } from './menu.js'
import { sendToRenderer } from './notify.js' import { sendToAllWindows, sendToRenderer, sendToWindow } from './notify.js'
import { resizeSpotlightWindow } from './spotlight.js' import { resizeSpotlightWindow } from './spotlight.js'
import { import {
clearAuthSession, clearAuthSession,
@ -19,11 +19,17 @@ import {
setAuthSession setAuthSession
} from './store.js' } from './store.js'
import { import {
createAppWindow,
getMainWindow, getMainWindow,
getWindowSession,
getWindowState, getWindowState,
handleWindowControl, handleWindowControl,
openExternalUrl, openExternalUrl,
openInternalUrl openInternalUrl,
syncWindowTabs,
beginTabDrag,
completeTabDrop,
cancelTabDrag
} from './window.js' } from './window.js'
export function createAppRpc() { export function createAppRpc() {
@ -31,11 +37,32 @@ export function createAppRpc() {
getOsInfo: async () => ({ getOsInfo: async () => ({
platform: process.platform platform: process.platform
}), }),
getWindowState: async () => getWindowState(), getWindowState: async ({ windowId } = {}) => getWindowState(windowId),
windowControl: async ({ action }) => { windowControl: async ({ action, windowId } = {}) => {
handleWindowControl(action) handleWindowControl(action, windowId)
return { ok: true } return { ok: true }
}, },
syncWindowTabs: async ({ windowId, activeTabId, tabs } = {}) => ({
ok: syncWindowTabs(windowId, { activeTabId, tabs })
}),
getWindowSession: async ({ windowId } = {}) => getWindowSession(windowId),
createAppWindow: async ({
tabs,
activeTabId,
windowId
} = {}) => {
await createAppWindow({
tabs,
activeTabId,
sourceWindowId: windowId
})
return { ok: true }
},
beginTabDrag: async ({ windowId, tab } = {}) =>
beginTabDrag({ windowId, tab }),
completeTabDrop: async ({ windowId, beforeTabId, insertBefore } = {}) =>
completeTabDrop({ windowId, beforeTabId, insertBefore }),
cancelTabDrag: async ({ windowId } = {}) => cancelTabDrag({ windowId }),
openExternalUrl: async ({ url }) => { openExternalUrl: async ({ url }) => {
openExternalUrl(url) openExternalUrl(url)
return { ok: true } return { ok: true }
@ -57,7 +84,7 @@ export function createAppRpc() {
startAppUpdate: async ({ update }) => { startAppUpdate: async ({ update }) => {
const mainWindow = getMainWindow() const mainWindow = getMainWindow()
const sendProgress = (payload) => { const sendProgress = (payload) => {
sendToRenderer('appUpdateProgress', { sendToAllWindows('appUpdateProgress', {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
...payload ...payload
}) })
@ -89,7 +116,7 @@ export function createAppRpc() {
error?.message || 'Failed to remove the duplicate installation.' error?.message || 'Failed to remove the duplicate installation.'
})) }))
.then((result) => { .then((result) => {
sendToRenderer('duplicateInstallationsRemoved', result) sendToAllWindows('duplicateInstallationsRemoved', result)
}) })
return { ok: true } return { ok: true }
}, },
@ -106,10 +133,20 @@ export function createAppRpc() {
handlers: { handlers: {
requests: requestHandlers, requests: requestHandlers,
messages: { messages: {
rendererRequest: ({ id, method, params }) => { rendererRequest: ({ id, method, params, windowId }) => {
const requestParams = params || {}
const requestWindowId = windowId || requestParams.windowId
const respond = (payload) => {
if (requestWindowId) {
sendToWindow(requestWindowId, 'rpcResponse', payload)
return
}
sendToRenderer('rpcResponse', payload)
}
const handler = requestHandlers[method] const handler = requestHandlers[method]
if (!handler) { if (!handler) {
sendToRenderer('rpcResponse', { respond({
id, id,
success: false, success: false,
error: `Unknown desktop RPC method: ${String(method)}` error: `Unknown desktop RPC method: ${String(method)}`
@ -118,16 +155,16 @@ export function createAppRpc() {
} }
void Promise.resolve() void Promise.resolve()
.then(() => handler(params || {})) .then(() => handler(requestParams))
.then((result) => { .then((result) => {
sendToRenderer('rpcResponse', { respond({
id, id,
success: true, success: true,
result result
}) })
}) })
.catch((error) => { .catch((error) => {
sendToRenderer('rpcResponse', { respond({
id, id,
success: false, success: false,
error: error?.message || String(error) error: error?.message || String(error)

70
src/desktop/session.js Normal file
View File

@ -0,0 +1,70 @@
import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { Utils } from 'electrobun/bun'
const SESSION_FILE = 'session.json'
function getSessionPath() {
return path.join(Utils.paths.userData, SESSION_FILE)
}
async function ensureUserDataDir() {
await mkdir(Utils.paths.userData, { recursive: true })
}
export function createEmptySession() {
return { windows: [] }
}
export async function getWindowSessionFile() {
try {
await ensureUserDataDir()
const sessionPath = getSessionPath()
if (!existsSync(sessionPath)) {
return createEmptySession()
}
const raw = await readFile(sessionPath, 'utf8')
const parsed = JSON.parse(raw)
if (
!parsed ||
typeof parsed !== 'object' ||
!Array.isArray(parsed.windows)
) {
return createEmptySession()
}
return parsed
} catch (error) {
console.warn('[window-session] Failed to read session.', error)
return createEmptySession()
}
}
export async function writeWindowSessionFile(session) {
if (!session || typeof session !== 'object') return false
try {
await ensureUserDataDir()
await writeFile(getSessionPath(), JSON.stringify(session, null, 2))
return true
} catch (error) {
console.warn('[window-session] Failed to write session.', error)
return false
}
}
export function writeWindowSessionFileSync(session) {
if (!session || typeof session !== 'object') return false
try {
mkdirSync(Utils.paths.userData, { recursive: true })
writeFileSync(getSessionPath(), JSON.stringify(session, null, 2))
return true
} catch (error) {
console.warn(
'[window-session] Failed to write session synchronously.',
error
)
return false
}
}

View File

@ -4,37 +4,126 @@ import {
applyMacOSWindowEffects, applyMacOSWindowEffects,
MAC_TRAFFIC_LIGHT_OFFSET MAC_TRAFFIC_LIGHT_OFFSET
} from './macos-window-effects.js' } from './macos-window-effects.js'
import { sendToRenderer, setMessageSender } from './notify.js' import {
configureWindowMessaging,
sendToRenderer,
sendToWindow
} from './notify.js'
import { import {
clampWindowToWorkArea, clampWindowToWorkArea,
isWindowWorkAreaMaximized isWindowWorkAreaMaximized
} from './windows-work-area.js' } from './windows-work-area.js'
import { findProtocolUrl, setSingleInstanceHandlers } from './single-instance.js'
import { getAppSettings } from './store.js'
import {
getWindowSessionFile,
writeWindowSessionFile,
writeWindowSessionFileSync
} from './session.js'
const isMacOS = process.platform === 'darwin' const isMacOS = process.platform === 'darwin'
const isWindows = process.platform === 'win32' const isWindows = process.platform === 'win32'
const WINDOWS_STARTUP_MAXIMIZE_DELAY_MS = 1500 const WINDOWS_STARTUP_MAXIMIZE_DELAY_MS = 1500
const DEV_SERVER_PORT = 5780 const DEV_SERVER_PORT = 5780
const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}` const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}`
import { findProtocolUrl, setSingleInstanceHandlers } from './single-instance.js' const DEFAULT_DASHBOARD_PATH = '/dashboard/production/overview'
const PROTOCOL_PREFIX = 'farmcontrol://'
let mainWindow = null const windows = new Map()
let webviewDomReady = false let focusedWindowId = null
const pendingNavigations = [] let sharedRpc = null
let appListenersBound = false
let persistTimer = null
export function getDefaultDashboardPath() {
return DEFAULT_DASHBOARD_PATH
}
function createWindowId() {
return `window-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
}
function getEntry(windowId) {
return windowId ? windows.get(windowId) : null
}
export function getFocusedWindowId() {
if (focusedWindowId && windows.has(focusedWindowId)) {
return focusedWindowId
}
const first = windows.keys().next()
return first.done ? null : first.value
}
export function getWindowById(windowId) {
return getEntry(windowId)?.window || null
}
export function getMainWindow() { export function getMainWindow() {
return mainWindow return getWindowById(getFocusedWindowId())
}
function dispatchToWindow(windowId, channel, data) {
const entry = getEntry(windowId)
const window = entry?.window
if (!window) return false
try {
const webview = window.webview
const channelLiteral = JSON.stringify(channel)
const payloadLiteral = JSON.stringify(data ?? null)
if (webview?.executeJavascript) {
webview.executeJavascript(
`window.__farmcontrolDispatchRpcMessage?.(${channelLiteral}, ${payloadLiteral})`
)
return true
}
const send = webview?.rpc?.send
if (!send) {
console.warn(
`No RPC sender available for channel: ${channel}. Is the window ready?`
)
return false
}
send[channel](data)
return true
} catch (error) {
console.warn(`Failed to send RPC message on channel: ${channel}`, error)
return false
}
}
function setupMessaging() {
configureWindowMessaging({
sendToFocused: (channel, data) =>
dispatchToWindow(getFocusedWindowId(), channel, data),
sendToWindow: (windowId, channel, data) =>
dispatchToWindow(windowId, channel, data),
sendToAll: (channel, data) => {
let sent = false
for (const id of windows.keys()) {
if (dispatchToWindow(id, channel, data)) {
sent = true
}
}
return sent
}
})
} }
export function showMainWindow() { export function showMainWindow() {
if (!mainWindow) return const window = getMainWindow()
if (!window) return
if (mainWindow.isMinimized?.()) { if (window.isMinimized?.()) {
mainWindow.restore?.() window.restore?.()
} }
mainWindow.show?.() window.show?.()
mainWindow.activate?.() window.activate?.()
} }
export async function getMainViewUrl() { export async function getMainViewUrl() {
@ -54,27 +143,26 @@ export async function getMainViewUrl() {
return 'views://mainview/index.html' return 'views://mainview/index.html'
} }
function deliverNavigation(redirectPath) { function deliverNavigation(windowId, redirectPath) {
sendToRenderer('navigate', redirectPath) sendToWindow(windowId, 'navigate', redirectPath)
const window = getWindowById(windowId)
if (!window) return
if (!mainWindow) return if (window.isMinimized?.()) {
window.restore?.()
if (mainWindow.isMinimized?.()) {
mainWindow.restore?.()
} }
mainWindow.show?.() window.show?.()
mainWindow.activate?.() window.activate?.()
} }
function flushPendingNavigations() { function flushPendingNavigations(windowId) {
if (!mainWindow || !webviewDomReady) { const entry = getEntry(windowId)
return if (!entry?.domReady) return
}
while (pendingNavigations.length > 0) { while (entry.pendingNavigations.length > 0) {
const redirectPath = pendingNavigations.shift() const redirectPath = entry.pendingNavigations.shift()
setTimeout(() => deliverNavigation(redirectPath), 100) setTimeout(() => deliverNavigation(windowId, redirectPath), 100)
} }
} }
@ -83,12 +171,16 @@ function sendNavigateToRenderer(redirectPath) {
return return
} }
if (!mainWindow || !webviewDomReady) { const windowId = getFocusedWindowId()
pendingNavigations.push(redirectPath) if (!windowId) return
const entry = getEntry(windowId)
if (!entry?.domReady) {
entry?.pendingNavigations.push(redirectPath)
return return
} }
setTimeout(() => deliverNavigation(redirectPath), 100) setTimeout(() => deliverNavigation(windowId, redirectPath), 100)
} }
export function openInternalUrl(url) { export function openInternalUrl(url) {
@ -96,8 +188,6 @@ export function openInternalUrl(url) {
return true return true
} }
const PROTOCOL_PREFIX = 'farmcontrol://'
function parseDeepLinkPath(url) { function parseDeepLinkPath(url) {
if (!url || typeof url !== 'string') { if (!url || typeof url !== 'string') {
return null return null
@ -143,8 +233,157 @@ export function handleDeepLinkFromArgv(launchUrl) {
} }
} }
function broadcastWindowState() { function readWindowSize(window) {
sendToRenderer('windowState', getWindowState()) const size = window?.getSize?.()
if (size && typeof size === 'object') {
return {
width: size.width ?? size[0],
height: size.height ?? size[1]
}
}
return { width: undefined, height: undefined }
}
function readWindowPosition(window) {
const position = window?.getPosition?.()
if (position && typeof position === 'object') {
return {
x: position.x ?? position[0],
y: position.y ?? position[1]
}
}
return { x: undefined, y: undefined }
}
function captureWindowLayout(windowId) {
const entry = getEntry(windowId)
if (!entry?.window) return
const { width, height } = readWindowSize(entry.window)
const { x, y } = readWindowPosition(entry.window)
if (width && height) {
entry.bounds = {
x: x ?? entry.bounds?.x ?? 100,
y: y ?? entry.bounds?.y ?? 100,
width,
height
}
}
entry.isFullScreen = entry.window.isFullScreen?.() ?? false
entry.isMaximized = isWindows
? isWindowWorkAreaMaximized(entry.window)
: (entry.window.isMaximized?.() ?? false)
}
function serializeWindows() {
return {
windows: [...windows.values()].map((entry) => ({
windowId: entry.id,
bounds: entry.bounds,
isMaximized: entry.isMaximized,
isFullScreen: entry.isFullScreen,
activeTabId: entry.activeTabId,
tabs: entry.tabs
}))
}
}
export function persistSessionNow() {
clearTimeout(persistTimer)
persistTimer = null
writeWindowSessionFileSync(serializeWindows())
}
function scheduleSessionPersist() {
clearTimeout(persistTimer)
persistTimer = setTimeout(() => {
void writeWindowSessionFile(serializeWindows())
}, 400)
}
export function syncWindowTabs(windowId, { activeTabId, tabs } = {}) {
const entry = getEntry(windowId) || getEntry(getFocusedWindowId())
if (!entry) return false
if (Array.isArray(tabs)) {
entry.tabs = tabs
}
if (activeTabId) {
entry.activeTabId = activeTabId
}
captureWindowLayout(entry.id)
scheduleSessionPersist()
return true
}
export function getWindowSession(windowId) {
const entry = getEntry(windowId) || getEntry(getFocusedWindowId())
if (!entry) {
return { windowId: null, tabs: [], activeTabId: null }
}
return {
windowId: entry.id,
tabs: entry.tabs,
activeTabId: entry.activeTabId,
bounds: entry.bounds,
isMaximized: entry.isMaximized,
isFullScreen: entry.isFullScreen
}
}
let pendingTabDrag = null
export function beginTabDrag({ windowId, tab } = {}) {
if (!tab?.id) return { ok: false }
pendingTabDrag = {
windowId: windowId || getFocusedWindowId(),
tab,
startedAt: Date.now()
}
return { ok: true }
}
export function completeTabDrop({
windowId,
beforeTabId,
insertBefore = false
} = {}) {
const pending = pendingTabDrag
pendingTabDrag = null
if (!pending?.tab) {
return { ok: false }
}
const targetWindowId = windowId || getFocusedWindowId()
if (pending.windowId && pending.windowId === targetWindowId) {
return { ok: true, sameWindow: true }
}
sendToWindow(pending.windowId, 'tabMovedAway', { tabId: pending.tab.id })
return {
ok: true,
sameWindow: false,
tab: pending.tab,
sourceWindowId: pending.windowId,
beforeTabId,
insertBefore
}
}
export function cancelTabDrag({ windowId } = {}) {
if (!pendingTabDrag) return { ok: true }
if (windowId && pendingTabDrag.windowId !== windowId) {
return { ok: true }
}
pendingTabDrag = null
return { ok: true }
}
function broadcastWindowState(windowId) {
const id = windowId || getFocusedWindowId()
if (!id) return
sendToWindow(id, 'windowState', getWindowState(id))
} }
function applyStartupWindowState(window) { function applyStartupWindowState(window) {
@ -154,7 +393,7 @@ function applyStartupWindowState(window) {
window.maximize?.() window.maximize?.()
} }
setTimeout(broadcastWindowState, 100) setTimeout(() => broadcastWindowState(), 100)
} }
function syncWindowsWebviewLayout(window) { function syncWindowsWebviewLayout(window) {
@ -167,80 +406,115 @@ function syncWindowsWebviewLayout(window) {
return return
} }
// Re-apply the current size so the native webview relayouts to the window.
window.setSize(width, height) window.setSize(width, height)
} }
function handleWindowsWindowChange(window) { function handleWindowsWindowChange(window, windowId) {
if (clampWindowToWorkArea(window)) { if (clampWindowToWorkArea(window)) {
syncWindowsWebviewLayout(window) syncWindowsWebviewLayout(window)
} }
broadcastWindowState() captureWindowLayout(windowId)
broadcastWindowState(windowId)
} }
function applyWindowsStartupWindowState(window) { function applyWindowsStartupWindowState(window, windowId) {
if (!window) return if (!window) return
setTimeout(() => { setTimeout(() => {
window.maximize?.() window.maximize?.()
// maximize() settles asynchronously; sync state after the frame updates. setTimeout(() => handleWindowsWindowChange(window, windowId), 100)
setTimeout(() => handleWindowsWindowChange(window), 100)
}, WINDOWS_STARTUP_MAXIMIZE_DELAY_MS) }, WINDOWS_STARTUP_MAXIMIZE_DELAY_MS)
} }
function setupWindowEvents(window) { function setupWindowEvents(window, windowId) {
// Electrobun emits resize/focus, not Electron's maximize/fullscreen events.
const onWindowChange = isWindows const onWindowChange = isWindows
? () => handleWindowsWindowChange(window) ? () => handleWindowsWindowChange(window, windowId)
: broadcastWindowState : () => {
captureWindowLayout(windowId)
broadcastWindowState(windowId)
scheduleSessionPersist()
}
window.on?.('resize', onWindowChange) window.on?.('resize', onWindowChange)
window.on?.('focus', broadcastWindowState)
window.on?.('move', onWindowChange) window.on?.('move', onWindowChange)
} window.on?.('focus', () => {
focusedWindowId = windowId
export function setupMainWindowMessaging(window = mainWindow) { broadcastWindowState(windowId)
if (!window) { })
return window.on?.('close', () => {
} captureWindowLayout(windowId)
windows.delete(windowId)
setMessageSender((channel, data) => { if (focusedWindowId === windowId) {
try { focusedWindowId = getFocusedWindowId()
const webview = window.webview
const channelLiteral = JSON.stringify(channel)
const payloadLiteral = JSON.stringify(data ?? null)
// Prefer direct JS dispatch — WebSocket RPC pushes can be deferred by
// WKWebView until the next interaction during native window transitions.
if (webview?.executeJavascript) {
webview.executeJavascript(
`window.__farmcontrolDispatchRpcMessage?.(${channelLiteral}, ${payloadLiteral})`
)
return true
}
const send = webview?.rpc?.send
if (!send) {
console.warn(
`No RPC sender available for channel: ${channel}. Is the window ready?`
)
return false
}
send[channel](data)
return true
} catch (error) {
console.warn(`Failed to send RPC message on channel: ${channel}`, error)
return false
} }
persistSessionNow()
}) })
} }
export async function createMainWindow(rpc) { function injectWindowId(window, windowId) {
try {
window?.webview?.executeJavascript?.(
`window.__farmcontrolWindowId = ${JSON.stringify(windowId)}`
)
} catch (error) {
console.warn('[window] Failed to inject window id.', error)
}
}
function createDefaultTab() {
const id = `tab-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
return {
id,
title: 'Overview - Production',
modelName: null,
history: [
{
pathname: DEFAULT_DASHBOARD_PATH,
search: '',
hash: ''
}
],
historyIndex: 0
}
}
function offsetBounds(bounds) {
if (!bounds) {
return { x: 120, y: 120, width: 1200, height: 800 }
}
return {
x: (bounds.x ?? 100) + 32,
y: (bounds.y ?? 100) + 32,
width: bounds.width || 1200,
height: bounds.height || 800
}
}
export async function createDesktopWindow({
rpc = sharedRpc,
windowId = createWindowId(),
bounds,
isMaximized = false,
isFullScreen = false,
tabs,
activeTabId,
applyDefaultMaximize = false
} = {}) {
if (!rpc) {
throw new Error('Cannot create a desktop window without RPC.')
}
sharedRpc = rpc
setupMessaging()
const initialTabs =
Array.isArray(tabs) && tabs.length > 0 ? tabs : [createDefaultTab()]
const initialActiveTabId = activeTabId || initialTabs[0].id
const frame = bounds || { width: 1200, height: 800, x: 100, y: 100 }
const url = await getMainViewUrl() const url = await getMainViewUrl()
mainWindow = new BrowserWindow({ const window = new BrowserWindow({
title: 'Farm Control', title: 'Farm Control',
url, url,
rpc, rpc,
@ -249,56 +523,147 @@ export async function createMainWindow(rpc) {
? { transparent: true, trafficLightOffset: MAC_TRAFFIC_LIGHT_OFFSET } ? { transparent: true, trafficLightOffset: MAC_TRAFFIC_LIGHT_OFFSET }
: {}), : {}),
frame: { frame: {
width: 1200, width: frame.width || 1200,
height: 800, height: frame.height || 800,
x: 100, x: frame.x ?? 100,
y: 100 y: frame.y ?? 100
} }
}) })
const entry = {
id: windowId,
window,
tabs: initialTabs,
activeTabId: initialActiveTabId,
bounds: frame,
isMaximized: Boolean(isMaximized),
isFullScreen: Boolean(isFullScreen),
domReady: false,
pendingNavigations: []
}
windows.set(windowId, entry)
focusedWindowId = windowId
injectWindowId(window, windowId)
if (isMacOS) { if (isMacOS) {
applyMacOSWindowEffects(mainWindow) applyMacOSWindowEffects(window)
} }
setupMainWindowMessaging(mainWindow) setupAppListeners()
setupWindowEvents(window, windowId)
setupNavigationGestures(window, windowId)
if (applyDefaultMaximize) {
applyStartupWindowState(window)
} else if (isMaximized) {
window.maximize?.()
setTimeout(() => broadcastWindowState(windowId), 100)
} else if (isFullScreen) {
window.setFullScreen?.(true)
setTimeout(() => broadcastWindowState(windowId), 100)
} else {
setTimeout(() => broadcastWindowState(windowId), 100)
}
return new Promise((resolve) => {
window.webview.on('dom-ready', () => {
entry.domReady = true
injectWindowId(window, windowId)
if (isWindows && applyDefaultMaximize) {
applyWindowsStartupWindowState(window, windowId)
}
flushPendingNavigations(windowId)
resolve(window)
})
})
}
export async function createAppWindow({
tabs,
activeTabId,
sourceWindowId
} = {}) {
const source = getEntry(sourceWindowId) || getEntry(getFocusedWindowId())
captureWindowLayout(source?.id)
return createDesktopWindow({
rpc: sharedRpc,
bounds: offsetBounds(source?.bounds),
tabs,
activeTabId,
applyDefaultMaximize: false
})
}
function setupAppListeners() {
if (appListenersBound) return
appListenersBound = true
if (isMacOS) { if (isMacOS) {
applyApplicationMenu() applyApplicationMenu()
setupApplicationMenuEvents({ setupApplicationMenuEvents({
onNavigate: sendNavigateToRenderer, onNavigate: sendNavigateToRenderer,
onToggleDevTools: () => { onToggleDevTools: () => {
mainWindow?.webview?.toggleDevTools?.() getMainWindow()?.webview?.toggleDevTools?.()
}, },
onCheckForUpdates: () => { onCheckForUpdates: () => {
sendToRenderer('checkForUpdates') sendToRenderer('checkForUpdates')
},
onNewTab: () => {
sendToRenderer('newTab')
},
onNewWindow: () => {
sendToRenderer('newWindow')
} }
}) })
} }
setupWindowEvents(mainWindow)
applyStartupWindowState(mainWindow)
Electrobun.events.on('open-url', (event) => { Electrobun.events.on('open-url', (event) => {
const url = event?.data?.url const url = event?.data?.url
if (url) { if (url) {
handleDeepLink(url) handleDeepLink(url)
} }
}) })
}
return new Promise((resolve) => { export async function startDesktopWindows(rpc) {
mainWindow.webview.on('dom-ready', () => { sharedRpc = rpc
webviewDomReady = true setupMessaging()
if (isWindows) { const settings = await getAppSettings()
applyWindowsStartupWindowState(mainWindow) const resumeLastSession = settings.appResumeLastSession !== false
} const saved = resumeLastSession
? await getWindowSessionFile()
: { windows: [] }
const savedWindows = Array.isArray(saved.windows) ? saved.windows : []
flushPendingNavigations() if (savedWindows.length > 0) {
resolve(mainWindow) for (const savedWindow of savedWindows) {
}) await createDesktopWindow({
rpc,
windowId: savedWindow.windowId || createWindowId(),
bounds: savedWindow.bounds,
isMaximized: savedWindow.isMaximized,
isFullScreen: savedWindow.isFullScreen,
tabs: savedWindow.tabs,
activeTabId: savedWindow.activeTabId,
applyDefaultMaximize: false
})
}
return getMainWindow()
}
return createDesktopWindow({
rpc,
applyDefaultMaximize: true
}) })
} }
export async function createMainWindow(rpc) {
return startDesktopWindows(rpc)
}
export async function setupDevAuthServer() { export async function setupDevAuthServer() {
const env = (process.env.NODE_ENV || 'development').trim() const env = (process.env.NODE_ENV || 'development').trim()
if (env !== 'development') return if (env !== 'development') return
@ -327,87 +692,101 @@ export function setupWindowsDeepLinkHandling() {
}) })
} }
export function getWindowState() { export function getWindowState(windowId) {
if (!mainWindow) { const window = getWindowById(windowId || getFocusedWindowId())
if (!window) {
return { isFullScreen: false, isMaximized: false } return { isFullScreen: false, isMaximized: false }
} }
return { return {
isFullScreen: mainWindow.isFullScreen?.() ?? false, isFullScreen: window.isFullScreen?.() ?? false,
isMaximized: isWindows isMaximized: isWindows
? isWindowWorkAreaMaximized(mainWindow) ? isWindowWorkAreaMaximized(window)
: (mainWindow.isMaximized?.() ?? false) : (window.isMaximized?.() ?? false)
} }
} }
export function handleWindowControl(action) { export function handleWindowControl(action, windowId) {
if (!mainWindow && action !== 'quit') return const window = getWindowById(windowId || getFocusedWindowId())
if (!window && action !== 'quit') return
switch (action) { switch (action) {
case 'minimize': case 'minimize':
mainWindow.minimize?.() window.minimize?.()
break break
case 'maximize': { case 'maximize': {
const currentlyMaximized = isWindows const currentlyMaximized = isWindows
? isWindowWorkAreaMaximized(mainWindow) ? isWindowWorkAreaMaximized(window)
: (mainWindow.isMaximized?.() ?? false) : (window.isMaximized?.() ?? false)
if (currentlyMaximized) { if (currentlyMaximized) {
mainWindow.unmaximize?.() window.unmaximize?.()
} else { } else {
mainWindow.maximize?.() window.maximize?.()
} }
if (isWindows) { if (isWindows) {
// Frame updates after maximize/unmaximize are async on Windows. setTimeout(
setTimeout(() => handleWindowsWindowChange(mainWindow), 100) () =>
handleWindowsWindowChange(
window,
windowId || getFocusedWindowId()
),
100
)
} }
break break
} }
case 'fullscreen': case 'fullscreen':
if (mainWindow.isFullScreen?.()) { if (window.isFullScreen?.()) {
mainWindow.setFullScreen?.(false) window.setFullScreen?.(false)
} else { } else {
mainWindow.setFullScreen?.(true) window.setFullScreen?.(true)
} }
setTimeout(broadcastWindowState, 100) setTimeout(
() => broadcastWindowState(windowId || getFocusedWindowId()),
100
)
break break
case 'close': case 'close':
mainWindow.close?.() window.close?.()
break break
case 'quit': case 'quit':
persistSessionNow()
Utils.quit() Utils.quit()
break break
case 'toggle-devtools': case 'toggle-devtools':
mainWindow?.webview?.toggleDevTools?.() window?.webview?.toggleDevTools?.()
break break
default: default:
break break
} }
} }
export function sendNavigationGesture(direction) { export function sendNavigationGesture(direction, windowId) {
sendToRenderer('navigationGesture', direction) sendToWindow(windowId || getFocusedWindowId(), 'navigationGesture', direction)
} }
export function setupNavigationGestures(window) { export function setupNavigationGestures(window, windowId) {
if (!window) return if (!window) return
const id = windowId || getFocusedWindowId()
if (process.platform === 'darwin') { if (process.platform === 'darwin') {
window.on?.('swipe', (_event, direction) => { window.on?.('swipe', (_event, direction) => {
if (direction === 'left') { if (direction === 'left') {
sendNavigationGesture('back') sendNavigationGesture('back', id)
} else if (direction === 'right') { } else if (direction === 'right') {
sendNavigationGesture('forward') sendNavigationGesture('forward', id)
} }
}) })
} }
window.on?.('app-command', (_event, command) => { window.on?.('app-command', (_event, command) => {
if (command === 'browser-backward') { if (command === 'browser-backward') {
sendNavigationGesture('back') sendNavigationGesture('back', id)
} else if (command === 'browser-forward') { } else if (command === 'browser-forward') {
sendNavigationGesture('forward') sendNavigationGesture('forward', id)
} }
}) })
} }

View File

@ -8,6 +8,12 @@ let initPromise = null
let initialized = false let initialized = false
let nextRequestId = 0 let nextRequestId = 0
export function getDesktopWindowId() {
if (typeof window === 'undefined') return null
if (window.__farmcontrolWindowId) return window.__farmcontrolWindowId
return null
}
export function isElectrobunDesktop() { export function isElectrobunDesktop() {
return Boolean( return Boolean(
typeof window !== 'undefined' && typeof window !== 'undefined' &&
@ -190,7 +196,12 @@ function invokeNativeRequest(method, params) {
JSON.stringify({ JSON.stringify({
type: 'message', type: 'message',
id: 'rendererRequest', id: 'rendererRequest',
payload: { id, method, params } payload: {
id,
method,
params,
windowId: params?.windowId || getDesktopWindowId()
}
}) })
) )
} catch (error) { } catch (error) {
@ -209,13 +220,17 @@ async function invokeRequest(method, params = {}) {
return null return null
} }
const windowId = getDesktopWindowId()
const payload =
windowId && params.windowId == null ? { ...params, windowId } : params
try { try {
const nativeRequest = invokeNativeRequest(method, params) const nativeRequest = invokeNativeRequest(method, payload)
if (nativeRequest) { if (nativeRequest) {
return await nativeRequest return await nativeRequest
} }
return await rpc.request[method](params) return await rpc.request[method](payload)
} catch (error) { } catch (error) {
console.warn(`Electrobun RPC request failed: ${method}`, error) console.warn(`Electrobun RPC request failed: ${method}`, error)
return null return null
@ -249,7 +264,13 @@ const electronAPI = {
setSidebarViewMenu: (sections) => setSidebarViewMenu: (sections) =>
invokeRequest('setSidebarViewMenu', { sections }), invokeRequest('setSidebarViewMenu', { sections }),
getAppVersion: () => invokeRequest('getAppVersion'), getAppVersion: () => invokeRequest('getAppVersion'),
getAppEngine: () => invokeRequest('getAppEngine') getAppEngine: () => invokeRequest('getAppEngine'),
syncWindowTabs: (payload) => invokeRequest('syncWindowTabs', payload),
getWindowSession: (payload) => invokeRequest('getWindowSession', payload),
createAppWindow: (payload) => invokeRequest('createAppWindow', payload),
beginTabDrag: (payload) => invokeRequest('beginTabDrag', payload),
completeTabDrop: (payload) => invokeRequest('completeTabDrop', payload),
cancelTabDrag: (payload) => invokeRequest('cancelTabDrag', payload)
} }
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {