refactor(web): unify theme handling behind a theme feature module (#45689)

This commit is contained in:
yyh authored and GitHub committed 2026-11-06 04:09:51 +00:00
1 parent 141bf922cf
commit eb1cd664ce
166 files changed
+635 -968

No files matched your search

-93
View File
@@ -602,11 +602,6 @@
"count": 1
}
},
"web/app/components/app/create-app-modal/index.tsx": {
"better-tailwindcss/enforce-logical-properties": {
"count": 7
}
},
"web/app/components/app/create-from-dsl-modal/uploader.tsx": {
"better-tailwindcss/enforce-logical-properties": {
"count": 4
@@ -642,11 +637,6 @@
"count": 1
}
},
"web/app/components/app/deploy/shared/deployment-configuration/credential-field.tsx": {
"better-tailwindcss/enforce-logical-properties": {
"count": 2
}
},
"web/app/components/app/deploy/shared/deployment-configuration/index.tsx": {
"better-tailwindcss/enforce-logical-properties": {
"count": 3
@@ -860,9 +850,6 @@
}
},
"web/app/components/base/audio-gallery/AudioPlayer.tsx": {
"better-tailwindcss/enforce-logical-properties": {
"count": 1
},
"jsx-a11y/media-has-caption": {
"count": 1
},
@@ -1402,9 +1389,6 @@
}
},
"web/app/components/base/markdown-blocks/code-block.tsx": {
"better-tailwindcss/enforce-logical-properties": {
"count": 1
},
"typescript/no-explicit-any": {
"count": 2
}
@@ -1808,11 +1792,6 @@
"count": 1
}
},
"web/app/components/billing/pricing/plans/self-hosted-plan-item/index.tsx": {
"better-tailwindcss/enforce-logical-properties": {
"count": 2
}
},
"web/app/components/billing/priority-label/index.tsx": {
"better-tailwindcss/enforce-logical-properties": {
"count": 2
@@ -2000,11 +1979,6 @@
"count": 1
}
},
"web/app/components/datasets/create/file-uploader/components/file-list-item.tsx": {
"better-tailwindcss/enforce-logical-properties": {
"count": 2
}
},
"web/app/components/datasets/create/file-uploader/components/upload-dropzone.tsx": {
"better-tailwindcss/enforce-logical-properties": {
"count": 3
@@ -2240,9 +2214,6 @@
}
},
"web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/file-list-item.tsx": {
"better-tailwindcss/enforce-logical-properties": {
"count": 1
},
"jsx-a11y/click-events-have-key-events": {
"count": 2
},
@@ -2406,9 +2377,6 @@
}
},
"web/app/components/datasets/documents/detail/batch-modal/csv-uploader.tsx": {
"better-tailwindcss/enforce-logical-properties": {
"count": 3
},
"no-restricted-globals": {
"count": 1
},
@@ -2917,11 +2885,6 @@
"count": 4
}
},
"web/app/components/plugins/card/index.tsx": {
"better-tailwindcss/enforce-logical-properties": {
"count": 6
}
},
"web/app/components/plugins/install-plugin/base/loading-error.tsx": {
"better-tailwindcss/enforce-logical-properties": {
"count": 2
@@ -3130,11 +3093,6 @@
"count": 3
}
},
"web/app/components/plugins/plugin-detail-panel/detail-header/index.tsx": {
"better-tailwindcss/enforce-logical-properties": {
"count": 4
}
},
"web/app/components/plugins/plugin-detail-panel/endpoint-card.tsx": {
"better-tailwindcss/enforce-logical-properties": {
"count": 4
@@ -3238,18 +3196,10 @@
}
},
"web/app/components/plugins/plugin-item/index.tsx": {
"better-tailwindcss/enforce-logical-properties": {
"count": 9
},
"typescript/no-explicit-any": {
"count": 1
}
},
"web/app/components/plugins/plugin-page/category-marketplace-panel.tsx": {
"better-tailwindcss/enforce-logical-properties": {
"count": 1
}
},
"web/app/components/plugins/plugin-page/empty/index.tsx": {
"better-tailwindcss/enforce-logical-properties": {
"count": 3
@@ -3330,11 +3280,6 @@
"count": 2
}
},
"web/app/components/plugins/provider-card.tsx": {
"better-tailwindcss/enforce-logical-properties": {
"count": 1
}
},
"web/app/components/plugins/readme-panel/content.tsx": {
"better-tailwindcss/enforce-logical-properties": {
"count": 1
@@ -3848,31 +3793,16 @@
"count": 2
}
},
"web/app/components/workflow/block-selector/tool/action-item.tsx": {
"better-tailwindcss/enforce-logical-properties": {
"count": 6
}
},
"web/app/components/workflow/block-selector/tool/tool-list-flat-view/list.tsx": {
"better-tailwindcss/enforce-logical-properties": {
"count": 1
}
},
"web/app/components/workflow/block-selector/tool/tool.tsx": {
"better-tailwindcss/enforce-logical-properties": {
"count": 14
}
},
"web/app/components/workflow/block-selector/trigger-plugin/action-item.tsx": {
"better-tailwindcss/enforce-logical-properties": {
"count": 6
}
},
"web/app/components/workflow/block-selector/trigger-plugin/item.tsx": {
"better-tailwindcss/enforce-logical-properties": {
"count": 3
}
},
"web/app/components/workflow/collaboration/components/user-cursors.tsx": {
"better-tailwindcss/enforce-logical-properties": {
"count": 1
@@ -4131,9 +4061,6 @@
}
},
"web/app/components/workflow/nodes/_base/components/editor/code-editor/index.tsx": {
"better-tailwindcss/enforce-logical-properties": {
"count": 1
},
"react/only-export-components": {
"count": 1
},
@@ -4959,11 +4886,6 @@
"count": 1
}
},
"web/app/components/workflow/nodes/llm/components/json-schema-config-modal/code-editor.tsx": {
"better-tailwindcss/enforce-logical-properties": {
"count": 2
}
},
"web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-importer.tsx": {
"better-tailwindcss/enforce-logical-properties": {
"count": 3
@@ -5779,11 +5701,6 @@
"count": 1
}
},
"web/app/components/workflow/run/status-container.tsx": {
"better-tailwindcss/enforce-logical-properties": {
"count": 5
}
},
"web/app/components/workflow/run/status.tsx": {
"jsx-a11y/anchor-has-content": {
"count": 1
@@ -6093,11 +6010,6 @@
"count": 3
}
},
"web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/provider-tool/item.tsx": {
"better-tailwindcss/enforce-logical-properties": {
"count": 9
}
},
"web/features/agent-v2/agent-detail/configure/components/preview/build-background.tsx": {
"better-tailwindcss/enforce-logical-properties": {
"count": 2
@@ -6183,11 +6095,6 @@
"count": 1
}
},
"web/features/app-access-error/page.tsx": {
"better-tailwindcss/enforce-logical-properties": {
"count": 3
}
},
"web/features/app-access-error/trial-boundary.tsx": {
"better-tailwindcss/enforce-logical-properties": {
"count": 1
+17
View File
@@ -70,6 +70,10 @@ const webRestrictedImports = [
importNames: ['useLocalStorageState'],
message: 'Do not use ahooks useLocalStorageState. Use foxact storage hooks instead.',
},
{
name: 'next-themes',
message: 'Use the @/features/theme module instead of next-themes.',
},
],
patterns: [
{
@@ -1809,6 +1813,19 @@ export const lintConfig = {
],
},
},
{
files: ['web/features/theme/*.{ts,tsx}'],
rules: {
'no-restricted-imports': [
'error',
{
...webRestrictedImports[1],
paths: webRestrictedImports[1].paths.filter((path) => path.name !== 'next-themes'),
patterns: [...webRestrictedImports[1].patterns, rawHttpImports],
},
],
},
},
{
...handwrittenApiScope,
excludeFiles: [...handwrittenApiScope.excludeFiles, ...marketplaceBoundaryFiles],
@@ -29,7 +29,7 @@ import { toast } from '@/app/notifications'
import { workspacePermissionKeysAtom } from '@/context/permission-state'
import { userProfileQueryOptions } from '@/features/account-profile/client'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import useTheme from '@/hooks/use-theme'
import { useResolvedTheme } from '@/features/theme/hooks'
import { useRouter } from '@/next/navigation'
import { consoleQuery } from '@/service/console'
import { getRedirection } from '@/utils/app-redirection'
@@ -246,7 +246,7 @@ function CreateApp({ onClose, onCreateFromTemplate, defaultAppMode }: CreateAppP
<div className="mb-2 flex items-center">
<button
type="button"
className="flex cursor-pointer items-center border-0 bg-transparent p-0 text-left focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
className="flex cursor-pointer items-center border-0 bg-transparent p-0 text-start focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
onClick={() => setIsAppTypeExpanded(!isAppTypeExpanded)}
>
<span className="system-2xs-medium-uppercase text-text-tertiary">
@@ -254,7 +254,7 @@ function CreateApp({ onClose, onCreateFromTemplate, defaultAppMode }: CreateAppP
</span>
<span
aria-hidden
className={`ml-1 i-ri-arrow-right-s-line size-4 text-text-tertiary transition-transform ${isAppTypeExpanded ? 'rotate-90' : ''}`}
className={`ms-1 i-ri-arrow-right-s-line size-4 text-text-tertiary transition-transform ${isAppTypeExpanded ? 'rotate-90' : ''}`}
/>
</button>
</div>
@@ -344,7 +344,7 @@ function CreateApp({ onClose, onCreateFromTemplate, defaultAppMode }: CreateAppP
>
{t(($) => $['newApp.captionDescription'], { ns: 'app' })}
</label>
<span className="ml-1 system-xs-regular text-text-tertiary">
<span className="ms-1 system-xs-regular text-text-tertiary">
({t(($) => $['newApp.optional'], { ns: 'app' })})
</span>
</div>
@@ -361,7 +361,7 @@ function CreateApp({ onClose, onCreateFromTemplate, defaultAppMode }: CreateAppP
<div className="flex items-center justify-between pt-5 pb-10">
<button
type="button"
className="flex cursor-pointer items-center gap-1 border-none bg-transparent p-0 text-left system-xs-regular text-text-tertiary focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
className="flex cursor-pointer items-center gap-1 border-none bg-transparent p-0 text-start system-xs-regular text-text-tertiary focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
onClick={onCreateFromTemplate}
>
<span>{t(($) => $['newApp.noIdeaTip'], { ns: 'app' })}</span>
@@ -391,7 +391,7 @@ function CreateApp({ onClose, onCreateFromTemplate, defaultAppMode }: CreateAppP
</div>
</div>
<div className="relative flex h-full flex-1 shrink justify-start overflow-hidden">
<div className="absolute top-0 right-0 left-0 h-6 border-b border-b-divider-subtle 2xl:h-34.75"></div>
<div className="absolute inset-s-0 inset-e-0 top-0 h-6 border-b border-b-divider-subtle 2xl:h-34.75"></div>
<div className="max-w-190 border-x border-x-divider-subtle">
<div className="h-6 2xl:h-34.75" />
<AppPreview mode={appMode} />
@@ -457,7 +457,7 @@ function AppTypeCard({ icon, title, description, active, onClick }: AppTypeCardP
<button
type="button"
className={cn(
'relative box-content h-21 w-47.75 cursor-pointer rounded-xl border-[0.5px] border-components-option-card-option-border bg-components-panel-on-panel-item-bg p-3 text-left shadow-xs outline-hidden hover:shadow-md focus-visible:ring-2 focus-visible:ring-state-accent-solid',
'relative box-content h-21 w-47.75 cursor-pointer rounded-xl border-[0.5px] border-components-option-card-option-border bg-components-panel-on-panel-item-bg p-3 text-start shadow-xs outline-hidden hover:shadow-md focus-visible:ring-2 focus-visible:ring-state-accent-solid',
active
? 'shadow-md outline-[1.5px] outline-components-option-card-option-selected-border outline-solid'
: '',
@@ -520,7 +520,7 @@ function AppPreview({ mode }: { mode: AppType }) {
}
function AppScreenShot({ mode, show }: { mode: AppType; show: boolean }) {
const { theme } = useTheme()
const theme = useResolvedTheme()
const modeToImageMap = {
chat: 'Chatbot',
'advanced-chat': 'Chatflow',
@@ -15,8 +15,7 @@ import {
import { StatusDot } from '@langgenius/dify-ui/status-dot'
import { useTranslation } from 'react-i18next'
import useGetIcon from '@/app/components/plugins/install-plugin/base/use-get-icon'
import useTheme from '@/hooks/use-theme'
import { Theme } from '@/types/app'
import { useResolvedTheme } from '@/features/theme/hooks'
import { credentialFieldId } from './utils/field-id'
import { credentialProviderName } from './utils/workflow-deployment-input'
import { WorkflowDependencyPreview } from './workflow-source-popover'
@@ -35,11 +34,10 @@ export function CredentialField({
const { t } = useTranslation(['deployments'])
const { t: tPlugin } = useTranslation(['plugin'])
const { getIconUrl } = useGetIcon()
const { theme } = useTheme()
const theme = useResolvedTheme()
const inputId = credentialFieldId(slot)
const name = credentialProviderName(slot.provider_id)
const iconFileName =
theme === Theme.dark ? slot.icon_dark || slot.icon : slot.icon || slot.icon_dark
const iconFileName = theme === 'dark' ? slot.icon_dark || slot.icon : slot.icon || slot.icon_dark
const iconSrc = iconFileName ? getIconUrl(iconFileName) : undefined
const category =
slot.category === PluginCategory.PLUGIN_CATEGORY_MODEL
@@ -113,7 +111,7 @@ export function CredentialField({
<SelectItem
key={candidate.credential_id}
value={candidate.credential_id}
className="h-8 gap-1.5 py-1 pr-1 pl-3 system-md-regular data-selected:bg-state-base-hover"
className="h-8 gap-1.5 py-1 ps-3 pe-1 system-md-regular data-selected:bg-state-base-hover"
>
<span className="flex size-2 shrink-0 items-center justify-center">
<StatusDot size="small" />
@@ -5,7 +5,7 @@ import { useCallback } from 'react'
import { selectToolsWithProvider } from '@/app/components/tools/utils/query-select'
import { findNodeIcon } from '@/app/components/workflow/hooks/use-tool-icon'
import { BlockEnum } from '@/app/components/workflow/types'
import useTheme from '@/hooks/use-theme'
import { useResolvedTheme } from '@/features/theme/hooks'
import { consoleQuery } from '@/service/console'
import { useAllTriggerPlugins } from '@/service/use-triggers'
@@ -60,7 +60,7 @@ export const useGetProviderIcon = (nodes: UnsupportedNode[]) => {
}),
)
const { data: triggerPlugins } = useAllTriggerPlugins(hasTriggerPluginNode)
const { theme } = useTheme()
const theme = useResolvedTheme()
return useCallback(
(node: UnsupportedNode) => {
@@ -1,13 +1,13 @@
import type { ResolvedTheme } from '@/features/theme/theme'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { WorkflowRunTriggeredFrom } from '@/models/log'
import { Theme } from '@/types/app'
import TriggerByDisplay from '../trigger-by-display'
let theme = Theme.light
let theme: ResolvedTheme = 'light'
vi.mock('@/hooks/use-theme', () => ({
default: () => ({ theme }),
vi.mock('@/features/theme/hooks', () => ({
useResolvedTheme: () => theme,
}))
vi.mock('@/app/components/workflow/block-icon', () => ({
default: ({ toolIcon }: { toolIcon?: string }) => (
@@ -17,7 +17,7 @@ vi.mock('@/app/components/workflow/block-icon', () => ({
describe('TriggerByDisplay', () => {
beforeEach(() => {
theme = Theme.light
theme = 'light'
})
it.each([
@@ -46,7 +46,7 @@ describe('TriggerByDisplay', () => {
})
it('uses the theme-specific plugin icon when available', () => {
theme = Theme.dark
theme = 'dark'
render(
<TriggerByDisplay
triggeredFrom={WorkflowRunTriggeredFrom.PLUGIN}
@@ -2,14 +2,14 @@
import type { TFunction } from 'i18next'
import type { FC } from 'react'
import type { ResolvedTheme } from '@/features/theme/theme'
import type { TriggerMetadata } from '@/models/log'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import BlockIcon from '@/app/components/workflow/block-icon'
import { BlockEnum } from '@/app/components/workflow/types'
import useTheme from '@/hooks/use-theme'
import { useResolvedTheme } from '@/features/theme/hooks'
import { WorkflowRunTriggeredFrom } from '@/models/log'
import { Theme } from '@/types/app'
type TriggerByDisplayProps = {
triggeredFrom: WorkflowRunTriggeredFrom
@@ -39,11 +39,11 @@ const getTriggerDisplayName = (
return nameMap[triggeredFrom] || triggeredFrom
}
const getPluginIcon = (metadata: TriggerMetadata | undefined, theme: Theme) => {
const getPluginIcon = (metadata: TriggerMetadata | undefined, theme: ResolvedTheme) => {
if (!metadata) return null
const icon =
theme === Theme.dark ? metadata.icon_dark || metadata.icon : metadata.icon || metadata.icon_dark
theme === 'dark' ? metadata.icon_dark || metadata.icon : metadata.icon || metadata.icon_dark
if (!icon) return null
@@ -53,7 +53,7 @@ const getPluginIcon = (metadata: TriggerMetadata | undefined, theme: Theme) => {
const getTriggerIcon = (
triggeredFrom: WorkflowRunTriggeredFrom,
metadata: TriggerMetadata | undefined,
theme: Theme,
theme: ResolvedTheme,
) => {
switch (triggeredFrom) {
case 'webhook':
@@ -118,7 +118,7 @@ const TriggerByDisplay: FC<TriggerByDisplayProps> = ({
triggerMetadata,
}) => {
const { t } = useTranslation(['appLog'])
const { theme } = useTheme()
const theme = useResolvedTheme()
const displayName = getTriggerDisplayName(triggeredFrom, t, triggerMetadata)
const icon = getTriggerIcon(triggeredFrom, triggerMetadata, theme)
+1 -1
View File
@@ -3,8 +3,8 @@ import { Separator } from '@langgenius/dify-ui/separator'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useLocale } from '#i18n'
import { DifyLogo } from '@/app/components/base/logo/dify-logo'
import ThemeSelector from '@/app/components/base/theme-selector'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import ThemeSelector from '@/features/theme/theme-selector'
import { setLocaleOnClient } from '@/i18n/client'
import { languages } from '@/i18n/language'
import { useRouter } from '@/next/navigation'
@@ -3,8 +3,7 @@ import * as React from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from '@/app/notifications'
import useTheme from '@/hooks/use-theme'
import { Theme } from '@/types/app'
import { useResolvedTheme } from '@/features/theme/hooks'
type AudioPlayerProps = Readonly<{
src?: string // Keep backward compatibility
@@ -22,7 +21,7 @@ const AudioPlayer: React.FC<AudioPlayerProps> = ({ src, srcs }) => {
const [hasStartedPlaying, setHasStartedPlaying] = useState(false)
const [hoverTime, setHoverTime] = useState(0)
const [isAudioAvailable, setIsAudioAvailable] = useState(true)
const { theme } = useTheme()
const theme = useResolvedTheme()
const playPauseLabel = t(($) => $[isPlaying ? 'operation.pause' : 'operation.play'], {
ns: 'common',
})
@@ -214,10 +213,10 @@ const AudioPlayer: React.FC<AudioPlayerProps> = ({ src, srcs }) => {
// Draw waveform bars
data.forEach((value, index) => {
let color
if (index * barWidth <= playedWidth) color = theme === Theme.light ? '#296DFF' : '#84ABFF'
if (index * barWidth <= playedWidth) color = theme === 'light' ? '#296DFF' : '#84ABFF'
else if (((index * barWidth) / width) * duration <= hoverTime)
color = theme === Theme.light ? 'rgba(21,90,239,.40)' : 'rgba(200, 206, 218, 0.28)'
else color = theme === Theme.light ? 'rgba(21,90,239,.20)' : 'rgba(200, 206, 218, 0.14)'
color = theme === 'light' ? 'rgba(21,90,239,.40)' : 'rgba(200, 206, 218, 0.28)'
else color = theme === 'light' ? 'rgba(21,90,239,.20)' : 'rgba(200, 206, 218, 0.14)'
const barHeight = value * height
const rectX = index * barWidth
const rectY = (height - barHeight) / 2
@@ -295,7 +294,7 @@ const AudioPlayer: React.FC<AudioPlayerProps> = ({ src, srcs }) => {
</div>
</div>
<div
className="absolute top-0 left-0 flex size-full items-center justify-center text-text-quaternary"
className="absolute inset-s-0 top-0 flex size-full items-center justify-center text-text-quaternary"
hidden={isAudioAvailable}
>
{t(($) => $['operation.audioSourceUnavailable'], { ns: 'common' })}
@@ -1,14 +1,14 @@
import type { ResolvedTheme } from '@/features/theme/theme'
import { act, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Theme } from '@/types/app'
import { CodeBlock } from '../code-block'
import { highlightCode } from '../code-highlight'
type UseThemeReturn = {
theme: Theme
theme: ResolvedTheme
}
const mockUseTheme = vi.fn<() => UseThemeReturn>(() => ({ theme: Theme.light }))
const mockUseTheme = vi.fn<() => UseThemeReturn>(() => ({ theme: 'light' }))
const mockEcharts = vi.hoisted(() => {
const state = {
option: undefined as unknown,
@@ -65,9 +65,9 @@ class MockAudioContext {
}))
}
vi.mock('@/hooks/use-theme', () => ({
vi.mock('@/features/theme/hooks', () => ({
__esModule: true,
default: () => mockUseTheme(),
useResolvedTheme: () => mockUseTheme().theme,
}))
// Keep the real highlighter so tests cover its output; the spy only forces
@@ -149,7 +149,7 @@ const findHighlightedKeyword = async () => {
describe('CodeBlock', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseTheme.mockReturnValue({ theme: Theme.light })
mockUseTheme.mockReturnValue({ theme: 'light' })
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
clientWidthSpy = vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(900)
@@ -249,7 +249,7 @@ describe('CodeBlock', () => {
})
it('should render syntax-highlighted output when language is standard and app theme is dark', async () => {
mockUseTheme.mockReturnValue({ theme: Theme.dark })
mockUseTheme.mockReturnValue({ theme: 'dark' })
render(<CodeBlock className="language-javascript">const y = 2;</CodeBlock>)
@@ -361,7 +361,7 @@ describe('CodeBlock', () => {
})
it('should render dark themed echarts path when app theme is dark', async () => {
mockUseTheme.mockReturnValue({ theme: Theme.dark })
mockUseTheme.mockReturnValue({ theme: 'dark' })
render(<CodeBlock className="language-echarts">{'{"series":[]}'}</CodeBlock>)
expect(await findEchartsHost())!.toBeInTheDocument()
@@ -19,8 +19,7 @@ import {
} from 'react'
import CopyIcon from '@/app/components/base/copy-icon'
import ErrorBoundary from '@/app/components/base/markdown/error-boundary'
import useTheme from '@/hooks/use-theme'
import { Theme } from '@/types/app'
import { useResolvedTheme } from '@/features/theme/hooks'
import { highlightCode } from './code-highlight'
const MermaidDiagram = dynamic(
@@ -156,7 +155,7 @@ type EChartsEventParams = {
}
export const CodeBlock: any = memo(({ inline, className, children = '', ...props }: any) => {
const { theme } = useTheme()
const theme = useResolvedTheme()
const [isSVG, setIsSVG] = useState(true)
const [chartState, setChartState] = useState<'loading' | 'success' | 'error'>('loading')
const [finalChartOption, setFinalChartOption] = useState<EChartsOption | null>(null)
@@ -171,7 +170,7 @@ export const CodeBlock: any = memo(({ inline, className, children = '', ...props
const match = /language-(\w+)/.exec(className || '')
const language = match?.[1]
const languageShowName = getCorrectCapitalizationLanguageName(language || '')
const isDarkMode = theme === Theme.dark
const isDarkMode = theme === 'dark'
const clearResizeTimer = useCallback(() => {
if (!resizeTimerRef.current) return
@@ -555,7 +554,7 @@ export const CodeBlock: any = memo(({ inline, className, children = '', ...props
return (
<div className="relative">
<div className="flex h-8 items-center justify-between rounded-t-[10px] border-b border-divider-subtle bg-components-input-bg-normal p-1 pl-3">
<div className="flex h-8 items-center justify-between rounded-t-[10px] border-b border-divider-subtle bg-components-input-bg-normal p-1 ps-3">
<div className="system-xs-semibold-uppercase text-text-secondary">{languageShowName}</div>
<div className="flex items-center gap-1">
{language === 'svg' && (
@@ -1,95 +0,0 @@
'use client'
import { useDirection } from '@langgenius/dify-ui/direction-provider'
import { IconButton } from '@langgenius/dify-ui/icon-button'
import {
Menu,
MenuContent,
MenuRadioGroup,
MenuRadioItem,
MenuRadioItemIndicator,
MenuTrigger,
} from '@langgenius/dify-ui/menu'
import { useTheme } from 'next-themes'
import { useSyncExternalStore } from 'react'
import { useTranslation } from 'react-i18next'
const THEMES = ['light', 'dark', 'system'] as const
export type Theme = (typeof THEMES)[number]
const isTheme = (value: string): value is Theme => {
return (THEMES as readonly string[]).includes(value)
}
const subscribeToNothing = () => () => {}
const useIsHydrated = () =>
useSyncExternalStore(
subscribeToNothing,
() => true,
() => false,
)
export default function ThemeSelector() {
const direction = useDirection()
const { t } = useTranslation(['common'])
const { theme, setTheme } = useTheme()
const isHydrated = useIsHydrated()
const currentTheme: Theme = theme && isTheme(theme) ? theme : 'system'
const getCurrentIcon = () => {
if (!isHydrated) return <span aria-hidden className="size-4" />
switch (currentTheme) {
case 'light':
return <span aria-hidden className="i-ri-sun-line size-4 text-text-tertiary" />
case 'dark':
return <span aria-hidden className="i-ri-moon-line size-4 text-text-tertiary" />
default:
return <span aria-hidden className="i-ri-computer-line size-4 text-text-tertiary" />
}
}
return (
<Menu>
<MenuTrigger
render={
<IconButton
aria-label={t(($) => $['theme.theme'], { ns: 'common' })}
size="lg"
className="data-popup-open:bg-state-base-hover"
>
{getCurrentIcon()}
</IconButton>
}
/>
<MenuContent dir={direction} sideOffset={6} className="w-36">
<MenuRadioGroup<Theme>
value={currentTheme}
onValueChange={(nextTheme) => setTheme(nextTheme)}
>
<MenuRadioItem<Theme> value="light" closeOnClick>
<span className="i-ri-sun-line size-4 text-text-tertiary" />
<span className="grow px-1 system-md-regular">
{t(($) => $['theme.light'], { ns: 'common' })}
</span>
<MenuRadioItemIndicator data-testid="light-icon" />
</MenuRadioItem>
<MenuRadioItem<Theme> value="dark" closeOnClick>
<span className="i-ri-moon-line size-4 text-text-tertiary" />
<span className="grow px-1 system-md-regular">
{t(($) => $['theme.dark'], { ns: 'common' })}
</span>
<MenuRadioItemIndicator data-testid="dark-icon" />
</MenuRadioItem>
<MenuRadioItem<Theme> value="system" closeOnClick>
<span className="i-ri-computer-line size-4 text-text-tertiary" />
<span className="grow px-1 system-md-regular">
{t(($) => $['theme.auto'], { ns: 'common' })}
</span>
<MenuRadioItemIndicator data-testid="system-icon" />
</MenuRadioItem>
</MenuRadioGroup>
</MenuContent>
</Menu>
)
}
@@ -3,8 +3,7 @@ import type { SelfHostedPlan } from '../../../config'
import { buttonVariants } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { useTranslation } from 'react-i18next'
import useTheme from '@/hooks/use-theme'
import { Theme } from '@/types/app'
import { useResolvedTheme } from '@/features/theme/hooks'
import { SELF_HOSTED_PLAN_URLS } from '../../../config'
import Community from '../../assets/community'
import Enterprise from '../../assets/enterprise'
@@ -41,7 +40,7 @@ const STYLE_MAP = {
export function SelfHostedPlanItem({ plan }: { plan: SelfHostedPlan }) {
const { t } = useTranslation(['billing'])
const { theme } = useTheme()
const theme = useResolvedTheme()
const i18nPrefix = `plans.${plan}` as const
const isFreePlan = plan === 'community'
const isPremiumPlan = plan === 'premium'
@@ -79,7 +78,7 @@ export function SelfHostedPlanItem({ plan }: { plan: SelfHostedPlan }) {
data-plan={plan}
className={cn(
buttonVariants({ variant: 'tertiary', size: null }),
'h-auto min-h-12 w-full justify-start gap-x-2 rounded-none bg-components-button-tertiary-bg py-3 pr-4 pl-5 system-xl-semibold whitespace-normal text-text-primary hover:bg-components-button-tertiary-bg-hover data-[plan=enterprise]:bg-saas-dify-blue-static data-[plan=enterprise]:text-text-primary-on-surface data-[plan=enterprise]:hover:bg-saas-dify-blue-static-hover data-[plan=premium]:bg-saas-background-inverted data-[plan=premium]:py-2 data-[plan=premium]:text-background-default data-[plan=premium]:hover:bg-saas-background-inverted-hover xl:whitespace-nowrap',
'h-auto min-h-12 w-full justify-start gap-x-2 rounded-none bg-components-button-tertiary-bg py-3 ps-5 pe-4 system-xl-semibold whitespace-normal text-text-primary hover:bg-components-button-tertiary-bg-hover data-[plan=enterprise]:bg-saas-dify-blue-static data-[plan=enterprise]:text-text-primary-on-surface data-[plan=enterprise]:hover:bg-saas-dify-blue-static-hover data-[plan=premium]:bg-saas-background-inverted data-[plan=premium]:py-2 data-[plan=premium]:text-background-default data-[plan=premium]:hover:bg-saas-background-inverted-hover xl:whitespace-nowrap',
)}
>
<span className="flex min-w-0 grow flex-wrap items-center gap-x-2 xl:flex-nowrap">
@@ -92,7 +91,7 @@ export function SelfHostedPlanItem({ plan }: { plan: SelfHostedPlan }) {
)}
{isPremiumPlan && (
<span aria-hidden className="pt-1.75 pb-px">
{theme === Theme.light ? (
{theme === 'light' ? (
<span
aria-hidden
className="i-custom-public-billing-aws-marketplace-light h-6 w-31.5"
@@ -6,8 +6,7 @@ import dynamic from 'next/dynamic'
import { useId, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import DocumentFileIcon from '@/app/components/datasets/common/document-file-icon'
import useTheme from '@/hooks/use-theme'
import { Theme } from '@/types/app'
import { useResolvedTheme } from '@/features/theme/hooks'
import { formatFileSize, getFileExtension } from '@/utils/format'
import { PROGRESS_COMPLETE, PROGRESS_ERROR } from '../constants'
@@ -26,8 +25,8 @@ const FileListItem = ({ fileItem, onPreview, onRemove }: FileListItemProps) => {
const fileNameId = useId()
const previewLabelId = useId()
const removeLabelId = useId()
const { theme } = useTheme()
const chartColor = useMemo(() => (theme === Theme.dark ? '#5289ff' : '#296dff'), [theme])
const theme = useResolvedTheme()
const chartColor = useMemo(() => (theme === 'dark' ? '#5289ff' : '#296dff'), [theme])
const isUploading = fileItem.progress >= 0 && fileItem.progress < PROGRESS_COMPLETE
const isError = fileItem.progress === PROGRESS_ERROR
@@ -43,7 +42,7 @@ const FileListItem = ({ fileItem, onPreview, onRemove }: FileListItemProps) => {
disabled={!fileItem.file?.id}
aria-labelledby={`${previewLabelId} ${fileNameId}`}
onClick={handleClick}
className="flex h-full min-w-0 grow items-center rounded-lg text-left focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-components-input-border-active"
className="flex h-full min-w-0 grow items-center rounded-lg text-start focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-components-input-border-active"
>
<span id={previewLabelId} className="sr-only">
{t(($) => $['stepOne.filePreview'], { ns: 'datasetCreation' })}
@@ -69,7 +68,7 @@ const FileListItem = ({ fileItem, onPreview, onRemove }: FileListItemProps) => {
</div>
</div>
</button>
<div className="flex w-16 shrink-0 items-center justify-end gap-1 pr-3">
<div className="flex w-16 shrink-0 items-center justify-end gap-1 pe-3">
{isUploading && (
<SimplePieChart
percentage={fileItem.progress}
@@ -5,8 +5,7 @@ import dynamic from 'next/dynamic'
import { useMemo } from 'react'
import DocumentFileIcon from '@/app/components/datasets/common/document-file-icon'
import { getFileType } from '@/app/components/datasets/common/image-uploader/utils'
import useTheme from '@/hooks/use-theme'
import { Theme } from '@/types/app'
import { useResolvedTheme } from '@/features/theme/hooks'
import { formatFileSize } from '@/utils/format'
import { PROGRESS_ERROR } from '../constants'
@@ -21,8 +20,8 @@ type FileListItemProps = {
}
const FileListItem = ({ fileItem, onPreview, onRemove }: FileListItemProps) => {
const { theme } = useTheme()
const chartColor = useMemo(() => (theme === Theme.dark ? '#5289ff' : '#296dff'), [theme])
const theme = useResolvedTheme()
const chartColor = useMemo(() => (theme === 'dark' ? '#5289ff' : '#296dff'), [theme])
const isUploading = fileItem.progress >= 0 && fileItem.progress < 100
const isError = fileItem.progress === PROGRESS_ERROR
@@ -62,7 +61,7 @@ const FileListItem = ({ fileItem, onPreview, onRemove }: FileListItemProps) => {
<span>{formatFileSize(fileItem.file.size)}</span>
</div>
</div>
<div className="flex w-16 shrink-0 items-center justify-end gap-1 pr-3">
<div className="flex w-16 shrink-0 items-center justify-end gap-1 pe-3">
{isUploading && (
<SimplePieChart
percentage={fileItem.progress}
@@ -11,10 +11,9 @@ import { useTranslation } from 'react-i18next'
import { getFileUploadErrorMessage } from '@/app/components/base/file-uploader/utils'
import SimplePieChart from '@/app/components/base/simple-pie-chart'
import { toast } from '@/app/notifications'
import useTheme from '@/hooks/use-theme'
import { useResolvedTheme } from '@/features/theme/hooks'
import { upload } from '@/service/base'
import { consoleQuery } from '@/service/console'
import { Theme } from '@/types/app'
type Props = Readonly<{
file: FileItem | undefined
@@ -175,8 +174,8 @@ const CSVUploader: FC<Props> = ({ file, updateFile }) => {
if (!isValid(currentFile)) return
initialUpload(currentFile)
}
const { theme } = useTheme()
const chartColor = useMemo(() => (theme === Theme.dark ? '#5289ff' : '#296dff'), [theme])
const theme = useResolvedTheme()
const chartColor = useMemo(() => (theme === 'dark' ? '#5289ff' : '#296dff'), [theme])
useEffect(() => {
dropRef.current?.addEventListener('dragenter', handleDragEnter)
dropRef.current?.addEventListener('dragover', handleDragOver)
@@ -213,14 +212,14 @@ const CSVUploader: FC<Props> = ({ file, updateFile }) => {
{t(($) => $['list.batchModal.csvUploadTitle'], { ns: 'datasetDocuments' })}
<button
type="button"
className="inline cursor-pointer border-none bg-transparent p-0 text-left text-text-accent focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
className="inline cursor-pointer border-none bg-transparent p-0 text-start text-text-accent focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
onClick={selectHandle}
>
{t(($) => $['list.batchModal.browse'], { ns: 'datasetDocuments' })}
</button>
</div>
</div>
{dragging && <div ref={dragRef} className="absolute top-0 left-0 size-full" />}
{dragging && <div ref={dragRef} className="absolute inset-s-0 top-0 size-full" />}
</div>
)}
{file && (
@@ -231,7 +230,7 @@ const CSVUploader: FC<Props> = ({ file, updateFile }) => {
)}
>
<span aria-hidden className="i-custom-public-files-csv h-8.5 w-8 shrink-0" />
<div className="ml-2 flex w-0 grow">
<div className="ms-2 flex w-0 grow">
<span className="max-w-[calc(100%-30px)] overflow-hidden text-ellipsis whitespace-nowrap text-text-primary">
{file.file.name.replace(/.csv$/, '')}
</span>
@@ -7,13 +7,13 @@ import { zSkillResponse } from '@dify/contracts/api/console/workspaces/zod.gen'
import { detectPlatform, useHotkey } from '@tanstack/react-hotkeys'
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ThemeProvider } from 'next-themes'
import { useReactFlow } from 'reactflow'
import { createNode } from '@/app/components/workflow/__tests__/fixtures'
import { renderWorkflowFlowHook } from '@/app/components/workflow/__tests__/workflow-test-env'
import { useWorkflowSearch } from '@/app/components/workflow/hooks/use-workflow-search'
import { toast } from '@/app/notifications'
import { AppToaster } from '@/app/notifications/app-toaster'
import { ThemeProvider } from '@/features/theme/provider'
import { seedCurrentWorkspaceQuery } from '@/test/console/current-workspace'
import { createConsoleQueryWrapper } from '@/test/console/query-data'
import { createDeferred } from '@/test/deferred'
@@ -129,7 +129,7 @@ const plugin: MarketplacePlugin = {
verification: { authorized_category: 'community' },
from: 'marketplace',
}
const darkTheme = /app.gotoAnything.actions.themeDark/
const darkTheme = /common.theme.dark/
const isMac = detectPlatform() === 'mac'
const triggerSearchShortcut = (target: HTMLElement = document.body) =>
fireEvent.keyDown(target, { key: 'k', ctrlKey: !isMac, metaKey: isMac })
@@ -151,7 +151,7 @@ function setup({ datasetOperator = false, enableSkill = true, canInstall = true
})
render(
<ConsoleWrapper>
<ThemeProvider defaultTheme="light" enableSystem={false}>
<ThemeProvider defaultPreference="light">
<GotoAnything />
<AppToaster timeout={0} />
</ThemeProvider>
@@ -2,11 +2,11 @@ import type { ReactNode } from 'react'
import type { consoleClient } from '@/service/console'
import type { marketplaceClient } from '@/service/marketplace'
import { detectPlatform } from '@tanstack/react-hotkeys'
import { ThemeProvider } from 'next-themes'
import { page, userEvent } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
import { MainNavSearchButton } from '@/app/components/main-nav/components/search-button'
import { MainNavMobileBar } from '@/app/components/main-nav/mobile-nav'
import { ThemeProvider } from '@/features/theme/provider'
import { createConsoleQueryWrapper } from '@/test/console/query-data'
import { createDatasetFixture } from '@/test/fixtures/dataset'
import { createPluginDeclarationFixture } from '@/test/fixtures/plugin'
@@ -118,7 +118,7 @@ function renderPalette(trigger: ReactNode = <MainNavSearchButton />) {
})
return render(
<ConsoleWrapper>
<ThemeProvider defaultTheme="light" enableSystem={false}>
<ThemeProvider defaultPreference="light">
{trigger}
<GotoAnything />
</ThemeProvider>
@@ -268,7 +268,7 @@ describe('desktop dialog', () => {
await page.getByRole('option', { name: theme }).click()
await expect.element(searchField(theme)).toHaveFocus()
await expect
.element(page.getByRole('option', { name: /app.gotoAnything.actions.themeDark/ }))
.element(page.getByRole('option', { name: /common.theme.dark/ }))
.toBeInTheDocument()
await userEvent.keyboard('{Backspace}')
@@ -24,12 +24,12 @@ describe('command journeys', () => {
it('lists and filters themes, and activates the selected theme', () => {
const context = createCommandContext()
expect(slashCommandRegistry.search('/theme', context).map((result) => result.id)).toEqual([
'system',
'light',
'dark',
'system',
])
executeResult('/theme dark', context)
expect(context.setTheme).toHaveBeenCalledExactlyOnceWith('dark')
expect(context.setThemePreference).toHaveBeenCalledExactlyOnceWith('dark')
})
it('finds a language by its native name and activates it through the alias', async () => {
@@ -109,8 +109,8 @@ describe('command journeys', () => {
executeResult('/theme dark', second)
expect(first.openExternal).toHaveBeenCalledExactlyOnceWith('https://one.example/docs')
expect(second.openExternal).toHaveBeenCalledExactlyOnceWith('https://two.example/docs')
expect(first.setTheme).not.toHaveBeenCalled()
expect(second.setTheme).toHaveBeenCalledExactlyOnceWith('dark')
expect(first.setThemePreference).not.toHaveBeenCalled()
expect(second.setThemePreference).toHaveBeenCalledExactlyOnceWith('dark')
})
it('renders localized commands on the server without a browser or activation effects', async () => {
@@ -118,8 +118,8 @@ describe('command journeys', () => {
await i18n.init({
lng: 'en-US',
resources: {
'en-US': { app: { 'gotoAnything.actions.themeDark': 'Dark' } },
'zh-Hans': { app: { 'gotoAnything.actions.themeDark': '深色' } },
'en-US': { common: { 'theme.dark': 'Dark' } },
'zh-Hans': { common: { 'theme.dark': '深色' } },
},
})
const first = createCommandContext({ t: i18n.t as CommandContext['t'] })
@@ -143,7 +143,7 @@ describe('command journeys', () => {
for (const context of [first, second]) {
slashCommandRegistry.search('/', context)
languageCommand.search('', context)
expect(context.setTheme).not.toHaveBeenCalled()
expect(context.setThemePreference).not.toHaveBeenCalled()
expect(context.openExternal).not.toHaveBeenCalled()
expect(context.navigate).not.toHaveBeenCalled()
expect(context.openGenerator).not.toHaveBeenCalled()
@@ -8,7 +8,7 @@ export function createCommandContext(overrides: Partial<CommandContext> = {}): C
agentsAvailable: true,
skillsAvailable: true,
currentApp: null,
setTheme: vi.fn(),
setThemePreference: vi.fn(),
setLocale: vi.fn().mockResolvedValue(undefined),
getDocsHomeUrl: () => 'https://docs.dify.ai/en',
onError: vi.fn(),
@@ -1,50 +1,37 @@
import type { CommandSearchResult } from '../types'
import type { CommandContext, SlashCommand } from './types'
import type { ThemePreference } from '@/features/theme/theme'
import { isThemePreference, themeOptions } from '@/features/theme/theme'
const THEME_ITEMS = [
{
id: 'system',
titleKey: 'gotoAnything.actions.themeSystem',
descKey: 'gotoAnything.actions.themeSystemDesc',
iconClassName: 'i-ri-computer-line',
},
{
id: 'light',
titleKey: 'gotoAnything.actions.themeLight',
descKey: 'gotoAnything.actions.themeLightDesc',
iconClassName: 'i-ri-sun-line',
},
{
id: 'dark',
titleKey: 'gotoAnything.actions.themeDark',
descKey: 'gotoAnything.actions.themeDarkDesc',
iconClassName: 'i-ri-moon-line',
},
] as const
const themeCommandDescriptionKeys = {
light: 'gotoAnything.actions.themeLightDesc',
dark: 'gotoAnything.actions.themeDarkDesc',
system: 'gotoAnything.actions.themeSystemDesc',
} as const satisfies Record<ThemePreference, string>
const buildThemeCommands = (query: string, context: CommandContext): CommandSearchResult[] => {
const q = query.toLowerCase()
const list = THEME_ITEMS.filter(
(item) =>
!q ||
context
.t(($) => $[item.titleKey], { ns: 'app', lng: context.locale })
.toLowerCase()
.includes(q) ||
item.id.includes(q),
)
return list.map((item) => ({
id: item.id,
title: context.t(($) => $[item.titleKey], { ns: 'app', lng: context.locale }),
description: context.t(($) => $[item.descKey], { ns: 'app', lng: context.locale }),
type: 'command' as const,
icon: (
<div className="flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-components-panel-bg">
<span aria-hidden className={`${item.iconClassName} size-4 text-text-tertiary`} />
</div>
),
data: { command: 'theme', args: { value: item.id } },
}))
return themeOptions
.map((option) => ({
...option,
title: context.t(($) => $[option.labelKey], { ns: 'common', lng: context.locale }),
}))
.filter((option) => !q || option.title.toLowerCase().includes(q) || option.value.includes(q))
.map((option) => ({
id: option.value,
title: option.title,
description: context.t(($) => $[themeCommandDescriptionKeys[option.value]], {
ns: 'app',
lng: context.locale,
}),
type: 'command' as const,
icon: (
<div className="flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-components-panel-bg">
<span aria-hidden className={`${option.iconClassName} size-4 text-text-tertiary`} />
</div>
),
data: { command: 'theme', args: { value: option.value } },
}))
}
/**
@@ -52,11 +39,10 @@ const buildThemeCommands = (query: string, context: CommandContext): CommandSear
*/
export const themeCommand: SlashCommand = {
name: 'theme',
description: 'Switch between light and dark themes',
description: 'Change appearance',
mode: 'submenu',
execute(args, context) {
const item = THEME_ITEMS.find((item) => item.id === args.value)
if (item) context.setTheme(item.id)
if (isThemePreference(args.value)) context.setThemePreference(args.value)
},
search(args: string, context) {
@@ -1,6 +1,7 @@
import type { TFunction } from 'i18next'
import type { CommandSearchResult } from '../types'
import type { useWorkflowGeneratorStore } from '@/app/components/workflow/workflow-generator/store'
import type { ThemePreference } from '@/features/theme/theme'
export type CommandContext = {
t: TFunction<['app', 'common', 'modelProvider', 'accountSettings']>
@@ -8,7 +9,7 @@ export type CommandContext = {
agentsAvailable: boolean
skillsAvailable: boolean
currentApp: { id: string; mode: 'workflow' | 'advanced-chat' } | null
setTheme: (theme: string) => void
setThemePreference: (preference: ThemePreference) => void
setLocale: (locale: string) => Promise<void>
getDocsHomeUrl: () => string
onError: (error: unknown) => void
@@ -1,11 +1,11 @@
import type { CommandContext } from './types'
import { skipToken, useQuery } from '@tanstack/react-query'
import { useTheme } from 'next-themes'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useWorkflowGeneratorStore } from '@/app/components/workflow/workflow-generator/store'
import { toast } from '@/app/notifications'
import { useDocLink } from '@/context/i18n'
import { useThemePreference } from '@/features/theme/hooks'
import { setLocaleOnClient } from '@/i18n/client'
import { useParams, useRouter } from '@/next/navigation'
import { consoleQuery } from '@/service/console'
@@ -22,7 +22,7 @@ export function useCommandContext(
isWorkflowPage: boolean,
): CommandContext {
const { t, i18n } = useTranslation(['app', 'common', 'modelProvider', 'accountSettings'])
const { setTheme } = useTheme()
const { setPreference: setThemePreference } = useThemePreference()
const { push, refresh } = useRouter()
const getDocsHomeUrl = useDocLink()
const { appId: routeAppId } = useParams<{ appId?: string }>()
@@ -47,7 +47,7 @@ export function useCommandContext(
isWorkflowPage && appId && (appMode === 'workflow' || appMode === 'advanced-chat')
? { id: appId, mode: appMode }
: null,
setTheme,
setThemePreference,
setLocale: async (locale) => {
await setLocaleOnClient(locale)
refresh()
@@ -69,7 +69,7 @@ export function useCommandContext(
isWorkflowPage,
appId,
appMode,
setTheme,
setThemePreference,
getDocsHomeUrl,
push,
refresh,
@@ -79,10 +79,10 @@ it.each(['ltr', 'rtl'] as const)(
)
await screen.getByRole('button', { name: 'accountSettings.account.account' }).click()
const appearance = screen.getByRole('menuitem', {
name: 'accountSettings.account.appearanceLabel',
name: 'common.theme.label',
})
await userEvent.hover(appearance)
const submenu = screen.getByRole('menu', { name: 'accountSettings.account.appearanceLabel' })
const submenu = screen.getByRole('menu', { name: 'common.theme.label' })
await expect.element(submenu).toBeVisible()
// Leave room on both sides so collision fallback cannot hide a fixed-side override.
await expect
@@ -94,9 +94,7 @@ it.each(['ltr', 'rtl'] as const)(
: submenuBounds.left >= triggerBounds.right
})
.toBe(true)
await userEvent.hover(
screen.getByRole('menuitemradio', { name: 'accountSettings.account.appearanceLight' }),
)
await userEvent.hover(screen.getByRole('menuitemradio', { name: 'common.theme.light' }))
await expect.element(submenu).toBeVisible()
await userEvent.keyboard('{Escape}')
await expect.element(appearance).toHaveFocus()
@@ -1,7 +1,7 @@
'use client'
import type { ReactElement, ReactNode } from 'react'
import type { Theme } from '@/app/components/base/theme-selector'
import type { ThemePreference } from '@/features/theme/theme'
import { Avatar, AvatarFallback, AvatarImage } from '@langgenius/dify-ui/avatar'
import { cn } from '@langgenius/dify-ui/cn'
import {
@@ -19,7 +19,6 @@ import {
MenuTrigger,
} from '@langgenius/dify-ui/menu'
import { useQuery, useSuspenseQuery } from '@tanstack/react-query'
import { useTheme } from 'next-themes'
import { useQueryState } from 'nuqs'
import { useRef, useSyncExternalStore } from 'react'
import { useTranslation } from 'react-i18next'
@@ -29,6 +28,8 @@ import {
settingsQueryParser,
} from '@/app/components/header/account-setting/query-params'
import { userProfileQueryOptions } from '@/features/account-profile/client'
import { useThemePreference } from '@/features/theme/hooks'
import { themeOptions } from '@/features/theme/theme'
import Link from '@/next/link'
import { consoleQuery } from '@/service/console'
import { useLogout } from '@/service/use-common'
@@ -70,42 +71,31 @@ function MainNavRadioItemContent({ iconClassName, label }: MainNavRadioItemConte
}
function AppearanceSubmenu() {
const { t } = useTranslation(['accountSettings'])
const { theme, setTheme } = useTheme()
const currentTheme: Theme =
theme === 'light' || theme === 'dark' || theme === 'system' ? theme : 'system'
const { t } = useTranslation(['common'])
const { preference, setPreference } = useThemePreference()
return (
<MenuSubmenu>
<MenuSubmenuTrigger className="px-3 py-1">
<MenuItemContent
iconClassName="i-ri-sun-line"
label={t(($) => $['account.appearanceLabel'], { ns: 'accountSettings' })}
label={t(($) => $['theme.label'], { ns: 'common' })}
/>
</MenuSubmenuTrigger>
<MenuContent className="max-h-90 w-34.75">
<MenuRadioGroup<Theme>
value={currentTheme}
onValueChange={(nextTheme) => setTheme(nextTheme)}
>
<MenuRadioItem<Theme> value="light" className="py-1">
<MainNavRadioItemContent
iconClassName="i-ri-sun-line"
label={t(($) => $['account.appearanceLight'], { ns: 'accountSettings' })}
/>
</MenuRadioItem>
<MenuRadioItem<Theme> value="dark" className="py-1">
<MainNavRadioItemContent
iconClassName="i-ri-moon-line"
label={t(($) => $['account.appearanceDark'], { ns: 'accountSettings' })}
/>
</MenuRadioItem>
<MenuRadioItem<Theme> value="system" className="py-1">
<MainNavRadioItemContent
iconClassName="i-ri-computer-line"
label={t(($) => $['account.appearanceSystem'], { ns: 'accountSettings' })}
/>
</MenuRadioItem>
<MenuRadioGroup<ThemePreference> value={preference} onValueChange={setPreference}>
{themeOptions.map((option) => (
<MenuRadioItem<ThemePreference>
key={option.value}
value={option.value}
className="py-1"
>
<MainNavRadioItemContent
iconClassName={option.iconClassName}
label={t(($) => $[option.labelKey], { ns: 'common' })}
/>
</MenuRadioItem>
))}
</MenuRadioGroup>
</MenuContent>
</MenuSubmenu>
@@ -143,7 +143,7 @@ it('opens preferences from the URL control, switches real pages, and closes with
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Open preferences' }))
const dialog = await findSettings()
expect(within(dialog).getByText('accountSettings.account.appearanceLabel')).toBeInTheDocument()
expect(within(dialog).getByText('common.theme.label')).toBeInTheDocument()
expect(within(dialog).getByRole('button', { current: 'page' })).toHaveAccessibleName(
'navigation.settings.preferences',
)
@@ -312,7 +312,7 @@ it.each([
])('hides billing and falls back to preferences for $reason', async ({ options }) => {
renderSettings(TAB.BILLING, options)
const dialog = await findSettings()
expect(within(dialog).getByText('accountSettings.account.appearanceLabel')).toBeInTheDocument()
expect(within(dialog).getByText('common.theme.label')).toBeInTheDocument()
expect(
within(dialog).queryByRole('button', { name: 'navigation.settings.billing' }),
).not.toBeInTheDocument()
@@ -7,12 +7,12 @@ import type { ConsoleQueryTestOptions } from '@/test/console/query-data'
import { zPluginCategoryInstalledPluginResponse } from '@dify/contracts/api/console/workspaces/zod.gen'
import { act, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ThemeProvider } from 'next-themes'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import ReadmePanel from '@/app/components/plugins/readme-panel'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import { toast } from '@/app/notifications'
import { AppToaster } from '@/app/notifications/app-toaster'
import { ThemeProvider } from '@/features/theme/provider'
import { consoleQuery } from '@/service/console'
import { seedModelProviderQueries } from '@/test/console/model-provider'
import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data'
@@ -128,7 +128,7 @@ function setup(
const queryClient = createConsoleQueryClient()
seedModelProviderQueries(queryClient)
renderWithConsoleQuery(
<ThemeProvider defaultTheme="light" enableSystem={false}>
<ThemeProvider defaultPreference="light">
<NuqsTestingAdapter>
<DataSourcePage {...props} />
<ReadmePanel />
@@ -3,7 +3,6 @@ import type { Plugin } from '@/app/components/plugins/types'
import type { MarketplaceLinks } from '@/features/marketplace/links'
import { cn } from '@langgenius/dify-ui/cn'
import { Separator } from '@langgenius/dify-ui/separator'
import { useTheme } from 'next-themes'
import { memo, useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder'
@@ -11,6 +10,7 @@ import List from '@/app/components/plugins/marketplace/list'
import { usePluginSettingsAccess } from '@/app/components/plugins/plugin-page/use-reference-setting'
import ProviderCard from '@/app/components/plugins/provider-card'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import { useResolvedTheme } from '@/features/theme/hooks'
import Link from '@/next/link'
import { useMarketplaceAllPlugins } from './hooks'
@@ -27,7 +27,7 @@ const InstallFromMarketplace = ({
searchText,
}: InstallFromMarketplaceProps) => {
const { t } = useTranslation(['plugin', 'modelProvider'])
const { theme } = useTheme()
const theme = useResolvedTheme()
const { canInstallPlugin } = usePluginSettingsAccess()
const [collapse, setCollapse] = useState(false)
const { plugins: allPlugins, isLoading: isAllPluginsLoading } = useMarketplaceAllPlugins(
@@ -3,7 +3,6 @@ import type { MarketplaceLinks } from '@/features/marketplace/links'
import { cn } from '@langgenius/dify-ui/cn'
import { Separator } from '@langgenius/dify-ui/separator'
import { useQuery } from '@tanstack/react-query'
import { useTheme } from 'next-themes'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder'
@@ -11,6 +10,7 @@ import List from '@/app/components/plugins/marketplace/list'
import { usePluginSettingsAccess } from '@/app/components/plugins/plugin-page/use-reference-setting'
import ProviderCard from '@/app/components/plugins/provider-card'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import { useResolvedTheme } from '@/features/theme/hooks'
import Link from '@/next/link'
import { consoleQuery } from '@/service/console'
import { useMarketplaceAllPlugins } from './hooks'
@@ -28,7 +28,7 @@ const InstallFromMarketplace = ({
stepByStepTourTarget,
}: InstallFromMarketplaceProps) => {
const { t } = useTranslation(['plugin', 'modelProvider'])
const { theme } = useTheme()
const theme = useResolvedTheme()
const { canInstallPlugin } = usePluginSettingsAccess()
const [collapse, setCollapse] = useState(false)
const [hasEnteredViewport, setHasEnteredViewport] = useState(
@@ -6,9 +6,8 @@ import type { FC } from 'react'
import type { ModelProvider } from '../declarations'
import type { ModelSelectorProvider } from '../model-selector/types'
import { cn } from '@langgenius/dify-ui/cn'
import useTheme from '@/hooks/use-theme'
import { useResolvedTheme } from '@/features/theme/hooks'
import { renderI18nObject } from '@/i18n/metadata'
import { Theme } from '@/types/app'
import { useLanguage } from '../hooks'
type ModelIconProps = {
@@ -29,13 +28,13 @@ const ModelIcon: FC<ModelIconProps> = ({
iconClassName,
isDeprecated = false,
}) => {
const { theme } = useTheme()
const theme = useResolvedTheme()
const language = useLanguage()
const lightIconUrl = provider?.icon_small ? renderI18nObject(provider.icon_small, language) : ''
const darkIconUrl = provider?.icon_small_dark
? renderI18nObject(provider.icon_small_dark, language)
: ''
const iconUrl = theme === Theme.dark ? darkIconUrl || lightIconUrl : lightIconUrl
const iconUrl = theme === 'dark' ? darkIconUrl || lightIconUrl : lightIconUrl
if (
provider?.provider &&
@@ -524,7 +524,7 @@ describe('workspace credits and marketplace availability', () => {
expect(screen.queryByRole('button', { name: /selector.install/ })).not.toBeInTheDocument()
expect(screen.getByRole('link', { name: /discoverMoreInMarketplace/ })).toHaveAttribute(
'href',
expect.stringMatching(/\/plugins\/model$/),
expect.stringMatching(/\/plugins\/model\?theme=light$/),
)
})
})
@@ -6,6 +6,7 @@ import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from '@langgenius/d
import { useId } from 'react'
import { useTranslation } from 'react-i18next'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import { useResolvedTheme } from '@/features/theme/hooks'
import { modelNameMap, providerLogoMap } from '../utils'
type MarketplaceSectionProps = {
@@ -14,7 +15,6 @@ type MarketplaceSectionProps = {
marketplaceCollapsed: boolean
installingProvider: ModelProviderQuotaGetPaid | null
canInstallPlugin: boolean
theme?: string
onMarketplaceCollapsedChange: (collapsed: boolean) => void
onInstallPlugin: (key: ModelProviderQuotaGetPaid) => void | Promise<void>
onOpenMarketplace?: () => void
@@ -26,12 +26,12 @@ function MarketplaceSection({
marketplaceCollapsed,
installingProvider,
canInstallPlugin,
theme,
onMarketplaceCollapsedChange,
onInstallPlugin,
onOpenMarketplace,
}: MarketplaceSectionProps) {
const { t } = useTranslation(['plugin', 'modelProvider'])
const theme = useResolvedTheme()
const headingId = useId()
if (marketplaceProviders.length === 0) return null
@@ -13,7 +13,6 @@ import {
PreviewCardContent,
} from '@langgenius/dify-ui/preview-card'
import { useQuery, useSuspenseQuery } from '@tanstack/react-query'
import { useTheme } from 'next-themes'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import checkTaskStatus from '@/app/components/plugins/install-plugin/base/check-task-status'
@@ -84,7 +83,6 @@ function Popup({
onHide,
}: PopupProps) {
const { t } = useTranslation(['common', 'modelProvider'])
const { theme } = useTheme()
const language = useLanguage()
const [previewCardHandle] = useState(() => createPreviewCardHandle<ModelSelectorPreviewPayload>())
const [marketplaceCollapsed, setMarketplaceCollapsed] = useState(false)
@@ -313,7 +311,6 @@ function Popup({
marketplaceCollapsed={marketplaceCollapsed}
installingProvider={installingProvider}
canInstallPlugin={canInstallPlugin}
theme={theme}
onMarketplaceCollapsedChange={setMarketplaceCollapsed}
onInstallPlugin={handleInstallPlugin}
onOpenMarketplace={onOpenMarketplace}
@@ -1,9 +1,8 @@
import type { ModelProviderSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen'
import type { FC } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import useTheme from '@/hooks/use-theme'
import { useResolvedTheme } from '@/features/theme/hooks'
import { renderI18nObject } from '@/i18n/metadata'
import { Theme } from '@/types/app'
import { useLanguage } from '../hooks'
type ProviderIconProps = {
@@ -14,21 +13,21 @@ type ProviderIconProps = {
className?: string
}
const ProviderIcon: FC<ProviderIconProps> = ({ provider, className }) => {
const { theme } = useTheme()
const theme = useResolvedTheme()
const language = useLanguage()
const lightIconUrl = provider.icon_small ? renderI18nObject(provider.icon_small, language) : ''
const darkIconUrl = provider.icon_small_dark
? renderI18nObject(provider.icon_small_dark, language)
: ''
const iconUrl = theme === Theme.dark ? darkIconUrl || lightIconUrl : lightIconUrl
const iconUrl = theme === 'dark' ? darkIconUrl || lightIconUrl : lightIconUrl
if (provider.provider === 'langgenius/anthropic/anthropic') {
return (
<div className={cn('py-1.75', className)}>
{theme === Theme.dark && (
{theme === 'dark' && (
<span aria-hidden className="i-custom-public-llm-anthropic-light h-2.5 w-22.5" />
)}
{theme === Theme.light && (
{theme === 'light' && (
<span aria-hidden className="i-custom-public-llm-anthropic-dark h-2.5 w-22.5" />
)}
</div>
@@ -223,7 +223,7 @@ describe('PreferencePage - Interactions', () => {
const language = screen.getByRole('combobox', { name: 'common.language.displayLanguage' })
const timezone = screen.getByRole('combobox', { name: 'common.language.timezone' })
const appearance = screen.getByRole('combobox', {
name: 'accountSettings.account.appearanceLabel',
name: 'common.theme.label',
})
await selectOption('common.language.displayLanguage', chinese.name)
@@ -1,4 +1,5 @@
'use client'
import type { ThemePreference } from '@/features/theme/theme'
import type { Locale } from '@/i18n'
import {
Combobox,
@@ -30,12 +31,13 @@ import {
SelectValue,
} from '@langgenius/dify-ui/select'
import { useMutation, useSuspenseQuery } from '@tanstack/react-query'
import { useTheme } from 'next-themes'
import { useId } from 'react'
import { useTranslation } from 'react-i18next'
import { useLocale } from '#i18n'
import { toast } from '@/app/notifications'
import { userProfileQueryOptions } from '@/features/account-profile/client'
import { useThemePreference } from '@/features/theme/hooks'
import { themeOptions } from '@/features/theme/theme'
import { setLocaleOnClient } from '@/i18n/client'
import { languages } from '@/i18n/language'
import { useRouter } from '@/next/navigation'
@@ -44,7 +46,6 @@ import { timezones } from '@/utils/timezone'
type TimezoneOption = (typeof timezones)[number]
type TimezoneValue = TimezoneOption['value']
type ThemeValue = 'system' | 'light' | 'dark'
const languageItems = languages
.filter((item) => item.supported)
@@ -67,7 +68,7 @@ export default function PreferencePage() {
})
const { t } = useTranslation(['common', 'accountSettings'])
const router = useRouter()
const { theme, setTheme } = useTheme()
const { preference, setPreference } = useThemePreference()
const updateProfile = useMutation(
consoleQuery.account.profile.patch.mutationOptions({
onSuccess: async (data, variables, onMutateResult, context) => {
@@ -90,39 +91,32 @@ export default function PreferencePage() {
},
}),
)
const themeItems = [
{
value: 'system',
label: t(($) => $['account.appearanceFollowSystem'], { ns: 'accountSettings' }),
},
{ value: 'light', label: t(($) => $['account.appearanceLight'], { ns: 'accountSettings' }) },
{ value: 'dark', label: t(($) => $['account.appearanceDark'], { ns: 'accountSettings' }) },
] satisfies { value: ThemeValue; label: string }[]
const themeItems = themeOptions.map((option) => ({
value: option.value,
label: t(($) => $[option.labelKey], { ns: 'common' }),
}))
const selectedLanguage = languageItems.find(
(item) => item.value === (locale || userProfile.interface_language),
)
const selectedTheme = themeItems.find((item) => item.value === theme)?.value ?? 'system'
const selectedTimezone = timezones.find((item) => item.value === userProfile.timezone)
return (
<div className="grid gap-6 pt-1 pb-4">
<div className="grid min-w-0 gap-1">
<Select<ThemeValue>
<Select<ThemePreference>
items={themeItems}
value={selectedTheme}
value={preference}
onValueChange={(nextValue) => {
if (nextValue === null || nextValue === selectedTheme) return
setTheme(nextValue)
if (nextValue === null || nextValue === preference) return
setPreference(nextValue)
}}
>
<SelectLabel>
{t(($) => $['account.appearanceLabel'], { ns: 'accountSettings' })}
</SelectLabel>
<SelectLabel>{t(($) => $['theme.label'], { ns: 'common' })}</SelectLabel>
<SelectTrigger size="medium">
<SelectValue<ThemeValue> />
<SelectValue<ThemePreference> />
</SelectTrigger>
<SelectContent>
{themeItems.map((item) => (
<SelectItem<ThemeValue> key={item.value} value={item.value}>
<SelectItem<ThemePreference> key={item.value} value={item.value}>
<SelectItemText>{item.label}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
@@ -1,13 +1,12 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vite-plus/test'
import { Theme } from '@/types/app'
import IconWithTooltip from '../icon-with-tooltip'
describe('IconWithTooltip', () => {
it('exposes the badge meaning without opening the tooltip', () => {
render(
<IconWithTooltip
theme={Theme.light}
theme="light"
lightIconClassName="i-custom-public-plugins-partner-light"
darkIconClassName="i-custom-public-plugins-partner-dark"
popupContent="Partner plugin"
@@ -1,13 +1,13 @@
import type { FC } from 'react'
import type { ResolvedTheme } from '@/features/theme/theme'
import { cn } from '@langgenius/dify-ui/cn'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import * as React from 'react'
import { Theme } from '@/types/app'
type IconWithTooltipProps = {
className?: string
popupContent?: string
theme: Theme
theme: ResolvedTheme
lightIconClassName: string
darkIconClassName: string
}
@@ -19,7 +19,7 @@ const IconWithTooltip: FC<IconWithTooltipProps> = ({
lightIconClassName,
darkIconClassName,
}) => {
const isDark = theme === Theme.dark
const isDark = theme === 'dark'
const iconClassName = cn(isDark ? darkIconClassName : lightIconClassName, 'size-5', className)
const icon = (
<span className="flex shrink-0 items-center justify-center">
@@ -1,7 +1,7 @@
'use client'
import type { FC } from 'react'
import useTheme from '@/hooks/use-theme'
import { useResolvedTheme } from '@/features/theme/hooks'
import IconWithTooltip from './icon-with-tooltip'
type PartnerProps = {
@@ -10,7 +10,7 @@ type PartnerProps = {
}
const Partner: FC<PartnerProps> = ({ className, text }) => {
const { theme } = useTheme()
const theme = useResolvedTheme()
return (
<IconWithTooltip
@@ -1,5 +1,5 @@
import type { FC } from 'react'
import useTheme from '@/hooks/use-theme'
import { useResolvedTheme } from '@/features/theme/hooks'
import IconWithTooltip from './icon-with-tooltip'
type VerifiedProps = {
@@ -8,7 +8,7 @@ type VerifiedProps = {
}
const Verified: FC<VerifiedProps> = ({ className, text }) => {
const { theme } = useTheme()
const theme = useResolvedTheme()
return (
<IconWithTooltip
+9 -10
View File
@@ -6,9 +6,8 @@ import * as React from 'react'
import { useTranslation } from '#i18n'
import { useGetLanguage } from '@/context/i18n'
import { currentWorkspaceIdAtom } from '@/context/workspace-state'
import useTheme from '@/hooks/use-theme'
import { useResolvedTheme } from '@/features/theme/hooks'
import { renderI18nObject } from '@/i18n/metadata'
import { Theme } from '@/types/app'
import { formatNumber } from '@/utils/format'
import Partner from '../base/badges/partner'
import Verified from '../base/badges/verified'
@@ -93,8 +92,8 @@ const Card = ({
const { categoriesMap } = useCategories(true)
const { category, type, name, org, label, brief, icon, icon_dark, verified, from } = payload
const badges = payload.badges ?? []
const { theme } = useTheme()
const activeIcon = theme === Theme.dark && icon_dark ? icon_dark : icon
const theme = useResolvedTheme()
const activeIcon = theme === 'dark' && icon_dark ? icon_dark : icon
const pluginIdentity = { from, name, org, type }
const getLocalizedText = (obj: Record<string, string> | undefined) =>
obj ? renderI18nObject(obj, locale) : ''
@@ -133,13 +132,13 @@ const Card = ({
</div>
{isPartner && (
<Partner
className="ml-0.5 size-4"
className="ms-0.5 size-4"
text={t(($) => $['marketplace.partnerTip'], { ns: 'plugin' })}
/>
)}
{verified && (
<Verified
className="ml-0.5 size-4"
className="ms-0.5 size-4"
text={t(($) => $['marketplace.verifiedTip'], { ns: 'plugin' })}
/>
)}
@@ -173,7 +172,7 @@ const Card = ({
text={getLocalizedText(brief)}
descriptionLineRows={effectiveDescriptionLineRows}
/>
{!!footer && <div className="px-4 pt-2 pr-5">{footer}</div>}
{!!footer && <div className="px-4 pe-5 pt-2">{footer}</div>}
</div>
</div>
)
@@ -191,18 +190,18 @@ const Card = ({
installFailed={installFailed}
plugin={pluginIdentity}
/>
<div className="ml-3 w-0 grow">
<div className="ms-3 w-0 grow">
<div className="flex h-5 items-center">
<Title title={getLocalizedText(label)} />
{isPartner && (
<Partner
className="ml-0.5 size-4"
className="ms-0.5 size-4"
text={t(($) => $['marketplace.partnerTip'], { ns: 'plugin' })}
/>
)}
{verified && (
<Verified
className="ml-0.5 size-4"
className="ms-0.5 size-4"
text={t(($) => $['marketplace.verifiedTip'], { ns: 'plugin' })}
/>
)}
@@ -2,9 +2,9 @@ import type { ReactElement } from 'react'
import type { consoleClient } from '@/service/console'
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ThemeProvider } from 'next-themes'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { PluginInstallPermissionProvider } from '@/app/components/plugins/install-plugin/components/plugin-install-permission-provider'
import { ThemeProvider } from '@/features/theme/provider'
import { seedModelProviderQueries } from '@/test/console/model-provider'
import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data'
import { createDeferred } from '@/test/deferred'
@@ -67,7 +67,7 @@ describe('MarketplaceDetailDialog', () => {
const onOpenChange = vi.fn()
render(
<ThemeProvider defaultTheme="dark" enableSystem={false}>
<ThemeProvider defaultPreference="dark">
<MarketplaceDetailDialog open isInstalled plugin={plugin} onOpenChange={onOpenChange} />
</ThemeProvider>,
)
@@ -90,7 +90,7 @@ describe('MarketplaceDetailDialog', () => {
it('installs from the embedded detail frame without opening a confirmation dialog', async () => {
render(
<ThemeProvider defaultTheme="dark" enableSystem={false}>
<ThemeProvider defaultPreference="dark">
<MarketplaceDetailDialog open isInstalled={false} plugin={plugin} onOpenChange={vi.fn()} />
</ThemeProvider>,
)
@@ -154,7 +154,7 @@ describe('MarketplaceDetailDialog', () => {
it('does not install when the workspace lacks plugin.install', async () => {
render(
<PluginInstallPermissionProvider canInstallPlugin={false}>
<ThemeProvider defaultTheme="dark" enableSystem={false}>
<ThemeProvider defaultPreference="dark">
<MarketplaceDetailDialog
open
isInstalled={false}
@@ -202,7 +202,7 @@ describe('MarketplaceDetailDialog', () => {
try {
render(
<ThemeProvider defaultTheme="dark" enableSystem={false}>
<ThemeProvider defaultPreference="dark">
<MarketplaceDetailDialog
open
isInstalled={false}
@@ -2,11 +2,11 @@
import type { Plugin } from '@/app/components/plugins/types'
import { useRefWithInit } from '@langgenius/dify-ui/use-ref-with-init'
import { useTheme } from 'next-themes'
import { useCallback, useEffect, useRef } from 'react'
import { useLocale, useTranslation } from '#i18n'
import { useOptionalPluginInstallPermission } from '@/app/components/plugins/install-plugin/hooks/use-plugin-install-permission'
import { marketplaceLinks } from '@/features/marketplace/links'
import { useResolvedTheme } from '@/features/theme/hooks'
import MarketplaceDetailDialogFrame from './frame'
import { useSilentMarketplaceInstall } from './use-silent-install'
@@ -120,9 +120,7 @@ function MarketplaceDetailDialog({
const { t } = useTranslation(['plugin'])
const locale = useLocale()
const { canInstallPlugin } = useOptionalPluginInstallPermission()
// resolvedTheme maps the "system" preference to the concrete light/dark
// value the marketplace page expects.
const { resolvedTheme } = useTheme()
const theme = useResolvedTheme()
const pluginLabel = plugin.label[locale] ?? plugin.label['en-US'] ?? plugin.name
const detailLabel = t(($) => $['detailPanel.operation.detail'], { ns: 'plugin' })
const installedForSrcRef = useRef(isInstalled)
@@ -132,7 +130,7 @@ function MarketplaceDetailDialog({
installed: String(installedForSrcRef.current),
language: locale,
source: globalThis.location?.origin,
theme: resolvedTheme,
theme,
view: 'modal',
})
const title = `${pluginLabel} · ${detailLabel}`
@@ -1,12 +1,12 @@
'use client'
import useTheme from '@/hooks/use-theme'
import { useResolvedTheme } from '@/features/theme/hooks'
type LineProps = {
className?: string
}
const Line = ({ className }: LineProps) => {
const { theme } = useTheme()
const theme = useResolvedTheme()
const isDarkMode = theme === 'dark'
if (isDarkMode) {
@@ -1,12 +1,12 @@
import type { ComponentProps } from 'react'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ThemeProvider } from 'next-themes'
import { afterEach, describe, expect, it, vi } from 'vite-plus/test'
import {
createPluginInstallPermissionStore,
PluginInstallPermissionContext,
} from '@/app/components/plugins/install-plugin/hooks/use-plugin-install-permission'
import { ThemeProvider } from '@/features/theme/provider'
import { seedModelProviderQueries } from '@/test/console/model-provider'
import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data'
import { createMarketplacePluginFixture } from '@/test/fixtures/plugin'
@@ -65,7 +65,7 @@ describe('CardWrapper', () => {
seedModelProviderQueries(queryClient)
const store = createPluginInstallPermissionStore({ canInstallPlugin })
return renderWithConsoleQuery(
<ThemeProvider defaultTheme="dark" enableSystem={false}>
<ThemeProvider defaultPreference="dark">
<PluginInstallPermissionContext value={store}>
<CardWrapper plugin={plugin} {...props} />
</PluginInstallPermissionContext>
@@ -1,10 +1,10 @@
'use client'
import type { MarketplaceTemplate } from '@dify/contracts/marketplace'
import { useTheme } from 'next-themes'
import { useCallback } from 'react'
import { useLocale, useTranslation } from '#i18n'
import { marketplaceLinks } from '@/features/marketplace/links'
import { useResolvedTheme } from '@/features/theme/hooks'
import MarketplaceDetailDialogFrame from '../detail-dialog/frame'
const MARKETPLACE_INSTALL_MESSAGE_TYPE = 'dify-marketplace:install-template'
@@ -24,14 +24,12 @@ export default function TemplateDetailDialog({
}: TemplateDetailDialogProps) {
const { t } = useTranslation(['plugin'])
const locale = useLocale()
// resolvedTheme maps the "system" preference to the concrete light/dark
// value the marketplace page expects.
const { resolvedTheme } = useTheme()
const theme = useResolvedTheme()
const detailLabel = t(($) => $['detailPanel.operation.detail'], { ns: 'plugin' })
const detailURL = marketplaceLinks.template(template, {
language: locale,
source: globalThis.location?.origin,
theme: resolvedTheme,
theme,
view: 'modal',
})
const handleMessage = useCallback(
@@ -2,11 +2,11 @@
import type { MarketplacePlugin, MarketplaceTemplate } from '@dify/contracts/marketplace'
import { useAtomValue } from 'jotai'
import { useTheme } from 'next-themes'
import { useSyncExternalStore } from 'react'
import { useLocale } from '#i18n'
import { marketplaceLinks } from '@/features/marketplace/links'
import { deploymentEditionAtom } from '@/features/system-features/state'
import { useResolvedTheme } from '@/features/theme/hooks'
const subscribe = () => () => {}
const getOrigin = () => window.location.origin
@@ -23,11 +23,11 @@ type TemplateDetail = Pick<
export function useMarketplaceDetailNavigation() {
const edition = useAtomValue(deploymentEditionAtom)
const language = useLocale()
const { resolvedTheme } = useTheme()
const theme = useResolvedTheme()
const source = useSyncExternalStore(subscribe, getOrigin, getServerOrigin)
const opensExternally = edition === 'COMMUNITY' || edition === 'ENTERPRISE'
const detailParams = { language, theme: resolvedTheme }
const detailParams = { language, theme }
const pluginHref = (plugin: PluginDetail) =>
opensExternally && plugin.type !== 'bundle'
? marketplaceLinks.officialPluginDetail(plugin, detailParams, source)
@@ -28,7 +28,7 @@ import { API_PREFIX } from '@/config'
import { useGetLanguage } from '@/context/i18n'
import { userProfileQueryOptions } from '@/features/account-profile/client'
import { useMarketplace, useMarketplacePluginVersions } from '@/features/marketplace/client'
import useTheme from '@/hooks/use-theme'
import { useResolvedTheme } from '@/features/theme/hooks'
import useTimestamp from '@/hooks/use-timestamp'
import { consoleQuery } from '@/service/console'
import { isEarlierThanVersion } from '@/utils/semver'
@@ -86,7 +86,7 @@ const DetailHeader = ({
...userProfileQueryOptions(),
select: (data) => data.profile.timezone ?? undefined,
})
const { theme } = useTheme()
const theme = useResolvedTheme()
const locale = useGetLanguage()
const currentLocale = useLocale()
const marketplace = useMarketplace()
@@ -200,13 +200,13 @@ const DetailHeader = ({
</div>
{/* Plugin Info */}
<div className="ml-3 w-0 grow">
<div className="ms-3 w-0 grow">
{/* Title Row */}
<div className="flex h-5 items-center">
<Title title={label[locale]} />
{verified && !isReadmeView && (
<Verified
className="ml-0.5 size-4"
className="ms-0.5 size-4"
text={t(($) => $['marketplace.verifiedTip'], { ns: 'plugin' })}
/>
)}
@@ -227,7 +227,7 @@ const DetailHeader = ({
className="relative mx-1 h-5 overflow-visible rounded-[5px] border border-divider-deep px-1.25 system-xs-medium text-text-tertiary"
>
{hasNewVersion && (
<span className="absolute -top-0.5 -right-0.5 size-1.5 rounded-xs border border-components-badge-status-light-error-border-inner bg-components-badge-status-light-error-bg shadow-sm" />
<span className="absolute -inset-e-0.5 -top-0.5 size-1.5 rounded-xs border border-components-badge-status-light-error-border-inner bg-components-badge-status-light-error-bg shadow-sm" />
)}
<span>{isFromGitHub ? (meta?.version ?? version) : version}</span>
{canChangeVersion && !loadingVersions && (
@@ -283,7 +283,7 @@ const DetailHeader = ({
<TooltipTrigger
render={
<div>
<Badge className="mr-1 cursor-pointer px-1">
<Badge className="me-1 cursor-pointer px-1">
<span
aria-hidden
className="i-custom-vender-system-auto-update-line size-3"
@@ -14,7 +14,7 @@ import {
import { useTranslation } from 'react-i18next'
import { useLocale } from '#i18n'
import { useMarketplace } from '@/features/marketplace/client'
import useTheme from '@/hooks/use-theme'
import { useResolvedTheme } from '@/features/theme/hooks'
import { PluginSource } from '../types'
type OperationMenuProps = Readonly<
@@ -55,7 +55,7 @@ export function OperationMenu({
const { t } = useTranslation(['plugin'])
const marketplace = useMarketplace()
const locale = useLocale()
const { theme } = useTheme()
const theme = useResolvedTheme()
const showInfo = source === PluginSource.github
const showCheckVersionAction = showCheckVersion && source === PluginSource.github
const detailUrl = !marketplace
@@ -19,8 +19,8 @@ import PluginItem from '../index'
const mockEnableMarketplace = vi.fn(() => true)
const mockTheme = vi.fn(() => 'light')
vi.mock('@/hooks/use-theme', () => ({
default: () => ({ theme: mockTheme() }),
vi.mock('@/features/theme/hooks', () => ({
useResolvedTheme: () => mockTheme(),
}))
const PluginPageContextProvider = PluginPageContext.Provider
@@ -18,8 +18,8 @@ import useRefreshPluginList from '@/app/components/plugins/install-plugin/hooks/
import { API_PREFIX } from '@/config'
import { userProfileQueryOptions } from '@/features/account-profile/client'
import { useMarketplace } from '@/features/marketplace/client'
import { useResolvedTheme } from '@/features/theme/hooks'
import { useRenderI18nObject } from '@/hooks/use-i18n'
import useTheme from '@/hooks/use-theme'
import { isEqualOrLaterThanVersion } from '@/utils/semver'
import Badge from '../../base/badge'
import Verified from '../base/badges/verified'
@@ -44,7 +44,7 @@ const PluginItem: FC<Props> = ({
plugin,
}) => {
const { t } = useTranslation(['plugin'])
const { theme } = useTheme()
const theme = useResolvedTheme()
const selectedPluginID = usePluginPageContext((v) =>
v.selectedItem?.type === 'plugin' ? v.selectedItem.id : undefined,
)
@@ -150,14 +150,14 @@ const PluginItem: FC<Props> = ({
width={40}
/>
</div>
<div className="ml-3 w-0 grow">
<div className="ms-3 w-0 grow">
<div className="flex h-5 items-center">
<div id={titleId} className="min-w-0">
<Title title={title} />
</div>
{verified && (
<Verified
className="ml-0.5 size-4"
className="ms-0.5 size-4"
text={t(($) => $['marketplace.verifiedTip'], { ns: 'plugin' })}
/>
)}
@@ -169,7 +169,7 @@ const PluginItem: FC<Props> = ({
ns: 'plugin',
minimalDifyVersion: declarationMeta.minimum_dify_version ?? '0.0.0',
})}
className="relative z-20 ml-0.5 inline-flex size-4 shrink-0 border-0 bg-transparent p-0"
className="relative z-20 ms-0.5 inline-flex size-4 shrink-0 border-0 bg-transparent p-0"
>
<RiErrorWarningLine color="red" className="size-4 text-text-accent" />
</PopoverTrigger>
@@ -182,7 +182,7 @@ const PluginItem: FC<Props> = ({
</Popover>
)}
<Badge
className="ml-1 shrink-0"
className="ms-1 shrink-0"
text={source === PluginSource.github ? plugin.meta!.version : plugin.version}
hasRedCornerMark={
source === PluginSource.marketplace &&
@@ -193,11 +193,11 @@ const PluginItem: FC<Props> = ({
</div>
<div className="relative flex h-4 min-w-0 items-center">
<Description
className="min-w-0 flex-1 group-focus-within/plugin-item:pr-20 group-hover/plugin-item:pr-20 [@media(hover:none)]:pr-20"
className="min-w-0 flex-1 group-focus-within/plugin-item:pe-20 group-hover/plugin-item:pe-20 [@media(hover:none)]:pe-20"
text={descriptionText}
descriptionLineRows={1}
/>
<div className="pointer-events-none absolute top-1/2 right-0 z-20 -translate-y-1/2 opacity-0 transition-opacity group-focus-within/plugin-item:pointer-events-auto group-focus-within/plugin-item:opacity-100 group-hover/plugin-item:pointer-events-auto group-hover/plugin-item:opacity-100 [@media(hover:none)]:pointer-events-auto [@media(hover:none)]:opacity-100">
<div className="pointer-events-none absolute inset-e-0 top-1/2 z-20 -translate-y-1/2 opacity-0 transition-opacity group-focus-within/plugin-item:pointer-events-auto group-focus-within/plugin-item:opacity-100 group-hover/plugin-item:pointer-events-auto group-hover/plugin-item:opacity-100 [@media(hover:none)]:pointer-events-auto [@media(hover:none)]:opacity-100">
<Action
pluginUniqueIdentifier={plugin_unique_identifier}
installationId={installation_id}
@@ -303,7 +303,7 @@ const PluginItem: FC<Props> = ({
</div>
{/* BG Effect for Deprecated Plugin */}
{source === PluginSource.marketplace && marketplace && isDeprecated && (
<div className="absolute -right-11.25 -bottom-17.75 z-0 size-40 bg-components-badge-status-light-warning-halo opacity-60 blur-[120px]" />
<div className="absolute -inset-e-11.25 -bottom-17.75 z-0 size-40 bg-components-badge-status-light-warning-halo opacity-60 blur-[120px]" />
)}
</div>
)
@@ -5,8 +5,8 @@ import type { ConsoleQueryTestOptions } from '@/test/console/query-data'
import { zGetWorkspacesCurrentPluginMarketplacePkgResponse } from '@dify/contracts/api/console/workspaces/zod.gen'
import { screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ThemeProvider } from 'next-themes'
import { marketplaceLinks } from '@/features/marketplace/links'
import { ThemeProvider } from '@/features/theme/provider'
import { seedModelProviderQueries } from '@/test/console/model-provider'
import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data'
import { createMarketplacePluginFixture, createPluginEntity } from '@/test/fixtures/plugin'
@@ -67,7 +67,7 @@ const setup = (
const queryClient = createConsoleQueryClient()
seedModelProviderQueries(queryClient)
const rendered = renderWithConsoleQuery(
<ThemeProvider defaultTheme="light" forcedTheme="light" enableSystem={false}>
<ThemeProvider defaultPreference="light">
<CategoryMarketplacePanel
marketplace={marketplaceLinks}
category={Category.trigger}
@@ -8,7 +8,6 @@ import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Separator } from '@langgenius/dify-ui/separator'
import { useQuery } from '@tanstack/react-query'
import { useTheme } from 'next-themes'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder'
@@ -16,6 +15,7 @@ import List from '@/app/components/plugins/marketplace/list'
import { useMarketplacePlugins } from '@/app/components/plugins/marketplace/query'
import { usePluginSettingsAccess } from '@/app/components/plugins/plugin-page/use-reference-setting'
import ProviderCard from '@/app/components/plugins/provider-card'
import { useResolvedTheme } from '@/features/theme/hooks'
import Link from '@/next/link'
import { consoleQuery } from '@/service/console'
import { getCategoryMarketplaceId } from './category-marketplace'
@@ -34,7 +34,7 @@ const CategoryMarketplacePanel = ({
tags?: string[]
}) => {
const { t } = useTranslation(['common', 'plugin', 'workflow', 'modelProvider'])
const { theme } = useTheme()
const theme = useResolvedTheme()
const { canInstallPlugin } = usePluginSettingsAccess()
const [collapsed, setCollapsed] = useState(false)
const {
@@ -103,7 +103,7 @@ const CategoryMarketplacePanel = ({
<button
type="button"
aria-expanded={!collapsed}
className="flex cursor-pointer items-center gap-1 border-0 bg-transparent p-0 text-left system-md-semibold text-text-primary"
className="flex cursor-pointer items-center gap-1 border-0 bg-transparent p-0 text-start system-md-semibold text-text-primary"
onClick={() => setCollapsed((value) => !value)}
>
<span
+3 -3
View File
@@ -5,7 +5,6 @@ import type { MarketplaceLinks } from '@/features/marketplace/links'
import { Button, buttonVariants } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { useBoolean } from 'ahooks'
import { useTheme } from 'next-themes'
import * as React from 'react'
import { useId, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
@@ -13,6 +12,7 @@ import { useLocale } from '#i18n'
import usePluginInstallPermission from '@/app/components/plugins/install-plugin/hooks/use-plugin-install-permission'
import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace'
import { useMarketplaceDetailNavigation } from '@/app/components/plugins/marketplace/use-detail-navigation'
import { useResolvedTheme } from '@/features/theme/hooks'
import { useRenderI18nObject } from '@/hooks/use-i18n'
import Badge from '../base/badge'
import Icon from './card/base/card-icon'
@@ -28,7 +28,7 @@ type Props = Readonly<{
const ProviderCardComponent: FC<Props> = ({ className, marketplace, payload }) => {
const getValueFromI18nObject = useRenderI18nObject()
const { t } = useTranslation(['plugin'])
const { theme } = useTheme()
const theme = useResolvedTheme()
const [
isShowInstallFromMarketplace,
{ setTrue: showInstallFromMarketplace, setFalse: hideInstallFromMarketplace },
@@ -54,7 +54,7 @@ const ProviderCardComponent: FC<Props> = ({ className, marketplace, payload }) =
{/* Header */}
<div className="flex">
<Icon src={payload.icon} />
<div className="ml-3 w-0 grow">
<div className="ms-3 w-0 grow">
<div className="flex h-5 items-center">
<h2
id={titleId}
@@ -1,9 +1,9 @@
import * as React from 'react'
import useTheme from '@/hooks/use-theme'
import { useResolvedTheme } from '@/features/theme/hooks'
import { basePath } from '@/utils/var'
const PipelineScreenShot = () => {
const { theme } = useTheme()
const theme = useResolvedTheme()
return (
<picture>
@@ -49,12 +49,12 @@ describe('TextGenerationMenu', () => {
const user = userEvent.setup()
render(<TextGenerationMenu data={siteInfo} />)
await user.click(screen.getByRole('button', { name: 'common.operation.more' }))
expect(screen.getByRole('menuitemradio', { name: 'common.theme.auto' })).toHaveAttribute(
expect(screen.getByRole('menuitemradio', { name: 'common.theme.system' })).toHaveAttribute(
'aria-checked',
'true',
)
await user.click(screen.getByRole('menuitemradio', { name: 'common.theme.light' }))
expect(mockSetTheme).toHaveBeenCalledWith('light', expect.anything())
expect(mockSetTheme).toHaveBeenCalledWith('light')
expect(screen.getByRole('menu')).toBeInTheDocument()
})
@@ -1,5 +1,6 @@
'use client'
import type { MenuContentProps } from '@langgenius/dify-ui/menu'
import type { ThemePreference } from '@/features/theme/theme'
import type { SiteInfo } from '@/models/share'
import { IconButton } from '@langgenius/dify-ui/icon-button'
import {
@@ -13,11 +14,12 @@ import {
MenuSeparator,
MenuTrigger,
} from '@langgenius/dify-ui/menu'
import { useTheme } from 'next-themes'
import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useWebAppStore } from '@/context/web-app-context'
import { useThemePreference } from '@/features/theme/hooks'
import { themeOptions } from '@/features/theme/theme'
import { AccessMode } from '@/models/access-control'
import { usePathname, useRouter } from '@/next/navigation'
import { resolveWebAppAddress } from '@/service/webapp-address'
@@ -42,7 +44,7 @@ function TextGenerationMenu({ data, side, align, hideLogout }: Props) {
router.replace(`/webapp-signin?redirect_url=${pathname}`)
}
const { theme, setTheme } = useTheme()
const { preference, setPreference } = useThemePreference()
const [show, setShow] = useState(false)
const handleOpenInfoModal = () => {
queueMicrotask(() => {
@@ -65,53 +67,34 @@ function TextGenerationMenu({ data, side, align, hideLogout }: Props) {
}
/>
<MenuContent side={side} align={align} className="w-56">
<MenuRadioGroup
value={theme ?? 'system'}
onValueChange={setTheme}
<MenuRadioGroup<ThemePreference>
value={preference}
onValueChange={setPreference}
className="flex items-center gap-2 px-3 system-md-regular text-text-secondary"
>
<MenuGroupLabel className="grow px-0 py-0 system-md-regular text-text-secondary normal-case">
{t(($) => $['theme.theme'], { ns: 'common' })}
{t(($) => $['theme.label'], { ns: 'common' })}
</MenuGroupLabel>
<div className="flex items-center rounded-[10px] bg-components-segmented-control-bg-normal p-0.5">
{(['system', 'light', 'dark'] as const).map((value, index) => (
<React.Fragment key={value}>
{themeOptions.map((option, index) => (
<React.Fragment key={option.value}>
{index > 0 && (
<div
aria-hidden
className={
(index === 1 && theme === 'dark') ||
(index === 2 && (theme ?? 'system') === 'system')
preference !== option.value && preference !== themeOptions[index - 1]?.value
? 'h-3.5 w-px bg-divider-regular'
: 'h-3.5 w-px bg-transparent'
}
/>
)}
<MenuRadioItem
value={value}
aria-label={t(
($) =>
$[
value === 'system'
? 'theme.auto'
: value === 'light'
? 'theme.light'
: 'theme.dark'
],
{ ns: 'common' },
)}
<MenuRadioItem<ThemePreference>
value={option.value}
aria-label={t(($) => $[option.labelKey], { ns: 'common' })}
className="mx-0 h-auto py-1 text-text-tertiary data-checked:bg-components-segmented-control-item-active-bg data-checked:text-text-accent-light-mode-only data-checked:shadow-sm data-highlighted:text-text-secondary data-checked:data-highlighted:bg-components-segmented-control-item-active-bg data-checked:data-highlighted:text-text-accent-light-mode-only"
>
<span aria-hidden className="p-0.5">
<span
className={
value === 'system'
? 'i-ri-computer-line size-4'
: value === 'light'
? 'i-ri-sun-line size-4'
: 'i-ri-moon-line size-4'
}
/>
<span className={`${option.iconClassName} size-4`} />
</span>
</MenuRadioItem>
</React.Fragment>
@@ -4,12 +4,12 @@ import type { useToolMarketplace } from './hooks'
import type { MarketplaceLinks } from '@/features/marketplace/links'
import { cn } from '@langgenius/dify-ui/cn'
import { IconButton } from '@langgenius/dify-ui/icon-button'
import { useTheme } from 'next-themes'
import { useTranslation } from 'react-i18next'
import { useLocale } from '#i18n'
import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder'
import List from '@/app/components/plugins/marketplace/list'
import { usePluginSettingsAccess } from '@/app/components/plugins/plugin-page/use-reference-setting'
import { useResolvedTheme } from '@/features/theme/hooks'
import { useRouter } from '@/next/navigation'
import { toolsContentInsetClassNames, toolsUnifiedContentFrameClassName } from '../content-inset'
@@ -33,7 +33,7 @@ const Marketplace = ({
}: MarketplaceProps) => {
const locale = useLocale()
const { t } = useTranslation(['common', 'plugin'])
const { theme } = useTheme()
const theme = useResolvedTheme()
const router = useRouter()
const { canInstallPlugin } = usePluginSettingsAccess()
const { isLoading, marketplaceCollections, marketplaceCollectionPluginsMap, plugins, page } =
+2 -2
View File
@@ -4,7 +4,7 @@ import { RiArrowRightLine, RiArrowRightUpLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { buildIntegrationPath } from '@/app/components/integrations/routes'
import { useDocLink } from '@/context/i18n'
import useTheme from '@/hooks/use-theme'
import { useResolvedTheme } from '@/features/theme/hooks'
import Link from '@/next/link'
import { ToolType } from '../../workflow/block-selector/types'
@@ -32,7 +32,7 @@ const getLink = (type?: ToolType) => {
const Empty = ({ type, isAgent }: Props) => {
const { t } = useTranslation(['tools'])
const docLink = useDocLink()
const { theme } = useTheme()
const theme = useResolvedTheme()
const hasLink = type === ToolType.Custom || type === ToolType.MCP
const renderType = isAgent ? ('agent' as const) : type
@@ -33,13 +33,13 @@ import useNodes from '@/app/components/workflow/store/workflow/use-nodes'
import { BlockEnum, InputVarType, isTriggerNode } from '@/app/components/workflow/types'
import { toast } from '@/app/notifications'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import useTheme from '@/hooks/use-theme'
import { useResolvedTheme } from '@/features/theme/hooks'
import { consoleQuery } from '@/service/console'
import { useResetWorkflowVersionHistory } from '@/service/use-workflow'
const FeaturesTrigger = () => {
const { t } = useTranslation(['common', 'workflow'])
const { theme } = useTheme()
const theme = useResolvedTheme()
const isChatflow = useAtomValue(isChatflowAtom)
const workflowStore = useWorkflowStore()
const queryClient = useQueryClient()
@@ -3,9 +3,9 @@ import type { MarketplaceLinks } from '@/features/marketplace/links'
import { IconButton } from '@langgenius/dify-ui/icon-button'
import { Menu, MenuContent, MenuItem, MenuLinkItem, MenuTrigger } from '@langgenius/dify-ui/menu'
import { useMutation } from '@tanstack/react-query'
import { useTheme } from 'next-themes'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import { useResolvedTheme } from '@/features/theme/hooks'
import { marketplaceQuery } from '@/service/marketplace'
import { downloadBlob } from '@/utils/download'
@@ -20,7 +20,7 @@ type Props = Readonly<{
function OperationMenu({ marketplace, open, onOpenChange, author, name, version }: Props) {
const { t } = useTranslation(['common'])
const { theme } = useTheme()
const theme = useResolvedTheme()
const downloadMutation = useMutation(
marketplaceQuery.downloadPlugin.mutationOptions({
@@ -12,8 +12,7 @@ import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { trackEvent } from '@/app/components/base/amplitude'
import { useGetLanguage } from '@/context/i18n'
import useTheme from '@/hooks/use-theme'
import { Theme } from '@/types/app'
import { useResolvedTheme } from '@/features/theme/hooks'
import { getProviderReference } from '@/utils/provider-reference'
import { basePath } from '@/utils/var'
import BlockIcon from '../../block-icon'
@@ -62,7 +61,7 @@ const ToolItem: FC<Props> = ({
const language = useGetLanguage()
const previewDescriptionId = React.useId()
const previewDescription = payload.description[language]
const { theme } = useTheme()
const theme = useResolvedTheme()
const normalizedIcon = useMemo<ToolWithProvider['icon']>(() => {
return normalizeProviderIcon(provider.icon) ?? provider.icon
}, [provider.icon])
@@ -71,7 +70,7 @@ const ToolItem: FC<Props> = ({
return normalizeProviderIcon(provider.icon_dark) ?? provider.icon_dark
}, [provider.icon_dark])
const providerIcon = useMemo(() => {
if (theme === Theme.dark && normalizedIconDark) return normalizedIconDark
if (theme === 'dark' && normalizedIconDark) return normalizedIconDark
return normalizedIcon
}, [theme, normalizedIcon, normalizedIconDark])
@@ -81,7 +80,7 @@ const ToolItem: FC<Props> = ({
size="medium"
aria-describedby={previewDescription ? previewDescriptionId : undefined}
disabled={disabled}
className="w-full justify-between pr-1 pl-5.25 text-left focus-visible:ring-inset disabled:cursor-default"
className="w-full justify-between ps-5.25 pe-1 text-start focus-visible:ring-inset disabled:cursor-default"
onClick={() => {
if (disabled) return
const params: Record<string, string> = {}
@@ -114,11 +113,11 @@ const ToolItem: FC<Props> = ({
})
}}
>
<div className="truncate border-l-2 border-divider-subtle py-2 pl-4 system-sm-medium text-text-secondary">
<div className="truncate border-s-2 border-divider-subtle py-2 ps-4 system-sm-medium text-text-secondary">
<span className={cn(disabled && 'opacity-30')}>{payload.label[language]}</span>
</div>
{isAdded && (
<div className="mr-4 system-xs-regular text-text-tertiary">
<div className="me-4 system-xs-regular text-text-tertiary">
{t(($) => $['addToolModal.added'], { ns: 'tools' })}
</div>
)}
@@ -11,8 +11,7 @@ import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useMCPToolAvailability } from '@/app/components/workflow/nodes/_base/components/mcp-tool-availability'
import { useGetLanguage } from '@/context/i18n'
import useTheme from '@/hooks/use-theme'
import { Theme } from '@/types/app'
import { useResolvedTheme } from '@/features/theme/hooks'
import { getProviderReference, matchesProviderReference } from '@/utils/provider-reference'
import { basePath } from '@/utils/var'
import { CollectionType } from '../../../tools/types'
@@ -69,7 +68,7 @@ function Tool({
const [previousHasSearchText, setPreviousHasSearchText] = React.useState(hasSearchText)
const isMCPTool = payload.type === CollectionType.mcp
const isShowCanNotChooseMCPTip = !isMCPToolAllowed && isMCPTool
const { theme } = useTheme()
const theme = useResolvedTheme()
const normalizedIcon = useMemo<ToolWithProvider['icon']>(() => {
return normalizeProviderIcon(payload.icon) ?? payload.icon
}, [payload.icon])
@@ -78,7 +77,7 @@ function Tool({
return normalizeProviderIcon(payload.icon_dark) ?? payload.icon_dark
}, [payload.icon_dark])
const providerIcon = useMemo<ToolWithProvider['icon']>(() => {
if (theme === Theme.dark && normalizedIconDark) return normalizedIconDark
if (theme === 'dark' && normalizedIconDark) return normalizedIconDark
return normalizedIcon
}, [theme, normalizedIcon, normalizedIconDark])
const getIsDisabled = useCallback(
@@ -120,17 +119,17 @@ function Tool({
className={cn('flex h-8 min-w-0 grow items-center', isShowCanNotChooseMCPTip && 'opacity-30')}
>
<BlockIcon className="shrink-0" type={BlockEnum.Tool} toolIcon={providerIcon} />
<div className="ml-2 flex w-0 grow items-center text-sm text-text-primary">
<div className="ms-2 flex w-0 grow items-center text-sm text-text-primary">
<span className="max-w-62.5 truncate">
{notShowProvider ? actions[0]?.label[language] : payload.label[language]}
</span>
{isFlatView && groupName && (
<span className="ml-2 shrink-0 system-xs-regular text-text-quaternary">{groupName}</span>
<span className="ms-2 shrink-0 system-xs-regular text-text-quaternary">{groupName}</span>
)}
{isMCPTool && (
<span
aria-hidden
className="ml-2 i-custom-vender-other-mcp size-3.5 shrink-0 text-text-quaternary"
className="ms-2 i-custom-vender-other-mcp size-3.5 shrink-0 text-text-quaternary"
/>
)}
</div>
@@ -204,12 +203,12 @@ function Tool({
<div className={cn('mb-1 last-of-type:mb-0', className)}>
<button
type="button"
className="flex h-8 w-full cursor-pointer items-center rounded-lg border-0 bg-transparent pr-2 pl-3 text-left hover:bg-state-base-hover focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid focus-visible:outline-hidden"
className="flex h-8 w-full cursor-pointer items-center rounded-lg border-0 bg-transparent ps-3 pe-2 text-start hover:bg-state-base-hover focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid focus-visible:outline-hidden"
onClick={handleWorkflowToolSelect}
>
{providerDetails}
{!canNotSelectMultiple && selectedStatus && (
<span className="ml-2 shrink-0 system-xs-regular text-text-tertiary">
<span className="ms-2 shrink-0 system-xs-regular text-text-tertiary">
{t(($) => $['addToolModal.added'], { ns: 'tools' })}
</span>
)}
@@ -227,13 +226,13 @@ function Tool({
<div className="group/item relative flex w-full items-center rounded-lg">
<CollapsibleTrigger
aria-controls={panelId}
className="group/collapsible flex h-8 min-h-8 w-full min-w-0 touch-manipulation items-center justify-start gap-0 rounded-lg bg-transparent pr-2 pl-3 text-start system-sm-medium text-text-secondary select-none group-hover/item:bg-state-base-hover hover:bg-state-base-hover hover:text-text-primary focus-visible:ring-inset data-panel-open:text-text-primary"
className="group/collapsible flex h-8 min-h-8 w-full min-w-0 touch-manipulation items-center justify-start gap-0 rounded-lg bg-transparent ps-3 pe-2 text-start system-sm-medium text-text-secondary select-none group-hover/item:bg-state-base-hover hover:bg-state-base-hover hover:text-text-primary focus-visible:ring-inset data-panel-open:text-text-primary"
>
{providerDetails}
{!isShowCanNotChooseMCPTip && !canNotSelectMultiple && selectedStatus && (
<span
className={cn(
'ml-2 shrink-0 system-xs-regular text-text-tertiary',
'ms-2 shrink-0 system-xs-regular text-text-tertiary',
!isAllSelected &&
'group-focus-within/item:hidden group-hover/item:hidden [@media(hover:none)]:hidden',
)}
@@ -244,13 +243,13 @@ function Tool({
<span
aria-hidden
className={cn(
'ml-2 i-ri-arrow-right-s-line size-4 shrink-0 text-text-quaternary transition-transform group-data-panel-open/collapsible:rotate-90 motion-reduce:transition-none',
'ms-2 i-ri-arrow-right-s-line size-4 shrink-0 text-text-quaternary transition-transform group-data-panel-open/collapsible:rotate-90 motion-reduce:transition-none',
!isShowCanNotChooseMCPTip &&
!canNotSelectMultiple &&
!isAllSelected &&
onSelectMultiple &&
'group-focus-within/item:hidden group-hover/item:hidden [@media(hover:none)]:hidden',
isShowCanNotChooseMCPTip && 'mr-7',
isShowCanNotChooseMCPTip && 'me-7',
)}
/>
</CollapsibleTrigger>
@@ -262,7 +261,7 @@ function Tool({
<Button
variant="ghost"
size="small"
className="pointer-events-none absolute top-1/2 right-1 -translate-y-1/2 text-components-button-secondary-accent-text opacity-0 group-focus-within/item:pointer-events-auto group-focus-within/item:opacity-100 group-hover/item:pointer-events-auto group-hover/item:opacity-100 [@media(hover:none)]:pointer-events-auto [@media(hover:none)]:opacity-100"
className="pointer-events-none absolute inset-e-1 top-1/2 -translate-y-1/2 text-components-button-secondary-accent-text opacity-0 group-focus-within/item:pointer-events-auto group-focus-within/item:opacity-100 group-hover/item:pointer-events-auto group-hover/item:opacity-100 [@media(hover:none)]:pointer-events-auto [@media(hover:none)]:opacity-100"
onClick={(event) => {
event.stopPropagation()
handleSelectAll()
@@ -272,7 +271,7 @@ function Tool({
</Button>
)}
{isShowCanNotChooseMCPTip && (
<div className="absolute top-1/2 right-3 flex -translate-y-1/2">
<div className="absolute inset-e-3 top-1/2 flex -translate-y-1/2">
<McpToolNotSupportTooltip />
</div>
)}
@@ -21,8 +21,7 @@ import { CollectionType } from '@/app/components/tools/types'
import BlockIcon from '@/app/components/workflow/block-icon'
import { BlockEnum } from '@/app/components/workflow/types'
import { useGetLanguage } from '@/context/i18n'
import useTheme from '@/hooks/use-theme'
import { Theme } from '@/types/app'
import { useResolvedTheme } from '@/features/theme/hooks'
import { basePath } from '@/utils/var'
import { BlockSelectorRow } from '../block-selector-row'
import TriggerPluginActionItem from './action-item'
@@ -58,7 +57,7 @@ const TriggerPluginItem: FC<Props> = ({
}) => {
const { t } = useTranslation(['workflow'])
const language = useGetLanguage()
const { theme } = useTheme()
const theme = useResolvedTheme()
const notShowProvider = payload.type === CollectionType.workflow
const actions = payload.events
const hasAction = !notShowProvider
@@ -92,7 +91,7 @@ const TriggerPluginItem: FC<Props> = ({
return normalizeProviderIcon(payload.icon_dark) ?? payload.icon_dark
}, [payload.icon_dark])
const providerIcon = useMemo<TriggerWithProvider['icon']>(() => {
if (theme === Theme.dark && normalizedIconDark) return normalizedIconDark
if (theme === 'dark' && normalizedIconDark) return normalizedIconDark
return normalizedIcon
}, [normalizedIcon, normalizedIconDark, theme])
const providerWithResolvedIcon = useMemo(
@@ -143,7 +142,7 @@ const TriggerPluginItem: FC<Props> = ({
>
<div className="flex min-w-0 grow items-center">
<BlockIcon
className="mr-2 shrink-0"
className="me-2 shrink-0"
type={BlockEnum.TriggerPlugin}
size="sm"
toolIcon={providerIcon}
@@ -152,13 +151,13 @@ const TriggerPluginItem: FC<Props> = ({
<span className="max-w-50 truncate">
{notShowProvider ? actions[0]?.label[language] : payload.label[language]}
</span>
<span className="ml-2 truncate system-xs-regular text-text-quaternary">
<span className="ms-2 truncate system-xs-regular text-text-quaternary">
{groupName}
</span>
</div>
</div>
<div className="ml-2 flex items-center">
<div className="ms-2 flex items-center">
{hasAction && (
<FoldIcon
className={cn(
@@ -4,10 +4,10 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/too
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import { useStore } from '@/app/components/workflow/store'
import useTheme from '@/hooks/use-theme'
import { useResolvedTheme } from '@/features/theme/hooks'
const ChatVariableButton = ({ disabled }: { disabled: boolean }) => {
const { theme } = useTheme()
const theme = useResolvedTheme()
const { t } = useTranslation(['workflow'])
const showChatVariablePanel = useStore((s) => s.showChatVariablePanel)
const setShowChatVariablePanel = useStore((s) => s.setShowChatVariablePanel)
@@ -5,10 +5,10 @@ import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import { useInputFieldPanel } from '@/app/components/rag-pipeline/hooks/use-input-field-panel'
import { useStore } from '@/app/components/workflow/store'
import useTheme from '@/hooks/use-theme'
import { useResolvedTheme } from '@/features/theme/hooks'
const EnvButton = ({ disabled }: { disabled: boolean }) => {
const { theme } = useTheme()
const theme = useResolvedTheme()
const { t } = useTranslation(['workflow'])
const setShowChatVariablePanel = useStore((s) => s.setShowChatVariablePanel)
const showEnvPanel = useStore((s) => s.showEnvPanel)
@@ -5,10 +5,10 @@ import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import { useInputFieldPanel } from '@/app/components/rag-pipeline/hooks/use-input-field-panel'
import { useStore } from '@/app/components/workflow/store'
import useTheme from '@/hooks/use-theme'
import { useResolvedTheme } from '@/features/theme/hooks'
const GlobalVariableButton = ({ disabled }: { disabled: boolean }) => {
const { theme } = useTheme()
const theme = useResolvedTheme()
const { t } = useTranslation(['workflow'])
const showGlobalVariablePanel = useStore((s) => s.showGlobalVariablePanel)
const setShowGlobalVariablePanel = useStore((s) => s.setShowGlobalVariablePanel)
@@ -12,7 +12,7 @@ import { getWorkflowVersionName } from '@/app/components/workflow/utils/version'
import { toast } from '@/app/notifications'
import { userProfileQueryOptions } from '@/features/account-profile/client'
import { deploymentEditionAtom } from '@/features/system-features/state'
import useTheme from '@/hooks/use-theme'
import { useResolvedTheme } from '@/features/theme/hooks'
import { consoleQuery } from '@/service/console'
import {
useInvalidateAllLastRun,
@@ -30,7 +30,7 @@ export type HeaderInRestoringProps = {
}
const HeaderInRestoring = ({ onRestoreSettled }: HeaderInRestoringProps) => {
const { t } = useTranslation(['billing', 'workflow', 'workflowHistory'])
const { theme } = useTheme()
const theme = useResolvedTheme()
const [isRestorePlanUpgradeModalOpen, setIsRestorePlanUpgradeModalOpen] = useState(false)
const deploymentEdition = useAtomValue(deploymentEditionAtom)
const { data: plan } = useQuery(
@@ -3,7 +3,7 @@ import { IconButton } from '@langgenius/dify-ui/icon-button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { useHotkey } from '@tanstack/react-hotkeys'
import { useTranslation } from 'react-i18next'
import useTheme from '@/hooks/use-theme'
import { useResolvedTheme } from '@/features/theme/hooks'
import { VERSION_HISTORY_HOTKEY } from '../hotkeys'
import { ShortcutKbd } from '../shortcuts/shortcut-kbd'
@@ -12,7 +12,7 @@ type VersionHistoryButtonProps = {
}
export function VersionHistoryButton({ onClick }: VersionHistoryButtonProps) {
const { theme } = useTheme()
const theme = useResolvedTheme()
const { t } = useTranslation(['workflow'])
const label = t(($) => $['common.versionHistory'], { ns: 'workflow' })
@@ -43,8 +43,8 @@ vi.mock('@/service/use-triggers', async () =>
)
let mockTheme = 'light'
vi.mock('@/hooks/use-theme', () => ({
default: () => ({ theme: mockTheme }),
vi.mock('@/features/theme/hooks', () => ({
useResolvedTheme: () => mockTheme,
}))
vi.mock('@/utils/var', async (importOriginal) => ({
@@ -9,7 +9,7 @@ import { useCallback, useMemo } from 'react'
import { resolveDatasourceIcon } from '@/app/components/rag-pipeline/utils/datasource-icon'
import { CollectionType } from '@/app/components/tools/types'
import { selectToolsWithProvider } from '@/app/components/tools/utils/query-select'
import useTheme from '@/hooks/use-theme'
import { useResolvedTheme } from '@/features/theme/hooks'
import { consoleQuery } from '@/service/console'
import { useAllTriggerPlugins } from '@/service/use-triggers'
import { canFindTool } from '@/utils'
@@ -189,7 +189,7 @@ export const useToolIcon = (data?: Node['data']) => {
)
const dataSourceList = useStore((s) => s.dataSourceList)
const { data: triggerPlugins } = useAllTriggerPlugins()
const { theme } = useTheme()
const theme = useResolvedTheme()
const toolIcon = useMemo(() => {
return (
@@ -239,7 +239,7 @@ export const useGetToolIcon = () => {
)
const { data: triggerPlugins } = useAllTriggerPlugins()
const workflowStore = useWorkflowStore()
const { theme } = useTheme()
const theme = useResolvedTheme()
const getToolIcon = useCallback(
(data: Node['data']) => {
@@ -8,8 +8,7 @@ import * as React from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { getFilesInLogs } from '@/app/components/base/file-uploader/utils'
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
import useTheme from '@/hooks/use-theme'
import { Theme } from '@/types/app'
import { useResolvedTheme } from '@/features/theme/hooks'
import { basePath } from '@/utils/var'
import Base from '../base'
import { serializeCodeEditorValue } from './utils'
@@ -76,7 +75,7 @@ const CodeEditor: FC<Props> = ({
const [isMounted, setIsMounted] = React.useState(false)
const minHeight = height || 200
const [editorContentHeight, setEditorContentHeight] = useState(56)
const { theme: appTheme } = useTheme()
const appTheme = useResolvedTheme()
const valueRef = useRef(value)
useEffect(() => {
valueRef.current = value
@@ -113,7 +112,7 @@ const CodeEditor: FC<Props> = ({
setIsFocus(false)
})
monaco.editor.setTheme(appTheme === Theme.light ? 'light' : 'vs-dark') // Fix: sometimes not load the default theme
monaco.editor.setTheme(appTheme === 'light' ? 'light' : 'vs-dark') // Fix: sometimes not load the default theme
onMount?.(editor, monaco)
setIsMounted(true)
@@ -122,7 +121,7 @@ const CodeEditor: FC<Props> = ({
const outPutValue = serializeCodeEditorValue(value, isJSONStringifyBeauty)
const theme = useMemo(() => {
if (appTheme === Theme.light) return 'light'
if (appTheme === 'light') return 'light'
return 'vs-dark'
}, [appTheme])
@@ -157,7 +156,7 @@ const CodeEditor: FC<Props> = ({
onMount={handleEditorDidMount}
/>
{!outPutValue && !isFocus && (
<div className="pointer-events-none absolute top-0 left-9 text-[13px] leading-4.5 font-normal text-components-input-text-placeholder">
<div className="pointer-events-none absolute inset-s-9 top-0 text-[13px] leading-4.5 font-normal text-components-input-text-placeholder">
{placeholder}
</div>
)}
@@ -6,8 +6,7 @@ import copy from 'copy-to-clipboard'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import useTheme from '@/hooks/use-theme'
import { Theme } from '@/types/app'
import { useResolvedTheme } from '@/features/theme/hooks'
type CodeEditorProps = {
value: string
@@ -38,7 +37,7 @@ const CodeEditor: FC<CodeEditorProps> = ({
onBlur,
}) => {
const { t } = useTranslation(['common'])
const { theme } = useTheme()
const theme = useResolvedTheme()
const monacoRef = useRef<Monaco | null>(null)
const editorRef = useRef<MonacoEditor | null>(null)
const [isMounted, setIsMounted] = React.useState(false)
@@ -46,7 +45,7 @@ const CodeEditor: FC<CodeEditorProps> = ({
useEffect(() => {
if (monacoRef.current) {
if (theme === Theme.light) monacoRef.current.editor.setTheme('light-theme')
if (theme === 'light') monacoRef.current.editor.setTheme('light-theme')
else monacoRef.current.editor.setTheme('dark-theme')
}
}, [theme])
@@ -101,7 +100,7 @@ const CodeEditor: FC<CodeEditorProps> = ({
)
const editorTheme = useMemo(() => {
if (theme === Theme.light) return 'light-theme'
if (theme === 'light') return 'light-theme'
return 'dark-theme'
}, [theme])
useEffect(() => {
@@ -125,7 +124,7 @@ const CodeEditor: FC<CodeEditorProps> = ({
)}
>
{!hideTopMenu && (
<div className="flex items-center justify-between pt-1 pr-1 pl-2">
<div className="flex items-center justify-between ps-2 pe-1 pt-1">
<div className="py-0.5 system-xs-semibold-uppercase text-text-secondary">
<span className="px-1 py-0.5">JSON</span>
</div>
@@ -11,9 +11,9 @@ import { useAutoGenModel } from '@/app/components/app/configuration/config/auto-
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { toast } from '@/app/notifications'
import useTheme from '@/hooks/use-theme'
import { useResolvedTheme } from '@/features/theme/hooks'
import { consoleQuery } from '@/service/console'
import { ModelModeType, Theme } from '@/types/app'
import { ModelModeType } from '@/types/app'
import { useMittContext } from '../visual-editor/context'
import { useVisualEditorStore } from '../visual-editor/store'
import { SchemaGeneratorDark, SchemaGeneratorLight } from './assets'
@@ -45,7 +45,7 @@ export function JsonSchemaGenerator({ onApply, alignOffset }: JsonSchemaGenerato
const [model, setModel] = useAutoGenModel()
const [instruction, setInstruction] = useState('')
const [schema, setSchema] = useState<SchemaRoot | null>(null)
const { theme } = useTheme()
const theme = useResolvedTheme()
const { defaultModel } = useModelListAndDefaultModelAndCurrentProviderAndModel(
ModelTypeEnum.textGeneration,
)
@@ -63,7 +63,7 @@ export function JsonSchemaGenerator({ onApply, alignOffset }: JsonSchemaGenerato
const advancedEditing = useVisualEditorStore((state) => state.advancedEditing)
const isAddingNewField = useVisualEditorStore((state) => state.isAddingNewField)
const { emit } = useMittContext()
const SchemaGenerator = theme === Theme.light ? SchemaGeneratorLight : SchemaGeneratorDark
const SchemaGenerator = theme === 'light' ? SchemaGeneratorLight : SchemaGeneratorDark
const handleTrigger = useCallback(
(e: React.MouseEvent<HTMLElement, MouseEvent>) => {
@@ -2,8 +2,7 @@
import type { FC } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { CopyFeedback } from '@/app/components/base/copy-feedback'
import useTheme from '@/hooks/use-theme'
import { Theme } from '@/types/app'
import { useResolvedTheme } from '@/features/theme/hooks'
type Props = {
readonly status: string
@@ -12,7 +11,7 @@ type Props = {
}
const StatusContainer: FC<Props> = ({ status, children, copyContent }) => {
const { theme } = useTheme()
const theme = useResolvedTheme()
const isCopyable = copyContent !== undefined
return (
@@ -20,68 +19,67 @@ const StatusContainer: FC<Props> = ({ status, children, copyContent }) => {
role="status"
className={cn(
'group/status relative rounded-lg border border-workflow-display-disabled-border-1 px-3 py-2.5 system-xs-regular break-all',
isCopyable && 'focus-within:pr-10 hover:pr-10 [@media(hover:none)]:pr-10',
isCopyable && 'focus-within:pe-10 hover:pe-10 [@media(hover:none)]:pe-10',
status === 'succeeded' &&
'border-[rgba(23,178,106,0.8)] bg-workflow-display-success-bg bg-[url(~@/app/components/workflow/run/assets/bg-line-success.svg)] text-text-success',
status === 'succeeded' &&
theme === Theme.light &&
theme === 'light' &&
'shadow-[inset_2px_2px_0_0_rgba(255,255,255,0.5),inset_0_1px_3px_0_rgba(0,0,0,0.12),inset_0_2px_24px_0_rgba(23,178,106,0.2),0_1px_2px_0_rgba(9,9,11,0.05),0_0_0_1px_rgba(0,0,0,0.05)]',
status === 'succeeded' &&
theme === Theme.dark &&
theme === 'dark' &&
'shadow-[inset_2px_2px_0_0_rgba(255,255,255,0.12),inset_0_1px_3px_0_rgba(0,0,0,0.4),inset_0_2px_24px_0_rgba(23,178,106,0.25),0_1px_2px_0_rgba(0,0,0,0.1),0_0_0_1px_rgba(24,24,27,0.95)]',
status === 'partial-succeeded' &&
'border-[rgba(23,178,106,0.8)] bg-workflow-display-success-bg bg-[url(~@/app/components/workflow/run/assets/bg-line-success.svg)] text-text-success',
status === 'partial-succeeded' &&
theme === Theme.light &&
theme === 'light' &&
'shadow-[inset_2px_2px_0_0_rgba(255,255,255,0.5),inset_0_1px_3px_0_rgba(0,0,0,0.12),inset_0_2px_24px_0_rgba(23,178,106,0.2),0_1px_2px_0_rgba(9,9,11,0.05),0_0_0_1px_rgba(0,0,0,0.05)]',
status === 'partial-succeeded' &&
theme === Theme.dark &&
theme === 'dark' &&
'shadow-[inset_2px_2px_0_0_rgba(255,255,255,0.12),inset_0_1px_3px_0_rgba(0,0,0,0.4),inset_0_2px_24px_0_rgba(23,178,106,0.25),0_1px_2px_0_rgba(0,0,0,0.1),0_0_0_1px_rgba(24,24,27,0.95)]',
status === 'failed' &&
'border-[rgba(240,68,56,0.8)] bg-workflow-display-error-bg bg-[url(~@/app/components/workflow/run/assets/bg-line-error.svg)] text-text-warning',
status === 'failed' &&
theme === Theme.light &&
theme === 'light' &&
'shadow-[inset_2px_2px_0_0_rgba(255,255,255,0.5),inset_0_1px_3px_0_rgba(0,0,0,0.12),inset_0_2px_24px_0_rgba(240,68,56,0.2),0_1px_2px_0_rgba(9,9,11,0.05),0_0_0_1px_rgba(0,0,0,0.05)]',
status === 'failed' &&
theme === Theme.dark &&
theme === 'dark' &&
'shadow-[inset_2px_2px_0_0_rgba(255,255,255,0.12),inset_0_1px_3px_0_rgba(0,0,0,0.4),inset_0_2px_24px_0_rgba(240,68,56,0.25),0_1px_2px_0_rgba(0,0,0,0.1),0_0_0_1px_rgba(24,24,27,0.95)]',
(status === 'stopped' || status === 'paused') &&
'border-[rgba(247,144,9,0.8)] bg-workflow-display-warning-bg bg-[url(~@/app/components/workflow/run/assets/bg-line-warning.svg)] text-text-destructive',
(status === 'stopped' || status === 'paused') &&
theme === Theme.light &&
theme === 'light' &&
'shadow-[inset_2px_2px_0_0_rgba(255,255,255,0.5),inset_0_1px_3px_0_rgba(0,0,0,0.12),inset_0_2px_24px_0_rgba(247,144,9,0.2),0_1px_2px_0_rgba(9,9,11,0.05),0_0_0_1px_rgba(0,0,0,0.05)]',
(status === 'stopped' || status === 'paused') &&
theme === Theme.dark &&
theme === 'dark' &&
'shadow-[inset_2px_2px_0_0_rgba(255,255,255,0.12),inset_0_1px_3px_0_rgba(0,0,0,0.4),inset_0_2px_24px_0_rgba(247,144,9,0.25),0_1px_2px_0_rgba(0,0,0,0.1),0_0_0_1px_rgba(24,24,27,0.95)]',
status === 'exception' &&
'border-[rgba(247,144,9,0.8)] bg-workflow-display-warning-bg bg-[url(~@/app/components/workflow/run/assets/bg-line-warning.svg)] text-text-destructive',
status === 'exception' &&
theme === Theme.light &&
theme === 'light' &&
'shadow-[inset_2px_2px_0_0_rgba(255,255,255,0.5),inset_0_1px_3px_0_rgba(0,0,0,0.12),inset_0_2px_24px_0_rgba(247,144,9,0.2),0_1px_2px_0_rgba(9,9,11,0.05),0_0_0_1px_rgba(0,0,0,0.05)]',
status === 'exception' &&
theme === Theme.dark &&
theme === 'dark' &&
'shadow-[inset_2px_2px_0_0_rgba(255,255,255,0.12),inset_0_1px_3px_0_rgba(0,0,0,0.4),inset_0_2px_24px_0_rgba(247,144,9,0.25),0_1px_2px_0_rgba(0,0,0,0.1),0_0_0_1px_rgba(24,24,27,0.95)]',
status === 'running' &&
'border-[rgba(11,165,236,0.8)] bg-workflow-display-normal-bg bg-[url(~@/app/components/workflow/run/assets/bg-line-running.svg)] text-util-colors-blue-light-blue-light-600',
status === 'running' &&
theme === Theme.light &&
theme === 'light' &&
'shadow-[inset_2px_2px_0_0_rgba(255,255,255,0.5),inset_0_1px_3px_0_rgba(0,0,0,0.12),inset_0_2px_24px_0_rgba(11,165,236,0.2),0_1px_2px_0_rgba(9,9,11,0.05),0_0_0_1px_rgba(0,0,0,0.05)]',
status === 'running' &&
theme === Theme.dark &&
theme === 'dark' &&
'shadow-[inset_2px_2px_0_0_rgba(255,255,255,0.12),inset_0_1px_3px_0_rgba(0,0,0,0.4),inset_0_2px_24px_0_rgba(11,165,236,0.25),0_1px_2px_0_rgba(0,0,0,0.1),0_0_0_1px_rgba(24,24,27,0.95)]',
)}
>
<div
className={cn(
'pointer-events-none absolute top-0 left-0 h-12.5 w-[65%] bg-no-repeat',
theme === Theme.light && 'bg-[url(~@/app/components/workflow/run/assets/highlight.svg)]',
theme === Theme.dark &&
'bg-[url(~@/app/components/workflow/run/assets/highlight-dark.svg)]',
'pointer-events-none absolute inset-s-0 top-0 h-12.5 w-[65%] bg-no-repeat',
theme === 'light' && 'bg-[url(~@/app/components/workflow/run/assets/highlight.svg)]',
theme === 'dark' && 'bg-[url(~@/app/components/workflow/run/assets/highlight-dark.svg)]',
)}
></div>
{children}
{isCopyable && (
<div className="pointer-events-none absolute top-1.5 right-1.5 z-10 opacity-0 transition-opacity group-focus-within/status:pointer-events-auto group-focus-within/status:opacity-100 group-hover/status:pointer-events-auto group-hover/status:opacity-100 [@media(hover:none)]:pointer-events-auto [@media(hover:none)]:opacity-100">
<div className="pointer-events-none absolute inset-e-1.5 top-1.5 z-10 opacity-0 transition-opacity group-focus-within/status:pointer-events-auto group-focus-within/status:opacity-100 group-hover/status:pointer-events-auto group-hover/status:opacity-100 [@media(hover:none)]:pointer-events-auto [@media(hover:none)]:opacity-100">
<CopyFeedback content={copyContent} />
</div>
)}
+2 -10
View File
@@ -1,10 +1,8 @@
import type { ThemeProviderProps } from 'next-themes'
import type { Metadata, Viewport } from '@/next'
import { TooltipProvider } from '@langgenius/dify-ui/tooltip'
import { HydrationBoundary } from '@tanstack/react-query'
import { dir } from 'i18next'
import { Provider as JotaiProvider } from 'jotai/react'
import { ThemeProvider } from 'next-themes'
import { NuqsAdapter } from 'nuqs/adapters/next/app'
import { AppToaster } from '@/app/notifications/app-toaster'
import { IS_PROD } from '@/config'
@@ -16,6 +14,7 @@ import {
dehydrateSystemFeatures,
getOptionalSystemFeatures,
} from '@/features/system-features/server'
import { ThemeProvider } from '@/features/theme/provider'
import { getLocaleOnServer } from '@/i18n/server'
import { headers } from '@/next/headers'
import { getApplicationTitle } from '@/utils/document-title'
@@ -58,13 +57,6 @@ export default async function RootLayout({ children }: { children: React.ReactNo
const datasetMap = getDatasetMap()
const requestHeaders = await headers()
const nonce = IS_PROD ? (requestHeaders.get('x-nonce') ?? undefined) : undefined
const themeProviderProps: Omit<ThemeProviderProps, 'children'> = {
attribute: 'data-theme',
defaultTheme: 'system',
enableSystem: true,
disableTransitionOnChange: true,
}
if (nonce !== undefined) themeProviderProps.nonce = nonce
const errorDocument = getAppAccessErrorDocument(requestHeaders)
const [locale] = errorDocument
@@ -79,7 +71,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
{!errorDocument && <CloudAnalytics />}
<div className="isolate">
<JotaiProvider>
<ThemeProvider {...themeProviderProps}>
<ThemeProvider nonce={nonce}>
<NuqsAdapter>
{errorDocument ? (
children
@@ -42,7 +42,7 @@ vi.mock('@/service/console', async (importOriginal) => {
}),
}
})
vi.mock('@/hooks/use-theme', () => ({ default: () => ({ theme: 'light' }) }))
vi.mock('@/features/theme/hooks', () => ({ useResolvedTheme: () => 'light' }))
vi.mock('@/context/i18n', () => ({
useGetLanguage: () => 'en_US',
useDocLink: () => () => 'https://docs.example.com',
@@ -9,9 +9,8 @@ import { CollectionType } from '@/app/components/tools/types'
import { selectToolsWithProvider } from '@/app/components/tools/utils/query-select'
import { API_PREFIX } from '@/config'
import { currentWorkspaceIdAtom } from '@/context/workspace-state'
import useTheme from '@/hooks/use-theme'
import { useResolvedTheme } from '@/features/theme/hooks'
import { consoleQuery } from '@/service/console'
import { Theme } from '@/types/app'
import { basePath } from '@/utils/var'
import {
createAgentToolProviderCatalog,
@@ -45,7 +44,7 @@ function getProviderByTool(providerById: Map<string, ToolWithProvider>, tool: Pr
export function useAgentPromptToolIconResolver(
providerTypes?: ReadonlySet<ProviderTool['providerType']>,
) {
const { theme } = useTheme()
const theme = useResolvedTheme()
const currentWorkspaceId = useAtomValue(currentWorkspaceIdAtom)
const { data: builtInTools } = useQuery(
consoleQuery.workspaces.current.tools.builtin.get.queryOptions({
@@ -87,8 +86,7 @@ export function useAgentPromptToolIconResolver(
return useMemo(
() => ({
getProviderIcon: (provider: ToolWithProvider) => {
const rawIcon =
theme === Theme.dark && provider.icon_dark ? provider.icon_dark : provider.icon
const rawIcon = theme === 'dark' && provider.icon_dark ? provider.icon_dark : provider.icon
return normalizeProviderIcon(rawIcon, currentWorkspaceId)
},
getProviderIcons: (provider: ToolWithProvider) => ({
@@ -98,7 +96,7 @@ export function useAgentPromptToolIconResolver(
getConfiguredToolIcon: (tool: ProviderTool) => {
const provider = getProviderByTool(providerById, tool)
const rawIcon =
theme === Theme.dark && (tool.iconDark ?? provider?.icon_dark)
theme === 'dark' && (tool.iconDark ?? provider?.icon_dark)
? (tool.iconDark ?? provider?.icon_dark)
: getAgentProviderToolIcon(tool, provider)
@@ -22,9 +22,8 @@ import BlockIcon from '@/app/components/workflow/block-icon'
import { InstallPluginButton } from '@/app/components/workflow/nodes/_base/components/install-plugin-button'
import { BlockEnum } from '@/app/components/workflow/types'
import { getMarketplacePluginIconUrl } from '@/features/marketplace/api'
import useTheme from '@/hooks/use-theme'
import { useResolvedTheme } from '@/features/theme/hooks'
import { usePluginManifestInfo } from '@/service/use-plugins'
import { Theme } from '@/types/app'
import { useAgentOrchestrateReadOnly } from '../../read-only-context'
function ProviderIcon({
@@ -146,7 +145,7 @@ function UninstalledPluginStatus({
return (
<span className="flex h-6 shrink-0 items-center rounded-md border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-2 system-xs-medium text-components-button-secondary-text shadow-xs backdrop-blur-[5px]">
{t(($) => $['detailPanel.toolSelector.uninstalledTitle'], { ns: 'plugin' })}
<StatusDot className="ml-2" status="warning" />
<StatusDot className="ms-2" status="warning" />
</span>
)
}
@@ -225,9 +224,9 @@ const ProviderToolActionItem = memo(
}, [action.id, onRemoveAction])
return (
<div className="group relative flex min-h-7 items-center gap-1 rounded-md py-px pr-0 pl-1 hover:bg-state-base-hover">
<div className="absolute top-0 bottom-0 left-[13.5px] w-px bg-divider-regular" />
<div className="flex min-w-0 flex-1 items-center py-1 pl-7">
<div className="group relative flex min-h-7 items-center gap-1 rounded-md py-px ps-1 pe-0 hover:bg-state-base-hover">
<div className="absolute inset-s-[13.5px] top-0 bottom-0 w-px bg-divider-regular" />
<div className="flex min-w-0 flex-1 items-center py-1 ps-7">
<span className="min-w-0 flex-1 truncate system-sm-regular text-text-secondary">
{action.name}
</span>
@@ -299,14 +298,14 @@ export const AgentProviderToolItem = memo(
}) => {
const { t } = useTranslation(['agentV2'])
const readOnly = useAgentOrchestrateReadOnly()
const { theme } = useTheme()
const theme = useResolvedTheme()
const shouldFetchPluginManifest =
isInstalled === false && !!tool.pluginId && !tool.pluginUniqueIdentifier
const { data: pluginManifestData } = usePluginManifestInfo(
shouldFetchPluginManifest ? tool.pluginId! : '',
)
const pluginManifest = pluginManifestData?.data.plugin
const configuredIcon = theme === Theme.dark && tool.iconDark ? tool.iconDark : tool.icon
const configuredIcon = theme === 'dark' && tool.iconDark ? tool.iconDark : tool.icon
const icon =
configuredIcon ??
(pluginManifest && tool.pluginId ? getMarketplacePluginIconUrl(tool.pluginId) : undefined)
@@ -322,8 +321,8 @@ export const AgentProviderToolItem = memo(
onOpenChange={onOpenChange}
className="overflow-hidden rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg p-1 shadow-xs shadow-shadow-shadow-3"
>
<div className="flex min-h-7 items-center gap-1 rounded-lg py-0.5 pr-0.5 pl-1">
<CollapsibleTrigger className="group/collapsible flex min-h-0 w-full min-w-0 flex-1 touch-manipulation items-center justify-start gap-2 rounded-md pr-1 text-left system-sm-medium text-text-secondary select-none">
<div className="flex min-h-7 items-center gap-1 rounded-lg py-0.5 ps-1 pe-0.5">
<CollapsibleTrigger className="group/collapsible flex min-h-0 w-full min-w-0 flex-1 touch-manipulation items-center justify-start gap-2 rounded-md pe-1 text-start system-sm-medium text-text-secondary select-none">
<ProviderIcon icon={icon} iconClassName={tool.iconClassName} />
<span className="flex min-w-0 items-center">
<span className="min-w-0 truncate system-sm-medium text-text-primary">
@@ -133,7 +133,7 @@ it('keeps an embedded error language and its portaled menus local to the dialog'
await user.click(screen.getByRole('button', { name: 'العربية (تونس)' }))
expect(screen.getByRole('menu')).toHaveAttribute('dir', 'rtl')
await user.keyboard('{Escape}')
await user.click(screen.getByRole('button', { name: arCommon['theme.theme'] }))
await user.click(screen.getByRole('button', { name: arCommon['theme.label'] }))
expect(screen.getByRole('menu')).toHaveAttribute('dir', 'rtl')
expect(document.documentElement.lang).toBe('zh-Hans')
expect(document.documentElement.dir).toBe('ltr')
@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import { ThemeProvider } from 'next-themes'
import { ThemeProvider } from '@/features/theme/provider'
import AppNotAccessible from './page'
const meta = {
@@ -11,7 +11,7 @@ const meta = {
},
decorators: [
(Story) => (
<ThemeProvider attribute="data-theme" defaultTheme="light">
<ThemeProvider defaultPreference="light">
<Story />
</ThemeProvider>
),
+3 -3
View File
@@ -9,8 +9,8 @@ import { Suspense, useEffect, useState } from 'react'
import { I18nextProvider, Trans, useTranslation } from 'react-i18next'
import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder'
import { DifyLogo } from '@/app/components/base/logo/dify-logo'
import ThemeSelector from '@/app/components/base/theme-selector'
import { I18nDirectionProvider } from '@/app/components/provider/i18n-direction'
import ThemeSelector from '@/features/theme/theme-selector'
import { loadI18nResource } from '@/i18n/load-resource'
import { getInitOptions } from '@/i18n/settings'
import { usePathname, useSearchParams } from '@/next/navigation'
@@ -60,7 +60,7 @@ function PageContent({ clientIp, embedded }: AppNotAccessibleProps) {
)}
>
<div className="flex min-h-full min-w-0 flex-col rounded-lg border border-effects-highlight bg-background-default-subtle">
<header className="flex shrink-0 items-center justify-between gap-2 pt-4 pr-3 pb-3 pl-6">
<header className="flex shrink-0 items-center justify-between gap-2 ps-6 pe-3 pt-4 pb-3">
<DifyLogo alt="Dify" size="large" className="w-15.75 shrink-0" />
<div className="flex min-w-0 items-center gap-1">
<LocaleMenu
@@ -84,7 +84,7 @@ function PageContent({ clientIp, embedded }: AppNotAccessibleProps) {
width={18}
height={29.1429}
alt=""
className="absolute top-[21.43%] left-[12.5%] w-3/4 max-w-none"
className="absolute inset-s-[12.5%] top-[21.43%] w-3/4 max-w-none"
/>
</span>
<span className="px-2 system-sm-medium whitespace-nowrap">
+10
View File
@@ -0,0 +1,10 @@
# Theme
This feature owns the color theme contract. It is the only Web module that imports `next-themes`; lint rejects that import everywhere else.
- `theme.ts` owns the `ThemePreference` (`light`, `dark`, `system`) and `ResolvedTheme` (`light`, `dark`) types, and the option list (value, icon, `common` label key) that every theme control renders in the same order.
- `provider.tsx` owns the provider configuration: the `data-theme` attribute, `system` as the default preference, and the CSP nonce.
- `hooks.ts` separates the two questions a component can ask. `useThemePreference` returns what the user chose and is for theme controls. `useResolvedTheme` returns the light or dark value in effect and is for icons, editors, charts, and Marketplace links. A Marketplace link never receives `system`.
- `theme-selector.tsx` is the standalone icon menu for pages without the account menu.
Theme copy lives in the `common` namespace (`theme.label`, `theme.light`, `theme.dark`, `theme.system`) because console, share, auth, and Goto Anything surfaces all load it. Surface-specific descriptions stay with their surface.
@@ -18,18 +18,18 @@ describe('ThemeSelector', () => {
const container = document.createElement('div')
container.innerHTML = renderToString(<ThemeSelector />)
expect(container.querySelector('button[aria-label="common.theme.theme"]')).not.toBeNull()
expect(container.querySelector('button[aria-label="common.theme.label"]')).not.toBeNull()
})
it.each([
['common.theme.light', 'light'],
['common.theme.dark', 'dark'],
['common.theme.auto', 'system'],
['common.theme.system', 'system'],
])('selects %s', async (name, theme) => {
const user = userEvent.setup()
render(<ThemeSelector />)
await user.click(screen.getByRole('button', { name: 'common.theme.theme' }))
await user.click(screen.getByRole('button', { name: 'common.theme.label' }))
await user.click(await screen.findByRole('menuitemradio', { name }))
expect(setTheme).toHaveBeenCalledWith(theme)
+29
View File
@@ -0,0 +1,29 @@
'use client'
import type { ResolvedTheme, ThemePreference } from './theme'
import { useTheme } from 'next-themes'
import { useCallback } from 'react'
import { isThemePreference } from './theme'
type ThemePreferenceState = {
preference: ThemePreference
setPreference: (preference: ThemePreference) => void
}
export function useThemePreference(): ThemePreferenceState {
const { theme, setTheme } = useTheme()
const setPreference = useCallback(
(preference: ThemePreference) => setTheme(preference),
[setTheme],
)
return {
preference: isThemePreference(theme) ? theme : 'system',
setPreference,
}
}
export function useResolvedTheme(): ResolvedTheme {
const { resolvedTheme } = useTheme()
return resolvedTheme === 'dark' ? 'dark' : 'light'
}
+27
View File
@@ -0,0 +1,27 @@
import type { ReactNode } from 'react'
import type { ThemePreference } from './theme'
import { ThemeProvider as NextThemesProvider } from 'next-themes'
type ThemeProviderProps = {
children: ReactNode
nonce?: string | undefined
defaultPreference?: ThemePreference
}
export function ThemeProvider({
children,
nonce,
defaultPreference = 'system',
}: ThemeProviderProps) {
return (
<NextThemesProvider
attribute="data-theme"
defaultTheme={defaultPreference}
enableSystem
disableTransitionOnChange
{...(nonce === undefined ? {} : { nonce })}
>
{children}
</NextThemesProvider>
)
}
+70
View File
@@ -0,0 +1,70 @@
'use client'
import type { ThemePreference } from './theme'
import { useDirection } from '@langgenius/dify-ui/direction-provider'
import { IconButton } from '@langgenius/dify-ui/icon-button'
import {
Menu,
MenuContent,
MenuRadioGroup,
MenuRadioItem,
MenuRadioItemIndicator,
MenuTrigger,
} from '@langgenius/dify-ui/menu'
import { useSyncExternalStore } from 'react'
import { useTranslation } from 'react-i18next'
import { useThemePreference } from './hooks'
import { themeOptions } from './theme'
const subscribeToNothing = () => () => {}
const useIsHydrated = () =>
useSyncExternalStore(
subscribeToNothing,
() => true,
() => false,
)
export default function ThemeSelector() {
const direction = useDirection()
const { t } = useTranslation(['common'])
const { preference, setPreference } = useThemePreference()
const isHydrated = useIsHydrated()
const currentOption = themeOptions.find((option) => option.value === preference)
return (
<Menu>
<MenuTrigger
render={
<IconButton
aria-label={t(($) => $['theme.label'], { ns: 'common' })}
size="lg"
className="data-popup-open:bg-state-base-hover"
>
{isHydrated && currentOption ? (
<span
aria-hidden
className={`${currentOption.iconClassName} size-4 text-text-tertiary`}
/>
) : (
<span aria-hidden className="size-4" />
)}
</IconButton>
}
/>
<MenuContent dir={direction} sideOffset={6} className="w-36">
<MenuRadioGroup<ThemePreference> value={preference} onValueChange={setPreference}>
{themeOptions.map((option) => (
<MenuRadioItem<ThemePreference> key={option.value} value={option.value} closeOnClick>
<span className={`${option.iconClassName} size-4 text-text-tertiary`} />
<span className="grow px-1 system-md-regular">
{t(($) => $[option.labelKey], { ns: 'common' })}
</span>
<MenuRadioItemIndicator data-testid={`${option.value}-icon`} />
</MenuRadioItem>
))}
</MenuRadioGroup>
</MenuContent>
</Menu>
)
}
+15
View File
@@ -0,0 +1,15 @@
export type ThemePreference = 'light' | 'dark' | 'system'
export type ResolvedTheme = Exclude<ThemePreference, 'system'>
export const themeOptions = [
{ value: 'light', iconClassName: 'i-ri-sun-line', labelKey: 'theme.light' },
{ value: 'dark', iconClassName: 'i-ri-moon-line', labelKey: 'theme.dark' },
{ value: 'system', iconClassName: 'i-ri-computer-line', labelKey: 'theme.system' },
] as const satisfies readonly {
value: ThemePreference
iconClassName: string
labelKey: `theme.${ThemePreference}`
}[]
export const isThemePreference = (value: unknown): value is ThemePreference =>
themeOptions.some((option) => option.value === value)
-13
View File
@@ -1,13 +0,0 @@
import { useTheme as useBaseTheme } from 'next-themes'
import { Theme } from '@/types/app'
const useTheme = () => {
const { theme, resolvedTheme, ...rest } = useBaseTheme()
return {
// only returns 'light' or 'dark' theme
theme: theme === Theme.system ? (resolvedTheme as Theme) : (theme as Theme),
...rest,
}
}
export default useTheme
@@ -1,10 +1,5 @@
{
"account.account": "الحساب",
"account.appearanceDark": "داكن",
"account.appearanceFollowSystem": "Follow system",
"account.appearanceLabel": "المظهر",
"account.appearanceLight": "فاتح",
"account.appearanceSystem": "النظام",
"account.changeEmail.authTip": "بمجرد تغيير بريدك الإلكتروني، لن تتمكن حسابات Google أو GitHub المرتبطة ببريدك الإلكتروني القديم من تسجيل الدخول إلى هذا الحساب.",
"account.changeEmail.changeTo": "تغيير إلى {{email}}",
"account.changeEmail.codeLabel": "رمز التحقق",
-3
View File
@@ -71,11 +71,8 @@
"gotoAnything.actions.slashDesc": "تنفيذ الأوامر (اكتب / لرؤية جميع الأوامر المتاحة)",
"gotoAnything.actions.slashTitle": "الأوامر",
"gotoAnything.actions.themeCategoryDesc": "تغيير السمة",
"gotoAnything.actions.themeDark": "السمة الداكنة",
"gotoAnything.actions.themeDarkDesc": "استخدم المظهر الداكن",
"gotoAnything.actions.themeLight": "السمة الفاتحة",
"gotoAnything.actions.themeLightDesc": "استخدم المظهر الفاتح",
"gotoAnything.actions.themeSystem": "سمة النظام",
"gotoAnything.actions.themeSystemDesc": "اتبع مظهر نظام التشغيل",
"gotoAnything.contextTokenHint": "عندما يكون الحقل فارغًا، يزيل Backspace عامل التصفية أو الصفحة الحالية.",
"gotoAnything.emptyState.noAppsFound": "لم يتم العثور على تطبيقات",
+2 -2
View File
@@ -326,10 +326,10 @@
"tag.placeholder": "العلامات",
"tag.selectorPlaceholder": "اكتب للبحث أو الإنشاء",
"tag.tags": "العلامات",
"theme.auto": "النظام",
"theme.dark": "داكن",
"theme.label": "المظهر",
"theme.light": "فاتح",
"theme.theme": "السمة",
"theme.system": "النظام",
"triggerPage.description": "حوّل أحداث الجهات الخارجية إلى مدخلات يمكن لتطبيقاتك التعرف عليها والتصرف بناءً عليها — لأتمتة workflows بدون تشغيل يدوي.",
"unit.char": "أحرف",
"userProfile.about": "حول",
@@ -1,10 +1,5 @@
{
"account.account": "Hesab",
"account.appearanceDark": "Tünd",
"account.appearanceFollowSystem": "Sistemə uyğunlaşdır",
"account.appearanceLabel": "Görünüş",
"account.appearanceLight": "Açıq",
"account.appearanceSystem": "Sistem",
"account.changeEmail.authTip": "E-poçt ünvanınız dəyişdirildikdən sonra köhnə e-poçt ünvanınıza bağlı Google və ya GitHub hesabları ilə bu hesaba daxil olmaq mümkün olmayacaq.",
"account.changeEmail.changeTo": "{{email}} ünvanı ilə əvəz et",
"account.changeEmail.codeLabel": "Doğrulama kodu",
-3
View File
@@ -71,11 +71,8 @@
"gotoAnything.actions.slashDesc": "Əmrləri icra et (bütün mövcud əmrləri görmək üçün / yaz)",
"gotoAnything.actions.slashTitle": "Əmrlər",
"gotoAnything.actions.themeCategoryDesc": "Mövzunu dəyişin",
"gotoAnything.actions.themeDark": "Tünd temaya keç",
"gotoAnything.actions.themeDarkDesc": "Tünd görünüşdən istifadə et",
"gotoAnything.actions.themeLight": "Açıq temaya keç",
"gotoAnything.actions.themeLightDesc": "Açıq görünüşdən istifadə et",
"gotoAnything.actions.themeSystem": "Sistem temasına keç",
"gotoAnything.actions.themeSystemDesc": "Əməliyyat sisteminizin görünüşünə uyğunlaşdır",
"gotoAnything.contextTokenHint": "Sahə boş olduqda Backspace cari filtri və ya səhifəni silir.",
"gotoAnything.emptyState.noAppsFound": "Tətbiq tapılmadı",
+2 -2
View File
@@ -326,10 +326,10 @@
"tag.placeholder": "Etiketlər",
"tag.selectorPlaceholder": "Axtarmaq və ya yaratmaq üçün yazın",
"tag.tags": "Etiketlər",
"theme.auto": "Sistem",
"theme.dark": "Tünd",
"theme.label": "Görünüş",
"theme.light": "Açıq",
"theme.theme": "Tema",
"theme.system": "Sistem",
"triggerPage.description": "Üçüncü tərəf hadisələrini tətbiqlərinizin tanıyıb emal edə biləcəyi girişlərə çevirin, iş axınlarını əl ilə işə salmadan avtomatlaşdırın.",
"unit.char": "simvol",
"userProfile.about": "Haqqında",
@@ -1,10 +1,5 @@
{
"account.account": "Konto",
"account.appearanceDark": "Dunkel",
"account.appearanceFollowSystem": "Follow system",
"account.appearanceLabel": "Darstellung",
"account.appearanceLight": "Hell",
"account.appearanceSystem": "System",
"account.changeEmail.authTip": "Sobald Ihre E-Mail geändert wurde, können Google- oder GitHub-Konten, die mit Ihrer alten E-Mail verknüpft sind, nicht mehr auf dieses Konto zugreifen.",
"account.changeEmail.changeTo": "Ändern zu {{email}}",
"account.changeEmail.codeLabel": "Bestätigungscode",
-3
View File
@@ -71,11 +71,8 @@
"gotoAnything.actions.slashDesc": "Führen Sie Befehle wie /theme, /lang aus",
"gotoAnything.actions.slashTitle": "Befehle",
"gotoAnything.actions.themeCategoryDesc": "Design wechseln",
"gotoAnything.actions.themeDark": "Dunkles Thema",
"gotoAnything.actions.themeDarkDesc": "Verwenden Sie das dunkle Erscheinungsbild",
"gotoAnything.actions.themeLight": "Helles Design",
"gotoAnything.actions.themeLightDesc": "Verwenden Sie das helle Erscheinungsbild",
"gotoAnything.actions.themeSystem": "Systemthema",
"gotoAnything.actions.themeSystemDesc": "Folgen Sie dem Aussehen Ihres Betriebssystems",
"gotoAnything.contextTokenHint": "Bei leerem Feld entfernt Backspace den aktuellen Filter oder die Seite.",
"gotoAnything.emptyState.noAppsFound": "Keine Apps gefunden",
+4 -4
View File
@@ -326,10 +326,10 @@
"tag.placeholder": "Tags",
"tag.selectorPlaceholder": "Typ zum Suchen oder Erstellen",
"tag.tags": "Tags",
"theme.auto": "System",
"theme.dark": "dunkel",
"theme.light": "Licht",
"theme.theme": "Thema",
"theme.dark": "Dunkel",
"theme.label": "Darstellung",
"theme.light": "Hell",
"theme.system": "System",
"triggerPage.description": "Wandle Drittanbieter-Ereignisse in Eingaben um, die deine Apps erkennen und verarbeiten können — automatisiere Workflows ohne manuelle Auslöser.",
"unit.char": "Zeichen",
"userProfile.about": "Über",
@@ -1,10 +1,5 @@
{
"account.account": "Account",
"account.appearanceDark": "Dark",
"account.appearanceFollowSystem": "Follow system",
"account.appearanceLabel": "Appearance",
"account.appearanceLight": "Light",
"account.appearanceSystem": "System",
"account.changeEmail.authTip": "Once your email is changed, Google or GitHub accounts linked to your old email will no longer be able to log in to this account.",
"account.changeEmail.changeTo": "Change to {{email}}",
"account.changeEmail.codeLabel": "Verification code",
+1 -4
View File
@@ -70,12 +70,9 @@
"gotoAnything.actions.searchApplications": "Search Applications",
"gotoAnything.actions.slashDesc": "Execute commands (type / to see all available commands)",
"gotoAnything.actions.slashTitle": "Commands",
"gotoAnything.actions.themeCategoryDesc": "Switch theme",
"gotoAnything.actions.themeDark": "Dark Theme",
"gotoAnything.actions.themeCategoryDesc": "Change appearance",
"gotoAnything.actions.themeDarkDesc": "Use dark appearance",
"gotoAnything.actions.themeLight": "Light Theme",
"gotoAnything.actions.themeLightDesc": "Use light appearance",
"gotoAnything.actions.themeSystem": "System Theme",
"gotoAnything.actions.themeSystemDesc": "Follow your OS appearance",
"gotoAnything.contextTokenHint": "In an empty field, Backspace removes the current filter or page.",
"gotoAnything.emptyState.noAppsFound": "No apps found",
Loaded 100 of 166 files, more files were not shown because too many files have changed in this diff. Show more