diff --git a/assets/stylesheets/App.css b/assets/stylesheets/App.css
index 833e47f3..a01191ad 100644
--- a/assets/stylesheets/App.css
+++ b/assets/stylesheets/App.css
@@ -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;
diff --git a/src/App.jsx b/src/App.jsx
index abfdb534..d84eec42 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -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 = () => {
+
@@ -183,6 +185,7 @@ const AppContent = () => {
+
diff --git a/src/bun/index.js b/src/bun/index.js
index 847d1499..75d0aa73 100644
--- a/src/bun/index.js
+++ b/src/bun/index.js
@@ -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()
})
diff --git a/src/components/Dashboard/Dashboard.jsx b/src/components/Dashboard/Dashboard.jsx
index cd85661d..30556356 100644
--- a/src/components/Dashboard/Dashboard.jsx
+++ b/src/components/Dashboard/Dashboard.jsx
@@ -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 (
-
-
-
- )
+ const { isElectron } = useNavigationTabs()
+
+ return {isElectron ? : }
}
export default Dashboard
diff --git a/src/components/Dashboard/Layout.jsx b/src/components/Dashboard/Layout.jsx
index 4516ab36..5373f6ff 100644
--- a/src/components/Dashboard/Layout.jsx
+++ b/src/components/Dashboard/Layout.jsx
@@ -66,7 +66,7 @@ const DashboardLayout = ({ children }) => {
{currentObjectTools}
- {children}
+
{children}
diff --git a/src/components/Dashboard/Management/About.jsx b/src/components/Dashboard/Management/About.jsx
index a2ca7d53..da6c0d60 100644
--- a/src/components/Dashboard/Management/About.jsx
+++ b/src/components/Dashboard/Management/About.jsx
@@ -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
})
diff --git a/src/components/Dashboard/Management/Notes/NoteInfo.jsx b/src/components/Dashboard/Management/Notes/NoteInfo.jsx
index ef653a79..52dce1da 100644
--- a/src/components/Dashboard/Management/Notes/NoteInfo.jsx
+++ b/src/components/Dashboard/Management/Notes/NoteInfo.jsx
@@ -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)
diff --git a/src/components/Dashboard/Management/Settings.jsx b/src/components/Dashboard/Management/Settings.jsx
index 4b594525..faa7e6bd 100644
--- a/src/components/Dashboard/Management/Settings.jsx
+++ b/src/components/Dashboard/Management/Settings.jsx
@@ -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,
diff --git a/src/components/Dashboard/common/DashboardBreadcrumb.jsx b/src/components/Dashboard/common/DashboardBreadcrumb.jsx
index c9e5f393..fc7a5845 100644
--- a/src/components/Dashboard/common/DashboardBreadcrumb.jsx
+++ b/src/components/Dashboard/common/DashboardBreadcrumb.jsx
@@ -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 = () => {
}
- onClick={() => navigate(-1)}
+ onClick={() => (isElectron ? goBack() : navigate(-1))}
style={{ padding: '0 2px', height: '22px' }}
/>
}
- onClick={() => navigate(1)}
+ onClick={() => (isElectron ? goForward() : navigate(1))}
style={{ padding: '0 2px', height: '22px' }}
/>
diff --git a/src/components/Dashboard/common/DashboardChildRoutes.jsx b/src/components/Dashboard/common/DashboardChildRoutes.jsx
new file mode 100644
index 00000000..7ee1e441
--- /dev/null
+++ b/src/components/Dashboard/common/DashboardChildRoutes.jsx
@@ -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) => (
+
+ {ProductionRoutes}
+ {InventoryRoutes}
+ {FinanceRoutes}
+ {SalesRoutes}
+ {ManagementRoutes}
+ {ModelRoutes}
+ {DeveloperRoutes}
+
+)
diff --git a/src/components/Dashboard/common/DashboardNavigation.jsx b/src/components/Dashboard/common/DashboardNavigation.jsx
index a18dc49f..bd497c73 100644
--- a/src/components/Dashboard/common/DashboardNavigation.jsx
+++ b/src/components/Dashboard/common/DashboardNavigation.jsx
@@ -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 = () => {
{' '}
>
) : null}
@@ -308,8 +314,26 @@ const DashboardNavigation = () => {
}}
/>
) : null}
-
- {showControls && !isMobile && menu}
+
+ {showControls && !isMobile ? (
+
+ {menu}
+ {isElectron ? (
+ <>
+
+
+ >
+ ) : null}
+
+ ) : null}
{!showControls && (
{
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
diff --git a/src/components/Dashboard/common/DashboardTabPanes.jsx b/src/components/Dashboard/common/DashboardTabPanes.jsx
new file mode 100644
index 00000000..ae9d6697
--- /dev/null
+++ b/src/components/Dashboard/common/DashboardTabPanes.jsx
@@ -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 = () => (
+
+
+
+)
+
+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
+ }
+
+ return (
+
+ {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 (
+
+
+
+
+ {wrapDashboardChildRoutes()}
+
+
+
+
+ )
+ })}
+
+ )
+}
+
+export default DashboardTabPanes
diff --git a/src/components/Dashboard/common/DashboardTabs.jsx b/src/components/Dashboard/common/DashboardTabs.jsx
new file mode 100644
index 00000000..0911f174
--- /dev/null
+++ b/src/components/Dashboard/common/DashboardTabs.jsx
@@ -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 }) => (
+
+
+ {tab.title || 'Farm Control'}
+
+ {
+ event.preventDefault()
+ event.stopPropagation()
+ onClose?.(tab.id)
+ }}
+ onMouseDown={(event) => event.stopPropagation()}
+ >
+
+
+
+)
+
+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: (
+
+ ),
+ label:
+ }
+ }),
+ [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 (
+
+
+
+
+ }
+ />
+
+
+
+ )
+}
+
+export default DashboardTabs
diff --git a/src/components/Dashboard/common/DashboardWindowButtons.jsx b/src/components/Dashboard/common/DashboardWindowButtons.jsx
index 9874bd58..d2476bdc 100644
--- a/src/components/Dashboard/common/DashboardWindowButtons.jsx
+++ b/src/components/Dashboard/common/DashboardWindowButtons.jsx
@@ -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 = (
}
@@ -70,7 +72,10 @@ const DashboardWindowButtons = () => {
>
) : (
diff --git a/src/components/Dashboard/common/ModelPage.jsx b/src/components/Dashboard/common/ModelPage.jsx
index 7bab95e1..e3409076 100644
--- a/src/components/Dashboard/common/ModelPage.jsx
+++ b/src/components/Dashboard/common/ModelPage.jsx
@@ -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
diff --git a/src/components/Dashboard/common/SegmentedNav.jsx b/src/components/Dashboard/common/SegmentedNav.jsx
index 6a13bbce..26bc14e0 100644
--- a/src/components/Dashboard/common/SegmentedNav.jsx
+++ b/src/components/Dashboard/common/SegmentedNav.jsx
@@ -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'
- 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(() => {
+ 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
diff --git a/src/components/Dashboard/common/WindowAppMenu.jsx b/src/components/Dashboard/common/WindowAppMenu.jsx
index a206aca9..51021f81 100644
--- a/src/components/Dashboard/common/WindowAppMenu.jsx
+++ b/src/components/Dashboard/common/WindowAppMenu.jsx
@@ -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 (
+
+
+
+
+
+ )
+ }
return (
)
diff --git a/src/components/Dashboard/common/tabDrag.js b/src/components/Dashboard/common/tabDrag.js
new file mode 100644
index 00000000..5aae8103
--- /dev/null
+++ b/src/components/Dashboard/common/tabDrag.js
@@ -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
+ }
+}
diff --git a/src/components/Dashboard/context/DashboardObjectToolsContext.jsx b/src/components/Dashboard/context/DashboardObjectToolsContext.jsx
index 582b9857..2975fdb3 100644
--- a/src/components/Dashboard/context/DashboardObjectToolsContext.jsx
+++ b/src/components/Dashboard/context/DashboardObjectToolsContext.jsx
@@ -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
diff --git a/src/components/Dashboard/context/ElectronContext.jsx b/src/components/Dashboard/context/ElectronContext.jsx
index a2f8518d..5ab2e26a 100644
--- a/src/components/Dashboard/context/ElectronContext.jsx
+++ b/src/components/Dashboard/context/ElectronContext.jsx
@@ -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}
diff --git a/src/components/Dashboard/context/NavigationTabsContext.jsx b/src/components/Dashboard/context/NavigationTabsContext.jsx
new file mode 100644
index 00000000..5b62d990
--- /dev/null
+++ b/src/components/Dashboard/context/NavigationTabsContext.jsx
@@ -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 (
+
+ {children}
+
+ )
+}
+
+// eslint-disable-next-line react-refresh/only-export-components
+export const useNavigationTabs = () => {
+ const context = useContext(NavigationTabsContext)
+ if (!context) {
+ throw new Error(
+ 'useNavigationTabs must be used within a NavigationTabsProvider'
+ )
+ }
+ return context
+}
+
+// eslint-disable-next-line react-refresh/only-export-components
+export const 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
diff --git a/src/components/Dashboard/context/ObjectListViewContext.jsx b/src/components/Dashboard/context/ObjectListViewContext.jsx
index 4d41e3f5..043c60ad 100644
--- a/src/components/Dashboard/context/ObjectListViewContext.jsx
+++ b/src/components/Dashboard/context/ObjectListViewContext.jsx
@@ -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(() => {
diff --git a/src/database/Settings.js b/src/database/Settings.js
index 0eff8601..d063084f 100644
--- a/src/database/Settings.js
+++ b/src/database/Settings.js
@@ -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 }
+ ]
}
]
},
diff --git a/src/desktop/menu.js b/src/desktop/menu.js
index 277a7147..9c1317ac 100644
--- a/src/desktop/menu.js
+++ b/src/desktop/menu.js
@@ -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 || "/");
diff --git a/src/desktop/notify.js b/src/desktop/notify.js
index f25fa610..00d3c190 100644
--- a/src/desktop/notify.js
+++ b/src/desktop/notify.js
@@ -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)
}
diff --git a/src/desktop/rpc.js b/src/desktop/rpc.js
index d495cd8a..43cc91bc 100644
--- a/src/desktop/rpc.js
+++ b/src/desktop/rpc.js
@@ -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)
diff --git a/src/desktop/session.js b/src/desktop/session.js
new file mode 100644
index 00000000..d6f5c81a
--- /dev/null
+++ b/src/desktop/session.js
@@ -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
+ }
+}
diff --git a/src/desktop/window.js b/src/desktop/window.js
index 8d07c83b..3c1534fa 100644
--- a/src/desktop/window.js
+++ b/src/desktop/window.js
@@ -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 : []
- flushPendingNavigations()
- resolve(mainWindow)
- })
+ 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()
+ }
+
+ 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)
}
})
}
diff --git a/src/electrobun-bridge.js b/src/electrobun-bridge.js
index 2c9f621b..e77029ec 100644
--- a/src/electrobun-bridge.js
+++ b/src/electrobun-bridge.js
@@ -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') {