fix(minapps): openMinApp function doesn't work properly (#10308)

* feat(minapps): support temporary minapps

* feat(settings): use openSmartMinApp with app logo to open docs sites

* refactor(icons): replace styled img with tailwind

* feat(tab): tighten types

* feat(tab): use minapps cache and log missing entries

* test(icons): update MinAppIcon snapshot to reflect class and attrs
This commit is contained in:
George·Dong 2025-09-24 14:19:06 +08:00 committed by GitHub
parent fe0c0fac1e
commit ec4d106a59
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 170 additions and 60 deletions

View File

@ -1,7 +1,6 @@
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
import { MinAppType } from '@renderer/types'
import { FC } from 'react'
import styled from 'styled-components'
interface Props {
app: MinAppType
@ -11,31 +10,52 @@ interface Props {
}
const MinAppIcon: FC<Props> = ({ app, size = 48, style, sidebar = false }) => {
// First try to find in DEFAULT_MIN_APPS for predefined styling
const _app = DEFAULT_MIN_APPS.find((item) => item.id === app.id)
if (!_app) {
return null
// If found in DEFAULT_MIN_APPS, use predefined styling
if (_app) {
return (
<img
src={_app.logo}
className="select-none rounded-2xl"
style={{
border: _app.bodered ? '0.5px solid var(--color-border)' : 'none',
width: `${size}px`,
height: `${size}px`,
backgroundColor: _app.background,
userSelect: 'none',
...(sidebar ? {} : app.style),
...style
}}
draggable={false}
alt={app.name || 'MinApp Icon'}
/>
)
}
return (
<Container
src={_app.logo}
style={{
border: _app.bodered ? '0.5px solid var(--color-border)' : 'none',
width: `${size}px`,
height: `${size}px`,
backgroundColor: _app.background,
...(sidebar ? {} : app.style),
...style
}}
/>
)
// If not found in DEFAULT_MIN_APPS but app has logo, use it (for temporary apps)
if (app.logo) {
return (
<img
src={app.logo}
className="select-none rounded-2xl"
style={{
border: 'none',
width: `${size}px`,
height: `${size}px`,
backgroundColor: 'transparent',
userSelect: 'none',
...(sidebar ? {} : app.style),
...style
}}
draggable={false}
alt={app.name || 'MinApp Icon'}
/>
)
}
return null
}
const Container = styled.img`
border-radius: 16px;
user-select: none;
-webkit-user-drag: none;
`
export default MinAppIcon

View File

@ -1,15 +1,11 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`MinAppIcon > should render correctly with various props 1`] = `
.c0 {
border-radius: 16px;
user-select: none;
-webkit-user-drag: none;
}
<img
class="c0"
alt="Test App"
class="select-none rounded-2xl"
draggable="false"
src="/test-logo-1.png"
style="border: 0.5px solid var(--color-border); width: 64px; height: 64px; background-color: rgb(240, 240, 240); opacity: 0.8; transform: scale(1.1); margin-top: 10px;"
style="border: 0.5px solid var(--color-border); width: 64px; height: 64px; background-color: rgb(240, 240, 240); user-select: none; opacity: 0.8; transform: scale(1.1); margin-top: 10px;"
/>
`;

View File

@ -1,4 +1,5 @@
import { PlusOutlined } from '@ant-design/icons'
import { loggerService } from '@logger'
import { Sortable, useDndReorder } from '@renderer/components/dnd'
import HorizontalScrollContainer from '@renderer/components/HorizontalScrollContainer'
import { isMac } from '@renderer/config/constant'
@ -12,9 +13,10 @@ import tabsService from '@renderer/services/TabsService'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import type { Tab } from '@renderer/store/tabs'
import { addTab, removeTab, setActiveTab, setTabs } from '@renderer/store/tabs'
import { ThemeMode } from '@renderer/types'
import { MinAppType, ThemeMode } from '@renderer/types'
import { classNames } from '@renderer/utils'
import { Tooltip } from 'antd'
import { LRUCache } from 'lru-cache'
import {
FileSearch,
Folder,
@ -45,14 +47,40 @@ interface TabsContainerProps {
children: React.ReactNode
}
const getTabIcon = (tabId: string, minapps: any[]): React.ReactNode | undefined => {
const logger = loggerService.withContext('TabContainer')
const getTabIcon = (
tabId: string,
minapps: MinAppType[],
minAppsCache?: LRUCache<string, MinAppType>
): 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)
let app = [...DEFAULT_MIN_APPS, ...minapps].find((app) => app.id === appId)
// If not found in permanent apps, search in temporary apps cache
// The cache stores apps opened via openSmartMinapp() for top navbar mode
// These are temporary MinApps that were opened but not yet saved to user's config
// The cache is LRU (Least Recently Used) with max size from settings
// Cache validity: Apps in cache are currently active/recently used, not outdated
if (!app && minAppsCache) {
app = minAppsCache.get(appId)
// Defensive programming: If app not found in cache but tab exists,
// the cache entry may have been evicted due to LRU policy
// Log warning for debugging potential sync issues
if (!app) {
logger.warn(`MinApp ${appId} not found in cache, using fallback icon`)
}
}
if (app) {
return <MinAppIcon size={14} app={app} />
}
// Fallback: If no app found (cache evicted), show default icon
return <LayoutGrid size={14} />
}
switch (tabId) {
@ -94,7 +122,7 @@ const TabsContainer: React.FC<TabsContainerProps> = ({ children }) => {
const activeTabId = useAppSelector((state) => state.tabs.activeTabId)
const isFullscreen = useFullscreen()
const { settedTheme, toggleTheme } = useTheme()
const { hideMinappPopup } = useMinappPopup()
const { hideMinappPopup, minAppsCache } = useMinappPopup()
const { minapps } = useMinapps()
const { t } = useTranslation()
@ -112,8 +140,23 @@ const TabsContainer: React.FC<TabsContainerProps> = ({ children }) => {
// 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'
let app = [...DEFAULT_MIN_APPS, ...minapps].find((app) => app.id === appId)
// If not found in permanent apps, search in temporary apps cache
// This ensures temporary MinApps display proper titles while being used
// The LRU cache automatically manages app lifecycle and prevents memory leaks
if (!app && minAppsCache) {
app = minAppsCache.get(appId)
// Defensive programming: If app not found in cache but tab exists,
// the cache entry may have been evicted due to LRU policy
if (!app) {
logger.warn(`MinApp ${appId} not found in cache, using fallback title`)
}
}
// Return app name if found, otherwise use fallback with appId
return app ? app.name : `MinApp-${appId}`
}
return getTitleLabel(tabId)
}
@ -196,7 +239,7 @@ 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, minapps)}</TabIcon>}
{tab.id && <TabIcon>{getTabIcon(tab.id, minapps, minAppsCache)}</TabIcon>}
<TabTitle>{getTabTitle(tab.id)}</TabTitle>
</TabHeader>
{tab.id !== 'home' && (

View File

@ -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 NavigationService from '@renderer/services/NavigationService'
import TabsService from '@renderer/services/TabsService'
import { useAppDispatch } from '@renderer/store'
import {
@ -14,6 +15,8 @@ import { clearWebviewState } from '@renderer/utils/webviewStateManager'
import { LRUCache } from 'lru-cache'
import { useCallback } from 'react'
import { useNavbarPosition } from './useSettings'
let minAppsCache: LRUCache<string, MinAppType>
/**
@ -34,6 +37,7 @@ export const useMinappPopup = () => {
const dispatch = useAppDispatch()
const { openedKeepAliveMinapps, openedOneOffMinapp, minappShow } = useRuntime()
const { maxKeepAliveMinapps } = useSettings() // 使用设置中的值
const { isTopNavbar } = useNavbarPosition()
const createLRUCache = useCallback(() => {
return new LRUCache<string, MinAppType>({
@ -165,6 +169,33 @@ export const useMinappPopup = () => {
dispatch(setMinappShow(false))
}, [dispatch, minappShow, openedOneOffMinapp])
/** Smart open minapp that adapts to navbar position */
const openSmartMinapp = useCallback(
(config: MinAppType, keepAlive: boolean = false) => {
if (isTopNavbar) {
// For top navbar mode, need to add to cache first for temporary apps
const cacheApp = minAppsCache.get(config.id)
if (!cacheApp) {
// Add temporary app to cache so MinAppPage can find it
minAppsCache.set(config.id, config)
}
// Set current minapp and show state
dispatch(setCurrentMinappId(config.id))
dispatch(setMinappShow(true))
// Then navigate to the app tab using NavigationService
if (NavigationService.navigate) {
NavigationService.navigate(`/apps/${config.id}`)
}
} else {
// For side navbar, use the traditional popup system
openMinapp(config, keepAlive)
}
},
[isTopNavbar, openMinapp, dispatch]
)
return {
openMinapp,
openMinappKeepAlive,
@ -172,6 +203,7 @@ export const useMinappPopup = () => {
closeMinapp,
hideMinappPopup,
closeAllMinapps,
openSmartMinapp,
// Expose cache instance for TabsService integration
minAppsCache
}

View File

@ -44,11 +44,20 @@ const MinAppPage: FC = () => {
}
}, [isTopNavbar])
// Find the app from all available apps
// Find the app from all available apps (including cached ones)
const app = useMemo(() => {
if (!appId) return null
return [...DEFAULT_MIN_APPS, ...minapps].find((app) => app.id === appId)
}, [appId, minapps])
// First try to find in default and custom mini-apps
let foundApp = [...DEFAULT_MIN_APPS, ...minapps].find((app) => app.id === appId)
// If not found and we have cache, try to find in cache (for temporary apps)
if (!foundApp && minAppsCache) {
foundApp = minAppsCache.get(appId)
}
return foundApp
}, [appId, minapps, minAppsCache])
useEffect(() => {
// If app not found, redirect to apps list

View File

@ -14,7 +14,7 @@ import { runAsyncFunction } from '@renderer/utils'
import { UpgradeChannel } from '@shared/config/constant'
import { Avatar, Button, Progress, Radio, Row, Switch, Tag, Tooltip } from 'antd'
import { debounce } from 'lodash'
import { Bug, FileCheck, Github, Globe, Mail, Rss } from 'lucide-react'
import { Bug, FileCheck, Globe, Mail, Rss } from 'lucide-react'
import { BadgeQuestionMark } from 'lucide-react'
import { FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -32,7 +32,7 @@ const AboutSettings: FC = () => {
const { theme } = useTheme()
const dispatch = useAppDispatch()
const { update } = useRuntime()
const { openMinapp } = useMinappPopup()
const { openSmartMinapp } = useMinappPopup()
const onCheckUpdate = debounce(
async () => {
@ -79,7 +79,7 @@ const AboutSettings: FC = () => {
const showLicense = async () => {
const { appPath } = await window.api.getAppInfo()
openMinapp({
openSmartMinapp({
id: 'cherrystudio-license',
name: t('settings.about.license.title'),
url: `file://${appPath}/resources/cherry-studio/license.html`,
@ -89,7 +89,7 @@ const AboutSettings: FC = () => {
const showReleases = async () => {
const { appPath } = await window.api.getAppInfo()
openMinapp({
openSmartMinapp({
id: 'cherrystudio-releases',
name: t('settings.about.releases.title'),
url: `file://${appPath}/resources/cherry-studio/releases.html?theme=${theme === ThemeMode.dark ? 'dark' : 'light'}`,
@ -309,7 +309,7 @@ const AboutSettings: FC = () => {
<SettingDivider />
<SettingRow>
<SettingRowTitle>
<Github size={18} />
<GithubOutlined size={18} />
{t('settings.about.feedback.title')}
</SettingRowTitle>
<Button onClick={() => onOpenWebsite('https://github.com/CherryHQ/cherry-studio/issues/new/choose')}>

View File

@ -1,5 +1,6 @@
import { InfoCircleOutlined } from '@ant-design/icons'
import { HStack } from '@renderer/components/Layout'
import { AppLogo } from '@renderer/config/env'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
import { RootState, useAppDispatch } from '@renderer/store'
@ -16,7 +17,7 @@ const JoplinSettings: FC = () => {
const { t } = useTranslation()
const { theme } = useTheme()
const dispatch = useAppDispatch()
const { openMinapp } = useMinappPopup()
const { openSmartMinapp } = useMinappPopup()
const joplinToken = useSelector((state: RootState) => state.settings.joplinToken)
const joplinUrl = useSelector((state: RootState) => state.settings.joplinUrl)
@ -66,10 +67,11 @@ const JoplinSettings: FC = () => {
}
const handleJoplinHelpClick = () => {
openMinapp({
openSmartMinapp({
id: 'joplin-help',
name: 'Joplin Help',
url: 'https://joplinapp.org/help/apps/clipper'
url: 'https://joplinapp.org/help/apps/clipper',
logo: AppLogo
})
}

View File

@ -1,6 +1,7 @@
import { InfoCircleOutlined } from '@ant-design/icons'
import { Client } from '@notionhq/client'
import { HStack } from '@renderer/components/Layout'
import { AppLogo } from '@renderer/config/env'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
import { RootState, useAppDispatch } from '@renderer/store'
@ -21,7 +22,7 @@ const NotionSettings: FC = () => {
const { t } = useTranslation()
const { theme } = useTheme()
const dispatch = useAppDispatch()
const { openMinapp } = useMinappPopup()
const { openSmartMinapp } = useMinappPopup()
const notionApiKey = useSelector((state: RootState) => state.settings.notionApiKey)
const notionDatabaseID = useSelector((state: RootState) => state.settings.notionDatabaseID)
@ -67,10 +68,11 @@ const NotionSettings: FC = () => {
}
const handleNotionTitleClick = () => {
openMinapp({
openSmartMinapp({
id: 'notion-help',
name: 'Notion Help',
url: 'https://docs.cherry-ai.com/advanced-basic/notion'
url: 'https://docs.cherry-ai.com/advanced-basic/notion',
logo: AppLogo
})
}

View File

@ -3,6 +3,7 @@ import { HStack } from '@renderer/components/Layout'
import { S3BackupManager } from '@renderer/components/S3BackupManager'
import { S3BackupModal, useS3BackupModal } from '@renderer/components/S3Modals'
import Selector from '@renderer/components/Selector'
import { AppLogo } from '@renderer/config/env'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
import { useSettings } from '@renderer/hooks/useSettings'
@ -47,7 +48,7 @@ const S3Settings: FC = () => {
const dispatch = useAppDispatch()
const { theme } = useTheme()
const { t } = useTranslation()
const { openMinapp } = useMinappPopup()
const { openSmartMinapp } = useMinappPopup()
const { s3Sync } = useAppSelector((state) => state.backup)
@ -62,10 +63,11 @@ const S3Settings: FC = () => {
}
const handleTitleClick = () => {
openMinapp({
openSmartMinapp({
id: 's3-help',
name: 'S3 Compatible Storage Help',
url: 'https://docs.cherry-ai.com/data-settings/s3-compatible'
url: 'https://docs.cherry-ai.com/data-settings/s3-compatible',
logo: AppLogo
})
}

View File

@ -1,6 +1,7 @@
import { InfoCircleOutlined } from '@ant-design/icons'
import { loggerService } from '@logger'
import { HStack } from '@renderer/components/Layout'
import { AppLogo } from '@renderer/config/env'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
import { RootState, useAppDispatch } from '@renderer/store'
@ -16,7 +17,7 @@ import { SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle
const logger = loggerService.withContext('SiyuanSettings')
const SiyuanSettings: FC = () => {
const { openMinapp } = useMinappPopup()
const { openSmartMinapp } = useMinappPopup()
const { t } = useTranslation()
const { theme } = useTheme()
const dispatch = useAppDispatch()
@ -43,10 +44,11 @@ const SiyuanSettings: FC = () => {
}
const handleSiyuanHelpClick = () => {
openMinapp({
openSmartMinapp({
id: 'siyuan-help',
name: 'Siyuan Help',
url: 'https://docs.cherry-ai.com/advanced-basic/siyuan'
url: 'https://docs.cherry-ai.com/advanced-basic/siyuan',
logo: AppLogo
})
}

View File

@ -1,5 +1,6 @@
import { InfoCircleOutlined } from '@ant-design/icons'
import { HStack } from '@renderer/components/Layout'
import { AppLogo } from '@renderer/config/env'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
import { RootState, useAppDispatch } from '@renderer/store'
@ -16,7 +17,7 @@ const YuqueSettings: FC = () => {
const { t } = useTranslation()
const { theme } = useTheme()
const dispatch = useAppDispatch()
const { openMinapp } = useMinappPopup()
const { openSmartMinapp } = useMinappPopup()
const yuqueToken = useSelector((state: RootState) => state.settings.yuqueToken)
const yuqueUrl = useSelector((state: RootState) => state.settings.yuqueUrl)
@ -65,10 +66,11 @@ const YuqueSettings: FC = () => {
}
const handleYuqueHelpClick = () => {
openMinapp({
openSmartMinapp({
id: 'yuque-help',
name: 'Yuque Help',
url: 'https://www.yuque.com/settings/tokens'
url: 'https://www.yuque.com/settings/tokens',
logo: AppLogo
})
}