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 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. |
| 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. |
## 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.
- 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
- 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
}
},
"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": {
"erasable-syntax-only/enums": {
"count": 1
},
"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
}
},

View File

@ -1,30 +1,7 @@
import type { ReactNode } from 'react'
import { Dialog, DialogPopup, DialogPortal, DialogTitle } from '@langgenius/dify-ui/dialog'
import { fireEvent, render, screen } from '@testing-library/react'
import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle'
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>
),
}))
import { AppDetailTop } from '../app-detail-top'
function TestGotoAnythingDialog() {
return (
@ -75,10 +52,8 @@ describe('AppDetailTop', () => {
const onToggle = vi.fn()
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)
})
})

View File

@ -1,30 +1,7 @@
import type { ReactNode } from 'react'
import { Dialog, DialogPopup, DialogPortal, DialogTitle } from '@langgenius/dify-ui/dialog'
import { fireEvent, render, screen } from '@testing-library/react'
import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle'
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>
),
}))
import { DatasetDetailTop } from '../dataset-detail-top'
function TestGotoAnythingDialog() {
return (
@ -73,10 +50,8 @@ describe('DatasetDetailTop', () => {
const onToggle = vi.fn()
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)
})
})

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 AppDetailSection from './app-detail-section'
import AppDetailTop from './app-detail-top'
import { AppDetailTop } from './app-detail-top'
export function AppDetailSidebar() {
return (

View File

@ -6,27 +6,26 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/too
import { formatForDisplay } from '@tanstack/react-hotkeys'
import { useTranslation } from 'react-i18next'
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 { GOTO_ANYTHING_HOTKEY } from '@/app/components/goto-anything/hotkeys'
import Link from '@/next/link'
import ToggleButton from './toggle-button'
type AppDetailTopProps = {
expand?: boolean
onToggle?: () => void
}
const SEARCH_SHORTCUT = ['Mod', 'K']
const AppDetailTop = ({ expand = true, onToggle }: AppDetailTopProps) => {
export function AppDetailTop({ expand = true, onToggle }: AppDetailTopProps) {
const { t } = useTranslation()
if (!expand) {
return (
<div className="flex w-full items-center justify-center px-3 pt-2 pb-1">
{onToggle && (
<ToggleButton
<DetailSidebarToggleButton
expand={expand}
handleToggle={onToggle}
onToggle={onToggle}
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"
/>
@ -82,7 +81,7 @@ const AppDetailTop = ({ expand = true, onToggle }: AppDetailTopProps) => {
>
<span className="px-0.5">{t(($) => $['gotoAnything.quickAction'], { ns: 'app' })}</span>
<KbdGroup>
{SEARCH_SHORTCUT.map((key) => (
{GOTO_ANYTHING_HOTKEY.split('+').map((key) => (
<Kbd key={key}>{formatForDisplay(key)}</Kbd>
))}
</KbdGroup>
@ -90,9 +89,9 @@ const AppDetailTop = ({ expand = true, onToggle }: AppDetailTopProps) => {
</Tooltip>
)}
{onToggle && (
<ToggleButton
<DetailSidebarToggleButton
expand={expand}
handleToggle={onToggle}
onToggle={onToggle}
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"
/>
@ -100,5 +99,3 @@ const AppDetailTop = ({ expand = true, onToggle }: AppDetailTopProps) => {
</div>
)
}
export default AppDetailTop

View File

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

View File

@ -6,27 +6,26 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/too
import { formatForDisplay } from '@tanstack/react-hotkeys'
import { useTranslation } from 'react-i18next'
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 { GOTO_ANYTHING_HOTKEY } from '@/app/components/goto-anything/hotkeys'
import Link from '@/next/link'
import ToggleButton from './toggle-button'
type DatasetDetailTopProps = {
expand?: boolean
onToggle?: () => void
}
const SEARCH_SHORTCUT = ['Mod', 'K']
const DatasetDetailTop = ({ expand = true, onToggle }: DatasetDetailTopProps) => {
export function DatasetDetailTop({ expand = true, onToggle }: DatasetDetailTopProps) {
const { t } = useTranslation()
if (!expand) {
return (
<div className="flex w-full items-center justify-center px-3 pt-2 pb-1">
{onToggle && (
<ToggleButton
<DetailSidebarToggleButton
expand={expand}
handleToggle={onToggle}
onToggle={onToggle}
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"
/>
@ -82,7 +81,7 @@ const DatasetDetailTop = ({ expand = true, onToggle }: DatasetDetailTopProps) =>
>
<span className="px-0.5">{t(($) => $['gotoAnything.quickAction'], { ns: 'app' })}</span>
<KbdGroup>
{SEARCH_SHORTCUT.map((key) => (
{GOTO_ANYTHING_HOTKEY.split('+').map((key) => (
<Kbd key={key}>{formatForDisplay(key)}</Kbd>
))}
</KbdGroup>
@ -90,9 +89,9 @@ const DatasetDetailTop = ({ expand = true, onToggle }: DatasetDetailTopProps) =>
</Tooltip>
)}
{onToggle && (
<ToggleButton
<DetailSidebarToggleButton
expand={expand}
handleToggle={onToggle}
onToggle={onToggle}
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"
/>
@ -100,5 +99,3 @@ const DatasetDetailTop = ({ expand = true, onToggle }: DatasetDetailTopProps) =>
</div>
)
}
export default DatasetDetailTop

View File

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

View File

@ -22,7 +22,7 @@ import { toast } from '@langgenius/dify-ui/toast'
import { useAtomValue } from 'jotai'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import CreateSnippetDialog from '@/app/components/snippets/create-snippet-dialog'
import { CreateSnippetDialog } from '@/app/components/snippets/create-snippet-dialog'
import {
canCreateAndModifySnippets,
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,
})
})
expect(sectionProps.summary?.publishShortcut).toEqual(['Mod', 'Shift', 'P'])
expect(mockRefetch).not.toHaveBeenCalled()
})

View File

@ -75,7 +75,6 @@ describe('app-publisher sections', () => {
publishDisabled={false}
published={false}
publishedAt={Date.now()}
publishShortcut={['Mod', 'Shift', 'P']}
startNodeLimitExceeded={false}
upgradeHighlightStyle={{}}
/>,
@ -113,7 +112,6 @@ describe('app-publisher sections', () => {
publishDisabled={false}
published={false}
publishedAt={undefined}
publishShortcut={['Mod', 'Shift', 'P']}
startNodeLimitExceeded={false}
upgradeHighlightStyle={{}}
/>,
@ -137,7 +135,6 @@ describe('app-publisher sections', () => {
publishDisabled={false}
published={false}
publishedAt={undefined}
publishShortcut={['Mod', 'Shift', 'P']}
startNodeLimitExceeded={false}
upgradeHighlightStyle={{}}
/>,
@ -161,7 +158,6 @@ describe('app-publisher sections', () => {
publishDisabled={false}
published={false}
publishedAt={undefined}
publishShortcut={['Mod', 'Shift', 'P']}
startNodeLimitExceeded
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 { ModelAndParameter } from '../configuration/debug/types'
import type {
@ -45,6 +44,7 @@ import { fetchPublishedWorkflow } from '@/service/workflow'
import { AppModeEnum } from '@/types/app'
import { basePath } from '@/utils/var'
import AccessControl from '../app-access-control'
import { APP_PUBLISH_HOTKEY } from './hotkeys'
import {
PublisherAccessSection,
PublisherActionsSection,
@ -81,9 +81,6 @@ export type AppPublisherProps = {
hasHumanInputNode?: boolean
}
const PUBLISH_HOTKEY = 'Mod+Shift+P' satisfies RegisterableHotkey
const PUBLISH_SHORTCUT = PUBLISH_HOTKEY.split('+')
export type AppPublisherPublishParams = ModelAndParameter | PublishWorkflowParams
type AppPublisherPublishHandler =
@ -307,7 +304,7 @@ export function AppPublisher({
}
}
useHotkey(PUBLISH_HOTKEY, (e) => {
useHotkey(APP_PUBLISH_HOTKEY, (e) => {
e.preventDefault()
if (publishDisabled || published) return
handlePublish()
@ -410,7 +407,6 @@ export function AppPublisher({
publishDisabled={publishDisabled}
published={published}
publishedAt={publishedAt}
publishShortcut={PUBLISH_SHORTCUT}
startNodeLimitExceeded={startNodeLimitExceeded}
upgradeHighlightStyle={upgradeHighlightStyle}
/>

View File

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

View File

@ -1,8 +1,10 @@
'use client'
import type { Hotkey } from '@tanstack/react-hotkeys'
import type { AppIconSelection } from '../../base/app-icon-picker'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Input } from '@langgenius/dify-ui/input'
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { toast } from '@langgenius/dify-ui/toast'
@ -22,7 +24,6 @@ import {
ListSparkle,
Logic,
} 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 { userProfileIdAtom } from '@/context/account-state'
import { workspacePermissionKeysAtom } from '@/context/permission-state'
@ -47,6 +48,8 @@ type CreateAppProps = {
defaultAppMode?: AppModeEnum
}
const CREATE_APP_HOTKEY = 'Mod+Enter' satisfies Hotkey
const shouldExpandBeginnerAppTypes = (appMode?: AppModeEnum) => {
return (
appMode === AppModeEnum.CHAT ||
@ -152,7 +155,7 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
const { run: handleCreateApp } = useDebounceFn(onCreate, { wait: 300 })
useHotkey(
'Mod+Enter',
CREATE_APP_HOTKEY,
() => {
if (isAppsFull || !canCreateApp) return
handleCreateApp()
@ -348,7 +351,7 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
>
<span>{t(($) => $['newApp.Create'], { ns: 'app' })}</span>
<KbdGroup>
{['Mod', 'Enter'].map((key) => (
{CREATE_APP_HOTKEY.split('+').map((key) => (
<Kbd key={key} color="white">
{formatForDisplay(key)}
</Kbd>
@ -434,9 +437,10 @@ type AppTypeCardProps = {
}
function AppTypeCard({ icon, title, description, active, onClick }: AppTypeCardProps) {
return (
<div
<button
type="button"
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
? '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}>
{description}
</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 { DSLImportMode, DSLImportStatus } from '@/models/app'
import { AppModeEnum } from '@/types/app'
import CreateFromDSLModal, { CreateFromDSLModalTab } from '../index'
import CreateFromDSLModal from '../index'
import { CreateFromDSLModalTab } from '../types'
const mockPush = vi.fn()
const mockImportDSL = vi.fn()

View File

@ -1,9 +1,11 @@
'use client'
import type { Hotkey } from '@tanstack/react-hotkeys'
import type { MouseEventHandler } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
import { Input } from '@langgenius/dify-ui/input'
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
import { toast } from '@langgenius/dify-ui/toast'
import { formatForDisplay, useHotkey } from '@tanstack/react-hotkeys'
@ -13,7 +15,6 @@ import { useAtomValue } from 'jotai'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
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 { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks'
import { userProfileIdAtom } from '@/context/account-state'
@ -26,21 +27,19 @@ import { importDSL, importDSLConfirm } from '@/service/apps'
import { useInvalidateAppList } from '@/service/use-apps'
import { getRedirection } from '@/utils/app-redirection'
import { trackCreateApp } from '@/utils/create-app-tracking'
import { CreateFromDSLModalTab } from './types'
import Uploader from './uploader'
type CreateFromDSLModalProps = {
show: boolean
onSuccess?: () => void
onClose: () => void
activeTab?: string
activeTab?: CreateFromDSLModalTab
dslUrl?: string
droppedFile?: File
}
export enum CreateFromDSLModalTab {
FROM_FILE = 'from-file',
FROM_URL = 'from-url',
}
const CREATE_FROM_DSL_HOTKEY = 'Mod+Enter' satisfies Hotkey
const CreateFromDSLModal = ({
show,
@ -91,8 +90,8 @@ const CreateFromDSLModal = ({
const isCreatingRef = useRef(false)
useEffect(() => {
if (droppedFile) handleFile(droppedFile)
}, [droppedFile, handleFile])
if (droppedFile) readFile(droppedFile)
}, [droppedFile, readFile])
const onCreate = async (_e?: React.MouseEvent) => {
if (currentTab === CreateFromDSLModalTab.FROM_FILE && !currentFile) return
@ -179,7 +178,7 @@ const CreateFromDSLModal = ({
const { run: handleCreateApp } = useDebounceFn(onCreate, { wait: 300 })
useHotkey(
'Mod+Enter',
CREATE_FROM_DSL_HOTKEY,
() => {
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">
<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' })}
<div className="flex size-8 cursor-pointer items-center" onClick={() => onClose()}>
<span className="i-ri-close-line size-5 text-text-tertiary" />
</div>
<Button
variant="ghost"
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 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) => (
<div
<button
type="button"
key={tab.key}
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',
)}
onClick={() => setCurrentTab(tab.key)}
@ -269,7 +275,7 @@ const CreateFromDSLModal = ({
{currentTab === tab.key && (
<div className="absolute bottom-0 h-[2px] w-full bg-util-colors-blue-brand-blue-brand-600"></div>
)}
</div>
</button>
))}
</div>
<div className="px-6 py-4">
@ -304,7 +310,7 @@ const CreateFromDSLModal = ({
>
<span>{t(($) => $['newApp.Create'], { ns: 'app' })}</span>
<KbdGroup>
{['Mod', 'Enter'].map((key) => (
{CREATE_FROM_DSL_HOTKEY.split('+').map((key) => (
<Kbd key={key} color="white">
{formatForDisplay(key)}
</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 { 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'
// Tab Component Tests

View File

@ -1,6 +1,6 @@
import * as React from 'react'
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'
type TabProps = {

View File

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

View File

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

View File

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

View File

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

View File

@ -9,7 +9,7 @@ import Divider from '@/app/components/base/divider'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { formatNumber } from '@/utils/format'
import { formatTime } from '@/utils/time'
import ActionButtons from './common/action-buttons'
import { ActionButtons } from './common/action-buttons'
import ChunkContent from './common/chunk-content'
import Dot from './common/dot'
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 { ChunkingMode } from '@/models/datasets'
import { DocumentContext } from '../../../context'
import ActionButtons from '../action-buttons'
import { ActionButtons } from '../action-buttons'
const mockUseHotkey = vi.fn()
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 { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
import { formatForDisplay, useHotkey } from '@tanstack/react-hotkeys'
import * as React from 'react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { ChunkingMode } from '@/models/datasets'
import { useDocumentContext } from '../../context'
type IActionButtonsProps = {
const CANCEL_HOTKEY = 'Escape' satisfies Hotkey
const SAVE_HOTKEY = 'Mod+S' satisfies Hotkey
type ActionButtonsProps = {
handleCancel: () => void
handleSave: () => void
loading: boolean
@ -18,7 +19,7 @@ type IActionButtonsProps = {
showRegenerationButton?: boolean
}
const ActionButtons: FC<IActionButtonsProps> = ({
export function ActionButtons({
handleCancel,
handleSave,
loading,
@ -26,25 +27,24 @@ const ActionButtons: FC<IActionButtonsProps> = ({
handleRegeneration,
isChildChunk = false,
showRegenerationButton = true,
}) => {
}: ActionButtonsProps) {
const { t } = useTranslation()
const docForm = useDocumentContext((s) => s.docForm)
const parentMode = useDocumentContext((s) => s.parentMode)
useHotkey('Escape', (e) => {
useHotkey(CANCEL_HOTKEY, (e) => {
e.preventDefault()
handleCancel()
})
useHotkey('Mod+S', (e) => {
useHotkey(SAVE_HOTKEY, (e) => {
e.preventDefault()
if (loading) return
handleSave()
})
const isParentChildParagraphMode = useMemo(() => {
return docForm === ChunkingMode.parentChild && parentMode === 'paragraph'
}, [docForm, parentMode])
const isParentChildParagraphMode =
docForm === ChunkingMode.parentChild && parentMode === 'paragraph'
return (
<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">
{t(($) => $['operation.cancel'], { ns: 'common' })}
</span>
<Kbd>{formatForDisplay('Escape')}</Kbd>
<Kbd>{formatForDisplay(CANCEL_HOTKEY)}</Kbd>
</div>
</Button>
{isParentChildParagraphMode &&
@ -72,7 +72,7 @@ const ActionButtons: FC<IActionButtonsProps> = ({
{t(($) => $['operation.save'], { ns: 'common' })}
</span>
<KbdGroup>
{['Mod', 'S'].map((key) => (
{SAVE_HOTKEY.split('+').map((key) => (
<Kbd key={key} color="white">
{formatForDisplay(key)}
</Kbd>
@ -83,7 +83,3 @@ const ActionButtons: FC<IActionButtonsProps> = ({
</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 { formatNumber } from '@/utils/format'
import { useDocumentContext } from '../context'
import ActionButtons from './common/action-buttons'
import { ActionButtons } from './common/action-buttons'
import AddAnother from './common/add-another'
import ChunkContent from './common/chunk-content'
import Dot from './common/dot'

View File

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

View File

@ -15,7 +15,7 @@ import { useAddSegment } from '@/service/knowledge/use-segment'
import { formatNumber } from '@/utils/format'
import { IndexingType } from '../../create/step-two'
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 ChunkContent from './completed/common/chunk-content'
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 { DETAIL_SIDEBAR_STORAGE_KEY } from '../storage'
@ -6,8 +6,8 @@ const { hotkeyRegistrations } = vi.hoisted(() => ({
hotkeyRegistrations: new Map<
string,
{
handler: (event: { preventDefault: () => void }) => void
options?: { ignoreInputs?: boolean }
handler: () => void
options?: { ignoreInputs?: boolean; preventDefault?: boolean }
}
>(),
}))
@ -25,8 +25,8 @@ vi.mock('@tanstack/react-hotkeys', async (importOriginal) => {
...actual,
useHotkey: (
hotkey: string,
handler: (event: { preventDefault: () => void }) => void,
options?: { ignoreInputs?: boolean },
handler: () => void,
options?: { ignoreInputs?: boolean; preventDefault?: boolean },
) => {
hotkeyRegistrations.set(hotkey, { handler, options })
},
@ -117,10 +117,19 @@ describe('DetailSidebarFrame', () => {
expect(screen.getByTestId('detail-top')).toHaveAttribute('data-expand', 'true')
expect(screen.getByTestId('detail-section')).toHaveAttribute('data-expand', 'true')
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', () => {
mockAppContextState.current = {
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 { useHotkey } from '@tanstack/react-hotkeys'
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 AccountSection from '@/app/components/main-nav/components/account-section'
import HelpMenu from '@/app/components/main-nav/components/help-menu'
import { langGeniusVersionInfoAtom } from '@/context/version-state'
import { DETAIL_SIDEBAR_TOGGLE_HOTKEY } from './hotkeys'
import { useDetailSidebarMode } from './storage'
type DetailSidebarRenderProps = {
@ -56,7 +57,7 @@ export function DetailSidebarFrame({
const currentEnv = langGeniusVersionInfo?.current_env
const showEnvTag = currentEnv === 'TESTING' || currentEnv === 'DEVELOPMENT'
const handleToggleDetailNavigation = useCallback(() => {
function handleToggleDetailNavigation() {
if (isDetailNavigationHoverPreviewOpen) {
if (detailNavigationTransitionTimerRef.current)
clearTimeout(detailNavigationTransitionTimerRef.current)
@ -73,25 +74,25 @@ export function DetailSidebarFrame({
const nextMode = detailNavigationExpanded ? 'collapse' : 'expand'
setDetailNavigationHoverPreviewOpen(false)
setStoredDetailSidebarExpand(nextMode)
}, [detailNavigationExpanded, isDetailNavigationHoverPreviewOpen, setStoredDetailSidebarExpand])
}
const openDetailNavigationHoverPreview = useCallback(() => {
function openDetailNavigationHoverPreview() {
if (detailNavigationExpanded) return
if (closeDetailNavigationHoverPreviewTimerRef.current)
clearTimeout(closeDetailNavigationHoverPreviewTimerRef.current)
setDetailNavigationHoverPreviewOpen(true)
}, [detailNavigationExpanded])
}
const closeDetailNavigationHoverPreview = useCallback(() => {
function closeDetailNavigationHoverPreview() {
if (closeDetailNavigationHoverPreviewTimerRef.current)
clearTimeout(closeDetailNavigationHoverPreviewTimerRef.current)
closeDetailNavigationHoverPreviewTimerRef.current = setTimeout(() => {
setDetailNavigationHoverPreviewOpen(false)
}, 120)
}, [])
}
useEffect(() => {
return () => {
@ -102,16 +103,10 @@ export function DetailSidebarFrame({
}
}, [])
useHotkey(
'Mod+B',
(e) => {
e.preventDefault()
handleToggleDetailNavigation()
},
{
ignoreInputs: false,
},
)
useHotkey(DETAIL_SIDEBAR_TOGGLE_HOTKEY, handleToggleDetailNavigation, {
ignoreInputs: false,
preventDefault: true,
})
return (
<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'
import type { Hotkey } from '@tanstack/react-hotkeys'
import type { AppIconType } from '@/types/app'
import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
@ -45,6 +46,8 @@ export type CreateAppModalProps = {
type CreateAppPayload = Parameters<CreateAppModalProps['onConfirm']>[0]
const SUBMIT_APP_HOTKEY = 'Mod+Enter' satisfies Hotkey
const CreateAppModal = ({
show = false,
isEditModal = false,
@ -118,7 +121,7 @@ const CreateAppModal = ({
const { run: handleSubmit } = useDebounceFn(submit, { wait: 300 })
useHotkey(
'Mod+Enter',
SUBMIT_APP_HOTKEY,
() => {
handleSubmit()
},
@ -237,7 +240,7 @@ const CreateAppModal = ({
: t(($) => $['operation.save'], { ns: 'common' })}
</span>
<KbdGroup>
{['Mod', 'Enter'].map((key) => (
{SUBMIT_APP_HOTKEY.split('+').map((key) => (
<Kbd key={key} color="white">
{formatForDisplay(key)}
</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 { Footer } from './components/footer'
import { gotoAnythingDialogHandle } from './dialog-handle'
import { GOTO_ANYTHING_HOTKEY } from './hotkeys'
const appWorkflowPathPattern = /^\/app\/[^/]+\/workflow$/
const sharedWorkflowPathPattern = /^\/workflow\/[^/]+$/
const ragPipelinePathPattern = /^\/datasets\/[^/]+\/pipeline$/
const searchHotkey = 'Mod+K'
const searchShortcut = searchHotkey.split('+')
type CommandOption = {
kind: 'command-option'
@ -288,7 +287,7 @@ function GotoAnythingDialog() {
}
useHotkey(
searchHotkey,
GOTO_ANYTHING_HOTKEY,
(event) => {
if (event.defaultPrevented) return
if (!gotoAnythingDialogHandle.isOpen && isEditableShortcutTarget(event.target)) return
@ -436,7 +435,7 @@ function GotoAnythingDialog() {
)}
</div>
<KbdGroup>
{searchShortcut.map((key) => (
{GOTO_ANYTHING_HOTKEY.split('+').map((key) => (
<Kbd key={key}>{formatForDisplay(key)}</Kbd>
))}
</KbdGroup>

View File

@ -5,8 +5,7 @@ import { Kbd } from '@langgenius/dify-ui/kbd'
import { formatForDisplay } from '@tanstack/react-hotkeys'
import { useTranslation } from 'react-i18next'
import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle'
const searchShortcut = ['Mod', 'K']
import { GOTO_ANYTHING_HOTKEY } from '@/app/components/goto-anything/hotkeys'
export function MainNavSearchButton() {
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" />
<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>
))}
</Kbd>

View File

@ -5,8 +5,8 @@ import { WorkflowRunningStatus } from '@/app/components/workflow/types'
import RagPipelineHeader from '../index'
import InputFieldButton from '../input-field-button'
import Publisher from '../publisher'
import Popup from '../publisher/popup'
import RunMode from '../run-mode'
import { Popup } from '../publisher/popup'
import { RunMode } from '../run-mode'
vi.mock('@/config', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/config')>()
@ -1080,12 +1080,6 @@ describe('RunMode', () => {
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', () => {

View File

@ -1,11 +1,35 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import RunMode from '../run-mode'
import { RunMode } from '../run-mode'
const mockHandleWorkflowStartRunInWorkflow = vi.fn()
const mockHandleStopRun = vi.fn()
const mockSetIsPreparingDataSource = 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 mockIsPreparingDataSource = false
@ -75,6 +99,7 @@ describe('RunMode', () => {
vi.clearAllMocks()
mockWorkflowRunningData = undefined
mockIsPreparingDataSource = false
hotkeyRegistrations.clear()
})
describe('Idle state', () => {
@ -109,6 +134,20 @@ describe('RunMode', () => {
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', () => {

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 InputFieldButton from './input-field-button'
import Publisher from './publisher'
import RunMode from './run-mode'
import { RunMode } from './run-mode'
const RagPipelineHeader = () => {
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 { beforeEach, describe, expect, it, vi } from 'vitest'
import Publisher from '../index'
import Popup from '../popup'
import { Popup } from '../popup'
vi.mock('@/config', async (importOriginal) => {
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', () => {
it('should display correct width when permission is allowed', () => {
mockIsAllowPublishAsCustomKnowledgePipelineTemplate.mockReturnValue(true)

View File

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

View File

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

View File

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

View File

@ -1,8 +1,7 @@
import { cn } from '@langgenius/dify-ui/cn'
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
import { RiCloseLine, RiDatabase2Line, RiLoader2Line, RiPlayLargeLine } from '@remixicon/react'
import { formatForDisplay } from '@tanstack/react-hotkeys'
import * as React from 'react'
import { formatForDisplay, useHotkey } from '@tanstack/react-hotkeys'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
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 { EVENT_WORKFLOW_STOP } from '@/app/components/workflow/variable-inspect/types'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { RAG_PIPELINE_RUN_HOTKEY } from './hotkeys'
type RunModeProps = {
text?: string
}
const RunMode = ({ text }: RunModeProps) => {
export function RunMode({ text }: RunModeProps) {
const { t } = useTranslation()
const { handleWorkflowStartRunInWorkflow } = useWorkflowStartRun()
const { handleStopRun } = useWorkflowRun()
@ -44,6 +44,17 @@ const RunMode = ({ text }: RunModeProps) => {
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
return (
@ -55,9 +66,7 @@ const RunMode = ({ text }: RunModeProps) => {
isDisabled && 'cursor-not-allowed bg-state-accent-hover',
isDisabled ? 'rounded-l-md' : 'rounded-md',
)}
onClick={() => {
if (canRun) handleWorkflowStartRunInWorkflow()
}}
onClick={handleRun}
disabled={isDisabled}
>
{!isDisabled && (
@ -82,7 +91,7 @@ const RunMode = ({ text }: RunModeProps) => {
)}
{!isDisabled && (
<KbdGroup>
{['Alt', 'R'].map((key) => (
{RAG_PIPELINE_RUN_HOTKEY.split('+').map((key) => (
<Kbd key={key}>{formatForDisplay(key)}</Kbd>
))}
</KbdGroup>
@ -113,5 +122,3 @@ const RunMode = ({ text }: RunModeProps) => {
</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', () => ({
default: () => null,
CreateSnippetDialog: () => null,
}))
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 { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import CreateSnippetDialog from '@/app/components/snippets/create-snippet-dialog'
import { CreateSnippetDialog } from '@/app/components/snippets/create-snippet-dialog'
import {
canCreateAndModifySnippets,
canManageSnippets,

View File

@ -4,7 +4,7 @@ import { Button } from '@langgenius/dify-ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { useState } from 'react'
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 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 { PipelineInputVarType } from '@/models/pipeline'
import { expectLoadingButton } from '@/test/button'
import CreateSnippetDialog from '../create-snippet-dialog'
import { CreateSnippetDialog } from '../create-snippet-dialog'
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', () => ({
useKeyPress: (_keys: string[], handler: () => void) => {
capturedKeyPressHandler = handler
},
}))
vi.mock('@tanstack/react-hotkeys', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-hotkeys')>()
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 = {
nodes: [],
@ -32,6 +60,8 @@ describe('CreateSnippetDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
capturedKeyPressHandler = undefined
capturedHotkey = undefined
capturedHotkeyOptions = undefined
})
it('should submit trimmed snippet values with the selected graph and input fields', async () => {
@ -178,6 +208,14 @@ describe('CreateSnippetDialog', () => {
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', () => {

View File

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

View File

@ -6,19 +6,18 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/too
import { formatForDisplay } from '@tanstack/react-hotkeys'
import { useTranslation } from 'react-i18next'
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 { GOTO_ANYTHING_HOTKEY } from '@/app/components/goto-anything/hotkeys'
import Link from '@/next/link'
import { useRouter } from '@/next/navigation'
import ToggleButton from '../../app-sidebar/toggle-button'
type SnippetDetailTopProps = {
expand?: boolean
onToggle?: () => void
}
const SEARCH_SHORTCUT = ['Mod', 'K']
const SnippetDetailTop = ({ expand = true, onToggle }: SnippetDetailTopProps) => {
export function SnippetDetailTop({ expand = true, onToggle }: SnippetDetailTopProps) {
const { t } = useTranslation()
const router = useRouter()
@ -26,9 +25,9 @@ const SnippetDetailTop = ({ expand = true, onToggle }: SnippetDetailTopProps) =>
return (
<div className="flex w-full items-center justify-center px-3 pt-2 pb-1">
{onToggle && (
<ToggleButton
<DetailSidebarToggleButton
expand={expand}
handleToggle={onToggle}
onToggle={onToggle}
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"
/>
@ -88,16 +87,16 @@ const SnippetDetailTop = ({ expand = true, onToggle }: SnippetDetailTopProps) =>
>
<span className="px-0.5">{t(($) => $['gotoAnything.quickAction'], { ns: 'app' })}</span>
<KbdGroup>
{SEARCH_SHORTCUT.map((key) => (
{GOTO_ANYTHING_HOTKEY.split('+').map((key) => (
<Kbd key={key}>{formatForDisplay(key)}</Kbd>
))}
</KbdGroup>
</TooltipContent>
</Tooltip>
{onToggle && (
<ToggleButton
<DetailSidebarToggleButton
expand={expand}
handleToggle={onToggle}
onToggle={onToggle}
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"
/>
@ -105,5 +104,3 @@ const SnippetDetailTop = ({ expand = true, onToggle }: SnippetDetailTopProps) =>
</div>
)
}
export default SnippetDetailTop

View File

@ -4,8 +4,8 @@ import { cn } from '@langgenius/dify-ui/cn'
import * as React from 'react'
import { useCallback } from 'react'
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 { TEST_RUN_MENU_HOTKEY } from '@/app/components/workflow/hotkeys'
import { ShortcutKbd } from '@/app/components/workflow/shortcuts/shortcut-kbd'
import { useStore } from '@/app/components/workflow/store'
import { WorkflowRunningStatus } from '@/app/components/workflow/types'

View File

@ -1,14 +1,24 @@
'use client'
import type { Hotkey } from '@tanstack/react-hotkeys'
import type { SnippetCanvasData, SnippetInputField } from '@/models/snippet'
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 { Textarea } from '@langgenius/dify-ui/textarea'
import { useKeyPress } from 'ahooks'
import { useCallback, useState } from 'react'
import { useHotkey } from '@tanstack/react-hotkeys'
import { useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
const CREATE_SNIPPET_HOTKEY = 'Mod+Enter' satisfies Hotkey
export type CreateSnippetDialogPayload = {
name: string
description: string
@ -39,7 +49,7 @@ const defaultGraph: SnippetCanvasData = {
viewport: { x: 0, y: 0, zoom: 1 },
}
function CreateSnippetDialog({
export function CreateSnippetDialog({
isOpen,
selectedGraph,
inputFields,
@ -51,20 +61,21 @@ function CreateSnippetDialog({
initialValue,
}: CreateSnippetDialogProps) {
const { t } = useTranslation()
const popupRef = useRef<HTMLDivElement>(null)
const [name, setName] = useState(initialValue?.name ?? '')
const [description, setDescription] = useState(initialValue?.description ?? '')
const resetForm = useCallback(() => {
function resetForm() {
setName('')
setDescription('')
}, [])
}
const handleClose = useCallback(() => {
function handleClose() {
resetForm()
onClose()
}, [onClose, resetForm])
}
const handleConfirm = useCallback(() => {
function handleConfirm() {
const trimmedName = name.trim()
const trimmedDescription = description.trim()
@ -78,75 +89,79 @@ function CreateSnippetDialog({
}
onConfirm(payload)
}, [description, inputFields, name, onConfirm, selectedGraph])
}
useKeyPress(['meta.enter', 'ctrl.enter'], () => {
if (!isOpen) return
if (isSubmitting) return
handleConfirm()
useHotkey(CREATE_SNIPPET_HOTKEY, handleConfirm, {
enabled: isOpen && !isSubmitting,
ignoreInputs: false,
preventDefault: false,
stopPropagation: false,
target: popupRef,
})
return (
<>
<Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}>
<DialogContent className="w-120 max-w-120 p-0">
<DialogCloseButton />
<DialogPortal>
<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">
<DialogTitle className="title-2xl-semi-bold text-text-primary">
{title || t(($) => $['snippet.createDialogTitle'], { ns: 'workflow' })}
</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 className="px-6 pt-6 pb-3">
<DialogTitle className="title-2xl-semi-bold text-text-primary">
{title || t(($) => $['snippet.createDialogTitle'], { ns: 'workflow' })}
</DialogTitle>
</div>
<div>
<div className="mb-1 flex h-6 items-center system-sm-medium text-text-secondary">
{t(($) => $['snippet.descriptionLabel'], { ns: 'workflow' })}
<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>
<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">
<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>
</DialogContent>
<div>
<div className="mb-1 flex h-6 items-center system-sm-medium text-text-secondary">
{t(($) => $['snippet.descriptionLabel'], { ns: 'workflow' })}
</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">
<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>
</>
)
}
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 { useCallback, useState } from 'react'
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 { 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', () => ({
default: (props: {
CreateSnippetDialog: (props: {
isOpen: boolean
selectedGraph?: {
nodes: Node[]

View File

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

View File

@ -1,5 +1,5 @@
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'
const hotkeyRegistrations = vi.hoisted(

View File

@ -13,7 +13,7 @@ import GlobalVariableButton from './global-variable-button'
import OnlineUsers from './online-users'
import RunAndHistory from './run-and-history'
import ScrollToSelectedNodeButton from './scroll-to-selected-node-button'
import VersionHistoryButton from './version-history-button'
import { VersionHistoryButton } from './version-history-button'
export type HeaderInNormalProps = {
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 { useEventEmitterContextContext } from '@/context/event-emitter'
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'
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>
<span className="ml-2 truncate">{option.name}</span>
</div>
{shortcutKey && <ShortcutKbd hotkey={shortcutKey} className="ml-2" textColor="secondary" />}
{shortcutKey && (
<ShortcutKbd displayKey={shortcutKey} className="ml-2" textColor="secondary" />
)}
</DropdownMenuItem>
)
}

View File

@ -1,21 +1,17 @@
import type { FC } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { useHotkey } from '@tanstack/react-hotkeys'
import * as React from 'react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import useTheme from '@/hooks/use-theme'
import { VERSION_HISTORY_HOTKEY } from '../hotkeys'
import { ShortcutKbd } from '../shortcuts/shortcut-kbd'
const VERSION_HISTORY_HOTKEY = 'Mod+Shift+H'
type VersionHistoryButtonProps = {
onClick: () => Promise<unknown> | unknown
}
const PopupContent = React.memo(() => {
function PopupContent() {
const { t } = useTranslation()
return (
<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" />
</div>
)
})
}
PopupContent.displayName = 'PopupContent'
const VersionHistoryButton: FC<VersionHistoryButtonProps> = ({ onClick }) => {
export function VersionHistoryButton({ onClick }: VersionHistoryButtonProps) {
const { theme } = useTheme()
const handleViewVersionHistory = useCallback(async () => {
await onClick?.()
}, [onClick])
useHotkey(
VERSION_HISTORY_HOTKEY,
() => {
void handleViewVersionHistory()
void onClick()
},
{
ignoreInputs: true,
@ -54,7 +45,7 @@ const VersionHistoryButton: FC<VersionHistoryButtonProps> = ({ onClick }) => {
'rounded-lg border border-transparent p-2',
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" />
</Button>
@ -66,5 +57,3 @@ const VersionHistoryButton: FC<VersionHistoryButtonProps> = ({ onClick }) => {
</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 { useWorkflowComment } from './hooks/use-workflow-comment'
import { useWorkflowSearch } from './hooks/use-workflow-search'
import { shouldPreventWorkflowBrowserDefault } from './hotkeys'
import CustomNode from './nodes'
import useMatchSchemaType from './nodes/_base/components/variable/use-match-schema-type'
import CustomDataSourceEmptyNode from './nodes/data-source-empty'
@ -446,10 +447,7 @@ export const Workflow: FC<WorkflowProps> = memo(
}, [handleSyncWorkflowDraftWhenPageClose, handleBeforeUnload])
useEventListener('keydown', (e) => {
if ((e.key === 'd' || e.key === 'D') && (e.ctrlKey || e.metaKey)) 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()
if (shouldPreventWorkflowBrowserDefault(e)) e.preventDefault()
})
useEventListener('mousemove', (e) => {
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 { WorkflowCanvasShortcutId } from '@/app/components/workflow/shortcuts/definitions'
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({
children,
hotkey,
shortcut,
}: {
children: ReactNode
hotkey?: RegisterableHotkey | (string & {})
shortcut?: WorkflowCanvasShortcutId
}) {
return (
<>
<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 { Hotkey } from '@tanstack/react-hotkeys'
import type { EditableOutputConfig, EditingState, OutputDraft } from './utils'
import { Button } from '@langgenius/dify-ui/button'
import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from '@langgenius/dify-ui/collapsible'
@ -18,7 +19,8 @@ import {
OUTPUT_NAME_PATTERN_SOURCE,
} from './utils'
const CONFIRM_HOTKEY = 'Mod+Enter'
const CANCEL_HOTKEY = 'Escape' satisfies Hotkey
const CONFIRM_HOTKEY = 'Mod+Enter' satisfies Hotkey
function ConfirmHotkeyHint() {
const displayKeys = formatForDisplay(CONFIRM_HOTKEY, { separatorToken: ' ' })
@ -70,8 +72,11 @@ export function OutputEditCard({
if (confirmDisabled) return
onConfirm(createOutputFromDraft(draft, { includeDefaultValue: allowDefaultValue }), state)
}
useHotkey(CONFIRM_HOTKEY, handleConfirm, { target: editorRef, ignoreInputs: false })
useHotkey('Escape', onCancel, { target: editorRef, ignoreInputs: false })
useHotkey(CONFIRM_HOTKEY, handleConfirm, {
target: editorRef,
ignoreInputs: false,
})
useHotkey(CANCEL_HOTKEY, onCancel, { target: editorRef, ignoreInputs: false })
return (
<div ref={editorRef}>
<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 { useHotkey } from '@tanstack/react-hotkeys'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
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 = {
isConfirmDisabled: boolean
@ -13,7 +12,7 @@ type AdvancedActionsProps = {
onConfirm: () => void
}
const AdvancedActions: FC<AdvancedActionsProps> = ({ isConfirmDisabled, onCancel, onConfirm }) => {
export function AdvancedActions({ isConfirmDisabled, onCancel, onConfirm }: AdvancedActionsProps) {
const { t } = useTranslation()
useHotkey(
@ -45,5 +44,3 @@ const AdvancedActions: FC<AdvancedActionsProps> = ({ isConfirmDisabled, onCancel
</div>
)
}
export default React.memo(AdvancedActions)

View File

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

View File

@ -8,7 +8,6 @@ import {
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { FlowType } from '@/types/common'
import { TEST_RUN_MENU_HOTKEY } from './header/shortcuts'
import {
useDSL,
useIsChatMode,
@ -17,6 +16,7 @@ import {
useWorkflowStartRun,
} from './hooks'
import { useHooksStore } from './hooks-store'
import { TEST_RUN_MENU_HOTKEY } from './hotkeys'
import { isSnippetCanvas } from './nodes/_base/hooks/snippet-input-field-vars'
import AddBlock from './operator/add-block'
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 =
| 'workflow.delete'
@ -26,18 +26,26 @@ export type WorkflowCanvasHotkeyMeta = {
description: string
}
export type WorkflowCanvasShortcutDefinition = {
type WorkflowCanvasShortcutDefinitionBase = {
id: WorkflowCanvasShortcutId
hotkeys: readonly RegisterableHotkey[]
displayHotkey?: RegisterableHotkey | (string & {})
name: string
description: string
}
export const WORKFLOW_CANVAS_SHORTCUTS: Record<
WorkflowCanvasShortcutId,
WorkflowCanvasShortcutDefinition
> = {
export type WorkflowCanvasHotkeyDefinition = WorkflowCanvasShortcutDefinitionBase & {
hotkeys: readonly Hotkey[]
displayHotkey?: Hotkey
}
type WorkflowCanvasHoldKeyDefinition = WorkflowCanvasShortcutDefinitionBase & {
holdKey: IndividualKey
}
type WorkflowCanvasShortcutDefinition =
| WorkflowCanvasHotkeyDefinition
| WorkflowCanvasHoldKeyDefinition
export const WORKFLOW_CANVAS_SHORTCUTS = {
'workflow.delete': {
id: 'workflow.delete',
hotkeys: ['Delete', 'Backspace'],
@ -139,16 +147,19 @@ export const WORKFLOW_CANVAS_SHORTCUTS: Record<
},
'workflow.dim-other-nodes': {
id: 'workflow.dim-other-nodes',
hotkeys: [{ key: 'Shift', shift: true }],
displayHotkey: 'Shift',
holdKey: 'Shift',
name: 'Dim other nodes',
description: 'Dim nodes outside the current workflow selection',
},
}
} as const satisfies Record<WorkflowCanvasShortcutId, WorkflowCanvasShortcutDefinition>
export const getWorkflowCanvasShortcutDisplayHotkey = (
export const getWorkflowCanvasShortcutDisplayKey = (
id: WorkflowCanvasShortcutId,
): RegisterableHotkey | (string & {}) => {
): Hotkey | IndividualKey => {
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 { FormatDisplayOptions, RegisterableHotkey } from '@tanstack/react-hotkeys'
import type { FormatDisplayOptions, Hotkey, IndividualKey } from '@tanstack/react-hotkeys'
import type { WorkflowCanvasShortcutId } from './definitions'
import { cn } from '@langgenius/dify-ui/cn'
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
import { formatForDisplay } from '@tanstack/react-hotkeys'
import { getWorkflowCanvasShortcutDisplayHotkey } from './definitions'
import { getWorkflowCanvasShortcutDisplayKey } from './definitions'
type ShortcutKbdProps = {
shortcut?: WorkflowCanvasShortcutId
hotkey?: RegisterableHotkey | (string & {})
type ShortcutKbdSource =
| { shortcut: WorkflowCanvasShortcutId; hotkey?: never; displayKey?: never }
| { shortcut?: never; hotkey: Hotkey; displayKey?: never }
| { shortcut?: never; hotkey?: never; displayKey: string }
type ShortcutKbdProps = ShortcutKbdSource & {
className?: string
textColor?: 'default' | 'secondary'
bgColor?: KbdColor
@ -16,13 +19,11 @@ type ShortcutKbdProps = {
}
const getDisplayKeys = (
hotkey: RegisterableHotkey | (string & {}),
hotkey: Hotkey | IndividualKey,
platform?: FormatDisplayOptions['platform'],
) => {
const displayOptions = platform ? { platform } : undefined
if (typeof hotkey !== 'string') return [formatForDisplay(hotkey, displayOptions)]
return hotkey
.split('+')
.filter(Boolean)
@ -32,17 +33,23 @@ const getDisplayKeys = (
export const ShortcutKbd = ({
shortcut,
hotkey,
displayKey,
className,
textColor = 'default',
bgColor = 'gray',
platform,
}: ShortcutKbdProps) => {
const displayHotkey =
hotkey ?? (shortcut ? getWorkflowCanvasShortcutDisplayHotkey(shortcut) : undefined)
const shortcutDisplayKey =
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 (
<KbdGroup className={cn(className)}>

View File

@ -1,5 +1,5 @@
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 { useCallback, useEffect, useMemo, useRef } from 'react'
import { useReactFlow } from 'reactflow'
@ -29,7 +29,7 @@ const isInputLikeElement = (element: Element | null) => {
}
const toHotkeyDefinitions = (
shortcut: WorkflowCanvasShortcutDefinition,
shortcut: WorkflowCanvasHotkeyDefinition,
callback: HotkeyCallback,
options?: UseHotkeyOptions,
): UseHotkeyDefinition[] => {
@ -68,7 +68,7 @@ export const useWorkflowHotkeys = (): void => {
const { handleLayout } = useWorkflowOrganize()
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 undimAllNodesRef = useRef(undimAllNodes)
undimAllNodesRef.current = undimAllNodes

View File

@ -1,6 +1,6 @@
import type { WorkflowGenerateErrorResponse } from '@dify/contracts/api/console/workflow-generate/types.gen'
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 WorkflowGeneratorModal from '../index'
import { useWorkflowGeneratorStore } from '../store'
@ -93,6 +93,19 @@ describe('WorkflowGeneratorModal', () => {
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', () => {

View File

@ -1,5 +1,6 @@
'use client'
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 { GeneratedGraph } from './types'
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 { Textarea } from '@langgenius/dify-ui/textarea'
import { toast } from '@langgenius/dify-ui/toast'
import { matchesKeyboardEvent } from '@tanstack/react-hotkeys'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useBoolean } from 'ahooks'
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 —
// keeping the limit client-side turns an opaque 400 into a visible input stop.
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
// (stable ``code`` + human ``detail`` + optional ``node_id``) so the error panel
@ -615,9 +618,7 @@ function WorkflowGeneratorModal() {
value={instruction}
onValueChange={setInstruction}
onKeyDown={(e) => {
// ⌘/Ctrl+Enter generates — the journey starts keyboard-first in
// the palette, so let it finish without reaching for the mouse.
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
if (matchesKeyboardEvent(e.nativeEvent, WORKFLOW_GENERATOR_SUBMIT_HOTKEY)) {
e.preventDefault()
if (!isLoading) onGenerate()
}

View File

@ -5,7 +5,7 @@ import type {
AgentReferencingWorkflowResponse,
AgentReferencingWorkflowsResponse,
} 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 { Collapsible, CollapsiblePanel } from '@langgenius/dify-ui/collapsible'
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
@ -25,7 +25,7 @@ import useTimestamp from '@/hooks/use-timestamp'
import { consoleQuery } from '@/service/client'
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'

View File

@ -12,11 +12,12 @@ import { formatForDisplay } from '@tanstack/react-hotkeys'
import { skipToken, useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
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 Divider from '@/app/components/base/divider'
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 { GOTO_ANYTHING_HOTKEY } from '@/app/components/goto-anything/hotkeys'
import Link from '@/next/link'
import { usePathname } from '@/next/navigation'
import { consoleQuery } from '@/service/client'
@ -39,8 +40,6 @@ type AgentDetailNavItem = {
activeIcon: NavIcon
}
const SEARCH_SHORTCUT = ['Mod', 'K']
const createAgentNavIcon = (iconClassName: string) => {
function AgentNavIcon({ className }: ComponentProps<'svg'>) {
return <span aria-hidden className={cn(iconClassName, className)} />
@ -92,9 +91,9 @@ export function AgentDetailTop({ expand = true, onToggle }: AgentDetailTopProps)
return (
<div className="flex w-full items-center justify-center px-3 pt-2 pb-1">
{onToggle && (
<ToggleButton
<DetailSidebarToggleButton
expand={expand}
handleToggle={onToggle}
onToggle={onToggle}
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"
/>
@ -145,16 +144,16 @@ export function AgentDetailTop({ expand = true, onToggle }: AgentDetailTopProps)
>
<span className="px-0.5">{tApp(($) => $['gotoAnything.quickAction'])}</span>
<KbdGroup>
{SEARCH_SHORTCUT.map((key) => (
{GOTO_ANYTHING_HOTKEY.split('+').map((key) => (
<Kbd key={key}>{formatForDisplay(key)}</Kbd>
))}
</KbdGroup>
</TooltipContent>
</Tooltip>
{onToggle && (
<ToggleButton
<DetailSidebarToggleButton
expand={expand}
handleToggle={onToggle}
onToggle={onToggle}
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"
/>

View File

@ -11,11 +11,12 @@ import { formatForDisplay } from '@tanstack/react-hotkeys'
import { useAtomValue } from 'jotai'
import { useTranslation } from 'react-i18next'
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 SidebarLeftArrowIcon from '@/app/components/base/icons/src/vender/SidebarLeftArrowIcon'
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 { GOTO_ANYTHING_HOTKEY } from '@/app/components/goto-anything/hotkeys'
import Link from '@/next/link'
import { usePathname } from '@/next/navigation'
import { DeploymentActionsMenu } from '../deployment-actions'
@ -78,8 +79,6 @@ const DEPLOYMENT_TABS: TabDef[] = [
{ key: 'api-tokens', icon: ApiIcon, selectedIcon: ApiSelectedIcon },
]
const SEARCH_SHORTCUT = ['Mod', 'K']
function DeploymentIcon({ expand }: { expand: boolean }) {
return (
<div
@ -198,9 +197,9 @@ export function DeploymentDetailTop({
return (
<div className="flex w-full items-center justify-center px-3 pt-2 pb-1">
{onToggle && (
<ToggleButton
<DetailSidebarToggleButton
expand={expand}
handleToggle={onToggle}
onToggle={onToggle}
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"
/>
@ -251,16 +250,16 @@ export function DeploymentDetailTop({
>
<span className="px-0.5">{t(($) => $['gotoAnything.quickAction'], { ns: 'app' })}</span>
<KbdGroup>
{SEARCH_SHORTCUT.map((key) => (
{GOTO_ANYTHING_HOTKEY.split('+').map((key) => (
<Kbd key={key}>{formatForDisplay(key)}</Kbd>
))}
</KbdGroup>
</TooltipContent>
</Tooltip>
{onToggle && (
<ToggleButton
<DetailSidebarToggleButton
expand={expand}
handleToggle={onToggle}
onToggle={onToggle}
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"
/>