From 4d1e6c08382da813447f96e0ac2ebff43cfc6901 Mon Sep 17 00:00:00 2001 From: anti Date: Wed, 22 Apr 2026 17:25:32 -0400 Subject: [PATCH] feat(web): add ? cheatsheet and / focus-search hotkeys - New ShortcutsHelp modal enumerates global, nav G-chord and palette bindings; openable via ? (Shift+/) or the command palette. - / dispatches a global decnet:focus-search event; Attackers, Bounty and LiveLogs listen and focus their in-page search inputs (pages without a local search are skipped per plan). - Respects the existing editable-element guard and Alt+K palette toggle; no rebinds to prior shortcuts. --- decnet_web/src/App.tsx | 6 +- decnet_web/src/components/Attackers.tsx | 6 +- decnet_web/src/components/Bounty.tsx | 6 +- .../CommandPalette/CommandPalette.tsx | 3 +- decnet_web/src/components/LiveLogs.tsx | 4 + .../ShortcutsHelp/ShortcutsHelp.css | 59 ++++++++++++++ .../ShortcutsHelp/ShortcutsHelp.tsx | 77 +++++++++++++++++++ decnet_web/src/hooks/useFocusSearch.ts | 18 +++++ decnet_web/src/hooks/useGlobalHotkeys.ts | 24 +++++- 9 files changed, 196 insertions(+), 7 deletions(-) create mode 100644 decnet_web/src/components/ShortcutsHelp/ShortcutsHelp.css create mode 100644 decnet_web/src/components/ShortcutsHelp/ShortcutsHelp.tsx create mode 100644 decnet_web/src/hooks/useFocusSearch.ts diff --git a/decnet_web/src/App.tsx b/decnet_web/src/App.tsx index 4b145a88..371e0846 100644 --- a/decnet_web/src/App.tsx +++ b/decnet_web/src/App.tsx @@ -15,6 +15,7 @@ import AgentEnrollment from './components/AgentEnrollment'; import MazeNET from './components/MazeNET/MazeNET'; import TopologyList from './components/TopologyList/TopologyList'; import CommandPalette from './components/CommandPalette/CommandPalette'; +import ShortcutsHelp from './components/ShortcutsHelp/ShortcutsHelp'; import { ToastProvider } from './components/Toasts/ToastProvider'; import { useToast } from './components/Toasts/useToast'; import { useGlobalHotkeys } from './hooks/useGlobalHotkeys'; @@ -60,10 +61,12 @@ const AuthedShell: React.FC = ({ onLogout, onSearch, searchQue const navigate = useNavigate(); const { push } = useToast(); const [cmdOpen, setCmdOpen] = useState(false); + const [helpOpen, setHelpOpen] = useState(false); - useGlobalHotkeys({ cmdOpen, setCmdOpen }); + useGlobalHotkeys({ cmdOpen, setCmdOpen, helpOpen, setHelpOpen }); const handleAction = (id: string) => { + if (id === 'shortcuts-help') { setHelpOpen(true); return; } if (id === 'deploy') navigate('/fleet'); window.dispatchEvent(new CustomEvent('decnet:cmd', { detail: { id } })); push({ text: ACTION_LABELS[id] ?? `${id.toUpperCase()} · QUEUED`, tone: 'violet', icon: 'terminal' }); @@ -94,6 +97,7 @@ const AuthedShell: React.FC = ({ onLogout, onSearch, searchQue onNav={navigate} onAction={handleAction} /> + setHelpOpen(false)} /> ); }; diff --git a/decnet_web/src/components/Attackers.tsx b/decnet_web/src/components/Attackers.tsx index 852547fc..9e54eaed 100644 --- a/decnet_web/src/components/Attackers.tsx +++ b/decnet_web/src/components/Attackers.tsx @@ -1,8 +1,9 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { useSearchParams, useNavigate } from 'react-router-dom'; import { Search, ChevronLeft, ChevronRight, Users } from 'lucide-react'; import api from '../utils/api'; import EmptyState from './EmptyState/EmptyState'; +import { useFocusSearch } from '../hooks/useFocusSearch'; import './Dashboard.css'; import './Attackers.css'; @@ -63,6 +64,8 @@ const Attackers: React.FC = () => { const [total, setTotal] = useState(0); const [loading, setLoading] = useState(true); const [searchInput, setSearchInput] = useState(query); + const searchRef = useRef(null); + useFocusSearch(searchRef); const limit = 50; @@ -122,6 +125,7 @@ const Attackers: React.FC = () => {
{ const [total, setTotal] = useState(0); const [loading, setLoading] = useState(true); const [searchInput, setSearchInput] = useState(query); + const searchRef = useRef(null); + useFocusSearch(searchRef); const [selected, setSelected] = useState(null); const limit = 50; @@ -118,6 +121,7 @@ const Bounty: React.FC = () => {
{ const [loading, setLoading] = useState(true); const [streaming, setStreaming] = useState(isLive); const [searchInput, setSearchInput] = useState(query); + const searchRef = useRef(null); + useFocusSearch(searchRef); const [selectedHour, setSelectedHour] = useState(null); const eventSourceRef = useRef(null); @@ -200,6 +203,7 @@ const LiveLogs: React.FC = () => {
void; +} + +interface Binding { + keys: string; + label: string; +} + +const GLOBAL: Binding[] = [ + { keys: 'Alt+K', label: 'Open command palette' }, + { keys: '?', label: 'Show this cheatsheet' }, + { keys: '/', label: 'Focus page search' }, + { keys: 'Esc', label: 'Close modal / palette' }, +]; + +const NAV: Binding[] = [ + { keys: 'G D', label: 'Dashboard' }, + { keys: 'G F', label: 'Decoy Fleet' }, + { keys: 'G M', label: 'MazeNET' }, + { keys: 'G L', label: 'Live Logs' }, + { keys: 'G B', label: 'Bounty Vault' }, + { keys: 'G A', label: 'Attackers' }, + { keys: 'G S', label: 'SWARM Hosts' }, + { keys: 'G U', label: 'Remote Updates' }, + { keys: 'G E', label: 'Agent Enrollment' }, + { keys: 'G C', label: 'Config' }, +]; + +const PALETTE: Binding[] = [ + { keys: '↑ ↓', label: 'Navigate entries' }, + { keys: '⏎', label: 'Run selected entry' }, + { keys: 'Esc', label: 'Dismiss palette' }, +]; + +const Group: React.FC<{ title: string; rows: Binding[] }> = ({ title, rows }) => ( +
+

{title}

+
+ {rows.map(r => ( +
+ + {r.keys.split(' ').map((k, i) => ( + {k} + ))} + + {r.label} +
+ ))} +
+
+); + +const ShortcutsHelp: React.FC = ({ open, onClose }) => ( + +
+ + + +
+
+); + +export default ShortcutsHelp; diff --git a/decnet_web/src/hooks/useFocusSearch.ts b/decnet_web/src/hooks/useFocusSearch.ts new file mode 100644 index 00000000..5c2f1828 --- /dev/null +++ b/decnet_web/src/hooks/useFocusSearch.ts @@ -0,0 +1,18 @@ +import { useEffect, RefObject } from 'react'; + +/** + * Focus the given input when the global `decnet:focus-search` event fires + * (dispatched by the `/` hotkey in useGlobalHotkeys). + */ +export function useFocusSearch(ref: RefObject): void { + useEffect(() => { + const handler = () => { + const el = ref.current; + if (!el) return; + el.focus(); + try { el.select(); } catch { /* ignore */ } + }; + window.addEventListener('decnet:focus-search', handler); + return () => window.removeEventListener('decnet:focus-search', handler); + }, [ref]); +} diff --git a/decnet_web/src/hooks/useGlobalHotkeys.ts b/decnet_web/src/hooks/useGlobalHotkeys.ts index a2b7faec..697e4a57 100644 --- a/decnet_web/src/hooks/useGlobalHotkeys.ts +++ b/decnet_web/src/hooks/useGlobalHotkeys.ts @@ -4,6 +4,8 @@ import { useNavigate } from 'react-router-dom'; interface Options { cmdOpen: boolean; setCmdOpen: (v: boolean | ((prev: boolean) => boolean)) => void; + helpOpen: boolean; + setHelpOpen: (v: boolean | ((prev: boolean) => boolean)) => void; } const G_NAV: Record = { @@ -27,7 +29,7 @@ function isEditable(el: EventTarget | null): boolean { return tag === 'INPUT' || tag === 'TEXTAREA' || el.isContentEditable; } -export function useGlobalHotkeys({ cmdOpen, setCmdOpen }: Options): void { +export function useGlobalHotkeys({ cmdOpen, setCmdOpen, helpOpen, setHelpOpen }: Options): void { const navigate = useNavigate(); const pendingG = useRef(false); const gTimer = useRef | null>(null); @@ -52,10 +54,26 @@ export function useGlobalHotkeys({ cmdOpen, setCmdOpen }: Options): void { return; } - if (cmdOpen) return; + if (cmdOpen || helpOpen) return; if (isEditable(e.target)) return; if (e.metaKey || e.ctrlKey || e.altKey) return; + // `?` (Shift+/) — open shortcuts cheatsheet + if (e.key === '?') { + e.preventDefault(); + setHelpOpen(true); + clearG(); + return; + } + + // `/` — focus page search (page listens for the event) + if (e.key === '/') { + e.preventDefault(); + window.dispatchEvent(new CustomEvent('decnet:focus-search')); + clearG(); + return; + } + const k = e.key.toLowerCase(); if (pendingG.current && G_NAV[k]) { @@ -77,5 +95,5 @@ export function useGlobalHotkeys({ cmdOpen, setCmdOpen }: Options): void { window.removeEventListener('keydown', onKey); if (gTimer.current) clearTimeout(gTimer.current); }; - }, [cmdOpen, setCmdOpen, navigate]); + }, [cmdOpen, setCmdOpen, helpOpen, setHelpOpen, navigate]); }