mirror of
https://github.com/langgenius/dify.git
synced 2026-09-08 11:04:27 +08:00
refactor(web): standardize hotkey ownership and typing (#38960)
This commit is contained in:
parent
f86bfb2a31
commit
e134c947c1
@ -18,6 +18,7 @@ Use this as the component decision guide for Dify web. Existing code is referenc
|
|||||||
| Should URL state enter Jotai? | Let Next.js route params and `nuqs` own URL state and updates. | Query atoms or shared derived atoms need a read-only bridge hydrated at the route/surface boundary. |
|
| Should URL state enter Jotai? | Let Next.js route params and `nuqs` own URL state and updates. | Query atoms or shared derived atoms need a read-only bridge hydrated at the route/surface boundary. |
|
||||||
| Should this query/mutation become an atom? | Use TanStack Query hooks at the lowest owner. | It reads atom state, feeds derived atoms, or participates in shared Jotai workflow orchestration. |
|
| Should this query/mutation become an atom? | Use TanStack Query hooks at the lowest owner. | It reads atom state, feeds derived atoms, or participates in shared Jotai workflow orchestration. |
|
||||||
| Should this be a helper/wrapper? | Prefer direct readable code at the use site. | The name captures a stable domain rule or the wrapper owns real behavior, validation, state, error handling, or semantics. |
|
| Should this be a helper/wrapper? | Prefer direct readable code at the use site. | The name captures a stable domain rule or the wrapper owns real behavior, validation, state, error handling, or semantics. |
|
||||||
|
| Where should a hotkey live? | Keep a single-owner hotkey constant in its component. | Multiple production files share one command, or the feature owns a real command registry with shared metadata and behavior. |
|
||||||
| Is an Effect needed? | No. Derive during render or handle the user action in the event handler. | It synchronizes with an external system such as browser APIs, subscriptions, timers, analytics, or imperative DOM/non-React widgets. |
|
| Is an Effect needed? | No. Derive during render or handle the user action in the event handler. | It synchronizes with an external system such as browser APIs, subscriptions, timers, analytics, or imperative DOM/non-React widgets. |
|
||||||
|
|
||||||
## Core Defaults
|
## Core Defaults
|
||||||
@ -77,6 +78,19 @@ Use this as the component decision guide for Dify web. Existing code is referenc
|
|||||||
- Name values by their domain role and backend API contract, especially persistent IDs and route params. Normalize framework or route params at the boundary.
|
- Name values by their domain role and backend API contract, especially persistent IDs and route params. Normalize framework or route params at the boundary.
|
||||||
- Put fallback and invariant checks in the lowest component that already handles that state. Do not extract helpers whose only behavior is hiding missing display data.
|
- Put fallback and invariant checks in the lowest component that already handles that state. Do not extract helpers whose only behavior is hiding missing display data.
|
||||||
|
|
||||||
|
## Keyboard Shortcuts
|
||||||
|
|
||||||
|
- Distinguish application commands from local keyboard semantics before choosing an API. Use `@tanstack/react-hotkeys` for application commands. Keep menu navigation, dialog Escape handling owned by a primitive, editor commands, and other widget-scoped ARIA interactions in their local component or primitive.
|
||||||
|
- Use `useHotkey` or `useHotkeys` for registered commands. For a command intentionally owned by an existing `onKeyDown`, use `matchesKeyboardEvent` instead of hand-written `metaKey` / `ctrlKey` parsing or a second global listener.
|
||||||
|
- Define a reusable string command with `satisfies Hotkey` and an object-form command with `satisfies RawHotkey`. Reserve `RegisterableHotkey` for API boundaries that intentionally accept either form. A one-time inline literal passed directly to TanStack is already type-checked; extract it when registration, display, metadata, or another production consumer needs the same source.
|
||||||
|
- Keep registered hotkeys distinct from held keys and display-only accelerators. Use `IndividualKey` with `useKeyHold` for held-key interactions, and use an explicitly named `displayKey` for local widget accelerators that are not registered `Hotkey` values.
|
||||||
|
- Keep registration and keycap/menu display derived from one canonical command. Do not maintain a hotkey string beside a separate `['Mod', ...]` display array.
|
||||||
|
- Keep a single-owner command constant in its owning component. Create a feature-local `hotkeys.ts` only when multiple production files consume the same command. Keep a dedicated definitions/registry module when a feature owns a real command system with IDs, metadata, alternate bindings, and centralized registration. Tests do not count as another production owner, and file-name uniformity alone is not a reason to extract.
|
||||||
|
- Make scope and availability explicit. Use `enabled` for business or surface lifecycle, `ignoreInputs` for whether input-like elements may trigger the command, and `target` when the command belongs to a concrete DOM subtree. Global application commands may use the document target; inline editors and composed overlays should prefer the actual editor or Base UI Popup ref when that owner is exposed.
|
||||||
|
- Put a scoped ref on the real behavior owner. Do not add a wrapper DOM element solely to obtain a hotkey target. If a shared overlay convenience component hides the Popup ref, either rely on its modal lifecycle/focus boundary when that is sufficient or design the primitive API separately; do not create a fake owner at the call site.
|
||||||
|
- Set `preventDefault` and `stopPropagation` according to the existing product behavior and browser interaction. Do not silently accept TanStack defaults when migrating from another listener if that changes typing, submission, or propagation semantics.
|
||||||
|
- Test observable command behavior, disabled/input/target scope, and the shared registration/display contract at the owning feature boundary. Prefer partial mocks that retain TanStack formatting and matching behavior when a registration boundary must be isolated.
|
||||||
|
|
||||||
## Generated API And Nullable Data
|
## Generated API And Nullable Data
|
||||||
|
|
||||||
- Treat generated contracts as authoritative at API, query, mutation, cache, and service boundaries. For enterprise APIs, use `packages/contracts/generated/enterprise/*`.
|
- Treat generated contracts as authoritative at API, query, mutation, cache, and service boundaries. For enterprise APIs, use `packages/contracts/generated/enterprise/*`.
|
||||||
|
|||||||
@ -757,34 +757,8 @@
|
|||||||
"count": 1
|
"count": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"web/app/components/app/create-app-modal/index.tsx": {
|
|
||||||
"jsx_a11y/click-events-have-key-events": {
|
|
||||||
"count": 1
|
|
||||||
},
|
|
||||||
"jsx_a11y/no-static-element-interactions": {
|
|
||||||
"count": 1
|
|
||||||
},
|
|
||||||
"no-restricted-imports": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"web/app/components/app/create-from-dsl-modal/index.tsx": {
|
"web/app/components/app/create-from-dsl-modal/index.tsx": {
|
||||||
"erasable-syntax-only/enums": {
|
|
||||||
"count": 1
|
|
||||||
},
|
|
||||||
"eslint-react/set-state-in-effect": {
|
"eslint-react/set-state-in-effect": {
|
||||||
"count": 2
|
|
||||||
},
|
|
||||||
"jsx_a11y/click-events-have-key-events": {
|
|
||||||
"count": 2
|
|
||||||
},
|
|
||||||
"jsx_a11y/no-static-element-interactions": {
|
|
||||||
"count": 2
|
|
||||||
},
|
|
||||||
"no-restricted-imports": {
|
|
||||||
"count": 1
|
|
||||||
},
|
|
||||||
"react/only-export-components": {
|
|
||||||
"count": 1
|
"count": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,30 +1,7 @@
|
|||||||
import type { ReactNode } from 'react'
|
|
||||||
import { Dialog, DialogPopup, DialogPortal, DialogTitle } from '@langgenius/dify-ui/dialog'
|
import { Dialog, DialogPopup, DialogPortal, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||||
import { fireEvent, render, screen } from '@testing-library/react'
|
import { fireEvent, render, screen } from '@testing-library/react'
|
||||||
import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle'
|
import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle'
|
||||||
import AppDetailTop from '../app-detail-top'
|
import { AppDetailTop } from '../app-detail-top'
|
||||||
|
|
||||||
vi.mock('../toggle-button', () => ({
|
|
||||||
default: ({
|
|
||||||
expand,
|
|
||||||
handleToggle,
|
|
||||||
icon,
|
|
||||||
}: {
|
|
||||||
expand: boolean
|
|
||||||
handleToggle: () => void
|
|
||||||
icon?: ReactNode
|
|
||||||
}) => (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-testid="toggle-button"
|
|
||||||
data-expand={expand}
|
|
||||||
data-has-icon={Boolean(icon)}
|
|
||||||
onClick={handleToggle}
|
|
||||||
>
|
|
||||||
Toggle
|
|
||||||
</button>
|
|
||||||
),
|
|
||||||
}))
|
|
||||||
|
|
||||||
function TestGotoAnythingDialog() {
|
function TestGotoAnythingDialog() {
|
||||||
return (
|
return (
|
||||||
@ -75,10 +52,8 @@ describe('AppDetailTop', () => {
|
|||||||
const onToggle = vi.fn()
|
const onToggle = vi.fn()
|
||||||
|
|
||||||
render(<AppDetailTop onToggle={onToggle} />)
|
render(<AppDetailTop onToggle={onToggle} />)
|
||||||
fireEvent.click(screen.getByTestId('toggle-button'))
|
fireEvent.click(screen.getByRole('button', { name: 'layout.sidebar.collapseSidebar' }))
|
||||||
|
|
||||||
expect(screen.getByTestId('toggle-button')).toHaveAttribute('data-expand', 'true')
|
|
||||||
expect(screen.getByTestId('toggle-button')).toHaveAttribute('data-has-icon', 'true')
|
|
||||||
expect(onToggle).toHaveBeenCalledTimes(1)
|
expect(onToggle).toHaveBeenCalledTimes(1)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,30 +1,7 @@
|
|||||||
import type { ReactNode } from 'react'
|
|
||||||
import { Dialog, DialogPopup, DialogPortal, DialogTitle } from '@langgenius/dify-ui/dialog'
|
import { Dialog, DialogPopup, DialogPortal, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||||
import { fireEvent, render, screen } from '@testing-library/react'
|
import { fireEvent, render, screen } from '@testing-library/react'
|
||||||
import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle'
|
import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle'
|
||||||
import DatasetDetailTop from '../dataset-detail-top'
|
import { DatasetDetailTop } from '../dataset-detail-top'
|
||||||
|
|
||||||
vi.mock('../toggle-button', () => ({
|
|
||||||
default: ({
|
|
||||||
expand,
|
|
||||||
handleToggle,
|
|
||||||
icon,
|
|
||||||
}: {
|
|
||||||
expand: boolean
|
|
||||||
handleToggle: () => void
|
|
||||||
icon?: ReactNode
|
|
||||||
}) => (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-testid="toggle-button"
|
|
||||||
data-expand={expand}
|
|
||||||
data-has-icon={Boolean(icon)}
|
|
||||||
onClick={handleToggle}
|
|
||||||
>
|
|
||||||
Toggle
|
|
||||||
</button>
|
|
||||||
),
|
|
||||||
}))
|
|
||||||
|
|
||||||
function TestGotoAnythingDialog() {
|
function TestGotoAnythingDialog() {
|
||||||
return (
|
return (
|
||||||
@ -73,10 +50,8 @@ describe('DatasetDetailTop', () => {
|
|||||||
const onToggle = vi.fn()
|
const onToggle = vi.fn()
|
||||||
|
|
||||||
render(<DatasetDetailTop expand={false} onToggle={onToggle} />)
|
render(<DatasetDetailTop expand={false} onToggle={onToggle} />)
|
||||||
fireEvent.click(screen.getByTestId('toggle-button'))
|
fireEvent.click(screen.getByRole('button', { name: 'layout.sidebar.expandSidebar' }))
|
||||||
|
|
||||||
expect(screen.getByTestId('toggle-button')).toHaveAttribute('data-expand', 'false')
|
|
||||||
expect(screen.getByTestId('toggle-button')).toHaveAttribute('data-has-icon', 'true')
|
|
||||||
expect(onToggle).toHaveBeenCalledTimes(1)
|
expect(onToggle).toHaveBeenCalledTimes(1)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { DetailSidebarFrame } from '@/app/components/detail-sidebar'
|
import { DetailSidebarFrame } from '@/app/components/detail-sidebar'
|
||||||
import AppDetailSection from './app-detail-section'
|
import AppDetailSection from './app-detail-section'
|
||||||
import AppDetailTop from './app-detail-top'
|
import { AppDetailTop } from './app-detail-top'
|
||||||
|
|
||||||
export function AppDetailSidebar() {
|
export function AppDetailSidebar() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -6,27 +6,26 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/too
|
|||||||
import { formatForDisplay } from '@tanstack/react-hotkeys'
|
import { formatForDisplay } from '@tanstack/react-hotkeys'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import SidebarLeftArrowIcon from '@/app/components/base/icons/src/vender/SidebarLeftArrowIcon'
|
import SidebarLeftArrowIcon from '@/app/components/base/icons/src/vender/SidebarLeftArrowIcon'
|
||||||
|
import { DetailSidebarToggleButton } from '@/app/components/detail-sidebar/toggle-button'
|
||||||
import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle'
|
import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle'
|
||||||
|
import { GOTO_ANYTHING_HOTKEY } from '@/app/components/goto-anything/hotkeys'
|
||||||
import Link from '@/next/link'
|
import Link from '@/next/link'
|
||||||
import ToggleButton from './toggle-button'
|
|
||||||
|
|
||||||
type AppDetailTopProps = {
|
type AppDetailTopProps = {
|
||||||
expand?: boolean
|
expand?: boolean
|
||||||
onToggle?: () => void
|
onToggle?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const SEARCH_SHORTCUT = ['Mod', 'K']
|
export function AppDetailTop({ expand = true, onToggle }: AppDetailTopProps) {
|
||||||
|
|
||||||
const AppDetailTop = ({ expand = true, onToggle }: AppDetailTopProps) => {
|
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
if (!expand) {
|
if (!expand) {
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full items-center justify-center px-3 pt-2 pb-1">
|
<div className="flex w-full items-center justify-center px-3 pt-2 pb-1">
|
||||||
{onToggle && (
|
{onToggle && (
|
||||||
<ToggleButton
|
<DetailSidebarToggleButton
|
||||||
expand={expand}
|
expand={expand}
|
||||||
handleToggle={onToggle}
|
onToggle={onToggle}
|
||||||
icon={<SidebarLeftArrowIcon aria-hidden className="size-4" />}
|
icon={<SidebarLeftArrowIcon aria-hidden className="size-4" />}
|
||||||
className="size-8 rounded-[10px] border-0 bg-transparent px-0 text-text-tertiary shadow-none hover:border-0 hover:bg-state-base-hover hover:text-text-secondary"
|
className="size-8 rounded-[10px] border-0 bg-transparent px-0 text-text-tertiary shadow-none hover:border-0 hover:bg-state-base-hover hover:text-text-secondary"
|
||||||
/>
|
/>
|
||||||
@ -82,7 +81,7 @@ const AppDetailTop = ({ expand = true, onToggle }: AppDetailTopProps) => {
|
|||||||
>
|
>
|
||||||
<span className="px-0.5">{t(($) => $['gotoAnything.quickAction'], { ns: 'app' })}</span>
|
<span className="px-0.5">{t(($) => $['gotoAnything.quickAction'], { ns: 'app' })}</span>
|
||||||
<KbdGroup>
|
<KbdGroup>
|
||||||
{SEARCH_SHORTCUT.map((key) => (
|
{GOTO_ANYTHING_HOTKEY.split('+').map((key) => (
|
||||||
<Kbd key={key}>{formatForDisplay(key)}</Kbd>
|
<Kbd key={key}>{formatForDisplay(key)}</Kbd>
|
||||||
))}
|
))}
|
||||||
</KbdGroup>
|
</KbdGroup>
|
||||||
@ -90,9 +89,9 @@ const AppDetailTop = ({ expand = true, onToggle }: AppDetailTopProps) => {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{onToggle && (
|
{onToggle && (
|
||||||
<ToggleButton
|
<DetailSidebarToggleButton
|
||||||
expand={expand}
|
expand={expand}
|
||||||
handleToggle={onToggle}
|
onToggle={onToggle}
|
||||||
icon={<SidebarLeftArrowIcon aria-hidden className="size-4" />}
|
icon={<SidebarLeftArrowIcon aria-hidden className="size-4" />}
|
||||||
className="size-8 rounded-[10px] border-0 bg-transparent px-0 text-text-tertiary shadow-none hover:border-0 hover:bg-state-base-hover hover:text-text-secondary"
|
className="size-8 rounded-[10px] border-0 bg-transparent px-0 text-text-tertiary shadow-none hover:border-0 hover:bg-state-base-hover hover:text-text-secondary"
|
||||||
/>
|
/>
|
||||||
@ -100,5 +99,3 @@ const AppDetailTop = ({ expand = true, onToggle }: AppDetailTopProps) => {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default AppDetailTop
|
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { DetailSidebarFrame } from '@/app/components/detail-sidebar'
|
import { DetailSidebarFrame } from '@/app/components/detail-sidebar'
|
||||||
import DatasetDetailSection from './dataset-detail-section'
|
import DatasetDetailSection from './dataset-detail-section'
|
||||||
import DatasetDetailTop from './dataset-detail-top'
|
import { DatasetDetailTop } from './dataset-detail-top'
|
||||||
|
|
||||||
export function DatasetDetailSidebar() {
|
export function DatasetDetailSidebar() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -6,27 +6,26 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/too
|
|||||||
import { formatForDisplay } from '@tanstack/react-hotkeys'
|
import { formatForDisplay } from '@tanstack/react-hotkeys'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import SidebarLeftArrowIcon from '@/app/components/base/icons/src/vender/SidebarLeftArrowIcon'
|
import SidebarLeftArrowIcon from '@/app/components/base/icons/src/vender/SidebarLeftArrowIcon'
|
||||||
|
import { DetailSidebarToggleButton } from '@/app/components/detail-sidebar/toggle-button'
|
||||||
import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle'
|
import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle'
|
||||||
|
import { GOTO_ANYTHING_HOTKEY } from '@/app/components/goto-anything/hotkeys'
|
||||||
import Link from '@/next/link'
|
import Link from '@/next/link'
|
||||||
import ToggleButton from './toggle-button'
|
|
||||||
|
|
||||||
type DatasetDetailTopProps = {
|
type DatasetDetailTopProps = {
|
||||||
expand?: boolean
|
expand?: boolean
|
||||||
onToggle?: () => void
|
onToggle?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const SEARCH_SHORTCUT = ['Mod', 'K']
|
export function DatasetDetailTop({ expand = true, onToggle }: DatasetDetailTopProps) {
|
||||||
|
|
||||||
const DatasetDetailTop = ({ expand = true, onToggle }: DatasetDetailTopProps) => {
|
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
if (!expand) {
|
if (!expand) {
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full items-center justify-center px-3 pt-2 pb-1">
|
<div className="flex w-full items-center justify-center px-3 pt-2 pb-1">
|
||||||
{onToggle && (
|
{onToggle && (
|
||||||
<ToggleButton
|
<DetailSidebarToggleButton
|
||||||
expand={expand}
|
expand={expand}
|
||||||
handleToggle={onToggle}
|
onToggle={onToggle}
|
||||||
icon={<SidebarLeftArrowIcon aria-hidden className="size-4" />}
|
icon={<SidebarLeftArrowIcon aria-hidden className="size-4" />}
|
||||||
className="size-8 rounded-[10px] border-0 bg-transparent px-0 text-text-tertiary shadow-none hover:border-0 hover:bg-state-base-hover hover:text-text-secondary"
|
className="size-8 rounded-[10px] border-0 bg-transparent px-0 text-text-tertiary shadow-none hover:border-0 hover:bg-state-base-hover hover:text-text-secondary"
|
||||||
/>
|
/>
|
||||||
@ -82,7 +81,7 @@ const DatasetDetailTop = ({ expand = true, onToggle }: DatasetDetailTopProps) =>
|
|||||||
>
|
>
|
||||||
<span className="px-0.5">{t(($) => $['gotoAnything.quickAction'], { ns: 'app' })}</span>
|
<span className="px-0.5">{t(($) => $['gotoAnything.quickAction'], { ns: 'app' })}</span>
|
||||||
<KbdGroup>
|
<KbdGroup>
|
||||||
{SEARCH_SHORTCUT.map((key) => (
|
{GOTO_ANYTHING_HOTKEY.split('+').map((key) => (
|
||||||
<Kbd key={key}>{formatForDisplay(key)}</Kbd>
|
<Kbd key={key}>{formatForDisplay(key)}</Kbd>
|
||||||
))}
|
))}
|
||||||
</KbdGroup>
|
</KbdGroup>
|
||||||
@ -90,9 +89,9 @@ const DatasetDetailTop = ({ expand = true, onToggle }: DatasetDetailTopProps) =>
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{onToggle && (
|
{onToggle && (
|
||||||
<ToggleButton
|
<DetailSidebarToggleButton
|
||||||
expand={expand}
|
expand={expand}
|
||||||
handleToggle={onToggle}
|
onToggle={onToggle}
|
||||||
icon={<SidebarLeftArrowIcon aria-hidden className="size-4" />}
|
icon={<SidebarLeftArrowIcon aria-hidden className="size-4" />}
|
||||||
className="size-8 rounded-[10px] border-0 bg-transparent px-0 text-text-tertiary shadow-none hover:border-0 hover:bg-state-base-hover hover:text-text-secondary"
|
className="size-8 rounded-[10px] border-0 bg-transparent px-0 text-text-tertiary shadow-none hover:border-0 hover:bg-state-base-hover hover:text-text-secondary"
|
||||||
/>
|
/>
|
||||||
@ -100,5 +99,3 @@ const DatasetDetailTop = ({ expand = true, onToggle }: DatasetDetailTopProps) =>
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default DatasetDetailTop
|
|
||||||
|
|||||||
@ -140,7 +140,7 @@ type MockCreateSnippetDialogProps = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
vi.mock('@/app/components/snippets/create-snippet-dialog', () => ({
|
vi.mock('@/app/components/snippets/create-snippet-dialog', () => ({
|
||||||
default: ({
|
CreateSnippetDialog: ({
|
||||||
isOpen,
|
isOpen,
|
||||||
title,
|
title,
|
||||||
confirmText,
|
confirmText,
|
||||||
|
|||||||
@ -22,7 +22,7 @@ import { toast } from '@langgenius/dify-ui/toast'
|
|||||||
import { useAtomValue } from 'jotai'
|
import { useAtomValue } from 'jotai'
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import CreateSnippetDialog from '@/app/components/snippets/create-snippet-dialog'
|
import { CreateSnippetDialog } from '@/app/components/snippets/create-snippet-dialog'
|
||||||
import {
|
import {
|
||||||
canCreateAndModifySnippets,
|
canCreateAndModifySnippets,
|
||||||
canManageSnippets,
|
canManageSnippets,
|
||||||
|
|||||||
@ -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)
|
|
||||||
@ -285,7 +285,6 @@ describe('AppPublisher', () => {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
expect(sectionProps.summary?.publishShortcut).toEqual(['Mod', 'Shift', 'P'])
|
|
||||||
expect(mockRefetch).not.toHaveBeenCalled()
|
expect(mockRefetch).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -75,7 +75,6 @@ describe('app-publisher sections', () => {
|
|||||||
publishDisabled={false}
|
publishDisabled={false}
|
||||||
published={false}
|
published={false}
|
||||||
publishedAt={Date.now()}
|
publishedAt={Date.now()}
|
||||||
publishShortcut={['Mod', 'Shift', 'P']}
|
|
||||||
startNodeLimitExceeded={false}
|
startNodeLimitExceeded={false}
|
||||||
upgradeHighlightStyle={{}}
|
upgradeHighlightStyle={{}}
|
||||||
/>,
|
/>,
|
||||||
@ -113,7 +112,6 @@ describe('app-publisher sections', () => {
|
|||||||
publishDisabled={false}
|
publishDisabled={false}
|
||||||
published={false}
|
published={false}
|
||||||
publishedAt={undefined}
|
publishedAt={undefined}
|
||||||
publishShortcut={['Mod', 'Shift', 'P']}
|
|
||||||
startNodeLimitExceeded={false}
|
startNodeLimitExceeded={false}
|
||||||
upgradeHighlightStyle={{}}
|
upgradeHighlightStyle={{}}
|
||||||
/>,
|
/>,
|
||||||
@ -137,7 +135,6 @@ describe('app-publisher sections', () => {
|
|||||||
publishDisabled={false}
|
publishDisabled={false}
|
||||||
published={false}
|
published={false}
|
||||||
publishedAt={undefined}
|
publishedAt={undefined}
|
||||||
publishShortcut={['Mod', 'Shift', 'P']}
|
|
||||||
startNodeLimitExceeded={false}
|
startNodeLimitExceeded={false}
|
||||||
upgradeHighlightStyle={{}}
|
upgradeHighlightStyle={{}}
|
||||||
/>,
|
/>,
|
||||||
@ -161,7 +158,6 @@ describe('app-publisher sections', () => {
|
|||||||
publishDisabled={false}
|
publishDisabled={false}
|
||||||
published={false}
|
published={false}
|
||||||
publishedAt={undefined}
|
publishedAt={undefined}
|
||||||
publishShortcut={['Mod', 'Shift', 'P']}
|
|
||||||
startNodeLimitExceeded
|
startNodeLimitExceeded
|
||||||
upgradeHighlightStyle={{}}
|
upgradeHighlightStyle={{}}
|
||||||
/>,
|
/>,
|
||||||
|
|||||||
3
web/app/components/app/app-publisher/hotkeys.ts
Normal file
3
web/app/components/app/app-publisher/hotkeys.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import type { Hotkey } from '@tanstack/react-hotkeys'
|
||||||
|
|
||||||
|
export const APP_PUBLISH_HOTKEY = 'Mod+Shift+P' satisfies Hotkey
|
||||||
@ -1,4 +1,3 @@
|
|||||||
import type { RegisterableHotkey } from '@tanstack/react-hotkeys'
|
|
||||||
import type { FormEvent } from 'react'
|
import type { FormEvent } from 'react'
|
||||||
import type { ModelAndParameter } from '../configuration/debug/types'
|
import type { ModelAndParameter } from '../configuration/debug/types'
|
||||||
import type {
|
import type {
|
||||||
@ -45,6 +44,7 @@ import { fetchPublishedWorkflow } from '@/service/workflow'
|
|||||||
import { AppModeEnum } from '@/types/app'
|
import { AppModeEnum } from '@/types/app'
|
||||||
import { basePath } from '@/utils/var'
|
import { basePath } from '@/utils/var'
|
||||||
import AccessControl from '../app-access-control'
|
import AccessControl from '../app-access-control'
|
||||||
|
import { APP_PUBLISH_HOTKEY } from './hotkeys'
|
||||||
import {
|
import {
|
||||||
PublisherAccessSection,
|
PublisherAccessSection,
|
||||||
PublisherActionsSection,
|
PublisherActionsSection,
|
||||||
@ -81,9 +81,6 @@ export type AppPublisherProps = {
|
|||||||
hasHumanInputNode?: boolean
|
hasHumanInputNode?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const PUBLISH_HOTKEY = 'Mod+Shift+P' satisfies RegisterableHotkey
|
|
||||||
const PUBLISH_SHORTCUT = PUBLISH_HOTKEY.split('+')
|
|
||||||
|
|
||||||
export type AppPublisherPublishParams = ModelAndParameter | PublishWorkflowParams
|
export type AppPublisherPublishParams = ModelAndParameter | PublishWorkflowParams
|
||||||
|
|
||||||
type AppPublisherPublishHandler =
|
type AppPublisherPublishHandler =
|
||||||
@ -307,7 +304,7 @@ export function AppPublisher({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useHotkey(PUBLISH_HOTKEY, (e) => {
|
useHotkey(APP_PUBLISH_HOTKEY, (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (publishDisabled || published) return
|
if (publishDisabled || published) return
|
||||||
handlePublish()
|
handlePublish()
|
||||||
@ -410,7 +407,6 @@ export function AppPublisher({
|
|||||||
publishDisabled={publishDisabled}
|
publishDisabled={publishDisabled}
|
||||||
published={published}
|
published={published}
|
||||||
publishedAt={publishedAt}
|
publishedAt={publishedAt}
|
||||||
publishShortcut={PUBLISH_SHORTCUT}
|
|
||||||
startNodeLimitExceeded={startNodeLimitExceeded}
|
startNodeLimitExceeded={startNodeLimitExceeded}
|
||||||
upgradeHighlightStyle={upgradeHighlightStyle}
|
upgradeHighlightStyle={upgradeHighlightStyle}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import Loading from '@/app/components/base/loading'
|
|||||||
import UpgradeBtn from '@/app/components/billing/upgrade-btn'
|
import UpgradeBtn from '@/app/components/billing/upgrade-btn'
|
||||||
import WorkflowToolConfigureButton from '@/app/components/tools/workflow-tool/configure-button'
|
import WorkflowToolConfigureButton from '@/app/components/tools/workflow-tool/configure-button'
|
||||||
import { AppModeEnum } from '@/types/app'
|
import { AppModeEnum } from '@/types/app'
|
||||||
|
import { APP_PUBLISH_HOTKEY } from './hotkeys'
|
||||||
import PublishWithMultipleModel from './publish-with-multiple-model'
|
import PublishWithMultipleModel from './publish-with-multiple-model'
|
||||||
import SuggestedAction from './suggested-action'
|
import SuggestedAction from './suggested-action'
|
||||||
import { ACCESS_MODE_MAP } from './utils'
|
import { ACCESS_MODE_MAP } from './utils'
|
||||||
@ -30,7 +31,6 @@ type SummarySectionProps = Pick<
|
|||||||
handleRestore: () => Promise<void>
|
handleRestore: () => Promise<void>
|
||||||
isChatApp: boolean
|
isChatApp: boolean
|
||||||
published: boolean
|
published: boolean
|
||||||
publishShortcut: string[]
|
|
||||||
upgradeHighlightStyle: CSSProperties
|
upgradeHighlightStyle: CSSProperties
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -109,7 +109,6 @@ export const PublisherSummarySection = ({
|
|||||||
publishDisabled = false,
|
publishDisabled = false,
|
||||||
published,
|
published,
|
||||||
publishedAt,
|
publishedAt,
|
||||||
publishShortcut,
|
|
||||||
startNodeLimitExceeded = false,
|
startNodeLimitExceeded = false,
|
||||||
upgradeHighlightStyle,
|
upgradeHighlightStyle,
|
||||||
}: SummarySectionProps) => {
|
}: SummarySectionProps) => {
|
||||||
@ -163,7 +162,7 @@ export const PublisherSummarySection = ({
|
|||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<span>{t(($) => $['common.publishUpdate'], { ns: 'workflow' })}</span>
|
<span>{t(($) => $['common.publishUpdate'], { ns: 'workflow' })}</span>
|
||||||
<KbdGroup>
|
<KbdGroup>
|
||||||
{publishShortcut.map((key) => (
|
{APP_PUBLISH_HOTKEY.split('+').map((key) => (
|
||||||
<Kbd key={key} color="white">
|
<Kbd key={key} color="white">
|
||||||
{formatForDisplay(key)}
|
{formatForDisplay(key)}
|
||||||
</Kbd>
|
</Kbd>
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import type { Hotkey } from '@tanstack/react-hotkeys'
|
||||||
import type { AppIconSelection } from '../../base/app-icon-picker'
|
import type { AppIconSelection } from '../../base/app-icon-picker'
|
||||||
import { Button } from '@langgenius/dify-ui/button'
|
import { Button } from '@langgenius/dify-ui/button'
|
||||||
import { cn } from '@langgenius/dify-ui/cn'
|
import { cn } from '@langgenius/dify-ui/cn'
|
||||||
|
import { Input } from '@langgenius/dify-ui/input'
|
||||||
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
|
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
|
||||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||||
import { toast } from '@langgenius/dify-ui/toast'
|
import { toast } from '@langgenius/dify-ui/toast'
|
||||||
@ -22,7 +24,6 @@ import {
|
|||||||
ListSparkle,
|
ListSparkle,
|
||||||
Logic,
|
Logic,
|
||||||
} from '@/app/components/base/icons/src/vender/solid/communication'
|
} from '@/app/components/base/icons/src/vender/solid/communication'
|
||||||
import Input from '@/app/components/base/input'
|
|
||||||
import AppsFull from '@/app/components/billing/apps-full-in-dialog'
|
import AppsFull from '@/app/components/billing/apps-full-in-dialog'
|
||||||
import { userProfileIdAtom } from '@/context/account-state'
|
import { userProfileIdAtom } from '@/context/account-state'
|
||||||
import { workspacePermissionKeysAtom } from '@/context/permission-state'
|
import { workspacePermissionKeysAtom } from '@/context/permission-state'
|
||||||
@ -47,6 +48,8 @@ type CreateAppProps = {
|
|||||||
defaultAppMode?: AppModeEnum
|
defaultAppMode?: AppModeEnum
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CREATE_APP_HOTKEY = 'Mod+Enter' satisfies Hotkey
|
||||||
|
|
||||||
const shouldExpandBeginnerAppTypes = (appMode?: AppModeEnum) => {
|
const shouldExpandBeginnerAppTypes = (appMode?: AppModeEnum) => {
|
||||||
return (
|
return (
|
||||||
appMode === AppModeEnum.CHAT ||
|
appMode === AppModeEnum.CHAT ||
|
||||||
@ -152,7 +155,7 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
|
|||||||
|
|
||||||
const { run: handleCreateApp } = useDebounceFn(onCreate, { wait: 300 })
|
const { run: handleCreateApp } = useDebounceFn(onCreate, { wait: 300 })
|
||||||
useHotkey(
|
useHotkey(
|
||||||
'Mod+Enter',
|
CREATE_APP_HOTKEY,
|
||||||
() => {
|
() => {
|
||||||
if (isAppsFull || !canCreateApp) return
|
if (isAppsFull || !canCreateApp) return
|
||||||
handleCreateApp()
|
handleCreateApp()
|
||||||
@ -348,7 +351,7 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
|
|||||||
>
|
>
|
||||||
<span>{t(($) => $['newApp.Create'], { ns: 'app' })}</span>
|
<span>{t(($) => $['newApp.Create'], { ns: 'app' })}</span>
|
||||||
<KbdGroup>
|
<KbdGroup>
|
||||||
{['Mod', 'Enter'].map((key) => (
|
{CREATE_APP_HOTKEY.split('+').map((key) => (
|
||||||
<Kbd key={key} color="white">
|
<Kbd key={key} color="white">
|
||||||
{formatForDisplay(key)}
|
{formatForDisplay(key)}
|
||||||
</Kbd>
|
</Kbd>
|
||||||
@ -434,9 +437,10 @@ type AppTypeCardProps = {
|
|||||||
}
|
}
|
||||||
function AppTypeCard({ icon, title, description, active, onClick }: AppTypeCardProps) {
|
function AppTypeCard({ icon, title, description, active, onClick }: AppTypeCardProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<button
|
||||||
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
`relative box-content h-[84px] w-[191px] cursor-pointer rounded-xl border-[0.5px] border-components-option-card-option-border bg-components-panel-on-panel-item-bg p-3 shadow-xs hover:shadow-md`,
|
'relative box-content h-[84px] w-[191px] cursor-pointer rounded-xl border-[0.5px] border-components-option-card-option-border bg-components-panel-on-panel-item-bg p-3 text-left shadow-xs outline-hidden hover:shadow-md focus-visible:ring-2 focus-visible:ring-state-accent-solid',
|
||||||
active
|
active
|
||||||
? 'shadow-md outline-[1.5px] outline-components-option-card-option-selected-border outline-solid'
|
? 'shadow-md outline-[1.5px] outline-components-option-card-option-selected-border outline-solid'
|
||||||
: '',
|
: '',
|
||||||
@ -448,7 +452,7 @@ function AppTypeCard({ icon, title, description, active, onClick }: AppTypeCardP
|
|||||||
<div className="line-clamp-2 system-xs-regular text-text-tertiary" title={description}>
|
<div className="line-clamp-2 system-xs-regular text-text-tertiary" title={description}>
|
||||||
{description}
|
{description}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -4,7 +4,8 @@ import { renderWithSystemFeatures as render } from '@/__tests__/utils/mock-syste
|
|||||||
import { NEED_REFRESH_APP_LIST_KEY } from '@/app/components/apps/storage'
|
import { NEED_REFRESH_APP_LIST_KEY } from '@/app/components/apps/storage'
|
||||||
import { DSLImportMode, DSLImportStatus } from '@/models/app'
|
import { DSLImportMode, DSLImportStatus } from '@/models/app'
|
||||||
import { AppModeEnum } from '@/types/app'
|
import { AppModeEnum } from '@/types/app'
|
||||||
import CreateFromDSLModal, { CreateFromDSLModalTab } from '../index'
|
import CreateFromDSLModal from '../index'
|
||||||
|
import { CreateFromDSLModalTab } from '../types'
|
||||||
|
|
||||||
const mockPush = vi.fn()
|
const mockPush = vi.fn()
|
||||||
const mockImportDSL = vi.fn()
|
const mockImportDSL = vi.fn()
|
||||||
|
|||||||
@ -1,9 +1,11 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import type { Hotkey } from '@tanstack/react-hotkeys'
|
||||||
import type { MouseEventHandler } from 'react'
|
import type { MouseEventHandler } from 'react'
|
||||||
import { Button } from '@langgenius/dify-ui/button'
|
import { Button } from '@langgenius/dify-ui/button'
|
||||||
import { cn } from '@langgenius/dify-ui/cn'
|
import { cn } from '@langgenius/dify-ui/cn'
|
||||||
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
|
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
|
||||||
|
import { Input } from '@langgenius/dify-ui/input'
|
||||||
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
|
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
|
||||||
import { toast } from '@langgenius/dify-ui/toast'
|
import { toast } from '@langgenius/dify-ui/toast'
|
||||||
import { formatForDisplay, useHotkey } from '@tanstack/react-hotkeys'
|
import { formatForDisplay, useHotkey } from '@tanstack/react-hotkeys'
|
||||||
@ -13,7 +15,6 @@ import { useAtomValue } from 'jotai'
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useSetNeedRefreshAppList } from '@/app/components/apps/storage'
|
import { useSetNeedRefreshAppList } from '@/app/components/apps/storage'
|
||||||
import Input from '@/app/components/base/input'
|
|
||||||
import AppsFull from '@/app/components/billing/apps-full-in-dialog'
|
import AppsFull from '@/app/components/billing/apps-full-in-dialog'
|
||||||
import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks'
|
import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks'
|
||||||
import { userProfileIdAtom } from '@/context/account-state'
|
import { userProfileIdAtom } from '@/context/account-state'
|
||||||
@ -26,21 +27,19 @@ import { importDSL, importDSLConfirm } from '@/service/apps'
|
|||||||
import { useInvalidateAppList } from '@/service/use-apps'
|
import { useInvalidateAppList } from '@/service/use-apps'
|
||||||
import { getRedirection } from '@/utils/app-redirection'
|
import { getRedirection } from '@/utils/app-redirection'
|
||||||
import { trackCreateApp } from '@/utils/create-app-tracking'
|
import { trackCreateApp } from '@/utils/create-app-tracking'
|
||||||
|
import { CreateFromDSLModalTab } from './types'
|
||||||
import Uploader from './uploader'
|
import Uploader from './uploader'
|
||||||
|
|
||||||
type CreateFromDSLModalProps = {
|
type CreateFromDSLModalProps = {
|
||||||
show: boolean
|
show: boolean
|
||||||
onSuccess?: () => void
|
onSuccess?: () => void
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
activeTab?: string
|
activeTab?: CreateFromDSLModalTab
|
||||||
dslUrl?: string
|
dslUrl?: string
|
||||||
droppedFile?: File
|
droppedFile?: File
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum CreateFromDSLModalTab {
|
const CREATE_FROM_DSL_HOTKEY = 'Mod+Enter' satisfies Hotkey
|
||||||
FROM_FILE = 'from-file',
|
|
||||||
FROM_URL = 'from-url',
|
|
||||||
}
|
|
||||||
|
|
||||||
const CreateFromDSLModal = ({
|
const CreateFromDSLModal = ({
|
||||||
show,
|
show,
|
||||||
@ -91,8 +90,8 @@ const CreateFromDSLModal = ({
|
|||||||
const isCreatingRef = useRef(false)
|
const isCreatingRef = useRef(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (droppedFile) handleFile(droppedFile)
|
if (droppedFile) readFile(droppedFile)
|
||||||
}, [droppedFile, handleFile])
|
}, [droppedFile, readFile])
|
||||||
|
|
||||||
const onCreate = async (_e?: React.MouseEvent) => {
|
const onCreate = async (_e?: React.MouseEvent) => {
|
||||||
if (currentTab === CreateFromDSLModalTab.FROM_FILE && !currentFile) return
|
if (currentTab === CreateFromDSLModalTab.FROM_FILE && !currentFile) return
|
||||||
@ -179,7 +178,7 @@ const CreateFromDSLModal = ({
|
|||||||
const { run: handleCreateApp } = useDebounceFn(onCreate, { wait: 300 })
|
const { run: handleCreateApp } = useDebounceFn(onCreate, { wait: 300 })
|
||||||
|
|
||||||
useHotkey(
|
useHotkey(
|
||||||
'Mod+Enter',
|
CREATE_FROM_DSL_HOTKEY,
|
||||||
() => {
|
() => {
|
||||||
handleCreateApp(undefined)
|
handleCreateApp(undefined)
|
||||||
},
|
},
|
||||||
@ -251,16 +250,23 @@ const CreateFromDSLModal = ({
|
|||||||
<DialogContent className="w-full max-w-[480px]! overflow-hidden! rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0! text-left align-middle shadow-xl">
|
<DialogContent className="w-full max-w-[480px]! overflow-hidden! rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0! text-left align-middle shadow-xl">
|
||||||
<div className="flex items-center justify-between pt-6 pr-5 pb-3 pl-6 title-2xl-semi-bold text-text-primary">
|
<div className="flex items-center justify-between pt-6 pr-5 pb-3 pl-6 title-2xl-semi-bold text-text-primary">
|
||||||
{t(($) => $.importApp, { ns: 'app' })}
|
{t(($) => $.importApp, { ns: 'app' })}
|
||||||
<div className="flex size-8 cursor-pointer items-center" onClick={() => onClose()}>
|
<Button
|
||||||
<span className="i-ri-close-line size-5 text-text-tertiary" />
|
variant="ghost"
|
||||||
</div>
|
size="small"
|
||||||
|
aria-label={t(($) => $['operation.cancel'], { ns: 'common' })}
|
||||||
|
className="size-8 p-0"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<span aria-hidden className="i-ri-close-line size-5 text-text-tertiary" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex h-9 items-center space-x-6 border-b border-divider-subtle px-6 system-md-semibold text-text-tertiary">
|
<div className="flex h-9 items-center space-x-6 border-b border-divider-subtle px-6 system-md-semibold text-text-tertiary">
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
<div
|
<button
|
||||||
|
type="button"
|
||||||
key={tab.key}
|
key={tab.key}
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative flex h-full cursor-pointer items-center',
|
'relative flex h-full cursor-pointer items-center outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid',
|
||||||
currentTab === tab.key && 'text-text-primary',
|
currentTab === tab.key && 'text-text-primary',
|
||||||
)}
|
)}
|
||||||
onClick={() => setCurrentTab(tab.key)}
|
onClick={() => setCurrentTab(tab.key)}
|
||||||
@ -269,7 +275,7 @@ const CreateFromDSLModal = ({
|
|||||||
{currentTab === tab.key && (
|
{currentTab === tab.key && (
|
||||||
<div className="absolute bottom-0 h-[2px] w-full bg-util-colors-blue-brand-blue-brand-600"></div>
|
<div className="absolute bottom-0 h-[2px] w-full bg-util-colors-blue-brand-blue-brand-600"></div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="px-6 py-4">
|
<div className="px-6 py-4">
|
||||||
@ -304,7 +310,7 @@ const CreateFromDSLModal = ({
|
|||||||
>
|
>
|
||||||
<span>{t(($) => $['newApp.Create'], { ns: 'app' })}</span>
|
<span>{t(($) => $['newApp.Create'], { ns: 'app' })}</span>
|
||||||
<KbdGroup>
|
<KbdGroup>
|
||||||
{['Mod', 'Enter'].map((key) => (
|
{CREATE_FROM_DSL_HOTKEY.split('+').map((key) => (
|
||||||
<Kbd key={key} color="white">
|
<Kbd key={key} color="white">
|
||||||
{formatForDisplay(key)}
|
{formatForDisplay(key)}
|
||||||
</Kbd>
|
</Kbd>
|
||||||
|
|||||||
7
web/app/components/app/create-from-dsl-modal/types.ts
Normal file
7
web/app/components/app/create-from-dsl-modal/types.ts
Normal 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]
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { fireEvent, render, screen } from '@testing-library/react'
|
import { fireEvent, render, screen } from '@testing-library/react'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { CreateFromDSLModalTab } from '@/app/components/app/create-from-dsl-modal'
|
import { CreateFromDSLModalTab } from '../../hooks/use-dsl-import'
|
||||||
import Tab from '../index'
|
import Tab from '../index'
|
||||||
|
|
||||||
// Tab Component Tests
|
// Tab Component Tests
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { CreateFromDSLModalTab } from '@/app/components/app/create-from-dsl-modal'
|
import { CreateFromDSLModalTab } from '../hooks/use-dsl-import'
|
||||||
import Item from './item'
|
import Item from './item'
|
||||||
|
|
||||||
type TabProps = {
|
type TabProps = {
|
||||||
|
|||||||
@ -49,7 +49,7 @@ vi.mock('@/service/knowledge/use-segment', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('../completed/common/action-buttons', () => ({
|
vi.mock('../completed/common/action-buttons', () => ({
|
||||||
default: ({
|
ActionButtons: ({
|
||||||
handleCancel,
|
handleCancel,
|
||||||
handleSave,
|
handleSave,
|
||||||
loading,
|
loading,
|
||||||
|
|||||||
@ -31,7 +31,7 @@ vi.mock('@/context/event-emitter', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('../common/action-buttons', () => ({
|
vi.mock('../common/action-buttons', () => ({
|
||||||
default: ({
|
ActionButtons: ({
|
||||||
handleCancel,
|
handleCancel,
|
||||||
handleSave,
|
handleSave,
|
||||||
loading,
|
loading,
|
||||||
|
|||||||
@ -45,7 +45,7 @@ vi.mock('@/service/knowledge/use-segment', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('../common/action-buttons', () => ({
|
vi.mock('../common/action-buttons', () => ({
|
||||||
default: ({
|
ActionButtons: ({
|
||||||
handleCancel,
|
handleCancel,
|
||||||
handleSave,
|
handleSave,
|
||||||
loading,
|
loading,
|
||||||
|
|||||||
@ -53,7 +53,7 @@ vi.mock('@/context/event-emitter', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('../common/action-buttons', () => ({
|
vi.mock('../common/action-buttons', () => ({
|
||||||
default: ({
|
ActionButtons: ({
|
||||||
handleCancel,
|
handleCancel,
|
||||||
handleSave,
|
handleSave,
|
||||||
handleRegeneration,
|
handleRegeneration,
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import Divider from '@/app/components/base/divider'
|
|||||||
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||||
import { formatNumber } from '@/utils/format'
|
import { formatNumber } from '@/utils/format'
|
||||||
import { formatTime } from '@/utils/time'
|
import { formatTime } from '@/utils/time'
|
||||||
import ActionButtons from './common/action-buttons'
|
import { ActionButtons } from './common/action-buttons'
|
||||||
import ChunkContent from './common/chunk-content'
|
import ChunkContent from './common/chunk-content'
|
||||||
import Dot from './common/dot'
|
import Dot from './common/dot'
|
||||||
import { SegmentIndexTag } from './common/segment-index-tag'
|
import { SegmentIndexTag } from './common/segment-index-tag'
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { fireEvent, render, screen } from '@testing-library/react'
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { ChunkingMode } from '@/models/datasets'
|
import { ChunkingMode } from '@/models/datasets'
|
||||||
import { DocumentContext } from '../../../context'
|
import { DocumentContext } from '../../../context'
|
||||||
import ActionButtons from '../action-buttons'
|
import { ActionButtons } from '../action-buttons'
|
||||||
|
|
||||||
const mockUseHotkey = vi.fn()
|
const mockUseHotkey = vi.fn()
|
||||||
vi.mock('@tanstack/react-hotkeys', async (importOriginal) => {
|
vi.mock('@tanstack/react-hotkeys', async (importOriginal) => {
|
||||||
|
|||||||
@ -1,14 +1,15 @@
|
|||||||
import type { FC } from 'react'
|
import type { Hotkey } from '@tanstack/react-hotkeys'
|
||||||
import { Button } from '@langgenius/dify-ui/button'
|
import { Button } from '@langgenius/dify-ui/button'
|
||||||
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
|
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
|
||||||
import { formatForDisplay, useHotkey } from '@tanstack/react-hotkeys'
|
import { formatForDisplay, useHotkey } from '@tanstack/react-hotkeys'
|
||||||
import * as React from 'react'
|
|
||||||
import { useMemo } from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { ChunkingMode } from '@/models/datasets'
|
import { ChunkingMode } from '@/models/datasets'
|
||||||
import { useDocumentContext } from '../../context'
|
import { useDocumentContext } from '../../context'
|
||||||
|
|
||||||
type IActionButtonsProps = {
|
const CANCEL_HOTKEY = 'Escape' satisfies Hotkey
|
||||||
|
const SAVE_HOTKEY = 'Mod+S' satisfies Hotkey
|
||||||
|
|
||||||
|
type ActionButtonsProps = {
|
||||||
handleCancel: () => void
|
handleCancel: () => void
|
||||||
handleSave: () => void
|
handleSave: () => void
|
||||||
loading: boolean
|
loading: boolean
|
||||||
@ -18,7 +19,7 @@ type IActionButtonsProps = {
|
|||||||
showRegenerationButton?: boolean
|
showRegenerationButton?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const ActionButtons: FC<IActionButtonsProps> = ({
|
export function ActionButtons({
|
||||||
handleCancel,
|
handleCancel,
|
||||||
handleSave,
|
handleSave,
|
||||||
loading,
|
loading,
|
||||||
@ -26,25 +27,24 @@ const ActionButtons: FC<IActionButtonsProps> = ({
|
|||||||
handleRegeneration,
|
handleRegeneration,
|
||||||
isChildChunk = false,
|
isChildChunk = false,
|
||||||
showRegenerationButton = true,
|
showRegenerationButton = true,
|
||||||
}) => {
|
}: ActionButtonsProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const docForm = useDocumentContext((s) => s.docForm)
|
const docForm = useDocumentContext((s) => s.docForm)
|
||||||
const parentMode = useDocumentContext((s) => s.parentMode)
|
const parentMode = useDocumentContext((s) => s.parentMode)
|
||||||
|
|
||||||
useHotkey('Escape', (e) => {
|
useHotkey(CANCEL_HOTKEY, (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
handleCancel()
|
handleCancel()
|
||||||
})
|
})
|
||||||
|
|
||||||
useHotkey('Mod+S', (e) => {
|
useHotkey(SAVE_HOTKEY, (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (loading) return
|
if (loading) return
|
||||||
handleSave()
|
handleSave()
|
||||||
})
|
})
|
||||||
|
|
||||||
const isParentChildParagraphMode = useMemo(() => {
|
const isParentChildParagraphMode =
|
||||||
return docForm === ChunkingMode.parentChild && parentMode === 'paragraph'
|
docForm === ChunkingMode.parentChild && parentMode === 'paragraph'
|
||||||
}, [docForm, parentMode])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-x-2">
|
<div className="flex items-center gap-x-2">
|
||||||
@ -53,7 +53,7 @@ const ActionButtons: FC<IActionButtonsProps> = ({
|
|||||||
<span className="system-sm-medium text-components-button-secondary-text">
|
<span className="system-sm-medium text-components-button-secondary-text">
|
||||||
{t(($) => $['operation.cancel'], { ns: 'common' })}
|
{t(($) => $['operation.cancel'], { ns: 'common' })}
|
||||||
</span>
|
</span>
|
||||||
<Kbd>{formatForDisplay('Escape')}</Kbd>
|
<Kbd>{formatForDisplay(CANCEL_HOTKEY)}</Kbd>
|
||||||
</div>
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
{isParentChildParagraphMode &&
|
{isParentChildParagraphMode &&
|
||||||
@ -72,7 +72,7 @@ const ActionButtons: FC<IActionButtonsProps> = ({
|
|||||||
{t(($) => $['operation.save'], { ns: 'common' })}
|
{t(($) => $['operation.save'], { ns: 'common' })}
|
||||||
</span>
|
</span>
|
||||||
<KbdGroup>
|
<KbdGroup>
|
||||||
{['Mod', 'S'].map((key) => (
|
{SAVE_HOTKEY.split('+').map((key) => (
|
||||||
<Kbd key={key} color="white">
|
<Kbd key={key} color="white">
|
||||||
{formatForDisplay(key)}
|
{formatForDisplay(key)}
|
||||||
</Kbd>
|
</Kbd>
|
||||||
@ -83,7 +83,3 @@ const ActionButtons: FC<IActionButtonsProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
ActionButtons.displayName = 'ActionButtons'
|
|
||||||
|
|
||||||
export default React.memo(ActionButtons)
|
|
||||||
|
|||||||
@ -11,7 +11,7 @@ import { useParams } from '@/next/navigation'
|
|||||||
import { useAddChildSegment } from '@/service/knowledge/use-segment'
|
import { useAddChildSegment } from '@/service/knowledge/use-segment'
|
||||||
import { formatNumber } from '@/utils/format'
|
import { formatNumber } from '@/utils/format'
|
||||||
import { useDocumentContext } from '../context'
|
import { useDocumentContext } from '../context'
|
||||||
import ActionButtons from './common/action-buttons'
|
import { ActionButtons } from './common/action-buttons'
|
||||||
import AddAnother from './common/add-another'
|
import AddAnother from './common/add-another'
|
||||||
import ChunkContent from './common/chunk-content'
|
import ChunkContent from './common/chunk-content'
|
||||||
import Dot from './common/dot'
|
import Dot from './common/dot'
|
||||||
|
|||||||
@ -13,7 +13,7 @@ import { useEventEmitterContextContext } from '@/context/event-emitter'
|
|||||||
import { ChunkingMode } from '@/models/datasets'
|
import { ChunkingMode } from '@/models/datasets'
|
||||||
import { formatNumber } from '@/utils/format'
|
import { formatNumber } from '@/utils/format'
|
||||||
import { useDocumentContext } from '../context'
|
import { useDocumentContext } from '../context'
|
||||||
import ActionButtons from './common/action-buttons'
|
import { ActionButtons } from './common/action-buttons'
|
||||||
import ChunkContent from './common/chunk-content'
|
import ChunkContent from './common/chunk-content'
|
||||||
import Dot from './common/dot'
|
import Dot from './common/dot'
|
||||||
import Keywords from './common/keywords'
|
import Keywords from './common/keywords'
|
||||||
|
|||||||
@ -15,7 +15,7 @@ import { useAddSegment } from '@/service/knowledge/use-segment'
|
|||||||
import { formatNumber } from '@/utils/format'
|
import { formatNumber } from '@/utils/format'
|
||||||
import { IndexingType } from '../../create/step-two'
|
import { IndexingType } from '../../create/step-two'
|
||||||
import { useSegmentListContext } from './completed'
|
import { useSegmentListContext } from './completed'
|
||||||
import ActionButtons from './completed/common/action-buttons'
|
import { ActionButtons } from './completed/common/action-buttons'
|
||||||
import AddAnother from './completed/common/add-another'
|
import AddAnother from './completed/common/add-another'
|
||||||
import ChunkContent from './completed/common/chunk-content'
|
import ChunkContent from './completed/common/chunk-content'
|
||||||
import Dot from './completed/common/dot'
|
import Dot from './completed/common/dot'
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { fireEvent, render, screen } from '@testing-library/react'
|
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||||
import { DetailSidebarFrame } from '..'
|
import { DetailSidebarFrame } from '..'
|
||||||
import { DETAIL_SIDEBAR_STORAGE_KEY } from '../storage'
|
import { DETAIL_SIDEBAR_STORAGE_KEY } from '../storage'
|
||||||
|
|
||||||
@ -6,8 +6,8 @@ const { hotkeyRegistrations } = vi.hoisted(() => ({
|
|||||||
hotkeyRegistrations: new Map<
|
hotkeyRegistrations: new Map<
|
||||||
string,
|
string,
|
||||||
{
|
{
|
||||||
handler: (event: { preventDefault: () => void }) => void
|
handler: () => void
|
||||||
options?: { ignoreInputs?: boolean }
|
options?: { ignoreInputs?: boolean; preventDefault?: boolean }
|
||||||
}
|
}
|
||||||
>(),
|
>(),
|
||||||
}))
|
}))
|
||||||
@ -25,8 +25,8 @@ vi.mock('@tanstack/react-hotkeys', async (importOriginal) => {
|
|||||||
...actual,
|
...actual,
|
||||||
useHotkey: (
|
useHotkey: (
|
||||||
hotkey: string,
|
hotkey: string,
|
||||||
handler: (event: { preventDefault: () => void }) => void,
|
handler: () => void,
|
||||||
options?: { ignoreInputs?: boolean },
|
options?: { ignoreInputs?: boolean; preventDefault?: boolean },
|
||||||
) => {
|
) => {
|
||||||
hotkeyRegistrations.set(hotkey, { handler, options })
|
hotkeyRegistrations.set(hotkey, { handler, options })
|
||||||
},
|
},
|
||||||
@ -117,10 +117,19 @@ describe('DetailSidebarFrame', () => {
|
|||||||
expect(screen.getByTestId('detail-top')).toHaveAttribute('data-expand', 'true')
|
expect(screen.getByTestId('detail-top')).toHaveAttribute('data-expand', 'true')
|
||||||
expect(screen.getByTestId('detail-section')).toHaveAttribute('data-expand', 'true')
|
expect(screen.getByTestId('detail-section')).toHaveAttribute('data-expand', 'true')
|
||||||
expect(hotkeyRegistrations.get('Mod+B')?.options).toEqual(
|
expect(hotkeyRegistrations.get('Mod+B')?.options).toEqual(
|
||||||
expect.objectContaining({ ignoreInputs: false }),
|
expect.objectContaining({ ignoreInputs: false, preventDefault: true }),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('toggles detail content from the registered shortcut', () => {
|
||||||
|
renderDetailSidebarFrame()
|
||||||
|
|
||||||
|
act(() => hotkeyRegistrations.get('Mod+B')?.handler())
|
||||||
|
|
||||||
|
expect(screen.getByRole('complementary')).toHaveClass('w-16')
|
||||||
|
expect(screen.getByTestId('detail-top')).toHaveAttribute('data-expand', 'false')
|
||||||
|
})
|
||||||
|
|
||||||
it('collapses detail content from the top toggle and hides environment metadata', () => {
|
it('collapses detail content from the top toggle and hides environment metadata', () => {
|
||||||
mockAppContextState.current = {
|
mockAppContextState.current = {
|
||||||
langGeniusVersionInfo: {
|
langGeniusVersionInfo: {
|
||||||
|
|||||||
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
3
web/app/components/detail-sidebar/hotkeys.ts
Normal file
3
web/app/components/detail-sidebar/hotkeys.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import type { Hotkey } from '@tanstack/react-hotkeys'
|
||||||
|
|
||||||
|
export const DETAIL_SIDEBAR_TOGGLE_HOTKEY = 'Mod+B' satisfies Hotkey
|
||||||
@ -4,11 +4,12 @@ import type { ReactNode } from 'react'
|
|||||||
import { cn } from '@langgenius/dify-ui/cn'
|
import { cn } from '@langgenius/dify-ui/cn'
|
||||||
import { useHotkey } from '@tanstack/react-hotkeys'
|
import { useHotkey } from '@tanstack/react-hotkeys'
|
||||||
import { useAtomValue } from 'jotai'
|
import { useAtomValue } from 'jotai'
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import EnvNav from '@/app/components/header/env-nav'
|
import EnvNav from '@/app/components/header/env-nav'
|
||||||
import AccountSection from '@/app/components/main-nav/components/account-section'
|
import AccountSection from '@/app/components/main-nav/components/account-section'
|
||||||
import HelpMenu from '@/app/components/main-nav/components/help-menu'
|
import HelpMenu from '@/app/components/main-nav/components/help-menu'
|
||||||
import { langGeniusVersionInfoAtom } from '@/context/version-state'
|
import { langGeniusVersionInfoAtom } from '@/context/version-state'
|
||||||
|
import { DETAIL_SIDEBAR_TOGGLE_HOTKEY } from './hotkeys'
|
||||||
import { useDetailSidebarMode } from './storage'
|
import { useDetailSidebarMode } from './storage'
|
||||||
|
|
||||||
type DetailSidebarRenderProps = {
|
type DetailSidebarRenderProps = {
|
||||||
@ -56,7 +57,7 @@ export function DetailSidebarFrame({
|
|||||||
const currentEnv = langGeniusVersionInfo?.current_env
|
const currentEnv = langGeniusVersionInfo?.current_env
|
||||||
const showEnvTag = currentEnv === 'TESTING' || currentEnv === 'DEVELOPMENT'
|
const showEnvTag = currentEnv === 'TESTING' || currentEnv === 'DEVELOPMENT'
|
||||||
|
|
||||||
const handleToggleDetailNavigation = useCallback(() => {
|
function handleToggleDetailNavigation() {
|
||||||
if (isDetailNavigationHoverPreviewOpen) {
|
if (isDetailNavigationHoverPreviewOpen) {
|
||||||
if (detailNavigationTransitionTimerRef.current)
|
if (detailNavigationTransitionTimerRef.current)
|
||||||
clearTimeout(detailNavigationTransitionTimerRef.current)
|
clearTimeout(detailNavigationTransitionTimerRef.current)
|
||||||
@ -73,25 +74,25 @@ export function DetailSidebarFrame({
|
|||||||
const nextMode = detailNavigationExpanded ? 'collapse' : 'expand'
|
const nextMode = detailNavigationExpanded ? 'collapse' : 'expand'
|
||||||
setDetailNavigationHoverPreviewOpen(false)
|
setDetailNavigationHoverPreviewOpen(false)
|
||||||
setStoredDetailSidebarExpand(nextMode)
|
setStoredDetailSidebarExpand(nextMode)
|
||||||
}, [detailNavigationExpanded, isDetailNavigationHoverPreviewOpen, setStoredDetailSidebarExpand])
|
}
|
||||||
|
|
||||||
const openDetailNavigationHoverPreview = useCallback(() => {
|
function openDetailNavigationHoverPreview() {
|
||||||
if (detailNavigationExpanded) return
|
if (detailNavigationExpanded) return
|
||||||
|
|
||||||
if (closeDetailNavigationHoverPreviewTimerRef.current)
|
if (closeDetailNavigationHoverPreviewTimerRef.current)
|
||||||
clearTimeout(closeDetailNavigationHoverPreviewTimerRef.current)
|
clearTimeout(closeDetailNavigationHoverPreviewTimerRef.current)
|
||||||
|
|
||||||
setDetailNavigationHoverPreviewOpen(true)
|
setDetailNavigationHoverPreviewOpen(true)
|
||||||
}, [detailNavigationExpanded])
|
}
|
||||||
|
|
||||||
const closeDetailNavigationHoverPreview = useCallback(() => {
|
function closeDetailNavigationHoverPreview() {
|
||||||
if (closeDetailNavigationHoverPreviewTimerRef.current)
|
if (closeDetailNavigationHoverPreviewTimerRef.current)
|
||||||
clearTimeout(closeDetailNavigationHoverPreviewTimerRef.current)
|
clearTimeout(closeDetailNavigationHoverPreviewTimerRef.current)
|
||||||
|
|
||||||
closeDetailNavigationHoverPreviewTimerRef.current = setTimeout(() => {
|
closeDetailNavigationHoverPreviewTimerRef.current = setTimeout(() => {
|
||||||
setDetailNavigationHoverPreviewOpen(false)
|
setDetailNavigationHoverPreviewOpen(false)
|
||||||
}, 120)
|
}, 120)
|
||||||
}, [])
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@ -102,16 +103,10 @@ export function DetailSidebarFrame({
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useHotkey(
|
useHotkey(DETAIL_SIDEBAR_TOGGLE_HOTKEY, handleToggleDetailNavigation, {
|
||||||
'Mod+B',
|
ignoreInputs: false,
|
||||||
(e) => {
|
preventDefault: true,
|
||||||
e.preventDefault()
|
})
|
||||||
handleToggleDetailNavigation()
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ignoreInputs: false,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
|
|||||||
62
web/app/components/detail-sidebar/toggle-button.tsx
Normal file
62
web/app/components/detail-sidebar/toggle-button.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,4 +1,5 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
import type { Hotkey } from '@tanstack/react-hotkeys'
|
||||||
import type { AppIconType } from '@/types/app'
|
import type { AppIconType } from '@/types/app'
|
||||||
import { Button } from '@langgenius/dify-ui/button'
|
import { Button } from '@langgenius/dify-ui/button'
|
||||||
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
|
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||||
@ -45,6 +46,8 @@ export type CreateAppModalProps = {
|
|||||||
|
|
||||||
type CreateAppPayload = Parameters<CreateAppModalProps['onConfirm']>[0]
|
type CreateAppPayload = Parameters<CreateAppModalProps['onConfirm']>[0]
|
||||||
|
|
||||||
|
const SUBMIT_APP_HOTKEY = 'Mod+Enter' satisfies Hotkey
|
||||||
|
|
||||||
const CreateAppModal = ({
|
const CreateAppModal = ({
|
||||||
show = false,
|
show = false,
|
||||||
isEditModal = false,
|
isEditModal = false,
|
||||||
@ -118,7 +121,7 @@ const CreateAppModal = ({
|
|||||||
const { run: handleSubmit } = useDebounceFn(submit, { wait: 300 })
|
const { run: handleSubmit } = useDebounceFn(submit, { wait: 300 })
|
||||||
|
|
||||||
useHotkey(
|
useHotkey(
|
||||||
'Mod+Enter',
|
SUBMIT_APP_HOTKEY,
|
||||||
() => {
|
() => {
|
||||||
handleSubmit()
|
handleSubmit()
|
||||||
},
|
},
|
||||||
@ -237,7 +240,7 @@ const CreateAppModal = ({
|
|||||||
: t(($) => $['operation.save'], { ns: 'common' })}
|
: t(($) => $['operation.save'], { ns: 'common' })}
|
||||||
</span>
|
</span>
|
||||||
<KbdGroup>
|
<KbdGroup>
|
||||||
{['Mod', 'Enter'].map((key) => (
|
{SUBMIT_APP_HOTKEY.split('+').map((key) => (
|
||||||
<Kbd key={key} color="white">
|
<Kbd key={key} color="white">
|
||||||
{formatForDisplay(key)}
|
{formatForDisplay(key)}
|
||||||
</Kbd>
|
</Kbd>
|
||||||
|
|||||||
3
web/app/components/goto-anything/hotkeys.ts
Normal file
3
web/app/components/goto-anything/hotkeys.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import type { Hotkey } from '@tanstack/react-hotkeys'
|
||||||
|
|
||||||
|
export const GOTO_ANYTHING_HOTKEY = 'Mod+K' satisfies Hotkey
|
||||||
@ -51,12 +51,11 @@ import { addRecentItem, getRecentItems } from './actions/recent-store'
|
|||||||
import { EmptyState } from './components/empty-state'
|
import { EmptyState } from './components/empty-state'
|
||||||
import { Footer } from './components/footer'
|
import { Footer } from './components/footer'
|
||||||
import { gotoAnythingDialogHandle } from './dialog-handle'
|
import { gotoAnythingDialogHandle } from './dialog-handle'
|
||||||
|
import { GOTO_ANYTHING_HOTKEY } from './hotkeys'
|
||||||
|
|
||||||
const appWorkflowPathPattern = /^\/app\/[^/]+\/workflow$/
|
const appWorkflowPathPattern = /^\/app\/[^/]+\/workflow$/
|
||||||
const sharedWorkflowPathPattern = /^\/workflow\/[^/]+$/
|
const sharedWorkflowPathPattern = /^\/workflow\/[^/]+$/
|
||||||
const ragPipelinePathPattern = /^\/datasets\/[^/]+\/pipeline$/
|
const ragPipelinePathPattern = /^\/datasets\/[^/]+\/pipeline$/
|
||||||
const searchHotkey = 'Mod+K'
|
|
||||||
const searchShortcut = searchHotkey.split('+')
|
|
||||||
|
|
||||||
type CommandOption = {
|
type CommandOption = {
|
||||||
kind: 'command-option'
|
kind: 'command-option'
|
||||||
@ -288,7 +287,7 @@ function GotoAnythingDialog() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useHotkey(
|
useHotkey(
|
||||||
searchHotkey,
|
GOTO_ANYTHING_HOTKEY,
|
||||||
(event) => {
|
(event) => {
|
||||||
if (event.defaultPrevented) return
|
if (event.defaultPrevented) return
|
||||||
if (!gotoAnythingDialogHandle.isOpen && isEditableShortcutTarget(event.target)) return
|
if (!gotoAnythingDialogHandle.isOpen && isEditableShortcutTarget(event.target)) return
|
||||||
@ -436,7 +435,7 @@ function GotoAnythingDialog() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<KbdGroup>
|
<KbdGroup>
|
||||||
{searchShortcut.map((key) => (
|
{GOTO_ANYTHING_HOTKEY.split('+').map((key) => (
|
||||||
<Kbd key={key}>{formatForDisplay(key)}</Kbd>
|
<Kbd key={key}>{formatForDisplay(key)}</Kbd>
|
||||||
))}
|
))}
|
||||||
</KbdGroup>
|
</KbdGroup>
|
||||||
|
|||||||
@ -5,8 +5,7 @@ import { Kbd } from '@langgenius/dify-ui/kbd'
|
|||||||
import { formatForDisplay } from '@tanstack/react-hotkeys'
|
import { formatForDisplay } from '@tanstack/react-hotkeys'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle'
|
import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle'
|
||||||
|
import { GOTO_ANYTHING_HOTKEY } from '@/app/components/goto-anything/hotkeys'
|
||||||
const searchShortcut = ['Mod', 'K']
|
|
||||||
|
|
||||||
export function MainNavSearchButton() {
|
export function MainNavSearchButton() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
@ -24,7 +23,7 @@ export function MainNavSearchButton() {
|
|||||||
>
|
>
|
||||||
<span aria-hidden className="i-custom-vender-main-nav-quick-search h-4 w-4" />
|
<span aria-hidden className="i-custom-vender-main-nav-quick-search h-4 w-4" />
|
||||||
<Kbd className="h-4.5 min-w-0 rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-1 py-0.5 system-2xs-medium-uppercase text-text-tertiary">
|
<Kbd className="h-4.5 min-w-0 rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-1 py-0.5 system-2xs-medium-uppercase text-text-tertiary">
|
||||||
{searchShortcut.map((key) => (
|
{GOTO_ANYTHING_HOTKEY.split('+').map((key) => (
|
||||||
<span key={key}>{formatForDisplay(key)}</span>
|
<span key={key}>{formatForDisplay(key)}</span>
|
||||||
))}
|
))}
|
||||||
</Kbd>
|
</Kbd>
|
||||||
|
|||||||
@ -5,8 +5,8 @@ import { WorkflowRunningStatus } from '@/app/components/workflow/types'
|
|||||||
import RagPipelineHeader from '../index'
|
import RagPipelineHeader from '../index'
|
||||||
import InputFieldButton from '../input-field-button'
|
import InputFieldButton from '../input-field-button'
|
||||||
import Publisher from '../publisher'
|
import Publisher from '../publisher'
|
||||||
import Popup from '../publisher/popup'
|
import { Popup } from '../publisher/popup'
|
||||||
import RunMode from '../run-mode'
|
import { RunMode } from '../run-mode'
|
||||||
|
|
||||||
vi.mock('@/config', async (importOriginal) => {
|
vi.mock('@/config', async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import('@/config')>()
|
const actual = await importOriginal<typeof import('@/config')>()
|
||||||
@ -1080,12 +1080,6 @@ describe('RunMode', () => {
|
|||||||
expect(wrapper)!.toHaveClass('items-center')
|
expect(wrapper)!.toHaveClass('items-center')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('Memoization', () => {
|
|
||||||
it('should be wrapped in React.memo', () => {
|
|
||||||
expect((RunMode as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo'))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('Integration', () => {
|
describe('Integration', () => {
|
||||||
|
|||||||
@ -1,11 +1,35 @@
|
|||||||
import { fireEvent, render, screen } from '@testing-library/react'
|
import { fireEvent, render, screen } from '@testing-library/react'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import RunMode from '../run-mode'
|
import { RunMode } from '../run-mode'
|
||||||
|
|
||||||
const mockHandleWorkflowStartRunInWorkflow = vi.fn()
|
const mockHandleWorkflowStartRunInWorkflow = vi.fn()
|
||||||
const mockHandleStopRun = vi.fn()
|
const mockHandleStopRun = vi.fn()
|
||||||
const mockSetIsPreparingDataSource = vi.fn()
|
const mockSetIsPreparingDataSource = vi.fn()
|
||||||
const mockSetShowDebugAndPreviewPanel = vi.fn()
|
const mockSetShowDebugAndPreviewPanel = vi.fn()
|
||||||
|
const hotkeyRegistrations = vi.hoisted(
|
||||||
|
() =>
|
||||||
|
new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
callback: () => void
|
||||||
|
options?: { enabled?: boolean; ignoreInputs?: boolean; preventDefault?: boolean }
|
||||||
|
}
|
||||||
|
>(),
|
||||||
|
)
|
||||||
|
|
||||||
|
vi.mock('@tanstack/react-hotkeys', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('@tanstack/react-hotkeys')>()
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useHotkey: (
|
||||||
|
hotkey: string,
|
||||||
|
callback: () => void,
|
||||||
|
options?: { enabled?: boolean; ignoreInputs?: boolean; preventDefault?: boolean },
|
||||||
|
) => {
|
||||||
|
hotkeyRegistrations.set(hotkey, { callback, options })
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
let mockWorkflowRunningData: { task_id: string; result: { status: string } } | undefined
|
let mockWorkflowRunningData: { task_id: string; result: { status: string } } | undefined
|
||||||
let mockIsPreparingDataSource = false
|
let mockIsPreparingDataSource = false
|
||||||
@ -75,6 +99,7 @@ describe('RunMode', () => {
|
|||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
mockWorkflowRunningData = undefined
|
mockWorkflowRunningData = undefined
|
||||||
mockIsPreparingDataSource = false
|
mockIsPreparingDataSource = false
|
||||||
|
hotkeyRegistrations.clear()
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('Idle state', () => {
|
describe('Idle state', () => {
|
||||||
@ -109,6 +134,20 @@ describe('RunMode', () => {
|
|||||||
|
|
||||||
expect(mockHandleWorkflowStartRunInWorkflow).toHaveBeenCalled()
|
expect(mockHandleWorkflowStartRunInWorkflow).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should run through the enabled application shortcut', () => {
|
||||||
|
render(<RunMode />)
|
||||||
|
|
||||||
|
const registration = hotkeyRegistrations.get('Alt+R')
|
||||||
|
registration?.callback()
|
||||||
|
|
||||||
|
expect(mockHandleWorkflowStartRunInWorkflow).toHaveBeenCalledOnce()
|
||||||
|
expect(registration?.options).toEqual({
|
||||||
|
enabled: true,
|
||||||
|
ignoreInputs: true,
|
||||||
|
preventDefault: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('Running state', () => {
|
describe('Running state', () => {
|
||||||
|
|||||||
@ -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
|
||||||
@ -4,7 +4,7 @@ import Header from '@/app/components/workflow/header'
|
|||||||
import { useStore } from '@/app/components/workflow/store'
|
import { useStore } from '@/app/components/workflow/store'
|
||||||
import InputFieldButton from './input-field-button'
|
import InputFieldButton from './input-field-button'
|
||||||
import Publisher from './publisher'
|
import Publisher from './publisher'
|
||||||
import RunMode from './run-mode'
|
import { RunMode } from './run-mode'
|
||||||
|
|
||||||
const RagPipelineHeader = () => {
|
const RagPipelineHeader = () => {
|
||||||
const pipelineId = useStore((s) => s.pipelineId)
|
const pipelineId = useStore((s) => s.pipelineId)
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import Publisher from '../index'
|
import Publisher from '../index'
|
||||||
import Popup from '../popup'
|
import { Popup } from '../popup'
|
||||||
|
|
||||||
vi.mock('@/config', async (importOriginal) => {
|
vi.mock('@/config', async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import('@/config')>()
|
const actual = await importOriginal<typeof import('@/config')>()
|
||||||
@ -971,13 +971,6 @@ describe('publisher', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('Component Memoization', () => {
|
|
||||||
it('should be memoized with React.memo', () => {
|
|
||||||
expect(Popup).toBeDefined()
|
|
||||||
expect((Popup as unknown as { $$typeof?: symbol }).$$typeof?.toString()).toContain('Symbol')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Prop Variations', () => {
|
describe('Prop Variations', () => {
|
||||||
it('should display correct width when permission is allowed', () => {
|
it('should display correct width when permission is allowed', () => {
|
||||||
mockIsAllowPublishAsCustomKnowledgePipelineTemplate.mockReturnValue(true)
|
mockIsAllowPublishAsCustomKnowledgePipelineTemplate.mockReturnValue(true)
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { fireEvent, render, screen } from '@testing-library/react'
|
import { fireEvent, render, screen } from '@testing-library/react'
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import Popup from '../popup'
|
import { Popup } from '../popup'
|
||||||
|
|
||||||
vi.mock('@langgenius/dify-ui/alert-dialog', () => ({
|
vi.mock('@langgenius/dify-ui/alert-dialog', () => ({
|
||||||
AlertDialog: ({
|
AlertDialog: ({
|
||||||
|
|||||||
@ -17,7 +17,7 @@ import {
|
|||||||
usePublishAsCustomizedPipeline,
|
usePublishAsCustomizedPipeline,
|
||||||
} from '@/service/use-pipeline'
|
} from '@/service/use-pipeline'
|
||||||
import PublishAsKnowledgePipelineModal from '../../publish-as-knowledge-pipeline-modal'
|
import PublishAsKnowledgePipelineModal from '../../publish-as-knowledge-pipeline-modal'
|
||||||
import Popup from './popup'
|
import { Popup } from './popup'
|
||||||
|
|
||||||
const Publisher = () => {
|
const Publisher = () => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|||||||
@ -16,7 +16,7 @@ import { RiArrowRightUpLine, RiPlayCircleLine, RiTerminalBoxLine } from '@remixi
|
|||||||
import { formatForDisplay, useHotkey } from '@tanstack/react-hotkeys'
|
import { formatForDisplay, useHotkey } from '@tanstack/react-hotkeys'
|
||||||
import { useBoolean } from 'ahooks'
|
import { useBoolean } from 'ahooks'
|
||||||
import { useAtomValue } from 'jotai'
|
import { useAtomValue } from 'jotai'
|
||||||
import { memo, useCallback, useState } from 'react'
|
import { useCallback, useState } from 'react'
|
||||||
import { Trans, useTranslation } from 'react-i18next'
|
import { Trans, useTranslation } from 'react-i18next'
|
||||||
import { trackEvent } from '@/app/components/base/amplitude'
|
import { trackEvent } from '@/app/components/base/amplitude'
|
||||||
import Divider from '@/app/components/base/divider'
|
import Divider from '@/app/components/base/divider'
|
||||||
@ -42,8 +42,8 @@ import { useInvalid } from '@/service/use-base'
|
|||||||
import { publishedPipelineInfoQueryKeyPrefix } from '@/service/use-pipeline'
|
import { publishedPipelineInfoQueryKeyPrefix } from '@/service/use-pipeline'
|
||||||
import { usePublishWorkflow } from '@/service/use-workflow'
|
import { usePublishWorkflow } from '@/service/use-workflow'
|
||||||
import { getDatasetACLCapabilities } from '@/utils/permission'
|
import { getDatasetACLCapabilities } from '@/utils/permission'
|
||||||
|
import { RAG_PIPELINE_PUBLISH_HOTKEY } from '../hotkeys'
|
||||||
|
|
||||||
const PUBLISH_SHORTCUT = ['Mod', 'Shift', 'P']
|
|
||||||
type PopupProps = {
|
type PopupProps = {
|
||||||
onRequestClose?: () => void
|
onRequestClose?: () => void
|
||||||
confirmVisible?: boolean
|
confirmVisible?: boolean
|
||||||
@ -53,14 +53,14 @@ type PopupProps = {
|
|||||||
onShowPublishAsKnowledgePipelineModal?: () => void
|
onShowPublishAsKnowledgePipelineModal?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const Popup = ({
|
export function Popup({
|
||||||
onRequestClose,
|
onRequestClose,
|
||||||
confirmVisible: controlledConfirmVisible,
|
confirmVisible: controlledConfirmVisible,
|
||||||
onShowConfirm,
|
onShowConfirm,
|
||||||
onHideConfirm,
|
onHideConfirm,
|
||||||
isPublishingAsCustomizedPipeline = false,
|
isPublishingAsCustomizedPipeline = false,
|
||||||
onShowPublishAsKnowledgePipelineModal,
|
onShowPublishAsKnowledgePipelineModal,
|
||||||
}: PopupProps) => {
|
}: PopupProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { datasetId } = useParams()
|
const { datasetId } = useParams()
|
||||||
const { push } = useRouter()
|
const { push } = useRouter()
|
||||||
@ -182,10 +182,10 @@ const Popup = ({
|
|||||||
handleHideConfirm,
|
handleHideConfirm,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
useHotkey('Mod+Shift+P', (e) => {
|
useHotkey(RAG_PIPELINE_PUBLISH_HOTKEY, () => void handlePublish(), {
|
||||||
e.preventDefault()
|
enabled: !published && !publishing,
|
||||||
if (published) return
|
ignoreInputs: true,
|
||||||
handlePublish()
|
preventDefault: true,
|
||||||
})
|
})
|
||||||
const goToAddDocuments = useCallback(() => {
|
const goToAddDocuments = useCallback(() => {
|
||||||
if (isAddDocumentsDisabled) return
|
if (isAddDocumentsDisabled) return
|
||||||
@ -243,7 +243,7 @@ const Popup = ({
|
|||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<span>{t(($) => $['common.publishUpdate'], { ns: 'workflow' })}</span>
|
<span>{t(($) => $['common.publishUpdate'], { ns: 'workflow' })}</span>
|
||||||
<KbdGroup>
|
<KbdGroup>
|
||||||
{PUBLISH_SHORTCUT.map((key) => (
|
{RAG_PIPELINE_PUBLISH_HOTKEY.split('+').map((key) => (
|
||||||
<Kbd key={key} color="white">
|
<Kbd key={key} color="white">
|
||||||
{formatForDisplay(key)}
|
{formatForDisplay(key)}
|
||||||
</Kbd>
|
</Kbd>
|
||||||
@ -334,4 +334,3 @@ const Popup = ({
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
export default memo(Popup)
|
|
||||||
|
|||||||
@ -1,8 +1,7 @@
|
|||||||
import { cn } from '@langgenius/dify-ui/cn'
|
import { cn } from '@langgenius/dify-ui/cn'
|
||||||
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
|
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
|
||||||
import { RiCloseLine, RiDatabase2Line, RiLoader2Line, RiPlayLargeLine } from '@remixicon/react'
|
import { RiCloseLine, RiDatabase2Line, RiLoader2Line, RiPlayLargeLine } from '@remixicon/react'
|
||||||
import { formatForDisplay } from '@tanstack/react-hotkeys'
|
import { formatForDisplay, useHotkey } from '@tanstack/react-hotkeys'
|
||||||
import * as React from 'react'
|
|
||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { StopCircle } from '@/app/components/base/icons/src/vender/line/mediaAndDevices'
|
import { StopCircle } from '@/app/components/base/icons/src/vender/line/mediaAndDevices'
|
||||||
@ -12,12 +11,13 @@ import { useStore, useWorkflowStore } from '@/app/components/workflow/store'
|
|||||||
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
|
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
|
||||||
import { EVENT_WORKFLOW_STOP } from '@/app/components/workflow/variable-inspect/types'
|
import { EVENT_WORKFLOW_STOP } from '@/app/components/workflow/variable-inspect/types'
|
||||||
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||||
|
import { RAG_PIPELINE_RUN_HOTKEY } from './hotkeys'
|
||||||
|
|
||||||
type RunModeProps = {
|
type RunModeProps = {
|
||||||
text?: string
|
text?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const RunMode = ({ text }: RunModeProps) => {
|
export function RunMode({ text }: RunModeProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { handleWorkflowStartRunInWorkflow } = useWorkflowStartRun()
|
const { handleWorkflowStartRunInWorkflow } = useWorkflowStartRun()
|
||||||
const { handleStopRun } = useWorkflowRun()
|
const { handleStopRun } = useWorkflowRun()
|
||||||
@ -44,6 +44,17 @@ const RunMode = ({ text }: RunModeProps) => {
|
|||||||
if (v.type === EVENT_WORKFLOW_STOP) handleStop()
|
if (v.type === EVENT_WORKFLOW_STOP) handleStop()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function handleRun() {
|
||||||
|
if (isDisabled) return
|
||||||
|
handleWorkflowStartRunInWorkflow()
|
||||||
|
}
|
||||||
|
|
||||||
|
useHotkey(RAG_PIPELINE_RUN_HOTKEY, handleRun, {
|
||||||
|
enabled: !isDisabled,
|
||||||
|
ignoreInputs: true,
|
||||||
|
preventDefault: true,
|
||||||
|
})
|
||||||
|
|
||||||
if (!canRun) return null
|
if (!canRun) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -55,9 +66,7 @@ const RunMode = ({ text }: RunModeProps) => {
|
|||||||
isDisabled && 'cursor-not-allowed bg-state-accent-hover',
|
isDisabled && 'cursor-not-allowed bg-state-accent-hover',
|
||||||
isDisabled ? 'rounded-l-md' : 'rounded-md',
|
isDisabled ? 'rounded-l-md' : 'rounded-md',
|
||||||
)}
|
)}
|
||||||
onClick={() => {
|
onClick={handleRun}
|
||||||
if (canRun) handleWorkflowStartRunInWorkflow()
|
|
||||||
}}
|
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
>
|
>
|
||||||
{!isDisabled && (
|
{!isDisabled && (
|
||||||
@ -82,7 +91,7 @@ const RunMode = ({ text }: RunModeProps) => {
|
|||||||
)}
|
)}
|
||||||
{!isDisabled && (
|
{!isDisabled && (
|
||||||
<KbdGroup>
|
<KbdGroup>
|
||||||
{['Alt', 'R'].map((key) => (
|
{RAG_PIPELINE_RUN_HOTKEY.split('+').map((key) => (
|
||||||
<Kbd key={key}>{formatForDisplay(key)}</Kbd>
|
<Kbd key={key}>{formatForDisplay(key)}</Kbd>
|
||||||
))}
|
))}
|
||||||
</KbdGroup>
|
</KbdGroup>
|
||||||
@ -113,5 +122,3 @@ const RunMode = ({ text }: RunModeProps) => {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default React.memo(RunMode)
|
|
||||||
|
|||||||
@ -184,7 +184,7 @@ vi.mock('@/features/tag-management/components/tag-filter', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/app/components/snippets/create-snippet-dialog', () => ({
|
vi.mock('@/app/components/snippets/create-snippet-dialog', () => ({
|
||||||
default: () => null,
|
CreateSnippetDialog: () => null,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/features/tag-management/components/tag-selector', () => ({
|
vi.mock('@/features/tag-management/components/tag-selector', () => ({
|
||||||
|
|||||||
@ -22,7 +22,7 @@ import { toast } from '@langgenius/dify-ui/toast'
|
|||||||
import { useAtomValue } from 'jotai'
|
import { useAtomValue } from 'jotai'
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import CreateSnippetDialog from '@/app/components/snippets/create-snippet-dialog'
|
import { CreateSnippetDialog } from '@/app/components/snippets/create-snippet-dialog'
|
||||||
import {
|
import {
|
||||||
canCreateAndModifySnippets,
|
canCreateAndModifySnippets,
|
||||||
canManageSnippets,
|
canManageSnippets,
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { Button } from '@langgenius/dify-ui/button'
|
|||||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import CreateSnippetDialog from '@/app/components/snippets/create-snippet-dialog'
|
import { CreateSnippetDialog } from '@/app/components/snippets/create-snippet-dialog'
|
||||||
import { useCreateSnippet } from '@/app/components/snippets/hooks/use-create-snippet'
|
import { useCreateSnippet } from '@/app/components/snippets/hooks/use-create-snippet'
|
||||||
import ImportSnippetDSLDialog from '@/app/components/snippets/import-snippet-dsl-dialog'
|
import ImportSnippetDSLDialog from '@/app/components/snippets/import-snippet-dsl-dialog'
|
||||||
|
|
||||||
|
|||||||
@ -3,15 +3,43 @@ import { render, screen, waitFor } from '@testing-library/react'
|
|||||||
import userEvent from '@testing-library/user-event'
|
import userEvent from '@testing-library/user-event'
|
||||||
import { PipelineInputVarType } from '@/models/pipeline'
|
import { PipelineInputVarType } from '@/models/pipeline'
|
||||||
import { expectLoadingButton } from '@/test/button'
|
import { expectLoadingButton } from '@/test/button'
|
||||||
import CreateSnippetDialog from '../create-snippet-dialog'
|
import { CreateSnippetDialog } from '../create-snippet-dialog'
|
||||||
|
|
||||||
let capturedKeyPressHandler: (() => void) | undefined
|
let capturedKeyPressHandler: (() => void) | undefined
|
||||||
|
let capturedHotkey: string | undefined
|
||||||
|
let capturedHotkeyOptions:
|
||||||
|
| {
|
||||||
|
enabled?: boolean
|
||||||
|
ignoreInputs?: boolean
|
||||||
|
preventDefault?: boolean
|
||||||
|
stopPropagation?: boolean
|
||||||
|
target?: React.RefObject<HTMLElement | null>
|
||||||
|
}
|
||||||
|
| undefined
|
||||||
|
|
||||||
vi.mock('ahooks', () => ({
|
vi.mock('@tanstack/react-hotkeys', async (importOriginal) => {
|
||||||
useKeyPress: (_keys: string[], handler: () => void) => {
|
const actual = await importOriginal<typeof import('@tanstack/react-hotkeys')>()
|
||||||
capturedKeyPressHandler = handler
|
return {
|
||||||
},
|
...actual,
|
||||||
}))
|
useHotkey: (
|
||||||
|
hotkey: string,
|
||||||
|
handler: () => void,
|
||||||
|
options?: {
|
||||||
|
enabled?: boolean
|
||||||
|
ignoreInputs?: boolean
|
||||||
|
preventDefault?: boolean
|
||||||
|
stopPropagation?: boolean
|
||||||
|
target?: React.RefObject<HTMLElement | null>
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
capturedHotkey = hotkey
|
||||||
|
capturedKeyPressHandler = () => {
|
||||||
|
if (options?.enabled !== false) handler()
|
||||||
|
}
|
||||||
|
capturedHotkeyOptions = options
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const selectedGraph: SnippetCanvasData = {
|
const selectedGraph: SnippetCanvasData = {
|
||||||
nodes: [],
|
nodes: [],
|
||||||
@ -32,6 +60,8 @@ describe('CreateSnippetDialog', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
capturedKeyPressHandler = undefined
|
capturedKeyPressHandler = undefined
|
||||||
|
capturedHotkey = undefined
|
||||||
|
capturedHotkeyOptions = undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should submit trimmed snippet values with the selected graph and input fields', async () => {
|
it('should submit trimmed snippet values with the selected graph and input fields', async () => {
|
||||||
@ -178,6 +208,14 @@ describe('CreateSnippetDialog', () => {
|
|||||||
name: 'Keyboard snippet',
|
name: 'Keyboard snippet',
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
expect(capturedHotkeyOptions).toMatchObject({
|
||||||
|
enabled: true,
|
||||||
|
ignoreInputs: false,
|
||||||
|
preventDefault: false,
|
||||||
|
stopPropagation: false,
|
||||||
|
})
|
||||||
|
expect(capturedHotkeyOptions?.target?.current).toBe(screen.getByRole('dialog'))
|
||||||
|
expect(capturedHotkey).toBe('Mod+Enter')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should disable form controls while submitting', () => {
|
it('should disable form controls while submitting', () => {
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { DetailSidebarFrame } from '@/app/components/detail-sidebar'
|
import { DetailSidebarFrame } from '@/app/components/detail-sidebar'
|
||||||
import { SnippetDetailSection } from './snippet-detail-section'
|
import { SnippetDetailSection } from './snippet-detail-section'
|
||||||
import SnippetDetailTop from './snippet-detail-top'
|
import { SnippetDetailTop } from './snippet-detail-top'
|
||||||
|
|
||||||
export function SnippetDetailSidebar() {
|
export function SnippetDetailSidebar() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -6,19 +6,18 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/too
|
|||||||
import { formatForDisplay } from '@tanstack/react-hotkeys'
|
import { formatForDisplay } from '@tanstack/react-hotkeys'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import SidebarLeftArrowIcon from '@/app/components/base/icons/src/vender/SidebarLeftArrowIcon'
|
import SidebarLeftArrowIcon from '@/app/components/base/icons/src/vender/SidebarLeftArrowIcon'
|
||||||
|
import { DetailSidebarToggleButton } from '@/app/components/detail-sidebar/toggle-button'
|
||||||
import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle'
|
import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle'
|
||||||
|
import { GOTO_ANYTHING_HOTKEY } from '@/app/components/goto-anything/hotkeys'
|
||||||
import Link from '@/next/link'
|
import Link from '@/next/link'
|
||||||
import { useRouter } from '@/next/navigation'
|
import { useRouter } from '@/next/navigation'
|
||||||
import ToggleButton from '../../app-sidebar/toggle-button'
|
|
||||||
|
|
||||||
type SnippetDetailTopProps = {
|
type SnippetDetailTopProps = {
|
||||||
expand?: boolean
|
expand?: boolean
|
||||||
onToggle?: () => void
|
onToggle?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const SEARCH_SHORTCUT = ['Mod', 'K']
|
export function SnippetDetailTop({ expand = true, onToggle }: SnippetDetailTopProps) {
|
||||||
|
|
||||||
const SnippetDetailTop = ({ expand = true, onToggle }: SnippetDetailTopProps) => {
|
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
@ -26,9 +25,9 @@ const SnippetDetailTop = ({ expand = true, onToggle }: SnippetDetailTopProps) =>
|
|||||||
return (
|
return (
|
||||||
<div className="flex w-full items-center justify-center px-3 pt-2 pb-1">
|
<div className="flex w-full items-center justify-center px-3 pt-2 pb-1">
|
||||||
{onToggle && (
|
{onToggle && (
|
||||||
<ToggleButton
|
<DetailSidebarToggleButton
|
||||||
expand={expand}
|
expand={expand}
|
||||||
handleToggle={onToggle}
|
onToggle={onToggle}
|
||||||
icon={<SidebarLeftArrowIcon aria-hidden className="size-4" />}
|
icon={<SidebarLeftArrowIcon aria-hidden className="size-4" />}
|
||||||
className="size-8 rounded-[10px] border-0 bg-transparent px-0 text-text-tertiary shadow-none hover:border-0 hover:bg-state-base-hover hover:text-text-secondary"
|
className="size-8 rounded-[10px] border-0 bg-transparent px-0 text-text-tertiary shadow-none hover:border-0 hover:bg-state-base-hover hover:text-text-secondary"
|
||||||
/>
|
/>
|
||||||
@ -88,16 +87,16 @@ const SnippetDetailTop = ({ expand = true, onToggle }: SnippetDetailTopProps) =>
|
|||||||
>
|
>
|
||||||
<span className="px-0.5">{t(($) => $['gotoAnything.quickAction'], { ns: 'app' })}</span>
|
<span className="px-0.5">{t(($) => $['gotoAnything.quickAction'], { ns: 'app' })}</span>
|
||||||
<KbdGroup>
|
<KbdGroup>
|
||||||
{SEARCH_SHORTCUT.map((key) => (
|
{GOTO_ANYTHING_HOTKEY.split('+').map((key) => (
|
||||||
<Kbd key={key}>{formatForDisplay(key)}</Kbd>
|
<Kbd key={key}>{formatForDisplay(key)}</Kbd>
|
||||||
))}
|
))}
|
||||||
</KbdGroup>
|
</KbdGroup>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{onToggle && (
|
{onToggle && (
|
||||||
<ToggleButton
|
<DetailSidebarToggleButton
|
||||||
expand={expand}
|
expand={expand}
|
||||||
handleToggle={onToggle}
|
onToggle={onToggle}
|
||||||
icon={<SidebarLeftArrowIcon aria-hidden className="size-4" />}
|
icon={<SidebarLeftArrowIcon aria-hidden className="size-4" />}
|
||||||
className="size-8 rounded-[10px] border-0 bg-transparent px-0 text-text-tertiary shadow-none hover:border-0 hover:bg-state-base-hover hover:text-text-secondary"
|
className="size-8 rounded-[10px] border-0 bg-transparent px-0 text-text-tertiary shadow-none hover:border-0 hover:bg-state-base-hover hover:text-text-secondary"
|
||||||
/>
|
/>
|
||||||
@ -105,5 +104,3 @@ const SnippetDetailTop = ({ expand = true, onToggle }: SnippetDetailTopProps) =>
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default SnippetDetailTop
|
|
||||||
|
|||||||
@ -4,8 +4,8 @@ import { cn } from '@langgenius/dify-ui/cn'
|
|||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { TEST_RUN_MENU_HOTKEY } from '@/app/components/workflow/header/shortcuts.ts'
|
|
||||||
import { useWorkflowRun, useWorkflowStartRun } from '@/app/components/workflow/hooks'
|
import { useWorkflowRun, useWorkflowStartRun } from '@/app/components/workflow/hooks'
|
||||||
|
import { TEST_RUN_MENU_HOTKEY } from '@/app/components/workflow/hotkeys'
|
||||||
import { ShortcutKbd } from '@/app/components/workflow/shortcuts/shortcut-kbd'
|
import { ShortcutKbd } from '@/app/components/workflow/shortcuts/shortcut-kbd'
|
||||||
import { useStore } from '@/app/components/workflow/store'
|
import { useStore } from '@/app/components/workflow/store'
|
||||||
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
|
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
|
||||||
|
|||||||
@ -1,14 +1,24 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import type { Hotkey } from '@tanstack/react-hotkeys'
|
||||||
import type { SnippetCanvasData, SnippetInputField } from '@/models/snippet'
|
import type { SnippetCanvasData, SnippetInputField } from '@/models/snippet'
|
||||||
import { Button } from '@langgenius/dify-ui/button'
|
import { Button } from '@langgenius/dify-ui/button'
|
||||||
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogBackdrop,
|
||||||
|
DialogCloseButton,
|
||||||
|
DialogPopup,
|
||||||
|
DialogPortal,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@langgenius/dify-ui/dialog'
|
||||||
import { Input } from '@langgenius/dify-ui/input'
|
import { Input } from '@langgenius/dify-ui/input'
|
||||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||||
import { useKeyPress } from 'ahooks'
|
import { useHotkey } from '@tanstack/react-hotkeys'
|
||||||
import { useCallback, useState } from 'react'
|
import { useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
const CREATE_SNIPPET_HOTKEY = 'Mod+Enter' satisfies Hotkey
|
||||||
|
|
||||||
export type CreateSnippetDialogPayload = {
|
export type CreateSnippetDialogPayload = {
|
||||||
name: string
|
name: string
|
||||||
description: string
|
description: string
|
||||||
@ -39,7 +49,7 @@ const defaultGraph: SnippetCanvasData = {
|
|||||||
viewport: { x: 0, y: 0, zoom: 1 },
|
viewport: { x: 0, y: 0, zoom: 1 },
|
||||||
}
|
}
|
||||||
|
|
||||||
function CreateSnippetDialog({
|
export function CreateSnippetDialog({
|
||||||
isOpen,
|
isOpen,
|
||||||
selectedGraph,
|
selectedGraph,
|
||||||
inputFields,
|
inputFields,
|
||||||
@ -51,20 +61,21 @@ function CreateSnippetDialog({
|
|||||||
initialValue,
|
initialValue,
|
||||||
}: CreateSnippetDialogProps) {
|
}: CreateSnippetDialogProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const popupRef = useRef<HTMLDivElement>(null)
|
||||||
const [name, setName] = useState(initialValue?.name ?? '')
|
const [name, setName] = useState(initialValue?.name ?? '')
|
||||||
const [description, setDescription] = useState(initialValue?.description ?? '')
|
const [description, setDescription] = useState(initialValue?.description ?? '')
|
||||||
|
|
||||||
const resetForm = useCallback(() => {
|
function resetForm() {
|
||||||
setName('')
|
setName('')
|
||||||
setDescription('')
|
setDescription('')
|
||||||
}, [])
|
}
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
function handleClose() {
|
||||||
resetForm()
|
resetForm()
|
||||||
onClose()
|
onClose()
|
||||||
}, [onClose, resetForm])
|
}
|
||||||
|
|
||||||
const handleConfirm = useCallback(() => {
|
function handleConfirm() {
|
||||||
const trimmedName = name.trim()
|
const trimmedName = name.trim()
|
||||||
const trimmedDescription = description.trim()
|
const trimmedDescription = description.trim()
|
||||||
|
|
||||||
@ -78,75 +89,79 @@ function CreateSnippetDialog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
onConfirm(payload)
|
onConfirm(payload)
|
||||||
}, [description, inputFields, name, onConfirm, selectedGraph])
|
}
|
||||||
|
|
||||||
useKeyPress(['meta.enter', 'ctrl.enter'], () => {
|
useHotkey(CREATE_SNIPPET_HOTKEY, handleConfirm, {
|
||||||
if (!isOpen) return
|
enabled: isOpen && !isSubmitting,
|
||||||
|
ignoreInputs: false,
|
||||||
if (isSubmitting) return
|
preventDefault: false,
|
||||||
|
stopPropagation: false,
|
||||||
handleConfirm()
|
target: popupRef,
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}>
|
<Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}>
|
||||||
<DialogContent className="w-120 max-w-120 p-0">
|
<DialogPortal>
|
||||||
<DialogCloseButton />
|
<DialogBackdrop />
|
||||||
|
<DialogPopup
|
||||||
|
ref={popupRef}
|
||||||
|
className="fixed top-1/2 left-1/2 max-h-[80dvh] w-120 max-w-120 -translate-x-1/2 -translate-y-1/2 overflow-y-auto overscroll-contain p-0"
|
||||||
|
>
|
||||||
|
<DialogCloseButton />
|
||||||
|
|
||||||
<div className="px-6 pt-6 pb-3">
|
<div className="px-6 pt-6 pb-3">
|
||||||
<DialogTitle className="title-2xl-semi-bold text-text-primary">
|
<DialogTitle className="title-2xl-semi-bold text-text-primary">
|
||||||
{title || t(($) => $['snippet.createDialogTitle'], { ns: 'workflow' })}
|
{title || t(($) => $['snippet.createDialogTitle'], { ns: 'workflow' })}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4 px-6 py-2">
|
|
||||||
<div>
|
|
||||||
<div className="mb-1 flex h-6 items-center system-sm-medium text-text-secondary">
|
|
||||||
{t(($) => $['snippet.nameLabel'], { ns: 'workflow' })}
|
|
||||||
</div>
|
|
||||||
<Input
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
placeholder={t(($) => $['snippet.namePlaceholder'], { ns: 'workflow' }) || ''}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div className="space-y-4 px-6 py-2">
|
||||||
<div className="mb-1 flex h-6 items-center system-sm-medium text-text-secondary">
|
<div>
|
||||||
{t(($) => $['snippet.descriptionLabel'], { ns: 'workflow' })}
|
<div className="mb-1 flex h-6 items-center system-sm-medium text-text-secondary">
|
||||||
|
{t(($) => $['snippet.nameLabel'], { ns: 'workflow' })}
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder={t(($) => $['snippet.namePlaceholder'], { ns: 'workflow' }) || ''}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Textarea
|
|
||||||
className="resize-none"
|
|
||||||
value={description}
|
|
||||||
onValueChange={(value) => setDescription(value)}
|
|
||||||
placeholder={
|
|
||||||
t(($) => $['snippet.descriptionPlaceholder'], { ns: 'workflow' }) || ''
|
|
||||||
}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-2 px-6 pb-6">
|
<div>
|
||||||
<Button disabled={isSubmitting} onClick={handleClose}>
|
<div className="mb-1 flex h-6 items-center system-sm-medium text-text-secondary">
|
||||||
{t(($) => $['operation.cancel'], { ns: 'common' })}
|
{t(($) => $['snippet.descriptionLabel'], { ns: 'workflow' })}
|
||||||
</Button>
|
</div>
|
||||||
<Button
|
<Textarea
|
||||||
variant="primary"
|
className="resize-none"
|
||||||
disabled={!name.trim() || isSubmitting}
|
value={description}
|
||||||
loading={isSubmitting}
|
onValueChange={(value) => setDescription(value)}
|
||||||
onClick={handleConfirm}
|
placeholder={
|
||||||
>
|
t(($) => $['snippet.descriptionPlaceholder'], { ns: 'workflow' }) || ''
|
||||||
{confirmText || t(($) => $['snippet.confirm'], { ns: 'workflow' })}
|
}
|
||||||
</Button>
|
disabled={isSubmitting}
|
||||||
</div>
|
/>
|
||||||
</DialogContent>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-2 px-6 pb-6">
|
||||||
|
<Button disabled={isSubmitting} onClick={handleClose}>
|
||||||
|
{t(($) => $['operation.cancel'], { ns: 'common' })}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
disabled={!name.trim() || isSubmitting}
|
||||||
|
loading={isSubmitting}
|
||||||
|
onClick={handleConfirm}
|
||||||
|
>
|
||||||
|
{confirmText || t(($) => $['snippet.confirm'], { ns: 'workflow' })}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogPopup>
|
||||||
|
</DialogPortal>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default CreateSnippetDialog
|
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import type { Edge, Node, ValueSelector } from '@/app/components/workflow/types'
|
|||||||
import type { SnippetCanvasData, SnippetInputField } from '@/models/snippet'
|
import type { SnippetCanvasData, SnippetInputField } from '@/models/snippet'
|
||||||
import { useCallback, useState } from 'react'
|
import { useCallback, useState } from 'react'
|
||||||
import { getNodesBounds } from 'reactflow'
|
import { getNodesBounds } from 'reactflow'
|
||||||
import CreateSnippetDialog from '@/app/components/snippets/create-snippet-dialog'
|
import { CreateSnippetDialog } from '@/app/components/snippets/create-snippet-dialog'
|
||||||
import { PipelineInputVarType } from '@/models/pipeline'
|
import { PipelineInputVarType } from '@/models/pipeline'
|
||||||
import { useCreateSnippet } from './use-create-snippet'
|
import { useCreateSnippet } from './use-create-snippet'
|
||||||
|
|
||||||
|
|||||||
33
web/app/components/workflow/__tests__/hotkeys.spec.ts
Normal file
33
web/app/components/workflow/__tests__/hotkeys.spec.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -80,7 +80,7 @@ vi.mock('@/app/components/snippets/hooks/use-create-snippet', async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
vi.mock('@/app/components/snippets/create-snippet-dialog', () => ({
|
vi.mock('@/app/components/snippets/create-snippet-dialog', () => ({
|
||||||
default: (props: {
|
CreateSnippetDialog: (props: {
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
selectedGraph?: {
|
selectedGraph?: {
|
||||||
nodes: Node[]
|
nodes: Node[]
|
||||||
|
|||||||
@ -147,7 +147,7 @@ vi.mock('../run-and-history', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('../version-history-button', () => ({
|
vi.mock('../version-history-button', () => ({
|
||||||
default: ({ onClick }: { onClick: () => void }) => (
|
VersionHistoryButton: ({ onClick }: { onClick: () => void }) => (
|
||||||
<button type="button" onClick={onClick}>
|
<button type="button" onClick={onClick}>
|
||||||
version-history
|
version-history
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||||
import VersionHistoryButton from '../version-history-button'
|
import { VersionHistoryButton } from '../version-history-button'
|
||||||
|
|
||||||
let mockTheme: 'light' | 'dark' = 'light'
|
let mockTheme: 'light' | 'dark' = 'light'
|
||||||
const hotkeyRegistrations = vi.hoisted(
|
const hotkeyRegistrations = vi.hoisted(
|
||||||
|
|||||||
@ -13,7 +13,7 @@ import GlobalVariableButton from './global-variable-button'
|
|||||||
import OnlineUsers from './online-users'
|
import OnlineUsers from './online-users'
|
||||||
import RunAndHistory from './run-and-history'
|
import RunAndHistory from './run-and-history'
|
||||||
import ScrollToSelectedNodeButton from './scroll-to-selected-node-button'
|
import ScrollToSelectedNodeButton from './scroll-to-selected-node-button'
|
||||||
import VersionHistoryButton from './version-history-button'
|
import { VersionHistoryButton } from './version-history-button'
|
||||||
|
|
||||||
export type HeaderInNormalProps = {
|
export type HeaderInNormalProps = {
|
||||||
components?: {
|
components?: {
|
||||||
|
|||||||
@ -19,7 +19,7 @@ import { WorkflowRunningStatus } from '@/app/components/workflow/types'
|
|||||||
import { EVENT_WORKFLOW_STOP } from '@/app/components/workflow/variable-inspect/types'
|
import { EVENT_WORKFLOW_STOP } from '@/app/components/workflow/variable-inspect/types'
|
||||||
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||||
import { useDynamicTestRunOptions } from '../hooks/use-dynamic-test-run-options'
|
import { useDynamicTestRunOptions } from '../hooks/use-dynamic-test-run-options'
|
||||||
import { TEST_RUN_MENU_HOTKEY } from './shortcuts'
|
import { TEST_RUN_MENU_HOTKEY } from '../hotkeys'
|
||||||
import TestRunMenu, { TriggerType } from './test-run-menu'
|
import TestRunMenu, { TriggerType } from './test-run-menu'
|
||||||
|
|
||||||
type RunModeProps = {
|
type RunModeProps = {
|
||||||
|
|||||||
@ -1 +0,0 @@
|
|||||||
export const TEST_RUN_MENU_HOTKEY = 'Alt+R'
|
|
||||||
@ -32,7 +32,9 @@ export const OptionRow = ({
|
|||||||
<div className="flex size-6 shrink-0 items-center justify-center">{option.icon}</div>
|
<div className="flex size-6 shrink-0 items-center justify-center">{option.icon}</div>
|
||||||
<span className="ml-2 truncate">{option.name}</span>
|
<span className="ml-2 truncate">{option.name}</span>
|
||||||
</div>
|
</div>
|
||||||
{shortcutKey && <ShortcutKbd hotkey={shortcutKey} className="ml-2" textColor="secondary" />}
|
{shortcutKey && (
|
||||||
|
<ShortcutKbd displayKey={shortcutKey} className="ml-2" textColor="secondary" />
|
||||||
|
)}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,21 +1,17 @@
|
|||||||
import type { FC } from 'react'
|
|
||||||
import { Button } from '@langgenius/dify-ui/button'
|
import { Button } from '@langgenius/dify-ui/button'
|
||||||
import { cn } from '@langgenius/dify-ui/cn'
|
import { cn } from '@langgenius/dify-ui/cn'
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||||
import { useHotkey } from '@tanstack/react-hotkeys'
|
import { useHotkey } from '@tanstack/react-hotkeys'
|
||||||
import * as React from 'react'
|
|
||||||
import { useCallback } from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import useTheme from '@/hooks/use-theme'
|
import useTheme from '@/hooks/use-theme'
|
||||||
|
import { VERSION_HISTORY_HOTKEY } from '../hotkeys'
|
||||||
import { ShortcutKbd } from '../shortcuts/shortcut-kbd'
|
import { ShortcutKbd } from '../shortcuts/shortcut-kbd'
|
||||||
|
|
||||||
const VERSION_HISTORY_HOTKEY = 'Mod+Shift+H'
|
|
||||||
|
|
||||||
type VersionHistoryButtonProps = {
|
type VersionHistoryButtonProps = {
|
||||||
onClick: () => Promise<unknown> | unknown
|
onClick: () => Promise<unknown> | unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
const PopupContent = React.memo(() => {
|
function PopupContent() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-x-1">
|
<div className="flex items-center gap-x-1">
|
||||||
@ -25,20 +21,15 @@ const PopupContent = React.memo(() => {
|
|||||||
<ShortcutKbd hotkey={VERSION_HISTORY_HOTKEY} bgColor="gray" textColor="secondary" />
|
<ShortcutKbd hotkey={VERSION_HISTORY_HOTKEY} bgColor="gray" textColor="secondary" />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})
|
}
|
||||||
|
|
||||||
PopupContent.displayName = 'PopupContent'
|
export function VersionHistoryButton({ onClick }: VersionHistoryButtonProps) {
|
||||||
|
|
||||||
const VersionHistoryButton: FC<VersionHistoryButtonProps> = ({ onClick }) => {
|
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const handleViewVersionHistory = useCallback(async () => {
|
|
||||||
await onClick?.()
|
|
||||||
}, [onClick])
|
|
||||||
|
|
||||||
useHotkey(
|
useHotkey(
|
||||||
VERSION_HISTORY_HOTKEY,
|
VERSION_HISTORY_HOTKEY,
|
||||||
() => {
|
() => {
|
||||||
void handleViewVersionHistory()
|
void onClick()
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ignoreInputs: true,
|
ignoreInputs: true,
|
||||||
@ -54,7 +45,7 @@ const VersionHistoryButton: FC<VersionHistoryButtonProps> = ({ onClick }) => {
|
|||||||
'rounded-lg border border-transparent p-2',
|
'rounded-lg border border-transparent p-2',
|
||||||
theme === 'dark' && 'border-black/5 bg-white/10 backdrop-blur-xs',
|
theme === 'dark' && 'border-black/5 bg-white/10 backdrop-blur-xs',
|
||||||
)}
|
)}
|
||||||
onClick={handleViewVersionHistory}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
<span className="i-ri-history-line size-4 text-components-button-secondary-text" />
|
<span className="i-ri-history-line size-4 text-components-button-secondary-text" />
|
||||||
</Button>
|
</Button>
|
||||||
@ -66,5 +57,3 @@ const VersionHistoryButton: FC<VersionHistoryButtonProps> = ({ onClick }) => {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default VersionHistoryButton
|
|
||||||
|
|||||||
19
web/app/components/workflow/hotkeys.ts
Normal file
19
web/app/components/workflow/hotkeys.ts
Normal 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),
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -72,6 +72,7 @@ import {
|
|||||||
import { HooksStoreContextProvider, useHooksStore } from './hooks-store'
|
import { HooksStoreContextProvider, useHooksStore } from './hooks-store'
|
||||||
import { useWorkflowComment } from './hooks/use-workflow-comment'
|
import { useWorkflowComment } from './hooks/use-workflow-comment'
|
||||||
import { useWorkflowSearch } from './hooks/use-workflow-search'
|
import { useWorkflowSearch } from './hooks/use-workflow-search'
|
||||||
|
import { shouldPreventWorkflowBrowserDefault } from './hotkeys'
|
||||||
import CustomNode from './nodes'
|
import CustomNode from './nodes'
|
||||||
import useMatchSchemaType from './nodes/_base/components/variable/use-match-schema-type'
|
import useMatchSchemaType from './nodes/_base/components/variable/use-match-schema-type'
|
||||||
import CustomDataSourceEmptyNode from './nodes/data-source-empty'
|
import CustomDataSourceEmptyNode from './nodes/data-source-empty'
|
||||||
@ -446,10 +447,7 @@ export const Workflow: FC<WorkflowProps> = memo(
|
|||||||
}, [handleSyncWorkflowDraftWhenPageClose, handleBeforeUnload])
|
}, [handleSyncWorkflowDraftWhenPageClose, handleBeforeUnload])
|
||||||
|
|
||||||
useEventListener('keydown', (e) => {
|
useEventListener('keydown', (e) => {
|
||||||
if ((e.key === 'd' || e.key === 'D') && (e.ctrlKey || e.metaKey)) e.preventDefault()
|
if (shouldPreventWorkflowBrowserDefault(e)) e.preventDefault()
|
||||||
if ((e.key === 'z' || e.key === 'Z') && (e.ctrlKey || e.metaKey)) e.preventDefault()
|
|
||||||
if ((e.key === 'y' || e.key === 'Y') && (e.ctrlKey || e.metaKey)) e.preventDefault()
|
|
||||||
if ((e.key === 's' || e.key === 'S') && (e.ctrlKey || e.metaKey)) e.preventDefault()
|
|
||||||
})
|
})
|
||||||
useEventListener('mousemove', (e) => {
|
useEventListener('mousemove', (e) => {
|
||||||
const containerClientRect = workflowContainerRef.current?.getBoundingClientRect()
|
const containerClientRect = workflowContainerRef.current?.getBoundingClientRect()
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
import type { RegisterableHotkey } from '@tanstack/react-hotkeys'
|
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import type { WorkflowCanvasShortcutId } from '@/app/components/workflow/shortcuts/definitions'
|
import type { WorkflowCanvasShortcutId } from '@/app/components/workflow/shortcuts/definitions'
|
||||||
import { ShortcutKbd } from '@/app/components/workflow/shortcuts/shortcut-kbd'
|
import { ShortcutKbd } from '@/app/components/workflow/shortcuts/shortcut-kbd'
|
||||||
@ -9,17 +8,15 @@ export const NODE_ACTIONS_MENU_DELETE_ITEM_CLASS_NAME = `${NODE_ACTIONS_MENU_ITE
|
|||||||
|
|
||||||
export function NodeActionsMenuItemContent({
|
export function NodeActionsMenuItemContent({
|
||||||
children,
|
children,
|
||||||
hotkey,
|
|
||||||
shortcut,
|
shortcut,
|
||||||
}: {
|
}: {
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
hotkey?: RegisterableHotkey | (string & {})
|
|
||||||
shortcut?: WorkflowCanvasShortcutId
|
shortcut?: WorkflowCanvasShortcutId
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<span className="min-w-0 truncate">{children}</span>
|
<span className="min-w-0 truncate">{children}</span>
|
||||||
{(shortcut || hotkey) && <ShortcutKbd shortcut={shortcut} hotkey={hotkey} />}
|
{shortcut && <ShortcutKbd shortcut={shortcut} />}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import type { DeclaredOutputConfig } from '@dify/contracts/api/console/apps/types.gen'
|
import type { DeclaredOutputConfig } from '@dify/contracts/api/console/apps/types.gen'
|
||||||
|
import type { Hotkey } from '@tanstack/react-hotkeys'
|
||||||
import type { EditableOutputConfig, EditingState, OutputDraft } from './utils'
|
import type { EditableOutputConfig, EditingState, OutputDraft } from './utils'
|
||||||
import { Button } from '@langgenius/dify-ui/button'
|
import { Button } from '@langgenius/dify-ui/button'
|
||||||
import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from '@langgenius/dify-ui/collapsible'
|
import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from '@langgenius/dify-ui/collapsible'
|
||||||
@ -18,7 +19,8 @@ import {
|
|||||||
OUTPUT_NAME_PATTERN_SOURCE,
|
OUTPUT_NAME_PATTERN_SOURCE,
|
||||||
} from './utils'
|
} from './utils'
|
||||||
|
|
||||||
const CONFIRM_HOTKEY = 'Mod+Enter'
|
const CANCEL_HOTKEY = 'Escape' satisfies Hotkey
|
||||||
|
const CONFIRM_HOTKEY = 'Mod+Enter' satisfies Hotkey
|
||||||
|
|
||||||
function ConfirmHotkeyHint() {
|
function ConfirmHotkeyHint() {
|
||||||
const displayKeys = formatForDisplay(CONFIRM_HOTKEY, { separatorToken: ' ' })
|
const displayKeys = formatForDisplay(CONFIRM_HOTKEY, { separatorToken: ' ' })
|
||||||
@ -70,8 +72,11 @@ export function OutputEditCard({
|
|||||||
if (confirmDisabled) return
|
if (confirmDisabled) return
|
||||||
onConfirm(createOutputFromDraft(draft, { includeDefaultValue: allowDefaultValue }), state)
|
onConfirm(createOutputFromDraft(draft, { includeDefaultValue: allowDefaultValue }), state)
|
||||||
}
|
}
|
||||||
useHotkey(CONFIRM_HOTKEY, handleConfirm, { target: editorRef, ignoreInputs: false })
|
useHotkey(CONFIRM_HOTKEY, handleConfirm, {
|
||||||
useHotkey('Escape', onCancel, { target: editorRef, ignoreInputs: false })
|
target: editorRef,
|
||||||
|
ignoreInputs: false,
|
||||||
|
})
|
||||||
|
useHotkey(CANCEL_HOTKEY, onCancel, { target: editorRef, ignoreInputs: false })
|
||||||
return (
|
return (
|
||||||
<div ref={editorRef}>
|
<div ref={editorRef}>
|
||||||
<Form
|
<Form
|
||||||
|
|||||||
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -1,11 +1,10 @@
|
|||||||
import type { FC } from 'react'
|
import type { Hotkey } from '@tanstack/react-hotkeys'
|
||||||
import { Button } from '@langgenius/dify-ui/button'
|
import { Button } from '@langgenius/dify-ui/button'
|
||||||
import { useHotkey } from '@tanstack/react-hotkeys'
|
import { useHotkey } from '@tanstack/react-hotkeys'
|
||||||
import * as React from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { ShortcutKbd } from '@/app/components/workflow/shortcuts/shortcut-kbd'
|
import { ShortcutKbd } from '@/app/components/workflow/shortcuts/shortcut-kbd'
|
||||||
|
|
||||||
const JSON_SCHEMA_CONFIRM_HOTKEY = 'Mod+Enter'
|
const JSON_SCHEMA_CONFIRM_HOTKEY = 'Mod+Enter' satisfies Hotkey
|
||||||
|
|
||||||
type AdvancedActionsProps = {
|
type AdvancedActionsProps = {
|
||||||
isConfirmDisabled: boolean
|
isConfirmDisabled: boolean
|
||||||
@ -13,7 +12,7 @@ type AdvancedActionsProps = {
|
|||||||
onConfirm: () => void
|
onConfirm: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const AdvancedActions: FC<AdvancedActionsProps> = ({ isConfirmDisabled, onCancel, onConfirm }) => {
|
export function AdvancedActions({ isConfirmDisabled, onCancel, onConfirm }: AdvancedActionsProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
useHotkey(
|
useHotkey(
|
||||||
@ -45,5 +44,3 @@ const AdvancedActions: FC<AdvancedActionsProps> = ({ isConfirmDisabled, onCancel
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default React.memo(AdvancedActions)
|
|
||||||
|
|||||||
@ -13,7 +13,7 @@ import { ArrayType, Type } from '../../../../types'
|
|||||||
import { useMittContext } from '../context'
|
import { useMittContext } from '../context'
|
||||||
import { useVisualEditorStore } from '../store'
|
import { useVisualEditorStore } from '../store'
|
||||||
import Actions from './actions'
|
import Actions from './actions'
|
||||||
import AdvancedActions from './advanced-actions'
|
import { AdvancedActions } from './advanced-actions'
|
||||||
import AdvancedOptions from './advanced-options'
|
import AdvancedOptions from './advanced-options'
|
||||||
import AutoWidthInput from './auto-width-input'
|
import AutoWidthInput from './auto-width-input'
|
||||||
import RequiredSwitch from './required-switch'
|
import RequiredSwitch from './required-switch'
|
||||||
|
|||||||
@ -8,7 +8,6 @@ import {
|
|||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { FlowType } from '@/types/common'
|
import { FlowType } from '@/types/common'
|
||||||
import { TEST_RUN_MENU_HOTKEY } from './header/shortcuts'
|
|
||||||
import {
|
import {
|
||||||
useDSL,
|
useDSL,
|
||||||
useIsChatMode,
|
useIsChatMode,
|
||||||
@ -17,6 +16,7 @@ import {
|
|||||||
useWorkflowStartRun,
|
useWorkflowStartRun,
|
||||||
} from './hooks'
|
} from './hooks'
|
||||||
import { useHooksStore } from './hooks-store'
|
import { useHooksStore } from './hooks-store'
|
||||||
|
import { TEST_RUN_MENU_HOTKEY } from './hotkeys'
|
||||||
import { isSnippetCanvas } from './nodes/_base/hooks/snippet-input-field-vars'
|
import { isSnippetCanvas } from './nodes/_base/hooks/snippet-input-field-vars'
|
||||||
import AddBlock from './operator/add-block'
|
import AddBlock from './operator/add-block'
|
||||||
import { useOperator } from './operator/hooks'
|
import { useOperator } from './operator/hooks'
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import type { RegisterableHotkey } from '@tanstack/react-hotkeys'
|
import type { Hotkey, IndividualKey } from '@tanstack/react-hotkeys'
|
||||||
|
|
||||||
export type WorkflowCanvasShortcutId =
|
export type WorkflowCanvasShortcutId =
|
||||||
| 'workflow.delete'
|
| 'workflow.delete'
|
||||||
@ -26,18 +26,26 @@ export type WorkflowCanvasHotkeyMeta = {
|
|||||||
description: string
|
description: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WorkflowCanvasShortcutDefinition = {
|
type WorkflowCanvasShortcutDefinitionBase = {
|
||||||
id: WorkflowCanvasShortcutId
|
id: WorkflowCanvasShortcutId
|
||||||
hotkeys: readonly RegisterableHotkey[]
|
|
||||||
displayHotkey?: RegisterableHotkey | (string & {})
|
|
||||||
name: string
|
name: string
|
||||||
description: string
|
description: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const WORKFLOW_CANVAS_SHORTCUTS: Record<
|
export type WorkflowCanvasHotkeyDefinition = WorkflowCanvasShortcutDefinitionBase & {
|
||||||
WorkflowCanvasShortcutId,
|
hotkeys: readonly Hotkey[]
|
||||||
WorkflowCanvasShortcutDefinition
|
displayHotkey?: Hotkey
|
||||||
> = {
|
}
|
||||||
|
|
||||||
|
type WorkflowCanvasHoldKeyDefinition = WorkflowCanvasShortcutDefinitionBase & {
|
||||||
|
holdKey: IndividualKey
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorkflowCanvasShortcutDefinition =
|
||||||
|
| WorkflowCanvasHotkeyDefinition
|
||||||
|
| WorkflowCanvasHoldKeyDefinition
|
||||||
|
|
||||||
|
export const WORKFLOW_CANVAS_SHORTCUTS = {
|
||||||
'workflow.delete': {
|
'workflow.delete': {
|
||||||
id: 'workflow.delete',
|
id: 'workflow.delete',
|
||||||
hotkeys: ['Delete', 'Backspace'],
|
hotkeys: ['Delete', 'Backspace'],
|
||||||
@ -139,16 +147,19 @@ export const WORKFLOW_CANVAS_SHORTCUTS: Record<
|
|||||||
},
|
},
|
||||||
'workflow.dim-other-nodes': {
|
'workflow.dim-other-nodes': {
|
||||||
id: 'workflow.dim-other-nodes',
|
id: 'workflow.dim-other-nodes',
|
||||||
hotkeys: [{ key: 'Shift', shift: true }],
|
holdKey: 'Shift',
|
||||||
displayHotkey: 'Shift',
|
|
||||||
name: 'Dim other nodes',
|
name: 'Dim other nodes',
|
||||||
description: 'Dim nodes outside the current workflow selection',
|
description: 'Dim nodes outside the current workflow selection',
|
||||||
},
|
},
|
||||||
}
|
} as const satisfies Record<WorkflowCanvasShortcutId, WorkflowCanvasShortcutDefinition>
|
||||||
|
|
||||||
export const getWorkflowCanvasShortcutDisplayHotkey = (
|
export const getWorkflowCanvasShortcutDisplayKey = (
|
||||||
id: WorkflowCanvasShortcutId,
|
id: WorkflowCanvasShortcutId,
|
||||||
): RegisterableHotkey | (string & {}) => {
|
): Hotkey | IndividualKey => {
|
||||||
const shortcut = WORKFLOW_CANVAS_SHORTCUTS[id]
|
const shortcut = WORKFLOW_CANVAS_SHORTCUTS[id]
|
||||||
return shortcut.displayHotkey ?? shortcut.hotkeys[0]!
|
|
||||||
|
if ('displayHotkey' in shortcut && shortcut.displayHotkey) return shortcut.displayHotkey
|
||||||
|
if ('hotkeys' in shortcut) return shortcut.hotkeys[0]
|
||||||
|
|
||||||
|
return shortcut.holdKey
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,14 +1,17 @@
|
|||||||
import type { KbdColor } from '@langgenius/dify-ui/kbd'
|
import type { KbdColor } from '@langgenius/dify-ui/kbd'
|
||||||
import type { FormatDisplayOptions, RegisterableHotkey } from '@tanstack/react-hotkeys'
|
import type { FormatDisplayOptions, Hotkey, IndividualKey } from '@tanstack/react-hotkeys'
|
||||||
import type { WorkflowCanvasShortcutId } from './definitions'
|
import type { WorkflowCanvasShortcutId } from './definitions'
|
||||||
import { cn } from '@langgenius/dify-ui/cn'
|
import { cn } from '@langgenius/dify-ui/cn'
|
||||||
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
|
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
|
||||||
import { formatForDisplay } from '@tanstack/react-hotkeys'
|
import { formatForDisplay } from '@tanstack/react-hotkeys'
|
||||||
import { getWorkflowCanvasShortcutDisplayHotkey } from './definitions'
|
import { getWorkflowCanvasShortcutDisplayKey } from './definitions'
|
||||||
|
|
||||||
type ShortcutKbdProps = {
|
type ShortcutKbdSource =
|
||||||
shortcut?: WorkflowCanvasShortcutId
|
| { shortcut: WorkflowCanvasShortcutId; hotkey?: never; displayKey?: never }
|
||||||
hotkey?: RegisterableHotkey | (string & {})
|
| { shortcut?: never; hotkey: Hotkey; displayKey?: never }
|
||||||
|
| { shortcut?: never; hotkey?: never; displayKey: string }
|
||||||
|
|
||||||
|
type ShortcutKbdProps = ShortcutKbdSource & {
|
||||||
className?: string
|
className?: string
|
||||||
textColor?: 'default' | 'secondary'
|
textColor?: 'default' | 'secondary'
|
||||||
bgColor?: KbdColor
|
bgColor?: KbdColor
|
||||||
@ -16,13 +19,11 @@ type ShortcutKbdProps = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getDisplayKeys = (
|
const getDisplayKeys = (
|
||||||
hotkey: RegisterableHotkey | (string & {}),
|
hotkey: Hotkey | IndividualKey,
|
||||||
platform?: FormatDisplayOptions['platform'],
|
platform?: FormatDisplayOptions['platform'],
|
||||||
) => {
|
) => {
|
||||||
const displayOptions = platform ? { platform } : undefined
|
const displayOptions = platform ? { platform } : undefined
|
||||||
|
|
||||||
if (typeof hotkey !== 'string') return [formatForDisplay(hotkey, displayOptions)]
|
|
||||||
|
|
||||||
return hotkey
|
return hotkey
|
||||||
.split('+')
|
.split('+')
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
@ -32,17 +33,23 @@ const getDisplayKeys = (
|
|||||||
export const ShortcutKbd = ({
|
export const ShortcutKbd = ({
|
||||||
shortcut,
|
shortcut,
|
||||||
hotkey,
|
hotkey,
|
||||||
|
displayKey,
|
||||||
className,
|
className,
|
||||||
textColor = 'default',
|
textColor = 'default',
|
||||||
bgColor = 'gray',
|
bgColor = 'gray',
|
||||||
platform,
|
platform,
|
||||||
}: ShortcutKbdProps) => {
|
}: ShortcutKbdProps) => {
|
||||||
const displayHotkey =
|
const shortcutDisplayKey =
|
||||||
hotkey ?? (shortcut ? getWorkflowCanvasShortcutDisplayHotkey(shortcut) : undefined)
|
hotkey ?? (shortcut ? getWorkflowCanvasShortcutDisplayKey(shortcut) : undefined)
|
||||||
|
|
||||||
if (!displayHotkey) return null
|
const displayOptions = platform ? { platform } : undefined
|
||||||
|
const displayKeys = displayKey
|
||||||
|
? [formatForDisplay(displayKey, displayOptions)]
|
||||||
|
: shortcutDisplayKey
|
||||||
|
? getDisplayKeys(shortcutDisplayKey, platform)
|
||||||
|
: []
|
||||||
|
|
||||||
const displayKeys = getDisplayKeys(displayHotkey, platform)
|
if (!displayKeys.length) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<KbdGroup className={cn(className)}>
|
<KbdGroup className={cn(className)}>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import type { HotkeyCallback, UseHotkeyDefinition, UseHotkeyOptions } from '@tanstack/react-hotkeys'
|
import type { HotkeyCallback, UseHotkeyDefinition, UseHotkeyOptions } from '@tanstack/react-hotkeys'
|
||||||
import type { WorkflowCanvasHotkeyMeta, WorkflowCanvasShortcutDefinition } from './definitions'
|
import type { WorkflowCanvasHotkeyDefinition, WorkflowCanvasHotkeyMeta } from './definitions'
|
||||||
import { useHotkeys, useKeyHold } from '@tanstack/react-hotkeys'
|
import { useHotkeys, useKeyHold } from '@tanstack/react-hotkeys'
|
||||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||||
import { useReactFlow } from 'reactflow'
|
import { useReactFlow } from 'reactflow'
|
||||||
@ -29,7 +29,7 @@ const isInputLikeElement = (element: Element | null) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const toHotkeyDefinitions = (
|
const toHotkeyDefinitions = (
|
||||||
shortcut: WorkflowCanvasShortcutDefinition,
|
shortcut: WorkflowCanvasHotkeyDefinition,
|
||||||
callback: HotkeyCallback,
|
callback: HotkeyCallback,
|
||||||
options?: UseHotkeyOptions,
|
options?: UseHotkeyOptions,
|
||||||
): UseHotkeyDefinition[] => {
|
): UseHotkeyDefinition[] => {
|
||||||
@ -68,7 +68,7 @@ export const useWorkflowHotkeys = (): void => {
|
|||||||
const { handleLayout } = useWorkflowOrganize()
|
const { handleLayout } = useWorkflowOrganize()
|
||||||
|
|
||||||
const { zoomTo, getZoom, fitView, getNodes } = useReactFlow()
|
const { zoomTo, getZoom, fitView, getNodes } = useReactFlow()
|
||||||
const isShiftHeld = useKeyHold('Shift')
|
const isShiftHeld = useKeyHold(WORKFLOW_CANVAS_SHORTCUTS['workflow.dim-other-nodes'].holdKey)
|
||||||
const shiftDimmedRef = useRef(false)
|
const shiftDimmedRef = useRef(false)
|
||||||
const undimAllNodesRef = useRef(undimAllNodes)
|
const undimAllNodesRef = useRef(undimAllNodes)
|
||||||
undimAllNodesRef.current = undimAllNodes
|
undimAllNodesRef.current = undimAllNodes
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import type { WorkflowGenerateErrorResponse } from '@dify/contracts/api/console/workflow-generate/types.gen'
|
import type { WorkflowGenerateErrorResponse } from '@dify/contracts/api/console/workflow-generate/types.gen'
|
||||||
import type { GenerateWorkflowStreamCallbacks } from '@/service/workflow-generator'
|
import type { GenerateWorkflowStreamCallbacks } from '@/service/workflow-generator'
|
||||||
import { render, screen, waitFor } from '@testing-library/react'
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||||
import userEvent from '@testing-library/user-event'
|
import userEvent from '@testing-library/user-event'
|
||||||
import WorkflowGeneratorModal from '../index'
|
import WorkflowGeneratorModal from '../index'
|
||||||
import { useWorkflowGeneratorStore } from '../store'
|
import { useWorkflowGeneratorStore } from '../store'
|
||||||
@ -93,6 +93,19 @@ describe('WorkflowGeneratorModal', () => {
|
|||||||
|
|
||||||
expect(instruction).toHaveValue('Summarize a URL')
|
expect(instruction).toHaveValue('Summarize a URL')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should generate from the instruction shortcut', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<WorkflowGeneratorModal />)
|
||||||
|
|
||||||
|
const instruction = screen.getByRole('textbox', {
|
||||||
|
name: /workflowGenerator\.instruction/i,
|
||||||
|
})
|
||||||
|
await user.type(instruction, 'Summarize a URL')
|
||||||
|
fireEvent.keyDown(instruction, { key: 'Enter', ctrlKey: true })
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockGenerateWorkflowStream).toHaveBeenCalledOnce())
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('Generation errors', () => {
|
describe('Generation errors', () => {
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import type { WorkflowGenerateErrorResponse } from '@dify/contracts/api/console/workflow-generate/types.gen'
|
import type { WorkflowGenerateErrorResponse } from '@dify/contracts/api/console/workflow-generate/types.gen'
|
||||||
|
import type { Hotkey } from '@tanstack/react-hotkeys'
|
||||||
import type { SelectorParam, TFunction } from 'i18next'
|
import type { SelectorParam, TFunction } from 'i18next'
|
||||||
import type { GeneratedGraph } from './types'
|
import type { GeneratedGraph } from './types'
|
||||||
import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||||
@ -23,6 +24,7 @@ import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@langgeni
|
|||||||
import { Field, FieldLabel } from '@langgenius/dify-ui/field'
|
import { Field, FieldLabel } from '@langgenius/dify-ui/field'
|
||||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||||
import { toast } from '@langgenius/dify-ui/toast'
|
import { toast } from '@langgenius/dify-ui/toast'
|
||||||
|
import { matchesKeyboardEvent } from '@tanstack/react-hotkeys'
|
||||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||||
import { useBoolean } from 'ahooks'
|
import { useBoolean } from 'ahooks'
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
@ -63,6 +65,7 @@ const FE_TIMEOUT_MS = 90_000
|
|||||||
// Mirrors the backend's instruction/ideal-output cap on /workflow-generate —
|
// Mirrors the backend's instruction/ideal-output cap on /workflow-generate —
|
||||||
// keeping the limit client-side turns an opaque 400 into a visible input stop.
|
// keeping the limit client-side turns an opaque 400 into a visible input stop.
|
||||||
const MAX_INSTRUCTION_LENGTH = 10_000
|
const MAX_INSTRUCTION_LENGTH = 10_000
|
||||||
|
const WORKFLOW_GENERATOR_SUBMIT_HOTKEY = 'Mod+Enter' satisfies Hotkey
|
||||||
|
|
||||||
// A single structured generation error. Mirrors the backend ``errors[]`` entry
|
// A single structured generation error. Mirrors the backend ``errors[]`` entry
|
||||||
// (stable ``code`` + human ``detail`` + optional ``node_id``) so the error panel
|
// (stable ``code`` + human ``detail`` + optional ``node_id``) so the error panel
|
||||||
@ -615,9 +618,7 @@ function WorkflowGeneratorModal() {
|
|||||||
value={instruction}
|
value={instruction}
|
||||||
onValueChange={setInstruction}
|
onValueChange={setInstruction}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
// ⌘/Ctrl+Enter generates — the journey starts keyboard-first in
|
if (matchesKeyboardEvent(e.nativeEvent, WORKFLOW_GENERATOR_SUBMIT_HOTKEY)) {
|
||||||
// the palette, so let it finish without reaching for the mouse.
|
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!isLoading) onGenerate()
|
if (!isLoading) onGenerate()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import type {
|
|||||||
AgentReferencingWorkflowResponse,
|
AgentReferencingWorkflowResponse,
|
||||||
AgentReferencingWorkflowsResponse,
|
AgentReferencingWorkflowsResponse,
|
||||||
} from '@dify/contracts/api/console/agent/types.gen'
|
} from '@dify/contracts/api/console/agent/types.gen'
|
||||||
import type { RegisterableHotkey } from '@tanstack/react-hotkeys'
|
import type { Hotkey } from '@tanstack/react-hotkeys'
|
||||||
import { Button } from '@langgenius/dify-ui/button'
|
import { Button } from '@langgenius/dify-ui/button'
|
||||||
import { Collapsible, CollapsiblePanel } from '@langgenius/dify-ui/collapsible'
|
import { Collapsible, CollapsiblePanel } from '@langgenius/dify-ui/collapsible'
|
||||||
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
|
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
|
||||||
@ -25,7 +25,7 @@ import useTimestamp from '@/hooks/use-timestamp'
|
|||||||
import { consoleQuery } from '@/service/client'
|
import { consoleQuery } from '@/service/client'
|
||||||
import { AgentPublishImpactDetails } from './publish-impact-details'
|
import { AgentPublishImpactDetails } from './publish-impact-details'
|
||||||
|
|
||||||
const PUBLISH_AGENT_HOTKEY = 'Mod+Shift+P' satisfies RegisterableHotkey
|
const PUBLISH_AGENT_HOTKEY = 'Mod+Shift+P' satisfies Hotkey
|
||||||
|
|
||||||
type AgentConfigurePublishState = 'draft' | 'publishing' | 'published' | 'unpublished'
|
type AgentConfigurePublishState = 'draft' | 'publishing' | 'published' | 'unpublished'
|
||||||
|
|
||||||
|
|||||||
@ -12,11 +12,12 @@ import { formatForDisplay } from '@tanstack/react-hotkeys'
|
|||||||
import { skipToken, useQuery } from '@tanstack/react-query'
|
import { skipToken, useQuery } from '@tanstack/react-query'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import NavLink from '@/app/components/app-sidebar/nav-link'
|
import NavLink from '@/app/components/app-sidebar/nav-link'
|
||||||
import ToggleButton from '@/app/components/app-sidebar/toggle-button'
|
|
||||||
import AppIcon from '@/app/components/base/app-icon'
|
import AppIcon from '@/app/components/base/app-icon'
|
||||||
import Divider from '@/app/components/base/divider'
|
import Divider from '@/app/components/base/divider'
|
||||||
import SidebarLeftArrowIcon from '@/app/components/base/icons/src/vender/SidebarLeftArrowIcon'
|
import SidebarLeftArrowIcon from '@/app/components/base/icons/src/vender/SidebarLeftArrowIcon'
|
||||||
|
import { DetailSidebarToggleButton } from '@/app/components/detail-sidebar/toggle-button'
|
||||||
import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle'
|
import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle'
|
||||||
|
import { GOTO_ANYTHING_HOTKEY } from '@/app/components/goto-anything/hotkeys'
|
||||||
import Link from '@/next/link'
|
import Link from '@/next/link'
|
||||||
import { usePathname } from '@/next/navigation'
|
import { usePathname } from '@/next/navigation'
|
||||||
import { consoleQuery } from '@/service/client'
|
import { consoleQuery } from '@/service/client'
|
||||||
@ -39,8 +40,6 @@ type AgentDetailNavItem = {
|
|||||||
activeIcon: NavIcon
|
activeIcon: NavIcon
|
||||||
}
|
}
|
||||||
|
|
||||||
const SEARCH_SHORTCUT = ['Mod', 'K']
|
|
||||||
|
|
||||||
const createAgentNavIcon = (iconClassName: string) => {
|
const createAgentNavIcon = (iconClassName: string) => {
|
||||||
function AgentNavIcon({ className }: ComponentProps<'svg'>) {
|
function AgentNavIcon({ className }: ComponentProps<'svg'>) {
|
||||||
return <span aria-hidden className={cn(iconClassName, className)} />
|
return <span aria-hidden className={cn(iconClassName, className)} />
|
||||||
@ -92,9 +91,9 @@ export function AgentDetailTop({ expand = true, onToggle }: AgentDetailTopProps)
|
|||||||
return (
|
return (
|
||||||
<div className="flex w-full items-center justify-center px-3 pt-2 pb-1">
|
<div className="flex w-full items-center justify-center px-3 pt-2 pb-1">
|
||||||
{onToggle && (
|
{onToggle && (
|
||||||
<ToggleButton
|
<DetailSidebarToggleButton
|
||||||
expand={expand}
|
expand={expand}
|
||||||
handleToggle={onToggle}
|
onToggle={onToggle}
|
||||||
icon={<SidebarLeftArrowIcon aria-hidden className="size-4" />}
|
icon={<SidebarLeftArrowIcon aria-hidden className="size-4" />}
|
||||||
className="size-8 rounded-[10px] border-0 bg-transparent px-0 text-text-tertiary shadow-none hover:border-0 hover:bg-state-base-hover hover:text-text-secondary"
|
className="size-8 rounded-[10px] border-0 bg-transparent px-0 text-text-tertiary shadow-none hover:border-0 hover:bg-state-base-hover hover:text-text-secondary"
|
||||||
/>
|
/>
|
||||||
@ -145,16 +144,16 @@ export function AgentDetailTop({ expand = true, onToggle }: AgentDetailTopProps)
|
|||||||
>
|
>
|
||||||
<span className="px-0.5">{tApp(($) => $['gotoAnything.quickAction'])}</span>
|
<span className="px-0.5">{tApp(($) => $['gotoAnything.quickAction'])}</span>
|
||||||
<KbdGroup>
|
<KbdGroup>
|
||||||
{SEARCH_SHORTCUT.map((key) => (
|
{GOTO_ANYTHING_HOTKEY.split('+').map((key) => (
|
||||||
<Kbd key={key}>{formatForDisplay(key)}</Kbd>
|
<Kbd key={key}>{formatForDisplay(key)}</Kbd>
|
||||||
))}
|
))}
|
||||||
</KbdGroup>
|
</KbdGroup>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{onToggle && (
|
{onToggle && (
|
||||||
<ToggleButton
|
<DetailSidebarToggleButton
|
||||||
expand={expand}
|
expand={expand}
|
||||||
handleToggle={onToggle}
|
onToggle={onToggle}
|
||||||
icon={<SidebarLeftArrowIcon aria-hidden className="size-4" />}
|
icon={<SidebarLeftArrowIcon aria-hidden className="size-4" />}
|
||||||
className="size-8 rounded-[10px] border-0 bg-transparent px-0 text-text-tertiary shadow-none hover:border-0 hover:bg-state-base-hover hover:text-text-secondary"
|
className="size-8 rounded-[10px] border-0 bg-transparent px-0 text-text-tertiary shadow-none hover:border-0 hover:bg-state-base-hover hover:text-text-secondary"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -11,11 +11,12 @@ import { formatForDisplay } from '@tanstack/react-hotkeys'
|
|||||||
import { useAtomValue } from 'jotai'
|
import { useAtomValue } from 'jotai'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import NavLink from '@/app/components/app-sidebar/nav-link'
|
import NavLink from '@/app/components/app-sidebar/nav-link'
|
||||||
import ToggleButton from '@/app/components/app-sidebar/toggle-button'
|
|
||||||
import Divider from '@/app/components/base/divider'
|
import Divider from '@/app/components/base/divider'
|
||||||
import SidebarLeftArrowIcon from '@/app/components/base/icons/src/vender/SidebarLeftArrowIcon'
|
import SidebarLeftArrowIcon from '@/app/components/base/icons/src/vender/SidebarLeftArrowIcon'
|
||||||
import { SkeletonContainer, SkeletonRectangle } from '@/app/components/base/skeleton'
|
import { SkeletonContainer, SkeletonRectangle } from '@/app/components/base/skeleton'
|
||||||
|
import { DetailSidebarToggleButton } from '@/app/components/detail-sidebar/toggle-button'
|
||||||
import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle'
|
import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle'
|
||||||
|
import { GOTO_ANYTHING_HOTKEY } from '@/app/components/goto-anything/hotkeys'
|
||||||
import Link from '@/next/link'
|
import Link from '@/next/link'
|
||||||
import { usePathname } from '@/next/navigation'
|
import { usePathname } from '@/next/navigation'
|
||||||
import { DeploymentActionsMenu } from '../deployment-actions'
|
import { DeploymentActionsMenu } from '../deployment-actions'
|
||||||
@ -78,8 +79,6 @@ const DEPLOYMENT_TABS: TabDef[] = [
|
|||||||
{ key: 'api-tokens', icon: ApiIcon, selectedIcon: ApiSelectedIcon },
|
{ key: 'api-tokens', icon: ApiIcon, selectedIcon: ApiSelectedIcon },
|
||||||
]
|
]
|
||||||
|
|
||||||
const SEARCH_SHORTCUT = ['Mod', 'K']
|
|
||||||
|
|
||||||
function DeploymentIcon({ expand }: { expand: boolean }) {
|
function DeploymentIcon({ expand }: { expand: boolean }) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -198,9 +197,9 @@ export function DeploymentDetailTop({
|
|||||||
return (
|
return (
|
||||||
<div className="flex w-full items-center justify-center px-3 pt-2 pb-1">
|
<div className="flex w-full items-center justify-center px-3 pt-2 pb-1">
|
||||||
{onToggle && (
|
{onToggle && (
|
||||||
<ToggleButton
|
<DetailSidebarToggleButton
|
||||||
expand={expand}
|
expand={expand}
|
||||||
handleToggle={onToggle}
|
onToggle={onToggle}
|
||||||
icon={<SidebarLeftArrowIcon aria-hidden className="size-4" />}
|
icon={<SidebarLeftArrowIcon aria-hidden className="size-4" />}
|
||||||
className="size-8 rounded-[10px] border-0 bg-transparent px-0 text-text-tertiary shadow-none hover:border-0 hover:bg-state-base-hover hover:text-text-secondary"
|
className="size-8 rounded-[10px] border-0 bg-transparent px-0 text-text-tertiary shadow-none hover:border-0 hover:bg-state-base-hover hover:text-text-secondary"
|
||||||
/>
|
/>
|
||||||
@ -251,16 +250,16 @@ export function DeploymentDetailTop({
|
|||||||
>
|
>
|
||||||
<span className="px-0.5">{t(($) => $['gotoAnything.quickAction'], { ns: 'app' })}</span>
|
<span className="px-0.5">{t(($) => $['gotoAnything.quickAction'], { ns: 'app' })}</span>
|
||||||
<KbdGroup>
|
<KbdGroup>
|
||||||
{SEARCH_SHORTCUT.map((key) => (
|
{GOTO_ANYTHING_HOTKEY.split('+').map((key) => (
|
||||||
<Kbd key={key}>{formatForDisplay(key)}</Kbd>
|
<Kbd key={key}>{formatForDisplay(key)}</Kbd>
|
||||||
))}
|
))}
|
||||||
</KbdGroup>
|
</KbdGroup>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{onToggle && (
|
{onToggle && (
|
||||||
<ToggleButton
|
<DetailSidebarToggleButton
|
||||||
expand={expand}
|
expand={expand}
|
||||||
handleToggle={onToggle}
|
onToggle={onToggle}
|
||||||
icon={<SidebarLeftArrowIcon aria-hidden className="size-4" />}
|
icon={<SidebarLeftArrowIcon aria-hidden className="size-4" />}
|
||||||
className="size-8 rounded-[10px] border-0 bg-transparent px-0 text-text-tertiary shadow-none hover:border-0 hover:bg-state-base-hover hover:text-text-secondary"
|
className="size-8 rounded-[10px] border-0 bg-transparent px-0 text-text-tertiary shadow-none hover:border-0 hover:bg-state-base-hover hover:text-text-secondary"
|
||||||
/>
|
/>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user