This is an automated email from the ASF dual-hosted git repository.
pierrejeambrun pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new 70bb308b19a Add keyboard shortcut help dialog to discover available
shortcuts (? key) (#68200)
70bb308b19a is described below
commit 70bb308b19acbae0f04075063f8546eb5bc0e200
Author: Yeonguk Choo <[email protected]>
AuthorDate: Fri Jun 26 01:00:55 2026 +0900
Add keyboard shortcut help dialog to discover available shortcuts (? key)
(#68200)
* feat: keyboard shortcuts help dialog (press ?)
* remove unnecessary useCallback and useMemo hooks
* drop grid keyboard shortcut tooltip
* centralize shortcut definitions in a single catalog
* use nesting to reuse concept translation keys
* register code viewer fullscreen shortcut in the catalog
---
.../airflow/ui/public/i18n/locales/en/common.json | 41 +++++
.../src/airflow/ui/public/i18n/locales/en/dag.json | 4 +-
.../ui/src/components/Clear/Run/ClearRunButton.tsx | 13 +-
.../Clear/TaskInstance/ClearTaskInstanceButton.tsx | 13 +-
.../FilterBar/filters/TextSearchFilter.tsx | 13 +-
.../airflow/ui/src/components/GraphTaskFilters.tsx | 9 +-
.../KeyboardShortcuts/KeyboardShortcutsModal.tsx | 106 ++++++++++++
.../KeyboardShortcuts/formatShortcutCombo.test.ts | 41 +++++
.../KeyboardShortcuts/formatShortcutCombo.ts | 66 ++++++++
.../ui/src/components/KeyboardShortcuts/index.ts | 20 +++
.../src/components/MarkAs/Run/MarkRunAsButton.tsx | 23 +--
.../MarkAs/TaskGroup/MarkTaskGroupAsButton.tsx | 23 +--
.../TaskInstance/MarkTaskInstanceAsButton.tsx | 23 +--
.../src/airflow/ui/src/components/SearchBar.tsx | 13 +-
.../src/components/SearchDags/SearchDagsButton.tsx | 15 +-
.../ui/src/context/keyboardShortcuts/Context.ts | 58 +++++++
.../keyboardShortcuts/ShortcutRegistryProvider.tsx | 45 ++++++
.../ui/src/context/keyboardShortcuts/index.ts | 22 +++
.../ui/src/context/keyboardShortcuts/shortcuts.ts | 179 +++++++++++++++++++++
.../keyboardShortcuts/useShortcutRegistry.ts | 23 +++
.../src/hooks/navigation/useKeyboardNavigation.ts | 19 ++-
.../src/airflow/ui/src/hooks/useShortcut.test.tsx | 111 +++++++++++++
.../src/airflow/ui/src/hooks/useShortcut.ts | 76 +++++++++
.../src/airflow/ui/src/layouts/BaseLayout.tsx | 2 +
.../ui/src/layouts/Details/PanelButtons.tsx | 26 +--
airflow-core/src/airflow/ui/src/main.tsx | 5 +-
.../src/airflow/ui/src/pages/Dag/Code/Code.tsx | 14 +-
.../ui/src/pages/Dag/Overview/FailedLogs.tsx | 8 +-
.../src/pages/TaskInstance/Logs/LogSearchInput.tsx | 13 +-
.../ui/src/pages/TaskInstance/Logs/Logs.tsx | 33 +++-
.../src/pages/TaskInstance/Logs/TaskLogContent.tsx | 15 +-
31 files changed, 958 insertions(+), 114 deletions(-)
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
index 7b5f37c9cfd..d8e490fa451 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
@@ -226,6 +226,47 @@
},
"selectLanguage": "Select Language",
"selected": "Selected",
+ "shortcuts": {
+ "categories": {
+ "code": "Code",
+ "dagView": "Dag View",
+ "filters": "Filters",
+ "global": "Global",
+ "logs": "Logs",
+ "navigation": "Navigation",
+ "runActions": "Run & Task Actions",
+ "search": "Search"
+ },
+ "descriptions": {
+ "clearRun": "Clear $t(dagRun_one)",
+ "clearTaskInstance": "Clear $t(taskInstance_one)",
+ "downloadLogs": "Download logs",
+ "focusFilterSearch": "Focus filter search",
+ "focusLogSearch": "Search logs",
+ "focusSearch": "Focus search",
+ "markRunFailed": "Mark $t(dagRun_one) as failed",
+ "markRunSuccess": "Mark $t(dagRun_one) as success",
+ "markTaskFailed": "Mark $t(task_one) as failed",
+ "markTaskGroupFailed": "Mark $t(taskGroup_one) as failed",
+ "markTaskGroupSuccess": "Mark $t(taskGroup_one) as success",
+ "markTaskSuccess": "Mark $t(task_one) as success",
+ "navigateTasks": "Navigate $t(task_other)",
+ "openGraphFilters": "Open graph filters",
+ "scrollBottom": "Scroll to bottom",
+ "scrollTop": "Scroll to top",
+ "searchDags": "Search $t(dag_other)",
+ "showHelp": "Show $t(shortcuts.title)",
+ "toggleExpand": "Expand or collapse all groups",
+ "toggleFullscreen": "Toggle fullscreen",
+ "toggleGraphGrid": "Toggle graph / grid view",
+ "toggleSource": "Toggle source",
+ "toggleTaskGroup": "Expand or collapse $t(taskGroup_one)",
+ "toggleTimestamp": "Toggle timestamps",
+ "toggleWrap": "Toggle $t(wrap.wrap)"
+ },
+ "empty": "No keyboard shortcuts are available on this page.",
+ "title": "Keyboard Shortcuts"
+ },
"showDetailsPanel": "Show Details Panel",
"signedInAs": "Signed in as",
"source": {
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json
b/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json
index b51add8d5ec..ab1e332ebae 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json
@@ -103,9 +103,7 @@
"warning": "WARNING"
},
"navigation": {
- "navigation": "Navigation: Shift+{{arrow}}",
- "openGraphFilters": "Task Filters: Ctrl+Shift+F",
- "toggleGroup": "Toggle group: Space"
+ "openGraphFilters": "Task Filters: Ctrl+Shift+F"
},
"notFound": {
"back": "Go Back",
diff --git
a/airflow-core/src/airflow/ui/src/components/Clear/Run/ClearRunButton.tsx
b/airflow-core/src/airflow/ui/src/components/Clear/Run/ClearRunButton.tsx
index 6cbc3aa08b0..c4c040a1c1b 100644
--- a/airflow-core/src/airflow/ui/src/components/Clear/Run/ClearRunButton.tsx
+++ b/airflow-core/src/airflow/ui/src/components/Clear/Run/ClearRunButton.tsx
@@ -17,12 +17,13 @@
* under the License.
*/
import { useDisclosure } from "@chakra-ui/react";
-import { useHotkeys } from "react-hotkeys-hook";
import { useTranslation } from "react-i18next";
import { CgRedo } from "react-icons/cg";
import type { DAGRunResponse } from "openapi/requests/types.gen";
import { IconButton } from "src/components/ui";
+import { SHORTCUTS } from "src/context/keyboardShortcuts";
+import { useShortcut } from "src/hooks/useShortcut";
import ClearRunDialog from "./ClearRunDialog";
@@ -35,13 +36,13 @@ const ClearRunButton = ({ dagRun, isHotkeyEnabled = false
}: Props) => {
const { onClose, onOpen, open } = useDisclosure();
const { t: translate } = useTranslation();
- useHotkeys(
- "shift+c",
- () => {
+ useShortcut({
+ ...SHORTCUTS.runActions.clearRun,
+ callback: () => {
onOpen();
},
- { enabled: isHotkeyEnabled },
- );
+ options: { enabled: isHotkeyEnabled },
+ });
return (
<>
diff --git
a/airflow-core/src/airflow/ui/src/components/Clear/TaskInstance/ClearTaskInstanceButton.tsx
b/airflow-core/src/airflow/ui/src/components/Clear/TaskInstance/ClearTaskInstanceButton.tsx
index 84fc78c1e68..07bfc56f80c 100644
---
a/airflow-core/src/airflow/ui/src/components/Clear/TaskInstance/ClearTaskInstanceButton.tsx
+++
b/airflow-core/src/airflow/ui/src/components/Clear/TaskInstance/ClearTaskInstanceButton.tsx
@@ -17,13 +17,14 @@
* under the License.
*/
import { useDisclosure } from "@chakra-ui/react";
-import { useHotkeys } from "react-hotkeys-hook";
import { useTranslation } from "react-i18next";
import { CgRedo } from "react-icons/cg";
import type { LightGridTaskInstanceSummary, TaskInstanceResponse } from
"openapi/requests/types.gen";
import { ClearGroupTaskInstanceDialog } from
"src/components/Clear/TaskInstance/ClearGroupTaskInstanceDialog";
import { IconButton } from "src/components/ui";
+import { SHORTCUTS } from "src/context/keyboardShortcuts";
+import { useShortcut } from "src/hooks/useShortcut";
import ClearTaskInstanceDialog from "./ClearTaskInstanceDialog";
@@ -58,17 +59,17 @@ const ClearTaskInstanceButton = ({
const selectedInstance = taskInstance ?? groupTaskInstance;
- useHotkeys(
- "shift+c",
- () => {
+ useShortcut({
+ ...SHORTCUTS.runActions.clearTaskInstance,
+ callback: () => {
if (onOpen && selectedInstance) {
onOpen(selectedInstance);
} else {
onOpenInternal();
}
},
- { enabled: isHotkeyEnabled },
- );
+ options: { enabled: isHotkeyEnabled },
+ });
const label = allMapped
? isHotkeyEnabled
diff --git
a/airflow-core/src/airflow/ui/src/components/FilterBar/filters/TextSearchFilter.tsx
b/airflow-core/src/airflow/ui/src/components/FilterBar/filters/TextSearchFilter.tsx
index 32bfa292318..09f1c5d3ee4 100644
---
a/airflow-core/src/airflow/ui/src/components/FilterBar/filters/TextSearchFilter.tsx
+++
b/airflow-core/src/airflow/ui/src/components/FilterBar/filters/TextSearchFilter.tsx
@@ -18,11 +18,12 @@
*/
import { HStack } from "@chakra-ui/react";
import { useRef } from "react";
-import { useHotkeys } from "react-hotkeys-hook";
import { LuRegex } from "react-icons/lu";
import { AdvancedSearchToggle } from "src/components/AdvancedSearchToggle";
+import { SHORTCUTS } from "src/context/keyboardShortcuts";
import { useAdvancedSearch } from "src/hooks/useAdvancedSearch";
+import { useShortcut } from "src/hooks/useShortcut";
import { InputWithAddon } from "../../ui";
import { FilterPill } from "../FilterPill";
@@ -42,15 +43,15 @@ export const TextSearchFilter = ({ filter, onChange,
onRemove }: FilterPluginPro
onChange(newValue || undefined);
};
- useHotkeys(
- "mod+k",
- () => {
+ useShortcut({
+ ...SHORTCUTS.search.focusFilterSearch,
+ callback: () => {
if (!filter.config.hotkeyDisabled) {
hotkeyInputRef.current?.focus();
}
},
- { enabled: !filter.config.hotkeyDisabled, preventDefault: true },
- );
+ options: { enabled: !filter.config.hotkeyDisabled, preventDefault: true },
+ });
const isAdvanced = showAdvancedToggle && advanced.enabled;
const stringValue = hasValue && typeof filter.value === "string" ?
filter.value : "";
diff --git a/airflow-core/src/airflow/ui/src/components/GraphTaskFilters.tsx
b/airflow-core/src/airflow/ui/src/components/GraphTaskFilters.tsx
index e6515458f48..208f9a7b149 100644
--- a/airflow-core/src/airflow/ui/src/components/GraphTaskFilters.tsx
+++ b/airflow-core/src/airflow/ui/src/components/GraphTaskFilters.tsx
@@ -25,7 +25,6 @@ import {
VStack,
} from "@chakra-ui/react";
import { useEffect, useRef, useState } from "react";
-import { useHotkeys } from "react-hotkeys-hook";
import { useTranslation } from "react-i18next";
import { FiSearch } from "react-icons/fi";
import { useParams, useSearchParams } from "react-router-dom";
@@ -39,6 +38,8 @@ import { NumberInputField, NumberInputRoot } from
"src/components/ui/NumberInput
import { SearchParamsKeys } from "src/constants/searchParams";
import { taskInstanceStateOptions } from "src/constants/stateOptions";
import { useGroups } from "src/context/groups";
+import { SHORTCUTS } from "src/context/keyboardShortcuts";
+import { useShortcut } from "src/hooks/useShortcut";
export const GraphTaskFilters = () => {
const { t: translate } = useTranslation(["dag", "tasks"]);
@@ -140,7 +141,11 @@ export const GraphTaskFilters = () => {
const [isOpen, setIsOpen] = useState(false);
- useHotkeys("mod+shift+f", () => setIsOpen(true), { preventDefault: true });
+ useShortcut({
+ ...SHORTCUTS.filters.openGraphFilters,
+ callback: () => setIsOpen(true),
+ options: { preventDefault: true },
+ });
const panelTitle = translate("dag:panel.graphFilters.title");
diff --git
a/airflow-core/src/airflow/ui/src/components/KeyboardShortcuts/KeyboardShortcutsModal.tsx
b/airflow-core/src/airflow/ui/src/components/KeyboardShortcuts/KeyboardShortcutsModal.tsx
new file mode 100644
index 00000000000..6ff477c626c
--- /dev/null
+++
b/airflow-core/src/airflow/ui/src/components/KeyboardShortcuts/KeyboardShortcutsModal.tsx
@@ -0,0 +1,106 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { Box, Flex, HStack, Heading, Kbd, Text, VStack } from
"@chakra-ui/react";
+import { useState } from "react";
+import { useTranslation } from "react-i18next";
+
+import { Dialog } from "src/components/ui";
+import {
+ SHORTCUT_CATEGORIES,
+ SHORTCUTS,
+ type ShortcutEntry,
+ useShortcutRegistry,
+} from "src/context/keyboardShortcuts";
+import { useShortcut } from "src/hooks/useShortcut";
+import { getMetaKey } from "src/utils";
+
+import { formatShortcutCombo } from "./formatShortcutCombo";
+
+const buildGroups = (shortcuts: ReadonlyArray<ShortcutEntry>) =>
+ SHORTCUT_CATEGORIES.map((category) => {
+ const seen = new Set<string>();
+ const items = shortcuts
+ .filter((entry) => entry.category === category)
+ .filter((entry) => {
+ const key = `${entry.keys.join("+")}|${entry.description}`;
+
+ if (seen.has(key)) {
+ return false;
+ }
+ seen.add(key);
+
+ return true;
+ });
+
+ return { category, items };
+ }).filter((group) => group.items.length > 0);
+
+export const KeyboardShortcutsModal = () => {
+ const { t: translate } = useTranslation("common");
+ const { shortcuts } = useShortcutRegistry();
+ const [open, setOpen] = useState(false);
+ const metaKey = getMetaKey();
+
+ const toggle = () => setOpen((prev) => !prev);
+
+ useShortcut({
+ ...SHORTCUTS.global.showHelp,
+ callback: toggle,
+ });
+
+ const groups = buildGroups(shortcuts);
+
+ return (
+ <Dialog.Root lazyMount onOpenChange={(event) => setOpen(event.open)}
open={open} size="md">
+ <Dialog.Content backdrop>
+ <Dialog.Header>{translate("shortcuts.title")}</Dialog.Header>
+ <Dialog.CloseTrigger />
+ <Dialog.Body>
+ {groups.length === 0 ? (
+ <Text color="fg.muted">{translate("shortcuts.empty")}</Text>
+ ) : (
+ <VStack align="stretch" gap={4}>
+ {groups.map(({ category, items }) => (
+ <Box key={category}>
+ <Heading color="fg.muted" mb={2} size="xs"
textTransform="uppercase">
+ {translate(`shortcuts.categories.${category}`)}
+ </Heading>
+ <VStack align="stretch" gap={1}>
+ {items.map((entry) => (
+ <Flex alignItems="center" gap={4}
justifyContent="space-between" key={entry.id}>
+ <Text fontSize="sm">{entry.description}</Text>
+ <HStack gap={1}>
+ {entry.keys.map((combo) => (
+ <Kbd key={combo} size="sm">
+ {formatShortcutCombo(combo, metaKey)}
+ </Kbd>
+ ))}
+ </HStack>
+ </Flex>
+ ))}
+ </VStack>
+ </Box>
+ ))}
+ </VStack>
+ )}
+ </Dialog.Body>
+ </Dialog.Content>
+ </Dialog.Root>
+ );
+};
diff --git
a/airflow-core/src/airflow/ui/src/components/KeyboardShortcuts/formatShortcutCombo.test.ts
b/airflow-core/src/airflow/ui/src/components/KeyboardShortcuts/formatShortcutCombo.test.ts
new file mode 100644
index 00000000000..e128fc376f6
--- /dev/null
+++
b/airflow-core/src/airflow/ui/src/components/KeyboardShortcuts/formatShortcutCombo.test.ts
@@ -0,0 +1,41 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { describe, expect, it } from "vitest";
+
+import { formatShortcutCombo } from "./formatShortcutCombo";
+
+describe("formatShortcutCombo", () => {
+ it.each([
+ ["w", "⌘", "W"],
+ ["mod+k", "⌘", "⌘ K"],
+ ["mod+k", "Ctrl", "Ctrl K"],
+ ["mod+shift+f", "⌘", "⌘ ⇧ F"],
+ ["mod+ArrowUp", "Ctrl", "Ctrl ↑"],
+ ["shift+ArrowDown", "⌘", "⇧ ↓"],
+ ["space", "⌘", "Space"],
+ ["/", "⌘", "/"],
+ ])("formats %s (meta=%s) as %s", (combo, metaKey, expected) => {
+ expect(formatShortcutCombo(combo, metaKey)).toBe(expected);
+ });
+
+ it("renders the ? alias for the help shortcut regardless of casing", () => {
+ expect(formatShortcutCombo("shift+Slash", "⌘")).toBe("?");
+ expect(formatShortcutCombo("shift+slash", "Ctrl")).toBe("?");
+ });
+});
diff --git
a/airflow-core/src/airflow/ui/src/components/KeyboardShortcuts/formatShortcutCombo.ts
b/airflow-core/src/airflow/ui/src/components/KeyboardShortcuts/formatShortcutCombo.ts
new file mode 100644
index 00000000000..4819a84f5a1
--- /dev/null
+++
b/airflow-core/src/airflow/ui/src/components/KeyboardShortcuts/formatShortcutCombo.ts
@@ -0,0 +1,66 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+// Symbols for keys whose name is not the desired display label.
+const KEY_SYMBOLS: Record<string, string> = {
+ alt: "⌥",
+ arrowdown: "↓",
+ arrowleft: "←",
+ arrowright: "→",
+ arrowup: "↑",
+ ctrl: "Ctrl",
+ shift: "⇧",
+ slash: "/",
+ space: "Space",
+};
+
+// Combos bound to one key for matching reasons but better shown as a single
glyph.
+// `shift+Slash` is how react-hotkeys-hook reliably matches the "?" key.
+const COMBO_ALIASES: Record<string, string> = {
+ "shift+slash": "?",
+};
+
+/**
+ * Turn a `react-hotkeys-hook` combo such as `"mod+shift+f"` into a
human-readable
+ * label like `"⌘ ⇧ F"`. `metaKey` is the platform's meta key symbol
(`⌘`/`Ctrl`).
+ */
+export const formatShortcutCombo = (combo: string, metaKey: string): string =>
{
+ const alias = COMBO_ALIASES[combo.toLowerCase()];
+
+ if (alias !== undefined) {
+ return alias;
+ }
+
+ return combo
+ .split("+")
+ .map((part) => {
+ const lower = part.toLowerCase();
+
+ if (lower === "mod") {
+ return metaKey;
+ }
+
+ if (lower in KEY_SYMBOLS) {
+ return KEY_SYMBOLS[lower];
+ }
+
+ return part.length === 1 ? part.toUpperCase() : part;
+ })
+ .join(" ");
+};
diff --git
a/airflow-core/src/airflow/ui/src/components/KeyboardShortcuts/index.ts
b/airflow-core/src/airflow/ui/src/components/KeyboardShortcuts/index.ts
new file mode 100644
index 00000000000..f98aa5f41b7
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/components/KeyboardShortcuts/index.ts
@@ -0,0 +1,20 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+export { KeyboardShortcutsModal } from "./KeyboardShortcutsModal";
+export { formatShortcutCombo } from "./formatShortcutCombo";
diff --git
a/airflow-core/src/airflow/ui/src/components/MarkAs/Run/MarkRunAsButton.tsx
b/airflow-core/src/airflow/ui/src/components/MarkAs/Run/MarkRunAsButton.tsx
index e933ec88838..b89946aa352 100644
--- a/airflow-core/src/airflow/ui/src/components/MarkAs/Run/MarkRunAsButton.tsx
+++ b/airflow-core/src/airflow/ui/src/components/MarkAs/Run/MarkRunAsButton.tsx
@@ -18,7 +18,6 @@
*/
import { Box, HStack, useDisclosure } from "@chakra-ui/react";
import { useState } from "react";
-import { useHotkeys } from "react-hotkeys-hook";
import { useTranslation } from "react-i18next";
import { FiX } from "react-icons/fi";
import { LuCheck } from "react-icons/lu";
@@ -26,6 +25,8 @@ import { LuCheck } from "react-icons/lu";
import type { DagRunMutableStates, DAGRunResponse } from
"openapi/requests/types.gen";
import { StateBadge } from "src/components/StateBadge";
import { IconButton, Menu, Tooltip } from "src/components/ui";
+import { SHORTCUTS } from "src/context/keyboardShortcuts";
+import { useShortcut } from "src/hooks/useShortcut";
import { allowedStates } from "../utils";
import MarkRunAsDialog from "./MarkRunAsDialog";
@@ -40,23 +41,23 @@ const MarkRunAsButton = ({ dagRun, isHotkeyEnabled = false
}: Props) => {
const [state, setState] = useState<DagRunMutableStates>("success");
const { t: translate } = useTranslation();
- useHotkeys(
- "shift+f",
- () => {
+ useShortcut({
+ ...SHORTCUTS.runActions.markRunFailed,
+ callback: () => {
setState("failed");
onOpen();
},
- { enabled: isHotkeyEnabled },
- );
+ options: { enabled: isHotkeyEnabled },
+ });
- useHotkeys(
- "shift+s",
- () => {
+ useShortcut({
+ ...SHORTCUTS.runActions.markRunSuccess,
+ callback: () => {
setState("success");
onOpen();
},
- { enabled: isHotkeyEnabled },
- );
+ options: { enabled: isHotkeyEnabled },
+ });
const label = translate("dags:runAndTaskActions.markAs.button", {
type: translate("dagRun_one"),
diff --git
a/airflow-core/src/airflow/ui/src/components/MarkAs/TaskGroup/MarkTaskGroupAsButton.tsx
b/airflow-core/src/airflow/ui/src/components/MarkAs/TaskGroup/MarkTaskGroupAsButton.tsx
index fa5cd2322ae..9cc44162fb0 100644
---
a/airflow-core/src/airflow/ui/src/components/MarkAs/TaskGroup/MarkTaskGroupAsButton.tsx
+++
b/airflow-core/src/airflow/ui/src/components/MarkAs/TaskGroup/MarkTaskGroupAsButton.tsx
@@ -18,7 +18,6 @@
*/
import { Box, HStack, useDisclosure } from "@chakra-ui/react";
import { useState } from "react";
-import { useHotkeys } from "react-hotkeys-hook";
import { useTranslation } from "react-i18next";
import { FiX } from "react-icons/fi";
import { LuCheck } from "react-icons/lu";
@@ -26,6 +25,8 @@ import { LuCheck } from "react-icons/lu";
import type { LightGridTaskInstanceSummary, TaskInstanceState } from
"openapi/requests/types.gen";
import { StateBadge } from "src/components/StateBadge";
import { IconButton, Menu, Tooltip } from "src/components/ui";
+import { SHORTCUTS } from "src/context/keyboardShortcuts";
+import { useShortcut } from "src/hooks/useShortcut";
import { allowedStates } from "../utils";
import MarkTaskGroupAsDialog from "./MarkTaskGroupAsDialog";
@@ -40,23 +41,23 @@ const MarkTaskGroupAsButton = ({ groupTaskInstance,
isHotkeyEnabled = false }: P
const { t: translate } = useTranslation();
const [state, setState] = useState<TaskInstanceState>("success");
- useHotkeys(
- "shift+f",
- () => {
+ useShortcut({
+ ...SHORTCUTS.runActions.markTaskGroupFailed,
+ callback: () => {
setState("failed");
onOpen();
},
- { enabled: isHotkeyEnabled },
- );
+ options: { enabled: isHotkeyEnabled },
+ });
- useHotkeys(
- "shift+s",
- () => {
+ useShortcut({
+ ...SHORTCUTS.runActions.markTaskGroupSuccess,
+ callback: () => {
setState("success");
onOpen();
},
- { enabled: isHotkeyEnabled },
- );
+ options: { enabled: isHotkeyEnabled },
+ });
const label = translate("dags:runAndTaskActions.markAs.button", {
type: translate("taskGroup_one"),
diff --git
a/airflow-core/src/airflow/ui/src/components/MarkAs/TaskInstance/MarkTaskInstanceAsButton.tsx
b/airflow-core/src/airflow/ui/src/components/MarkAs/TaskInstance/MarkTaskInstanceAsButton.tsx
index 20680890aa7..5c235474ac8 100644
---
a/airflow-core/src/airflow/ui/src/components/MarkAs/TaskInstance/MarkTaskInstanceAsButton.tsx
+++
b/airflow-core/src/airflow/ui/src/components/MarkAs/TaskInstance/MarkTaskInstanceAsButton.tsx
@@ -18,7 +18,6 @@
*/
import { Box, HStack, useDisclosure } from "@chakra-ui/react";
import { useState } from "react";
-import { useHotkeys } from "react-hotkeys-hook";
import { useTranslation } from "react-i18next";
import { FiX } from "react-icons/fi";
import { LuCheck } from "react-icons/lu";
@@ -26,6 +25,8 @@ import { LuCheck } from "react-icons/lu";
import type { TaskInstanceResponse, TaskInstanceState } from
"openapi/requests/types.gen";
import { StateBadge } from "src/components/StateBadge";
import { IconButton, Menu, Tooltip } from "src/components/ui";
+import { SHORTCUTS } from "src/context/keyboardShortcuts";
+import { useShortcut } from "src/hooks/useShortcut";
import { allowedStates } from "../utils";
import MarkTaskInstanceAsDialog from "./MarkTaskInstanceAsDialog";
@@ -41,23 +42,23 @@ const MarkTaskInstanceAsButton = ({ isHotkeyEnabled =
false, taskInstance }: Pro
const [state, setState] = useState<TaskInstanceState>("success");
- useHotkeys(
- "shift+f",
- () => {
+ useShortcut({
+ ...SHORTCUTS.runActions.markTaskFailed,
+ callback: () => {
setState("failed");
onOpen();
},
- { enabled: isHotkeyEnabled },
- );
+ options: { enabled: isHotkeyEnabled },
+ });
- useHotkeys(
- "shift+s",
- () => {
+ useShortcut({
+ ...SHORTCUTS.runActions.markTaskSuccess,
+ callback: () => {
setState("success");
onOpen();
},
- { enabled: isHotkeyEnabled },
- );
+ options: { enabled: isHotkeyEnabled },
+ });
const label = translate("dags:runAndTaskActions.markAs.button", {
type: translate("taskInstance_one"),
diff --git a/airflow-core/src/airflow/ui/src/components/SearchBar.tsx
b/airflow-core/src/airflow/ui/src/components/SearchBar.tsx
index cf51c59981a..57981808c62 100644
--- a/airflow-core/src/airflow/ui/src/components/SearchBar.tsx
+++ b/airflow-core/src/airflow/ui/src/components/SearchBar.tsx
@@ -18,12 +18,13 @@
*/
import { CloseButton, HStack, Input, InputGroup, Kbd, type InputGroupProps }
from "@chakra-ui/react";
import { useEffect, useRef, useState, type ChangeEvent } from "react";
-import { useHotkeys } from "react-hotkeys-hook";
import { useTranslation } from "react-i18next";
import { FiSearch } from "react-icons/fi";
import { useDebouncedCallback } from "use-debounce";
import { AdvancedSearchToggle, type AdvancedSearchToggleProps } from
"src/components/AdvancedSearchToggle";
+import { SHORTCUTS } from "src/context/keyboardShortcuts";
+import { useShortcut } from "src/hooks/useShortcut";
import { getMetaKey } from "src/utils";
const debounceDelay = 200;
@@ -74,13 +75,13 @@ export const SearchBar = ({
onChange("");
};
- useHotkeys(
- "mod+k",
- () => {
+ useShortcut({
+ ...SHORTCUTS.search.focusSearch,
+ callback: () => {
searchRef.current?.focus();
},
- { enabled: !hotkeyDisabled, preventDefault: true },
- );
+ options: { enabled: !hotkeyDisabled, preventDefault: true },
+ });
const inputGroup = (
<InputGroup
diff --git
a/airflow-core/src/airflow/ui/src/components/SearchDags/SearchDagsButton.tsx
b/airflow-core/src/airflow/ui/src/components/SearchDags/SearchDagsButton.tsx
index 2080ca100c7..f1f473ddca6 100644
--- a/airflow-core/src/airflow/ui/src/components/SearchDags/SearchDagsButton.tsx
+++ b/airflow-core/src/airflow/ui/src/components/SearchDags/SearchDagsButton.tsx
@@ -18,11 +18,12 @@
*/
import { Button, Box, Kbd } from "@chakra-ui/react";
import { useState } from "react";
-import { useHotkeys } from "react-hotkeys-hook";
import { useTranslation } from "react-i18next";
import { MdSearch } from "react-icons/md";
import { Dialog } from "src/components/ui";
+import { SHORTCUTS } from "src/context/keyboardShortcuts";
+import { useShortcut } from "src/hooks/useShortcut";
import { getMetaKey } from "src/utils";
import { SearchDags } from "./SearchDags";
@@ -36,14 +37,14 @@ export const SearchDagsButton = () => {
setIsOpen(false);
};
- useHotkeys(
- "mod+k",
- () => {
+ useShortcut({
+ ...SHORTCUTS.search.searchDags,
+ callback: () => {
setIsOpen(true);
},
- [isOpen],
- { preventDefault: true },
- );
+ dependencies: [isOpen],
+ options: { preventDefault: true },
+ });
return (
<Box>
diff --git
a/airflow-core/src/airflow/ui/src/context/keyboardShortcuts/Context.ts
b/airflow-core/src/airflow/ui/src/context/keyboardShortcuts/Context.ts
new file mode 100644
index 00000000000..e4320ed9c06
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/context/keyboardShortcuts/Context.ts
@@ -0,0 +1,58 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { createContext } from "react";
+
+// Display order of shortcut categories in the help modal.
+export const SHORTCUT_CATEGORIES = [
+ "global",
+ "navigation",
+ "dagView",
+ "search",
+ "filters",
+ "logs",
+ "code",
+ "runActions",
+] as const;
+
+export type ShortcutCategory = (typeof SHORTCUT_CATEGORIES)[number];
+
+export type ShortcutEntry = {
+ readonly category: ShortcutCategory;
+ readonly description: string;
+ readonly id: string;
+ readonly keys: ReadonlyArray<string>;
+};
+
+export type ShortcutRegistryContextValue = {
+ readonly register: (entry: ShortcutEntry) => void;
+ readonly shortcuts: ReadonlyArray<ShortcutEntry>;
+ readonly unregister: (id: string) => void;
+};
+
+// No-op default so components using shortcuts can render without the provider
+// (e.g. in isolated unit tests). The hotkeys still work; they just aren't
+// listed in the help dialog. The real app wraps the tree in
+// ShortcutRegistryProvider, so the dialog is populated there.
+const NOOP_REGISTRY: ShortcutRegistryContextValue = {
+ register: () => undefined,
+ shortcuts: [],
+ unregister: () => undefined,
+};
+
+export const ShortcutRegistryContext =
createContext<ShortcutRegistryContextValue>(NOOP_REGISTRY);
diff --git
a/airflow-core/src/airflow/ui/src/context/keyboardShortcuts/ShortcutRegistryProvider.tsx
b/airflow-core/src/airflow/ui/src/context/keyboardShortcuts/ShortcutRegistryProvider.tsx
new file mode 100644
index 00000000000..645a5a5da19
--- /dev/null
+++
b/airflow-core/src/airflow/ui/src/context/keyboardShortcuts/ShortcutRegistryProvider.tsx
@@ -0,0 +1,45 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { useState, type PropsWithChildren } from "react";
+
+import { ShortcutRegistryContext, type ShortcutEntry } from "./Context";
+
+/**
+ * Holds the set of keyboard shortcuts that are currently mounted and enabled.
+ *
+ * Each `useShortcut` call registers itself here on mount and removes itself on
+ * unmount, mirroring the lifecycle of the underlying `react-hotkeys-hook`
+ * binding. The help modal renders from this registry, so it always reflects
the
+ * shortcuts actually available on the current page.
+ */
+export const ShortcutRegistryProvider = ({ children }: PropsWithChildren) => {
+ const [shortcuts, setShortcuts] = useState<ReadonlyArray<ShortcutEntry>>([]);
+
+ const register = (entry: ShortcutEntry) => {
+ setShortcuts((prev) => [...prev.filter((item) => item.id !== entry.id),
entry]);
+ };
+
+ const unregister = (id: string) => {
+ setShortcuts((prev) => prev.filter((item) => item.id !== id));
+ };
+
+ const value = { register, shortcuts, unregister };
+
+ return <ShortcutRegistryContext.Provider
value={value}>{children}</ShortcutRegistryContext.Provider>;
+};
diff --git a/airflow-core/src/airflow/ui/src/context/keyboardShortcuts/index.ts
b/airflow-core/src/airflow/ui/src/context/keyboardShortcuts/index.ts
new file mode 100644
index 00000000000..b2634450013
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/context/keyboardShortcuts/index.ts
@@ -0,0 +1,22 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+export { SHORTCUT_CATEGORIES, type ShortcutCategory, type ShortcutEntry } from
"./Context";
+export { SHORTCUTS, type ShortcutDefinition } from "./shortcuts";
+export { ShortcutRegistryProvider } from "./ShortcutRegistryProvider";
+export { useShortcutRegistry } from "./useShortcutRegistry";
diff --git
a/airflow-core/src/airflow/ui/src/context/keyboardShortcuts/shortcuts.ts
b/airflow-core/src/airflow/ui/src/context/keyboardShortcuts/shortcuts.ts
new file mode 100644
index 00000000000..1271037a99f
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/context/keyboardShortcuts/shortcuts.ts
@@ -0,0 +1,179 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import type { Keys } from "react-hotkeys-hook";
+
+import type { ShortcutCategory } from "./Context";
+
+/**
+ * Static definition of a keyboard shortcut: its key combination, the category
it
+ * belongs to in the help modal, and the `common` i18n key for its description.
+ *
+ * The runtime callback and any dynamic options (e.g. `enabled`) stay at the
call
+ * site, since they depend on component state.
+ */
+export type ShortcutDefinition = {
+ readonly category: ShortcutCategory;
+ readonly descriptionKey: string;
+ readonly keys: Keys;
+};
+
+// Every category must be present, and each entry's `category` field must match
+// the group it sits in — both are enforced at compile time.
+type CategorizedShortcuts = {
+ readonly [Category in ShortcutCategory]: Record<
+ string,
+ { readonly category: Category } & ShortcutDefinition
+ >;
+};
+
+/**
+ * Single source of truth for every keyboard shortcut in the UI, grouped by the
+ * category shown in the help modal.
+ *
+ * Each `useShortcut` call spreads one of these entries (e.g.
+ * `...SHORTCUTS.logs.toggleWrap`) instead of redeclaring its keys, category
and
+ * description inline, so maintainers can see every shortcut that already
exists
+ * in one place and avoid clashing key bindings. Description text lives in
+ * `common.json` under the referenced `descriptionKey`.
+ */
+export const SHORTCUTS = {
+ code: {
+ toggleFullscreen: {
+ category: "code",
+ descriptionKey: "shortcuts.descriptions.toggleFullscreen",
+ keys: "f",
+ },
+ toggleWrap: { category: "code", descriptionKey:
"shortcuts.descriptions.toggleWrap", keys: "w" },
+ },
+ dagView: {
+ toggleGraphGrid: {
+ category: "dagView",
+ descriptionKey: "shortcuts.descriptions.toggleGraphGrid",
+ keys: "g",
+ },
+ },
+ filters: {
+ openGraphFilters: {
+ category: "filters",
+ descriptionKey: "shortcuts.descriptions.openGraphFilters",
+ keys: "mod+shift+f",
+ },
+ },
+ global: {
+ showHelp: {
+ category: "global",
+ descriptionKey: "shortcuts.descriptions.showHelp",
+ keys: "shift+Slash",
+ },
+ },
+ logs: {
+ downloadLogs: { category: "logs", descriptionKey:
"shortcuts.descriptions.downloadLogs", keys: "d" },
+ focusLogSearch: {
+ category: "logs",
+ descriptionKey: "shortcuts.descriptions.focusLogSearch",
+ keys: "/",
+ },
+ scrollBottom: {
+ category: "logs",
+ descriptionKey: "shortcuts.descriptions.scrollBottom",
+ keys: "mod+ArrowDown",
+ },
+ scrollTop: { category: "logs", descriptionKey:
"shortcuts.descriptions.scrollTop", keys: "mod+ArrowUp" },
+ toggleExpand: { category: "logs", descriptionKey:
"shortcuts.descriptions.toggleExpand", keys: "e" },
+ toggleFullscreen: {
+ category: "logs",
+ descriptionKey: "shortcuts.descriptions.toggleFullscreen",
+ keys: "f",
+ },
+ toggleSource: { category: "logs", descriptionKey:
"shortcuts.descriptions.toggleSource", keys: "s" },
+ toggleTimestamp: {
+ category: "logs",
+ descriptionKey: "shortcuts.descriptions.toggleTimestamp",
+ keys: "t",
+ },
+ toggleWrap: { category: "logs", descriptionKey:
"shortcuts.descriptions.toggleWrap", keys: "w" },
+ },
+ navigation: {
+ navigateTasks: {
+ category: "navigation",
+ descriptionKey: "shortcuts.descriptions.navigateTasks",
+ keys: ["shift+ArrowDown", "shift+ArrowUp", "shift+ArrowLeft",
"shift+ArrowRight"],
+ },
+ toggleTaskGroup: {
+ category: "navigation",
+ descriptionKey: "shortcuts.descriptions.toggleTaskGroup",
+ keys: "space",
+ },
+ },
+ runActions: {
+ clearRun: {
+ category: "runActions",
+ descriptionKey: "shortcuts.descriptions.clearRun",
+ keys: "shift+c",
+ },
+ clearTaskInstance: {
+ category: "runActions",
+ descriptionKey: "shortcuts.descriptions.clearTaskInstance",
+ keys: "shift+c",
+ },
+ markRunFailed: {
+ category: "runActions",
+ descriptionKey: "shortcuts.descriptions.markRunFailed",
+ keys: "shift+f",
+ },
+ markRunSuccess: {
+ category: "runActions",
+ descriptionKey: "shortcuts.descriptions.markRunSuccess",
+ keys: "shift+s",
+ },
+ markTaskFailed: {
+ category: "runActions",
+ descriptionKey: "shortcuts.descriptions.markTaskFailed",
+ keys: "shift+f",
+ },
+ markTaskGroupFailed: {
+ category: "runActions",
+ descriptionKey: "shortcuts.descriptions.markTaskGroupFailed",
+ keys: "shift+f",
+ },
+ markTaskGroupSuccess: {
+ category: "runActions",
+ descriptionKey: "shortcuts.descriptions.markTaskGroupSuccess",
+ keys: "shift+s",
+ },
+ markTaskSuccess: {
+ category: "runActions",
+ descriptionKey: "shortcuts.descriptions.markTaskSuccess",
+ keys: "shift+s",
+ },
+ },
+ search: {
+ focusFilterSearch: {
+ category: "search",
+ descriptionKey: "shortcuts.descriptions.focusFilterSearch",
+ keys: "mod+k",
+ },
+ focusSearch: {
+ category: "search",
+ descriptionKey: "shortcuts.descriptions.focusSearch",
+ keys: "mod+k",
+ },
+ searchDags: { category: "search", descriptionKey:
"shortcuts.descriptions.searchDags", keys: "mod+k" },
+ },
+} satisfies CategorizedShortcuts;
diff --git
a/airflow-core/src/airflow/ui/src/context/keyboardShortcuts/useShortcutRegistry.ts
b/airflow-core/src/airflow/ui/src/context/keyboardShortcuts/useShortcutRegistry.ts
new file mode 100644
index 00000000000..ff35397696a
--- /dev/null
+++
b/airflow-core/src/airflow/ui/src/context/keyboardShortcuts/useShortcutRegistry.ts
@@ -0,0 +1,23 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { useContext } from "react";
+
+import { ShortcutRegistryContext, type ShortcutRegistryContextValue } from
"./Context";
+
+export const useShortcutRegistry = (): ShortcutRegistryContextValue =>
useContext(ShortcutRegistryContext);
diff --git
a/airflow-core/src/airflow/ui/src/hooks/navigation/useKeyboardNavigation.ts
b/airflow-core/src/airflow/ui/src/hooks/navigation/useKeyboardNavigation.ts
index 5a99d9cc8f8..cfeb0976c3c 100644
--- a/airflow-core/src/airflow/ui/src/hooks/navigation/useKeyboardNavigation.ts
+++ b/airflow-core/src/airflow/ui/src/hooks/navigation/useKeyboardNavigation.ts
@@ -16,12 +16,11 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { useHotkeys } from "react-hotkeys-hook";
+import { SHORTCUTS } from "src/context/keyboardShortcuts";
+import { useShortcut } from "src/hooks/useShortcut";
import type { ArrowKey, NavigationDirection } from "./types";
-const ARROW_KEYS = ["shift+ArrowDown", "shift+ArrowUp", "shift+ArrowLeft",
"shift+ArrowRight"] as const;
-
type Props = {
enabled?: boolean;
onNavigate: (direction: NavigationDirection) => void;
@@ -55,7 +54,17 @@ export const useKeyboardNavigation = ({ enabled = true,
onNavigate, onToggleGrou
const hotkeyOptions = { enabled, preventDefault: true };
- useHotkeys(ARROW_KEYS.join(","), handleNormalKeyPress, hotkeyOptions,
[onNavigate]);
+ useShortcut({
+ ...SHORTCUTS.navigation.navigateTasks,
+ callback: handleNormalKeyPress,
+ dependencies: [onNavigate],
+ options: hotkeyOptions,
+ });
- useHotkeys("space", () => onToggleGroup?.(), hotkeyOptions, [onToggleGroup]);
+ useShortcut({
+ ...SHORTCUTS.navigation.toggleTaskGroup,
+ callback: () => onToggleGroup?.(),
+ dependencies: [onToggleGroup],
+ options: hotkeyOptions,
+ });
};
diff --git a/airflow-core/src/airflow/ui/src/hooks/useShortcut.test.tsx
b/airflow-core/src/airflow/ui/src/hooks/useShortcut.test.tsx
new file mode 100644
index 00000000000..45e1f5bf6a8
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/hooks/useShortcut.test.tsx
@@ -0,0 +1,111 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { renderHook } from "@testing-library/react";
+import type { PropsWithChildren } from "react";
+import { describe, expect, it, vi } from "vitest";
+
+import {
+ ShortcutRegistryProvider,
+ type ShortcutEntry,
+ useShortcutRegistry,
+} from "src/context/keyboardShortcuts";
+
+import { useShortcut } from "./useShortcut";
+
+const mockTranslate = vi.fn((key: string) => {
+ const translations: Record<string, string> = {
+ "shortcuts.descriptions.toggleWrap": "Toggle Wrap",
+ };
+
+ return translations[key] ?? key;
+});
+
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({
+ i18n: { language: "en" },
+ // eslint-disable-next-line id-length
+ t: mockTranslate,
+ }),
+}));
+
+const wrapper = ({ children }: PropsWithChildren) => (
+ <ShortcutRegistryProvider>{children}</ShortcutRegistryProvider>
+);
+
+const renderShortcut = (initialEnabled = true) =>
+ renderHook(
+ ({ enabled }: { enabled: boolean }): ReadonlyArray<ShortcutEntry> => {
+ useShortcut({
+ callback: vi.fn(),
+ category: "logs",
+ descriptionKey: "shortcuts.descriptions.toggleWrap",
+ keys: "w",
+ options: { enabled },
+ });
+
+ return useShortcutRegistry().shortcuts;
+ },
+ { initialProps: { enabled: initialEnabled }, wrapper },
+ );
+
+describe("useShortcut", () => {
+ it("registers the shortcut while mounted", () => {
+ const { result } = renderShortcut();
+
+ expect(result.current).toHaveLength(1);
+ expect(result.current[0]).toMatchObject({
+ category: "logs",
+ description: "Toggle Wrap",
+ keys: ["w"],
+ });
+ });
+
+ it("removes the shortcut when it becomes disabled", () => {
+ const { rerender, result } = renderShortcut(true);
+
+ expect(result.current).toHaveLength(1);
+ rerender({ enabled: false });
+ expect(result.current).toHaveLength(0);
+ });
+
+ it("does not register a disabled shortcut", () => {
+ const { result } = renderShortcut(false);
+
+ expect(result.current).toHaveLength(0);
+ });
+
+ it("registers each combo of a multi-key shortcut for display", () => {
+ const { result } = renderHook(
+ (): ReadonlyArray<ShortcutEntry> => {
+ useShortcut({
+ callback: vi.fn(),
+ category: "navigation",
+ descriptionKey: "shortcuts.descriptions.navigateTasks",
+ keys: ["shift+ArrowUp", "shift+ArrowDown"],
+ });
+
+ return useShortcutRegistry().shortcuts;
+ },
+ { wrapper },
+ );
+
+ expect(result.current).toHaveLength(1);
+ expect(result.current[0]?.keys).toEqual(["shift+ArrowUp",
"shift+ArrowDown"]);
+ });
+});
diff --git a/airflow-core/src/airflow/ui/src/hooks/useShortcut.ts
b/airflow-core/src/airflow/ui/src/hooks/useShortcut.ts
new file mode 100644
index 00000000000..548389657d5
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/hooks/useShortcut.ts
@@ -0,0 +1,76 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { type DependencyList, useEffect, useId } from "react";
+import { type HotkeyCallback, type Options, useHotkeys } from
"react-hotkeys-hook";
+import { useTranslation } from "react-i18next";
+
+import { type ShortcutDefinition, useShortcutRegistry } from
"src/context/keyboardShortcuts";
+
+type UseShortcutParams = {
+ readonly callback: HotkeyCallback;
+ readonly dependencies?: DependencyList;
+ readonly options?: Options;
+} & ShortcutDefinition;
+
+/**
+ * Thin wrapper around `react-hotkeys-hook`'s `useHotkeys` that also publishes
the
+ * shortcut to the keyboard-shortcut registry so it shows up in the help modal.
+ *
+ * The static `category`, `descriptionKey` and `keys` come from a `SHORTCUTS`
+ * definition (spread in at the call site); `callback`, `options` and
+ * `dependencies` stay at the call site since they depend on component state.
The
+ * shortcut is registered only while it is enabled, matching when the hotkey is
+ * actually bound.
+ */
+export const useShortcut = ({
+ callback,
+ category,
+ dependencies,
+ descriptionKey,
+ keys,
+ options,
+}: UseShortcutParams) => {
+ const id = useId();
+ const { t: translate } = useTranslation("common");
+ const { register, unregister } = useShortcutRegistry();
+
+ const ref = useHotkeys(keys, callback, options, dependencies);
+
+ const description = translate(descriptionKey);
+
+ const keyList: Array<string> = typeof keys === "string" ? [keys] : [...keys];
+ // A `false` literal means the hotkey is not bound; a function trigger is
evaluated
+ // per event but the binding still exists, so we treat it as enabled here.
+ const isEnabled = options?.enabled !== false;
+ const keyListId = keyList.join(",");
+
+ useEffect(() => {
+ if (!isEnabled) {
+ return undefined;
+ }
+
+ register({ category, description, id, keys: keyList });
+
+ return () => unregister(id);
+ // `keyListId` stands in for `keyList`'s identity to avoid re-running on
each render.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [category, description, id, isEnabled, keyListId, register, unregister]);
+
+ return ref;
+};
diff --git a/airflow-core/src/airflow/ui/src/layouts/BaseLayout.tsx
b/airflow-core/src/airflow/ui/src/layouts/BaseLayout.tsx
index e06c92bc0e5..5664e702a33 100644
--- a/airflow-core/src/airflow/ui/src/layouts/BaseLayout.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/BaseLayout.tsx
@@ -23,6 +23,7 @@ import { Outlet } from "react-router-dom";
import { usePluginServiceGetPlugins } from "openapi/queries";
import type { ReactAppResponse } from "openapi/requests/types.gen";
+import { KeyboardShortcutsModal } from "src/components/KeyboardShortcuts";
import { ReactPlugin } from "src/pages/ReactPlugin";
import { useConfig } from "src/queries/useConfig";
import { DocumentTitleProvider } from "src/utils";
@@ -101,6 +102,7 @@ export const BaseLayout = ({ children }: PropsWithChildren)
=> {
<LocaleProvider locale={i18n.language || "en"}>
<DocumentTitleProvider>
<Box display="flex" flexDirection="column" h="100vh">
+ <KeyboardShortcutsModal />
<Nav />
<Box
_ltr={{ ml: 16 }}
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/PanelButtons.tsx
b/airflow-core/src/airflow/ui/src/layouts/Details/PanelButtons.tsx
index 2d550c20941..5c34dde8bd3 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/PanelButtons.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/PanelButtons.tsx
@@ -29,7 +29,6 @@ import {
} from "@chakra-ui/react";
import { useReactFlow } from "@xyflow/react";
import { useEffect, useRef } from "react";
-import { useHotkeys } from "react-hotkeys-hook";
import { useTranslation } from "react-i18next";
import { FiGrid } from "react-icons/fi";
import { LuChartGantt, LuKeyboard } from "react-icons/lu";
@@ -46,6 +45,8 @@ import { type ButtonGroupOption, ButtonGroupToggle } from
"src/components/ui/But
import type { DagView } from "src/constants/dagView";
import { dependenciesKey } from "src/constants/localStorage";
import type { VersionIndicatorOptions } from
"src/constants/showVersionIndicatorOptions";
+import { SHORTCUTS } from "src/context/keyboardShortcuts";
+import { useShortcut } from "src/hooks/useShortcut";
import { useContainerWidth } from "src/utils/useContainerWidth";
import { DagRunSelect } from "./DagRunSelect";
@@ -187,17 +188,17 @@ export const PanelButtons = ({
}
};
- useHotkeys(
- "g",
- () => {
+ useShortcut({
+ ...SHORTCUTS.dagView.toggleGraphGrid,
+ callback: () => {
const newView = dagView === "graph" ? "grid" : "graph";
setDagView(newView);
handleFocus(newView);
},
- [dagView],
- { preventDefault: true },
- );
+ dependencies: [dagView],
+ options: { preventDefault: true },
+ });
return (
<Box bg="bg" pr={4} ref={containerRef} width="100%" zIndex={1}>
@@ -321,17 +322,6 @@ export const PanelButtons = ({
<GridFilters />
<Flex color="fg.muted" gap={2} justifyContent="flex-end" mt={1}>
<RunTypeLegend />
- <Tooltip
- content={
- <Box>
- <Text>{translate("dag:navigation.navigation", { arrow:
"↑↓←→" })}</Text>
- <Text>{translate("dag:navigation.toggleGroup")}</Text>
- </Box>
- }
- portalled
- >
- <LuKeyboard />
- </Tooltip>
</Flex>
</Flex>
)}
diff --git a/airflow-core/src/airflow/ui/src/main.tsx
b/airflow-core/src/airflow/ui/src/main.tsx
index ef24cb75b29..e1ef1be5257 100644
--- a/airflow-core/src/airflow/ui/src/main.tsx
+++ b/airflow-core/src/airflow/ui/src/main.tsx
@@ -31,6 +31,7 @@ import * as ReactJSXRuntime from "react/jsx-runtime";
import type { HTTPExceptionResponse } from "openapi/requests/types.gen";
import { ChakraCustomProvider } from "src/context/ChakraCustomProvider";
import { ColorModeProvider } from "src/context/colorMode";
+import { ShortcutRegistryProvider } from "src/context/keyboardShortcuts";
import { TimezoneProvider } from "src/context/timezone";
import { router } from "src/router";
import { getNextHref, getRedirectPath } from "src/utils/links.ts";
@@ -101,7 +102,9 @@ createRoot(document.querySelector("#root") as
HTMLDivElement).render(
<ChakraCustomProvider>
<ColorModeProvider>
<TimezoneProvider>
- <RouterProvider router={router} />
+ <ShortcutRegistryProvider>
+ <RouterProvider router={router} />
+ </ShortcutRegistryProvider>
</TimezoneProvider>
</ColorModeProvider>
</ChakraCustomProvider>
diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Code/Code.tsx
b/airflow-core/src/airflow/ui/src/pages/Dag/Code/Code.tsx
index 65622d58239..66988187031 100644
--- a/airflow-core/src/airflow/ui/src/pages/Dag/Code/Code.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/Code/Code.tsx
@@ -18,7 +18,6 @@
*/
import { Box, Button, Heading, HStack, Link, VStack } from "@chakra-ui/react";
import { useState } from "react";
-import { useHotkeys } from "react-hotkeys-hook";
import { useTranslation } from "react-i18next";
import { MdOutlineDifference, MdOutlineOpenInFull, MdWrapText } from
"react-icons/md";
import { useParams } from "react-router-dom";
@@ -38,7 +37,9 @@ import Time from "src/components/Time";
import { Dialog, IconButton, ProgressBar } from "src/components/ui";
import { LazyClipboard } from "src/components/ui/LazyClipboard";
import { useMonacoTheme } from "src/context/colorMode";
+import { SHORTCUTS } from "src/context/keyboardShortcuts";
import useSelectedVersion from "src/hooks/useSelectedVersion";
+import { useShortcut } from "src/hooks/useShortcut";
import { useConfig } from "src/queries/useConfig";
import { renderDuration } from "src/utils";
@@ -118,8 +119,15 @@ export const Code = () => {
const { beforeMount, theme } = useMonacoTheme();
- useHotkeys("w", toggleWrap);
- useHotkeys("f", toggleFullscreen);
+ useShortcut({
+ ...SHORTCUTS.code.toggleFullscreen,
+ callback: toggleFullscreen,
+ });
+
+ useShortcut({
+ ...SHORTCUTS.code.toggleWrap,
+ callback: toggleWrap,
+ });
const editorOptions: EditorProps["options"] = {
automaticLayout: true,
diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Overview/FailedLogs.tsx
b/airflow-core/src/airflow/ui/src/pages/Dag/Overview/FailedLogs.tsx
index 9d3bde42a6a..fa05f2720ef 100644
--- a/airflow-core/src/airflow/ui/src/pages/Dag/Overview/FailedLogs.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/Overview/FailedLogs.tsx
@@ -18,11 +18,12 @@
*/
import { Flex, Heading, Button } from "@chakra-ui/react";
import { useState } from "react";
-import { useHotkeys } from "react-hotkeys-hook";
import { useTranslation } from "react-i18next";
import type { TaskInstanceCollectionResponse } from
"openapi/requests/types.gen";
import { Tooltip } from "src/components/ui";
+import { SHORTCUTS } from "src/context/keyboardShortcuts";
+import { useShortcut } from "src/hooks/useShortcut";
import { useConfig } from "src/queries/useConfig";
import { TaskLogPreview } from "./TaskLogPreview";
@@ -40,7 +41,10 @@ const FailedLogs = ({
const toggleWrap = () => setWrap(!wrap);
- useHotkeys("w", toggleWrap);
+ useShortcut({
+ ...SHORTCUTS.logs.toggleWrap,
+ callback: toggleWrap,
+ });
if (taskLogs === undefined || taskLogs.length <= 0) {
return undefined;
diff --git
a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/LogSearchInput.tsx
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/LogSearchInput.tsx
index 75224efc79a..40f9933f48d 100644
--- a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/LogSearchInput.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/LogSearchInput.tsx
@@ -18,11 +18,12 @@
*/
import { CloseButton, HStack, Input, InputGroup, Text } from
"@chakra-ui/react";
import { useRef, type KeyboardEvent } from "react";
-import { useHotkeys } from "react-hotkeys-hook";
import { useTranslation } from "react-i18next";
import { FiChevronDown, FiChevronUp, FiSearch } from "react-icons/fi";
import { IconButton } from "src/components/ui";
+import { SHORTCUTS } from "src/context/keyboardShortcuts";
+import { useShortcut } from "src/hooks/useShortcut";
export type LogSearchInputProps = {
readonly currentMatchIndex: number;
@@ -44,14 +45,14 @@ export const LogSearchInput = ({
const { t: translate } = useTranslation("dag");
const searchInputRef = useRef<HTMLInputElement>(null);
- useHotkeys(
- "/",
- (event) => {
+ useShortcut({
+ ...SHORTCUTS.logs.focusLogSearch,
+ callback: (event) => {
event.preventDefault();
searchInputRef.current?.focus();
},
- { enableOnFormTags: false },
- );
+ options: { enableOnFormTags: false },
+ });
const handleSearchKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter") {
diff --git a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/Logs.tsx
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/Logs.tsx
index b9a2d61f08c..fc52982e803 100644
--- a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/Logs.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/Logs.tsx
@@ -18,7 +18,6 @@
*/
import { Box, Heading } from "@chakra-ui/react";
import { useState } from "react";
-import { useHotkeys } from "react-hotkeys-hook";
import { useTranslation } from "react-i18next";
import { useParams, useSearchParams } from "react-router-dom";
import { useLocalStorage } from "usehooks-ts";
@@ -27,6 +26,8 @@ import { useTaskInstanceServiceGetMappedTaskInstance } from
"openapi/queries";
import { Dialog } from "src/components/ui";
import { LOG_SHOW_SOURCE_KEY, LOG_SHOW_TIMESTAMP_KEY, LOG_WRAP_KEY } from
"src/constants/localStorage";
import { SearchParamsKeys } from "src/constants/searchParams";
+import { SHORTCUTS } from "src/context/keyboardShortcuts";
+import { useShortcut } from "src/hooks/useShortcut";
import { useConfig } from "src/queries/useConfig";
import { useLogs } from "src/queries/useLogs";
@@ -160,12 +161,30 @@ export const Logs = () => {
const toggleFullscreen = () => setFullscreen(!fullscreen);
const toggleExpanded = () => setExpanded((act) => !act);
- useHotkeys("w", toggleWrap);
- useHotkeys("f", toggleFullscreen);
- useHotkeys("e", toggleExpanded);
- useHotkeys("t", toggleTimestamp);
- useHotkeys("s", toggleSource);
- useHotkeys("d", downloadLogs);
+ useShortcut({
+ ...SHORTCUTS.logs.toggleWrap,
+ callback: toggleWrap,
+ });
+ useShortcut({
+ ...SHORTCUTS.logs.toggleFullscreen,
+ callback: toggleFullscreen,
+ });
+ useShortcut({
+ ...SHORTCUTS.logs.toggleExpand,
+ callback: toggleExpanded,
+ });
+ useShortcut({
+ ...SHORTCUTS.logs.toggleTimestamp,
+ callback: toggleTimestamp,
+ });
+ useShortcut({
+ ...SHORTCUTS.logs.toggleSource,
+ callback: toggleSource,
+ });
+ useShortcut({
+ ...SHORTCUTS.logs.downloadLogs,
+ callback: downloadLogs,
+ });
const onOpenChange = () => {
setFullscreen(false);
diff --git
a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/TaskLogContent.tsx
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/TaskLogContent.tsx
index 21583e19764..62fa6b9bfc2 100644
--- a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/TaskLogContent.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/TaskLogContent.tsx
@@ -19,10 +19,11 @@
import { Box, Code, VStack } from "@chakra-ui/react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { useLayoutEffect, useRef, useCallback, useEffect } from "react";
-import { useHotkeys } from "react-hotkeys-hook";
import { ErrorAlert } from "src/components/ErrorAlert";
import { ProgressBar } from "src/components/ui";
+import { SHORTCUTS } from "src/context/keyboardShortcuts";
+import { useShortcut } from "src/hooks/useShortcut";
import type { ParsedLogEntry } from "src/queries/useLogs";
import { HighlightedText } from "./HighlightedText";
@@ -148,8 +149,16 @@ export const TaskLogContent = ({
}
};
- useHotkeys("mod+ArrowDown", () => handleScrollTo("bottom"), { enabled:
!isLoading });
- useHotkeys("mod+ArrowUp", () => handleScrollTo("top"), { enabled: !isLoading
});
+ useShortcut({
+ ...SHORTCUTS.logs.scrollBottom,
+ callback: () => handleScrollTo("bottom"),
+ options: { enabled: !isLoading },
+ });
+ useShortcut({
+ ...SHORTCUTS.logs.scrollTop,
+ callback: () => handleScrollTo("top"),
+ options: { enabled: !isLoading },
+ });
return (
<Box display="flex" flexDirection="column" flexGrow={1} h="100%"
minHeight={0} position="relative">