fix: improve keyboard shortcuts (#5601)

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
Co-authored-by: James George <25279263+jamesgeorge007@users.noreply.github.com>
This commit is contained in:
Chhavi Goyal 2025-12-12 03:53:59 -05:00 committed by GitHub
parent 65ee147681
commit 973572d060
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 260 additions and 17 deletions

View file

@ -1370,7 +1370,11 @@
"command_menu": "Search & command menu",
"help_menu": "Help menu",
"show_all": "Keyboard shortcuts",
"title": "General"
"title": "General",
"comment_uncomment": "Comment/Uncomment",
"close_tab": "Close Tab",
"undo": "Undo",
"redo": "Redo"
},
"miscellaneous": {
"invite": "Invite people to Hoppscotch",

View file

@ -99,6 +99,12 @@ const ensureCompilerOptions = (() => {
command: null,
})
// Add Cmd+Y redo keybinding for Monaco
monaco.editor.addKeybindingRule({
keybinding: monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyY,
command: "redo",
})
applied = true
}
})()

View file

@ -1,5 +1,10 @@
<template>
<HoppSmartSlideOver :show="show" :title="t('app.shortcuts')" @close="close()">
<HoppSmartSlideOver
:show="show"
:title="t('app.shortcuts')"
data-shortcuts-flyout
@close="close()"
>
<template #content>
<div
class="sticky top-0 z-10 flex flex-shrink-0 flex-col overflow-x-auto bg-primary"

View file

@ -19,7 +19,12 @@ import {
StreamLanguage,
syntaxHighlighting,
} from "@codemirror/language"
import { defaultKeymap, indentLess, insertTab } from "@codemirror/commands"
import {
defaultKeymap,
indentLess,
insertTab,
redo,
} from "@codemirror/commands"
import { Completion, autocompletion } from "@codemirror/autocomplete"
import { linter } from "@codemirror/lint"
import { watch, ref, Ref, onMounted, onBeforeUnmount } from "vue"
@ -53,7 +58,11 @@ import {
import { HoppEnvironmentPlugin } from "@helpers/editor/extensions/HoppEnvironment"
import xmlFormat from "xml-formatter"
import { platform } from "~/platform"
import { invokeAction } from "~/helpers/actions"
import {
invokeAction,
registerCodeMirrorView,
unregisterCodeMirrorView,
} from "~/helpers/actions"
import { useDebounceFn } from "@vueuse/core"
// TODO: Migrate from legacy mode
@ -165,6 +174,13 @@ const hoppLang = (
exts.push(hoppLinterExt(linter))
if (completer) exts.push(hoppCompleterExt(completer))
// Add comment token configuration for JSONC to enable comment toggle
if (language === jsoncLanguage) {
exts.push(
EditorState.languageData.of(() => [{ commentTokens: { line: "//" } }])
)
}
return language ? new LanguageSupport(language, exts) : exts
}
@ -405,7 +421,10 @@ export function useCodemirror(
.toJSON()
.join(update.state.lineBreak)
if (!options.extendedEditorConfig.readOnly) {
value.value = cachedValue.value
// Only update if the value is actually different to prevent circular updates
if (value.value !== cachedValue.value) {
value.value = cachedValue.value
}
if (options.onChange) {
options.onChange(cachedValue.value)
}
@ -462,6 +481,16 @@ export function useCodemirror(
run: indentLess,
},
]),
Prec.highest(
keymap.of([
{
key: "Ctrl-y",
mac: "Cmd-y",
preventDefault: true,
run: redo,
},
])
),
Prec.highest(
keymap.of([
{
@ -496,6 +525,9 @@ export function useCodemirror(
scrollTo: EditorView.scrollIntoView(0),
})
// Register the view for global access
registerCodeMirrorView(view.value.dom, view.value)
options.onInit?.(view.value)
}
@ -507,10 +539,16 @@ export function useCodemirror(
watch(el, () => {
if (el.value) {
if (view.value) view.value.destroy()
if (view.value) {
unregisterCodeMirrorView(view.value.dom)
view.value.destroy()
}
initView(el.value)
} else {
view.value?.destroy()
if (view.value) {
unregisterCodeMirrorView(view.value.dom)
view.value.destroy()
}
view.value = undefined
}
})
@ -521,7 +559,10 @@ export function useCodemirror(
watch(value, (newVal) => {
if (newVal === undefined) {
view.value?.destroy()
if (view.value) {
unregisterCodeMirrorView(view.value.dom)
view.value.destroy()
}
view.value = undefined
return
}

View file

@ -11,6 +11,37 @@ import { HoppGQLSaveContext } from "./graphql/document"
import { GQLOptionTabs } from "~/components/graphql/RequestOptions.vue"
import { getKernelMode } from "@hoppscotch/kernel"
import { invoke } from "@tauri-apps/api/core"
import { undo, redo, toggleComment } from "@codemirror/commands"
import { EditorView } from "@codemirror/view"
import { isCodeMirrorEditor } from "./utils/dom"
// Global registry for CodeMirror views
const codeMirrorViews = new WeakMap<Element, EditorView>()
/**
* Register a CodeMirror view with its DOM element
*/
export function registerCodeMirrorView(element: Element, view: EditorView) {
codeMirrorViews.set(element, view)
}
/**
* Unregister a CodeMirror view
*/
export function unregisterCodeMirrorView(element: Element) {
codeMirrorViews.delete(element)
}
/**
* Get the CodeMirror EditorView instance from a DOM element
*/
function getCodeMirrorView(element: Element): EditorView | null {
const editorElement = element.closest(".cm-editor")
if (editorElement) {
return codeMirrorViews.get(editorElement) || null
}
return null
}
export type HoppAction =
| "contextmenu.open" // Send/Cancel a Hoppscotch Request
@ -79,6 +110,9 @@ export type HoppAction =
| "user.login" // Login to Hoppscotch
| "user.logout" // Log out of Hoppscotch
| "editor.format" // Format editor content
| "editor.undo" // Undo editor content
| "editor.redo" // Redo editor content
| "editor.comment-toggle" // Toggle comment in editor
| "modals.team.delete" // Delete team
| "workspace.switch" // Switch workspace
| "rest.request.open" // Open REST request
@ -355,4 +389,35 @@ function setupCoreActionHandlers() {
})
}
// Editor action handlers
bindAction("editor.undo", () => {
const activeElement = document.activeElement
if (activeElement && isCodeMirrorEditor(activeElement)) {
const editorView = getCodeMirrorView(activeElement)
if (editorView) {
undo(editorView)
}
}
})
bindAction("editor.redo", () => {
const activeElement = document.activeElement
if (activeElement && isCodeMirrorEditor(activeElement)) {
const editorView = getCodeMirrorView(activeElement)
if (editorView) {
redo(editorView)
}
}
})
bindAction("editor.comment-toggle", () => {
const activeElement = document.activeElement
if (activeElement && isCodeMirrorEditor(activeElement)) {
const editorView = getCodeMirrorView(activeElement)
if (editorView) {
toggleComment(editorView)
}
}
})
setupCoreActionHandlers()

View file

@ -1,7 +1,13 @@
import { onBeforeUnmount, onMounted } from "vue"
import { HoppActionWithOptionalArgs, invokeAction } from "./actions"
import { isAppleDevice } from "./platformutils"
import { isCodeMirrorEditor, isDOMElement, isTypableElement } from "./utils/dom"
import {
isCodeMirrorEditor,
isDOMElement,
isInShortcutsFlyout,
isMonacoEditor,
isTypableElement,
} from "./utils/dom"
import { getKernelMode } from "@hoppscotch/kernel"
import { listen } from "@tauri-apps/api/event"
@ -63,8 +69,9 @@ const baseBindings: {
"alt-u": "request.method.put",
"alt-x": "request.method.delete",
"ctrl-k": "modals.search.toggle",
"ctrl-/": "flyouts.keybinds.toggle",
"ctrl-/": "editor.comment-toggle",
"shift-/": "modals.support.toggle",
"ctrl-shift-/": "flyouts.keybinds.toggle",
"ctrl-m": "modals.share.toggle",
"alt-r": "navigation.jump.rest",
"alt-q": "navigation.jump.graphql",
@ -77,10 +84,19 @@ const baseBindings: {
"ctrl-.": "response.copy",
"ctrl-e": "response.save-as-example",
"ctrl-shift-l": "editor.format",
"ctrl-z": "editor.undo",
"ctrl-y": "editor.redo",
"ctrl-delete": "response.erase",
"ctrl-backspace": "response.erase",
}
// Web-only bindings
const webBindings: {
[_ in ShortcutKey]?: HoppActionWithOptionalArgs
} = {
"ctrl-d": "tab.close-current",
}
// Desktop-only bindings
const desktopBindings: {
[_ in ShortcutKey]?: HoppActionWithOptionalArgs
@ -107,7 +123,10 @@ function getActiveBindings(): typeof baseBindings {
}
}
return baseBindings
return {
...baseBindings,
...webBindings,
}
}
export const bindings = getActiveBindings()
@ -119,7 +138,8 @@ export const bindings = getActiveBindings()
*/
export function hookKeybindingsListener() {
onMounted(async () => {
document.addEventListener("keydown", handleKeyDown)
// Use capture phase to intercept events before browser handles them
document.addEventListener("keydown", handleKeyDown, true)
// Listen for Tauri events (desktop only)
if (getKernelMode() === "desktop") {
@ -138,7 +158,7 @@ export function hookKeybindingsListener() {
})
onBeforeUnmount(() => {
document.removeEventListener("keydown", handleKeyDown)
document.removeEventListener("keydown", handleKeyDown, true)
if (unlistenTauriEvent) {
unlistenTauriEvent()
@ -156,6 +176,57 @@ function handleKeyDown(ev: KeyboardEvent) {
const activeBindings = getActiveBindings()
const boundAction = activeBindings[binding]
// Special handling for Ctrl+D (tab close for web browsers)
if (binding === "ctrl-d" && boundAction) {
ev.preventDefault()
ev.stopPropagation()
ev.stopImmediatePropagation()
if (boundAction) {
invokeAction(boundAction, undefined, "keypress")
}
return
}
// Special handling for undo/redo - let CodeMirror and Monaco handle these in editors
if (binding === "ctrl-z" || binding === "ctrl-y") {
const target = ev.target
if (
isDOMElement(target) &&
(isCodeMirrorEditor(target) ||
isMonacoEditor(target) ||
isTypableElement(target))
) {
return
}
}
// Special handling for comment toggle - let CodeMirror and Monaco handle this in editors
if (binding === "ctrl-/") {
const target = ev.target
if (!isDOMElement(target)) return
// Let editors handle it normally
if (isCodeMirrorEditor(target) || isMonacoEditor(target)) return
// If inside shortcuts flyout, always toggle it (even if focused on search input)
// If not in editor or input, fall back to keybinds flyout
const shouldToggle =
isInShortcutsFlyout(target) || !isTypableElement(target)
if (shouldToggle) {
invokeAction("flyouts.keybinds.toggle", undefined, "keypress")
ev.preventDefault()
return
}
// If in a normal input field, let browser handle it
return
}
// If no action is bound, do nothing
if (!boundAction) return
ev.preventDefault()
@ -196,11 +267,11 @@ function generateKeybindingString(ev: KeyboardEvent): ShortcutKey | null {
return null
}
// Restrict alt+up and alt+down when the target is a codemirror editor
// Restrict alt+up and alt+down when the target is a CodeMirror or Monaco editor
if (
modifierKey === "alt" &&
(key === "up" || key === "down") &&
isCodeMirrorEditor(target)
(isCodeMirrorEditor(target) || isMonacoEditor(target))
) {
return null
}

View file

@ -33,6 +33,21 @@ export function getShortcuts(t: (x: string) => string): ShortcutDef[] {
keys: ["ESC"],
section: t("shortcut.general.title"),
},
{
label: t("shortcut.general.undo"),
keys: [getPlatformSpecialKey(), "Z"],
section: t("shortcut.general.title"),
},
{
label: t("shortcut.general.redo"),
keys: [getPlatformSpecialKey(), "Y"],
section: t("shortcut.general.title"),
},
{
label: t("shortcut.general.comment_uncomment"),
keys: [getPlatformSpecialKey(), "/"],
section: t("shortcut.general.title"),
},
// Request
{
@ -148,6 +163,15 @@ export function getShortcuts(t: (x: string) => string): ShortcutDef[] {
},
]
// Web-only shortcuts
const webShortcuts: ShortcutDef[] = [
{
label: t("shortcut.general.close_tab"),
keys: [getPlatformSpecialKey(), "D"],
section: t("shortcut.general.title"),
},
]
// Desktop-only shortcuts
const desktopShortcuts: ShortcutDef[] = [
{
@ -182,6 +206,9 @@ export function getShortcuts(t: (x: string) => string): ShortcutDef[] {
},
]
// Return base shortcuts + desktop shortcuts only if in desktop mode
return isDesktop ? [...baseShortcuts, ...desktopShortcuts] : baseShortcuts
// Return base shortcuts + platform-specific shortcuts
if (isDesktop) {
return [...baseShortcuts, ...desktopShortcuts]
}
return [...baseShortcuts, ...webShortcuts]
}

View file

@ -27,3 +27,27 @@ export function isCodeMirrorEditor(el: EventTarget | null): boolean {
if (!(el instanceof HTMLElement)) return false
return el.closest(".cm-editor") !== null
}
/**
* Checks if an element is a Monaco editor.
* @param el The element to check. If this is not an HTMLElement, the function will return false.
* @returns True if the element is a Monaco editor, false otherwise.
*/
export function isMonacoEditor(el: EventTarget | null): boolean {
if (!(el instanceof HTMLElement)) return false
return el.closest(".monaco-editor") !== null
}
/**
* Checks if an element is inside the shortcuts flyout panel.
* @param el The element to check. If this is not an HTMLElement, the function will return false.
* @returns True if the element is inside the shortcuts flyout, false otherwise.
*/
export function isInShortcutsFlyout(el: EventTarget | null): boolean {
if (!(el instanceof HTMLElement)) return false
const slideOver = el.closest("[data-shortcuts-flyout]")
if (slideOver) return true
return false
}