Skip to content

Commit 78e623a

Browse files
feat(desktop): native tab keyboard shortcuts (hoppscotch#5190)
1 parent aa5b540 commit 78e623a

File tree

13 files changed

+426
-15
lines changed

13 files changed

+426
-15
lines changed

devenv.nix

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ in {
3939
nodePackages.prisma
4040
prisma-engines
4141
cargo-edit
42+
cargo-tauri
4243
] ++ lib.optionals pkgs.stdenv.isDarwin darwinPackages
4344
++ lib.optionals pkgs.stdenv.isLinux linuxPackages;
4445

packages/hoppscotch-common/locales/en.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1199,6 +1199,10 @@
11991199
"close_others": "Close all other tabs",
12001200
"duplicate": "Duplicate current tab",
12011201
"new_tab": "Open a new tab",
1202+
"next": "Switch to next tab",
1203+
"previous": "Switch to previous tab",
1204+
"switch_to_first": "Switch to first tab",
1205+
"switch_to_last": "Switch to last tab",
12021206
"title": "Tabs"
12031207
},
12041208
"workspace": {

packages/hoppscotch-common/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
"@noble/curves": "1.6.0",
4747
"@scure/base": "1.1.9",
4848
"@shopify/lang-jsonc": "1.0.0",
49+
"@tauri-apps/api": "2.1.1",
4950
"@tauri-apps/plugin-store": "2.2.0",
5051
"@types/hawk": "9.0.6",
5152
"@types/markdown-it": "14.1.2",

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@ export type HoppAction =
3333
| "tab.close-current" // Close current tab
3434
| "tab.close-other" // Close other tabs
3535
| "tab.open-new" // Open new tab
36+
| "tab.next" // Switch to next tab
37+
| "tab.prev" // Switch to previous tab
38+
| "tab.switch-to-first" // Switch to first tab
39+
| "tab.switch-to-last" // Switch to last tab
40+
| "tab.reopen-closed" // Reopen recently closed tab
3641
| "collection.new" // Create root collection
3742
| "flyouts.chat.open" // Shows the keybinds flyout
3843
| "flyouts.keybinds.toggle" // Shows the keybinds flyout

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

Lines changed: 79 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { onBeforeUnmount, onMounted } from "vue"
22
import { HoppActionWithOptionalArgs, invokeAction } from "./actions"
33
import { isAppleDevice } from "./platformutils"
44
import { isDOMElement, isTypableElement } from "./utils/dom"
5+
import { getKernelMode } from "@hoppscotch/kernel"
6+
import { listen } from "@tauri-apps/api/event"
57

68
/**
79
* This variable keeps track whether keybindings are being accepted
@@ -10,6 +12,11 @@ import { isDOMElement, isTypableElement } from "./utils/dom"
1012
*/
1113
let keybindingsEnabled = true
1214

15+
/**
16+
* Unlisten function for Tauri event
17+
*/
18+
let unlistenTauriEvent: (() => void) | null = null
19+
1320
/**
1421
* Alt is also regarded as macOS OPTION (⌥) key
1522
* Ctrl is also regarded as macOS COMMAND (⌘) key (NOTE: this differs from HTML Keyboard spec where COMMAND is Meta key!)
@@ -30,7 +37,7 @@ type Key =
3037
| "k" | "l" | "m" | "n" | "o" | "p" | "q" | "r" | "s" | "t"
3138
| "u" | "v" | "w" | "x" | "y" | "z" | "0" | "1" | "2" | "3"
3239
| "4" | "5" | "6" | "7" | "8" | "9" | "up" | "down" | "left"
33-
| "right" | "/" | "?" | "." | "enter"
40+
| "right" | "/" | "?" | "." | "enter" | "tab"
3441
/* eslint-enable */
3542

3643
type ModifierBasedShortcutKey = `${ModifierKeys}-${Key}`
@@ -39,7 +46,8 @@ type SingleCharacterShortcutKey = `${Key}`
3946

4047
type ShortcutKey = ModifierBasedShortcutKey | SingleCharacterShortcutKey
4148

42-
export const bindings: {
49+
// Base bindings available on all platforms
50+
const baseBindings: {
4351
[_ in ShortcutKey]?: HoppActionWithOptionalArgs
4452
} = {
4553
"ctrl-enter": "request.send-cancel",
@@ -71,18 +79,68 @@ export const bindings: {
7179
"ctrl-shift-l": "editor.format",
7280
}
7381

82+
// Desktop-only bindings
83+
const desktopBindings: {
84+
[_ in ShortcutKey]?: HoppActionWithOptionalArgs
85+
} = {
86+
"ctrl-w": "tab.close-current",
87+
"ctrl-t": "tab.open-new",
88+
"ctrl-alt-left": "tab.prev",
89+
"ctrl-alt-right": "tab.next",
90+
"ctrl-alt-0": "tab.switch-to-last",
91+
"ctrl-alt-9": "tab.switch-to-first",
92+
}
93+
94+
/**
95+
* Get bindings based on the current kernel mode
96+
*/
97+
function getActiveBindings(): typeof baseBindings {
98+
const kernelMode = getKernelMode()
99+
100+
if (kernelMode === "desktop") {
101+
return {
102+
...baseBindings,
103+
...desktopBindings,
104+
}
105+
}
106+
107+
return baseBindings
108+
}
109+
110+
export const bindings = getActiveBindings()
111+
74112
/**
75113
* A composable that hooks to the caller component's
76114
* lifecycle and hooks to the keyboard events to fire
77115
* the appropriate actions based on keybindings
78116
*/
79117
export function hookKeybindingsListener() {
80-
onMounted(() => {
118+
onMounted(async () => {
81119
document.addEventListener("keydown", handleKeyDown)
120+
121+
// Listen for Tauri events (desktop only)
122+
if (getKernelMode() === "desktop") {
123+
try {
124+
unlistenTauriEvent = await listen(
125+
"hoppscotch_desktop_shortcut",
126+
(ev) => {
127+
console.info("Tauri shortcut ev", ev)
128+
handleTauriShortcut(ev.payload as string)
129+
}
130+
)
131+
} catch (error) {
132+
console.error("Failed to setup Tauri event listener:", error)
133+
}
134+
}
82135
})
83136

84137
onBeforeUnmount(() => {
85138
document.removeEventListener("keydown", handleKeyDown)
139+
140+
if (unlistenTauriEvent) {
141+
unlistenTauriEvent()
142+
unlistenTauriEvent = null
143+
}
86144
})
87145
}
88146

@@ -93,13 +151,27 @@ function handleKeyDown(ev: KeyboardEvent) {
93151
const binding = generateKeybindingString(ev)
94152
if (!binding) return
95153

96-
const boundAction = bindings[binding]
154+
const activeBindings = getActiveBindings()
155+
const boundAction = activeBindings[binding]
97156
if (!boundAction) return
98157

99158
ev.preventDefault()
100159
invokeAction(boundAction, undefined, "keypress")
101160
}
102161

162+
function handleTauriShortcut(shortcut: string) {
163+
console.info("Tauri shortcut:", shortcut)
164+
165+
// Do not check keybinds if the mode is disabled
166+
if (!keybindingsEnabled) return
167+
168+
const activeBindings = getActiveBindings()
169+
const boundAction = activeBindings[shortcut as ShortcutKey]
170+
if (!boundAction) return
171+
172+
invokeAction(boundAction, undefined, "keypress")
173+
}
174+
103175
function generateKeybindingString(ev: KeyboardEvent): ShortcutKey | null {
104176
const target = ev.target
105177

@@ -140,6 +212,9 @@ function getPressedKey(ev: KeyboardEvent): Key | null {
140212
return key.slice(5) as Key
141213
}
142214

215+
// Check for Tab key
216+
if (key === "tab") return "tab"
217+
143218
// Check letter keys
144219
const isLetter = key.length === 1 && key >= "a" && key <= "z"
145220
if (isLetter) return key as Key

packages/hoppscotch-common/src/pages/graphql.vue

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,11 +240,34 @@ defineActionHandler("request.rename", () => {
240240
defineActionHandler("tab.duplicate-tab", ({ tabID }) => {
241241
duplicateTab(tabID ?? currentTabID.value)
242242
})
243+
243244
defineActionHandler("tab.close-current", () => {
244245
removeTab(currentTabID.value)
245246
})
247+
246248
defineActionHandler("tab.close-other", () => {
247249
tabs.closeOtherTabs(currentTabID.value)
248250
})
251+
249252
defineActionHandler("tab.open-new", addNewTab)
253+
254+
defineActionHandler("tab.next", () => {
255+
tabs.goToNextTab()
256+
})
257+
258+
defineActionHandler("tab.prev", () => {
259+
tabs.goToPreviousTab()
260+
})
261+
262+
defineActionHandler("tab.switch-to-first", () => {
263+
tabs.goToFirstTab()
264+
})
265+
266+
defineActionHandler("tab.switch-to-last", () => {
267+
tabs.goToLastTab()
268+
})
269+
270+
defineActionHandler("tab.reopen-closed", () => {
271+
tabs.reopenClosedTab()
272+
})
250273
</script>

packages/hoppscotch-common/src/pages/index.vue

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,17 +401,41 @@ defineActionHandler("request.rename", () => {
401401
if (tabs.currentActiveTab.value.document.type === "request")
402402
openReqRenameModal(tabs.currentActiveTab.value.id)
403403
})
404+
404405
defineActionHandler("tab.duplicate-tab", ({ tabID }) => {
405406
duplicateTab(tabID ?? currentTabID.value)
406407
})
408+
407409
defineActionHandler("tab.close-current", () => {
408410
removeTab(currentTabID.value)
409411
})
412+
410413
defineActionHandler("tab.close-other", () => {
411414
tabs.closeOtherTabs(currentTabID.value)
412415
})
416+
413417
defineActionHandler("tab.open-new", addNewTab)
414418
419+
defineActionHandler("tab.next", () => {
420+
tabs.goToNextTab()
421+
})
422+
423+
defineActionHandler("tab.prev", () => {
424+
tabs.goToPreviousTab()
425+
})
426+
427+
defineActionHandler("tab.switch-to-first", () => {
428+
tabs.goToFirstTab()
429+
})
430+
431+
defineActionHandler("tab.switch-to-last", () => {
432+
tabs.goToLastTab()
433+
})
434+
435+
defineActionHandler("tab.reopen-closed", () => {
436+
tabs.reopenClosedTab()
437+
})
438+
415439
useService(RequestInspectorService)
416440
useService(EnvironmentInspectorService)
417441
useService(ResponseInspectorService)

packages/hoppscotch-common/src/services/spotlight/searchers/tab.searcher.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,15 @@ import IconCopy from "~icons/lucide/copy"
1111
import IconCopyPlus from "~icons/lucide/copy-plus"
1212
import IconXCircle from "~icons/lucide/x-circle"
1313
import IconXSquare from "~icons/lucide/x-square"
14+
import IconArrowLeft from "~icons/lucide/arrow-left"
15+
import IconArrowRight from "~icons/lucide/arrow-right"
16+
import IconChevronsLeft from "~icons/lucide/chevrons-left"
17+
import IconChevronsRight from "~icons/lucide/chevrons-right"
1418
import { invokeAction } from "~/helpers/actions"
1519
import { RESTTabService } from "~/services/tab/rest"
1620
import { GQLTabService } from "~/services/tab/graphql"
1721
import { Container } from "dioc"
22+
import { getKernelMode } from "@hoppscotch/kernel"
1823

1924
type Doc = {
2025
text: string | string[]
@@ -53,6 +58,8 @@ export class TabSpotlightSearcherService extends StaticSpotlightSearcherService<
5358
: this.restTab.getActiveTabs().value.length === 1
5459
)
5560

61+
private isDesktopMode = computed(() => getKernelMode() === "desktop")
62+
5663
private documents: Record<string, Doc> = reactive({
5764
duplicate_tab: {
5865
text: [this.t("spotlight.tab.title"), this.t("spotlight.tab.duplicate")],
@@ -88,6 +95,39 @@ export class TabSpotlightSearcherService extends StaticSpotlightSearcherService<
8895
icon: markRaw(IconCopyPlus),
8996
excludeFromSearch: computed(() => !this.showAction.value),
9097
},
98+
// NOTE: Desktop-only actions
99+
tab_prev: {
100+
text: [this.t("spotlight.tab.title"), this.t("spotlight.tab.previous")],
101+
alternates: ["tab", "previous", "prev", "switch"],
102+
icon: markRaw(IconArrowLeft),
103+
excludeFromSearch: computed(
104+
() => !this.showAction.value || !this.isDesktopMode.value || this.isOnlyTab.value
105+
),
106+
},
107+
tab_next: {
108+
text: [this.t("spotlight.tab.title"), this.t("spotlight.tab.next")],
109+
alternates: ["tab", "next", "switch"],
110+
icon: markRaw(IconArrowRight),
111+
excludeFromSearch: computed(
112+
() => !this.showAction.value || !this.isDesktopMode.value || this.isOnlyTab.value
113+
),
114+
},
115+
tab_switch_to_first: {
116+
text: [this.t("spotlight.tab.title"), this.t("spotlight.tab.switch_to_first")],
117+
alternates: ["tab", "first", "switch", "go to first"],
118+
icon: markRaw(IconChevronsLeft),
119+
excludeFromSearch: computed(
120+
() => !this.showAction.value || !this.isDesktopMode.value || this.isOnlyTab.value
121+
),
122+
},
123+
tab_switch_to_last: {
124+
text: [this.t("spotlight.tab.title"), this.t("spotlight.tab.switch_to_last")],
125+
alternates: ["tab", "last", "switch", "go to last"],
126+
icon: markRaw(IconChevronsRight),
127+
excludeFromSearch: computed(
128+
() => !this.showAction.value || !this.isDesktopMode.value || this.isOnlyTab.value
129+
),
130+
},
91131
})
92132

93133
// TODO: Constructors are no longer recommended as of dioc > 3, use onServiceInit instead
@@ -122,5 +162,9 @@ export class TabSpotlightSearcherService extends StaticSpotlightSearcherService<
122162
if (id === "close_current_tab") invokeAction("tab.close-current")
123163
if (id === "close_other_tabs") invokeAction("tab.close-other")
124164
if (id === "open_new_tab") invokeAction("tab.open-new")
165+
if (id === "tab_prev") invokeAction("tab.prev")
166+
if (id === "tab_next") invokeAction("tab.next")
167+
if (id === "tab_switch_to_first") invokeAction("tab.switch-to-first")
168+
if (id === "tab_switch_to_last") invokeAction("tab.switch-to-last")
125169
}
126170
}

packages/hoppscotch-common/src/services/tab/index.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,39 @@ export interface TabService<Doc> {
9999
*/
100100
closeOtherTabs(tabID: string): void
101101

102+
/**
103+
* Navigates to the next tab in the tab order.
104+
*/
105+
goToNextTab(): void
106+
107+
/**
108+
* Navigates to the previous tab in the tab order.
109+
*/
110+
goToPreviousTab(): void
111+
112+
/**
113+
* NOTE: Currently inert, plumbing is done, some platform issues around shortcuts, WIP for future.
114+
* Navigates to a tab by its index position (1-based).
115+
* @param index - The 1-based index of the tab to navigate to.
116+
*/
117+
goToTabByIndex(index: number): void
118+
119+
/**
120+
* Navigates to the first tab in the tab order.
121+
*/
122+
goToFirstTab(): void
123+
124+
/**
125+
* Navigates to the last tab in the tab order.
126+
*/
127+
goToLastTab(): void
128+
129+
/**
130+
* Reopens the most recently closed tab.
131+
* @returns True if a tab was reopened, false if no closed tabs are available.
132+
*/
133+
reopenClosedTab(): boolean
134+
102135
/**
103136
* Gets a computed reference to a persistable tab state.
104137
* @returns A computed reference to a persistable tab state object.

0 commit comments

Comments
 (0)