diff --git a/src/renderer/src/Router.tsx b/src/renderer/src/Router.tsx index 8985a1a41d..505b81dcb8 100644 --- a/src/renderer/src/Router.tsx +++ b/src/renderer/src/Router.tsx @@ -14,6 +14,7 @@ import FilesPage from './pages/files/FilesPage' import HomePage from './pages/home/HomePage' import KnowledgePage from './pages/knowledge/KnowledgePage' import LaunchpadPage from './pages/launchpad/LaunchpadPage' +import MinAppPage from './pages/minapps/MinAppPage' import MinAppsPage from './pages/minapps/MinAppsPage' import NotesPage from './pages/notes/NotesPage' import PaintingsRoutePage from './pages/paintings/PaintingsRoutePage' @@ -34,6 +35,7 @@ const Router: FC = () => { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/renderer/src/components/MinApp/MinApp.tsx b/src/renderer/src/components/MinApp/MinApp.tsx index c9b6a5b55b..c14346de2f 100644 --- a/src/renderer/src/components/MinApp/MinApp.tsx +++ b/src/renderer/src/components/MinApp/MinApp.tsx @@ -13,6 +13,7 @@ import { Dropdown } from 'antd' import { FC } from 'react' import { useTranslation } from 'react-i18next' import { useDispatch } from 'react-redux' +import { useNavigate } from 'react-router-dom' import styled from 'styled-components' interface Props { @@ -30,6 +31,7 @@ const MinApp: FC = ({ app, onClick, size = 60, isLast }) => { const { minapps, pinned, disabled, updateMinapps, updateDisabledMinapps, updatePinnedMinapps } = useMinapps() const { openedKeepAliveMinapps, currentMinappId, minappShow } = useRuntime() const dispatch = useDispatch() + const navigate = useNavigate() const isPinned = pinned.some((p) => p.id === app.id) const isVisible = minapps.some((m) => m.id === app.id) const isActive = minappShow && currentMinappId === app.id @@ -37,7 +39,13 @@ const MinApp: FC = ({ app, onClick, size = 60, isLast }) => { const { isTopNavbar } = useNavbarPosition() const handleClick = () => { - openMinappKeepAlive(app) + if (isTopNavbar) { + // 顶部导航栏:导航到小程序页面 + navigate(`/apps/${app.id}`) + } else { + // 侧边导航栏:保持原有弹窗行为 + openMinappKeepAlive(app) + } onClick?.() } diff --git a/src/renderer/src/components/MinApp/MinappPopupContainer.tsx b/src/renderer/src/components/MinApp/MinappPopupContainer.tsx index 226598dc57..2a99f2b560 100644 --- a/src/renderer/src/components/MinApp/MinappPopupContainer.tsx +++ b/src/renderer/src/components/MinApp/MinappPopupContainer.tsx @@ -24,6 +24,7 @@ import { useAppDispatch } from '@renderer/store' import { setMinappsOpenLinkExternal } from '@renderer/store/settings' import { MinAppType } from '@renderer/types' import { delay } from '@renderer/utils' +import { clearWebviewState, getWebviewLoaded, setWebviewLoaded } from '@renderer/utils/webviewStateManager' import { Alert, Avatar, Button, Drawer, Tooltip } from 'antd' import { WebviewTag } from 'electron' import { useEffect, useMemo, useRef, useState } from 'react' @@ -162,8 +163,7 @@ const MinappPopupContainer: React.FC = () => { /** store the webview refs, one of the key to make them keepalive */ const webviewRefs = useRef>(new Map()) - /** indicate whether the webview has loaded */ - const webviewLoadedRefs = useRef>(new Map()) + /** Note: WebView loaded states now managed globally via webviewStateManager */ /** whether the minapps open link external is enabled */ const { minappsOpenLinkExternal } = useSettings() @@ -185,7 +185,7 @@ const MinappPopupContainer: React.FC = () => { setIsPopupShow(true) - if (webviewLoadedRefs.current.get(currentMinappId)) { + if (getWebviewLoaded(currentMinappId)) { setIsReady(true) /** the case that open the minapp from sidebar */ } else if (lastMinappId.current !== currentMinappId && lastMinappShow.current === minappShow) { @@ -216,17 +216,21 @@ const MinappPopupContainer: React.FC = () => { webviewRef.style.display = appid === currentMinappId ? 'inline-flex' : 'none' }) - //delete the extra webviewLoadedRefs - webviewLoadedRefs.current.forEach((_, appid) => { - if (!webviewRefs.current.has(appid)) { - webviewLoadedRefs.current.delete(appid) - } else if (appid === currentMinappId) { - const webviewId = webviewRefs.current.get(appid)?.getWebContentsId() - if (webviewId) { - window.api.webview.setOpenLinkExternal(webviewId, minappsOpenLinkExternal) + // Set external link behavior for current minapp + if (currentMinappId) { + const webviewElement = webviewRefs.current.get(currentMinappId) + if (webviewElement) { + try { + const webviewId = webviewElement.getWebContentsId() + if (webviewId) { + window.api.webview.setOpenLinkExternal(webviewId, minappsOpenLinkExternal) + } + } catch (error) { + // WebView not ready yet, will be set when it's loaded + logger.debug(`WebView ${currentMinappId} not ready for getWebContentsId()`) } } - }) + } }, [currentMinappId, minappsOpenLinkExternal]) /** only the keepalive minapp can be minimized */ @@ -255,15 +259,17 @@ const MinappPopupContainer: React.FC = () => { /** get the current app info with extra info */ let currentAppInfo: AppInfo | null = null if (currentMinappId) { - const currentApp = combinedApps.find((item) => item.id === currentMinappId) as MinAppType - currentAppInfo = { ...currentApp, ...appsExtraInfo[currentApp.id] } + const currentApp = combinedApps.find((item) => item.id === currentMinappId) + if (currentApp) { + currentAppInfo = { ...currentApp, ...appsExtraInfo[currentApp.id] } + } } /** will close the popup and delete the webview */ const handlePopupClose = async (appid: string) => { setIsPopupShow(false) await delay(0.3) - webviewLoadedRefs.current.delete(appid) + clearWebviewState(appid) closeMinapp(appid) } @@ -292,10 +298,17 @@ const MinappPopupContainer: React.FC = () => { /** the callback function to set the webviews loaded indicator */ const handleWebviewLoaded = (appid: string) => { - webviewLoadedRefs.current.set(appid, true) - const webviewId = webviewRefs.current.get(appid)?.getWebContentsId() - if (webviewId) { - window.api.webview.setOpenLinkExternal(webviewId, minappsOpenLinkExternal) + setWebviewLoaded(appid, true) + const webviewElement = webviewRefs.current.get(appid) + if (webviewElement) { + try { + const webviewId = webviewElement.getWebContentsId() + if (webviewId) { + window.api.webview.setOpenLinkExternal(webviewId, minappsOpenLinkExternal) + } + } catch (error) { + logger.debug(`WebView ${appid} not ready for getWebContentsId() in handleWebviewLoaded`) + } } if (appid == currentMinappId) { setTimeoutTimer('handleWebviewLoaded', () => setIsReady(true), 200) @@ -352,16 +365,28 @@ const MinappPopupContainer: React.FC = () => { /** navigate back in webview history */ const handleGoBack = (appid: string) => { const webview = webviewRefs.current.get(appid) - if (webview && webview.canGoBack()) { - webview.goBack() + if (webview) { + try { + if (webview.canGoBack()) { + webview.goBack() + } + } catch (error) { + logger.debug(`WebView ${appid} not ready for goBack()`) + } } } /** navigate forward in webview history */ const handleGoForward = (appid: string) => { const webview = webviewRefs.current.get(appid) - if (webview && webview.canGoForward()) { - webview.goForward() + if (webview) { + try { + if (webview.canGoForward()) { + webview.goForward() + } + } catch (error) { + logger.debug(`WebView ${appid} not ready for goForward()`) + } } } @@ -409,7 +434,7 @@ const MinappPopupContainer: React.FC = () => { )} - + handleGoBack(appInfo.id)}> @@ -498,19 +523,23 @@ const MinappPopupContainer: React.FC = () => { return ( } + title={isTopNavbar ? null : } placement="bottom" onClose={handlePopupMinimize} open={isPopupShow} mask={false} rootClassName="minapp-drawer" maskClassName="minapp-mask" - height={'100%'} + height={isTopNavbar ? 'calc(100% - var(--navbar-height))' : '100%'} maskClosable={false} closeIcon={null} - style={{ - marginLeft: isLeftNavbar ? 'var(--sidebar-width)' : 0, - backgroundColor: window.root.style.background + styles={{ + wrapper: { + position: 'fixed', + marginLeft: isLeftNavbar ? 'var(--sidebar-width)' : 0, + marginTop: isTopNavbar ? 'var(--navbar-height)' : 0, + backgroundColor: window.root.style.background + } }}> {/* 在所有小程序中显示GoogleLoginTip */} <GoogleLoginTip isReady={isReady} currentUrl={currentUrl} currentAppId={currentMinappId} /> @@ -566,7 +595,7 @@ const TitleTextTooltip = styled.span` } ` -const ButtonsGroup = styled.div` +const ButtonsGroup = styled.div<{ isTopNavbar: boolean }>` display: flex; flex-direction: row; align-items: center; diff --git a/src/renderer/src/components/MinApp/TopViewMinappContainer.tsx b/src/renderer/src/components/MinApp/TopViewMinappContainer.tsx index 3cf05bf8c9..866f46ff8e 100644 --- a/src/renderer/src/components/MinApp/TopViewMinappContainer.tsx +++ b/src/renderer/src/components/MinApp/TopViewMinappContainer.tsx @@ -1,11 +1,14 @@ import MinappPopupContainer from '@renderer/components/MinApp/MinappPopupContainer' import { useRuntime } from '@renderer/hooks/useRuntime' +import { useNavbarPosition } from '@renderer/hooks/useSettings' const TopViewMinappContainer = () => { const { openedKeepAliveMinapps, openedOneOffMinapp } = useRuntime() + const { isLeftNavbar } = useNavbarPosition() const isCreate = openedKeepAliveMinapps.length > 0 || openedOneOffMinapp !== null - return <>{isCreate && <MinappPopupContainer />}</> + // Only show popup container in sidebar mode (left navbar), not in tab mode (top navbar) + return <>{isCreate && isLeftNavbar && <MinappPopupContainer />}</> } export default TopViewMinappContainer diff --git a/src/renderer/src/components/MinApp/WebviewContainer.tsx b/src/renderer/src/components/MinApp/WebviewContainer.tsx index 361bd39696..dd8b3c412b 100644 --- a/src/renderer/src/components/MinApp/WebviewContainer.tsx +++ b/src/renderer/src/components/MinApp/WebviewContainer.tsx @@ -1,7 +1,10 @@ -import { useNavbarPosition, useSettings } from '@renderer/hooks/useSettings' +import { loggerService } from '@logger' +import { useSettings } from '@renderer/hooks/useSettings' import { WebviewTag } from 'electron' import { memo, useEffect, useRef } from 'react' +const logger = loggerService.withContext('WebviewContainer') + /** * WebviewContainer is a component that renders a webview element. * It is used in the MinAppPopupContainer component. @@ -23,7 +26,6 @@ const WebviewContainer = memo( }) => { const webviewRef = useRef<WebviewTag | null>(null) const { enableSpellCheck } = useSettings() - const { isLeftNavbar } = useNavbarPosition() const setRef = (appid: string) => { onSetRefCallback(appid, null) @@ -41,8 +43,29 @@ const WebviewContainer = memo( useEffect(() => { if (!webviewRef.current) return + let loadCallbackFired = false + const handleLoaded = () => { - onLoadedCallback(appid) + logger.debug(`WebView did-finish-load for app: ${appid}`) + // Only fire callback once per load cycle + if (!loadCallbackFired) { + loadCallbackFired = true + // Small delay to ensure content is actually visible + setTimeout(() => { + logger.debug(`Calling onLoadedCallback for app: ${appid}`) + onLoadedCallback(appid) + }, 100) + } + } + + // Additional callback for when page is ready to show + const handleReadyToShow = () => { + logger.debug(`WebView ready-to-show for app: ${appid}`) + if (!loadCallbackFired) { + loadCallbackFired = true + logger.debug(`Calling onLoadedCallback from ready-to-show for app: ${appid}`) + onLoadedCallback(appid) + } } const handleNavigate = (event: any) => { @@ -56,16 +79,25 @@ const WebviewContainer = memo( } } + const handleStartLoading = () => { + // Reset callback flag when starting a new load + loadCallbackFired = false + } + + webviewRef.current.addEventListener('did-start-loading', handleStartLoading) webviewRef.current.addEventListener('dom-ready', handleDomReady) webviewRef.current.addEventListener('did-finish-load', handleLoaded) + webviewRef.current.addEventListener('ready-to-show', handleReadyToShow) webviewRef.current.addEventListener('did-navigate-in-page', handleNavigate) // we set the url when the webview is ready webviewRef.current.src = url return () => { + webviewRef.current?.removeEventListener('did-start-loading', handleStartLoading) webviewRef.current?.removeEventListener('dom-ready', handleDomReady) webviewRef.current?.removeEventListener('did-finish-load', handleLoaded) + webviewRef.current?.removeEventListener('ready-to-show', handleReadyToShow) webviewRef.current?.removeEventListener('did-navigate-in-page', handleNavigate) } // because the appid and url are enough, no need to add onLoadedCallback @@ -73,8 +105,8 @@ const WebviewContainer = memo( }, [appid, url]) const WebviewStyle: React.CSSProperties = { - width: isLeftNavbar ? 'calc(100vw - var(--sidebar-width))' : '100vw', - height: 'calc(100vh - var(--navbar-height))', + width: '100%', + height: '100%', backgroundColor: 'var(--color-background)', display: 'inline-flex' } diff --git a/src/renderer/src/components/Tab/TabContainer.tsx b/src/renderer/src/components/Tab/TabContainer.tsx index 1168e02431..6a1d85eb5f 100644 --- a/src/renderer/src/components/Tab/TabContainer.tsx +++ b/src/renderer/src/components/Tab/TabContainer.tsx @@ -1,11 +1,12 @@ import { PlusOutlined } from '@ant-design/icons' -import { TopNavbarOpenedMinappTabs } from '@renderer/components/app/PinnedMinapps' import { Sortable, useDndReorder } from '@renderer/components/dnd' import Scrollbar from '@renderer/components/Scrollbar' import { isLinux, isMac, isWin } from '@renderer/config/constant' +import { DEFAULT_MIN_APPS } from '@renderer/config/minapps' import { useTheme } from '@renderer/context/ThemeProvider' import { useFullscreen } from '@renderer/hooks/useFullscreen' import { useMinappPopup } from '@renderer/hooks/useMinappPopup' +import { useMinapps } from '@renderer/hooks/useMinapps' import { getThemeModeLabel, getTitleLabel } from '@renderer/i18n/label' import tabsService from '@renderer/services/TabsService' import { useAppDispatch, useAppSelector } from '@renderer/store' @@ -37,11 +38,22 @@ import { useTranslation } from 'react-i18next' import { useLocation, useNavigate } from 'react-router-dom' import styled from 'styled-components' +import MinAppIcon from '../Icons/MinAppIcon' + interface TabsContainerProps { children: React.ReactNode } -const getTabIcon = (tabId: string): React.ReactNode | undefined => { +const getTabIcon = (tabId: string, minapps: any[]): React.ReactNode | undefined => { + // Check if it's a minapp tab (format: apps:appId) + if (tabId.startsWith('apps:')) { + const appId = tabId.replace('apps:', '') + const app = [...DEFAULT_MIN_APPS, ...minapps].find((app) => app.id === appId) + if (app) { + return <MinAppIcon size={14} app={app} /> + } + } + switch (tabId) { case 'home': return <Home size={14} /> @@ -82,6 +94,7 @@ const TabsContainer: React.FC<TabsContainerProps> = ({ children }) => { const isFullscreen = useFullscreen() const { settedTheme, toggleTheme } = useTheme() const { hideMinappPopup } = useMinappPopup() + const { minapps } = useMinapps() const { t } = useTranslation() const scrollRef = useRef<HTMLDivElement>(null) const [canScroll, setCanScroll] = useState(false) @@ -89,9 +102,23 @@ const TabsContainer: React.FC<TabsContainerProps> = ({ children }) => { const getTabId = (path: string): string => { if (path === '/') return 'home' const segments = path.split('/') + // Handle minapp paths: /apps/appId -> apps:appId + if (segments[1] === 'apps' && segments[2]) { + return `apps:${segments[2]}` + } return segments[1] // 获取第一个路径段作为 id } + const getTabTitle = (tabId: string): string => { + // Check if it's a minapp tab + if (tabId.startsWith('apps:')) { + const appId = tabId.replace('apps:', '') + const app = [...DEFAULT_MIN_APPS, ...minapps].find((app) => app.id === appId) + return app ? app.name : 'MinApp' + } + return getTitleLabel(tabId) + } + const shouldCreateTab = (path: string) => { if (path === '/') return false if (path === '/settings') return false @@ -196,8 +223,8 @@ const TabsContainer: React.FC<TabsContainerProps> = ({ children }) => { renderItem={(tab) => ( <Tab key={tab.id} active={tab.id === activeTabId} onClick={() => handleTabClick(tab)}> <TabHeader> - {tab.id && <TabIcon>{getTabIcon(tab.id)}</TabIcon>} - <TabTitle>{getTitleLabel(tab.id)}</TabTitle> + {tab.id && <TabIcon>{getTabIcon(tab.id, minapps)}</TabIcon>} + <TabTitle>{getTabTitle(tab.id)}</TabTitle> </TabHeader> {tab.id !== 'home' && ( <CloseButton @@ -224,7 +251,6 @@ const TabsContainer: React.FC<TabsContainerProps> = ({ children }) => { </AddTabButton> </TabsArea> <RightButtonsContainer> - <TopNavbarOpenedMinappTabs /> <Tooltip title={t('settings.theme.title') + ': ' + getThemeModeLabel(settedTheme)} mouseEnterDelay={0.8} diff --git a/src/renderer/src/components/app/PinnedMinapps.tsx b/src/renderer/src/components/app/PinnedMinapps.tsx index 213a1e163c..730192d443 100644 --- a/src/renderer/src/components/app/PinnedMinapps.tsx +++ b/src/renderer/src/components/app/PinnedMinapps.tsx @@ -6,107 +6,13 @@ import { useNavbarPosition, useSettings } from '@renderer/hooks/useSettings' import { MinAppType } from '@renderer/types' import type { MenuProps } from 'antd' import { Dropdown, Tooltip } from 'antd' -import { FC, useEffect, useState } from 'react' +import { FC, useEffect } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' import { DraggableList } from '../DraggableList' import MinAppIcon from '../Icons/MinAppIcon' -/** Tabs of opened minapps in top navbar */ -export const TopNavbarOpenedMinappTabs: FC = () => { - const { minappShow, openedKeepAliveMinapps, currentMinappId } = useRuntime() - const { openMinappKeepAlive, hideMinappPopup, closeMinapp, closeAllMinapps } = useMinappPopup() - const { showOpenedMinappsInSidebar } = useSettings() - const { theme } = useTheme() - const { t } = useTranslation() - const [keepAliveMinapps, setKeepAliveMinapps] = useState(openedKeepAliveMinapps) - - useEffect(() => { - const timer = setTimeout(() => setKeepAliveMinapps(openedKeepAliveMinapps), 300) - return () => clearTimeout(timer) - }, [openedKeepAliveMinapps]) - - // animation for minapp switch indicator - useEffect(() => { - const iconDefaultWidth = 30 // 22px icon + 8px gap - const iconDefaultOffset = 10 // initial offset - const container = document.querySelector('.TopNavContainer') as HTMLElement - const activeIcon = document.querySelector('.TopNavContainer .opened-active') as HTMLElement - - let indicatorLeft = 0, - indicatorBottom = 0 - if (minappShow && activeIcon && container) { - indicatorLeft = activeIcon.offsetLeft + activeIcon.offsetWidth / 2 - 4 // 4 is half of the indicator's width (8px) - indicatorBottom = 0 - } else { - indicatorLeft = - ((keepAliveMinapps.length > 0 ? keepAliveMinapps.length : 1) / 2) * iconDefaultWidth + iconDefaultOffset - 4 - indicatorBottom = -50 - } - container?.style.setProperty('--indicator-left', `${indicatorLeft}px`) - container?.style.setProperty('--indicator-bottom', `${indicatorBottom}px`) - }, [currentMinappId, keepAliveMinapps, minappShow]) - - const handleOnClick = (app: MinAppType) => { - if (minappShow && currentMinappId === app.id) { - hideMinappPopup() - } else { - openMinappKeepAlive(app) - } - } - - // 检查是否需要显示已打开小程序组件 - const isShowOpened = showOpenedMinappsInSidebar && keepAliveMinapps.length > 0 - - // 如果不需要显示,返回空容器 - if (!isShowOpened) return null - - return ( - <TopNavContainer - className="TopNavContainer" - style={{ backgroundColor: keepAliveMinapps.length > 0 ? 'var(--color-list-item)' : 'transparent' }}> - <TopNavMenus> - {keepAliveMinapps.map((app) => { - const menuItems: MenuProps['items'] = [ - { - key: 'closeApp', - label: t('minapp.sidebar.close.title'), - onClick: () => { - closeMinapp(app.id) - } - }, - { - key: 'closeAllApp', - label: t('minapp.sidebar.closeall.title'), - onClick: () => { - closeAllMinapps() - } - } - ] - - const isActive = minappShow && currentMinappId === app.id - - return ( - <Tooltip key={app.id} title={app.name} mouseEnterDelay={0.8} placement="bottom"> - <Dropdown menu={{ items: menuItems }} trigger={['contextMenu']} overlayStyle={{ zIndex: 10000 }}> - <TopNavItemContainer - onClick={() => handleOnClick(app)} - theme={theme} - className={`${isActive ? 'opened-active' : ''}`}> - <TopNavIcon theme={theme}> - <MinAppIcon size={22} app={app} style={{ border: 'none', padding: 0 }} /> - </TopNavIcon> - </TopNavItemContainer> - </Dropdown> - </Tooltip> - ) - })} - </TopNavMenus> - </TopNavContainer> - ) -} - /** Tabs of opened minapps in sidebar */ export const SidebarOpenedMinappTabs: FC = () => { const { minappShow, openedKeepAliveMinapps, currentMinappId } = useRuntime() @@ -116,7 +22,7 @@ export const SidebarOpenedMinappTabs: FC = () => { const { t } = useTranslation() const { isLeftNavbar } = useNavbarPosition() - const handleOnClick = (app) => { + const handleOnClick = (app: MinAppType) => { if (minappShow && currentMinappId === app.id) { hideMinappPopup() } else { @@ -329,50 +235,3 @@ const TabsWrapper = styled.div` border-radius: 20px; overflow: hidden; ` - -const TopNavContainer = styled.div` - display: flex; - align-items: center; - padding: 2px; - gap: 4px; - background-color: var(--color-list-item); - border-radius: 20px; - margin: 0 5px; - position: relative; - overflow: hidden; - - &::after { - content: ''; - position: absolute; - left: var(--indicator-left, 0); - bottom: var(--indicator-bottom, 0); - width: 8px; - height: 4px; - background-color: var(--color-primary); - transition: - left 0.3s cubic-bezier(0.4, 0, 0.2, 1), - bottom 0.3s ease-in-out; - border-radius: 2px; - } -` - -const TopNavMenus = styled.div` - display: flex; - align-items: center; - gap: 8px; - height: 100%; -` - -const TopNavIcon = styled(Icon)` - width: 22px; - height: 22px; -` - -const TopNavItemContainer = styled.div` - display: flex; - transition: border 0.2s ease; - border-radius: 18px; - cursor: pointer; - border-radius: 50%; - padding: 2px; -` diff --git a/src/renderer/src/hooks/useMinappPopup.ts b/src/renderer/src/hooks/useMinappPopup.ts index 0ae339f6dc..25d43399a7 100644 --- a/src/renderer/src/hooks/useMinappPopup.ts +++ b/src/renderer/src/hooks/useMinappPopup.ts @@ -1,6 +1,7 @@ import { DEFAULT_MIN_APPS } from '@renderer/config/minapps' import { useRuntime } from '@renderer/hooks/useRuntime' import { useSettings } from '@renderer/hooks/useSettings' // 使用设置中的值 +import TabsService from '@renderer/services/TabsService' import { useAppDispatch } from '@renderer/store' import { setCurrentMinappId, @@ -9,6 +10,7 @@ import { setOpenedOneOffMinapp } from '@renderer/store/runtime' import { MinAppType } from '@renderer/types' +import { clearWebviewState } from '@renderer/utils/webviewStateManager' import { LRUCache } from 'lru-cache' import { useCallback } from 'react' @@ -36,7 +38,18 @@ export const useMinappPopup = () => { const createLRUCache = useCallback(() => { return new LRUCache<string, MinAppType>({ max: maxKeepAliveMinapps, - disposeAfter: () => { + disposeAfter: (_value, key) => { + // Clean up WebView state when app is disposed from cache + clearWebviewState(key) + + // Close corresponding tab if it exists + const tabs = TabsService.getTabs() + const tabToClose = tabs.find((tab) => tab.path === `/apps/${key}`) + if (tabToClose) { + TabsService.closeTab(tabToClose.id) + } + + // Update Redux state dispatch(setOpenedKeepAliveMinapps(Array.from(minAppsCache.values()))) }, onInsert: () => { @@ -158,6 +171,8 @@ export const useMinappPopup = () => { openMinappById, closeMinapp, hideMinappPopup, - closeAllMinapps + closeAllMinapps, + // Expose cache instance for TabsService integration + minAppsCache } } diff --git a/src/renderer/src/pages/launchpad/LaunchpadPage.tsx b/src/renderer/src/pages/launchpad/LaunchpadPage.tsx index a4f3452329..ae7ee55c79 100644 --- a/src/renderer/src/pages/launchpad/LaunchpadPage.tsx +++ b/src/renderer/src/pages/launchpad/LaunchpadPage.tsx @@ -2,7 +2,6 @@ import App from '@renderer/components/MinApp/MinApp' import { useMinapps } from '@renderer/hooks/useMinapps' import { useRuntime } from '@renderer/hooks/useRuntime' import { useSettings } from '@renderer/hooks/useSettings' -import tabsService from '@renderer/services/TabsService' import { Code, FileSearch, Folder, Languages, LayoutGrid, NotepadText, Palette, Sparkle } from 'lucide-react' import { FC, useMemo } from 'react' import { useTranslation } from 'react-i18next' @@ -105,7 +104,7 @@ const LaunchpadPage: FC = () => { <Grid> {sortedMinapps.map((app) => ( <AppWrapper key={app.id}> - <App app={app} size={56} onClick={() => setTimeout(() => tabsService.closeTab('launchpad'), 350)} /> + <App app={app} size={56} /> </AppWrapper> ))} </Grid> diff --git a/src/renderer/src/pages/minapps/MinAppPage.tsx b/src/renderer/src/pages/minapps/MinAppPage.tsx new file mode 100644 index 0000000000..f56b60c761 --- /dev/null +++ b/src/renderer/src/pages/minapps/MinAppPage.tsx @@ -0,0 +1,102 @@ +import { loggerService } from '@logger' +import { DEFAULT_MIN_APPS } from '@renderer/config/minapps' +import { useMinappPopup } from '@renderer/hooks/useMinappPopup' +import { useMinapps } from '@renderer/hooks/useMinapps' +import { useRuntime } from '@renderer/hooks/useRuntime' +import { useNavbarPosition } from '@renderer/hooks/useSettings' +import TabsService from '@renderer/services/TabsService' +import { FC, useEffect, useMemo, useRef } from 'react' +import { useNavigate, useParams } from 'react-router-dom' +import styled from 'styled-components' + +import MinAppFullPageView from './components/MinAppFullPageView' + +const logger = loggerService.withContext('MinAppPage') + +const MinAppPage: FC = () => { + const { appId } = useParams<{ appId: string }>() + const { isTopNavbar } = useNavbarPosition() + const { openMinappKeepAlive, minAppsCache } = useMinappPopup() + const { minapps } = useMinapps() + const { openedKeepAliveMinapps } = useRuntime() + const navigate = useNavigate() + + // Remember the initial navbar position when component mounts + const initialIsTopNavbar = useRef<boolean>(isTopNavbar) + const hasRedirected = useRef<boolean>(false) + + // Initialize TabsService with cache reference + useEffect(() => { + if (minAppsCache) { + TabsService.setMinAppsCache(minAppsCache) + } + }, [minAppsCache]) + + // Debug: track navbar position changes + useEffect(() => { + if (initialIsTopNavbar.current !== isTopNavbar) { + logger.debug(`NavBar position changed from ${initialIsTopNavbar.current} to ${isTopNavbar}`) + } + }, [isTopNavbar]) + + // Find the app from all available apps + const app = useMemo(() => { + if (!appId) return null + return [...DEFAULT_MIN_APPS, ...minapps].find((app) => app.id === appId) + }, [appId, minapps]) + + useEffect(() => { + // If app not found, redirect to apps list + if (!app) { + navigate('/apps') + return + } + + // For sidebar navigation, redirect to apps list and open popup + // Only check once and only if we haven't already redirected + if (!initialIsTopNavbar.current && !hasRedirected.current) { + hasRedirected.current = true + navigate('/apps') + // Open popup after navigation + setTimeout(() => { + openMinappKeepAlive(app) + }, 100) + return + } + + // For top navbar mode, integrate with cache system + if (initialIsTopNavbar.current) { + // Check if app is already in the keep-alive cache + const isAlreadyInCache = openedKeepAliveMinapps.some((cachedApp) => cachedApp.id === app.id) + + if (!isAlreadyInCache) { + logger.debug(`Adding app ${app.id} to keep-alive cache via openMinappKeepAlive`) + // Add to cache without showing popup (for tab mode) + openMinappKeepAlive(app) + } else { + logger.debug(`App ${app.id} already in keep-alive cache`) + } + } + }, [app, navigate, openMinappKeepAlive, openedKeepAliveMinapps, initialIsTopNavbar]) + + // Don't render anything if app not found or not in top navbar mode initially + if (!app || !initialIsTopNavbar.current) { + return null + } + + return ( + <Container> + <MinAppFullPageView app={app} /> + </Container> + ) +} + +const Container = styled.div` + display: flex; + flex: 1; + flex-direction: column; + height: 100%; + overflow: hidden; +` + +export default MinAppPage diff --git a/src/renderer/src/pages/minapps/components/MinAppFullPageView.tsx b/src/renderer/src/pages/minapps/components/MinAppFullPageView.tsx new file mode 100644 index 0000000000..1265e693ff --- /dev/null +++ b/src/renderer/src/pages/minapps/components/MinAppFullPageView.tsx @@ -0,0 +1,166 @@ +import { loggerService } from '@logger' +import WebviewContainer from '@renderer/components/MinApp/WebviewContainer' +import { useSettings } from '@renderer/hooks/useSettings' +import { MinAppType } from '@renderer/types' +import { getWebviewLoaded, setWebviewLoaded } from '@renderer/utils/webviewStateManager' +import { Avatar } from 'antd' +import { WebviewTag } from 'electron' +import { FC, useCallback, useEffect, useRef, useState } from 'react' +import BeatLoader from 'react-spinners/BeatLoader' +import styled from 'styled-components' + +import MinimalToolbar from './MinimalToolbar' + +const logger = loggerService.withContext('MinAppFullPageView') + +interface Props { + app: MinAppType +} + +const MinAppFullPageView: FC<Props> = ({ app }) => { + const webviewRef = useRef<WebviewTag | null>(null) + const [isReady, setIsReady] = useState(false) + const [currentUrl, setCurrentUrl] = useState<string | null>(null) + const { minappsOpenLinkExternal } = useSettings() + + // Debug: log isReady state changes + useEffect(() => { + logger.debug(`isReady state changed to: ${isReady}`) + }, [isReady]) + + // Initialize when app changes - smart loading state detection using global state + useEffect(() => { + setCurrentUrl(app.url) + + // Check if this WebView has been loaded before using global state manager + if (getWebviewLoaded(app.id)) { + logger.debug(`App ${app.id} already loaded before, setting ready immediately`) + setIsReady(true) + return // No cleanup needed for immediate ready state + } else { + logger.debug(`App ${app.id} not loaded before, showing loading state`) + setIsReady(false) + + // Backup timer logic removed as requested—loading animation will show indefinitely if needed. + // (See version control history for previous implementation.) + } + }, [app]) + + const handleWebviewSetRef = useCallback((_appId: string, element: WebviewTag | null) => { + webviewRef.current = element + if (element) { + logger.debug('WebView element set') + } + }, []) + + const handleWebviewLoaded = useCallback( + (appId: string) => { + logger.debug(`WebView loaded for app: ${appId}`) + const webviewId = webviewRef.current?.getWebContentsId() + if (webviewId) { + window.api.webview.setOpenLinkExternal(webviewId, minappsOpenLinkExternal) + } + + // Mark this WebView as loaded for future use in global state + setWebviewLoaded(appId, true) + + // Use small delay like MinappPopupContainer (100ms) to ensure content is visible + if (appId === app.id) { + setTimeout(() => { + logger.debug(`WebView loaded callback: setting isReady to true for ${appId}`) + setIsReady(true) + }, 100) + } + }, + [minappsOpenLinkExternal, app.id] + ) + + const handleWebviewNavigate = useCallback((_appId: string, url: string) => { + logger.debug(`URL changed: ${url}`) + setCurrentUrl(url) + }, []) + + const handleReload = useCallback(() => { + if (webviewRef.current) { + // Clear the loaded state for this app since we're reloading using global state + setWebviewLoaded(app.id, false) + setIsReady(false) // Set loading state when reloading + webviewRef.current.src = app.url + } + }, [app.url, app.id]) + + const handleOpenDevTools = useCallback(() => { + if (webviewRef.current) { + webviewRef.current.openDevTools() + } + }, []) + + return ( + <Container> + <MinimalToolbar + app={app} + webviewRef={webviewRef} + currentUrl={currentUrl} + onReload={handleReload} + onOpenDevTools={handleOpenDevTools} + /> + + <WebviewArea> + {!isReady && ( + <LoadingMask> + <LoadingOverlay> + <Avatar src={app.logo} size={60} style={{ border: '1px solid var(--color-border)' }} /> + <BeatLoader color="var(--color-text-2)" size={8} style={{ marginTop: 12 }} /> + </LoadingOverlay> + </LoadingMask> + )} + + <WebviewContainer + key={app.id} + appid={app.id} + url={app.url} + onSetRefCallback={handleWebviewSetRef} + onLoadedCallback={handleWebviewLoaded} + onNavigateCallback={handleWebviewNavigate} + /> + </WebviewArea> + </Container> + ) +} + +const Container = styled.div` + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; +` + +const WebviewArea = styled.div` + flex: 1; + position: relative; + overflow: hidden; + background-color: var(--color-background); + min-height: 0; /* Ensure flex child can shrink */ +` + +const LoadingMask = styled.div` + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: var(--color-background); + z-index: 100; + display: flex; + align-items: center; + justify-content: center; +` + +const LoadingOverlay = styled.div` + display: flex; + flex-direction: column; + align-items: center; + pointer-events: none; +` + +export default MinAppFullPageView diff --git a/src/renderer/src/pages/minapps/components/MinimalToolbar.tsx b/src/renderer/src/pages/minapps/components/MinimalToolbar.tsx new file mode 100644 index 0000000000..60a43b8684 --- /dev/null +++ b/src/renderer/src/pages/minapps/components/MinimalToolbar.tsx @@ -0,0 +1,218 @@ +import { + ArrowLeftOutlined, + ArrowRightOutlined, + CodeOutlined, + ExportOutlined, + LinkOutlined, + MinusOutlined, + PushpinOutlined, + ReloadOutlined +} from '@ant-design/icons' +import { DEFAULT_MIN_APPS } from '@renderer/config/minapps' +import { useMinapps } from '@renderer/hooks/useMinapps' +import { useSettings } from '@renderer/hooks/useSettings' +import { useAppDispatch } from '@renderer/store' +import { setMinappsOpenLinkExternal } from '@renderer/store/settings' +import { MinAppType } from '@renderer/types' +import { Tooltip } from 'antd' +import { WebviewTag } from 'electron' +import { FC, useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useNavigate } from 'react-router-dom' +import styled from 'styled-components' + +interface Props { + app: MinAppType + webviewRef: React.RefObject<WebviewTag | null> + currentUrl: string | null + onReload: () => void + onOpenDevTools: () => void +} + +const MinimalToolbar: FC<Props> = ({ app, webviewRef, currentUrl, onReload, onOpenDevTools }) => { + const { t } = useTranslation() + const { pinned, updatePinnedMinapps } = useMinapps() + const { minappsOpenLinkExternal } = useSettings() + const dispatch = useAppDispatch() + const navigate = useNavigate() + const [canGoBack, setCanGoBack] = useState(false) + const [canGoForward, setCanGoForward] = useState(false) + + const isInDevelopment = process.env.NODE_ENV === 'development' + const canPinned = DEFAULT_MIN_APPS.some((item) => item.id === app.id) + const isPinned = pinned.some((item) => item.id === app.id) + const canOpenExternalLink = app.url.startsWith('http://') || app.url.startsWith('https://') + + // Update navigation state + const updateNavigationState = useCallback(() => { + if (webviewRef.current) { + setCanGoBack(webviewRef.current.canGoBack()) + setCanGoForward(webviewRef.current.canGoForward()) + } + }, [webviewRef]) + + const handleGoBack = useCallback(() => { + if (webviewRef.current && webviewRef.current.canGoBack()) { + webviewRef.current.goBack() + updateNavigationState() + } + }, [webviewRef, updateNavigationState]) + + const handleGoForward = useCallback(() => { + if (webviewRef.current && webviewRef.current.canGoForward()) { + webviewRef.current.goForward() + updateNavigationState() + } + }, [webviewRef, updateNavigationState]) + + const handleMinimize = useCallback(() => { + navigate('/apps') + }, [navigate]) + + const handleTogglePin = useCallback(() => { + const newPinned = isPinned ? pinned.filter((item) => item.id !== app.id) : [...pinned, app] + updatePinnedMinapps(newPinned) + }, [app, isPinned, pinned, updatePinnedMinapps]) + + const handleToggleOpenExternal = useCallback(() => { + dispatch(setMinappsOpenLinkExternal(!minappsOpenLinkExternal)) + }, [dispatch, minappsOpenLinkExternal]) + + const handleOpenLink = useCallback(() => { + const urlToOpen = currentUrl || app.url + window.api.openWebsite(urlToOpen) + }, [currentUrl, app.url]) + + return ( + <ToolbarContainer> + <LeftSection> + <ButtonGroup> + <Tooltip title={t('minapp.popup.goBack')} placement="bottom"> + <ToolbarButton onClick={handleGoBack} $disabled={!canGoBack}> + <ArrowLeftOutlined /> + </ToolbarButton> + </Tooltip> + + <Tooltip title={t('minapp.popup.goForward')} placement="bottom"> + <ToolbarButton onClick={handleGoForward} $disabled={!canGoForward}> + <ArrowRightOutlined /> + </ToolbarButton> + </Tooltip> + + <Tooltip title={t('minapp.popup.refresh')} placement="bottom"> + <ToolbarButton onClick={onReload}> + <ReloadOutlined /> + </ToolbarButton> + </Tooltip> + </ButtonGroup> + </LeftSection> + + <RightSection> + <ButtonGroup> + {canOpenExternalLink && ( + <Tooltip title={t('minapp.popup.openExternal')} placement="bottom"> + <ToolbarButton onClick={handleOpenLink}> + <ExportOutlined /> + </ToolbarButton> + </Tooltip> + )} + + {canPinned && ( + <Tooltip + title={isPinned ? t('minapp.remove_from_launchpad') : t('minapp.add_to_launchpad')} + placement="bottom"> + <ToolbarButton onClick={handleTogglePin} $active={isPinned}> + <PushpinOutlined /> + </ToolbarButton> + </Tooltip> + )} + + <Tooltip + title={ + minappsOpenLinkExternal + ? t('minapp.popup.open_link_external_on') + : t('minapp.popup.open_link_external_off') + } + placement="bottom"> + <ToolbarButton onClick={handleToggleOpenExternal} $active={minappsOpenLinkExternal}> + <LinkOutlined /> + </ToolbarButton> + </Tooltip> + + {isInDevelopment && ( + <Tooltip title={t('minapp.popup.devtools')} placement="bottom"> + <ToolbarButton onClick={onOpenDevTools}> + <CodeOutlined /> + </ToolbarButton> + </Tooltip> + )} + + <Tooltip title={t('minapp.popup.minimize')} placement="bottom"> + <ToolbarButton onClick={handleMinimize}> + <MinusOutlined /> + </ToolbarButton> + </Tooltip> + </ButtonGroup> + </RightSection> + </ToolbarContainer> + ) +} + +const ToolbarContainer = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + height: 35px; + padding: 0 12px; + background-color: var(--color-background); + flex-shrink: 0; +` + +const LeftSection = styled.div` + display: flex; + align-items: center; + gap: 8px; +` + +const RightSection = styled.div` + display: flex; + align-items: center; +` + +const ButtonGroup = styled.div` + display: flex; + align-items: center; + gap: 2px; +` + +const ToolbarButton = styled.button<{ + $disabled?: boolean + $active?: boolean +}>` + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: none; + border-radius: 4px; + background: ${({ $active }) => ($active ? 'var(--color-primary-bg)' : 'transparent')}; + color: ${({ $disabled, $active }) => + $disabled ? 'var(--color-text-3)' : $active ? 'var(--color-primary)' : 'var(--color-text-2)'}; + cursor: ${({ $disabled }) => ($disabled ? 'default' : 'pointer')}; + transition: all 0.2s ease; + font-size: 12px; + + &:hover { + background: ${({ $disabled, $active }) => + $disabled ? 'transparent' : $active ? 'var(--color-primary-bg)' : 'var(--color-background-soft)'}; + color: ${({ $disabled, $active }) => + $disabled ? 'var(--color-text-3)' : $active ? 'var(--color-primary)' : 'var(--color-text-1)'}; + } + + &:active { + transform: ${({ $disabled }) => ($disabled ? 'none' : 'scale(0.95)')}; + } +` + +export default MinimalToolbar diff --git a/src/renderer/src/services/TabsService.ts b/src/renderer/src/services/TabsService.ts index 0153dd5663..a05220f1a3 100644 --- a/src/renderer/src/services/TabsService.ts +++ b/src/renderer/src/services/TabsService.ts @@ -1,12 +1,29 @@ import { loggerService } from '@logger' import store from '@renderer/store' import { removeTab, setActiveTab } from '@renderer/store/tabs' +import { MinAppType } from '@renderer/types' +import { clearWebviewState } from '@renderer/utils/webviewStateManager' +import { LRUCache } from 'lru-cache' import NavigationService from './NavigationService' const logger = loggerService.withContext('TabsService') class TabsService { + private minAppsCache: LRUCache<string, MinAppType> | null = null + + /** + * Sets the reference to the mini-apps LRU cache used for managing mini-app lifecycle and cleanup. + * This method is required to integrate TabsService with the mini-apps cache system, allowing TabsService + * to perform cache cleanup when tabs associated with mini-apps are closed. The cache instance is typically + * provided by the mini-app popup system and enables TabsService to maintain cache consistency and prevent + * stale data. + * @param cache The LRUCache instance containing mini-app data, provided by useMinappPopup. + */ + public setMinAppsCache(cache: LRUCache<string, MinAppType>) { + this.minAppsCache = cache + logger.debug('Mini-apps cache reference set in TabsService') + } /** * 关闭指定的标签页 * @param tabId 要关闭的标签页ID @@ -49,6 +66,9 @@ class TabsService { } } + // Clean up mini-app cache if this is a mini-app tab + this.cleanupMinAppCache(tabId) + // 使用 Redux action 移除标签页 store.dispatch(removeTab(tabId)) @@ -56,6 +76,32 @@ class TabsService { return true } + /** + * Clean up mini-app cache and WebView state when tab is closed + * @param tabId The tab ID to clean up + */ + private cleanupMinAppCache(tabId: string) { + // Check if this is a mini-app tab (format: /apps/{appId}) + const tabs = store.getState().tabs.tabs + const tab = tabs.find((t) => t.id === tabId) + + if (tab && tab.path.startsWith('/apps/')) { + const appId = tab.path.replace('/apps/', '') + + if (this.minAppsCache && this.minAppsCache.has(appId)) { + logger.debug(`Cleaning up mini-app cache for app: ${appId}`) + + // Remove from LRU cache - this will trigger disposeAfter callback + this.minAppsCache.delete(appId) + + // Clear WebView state + clearWebviewState(appId) + + logger.info(`Mini-app ${appId} removed from cache due to tab closure`) + } + } + } + /** * 获取所有标签页 */ diff --git a/src/renderer/src/utils/webviewStateManager.ts b/src/renderer/src/utils/webviewStateManager.ts new file mode 100644 index 0000000000..2512ef5f62 --- /dev/null +++ b/src/renderer/src/utils/webviewStateManager.ts @@ -0,0 +1,55 @@ +import { loggerService } from '@logger' + +const logger = loggerService.withContext('WebviewStateManager') + +// Global WebView loaded states - shared between popup and tab modes +const globalWebviewStates = new Map<string, boolean>() + +/** + * Set WebView loaded state for a specific app + * @param appId - The mini-app ID + * @param loaded - Whether the WebView is loaded + */ +export const setWebviewLoaded = (appId: string, loaded: boolean) => { + globalWebviewStates.set(appId, loaded) + logger.debug(`WebView state set for ${appId}: ${loaded}`) +} + +/** + * Get WebView loaded state for a specific app + * @param appId - The mini-app ID + * @returns Whether the WebView is loaded + */ +export const getWebviewLoaded = (appId: string): boolean => { + return globalWebviewStates.get(appId) || false +} + +/** + * Clear WebView state for a specific app + * @param appId - The mini-app ID + */ +export const clearWebviewState = (appId: string) => { + const wasLoaded = globalWebviewStates.delete(appId) + if (wasLoaded) { + logger.debug(`WebView state cleared for ${appId}`) + } +} + +/** + * Clear all WebView states + */ +export const clearAllWebviewStates = () => { + const count = globalWebviewStates.size + globalWebviewStates.clear() + logger.debug(`Cleared all WebView states (${count} apps)`) +} + +/** + * Get all loaded app IDs + * @returns Array of app IDs that have loaded WebViews + */ +export const getLoadedAppIds = (): string[] => { + return Array.from(globalWebviewStates.entries()) + .filter(([, loaded]) => loaded) + .map(([appId]) => appId) +}