diff --git a/packages/app/src/components/browser-pane.electron.tsx b/packages/app/src/components/browser-pane.electron.tsx
index 90dc1db5d..a6cf4f3ee 100644
--- a/packages/app/src/components/browser-pane.electron.tsx
+++ b/packages/app/src/components/browser-pane.electron.tsx
@@ -8,7 +8,7 @@ import {
createElement,
} from "react";
import { Pressable, Text, TextInput, View } from "react-native";
-import { ArrowLeft, ArrowRight, MousePointer2, RefreshCw } from "lucide-react-native";
+import { ArrowLeft, ArrowRight, MousePointer2, RotateCw } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import {
buildWorkspaceAttachmentScopeKey,
@@ -336,13 +336,27 @@ export function BrowserPane({
const updateBrowserRef = useRef(updateBrowser);
updateBrowserRef.current = updateBrowser;
- const focusUrlBar = useCallback(() => {
- urlInputRef.current?.focus();
+ const selectUrlBar = useCallback(() => {
window.setTimeout(() => {
getTextInputNativeElement(urlInputRef.current)?.select();
}, 0);
}, []);
+ const markActivePane = useCallback(() => {
+ if (!isElectronRuntime()) return;
+ void getDesktopHost()?.browser?.setActivePane?.(browserIdRef.current);
+ }, []);
+
+ const handleUrlBarFocus = useCallback(() => {
+ markActivePane();
+ selectUrlBar();
+ }, [markActivePane, selectUrlBar]);
+
+ const focusUrlBar = useCallback(() => {
+ urlInputRef.current?.focus();
+ selectUrlBar();
+ }, [selectUrlBar]);
+
const syncNavigationState = useCallback((input?: { syncUrl?: boolean }) => {
const webview = webviewRef.current;
if (!webview || !domReadyRef.current) {
@@ -466,6 +480,7 @@ export function BrowserPane({
};
const handleWebviewFocus = () => {
onFocusPane?.();
+ markActivePane();
};
webview.addEventListener("did-start-loading", handleStartLoading);
@@ -937,7 +952,7 @@ export function BrowserPane({
onPress={handleRefresh}
style={baseIconButtonStyle}
>
-
+
@@ -946,6 +961,7 @@ export function BrowserPane({
autoCapitalize="none"
autoCorrect={false}
onChangeText={setDraftUrl}
+ onFocus={handleUrlBarFocus}
onSubmitEditing={handleNavigateDraftUrl}
placeholder="Enter URL"
placeholderTextColor={theme.colors.foregroundMuted}
diff --git a/packages/app/src/desktop/host.ts b/packages/app/src/desktop/host.ts
index c3b9fceaa..4ec360a23 100644
--- a/packages/app/src/desktop/host.ts
+++ b/packages/app/src/desktop/host.ts
@@ -74,6 +74,10 @@ export interface DesktopBrowserShortcutEvent {
action: "focus-url";
}
+export interface DesktopBrowserBridge {
+ setActivePane?: (browserId: string | null) => Promise;
+}
+
export interface DesktopInvokeBridge {
invoke?: (command: string, args?: Record) => Promise;
}
@@ -88,6 +92,7 @@ export interface DesktopHostBridge {
notification?: DesktopNotificationBridge;
opener?: DesktopOpenerBridge;
menu?: DesktopMenuBridge;
+ browser?: DesktopBrowserBridge;
}
declare global {
diff --git a/packages/app/src/hooks/use-keyboard-shortcuts.ts b/packages/app/src/hooks/use-keyboard-shortcuts.ts
index 857006152..7518050b9 100644
--- a/packages/app/src/hooks/use-keyboard-shortcuts.ts
+++ b/packages/app/src/hooks/use-keyboard-shortcuts.ts
@@ -24,6 +24,7 @@ import { getShortcutOs } from "@/utils/shortcut-platform";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
import { isNative } from "@/constants/platform";
+import { getDesktopHost, isElectronRuntime } from "@/desktop/host";
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
import { getRelativeSidebarShortcutTarget } from "@/utils/sidebar-shortcuts";
import { useActiveServerId } from "@/hooks/use-active-server-id";
@@ -418,6 +419,26 @@ export function useKeyboardShortcuts({
window.addEventListener("keyup", handleKeyUp, true);
window.addEventListener("blur", handleBlurOrHide);
document.addEventListener("visibilitychange", handleBlurOrHide);
+
+ const forwardedKeySubscription = isElectronRuntime()
+ ? getDesktopHost()?.events?.on?.("browser-forwarded-key", (payload) => {
+ if (!payload || typeof payload !== "object") return;
+ const p = payload as Record;
+ if (typeof p.key !== "string") return;
+ window.dispatchEvent(
+ new KeyboardEvent("keydown", {
+ key: p.key,
+ code: typeof p.code === "string" ? p.code : "",
+ metaKey: p.meta === true,
+ ctrlKey: p.control === true,
+ shiftKey: p.shift === true,
+ altKey: p.alt === true,
+ bubbles: true,
+ }),
+ );
+ })
+ : null;
+
return () => {
if (chordStateRef.current.timeoutId !== null) {
clearTimeout(chordStateRef.current.timeoutId);
@@ -431,6 +452,11 @@ export function useKeyboardShortcuts({
window.removeEventListener("keyup", handleKeyUp, true);
window.removeEventListener("blur", handleBlurOrHide);
document.removeEventListener("visibilitychange", handleBlurOrHide);
+ if (typeof forwardedKeySubscription === "function") {
+ forwardedKeySubscription();
+ } else {
+ void forwardedKeySubscription?.then((dispose) => dispose());
+ }
};
}, [
bindings,
diff --git a/packages/desktop/src/features/browser-webviews.ts b/packages/desktop/src/features/browser-webviews.ts
index 3cce3480b..9e1d5bff5 100644
--- a/packages/desktop/src/features/browser-webviews.ts
+++ b/packages/desktop/src/features/browser-webviews.ts
@@ -1,11 +1,15 @@
-import type { WebContents } from "electron";
+import { webContents as allWebContents, type WebContents } from "electron";
const browserIdsByWebContentsId = new Map();
+let activeBrowserPaneId: string | null = null;
export function registerPaseoBrowserWebContents(contents: WebContents, browserId: string): void {
browserIdsByWebContentsId.set(contents.id, browserId);
contents.once("destroyed", () => {
browserIdsByWebContentsId.delete(contents.id);
+ if (activeBrowserPaneId === browserId) {
+ activeBrowserPaneId = null;
+ }
});
}
@@ -15,3 +19,21 @@ export function getPaseoBrowserIdForWebContents(contents: WebContents | null): s
}
return browserIdsByWebContentsId.get(contents.id) ?? null;
}
+
+export function setActivePaseoBrowserPaneId(browserId: string | null): void {
+ activeBrowserPaneId = browserId;
+}
+
+export function getActivePaseoBrowserWebContents(): WebContents | null {
+ if (!activeBrowserPaneId) {
+ return null;
+ }
+ for (const [contentsId, browserId] of browserIdsByWebContentsId) {
+ if (browserId !== activeBrowserPaneId) continue;
+ const contents = allWebContents.fromId(contentsId);
+ if (contents && !contents.isDestroyed()) {
+ return contents;
+ }
+ }
+ return null;
+}
diff --git a/packages/desktop/src/features/menu.ts b/packages/desktop/src/features/menu.ts
index e036c3d71..cd66fe722 100644
--- a/packages/desktop/src/features/menu.ts
+++ b/packages/desktop/src/features/menu.ts
@@ -1,5 +1,8 @@
import { app, Menu, BrowserWindow, ipcMain, webContents } from "electron";
-import { getPaseoBrowserIdForWebContents } from "./browser-webviews.js";
+import {
+ getActivePaseoBrowserWebContents,
+ getPaseoBrowserIdForWebContents,
+} from "./browser-webviews.js";
interface ShowContextMenuInput {
kind?: "terminal";
@@ -20,8 +23,12 @@ function getFocusedPaseoBrowserWebContents(): Electron.WebContents | null {
return getPaseoBrowserIdForWebContents(focusedContents) ? focusedContents : null;
}
+function getReloadTargetBrowserWebContents(): Electron.WebContents | null {
+ return getFocusedPaseoBrowserWebContents() ?? getActivePaseoBrowserWebContents();
+}
+
function reloadFocusedContentsOrWindow(win: BrowserWindow, options?: { ignoreCache?: boolean }) {
- const browserContents = getFocusedPaseoBrowserWebContents();
+ const browserContents = getReloadTargetBrowserWebContents();
if (browserContents) {
if (options?.ignoreCache) {
browserContents.reloadIgnoringCache();
diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts
index 731e73408..5ddef207a 100644
--- a/packages/desktop/src/main.ts
+++ b/packages/desktop/src/main.ts
@@ -36,6 +36,7 @@ import { setupApplicationMenu } from "./features/menu.js";
import {
getPaseoBrowserIdForWebContents,
registerPaseoBrowserWebContents,
+ setActivePaseoBrowserPaneId,
} from "./features/browser-webviews.js";
import { parseOpenProjectPathFromArgv } from "./open-project-routing.js";
import { getDesktopSettingsStore } from "./settings/desktop-settings-electron.js";
@@ -76,6 +77,33 @@ function preventUnsafeBrowserWebviewNavigation(
}
const OPEN_PROJECT_EVENT = "paseo:event:open-project";
const BROWSER_SHORTCUT_EVENT = "paseo:event:browser-shortcut";
+const BROWSER_FORWARDED_KEY_EVENT = "paseo:event:browser-forwarded-key";
+
+const FORWARDED_PASEO_SHORTCUT_KEYS = new Set([
+ "b",
+ "e",
+ "w",
+ "t",
+ "k",
+ "/",
+ "\\",
+ ",",
+ ".",
+ "1",
+ "2",
+ "3",
+ "4",
+ "5",
+ "6",
+ "7",
+ "8",
+ "9",
+ "enter",
+ "arrowleft",
+ "arrowright",
+ "arrowup",
+ "arrowdown",
+]);
const DESKTOP_SMOKE_ENV = "PASEO_DESKTOP_SMOKE";
const DESKTOP_SMOKE_STOP_REQUEST = "paseo-smoke-stop";
app.setName("Paseo");
@@ -105,6 +133,16 @@ function isBrowserLocationInput(input: Electron.Input): boolean {
return (input.meta || input.control) && input.key.toLowerCase() === "l";
}
+function isForwardablePaseoShortcutInput(input: Electron.Input): boolean {
+ if (input.type !== "keyDown") {
+ return false;
+ }
+ if (!input.meta && !input.control) {
+ return false;
+ }
+ return FORWARDED_PASEO_SHORTCUT_KEYS.has(input.key.toLowerCase());
+}
+
// In dev mode, detect git worktrees and isolate each instance so multiple
// Electron windows can run side-by-side (separate userData = separate lock).
let devWorktreeName: string | null = null;
@@ -177,6 +215,10 @@ ipcMain.handle("paseo:get-pending-open-project", () => {
return result;
});
+ipcMain.handle("paseo:browser:set-active-pane", (_event, browserId: unknown) => {
+ setActivePaseoBrowserPaneId(typeof browserId === "string" ? browserId : null);
+});
+
protocol.registerSchemesAsPrivileged([
{ scheme: APP_SCHEME, privileges: { standard: true, secure: true, supportFetchAPI: true } },
]);
@@ -317,6 +359,18 @@ async function createMainWindow(): Promise {
action: "focus-url",
...(focusedBrowserId ? { browserId: focusedBrowserId } : {}),
});
+ return;
+ }
+ if (isForwardablePaseoShortcutInput(input)) {
+ event.preventDefault();
+ mainWindow.webContents.send(BROWSER_FORWARDED_KEY_EVENT, {
+ key: input.key,
+ code: input.code,
+ meta: input.meta,
+ control: input.control,
+ shift: input.shift,
+ alt: input.alt,
+ });
}
});
contents.setWindowOpenHandler(({ url }) => {
diff --git a/packages/desktop/src/preload.ts b/packages/desktop/src/preload.ts
index 8b7b02b5f..3159e7641 100644
--- a/packages/desktop/src/preload.ts
+++ b/packages/desktop/src/preload.ts
@@ -57,4 +57,8 @@ contextBridge.exposeInMainWorld("paseoDesktop", {
showContextMenu: (input?: Record) =>
ipcRenderer.invoke("paseo:menu:showContextMenu", input),
},
+ browser: {
+ setActivePane: (browserId: string | null) =>
+ ipcRenderer.invoke("paseo:browser:set-active-pane", browserId),
+ },
});