Skip to content

Commit 973572d

Browse files
chhaviG22cubic-dev-ai[bot]jamesgeorge007
authored
fix: improve keyboard shortcuts (hoppscotch#5601)
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> Co-authored-by: James George <[email protected]>
1 parent 65ee147 commit 973572d

File tree

8 files changed

+260
-17
lines changed

8 files changed

+260
-17
lines changed

packages/hoppscotch-common/locales/en.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1370,7 +1370,11 @@
13701370
"command_menu": "Search & command menu",
13711371
"help_menu": "Help menu",
13721372
"show_all": "Keyboard shortcuts",
1373-
"title": "General"
1373+
"title": "General",
1374+
"comment_uncomment": "Comment/Uncomment",
1375+
"close_tab": "Close Tab",
1376+
"undo": "Undo",
1377+
"redo": "Redo"
13741378
},
13751379
"miscellaneous": {
13761380
"invite": "Invite people to Hoppscotch",

packages/hoppscotch-common/src/components/MonacoScriptEditor.vue

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,12 @@ const ensureCompilerOptions = (() => {
9999
command: null,
100100
})
101101
102+
// Add Cmd+Y redo keybinding for Monaco
103+
monaco.editor.addKeybindingRule({
104+
keybinding: monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyY,
105+
command: "redo",
106+
})
107+
102108
applied = true
103109
}
104110
})()

packages/hoppscotch-common/src/components/app/Shortcuts.vue

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
<template>
2-
<HoppSmartSlideOver :show="show" :title="t('app.shortcuts')" @close="close()">
2+
<HoppSmartSlideOver
3+
:show="show"
4+
:title="t('app.shortcuts')"
5+
data-shortcuts-flyout
6+
@close="close()"
7+
>
38
<template #content>
49
<div
510
class="sticky top-0 z-10 flex flex-shrink-0 flex-col overflow-x-auto bg-primary"

packages/hoppscotch-common/src/composables/codemirror.ts

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,12 @@ import {
1919
StreamLanguage,
2020
syntaxHighlighting,
2121
} from "@codemirror/language"
22-
import { defaultKeymap, indentLess, insertTab } from "@codemirror/commands"
22+
import {
23+
defaultKeymap,
24+
indentLess,
25+
insertTab,
26+
redo,
27+
} from "@codemirror/commands"
2328
import { Completion, autocompletion } from "@codemirror/autocomplete"
2429
import { linter } from "@codemirror/lint"
2530
import { watch, ref, Ref, onMounted, onBeforeUnmount } from "vue"
@@ -53,7 +58,11 @@ import {
5358
import { HoppEnvironmentPlugin } from "@helpers/editor/extensions/HoppEnvironment"
5459
import xmlFormat from "xml-formatter"
5560
import { platform } from "~/platform"
56-
import { invokeAction } from "~/helpers/actions"
61+
import {
62+
invokeAction,
63+
registerCodeMirrorView,
64+
unregisterCodeMirrorView,
65+
} from "~/helpers/actions"
5766
import { useDebounceFn } from "@vueuse/core"
5867
// TODO: Migrate from legacy mode
5968

@@ -165,6 +174,13 @@ const hoppLang = (
165174
exts.push(hoppLinterExt(linter))
166175
if (completer) exts.push(hoppCompleterExt(completer))
167176

177+
// Add comment token configuration for JSONC to enable comment toggle
178+
if (language === jsoncLanguage) {
179+
exts.push(
180+
EditorState.languageData.of(() => [{ commentTokens: { line: "//" } }])
181+
)
182+
}
183+
168184
return language ? new LanguageSupport(language, exts) : exts
169185
}
170186

@@ -405,7 +421,10 @@ export function useCodemirror(
405421
.toJSON()
406422
.join(update.state.lineBreak)
407423
if (!options.extendedEditorConfig.readOnly) {
408-
value.value = cachedValue.value
424+
// Only update if the value is actually different to prevent circular updates
425+
if (value.value !== cachedValue.value) {
426+
value.value = cachedValue.value
427+
}
409428
if (options.onChange) {
410429
options.onChange(cachedValue.value)
411430
}
@@ -462,6 +481,16 @@ export function useCodemirror(
462481
run: indentLess,
463482
},
464483
]),
484+
Prec.highest(
485+
keymap.of([
486+
{
487+
key: "Ctrl-y",
488+
mac: "Cmd-y",
489+
preventDefault: true,
490+
run: redo,
491+
},
492+
])
493+
),
465494
Prec.highest(
466495
keymap.of([
467496
{
@@ -496,6 +525,9 @@ export function useCodemirror(
496525
scrollTo: EditorView.scrollIntoView(0),
497526
})
498527

528+
// Register the view for global access
529+
registerCodeMirrorView(view.value.dom, view.value)
530+
499531
options.onInit?.(view.value)
500532
}
501533

@@ -507,10 +539,16 @@ export function useCodemirror(
507539

508540
watch(el, () => {
509541
if (el.value) {
510-
if (view.value) view.value.destroy()
542+
if (view.value) {
543+
unregisterCodeMirrorView(view.value.dom)
544+
view.value.destroy()
545+
}
511546
initView(el.value)
512547
} else {
513-
view.value?.destroy()
548+
if (view.value) {
549+
unregisterCodeMirrorView(view.value.dom)
550+
view.value.destroy()
551+
}
514552
view.value = undefined
515553
}
516554
})
@@ -521,7 +559,10 @@ export function useCodemirror(
521559

522560
watch(value, (newVal) => {
523561
if (newVal === undefined) {
524-
view.value?.destroy()
562+
if (view.value) {
563+
unregisterCodeMirrorView(view.value.dom)
564+
view.value.destroy()
565+
}
525566
view.value = undefined
526567
return
527568
}

packages/hoppscotch-common/src/helpers/actions.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,37 @@ import { HoppGQLSaveContext } from "./graphql/document"
1111
import { GQLOptionTabs } from "~/components/graphql/RequestOptions.vue"
1212
import { getKernelMode } from "@hoppscotch/kernel"
1313
import { invoke } from "@tauri-apps/api/core"
14+
import { undo, redo, toggleComment } from "@codemirror/commands"
15+
import { EditorView } from "@codemirror/view"
16+
import { isCodeMirrorEditor } from "./utils/dom"
17+
18+
// Global registry for CodeMirror views
19+
const codeMirrorViews = new WeakMap<Element, EditorView>()
20+
21+
/**
22+
* Register a CodeMirror view with its DOM element
23+
*/
24+
export function registerCodeMirrorView(element: Element, view: EditorView) {
25+
codeMirrorViews.set(element, view)
26+
}
27+
28+
/**
29+
* Unregister a CodeMirror view
30+
*/
31+
export function unregisterCodeMirrorView(element: Element) {
32+
codeMirrorViews.delete(element)
33+
}
34+
35+
/**
36+
* Get the CodeMirror EditorView instance from a DOM element
37+
*/
38+
function getCodeMirrorView(element: Element): EditorView | null {
39+
const editorElement = element.closest(".cm-editor")
40+
if (editorElement) {
41+
return codeMirrorViews.get(editorElement) || null
42+
}
43+
return null
44+
}
1445

1546
export type HoppAction =
1647
| "contextmenu.open" // Send/Cancel a Hoppscotch Request
@@ -79,6 +110,9 @@ export type HoppAction =
79110
| "user.login" // Login to Hoppscotch
80111
| "user.logout" // Log out of Hoppscotch
81112
| "editor.format" // Format editor content
113+
| "editor.undo" // Undo editor content
114+
| "editor.redo" // Redo editor content
115+
| "editor.comment-toggle" // Toggle comment in editor
82116
| "modals.team.delete" // Delete team
83117
| "workspace.switch" // Switch workspace
84118
| "rest.request.open" // Open REST request
@@ -355,4 +389,35 @@ function setupCoreActionHandlers() {
355389
})
356390
}
357391

392+
// Editor action handlers
393+
bindAction("editor.undo", () => {
394+
const activeElement = document.activeElement
395+
if (activeElement && isCodeMirrorEditor(activeElement)) {
396+
const editorView = getCodeMirrorView(activeElement)
397+
if (editorView) {
398+
undo(editorView)
399+
}
400+
}
401+
})
402+
403+
bindAction("editor.redo", () => {
404+
const activeElement = document.activeElement
405+
if (activeElement && isCodeMirrorEditor(activeElement)) {
406+
const editorView = getCodeMirrorView(activeElement)
407+
if (editorView) {
408+
redo(editorView)
409+
}
410+
}
411+
})
412+
413+
bindAction("editor.comment-toggle", () => {
414+
const activeElement = document.activeElement
415+
if (activeElement && isCodeMirrorEditor(activeElement)) {
416+
const editorView = getCodeMirrorView(activeElement)
417+
if (editorView) {
418+
toggleComment(editorView)
419+
}
420+
}
421+
})
422+
358423
setupCoreActionHandlers()

packages/hoppscotch-common/src/helpers/keybindings.ts

Lines changed: 78 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
import { onBeforeUnmount, onMounted } from "vue"
22
import { HoppActionWithOptionalArgs, invokeAction } from "./actions"
33
import { isAppleDevice } from "./platformutils"
4-
import { isCodeMirrorEditor, isDOMElement, isTypableElement } from "./utils/dom"
4+
import {
5+
isCodeMirrorEditor,
6+
isDOMElement,
7+
isInShortcutsFlyout,
8+
isMonacoEditor,
9+
isTypableElement,
10+
} from "./utils/dom"
511
import { getKernelMode } from "@hoppscotch/kernel"
612
import { listen } from "@tauri-apps/api/event"
713

@@ -63,8 +69,9 @@ const baseBindings: {
6369
"alt-u": "request.method.put",
6470
"alt-x": "request.method.delete",
6571
"ctrl-k": "modals.search.toggle",
66-
"ctrl-/": "flyouts.keybinds.toggle",
72+
"ctrl-/": "editor.comment-toggle",
6773
"shift-/": "modals.support.toggle",
74+
"ctrl-shift-/": "flyouts.keybinds.toggle",
6875
"ctrl-m": "modals.share.toggle",
6976
"alt-r": "navigation.jump.rest",
7077
"alt-q": "navigation.jump.graphql",
@@ -77,10 +84,19 @@ const baseBindings: {
7784
"ctrl-.": "response.copy",
7885
"ctrl-e": "response.save-as-example",
7986
"ctrl-shift-l": "editor.format",
87+
"ctrl-z": "editor.undo",
88+
"ctrl-y": "editor.redo",
8089
"ctrl-delete": "response.erase",
8190
"ctrl-backspace": "response.erase",
8291
}
8392

93+
// Web-only bindings
94+
const webBindings: {
95+
[_ in ShortcutKey]?: HoppActionWithOptionalArgs
96+
} = {
97+
"ctrl-d": "tab.close-current",
98+
}
99+
84100
// Desktop-only bindings
85101
const desktopBindings: {
86102
[_ in ShortcutKey]?: HoppActionWithOptionalArgs
@@ -107,7 +123,10 @@ function getActiveBindings(): typeof baseBindings {
107123
}
108124
}
109125

110-
return baseBindings
126+
return {
127+
...baseBindings,
128+
...webBindings,
129+
}
111130
}
112131

113132
export const bindings = getActiveBindings()
@@ -119,7 +138,8 @@ export const bindings = getActiveBindings()
119138
*/
120139
export function hookKeybindingsListener() {
121140
onMounted(async () => {
122-
document.addEventListener("keydown", handleKeyDown)
141+
// Use capture phase to intercept events before browser handles them
142+
document.addEventListener("keydown", handleKeyDown, true)
123143

124144
// Listen for Tauri events (desktop only)
125145
if (getKernelMode() === "desktop") {
@@ -138,7 +158,7 @@ export function hookKeybindingsListener() {
138158
})
139159

140160
onBeforeUnmount(() => {
141-
document.removeEventListener("keydown", handleKeyDown)
161+
document.removeEventListener("keydown", handleKeyDown, true)
142162

143163
if (unlistenTauriEvent) {
144164
unlistenTauriEvent()
@@ -156,6 +176,57 @@ function handleKeyDown(ev: KeyboardEvent) {
156176

157177
const activeBindings = getActiveBindings()
158178
const boundAction = activeBindings[binding]
179+
180+
// Special handling for Ctrl+D (tab close for web browsers)
181+
if (binding === "ctrl-d" && boundAction) {
182+
ev.preventDefault()
183+
ev.stopPropagation()
184+
ev.stopImmediatePropagation()
185+
186+
if (boundAction) {
187+
invokeAction(boundAction, undefined, "keypress")
188+
}
189+
return
190+
}
191+
192+
// Special handling for undo/redo - let CodeMirror and Monaco handle these in editors
193+
if (binding === "ctrl-z" || binding === "ctrl-y") {
194+
const target = ev.target
195+
if (
196+
isDOMElement(target) &&
197+
(isCodeMirrorEditor(target) ||
198+
isMonacoEditor(target) ||
199+
isTypableElement(target))
200+
) {
201+
return
202+
}
203+
}
204+
205+
// Special handling for comment toggle - let CodeMirror and Monaco handle this in editors
206+
if (binding === "ctrl-/") {
207+
const target = ev.target
208+
209+
if (!isDOMElement(target)) return
210+
211+
// Let editors handle it normally
212+
if (isCodeMirrorEditor(target) || isMonacoEditor(target)) return
213+
214+
// If inside shortcuts flyout, always toggle it (even if focused on search input)
215+
// If not in editor or input, fall back to keybinds flyout
216+
const shouldToggle =
217+
isInShortcutsFlyout(target) || !isTypableElement(target)
218+
219+
if (shouldToggle) {
220+
invokeAction("flyouts.keybinds.toggle", undefined, "keypress")
221+
ev.preventDefault()
222+
return
223+
}
224+
225+
// If in a normal input field, let browser handle it
226+
return
227+
}
228+
229+
// If no action is bound, do nothing
159230
if (!boundAction) return
160231

161232
ev.preventDefault()
@@ -196,11 +267,11 @@ function generateKeybindingString(ev: KeyboardEvent): ShortcutKey | null {
196267
return null
197268
}
198269

199-
// Restrict alt+up and alt+down when the target is a codemirror editor
270+
// Restrict alt+up and alt+down when the target is a CodeMirror or Monaco editor
200271
if (
201272
modifierKey === "alt" &&
202273
(key === "up" || key === "down") &&
203-
isCodeMirrorEditor(target)
274+
(isCodeMirrorEditor(target) || isMonacoEditor(target))
204275
) {
205276
return null
206277
}

0 commit comments

Comments
 (0)