feat(desktop): native tab keyboard shortcuts (#5190)

This commit is contained in:
Shreyas 2025-06-24 23:16:31 +05:30 committed by GitHub
parent aa5b540412
commit 78e623a847
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 426 additions and 15 deletions

View file

@ -39,6 +39,7 @@ in {
nodePackages.prisma
prisma-engines
cargo-edit
cargo-tauri
] ++ lib.optionals pkgs.stdenv.isDarwin darwinPackages
++ lib.optionals pkgs.stdenv.isLinux linuxPackages;

View file

@ -1199,6 +1199,10 @@
"close_others": "Close all other tabs",
"duplicate": "Duplicate current tab",
"new_tab": "Open a new tab",
"next": "Switch to next tab",
"previous": "Switch to previous tab",
"switch_to_first": "Switch to first tab",
"switch_to_last": "Switch to last tab",
"title": "Tabs"
},
"workspace": {

View file

@ -46,6 +46,7 @@
"@noble/curves": "1.6.0",
"@scure/base": "1.1.9",
"@shopify/lang-jsonc": "1.0.0",
"@tauri-apps/api": "2.1.1",
"@tauri-apps/plugin-store": "2.2.0",
"@types/hawk": "9.0.6",
"@types/markdown-it": "14.1.2",

View file

@ -33,6 +33,11 @@ export type HoppAction =
| "tab.close-current" // Close current tab
| "tab.close-other" // Close other tabs
| "tab.open-new" // Open new tab
| "tab.next" // Switch to next tab
| "tab.prev" // Switch to previous tab
| "tab.switch-to-first" // Switch to first tab
| "tab.switch-to-last" // Switch to last tab
| "tab.reopen-closed" // Reopen recently closed tab
| "collection.new" // Create root collection
| "flyouts.chat.open" // Shows the keybinds flyout
| "flyouts.keybinds.toggle" // Shows the keybinds flyout

View file

@ -2,6 +2,8 @@ import { onBeforeUnmount, onMounted } from "vue"
import { HoppActionWithOptionalArgs, invokeAction } from "./actions"
import { isAppleDevice } from "./platformutils"
import { isDOMElement, isTypableElement } from "./utils/dom"
import { getKernelMode } from "@hoppscotch/kernel"
import { listen } from "@tauri-apps/api/event"
/**
* This variable keeps track whether keybindings are being accepted
@ -10,6 +12,11 @@ import { isDOMElement, isTypableElement } from "./utils/dom"
*/
let keybindingsEnabled = true
/**
* Unlisten function for Tauri event
*/
let unlistenTauriEvent: (() => void) | null = null
/**
* Alt is also regarded as macOS OPTION () key
* 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 =
| "k" | "l" | "m" | "n" | "o" | "p" | "q" | "r" | "s" | "t"
| "u" | "v" | "w" | "x" | "y" | "z" | "0" | "1" | "2" | "3"
| "4" | "5" | "6" | "7" | "8" | "9" | "up" | "down" | "left"
| "right" | "/" | "?" | "." | "enter"
| "right" | "/" | "?" | "." | "enter" | "tab"
/* eslint-enable */
type ModifierBasedShortcutKey = `${ModifierKeys}-${Key}`
@ -39,7 +46,8 @@ type SingleCharacterShortcutKey = `${Key}`
type ShortcutKey = ModifierBasedShortcutKey | SingleCharacterShortcutKey
export const bindings: {
// Base bindings available on all platforms
const baseBindings: {
[_ in ShortcutKey]?: HoppActionWithOptionalArgs
} = {
"ctrl-enter": "request.send-cancel",
@ -71,18 +79,68 @@ export const bindings: {
"ctrl-shift-l": "editor.format",
}
// Desktop-only bindings
const desktopBindings: {
[_ in ShortcutKey]?: HoppActionWithOptionalArgs
} = {
"ctrl-w": "tab.close-current",
"ctrl-t": "tab.open-new",
"ctrl-alt-left": "tab.prev",
"ctrl-alt-right": "tab.next",
"ctrl-alt-0": "tab.switch-to-last",
"ctrl-alt-9": "tab.switch-to-first",
}
/**
* Get bindings based on the current kernel mode
*/
function getActiveBindings(): typeof baseBindings {
const kernelMode = getKernelMode()
if (kernelMode === "desktop") {
return {
...baseBindings,
...desktopBindings,
}
}
return baseBindings
}
export const bindings = getActiveBindings()
/**
* A composable that hooks to the caller component's
* lifecycle and hooks to the keyboard events to fire
* the appropriate actions based on keybindings
*/
export function hookKeybindingsListener() {
onMounted(() => {
onMounted(async () => {
document.addEventListener("keydown", handleKeyDown)
// Listen for Tauri events (desktop only)
if (getKernelMode() === "desktop") {
try {
unlistenTauriEvent = await listen(
"hoppscotch_desktop_shortcut",
(ev) => {
console.info("Tauri shortcut ev", ev)
handleTauriShortcut(ev.payload as string)
}
)
} catch (error) {
console.error("Failed to setup Tauri event listener:", error)
}
}
})
onBeforeUnmount(() => {
document.removeEventListener("keydown", handleKeyDown)
if (unlistenTauriEvent) {
unlistenTauriEvent()
unlistenTauriEvent = null
}
})
}
@ -93,13 +151,27 @@ function handleKeyDown(ev: KeyboardEvent) {
const binding = generateKeybindingString(ev)
if (!binding) return
const boundAction = bindings[binding]
const activeBindings = getActiveBindings()
const boundAction = activeBindings[binding]
if (!boundAction) return
ev.preventDefault()
invokeAction(boundAction, undefined, "keypress")
}
function handleTauriShortcut(shortcut: string) {
console.info("Tauri shortcut:", shortcut)
// Do not check keybinds if the mode is disabled
if (!keybindingsEnabled) return
const activeBindings = getActiveBindings()
const boundAction = activeBindings[shortcut as ShortcutKey]
if (!boundAction) return
invokeAction(boundAction, undefined, "keypress")
}
function generateKeybindingString(ev: KeyboardEvent): ShortcutKey | null {
const target = ev.target
@ -140,6 +212,9 @@ function getPressedKey(ev: KeyboardEvent): Key | null {
return key.slice(5) as Key
}
// Check for Tab key
if (key === "tab") return "tab"
// Check letter keys
const isLetter = key.length === 1 && key >= "a" && key <= "z"
if (isLetter) return key as Key

View file

@ -240,11 +240,34 @@ defineActionHandler("request.rename", () => {
defineActionHandler("tab.duplicate-tab", ({ tabID }) => {
duplicateTab(tabID ?? currentTabID.value)
})
defineActionHandler("tab.close-current", () => {
removeTab(currentTabID.value)
})
defineActionHandler("tab.close-other", () => {
tabs.closeOtherTabs(currentTabID.value)
})
defineActionHandler("tab.open-new", addNewTab)
defineActionHandler("tab.next", () => {
tabs.goToNextTab()
})
defineActionHandler("tab.prev", () => {
tabs.goToPreviousTab()
})
defineActionHandler("tab.switch-to-first", () => {
tabs.goToFirstTab()
})
defineActionHandler("tab.switch-to-last", () => {
tabs.goToLastTab()
})
defineActionHandler("tab.reopen-closed", () => {
tabs.reopenClosedTab()
})
</script>

View file

@ -401,17 +401,41 @@ defineActionHandler("request.rename", () => {
if (tabs.currentActiveTab.value.document.type === "request")
openReqRenameModal(tabs.currentActiveTab.value.id)
})
defineActionHandler("tab.duplicate-tab", ({ tabID }) => {
duplicateTab(tabID ?? currentTabID.value)
})
defineActionHandler("tab.close-current", () => {
removeTab(currentTabID.value)
})
defineActionHandler("tab.close-other", () => {
tabs.closeOtherTabs(currentTabID.value)
})
defineActionHandler("tab.open-new", addNewTab)
defineActionHandler("tab.next", () => {
tabs.goToNextTab()
})
defineActionHandler("tab.prev", () => {
tabs.goToPreviousTab()
})
defineActionHandler("tab.switch-to-first", () => {
tabs.goToFirstTab()
})
defineActionHandler("tab.switch-to-last", () => {
tabs.goToLastTab()
})
defineActionHandler("tab.reopen-closed", () => {
tabs.reopenClosedTab()
})
useService(RequestInspectorService)
useService(EnvironmentInspectorService)
useService(ResponseInspectorService)

View file

@ -11,10 +11,15 @@ import IconCopy from "~icons/lucide/copy"
import IconCopyPlus from "~icons/lucide/copy-plus"
import IconXCircle from "~icons/lucide/x-circle"
import IconXSquare from "~icons/lucide/x-square"
import IconArrowLeft from "~icons/lucide/arrow-left"
import IconArrowRight from "~icons/lucide/arrow-right"
import IconChevronsLeft from "~icons/lucide/chevrons-left"
import IconChevronsRight from "~icons/lucide/chevrons-right"
import { invokeAction } from "~/helpers/actions"
import { RESTTabService } from "~/services/tab/rest"
import { GQLTabService } from "~/services/tab/graphql"
import { Container } from "dioc"
import { getKernelMode } from "@hoppscotch/kernel"
type Doc = {
text: string | string[]
@ -53,6 +58,8 @@ export class TabSpotlightSearcherService extends StaticSpotlightSearcherService<
: this.restTab.getActiveTabs().value.length === 1
)
private isDesktopMode = computed(() => getKernelMode() === "desktop")
private documents: Record<string, Doc> = reactive({
duplicate_tab: {
text: [this.t("spotlight.tab.title"), this.t("spotlight.tab.duplicate")],
@ -88,6 +95,39 @@ export class TabSpotlightSearcherService extends StaticSpotlightSearcherService<
icon: markRaw(IconCopyPlus),
excludeFromSearch: computed(() => !this.showAction.value),
},
// NOTE: Desktop-only actions
tab_prev: {
text: [this.t("spotlight.tab.title"), this.t("spotlight.tab.previous")],
alternates: ["tab", "previous", "prev", "switch"],
icon: markRaw(IconArrowLeft),
excludeFromSearch: computed(
() => !this.showAction.value || !this.isDesktopMode.value || this.isOnlyTab.value
),
},
tab_next: {
text: [this.t("spotlight.tab.title"), this.t("spotlight.tab.next")],
alternates: ["tab", "next", "switch"],
icon: markRaw(IconArrowRight),
excludeFromSearch: computed(
() => !this.showAction.value || !this.isDesktopMode.value || this.isOnlyTab.value
),
},
tab_switch_to_first: {
text: [this.t("spotlight.tab.title"), this.t("spotlight.tab.switch_to_first")],
alternates: ["tab", "first", "switch", "go to first"],
icon: markRaw(IconChevronsLeft),
excludeFromSearch: computed(
() => !this.showAction.value || !this.isDesktopMode.value || this.isOnlyTab.value
),
},
tab_switch_to_last: {
text: [this.t("spotlight.tab.title"), this.t("spotlight.tab.switch_to_last")],
alternates: ["tab", "last", "switch", "go to last"],
icon: markRaw(IconChevronsRight),
excludeFromSearch: computed(
() => !this.showAction.value || !this.isDesktopMode.value || this.isOnlyTab.value
),
},
})
// TODO: Constructors are no longer recommended as of dioc > 3, use onServiceInit instead
@ -122,5 +162,9 @@ export class TabSpotlightSearcherService extends StaticSpotlightSearcherService<
if (id === "close_current_tab") invokeAction("tab.close-current")
if (id === "close_other_tabs") invokeAction("tab.close-other")
if (id === "open_new_tab") invokeAction("tab.open-new")
if (id === "tab_prev") invokeAction("tab.prev")
if (id === "tab_next") invokeAction("tab.next")
if (id === "tab_switch_to_first") invokeAction("tab.switch-to-first")
if (id === "tab_switch_to_last") invokeAction("tab.switch-to-last")
}
}

View file

@ -99,6 +99,39 @@ export interface TabService<Doc> {
*/
closeOtherTabs(tabID: string): void
/**
* Navigates to the next tab in the tab order.
*/
goToNextTab(): void
/**
* Navigates to the previous tab in the tab order.
*/
goToPreviousTab(): void
/**
* NOTE: Currently inert, plumbing is done, some platform issues around shortcuts, WIP for future.
* Navigates to a tab by its index position (1-based).
* @param index - The 1-based index of the tab to navigate to.
*/
goToTabByIndex(index: number): void
/**
* Navigates to the first tab in the tab order.
*/
goToFirstTab(): void
/**
* Navigates to the last tab in the tab order.
*/
goToLastTab(): void
/**
* Reopens the most recently closed tab.
* @returns True if a tab was reopened, false if no closed tabs are available.
*/
reopenClosedTab(): boolean
/**
* Gets a computed reference to a persistable tab state.
* @returns A computed reference to a persistable tab state object.

View file

@ -25,6 +25,8 @@ export abstract class TabService<Doc>
HoppTab<Doc>
> // TODO: The implicit cast is necessary as the reactive unwraps the inner types, creating weird type errors, this needs to be refactored and removed
protected tabOrdering = ref<string[]>(["test"])
protected recentlyClosedTabs: Array<{ tab: HoppTab<Doc>; index: number }> = []
protected readonly MAX_CLOSED_TABS_HISTORY = 10
public currentTabID = refWithControl("test", {
onBeforeChange: (newTabID) => {
@ -168,7 +170,12 @@ export abstract class TabService<Doc>
return
}
this.tabOrdering.value.splice(this.tabOrdering.value.indexOf(tabID), 1)
const tabIndex = this.tabOrdering.value.indexOf(tabID)
const tab = this.tabMap.get(tabID)!
this.addToRecentlyClosedTabs(tab, tabIndex)
this.tabOrdering.value.splice(tabIndex, 1)
nextTick(() => {
this.tabMap.delete(tabID)
@ -192,6 +199,64 @@ export abstract class TabService<Doc>
this.currentTabID.value = tabID
}
public goToNextTab(): void {
const currentIndex = this.tabOrdering.value.indexOf(this.currentTabID.value)
const nextIndex = (currentIndex + 1) % this.tabOrdering.value.length
const nextTabID = this.tabOrdering.value[nextIndex]
this.setActiveTab(nextTabID)
}
public goToPreviousTab(): void {
const currentIndex = this.tabOrdering.value.indexOf(this.currentTabID.value)
const prevIndex =
currentIndex === 0 ? this.tabOrdering.value.length - 1 : currentIndex - 1
const prevTabID = this.tabOrdering.value[prevIndex]
this.setActiveTab(prevTabID)
}
// NOTE: Currently inert, plumbing is done, some platform issues around shortcuts, WIP for future.
public goToTabByIndex(index: number): void {
if (index >= 1 && index <= this.tabOrdering.value.length) {
const tabID = this.tabOrdering.value[index - 1]
this.setActiveTab(tabID)
}
}
public goToFirstTab(): void {
const firstTabID = this.tabOrdering.value[0]
this.setActiveTab(firstTabID)
}
public goToLastTab(): void {
const lastTabID = this.tabOrdering.value[this.tabOrdering.value.length - 1]
this.setActiveTab(lastTabID)
}
public reopenClosedTab(): boolean {
if (this.recentlyClosedTabs.length === 0) {
return false
}
const { tab, index } = this.recentlyClosedTabs.pop()!
this.tabMap.set(tab.id, tab)
const insertIndex = Math.min(index, this.tabOrdering.value.length)
this.tabOrdering.value.splice(insertIndex, 0, tab.id)
this.setActiveTab(tab.id)
return true
}
private addToRecentlyClosedTabs(tab: HoppTab<Doc>, index: number): void {
this.recentlyClosedTabs.push({ tab, index })
if (this.recentlyClosedTabs.length > this.MAX_CLOSED_TABS_HISTORY) {
this.recentlyClosedTabs.shift()
}
}
public persistableTabState = computed<PersistableTabState<Doc>>(() => ({
lastActiveTabID: this.currentTabID.value,
orderedDocs: this.tabOrdering.value.map((tabID) => {

View file

@ -2,9 +2,11 @@ pub mod logger;
pub mod server;
pub mod updater;
use std::ops::Not;
use std::sync::OnceLock;
use tauri::Emitter;
use tauri::menu::{MenuBuilder, MenuItemBuilder, SubmenuBuilder};
use tauri::{Emitter, Manager};
use tauri_plugin_appload::VendorConfigBuilder;
use tauri_plugin_deep_link::DeepLinkExt;
use tauri_plugin_window_state::StateFlags;
@ -21,7 +23,143 @@ fn hopp_auth_port() -> u16 {
pub fn run() {
tauri::Builder::default()
.setup(|app| {
let new_tab = MenuItemBuilder::with_id("new_tab", "New Tab")
.accelerator("CmdOrCtrl+T")
.build(app)
.expect("Failed to build new tab menu item");
let close_tab = MenuItemBuilder::with_id("close_tab", "Close Tab")
.accelerator("CmdOrCtrl+W")
.build(app)
.expect("Failed to build close tab menu item");
let reopen_tab = MenuItemBuilder::with_id("reopen_tab", "Reopen Closed Tab")
.accelerator("CmdOrCtrl+Shift+T")
.build(app)
.expect("Failed to build reopen tab menu item");
let file_menu = SubmenuBuilder::new(app, "File")
.item(&new_tab)
.item(&close_tab)
.item(&reopen_tab)
.build()
.expect("Failed to build file menu");
let focus_url = MenuItemBuilder::with_id("focus_url", "Focus Address Bar")
.accelerator("CmdOrCtrl+L")
.build(app)
.expect("Failed to build focus URL menu item");
let view_menu = SubmenuBuilder::new(app, "View")
.item(&focus_url)
.build()
.expect("Failed to build view menu");
let next_tab = MenuItemBuilder::with_id("next_tab", "Next Tab")
.accelerator("CmdOrCtrl+Alt+Right")
.build(app)
.expect("Failed to build next tab menu item");
let prev_tab = MenuItemBuilder::with_id("prev_tab", "Previous Tab")
.accelerator("CmdOrCtrl+Alt+Left")
.build(app)
.expect("Failed to build previous tab menu item");
let tab_first = MenuItemBuilder::with_id("tab_first", "Switch to First Tab")
.accelerator("CmdOrCtrl+Alt+9")
.build(app)
.expect("Failed to build first tab menu item");
let tab_last = MenuItemBuilder::with_id("tab_last", "Switch to Last Tab")
.accelerator("CmdOrCtrl+Alt+0")
.build(app)
.expect("Failed to build last tab menu item");
let tabs_menu = SubmenuBuilder::new(app, "Tabs")
.item(&next_tab)
.item(&prev_tab)
.separator()
.item(&tab_first)
.item(&tab_last)
.build()
.expect("Failed to build tabs menu");
let menu = MenuBuilder::new(app)
.item(&file_menu)
.item(&view_menu)
.item(&tabs_menu)
.build()
.expect("Failed to build menu");
app.set_menu(menu).expect("Failed to set menu");
let handle = app.handle().clone();
app.on_menu_event(move |app, event| {
let event_id = event.id().as_ref();
let webview_windows = app.webview_windows();
let perhaps_window = webview_windows
.iter()
// Any window that's not `main` since there can
// only be one window open at the moment.
//
// NOTE: Generally this should use `get_focused_window`
// but that requires `unstable` flag currently
// and causes arrow keys to insert escape sequences on MacOS
// see: https://github.com/hoppscotch/hoppscotch/pull/5108
.filter(|kv| kv.0.eq_ignore_ascii_case("main").not())
.last()
.map(|lw| lw.1);
if let Some(window) = perhaps_window {
let shortcut_event = match event_id {
"new_tab" => {
tracing::info!("New Tab menu item triggered (CMD+T/Ctrl+T)");
Some("ctrl-t")
}
"close_tab" => {
tracing::info!("Close Tab menu item triggered (CMD+W/Ctrl+W)");
Some("ctrl-w")
}
"reopen_tab" => {
tracing::info!(
"Reopen Tab menu item triggered (CMD+Shift+T/Ctrl+Shift+T)"
);
Some("ctrl-shift-t")
}
"next_tab" => {
tracing::info!("Next Tab menu item triggered (CMD+Alt+Right/Ctrl+Alt+Right)");
Some("ctrl-alt-right")
}
"prev_tab" => {
tracing::info!(
"Previous Tab menu item triggered (CMD+Alt+Left/Ctrl+Alt+Left)"
);
Some("ctrl-alt-left")
}
"tab_first" => {
tracing::info!("Switch to First Tab menu item triggered (CMD+Alt+9/Ctrl+Alt+9)");
Some("ctrl-alt-9")
}
"tab_last" => {
tracing::info!("Switch to Last Tab menu item triggered (CMD+Alt+0/Ctrl+Alt+0)");
Some("ctrl-alt-0")
}
_ => None,
};
if let Some(shortcut) = shortcut_event {
window
.emit("hoppscotch_desktop_shortcut", shortcut)
.unwrap_or_else(|e| {
tracing::error!(
error.message = %e,
shortcut = shortcut,
"Failed to emit shortcut event from menu"
);
});
}
}
});
let server_port = portpicker::pick_unused_port().expect("Cannot find unused port");
tracing::info!("Selected server port: {}", server_port);

View file

@ -26,7 +26,7 @@ fn main() {
println!("Starting Hoppscotch Desktop...");
return hoppscotch_desktop_lib::run()
return hoppscotch_desktop_lib::run();
};
let Ok(LogGuard(guard)) = logger::setup(&log_file_path) else {
@ -34,7 +34,7 @@ fn main() {
println!("Starting Hoppscotch Desktop...");
return hoppscotch_desktop_lib::run()
return hoppscotch_desktop_lib::run();
};
// This keeps the guard alive, this is scoped to `main`

View file

@ -91,7 +91,7 @@ importers:
version: 0.2.1(eslint@9.27.0(jiti@2.4.2))(terser@5.39.2)(typescript@5.6.3)(vite@5.4.9(@types/node@22.7.5)(sass@1.80.3)(terser@5.39.2))(vue@3.5.12(typescript@5.6.3))
'@tauri-apps/api':
specifier: ^2.0.2
version: 2.0.2
version: 2.1.1
'@tauri-apps/plugin-shell':
specifier: ^2.0.0
version: 2.0.0
@ -545,6 +545,9 @@ importers:
'@shopify/lang-jsonc':
specifier: 1.0.0
version: 1.0.0
'@tauri-apps/api':
specifier: 2.1.1
version: 2.1.1
'@tauri-apps/plugin-store':
specifier: 2.2.0
version: 2.2.0
@ -5797,9 +5800,6 @@ packages:
resolution: {integrity: sha512-6unsZDOdlXTmauU3NhWhn+Cx0rODV+rvNvTdvolE5Kls5ybA6cqndQENDt1+FS0tF7ozCP66jwWoH6a5h90BrA==}
engines: {node: '>= 14.6.0', npm: '>= 6.6.0', yarn: '>= 1.19.1'}
'@tauri-apps/api@2.0.2':
resolution: {integrity: sha512-3wSwmG+1kr6WrgAFKK5ijkNFPp8TT3FLj3YHUb5EwMO+3FxX4uWlfSWkeeBy+Kc1RsKzugtYLuuya+98Flj+3w==}
'@tauri-apps/api@2.1.1':
resolution: {integrity: sha512-fzUfFFKo4lknXGJq8qrCidkUcKcH2UHhfaaCNt4GzgzGaW2iS26uFOg4tS3H4P8D6ZEeUxtiD5z0nwFF0UN30A==}
@ -20075,8 +20075,6 @@ snapshots:
'@tauri-apps/api@1.5.1': {}
'@tauri-apps/api@2.0.2': {}
'@tauri-apps/api@2.1.1': {}
'@tauri-apps/cli-darwin-arm64@1.5.6':
@ -20179,7 +20177,7 @@ snapshots:
'@tauri-apps/plugin-shell@2.0.0':
dependencies:
'@tauri-apps/api': 2.0.2
'@tauri-apps/api': 2.1.1
'@tauri-apps/plugin-shell@2.0.1':
dependencies: