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;
}
.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 {
stroke: currentColor;
stroke-width: 12px;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,11 +1,14 @@
// DashboardBreadcrumb.js
import { useContext } from 'react'
import { Breadcrumb, Button, Flex, Space } from 'antd'
import { Link, useLocation, useNavigate } from 'react-router-dom'
import ArrowLeftIcon from '../../Icons/ArrowLeftIcon'
import ArrowRightIcon from '../../Icons/ArrowRightIcon'
import FilterIcon from '../../Icons/FilterIcon'
import { getModelByPluralName } from '../../../database/ObjectModels'
import { ElectronContext } from '../context/ElectronContext'
import { useTableState } from '../context/TableStateContext'
import { useNavigationTabs } from '../context/NavigationTabsContext'
const breadcrumbNameMap = {
production: 'Production',
@ -28,6 +31,8 @@ const mainSections = ['production', 'inventory', 'management', 'developer']
const DashboardBreadcrumb = () => {
const location = useLocation()
const navigate = useNavigate()
const { isElectron } = useContext(ElectronContext)
const { goBack, goForward } = useNavigationTabs()
const { hasPageFilter, hasStoredFilter, getObjectListView } = useTableState()
const pathSnippets = location.pathname.split('/').filter((i) => i)
@ -97,13 +102,13 @@ const DashboardBreadcrumb = () => {
<Button
type='text'
icon={<ArrowLeftIcon style={{ fontSize: '14px' }} />}
onClick={() => navigate(-1)}
onClick={() => (isElectron ? goBack() : navigate(-1))}
style={{ padding: '0 2px', height: '22px' }}
/>
<Button
type='text'
icon={<ArrowRightIcon style={{ fontSize: '14px' }} />}
onClick={() => navigate(1)}
onClick={() => (isElectron ? goForward() : navigate(1))}
style={{ padding: '0 2px', height: '22px' }}
/>
</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 WindowAppMenu from './WindowAppMenu'
import WebAppSwitcher from './WebAppSwitcher'
import DashboardTabs from './DashboardTabs'
import {
filterSidebarItemsByListPermission,
getSidebarDefaultPath,
@ -263,7 +264,7 @@ const DashboardNavigation = () => {
items={headerMenuItems}
style={{
flexWrap: 'wrap',
flexGrow: 1,
flexGrow: isElectron && !isMobile ? 0 : 1,
border: 0,
minWidth: 0
}}
@ -287,7 +288,12 @@ const DashboardNavigation = () => {
<WindowAppMenu />{' '}
<Divider
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}
@ -308,8 +314,26 @@ const DashboardNavigation = () => {
}}
/>
) : null}
<div style={{ flexGrow: 1 }}>
{showControls && !isMobile && menu}
<div style={{ flexGrow: 1, minWidth: 0 }}>
{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 && (
<Text
type='secondary'

View File

@ -25,6 +25,7 @@ import usePageLayout, {
normalizeColumnProportions
} from '../hooks/usePageLayout'
import { OverviewLayoutContext } from '../context/OverviewLayoutContext'
import { useNavigationTabPage } from '../context/NavigationTabsContext'
const flattenOverviewSections = (sectionList = []) => {
const leaves = {}
@ -124,6 +125,10 @@ const DashboardOverviewPage = ({
onOpenActionsModal,
sections = []
}) => {
const overviewTitle = pageName.endsWith('Overview')
? `Overview - ${pageName.slice(0, -'Overview'.length)}`
: pageName
useNavigationTabPage({ title: overviewTitle })
const [savedCollapseState, updateCollapseState] = useCollapseState(
pageName,
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 ExpandIcon from '../../Icons/ExpandIcon'
import MacOSTrafficLights from './MacOSTrafficLights'
import { useThemeContext } from '../context/ThemeContext'
const DashboardWindowButtons = () => {
const { isMaximized, handleWindowControl, platform, isFullScreen } =
useContext(ElectronContext)
const { showNavigationLabels } = useThemeContext()
const closeButton = (
<Button
icon={<XMarkIcon />}
@ -70,7 +72,10 @@ const DashboardWindowButtons = () => {
<MacOSTrafficLights />
<Divider
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 { useActions } from '../context/ActionsContext'
import { AuthContext } from '../context/AuthContext'
import { useNavigationTabPage, useIsNavigationTabActive } from '../context/NavigationTabsContext'
const ModelPage = ({ modelName, pageName }) => {
const model = getModelByName(modelName)
@ -15,8 +16,12 @@ const ModelPage = ({ modelName, pageName }) => {
const { setCurrentObject, setCurrentObjectType } = useActions()
const { userProfile } = useContext(AuthContext)
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(() => {
if (!isTabActive) return undefined
setCurrentObjectType(modelName)
if (objectId) {
setCurrentObject({ _id: objectId, _user: userProfile })
@ -25,7 +30,14 @@ const ModelPage = ({ modelName, pageName }) => {
setCurrentObject(null)
setCurrentObjectType(null)
}
}, [modelName, objectId, setCurrentObject, setCurrentObjectType, userProfile])
}, [
isTabActive,
modelName,
objectId,
setCurrentObject,
setCurrentObjectType,
userProfile
])
if (!page?.content) {
return null

View File

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

View File

@ -4,8 +4,10 @@ import { Button, Dropdown, Flex } from 'antd'
import { useNavigate } from 'react-router-dom'
import { ElectronContext } from '../context/ElectronContext'
import { AuthContext } from '../context/AuthContext'
import { useNavigationTabs } from '../context/NavigationTabsContext'
import { useAppUpdateContext } from '../context/AppUpdateContext'
import { getSidebarMenuSections } from '../../../database/Sidebars'
import KeyboardShortcut from './KeyboardShortcut'
import FarmControlLogoSmall from '../../Logos/FarmControlLogoSmall'
const runEditCommand = (command) => {
@ -78,6 +80,7 @@ const WindowAppMenu = () => {
const navigate = useNavigate()
const { userProfile } = useContext(AuthContext)
const { handleWindowControl } = useContext(ElectronContext)
const { addTab, createNewWindow } = useNavigationTabs()
const { checkForUpdates } = useAppUpdateContext()
const includeDev = import.meta.env.DEV
@ -111,13 +114,24 @@ const WindowAppMenu = () => {
const fileMenuItems = useMemo(
() => [
{
key: 'new-window',
label: 'New Window',
onClick: () => createNewWindow()
},
{
key: 'new-tab',
label: 'New Tab',
onClick: () => addTab()
},
{ type: 'divider' },
{
key: 'close',
label: 'Close Window',
onClick: () => handleWindowControl('close')
}
],
[handleWindowControl]
[addTab, createNewWindow, handleWindowControl]
)
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 (
<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
} from 'react'
import PropTypes from 'prop-types'
import { useIsNavigationTabActive } from './NavigationTabsContext'
const DashboardObjectToolsContext = createContext()
@ -59,6 +60,7 @@ export const useDashboardObjectToolsContext = () => {
export const useDashboardObjectTools = (tools) => {
const { setCurrentObjectTools, clearCurrentObjectTools } =
useDashboardObjectToolsContext()
const isTabActive = useIsNavigationTabActive()
const ownerIdRef = useRef(null)
if (ownerIdRef.current == null) {
@ -66,8 +68,9 @@ export const useDashboardObjectTools = (tools) => {
}
useEffect(() => {
if (!isTabActive) return
setCurrentObjectTools(tools, ownerIdRef.current)
}, [tools, setCurrentObjectTools])
}, [isTabActive, tools, setCurrentObjectTools])
useEffect(() => {
const ownerId = ownerIdRef.current

View File

@ -35,6 +35,12 @@ const ElectronProvider = ({ children }) => {
const [electronAvailable] = useState(isElectron())
const navigate = useNavigate()
const lastNavigationAtRef = useRef(0)
const tabHistoryHandlerRef = useRef(null)
const registerTabHistoryHandler = useCallback((handler) => {
tabHistoryHandlerRef.current =
typeof handler === 'function' ? handler : null
}, [])
const navigateHistory = useCallback(
(direction) => {
@ -42,6 +48,11 @@ const ElectronProvider = ({ children }) => {
if (now - lastNavigationAtRef.current < 300) return
lastNavigationAtRef.current = now
if (tabHistoryHandlerRef.current) {
tabHistoryHandlerRef.current(direction)
return
}
if (direction === 'back') {
navigate(-1)
} else if (direction === 'forward') {
@ -158,6 +169,83 @@ const ElectronProvider = ({ children }) => {
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 () => {
if (!electronAvailable) return null
return await desktopBridge.getAuthSession()
@ -315,7 +403,17 @@ const ElectronProvider = ({ children }) => {
resizeSpotlightWindow,
setSidebarViewMenu,
getElectronVersion,
getAppEngine
getAppEngine,
registerTabHistoryHandler,
getWindowSession,
syncWindowTabs,
createAppWindow,
onNewTabRequest,
onNewWindowRequest,
onTabMovedAway,
beginTabDrag,
completeTabDrop,
cancelTabDrag
}}
>
{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 useViewMode from '../hooks/useViewMode'
import { DEFAULT_VIEW_MODE, normalizeViewMode } from '../common/viewModeUtils'
import { getModelByName } from '../../../database/ObjectModels'
import { useNavigationTabPage } from './NavigationTabsContext'
const ObjectListViewContext = createContext()
@ -127,6 +129,11 @@ export const ObjectListViewProvider = ({ children, objectType, tableRef }) => {
} = useContext(ApiServerContext)
const { token, authInitialized } = useContext(AuthContext)
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 [draftViews, setDraftViews] = useState(() => {

View File

@ -121,7 +121,8 @@ export const APP_SETTING_NAMES = [
'appTheme',
'appShowNavigationLabels',
'appUpdateBranch',
'appUpdateEngine'
'appUpdateEngine',
'appResumeLastSession'
]
const themeOptions = [
@ -208,6 +209,17 @@ const settings = {
{ label: 'Show', value: true },
{ 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() {
return {
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,
onToggleDevTools,
onCheckForUpdates,
onNewTab,
onNewWindow,
}) {
if (process.platform !== "darwin") {
return;
@ -174,6 +189,16 @@ export function setupApplicationMenuEvents({
return;
}
if (action === "new-tab") {
onNewTab?.();
return;
}
if (action === "new-window") {
onNewWindow?.();
return;
}
if (action.startsWith(SIDEBAR_MENU_ACTION_PREFIX)) {
const path = action.slice(SIDEBAR_MENU_ACTION_PREFIX.length);
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) {
sendMessage = sender;
if (typeof sender === 'function') {
sendToFocusedImpl = sender
}
}
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
} from './check-duplicate-installations.js'
import { setSidebarViewMenu } from './menu.js'
import { sendToRenderer } from './notify.js'
import { sendToAllWindows, sendToRenderer, sendToWindow } from './notify.js'
import { resizeSpotlightWindow } from './spotlight.js'
import {
clearAuthSession,
@ -19,11 +19,17 @@ import {
setAuthSession
} from './store.js'
import {
createAppWindow,
getMainWindow,
getWindowSession,
getWindowState,
handleWindowControl,
openExternalUrl,
openInternalUrl
openInternalUrl,
syncWindowTabs,
beginTabDrag,
completeTabDrop,
cancelTabDrag
} from './window.js'
export function createAppRpc() {
@ -31,11 +37,32 @@ export function createAppRpc() {
getOsInfo: async () => ({
platform: process.platform
}),
getWindowState: async () => getWindowState(),
windowControl: async ({ action }) => {
handleWindowControl(action)
getWindowState: async ({ windowId } = {}) => getWindowState(windowId),
windowControl: async ({ action, windowId } = {}) => {
handleWindowControl(action, windowId)
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(url)
return { ok: true }
@ -57,7 +84,7 @@ export function createAppRpc() {
startAppUpdate: async ({ update }) => {
const mainWindow = getMainWindow()
const sendProgress = (payload) => {
sendToRenderer('appUpdateProgress', {
sendToAllWindows('appUpdateProgress', {
timestamp: new Date().toISOString(),
...payload
})
@ -89,7 +116,7 @@ export function createAppRpc() {
error?.message || 'Failed to remove the duplicate installation.'
}))
.then((result) => {
sendToRenderer('duplicateInstallationsRemoved', result)
sendToAllWindows('duplicateInstallationsRemoved', result)
})
return { ok: true }
},
@ -106,10 +133,20 @@ export function createAppRpc() {
handlers: {
requests: requestHandlers,
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]
if (!handler) {
sendToRenderer('rpcResponse', {
respond({
id,
success: false,
error: `Unknown desktop RPC method: ${String(method)}`
@ -118,16 +155,16 @@ export function createAppRpc() {
}
void Promise.resolve()
.then(() => handler(params || {}))
.then(() => handler(requestParams))
.then((result) => {
sendToRenderer('rpcResponse', {
respond({
id,
success: true,
result
})
})
.catch((error) => {
sendToRenderer('rpcResponse', {
respond({
id,
success: false,
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,
MAC_TRAFFIC_LIGHT_OFFSET
} from './macos-window-effects.js'
import { sendToRenderer, setMessageSender } from './notify.js'
import {
configureWindowMessaging,
sendToRenderer,
sendToWindow
} from './notify.js'
import {
clampWindowToWorkArea,
isWindowWorkAreaMaximized
} 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 isWindows = process.platform === 'win32'
const WINDOWS_STARTUP_MAXIMIZE_DELAY_MS = 1500
const DEV_SERVER_PORT = 5780
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
let webviewDomReady = false
const pendingNavigations = []
const windows = new Map()
let focusedWindowId = null
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() {
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() {
if (!mainWindow) return
const window = getMainWindow()
if (!window) return
if (mainWindow.isMinimized?.()) {
mainWindow.restore?.()
if (window.isMinimized?.()) {
window.restore?.()
}
mainWindow.show?.()
mainWindow.activate?.()
window.show?.()
window.activate?.()
}
export async function getMainViewUrl() {
@ -54,27 +143,26 @@ export async function getMainViewUrl() {
return 'views://mainview/index.html'
}
function deliverNavigation(redirectPath) {
sendToRenderer('navigate', redirectPath)
function deliverNavigation(windowId, redirectPath) {
sendToWindow(windowId, 'navigate', redirectPath)
const window = getWindowById(windowId)
if (!window) return
if (!mainWindow) return
if (mainWindow.isMinimized?.()) {
mainWindow.restore?.()
if (window.isMinimized?.()) {
window.restore?.()
}
mainWindow.show?.()
mainWindow.activate?.()
window.show?.()
window.activate?.()
}
function flushPendingNavigations() {
if (!mainWindow || !webviewDomReady) {
return
}
function flushPendingNavigations(windowId) {
const entry = getEntry(windowId)
if (!entry?.domReady) return
while (pendingNavigations.length > 0) {
const redirectPath = pendingNavigations.shift()
setTimeout(() => deliverNavigation(redirectPath), 100)
while (entry.pendingNavigations.length > 0) {
const redirectPath = entry.pendingNavigations.shift()
setTimeout(() => deliverNavigation(windowId, redirectPath), 100)
}
}
@ -83,12 +171,16 @@ function sendNavigateToRenderer(redirectPath) {
return
}
if (!mainWindow || !webviewDomReady) {
pendingNavigations.push(redirectPath)
const windowId = getFocusedWindowId()
if (!windowId) return
const entry = getEntry(windowId)
if (!entry?.domReady) {
entry?.pendingNavigations.push(redirectPath)
return
}
setTimeout(() => deliverNavigation(redirectPath), 100)
setTimeout(() => deliverNavigation(windowId, redirectPath), 100)
}
export function openInternalUrl(url) {
@ -96,8 +188,6 @@ export function openInternalUrl(url) {
return true
}
const PROTOCOL_PREFIX = 'farmcontrol://'
function parseDeepLinkPath(url) {
if (!url || typeof url !== 'string') {
return null
@ -143,8 +233,157 @@ export function handleDeepLinkFromArgv(launchUrl) {
}
}
function broadcastWindowState() {
sendToRenderer('windowState', getWindowState())
function readWindowSize(window) {
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) {
@ -154,7 +393,7 @@ function applyStartupWindowState(window) {
window.maximize?.()
}
setTimeout(broadcastWindowState, 100)
setTimeout(() => broadcastWindowState(), 100)
}
function syncWindowsWebviewLayout(window) {
@ -167,80 +406,115 @@ function syncWindowsWebviewLayout(window) {
return
}
// Re-apply the current size so the native webview relayouts to the window.
window.setSize(width, height)
}
function handleWindowsWindowChange(window) {
function handleWindowsWindowChange(window, windowId) {
if (clampWindowToWorkArea(window)) {
syncWindowsWebviewLayout(window)
}
broadcastWindowState()
captureWindowLayout(windowId)
broadcastWindowState(windowId)
}
function applyWindowsStartupWindowState(window) {
function applyWindowsStartupWindowState(window, windowId) {
if (!window) return
setTimeout(() => {
window.maximize?.()
// maximize() settles asynchronously; sync state after the frame updates.
setTimeout(() => handleWindowsWindowChange(window), 100)
setTimeout(() => handleWindowsWindowChange(window, windowId), 100)
}, WINDOWS_STARTUP_MAXIMIZE_DELAY_MS)
}
function setupWindowEvents(window) {
// Electrobun emits resize/focus, not Electron's maximize/fullscreen events.
function setupWindowEvents(window, windowId) {
const onWindowChange = isWindows
? () => handleWindowsWindowChange(window)
: broadcastWindowState
? () => handleWindowsWindowChange(window, windowId)
: () => {
captureWindowLayout(windowId)
broadcastWindowState(windowId)
scheduleSessionPersist()
}
window.on?.('resize', onWindowChange)
window.on?.('focus', broadcastWindowState)
window.on?.('move', onWindowChange)
}
export function setupMainWindowMessaging(window = mainWindow) {
if (!window) {
return
}
setMessageSender((channel, data) => {
try {
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
window.on?.('focus', () => {
focusedWindowId = windowId
broadcastWindowState(windowId)
})
window.on?.('close', () => {
captureWindowLayout(windowId)
windows.delete(windowId)
if (focusedWindowId === windowId) {
focusedWindowId = getFocusedWindowId()
}
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()
mainWindow = new BrowserWindow({
const window = new BrowserWindow({
title: 'Farm Control',
url,
rpc,
@ -249,56 +523,147 @@ export async function createMainWindow(rpc) {
? { transparent: true, trafficLightOffset: MAC_TRAFFIC_LIGHT_OFFSET }
: {}),
frame: {
width: 1200,
height: 800,
x: 100,
y: 100
width: frame.width || 1200,
height: frame.height || 800,
x: frame.x ?? 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) {
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) {
applyApplicationMenu()
setupApplicationMenuEvents({
onNavigate: sendNavigateToRenderer,
onToggleDevTools: () => {
mainWindow?.webview?.toggleDevTools?.()
getMainWindow()?.webview?.toggleDevTools?.()
},
onCheckForUpdates: () => {
sendToRenderer('checkForUpdates')
},
onNewTab: () => {
sendToRenderer('newTab')
},
onNewWindow: () => {
sendToRenderer('newWindow')
}
})
}
setupWindowEvents(mainWindow)
applyStartupWindowState(mainWindow)
Electrobun.events.on('open-url', (event) => {
const url = event?.data?.url
if (url) {
handleDeepLink(url)
}
})
}
return new Promise((resolve) => {
mainWindow.webview.on('dom-ready', () => {
webviewDomReady = true
export async function startDesktopWindows(rpc) {
sharedRpc = rpc
setupMessaging()
if (isWindows) {
applyWindowsStartupWindowState(mainWindow)
const settings = await getAppSettings()
const resumeLastSession = settings.appResumeLastSession !== false
const saved = resumeLastSession
? await getWindowSessionFile()
: { windows: [] }
const savedWindows = Array.isArray(saved.windows) ? saved.windows : []
if (savedWindows.length > 0) {
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()
}
flushPendingNavigations()
resolve(mainWindow)
})
return createDesktopWindow({
rpc,
applyDefaultMaximize: true
})
}
export async function createMainWindow(rpc) {
return startDesktopWindows(rpc)
}
export async function setupDevAuthServer() {
const env = (process.env.NODE_ENV || 'development').trim()
if (env !== 'development') return
@ -327,87 +692,101 @@ export function setupWindowsDeepLinkHandling() {
})
}
export function getWindowState() {
if (!mainWindow) {
export function getWindowState(windowId) {
const window = getWindowById(windowId || getFocusedWindowId())
if (!window) {
return { isFullScreen: false, isMaximized: false }
}
return {
isFullScreen: mainWindow.isFullScreen?.() ?? false,
isFullScreen: window.isFullScreen?.() ?? false,
isMaximized: isWindows
? isWindowWorkAreaMaximized(mainWindow)
: (mainWindow.isMaximized?.() ?? false)
? isWindowWorkAreaMaximized(window)
: (window.isMaximized?.() ?? false)
}
}
export function handleWindowControl(action) {
if (!mainWindow && action !== 'quit') return
export function handleWindowControl(action, windowId) {
const window = getWindowById(windowId || getFocusedWindowId())
if (!window && action !== 'quit') return
switch (action) {
case 'minimize':
mainWindow.minimize?.()
window.minimize?.()
break
case 'maximize': {
const currentlyMaximized = isWindows
? isWindowWorkAreaMaximized(mainWindow)
: (mainWindow.isMaximized?.() ?? false)
? isWindowWorkAreaMaximized(window)
: (window.isMaximized?.() ?? false)
if (currentlyMaximized) {
mainWindow.unmaximize?.()
window.unmaximize?.()
} else {
mainWindow.maximize?.()
window.maximize?.()
}
if (isWindows) {
// Frame updates after maximize/unmaximize are async on Windows.
setTimeout(() => handleWindowsWindowChange(mainWindow), 100)
setTimeout(
() =>
handleWindowsWindowChange(
window,
windowId || getFocusedWindowId()
),
100
)
}
break
}
case 'fullscreen':
if (mainWindow.isFullScreen?.()) {
mainWindow.setFullScreen?.(false)
if (window.isFullScreen?.()) {
window.setFullScreen?.(false)
} else {
mainWindow.setFullScreen?.(true)
window.setFullScreen?.(true)
}
setTimeout(broadcastWindowState, 100)
setTimeout(
() => broadcastWindowState(windowId || getFocusedWindowId()),
100
)
break
case 'close':
mainWindow.close?.()
window.close?.()
break
case 'quit':
persistSessionNow()
Utils.quit()
break
case 'toggle-devtools':
mainWindow?.webview?.toggleDevTools?.()
window?.webview?.toggleDevTools?.()
break
default:
break
}
}
export function sendNavigationGesture(direction) {
sendToRenderer('navigationGesture', direction)
export function sendNavigationGesture(direction, windowId) {
sendToWindow(windowId || getFocusedWindowId(), 'navigationGesture', direction)
}
export function setupNavigationGestures(window) {
export function setupNavigationGestures(window, windowId) {
if (!window) return
const id = windowId || getFocusedWindowId()
if (process.platform === 'darwin') {
window.on?.('swipe', (_event, direction) => {
if (direction === 'left') {
sendNavigationGesture('back')
sendNavigationGesture('back', id)
} else if (direction === 'right') {
sendNavigationGesture('forward')
sendNavigationGesture('forward', id)
}
})
}
window.on?.('app-command', (_event, command) => {
if (command === 'browser-backward') {
sendNavigationGesture('back')
sendNavigationGesture('back', id)
} else if (command === 'browser-forward') {
sendNavigationGesture('forward')
sendNavigationGesture('forward', id)
}
})
}

View File

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