Implement Navigation Tabs and Dashboard Enhancements
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
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:
parent
7dfaef65a4
commit
d16aa373be
@ -3346,6 +3346,79 @@ body.objectKanbanColumnResizing * {
|
|||||||
min-width: 20px;
|
min-width: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dashboard-tabs {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-tabs .segmented-nav-item-label {
|
||||||
|
padding-inline: 0px;
|
||||||
|
padding-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-tabs-segmented-wrap {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-tabs-segmented-wrap .simplebar-track.simplebar-vertical {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-tabs-segmented-inner {
|
||||||
|
width: max-content;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-tabs-label {
|
||||||
|
max-width: 200px;
|
||||||
|
margin-top: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-tabs-close {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
margin-left: 2px;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--color-text-secondary, inherit);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-tabs-close:hover {
|
||||||
|
opacity: 1;
|
||||||
|
background: var(--color-button-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-tabs-add-button {
|
||||||
|
padding-inline: 4px;
|
||||||
|
min-width: 20px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-tab-panes {
|
||||||
|
position: relative;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-tab-pane {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-tab-pane-active {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-tab-pane-inactive {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.state-tag-loading svg path {
|
.state-tag-loading svg path {
|
||||||
stroke: currentColor;
|
stroke: currentColor;
|
||||||
stroke-width: 12px;
|
stroke-width: 12px;
|
||||||
|
|||||||
@ -30,6 +30,7 @@ import AppError from './components/App/AppError'
|
|||||||
import { ApiServerProvider } from './components/Dashboard/context/ApiServerContext.jsx'
|
import { ApiServerProvider } from './components/Dashboard/context/ApiServerContext.jsx'
|
||||||
import { NotificationProvider } from './components/Dashboard/context/NotificationContext.jsx'
|
import { NotificationProvider } from './components/Dashboard/context/NotificationContext.jsx'
|
||||||
import { ElectronProvider } from './components/Dashboard/context/ElectronContext.jsx'
|
import { ElectronProvider } from './components/Dashboard/context/ElectronContext.jsx'
|
||||||
|
import { NavigationTabsProvider } from './components/Dashboard/context/NavigationTabsContext.jsx'
|
||||||
import { MessageProvider } from './components/Dashboard/context/MessageContext.jsx'
|
import { MessageProvider } from './components/Dashboard/context/MessageContext.jsx'
|
||||||
import { TooltipProvider } from './components/Dashboard/context/TooltipContext.jsx'
|
import { TooltipProvider } from './components/Dashboard/context/TooltipContext.jsx'
|
||||||
import { AppUpdateProvider } from './components/Dashboard/context/AppUpdateContext.jsx'
|
import { AppUpdateProvider } from './components/Dashboard/context/AppUpdateContext.jsx'
|
||||||
@ -84,6 +85,7 @@ const AppContent = () => {
|
|||||||
<App>
|
<App>
|
||||||
<Router>
|
<Router>
|
||||||
<ElectronProvider>
|
<ElectronProvider>
|
||||||
|
<NavigationTabsProvider>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<PrintServerProvider>
|
<PrintServerProvider>
|
||||||
<ApiServerProvider>
|
<ApiServerProvider>
|
||||||
@ -183,6 +185,7 @@ const AppContent = () => {
|
|||||||
</ApiServerProvider>
|
</ApiServerProvider>
|
||||||
</PrintServerProvider>
|
</PrintServerProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
|
</NavigationTabsProvider>
|
||||||
</ElectronProvider>
|
</ElectronProvider>
|
||||||
</Router>
|
</Router>
|
||||||
</App>
|
</App>
|
||||||
|
|||||||
@ -22,22 +22,22 @@ const {
|
|||||||
const {
|
const {
|
||||||
createMainWindow,
|
createMainWindow,
|
||||||
handleDeepLinkFromArgv,
|
handleDeepLinkFromArgv,
|
||||||
|
persistSessionNow,
|
||||||
setupDevAuthServer,
|
setupDevAuthServer,
|
||||||
setupNavigationGestures,
|
|
||||||
setupWindowsDeepLinkHandling
|
setupWindowsDeepLinkHandling
|
||||||
} = await import('../desktop/window.js')
|
} = await import('../desktop/window.js')
|
||||||
|
|
||||||
setupWindowsDeepLinkHandling()
|
setupWindowsDeepLinkHandling()
|
||||||
|
|
||||||
const rpc = createAppRpc()
|
const rpc = createAppRpc()
|
||||||
const mainWindow = await createMainWindow(rpc)
|
await createMainWindow(rpc)
|
||||||
|
|
||||||
setupNavigationGestures(mainWindow)
|
|
||||||
registerGlobalShortcuts(rpc)
|
registerGlobalShortcuts(rpc)
|
||||||
setupDevAuthServer()
|
setupDevAuthServer()
|
||||||
handleDeepLinkFromArgv(launchUrl)
|
handleDeepLinkFromArgv(launchUrl)
|
||||||
|
|
||||||
process.on('exit', () => {
|
process.on('exit', () => {
|
||||||
|
persistSessionNow()
|
||||||
unregisterGlobalShortcuts()
|
unregisterGlobalShortcuts()
|
||||||
closeSingleInstanceServer()
|
closeSingleInstanceServer()
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,13 +1,12 @@
|
|||||||
// Dashboard.js
|
|
||||||
import Layout from './Layout'
|
import Layout from './Layout'
|
||||||
import { Outlet } from 'react-router-dom'
|
import { Outlet } from 'react-router-dom'
|
||||||
|
import { useNavigationTabs } from './context/NavigationTabsContext'
|
||||||
|
import DashboardTabPanes from './common/DashboardTabPanes'
|
||||||
|
|
||||||
const Dashboard = () => {
|
const Dashboard = () => {
|
||||||
return (
|
const { isElectron } = useNavigationTabs()
|
||||||
<Layout>
|
|
||||||
<Outlet />
|
return <Layout>{isElectron ? <DashboardTabPanes /> : <Outlet />}</Layout>
|
||||||
</Layout>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Dashboard
|
export default Dashboard
|
||||||
|
|||||||
@ -66,7 +66,7 @@ const DashboardLayout = ({ children }) => {
|
|||||||
<DashboardBreadcrumb style={{ margin: '16px 0' }} />
|
<DashboardBreadcrumb style={{ margin: '16px 0' }} />
|
||||||
{currentObjectTools}
|
{currentObjectTools}
|
||||||
</Flex>
|
</Flex>
|
||||||
{children}
|
<div style={{ flex: 1, minHeight: 0 }}>{children}</div>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Content>
|
</Content>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@ -22,9 +22,11 @@ import { useMediaQuery } from 'react-responsive'
|
|||||||
import FarmControlAppIcon from '../../Logos/FarmControlAppIcon'
|
import FarmControlAppIcon from '../../Logos/FarmControlAppIcon'
|
||||||
import SoftwareUpdateIcon from '../../Icons/SoftwareUpdateIcon'
|
import SoftwareUpdateIcon from '../../Icons/SoftwareUpdateIcon'
|
||||||
import ExternalLink from '../common/ExternalLink'
|
import ExternalLink from '../common/ExternalLink'
|
||||||
|
import { useNavigationTabPage } from '../context/NavigationTabsContext'
|
||||||
const { Title, Text } = Typography
|
const { Title, Text } = Typography
|
||||||
|
|
||||||
const About = () => {
|
const About = () => {
|
||||||
|
useNavigationTabPage({ title: 'About' })
|
||||||
const [collapseState, updateCollapseState] = useCollapseState('About', {
|
const [collapseState, updateCollapseState] = useCollapseState('About', {
|
||||||
updater: true
|
updater: true
|
||||||
})
|
})
|
||||||
|
|||||||
@ -21,6 +21,7 @@ import ActivityIndicator from '../../common/ActivityIndicator.jsx'
|
|||||||
import ActionHandler from '../../common/ActionHandler.jsx'
|
import ActionHandler from '../../common/ActionHandler.jsx'
|
||||||
import ObjectActions from '../../common/ObjectActions.jsx'
|
import ObjectActions from '../../common/ObjectActions.jsx'
|
||||||
import { useDashboardObjectTools } from '../../context/DashboardObjectToolsContext'
|
import { useDashboardObjectTools } from '../../context/DashboardObjectToolsContext'
|
||||||
|
import { useNavigationTabPage } from '../../context/NavigationTabsContext'
|
||||||
import ObjectTable from '../../common/ObjectTable.jsx'
|
import ObjectTable from '../../common/ObjectTable.jsx'
|
||||||
import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx'
|
import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx'
|
||||||
import InfoActionButtons from '../../common/InfoActionButtons.jsx'
|
import InfoActionButtons from '../../common/InfoActionButtons.jsx'
|
||||||
@ -32,6 +33,7 @@ const log = loglevel.getLogger('NoteInfo')
|
|||||||
log.setLevel(config.logLevel)
|
log.setLevel(config.logLevel)
|
||||||
|
|
||||||
const NoteInfo = () => {
|
const NoteInfo = () => {
|
||||||
|
useNavigationTabPage({ title: 'Info - Note', modelName: 'note' })
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const objectFormRef = useRef(null)
|
const objectFormRef = useRef(null)
|
||||||
const actionHandlerRef = useRef(null)
|
const actionHandlerRef = useRef(null)
|
||||||
|
|||||||
@ -23,6 +23,7 @@ import settingsSchema, {
|
|||||||
getVisibleSettingsSections,
|
getVisibleSettingsSections,
|
||||||
pickSettings
|
pickSettings
|
||||||
} from '../../../database/Settings'
|
} from '../../../database/Settings'
|
||||||
|
import { useNavigationTabPage } from '../context/NavigationTabsContext'
|
||||||
|
|
||||||
const DEFAULT_UPDATE_BRANCH = 'main'
|
const DEFAULT_UPDATE_BRANCH = 'main'
|
||||||
const DEFAULT_UPDATE_ENGINE = 'native'
|
const DEFAULT_UPDATE_ENGINE = 'native'
|
||||||
@ -35,6 +36,7 @@ const LEGACY_ELECTRON_USER_KEYS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
const Settings = () => {
|
const Settings = () => {
|
||||||
|
useNavigationTabPage({ title: 'Settings' })
|
||||||
const {
|
const {
|
||||||
isDarkMode,
|
isDarkMode,
|
||||||
isCompact,
|
isCompact,
|
||||||
|
|||||||
@ -1,11 +1,14 @@
|
|||||||
// DashboardBreadcrumb.js
|
// DashboardBreadcrumb.js
|
||||||
|
import { useContext } from 'react'
|
||||||
import { Breadcrumb, Button, Flex, Space } from 'antd'
|
import { Breadcrumb, Button, Flex, Space } from 'antd'
|
||||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||||
import ArrowLeftIcon from '../../Icons/ArrowLeftIcon'
|
import ArrowLeftIcon from '../../Icons/ArrowLeftIcon'
|
||||||
import ArrowRightIcon from '../../Icons/ArrowRightIcon'
|
import ArrowRightIcon from '../../Icons/ArrowRightIcon'
|
||||||
import FilterIcon from '../../Icons/FilterIcon'
|
import FilterIcon from '../../Icons/FilterIcon'
|
||||||
import { getModelByPluralName } from '../../../database/ObjectModels'
|
import { getModelByPluralName } from '../../../database/ObjectModels'
|
||||||
|
import { ElectronContext } from '../context/ElectronContext'
|
||||||
import { useTableState } from '../context/TableStateContext'
|
import { useTableState } from '../context/TableStateContext'
|
||||||
|
import { useNavigationTabs } from '../context/NavigationTabsContext'
|
||||||
|
|
||||||
const breadcrumbNameMap = {
|
const breadcrumbNameMap = {
|
||||||
production: 'Production',
|
production: 'Production',
|
||||||
@ -28,6 +31,8 @@ const mainSections = ['production', 'inventory', 'management', 'developer']
|
|||||||
const DashboardBreadcrumb = () => {
|
const DashboardBreadcrumb = () => {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const { isElectron } = useContext(ElectronContext)
|
||||||
|
const { goBack, goForward } = useNavigationTabs()
|
||||||
const { hasPageFilter, hasStoredFilter, getObjectListView } = useTableState()
|
const { hasPageFilter, hasStoredFilter, getObjectListView } = useTableState()
|
||||||
const pathSnippets = location.pathname.split('/').filter((i) => i)
|
const pathSnippets = location.pathname.split('/').filter((i) => i)
|
||||||
|
|
||||||
@ -97,13 +102,13 @@ const DashboardBreadcrumb = () => {
|
|||||||
<Button
|
<Button
|
||||||
type='text'
|
type='text'
|
||||||
icon={<ArrowLeftIcon style={{ fontSize: '14px' }} />}
|
icon={<ArrowLeftIcon style={{ fontSize: '14px' }} />}
|
||||||
onClick={() => navigate(-1)}
|
onClick={() => (isElectron ? goBack() : navigate(-1))}
|
||||||
style={{ padding: '0 2px', height: '22px' }}
|
style={{ padding: '0 2px', height: '22px' }}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
type='text'
|
type='text'
|
||||||
icon={<ArrowRightIcon style={{ fontSize: '14px' }} />}
|
icon={<ArrowRightIcon style={{ fontSize: '14px' }} />}
|
||||||
onClick={() => navigate(1)}
|
onClick={() => (isElectron ? goForward() : navigate(1))}
|
||||||
style={{ padding: '0 2px', height: '22px' }}
|
style={{ padding: '0 2px', height: '22px' }}
|
||||||
/>
|
/>
|
||||||
</Space.Compact>
|
</Space.Compact>
|
||||||
|
|||||||
21
src/components/Dashboard/common/DashboardChildRoutes.jsx
Normal file
21
src/components/Dashboard/common/DashboardChildRoutes.jsx
Normal 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>
|
||||||
|
)
|
||||||
@ -40,6 +40,7 @@ import { ElectronContext } from '../context/ElectronContext'
|
|||||||
import DashboardWindowButtons from './DashboardWindowButtons'
|
import DashboardWindowButtons from './DashboardWindowButtons'
|
||||||
import WindowAppMenu from './WindowAppMenu'
|
import WindowAppMenu from './WindowAppMenu'
|
||||||
import WebAppSwitcher from './WebAppSwitcher'
|
import WebAppSwitcher from './WebAppSwitcher'
|
||||||
|
import DashboardTabs from './DashboardTabs'
|
||||||
import {
|
import {
|
||||||
filterSidebarItemsByListPermission,
|
filterSidebarItemsByListPermission,
|
||||||
getSidebarDefaultPath,
|
getSidebarDefaultPath,
|
||||||
@ -263,7 +264,7 @@ const DashboardNavigation = () => {
|
|||||||
items={headerMenuItems}
|
items={headerMenuItems}
|
||||||
style={{
|
style={{
|
||||||
flexWrap: 'wrap',
|
flexWrap: 'wrap',
|
||||||
flexGrow: 1,
|
flexGrow: isElectron && !isMobile ? 0 : 1,
|
||||||
border: 0,
|
border: 0,
|
||||||
minWidth: 0
|
minWidth: 0
|
||||||
}}
|
}}
|
||||||
@ -287,7 +288,12 @@ const DashboardNavigation = () => {
|
|||||||
<WindowAppMenu />{' '}
|
<WindowAppMenu />{' '}
|
||||||
<Divider
|
<Divider
|
||||||
type='vertical'
|
type='vertical'
|
||||||
style={{ height: '14px', margin: '3px 3px 0 1.5px' }}
|
style={{
|
||||||
|
height: '14px',
|
||||||
|
margin: showNavigationLabels
|
||||||
|
? '3px 3px 0 1.5px'
|
||||||
|
: '3px 0px 0 1.5px'
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
@ -308,8 +314,26 @@ const DashboardNavigation = () => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<div style={{ flexGrow: 1 }}>
|
<div style={{ flexGrow: 1, minWidth: 0 }}>
|
||||||
{showControls && !isMobile && menu}
|
{showControls && !isMobile ? (
|
||||||
|
<Flex align='center' style={{ width: '100%', minWidth: 0 }}>
|
||||||
|
{menu}
|
||||||
|
{isElectron ? (
|
||||||
|
<>
|
||||||
|
<Divider
|
||||||
|
type='vertical'
|
||||||
|
style={{
|
||||||
|
margin: showNavigationLabels
|
||||||
|
? '3px 16px 0 4px'
|
||||||
|
: '3px 16px 0 0',
|
||||||
|
height: '14px'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<DashboardTabs />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</Flex>
|
||||||
|
) : null}
|
||||||
{!showControls && (
|
{!showControls && (
|
||||||
<Text
|
<Text
|
||||||
type='secondary'
|
type='secondary'
|
||||||
|
|||||||
@ -25,6 +25,7 @@ import usePageLayout, {
|
|||||||
normalizeColumnProportions
|
normalizeColumnProportions
|
||||||
} from '../hooks/usePageLayout'
|
} from '../hooks/usePageLayout'
|
||||||
import { OverviewLayoutContext } from '../context/OverviewLayoutContext'
|
import { OverviewLayoutContext } from '../context/OverviewLayoutContext'
|
||||||
|
import { useNavigationTabPage } from '../context/NavigationTabsContext'
|
||||||
|
|
||||||
const flattenOverviewSections = (sectionList = []) => {
|
const flattenOverviewSections = (sectionList = []) => {
|
||||||
const leaves = {}
|
const leaves = {}
|
||||||
@ -124,6 +125,10 @@ const DashboardOverviewPage = ({
|
|||||||
onOpenActionsModal,
|
onOpenActionsModal,
|
||||||
sections = []
|
sections = []
|
||||||
}) => {
|
}) => {
|
||||||
|
const overviewTitle = pageName.endsWith('Overview')
|
||||||
|
? `Overview - ${pageName.slice(0, -'Overview'.length)}`
|
||||||
|
: pageName
|
||||||
|
useNavigationTabPage({ title: overviewTitle })
|
||||||
const [savedCollapseState, updateCollapseState] = useCollapseState(
|
const [savedCollapseState, updateCollapseState] = useCollapseState(
|
||||||
pageName,
|
pageName,
|
||||||
collapseDefaults
|
collapseDefaults
|
||||||
|
|||||||
126
src/components/Dashboard/common/DashboardTabPanes.jsx
Normal file
126
src/components/Dashboard/common/DashboardTabPanes.jsx
Normal 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
|
||||||
139
src/components/Dashboard/common/DashboardTabs.jsx
Normal file
139
src/components/Dashboard/common/DashboardTabs.jsx
Normal 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
|
||||||
@ -6,11 +6,13 @@ import MinusIcon from '../../Icons/MinusIcon'
|
|||||||
import ContractIcon from '../../Icons/ContractIcon'
|
import ContractIcon from '../../Icons/ContractIcon'
|
||||||
import ExpandIcon from '../../Icons/ExpandIcon'
|
import ExpandIcon from '../../Icons/ExpandIcon'
|
||||||
import MacOSTrafficLights from './MacOSTrafficLights'
|
import MacOSTrafficLights from './MacOSTrafficLights'
|
||||||
|
import { useThemeContext } from '../context/ThemeContext'
|
||||||
|
|
||||||
const DashboardWindowButtons = () => {
|
const DashboardWindowButtons = () => {
|
||||||
const { isMaximized, handleWindowControl, platform, isFullScreen } =
|
const { isMaximized, handleWindowControl, platform, isFullScreen } =
|
||||||
useContext(ElectronContext)
|
useContext(ElectronContext)
|
||||||
|
|
||||||
|
const { showNavigationLabels } = useThemeContext()
|
||||||
const closeButton = (
|
const closeButton = (
|
||||||
<Button
|
<Button
|
||||||
icon={<XMarkIcon />}
|
icon={<XMarkIcon />}
|
||||||
@ -70,7 +72,10 @@ const DashboardWindowButtons = () => {
|
|||||||
<MacOSTrafficLights />
|
<MacOSTrafficLights />
|
||||||
<Divider
|
<Divider
|
||||||
type='vertical'
|
type='vertical'
|
||||||
style={{ height: '14px', margin: '3px 6px 0 0' }}
|
style={{
|
||||||
|
height: '14px',
|
||||||
|
margin: showNavigationLabels ? '3px 6px 0 0' : '3px 0px 0 0'
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { getModelByName } from '../../../database/ObjectModels'
|
|||||||
import { getObjectIdFromSearch } from '../../../utils/modelActions'
|
import { getObjectIdFromSearch } from '../../../utils/modelActions'
|
||||||
import { useActions } from '../context/ActionsContext'
|
import { useActions } from '../context/ActionsContext'
|
||||||
import { AuthContext } from '../context/AuthContext'
|
import { AuthContext } from '../context/AuthContext'
|
||||||
|
import { useNavigationTabPage, useIsNavigationTabActive } from '../context/NavigationTabsContext'
|
||||||
|
|
||||||
const ModelPage = ({ modelName, pageName }) => {
|
const ModelPage = ({ modelName, pageName }) => {
|
||||||
const model = getModelByName(modelName)
|
const model = getModelByName(modelName)
|
||||||
@ -15,8 +16,12 @@ const ModelPage = ({ modelName, pageName }) => {
|
|||||||
const { setCurrentObject, setCurrentObjectType } = useActions()
|
const { setCurrentObject, setCurrentObjectType } = useActions()
|
||||||
const { userProfile } = useContext(AuthContext)
|
const { userProfile } = useContext(AuthContext)
|
||||||
const objectId = getObjectIdFromSearch(modelName, location.search)
|
const objectId = getObjectIdFromSearch(modelName, location.search)
|
||||||
|
const pageTitle = `${pageName.charAt(0).toUpperCase()}${pageName.slice(1)} - ${model?.label || modelName}`
|
||||||
|
const isTabActive = useIsNavigationTabActive()
|
||||||
|
useNavigationTabPage({ title: pageTitle, modelName })
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!isTabActive) return undefined
|
||||||
setCurrentObjectType(modelName)
|
setCurrentObjectType(modelName)
|
||||||
if (objectId) {
|
if (objectId) {
|
||||||
setCurrentObject({ _id: objectId, _user: userProfile })
|
setCurrentObject({ _id: objectId, _user: userProfile })
|
||||||
@ -25,7 +30,14 @@ const ModelPage = ({ modelName, pageName }) => {
|
|||||||
setCurrentObject(null)
|
setCurrentObject(null)
|
||||||
setCurrentObjectType(null)
|
setCurrentObjectType(null)
|
||||||
}
|
}
|
||||||
}, [modelName, objectId, setCurrentObject, setCurrentObjectType, userProfile])
|
}, [
|
||||||
|
isTabActive,
|
||||||
|
modelName,
|
||||||
|
objectId,
|
||||||
|
setCurrentObject,
|
||||||
|
setCurrentObjectType,
|
||||||
|
userProfile
|
||||||
|
])
|
||||||
|
|
||||||
if (!page?.content) {
|
if (!page?.content) {
|
||||||
return null
|
return null
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import {
|
|||||||
} from 'react'
|
} from 'react'
|
||||||
import { HolderOutlined } from '@ant-design/icons'
|
import { HolderOutlined } from '@ant-design/icons'
|
||||||
import classNames from 'classnames'
|
import classNames from 'classnames'
|
||||||
|
import { hasExternalTabDrag } from './tabDrag'
|
||||||
|
|
||||||
const SegmentedNavReorderContext = createContext(null)
|
const SegmentedNavReorderContext = createContext(null)
|
||||||
|
|
||||||
@ -59,6 +60,9 @@ const SegmentedNav = ({
|
|||||||
options = [],
|
options = [],
|
||||||
onChange,
|
onChange,
|
||||||
onReorder,
|
onReorder,
|
||||||
|
onExternalDrop,
|
||||||
|
onItemDragStart,
|
||||||
|
onItemDragEnd,
|
||||||
className,
|
className,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
animated = true
|
animated = true
|
||||||
@ -75,7 +79,10 @@ const SegmentedNav = ({
|
|||||||
const [dropTarget, setDropTarget] = useState(null)
|
const [dropTarget, setDropTarget] = useState(null)
|
||||||
|
|
||||||
const enabledOptions = getEnabledOptions(options, disabled)
|
const enabledOptions = getEnabledOptions(options, disabled)
|
||||||
const isReordering = Boolean(onReorder && draggedValue != null)
|
const acceptsExternal = Boolean(onExternalDrop)
|
||||||
|
const isReordering = Boolean(
|
||||||
|
(onReorder || acceptsExternal) && (draggedValue != null || dropTarget)
|
||||||
|
)
|
||||||
|
|
||||||
const setItemRef = useCallback((optionValue, element) => {
|
const setItemRef = useCallback((optionValue, element) => {
|
||||||
if (element) {
|
if (element) {
|
||||||
@ -172,21 +179,32 @@ const SegmentedNav = ({
|
|||||||
|
|
||||||
setDraggedValue(optionValue)
|
setDraggedValue(optionValue)
|
||||||
event.dataTransfer.effectAllowed = 'move'
|
event.dataTransfer.effectAllowed = 'move'
|
||||||
event.dataTransfer.setData('text/plain', String(optionValue))
|
if (!onItemDragStart) {
|
||||||
|
event.dataTransfer.setData('text/plain', String(optionValue))
|
||||||
|
}
|
||||||
|
onItemDragStart?.(event, optionValue)
|
||||||
},
|
},
|
||||||
[onReorder]
|
[onItemDragStart, onReorder]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleDragEnd = useCallback(() => {
|
const handleDragEnd = useCallback(() => {
|
||||||
|
onItemDragEnd?.()
|
||||||
clearDragState()
|
clearDragState()
|
||||||
}, [clearDragState])
|
}, [clearDragState, onItemDragEnd])
|
||||||
|
|
||||||
const handleItemDragOver = useCallback(
|
const handleItemDragOver = useCallback(
|
||||||
(event, option) => {
|
(event, option) => {
|
||||||
if (!onReorder || draggedValue == null || !option.reorderable) {
|
const isExternal = acceptsExternal && hasExternalTabDrag(event)
|
||||||
|
if ((!onReorder && !isExternal) || !option.reorderable) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (String(option.value) === String(draggedValue)) {
|
if (draggedValue == null && !isExternal) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
draggedValue != null &&
|
||||||
|
String(option.value) === String(draggedValue)
|
||||||
|
) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -200,12 +218,13 @@ const SegmentedNav = ({
|
|||||||
insertBefore: event.clientX < rect.left + rect.width / 2
|
insertBefore: event.clientX < rect.left + rect.width / 2
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
[draggedValue, onReorder]
|
[acceptsExternal, draggedValue, onReorder]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleItemDrop = useCallback(
|
const handleItemDrop = useCallback(
|
||||||
(event, option) => {
|
(event, option) => {
|
||||||
if (!onReorder || draggedValue == null || !option.reorderable) {
|
const isExternal = acceptsExternal && draggedValue == null
|
||||||
|
if ((!onReorder && !isExternal) || !option.reorderable) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -217,18 +236,36 @@ const SegmentedNav = ({
|
|||||||
insertBefore: true
|
insertBefore: true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isExternal) {
|
||||||
|
onExternalDrop?.(target.value, target.insertBefore, event)
|
||||||
|
clearDragState()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (draggedValue == null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (String(draggedValue) !== String(target.value)) {
|
if (String(draggedValue) !== String(target.value)) {
|
||||||
onReorder(draggedValue, target.value, target.insertBefore)
|
onReorder(draggedValue, target.value, target.insertBefore)
|
||||||
}
|
}
|
||||||
|
|
||||||
clearDragState()
|
clearDragState()
|
||||||
},
|
},
|
||||||
[clearDragState, draggedValue, dropTarget, onReorder]
|
[
|
||||||
|
acceptsExternal,
|
||||||
|
clearDragState,
|
||||||
|
draggedValue,
|
||||||
|
dropTarget,
|
||||||
|
onExternalDrop,
|
||||||
|
onReorder
|
||||||
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleGroupDragOver = useCallback(
|
const handleGroupDragOver = useCallback(
|
||||||
(event) => {
|
(event) => {
|
||||||
if (!onReorder || draggedValue == null) {
|
const isExternal = acceptsExternal && hasExternalTabDrag(event)
|
||||||
|
if ((!onReorder && !isExternal) || (draggedValue == null && !isExternal)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -253,12 +290,13 @@ const SegmentedNav = ({
|
|||||||
insertBefore: false
|
insertBefore: false
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
[draggedValue, onReorder, options]
|
[acceptsExternal, draggedValue, onReorder, options]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleGroupDrop = useCallback(
|
const handleGroupDrop = useCallback(
|
||||||
(event) => {
|
(event) => {
|
||||||
if (!onReorder || draggedValue == null) {
|
const isExternal = acceptsExternal && draggedValue == null
|
||||||
|
if ((!onReorder && !isExternal) || (draggedValue == null && !isExternal)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -274,13 +312,31 @@ const SegmentedNav = ({
|
|||||||
|
|
||||||
const reorderableOptions = options.filter((option) => option.reorderable)
|
const reorderableOptions = options.filter((option) => option.reorderable)
|
||||||
const lastOption = reorderableOptions[reorderableOptions.length - 1]
|
const lastOption = reorderableOptions[reorderableOptions.length - 1]
|
||||||
if (lastOption && String(draggedValue) !== String(lastOption.value)) {
|
if (!lastOption) {
|
||||||
|
clearDragState()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isExternal) {
|
||||||
|
onExternalDrop?.(lastOption.value, false, event)
|
||||||
|
clearDragState()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (String(draggedValue) !== String(lastOption.value)) {
|
||||||
onReorder(draggedValue, lastOption.value, false)
|
onReorder(draggedValue, lastOption.value, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
clearDragState()
|
clearDragState()
|
||||||
},
|
},
|
||||||
[clearDragState, draggedValue, onReorder, options]
|
[
|
||||||
|
acceptsExternal,
|
||||||
|
clearDragState,
|
||||||
|
draggedValue,
|
||||||
|
onExternalDrop,
|
||||||
|
onReorder,
|
||||||
|
options
|
||||||
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
const getDragHandleProps = useCallback(
|
const getDragHandleProps = useCallback(
|
||||||
@ -381,6 +437,13 @@ const SegmentedNav = ({
|
|||||||
isDropTarget && !dropTarget.insertBefore,
|
isDropTarget && !dropTarget.insertBefore,
|
||||||
'segmented-nav-item-icon': option.icon
|
'segmented-nav-item-icon': option.icon
|
||||||
})}
|
})}
|
||||||
|
draggable={Boolean(onReorder && option.reorderable)}
|
||||||
|
onDragStart={
|
||||||
|
option.reorderable
|
||||||
|
? (event) => handleDragStart(event, option.value)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onDragEnd={option.reorderable ? handleDragEnd : undefined}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!isDisabled) {
|
if (!isDisabled) {
|
||||||
onChange?.(option.value)
|
onChange?.(option.value)
|
||||||
@ -431,6 +494,9 @@ SegmentedNav.propTypes = {
|
|||||||
),
|
),
|
||||||
onChange: PropTypes.func,
|
onChange: PropTypes.func,
|
||||||
onReorder: PropTypes.func,
|
onReorder: PropTypes.func,
|
||||||
|
onExternalDrop: PropTypes.func,
|
||||||
|
onItemDragStart: PropTypes.func,
|
||||||
|
onItemDragEnd: PropTypes.func,
|
||||||
className: PropTypes.string,
|
className: PropTypes.string,
|
||||||
disabled: PropTypes.bool,
|
disabled: PropTypes.bool,
|
||||||
animated: PropTypes.bool
|
animated: PropTypes.bool
|
||||||
|
|||||||
@ -4,8 +4,10 @@ import { Button, Dropdown, Flex } from 'antd'
|
|||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { ElectronContext } from '../context/ElectronContext'
|
import { ElectronContext } from '../context/ElectronContext'
|
||||||
import { AuthContext } from '../context/AuthContext'
|
import { AuthContext } from '../context/AuthContext'
|
||||||
|
import { useNavigationTabs } from '../context/NavigationTabsContext'
|
||||||
import { useAppUpdateContext } from '../context/AppUpdateContext'
|
import { useAppUpdateContext } from '../context/AppUpdateContext'
|
||||||
import { getSidebarMenuSections } from '../../../database/Sidebars'
|
import { getSidebarMenuSections } from '../../../database/Sidebars'
|
||||||
|
import KeyboardShortcut from './KeyboardShortcut'
|
||||||
import FarmControlLogoSmall from '../../Logos/FarmControlLogoSmall'
|
import FarmControlLogoSmall from '../../Logos/FarmControlLogoSmall'
|
||||||
|
|
||||||
const runEditCommand = (command) => {
|
const runEditCommand = (command) => {
|
||||||
@ -78,6 +80,7 @@ const WindowAppMenu = () => {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { userProfile } = useContext(AuthContext)
|
const { userProfile } = useContext(AuthContext)
|
||||||
const { handleWindowControl } = useContext(ElectronContext)
|
const { handleWindowControl } = useContext(ElectronContext)
|
||||||
|
const { addTab, createNewWindow } = useNavigationTabs()
|
||||||
const { checkForUpdates } = useAppUpdateContext()
|
const { checkForUpdates } = useAppUpdateContext()
|
||||||
const includeDev = import.meta.env.DEV
|
const includeDev = import.meta.env.DEV
|
||||||
|
|
||||||
@ -111,13 +114,24 @@ const WindowAppMenu = () => {
|
|||||||
|
|
||||||
const fileMenuItems = useMemo(
|
const fileMenuItems = useMemo(
|
||||||
() => [
|
() => [
|
||||||
|
{
|
||||||
|
key: 'new-window',
|
||||||
|
label: 'New Window',
|
||||||
|
onClick: () => createNewWindow()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'new-tab',
|
||||||
|
label: 'New Tab',
|
||||||
|
onClick: () => addTab()
|
||||||
|
},
|
||||||
|
{ type: 'divider' },
|
||||||
{
|
{
|
||||||
key: 'close',
|
key: 'close',
|
||||||
label: 'Close Window',
|
label: 'Close Window',
|
||||||
onClick: () => handleWindowControl('close')
|
onClick: () => handleWindowControl('close')
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
[handleWindowControl]
|
[addTab, createNewWindow, handleWindowControl]
|
||||||
)
|
)
|
||||||
|
|
||||||
const editMenuItems = useMemo(
|
const editMenuItems = useMemo(
|
||||||
@ -250,6 +264,19 @@ const WindowAppMenu = () => {
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (menu.key === 'file') {
|
||||||
|
return (
|
||||||
|
<KeyboardShortcut
|
||||||
|
key={menu.key}
|
||||||
|
shortcut='ctrl+t'
|
||||||
|
onTrigger={addTab}
|
||||||
|
>
|
||||||
|
<KeyboardShortcut shortcut='ctrl+n' onTrigger={createNewWindow}>
|
||||||
|
<MenuButton label={menu.label} items={menu.items} />
|
||||||
|
</KeyboardShortcut>
|
||||||
|
</KeyboardShortcut>
|
||||||
|
)
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<MenuButton key={menu.key} label={menu.label} items={menu.items} />
|
<MenuButton key={menu.key} label={menu.label} items={menu.items} />
|
||||||
)
|
)
|
||||||
|
|||||||
59
src/components/Dashboard/common/tabDrag.js
Normal file
59
src/components/Dashboard/common/tabDrag.js
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -7,6 +7,7 @@ import {
|
|||||||
useState
|
useState
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import PropTypes from 'prop-types'
|
import PropTypes from 'prop-types'
|
||||||
|
import { useIsNavigationTabActive } from './NavigationTabsContext'
|
||||||
|
|
||||||
const DashboardObjectToolsContext = createContext()
|
const DashboardObjectToolsContext = createContext()
|
||||||
|
|
||||||
@ -59,6 +60,7 @@ export const useDashboardObjectToolsContext = () => {
|
|||||||
export const useDashboardObjectTools = (tools) => {
|
export const useDashboardObjectTools = (tools) => {
|
||||||
const { setCurrentObjectTools, clearCurrentObjectTools } =
|
const { setCurrentObjectTools, clearCurrentObjectTools } =
|
||||||
useDashboardObjectToolsContext()
|
useDashboardObjectToolsContext()
|
||||||
|
const isTabActive = useIsNavigationTabActive()
|
||||||
const ownerIdRef = useRef(null)
|
const ownerIdRef = useRef(null)
|
||||||
|
|
||||||
if (ownerIdRef.current == null) {
|
if (ownerIdRef.current == null) {
|
||||||
@ -66,8 +68,9 @@ export const useDashboardObjectTools = (tools) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!isTabActive) return
|
||||||
setCurrentObjectTools(tools, ownerIdRef.current)
|
setCurrentObjectTools(tools, ownerIdRef.current)
|
||||||
}, [tools, setCurrentObjectTools])
|
}, [isTabActive, tools, setCurrentObjectTools])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const ownerId = ownerIdRef.current
|
const ownerId = ownerIdRef.current
|
||||||
|
|||||||
@ -35,6 +35,12 @@ const ElectronProvider = ({ children }) => {
|
|||||||
const [electronAvailable] = useState(isElectron())
|
const [electronAvailable] = useState(isElectron())
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const lastNavigationAtRef = useRef(0)
|
const lastNavigationAtRef = useRef(0)
|
||||||
|
const tabHistoryHandlerRef = useRef(null)
|
||||||
|
|
||||||
|
const registerTabHistoryHandler = useCallback((handler) => {
|
||||||
|
tabHistoryHandlerRef.current =
|
||||||
|
typeof handler === 'function' ? handler : null
|
||||||
|
}, [])
|
||||||
|
|
||||||
const navigateHistory = useCallback(
|
const navigateHistory = useCallback(
|
||||||
(direction) => {
|
(direction) => {
|
||||||
@ -42,6 +48,11 @@ const ElectronProvider = ({ children }) => {
|
|||||||
if (now - lastNavigationAtRef.current < 300) return
|
if (now - lastNavigationAtRef.current < 300) return
|
||||||
lastNavigationAtRef.current = now
|
lastNavigationAtRef.current = now
|
||||||
|
|
||||||
|
if (tabHistoryHandlerRef.current) {
|
||||||
|
tabHistoryHandlerRef.current(direction)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (direction === 'back') {
|
if (direction === 'back') {
|
||||||
navigate(-1)
|
navigate(-1)
|
||||||
} else if (direction === 'forward') {
|
} else if (direction === 'forward') {
|
||||||
@ -158,6 +169,83 @@ const ElectronProvider = ({ children }) => {
|
|||||||
void desktopBridge.windowControl(action)
|
void desktopBridge.windowControl(action)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getWindowSession = useCallback(async () => {
|
||||||
|
if (!electronAvailable) return null
|
||||||
|
return await desktopBridge.getWindowSession()
|
||||||
|
}, [electronAvailable])
|
||||||
|
|
||||||
|
const syncWindowTabs = useCallback(
|
||||||
|
async (payload) => {
|
||||||
|
if (!electronAvailable) return false
|
||||||
|
const result = await desktopBridge.syncWindowTabs(payload)
|
||||||
|
return result?.ok ?? false
|
||||||
|
},
|
||||||
|
[electronAvailable]
|
||||||
|
)
|
||||||
|
|
||||||
|
const createAppWindow = useCallback(
|
||||||
|
async (payload) => {
|
||||||
|
if (!electronAvailable) return false
|
||||||
|
const result = await desktopBridge.createAppWindow(payload)
|
||||||
|
return result?.ok ?? false
|
||||||
|
},
|
||||||
|
[electronAvailable]
|
||||||
|
)
|
||||||
|
|
||||||
|
const onNewTabRequest = useCallback(
|
||||||
|
(handler) => {
|
||||||
|
if (!electronAvailable || typeof handler !== 'function') {
|
||||||
|
return () => {}
|
||||||
|
}
|
||||||
|
return desktopBridge.onMessage('newTab', handler)
|
||||||
|
},
|
||||||
|
[electronAvailable]
|
||||||
|
)
|
||||||
|
|
||||||
|
const onNewWindowRequest = useCallback(
|
||||||
|
(handler) => {
|
||||||
|
if (!electronAvailable || typeof handler !== 'function') {
|
||||||
|
return () => {}
|
||||||
|
}
|
||||||
|
return desktopBridge.onMessage('newWindow', handler)
|
||||||
|
},
|
||||||
|
[electronAvailable]
|
||||||
|
)
|
||||||
|
|
||||||
|
const onTabMovedAway = useCallback(
|
||||||
|
(handler) => {
|
||||||
|
if (!electronAvailable || typeof handler !== 'function') {
|
||||||
|
return () => {}
|
||||||
|
}
|
||||||
|
return desktopBridge.onMessage('tabMovedAway', handler)
|
||||||
|
},
|
||||||
|
[electronAvailable]
|
||||||
|
)
|
||||||
|
|
||||||
|
const beginTabDrag = useCallback(
|
||||||
|
async (payload) => {
|
||||||
|
if (!electronAvailable) return { ok: false }
|
||||||
|
return (await desktopBridge.beginTabDrag(payload)) || { ok: false }
|
||||||
|
},
|
||||||
|
[electronAvailable]
|
||||||
|
)
|
||||||
|
|
||||||
|
const completeTabDrop = useCallback(
|
||||||
|
async (payload) => {
|
||||||
|
if (!electronAvailable) return { ok: false }
|
||||||
|
return (await desktopBridge.completeTabDrop(payload)) || { ok: false }
|
||||||
|
},
|
||||||
|
[electronAvailable]
|
||||||
|
)
|
||||||
|
|
||||||
|
const cancelTabDrag = useCallback(
|
||||||
|
async (payload) => {
|
||||||
|
if (!electronAvailable) return { ok: false }
|
||||||
|
return (await desktopBridge.cancelTabDrag(payload)) || { ok: false }
|
||||||
|
},
|
||||||
|
[electronAvailable]
|
||||||
|
)
|
||||||
|
|
||||||
const getAuthSession = async () => {
|
const getAuthSession = async () => {
|
||||||
if (!electronAvailable) return null
|
if (!electronAvailable) return null
|
||||||
return await desktopBridge.getAuthSession()
|
return await desktopBridge.getAuthSession()
|
||||||
@ -315,7 +403,17 @@ const ElectronProvider = ({ children }) => {
|
|||||||
resizeSpotlightWindow,
|
resizeSpotlightWindow,
|
||||||
setSidebarViewMenu,
|
setSidebarViewMenu,
|
||||||
getElectronVersion,
|
getElectronVersion,
|
||||||
getAppEngine
|
getAppEngine,
|
||||||
|
registerTabHistoryHandler,
|
||||||
|
getWindowSession,
|
||||||
|
syncWindowTabs,
|
||||||
|
createAppWindow,
|
||||||
|
onNewTabRequest,
|
||||||
|
onNewWindowRequest,
|
||||||
|
onTabMovedAway,
|
||||||
|
beginTabDrag,
|
||||||
|
completeTabDrop,
|
||||||
|
cancelTabDrag
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
528
src/components/Dashboard/context/NavigationTabsContext.jsx
Normal file
528
src/components/Dashboard/context/NavigationTabsContext.jsx
Normal 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
|
||||||
@ -14,6 +14,8 @@ import { AuthContext } from './AuthContext'
|
|||||||
import { useTableState } from './TableStateContext'
|
import { useTableState } from './TableStateContext'
|
||||||
import useViewMode from '../hooks/useViewMode'
|
import useViewMode from '../hooks/useViewMode'
|
||||||
import { DEFAULT_VIEW_MODE, normalizeViewMode } from '../common/viewModeUtils'
|
import { DEFAULT_VIEW_MODE, normalizeViewMode } from '../common/viewModeUtils'
|
||||||
|
import { getModelByName } from '../../../database/ObjectModels'
|
||||||
|
import { useNavigationTabPage } from './NavigationTabsContext'
|
||||||
|
|
||||||
const ObjectListViewContext = createContext()
|
const ObjectListViewContext = createContext()
|
||||||
|
|
||||||
@ -127,6 +129,11 @@ export const ObjectListViewProvider = ({ children, objectType, tableRef }) => {
|
|||||||
} = useContext(ApiServerContext)
|
} = useContext(ApiServerContext)
|
||||||
const { token, authInitialized } = useContext(AuthContext)
|
const { token, authInitialized } = useContext(AuthContext)
|
||||||
const { getViewFromUrl, persistView, setObjectListView } = useTableState()
|
const { getViewFromUrl, persistView, setObjectListView } = useTableState()
|
||||||
|
const listModel = getModelByName(objectType)
|
||||||
|
useNavigationTabPage({
|
||||||
|
title: listModel?.label ? `List - ${listModel.label}` : 'List',
|
||||||
|
modelName: objectType
|
||||||
|
})
|
||||||
|
|
||||||
const [views, setViews] = useState(() => readCachedViews(objectType) || [])
|
const [views, setViews] = useState(() => readCachedViews(objectType) || [])
|
||||||
const [draftViews, setDraftViews] = useState(() => {
|
const [draftViews, setDraftViews] = useState(() => {
|
||||||
|
|||||||
@ -121,7 +121,8 @@ export const APP_SETTING_NAMES = [
|
|||||||
'appTheme',
|
'appTheme',
|
||||||
'appShowNavigationLabels',
|
'appShowNavigationLabels',
|
||||||
'appUpdateBranch',
|
'appUpdateBranch',
|
||||||
'appUpdateEngine'
|
'appUpdateEngine',
|
||||||
|
'appResumeLastSession'
|
||||||
]
|
]
|
||||||
|
|
||||||
const themeOptions = [
|
const themeOptions = [
|
||||||
@ -208,6 +209,17 @@ const settings = {
|
|||||||
{ label: 'Show', value: true },
|
{ label: 'Show', value: true },
|
||||||
{ label: 'Hide', value: false }
|
{ label: 'Hide', value: false }
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'appResumeLastSession',
|
||||||
|
label: 'Resume Last Session',
|
||||||
|
type: 'select',
|
||||||
|
required: true,
|
||||||
|
defaultValue: true,
|
||||||
|
options: [
|
||||||
|
{ label: 'Resume last session', value: true },
|
||||||
|
{ label: 'Start fresh', value: false }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@ -51,7 +51,20 @@ function buildEditMenu() {
|
|||||||
function buildFileMenu() {
|
function buildFileMenu() {
|
||||||
return {
|
return {
|
||||||
label: "File",
|
label: "File",
|
||||||
submenu: [{ role: "close" }],
|
submenu: [
|
||||||
|
{
|
||||||
|
label: "New Window",
|
||||||
|
accelerator: "CommandOrControl+N",
|
||||||
|
action: "new-window",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "New Tab",
|
||||||
|
accelerator: "CommandOrControl+T",
|
||||||
|
action: "new-tab",
|
||||||
|
},
|
||||||
|
{ type: "separator" },
|
||||||
|
{ role: "close" },
|
||||||
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -153,6 +166,8 @@ export function setupApplicationMenuEvents({
|
|||||||
onNavigate,
|
onNavigate,
|
||||||
onToggleDevTools,
|
onToggleDevTools,
|
||||||
onCheckForUpdates,
|
onCheckForUpdates,
|
||||||
|
onNewTab,
|
||||||
|
onNewWindow,
|
||||||
}) {
|
}) {
|
||||||
if (process.platform !== "darwin") {
|
if (process.platform !== "darwin") {
|
||||||
return;
|
return;
|
||||||
@ -174,6 +189,16 @@ export function setupApplicationMenuEvents({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (action === "new-tab") {
|
||||||
|
onNewTab?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "new-window") {
|
||||||
|
onNewWindow?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (action.startsWith(SIDEBAR_MENU_ACTION_PREFIX)) {
|
if (action.startsWith(SIDEBAR_MENU_ACTION_PREFIX)) {
|
||||||
const path = action.slice(SIDEBAR_MENU_ACTION_PREFIX.length);
|
const path = action.slice(SIDEBAR_MENU_ACTION_PREFIX.length);
|
||||||
navigateHandler?.(path || "/");
|
navigateHandler?.(path || "/");
|
||||||
|
|||||||
@ -1,9 +1,37 @@
|
|||||||
let sendMessage = () => false;
|
let sendToFocusedImpl = () => false
|
||||||
|
let sendToWindowImpl = () => false
|
||||||
|
let sendToAllImpl = () => false
|
||||||
|
|
||||||
|
export function configureWindowMessaging({
|
||||||
|
sendToFocused,
|
||||||
|
sendToWindow,
|
||||||
|
sendToAll
|
||||||
|
}) {
|
||||||
|
if (typeof sendToFocused === 'function') {
|
||||||
|
sendToFocusedImpl = sendToFocused
|
||||||
|
}
|
||||||
|
if (typeof sendToWindow === 'function') {
|
||||||
|
sendToWindowImpl = sendToWindow
|
||||||
|
}
|
||||||
|
if (typeof sendToAll === 'function') {
|
||||||
|
sendToAllImpl = sendToAll
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function setMessageSender(sender) {
|
export function setMessageSender(sender) {
|
||||||
sendMessage = sender;
|
if (typeof sender === 'function') {
|
||||||
|
sendToFocusedImpl = sender
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sendToRenderer(channel, data) {
|
export function sendToRenderer(channel, data) {
|
||||||
return sendMessage(channel, data);
|
return sendToFocusedImpl(channel, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendToWindow(windowId, channel, data) {
|
||||||
|
return sendToWindowImpl(windowId, channel, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendToAllWindows(channel, data) {
|
||||||
|
return sendToAllImpl(channel, data)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import {
|
|||||||
removeDuplicateInstallations
|
removeDuplicateInstallations
|
||||||
} from './check-duplicate-installations.js'
|
} from './check-duplicate-installations.js'
|
||||||
import { setSidebarViewMenu } from './menu.js'
|
import { setSidebarViewMenu } from './menu.js'
|
||||||
import { sendToRenderer } from './notify.js'
|
import { sendToAllWindows, sendToRenderer, sendToWindow } from './notify.js'
|
||||||
import { resizeSpotlightWindow } from './spotlight.js'
|
import { resizeSpotlightWindow } from './spotlight.js'
|
||||||
import {
|
import {
|
||||||
clearAuthSession,
|
clearAuthSession,
|
||||||
@ -19,11 +19,17 @@ import {
|
|||||||
setAuthSession
|
setAuthSession
|
||||||
} from './store.js'
|
} from './store.js'
|
||||||
import {
|
import {
|
||||||
|
createAppWindow,
|
||||||
getMainWindow,
|
getMainWindow,
|
||||||
|
getWindowSession,
|
||||||
getWindowState,
|
getWindowState,
|
||||||
handleWindowControl,
|
handleWindowControl,
|
||||||
openExternalUrl,
|
openExternalUrl,
|
||||||
openInternalUrl
|
openInternalUrl,
|
||||||
|
syncWindowTabs,
|
||||||
|
beginTabDrag,
|
||||||
|
completeTabDrop,
|
||||||
|
cancelTabDrag
|
||||||
} from './window.js'
|
} from './window.js'
|
||||||
|
|
||||||
export function createAppRpc() {
|
export function createAppRpc() {
|
||||||
@ -31,11 +37,32 @@ export function createAppRpc() {
|
|||||||
getOsInfo: async () => ({
|
getOsInfo: async () => ({
|
||||||
platform: process.platform
|
platform: process.platform
|
||||||
}),
|
}),
|
||||||
getWindowState: async () => getWindowState(),
|
getWindowState: async ({ windowId } = {}) => getWindowState(windowId),
|
||||||
windowControl: async ({ action }) => {
|
windowControl: async ({ action, windowId } = {}) => {
|
||||||
handleWindowControl(action)
|
handleWindowControl(action, windowId)
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
},
|
},
|
||||||
|
syncWindowTabs: async ({ windowId, activeTabId, tabs } = {}) => ({
|
||||||
|
ok: syncWindowTabs(windowId, { activeTabId, tabs })
|
||||||
|
}),
|
||||||
|
getWindowSession: async ({ windowId } = {}) => getWindowSession(windowId),
|
||||||
|
createAppWindow: async ({
|
||||||
|
tabs,
|
||||||
|
activeTabId,
|
||||||
|
windowId
|
||||||
|
} = {}) => {
|
||||||
|
await createAppWindow({
|
||||||
|
tabs,
|
||||||
|
activeTabId,
|
||||||
|
sourceWindowId: windowId
|
||||||
|
})
|
||||||
|
return { ok: true }
|
||||||
|
},
|
||||||
|
beginTabDrag: async ({ windowId, tab } = {}) =>
|
||||||
|
beginTabDrag({ windowId, tab }),
|
||||||
|
completeTabDrop: async ({ windowId, beforeTabId, insertBefore } = {}) =>
|
||||||
|
completeTabDrop({ windowId, beforeTabId, insertBefore }),
|
||||||
|
cancelTabDrag: async ({ windowId } = {}) => cancelTabDrag({ windowId }),
|
||||||
openExternalUrl: async ({ url }) => {
|
openExternalUrl: async ({ url }) => {
|
||||||
openExternalUrl(url)
|
openExternalUrl(url)
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
@ -57,7 +84,7 @@ export function createAppRpc() {
|
|||||||
startAppUpdate: async ({ update }) => {
|
startAppUpdate: async ({ update }) => {
|
||||||
const mainWindow = getMainWindow()
|
const mainWindow = getMainWindow()
|
||||||
const sendProgress = (payload) => {
|
const sendProgress = (payload) => {
|
||||||
sendToRenderer('appUpdateProgress', {
|
sendToAllWindows('appUpdateProgress', {
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
...payload
|
...payload
|
||||||
})
|
})
|
||||||
@ -89,7 +116,7 @@ export function createAppRpc() {
|
|||||||
error?.message || 'Failed to remove the duplicate installation.'
|
error?.message || 'Failed to remove the duplicate installation.'
|
||||||
}))
|
}))
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
sendToRenderer('duplicateInstallationsRemoved', result)
|
sendToAllWindows('duplicateInstallationsRemoved', result)
|
||||||
})
|
})
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
},
|
},
|
||||||
@ -106,10 +133,20 @@ export function createAppRpc() {
|
|||||||
handlers: {
|
handlers: {
|
||||||
requests: requestHandlers,
|
requests: requestHandlers,
|
||||||
messages: {
|
messages: {
|
||||||
rendererRequest: ({ id, method, params }) => {
|
rendererRequest: ({ id, method, params, windowId }) => {
|
||||||
|
const requestParams = params || {}
|
||||||
|
const requestWindowId = windowId || requestParams.windowId
|
||||||
|
const respond = (payload) => {
|
||||||
|
if (requestWindowId) {
|
||||||
|
sendToWindow(requestWindowId, 'rpcResponse', payload)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sendToRenderer('rpcResponse', payload)
|
||||||
|
}
|
||||||
|
|
||||||
const handler = requestHandlers[method]
|
const handler = requestHandlers[method]
|
||||||
if (!handler) {
|
if (!handler) {
|
||||||
sendToRenderer('rpcResponse', {
|
respond({
|
||||||
id,
|
id,
|
||||||
success: false,
|
success: false,
|
||||||
error: `Unknown desktop RPC method: ${String(method)}`
|
error: `Unknown desktop RPC method: ${String(method)}`
|
||||||
@ -118,16 +155,16 @@ export function createAppRpc() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void Promise.resolve()
|
void Promise.resolve()
|
||||||
.then(() => handler(params || {}))
|
.then(() => handler(requestParams))
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
sendToRenderer('rpcResponse', {
|
respond({
|
||||||
id,
|
id,
|
||||||
success: true,
|
success: true,
|
||||||
result
|
result
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
sendToRenderer('rpcResponse', {
|
respond({
|
||||||
id,
|
id,
|
||||||
success: false,
|
success: false,
|
||||||
error: error?.message || String(error)
|
error: error?.message || String(error)
|
||||||
|
|||||||
70
src/desktop/session.js
Normal file
70
src/desktop/session.js
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,37 +4,126 @@ import {
|
|||||||
applyMacOSWindowEffects,
|
applyMacOSWindowEffects,
|
||||||
MAC_TRAFFIC_LIGHT_OFFSET
|
MAC_TRAFFIC_LIGHT_OFFSET
|
||||||
} from './macos-window-effects.js'
|
} from './macos-window-effects.js'
|
||||||
import { sendToRenderer, setMessageSender } from './notify.js'
|
import {
|
||||||
|
configureWindowMessaging,
|
||||||
|
sendToRenderer,
|
||||||
|
sendToWindow
|
||||||
|
} from './notify.js'
|
||||||
import {
|
import {
|
||||||
clampWindowToWorkArea,
|
clampWindowToWorkArea,
|
||||||
isWindowWorkAreaMaximized
|
isWindowWorkAreaMaximized
|
||||||
} from './windows-work-area.js'
|
} from './windows-work-area.js'
|
||||||
|
import { findProtocolUrl, setSingleInstanceHandlers } from './single-instance.js'
|
||||||
|
import { getAppSettings } from './store.js'
|
||||||
|
import {
|
||||||
|
getWindowSessionFile,
|
||||||
|
writeWindowSessionFile,
|
||||||
|
writeWindowSessionFileSync
|
||||||
|
} from './session.js'
|
||||||
|
|
||||||
const isMacOS = process.platform === 'darwin'
|
const isMacOS = process.platform === 'darwin'
|
||||||
const isWindows = process.platform === 'win32'
|
const isWindows = process.platform === 'win32'
|
||||||
const WINDOWS_STARTUP_MAXIMIZE_DELAY_MS = 1500
|
const WINDOWS_STARTUP_MAXIMIZE_DELAY_MS = 1500
|
||||||
|
|
||||||
const DEV_SERVER_PORT = 5780
|
const DEV_SERVER_PORT = 5780
|
||||||
const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}`
|
const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}`
|
||||||
import { findProtocolUrl, setSingleInstanceHandlers } from './single-instance.js'
|
const DEFAULT_DASHBOARD_PATH = '/dashboard/production/overview'
|
||||||
|
const PROTOCOL_PREFIX = 'farmcontrol://'
|
||||||
|
|
||||||
let mainWindow = null
|
const windows = new Map()
|
||||||
let webviewDomReady = false
|
let focusedWindowId = null
|
||||||
const pendingNavigations = []
|
let sharedRpc = null
|
||||||
|
let appListenersBound = false
|
||||||
|
let persistTimer = null
|
||||||
|
|
||||||
|
export function getDefaultDashboardPath() {
|
||||||
|
return DEFAULT_DASHBOARD_PATH
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWindowId() {
|
||||||
|
return `window-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEntry(windowId) {
|
||||||
|
return windowId ? windows.get(windowId) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFocusedWindowId() {
|
||||||
|
if (focusedWindowId && windows.has(focusedWindowId)) {
|
||||||
|
return focusedWindowId
|
||||||
|
}
|
||||||
|
const first = windows.keys().next()
|
||||||
|
return first.done ? null : first.value
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWindowById(windowId) {
|
||||||
|
return getEntry(windowId)?.window || null
|
||||||
|
}
|
||||||
|
|
||||||
export function getMainWindow() {
|
export function getMainWindow() {
|
||||||
return mainWindow
|
return getWindowById(getFocusedWindowId())
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispatchToWindow(windowId, channel, data) {
|
||||||
|
const entry = getEntry(windowId)
|
||||||
|
const window = entry?.window
|
||||||
|
if (!window) return false
|
||||||
|
|
||||||
|
try {
|
||||||
|
const webview = window.webview
|
||||||
|
const channelLiteral = JSON.stringify(channel)
|
||||||
|
const payloadLiteral = JSON.stringify(data ?? null)
|
||||||
|
|
||||||
|
if (webview?.executeJavascript) {
|
||||||
|
webview.executeJavascript(
|
||||||
|
`window.__farmcontrolDispatchRpcMessage?.(${channelLiteral}, ${payloadLiteral})`
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const send = webview?.rpc?.send
|
||||||
|
if (!send) {
|
||||||
|
console.warn(
|
||||||
|
`No RPC sender available for channel: ${channel}. Is the window ready?`
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
send[channel](data)
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Failed to send RPC message on channel: ${channel}`, error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupMessaging() {
|
||||||
|
configureWindowMessaging({
|
||||||
|
sendToFocused: (channel, data) =>
|
||||||
|
dispatchToWindow(getFocusedWindowId(), channel, data),
|
||||||
|
sendToWindow: (windowId, channel, data) =>
|
||||||
|
dispatchToWindow(windowId, channel, data),
|
||||||
|
sendToAll: (channel, data) => {
|
||||||
|
let sent = false
|
||||||
|
for (const id of windows.keys()) {
|
||||||
|
if (dispatchToWindow(id, channel, data)) {
|
||||||
|
sent = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sent
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function showMainWindow() {
|
export function showMainWindow() {
|
||||||
if (!mainWindow) return
|
const window = getMainWindow()
|
||||||
|
if (!window) return
|
||||||
|
|
||||||
if (mainWindow.isMinimized?.()) {
|
if (window.isMinimized?.()) {
|
||||||
mainWindow.restore?.()
|
window.restore?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
mainWindow.show?.()
|
window.show?.()
|
||||||
mainWindow.activate?.()
|
window.activate?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getMainViewUrl() {
|
export async function getMainViewUrl() {
|
||||||
@ -54,27 +143,26 @@ export async function getMainViewUrl() {
|
|||||||
return 'views://mainview/index.html'
|
return 'views://mainview/index.html'
|
||||||
}
|
}
|
||||||
|
|
||||||
function deliverNavigation(redirectPath) {
|
function deliverNavigation(windowId, redirectPath) {
|
||||||
sendToRenderer('navigate', redirectPath)
|
sendToWindow(windowId, 'navigate', redirectPath)
|
||||||
|
const window = getWindowById(windowId)
|
||||||
|
if (!window) return
|
||||||
|
|
||||||
if (!mainWindow) return
|
if (window.isMinimized?.()) {
|
||||||
|
window.restore?.()
|
||||||
if (mainWindow.isMinimized?.()) {
|
|
||||||
mainWindow.restore?.()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
mainWindow.show?.()
|
window.show?.()
|
||||||
mainWindow.activate?.()
|
window.activate?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
function flushPendingNavigations() {
|
function flushPendingNavigations(windowId) {
|
||||||
if (!mainWindow || !webviewDomReady) {
|
const entry = getEntry(windowId)
|
||||||
return
|
if (!entry?.domReady) return
|
||||||
}
|
|
||||||
|
|
||||||
while (pendingNavigations.length > 0) {
|
while (entry.pendingNavigations.length > 0) {
|
||||||
const redirectPath = pendingNavigations.shift()
|
const redirectPath = entry.pendingNavigations.shift()
|
||||||
setTimeout(() => deliverNavigation(redirectPath), 100)
|
setTimeout(() => deliverNavigation(windowId, redirectPath), 100)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -83,12 +171,16 @@ function sendNavigateToRenderer(redirectPath) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!mainWindow || !webviewDomReady) {
|
const windowId = getFocusedWindowId()
|
||||||
pendingNavigations.push(redirectPath)
|
if (!windowId) return
|
||||||
|
|
||||||
|
const entry = getEntry(windowId)
|
||||||
|
if (!entry?.domReady) {
|
||||||
|
entry?.pendingNavigations.push(redirectPath)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setTimeout(() => deliverNavigation(redirectPath), 100)
|
setTimeout(() => deliverNavigation(windowId, redirectPath), 100)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function openInternalUrl(url) {
|
export function openInternalUrl(url) {
|
||||||
@ -96,8 +188,6 @@ export function openInternalUrl(url) {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
const PROTOCOL_PREFIX = 'farmcontrol://'
|
|
||||||
|
|
||||||
function parseDeepLinkPath(url) {
|
function parseDeepLinkPath(url) {
|
||||||
if (!url || typeof url !== 'string') {
|
if (!url || typeof url !== 'string') {
|
||||||
return null
|
return null
|
||||||
@ -143,8 +233,157 @@ export function handleDeepLinkFromArgv(launchUrl) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function broadcastWindowState() {
|
function readWindowSize(window) {
|
||||||
sendToRenderer('windowState', getWindowState())
|
const size = window?.getSize?.()
|
||||||
|
if (size && typeof size === 'object') {
|
||||||
|
return {
|
||||||
|
width: size.width ?? size[0],
|
||||||
|
height: size.height ?? size[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { width: undefined, height: undefined }
|
||||||
|
}
|
||||||
|
|
||||||
|
function readWindowPosition(window) {
|
||||||
|
const position = window?.getPosition?.()
|
||||||
|
if (position && typeof position === 'object') {
|
||||||
|
return {
|
||||||
|
x: position.x ?? position[0],
|
||||||
|
y: position.y ?? position[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { x: undefined, y: undefined }
|
||||||
|
}
|
||||||
|
|
||||||
|
function captureWindowLayout(windowId) {
|
||||||
|
const entry = getEntry(windowId)
|
||||||
|
if (!entry?.window) return
|
||||||
|
|
||||||
|
const { width, height } = readWindowSize(entry.window)
|
||||||
|
const { x, y } = readWindowPosition(entry.window)
|
||||||
|
if (width && height) {
|
||||||
|
entry.bounds = {
|
||||||
|
x: x ?? entry.bounds?.x ?? 100,
|
||||||
|
y: y ?? entry.bounds?.y ?? 100,
|
||||||
|
width,
|
||||||
|
height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entry.isFullScreen = entry.window.isFullScreen?.() ?? false
|
||||||
|
entry.isMaximized = isWindows
|
||||||
|
? isWindowWorkAreaMaximized(entry.window)
|
||||||
|
: (entry.window.isMaximized?.() ?? false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeWindows() {
|
||||||
|
return {
|
||||||
|
windows: [...windows.values()].map((entry) => ({
|
||||||
|
windowId: entry.id,
|
||||||
|
bounds: entry.bounds,
|
||||||
|
isMaximized: entry.isMaximized,
|
||||||
|
isFullScreen: entry.isFullScreen,
|
||||||
|
activeTabId: entry.activeTabId,
|
||||||
|
tabs: entry.tabs
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function persistSessionNow() {
|
||||||
|
clearTimeout(persistTimer)
|
||||||
|
persistTimer = null
|
||||||
|
writeWindowSessionFileSync(serializeWindows())
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleSessionPersist() {
|
||||||
|
clearTimeout(persistTimer)
|
||||||
|
persistTimer = setTimeout(() => {
|
||||||
|
void writeWindowSessionFile(serializeWindows())
|
||||||
|
}, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncWindowTabs(windowId, { activeTabId, tabs } = {}) {
|
||||||
|
const entry = getEntry(windowId) || getEntry(getFocusedWindowId())
|
||||||
|
if (!entry) return false
|
||||||
|
|
||||||
|
if (Array.isArray(tabs)) {
|
||||||
|
entry.tabs = tabs
|
||||||
|
}
|
||||||
|
if (activeTabId) {
|
||||||
|
entry.activeTabId = activeTabId
|
||||||
|
}
|
||||||
|
captureWindowLayout(entry.id)
|
||||||
|
scheduleSessionPersist()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWindowSession(windowId) {
|
||||||
|
const entry = getEntry(windowId) || getEntry(getFocusedWindowId())
|
||||||
|
if (!entry) {
|
||||||
|
return { windowId: null, tabs: [], activeTabId: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
windowId: entry.id,
|
||||||
|
tabs: entry.tabs,
|
||||||
|
activeTabId: entry.activeTabId,
|
||||||
|
bounds: entry.bounds,
|
||||||
|
isMaximized: entry.isMaximized,
|
||||||
|
isFullScreen: entry.isFullScreen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let pendingTabDrag = null
|
||||||
|
|
||||||
|
export function beginTabDrag({ windowId, tab } = {}) {
|
||||||
|
if (!tab?.id) return { ok: false }
|
||||||
|
pendingTabDrag = {
|
||||||
|
windowId: windowId || getFocusedWindowId(),
|
||||||
|
tab,
|
||||||
|
startedAt: Date.now()
|
||||||
|
}
|
||||||
|
return { ok: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function completeTabDrop({
|
||||||
|
windowId,
|
||||||
|
beforeTabId,
|
||||||
|
insertBefore = false
|
||||||
|
} = {}) {
|
||||||
|
const pending = pendingTabDrag
|
||||||
|
pendingTabDrag = null
|
||||||
|
if (!pending?.tab) {
|
||||||
|
return { ok: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetWindowId = windowId || getFocusedWindowId()
|
||||||
|
if (pending.windowId && pending.windowId === targetWindowId) {
|
||||||
|
return { ok: true, sameWindow: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
sendToWindow(pending.windowId, 'tabMovedAway', { tabId: pending.tab.id })
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
sameWindow: false,
|
||||||
|
tab: pending.tab,
|
||||||
|
sourceWindowId: pending.windowId,
|
||||||
|
beforeTabId,
|
||||||
|
insertBefore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cancelTabDrag({ windowId } = {}) {
|
||||||
|
if (!pendingTabDrag) return { ok: true }
|
||||||
|
if (windowId && pendingTabDrag.windowId !== windowId) {
|
||||||
|
return { ok: true }
|
||||||
|
}
|
||||||
|
pendingTabDrag = null
|
||||||
|
return { ok: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
function broadcastWindowState(windowId) {
|
||||||
|
const id = windowId || getFocusedWindowId()
|
||||||
|
if (!id) return
|
||||||
|
sendToWindow(id, 'windowState', getWindowState(id))
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyStartupWindowState(window) {
|
function applyStartupWindowState(window) {
|
||||||
@ -154,7 +393,7 @@ function applyStartupWindowState(window) {
|
|||||||
window.maximize?.()
|
window.maximize?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
setTimeout(broadcastWindowState, 100)
|
setTimeout(() => broadcastWindowState(), 100)
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncWindowsWebviewLayout(window) {
|
function syncWindowsWebviewLayout(window) {
|
||||||
@ -167,80 +406,115 @@ function syncWindowsWebviewLayout(window) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-apply the current size so the native webview relayouts to the window.
|
|
||||||
window.setSize(width, height)
|
window.setSize(width, height)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleWindowsWindowChange(window) {
|
function handleWindowsWindowChange(window, windowId) {
|
||||||
if (clampWindowToWorkArea(window)) {
|
if (clampWindowToWorkArea(window)) {
|
||||||
syncWindowsWebviewLayout(window)
|
syncWindowsWebviewLayout(window)
|
||||||
}
|
}
|
||||||
|
|
||||||
broadcastWindowState()
|
captureWindowLayout(windowId)
|
||||||
|
broadcastWindowState(windowId)
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyWindowsStartupWindowState(window) {
|
function applyWindowsStartupWindowState(window, windowId) {
|
||||||
if (!window) return
|
if (!window) return
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.maximize?.()
|
window.maximize?.()
|
||||||
// maximize() settles asynchronously; sync state after the frame updates.
|
setTimeout(() => handleWindowsWindowChange(window, windowId), 100)
|
||||||
setTimeout(() => handleWindowsWindowChange(window), 100)
|
|
||||||
}, WINDOWS_STARTUP_MAXIMIZE_DELAY_MS)
|
}, WINDOWS_STARTUP_MAXIMIZE_DELAY_MS)
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupWindowEvents(window) {
|
function setupWindowEvents(window, windowId) {
|
||||||
// Electrobun emits resize/focus, not Electron's maximize/fullscreen events.
|
|
||||||
const onWindowChange = isWindows
|
const onWindowChange = isWindows
|
||||||
? () => handleWindowsWindowChange(window)
|
? () => handleWindowsWindowChange(window, windowId)
|
||||||
: broadcastWindowState
|
: () => {
|
||||||
|
captureWindowLayout(windowId)
|
||||||
|
broadcastWindowState(windowId)
|
||||||
|
scheduleSessionPersist()
|
||||||
|
}
|
||||||
|
|
||||||
window.on?.('resize', onWindowChange)
|
window.on?.('resize', onWindowChange)
|
||||||
window.on?.('focus', broadcastWindowState)
|
|
||||||
window.on?.('move', onWindowChange)
|
window.on?.('move', onWindowChange)
|
||||||
}
|
window.on?.('focus', () => {
|
||||||
|
focusedWindowId = windowId
|
||||||
export function setupMainWindowMessaging(window = mainWindow) {
|
broadcastWindowState(windowId)
|
||||||
if (!window) {
|
})
|
||||||
return
|
window.on?.('close', () => {
|
||||||
}
|
captureWindowLayout(windowId)
|
||||||
|
windows.delete(windowId)
|
||||||
setMessageSender((channel, data) => {
|
if (focusedWindowId === windowId) {
|
||||||
try {
|
focusedWindowId = getFocusedWindowId()
|
||||||
const webview = window.webview
|
|
||||||
const channelLiteral = JSON.stringify(channel)
|
|
||||||
const payloadLiteral = JSON.stringify(data ?? null)
|
|
||||||
|
|
||||||
// Prefer direct JS dispatch — WebSocket RPC pushes can be deferred by
|
|
||||||
// WKWebView until the next interaction during native window transitions.
|
|
||||||
if (webview?.executeJavascript) {
|
|
||||||
webview.executeJavascript(
|
|
||||||
`window.__farmcontrolDispatchRpcMessage?.(${channelLiteral}, ${payloadLiteral})`
|
|
||||||
)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
const send = webview?.rpc?.send
|
|
||||||
if (!send) {
|
|
||||||
console.warn(
|
|
||||||
`No RPC sender available for channel: ${channel}. Is the window ready?`
|
|
||||||
)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
send[channel](data)
|
|
||||||
return true
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(`Failed to send RPC message on channel: ${channel}`, error)
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
persistSessionNow()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createMainWindow(rpc) {
|
function injectWindowId(window, windowId) {
|
||||||
|
try {
|
||||||
|
window?.webview?.executeJavascript?.(
|
||||||
|
`window.__farmcontrolWindowId = ${JSON.stringify(windowId)}`
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[window] Failed to inject window id.', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDefaultTab() {
|
||||||
|
const id = `tab-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
title: 'Overview - Production',
|
||||||
|
modelName: null,
|
||||||
|
history: [
|
||||||
|
{
|
||||||
|
pathname: DEFAULT_DASHBOARD_PATH,
|
||||||
|
search: '',
|
||||||
|
hash: ''
|
||||||
|
}
|
||||||
|
],
|
||||||
|
historyIndex: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function offsetBounds(bounds) {
|
||||||
|
if (!bounds) {
|
||||||
|
return { x: 120, y: 120, width: 1200, height: 800 }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
x: (bounds.x ?? 100) + 32,
|
||||||
|
y: (bounds.y ?? 100) + 32,
|
||||||
|
width: bounds.width || 1200,
|
||||||
|
height: bounds.height || 800
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createDesktopWindow({
|
||||||
|
rpc = sharedRpc,
|
||||||
|
windowId = createWindowId(),
|
||||||
|
bounds,
|
||||||
|
isMaximized = false,
|
||||||
|
isFullScreen = false,
|
||||||
|
tabs,
|
||||||
|
activeTabId,
|
||||||
|
applyDefaultMaximize = false
|
||||||
|
} = {}) {
|
||||||
|
if (!rpc) {
|
||||||
|
throw new Error('Cannot create a desktop window without RPC.')
|
||||||
|
}
|
||||||
|
|
||||||
|
sharedRpc = rpc
|
||||||
|
setupMessaging()
|
||||||
|
|
||||||
|
const initialTabs =
|
||||||
|
Array.isArray(tabs) && tabs.length > 0 ? tabs : [createDefaultTab()]
|
||||||
|
const initialActiveTabId = activeTabId || initialTabs[0].id
|
||||||
|
const frame = bounds || { width: 1200, height: 800, x: 100, y: 100 }
|
||||||
const url = await getMainViewUrl()
|
const url = await getMainViewUrl()
|
||||||
|
|
||||||
mainWindow = new BrowserWindow({
|
const window = new BrowserWindow({
|
||||||
title: 'Farm Control',
|
title: 'Farm Control',
|
||||||
url,
|
url,
|
||||||
rpc,
|
rpc,
|
||||||
@ -249,56 +523,147 @@ export async function createMainWindow(rpc) {
|
|||||||
? { transparent: true, trafficLightOffset: MAC_TRAFFIC_LIGHT_OFFSET }
|
? { transparent: true, trafficLightOffset: MAC_TRAFFIC_LIGHT_OFFSET }
|
||||||
: {}),
|
: {}),
|
||||||
frame: {
|
frame: {
|
||||||
width: 1200,
|
width: frame.width || 1200,
|
||||||
height: 800,
|
height: frame.height || 800,
|
||||||
x: 100,
|
x: frame.x ?? 100,
|
||||||
y: 100
|
y: frame.y ?? 100
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const entry = {
|
||||||
|
id: windowId,
|
||||||
|
window,
|
||||||
|
tabs: initialTabs,
|
||||||
|
activeTabId: initialActiveTabId,
|
||||||
|
bounds: frame,
|
||||||
|
isMaximized: Boolean(isMaximized),
|
||||||
|
isFullScreen: Boolean(isFullScreen),
|
||||||
|
domReady: false,
|
||||||
|
pendingNavigations: []
|
||||||
|
}
|
||||||
|
windows.set(windowId, entry)
|
||||||
|
focusedWindowId = windowId
|
||||||
|
injectWindowId(window, windowId)
|
||||||
|
|
||||||
if (isMacOS) {
|
if (isMacOS) {
|
||||||
applyMacOSWindowEffects(mainWindow)
|
applyMacOSWindowEffects(window)
|
||||||
}
|
}
|
||||||
|
|
||||||
setupMainWindowMessaging(mainWindow)
|
setupAppListeners()
|
||||||
|
setupWindowEvents(window, windowId)
|
||||||
|
setupNavigationGestures(window, windowId)
|
||||||
|
|
||||||
|
if (applyDefaultMaximize) {
|
||||||
|
applyStartupWindowState(window)
|
||||||
|
} else if (isMaximized) {
|
||||||
|
window.maximize?.()
|
||||||
|
setTimeout(() => broadcastWindowState(windowId), 100)
|
||||||
|
} else if (isFullScreen) {
|
||||||
|
window.setFullScreen?.(true)
|
||||||
|
setTimeout(() => broadcastWindowState(windowId), 100)
|
||||||
|
} else {
|
||||||
|
setTimeout(() => broadcastWindowState(windowId), 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
window.webview.on('dom-ready', () => {
|
||||||
|
entry.domReady = true
|
||||||
|
injectWindowId(window, windowId)
|
||||||
|
|
||||||
|
if (isWindows && applyDefaultMaximize) {
|
||||||
|
applyWindowsStartupWindowState(window, windowId)
|
||||||
|
}
|
||||||
|
|
||||||
|
flushPendingNavigations(windowId)
|
||||||
|
resolve(window)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createAppWindow({
|
||||||
|
tabs,
|
||||||
|
activeTabId,
|
||||||
|
sourceWindowId
|
||||||
|
} = {}) {
|
||||||
|
const source = getEntry(sourceWindowId) || getEntry(getFocusedWindowId())
|
||||||
|
captureWindowLayout(source?.id)
|
||||||
|
return createDesktopWindow({
|
||||||
|
rpc: sharedRpc,
|
||||||
|
bounds: offsetBounds(source?.bounds),
|
||||||
|
tabs,
|
||||||
|
activeTabId,
|
||||||
|
applyDefaultMaximize: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupAppListeners() {
|
||||||
|
if (appListenersBound) return
|
||||||
|
appListenersBound = true
|
||||||
|
|
||||||
if (isMacOS) {
|
if (isMacOS) {
|
||||||
applyApplicationMenu()
|
applyApplicationMenu()
|
||||||
setupApplicationMenuEvents({
|
setupApplicationMenuEvents({
|
||||||
onNavigate: sendNavigateToRenderer,
|
onNavigate: sendNavigateToRenderer,
|
||||||
onToggleDevTools: () => {
|
onToggleDevTools: () => {
|
||||||
mainWindow?.webview?.toggleDevTools?.()
|
getMainWindow()?.webview?.toggleDevTools?.()
|
||||||
},
|
},
|
||||||
onCheckForUpdates: () => {
|
onCheckForUpdates: () => {
|
||||||
sendToRenderer('checkForUpdates')
|
sendToRenderer('checkForUpdates')
|
||||||
|
},
|
||||||
|
onNewTab: () => {
|
||||||
|
sendToRenderer('newTab')
|
||||||
|
},
|
||||||
|
onNewWindow: () => {
|
||||||
|
sendToRenderer('newWindow')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
setupWindowEvents(mainWindow)
|
|
||||||
applyStartupWindowState(mainWindow)
|
|
||||||
|
|
||||||
Electrobun.events.on('open-url', (event) => {
|
Electrobun.events.on('open-url', (event) => {
|
||||||
const url = event?.data?.url
|
const url = event?.data?.url
|
||||||
if (url) {
|
if (url) {
|
||||||
handleDeepLink(url)
|
handleDeepLink(url)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
export async function startDesktopWindows(rpc) {
|
||||||
mainWindow.webview.on('dom-ready', () => {
|
sharedRpc = rpc
|
||||||
webviewDomReady = true
|
setupMessaging()
|
||||||
|
|
||||||
if (isWindows) {
|
const settings = await getAppSettings()
|
||||||
applyWindowsStartupWindowState(mainWindow)
|
const resumeLastSession = settings.appResumeLastSession !== false
|
||||||
}
|
const saved = resumeLastSession
|
||||||
|
? await getWindowSessionFile()
|
||||||
|
: { windows: [] }
|
||||||
|
const savedWindows = Array.isArray(saved.windows) ? saved.windows : []
|
||||||
|
|
||||||
flushPendingNavigations()
|
if (savedWindows.length > 0) {
|
||||||
resolve(mainWindow)
|
for (const savedWindow of savedWindows) {
|
||||||
})
|
await createDesktopWindow({
|
||||||
|
rpc,
|
||||||
|
windowId: savedWindow.windowId || createWindowId(),
|
||||||
|
bounds: savedWindow.bounds,
|
||||||
|
isMaximized: savedWindow.isMaximized,
|
||||||
|
isFullScreen: savedWindow.isFullScreen,
|
||||||
|
tabs: savedWindow.tabs,
|
||||||
|
activeTabId: savedWindow.activeTabId,
|
||||||
|
applyDefaultMaximize: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return getMainWindow()
|
||||||
|
}
|
||||||
|
|
||||||
|
return createDesktopWindow({
|
||||||
|
rpc,
|
||||||
|
applyDefaultMaximize: true
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createMainWindow(rpc) {
|
||||||
|
return startDesktopWindows(rpc)
|
||||||
|
}
|
||||||
|
|
||||||
export async function setupDevAuthServer() {
|
export async function setupDevAuthServer() {
|
||||||
const env = (process.env.NODE_ENV || 'development').trim()
|
const env = (process.env.NODE_ENV || 'development').trim()
|
||||||
if (env !== 'development') return
|
if (env !== 'development') return
|
||||||
@ -327,87 +692,101 @@ export function setupWindowsDeepLinkHandling() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getWindowState() {
|
export function getWindowState(windowId) {
|
||||||
if (!mainWindow) {
|
const window = getWindowById(windowId || getFocusedWindowId())
|
||||||
|
if (!window) {
|
||||||
return { isFullScreen: false, isMaximized: false }
|
return { isFullScreen: false, isMaximized: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isFullScreen: mainWindow.isFullScreen?.() ?? false,
|
isFullScreen: window.isFullScreen?.() ?? false,
|
||||||
isMaximized: isWindows
|
isMaximized: isWindows
|
||||||
? isWindowWorkAreaMaximized(mainWindow)
|
? isWindowWorkAreaMaximized(window)
|
||||||
: (mainWindow.isMaximized?.() ?? false)
|
: (window.isMaximized?.() ?? false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function handleWindowControl(action) {
|
export function handleWindowControl(action, windowId) {
|
||||||
if (!mainWindow && action !== 'quit') return
|
const window = getWindowById(windowId || getFocusedWindowId())
|
||||||
|
if (!window && action !== 'quit') return
|
||||||
|
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 'minimize':
|
case 'minimize':
|
||||||
mainWindow.minimize?.()
|
window.minimize?.()
|
||||||
break
|
break
|
||||||
case 'maximize': {
|
case 'maximize': {
|
||||||
const currentlyMaximized = isWindows
|
const currentlyMaximized = isWindows
|
||||||
? isWindowWorkAreaMaximized(mainWindow)
|
? isWindowWorkAreaMaximized(window)
|
||||||
: (mainWindow.isMaximized?.() ?? false)
|
: (window.isMaximized?.() ?? false)
|
||||||
|
|
||||||
if (currentlyMaximized) {
|
if (currentlyMaximized) {
|
||||||
mainWindow.unmaximize?.()
|
window.unmaximize?.()
|
||||||
} else {
|
} else {
|
||||||
mainWindow.maximize?.()
|
window.maximize?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isWindows) {
|
if (isWindows) {
|
||||||
// Frame updates after maximize/unmaximize are async on Windows.
|
setTimeout(
|
||||||
setTimeout(() => handleWindowsWindowChange(mainWindow), 100)
|
() =>
|
||||||
|
handleWindowsWindowChange(
|
||||||
|
window,
|
||||||
|
windowId || getFocusedWindowId()
|
||||||
|
),
|
||||||
|
100
|
||||||
|
)
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'fullscreen':
|
case 'fullscreen':
|
||||||
if (mainWindow.isFullScreen?.()) {
|
if (window.isFullScreen?.()) {
|
||||||
mainWindow.setFullScreen?.(false)
|
window.setFullScreen?.(false)
|
||||||
} else {
|
} else {
|
||||||
mainWindow.setFullScreen?.(true)
|
window.setFullScreen?.(true)
|
||||||
}
|
}
|
||||||
setTimeout(broadcastWindowState, 100)
|
setTimeout(
|
||||||
|
() => broadcastWindowState(windowId || getFocusedWindowId()),
|
||||||
|
100
|
||||||
|
)
|
||||||
break
|
break
|
||||||
case 'close':
|
case 'close':
|
||||||
mainWindow.close?.()
|
window.close?.()
|
||||||
break
|
break
|
||||||
case 'quit':
|
case 'quit':
|
||||||
|
persistSessionNow()
|
||||||
Utils.quit()
|
Utils.quit()
|
||||||
break
|
break
|
||||||
case 'toggle-devtools':
|
case 'toggle-devtools':
|
||||||
mainWindow?.webview?.toggleDevTools?.()
|
window?.webview?.toggleDevTools?.()
|
||||||
break
|
break
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sendNavigationGesture(direction) {
|
export function sendNavigationGesture(direction, windowId) {
|
||||||
sendToRenderer('navigationGesture', direction)
|
sendToWindow(windowId || getFocusedWindowId(), 'navigationGesture', direction)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setupNavigationGestures(window) {
|
export function setupNavigationGestures(window, windowId) {
|
||||||
if (!window) return
|
if (!window) return
|
||||||
|
|
||||||
|
const id = windowId || getFocusedWindowId()
|
||||||
|
|
||||||
if (process.platform === 'darwin') {
|
if (process.platform === 'darwin') {
|
||||||
window.on?.('swipe', (_event, direction) => {
|
window.on?.('swipe', (_event, direction) => {
|
||||||
if (direction === 'left') {
|
if (direction === 'left') {
|
||||||
sendNavigationGesture('back')
|
sendNavigationGesture('back', id)
|
||||||
} else if (direction === 'right') {
|
} else if (direction === 'right') {
|
||||||
sendNavigationGesture('forward')
|
sendNavigationGesture('forward', id)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
window.on?.('app-command', (_event, command) => {
|
window.on?.('app-command', (_event, command) => {
|
||||||
if (command === 'browser-backward') {
|
if (command === 'browser-backward') {
|
||||||
sendNavigationGesture('back')
|
sendNavigationGesture('back', id)
|
||||||
} else if (command === 'browser-forward') {
|
} else if (command === 'browser-forward') {
|
||||||
sendNavigationGesture('forward')
|
sendNavigationGesture('forward', id)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,6 +8,12 @@ let initPromise = null
|
|||||||
let initialized = false
|
let initialized = false
|
||||||
let nextRequestId = 0
|
let nextRequestId = 0
|
||||||
|
|
||||||
|
export function getDesktopWindowId() {
|
||||||
|
if (typeof window === 'undefined') return null
|
||||||
|
if (window.__farmcontrolWindowId) return window.__farmcontrolWindowId
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
export function isElectrobunDesktop() {
|
export function isElectrobunDesktop() {
|
||||||
return Boolean(
|
return Boolean(
|
||||||
typeof window !== 'undefined' &&
|
typeof window !== 'undefined' &&
|
||||||
@ -190,7 +196,12 @@ function invokeNativeRequest(method, params) {
|
|||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
type: 'message',
|
type: 'message',
|
||||||
id: 'rendererRequest',
|
id: 'rendererRequest',
|
||||||
payload: { id, method, params }
|
payload: {
|
||||||
|
id,
|
||||||
|
method,
|
||||||
|
params,
|
||||||
|
windowId: params?.windowId || getDesktopWindowId()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -209,13 +220,17 @@ async function invokeRequest(method, params = {}) {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const windowId = getDesktopWindowId()
|
||||||
|
const payload =
|
||||||
|
windowId && params.windowId == null ? { ...params, windowId } : params
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const nativeRequest = invokeNativeRequest(method, params)
|
const nativeRequest = invokeNativeRequest(method, payload)
|
||||||
if (nativeRequest) {
|
if (nativeRequest) {
|
||||||
return await nativeRequest
|
return await nativeRequest
|
||||||
}
|
}
|
||||||
|
|
||||||
return await rpc.request[method](params)
|
return await rpc.request[method](payload)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(`Electrobun RPC request failed: ${method}`, error)
|
console.warn(`Electrobun RPC request failed: ${method}`, error)
|
||||||
return null
|
return null
|
||||||
@ -249,7 +264,13 @@ const electronAPI = {
|
|||||||
setSidebarViewMenu: (sections) =>
|
setSidebarViewMenu: (sections) =>
|
||||||
invokeRequest('setSidebarViewMenu', { sections }),
|
invokeRequest('setSidebarViewMenu', { sections }),
|
||||||
getAppVersion: () => invokeRequest('getAppVersion'),
|
getAppVersion: () => invokeRequest('getAppVersion'),
|
||||||
getAppEngine: () => invokeRequest('getAppEngine')
|
getAppEngine: () => invokeRequest('getAppEngine'),
|
||||||
|
syncWindowTabs: (payload) => invokeRequest('syncWindowTabs', payload),
|
||||||
|
getWindowSession: (payload) => invokeRequest('getWindowSession', payload),
|
||||||
|
createAppWindow: (payload) => invokeRequest('createAppWindow', payload),
|
||||||
|
beginTabDrag: (payload) => invokeRequest('beginTabDrag', payload),
|
||||||
|
completeTabDrop: (payload) => invokeRequest('completeTabDrop', payload),
|
||||||
|
cancelTabDrag: (payload) => invokeRequest('cancelTabDrag', payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user