refactor(web): standardize hotkey ownership and typing (#38960)

This commit is contained in:
yyh 2026-07-15 10:22:40 +08:00 committed by GitHub
parent f86bfb2a31
commit e134c947c1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
85 changed files with 686 additions and 541 deletions

View File

@ -18,6 +18,7 @@ Use this as the component decision guide for Dify web. Existing code is referenc
| Should URL state enter Jotai? | Let Next.js route params and `nuqs` own URL state and updates. | Query atoms or shared derived atoms need a read-only bridge hydrated at the route/surface boundary. | | Should URL state enter Jotai? | Let Next.js route params and `nuqs` own URL state and updates. | Query atoms or shared derived atoms need a read-only bridge hydrated at the route/surface boundary. |
| Should this query/mutation become an atom? | Use TanStack Query hooks at the lowest owner. | It reads atom state, feeds derived atoms, or participates in shared Jotai workflow orchestration. | | Should this query/mutation become an atom? | Use TanStack Query hooks at the lowest owner. | It reads atom state, feeds derived atoms, or participates in shared Jotai workflow orchestration. |
| Should this be a helper/wrapper? | Prefer direct readable code at the use site. | The name captures a stable domain rule or the wrapper owns real behavior, validation, state, error handling, or semantics. | | Should this be a helper/wrapper? | Prefer direct readable code at the use site. | The name captures a stable domain rule or the wrapper owns real behavior, validation, state, error handling, or semantics. |
| Where should a hotkey live? | Keep a single-owner hotkey constant in its component. | Multiple production files share one command, or the feature owns a real command registry with shared metadata and behavior. |
| Is an Effect needed? | No. Derive during render or handle the user action in the event handler. | It synchronizes with an external system such as browser APIs, subscriptions, timers, analytics, or imperative DOM/non-React widgets. | | Is an Effect needed? | No. Derive during render or handle the user action in the event handler. | It synchronizes with an external system such as browser APIs, subscriptions, timers, analytics, or imperative DOM/non-React widgets. |
## Core Defaults ## Core Defaults
@ -77,6 +78,19 @@ Use this as the component decision guide for Dify web. Existing code is referenc
- Name values by their domain role and backend API contract, especially persistent IDs and route params. Normalize framework or route params at the boundary. - Name values by their domain role and backend API contract, especially persistent IDs and route params. Normalize framework or route params at the boundary.
- Put fallback and invariant checks in the lowest component that already handles that state. Do not extract helpers whose only behavior is hiding missing display data. - Put fallback and invariant checks in the lowest component that already handles that state. Do not extract helpers whose only behavior is hiding missing display data.
## Keyboard Shortcuts
- Distinguish application commands from local keyboard semantics before choosing an API. Use `@tanstack/react-hotkeys` for application commands. Keep menu navigation, dialog Escape handling owned by a primitive, editor commands, and other widget-scoped ARIA interactions in their local component or primitive.
- Use `useHotkey` or `useHotkeys` for registered commands. For a command intentionally owned by an existing `onKeyDown`, use `matchesKeyboardEvent` instead of hand-written `metaKey` / `ctrlKey` parsing or a second global listener.
- Define a reusable string command with `satisfies Hotkey` and an object-form command with `satisfies RawHotkey`. Reserve `RegisterableHotkey` for API boundaries that intentionally accept either form. A one-time inline literal passed directly to TanStack is already type-checked; extract it when registration, display, metadata, or another production consumer needs the same source.
- Keep registered hotkeys distinct from held keys and display-only accelerators. Use `IndividualKey` with `useKeyHold` for held-key interactions, and use an explicitly named `displayKey` for local widget accelerators that are not registered `Hotkey` values.
- Keep registration and keycap/menu display derived from one canonical command. Do not maintain a hotkey string beside a separate `['Mod', ...]` display array.
- Keep a single-owner command constant in its owning component. Create a feature-local `hotkeys.ts` only when multiple production files consume the same command. Keep a dedicated definitions/registry module when a feature owns a real command system with IDs, metadata, alternate bindings, and centralized registration. Tests do not count as another production owner, and file-name uniformity alone is not a reason to extract.
- Make scope and availability explicit. Use `enabled` for business or surface lifecycle, `ignoreInputs` for whether input-like elements may trigger the command, and `target` when the command belongs to a concrete DOM subtree. Global application commands may use the document target; inline editors and composed overlays should prefer the actual editor or Base UI Popup ref when that owner is exposed.
- Put a scoped ref on the real behavior owner. Do not add a wrapper DOM element solely to obtain a hotkey target. If a shared overlay convenience component hides the Popup ref, either rely on its modal lifecycle/focus boundary when that is sufficient or design the primitive API separately; do not create a fake owner at the call site.
- Set `preventDefault` and `stopPropagation` according to the existing product behavior and browser interaction. Do not silently accept TanStack defaults when migrating from another listener if that changes typing, submission, or propagation semantics.
- Test observable command behavior, disabled/input/target scope, and the shared registration/display contract at the owning feature boundary. Prefer partial mocks that retain TanStack formatting and matching behavior when a registration boundary must be isolated.
## Generated API And Nullable Data ## Generated API And Nullable Data
- Treat generated contracts as authoritative at API, query, mutation, cache, and service boundaries. For enterprise APIs, use `packages/contracts/generated/enterprise/*`. - Treat generated contracts as authoritative at API, query, mutation, cache, and service boundaries. For enterprise APIs, use `packages/contracts/generated/enterprise/*`.

View File

@ -757,34 +757,8 @@
"count": 1 "count": 1
} }
}, },
"web/app/components/app/create-app-modal/index.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
},
"jsx_a11y/no-static-element-interactions": {
"count": 1
},
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/app/create-from-dsl-modal/index.tsx": { "web/app/components/app/create-from-dsl-modal/index.tsx": {
"erasable-syntax-only/enums": {
"count": 1
},
"eslint-react/set-state-in-effect": { "eslint-react/set-state-in-effect": {
"count": 2
},
"jsx_a11y/click-events-have-key-events": {
"count": 2
},
"jsx_a11y/no-static-element-interactions": {
"count": 2
},
"no-restricted-imports": {
"count": 1
},
"react/only-export-components": {
"count": 1 "count": 1
} }
}, },

View File

@ -1,30 +1,7 @@
import type { ReactNode } from 'react'
import { Dialog, DialogPopup, DialogPortal, DialogTitle } from '@langgenius/dify-ui/dialog' import { Dialog, DialogPopup, DialogPortal, DialogTitle } from '@langgenius/dify-ui/dialog'
import { fireEvent, render, screen } from '@testing-library/react' import { fireEvent, render, screen } from '@testing-library/react'
import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle' import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle'
import AppDetailTop from '../app-detail-top' import { AppDetailTop } from '../app-detail-top'
vi.mock('../toggle-button', () => ({
default: ({
expand,
handleToggle,
icon,
}: {
expand: boolean
handleToggle: () => void
icon?: ReactNode
}) => (
<button
type="button"
data-testid="toggle-button"
data-expand={expand}
data-has-icon={Boolean(icon)}
onClick={handleToggle}
>
Toggle
</button>
),
}))
function TestGotoAnythingDialog() { function TestGotoAnythingDialog() {
return ( return (
@ -75,10 +52,8 @@ describe('AppDetailTop', () => {
const onToggle = vi.fn() const onToggle = vi.fn()
render(<AppDetailTop onToggle={onToggle} />) render(<AppDetailTop onToggle={onToggle} />)
fireEvent.click(screen.getByTestId('toggle-button')) fireEvent.click(screen.getByRole('button', { name: 'layout.sidebar.collapseSidebar' }))
expect(screen.getByTestId('toggle-button')).toHaveAttribute('data-expand', 'true')
expect(screen.getByTestId('toggle-button')).toHaveAttribute('data-has-icon', 'true')
expect(onToggle).toHaveBeenCalledTimes(1) expect(onToggle).toHaveBeenCalledTimes(1)
}) })
}) })

View File

@ -1,30 +1,7 @@
import type { ReactNode } from 'react'
import { Dialog, DialogPopup, DialogPortal, DialogTitle } from '@langgenius/dify-ui/dialog' import { Dialog, DialogPopup, DialogPortal, DialogTitle } from '@langgenius/dify-ui/dialog'
import { fireEvent, render, screen } from '@testing-library/react' import { fireEvent, render, screen } from '@testing-library/react'
import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle' import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle'
import DatasetDetailTop from '../dataset-detail-top' import { DatasetDetailTop } from '../dataset-detail-top'
vi.mock('../toggle-button', () => ({
default: ({
expand,
handleToggle,
icon,
}: {
expand: boolean
handleToggle: () => void
icon?: ReactNode
}) => (
<button
type="button"
data-testid="toggle-button"
data-expand={expand}
data-has-icon={Boolean(icon)}
onClick={handleToggle}
>
Toggle
</button>
),
}))
function TestGotoAnythingDialog() { function TestGotoAnythingDialog() {
return ( return (
@ -73,10 +50,8 @@ describe('DatasetDetailTop', () => {
const onToggle = vi.fn() const onToggle = vi.fn()
render(<DatasetDetailTop expand={false} onToggle={onToggle} />) render(<DatasetDetailTop expand={false} onToggle={onToggle} />)
fireEvent.click(screen.getByTestId('toggle-button')) fireEvent.click(screen.getByRole('button', { name: 'layout.sidebar.expandSidebar' }))
expect(screen.getByTestId('toggle-button')).toHaveAttribute('data-expand', 'false')
expect(screen.getByTestId('toggle-button')).toHaveAttribute('data-has-icon', 'true')
expect(onToggle).toHaveBeenCalledTimes(1) expect(onToggle).toHaveBeenCalledTimes(1)
}) })
}) })

View File

@ -1,40 +0,0 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import ToggleButton from '../toggle-button'
describe('ToggleButton', () => {
it('should render collapse arrow when expanded', () => {
render(<ToggleButton expand handleToggle={vi.fn()} />)
const button = screen.getByRole('button')
expect(button).toBeInTheDocument()
})
it('should render expand arrow when collapsed', () => {
render(<ToggleButton expand={false} handleToggle={vi.fn()} />)
const button = screen.getByRole('button')
expect(button).toBeInTheDocument()
})
it('should call handleToggle when clicked', async () => {
const user = userEvent.setup()
const handleToggle = vi.fn()
render(<ToggleButton expand handleToggle={handleToggle} />)
await user.click(screen.getByRole('button'))
expect(handleToggle).toHaveBeenCalledTimes(1)
})
it('should apply custom className', () => {
render(<ToggleButton expand handleToggle={vi.fn()} className="custom-class" />)
const button = screen.getByRole('button')
expect(button).toHaveClass('custom-class')
})
it('should have rounded-full style', () => {
render(<ToggleButton expand handleToggle={vi.fn()} />)
const button = screen.getByRole('button')
expect(button).toHaveClass('rounded-full')
})
})

View File

@ -2,7 +2,7 @@
import { DetailSidebarFrame } from '@/app/components/detail-sidebar' import { DetailSidebarFrame } from '@/app/components/detail-sidebar'
import AppDetailSection from './app-detail-section' import AppDetailSection from './app-detail-section'
import AppDetailTop from './app-detail-top' import { AppDetailTop } from './app-detail-top'
export function AppDetailSidebar() { export function AppDetailSidebar() {
return ( return (

View File

@ -6,27 +6,26 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/too
import { formatForDisplay } from '@tanstack/react-hotkeys' import { formatForDisplay } from '@tanstack/react-hotkeys'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import SidebarLeftArrowIcon from '@/app/components/base/icons/src/vender/SidebarLeftArrowIcon' import SidebarLeftArrowIcon from '@/app/components/base/icons/src/vender/SidebarLeftArrowIcon'
import { DetailSidebarToggleButton } from '@/app/components/detail-sidebar/toggle-button'
import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle' import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle'
import { GOTO_ANYTHING_HOTKEY } from '@/app/components/goto-anything/hotkeys'
import Link from '@/next/link' import Link from '@/next/link'
import ToggleButton from './toggle-button'
type AppDetailTopProps = { type AppDetailTopProps = {
expand?: boolean expand?: boolean
onToggle?: () => void onToggle?: () => void
} }
const SEARCH_SHORTCUT = ['Mod', 'K'] export function AppDetailTop({ expand = true, onToggle }: AppDetailTopProps) {
const AppDetailTop = ({ expand = true, onToggle }: AppDetailTopProps) => {
const { t } = useTranslation() const { t } = useTranslation()
if (!expand) { if (!expand) {
return ( return (
<div className="flex w-full items-center justify-center px-3 pt-2 pb-1"> <div className="flex w-full items-center justify-center px-3 pt-2 pb-1">
{onToggle && ( {onToggle && (
<ToggleButton <DetailSidebarToggleButton
expand={expand} expand={expand}
handleToggle={onToggle} onToggle={onToggle}
icon={<SidebarLeftArrowIcon aria-hidden className="size-4" />} icon={<SidebarLeftArrowIcon aria-hidden className="size-4" />}
className="size-8 rounded-[10px] border-0 bg-transparent px-0 text-text-tertiary shadow-none hover:border-0 hover:bg-state-base-hover hover:text-text-secondary" className="size-8 rounded-[10px] border-0 bg-transparent px-0 text-text-tertiary shadow-none hover:border-0 hover:bg-state-base-hover hover:text-text-secondary"
/> />
@ -82,7 +81,7 @@ const AppDetailTop = ({ expand = true, onToggle }: AppDetailTopProps) => {
> >
<span className="px-0.5">{t(($) => $['gotoAnything.quickAction'], { ns: 'app' })}</span> <span className="px-0.5">{t(($) => $['gotoAnything.quickAction'], { ns: 'app' })}</span>
<KbdGroup> <KbdGroup>
{SEARCH_SHORTCUT.map((key) => ( {GOTO_ANYTHING_HOTKEY.split('+').map((key) => (
<Kbd key={key}>{formatForDisplay(key)}</Kbd> <Kbd key={key}>{formatForDisplay(key)}</Kbd>
))} ))}
</KbdGroup> </KbdGroup>
@ -90,9 +89,9 @@ const AppDetailTop = ({ expand = true, onToggle }: AppDetailTopProps) => {
</Tooltip> </Tooltip>
)} )}
{onToggle && ( {onToggle && (
<ToggleButton <DetailSidebarToggleButton
expand={expand} expand={expand}
handleToggle={onToggle} onToggle={onToggle}
icon={<SidebarLeftArrowIcon aria-hidden className="size-4" />} icon={<SidebarLeftArrowIcon aria-hidden className="size-4" />}
className="size-8 rounded-[10px] border-0 bg-transparent px-0 text-text-tertiary shadow-none hover:border-0 hover:bg-state-base-hover hover:text-text-secondary" className="size-8 rounded-[10px] border-0 bg-transparent px-0 text-text-tertiary shadow-none hover:border-0 hover:bg-state-base-hover hover:text-text-secondary"
/> />
@ -100,5 +99,3 @@ const AppDetailTop = ({ expand = true, onToggle }: AppDetailTopProps) => {
</div> </div>
) )
} }
export default AppDetailTop

View File

@ -2,7 +2,7 @@
import { DetailSidebarFrame } from '@/app/components/detail-sidebar' import { DetailSidebarFrame } from '@/app/components/detail-sidebar'
import DatasetDetailSection from './dataset-detail-section' import DatasetDetailSection from './dataset-detail-section'
import DatasetDetailTop from './dataset-detail-top' import { DatasetDetailTop } from './dataset-detail-top'
export function DatasetDetailSidebar() { export function DatasetDetailSidebar() {
return ( return (

View File

@ -6,27 +6,26 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/too
import { formatForDisplay } from '@tanstack/react-hotkeys' import { formatForDisplay } from '@tanstack/react-hotkeys'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import SidebarLeftArrowIcon from '@/app/components/base/icons/src/vender/SidebarLeftArrowIcon' import SidebarLeftArrowIcon from '@/app/components/base/icons/src/vender/SidebarLeftArrowIcon'
import { DetailSidebarToggleButton } from '@/app/components/detail-sidebar/toggle-button'
import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle' import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle'
import { GOTO_ANYTHING_HOTKEY } from '@/app/components/goto-anything/hotkeys'
import Link from '@/next/link' import Link from '@/next/link'
import ToggleButton from './toggle-button'
type DatasetDetailTopProps = { type DatasetDetailTopProps = {
expand?: boolean expand?: boolean
onToggle?: () => void onToggle?: () => void
} }
const SEARCH_SHORTCUT = ['Mod', 'K'] export function DatasetDetailTop({ expand = true, onToggle }: DatasetDetailTopProps) {
const DatasetDetailTop = ({ expand = true, onToggle }: DatasetDetailTopProps) => {
const { t } = useTranslation() const { t } = useTranslation()
if (!expand) { if (!expand) {
return ( return (
<div className="flex w-full items-center justify-center px-3 pt-2 pb-1"> <div className="flex w-full items-center justify-center px-3 pt-2 pb-1">
{onToggle && ( {onToggle && (
<ToggleButton <DetailSidebarToggleButton
expand={expand} expand={expand}
handleToggle={onToggle} onToggle={onToggle}
icon={<SidebarLeftArrowIcon aria-hidden className="size-4" />} icon={<SidebarLeftArrowIcon aria-hidden className="size-4" />}
className="size-8 rounded-[10px] border-0 bg-transparent px-0 text-text-tertiary shadow-none hover:border-0 hover:bg-state-base-hover hover:text-text-secondary" className="size-8 rounded-[10px] border-0 bg-transparent px-0 text-text-tertiary shadow-none hover:border-0 hover:bg-state-base-hover hover:text-text-secondary"
/> />
@ -82,7 +81,7 @@ const DatasetDetailTop = ({ expand = true, onToggle }: DatasetDetailTopProps) =>
> >
<span className="px-0.5">{t(($) => $['gotoAnything.quickAction'], { ns: 'app' })}</span> <span className="px-0.5">{t(($) => $['gotoAnything.quickAction'], { ns: 'app' })}</span>
<KbdGroup> <KbdGroup>
{SEARCH_SHORTCUT.map((key) => ( {GOTO_ANYTHING_HOTKEY.split('+').map((key) => (
<Kbd key={key}>{formatForDisplay(key)}</Kbd> <Kbd key={key}>{formatForDisplay(key)}</Kbd>
))} ))}
</KbdGroup> </KbdGroup>
@ -90,9 +89,9 @@ const DatasetDetailTop = ({ expand = true, onToggle }: DatasetDetailTopProps) =>
</Tooltip> </Tooltip>
)} )}
{onToggle && ( {onToggle && (
<ToggleButton <DetailSidebarToggleButton
expand={expand} expand={expand}
handleToggle={onToggle} onToggle={onToggle}
icon={<SidebarLeftArrowIcon aria-hidden className="size-4" />} icon={<SidebarLeftArrowIcon aria-hidden className="size-4" />}
className="size-8 rounded-[10px] border-0 bg-transparent px-0 text-text-tertiary shadow-none hover:border-0 hover:bg-state-base-hover hover:text-text-secondary" className="size-8 rounded-[10px] border-0 bg-transparent px-0 text-text-tertiary shadow-none hover:border-0 hover:bg-state-base-hover hover:text-text-secondary"
/> />
@ -100,5 +99,3 @@ const DatasetDetailTop = ({ expand = true, onToggle }: DatasetDetailTopProps) =>
</div> </div>
) )
} }
export default DatasetDetailTop

View File

@ -140,7 +140,7 @@ type MockCreateSnippetDialogProps = {
} }
vi.mock('@/app/components/snippets/create-snippet-dialog', () => ({ vi.mock('@/app/components/snippets/create-snippet-dialog', () => ({
default: ({ CreateSnippetDialog: ({
isOpen, isOpen,
title, title,
confirmText, confirmText,

View File

@ -22,7 +22,7 @@ import { toast } from '@langgenius/dify-ui/toast'
import { useAtomValue } from 'jotai' import { useAtomValue } from 'jotai'
import * as React from 'react' import * as React from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import CreateSnippetDialog from '@/app/components/snippets/create-snippet-dialog' import { CreateSnippetDialog } from '@/app/components/snippets/create-snippet-dialog'
import { import {
canCreateAndModifySnippets, canCreateAndModifySnippets,
canManageSnippets, canManageSnippets,

View File

@ -1,76 +0,0 @@
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { formatForDisplay } from '@tanstack/react-hotkeys'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
type ToggleTooltipContentProps = {
expand: boolean
}
const TOGGLE_SHORTCUT = ['Mod', 'B']
const ToggleTooltipContent = ({ expand }: ToggleTooltipContentProps) => {
const { t } = useTranslation()
return (
<div className="flex items-center gap-x-1">
<span className="px-0.5 system-xs-medium text-text-secondary">
{expand
? t(($) => $['sidebar.collapseSidebar'], { ns: 'layout' })
: t(($) => $['sidebar.expandSidebar'], { ns: 'layout' })}
</span>
<KbdGroup>
{TOGGLE_SHORTCUT.map((key) => (
<Kbd key={key}>{formatForDisplay(key)}</Kbd>
))}
</KbdGroup>
</div>
)
}
type ToggleButtonProps = {
expand: boolean
handleToggle: () => void
className?: string
icon?: React.ReactNode
iconClassName?: string
}
const ToggleButton = ({
expand,
handleToggle,
className,
icon,
iconClassName,
}: ToggleButtonProps) => {
return (
<Tooltip>
<TooltipTrigger
render={
<Button
size="small"
onClick={handleToggle}
className={cn('rounded-full px-1', className)}
/>
}
>
{icon ||
(iconClassName ? (
<span aria-hidden className={cn('size-4', iconClassName)} />
) : expand ? (
<span aria-hidden className="i-ri-arrow-left-s-line size-4" />
) : (
<span aria-hidden className="i-ri-arrow-right-s-line size-4" />
))}
</TooltipTrigger>
<TooltipContent placement="right" className="rounded-lg p-1.5">
<ToggleTooltipContent expand={expand} />
</TooltipContent>
</Tooltip>
)
}
export default React.memo(ToggleButton)

View File

@ -285,7 +285,6 @@ describe('AppPublisher', () => {
enabled: true, enabled: true,
}) })
}) })
expect(sectionProps.summary?.publishShortcut).toEqual(['Mod', 'Shift', 'P'])
expect(mockRefetch).not.toHaveBeenCalled() expect(mockRefetch).not.toHaveBeenCalled()
}) })

View File

@ -75,7 +75,6 @@ describe('app-publisher sections', () => {
publishDisabled={false} publishDisabled={false}
published={false} published={false}
publishedAt={Date.now()} publishedAt={Date.now()}
publishShortcut={['Mod', 'Shift', 'P']}
startNodeLimitExceeded={false} startNodeLimitExceeded={false}
upgradeHighlightStyle={{}} upgradeHighlightStyle={{}}
/>, />,
@ -113,7 +112,6 @@ describe('app-publisher sections', () => {
publishDisabled={false} publishDisabled={false}
published={false} published={false}
publishedAt={undefined} publishedAt={undefined}
publishShortcut={['Mod', 'Shift', 'P']}
startNodeLimitExceeded={false} startNodeLimitExceeded={false}
upgradeHighlightStyle={{}} upgradeHighlightStyle={{}}
/>, />,
@ -137,7 +135,6 @@ describe('app-publisher sections', () => {
publishDisabled={false} publishDisabled={false}
published={false} published={false}
publishedAt={undefined} publishedAt={undefined}
publishShortcut={['Mod', 'Shift', 'P']}
startNodeLimitExceeded={false} startNodeLimitExceeded={false}
upgradeHighlightStyle={{}} upgradeHighlightStyle={{}}
/>, />,
@ -161,7 +158,6 @@ describe('app-publisher sections', () => {
publishDisabled={false} publishDisabled={false}
published={false} published={false}
publishedAt={undefined} publishedAt={undefined}
publishShortcut={['Mod', 'Shift', 'P']}
startNodeLimitExceeded startNodeLimitExceeded
upgradeHighlightStyle={{}} upgradeHighlightStyle={{}}
/>, />,

View File

@ -0,0 +1,3 @@
import type { Hotkey } from '@tanstack/react-hotkeys'
export const APP_PUBLISH_HOTKEY = 'Mod+Shift+P' satisfies Hotkey

View File

@ -1,4 +1,3 @@
import type { RegisterableHotkey } from '@tanstack/react-hotkeys'
import type { FormEvent } from 'react' import type { FormEvent } from 'react'
import type { ModelAndParameter } from '../configuration/debug/types' import type { ModelAndParameter } from '../configuration/debug/types'
import type { import type {
@ -45,6 +44,7 @@ import { fetchPublishedWorkflow } from '@/service/workflow'
import { AppModeEnum } from '@/types/app' import { AppModeEnum } from '@/types/app'
import { basePath } from '@/utils/var' import { basePath } from '@/utils/var'
import AccessControl from '../app-access-control' import AccessControl from '../app-access-control'
import { APP_PUBLISH_HOTKEY } from './hotkeys'
import { import {
PublisherAccessSection, PublisherAccessSection,
PublisherActionsSection, PublisherActionsSection,
@ -81,9 +81,6 @@ export type AppPublisherProps = {
hasHumanInputNode?: boolean hasHumanInputNode?: boolean
} }
const PUBLISH_HOTKEY = 'Mod+Shift+P' satisfies RegisterableHotkey
const PUBLISH_SHORTCUT = PUBLISH_HOTKEY.split('+')
export type AppPublisherPublishParams = ModelAndParameter | PublishWorkflowParams export type AppPublisherPublishParams = ModelAndParameter | PublishWorkflowParams
type AppPublisherPublishHandler = type AppPublisherPublishHandler =
@ -307,7 +304,7 @@ export function AppPublisher({
} }
} }
useHotkey(PUBLISH_HOTKEY, (e) => { useHotkey(APP_PUBLISH_HOTKEY, (e) => {
e.preventDefault() e.preventDefault()
if (publishDisabled || published) return if (publishDisabled || published) return
handlePublish() handlePublish()
@ -410,7 +407,6 @@ export function AppPublisher({
publishDisabled={publishDisabled} publishDisabled={publishDisabled}
published={published} published={published}
publishedAt={publishedAt} publishedAt={publishedAt}
publishShortcut={PUBLISH_SHORTCUT}
startNodeLimitExceeded={startNodeLimitExceeded} startNodeLimitExceeded={startNodeLimitExceeded}
upgradeHighlightStyle={upgradeHighlightStyle} upgradeHighlightStyle={upgradeHighlightStyle}
/> />

View File

@ -12,6 +12,7 @@ import Loading from '@/app/components/base/loading'
import UpgradeBtn from '@/app/components/billing/upgrade-btn' import UpgradeBtn from '@/app/components/billing/upgrade-btn'
import WorkflowToolConfigureButton from '@/app/components/tools/workflow-tool/configure-button' import WorkflowToolConfigureButton from '@/app/components/tools/workflow-tool/configure-button'
import { AppModeEnum } from '@/types/app' import { AppModeEnum } from '@/types/app'
import { APP_PUBLISH_HOTKEY } from './hotkeys'
import PublishWithMultipleModel from './publish-with-multiple-model' import PublishWithMultipleModel from './publish-with-multiple-model'
import SuggestedAction from './suggested-action' import SuggestedAction from './suggested-action'
import { ACCESS_MODE_MAP } from './utils' import { ACCESS_MODE_MAP } from './utils'
@ -30,7 +31,6 @@ type SummarySectionProps = Pick<
handleRestore: () => Promise<void> handleRestore: () => Promise<void>
isChatApp: boolean isChatApp: boolean
published: boolean published: boolean
publishShortcut: string[]
upgradeHighlightStyle: CSSProperties upgradeHighlightStyle: CSSProperties
} }
@ -109,7 +109,6 @@ export const PublisherSummarySection = ({
publishDisabled = false, publishDisabled = false,
published, published,
publishedAt, publishedAt,
publishShortcut,
startNodeLimitExceeded = false, startNodeLimitExceeded = false,
upgradeHighlightStyle, upgradeHighlightStyle,
}: SummarySectionProps) => { }: SummarySectionProps) => {
@ -163,7 +162,7 @@ export const PublisherSummarySection = ({
<div className="flex gap-1"> <div className="flex gap-1">
<span>{t(($) => $['common.publishUpdate'], { ns: 'workflow' })}</span> <span>{t(($) => $['common.publishUpdate'], { ns: 'workflow' })}</span>
<KbdGroup> <KbdGroup>
{publishShortcut.map((key) => ( {APP_PUBLISH_HOTKEY.split('+').map((key) => (
<Kbd key={key} color="white"> <Kbd key={key} color="white">
{formatForDisplay(key)} {formatForDisplay(key)}
</Kbd> </Kbd>

View File

@ -1,8 +1,10 @@
'use client' 'use client'
import type { Hotkey } from '@tanstack/react-hotkeys'
import type { AppIconSelection } from '../../base/app-icon-picker' import type { AppIconSelection } from '../../base/app-icon-picker'
import { Button } from '@langgenius/dify-ui/button' import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn' import { cn } from '@langgenius/dify-ui/cn'
import { Input } from '@langgenius/dify-ui/input'
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd' import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
import { Textarea } from '@langgenius/dify-ui/textarea' import { Textarea } from '@langgenius/dify-ui/textarea'
import { toast } from '@langgenius/dify-ui/toast' import { toast } from '@langgenius/dify-ui/toast'
@ -22,7 +24,6 @@ import {
ListSparkle, ListSparkle,
Logic, Logic,
} from '@/app/components/base/icons/src/vender/solid/communication' } from '@/app/components/base/icons/src/vender/solid/communication'
import Input from '@/app/components/base/input'
import AppsFull from '@/app/components/billing/apps-full-in-dialog' import AppsFull from '@/app/components/billing/apps-full-in-dialog'
import { userProfileIdAtom } from '@/context/account-state' import { userProfileIdAtom } from '@/context/account-state'
import { workspacePermissionKeysAtom } from '@/context/permission-state' import { workspacePermissionKeysAtom } from '@/context/permission-state'
@ -47,6 +48,8 @@ type CreateAppProps = {
defaultAppMode?: AppModeEnum defaultAppMode?: AppModeEnum
} }
const CREATE_APP_HOTKEY = 'Mod+Enter' satisfies Hotkey
const shouldExpandBeginnerAppTypes = (appMode?: AppModeEnum) => { const shouldExpandBeginnerAppTypes = (appMode?: AppModeEnum) => {
return ( return (
appMode === AppModeEnum.CHAT || appMode === AppModeEnum.CHAT ||
@ -152,7 +155,7 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
const { run: handleCreateApp } = useDebounceFn(onCreate, { wait: 300 }) const { run: handleCreateApp } = useDebounceFn(onCreate, { wait: 300 })
useHotkey( useHotkey(
'Mod+Enter', CREATE_APP_HOTKEY,
() => { () => {
if (isAppsFull || !canCreateApp) return if (isAppsFull || !canCreateApp) return
handleCreateApp() handleCreateApp()
@ -348,7 +351,7 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
> >
<span>{t(($) => $['newApp.Create'], { ns: 'app' })}</span> <span>{t(($) => $['newApp.Create'], { ns: 'app' })}</span>
<KbdGroup> <KbdGroup>
{['Mod', 'Enter'].map((key) => ( {CREATE_APP_HOTKEY.split('+').map((key) => (
<Kbd key={key} color="white"> <Kbd key={key} color="white">
{formatForDisplay(key)} {formatForDisplay(key)}
</Kbd> </Kbd>
@ -434,9 +437,10 @@ type AppTypeCardProps = {
} }
function AppTypeCard({ icon, title, description, active, onClick }: AppTypeCardProps) { function AppTypeCard({ icon, title, description, active, onClick }: AppTypeCardProps) {
return ( return (
<div <button
type="button"
className={cn( className={cn(
`relative box-content h-[84px] w-[191px] cursor-pointer rounded-xl border-[0.5px] border-components-option-card-option-border bg-components-panel-on-panel-item-bg p-3 shadow-xs hover:shadow-md`, 'relative box-content h-[84px] w-[191px] 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',
active active
? 'shadow-md outline-[1.5px] outline-components-option-card-option-selected-border outline-solid' ? 'shadow-md outline-[1.5px] outline-components-option-card-option-selected-border outline-solid'
: '', : '',
@ -448,7 +452,7 @@ function AppTypeCard({ icon, title, description, active, onClick }: AppTypeCardP
<div className="line-clamp-2 system-xs-regular text-text-tertiary" title={description}> <div className="line-clamp-2 system-xs-regular text-text-tertiary" title={description}>
{description} {description}
</div> </div>
</div> </button>
) )
} }

View File

@ -4,7 +4,8 @@ import { renderWithSystemFeatures as render } from '@/__tests__/utils/mock-syste
import { NEED_REFRESH_APP_LIST_KEY } from '@/app/components/apps/storage' import { NEED_REFRESH_APP_LIST_KEY } from '@/app/components/apps/storage'
import { DSLImportMode, DSLImportStatus } from '@/models/app' import { DSLImportMode, DSLImportStatus } from '@/models/app'
import { AppModeEnum } from '@/types/app' import { AppModeEnum } from '@/types/app'
import CreateFromDSLModal, { CreateFromDSLModalTab } from '../index' import CreateFromDSLModal from '../index'
import { CreateFromDSLModalTab } from '../types'
const mockPush = vi.fn() const mockPush = vi.fn()
const mockImportDSL = vi.fn() const mockImportDSL = vi.fn()

View File

@ -1,9 +1,11 @@
'use client' 'use client'
import type { Hotkey } from '@tanstack/react-hotkeys'
import type { MouseEventHandler } from 'react' import type { MouseEventHandler } from 'react'
import { Button } from '@langgenius/dify-ui/button' import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn' import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
import { Input } from '@langgenius/dify-ui/input'
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd' import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
import { toast } from '@langgenius/dify-ui/toast' import { toast } from '@langgenius/dify-ui/toast'
import { formatForDisplay, useHotkey } from '@tanstack/react-hotkeys' import { formatForDisplay, useHotkey } from '@tanstack/react-hotkeys'
@ -13,7 +15,6 @@ import { useAtomValue } from 'jotai'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useSetNeedRefreshAppList } from '@/app/components/apps/storage' import { useSetNeedRefreshAppList } from '@/app/components/apps/storage'
import Input from '@/app/components/base/input'
import AppsFull from '@/app/components/billing/apps-full-in-dialog' import AppsFull from '@/app/components/billing/apps-full-in-dialog'
import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks' import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks'
import { userProfileIdAtom } from '@/context/account-state' import { userProfileIdAtom } from '@/context/account-state'
@ -26,21 +27,19 @@ import { importDSL, importDSLConfirm } from '@/service/apps'
import { useInvalidateAppList } from '@/service/use-apps' import { useInvalidateAppList } from '@/service/use-apps'
import { getRedirection } from '@/utils/app-redirection' import { getRedirection } from '@/utils/app-redirection'
import { trackCreateApp } from '@/utils/create-app-tracking' import { trackCreateApp } from '@/utils/create-app-tracking'
import { CreateFromDSLModalTab } from './types'
import Uploader from './uploader' import Uploader from './uploader'
type CreateFromDSLModalProps = { type CreateFromDSLModalProps = {
show: boolean show: boolean
onSuccess?: () => void onSuccess?: () => void
onClose: () => void onClose: () => void
activeTab?: string activeTab?: CreateFromDSLModalTab
dslUrl?: string dslUrl?: string
droppedFile?: File droppedFile?: File
} }
export enum CreateFromDSLModalTab { const CREATE_FROM_DSL_HOTKEY = 'Mod+Enter' satisfies Hotkey
FROM_FILE = 'from-file',
FROM_URL = 'from-url',
}
const CreateFromDSLModal = ({ const CreateFromDSLModal = ({
show, show,
@ -91,8 +90,8 @@ const CreateFromDSLModal = ({
const isCreatingRef = useRef(false) const isCreatingRef = useRef(false)
useEffect(() => { useEffect(() => {
if (droppedFile) handleFile(droppedFile) if (droppedFile) readFile(droppedFile)
}, [droppedFile, handleFile]) }, [droppedFile, readFile])
const onCreate = async (_e?: React.MouseEvent) => { const onCreate = async (_e?: React.MouseEvent) => {
if (currentTab === CreateFromDSLModalTab.FROM_FILE && !currentFile) return if (currentTab === CreateFromDSLModalTab.FROM_FILE && !currentFile) return
@ -179,7 +178,7 @@ const CreateFromDSLModal = ({
const { run: handleCreateApp } = useDebounceFn(onCreate, { wait: 300 }) const { run: handleCreateApp } = useDebounceFn(onCreate, { wait: 300 })
useHotkey( useHotkey(
'Mod+Enter', CREATE_FROM_DSL_HOTKEY,
() => { () => {
handleCreateApp(undefined) handleCreateApp(undefined)
}, },
@ -251,16 +250,23 @@ const CreateFromDSLModal = ({
<DialogContent className="w-full max-w-[480px]! overflow-hidden! rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0! text-left align-middle shadow-xl"> <DialogContent className="w-full max-w-[480px]! overflow-hidden! rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0! text-left align-middle shadow-xl">
<div className="flex items-center justify-between pt-6 pr-5 pb-3 pl-6 title-2xl-semi-bold text-text-primary"> <div className="flex items-center justify-between pt-6 pr-5 pb-3 pl-6 title-2xl-semi-bold text-text-primary">
{t(($) => $.importApp, { ns: 'app' })} {t(($) => $.importApp, { ns: 'app' })}
<div className="flex size-8 cursor-pointer items-center" onClick={() => onClose()}> <Button
<span className="i-ri-close-line size-5 text-text-tertiary" /> variant="ghost"
</div> size="small"
aria-label={t(($) => $['operation.cancel'], { ns: 'common' })}
className="size-8 p-0"
onClick={onClose}
>
<span aria-hidden className="i-ri-close-line size-5 text-text-tertiary" />
</Button>
</div> </div>
<div className="flex h-9 items-center space-x-6 border-b border-divider-subtle px-6 system-md-semibold text-text-tertiary"> <div className="flex h-9 items-center space-x-6 border-b border-divider-subtle px-6 system-md-semibold text-text-tertiary">
{tabs.map((tab) => ( {tabs.map((tab) => (
<div <button
type="button"
key={tab.key} key={tab.key}
className={cn( className={cn(
'relative flex h-full cursor-pointer items-center', 'relative flex h-full cursor-pointer items-center outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid',
currentTab === tab.key && 'text-text-primary', currentTab === tab.key && 'text-text-primary',
)} )}
onClick={() => setCurrentTab(tab.key)} onClick={() => setCurrentTab(tab.key)}
@ -269,7 +275,7 @@ const CreateFromDSLModal = ({
{currentTab === tab.key && ( {currentTab === tab.key && (
<div className="absolute bottom-0 h-[2px] w-full bg-util-colors-blue-brand-blue-brand-600"></div> <div className="absolute bottom-0 h-[2px] w-full bg-util-colors-blue-brand-blue-brand-600"></div>
)} )}
</div> </button>
))} ))}
</div> </div>
<div className="px-6 py-4"> <div className="px-6 py-4">
@ -304,7 +310,7 @@ const CreateFromDSLModal = ({
> >
<span>{t(($) => $['newApp.Create'], { ns: 'app' })}</span> <span>{t(($) => $['newApp.Create'], { ns: 'app' })}</span>
<KbdGroup> <KbdGroup>
{['Mod', 'Enter'].map((key) => ( {CREATE_FROM_DSL_HOTKEY.split('+').map((key) => (
<Kbd key={key} color="white"> <Kbd key={key} color="white">
{formatForDisplay(key)} {formatForDisplay(key)}
</Kbd> </Kbd>

View File

@ -0,0 +1,7 @@
export const CreateFromDSLModalTab = {
FROM_FILE: 'from-file',
FROM_URL: 'from-url',
} as const
export type CreateFromDSLModalTab =
(typeof CreateFromDSLModalTab)[keyof typeof CreateFromDSLModalTab]

View File

@ -1,6 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react' import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { CreateFromDSLModalTab } from '@/app/components/app/create-from-dsl-modal' import { CreateFromDSLModalTab } from '../../hooks/use-dsl-import'
import Tab from '../index' import Tab from '../index'
// Tab Component Tests // Tab Component Tests

View File

@ -1,6 +1,6 @@
import * as React from 'react' import * as React from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { CreateFromDSLModalTab } from '@/app/components/app/create-from-dsl-modal' import { CreateFromDSLModalTab } from '../hooks/use-dsl-import'
import Item from './item' import Item from './item'
type TabProps = { type TabProps = {

View File

@ -49,7 +49,7 @@ vi.mock('@/service/knowledge/use-segment', () => ({
})) }))
vi.mock('../completed/common/action-buttons', () => ({ vi.mock('../completed/common/action-buttons', () => ({
default: ({ ActionButtons: ({
handleCancel, handleCancel,
handleSave, handleSave,
loading, loading,

View File

@ -31,7 +31,7 @@ vi.mock('@/context/event-emitter', () => ({
})) }))
vi.mock('../common/action-buttons', () => ({ vi.mock('../common/action-buttons', () => ({
default: ({ ActionButtons: ({
handleCancel, handleCancel,
handleSave, handleSave,
loading, loading,

View File

@ -45,7 +45,7 @@ vi.mock('@/service/knowledge/use-segment', () => ({
})) }))
vi.mock('../common/action-buttons', () => ({ vi.mock('../common/action-buttons', () => ({
default: ({ ActionButtons: ({
handleCancel, handleCancel,
handleSave, handleSave,
loading, loading,

View File

@ -53,7 +53,7 @@ vi.mock('@/context/event-emitter', () => ({
})) }))
vi.mock('../common/action-buttons', () => ({ vi.mock('../common/action-buttons', () => ({
default: ({ ActionButtons: ({
handleCancel, handleCancel,
handleSave, handleSave,
handleRegeneration, handleRegeneration,

View File

@ -9,7 +9,7 @@ import Divider from '@/app/components/base/divider'
import { useEventEmitterContextContext } from '@/context/event-emitter' import { useEventEmitterContextContext } from '@/context/event-emitter'
import { formatNumber } from '@/utils/format' import { formatNumber } from '@/utils/format'
import { formatTime } from '@/utils/time' import { formatTime } from '@/utils/time'
import ActionButtons from './common/action-buttons' import { ActionButtons } from './common/action-buttons'
import ChunkContent from './common/chunk-content' import ChunkContent from './common/chunk-content'
import Dot from './common/dot' import Dot from './common/dot'
import { SegmentIndexTag } from './common/segment-index-tag' import { SegmentIndexTag } from './common/segment-index-tag'

View File

@ -2,7 +2,7 @@ import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ChunkingMode } from '@/models/datasets' import { ChunkingMode } from '@/models/datasets'
import { DocumentContext } from '../../../context' import { DocumentContext } from '../../../context'
import ActionButtons from '../action-buttons' import { ActionButtons } from '../action-buttons'
const mockUseHotkey = vi.fn() const mockUseHotkey = vi.fn()
vi.mock('@tanstack/react-hotkeys', async (importOriginal) => { vi.mock('@tanstack/react-hotkeys', async (importOriginal) => {

View File

@ -1,14 +1,15 @@
import type { FC } from 'react' import type { Hotkey } from '@tanstack/react-hotkeys'
import { Button } from '@langgenius/dify-ui/button' import { Button } from '@langgenius/dify-ui/button'
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd' import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
import { formatForDisplay, useHotkey } from '@tanstack/react-hotkeys' import { formatForDisplay, useHotkey } from '@tanstack/react-hotkeys'
import * as React from 'react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { ChunkingMode } from '@/models/datasets' import { ChunkingMode } from '@/models/datasets'
import { useDocumentContext } from '../../context' import { useDocumentContext } from '../../context'
type IActionButtonsProps = { const CANCEL_HOTKEY = 'Escape' satisfies Hotkey
const SAVE_HOTKEY = 'Mod+S' satisfies Hotkey
type ActionButtonsProps = {
handleCancel: () => void handleCancel: () => void
handleSave: () => void handleSave: () => void
loading: boolean loading: boolean
@ -18,7 +19,7 @@ type IActionButtonsProps = {
showRegenerationButton?: boolean showRegenerationButton?: boolean
} }
const ActionButtons: FC<IActionButtonsProps> = ({ export function ActionButtons({
handleCancel, handleCancel,
handleSave, handleSave,
loading, loading,
@ -26,25 +27,24 @@ const ActionButtons: FC<IActionButtonsProps> = ({
handleRegeneration, handleRegeneration,
isChildChunk = false, isChildChunk = false,
showRegenerationButton = true, showRegenerationButton = true,
}) => { }: ActionButtonsProps) {
const { t } = useTranslation() const { t } = useTranslation()
const docForm = useDocumentContext((s) => s.docForm) const docForm = useDocumentContext((s) => s.docForm)
const parentMode = useDocumentContext((s) => s.parentMode) const parentMode = useDocumentContext((s) => s.parentMode)
useHotkey('Escape', (e) => { useHotkey(CANCEL_HOTKEY, (e) => {
e.preventDefault() e.preventDefault()
handleCancel() handleCancel()
}) })
useHotkey('Mod+S', (e) => { useHotkey(SAVE_HOTKEY, (e) => {
e.preventDefault() e.preventDefault()
if (loading) return if (loading) return
handleSave() handleSave()
}) })
const isParentChildParagraphMode = useMemo(() => { const isParentChildParagraphMode =
return docForm === ChunkingMode.parentChild && parentMode === 'paragraph' docForm === ChunkingMode.parentChild && parentMode === 'paragraph'
}, [docForm, parentMode])
return ( return (
<div className="flex items-center gap-x-2"> <div className="flex items-center gap-x-2">
@ -53,7 +53,7 @@ const ActionButtons: FC<IActionButtonsProps> = ({
<span className="system-sm-medium text-components-button-secondary-text"> <span className="system-sm-medium text-components-button-secondary-text">
{t(($) => $['operation.cancel'], { ns: 'common' })} {t(($) => $['operation.cancel'], { ns: 'common' })}
</span> </span>
<Kbd>{formatForDisplay('Escape')}</Kbd> <Kbd>{formatForDisplay(CANCEL_HOTKEY)}</Kbd>
</div> </div>
</Button> </Button>
{isParentChildParagraphMode && {isParentChildParagraphMode &&
@ -72,7 +72,7 @@ const ActionButtons: FC<IActionButtonsProps> = ({
{t(($) => $['operation.save'], { ns: 'common' })} {t(($) => $['operation.save'], { ns: 'common' })}
</span> </span>
<KbdGroup> <KbdGroup>
{['Mod', 'S'].map((key) => ( {SAVE_HOTKEY.split('+').map((key) => (
<Kbd key={key} color="white"> <Kbd key={key} color="white">
{formatForDisplay(key)} {formatForDisplay(key)}
</Kbd> </Kbd>
@ -83,7 +83,3 @@ const ActionButtons: FC<IActionButtonsProps> = ({
</div> </div>
) )
} }
ActionButtons.displayName = 'ActionButtons'
export default React.memo(ActionButtons)

View File

@ -11,7 +11,7 @@ import { useParams } from '@/next/navigation'
import { useAddChildSegment } from '@/service/knowledge/use-segment' import { useAddChildSegment } from '@/service/knowledge/use-segment'
import { formatNumber } from '@/utils/format' import { formatNumber } from '@/utils/format'
import { useDocumentContext } from '../context' import { useDocumentContext } from '../context'
import ActionButtons from './common/action-buttons' import { ActionButtons } from './common/action-buttons'
import AddAnother from './common/add-another' import AddAnother from './common/add-another'
import ChunkContent from './common/chunk-content' import ChunkContent from './common/chunk-content'
import Dot from './common/dot' import Dot from './common/dot'

View File

@ -13,7 +13,7 @@ import { useEventEmitterContextContext } from '@/context/event-emitter'
import { ChunkingMode } from '@/models/datasets' import { ChunkingMode } from '@/models/datasets'
import { formatNumber } from '@/utils/format' import { formatNumber } from '@/utils/format'
import { useDocumentContext } from '../context' import { useDocumentContext } from '../context'
import ActionButtons from './common/action-buttons' import { ActionButtons } from './common/action-buttons'
import ChunkContent from './common/chunk-content' import ChunkContent from './common/chunk-content'
import Dot from './common/dot' import Dot from './common/dot'
import Keywords from './common/keywords' import Keywords from './common/keywords'

View File

@ -15,7 +15,7 @@ import { useAddSegment } from '@/service/knowledge/use-segment'
import { formatNumber } from '@/utils/format' import { formatNumber } from '@/utils/format'
import { IndexingType } from '../../create/step-two' import { IndexingType } from '../../create/step-two'
import { useSegmentListContext } from './completed' import { useSegmentListContext } from './completed'
import ActionButtons from './completed/common/action-buttons' import { ActionButtons } from './completed/common/action-buttons'
import AddAnother from './completed/common/add-another' import AddAnother from './completed/common/add-another'
import ChunkContent from './completed/common/chunk-content' import ChunkContent from './completed/common/chunk-content'
import Dot from './completed/common/dot' import Dot from './completed/common/dot'

View File

@ -1,4 +1,4 @@
import { fireEvent, render, screen } from '@testing-library/react' import { act, fireEvent, render, screen } from '@testing-library/react'
import { DetailSidebarFrame } from '..' import { DetailSidebarFrame } from '..'
import { DETAIL_SIDEBAR_STORAGE_KEY } from '../storage' import { DETAIL_SIDEBAR_STORAGE_KEY } from '../storage'
@ -6,8 +6,8 @@ const { hotkeyRegistrations } = vi.hoisted(() => ({
hotkeyRegistrations: new Map< hotkeyRegistrations: new Map<
string, string,
{ {
handler: (event: { preventDefault: () => void }) => void handler: () => void
options?: { ignoreInputs?: boolean } options?: { ignoreInputs?: boolean; preventDefault?: boolean }
} }
>(), >(),
})) }))
@ -25,8 +25,8 @@ vi.mock('@tanstack/react-hotkeys', async (importOriginal) => {
...actual, ...actual,
useHotkey: ( useHotkey: (
hotkey: string, hotkey: string,
handler: (event: { preventDefault: () => void }) => void, handler: () => void,
options?: { ignoreInputs?: boolean }, options?: { ignoreInputs?: boolean; preventDefault?: boolean },
) => { ) => {
hotkeyRegistrations.set(hotkey, { handler, options }) hotkeyRegistrations.set(hotkey, { handler, options })
}, },
@ -117,10 +117,19 @@ describe('DetailSidebarFrame', () => {
expect(screen.getByTestId('detail-top')).toHaveAttribute('data-expand', 'true') expect(screen.getByTestId('detail-top')).toHaveAttribute('data-expand', 'true')
expect(screen.getByTestId('detail-section')).toHaveAttribute('data-expand', 'true') expect(screen.getByTestId('detail-section')).toHaveAttribute('data-expand', 'true')
expect(hotkeyRegistrations.get('Mod+B')?.options).toEqual( expect(hotkeyRegistrations.get('Mod+B')?.options).toEqual(
expect.objectContaining({ ignoreInputs: false }), expect.objectContaining({ ignoreInputs: false, preventDefault: true }),
) )
}) })
it('toggles detail content from the registered shortcut', () => {
renderDetailSidebarFrame()
act(() => hotkeyRegistrations.get('Mod+B')?.handler())
expect(screen.getByRole('complementary')).toHaveClass('w-16')
expect(screen.getByTestId('detail-top')).toHaveAttribute('data-expand', 'false')
})
it('collapses detail content from the top toggle and hides environment metadata', () => { it('collapses detail content from the top toggle and hides environment metadata', () => {
mockAppContextState.current = { mockAppContextState.current = {
langGeniusVersionInfo: { langGeniusVersionInfo: {

View File

@ -0,0 +1,29 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { DetailSidebarToggleButton } from '../toggle-button'
describe('DetailSidebarToggleButton', () => {
it('labels the expanded sidebar action', () => {
render(<DetailSidebarToggleButton expand onToggle={vi.fn()} />)
expect(
screen.getByRole('button', { name: 'layout.sidebar.collapseSidebar' }),
).toBeInTheDocument()
})
it('labels the collapsed sidebar action', () => {
render(<DetailSidebarToggleButton expand={false} onToggle={vi.fn()} />)
expect(screen.getByRole('button', { name: 'layout.sidebar.expandSidebar' })).toBeInTheDocument()
})
it('toggles the sidebar when activated', async () => {
const user = userEvent.setup()
const onToggle = vi.fn()
render(<DetailSidebarToggleButton expand onToggle={onToggle} />)
await user.click(screen.getByRole('button', { name: 'layout.sidebar.collapseSidebar' }))
expect(onToggle).toHaveBeenCalledTimes(1)
})
})

View File

@ -0,0 +1,3 @@
import type { Hotkey } from '@tanstack/react-hotkeys'
export const DETAIL_SIDEBAR_TOGGLE_HOTKEY = 'Mod+B' satisfies Hotkey

View File

@ -4,11 +4,12 @@ import type { ReactNode } from 'react'
import { cn } from '@langgenius/dify-ui/cn' import { cn } from '@langgenius/dify-ui/cn'
import { useHotkey } from '@tanstack/react-hotkeys' import { useHotkey } from '@tanstack/react-hotkeys'
import { useAtomValue } from 'jotai' import { useAtomValue } from 'jotai'
import { useCallback, useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import EnvNav from '@/app/components/header/env-nav' import EnvNav from '@/app/components/header/env-nav'
import AccountSection from '@/app/components/main-nav/components/account-section' import AccountSection from '@/app/components/main-nav/components/account-section'
import HelpMenu from '@/app/components/main-nav/components/help-menu' import HelpMenu from '@/app/components/main-nav/components/help-menu'
import { langGeniusVersionInfoAtom } from '@/context/version-state' import { langGeniusVersionInfoAtom } from '@/context/version-state'
import { DETAIL_SIDEBAR_TOGGLE_HOTKEY } from './hotkeys'
import { useDetailSidebarMode } from './storage' import { useDetailSidebarMode } from './storage'
type DetailSidebarRenderProps = { type DetailSidebarRenderProps = {
@ -56,7 +57,7 @@ export function DetailSidebarFrame({
const currentEnv = langGeniusVersionInfo?.current_env const currentEnv = langGeniusVersionInfo?.current_env
const showEnvTag = currentEnv === 'TESTING' || currentEnv === 'DEVELOPMENT' const showEnvTag = currentEnv === 'TESTING' || currentEnv === 'DEVELOPMENT'
const handleToggleDetailNavigation = useCallback(() => { function handleToggleDetailNavigation() {
if (isDetailNavigationHoverPreviewOpen) { if (isDetailNavigationHoverPreviewOpen) {
if (detailNavigationTransitionTimerRef.current) if (detailNavigationTransitionTimerRef.current)
clearTimeout(detailNavigationTransitionTimerRef.current) clearTimeout(detailNavigationTransitionTimerRef.current)
@ -73,25 +74,25 @@ export function DetailSidebarFrame({
const nextMode = detailNavigationExpanded ? 'collapse' : 'expand' const nextMode = detailNavigationExpanded ? 'collapse' : 'expand'
setDetailNavigationHoverPreviewOpen(false) setDetailNavigationHoverPreviewOpen(false)
setStoredDetailSidebarExpand(nextMode) setStoredDetailSidebarExpand(nextMode)
}, [detailNavigationExpanded, isDetailNavigationHoverPreviewOpen, setStoredDetailSidebarExpand]) }
const openDetailNavigationHoverPreview = useCallback(() => { function openDetailNavigationHoverPreview() {
if (detailNavigationExpanded) return if (detailNavigationExpanded) return
if (closeDetailNavigationHoverPreviewTimerRef.current) if (closeDetailNavigationHoverPreviewTimerRef.current)
clearTimeout(closeDetailNavigationHoverPreviewTimerRef.current) clearTimeout(closeDetailNavigationHoverPreviewTimerRef.current)
setDetailNavigationHoverPreviewOpen(true) setDetailNavigationHoverPreviewOpen(true)
}, [detailNavigationExpanded]) }
const closeDetailNavigationHoverPreview = useCallback(() => { function closeDetailNavigationHoverPreview() {
if (closeDetailNavigationHoverPreviewTimerRef.current) if (closeDetailNavigationHoverPreviewTimerRef.current)
clearTimeout(closeDetailNavigationHoverPreviewTimerRef.current) clearTimeout(closeDetailNavigationHoverPreviewTimerRef.current)
closeDetailNavigationHoverPreviewTimerRef.current = setTimeout(() => { closeDetailNavigationHoverPreviewTimerRef.current = setTimeout(() => {
setDetailNavigationHoverPreviewOpen(false) setDetailNavigationHoverPreviewOpen(false)
}, 120) }, 120)
}, []) }
useEffect(() => { useEffect(() => {
return () => { return () => {
@ -102,16 +103,10 @@ export function DetailSidebarFrame({
} }
}, []) }, [])
useHotkey( useHotkey(DETAIL_SIDEBAR_TOGGLE_HOTKEY, handleToggleDetailNavigation, {
'Mod+B', ignoreInputs: false,
(e) => { preventDefault: true,
e.preventDefault() })
handleToggleDetailNavigation()
},
{
ignoreInputs: false,
},
)
return ( return (
<aside <aside

View File

@ -0,0 +1,62 @@
import type { ReactNode } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { formatForDisplay } from '@tanstack/react-hotkeys'
import { useTranslation } from 'react-i18next'
import { DETAIL_SIDEBAR_TOGGLE_HOTKEY } from './hotkeys'
const detailSidebarToggleShortcutKeys = DETAIL_SIDEBAR_TOGGLE_HOTKEY.split('+')
type DetailSidebarToggleButtonProps = {
expand: boolean
onToggle: () => void
className?: string
icon?: ReactNode
}
export function DetailSidebarToggleButton({
expand,
onToggle,
className,
icon,
}: DetailSidebarToggleButtonProps) {
const { t } = useTranslation()
const label = expand
? t(($) => $['sidebar.collapseSidebar'], { ns: 'layout' })
: t(($) => $['sidebar.expandSidebar'], { ns: 'layout' })
return (
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="small"
aria-label={label}
onClick={onToggle}
className={cn('rounded-full px-1', className)}
/>
}
>
{icon ??
(expand ? (
<span aria-hidden className="i-ri-arrow-left-s-line size-4" />
) : (
<span aria-hidden className="i-ri-arrow-right-s-line size-4" />
))}
</TooltipTrigger>
<TooltipContent placement="right" className="rounded-lg p-1.5">
<div className="flex items-center gap-x-1">
<span className="px-0.5 system-xs-medium text-text-secondary">{label}</span>
<KbdGroup>
{detailSidebarToggleShortcutKeys.map((key) => (
<Kbd key={key}>{formatForDisplay(key)}</Kbd>
))}
</KbdGroup>
</div>
</TooltipContent>
</Tooltip>
)
}

View File

@ -1,4 +1,5 @@
'use client' 'use client'
import type { Hotkey } from '@tanstack/react-hotkeys'
import type { AppIconType } from '@/types/app' import type { AppIconType } from '@/types/app'
import { Button } from '@langgenius/dify-ui/button' import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
@ -45,6 +46,8 @@ export type CreateAppModalProps = {
type CreateAppPayload = Parameters<CreateAppModalProps['onConfirm']>[0] type CreateAppPayload = Parameters<CreateAppModalProps['onConfirm']>[0]
const SUBMIT_APP_HOTKEY = 'Mod+Enter' satisfies Hotkey
const CreateAppModal = ({ const CreateAppModal = ({
show = false, show = false,
isEditModal = false, isEditModal = false,
@ -118,7 +121,7 @@ const CreateAppModal = ({
const { run: handleSubmit } = useDebounceFn(submit, { wait: 300 }) const { run: handleSubmit } = useDebounceFn(submit, { wait: 300 })
useHotkey( useHotkey(
'Mod+Enter', SUBMIT_APP_HOTKEY,
() => { () => {
handleSubmit() handleSubmit()
}, },
@ -237,7 +240,7 @@ const CreateAppModal = ({
: t(($) => $['operation.save'], { ns: 'common' })} : t(($) => $['operation.save'], { ns: 'common' })}
</span> </span>
<KbdGroup> <KbdGroup>
{['Mod', 'Enter'].map((key) => ( {SUBMIT_APP_HOTKEY.split('+').map((key) => (
<Kbd key={key} color="white"> <Kbd key={key} color="white">
{formatForDisplay(key)} {formatForDisplay(key)}
</Kbd> </Kbd>

View File

@ -0,0 +1,3 @@
import type { Hotkey } from '@tanstack/react-hotkeys'
export const GOTO_ANYTHING_HOTKEY = 'Mod+K' satisfies Hotkey

View File

@ -51,12 +51,11 @@ import { addRecentItem, getRecentItems } from './actions/recent-store'
import { EmptyState } from './components/empty-state' import { EmptyState } from './components/empty-state'
import { Footer } from './components/footer' import { Footer } from './components/footer'
import { gotoAnythingDialogHandle } from './dialog-handle' import { gotoAnythingDialogHandle } from './dialog-handle'
import { GOTO_ANYTHING_HOTKEY } from './hotkeys'
const appWorkflowPathPattern = /^\/app\/[^/]+\/workflow$/ const appWorkflowPathPattern = /^\/app\/[^/]+\/workflow$/
const sharedWorkflowPathPattern = /^\/workflow\/[^/]+$/ const sharedWorkflowPathPattern = /^\/workflow\/[^/]+$/
const ragPipelinePathPattern = /^\/datasets\/[^/]+\/pipeline$/ const ragPipelinePathPattern = /^\/datasets\/[^/]+\/pipeline$/
const searchHotkey = 'Mod+K'
const searchShortcut = searchHotkey.split('+')
type CommandOption = { type CommandOption = {
kind: 'command-option' kind: 'command-option'
@ -288,7 +287,7 @@ function GotoAnythingDialog() {
} }
useHotkey( useHotkey(
searchHotkey, GOTO_ANYTHING_HOTKEY,
(event) => { (event) => {
if (event.defaultPrevented) return if (event.defaultPrevented) return
if (!gotoAnythingDialogHandle.isOpen && isEditableShortcutTarget(event.target)) return if (!gotoAnythingDialogHandle.isOpen && isEditableShortcutTarget(event.target)) return
@ -436,7 +435,7 @@ function GotoAnythingDialog() {
)} )}
</div> </div>
<KbdGroup> <KbdGroup>
{searchShortcut.map((key) => ( {GOTO_ANYTHING_HOTKEY.split('+').map((key) => (
<Kbd key={key}>{formatForDisplay(key)}</Kbd> <Kbd key={key}>{formatForDisplay(key)}</Kbd>
))} ))}
</KbdGroup> </KbdGroup>

View File

@ -5,8 +5,7 @@ import { Kbd } from '@langgenius/dify-ui/kbd'
import { formatForDisplay } from '@tanstack/react-hotkeys' import { formatForDisplay } from '@tanstack/react-hotkeys'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle' import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle'
import { GOTO_ANYTHING_HOTKEY } from '@/app/components/goto-anything/hotkeys'
const searchShortcut = ['Mod', 'K']
export function MainNavSearchButton() { export function MainNavSearchButton() {
const { t } = useTranslation() const { t } = useTranslation()
@ -24,7 +23,7 @@ export function MainNavSearchButton() {
> >
<span aria-hidden className="i-custom-vender-main-nav-quick-search h-4 w-4" /> <span aria-hidden className="i-custom-vender-main-nav-quick-search h-4 w-4" />
<Kbd className="h-4.5 min-w-0 rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-1 py-0.5 system-2xs-medium-uppercase text-text-tertiary"> <Kbd className="h-4.5 min-w-0 rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-1 py-0.5 system-2xs-medium-uppercase text-text-tertiary">
{searchShortcut.map((key) => ( {GOTO_ANYTHING_HOTKEY.split('+').map((key) => (
<span key={key}>{formatForDisplay(key)}</span> <span key={key}>{formatForDisplay(key)}</span>
))} ))}
</Kbd> </Kbd>

View File

@ -5,8 +5,8 @@ import { WorkflowRunningStatus } from '@/app/components/workflow/types'
import RagPipelineHeader from '../index' import RagPipelineHeader from '../index'
import InputFieldButton from '../input-field-button' import InputFieldButton from '../input-field-button'
import Publisher from '../publisher' import Publisher from '../publisher'
import Popup from '../publisher/popup' import { Popup } from '../publisher/popup'
import RunMode from '../run-mode' import { RunMode } from '../run-mode'
vi.mock('@/config', async (importOriginal) => { vi.mock('@/config', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/config')>() const actual = await importOriginal<typeof import('@/config')>()
@ -1080,12 +1080,6 @@ describe('RunMode', () => {
expect(wrapper)!.toHaveClass('items-center') expect(wrapper)!.toHaveClass('items-center')
}) })
}) })
describe('Memoization', () => {
it('should be wrapped in React.memo', () => {
expect((RunMode as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo'))
})
})
}) })
describe('Integration', () => { describe('Integration', () => {

View File

@ -1,11 +1,35 @@
import { fireEvent, render, screen } from '@testing-library/react' import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import RunMode from '../run-mode' import { RunMode } from '../run-mode'
const mockHandleWorkflowStartRunInWorkflow = vi.fn() const mockHandleWorkflowStartRunInWorkflow = vi.fn()
const mockHandleStopRun = vi.fn() const mockHandleStopRun = vi.fn()
const mockSetIsPreparingDataSource = vi.fn() const mockSetIsPreparingDataSource = vi.fn()
const mockSetShowDebugAndPreviewPanel = vi.fn() const mockSetShowDebugAndPreviewPanel = vi.fn()
const hotkeyRegistrations = vi.hoisted(
() =>
new Map<
string,
{
callback: () => void
options?: { enabled?: boolean; ignoreInputs?: boolean; preventDefault?: boolean }
}
>(),
)
vi.mock('@tanstack/react-hotkeys', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-hotkeys')>()
return {
...actual,
useHotkey: (
hotkey: string,
callback: () => void,
options?: { enabled?: boolean; ignoreInputs?: boolean; preventDefault?: boolean },
) => {
hotkeyRegistrations.set(hotkey, { callback, options })
},
}
})
let mockWorkflowRunningData: { task_id: string; result: { status: string } } | undefined let mockWorkflowRunningData: { task_id: string; result: { status: string } } | undefined
let mockIsPreparingDataSource = false let mockIsPreparingDataSource = false
@ -75,6 +99,7 @@ describe('RunMode', () => {
vi.clearAllMocks() vi.clearAllMocks()
mockWorkflowRunningData = undefined mockWorkflowRunningData = undefined
mockIsPreparingDataSource = false mockIsPreparingDataSource = false
hotkeyRegistrations.clear()
}) })
describe('Idle state', () => { describe('Idle state', () => {
@ -109,6 +134,20 @@ describe('RunMode', () => {
expect(mockHandleWorkflowStartRunInWorkflow).toHaveBeenCalled() expect(mockHandleWorkflowStartRunInWorkflow).toHaveBeenCalled()
}) })
it('should run through the enabled application shortcut', () => {
render(<RunMode />)
const registration = hotkeyRegistrations.get('Alt+R')
registration?.callback()
expect(mockHandleWorkflowStartRunInWorkflow).toHaveBeenCalledOnce()
expect(registration?.options).toEqual({
enabled: true,
ignoreInputs: true,
preventDefault: true,
})
})
}) })
describe('Running state', () => { describe('Running state', () => {

View File

@ -0,0 +1,4 @@
import type { Hotkey } from '@tanstack/react-hotkeys'
export const RAG_PIPELINE_RUN_HOTKEY = 'Alt+R' satisfies Hotkey
export const RAG_PIPELINE_PUBLISH_HOTKEY = 'Mod+Shift+P' satisfies Hotkey

View File

@ -4,7 +4,7 @@ import Header from '@/app/components/workflow/header'
import { useStore } from '@/app/components/workflow/store' import { useStore } from '@/app/components/workflow/store'
import InputFieldButton from './input-field-button' import InputFieldButton from './input-field-button'
import Publisher from './publisher' import Publisher from './publisher'
import RunMode from './run-mode' import { RunMode } from './run-mode'
const RagPipelineHeader = () => { const RagPipelineHeader = () => {
const pipelineId = useStore((s) => s.pipelineId) const pipelineId = useStore((s) => s.pipelineId)

View File

@ -4,7 +4,7 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react' import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import Publisher from '../index' import Publisher from '../index'
import Popup from '../popup' import { Popup } from '../popup'
vi.mock('@/config', async (importOriginal) => { vi.mock('@/config', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/config')>() const actual = await importOriginal<typeof import('@/config')>()
@ -971,13 +971,6 @@ describe('publisher', () => {
}) })
}) })
describe('Component Memoization', () => {
it('should be memoized with React.memo', () => {
expect(Popup).toBeDefined()
expect((Popup as unknown as { $$typeof?: symbol }).$$typeof?.toString()).toContain('Symbol')
})
})
describe('Prop Variations', () => { describe('Prop Variations', () => {
it('should display correct width when permission is allowed', () => { it('should display correct width when permission is allowed', () => {
mockIsAllowPublishAsCustomKnowledgePipelineTemplate.mockReturnValue(true) mockIsAllowPublishAsCustomKnowledgePipelineTemplate.mockReturnValue(true)

View File

@ -1,6 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react' import { fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import Popup from '../popup' import { Popup } from '../popup'
vi.mock('@langgenius/dify-ui/alert-dialog', () => ({ vi.mock('@langgenius/dify-ui/alert-dialog', () => ({
AlertDialog: ({ AlertDialog: ({

View File

@ -17,7 +17,7 @@ import {
usePublishAsCustomizedPipeline, usePublishAsCustomizedPipeline,
} from '@/service/use-pipeline' } from '@/service/use-pipeline'
import PublishAsKnowledgePipelineModal from '../../publish-as-knowledge-pipeline-modal' import PublishAsKnowledgePipelineModal from '../../publish-as-knowledge-pipeline-modal'
import Popup from './popup' import { Popup } from './popup'
const Publisher = () => { const Publisher = () => {
const { t } = useTranslation() const { t } = useTranslation()

View File

@ -16,7 +16,7 @@ import { RiArrowRightUpLine, RiPlayCircleLine, RiTerminalBoxLine } from '@remixi
import { formatForDisplay, useHotkey } from '@tanstack/react-hotkeys' import { formatForDisplay, useHotkey } from '@tanstack/react-hotkeys'
import { useBoolean } from 'ahooks' import { useBoolean } from 'ahooks'
import { useAtomValue } from 'jotai' import { useAtomValue } from 'jotai'
import { memo, useCallback, useState } from 'react' import { useCallback, useState } from 'react'
import { Trans, useTranslation } from 'react-i18next' import { Trans, useTranslation } from 'react-i18next'
import { trackEvent } from '@/app/components/base/amplitude' import { trackEvent } from '@/app/components/base/amplitude'
import Divider from '@/app/components/base/divider' import Divider from '@/app/components/base/divider'
@ -42,8 +42,8 @@ import { useInvalid } from '@/service/use-base'
import { publishedPipelineInfoQueryKeyPrefix } from '@/service/use-pipeline' import { publishedPipelineInfoQueryKeyPrefix } from '@/service/use-pipeline'
import { usePublishWorkflow } from '@/service/use-workflow' import { usePublishWorkflow } from '@/service/use-workflow'
import { getDatasetACLCapabilities } from '@/utils/permission' import { getDatasetACLCapabilities } from '@/utils/permission'
import { RAG_PIPELINE_PUBLISH_HOTKEY } from '../hotkeys'
const PUBLISH_SHORTCUT = ['Mod', 'Shift', 'P']
type PopupProps = { type PopupProps = {
onRequestClose?: () => void onRequestClose?: () => void
confirmVisible?: boolean confirmVisible?: boolean
@ -53,14 +53,14 @@ type PopupProps = {
onShowPublishAsKnowledgePipelineModal?: () => void onShowPublishAsKnowledgePipelineModal?: () => void
} }
const Popup = ({ export function Popup({
onRequestClose, onRequestClose,
confirmVisible: controlledConfirmVisible, confirmVisible: controlledConfirmVisible,
onShowConfirm, onShowConfirm,
onHideConfirm, onHideConfirm,
isPublishingAsCustomizedPipeline = false, isPublishingAsCustomizedPipeline = false,
onShowPublishAsKnowledgePipelineModal, onShowPublishAsKnowledgePipelineModal,
}: PopupProps) => { }: PopupProps) {
const { t } = useTranslation() const { t } = useTranslation()
const { datasetId } = useParams() const { datasetId } = useParams()
const { push } = useRouter() const { push } = useRouter()
@ -182,10 +182,10 @@ const Popup = ({
handleHideConfirm, handleHideConfirm,
], ],
) )
useHotkey('Mod+Shift+P', (e) => { useHotkey(RAG_PIPELINE_PUBLISH_HOTKEY, () => void handlePublish(), {
e.preventDefault() enabled: !published && !publishing,
if (published) return ignoreInputs: true,
handlePublish() preventDefault: true,
}) })
const goToAddDocuments = useCallback(() => { const goToAddDocuments = useCallback(() => {
if (isAddDocumentsDisabled) return if (isAddDocumentsDisabled) return
@ -243,7 +243,7 @@ const Popup = ({
<div className="flex gap-1"> <div className="flex gap-1">
<span>{t(($) => $['common.publishUpdate'], { ns: 'workflow' })}</span> <span>{t(($) => $['common.publishUpdate'], { ns: 'workflow' })}</span>
<KbdGroup> <KbdGroup>
{PUBLISH_SHORTCUT.map((key) => ( {RAG_PIPELINE_PUBLISH_HOTKEY.split('+').map((key) => (
<Kbd key={key} color="white"> <Kbd key={key} color="white">
{formatForDisplay(key)} {formatForDisplay(key)}
</Kbd> </Kbd>
@ -334,4 +334,3 @@ const Popup = ({
</div> </div>
) )
} }
export default memo(Popup)

View File

@ -1,8 +1,7 @@
import { cn } from '@langgenius/dify-ui/cn' import { cn } from '@langgenius/dify-ui/cn'
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd' import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
import { RiCloseLine, RiDatabase2Line, RiLoader2Line, RiPlayLargeLine } from '@remixicon/react' import { RiCloseLine, RiDatabase2Line, RiLoader2Line, RiPlayLargeLine } from '@remixicon/react'
import { formatForDisplay } from '@tanstack/react-hotkeys' import { formatForDisplay, useHotkey } from '@tanstack/react-hotkeys'
import * as React from 'react'
import { useCallback } from 'react' import { useCallback } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { StopCircle } from '@/app/components/base/icons/src/vender/line/mediaAndDevices' import { StopCircle } from '@/app/components/base/icons/src/vender/line/mediaAndDevices'
@ -12,12 +11,13 @@ import { useStore, useWorkflowStore } from '@/app/components/workflow/store'
import { WorkflowRunningStatus } from '@/app/components/workflow/types' import { WorkflowRunningStatus } from '@/app/components/workflow/types'
import { EVENT_WORKFLOW_STOP } from '@/app/components/workflow/variable-inspect/types' import { EVENT_WORKFLOW_STOP } from '@/app/components/workflow/variable-inspect/types'
import { useEventEmitterContextContext } from '@/context/event-emitter' import { useEventEmitterContextContext } from '@/context/event-emitter'
import { RAG_PIPELINE_RUN_HOTKEY } from './hotkeys'
type RunModeProps = { type RunModeProps = {
text?: string text?: string
} }
const RunMode = ({ text }: RunModeProps) => { export function RunMode({ text }: RunModeProps) {
const { t } = useTranslation() const { t } = useTranslation()
const { handleWorkflowStartRunInWorkflow } = useWorkflowStartRun() const { handleWorkflowStartRunInWorkflow } = useWorkflowStartRun()
const { handleStopRun } = useWorkflowRun() const { handleStopRun } = useWorkflowRun()
@ -44,6 +44,17 @@ const RunMode = ({ text }: RunModeProps) => {
if (v.type === EVENT_WORKFLOW_STOP) handleStop() if (v.type === EVENT_WORKFLOW_STOP) handleStop()
}) })
function handleRun() {
if (isDisabled) return
handleWorkflowStartRunInWorkflow()
}
useHotkey(RAG_PIPELINE_RUN_HOTKEY, handleRun, {
enabled: !isDisabled,
ignoreInputs: true,
preventDefault: true,
})
if (!canRun) return null if (!canRun) return null
return ( return (
@ -55,9 +66,7 @@ const RunMode = ({ text }: RunModeProps) => {
isDisabled && 'cursor-not-allowed bg-state-accent-hover', isDisabled && 'cursor-not-allowed bg-state-accent-hover',
isDisabled ? 'rounded-l-md' : 'rounded-md', isDisabled ? 'rounded-l-md' : 'rounded-md',
)} )}
onClick={() => { onClick={handleRun}
if (canRun) handleWorkflowStartRunInWorkflow()
}}
disabled={isDisabled} disabled={isDisabled}
> >
{!isDisabled && ( {!isDisabled && (
@ -82,7 +91,7 @@ const RunMode = ({ text }: RunModeProps) => {
)} )}
{!isDisabled && ( {!isDisabled && (
<KbdGroup> <KbdGroup>
{['Alt', 'R'].map((key) => ( {RAG_PIPELINE_RUN_HOTKEY.split('+').map((key) => (
<Kbd key={key}>{formatForDisplay(key)}</Kbd> <Kbd key={key}>{formatForDisplay(key)}</Kbd>
))} ))}
</KbdGroup> </KbdGroup>
@ -113,5 +122,3 @@ const RunMode = ({ text }: RunModeProps) => {
</div> </div>
) )
} }
export default React.memo(RunMode)

View File

@ -184,7 +184,7 @@ vi.mock('@/features/tag-management/components/tag-filter', () => ({
})) }))
vi.mock('@/app/components/snippets/create-snippet-dialog', () => ({ vi.mock('@/app/components/snippets/create-snippet-dialog', () => ({
default: () => null, CreateSnippetDialog: () => null,
})) }))
vi.mock('@/features/tag-management/components/tag-selector', () => ({ vi.mock('@/features/tag-management/components/tag-selector', () => ({

View File

@ -22,7 +22,7 @@ import { toast } from '@langgenius/dify-ui/toast'
import { useAtomValue } from 'jotai' import { useAtomValue } from 'jotai'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import CreateSnippetDialog from '@/app/components/snippets/create-snippet-dialog' import { CreateSnippetDialog } from '@/app/components/snippets/create-snippet-dialog'
import { import {
canCreateAndModifySnippets, canCreateAndModifySnippets,
canManageSnippets, canManageSnippets,

View File

@ -4,7 +4,7 @@ import { Button } from '@langgenius/dify-ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import CreateSnippetDialog from '@/app/components/snippets/create-snippet-dialog' import { CreateSnippetDialog } from '@/app/components/snippets/create-snippet-dialog'
import { useCreateSnippet } from '@/app/components/snippets/hooks/use-create-snippet' import { useCreateSnippet } from '@/app/components/snippets/hooks/use-create-snippet'
import ImportSnippetDSLDialog from '@/app/components/snippets/import-snippet-dsl-dialog' import ImportSnippetDSLDialog from '@/app/components/snippets/import-snippet-dsl-dialog'

View File

@ -3,15 +3,43 @@ import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
import { PipelineInputVarType } from '@/models/pipeline' import { PipelineInputVarType } from '@/models/pipeline'
import { expectLoadingButton } from '@/test/button' import { expectLoadingButton } from '@/test/button'
import CreateSnippetDialog from '../create-snippet-dialog' import { CreateSnippetDialog } from '../create-snippet-dialog'
let capturedKeyPressHandler: (() => void) | undefined let capturedKeyPressHandler: (() => void) | undefined
let capturedHotkey: string | undefined
let capturedHotkeyOptions:
| {
enabled?: boolean
ignoreInputs?: boolean
preventDefault?: boolean
stopPropagation?: boolean
target?: React.RefObject<HTMLElement | null>
}
| undefined
vi.mock('ahooks', () => ({ vi.mock('@tanstack/react-hotkeys', async (importOriginal) => {
useKeyPress: (_keys: string[], handler: () => void) => { const actual = await importOriginal<typeof import('@tanstack/react-hotkeys')>()
capturedKeyPressHandler = handler return {
}, ...actual,
})) useHotkey: (
hotkey: string,
handler: () => void,
options?: {
enabled?: boolean
ignoreInputs?: boolean
preventDefault?: boolean
stopPropagation?: boolean
target?: React.RefObject<HTMLElement | null>
},
) => {
capturedHotkey = hotkey
capturedKeyPressHandler = () => {
if (options?.enabled !== false) handler()
}
capturedHotkeyOptions = options
},
}
})
const selectedGraph: SnippetCanvasData = { const selectedGraph: SnippetCanvasData = {
nodes: [], nodes: [],
@ -32,6 +60,8 @@ describe('CreateSnippetDialog', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
capturedKeyPressHandler = undefined capturedKeyPressHandler = undefined
capturedHotkey = undefined
capturedHotkeyOptions = undefined
}) })
it('should submit trimmed snippet values with the selected graph and input fields', async () => { it('should submit trimmed snippet values with the selected graph and input fields', async () => {
@ -178,6 +208,14 @@ describe('CreateSnippetDialog', () => {
name: 'Keyboard snippet', name: 'Keyboard snippet',
}), }),
) )
expect(capturedHotkeyOptions).toMatchObject({
enabled: true,
ignoreInputs: false,
preventDefault: false,
stopPropagation: false,
})
expect(capturedHotkeyOptions?.target?.current).toBe(screen.getByRole('dialog'))
expect(capturedHotkey).toBe('Mod+Enter')
}) })
it('should disable form controls while submitting', () => { it('should disable form controls while submitting', () => {

View File

@ -2,7 +2,7 @@
import { DetailSidebarFrame } from '@/app/components/detail-sidebar' import { DetailSidebarFrame } from '@/app/components/detail-sidebar'
import { SnippetDetailSection } from './snippet-detail-section' import { SnippetDetailSection } from './snippet-detail-section'
import SnippetDetailTop from './snippet-detail-top' import { SnippetDetailTop } from './snippet-detail-top'
export function SnippetDetailSidebar() { export function SnippetDetailSidebar() {
return ( return (

View File

@ -6,19 +6,18 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/too
import { formatForDisplay } from '@tanstack/react-hotkeys' import { formatForDisplay } from '@tanstack/react-hotkeys'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import SidebarLeftArrowIcon from '@/app/components/base/icons/src/vender/SidebarLeftArrowIcon' import SidebarLeftArrowIcon from '@/app/components/base/icons/src/vender/SidebarLeftArrowIcon'
import { DetailSidebarToggleButton } from '@/app/components/detail-sidebar/toggle-button'
import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle' import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle'
import { GOTO_ANYTHING_HOTKEY } from '@/app/components/goto-anything/hotkeys'
import Link from '@/next/link' import Link from '@/next/link'
import { useRouter } from '@/next/navigation' import { useRouter } from '@/next/navigation'
import ToggleButton from '../../app-sidebar/toggle-button'
type SnippetDetailTopProps = { type SnippetDetailTopProps = {
expand?: boolean expand?: boolean
onToggle?: () => void onToggle?: () => void
} }
const SEARCH_SHORTCUT = ['Mod', 'K'] export function SnippetDetailTop({ expand = true, onToggle }: SnippetDetailTopProps) {
const SnippetDetailTop = ({ expand = true, onToggle }: SnippetDetailTopProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const router = useRouter() const router = useRouter()
@ -26,9 +25,9 @@ const SnippetDetailTop = ({ expand = true, onToggle }: SnippetDetailTopProps) =>
return ( return (
<div className="flex w-full items-center justify-center px-3 pt-2 pb-1"> <div className="flex w-full items-center justify-center px-3 pt-2 pb-1">
{onToggle && ( {onToggle && (
<ToggleButton <DetailSidebarToggleButton
expand={expand} expand={expand}
handleToggle={onToggle} onToggle={onToggle}
icon={<SidebarLeftArrowIcon aria-hidden className="size-4" />} icon={<SidebarLeftArrowIcon aria-hidden className="size-4" />}
className="size-8 rounded-[10px] border-0 bg-transparent px-0 text-text-tertiary shadow-none hover:border-0 hover:bg-state-base-hover hover:text-text-secondary" className="size-8 rounded-[10px] border-0 bg-transparent px-0 text-text-tertiary shadow-none hover:border-0 hover:bg-state-base-hover hover:text-text-secondary"
/> />
@ -88,16 +87,16 @@ const SnippetDetailTop = ({ expand = true, onToggle }: SnippetDetailTopProps) =>
> >
<span className="px-0.5">{t(($) => $['gotoAnything.quickAction'], { ns: 'app' })}</span> <span className="px-0.5">{t(($) => $['gotoAnything.quickAction'], { ns: 'app' })}</span>
<KbdGroup> <KbdGroup>
{SEARCH_SHORTCUT.map((key) => ( {GOTO_ANYTHING_HOTKEY.split('+').map((key) => (
<Kbd key={key}>{formatForDisplay(key)}</Kbd> <Kbd key={key}>{formatForDisplay(key)}</Kbd>
))} ))}
</KbdGroup> </KbdGroup>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
{onToggle && ( {onToggle && (
<ToggleButton <DetailSidebarToggleButton
expand={expand} expand={expand}
handleToggle={onToggle} onToggle={onToggle}
icon={<SidebarLeftArrowIcon aria-hidden className="size-4" />} icon={<SidebarLeftArrowIcon aria-hidden className="size-4" />}
className="size-8 rounded-[10px] border-0 bg-transparent px-0 text-text-tertiary shadow-none hover:border-0 hover:bg-state-base-hover hover:text-text-secondary" className="size-8 rounded-[10px] border-0 bg-transparent px-0 text-text-tertiary shadow-none hover:border-0 hover:bg-state-base-hover hover:text-text-secondary"
/> />
@ -105,5 +104,3 @@ const SnippetDetailTop = ({ expand = true, onToggle }: SnippetDetailTopProps) =>
</div> </div>
) )
} }
export default SnippetDetailTop

View File

@ -4,8 +4,8 @@ import { cn } from '@langgenius/dify-ui/cn'
import * as React from 'react' import * as React from 'react'
import { useCallback } from 'react' import { useCallback } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { TEST_RUN_MENU_HOTKEY } from '@/app/components/workflow/header/shortcuts.ts'
import { useWorkflowRun, useWorkflowStartRun } from '@/app/components/workflow/hooks' import { useWorkflowRun, useWorkflowStartRun } from '@/app/components/workflow/hooks'
import { TEST_RUN_MENU_HOTKEY } from '@/app/components/workflow/hotkeys'
import { ShortcutKbd } from '@/app/components/workflow/shortcuts/shortcut-kbd' import { ShortcutKbd } from '@/app/components/workflow/shortcuts/shortcut-kbd'
import { useStore } from '@/app/components/workflow/store' import { useStore } from '@/app/components/workflow/store'
import { WorkflowRunningStatus } from '@/app/components/workflow/types' import { WorkflowRunningStatus } from '@/app/components/workflow/types'

View File

@ -1,14 +1,24 @@
'use client' 'use client'
import type { Hotkey } from '@tanstack/react-hotkeys'
import type { SnippetCanvasData, SnippetInputField } from '@/models/snippet' import type { SnippetCanvasData, SnippetInputField } from '@/models/snippet'
import { Button } from '@langgenius/dify-ui/button' import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' import {
Dialog,
DialogBackdrop,
DialogCloseButton,
DialogPopup,
DialogPortal,
DialogTitle,
} from '@langgenius/dify-ui/dialog'
import { Input } from '@langgenius/dify-ui/input' import { Input } from '@langgenius/dify-ui/input'
import { Textarea } from '@langgenius/dify-ui/textarea' import { Textarea } from '@langgenius/dify-ui/textarea'
import { useKeyPress } from 'ahooks' import { useHotkey } from '@tanstack/react-hotkeys'
import { useCallback, useState } from 'react' import { useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
const CREATE_SNIPPET_HOTKEY = 'Mod+Enter' satisfies Hotkey
export type CreateSnippetDialogPayload = { export type CreateSnippetDialogPayload = {
name: string name: string
description: string description: string
@ -39,7 +49,7 @@ const defaultGraph: SnippetCanvasData = {
viewport: { x: 0, y: 0, zoom: 1 }, viewport: { x: 0, y: 0, zoom: 1 },
} }
function CreateSnippetDialog({ export function CreateSnippetDialog({
isOpen, isOpen,
selectedGraph, selectedGraph,
inputFields, inputFields,
@ -51,20 +61,21 @@ function CreateSnippetDialog({
initialValue, initialValue,
}: CreateSnippetDialogProps) { }: CreateSnippetDialogProps) {
const { t } = useTranslation() const { t } = useTranslation()
const popupRef = useRef<HTMLDivElement>(null)
const [name, setName] = useState(initialValue?.name ?? '') const [name, setName] = useState(initialValue?.name ?? '')
const [description, setDescription] = useState(initialValue?.description ?? '') const [description, setDescription] = useState(initialValue?.description ?? '')
const resetForm = useCallback(() => { function resetForm() {
setName('') setName('')
setDescription('') setDescription('')
}, []) }
const handleClose = useCallback(() => { function handleClose() {
resetForm() resetForm()
onClose() onClose()
}, [onClose, resetForm]) }
const handleConfirm = useCallback(() => { function handleConfirm() {
const trimmedName = name.trim() const trimmedName = name.trim()
const trimmedDescription = description.trim() const trimmedDescription = description.trim()
@ -78,75 +89,79 @@ function CreateSnippetDialog({
} }
onConfirm(payload) onConfirm(payload)
}, [description, inputFields, name, onConfirm, selectedGraph]) }
useKeyPress(['meta.enter', 'ctrl.enter'], () => { useHotkey(CREATE_SNIPPET_HOTKEY, handleConfirm, {
if (!isOpen) return enabled: isOpen && !isSubmitting,
ignoreInputs: false,
if (isSubmitting) return preventDefault: false,
stopPropagation: false,
handleConfirm() target: popupRef,
}) })
return ( return (
<> <>
<Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}> <Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}>
<DialogContent className="w-120 max-w-120 p-0"> <DialogPortal>
<DialogCloseButton /> <DialogBackdrop />
<DialogPopup
ref={popupRef}
className="fixed top-1/2 left-1/2 max-h-[80dvh] w-120 max-w-120 -translate-x-1/2 -translate-y-1/2 overflow-y-auto overscroll-contain p-0"
>
<DialogCloseButton />
<div className="px-6 pt-6 pb-3"> <div className="px-6 pt-6 pb-3">
<DialogTitle className="title-2xl-semi-bold text-text-primary"> <DialogTitle className="title-2xl-semi-bold text-text-primary">
{title || t(($) => $['snippet.createDialogTitle'], { ns: 'workflow' })} {title || t(($) => $['snippet.createDialogTitle'], { ns: 'workflow' })}
</DialogTitle> </DialogTitle>
</div>
<div className="space-y-4 px-6 py-2">
<div>
<div className="mb-1 flex h-6 items-center system-sm-medium text-text-secondary">
{t(($) => $['snippet.nameLabel'], { ns: 'workflow' })}
</div>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t(($) => $['snippet.namePlaceholder'], { ns: 'workflow' }) || ''}
disabled={isSubmitting}
autoFocus
/>
</div> </div>
<div> <div className="space-y-4 px-6 py-2">
<div className="mb-1 flex h-6 items-center system-sm-medium text-text-secondary"> <div>
{t(($) => $['snippet.descriptionLabel'], { ns: 'workflow' })} <div className="mb-1 flex h-6 items-center system-sm-medium text-text-secondary">
{t(($) => $['snippet.nameLabel'], { ns: 'workflow' })}
</div>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t(($) => $['snippet.namePlaceholder'], { ns: 'workflow' }) || ''}
disabled={isSubmitting}
autoFocus
/>
</div> </div>
<Textarea
className="resize-none"
value={description}
onValueChange={(value) => setDescription(value)}
placeholder={
t(($) => $['snippet.descriptionPlaceholder'], { ns: 'workflow' }) || ''
}
disabled={isSubmitting}
/>
</div>
</div>
<div className="flex items-center justify-end gap-2 px-6 pb-6"> <div>
<Button disabled={isSubmitting} onClick={handleClose}> <div className="mb-1 flex h-6 items-center system-sm-medium text-text-secondary">
{t(($) => $['operation.cancel'], { ns: 'common' })} {t(($) => $['snippet.descriptionLabel'], { ns: 'workflow' })}
</Button> </div>
<Button <Textarea
variant="primary" className="resize-none"
disabled={!name.trim() || isSubmitting} value={description}
loading={isSubmitting} onValueChange={(value) => setDescription(value)}
onClick={handleConfirm} placeholder={
> t(($) => $['snippet.descriptionPlaceholder'], { ns: 'workflow' }) || ''
{confirmText || t(($) => $['snippet.confirm'], { ns: 'workflow' })} }
</Button> disabled={isSubmitting}
</div> />
</DialogContent> </div>
</div>
<div className="flex items-center justify-end gap-2 px-6 pb-6">
<Button disabled={isSubmitting} onClick={handleClose}>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</Button>
<Button
variant="primary"
disabled={!name.trim() || isSubmitting}
loading={isSubmitting}
onClick={handleConfirm}
>
{confirmText || t(($) => $['snippet.confirm'], { ns: 'workflow' })}
</Button>
</div>
</DialogPopup>
</DialogPortal>
</Dialog> </Dialog>
</> </>
) )
} }
export default CreateSnippetDialog

View File

@ -2,7 +2,7 @@ import type { Edge, Node, ValueSelector } from '@/app/components/workflow/types'
import type { SnippetCanvasData, SnippetInputField } from '@/models/snippet' import type { SnippetCanvasData, SnippetInputField } from '@/models/snippet'
import { useCallback, useState } from 'react' import { useCallback, useState } from 'react'
import { getNodesBounds } from 'reactflow' import { getNodesBounds } from 'reactflow'
import CreateSnippetDialog from '@/app/components/snippets/create-snippet-dialog' import { CreateSnippetDialog } from '@/app/components/snippets/create-snippet-dialog'
import { PipelineInputVarType } from '@/models/pipeline' import { PipelineInputVarType } from '@/models/pipeline'
import { useCreateSnippet } from './use-create-snippet' import { useCreateSnippet } from './use-create-snippet'

View File

@ -0,0 +1,33 @@
import { detectPlatform } from '@tanstack/react-hotkeys'
import { shouldPreventWorkflowBrowserDefault } from '../hotkeys'
const primaryModifier = detectPlatform() === 'mac' ? { metaKey: true } : { ctrlKey: true }
function createKeyboardEvent(key: string, modifiers: KeyboardEventInit = {}) {
return new KeyboardEvent('keydown', { key, ...modifiers })
}
describe('workflow browser default hotkeys', () => {
it.each(['d', 'z', 'y', 's'])('matches the exact Mod+%s browser guard', (key) => {
expect(shouldPreventWorkflowBrowserDefault(createKeyboardEvent(key, primaryModifier))).toBe(
true,
)
})
it('matches the alternate redo hotkey', () => {
expect(
shouldPreventWorkflowBrowserDefault(
createKeyboardEvent('z', { ...primaryModifier, shiftKey: true }),
),
).toBe(true)
})
it('does not match plain or over-modified keys', () => {
expect(shouldPreventWorkflowBrowserDefault(createKeyboardEvent('s'))).toBe(false)
expect(
shouldPreventWorkflowBrowserDefault(
createKeyboardEvent('s', { ...primaryModifier, shiftKey: true }),
),
).toBe(false)
})
})

View File

@ -80,7 +80,7 @@ vi.mock('@/app/components/snippets/hooks/use-create-snippet', async () => {
}) })
vi.mock('@/app/components/snippets/create-snippet-dialog', () => ({ vi.mock('@/app/components/snippets/create-snippet-dialog', () => ({
default: (props: { CreateSnippetDialog: (props: {
isOpen: boolean isOpen: boolean
selectedGraph?: { selectedGraph?: {
nodes: Node[] nodes: Node[]

View File

@ -147,7 +147,7 @@ vi.mock('../run-and-history', () => ({
})) }))
vi.mock('../version-history-button', () => ({ vi.mock('../version-history-button', () => ({
default: ({ onClick }: { onClick: () => void }) => ( VersionHistoryButton: ({ onClick }: { onClick: () => void }) => (
<button type="button" onClick={onClick}> <button type="button" onClick={onClick}>
version-history version-history
</button> </button>

View File

@ -1,5 +1,5 @@
import { act, fireEvent, render, screen } from '@testing-library/react' import { act, fireEvent, render, screen } from '@testing-library/react'
import VersionHistoryButton from '../version-history-button' import { VersionHistoryButton } from '../version-history-button'
let mockTheme: 'light' | 'dark' = 'light' let mockTheme: 'light' | 'dark' = 'light'
const hotkeyRegistrations = vi.hoisted( const hotkeyRegistrations = vi.hoisted(

View File

@ -13,7 +13,7 @@ import GlobalVariableButton from './global-variable-button'
import OnlineUsers from './online-users' import OnlineUsers from './online-users'
import RunAndHistory from './run-and-history' import RunAndHistory from './run-and-history'
import ScrollToSelectedNodeButton from './scroll-to-selected-node-button' import ScrollToSelectedNodeButton from './scroll-to-selected-node-button'
import VersionHistoryButton from './version-history-button' import { VersionHistoryButton } from './version-history-button'
export type HeaderInNormalProps = { export type HeaderInNormalProps = {
components?: { components?: {

View File

@ -19,7 +19,7 @@ import { WorkflowRunningStatus } from '@/app/components/workflow/types'
import { EVENT_WORKFLOW_STOP } from '@/app/components/workflow/variable-inspect/types' import { EVENT_WORKFLOW_STOP } from '@/app/components/workflow/variable-inspect/types'
import { useEventEmitterContextContext } from '@/context/event-emitter' import { useEventEmitterContextContext } from '@/context/event-emitter'
import { useDynamicTestRunOptions } from '../hooks/use-dynamic-test-run-options' import { useDynamicTestRunOptions } from '../hooks/use-dynamic-test-run-options'
import { TEST_RUN_MENU_HOTKEY } from './shortcuts' import { TEST_RUN_MENU_HOTKEY } from '../hotkeys'
import TestRunMenu, { TriggerType } from './test-run-menu' import TestRunMenu, { TriggerType } from './test-run-menu'
type RunModeProps = { type RunModeProps = {

View File

@ -1 +0,0 @@
export const TEST_RUN_MENU_HOTKEY = 'Alt+R'

View File

@ -32,7 +32,9 @@ export const OptionRow = ({
<div className="flex size-6 shrink-0 items-center justify-center">{option.icon}</div> <div className="flex size-6 shrink-0 items-center justify-center">{option.icon}</div>
<span className="ml-2 truncate">{option.name}</span> <span className="ml-2 truncate">{option.name}</span>
</div> </div>
{shortcutKey && <ShortcutKbd hotkey={shortcutKey} className="ml-2" textColor="secondary" />} {shortcutKey && (
<ShortcutKbd displayKey={shortcutKey} className="ml-2" textColor="secondary" />
)}
</DropdownMenuItem> </DropdownMenuItem>
) )
} }

View File

@ -1,21 +1,17 @@
import type { FC } from 'react'
import { Button } from '@langgenius/dify-ui/button' import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn' import { cn } from '@langgenius/dify-ui/cn'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { useHotkey } from '@tanstack/react-hotkeys' import { useHotkey } from '@tanstack/react-hotkeys'
import * as React from 'react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import useTheme from '@/hooks/use-theme' import useTheme from '@/hooks/use-theme'
import { VERSION_HISTORY_HOTKEY } from '../hotkeys'
import { ShortcutKbd } from '../shortcuts/shortcut-kbd' import { ShortcutKbd } from '../shortcuts/shortcut-kbd'
const VERSION_HISTORY_HOTKEY = 'Mod+Shift+H'
type VersionHistoryButtonProps = { type VersionHistoryButtonProps = {
onClick: () => Promise<unknown> | unknown onClick: () => Promise<unknown> | unknown
} }
const PopupContent = React.memo(() => { function PopupContent() {
const { t } = useTranslation() const { t } = useTranslation()
return ( return (
<div className="flex items-center gap-x-1"> <div className="flex items-center gap-x-1">
@ -25,20 +21,15 @@ const PopupContent = React.memo(() => {
<ShortcutKbd hotkey={VERSION_HISTORY_HOTKEY} bgColor="gray" textColor="secondary" /> <ShortcutKbd hotkey={VERSION_HISTORY_HOTKEY} bgColor="gray" textColor="secondary" />
</div> </div>
) )
}) }
PopupContent.displayName = 'PopupContent' export function VersionHistoryButton({ onClick }: VersionHistoryButtonProps) {
const VersionHistoryButton: FC<VersionHistoryButtonProps> = ({ onClick }) => {
const { theme } = useTheme() const { theme } = useTheme()
const handleViewVersionHistory = useCallback(async () => {
await onClick?.()
}, [onClick])
useHotkey( useHotkey(
VERSION_HISTORY_HOTKEY, VERSION_HISTORY_HOTKEY,
() => { () => {
void handleViewVersionHistory() void onClick()
}, },
{ {
ignoreInputs: true, ignoreInputs: true,
@ -54,7 +45,7 @@ const VersionHistoryButton: FC<VersionHistoryButtonProps> = ({ onClick }) => {
'rounded-lg border border-transparent p-2', 'rounded-lg border border-transparent p-2',
theme === 'dark' && 'border-black/5 bg-white/10 backdrop-blur-xs', theme === 'dark' && 'border-black/5 bg-white/10 backdrop-blur-xs',
)} )}
onClick={handleViewVersionHistory} onClick={onClick}
> >
<span className="i-ri-history-line size-4 text-components-button-secondary-text" /> <span className="i-ri-history-line size-4 text-components-button-secondary-text" />
</Button> </Button>
@ -66,5 +57,3 @@ const VersionHistoryButton: FC<VersionHistoryButtonProps> = ({ onClick }) => {
</Tooltip> </Tooltip>
) )
} }
export default VersionHistoryButton

View File

@ -0,0 +1,19 @@
import type { Hotkey } from '@tanstack/react-hotkeys'
import { matchesKeyboardEvent } from '@tanstack/react-hotkeys'
import { WORKFLOW_CANVAS_SHORTCUTS } from './shortcuts/definitions'
export const TEST_RUN_MENU_HOTKEY = 'Alt+R' satisfies Hotkey
export const VERSION_HISTORY_HOTKEY = 'Mod+Shift+H' satisfies Hotkey
const WORKFLOW_BROWSER_DEFAULT_GUARD_HOTKEYS = [
...WORKFLOW_CANVAS_SHORTCUTS['workflow.duplicate'].hotkeys,
...WORKFLOW_CANVAS_SHORTCUTS['workflow.undo'].hotkeys,
...WORKFLOW_CANVAS_SHORTCUTS['workflow.redo'].hotkeys,
'Mod+S',
] satisfies readonly Hotkey[]
export function shouldPreventWorkflowBrowserDefault(event: KeyboardEvent) {
return WORKFLOW_BROWSER_DEFAULT_GUARD_HOTKEYS.some((hotkey) =>
matchesKeyboardEvent(event, hotkey),
)
}

View File

@ -72,6 +72,7 @@ import {
import { HooksStoreContextProvider, useHooksStore } from './hooks-store' import { HooksStoreContextProvider, useHooksStore } from './hooks-store'
import { useWorkflowComment } from './hooks/use-workflow-comment' import { useWorkflowComment } from './hooks/use-workflow-comment'
import { useWorkflowSearch } from './hooks/use-workflow-search' import { useWorkflowSearch } from './hooks/use-workflow-search'
import { shouldPreventWorkflowBrowserDefault } from './hotkeys'
import CustomNode from './nodes' import CustomNode from './nodes'
import useMatchSchemaType from './nodes/_base/components/variable/use-match-schema-type' import useMatchSchemaType from './nodes/_base/components/variable/use-match-schema-type'
import CustomDataSourceEmptyNode from './nodes/data-source-empty' import CustomDataSourceEmptyNode from './nodes/data-source-empty'
@ -446,10 +447,7 @@ export const Workflow: FC<WorkflowProps> = memo(
}, [handleSyncWorkflowDraftWhenPageClose, handleBeforeUnload]) }, [handleSyncWorkflowDraftWhenPageClose, handleBeforeUnload])
useEventListener('keydown', (e) => { useEventListener('keydown', (e) => {
if ((e.key === 'd' || e.key === 'D') && (e.ctrlKey || e.metaKey)) e.preventDefault() if (shouldPreventWorkflowBrowserDefault(e)) e.preventDefault()
if ((e.key === 'z' || e.key === 'Z') && (e.ctrlKey || e.metaKey)) e.preventDefault()
if ((e.key === 'y' || e.key === 'Y') && (e.ctrlKey || e.metaKey)) e.preventDefault()
if ((e.key === 's' || e.key === 'S') && (e.ctrlKey || e.metaKey)) e.preventDefault()
}) })
useEventListener('mousemove', (e) => { useEventListener('mousemove', (e) => {
const containerClientRect = workflowContainerRef.current?.getBoundingClientRect() const containerClientRect = workflowContainerRef.current?.getBoundingClientRect()

View File

@ -1,4 +1,3 @@
import type { RegisterableHotkey } from '@tanstack/react-hotkeys'
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import type { WorkflowCanvasShortcutId } from '@/app/components/workflow/shortcuts/definitions' import type { WorkflowCanvasShortcutId } from '@/app/components/workflow/shortcuts/definitions'
import { ShortcutKbd } from '@/app/components/workflow/shortcuts/shortcut-kbd' import { ShortcutKbd } from '@/app/components/workflow/shortcuts/shortcut-kbd'
@ -9,17 +8,15 @@ export const NODE_ACTIONS_MENU_DELETE_ITEM_CLASS_NAME = `${NODE_ACTIONS_MENU_ITE
export function NodeActionsMenuItemContent({ export function NodeActionsMenuItemContent({
children, children,
hotkey,
shortcut, shortcut,
}: { }: {
children: ReactNode children: ReactNode
hotkey?: RegisterableHotkey | (string & {})
shortcut?: WorkflowCanvasShortcutId shortcut?: WorkflowCanvasShortcutId
}) { }) {
return ( return (
<> <>
<span className="min-w-0 truncate">{children}</span> <span className="min-w-0 truncate">{children}</span>
{(shortcut || hotkey) && <ShortcutKbd shortcut={shortcut} hotkey={hotkey} />} {shortcut && <ShortcutKbd shortcut={shortcut} />}
</> </>
) )
} }

View File

@ -1,4 +1,5 @@
import type { DeclaredOutputConfig } from '@dify/contracts/api/console/apps/types.gen' import type { DeclaredOutputConfig } from '@dify/contracts/api/console/apps/types.gen'
import type { Hotkey } from '@tanstack/react-hotkeys'
import type { EditableOutputConfig, EditingState, OutputDraft } from './utils' import type { EditableOutputConfig, EditingState, OutputDraft } from './utils'
import { Button } from '@langgenius/dify-ui/button' import { Button } from '@langgenius/dify-ui/button'
import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from '@langgenius/dify-ui/collapsible' import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from '@langgenius/dify-ui/collapsible'
@ -18,7 +19,8 @@ import {
OUTPUT_NAME_PATTERN_SOURCE, OUTPUT_NAME_PATTERN_SOURCE,
} from './utils' } from './utils'
const CONFIRM_HOTKEY = 'Mod+Enter' const CANCEL_HOTKEY = 'Escape' satisfies Hotkey
const CONFIRM_HOTKEY = 'Mod+Enter' satisfies Hotkey
function ConfirmHotkeyHint() { function ConfirmHotkeyHint() {
const displayKeys = formatForDisplay(CONFIRM_HOTKEY, { separatorToken: ' ' }) const displayKeys = formatForDisplay(CONFIRM_HOTKEY, { separatorToken: ' ' })
@ -70,8 +72,11 @@ export function OutputEditCard({
if (confirmDisabled) return if (confirmDisabled) return
onConfirm(createOutputFromDraft(draft, { includeDefaultValue: allowDefaultValue }), state) onConfirm(createOutputFromDraft(draft, { includeDefaultValue: allowDefaultValue }), state)
} }
useHotkey(CONFIRM_HOTKEY, handleConfirm, { target: editorRef, ignoreInputs: false }) useHotkey(CONFIRM_HOTKEY, handleConfirm, {
useHotkey('Escape', onCancel, { target: editorRef, ignoreInputs: false }) target: editorRef,
ignoreInputs: false,
})
useHotkey(CANCEL_HOTKEY, onCancel, { target: editorRef, ignoreInputs: false })
return ( return (
<div ref={editorRef}> <div ref={editorRef}>
<Form <Form

View File

@ -0,0 +1,65 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { AdvancedActions } from '../advanced-actions'
const hotkeyRegistrations = vi.hoisted(
() =>
new Map<
string,
{
callback: () => void
options?: { enabled?: boolean; ignoreInputs?: boolean }
}
>(),
)
vi.mock('@tanstack/react-hotkeys', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-hotkeys')>()
return {
...actual,
useHotkey: (
hotkey: string,
callback: () => void,
options?: { enabled?: boolean; ignoreInputs?: boolean },
) => {
hotkeyRegistrations.set(hotkey, { callback, options })
},
}
})
describe('AdvancedActions', () => {
beforeEach(() => {
hotkeyRegistrations.clear()
})
it('runs the matching actions from the buttons', async () => {
const user = userEvent.setup()
const onCancel = vi.fn()
const onConfirm = vi.fn()
render(<AdvancedActions isConfirmDisabled={false} onCancel={onCancel} onConfirm={onConfirm} />)
await user.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
await user.click(screen.getByRole('button', { name: /^common\.operation\.confirm/ }))
expect(onCancel).toHaveBeenCalledOnce()
expect(onConfirm).toHaveBeenCalledOnce()
})
it('registers the confirm shortcut for input fields', () => {
const onConfirm = vi.fn()
render(<AdvancedActions isConfirmDisabled={false} onCancel={vi.fn()} onConfirm={onConfirm} />)
const registration = hotkeyRegistrations.get('Mod+Enter')
registration?.callback()
expect(onConfirm).toHaveBeenCalledOnce()
expect(registration?.options).toEqual({ enabled: true, ignoreInputs: false })
})
it('disables both confirmation paths when confirmation is unavailable', () => {
render(<AdvancedActions isConfirmDisabled onCancel={vi.fn()} onConfirm={vi.fn()} />)
expect(screen.getByRole('button', { name: /^common\.operation\.confirm/ })).toBeDisabled()
expect(hotkeyRegistrations.get('Mod+Enter')?.options?.enabled).toBe(false)
})
})

View File

@ -1,11 +1,10 @@
import type { FC } from 'react' import type { Hotkey } from '@tanstack/react-hotkeys'
import { Button } from '@langgenius/dify-ui/button' import { Button } from '@langgenius/dify-ui/button'
import { useHotkey } from '@tanstack/react-hotkeys' import { useHotkey } from '@tanstack/react-hotkeys'
import * as React from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { ShortcutKbd } from '@/app/components/workflow/shortcuts/shortcut-kbd' import { ShortcutKbd } from '@/app/components/workflow/shortcuts/shortcut-kbd'
const JSON_SCHEMA_CONFIRM_HOTKEY = 'Mod+Enter' const JSON_SCHEMA_CONFIRM_HOTKEY = 'Mod+Enter' satisfies Hotkey
type AdvancedActionsProps = { type AdvancedActionsProps = {
isConfirmDisabled: boolean isConfirmDisabled: boolean
@ -13,7 +12,7 @@ type AdvancedActionsProps = {
onConfirm: () => void onConfirm: () => void
} }
const AdvancedActions: FC<AdvancedActionsProps> = ({ isConfirmDisabled, onCancel, onConfirm }) => { export function AdvancedActions({ isConfirmDisabled, onCancel, onConfirm }: AdvancedActionsProps) {
const { t } = useTranslation() const { t } = useTranslation()
useHotkey( useHotkey(
@ -45,5 +44,3 @@ const AdvancedActions: FC<AdvancedActionsProps> = ({ isConfirmDisabled, onCancel
</div> </div>
) )
} }
export default React.memo(AdvancedActions)

View File

@ -13,7 +13,7 @@ import { ArrayType, Type } from '../../../../types'
import { useMittContext } from '../context' import { useMittContext } from '../context'
import { useVisualEditorStore } from '../store' import { useVisualEditorStore } from '../store'
import Actions from './actions' import Actions from './actions'
import AdvancedActions from './advanced-actions' import { AdvancedActions } from './advanced-actions'
import AdvancedOptions from './advanced-options' import AdvancedOptions from './advanced-options'
import AutoWidthInput from './auto-width-input' import AutoWidthInput from './auto-width-input'
import RequiredSwitch from './required-switch' import RequiredSwitch from './required-switch'

View File

@ -8,7 +8,6 @@ import {
import { useCallback } from 'react' import { useCallback } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { FlowType } from '@/types/common' import { FlowType } from '@/types/common'
import { TEST_RUN_MENU_HOTKEY } from './header/shortcuts'
import { import {
useDSL, useDSL,
useIsChatMode, useIsChatMode,
@ -17,6 +16,7 @@ import {
useWorkflowStartRun, useWorkflowStartRun,
} from './hooks' } from './hooks'
import { useHooksStore } from './hooks-store' import { useHooksStore } from './hooks-store'
import { TEST_RUN_MENU_HOTKEY } from './hotkeys'
import { isSnippetCanvas } from './nodes/_base/hooks/snippet-input-field-vars' import { isSnippetCanvas } from './nodes/_base/hooks/snippet-input-field-vars'
import AddBlock from './operator/add-block' import AddBlock from './operator/add-block'
import { useOperator } from './operator/hooks' import { useOperator } from './operator/hooks'

View File

@ -1,4 +1,4 @@
import type { RegisterableHotkey } from '@tanstack/react-hotkeys' import type { Hotkey, IndividualKey } from '@tanstack/react-hotkeys'
export type WorkflowCanvasShortcutId = export type WorkflowCanvasShortcutId =
| 'workflow.delete' | 'workflow.delete'
@ -26,18 +26,26 @@ export type WorkflowCanvasHotkeyMeta = {
description: string description: string
} }
export type WorkflowCanvasShortcutDefinition = { type WorkflowCanvasShortcutDefinitionBase = {
id: WorkflowCanvasShortcutId id: WorkflowCanvasShortcutId
hotkeys: readonly RegisterableHotkey[]
displayHotkey?: RegisterableHotkey | (string & {})
name: string name: string
description: string description: string
} }
export const WORKFLOW_CANVAS_SHORTCUTS: Record< export type WorkflowCanvasHotkeyDefinition = WorkflowCanvasShortcutDefinitionBase & {
WorkflowCanvasShortcutId, hotkeys: readonly Hotkey[]
WorkflowCanvasShortcutDefinition displayHotkey?: Hotkey
> = { }
type WorkflowCanvasHoldKeyDefinition = WorkflowCanvasShortcutDefinitionBase & {
holdKey: IndividualKey
}
type WorkflowCanvasShortcutDefinition =
| WorkflowCanvasHotkeyDefinition
| WorkflowCanvasHoldKeyDefinition
export const WORKFLOW_CANVAS_SHORTCUTS = {
'workflow.delete': { 'workflow.delete': {
id: 'workflow.delete', id: 'workflow.delete',
hotkeys: ['Delete', 'Backspace'], hotkeys: ['Delete', 'Backspace'],
@ -139,16 +147,19 @@ export const WORKFLOW_CANVAS_SHORTCUTS: Record<
}, },
'workflow.dim-other-nodes': { 'workflow.dim-other-nodes': {
id: 'workflow.dim-other-nodes', id: 'workflow.dim-other-nodes',
hotkeys: [{ key: 'Shift', shift: true }], holdKey: 'Shift',
displayHotkey: 'Shift',
name: 'Dim other nodes', name: 'Dim other nodes',
description: 'Dim nodes outside the current workflow selection', description: 'Dim nodes outside the current workflow selection',
}, },
} } as const satisfies Record<WorkflowCanvasShortcutId, WorkflowCanvasShortcutDefinition>
export const getWorkflowCanvasShortcutDisplayHotkey = ( export const getWorkflowCanvasShortcutDisplayKey = (
id: WorkflowCanvasShortcutId, id: WorkflowCanvasShortcutId,
): RegisterableHotkey | (string & {}) => { ): Hotkey | IndividualKey => {
const shortcut = WORKFLOW_CANVAS_SHORTCUTS[id] const shortcut = WORKFLOW_CANVAS_SHORTCUTS[id]
return shortcut.displayHotkey ?? shortcut.hotkeys[0]!
if ('displayHotkey' in shortcut && shortcut.displayHotkey) return shortcut.displayHotkey
if ('hotkeys' in shortcut) return shortcut.hotkeys[0]
return shortcut.holdKey
} }

View File

@ -1,14 +1,17 @@
import type { KbdColor } from '@langgenius/dify-ui/kbd' import type { KbdColor } from '@langgenius/dify-ui/kbd'
import type { FormatDisplayOptions, RegisterableHotkey } from '@tanstack/react-hotkeys' import type { FormatDisplayOptions, Hotkey, IndividualKey } from '@tanstack/react-hotkeys'
import type { WorkflowCanvasShortcutId } from './definitions' import type { WorkflowCanvasShortcutId } from './definitions'
import { cn } from '@langgenius/dify-ui/cn' import { cn } from '@langgenius/dify-ui/cn'
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd' import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
import { formatForDisplay } from '@tanstack/react-hotkeys' import { formatForDisplay } from '@tanstack/react-hotkeys'
import { getWorkflowCanvasShortcutDisplayHotkey } from './definitions' import { getWorkflowCanvasShortcutDisplayKey } from './definitions'
type ShortcutKbdProps = { type ShortcutKbdSource =
shortcut?: WorkflowCanvasShortcutId | { shortcut: WorkflowCanvasShortcutId; hotkey?: never; displayKey?: never }
hotkey?: RegisterableHotkey | (string & {}) | { shortcut?: never; hotkey: Hotkey; displayKey?: never }
| { shortcut?: never; hotkey?: never; displayKey: string }
type ShortcutKbdProps = ShortcutKbdSource & {
className?: string className?: string
textColor?: 'default' | 'secondary' textColor?: 'default' | 'secondary'
bgColor?: KbdColor bgColor?: KbdColor
@ -16,13 +19,11 @@ type ShortcutKbdProps = {
} }
const getDisplayKeys = ( const getDisplayKeys = (
hotkey: RegisterableHotkey | (string & {}), hotkey: Hotkey | IndividualKey,
platform?: FormatDisplayOptions['platform'], platform?: FormatDisplayOptions['platform'],
) => { ) => {
const displayOptions = platform ? { platform } : undefined const displayOptions = platform ? { platform } : undefined
if (typeof hotkey !== 'string') return [formatForDisplay(hotkey, displayOptions)]
return hotkey return hotkey
.split('+') .split('+')
.filter(Boolean) .filter(Boolean)
@ -32,17 +33,23 @@ const getDisplayKeys = (
export const ShortcutKbd = ({ export const ShortcutKbd = ({
shortcut, shortcut,
hotkey, hotkey,
displayKey,
className, className,
textColor = 'default', textColor = 'default',
bgColor = 'gray', bgColor = 'gray',
platform, platform,
}: ShortcutKbdProps) => { }: ShortcutKbdProps) => {
const displayHotkey = const shortcutDisplayKey =
hotkey ?? (shortcut ? getWorkflowCanvasShortcutDisplayHotkey(shortcut) : undefined) hotkey ?? (shortcut ? getWorkflowCanvasShortcutDisplayKey(shortcut) : undefined)
if (!displayHotkey) return null const displayOptions = platform ? { platform } : undefined
const displayKeys = displayKey
? [formatForDisplay(displayKey, displayOptions)]
: shortcutDisplayKey
? getDisplayKeys(shortcutDisplayKey, platform)
: []
const displayKeys = getDisplayKeys(displayHotkey, platform) if (!displayKeys.length) return null
return ( return (
<KbdGroup className={cn(className)}> <KbdGroup className={cn(className)}>

View File

@ -1,5 +1,5 @@
import type { HotkeyCallback, UseHotkeyDefinition, UseHotkeyOptions } from '@tanstack/react-hotkeys' import type { HotkeyCallback, UseHotkeyDefinition, UseHotkeyOptions } from '@tanstack/react-hotkeys'
import type { WorkflowCanvasHotkeyMeta, WorkflowCanvasShortcutDefinition } from './definitions' import type { WorkflowCanvasHotkeyDefinition, WorkflowCanvasHotkeyMeta } from './definitions'
import { useHotkeys, useKeyHold } from '@tanstack/react-hotkeys' import { useHotkeys, useKeyHold } from '@tanstack/react-hotkeys'
import { useCallback, useEffect, useMemo, useRef } from 'react' import { useCallback, useEffect, useMemo, useRef } from 'react'
import { useReactFlow } from 'reactflow' import { useReactFlow } from 'reactflow'
@ -29,7 +29,7 @@ const isInputLikeElement = (element: Element | null) => {
} }
const toHotkeyDefinitions = ( const toHotkeyDefinitions = (
shortcut: WorkflowCanvasShortcutDefinition, shortcut: WorkflowCanvasHotkeyDefinition,
callback: HotkeyCallback, callback: HotkeyCallback,
options?: UseHotkeyOptions, options?: UseHotkeyOptions,
): UseHotkeyDefinition[] => { ): UseHotkeyDefinition[] => {
@ -68,7 +68,7 @@ export const useWorkflowHotkeys = (): void => {
const { handleLayout } = useWorkflowOrganize() const { handleLayout } = useWorkflowOrganize()
const { zoomTo, getZoom, fitView, getNodes } = useReactFlow() const { zoomTo, getZoom, fitView, getNodes } = useReactFlow()
const isShiftHeld = useKeyHold('Shift') const isShiftHeld = useKeyHold(WORKFLOW_CANVAS_SHORTCUTS['workflow.dim-other-nodes'].holdKey)
const shiftDimmedRef = useRef(false) const shiftDimmedRef = useRef(false)
const undimAllNodesRef = useRef(undimAllNodes) const undimAllNodesRef = useRef(undimAllNodes)
undimAllNodesRef.current = undimAllNodes undimAllNodesRef.current = undimAllNodes

View File

@ -1,6 +1,6 @@
import type { WorkflowGenerateErrorResponse } from '@dify/contracts/api/console/workflow-generate/types.gen' import type { WorkflowGenerateErrorResponse } from '@dify/contracts/api/console/workflow-generate/types.gen'
import type { GenerateWorkflowStreamCallbacks } from '@/service/workflow-generator' import type { GenerateWorkflowStreamCallbacks } from '@/service/workflow-generator'
import { render, screen, waitFor } from '@testing-library/react' import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
import WorkflowGeneratorModal from '../index' import WorkflowGeneratorModal from '../index'
import { useWorkflowGeneratorStore } from '../store' import { useWorkflowGeneratorStore } from '../store'
@ -93,6 +93,19 @@ describe('WorkflowGeneratorModal', () => {
expect(instruction).toHaveValue('Summarize a URL') expect(instruction).toHaveValue('Summarize a URL')
}) })
it('should generate from the instruction shortcut', async () => {
const user = userEvent.setup()
render(<WorkflowGeneratorModal />)
const instruction = screen.getByRole('textbox', {
name: /workflowGenerator\.instruction/i,
})
await user.type(instruction, 'Summarize a URL')
fireEvent.keyDown(instruction, { key: 'Enter', ctrlKey: true })
await waitFor(() => expect(mockGenerateWorkflowStream).toHaveBeenCalledOnce())
})
}) })
describe('Generation errors', () => { describe('Generation errors', () => {

View File

@ -1,5 +1,6 @@
'use client' 'use client'
import type { WorkflowGenerateErrorResponse } from '@dify/contracts/api/console/workflow-generate/types.gen' import type { WorkflowGenerateErrorResponse } from '@dify/contracts/api/console/workflow-generate/types.gen'
import type { Hotkey } from '@tanstack/react-hotkeys'
import type { SelectorParam, TFunction } from 'i18next' import type { SelectorParam, TFunction } from 'i18next'
import type { GeneratedGraph } from './types' import type { GeneratedGraph } from './types'
import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations' import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations'
@ -23,6 +24,7 @@ import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@langgeni
import { Field, FieldLabel } from '@langgenius/dify-ui/field' import { Field, FieldLabel } from '@langgenius/dify-ui/field'
import { Textarea } from '@langgenius/dify-ui/textarea' import { Textarea } from '@langgenius/dify-ui/textarea'
import { toast } from '@langgenius/dify-ui/toast' import { toast } from '@langgenius/dify-ui/toast'
import { matchesKeyboardEvent } from '@tanstack/react-hotkeys'
import { useSuspenseQuery } from '@tanstack/react-query' import { useSuspenseQuery } from '@tanstack/react-query'
import { useBoolean } from 'ahooks' import { useBoolean } from 'ahooks'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
@ -63,6 +65,7 @@ const FE_TIMEOUT_MS = 90_000
// Mirrors the backend's instruction/ideal-output cap on /workflow-generate — // Mirrors the backend's instruction/ideal-output cap on /workflow-generate —
// keeping the limit client-side turns an opaque 400 into a visible input stop. // keeping the limit client-side turns an opaque 400 into a visible input stop.
const MAX_INSTRUCTION_LENGTH = 10_000 const MAX_INSTRUCTION_LENGTH = 10_000
const WORKFLOW_GENERATOR_SUBMIT_HOTKEY = 'Mod+Enter' satisfies Hotkey
// A single structured generation error. Mirrors the backend ``errors[]`` entry // A single structured generation error. Mirrors the backend ``errors[]`` entry
// (stable ``code`` + human ``detail`` + optional ``node_id``) so the error panel // (stable ``code`` + human ``detail`` + optional ``node_id``) so the error panel
@ -615,9 +618,7 @@ function WorkflowGeneratorModal() {
value={instruction} value={instruction}
onValueChange={setInstruction} onValueChange={setInstruction}
onKeyDown={(e) => { onKeyDown={(e) => {
// ⌘/Ctrl+Enter generates — the journey starts keyboard-first in if (matchesKeyboardEvent(e.nativeEvent, WORKFLOW_GENERATOR_SUBMIT_HOTKEY)) {
// the palette, so let it finish without reaching for the mouse.
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault() e.preventDefault()
if (!isLoading) onGenerate() if (!isLoading) onGenerate()
} }

View File

@ -5,7 +5,7 @@ import type {
AgentReferencingWorkflowResponse, AgentReferencingWorkflowResponse,
AgentReferencingWorkflowsResponse, AgentReferencingWorkflowsResponse,
} from '@dify/contracts/api/console/agent/types.gen' } from '@dify/contracts/api/console/agent/types.gen'
import type { RegisterableHotkey } from '@tanstack/react-hotkeys' import type { Hotkey } from '@tanstack/react-hotkeys'
import { Button } from '@langgenius/dify-ui/button' import { Button } from '@langgenius/dify-ui/button'
import { Collapsible, CollapsiblePanel } from '@langgenius/dify-ui/collapsible' import { Collapsible, CollapsiblePanel } from '@langgenius/dify-ui/collapsible'
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd' import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
@ -25,7 +25,7 @@ import useTimestamp from '@/hooks/use-timestamp'
import { consoleQuery } from '@/service/client' import { consoleQuery } from '@/service/client'
import { AgentPublishImpactDetails } from './publish-impact-details' import { AgentPublishImpactDetails } from './publish-impact-details'
const PUBLISH_AGENT_HOTKEY = 'Mod+Shift+P' satisfies RegisterableHotkey const PUBLISH_AGENT_HOTKEY = 'Mod+Shift+P' satisfies Hotkey
type AgentConfigurePublishState = 'draft' | 'publishing' | 'published' | 'unpublished' type AgentConfigurePublishState = 'draft' | 'publishing' | 'published' | 'unpublished'

View File

@ -12,11 +12,12 @@ import { formatForDisplay } from '@tanstack/react-hotkeys'
import { skipToken, useQuery } from '@tanstack/react-query' import { skipToken, useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import NavLink from '@/app/components/app-sidebar/nav-link' import NavLink from '@/app/components/app-sidebar/nav-link'
import ToggleButton from '@/app/components/app-sidebar/toggle-button'
import AppIcon from '@/app/components/base/app-icon' import AppIcon from '@/app/components/base/app-icon'
import Divider from '@/app/components/base/divider' import Divider from '@/app/components/base/divider'
import SidebarLeftArrowIcon from '@/app/components/base/icons/src/vender/SidebarLeftArrowIcon' import SidebarLeftArrowIcon from '@/app/components/base/icons/src/vender/SidebarLeftArrowIcon'
import { DetailSidebarToggleButton } from '@/app/components/detail-sidebar/toggle-button'
import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle' import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle'
import { GOTO_ANYTHING_HOTKEY } from '@/app/components/goto-anything/hotkeys'
import Link from '@/next/link' import Link from '@/next/link'
import { usePathname } from '@/next/navigation' import { usePathname } from '@/next/navigation'
import { consoleQuery } from '@/service/client' import { consoleQuery } from '@/service/client'
@ -39,8 +40,6 @@ type AgentDetailNavItem = {
activeIcon: NavIcon activeIcon: NavIcon
} }
const SEARCH_SHORTCUT = ['Mod', 'K']
const createAgentNavIcon = (iconClassName: string) => { const createAgentNavIcon = (iconClassName: string) => {
function AgentNavIcon({ className }: ComponentProps<'svg'>) { function AgentNavIcon({ className }: ComponentProps<'svg'>) {
return <span aria-hidden className={cn(iconClassName, className)} /> return <span aria-hidden className={cn(iconClassName, className)} />
@ -92,9 +91,9 @@ export function AgentDetailTop({ expand = true, onToggle }: AgentDetailTopProps)
return ( return (
<div className="flex w-full items-center justify-center px-3 pt-2 pb-1"> <div className="flex w-full items-center justify-center px-3 pt-2 pb-1">
{onToggle && ( {onToggle && (
<ToggleButton <DetailSidebarToggleButton
expand={expand} expand={expand}
handleToggle={onToggle} onToggle={onToggle}
icon={<SidebarLeftArrowIcon aria-hidden className="size-4" />} icon={<SidebarLeftArrowIcon aria-hidden className="size-4" />}
className="size-8 rounded-[10px] border-0 bg-transparent px-0 text-text-tertiary shadow-none hover:border-0 hover:bg-state-base-hover hover:text-text-secondary" className="size-8 rounded-[10px] border-0 bg-transparent px-0 text-text-tertiary shadow-none hover:border-0 hover:bg-state-base-hover hover:text-text-secondary"
/> />
@ -145,16 +144,16 @@ export function AgentDetailTop({ expand = true, onToggle }: AgentDetailTopProps)
> >
<span className="px-0.5">{tApp(($) => $['gotoAnything.quickAction'])}</span> <span className="px-0.5">{tApp(($) => $['gotoAnything.quickAction'])}</span>
<KbdGroup> <KbdGroup>
{SEARCH_SHORTCUT.map((key) => ( {GOTO_ANYTHING_HOTKEY.split('+').map((key) => (
<Kbd key={key}>{formatForDisplay(key)}</Kbd> <Kbd key={key}>{formatForDisplay(key)}</Kbd>
))} ))}
</KbdGroup> </KbdGroup>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
{onToggle && ( {onToggle && (
<ToggleButton <DetailSidebarToggleButton
expand={expand} expand={expand}
handleToggle={onToggle} onToggle={onToggle}
icon={<SidebarLeftArrowIcon aria-hidden className="size-4" />} icon={<SidebarLeftArrowIcon aria-hidden className="size-4" />}
className="size-8 rounded-[10px] border-0 bg-transparent px-0 text-text-tertiary shadow-none hover:border-0 hover:bg-state-base-hover hover:text-text-secondary" className="size-8 rounded-[10px] border-0 bg-transparent px-0 text-text-tertiary shadow-none hover:border-0 hover:bg-state-base-hover hover:text-text-secondary"
/> />

View File

@ -11,11 +11,12 @@ import { formatForDisplay } from '@tanstack/react-hotkeys'
import { useAtomValue } from 'jotai' import { useAtomValue } from 'jotai'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import NavLink from '@/app/components/app-sidebar/nav-link' import NavLink from '@/app/components/app-sidebar/nav-link'
import ToggleButton from '@/app/components/app-sidebar/toggle-button'
import Divider from '@/app/components/base/divider' import Divider from '@/app/components/base/divider'
import SidebarLeftArrowIcon from '@/app/components/base/icons/src/vender/SidebarLeftArrowIcon' import SidebarLeftArrowIcon from '@/app/components/base/icons/src/vender/SidebarLeftArrowIcon'
import { SkeletonContainer, SkeletonRectangle } from '@/app/components/base/skeleton' import { SkeletonContainer, SkeletonRectangle } from '@/app/components/base/skeleton'
import { DetailSidebarToggleButton } from '@/app/components/detail-sidebar/toggle-button'
import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle' import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle'
import { GOTO_ANYTHING_HOTKEY } from '@/app/components/goto-anything/hotkeys'
import Link from '@/next/link' import Link from '@/next/link'
import { usePathname } from '@/next/navigation' import { usePathname } from '@/next/navigation'
import { DeploymentActionsMenu } from '../deployment-actions' import { DeploymentActionsMenu } from '../deployment-actions'
@ -78,8 +79,6 @@ const DEPLOYMENT_TABS: TabDef[] = [
{ key: 'api-tokens', icon: ApiIcon, selectedIcon: ApiSelectedIcon }, { key: 'api-tokens', icon: ApiIcon, selectedIcon: ApiSelectedIcon },
] ]
const SEARCH_SHORTCUT = ['Mod', 'K']
function DeploymentIcon({ expand }: { expand: boolean }) { function DeploymentIcon({ expand }: { expand: boolean }) {
return ( return (
<div <div
@ -198,9 +197,9 @@ export function DeploymentDetailTop({
return ( return (
<div className="flex w-full items-center justify-center px-3 pt-2 pb-1"> <div className="flex w-full items-center justify-center px-3 pt-2 pb-1">
{onToggle && ( {onToggle && (
<ToggleButton <DetailSidebarToggleButton
expand={expand} expand={expand}
handleToggle={onToggle} onToggle={onToggle}
icon={<SidebarLeftArrowIcon aria-hidden className="size-4" />} icon={<SidebarLeftArrowIcon aria-hidden className="size-4" />}
className="size-8 rounded-[10px] border-0 bg-transparent px-0 text-text-tertiary shadow-none hover:border-0 hover:bg-state-base-hover hover:text-text-secondary" className="size-8 rounded-[10px] border-0 bg-transparent px-0 text-text-tertiary shadow-none hover:border-0 hover:bg-state-base-hover hover:text-text-secondary"
/> />
@ -251,16 +250,16 @@ export function DeploymentDetailTop({
> >
<span className="px-0.5">{t(($) => $['gotoAnything.quickAction'], { ns: 'app' })}</span> <span className="px-0.5">{t(($) => $['gotoAnything.quickAction'], { ns: 'app' })}</span>
<KbdGroup> <KbdGroup>
{SEARCH_SHORTCUT.map((key) => ( {GOTO_ANYTHING_HOTKEY.split('+').map((key) => (
<Kbd key={key}>{formatForDisplay(key)}</Kbd> <Kbd key={key}>{formatForDisplay(key)}</Kbd>
))} ))}
</KbdGroup> </KbdGroup>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
{onToggle && ( {onToggle && (
<ToggleButton <DetailSidebarToggleButton
expand={expand} expand={expand}
handleToggle={onToggle} onToggle={onToggle}
icon={<SidebarLeftArrowIcon aria-hidden className="size-4" />} icon={<SidebarLeftArrowIcon aria-hidden className="size-4" />}
className="size-8 rounded-[10px] border-0 bg-transparent px-0 text-text-tertiary shadow-none hover:border-0 hover:bg-state-base-hover hover:text-text-secondary" className="size-8 rounded-[10px] border-0 bg-transparent px-0 text-text-tertiary shadow-none hover:border-0 hover:bg-state-base-hover hover:text-text-secondary"
/> />