diff --git a/.agents/skills/how-to-write-component/SKILL.md b/.agents/skills/how-to-write-component/SKILL.md index 9d013876dd9..3572d03e757 100644 --- a/.agents/skills/how-to-write-component/SKILL.md +++ b/.agents/skills/how-to-write-component/SKILL.md @@ -18,6 +18,7 @@ Use this as the component decision guide for Dify web. Existing code is referenc | Should URL state enter Jotai? | Let Next.js route params and `nuqs` own URL state and updates. | Query atoms or shared derived atoms need a read-only bridge hydrated at the route/surface boundary. | | Should this query/mutation become an atom? | Use TanStack Query hooks at the lowest owner. | It reads atom state, feeds derived atoms, or participates in shared Jotai workflow orchestration. | | Should this be a helper/wrapper? | Prefer direct readable code at the use site. | The name captures a stable domain rule or the wrapper owns real behavior, validation, state, error handling, or semantics. | +| Where should a hotkey live? | Keep a single-owner hotkey constant in its component. | Multiple production files share one command, or the feature owns a real command registry with shared metadata and behavior. | | Is an Effect needed? | No. Derive during render or handle the user action in the event handler. | It synchronizes with an external system such as browser APIs, subscriptions, timers, analytics, or imperative DOM/non-React widgets. | ## Core Defaults @@ -77,6 +78,19 @@ Use this as the component decision guide for Dify web. Existing code is referenc - Name values by their domain role and backend API contract, especially persistent IDs and route params. Normalize framework or route params at the boundary. - Put fallback and invariant checks in the lowest component that already handles that state. Do not extract helpers whose only behavior is hiding missing display data. +## Keyboard Shortcuts + +- Distinguish application commands from local keyboard semantics before choosing an API. Use `@tanstack/react-hotkeys` for application commands. Keep menu navigation, dialog Escape handling owned by a primitive, editor commands, and other widget-scoped ARIA interactions in their local component or primitive. +- Use `useHotkey` or `useHotkeys` for registered commands. For a command intentionally owned by an existing `onKeyDown`, use `matchesKeyboardEvent` instead of hand-written `metaKey` / `ctrlKey` parsing or a second global listener. +- Define a reusable string command with `satisfies Hotkey` and an object-form command with `satisfies RawHotkey`. Reserve `RegisterableHotkey` for API boundaries that intentionally accept either form. A one-time inline literal passed directly to TanStack is already type-checked; extract it when registration, display, metadata, or another production consumer needs the same source. +- Keep registered hotkeys distinct from held keys and display-only accelerators. Use `IndividualKey` with `useKeyHold` for held-key interactions, and use an explicitly named `displayKey` for local widget accelerators that are not registered `Hotkey` values. +- Keep registration and keycap/menu display derived from one canonical command. Do not maintain a hotkey string beside a separate `['Mod', ...]` display array. +- Keep a single-owner command constant in its owning component. Create a feature-local `hotkeys.ts` only when multiple production files consume the same command. Keep a dedicated definitions/registry module when a feature owns a real command system with IDs, metadata, alternate bindings, and centralized registration. Tests do not count as another production owner, and file-name uniformity alone is not a reason to extract. +- Make scope and availability explicit. Use `enabled` for business or surface lifecycle, `ignoreInputs` for whether input-like elements may trigger the command, and `target` when the command belongs to a concrete DOM subtree. Global application commands may use the document target; inline editors and composed overlays should prefer the actual editor or Base UI Popup ref when that owner is exposed. +- Put a scoped ref on the real behavior owner. Do not add a wrapper DOM element solely to obtain a hotkey target. If a shared overlay convenience component hides the Popup ref, either rely on its modal lifecycle/focus boundary when that is sufficient or design the primitive API separately; do not create a fake owner at the call site. +- Set `preventDefault` and `stopPropagation` according to the existing product behavior and browser interaction. Do not silently accept TanStack defaults when migrating from another listener if that changes typing, submission, or propagation semantics. +- Test observable command behavior, disabled/input/target scope, and the shared registration/display contract at the owning feature boundary. Prefer partial mocks that retain TanStack formatting and matching behavior when a registration boundary must be isolated. + ## Generated API And Nullable Data - Treat generated contracts as authoritative at API, query, mutation, cache, and service boundaries. For enterprise APIs, use `packages/contracts/generated/enterprise/*`. diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index 06e93398aac..d11268cad75 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -757,34 +757,8 @@ "count": 1 } }, - "web/app/components/app/create-app-modal/index.tsx": { - "jsx_a11y/click-events-have-key-events": { - "count": 1 - }, - "jsx_a11y/no-static-element-interactions": { - "count": 1 - }, - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/app/create-from-dsl-modal/index.tsx": { - "erasable-syntax-only/enums": { - "count": 1 - }, "eslint-react/set-state-in-effect": { - "count": 2 - }, - "jsx_a11y/click-events-have-key-events": { - "count": 2 - }, - "jsx_a11y/no-static-element-interactions": { - "count": 2 - }, - "no-restricted-imports": { - "count": 1 - }, - "react/only-export-components": { "count": 1 } }, diff --git a/web/app/components/app-sidebar/__tests__/app-detail-top.spec.tsx b/web/app/components/app-sidebar/__tests__/app-detail-top.spec.tsx index 252bffd591c..af11886c8d7 100644 --- a/web/app/components/app-sidebar/__tests__/app-detail-top.spec.tsx +++ b/web/app/components/app-sidebar/__tests__/app-detail-top.spec.tsx @@ -1,30 +1,7 @@ -import type { ReactNode } from 'react' import { Dialog, DialogPopup, DialogPortal, DialogTitle } from '@langgenius/dify-ui/dialog' import { fireEvent, render, screen } from '@testing-library/react' import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle' -import AppDetailTop from '../app-detail-top' - -vi.mock('../toggle-button', () => ({ - default: ({ - expand, - handleToggle, - icon, - }: { - expand: boolean - handleToggle: () => void - icon?: ReactNode - }) => ( - - ), -})) +import { AppDetailTop } from '../app-detail-top' function TestGotoAnythingDialog() { return ( @@ -75,10 +52,8 @@ describe('AppDetailTop', () => { const onToggle = vi.fn() render() - 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) }) }) diff --git a/web/app/components/app-sidebar/__tests__/dataset-detail-top.spec.tsx b/web/app/components/app-sidebar/__tests__/dataset-detail-top.spec.tsx index c8d952df1e1..39cb314d280 100644 --- a/web/app/components/app-sidebar/__tests__/dataset-detail-top.spec.tsx +++ b/web/app/components/app-sidebar/__tests__/dataset-detail-top.spec.tsx @@ -1,30 +1,7 @@ -import type { ReactNode } from 'react' import { Dialog, DialogPopup, DialogPortal, DialogTitle } from '@langgenius/dify-ui/dialog' import { fireEvent, render, screen } from '@testing-library/react' import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle' -import DatasetDetailTop from '../dataset-detail-top' - -vi.mock('../toggle-button', () => ({ - default: ({ - expand, - handleToggle, - icon, - }: { - expand: boolean - handleToggle: () => void - icon?: ReactNode - }) => ( - - ), -})) +import { DatasetDetailTop } from '../dataset-detail-top' function TestGotoAnythingDialog() { return ( @@ -73,10 +50,8 @@ describe('DatasetDetailTop', () => { const onToggle = vi.fn() render() - 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) }) }) diff --git a/web/app/components/app-sidebar/__tests__/toggle-button.spec.tsx b/web/app/components/app-sidebar/__tests__/toggle-button.spec.tsx deleted file mode 100644 index 4ab0ecc3388..00000000000 --- a/web/app/components/app-sidebar/__tests__/toggle-button.spec.tsx +++ /dev/null @@ -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() - const button = screen.getByRole('button') - expect(button).toBeInTheDocument() - }) - - it('should render expand arrow when collapsed', () => { - render() - const button = screen.getByRole('button') - expect(button).toBeInTheDocument() - }) - - it('should call handleToggle when clicked', async () => { - const user = userEvent.setup() - const handleToggle = vi.fn() - render() - - await user.click(screen.getByRole('button')) - - expect(handleToggle).toHaveBeenCalledTimes(1) - }) - - it('should apply custom className', () => { - render() - const button = screen.getByRole('button') - expect(button).toHaveClass('custom-class') - }) - - it('should have rounded-full style', () => { - render() - const button = screen.getByRole('button') - expect(button).toHaveClass('rounded-full') - }) -}) diff --git a/web/app/components/app-sidebar/app-detail-sidebar.tsx b/web/app/components/app-sidebar/app-detail-sidebar.tsx index 019dbb41f16..c17fe0f060b 100644 --- a/web/app/components/app-sidebar/app-detail-sidebar.tsx +++ b/web/app/components/app-sidebar/app-detail-sidebar.tsx @@ -2,7 +2,7 @@ import { DetailSidebarFrame } from '@/app/components/detail-sidebar' import AppDetailSection from './app-detail-section' -import AppDetailTop from './app-detail-top' +import { AppDetailTop } from './app-detail-top' export function AppDetailSidebar() { return ( diff --git a/web/app/components/app-sidebar/app-detail-top.tsx b/web/app/components/app-sidebar/app-detail-top.tsx index b7578a70ba3..35d4914750a 100644 --- a/web/app/components/app-sidebar/app-detail-top.tsx +++ b/web/app/components/app-sidebar/app-detail-top.tsx @@ -6,27 +6,26 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/too import { formatForDisplay } from '@tanstack/react-hotkeys' import { useTranslation } from 'react-i18next' import SidebarLeftArrowIcon from '@/app/components/base/icons/src/vender/SidebarLeftArrowIcon' +import { DetailSidebarToggleButton } from '@/app/components/detail-sidebar/toggle-button' import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle' +import { GOTO_ANYTHING_HOTKEY } from '@/app/components/goto-anything/hotkeys' import Link from '@/next/link' -import ToggleButton from './toggle-button' type AppDetailTopProps = { expand?: boolean onToggle?: () => void } -const SEARCH_SHORTCUT = ['Mod', 'K'] - -const AppDetailTop = ({ expand = true, onToggle }: AppDetailTopProps) => { +export function AppDetailTop({ expand = true, onToggle }: AppDetailTopProps) { const { t } = useTranslation() if (!expand) { return (
{onToggle && ( - } 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) => { > {t(($) => $['gotoAnything.quickAction'], { ns: 'app' })} - {SEARCH_SHORTCUT.map((key) => ( + {GOTO_ANYTHING_HOTKEY.split('+').map((key) => ( {formatForDisplay(key)} ))} @@ -90,9 +89,9 @@ const AppDetailTop = ({ expand = true, onToggle }: AppDetailTopProps) => { )} {onToggle && ( - } 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) => {
) } - -export default AppDetailTop diff --git a/web/app/components/app-sidebar/dataset-detail-sidebar.tsx b/web/app/components/app-sidebar/dataset-detail-sidebar.tsx index aad4d4fe0de..1d589c8e174 100644 --- a/web/app/components/app-sidebar/dataset-detail-sidebar.tsx +++ b/web/app/components/app-sidebar/dataset-detail-sidebar.tsx @@ -2,7 +2,7 @@ import { DetailSidebarFrame } from '@/app/components/detail-sidebar' import DatasetDetailSection from './dataset-detail-section' -import DatasetDetailTop from './dataset-detail-top' +import { DatasetDetailTop } from './dataset-detail-top' export function DatasetDetailSidebar() { return ( diff --git a/web/app/components/app-sidebar/dataset-detail-top.tsx b/web/app/components/app-sidebar/dataset-detail-top.tsx index 27177951cae..0f4523993c2 100644 --- a/web/app/components/app-sidebar/dataset-detail-top.tsx +++ b/web/app/components/app-sidebar/dataset-detail-top.tsx @@ -6,27 +6,26 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/too import { formatForDisplay } from '@tanstack/react-hotkeys' import { useTranslation } from 'react-i18next' import SidebarLeftArrowIcon from '@/app/components/base/icons/src/vender/SidebarLeftArrowIcon' +import { DetailSidebarToggleButton } from '@/app/components/detail-sidebar/toggle-button' import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle' +import { GOTO_ANYTHING_HOTKEY } from '@/app/components/goto-anything/hotkeys' import Link from '@/next/link' -import ToggleButton from './toggle-button' type DatasetDetailTopProps = { expand?: boolean onToggle?: () => void } -const SEARCH_SHORTCUT = ['Mod', 'K'] - -const DatasetDetailTop = ({ expand = true, onToggle }: DatasetDetailTopProps) => { +export function DatasetDetailTop({ expand = true, onToggle }: DatasetDetailTopProps) { const { t } = useTranslation() if (!expand) { return (
{onToggle && ( - } 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) => > {t(($) => $['gotoAnything.quickAction'], { ns: 'app' })} - {SEARCH_SHORTCUT.map((key) => ( + {GOTO_ANYTHING_HOTKEY.split('+').map((key) => ( {formatForDisplay(key)} ))} @@ -90,9 +89,9 @@ const DatasetDetailTop = ({ expand = true, onToggle }: DatasetDetailTopProps) => )} {onToggle && ( - } 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) =>
) } - -export default DatasetDetailTop diff --git a/web/app/components/app-sidebar/snippet-info/__tests__/dropdown.spec.tsx b/web/app/components/app-sidebar/snippet-info/__tests__/dropdown.spec.tsx index f61cd08ae75..94f60eb26a2 100644 --- a/web/app/components/app-sidebar/snippet-info/__tests__/dropdown.spec.tsx +++ b/web/app/components/app-sidebar/snippet-info/__tests__/dropdown.spec.tsx @@ -140,7 +140,7 @@ type MockCreateSnippetDialogProps = { } vi.mock('@/app/components/snippets/create-snippet-dialog', () => ({ - default: ({ + CreateSnippetDialog: ({ isOpen, title, confirmText, diff --git a/web/app/components/app-sidebar/snippet-info/dropdown.tsx b/web/app/components/app-sidebar/snippet-info/dropdown.tsx index a2c8fa46975..27289b7c14f 100644 --- a/web/app/components/app-sidebar/snippet-info/dropdown.tsx +++ b/web/app/components/app-sidebar/snippet-info/dropdown.tsx @@ -22,7 +22,7 @@ import { toast } from '@langgenius/dify-ui/toast' import { useAtomValue } from 'jotai' import * as React from 'react' import { useTranslation } from 'react-i18next' -import CreateSnippetDialog from '@/app/components/snippets/create-snippet-dialog' +import { CreateSnippetDialog } from '@/app/components/snippets/create-snippet-dialog' import { canCreateAndModifySnippets, canManageSnippets, diff --git a/web/app/components/app-sidebar/toggle-button.tsx b/web/app/components/app-sidebar/toggle-button.tsx deleted file mode 100644 index 5a7c5c00db9..00000000000 --- a/web/app/components/app-sidebar/toggle-button.tsx +++ /dev/null @@ -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 ( -
- - {expand - ? t(($) => $['sidebar.collapseSidebar'], { ns: 'layout' }) - : t(($) => $['sidebar.expandSidebar'], { ns: 'layout' })} - - - {TOGGLE_SHORTCUT.map((key) => ( - {formatForDisplay(key)} - ))} - -
- ) -} - -type ToggleButtonProps = { - expand: boolean - handleToggle: () => void - className?: string - icon?: React.ReactNode - iconClassName?: string -} - -const ToggleButton = ({ - expand, - handleToggle, - className, - icon, - iconClassName, -}: ToggleButtonProps) => { - return ( - - - } - > - {icon || - (iconClassName ? ( - - ) : expand ? ( - - ) : ( - - ))} - - - - - - ) -} - -export default React.memo(ToggleButton) diff --git a/web/app/components/app/app-publisher/__tests__/index.spec.tsx b/web/app/components/app/app-publisher/__tests__/index.spec.tsx index 6620ba5f59d..dda5adde69e 100644 --- a/web/app/components/app/app-publisher/__tests__/index.spec.tsx +++ b/web/app/components/app/app-publisher/__tests__/index.spec.tsx @@ -285,7 +285,6 @@ describe('AppPublisher', () => { enabled: true, }) }) - expect(sectionProps.summary?.publishShortcut).toEqual(['Mod', 'Shift', 'P']) expect(mockRefetch).not.toHaveBeenCalled() }) diff --git a/web/app/components/app/app-publisher/__tests__/sections.spec.tsx b/web/app/components/app/app-publisher/__tests__/sections.spec.tsx index 358b72e2add..d14cb9fa8b2 100644 --- a/web/app/components/app/app-publisher/__tests__/sections.spec.tsx +++ b/web/app/components/app/app-publisher/__tests__/sections.spec.tsx @@ -75,7 +75,6 @@ describe('app-publisher sections', () => { publishDisabled={false} published={false} publishedAt={Date.now()} - publishShortcut={['Mod', 'Shift', 'P']} startNodeLimitExceeded={false} upgradeHighlightStyle={{}} />, @@ -113,7 +112,6 @@ describe('app-publisher sections', () => { publishDisabled={false} published={false} publishedAt={undefined} - publishShortcut={['Mod', 'Shift', 'P']} startNodeLimitExceeded={false} upgradeHighlightStyle={{}} />, @@ -137,7 +135,6 @@ describe('app-publisher sections', () => { publishDisabled={false} published={false} publishedAt={undefined} - publishShortcut={['Mod', 'Shift', 'P']} startNodeLimitExceeded={false} upgradeHighlightStyle={{}} />, @@ -161,7 +158,6 @@ describe('app-publisher sections', () => { publishDisabled={false} published={false} publishedAt={undefined} - publishShortcut={['Mod', 'Shift', 'P']} startNodeLimitExceeded upgradeHighlightStyle={{}} />, diff --git a/web/app/components/app/app-publisher/hotkeys.ts b/web/app/components/app/app-publisher/hotkeys.ts new file mode 100644 index 00000000000..a1b8f13b397 --- /dev/null +++ b/web/app/components/app/app-publisher/hotkeys.ts @@ -0,0 +1,3 @@ +import type { Hotkey } from '@tanstack/react-hotkeys' + +export const APP_PUBLISH_HOTKEY = 'Mod+Shift+P' satisfies Hotkey diff --git a/web/app/components/app/app-publisher/index.tsx b/web/app/components/app/app-publisher/index.tsx index c2668a22360..e3735481f9d 100644 --- a/web/app/components/app/app-publisher/index.tsx +++ b/web/app/components/app/app-publisher/index.tsx @@ -1,4 +1,3 @@ -import type { RegisterableHotkey } from '@tanstack/react-hotkeys' import type { FormEvent } from 'react' import type { ModelAndParameter } from '../configuration/debug/types' import type { @@ -45,6 +44,7 @@ import { fetchPublishedWorkflow } from '@/service/workflow' import { AppModeEnum } from '@/types/app' import { basePath } from '@/utils/var' import AccessControl from '../app-access-control' +import { APP_PUBLISH_HOTKEY } from './hotkeys' import { PublisherAccessSection, PublisherActionsSection, @@ -81,9 +81,6 @@ export type AppPublisherProps = { hasHumanInputNode?: boolean } -const PUBLISH_HOTKEY = 'Mod+Shift+P' satisfies RegisterableHotkey -const PUBLISH_SHORTCUT = PUBLISH_HOTKEY.split('+') - export type AppPublisherPublishParams = ModelAndParameter | PublishWorkflowParams type AppPublisherPublishHandler = @@ -307,7 +304,7 @@ export function AppPublisher({ } } - useHotkey(PUBLISH_HOTKEY, (e) => { + useHotkey(APP_PUBLISH_HOTKEY, (e) => { e.preventDefault() if (publishDisabled || published) return handlePublish() @@ -410,7 +407,6 @@ export function AppPublisher({ publishDisabled={publishDisabled} published={published} publishedAt={publishedAt} - publishShortcut={PUBLISH_SHORTCUT} startNodeLimitExceeded={startNodeLimitExceeded} upgradeHighlightStyle={upgradeHighlightStyle} /> diff --git a/web/app/components/app/app-publisher/sections.tsx b/web/app/components/app/app-publisher/sections.tsx index d3e0417d8a2..eb4bbc6f0e3 100644 --- a/web/app/components/app/app-publisher/sections.tsx +++ b/web/app/components/app/app-publisher/sections.tsx @@ -12,6 +12,7 @@ import Loading from '@/app/components/base/loading' import UpgradeBtn from '@/app/components/billing/upgrade-btn' import WorkflowToolConfigureButton from '@/app/components/tools/workflow-tool/configure-button' import { AppModeEnum } from '@/types/app' +import { APP_PUBLISH_HOTKEY } from './hotkeys' import PublishWithMultipleModel from './publish-with-multiple-model' import SuggestedAction from './suggested-action' import { ACCESS_MODE_MAP } from './utils' @@ -30,7 +31,6 @@ type SummarySectionProps = Pick< handleRestore: () => Promise isChatApp: boolean published: boolean - publishShortcut: string[] upgradeHighlightStyle: CSSProperties } @@ -109,7 +109,6 @@ export const PublisherSummarySection = ({ publishDisabled = false, published, publishedAt, - publishShortcut, startNodeLimitExceeded = false, upgradeHighlightStyle, }: SummarySectionProps) => { @@ -163,7 +162,7 @@ export const PublisherSummarySection = ({
{t(($) => $['common.publishUpdate'], { ns: 'workflow' })} - {publishShortcut.map((key) => ( + {APP_PUBLISH_HOTKEY.split('+').map((key) => ( {formatForDisplay(key)} diff --git a/web/app/components/app/create-app-modal/index.tsx b/web/app/components/app/create-app-modal/index.tsx index 5116559f2d5..14f0869b54f 100644 --- a/web/app/components/app/create-app-modal/index.tsx +++ b/web/app/components/app/create-app-modal/index.tsx @@ -1,8 +1,10 @@ 'use client' +import type { Hotkey } from '@tanstack/react-hotkeys' import type { AppIconSelection } from '../../base/app-icon-picker' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' +import { Input } from '@langgenius/dify-ui/input' import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd' import { Textarea } from '@langgenius/dify-ui/textarea' import { toast } from '@langgenius/dify-ui/toast' @@ -22,7 +24,6 @@ import { ListSparkle, Logic, } from '@/app/components/base/icons/src/vender/solid/communication' -import Input from '@/app/components/base/input' import AppsFull from '@/app/components/billing/apps-full-in-dialog' import { userProfileIdAtom } from '@/context/account-state' import { workspacePermissionKeysAtom } from '@/context/permission-state' @@ -47,6 +48,8 @@ type CreateAppProps = { defaultAppMode?: AppModeEnum } +const CREATE_APP_HOTKEY = 'Mod+Enter' satisfies Hotkey + const shouldExpandBeginnerAppTypes = (appMode?: AppModeEnum) => { return ( appMode === AppModeEnum.CHAT || @@ -152,7 +155,7 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }: const { run: handleCreateApp } = useDebounceFn(onCreate, { wait: 300 }) useHotkey( - 'Mod+Enter', + CREATE_APP_HOTKEY, () => { if (isAppsFull || !canCreateApp) return handleCreateApp() @@ -348,7 +351,7 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }: > {t(($) => $['newApp.Create'], { ns: 'app' })} - {['Mod', 'Enter'].map((key) => ( + {CREATE_APP_HOTKEY.split('+').map((key) => ( {formatForDisplay(key)} @@ -434,9 +437,10 @@ type AppTypeCardProps = { } function AppTypeCard({ icon, title, description, active, onClick }: AppTypeCardProps) { return ( -
{description}
-
+ ) } diff --git a/web/app/components/app/create-from-dsl-modal/__tests__/index.spec.tsx b/web/app/components/app/create-from-dsl-modal/__tests__/index.spec.tsx index c50025b263f..ab8f378d558 100644 --- a/web/app/components/app/create-from-dsl-modal/__tests__/index.spec.tsx +++ b/web/app/components/app/create-from-dsl-modal/__tests__/index.spec.tsx @@ -4,7 +4,8 @@ import { renderWithSystemFeatures as render } from '@/__tests__/utils/mock-syste import { NEED_REFRESH_APP_LIST_KEY } from '@/app/components/apps/storage' import { DSLImportMode, DSLImportStatus } from '@/models/app' import { AppModeEnum } from '@/types/app' -import CreateFromDSLModal, { CreateFromDSLModalTab } from '../index' +import CreateFromDSLModal from '../index' +import { CreateFromDSLModalTab } from '../types' const mockPush = vi.fn() const mockImportDSL = vi.fn() diff --git a/web/app/components/app/create-from-dsl-modal/index.tsx b/web/app/components/app/create-from-dsl-modal/index.tsx index 929646abac4..f5cd1db9b56 100644 --- a/web/app/components/app/create-from-dsl-modal/index.tsx +++ b/web/app/components/app/create-from-dsl-modal/index.tsx @@ -1,9 +1,11 @@ 'use client' +import type { Hotkey } from '@tanstack/react-hotkeys' import type { MouseEventHandler } from 'react' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' +import { Input } from '@langgenius/dify-ui/input' import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd' import { toast } from '@langgenius/dify-ui/toast' import { formatForDisplay, useHotkey } from '@tanstack/react-hotkeys' @@ -13,7 +15,6 @@ import { useAtomValue } from 'jotai' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useSetNeedRefreshAppList } from '@/app/components/apps/storage' -import Input from '@/app/components/base/input' import AppsFull from '@/app/components/billing/apps-full-in-dialog' import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks' import { userProfileIdAtom } from '@/context/account-state' @@ -26,21 +27,19 @@ import { importDSL, importDSLConfirm } from '@/service/apps' import { useInvalidateAppList } from '@/service/use-apps' import { getRedirection } from '@/utils/app-redirection' import { trackCreateApp } from '@/utils/create-app-tracking' +import { CreateFromDSLModalTab } from './types' import Uploader from './uploader' type CreateFromDSLModalProps = { show: boolean onSuccess?: () => void onClose: () => void - activeTab?: string + activeTab?: CreateFromDSLModalTab dslUrl?: string droppedFile?: File } -export enum CreateFromDSLModalTab { - FROM_FILE = 'from-file', - FROM_URL = 'from-url', -} +const CREATE_FROM_DSL_HOTKEY = 'Mod+Enter' satisfies Hotkey const CreateFromDSLModal = ({ show, @@ -91,8 +90,8 @@ const CreateFromDSLModal = ({ const isCreatingRef = useRef(false) useEffect(() => { - if (droppedFile) handleFile(droppedFile) - }, [droppedFile, handleFile]) + if (droppedFile) readFile(droppedFile) + }, [droppedFile, readFile]) const onCreate = async (_e?: React.MouseEvent) => { if (currentTab === CreateFromDSLModalTab.FROM_FILE && !currentFile) return @@ -179,7 +178,7 @@ const CreateFromDSLModal = ({ const { run: handleCreateApp } = useDebounceFn(onCreate, { wait: 300 }) useHotkey( - 'Mod+Enter', + CREATE_FROM_DSL_HOTKEY, () => { handleCreateApp(undefined) }, @@ -251,16 +250,23 @@ const CreateFromDSLModal = ({
{t(($) => $.importApp, { ns: 'app' })} -
onClose()}> - -
+
{tabs.map((tab) => ( -
setCurrentTab(tab.key)} @@ -269,7 +275,7 @@ const CreateFromDSLModal = ({ {currentTab === tab.key && (
)} -
+ ))}
@@ -304,7 +310,7 @@ const CreateFromDSLModal = ({ > {t(($) => $['newApp.Create'], { ns: 'app' })} - {['Mod', 'Enter'].map((key) => ( + {CREATE_FROM_DSL_HOTKEY.split('+').map((key) => ( {formatForDisplay(key)} diff --git a/web/app/components/app/create-from-dsl-modal/types.ts b/web/app/components/app/create-from-dsl-modal/types.ts new file mode 100644 index 00000000000..0cf1a8df7c0 --- /dev/null +++ b/web/app/components/app/create-from-dsl-modal/types.ts @@ -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] diff --git a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/tab/__tests__/index.spec.tsx b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/tab/__tests__/index.spec.tsx index a417a5e122b..b4853c5ccbb 100644 --- a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/tab/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/tab/__tests__/index.spec.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { CreateFromDSLModalTab } from '@/app/components/app/create-from-dsl-modal' +import { CreateFromDSLModalTab } from '../../hooks/use-dsl-import' import Tab from '../index' // Tab Component Tests diff --git a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/tab/index.tsx b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/tab/index.tsx index 129df9e34a8..5af2e53cb4a 100644 --- a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/tab/index.tsx +++ b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/tab/index.tsx @@ -1,6 +1,6 @@ import * as React from 'react' import { useTranslation } from 'react-i18next' -import { CreateFromDSLModalTab } from '@/app/components/app/create-from-dsl-modal' +import { CreateFromDSLModalTab } from '../hooks/use-dsl-import' import Item from './item' type TabProps = { diff --git a/web/app/components/datasets/documents/detail/__tests__/new-segment.spec.tsx b/web/app/components/datasets/documents/detail/__tests__/new-segment.spec.tsx index 8252496e1e1..8ee5b16c345 100644 --- a/web/app/components/datasets/documents/detail/__tests__/new-segment.spec.tsx +++ b/web/app/components/datasets/documents/detail/__tests__/new-segment.spec.tsx @@ -49,7 +49,7 @@ vi.mock('@/service/knowledge/use-segment', () => ({ })) vi.mock('../completed/common/action-buttons', () => ({ - default: ({ + ActionButtons: ({ handleCancel, handleSave, loading, diff --git a/web/app/components/datasets/documents/detail/completed/__tests__/child-segment-detail.spec.tsx b/web/app/components/datasets/documents/detail/completed/__tests__/child-segment-detail.spec.tsx index a207f6eb4c7..2de2284ee80 100644 --- a/web/app/components/datasets/documents/detail/completed/__tests__/child-segment-detail.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/__tests__/child-segment-detail.spec.tsx @@ -31,7 +31,7 @@ vi.mock('@/context/event-emitter', () => ({ })) vi.mock('../common/action-buttons', () => ({ - default: ({ + ActionButtons: ({ handleCancel, handleSave, loading, diff --git a/web/app/components/datasets/documents/detail/completed/__tests__/new-child-segment.spec.tsx b/web/app/components/datasets/documents/detail/completed/__tests__/new-child-segment.spec.tsx index 313b693b001..ef3d120f07c 100644 --- a/web/app/components/datasets/documents/detail/completed/__tests__/new-child-segment.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/__tests__/new-child-segment.spec.tsx @@ -45,7 +45,7 @@ vi.mock('@/service/knowledge/use-segment', () => ({ })) vi.mock('../common/action-buttons', () => ({ - default: ({ + ActionButtons: ({ handleCancel, handleSave, loading, diff --git a/web/app/components/datasets/documents/detail/completed/__tests__/segment-detail.spec.tsx b/web/app/components/datasets/documents/detail/completed/__tests__/segment-detail.spec.tsx index 8b8682d83a6..e5bb4c62ef7 100644 --- a/web/app/components/datasets/documents/detail/completed/__tests__/segment-detail.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/__tests__/segment-detail.spec.tsx @@ -53,7 +53,7 @@ vi.mock('@/context/event-emitter', () => ({ })) vi.mock('../common/action-buttons', () => ({ - default: ({ + ActionButtons: ({ handleCancel, handleSave, handleRegeneration, diff --git a/web/app/components/datasets/documents/detail/completed/child-segment-detail.tsx b/web/app/components/datasets/documents/detail/completed/child-segment-detail.tsx index 0bb4b2daa02..cff2be97ed3 100644 --- a/web/app/components/datasets/documents/detail/completed/child-segment-detail.tsx +++ b/web/app/components/datasets/documents/detail/completed/child-segment-detail.tsx @@ -9,7 +9,7 @@ import Divider from '@/app/components/base/divider' import { useEventEmitterContextContext } from '@/context/event-emitter' import { formatNumber } from '@/utils/format' import { formatTime } from '@/utils/time' -import ActionButtons from './common/action-buttons' +import { ActionButtons } from './common/action-buttons' import ChunkContent from './common/chunk-content' import Dot from './common/dot' import { SegmentIndexTag } from './common/segment-index-tag' diff --git a/web/app/components/datasets/documents/detail/completed/common/__tests__/action-buttons.spec.tsx b/web/app/components/datasets/documents/detail/completed/common/__tests__/action-buttons.spec.tsx index 1861c55ed7b..01ee0f70968 100644 --- a/web/app/components/datasets/documents/detail/completed/common/__tests__/action-buttons.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/common/__tests__/action-buttons.spec.tsx @@ -2,7 +2,7 @@ import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { ChunkingMode } from '@/models/datasets' import { DocumentContext } from '../../../context' -import ActionButtons from '../action-buttons' +import { ActionButtons } from '../action-buttons' const mockUseHotkey = vi.fn() vi.mock('@tanstack/react-hotkeys', async (importOriginal) => { diff --git a/web/app/components/datasets/documents/detail/completed/common/action-buttons.tsx b/web/app/components/datasets/documents/detail/completed/common/action-buttons.tsx index a8508c7572a..77444f35283 100644 --- a/web/app/components/datasets/documents/detail/completed/common/action-buttons.tsx +++ b/web/app/components/datasets/documents/detail/completed/common/action-buttons.tsx @@ -1,14 +1,15 @@ -import type { FC } from 'react' +import type { Hotkey } from '@tanstack/react-hotkeys' import { Button } from '@langgenius/dify-ui/button' import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd' import { formatForDisplay, useHotkey } from '@tanstack/react-hotkeys' -import * as React from 'react' -import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import { ChunkingMode } from '@/models/datasets' import { useDocumentContext } from '../../context' -type IActionButtonsProps = { +const CANCEL_HOTKEY = 'Escape' satisfies Hotkey +const SAVE_HOTKEY = 'Mod+S' satisfies Hotkey + +type ActionButtonsProps = { handleCancel: () => void handleSave: () => void loading: boolean @@ -18,7 +19,7 @@ type IActionButtonsProps = { showRegenerationButton?: boolean } -const ActionButtons: FC = ({ +export function ActionButtons({ handleCancel, handleSave, loading, @@ -26,25 +27,24 @@ const ActionButtons: FC = ({ handleRegeneration, isChildChunk = false, showRegenerationButton = true, -}) => { +}: ActionButtonsProps) { const { t } = useTranslation() const docForm = useDocumentContext((s) => s.docForm) const parentMode = useDocumentContext((s) => s.parentMode) - useHotkey('Escape', (e) => { + useHotkey(CANCEL_HOTKEY, (e) => { e.preventDefault() handleCancel() }) - useHotkey('Mod+S', (e) => { + useHotkey(SAVE_HOTKEY, (e) => { e.preventDefault() if (loading) return handleSave() }) - const isParentChildParagraphMode = useMemo(() => { - return docForm === ChunkingMode.parentChild && parentMode === 'paragraph' - }, [docForm, parentMode]) + const isParentChildParagraphMode = + docForm === ChunkingMode.parentChild && parentMode === 'paragraph' return (
@@ -53,7 +53,7 @@ const ActionButtons: FC = ({ {t(($) => $['operation.cancel'], { ns: 'common' })} - {formatForDisplay('Escape')} + {formatForDisplay(CANCEL_HOTKEY)}
{isParentChildParagraphMode && @@ -72,7 +72,7 @@ const ActionButtons: FC = ({ {t(($) => $['operation.save'], { ns: 'common' })} - {['Mod', 'S'].map((key) => ( + {SAVE_HOTKEY.split('+').map((key) => ( {formatForDisplay(key)} @@ -83,7 +83,3 @@ const ActionButtons: FC = ({
) } - -ActionButtons.displayName = 'ActionButtons' - -export default React.memo(ActionButtons) diff --git a/web/app/components/datasets/documents/detail/completed/new-child-segment.tsx b/web/app/components/datasets/documents/detail/completed/new-child-segment.tsx index bbda2bc0539..c7ba7e3e318 100644 --- a/web/app/components/datasets/documents/detail/completed/new-child-segment.tsx +++ b/web/app/components/datasets/documents/detail/completed/new-child-segment.tsx @@ -11,7 +11,7 @@ import { useParams } from '@/next/navigation' import { useAddChildSegment } from '@/service/knowledge/use-segment' import { formatNumber } from '@/utils/format' import { useDocumentContext } from '../context' -import ActionButtons from './common/action-buttons' +import { ActionButtons } from './common/action-buttons' import AddAnother from './common/add-another' import ChunkContent from './common/chunk-content' import Dot from './common/dot' diff --git a/web/app/components/datasets/documents/detail/completed/segment-detail.tsx b/web/app/components/datasets/documents/detail/completed/segment-detail.tsx index de39b2a503c..7a49fe808a6 100644 --- a/web/app/components/datasets/documents/detail/completed/segment-detail.tsx +++ b/web/app/components/datasets/documents/detail/completed/segment-detail.tsx @@ -13,7 +13,7 @@ import { useEventEmitterContextContext } from '@/context/event-emitter' import { ChunkingMode } from '@/models/datasets' import { formatNumber } from '@/utils/format' import { useDocumentContext } from '../context' -import ActionButtons from './common/action-buttons' +import { ActionButtons } from './common/action-buttons' import ChunkContent from './common/chunk-content' import Dot from './common/dot' import Keywords from './common/keywords' diff --git a/web/app/components/datasets/documents/detail/new-segment.tsx b/web/app/components/datasets/documents/detail/new-segment.tsx index e03b61ad441..392b888a48a 100644 --- a/web/app/components/datasets/documents/detail/new-segment.tsx +++ b/web/app/components/datasets/documents/detail/new-segment.tsx @@ -15,7 +15,7 @@ import { useAddSegment } from '@/service/knowledge/use-segment' import { formatNumber } from '@/utils/format' import { IndexingType } from '../../create/step-two' import { useSegmentListContext } from './completed' -import ActionButtons from './completed/common/action-buttons' +import { ActionButtons } from './completed/common/action-buttons' import AddAnother from './completed/common/add-another' import ChunkContent from './completed/common/chunk-content' import Dot from './completed/common/dot' diff --git a/web/app/components/detail-sidebar/__tests__/index.spec.tsx b/web/app/components/detail-sidebar/__tests__/index.spec.tsx index c59069cc525..90ea5423927 100644 --- a/web/app/components/detail-sidebar/__tests__/index.spec.tsx +++ b/web/app/components/detail-sidebar/__tests__/index.spec.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen } from '@testing-library/react' +import { act, fireEvent, render, screen } from '@testing-library/react' import { DetailSidebarFrame } from '..' import { DETAIL_SIDEBAR_STORAGE_KEY } from '../storage' @@ -6,8 +6,8 @@ const { hotkeyRegistrations } = vi.hoisted(() => ({ hotkeyRegistrations: new Map< string, { - handler: (event: { preventDefault: () => void }) => void - options?: { ignoreInputs?: boolean } + handler: () => void + options?: { ignoreInputs?: boolean; preventDefault?: boolean } } >(), })) @@ -25,8 +25,8 @@ vi.mock('@tanstack/react-hotkeys', async (importOriginal) => { ...actual, useHotkey: ( hotkey: string, - handler: (event: { preventDefault: () => void }) => void, - options?: { ignoreInputs?: boolean }, + handler: () => void, + options?: { ignoreInputs?: boolean; preventDefault?: boolean }, ) => { hotkeyRegistrations.set(hotkey, { handler, options }) }, @@ -117,10 +117,19 @@ describe('DetailSidebarFrame', () => { expect(screen.getByTestId('detail-top')).toHaveAttribute('data-expand', 'true') expect(screen.getByTestId('detail-section')).toHaveAttribute('data-expand', 'true') expect(hotkeyRegistrations.get('Mod+B')?.options).toEqual( - expect.objectContaining({ ignoreInputs: false }), + expect.objectContaining({ ignoreInputs: false, preventDefault: true }), ) }) + it('toggles detail content from the registered shortcut', () => { + renderDetailSidebarFrame() + + act(() => hotkeyRegistrations.get('Mod+B')?.handler()) + + expect(screen.getByRole('complementary')).toHaveClass('w-16') + expect(screen.getByTestId('detail-top')).toHaveAttribute('data-expand', 'false') + }) + it('collapses detail content from the top toggle and hides environment metadata', () => { mockAppContextState.current = { langGeniusVersionInfo: { diff --git a/web/app/components/detail-sidebar/__tests__/toggle-button.spec.tsx b/web/app/components/detail-sidebar/__tests__/toggle-button.spec.tsx new file mode 100644 index 00000000000..6f3c66ff446 --- /dev/null +++ b/web/app/components/detail-sidebar/__tests__/toggle-button.spec.tsx @@ -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() + + expect( + screen.getByRole('button', { name: 'layout.sidebar.collapseSidebar' }), + ).toBeInTheDocument() + }) + + it('labels the collapsed sidebar action', () => { + render() + + 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() + + await user.click(screen.getByRole('button', { name: 'layout.sidebar.collapseSidebar' })) + + expect(onToggle).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/app/components/detail-sidebar/hotkeys.ts b/web/app/components/detail-sidebar/hotkeys.ts new file mode 100644 index 00000000000..45f19d1c289 --- /dev/null +++ b/web/app/components/detail-sidebar/hotkeys.ts @@ -0,0 +1,3 @@ +import type { Hotkey } from '@tanstack/react-hotkeys' + +export const DETAIL_SIDEBAR_TOGGLE_HOTKEY = 'Mod+B' satisfies Hotkey diff --git a/web/app/components/detail-sidebar/index.tsx b/web/app/components/detail-sidebar/index.tsx index fba1442ac4e..89a3567a5d1 100644 --- a/web/app/components/detail-sidebar/index.tsx +++ b/web/app/components/detail-sidebar/index.tsx @@ -4,11 +4,12 @@ import type { ReactNode } from 'react' import { cn } from '@langgenius/dify-ui/cn' import { useHotkey } from '@tanstack/react-hotkeys' import { useAtomValue } from 'jotai' -import { useCallback, useEffect, useRef, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import EnvNav from '@/app/components/header/env-nav' import AccountSection from '@/app/components/main-nav/components/account-section' import HelpMenu from '@/app/components/main-nav/components/help-menu' import { langGeniusVersionInfoAtom } from '@/context/version-state' +import { DETAIL_SIDEBAR_TOGGLE_HOTKEY } from './hotkeys' import { useDetailSidebarMode } from './storage' type DetailSidebarRenderProps = { @@ -56,7 +57,7 @@ export function DetailSidebarFrame({ const currentEnv = langGeniusVersionInfo?.current_env const showEnvTag = currentEnv === 'TESTING' || currentEnv === 'DEVELOPMENT' - const handleToggleDetailNavigation = useCallback(() => { + function handleToggleDetailNavigation() { if (isDetailNavigationHoverPreviewOpen) { if (detailNavigationTransitionTimerRef.current) clearTimeout(detailNavigationTransitionTimerRef.current) @@ -73,25 +74,25 @@ export function DetailSidebarFrame({ const nextMode = detailNavigationExpanded ? 'collapse' : 'expand' setDetailNavigationHoverPreviewOpen(false) setStoredDetailSidebarExpand(nextMode) - }, [detailNavigationExpanded, isDetailNavigationHoverPreviewOpen, setStoredDetailSidebarExpand]) + } - const openDetailNavigationHoverPreview = useCallback(() => { + function openDetailNavigationHoverPreview() { if (detailNavigationExpanded) return if (closeDetailNavigationHoverPreviewTimerRef.current) clearTimeout(closeDetailNavigationHoverPreviewTimerRef.current) setDetailNavigationHoverPreviewOpen(true) - }, [detailNavigationExpanded]) + } - const closeDetailNavigationHoverPreview = useCallback(() => { + function closeDetailNavigationHoverPreview() { if (closeDetailNavigationHoverPreviewTimerRef.current) clearTimeout(closeDetailNavigationHoverPreviewTimerRef.current) closeDetailNavigationHoverPreviewTimerRef.current = setTimeout(() => { setDetailNavigationHoverPreviewOpen(false) }, 120) - }, []) + } useEffect(() => { return () => { @@ -102,16 +103,10 @@ export function DetailSidebarFrame({ } }, []) - useHotkey( - 'Mod+B', - (e) => { - e.preventDefault() - handleToggleDetailNavigation() - }, - { - ignoreInputs: false, - }, - ) + useHotkey(DETAIL_SIDEBAR_TOGGLE_HOTKEY, handleToggleDetailNavigation, { + ignoreInputs: false, + preventDefault: true, + }) return (