mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 16:34:36 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9357abaf3 | ||
|
|
cfefdbdb93 | ||
|
|
51b9ad7695 | ||
|
|
1c4400194a | ||
|
|
1502b7f90f | ||
|
|
16f688b726 |
@@ -34,6 +34,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
|
||||
| [docs/hover.md](docs/hover.md) | Hover — the canonical pattern (plain View + onPointerEnter/Leave, separate inner Pressable) and the three ways agents break it |
|
||||
| [docs/unistyles.md](docs/unistyles.md) | Unistyles gotchas — `useUnistyles()` is forbidden, alternatives in order |
|
||||
| [docs/floating-panels.md](docs/floating-panels.md) | Anchored popovers — Portal/Modal escape for Android, lifecycle gates, keyboard-shared-value, status-bar offset, the flash |
|
||||
| [docs/keyboard-shortcuts.md](docs/keyboard-shortcuts.md) | The binding table, per-platform key sources, badges vs routing, iOS `UIKeyCommand` limits |
|
||||
| [docs/menus.md](docs/menus.md) | The menu engine — popover vs sheet, submenu pages, hover intent, when a decision earns a submenu |
|
||||
| [docs/expo-router.md](docs/expo-router.md) | Expo Router route ownership, startup restore, and native blank-screen gotchas |
|
||||
| [docs/notifications.md](docs/notifications.md) | Notification tap ownership, native replay and acknowledgement, startup handoff, and payload QA |
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
# Keyboard shortcuts
|
||||
|
||||
Define shortcuts in [the shared binding table](../packages/app/src/keyboard/keyboard-shortcuts.ts). Platforms contribute key sources, not separate action maps, so matching and user rebinds stay consistent across runtimes. Add a shortcut through a binding; add platform support through a key source.
|
||||
|
||||
## Badges and routing
|
||||
|
||||
Routing runs whenever the app mounts its key sources, independently of layout.
|
||||
Use `useHardwareKeyboardPresent()` for hints and the Shortcuts settings section.
|
||||
iOS reads `GCKeyboard.coalesced` and connection notifications. Android watches input
|
||||
devices and hardware keyboard configuration. Web starts with a fine hover pointer and recognizes the first modified
|
||||
keydown, including on touch devices. Compact Electron keeps its bindings.
|
||||
|
||||
Keyboard navigation needs the sidebar's filtered workspace model even when its visual
|
||||
panel is hidden. Retain that model while keyboard navigation is active; gating it on
|
||||
panel visibility leaves number shortcuts with no targets after a compact startup.
|
||||
|
||||
Composer send-key customization remains a separate capability. Native retains its
|
||||
existing Return submission path; advertising its web-only customization would expose
|
||||
settings the native composer does not implement.
|
||||
|
||||
## Native sources
|
||||
|
||||
Use the iOS window press stream for hardware keys. `UIKeyCommand` only takes keys
|
||||
that must bypass the focused text control: composer Return and the top overlay's
|
||||
Enter and arrows. Keep the stream as the sole shortcut emitter: a command-owned
|
||||
press is still observed by the window.
|
||||
|
||||
The source spike used an iPad Pro 11-inch (M4) simulator on iOS 18.6 with a
|
||||
`UITextView` first responder. Both `UIWindow.sendEvent` and the root controller's
|
||||
`pressesBegan` received Cmd+B, Cmd+P and Escape. With Escape registered as a
|
||||
`UIKeyCommand`, the window observed it and the command handler also ran.
|
||||
|
||||
Simulator reserves Cmd+K for Toggle Software Keyboard. Injecting it produced
|
||||
HID 184 with empty characters rather than K; plain K, Cmd+B and Cmd+P arrived
|
||||
normally. Keep Simulator input capture off: Send Keyboard Input to Device
|
||||
prevented synthetic AppleScript input from arriving, including plain text.
|
||||
For simulator QA, use Cmd+B/P to check delivery and rebind the command center to
|
||||
Cmd+J to check toggle and rebind behavior. Cmd+K delivery needs a physical iPad.
|
||||
Simulator also reserves Cmd+1…4, Cmd+Shift+A and Cmd+comma. Exercise those actions
|
||||
through rebinds. For numeric injection, use AppleScript physical key codes (for example
|
||||
25 for 9); `keystroke "9"` did not deliver a digit in the QA session.
|
||||
Simulator exposes a virtual `GCKeyboard` even with Connect Hardware Keyboard off.
|
||||
Verify the iOS absence case on a physical iPhone.
|
||||
|
||||
Never register Backspace: it would take deletion away from text fields. Changes
|
||||
to the native module require a native rebuild; an over-the-air JavaScript update
|
||||
cannot deliver them.
|
||||
|
||||
## Overlay ownership
|
||||
|
||||
Register keyboard handling with `useOverlayRegistration` from `@/keyboard`.
|
||||
Pass the surface's open state, layer, handled keys and `onKeyDown`. The highest
|
||||
layer gets first refusal; the latest registration wins a tie. Native registration
|
||||
does not need a DOM ref. Web adds focus trapping and restoration through its adapter.
|
||||
|
||||
Capture a global overlay’s layer in state for its opening lifetime. Memo caches can
|
||||
be discarded or removed by React Compiler; recomputing against the registry lets an
|
||||
open modal rise above its own children and creates competing focus traps.
|
||||
|
||||
Declare Enter and arrow keys only while the surface owns them. Native sources
|
||||
use the top overlay's declarations to take those keys before text controls edit.
|
||||
Backspace stays with the focused input. Composition confirmation must not select
|
||||
an overlay row or submit the composer.
|
||||
|
||||
Android modals have their own windows. Observe them through React Native's
|
||||
`ExtraWindowEventListener` as well as the activity window. The dev client bypasses
|
||||
Expo's root-container handler, so an activity root-view wrapper misses presses.
|
||||
A modal can forward an unhandled event into the activity window. Observe the event
|
||||
once across that nested delivery without retaining recycled Android `KeyEvent`s.
|
||||
Keep an overlay-owned press consumed through key-up, even if its key-down closes
|
||||
the overlay; otherwise the modal's default Escape handler can close a second layer.
|
||||
|
||||
iOS scene startup installs the observing window after Expo creates its root view.
|
||||
Keep that integration in the hardware keyboard module's config plugin. Its window
|
||||
owns both the press stream and the text-control key commands, including above modals.
|
||||
|
||||
## Focus scopes
|
||||
|
||||
The composer and terminal declare focus with `useKeyboardFocusSurface`. Native
|
||||
scope derives from those declarations and command-center state. Releasing an old
|
||||
surface must not clear a newer owner's declaration during a tab switch. Web keeps
|
||||
its DOM-derived scope.
|
||||
|
||||
Escape interrupts an agent only in the message input or an unclaimed surface.
|
||||
A focused terminal excludes that binding. The source leaves terminal Escape alone;
|
||||
terminal Escape/Ctrl forwarding is a separate feature.
|
||||
Generated
+7
@@ -38931,6 +38931,7 @@
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.15.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.15.0",
|
||||
@@ -39183,6 +39184,7 @@
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.15.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.15.0",
|
||||
"@getpaseo/relay": "0.15.0",
|
||||
@@ -40081,6 +40083,7 @@
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.15.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -40110,6 +40113,7 @@
|
||||
"packages/plugin": {
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.15.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^1.4.0",
|
||||
"i18next": "^26.3.0",
|
||||
@@ -40151,6 +40155,7 @@
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.15.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"semver": "^7.8.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -40178,6 +40183,7 @@
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.15.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@noble/ciphers": "^2.4.0",
|
||||
"@noble/curves": "^2.4.0",
|
||||
@@ -40399,6 +40405,7 @@
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.15.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||
|
||||
@@ -142,6 +142,7 @@ export default {
|
||||
searchPaths: ["../../node_modules", "./node_modules"],
|
||||
},
|
||||
plugins: [
|
||||
"./modules/paseo-hardware-keyboard/app.plugin",
|
||||
"expo-router",
|
||||
withPasteInput,
|
||||
withAndroidScroll,
|
||||
|
||||
@@ -94,3 +94,21 @@ it("allows all orientations on iPhone and iPad", async () => {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
// Scene startup is the integration boundary for the iOS keyboard window.
|
||||
describe("hardware keyboard scene integration", () => {
|
||||
it("installs observation after Expo creates the scene window and is idempotent", () => {
|
||||
const {
|
||||
configureHardwareKeyboardAppDelegate,
|
||||
} = require("./modules/paseo-hardware-keyboard/app.plugin.js");
|
||||
const source =
|
||||
"import Expo\nclass Scene {\n super.scene(scene, willConnectTo: session, options: connectionOptions)\n}\n";
|
||||
const configured = configureHardwareKeyboardAppDelegate(source);
|
||||
expect(configured).toContain("import PaseoHardwareKeyboard");
|
||||
expect(configured).toContain("PaseoHardwareKeyboardWindow.observing(window)");
|
||||
expect(configured.indexOf("super.scene")).toBeLessThan(
|
||||
configured.indexOf(".observing(window)"),
|
||||
);
|
||||
expect(configureHardwareKeyboardAppDelegate(configured)).toBe(configured);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
expectNewWorkspaceForAddedProject,
|
||||
expectAddProjectResultVisible,
|
||||
navigateAddProjectResults,
|
||||
navigateAndDismissAddProject,
|
||||
watchAddProjectRenderWarnings,
|
||||
openAddProjectFlow,
|
||||
openAddProjectHostSelection,
|
||||
} from "../support/helpers/add-project-flow";
|
||||
@@ -146,6 +148,7 @@ test.describe("Add Project command-center flow", () => {
|
||||
test("Escape and Back restore searchable page input before closing at the root", async ({
|
||||
page,
|
||||
}) => {
|
||||
const renderWarnings = watchAddProjectRenderWarnings(page);
|
||||
await gotoAppShell(page);
|
||||
await addConnectedHostAndReload(page, {
|
||||
serverId: secondaryHost.serverId,
|
||||
@@ -183,8 +186,9 @@ test.describe("Add Project command-center flow", () => {
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await expectAddProjectPage(page, "host");
|
||||
await page.keyboard.press("Escape");
|
||||
await navigateAndDismissAddProject(page);
|
||||
await expect(addProjectFlow(page)).not.toBeVisible();
|
||||
expect(renderWarnings).toEqual([]);
|
||||
});
|
||||
|
||||
test("New directory creates a Project on the selected remote host", async ({ page }) => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtemp, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { Locator } from "@playwright/test";
|
||||
import { test, expect, type Page } from "../support/fixtures";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { connectSeedClient } from "../support/helpers/seed-client";
|
||||
@@ -59,6 +60,14 @@ async function openCompactActionsMenu(page: Page, workspaceId: string) {
|
||||
return menu;
|
||||
}
|
||||
|
||||
async function expectActionsInOrder(menu: Locator, labels: readonly string[]) {
|
||||
const items = menu.getByRole("menuitem");
|
||||
await expect(items).toHaveCount(labels.length);
|
||||
for (const [index, label] of labels.entries()) {
|
||||
await expect(items.nth(index).getByText(label, { exact: true })).toBeVisible();
|
||||
}
|
||||
}
|
||||
|
||||
test("compact git actions menu lists a promoted Pull once", async ({ page }) => {
|
||||
const repoPath = await createRemoteRepo("compact-menu-pull-");
|
||||
cleanupTasks.push(async () => {
|
||||
@@ -83,12 +92,7 @@ test("compact git actions menu lists a promoted Pull once", async ({ page }) =>
|
||||
const menu = await openCompactActionsMenu(page, workspaceId);
|
||||
|
||||
await expect(menu.getByTestId("changes-menu-pull")).toHaveCount(1);
|
||||
await expect(menu.getByRole("menuitem")).toHaveText([
|
||||
"Pull",
|
||||
"Push",
|
||||
"Pull and push",
|
||||
"Archive workspace",
|
||||
]);
|
||||
await expectActionsInOrder(menu, ["Pull", "Push", "Pull and push", "Archive workspace"]);
|
||||
});
|
||||
|
||||
test("compact git actions menu lists a promoted Push once", async ({ page }) => {
|
||||
@@ -106,10 +110,5 @@ test("compact git actions menu lists a promoted Push once", async ({ page }) =>
|
||||
const menu = await openCompactActionsMenu(page, workspaceId);
|
||||
|
||||
await expect(menu.getByTestId("changes-menu-push")).toHaveCount(1);
|
||||
await expect(menu.getByRole("menuitem")).toHaveText([
|
||||
"Push",
|
||||
"Pull",
|
||||
"Pull and push",
|
||||
"Archive workspace",
|
||||
]);
|
||||
await expectActionsInOrder(menu, ["Push", "Pull", "Pull and push", "Archive workspace"]);
|
||||
});
|
||||
|
||||
@@ -3,7 +3,6 @@ import path from "node:path";
|
||||
import {
|
||||
holdOlderHistoryPages,
|
||||
userScrollsTimelineToHistoryStart,
|
||||
waitForTimelineGeometryToSettle,
|
||||
} from "../support/helpers/timeline-pagination";
|
||||
import { getActiveTabTestId } from "../support/helpers/launcher";
|
||||
import type { Page, Locator } from "@playwright/test";
|
||||
@@ -408,38 +407,6 @@ async function seedWholeTurnHistory() {
|
||||
return agent;
|
||||
}
|
||||
|
||||
/** Read every command across the scroll window, without requiring offscreen rows to mount. */
|
||||
async function expectExpandedCommandsReadable(page: Page, count: number) {
|
||||
const timeline = page.getByTestId("agent-chat-scroll");
|
||||
const commands = new Set<number>();
|
||||
await timeline.hover();
|
||||
for (let step = 0; step < count && commands.size < count; step += 1) {
|
||||
const labels = await timeline
|
||||
.getByRole("button", { name: /echo stress-\d+/ })
|
||||
.allTextContents();
|
||||
for (const label of labels) {
|
||||
const command = /echo stress-(\d+)/.exec(label);
|
||||
if (command) commands.add(Number(command[1]));
|
||||
}
|
||||
if (commands.size < count) {
|
||||
await page.mouse.wheel(0, 300);
|
||||
await waitForTimelineGeometryToSettle(page);
|
||||
}
|
||||
}
|
||||
expect([...commands].sort((a, b) => a - b)).toEqual(
|
||||
Array.from({ length: count }, (_, index) => index),
|
||||
);
|
||||
}
|
||||
|
||||
async function returnToExpandedSummary(page: Page, summary: Locator) {
|
||||
await page.getByTestId("agent-chat-scroll").hover();
|
||||
for (let step = 0; step < 120 && !(await summary.isVisible()); step += 1) {
|
||||
await page.mouse.wheel(0, -300);
|
||||
await waitForTimelineGeometryToSettle(page);
|
||||
}
|
||||
await summary.scrollIntoViewIfNeeded();
|
||||
}
|
||||
|
||||
test("loads complete long turns and preserves expansion while revealing older history", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -456,7 +423,7 @@ test("loads complete long turns and preserves expansion while revealing older hi
|
||||
await expect(latest).toBeVisible();
|
||||
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
|
||||
await latest.click();
|
||||
await expectExpandedCommandsReadable(page, 120);
|
||||
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(120);
|
||||
await userScrollsTimelineToHistoryStart(page);
|
||||
await history.expectRequestedPages(1);
|
||||
history.releasePage(1);
|
||||
@@ -465,9 +432,9 @@ test("loads complete long turns and preserves expansion while revealing older hi
|
||||
.getByTestId("turn-summary")
|
||||
.getByRole("button", { name: "Ran 60 commands", exact: true }),
|
||||
).toBeVisible();
|
||||
await expectExpandedCommandsReadable(page, 120);
|
||||
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(120);
|
||||
history.expectNoRepeatedEntries();
|
||||
await returnToExpandedSummary(page, latest);
|
||||
await latest.scrollIntoViewIfNeeded();
|
||||
await latest.click();
|
||||
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
|
||||
} finally {
|
||||
|
||||
@@ -117,3 +117,25 @@ export async function expectAddProjectResultVisible(page: Page, text: string): P
|
||||
export async function chooseAddHostFromProjectFlow(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: /Add host.*No connected hosts/ }).click();
|
||||
}
|
||||
|
||||
export async function navigateAndDismissAddProject(page: Page): Promise<void> {
|
||||
// Keep both keys in one task so Escape closes with a selection update still queued.
|
||||
await page.evaluate(() => {
|
||||
for (const key of ["ArrowDown", "Escape"]) {
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key, code: key, bubbles: true, cancelable: true }),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function watchAddProjectRenderWarnings(page: Page): string[] {
|
||||
const warnings: string[] = [];
|
||||
page.on("console", (message) => {
|
||||
const text = message.text();
|
||||
if (text.includes("Cannot update a component") && text.includes("AddProjectFlow")) {
|
||||
warnings.push(text);
|
||||
}
|
||||
});
|
||||
return warnings;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
apply plugin: 'com.android.library'
|
||||
apply plugin: 'expo-module-gradle-plugin'
|
||||
|
||||
group = 'sh.paseo.keyboard'
|
||||
version = '0.1.0'
|
||||
android {
|
||||
namespace 'sh.paseo.keyboard'
|
||||
defaultConfig {
|
||||
versionCode 1
|
||||
versionName '0.1.0'
|
||||
}
|
||||
}
|
||||
dependencies {
|
||||
implementation 'com.facebook.react:react-android'
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package sh.paseo.keyboard
|
||||
|
||||
import android.view.KeyCharacterMap
|
||||
|
||||
internal fun hardwareCharacterKey(character: Int): String =
|
||||
if (character and KeyCharacterMap.COMBINING_ACCENT != 0) "Dead"
|
||||
else String(Character.toChars(character))
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
package sh.paseo.keyboard
|
||||
|
||||
import android.content.ComponentCallbacks
|
||||
import android.content.res.Configuration
|
||||
import android.hardware.input.InputManager
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.InputDevice
|
||||
import android.view.KeyEvent
|
||||
import android.view.Window
|
||||
import android.widget.EditText
|
||||
import android.view.inputmethod.BaseInputConnection
|
||||
import com.facebook.react.bridge.UiThreadUtil
|
||||
import com.facebook.react.bridge.ReactContext
|
||||
import com.facebook.react.interfaces.ExtraWindowEventListener
|
||||
import expo.modules.kotlin.modules.Module
|
||||
import expo.modules.kotlin.modules.ModuleDefinition
|
||||
|
||||
internal object HardwareKeyboard {
|
||||
var module: PaseoHardwareKeyboardModule? = null
|
||||
@Volatile var overlayKeys: Set<String> = emptySet()
|
||||
private val windows = mutableMapOf<Window, KeyboardWindowCallback>()
|
||||
val dispatcher = WindowKeyDispatcher<KeyEvent>()
|
||||
private val consumedPresses = mutableSetOf<Int>()
|
||||
|
||||
fun observe(window: Window) {
|
||||
if (windows.containsKey(window)) return
|
||||
val callback = KeyboardWindowCallback(window, window.callback)
|
||||
windows[window] = callback
|
||||
window.callback = callback
|
||||
}
|
||||
fun release(window: Window) {
|
||||
val callback = windows.remove(window) ?: return
|
||||
if (window.callback === callback) window.callback = callback.previous
|
||||
}
|
||||
fun releaseAll() {
|
||||
windows.keys.toList().forEach { release(it) }
|
||||
consumedPresses.clear()
|
||||
}
|
||||
|
||||
fun dispatch(event: KeyEvent, composing: Boolean): Boolean {
|
||||
if (event.flags and KeyEvent.FLAG_SOFT_KEYBOARD != 0) return false
|
||||
if (event.action != KeyEvent.ACTION_DOWN && event.action != KeyEvent.ACTION_UP) return false
|
||||
val code = code(event.keyCode)
|
||||
if (code.isEmpty()) return false
|
||||
val textModifiers = event.metaState and KeyEvent.META_CTRL_MASK.inv() and KeyEvent.META_META_MASK.inv()
|
||||
val character = event.getUnicodeChar(textModifiers)
|
||||
val named = !code.startsWith("Key") && !code.startsWith("Digit")
|
||||
val key = when {
|
||||
code.startsWith("Control") -> "Control"
|
||||
code.startsWith("Meta") -> "Meta"
|
||||
code.startsWith("Shift") -> "Shift"
|
||||
code.startsWith("Alt") -> "Alt"
|
||||
named && (character == 0 || code == "Enter" || code == "Tab" || code == "Backspace") -> code
|
||||
character != 0 -> hardwareCharacterKey(character)
|
||||
else -> code
|
||||
}
|
||||
module?.emit(mapOf(
|
||||
"key" to key, "code" to code,
|
||||
"altKey" to event.isAltPressed, "ctrlKey" to event.isCtrlPressed,
|
||||
"metaKey" to event.isMetaPressed, "shiftKey" to event.isShiftPressed,
|
||||
"repeat" to (event.repeatCount > 0), "isComposing" to composing,
|
||||
"phase" to if (event.action == KeyEvent.ACTION_UP) "up" else "down"
|
||||
))
|
||||
if (event.action == KeyEvent.ACTION_UP) return consumedPresses.remove(event.keyCode)
|
||||
val overlayOwnsKey = key != "Dead" && key in overlayKeys && !event.isCtrlPressed && !event.isMetaPressed && !event.isAltPressed && !composing
|
||||
if (overlayOwnsKey) consumedPresses.add(event.keyCode)
|
||||
return overlayOwnsKey
|
||||
}
|
||||
|
||||
private fun code(keyCode: Int): String = when (keyCode) {
|
||||
in KeyEvent.KEYCODE_A..KeyEvent.KEYCODE_Z -> "Key" + ('A'.code + keyCode - KeyEvent.KEYCODE_A).toChar()
|
||||
in KeyEvent.KEYCODE_0..KeyEvent.KEYCODE_9 -> "Digit" + (keyCode - KeyEvent.KEYCODE_0)
|
||||
in KeyEvent.KEYCODE_F1..KeyEvent.KEYCODE_F12 -> "F" + (keyCode - KeyEvent.KEYCODE_F1 + 1)
|
||||
KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> "Enter"
|
||||
KeyEvent.KEYCODE_ESCAPE -> "Escape"
|
||||
KeyEvent.KEYCODE_DEL -> "Backspace"
|
||||
KeyEvent.KEYCODE_FORWARD_DEL -> "Delete"
|
||||
KeyEvent.KEYCODE_TAB -> "Tab"
|
||||
KeyEvent.KEYCODE_SPACE -> "Space"
|
||||
KeyEvent.KEYCODE_DPAD_UP -> "ArrowUp"
|
||||
KeyEvent.KEYCODE_DPAD_DOWN -> "ArrowDown"
|
||||
KeyEvent.KEYCODE_DPAD_LEFT -> "ArrowLeft"
|
||||
KeyEvent.KEYCODE_DPAD_RIGHT -> "ArrowRight"
|
||||
KeyEvent.KEYCODE_CTRL_LEFT -> "ControlLeft"
|
||||
KeyEvent.KEYCODE_CTRL_RIGHT -> "ControlRight"
|
||||
KeyEvent.KEYCODE_META_LEFT -> "MetaLeft"
|
||||
KeyEvent.KEYCODE_META_RIGHT -> "MetaRight"
|
||||
KeyEvent.KEYCODE_SHIFT_LEFT -> "ShiftLeft"
|
||||
KeyEvent.KEYCODE_SHIFT_RIGHT -> "ShiftRight"
|
||||
KeyEvent.KEYCODE_ALT_LEFT -> "AltLeft"
|
||||
KeyEvent.KEYCODE_ALT_RIGHT -> "AltRight"
|
||||
KeyEvent.KEYCODE_COMMA -> "Comma"
|
||||
KeyEvent.KEYCODE_PERIOD -> "Period"
|
||||
KeyEvent.KEYCODE_MINUS -> "Minus"
|
||||
KeyEvent.KEYCODE_EQUALS -> "Equal"
|
||||
KeyEvent.KEYCODE_LEFT_BRACKET -> "BracketLeft"
|
||||
KeyEvent.KEYCODE_RIGHT_BRACKET -> "BracketRight"
|
||||
KeyEvent.KEYCODE_BACKSLASH -> "Backslash"
|
||||
KeyEvent.KEYCODE_SEMICOLON -> "Semicolon"
|
||||
KeyEvent.KEYCODE_APOSTROPHE -> "Quote"
|
||||
KeyEvent.KEYCODE_GRAVE -> "Backquote"
|
||||
KeyEvent.KEYCODE_SLASH -> "Slash"
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
|
||||
private class KeyboardWindowCallback(private val window: Window, val previous: Window.Callback) : Window.Callback by previous {
|
||||
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
||||
val editable = (window.currentFocus as? EditText)?.text
|
||||
val composing = editable != null && BaseInputConnection.getComposingSpanStart(editable) >= 0
|
||||
return HardwareKeyboard.dispatcher.dispatch(event, { HardwareKeyboard.dispatch(event, composing) }, { previous.dispatchKeyEvent(event) })
|
||||
}
|
||||
}
|
||||
|
||||
class PaseoHardwareKeyboardModule : Module(), ExtraWindowEventListener, InputManager.InputDeviceListener, ComponentCallbacks {
|
||||
private var inputManager: InputManager? = null
|
||||
private var registeredReactContext: ReactContext? = null
|
||||
private fun keyboardPresent(): Boolean {
|
||||
val config = registeredReactContext?.resources?.configuration
|
||||
return (config?.keyboard == Configuration.KEYBOARD_QWERTY && config.hardKeyboardHidden != Configuration.HARDKEYBOARDHIDDEN_YES) ||
|
||||
InputDevice.getDeviceIds().any { id -> InputDevice.getDevice(id)?.let { !it.isVirtual && it.keyboardType == InputDevice.KEYBOARD_TYPE_ALPHABETIC } == true }
|
||||
}
|
||||
private fun presenceChanged() { sendEvent("onHardwareKeyboardPresenceChanged", emptyMap<String, Any>()) }
|
||||
override fun onInputDeviceAdded(id: Int) { presenceChanged() }
|
||||
override fun onInputDeviceRemoved(id: Int) { presenceChanged() }
|
||||
override fun onInputDeviceChanged(id: Int) { presenceChanged() }
|
||||
override fun onConfigurationChanged(config: Configuration) { presenceChanged() }
|
||||
override fun onLowMemory() {}
|
||||
override fun onExtraWindowCreate(window: Window) { HardwareKeyboard.observe(window) }
|
||||
override fun onExtraWindowDestroy(window: Window) { HardwareKeyboard.release(window) }
|
||||
override fun definition() = ModuleDefinition {
|
||||
Name("PaseoHardwareKeyboard")
|
||||
Events("onHardwareKeyboardShortcut", "onHardwareKeyboardPresenceChanged")
|
||||
OnCreate {
|
||||
HardwareKeyboard.module = this@PaseoHardwareKeyboardModule
|
||||
val context = appContext.reactContext
|
||||
require(context is ReactContext) { "Hardware keyboard observation requires a React context" }
|
||||
registeredReactContext = context
|
||||
context.registerComponentCallbacks(this@PaseoHardwareKeyboardModule)
|
||||
inputManager = context.getSystemService(InputManager::class.java)
|
||||
inputManager?.registerInputDeviceListener(this@PaseoHardwareKeyboardModule, Handler(Looper.getMainLooper()))
|
||||
context.addExtraWindowEventListener(this@PaseoHardwareKeyboardModule)
|
||||
UiThreadUtil.runOnUiThread { appContext.currentActivity?.window?.let { HardwareKeyboard.observe(it) } }
|
||||
}
|
||||
OnDestroy {
|
||||
inputManager?.unregisterInputDeviceListener(this@PaseoHardwareKeyboardModule)
|
||||
inputManager = null
|
||||
registeredReactContext?.unregisterComponentCallbacks(this@PaseoHardwareKeyboardModule)
|
||||
registeredReactContext?.removeExtraWindowEventListener(this@PaseoHardwareKeyboardModule)
|
||||
registeredReactContext = null
|
||||
if (HardwareKeyboard.module === this@PaseoHardwareKeyboardModule) {
|
||||
HardwareKeyboard.module = null
|
||||
HardwareKeyboard.overlayKeys = emptySet()
|
||||
UiThreadUtil.runOnUiThread { if (HardwareKeyboard.module == null) HardwareKeyboard.releaseAll() }
|
||||
}
|
||||
}
|
||||
Function("getHardwareKeyboardPresent") { keyboardPresent() }
|
||||
Function("setOverlayKeys") { keys: List<String> ->
|
||||
HardwareKeyboard.overlayKeys = keys.filter { it in setOf("Escape", "Enter", "ArrowUp", "ArrowDown") }.toSet()
|
||||
}
|
||||
}
|
||||
internal fun emit(event: Map<String, Any>) { sendEvent("onHardwareKeyboardShortcut", event) }
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package sh.paseo.keyboard
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import expo.modules.core.interfaces.Package
|
||||
import expo.modules.core.interfaces.ReactActivityLifecycleListener
|
||||
|
||||
class PaseoHardwareKeyboardPackage : Package {
|
||||
override fun createReactActivityLifecycleListeners(activityContext: Context): List<ReactActivityLifecycleListener> = listOf(
|
||||
object : ReactActivityLifecycleListener {
|
||||
override fun onCreate(activity: Activity, savedInstanceState: Bundle?) { HardwareKeyboard.observe(activity.window) }
|
||||
override fun onResume(activity: Activity) { HardwareKeyboard.observe(activity.window) }
|
||||
override fun onDestroy(activity: Activity) { HardwareKeyboard.release(activity.window) }
|
||||
}
|
||||
)
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package sh.paseo.keyboard
|
||||
|
||||
// A modal can forward the same physical event into the activity window. Keep
|
||||
// observation outside that nested delivery, without retaining recycled KeyEvents.
|
||||
internal class WindowKeyDispatcher<Event : Any> {
|
||||
private val delivering = java.util.Collections.newSetFromMap(java.util.IdentityHashMap<Event, Boolean>())
|
||||
fun dispatch(event: Event, observe: () -> Boolean, deliver: () -> Boolean): Boolean {
|
||||
if (!delivering.add(event)) return deliver()
|
||||
return try { observe() || deliver() } finally { delivering.remove(event) }
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package sh.paseo.keyboard
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
class HardwareCharacterKeyTest {
|
||||
@Test fun deadAccentUsesDomKeyWithoutThrowing() {
|
||||
assertEquals("Dead", hardwareCharacterKey(Int.MIN_VALUE or 0xB4))
|
||||
}
|
||||
@Test fun ordinaryUnicodeRemainsText() {
|
||||
assertEquals("é", hardwareCharacterKey(0xE9))
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package sh.paseo.keyboard
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class WindowKeyDispatcherTest {
|
||||
@Test fun observesOnePressAcrossNestedWindows() {
|
||||
val dispatcher = WindowKeyDispatcher<Any>()
|
||||
val event = Any()
|
||||
var observations = 0
|
||||
var deliveries = 0
|
||||
dispatcher.dispatch(event, { observations++; false }, {
|
||||
dispatcher.dispatch(event, { observations++; false }, { deliveries++; false })
|
||||
})
|
||||
assertEquals(1, observations)
|
||||
assertEquals(1, deliveries)
|
||||
dispatcher.dispatch(event, { observations++; false }, { false })
|
||||
assertEquals(2, observations)
|
||||
}
|
||||
@Test fun consumedOverlayKeyDoesNotReachTheView() {
|
||||
val dispatcher = WindowKeyDispatcher<Any>()
|
||||
var deliveries = 0
|
||||
assertTrue(dispatcher.dispatch(Any(), { true }, { deliveries++; false }))
|
||||
assertEquals(0, deliveries)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
const { withAppDelegate } = require("expo/config-plugins");
|
||||
const SCENE_START = "super.scene(scene, willConnectTo: session, options: connectionOptions)";
|
||||
|
||||
function configureHardwareKeyboardAppDelegate(source) {
|
||||
source = source.replace(
|
||||
/^import PaseoHardwareKeyboard$/m,
|
||||
"internal import PaseoHardwareKeyboard",
|
||||
);
|
||||
if (source.includes("PaseoHardwareKeyboardWindow.observing(window)")) return source;
|
||||
if (!source.includes(SCENE_START)) {
|
||||
throw new Error("Hardware keyboard observation requires the app's Expo scene delegate");
|
||||
}
|
||||
const installation = `${SCENE_START}
|
||||
if let window {
|
||||
let observedWindow = PaseoHardwareKeyboardWindow.observing(window)
|
||||
self.window = observedWindow
|
||||
(UIApplication.shared.delegate as? ExpoReactNativeFactoryProvider)?.window = observedWindow
|
||||
}`;
|
||||
return "internal import PaseoHardwareKeyboard\n" + source.replace(SCENE_START, installation);
|
||||
}
|
||||
module.exports = function withHardwareKeyboardWindow(config) {
|
||||
return withAppDelegate(config, (modConfig) => {
|
||||
modConfig.modResults.contents = configureHardwareKeyboardAppDelegate(
|
||||
modConfig.modResults.contents,
|
||||
);
|
||||
return modConfig;
|
||||
});
|
||||
};
|
||||
module.exports.configureHardwareKeyboardAppDelegate = configureHardwareKeyboardAppDelegate;
|
||||
@@ -1,7 +1,9 @@
|
||||
{
|
||||
"platforms": ["ios"],
|
||||
"platforms": ["ios", "android"],
|
||||
"ios": {
|
||||
"modules": ["PaseoHardwareKeyboardModule"],
|
||||
"reactDelegateHandlers": ["PaseoHardwareKeyboardReactDelegateHandler"]
|
||||
"modules": ["PaseoHardwareKeyboardModule"]
|
||||
},
|
||||
"android": {
|
||||
"modules": ["sh.paseo.keyboard.PaseoHardwareKeyboardModule"]
|
||||
}
|
||||
}
|
||||
|
||||
+150
-12
@@ -1,53 +1,159 @@
|
||||
import ExpoModulesCore
|
||||
import UIKit
|
||||
import GameController
|
||||
|
||||
private let hardwareSubmitEventName = "onHardwareKeyboardSubmit"
|
||||
private let hardwareShortcutEventName = "onHardwareKeyboardShortcut"
|
||||
|
||||
private weak var activeModule: PaseoHardwareKeyboardModule?
|
||||
private var isHardwareSubmitEnabled = false
|
||||
private var overlayKeys: Set<String> = []
|
||||
|
||||
@objc
|
||||
public class PaseoHardwareKeyboardReactDelegateHandler: ExpoReactDelegateHandler {
|
||||
public override func createRootViewController() -> UIViewController? {
|
||||
return PaseoHardwareKeyboardRootViewController()
|
||||
/// UIKit spells non-character keys as sentinel input strings rather than as the
|
||||
/// character they would type. JS sends the DOM `code` and the mapping lives
|
||||
/// here, so the constants stay in the one place that owns UIKit vocabulary.
|
||||
private let namedKeyInputs: [String: String] = [
|
||||
"Escape": UIKeyCommand.inputEscape,
|
||||
"ArrowUp": UIKeyCommand.inputUpArrow,
|
||||
"ArrowDown": UIKeyCommand.inputDownArrow,
|
||||
"Enter": "\r",
|
||||
]
|
||||
|
||||
/// Whether the first responder is mid-IME-composition.
|
||||
///
|
||||
/// A committing key has to stay out of the way while a CJK candidate is being
|
||||
/// chosen: Enter confirms the candidate, and consuming it as a key command
|
||||
/// would submit the half-composed text instead.
|
||||
private func isComposingText() -> Bool {
|
||||
guard let responder = UIResponder.paseoCurrentFirstResponder as? UITextInput else {
|
||||
return false
|
||||
}
|
||||
return responder.markedTextRange != nil
|
||||
}
|
||||
|
||||
public class PaseoHardwareKeyboardModule: Module {
|
||||
private var keyboardObservers: [NSObjectProtocol] = []
|
||||
public func definition() -> ModuleDefinition {
|
||||
Name("PaseoHardwareKeyboard")
|
||||
|
||||
Events(hardwareSubmitEventName)
|
||||
Events(hardwareSubmitEventName, hardwareShortcutEventName, "onHardwareKeyboardPresenceChanged")
|
||||
|
||||
OnCreate {
|
||||
activeModule = self
|
||||
for name in [Notification.Name.GCKeyboardDidConnect, Notification.Name.GCKeyboardDidDisconnect] {
|
||||
self.keyboardObservers.append(NotificationCenter.default.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in
|
||||
self?.sendEvent("onHardwareKeyboardPresenceChanged", [:])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Function("getHardwareKeyboardPresent") { GCKeyboard.coalesced != nil }
|
||||
|
||||
Function("setHardwareKeyboardSubmitEnabled") { (enabled: Bool) in
|
||||
DispatchQueue.main.async {
|
||||
isHardwareSubmitEnabled = enabled
|
||||
}
|
||||
}
|
||||
|
||||
Function("setOverlayKeys") { (keys: [String]) in
|
||||
DispatchQueue.main.async {
|
||||
overlayKeys = Set(keys).intersection(["Enter", "ArrowUp", "ArrowDown"])
|
||||
UIMenuSystem.main.setNeedsRebuild()
|
||||
}
|
||||
}
|
||||
|
||||
OnDestroy {
|
||||
self.keyboardObservers.forEach(NotificationCenter.default.removeObserver)
|
||||
self.keyboardObservers = []
|
||||
if activeModule === self {
|
||||
activeModule = nil
|
||||
isHardwareSubmitEnabled = false
|
||||
overlayKeys = []
|
||||
}
|
||||
isHardwareSubmitEnabled = false
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate func emitHardwareKeyboardSubmit() {
|
||||
sendEvent(hardwareSubmitEventName, [:])
|
||||
}
|
||||
|
||||
fileprivate func emitHardwareKeyboardPress(_ press: UIPress, isRepeat: Bool) {
|
||||
guard let key = press.key else { return }
|
||||
let code = hardwareKeyCode(key)
|
||||
let characters = key.characters
|
||||
let named = namedKeyInputs.first { $0.value == characters }?.key
|
||||
let keyName: String
|
||||
if code.hasPrefix("Meta") { keyName = "Meta" }
|
||||
else if code.hasPrefix("Control") { keyName = "Control" }
|
||||
else if code.hasPrefix("Shift") { keyName = "Shift" }
|
||||
else if code.hasPrefix("Alt") { keyName = "Alt" }
|
||||
else { keyName = named ?? (characters.isEmpty ? code : characters) }
|
||||
guard !code.isEmpty else { return }
|
||||
sendEvent(hardwareShortcutEventName, [
|
||||
"key": keyName,
|
||||
"code": code,
|
||||
"altKey": key.modifierFlags.contains(.alternate),
|
||||
"ctrlKey": key.modifierFlags.contains(.control),
|
||||
"metaKey": key.modifierFlags.contains(.command),
|
||||
"shiftKey": key.modifierFlags.contains(.shift),
|
||||
"repeat": isRepeat,
|
||||
"isComposing": isComposingText(),
|
||||
"phase": press.phase == .ended || press.phase == .cancelled ? "up" : "down",
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
private final class PaseoHardwareKeyboardRootViewController: UIViewController {
|
||||
override var keyCommands: [UIKeyCommand]? {
|
||||
guard isHardwareSubmitEnabled && UIDevice.current.userInterfaceIdiom == .pad else {
|
||||
return super.keyCommands
|
||||
}
|
||||
public final class PaseoHardwareKeyboardWindow: UIWindow {
|
||||
private var pressedCodes: Set<Int> = []
|
||||
public static func observing(_ original: UIWindow) -> UIWindow {
|
||||
if original is PaseoHardwareKeyboardWindow { return original }
|
||||
guard let scene = original.windowScene else { return original }
|
||||
let observed = PaseoHardwareKeyboardWindow(windowScene: scene)
|
||||
observed.windowLevel = original.windowLevel
|
||||
observed.backgroundColor = original.backgroundColor
|
||||
let controller = original.rootViewController
|
||||
original.rootViewController = nil
|
||||
original.isHidden = true
|
||||
observed.rootViewController = controller
|
||||
observed.makeKeyAndVisible()
|
||||
return observed
|
||||
}
|
||||
|
||||
public override func sendEvent(_ event: UIEvent) {
|
||||
if let event = event as? UIPressesEvent {
|
||||
for press in event.allPresses where press.phase == .began || press.phase == .ended || press.phase == .cancelled {
|
||||
guard let key = press.key else { continue }
|
||||
let raw = Int(key.keyCode.rawValue)
|
||||
let isRepeat = press.phase == .began && pressedCodes.contains(raw)
|
||||
if press.phase == .began { pressedCodes.insert(raw) }
|
||||
else { pressedCodes.remove(raw) }
|
||||
activeModule?.emitHardwareKeyboardPress(press, isRepeat: isRepeat)
|
||||
}
|
||||
}
|
||||
super.sendEvent(event)
|
||||
}
|
||||
public override var keyCommands: [UIKeyCommand]? {
|
||||
var commands = super.keyCommands ?? []
|
||||
if isHardwareSubmitEnabled && !isPlainReturnClaimed && !isComposingText()
|
||||
&& UIDevice.current.userInterfaceIdiom == .pad
|
||||
{
|
||||
commands.append(makeSubmitCommand())
|
||||
}
|
||||
if !isComposingText() { commands.append(contentsOf: overlayKeys.compactMap(makeShortcutCommand)) }
|
||||
return commands
|
||||
}
|
||||
|
||||
/// Whether something registered has already asked for an unmodified Return.
|
||||
///
|
||||
/// The submit command is registered for as long as the composer holds focus,
|
||||
/// which includes while a picker is open over it. Registering both would put
|
||||
/// two commands on one press, and the composer would win a press the picker
|
||||
/// asked for — Enter would create the workspace instead of choosing a project.
|
||||
private var isPlainReturnClaimed: Bool {
|
||||
overlayKeys.contains("Enter")
|
||||
}
|
||||
|
||||
private func makeSubmitCommand() -> UIKeyCommand {
|
||||
let command = UIKeyCommand(
|
||||
input: "\r",
|
||||
modifierFlags: [],
|
||||
@@ -56,7 +162,14 @@ private final class PaseoHardwareKeyboardRootViewController: UIViewController {
|
||||
if #available(iOS 15.0, *) {
|
||||
command.wantsPriorityOverSystemBehavior = true
|
||||
}
|
||||
return (super.keyCommands ?? []) + [command]
|
||||
return command
|
||||
}
|
||||
|
||||
private func makeShortcutCommand(_ key: String) -> UIKeyCommand? {
|
||||
guard let input = namedKeyInputs[key] else { return nil }
|
||||
let command = UIKeyCommand(input: input, modifierFlags: [], action: #selector(handleHardwareKeyboardShortcut(_:)))
|
||||
if #available(iOS 15.0, *) { command.wantsPriorityOverSystemBehavior = true }
|
||||
return command
|
||||
}
|
||||
|
||||
@objc
|
||||
@@ -67,6 +180,9 @@ private final class PaseoHardwareKeyboardRootViewController: UIViewController {
|
||||
activeModule?.emitHardwareKeyboardSubmit()
|
||||
}
|
||||
|
||||
@objc
|
||||
private func handleHardwareKeyboardShortcut(_ sender: UIKeyCommand) {}
|
||||
|
||||
private func canSubmitCurrentTextInput() -> Bool {
|
||||
guard let responder = UIResponder.paseoCurrentFirstResponder else {
|
||||
return false
|
||||
@@ -97,3 +213,25 @@ private extension UIResponder {
|
||||
UIResponder.currentFirstResponder = self
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func hardwareKeyCode(_ key: UIKey) -> String {
|
||||
let raw = key.keyCode.rawValue
|
||||
if raw >= 4 && raw <= 29 {
|
||||
let letter = UnicodeScalar(Int(raw) + 61)!
|
||||
return "Key" + String(letter)
|
||||
}
|
||||
if raw >= 30 && raw <= 38 { return "Digit" + String(raw - 29) }
|
||||
if raw == 39 { return "Digit0" }
|
||||
let codes: [Int: String] = [
|
||||
40: "Enter", 41: "Escape", 42: "Backspace", 43: "Tab", 44: "Space",
|
||||
45: "Minus", 46: "Equal", 47: "BracketLeft", 48: "BracketRight",
|
||||
49: "Backslash", 51: "Semicolon", 52: "Quote", 53: "Backquote",
|
||||
54: "Comma", 55: "Period", 56: "Slash", 76: "Delete", 79: "ArrowRight",
|
||||
80: "ArrowLeft", 81: "ArrowDown", 82: "ArrowUp", 224: "ControlLeft",
|
||||
225: "ShiftLeft", 226: "AltLeft", 227: "MetaLeft", 228: "ControlRight",
|
||||
229: "ShiftRight", 230: "AltRight", 231: "MetaRight",
|
||||
]
|
||||
if raw >= 58 && raw <= 69 { return "F" + String(raw - 57) }
|
||||
return codes[Int(raw)] ?? ""
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
"dev:ios": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=development expo run:ios",
|
||||
"build:apk": "npm --prefix ../.. run build:apk",
|
||||
"build:apk:arm64": "npm --prefix ../.. run build:apk:arm64",
|
||||
"test:android": "expo prebuild --platform android --no-install && cd android && ./gradlew :paseo-native-trace:testReleaseUnitTest :react-native-enriched-markdown:testReleaseUnitTest --tests '*InlineCodeFormattingTest' --no-daemon --max-workers=2 -Dorg.gradle.parallel=false"
|
||||
"test:android": "expo prebuild --platform android --no-install && cd android && ./gradlew :paseo-native-trace:testReleaseUnitTest :paseo-hardware-keyboard:testReleaseUnitTest :react-native-enriched-markdown:testReleaseUnitTest --tests '*InlineCodeFormattingTest' --no-daemon --max-workers=2 -Dorg.gradle.parallel=false"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
|
||||
@@ -15,7 +15,7 @@ import { PaneFind, findShortcutPlatform, isFindShortcut, type PaneFindHandle } f
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { usePaneFocus } from "@/panels/pane-context";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { hasActiveWebOverlay } from "@/lib/overlay-root";
|
||||
import { hasActiveOverlay } from "@/keyboard";
|
||||
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { planTimelinePromptJump } from "@/timeline/timeline-sync-plan";
|
||||
@@ -104,7 +104,7 @@ export function ChatFind({
|
||||
useEffect(() => {
|
||||
if (!active || !isInteractive) return;
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.defaultPrevented || hasActiveWebOverlay() || isImeComposingKeyboardEvent(event))
|
||||
if (event.defaultPrevented || hasActiveOverlay() || isImeComposingKeyboardEvent(event))
|
||||
return;
|
||||
if (isFindShortcut(event, findShortcutPlatform())) {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -26,22 +26,14 @@ export function getMountedRecentStreamItems(): number {
|
||||
export function findMountedWindowStart(input: {
|
||||
items: StreamItem[];
|
||||
minMountedCount: number;
|
||||
// Caps how far turn alignment may rewind. History paging and the replica
|
||||
// cache omit it and still keep a whole turn.
|
||||
maxMountedCount?: number;
|
||||
}): number {
|
||||
const { items, minMountedCount } = input;
|
||||
if (items.length <= minMountedCount) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const mountedLimit =
|
||||
input.maxMountedCount === undefined
|
||||
? items.length
|
||||
: Math.max(minMountedCount, input.maxMountedCount);
|
||||
const earliestStart = Math.max(0, items.length - mountedLimit);
|
||||
let startIndex = Math.max(items.length - minMountedCount, 0);
|
||||
while (startIndex > earliestStart && continuesTurn(items[startIndex - 1], items[startIndex])) {
|
||||
while (startIndex > 0 && continuesTurn(items[startIndex - 1], items[startIndex])) {
|
||||
startIndex -= 1;
|
||||
}
|
||||
return startIndex;
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { StreamItem } from "@/types/stream";
|
||||
import { projectPluginTimelineItems } from "@/plugins/timeline/projection";
|
||||
import { findMountedWindowStart } from "./history-window";
|
||||
import { buildAgentStreamRenderModel } from "./model";
|
||||
import { MAX_MOUNTED_WEB_WINDOW_ITEMS } from "./web-virtualization";
|
||||
|
||||
function createTimestamp(seed: number): Date {
|
||||
return new Date(`2026-01-01T00:00:${seed.toString().padStart(2, "0")}.000Z`);
|
||||
@@ -126,30 +125,6 @@ describe("buildAgentStreamRenderModel", () => {
|
||||
expect(model.history).not.toContain(head[0]);
|
||||
});
|
||||
|
||||
it("virtualizes a desktop web turn that runs past the mounted cap", () => {
|
||||
const length = MAX_MOUNTED_WEB_WINDOW_ITEMS + 80;
|
||||
const tail = Array.from({ length }, (_, index) => ({
|
||||
...userMessage(`row-${index}`, index % 60),
|
||||
turnId: "long-run",
|
||||
}));
|
||||
|
||||
const model = buildAgentStreamRenderModel({
|
||||
isTurnActive: false,
|
||||
activeTurnStartedAt: null,
|
||||
tail,
|
||||
head: [],
|
||||
platform: "web",
|
||||
isMobileBreakpoint: false,
|
||||
});
|
||||
|
||||
expect(model.segments.historyMounted).toHaveLength(MAX_MOUNTED_WEB_WINDOW_ITEMS);
|
||||
expect(model.segments.historyVirtualized).toHaveLength(80);
|
||||
expect(model.segments.historyMounted[0]?.id).toBe(
|
||||
`row-${length - MAX_MOUNTED_WEB_WINDOW_ITEMS}`,
|
||||
);
|
||||
expect(model.segments.historyVirtualized[0]?.id).toBe("row-0");
|
||||
});
|
||||
|
||||
it("keeps the full committed tail mounted on mobile web", () => {
|
||||
const tail = [userMessage("u1", 1), assistantMessage("a1", 2)];
|
||||
const head = [assistantMessage("live-a", 3)];
|
||||
|
||||
@@ -2,10 +2,7 @@ import type { ReactNode } from "react";
|
||||
import { deriveStreamTurnTiming, type StreamTurnTiming } from "@/timeline/turn-time";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { findMountedWindowStart, getMountedRecentStreamItems } from "./history-window";
|
||||
import {
|
||||
getWebPartialVirtualizationThreshold,
|
||||
MAX_MOUNTED_WEB_WINDOW_ITEMS,
|
||||
} from "./web-virtualization";
|
||||
import { getWebPartialVirtualizationThreshold } from "./web-virtualization";
|
||||
import { orderHeadForStreamRenderStrategy, orderTailForStreamRenderStrategy } from "./strategy";
|
||||
import { resolveStreamRenderStrategy } from "./strategy-resolver";
|
||||
|
||||
@@ -137,7 +134,6 @@ function splitOrderedTail(params: {
|
||||
const mountedWindowStart = findMountedWindowStart({
|
||||
items: orderedTail,
|
||||
minMountedCount: getMountedRecentStreamItems(),
|
||||
maxMountedCount: MAX_MOUNTED_WEB_WINDOW_ITEMS,
|
||||
});
|
||||
const split = {
|
||||
history: orderedTail,
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD,
|
||||
estimateStreamItemHeight,
|
||||
findMountedWindowStart,
|
||||
MAX_MOUNTED_WEB_WINDOW_ITEMS,
|
||||
getWebMountedRecentStreamItems,
|
||||
getWebPartialVirtualizationThreshold,
|
||||
splitWebVirtualizedHistory,
|
||||
@@ -109,37 +108,6 @@ describe("findMountedWindowStart", () => {
|
||||
}),
|
||||
).toBe(39);
|
||||
});
|
||||
|
||||
it("keeps a whole turn when the caller does not cap the rewind", () => {
|
||||
const items = Array.from({ length: MAX_MOUNTED_WEB_WINDOW_ITEMS + 40 }, (_, index) => ({
|
||||
...toolCall(`t${index}`, index % 60),
|
||||
turnId: "long",
|
||||
}));
|
||||
|
||||
expect(findMountedWindowStart({ items, minMountedCount: 20 })).toBe(0);
|
||||
});
|
||||
|
||||
it("stops a long turn at the cap and still honors a larger minimum", () => {
|
||||
const items = Array.from({ length: 200 }, (_, index) => ({
|
||||
...toolCall(`t${index}`, index % 60),
|
||||
turnId: "long",
|
||||
}));
|
||||
|
||||
expect(
|
||||
findMountedWindowStart({
|
||||
items,
|
||||
minMountedCount: 20,
|
||||
maxMountedCount: MAX_MOUNTED_WEB_WINDOW_ITEMS,
|
||||
}),
|
||||
).toBe(200 - MAX_MOUNTED_WEB_WINDOW_ITEMS);
|
||||
expect(
|
||||
findMountedWindowStart({
|
||||
items,
|
||||
minMountedCount: 80,
|
||||
maxMountedCount: MAX_MOUNTED_WEB_WINDOW_ITEMS,
|
||||
}),
|
||||
).toBe(120);
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitWebVirtualizedHistory", () => {
|
||||
@@ -162,39 +130,6 @@ describe("splitWebVirtualizedHistory", () => {
|
||||
expect(window.mountedEntries[0]?.item.id).toBe("u5");
|
||||
expect(window.mountedEntries).toHaveLength(50);
|
||||
});
|
||||
|
||||
it("virtualizes the older rows of a turn that is longer than the mounted cap", () => {
|
||||
const length = MAX_MOUNTED_WEB_WINDOW_ITEMS + 25;
|
||||
const items = Array.from({ length }, (_, index) => ({
|
||||
...toolCall(`t${index}`, index % 60),
|
||||
turnId: "long",
|
||||
}));
|
||||
|
||||
const window = splitWebVirtualizedHistory({
|
||||
entries: indexEntries(items),
|
||||
minMountedCount: DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS,
|
||||
});
|
||||
|
||||
expect(window.mountedEntries).toHaveLength(MAX_MOUNTED_WEB_WINDOW_ITEMS);
|
||||
expect(window.virtualizedEntries).toHaveLength(25);
|
||||
expect(window.mountedEntries[0]?.item.id).toBe(`t${length - MAX_MOUNTED_WEB_WINDOW_ITEMS}`);
|
||||
expect(window.virtualizedEntries[0]?.item.id).toBe("t0");
|
||||
});
|
||||
|
||||
it("keeps a turn that fits under the cap mounted together", () => {
|
||||
const items = Array.from({ length: 40 }, (_, index) => ({
|
||||
...toolCall(`t${index}`, index),
|
||||
turnId: "short",
|
||||
}));
|
||||
|
||||
const window = splitWebVirtualizedHistory({
|
||||
entries: indexEntries(items),
|
||||
minMountedCount: DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS,
|
||||
});
|
||||
|
||||
expect(window.virtualizedEntries).toHaveLength(0);
|
||||
expect(window.mountedEntries).toHaveLength(40);
|
||||
});
|
||||
});
|
||||
|
||||
describe("estimateStreamItemHeight", () => {
|
||||
|
||||
@@ -8,9 +8,6 @@ import {
|
||||
|
||||
export const DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = 100;
|
||||
export const DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS = DEFAULT_MOUNTED_RECENT_STREAM_ITEMS;
|
||||
// Collapsed tool rows are about 40px, so this stays past a desktop viewport.
|
||||
// Opening a long run mounts that tail and virtualizes the older rows.
|
||||
export const MAX_MOUNTED_WEB_WINDOW_ITEMS = 60;
|
||||
const COLLAPSED_TOOL_SEQUENCE_ROW_HEIGHT_ESTIMATE = 40;
|
||||
|
||||
type BottomAnchorE2ETestGlobals = typeof globalThis & {
|
||||
@@ -89,7 +86,6 @@ export function splitWebVirtualizedHistory(input: {
|
||||
const startIndex = findMountedWindowStart({
|
||||
items: input.entries.map((entry) => entry.item),
|
||||
minMountedCount: input.minMountedCount,
|
||||
maxMountedCount: MAX_MOUNTED_WEB_WINDOW_ITEMS,
|
||||
});
|
||||
return {
|
||||
virtualizedEntries: input.entries.slice(0, startIndex),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useHardwareKeyboardPresent } from "@/keyboard";
|
||||
import { AddHostProvider, AddHostFlowHost } from "@/hosts/add-host";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
@@ -90,7 +91,7 @@ import { useActiveWorktreeNewAction } from "@/hooks/use-active-worktree-new-acti
|
||||
import { useGlobalNewWorkspaceAction } from "@/hooks/use-global-new-workspace-action";
|
||||
import { useLatchedBoolean } from "@/hooks/use-latched-boolean";
|
||||
import { useFaviconStatus } from "@/hooks/use-favicon-status";
|
||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||
import { useKeyboardShortcuts } from "@/keyboard/use-shortcuts";
|
||||
import { KeyboardShiftProvider } from "@/keyboard/shift";
|
||||
import { useCompactWebViewportZoomLock } from "@/hooks/use-compact-web-viewport-zoom-lock";
|
||||
import { useOpenProject } from "@/hooks/use-open-project";
|
||||
@@ -549,8 +550,12 @@ function SidebarChrome({
|
||||
const isMobileActive = useIsMobilePanelActive("agent-list");
|
||||
const isDesktopOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||
const active = visible && (isCompactLayout ? isMobileActive : isDesktopOpen);
|
||||
const hardwareKeyboardPresent = useHardwareKeyboardPresent();
|
||||
return (
|
||||
<SidebarModelProvider active={active}>
|
||||
<SidebarModelProvider
|
||||
active={active}
|
||||
keyboardNavigationActive={keyboardShortcutsEnabled && hardwareKeyboardPresent}
|
||||
>
|
||||
{mounted ? <LeftSidebar active={active} /> : null}
|
||||
<WorkspaceShortcutTargetsSubscriber enabled={keyboardShortcutsEnabled} />
|
||||
</SidebarModelProvider>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useOverlayRegistration, type KeyboardInputEvent } from "@/keyboard";
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import equal from "fast-deep-equal";
|
||||
import { recordRenderProfileReasons } from "@/utils/render-profiler";
|
||||
@@ -52,11 +53,7 @@ import { useProjects } from "@/hooks/use-projects";
|
||||
import { useTimeAgo } from "@/hooks/use-time-ago";
|
||||
import { createProjectIconTarget } from "@/projects/icon-target";
|
||||
import { useProjectIcons } from "@/projects/icons";
|
||||
import {
|
||||
OverlayLayerProvider,
|
||||
useGlobalWebOverlayLayer,
|
||||
useWebOverlayRegistration,
|
||||
} from "@/lib/overlay-root";
|
||||
import { OverlayLayerProvider, useGlobalOverlayLayer } from "@/lib/overlay-root";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import {
|
||||
useKeyboardShortcutsStore,
|
||||
@@ -763,6 +760,10 @@ function SectionRow({ row }: { row: Extract<CommandCenterListRow, { kind: "secti
|
||||
);
|
||||
}
|
||||
|
||||
// Escape arrives without asking; it is registered for `agent.interrupt` anyway.
|
||||
// Backspace stays off deliberately — the palette pops its scope with it on web,
|
||||
// but a registered command would stop the search field deleting text.
|
||||
|
||||
export function CommandCenter() {
|
||||
const { t } = useTranslation();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
@@ -780,7 +781,7 @@ export function CommandCenter() {
|
||||
interaction,
|
||||
);
|
||||
const showBottomSheet = isCompact && isNative;
|
||||
const modalLayer = useGlobalWebOverlayLayer("modal", isWeb && state.open && !showBottomSheet);
|
||||
const modalLayer = useGlobalOverlayLayer("modal", state.open);
|
||||
const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({
|
||||
visible: state.open,
|
||||
isEnabled: showBottomSheet,
|
||||
@@ -874,23 +875,20 @@ export function CommandCenter() {
|
||||
: {}),
|
||||
scrollEventThrottle: 16,
|
||||
};
|
||||
const keyPress = useCallback(
|
||||
({ nativeEvent: { key } }: { nativeEvent: { key: string } }) => state.key(key),
|
||||
[state],
|
||||
);
|
||||
const submit = useCallback(() => state.key("Enter"), [state]);
|
||||
const handleWebOverlayKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
const handleOverlayKeyDown = useCallback(
|
||||
(event: KeyboardInputEvent) => {
|
||||
if (!state.key(event.key)) return false;
|
||||
event.preventDefault();
|
||||
return true;
|
||||
},
|
||||
[state],
|
||||
);
|
||||
const setWebOverlayScope = useWebOverlayRegistration({
|
||||
active: isWeb && state.open && !showBottomSheet,
|
||||
const setOverlayScope = useOverlayRegistration({
|
||||
keys: ["Escape", "Enter", "ArrowUp", "ArrowDown"],
|
||||
active: state.open,
|
||||
layer: modalLayer,
|
||||
onKeyDown: handleWebOverlayKeyDown,
|
||||
onKeyDown: handleOverlayKeyDown,
|
||||
});
|
||||
|
||||
if (showBottomSheet) {
|
||||
@@ -911,45 +909,46 @@ export function CommandCenter() {
|
||||
keyboardBlurBehavior="restore"
|
||||
accessible={false}
|
||||
>
|
||||
<View style={[styles.bottomSheetHeader, styles.searchRow]} testID="command-center-header">
|
||||
{state.scope === "files" ? (
|
||||
<ScopeChip label={t("shell.commandCenter.files")} onRemove={state.clearScope} />
|
||||
) : null}
|
||||
<ThemedBottomSheetTextInput
|
||||
testID="command-center-input"
|
||||
ref={bottomSheetInputRef}
|
||||
initialValue={state.query}
|
||||
variant="bottom-sheet"
|
||||
onChangeText={state.setQuery}
|
||||
onKeyPress={keyPress}
|
||||
onSubmitEditing={submit}
|
||||
placeholder={
|
||||
state.scope === "files"
|
||||
? t("shell.commandCenter.filePlaceholder")
|
||||
: t("shell.commandCenter.placeholder")
|
||||
}
|
||||
style={[styles.input, styles.growingInput]}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoFocus
|
||||
/>
|
||||
<FileSearchLoadingIndicator
|
||||
loading={state.fileSearchLoading}
|
||||
label={t("shell.commandCenter.searchingFiles")}
|
||||
/>
|
||||
</View>
|
||||
{fileSearchError}
|
||||
<BottomSheetFlatList ref={bottomSheetListRef} {...commonListProps} />
|
||||
<OverlayLayerProvider layer={modalLayer}>
|
||||
<View style={[styles.bottomSheetHeader, styles.searchRow]} testID="command-center-header">
|
||||
{state.scope === "files" ? (
|
||||
<ScopeChip label={t("shell.commandCenter.files")} onRemove={state.clearScope} />
|
||||
) : null}
|
||||
<ThemedBottomSheetTextInput
|
||||
testID="command-center-input"
|
||||
ref={bottomSheetInputRef}
|
||||
initialValue={state.query}
|
||||
variant="bottom-sheet"
|
||||
onChangeText={state.setQuery}
|
||||
onSubmitEditing={submit}
|
||||
placeholder={
|
||||
state.scope === "files"
|
||||
? t("shell.commandCenter.filePlaceholder")
|
||||
: t("shell.commandCenter.placeholder")
|
||||
}
|
||||
style={[styles.input, styles.growingInput]}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoFocus
|
||||
/>
|
||||
<FileSearchLoadingIndicator
|
||||
loading={state.fileSearchLoading}
|
||||
label={t("shell.commandCenter.searchingFiles")}
|
||||
/>
|
||||
</View>
|
||||
{fileSearchError}
|
||||
<BottomSheetFlatList ref={bottomSheetListRef} {...commonListProps} />
|
||||
</OverlayLayerProvider>
|
||||
</IsolatedBottomSheetModal>
|
||||
);
|
||||
}
|
||||
if (!state.open) return null;
|
||||
return (
|
||||
<OverlayLayerProvider layer={isWeb ? modalLayer : 0}>
|
||||
<OverlayLayerProvider layer={modalLayer}>
|
||||
<Modal visible transparent animationType="fade" onRequestClose={state.close}>
|
||||
<View style={styles.overlay}>
|
||||
<Pressable style={styles.backdrop} onPress={state.close} />
|
||||
<View ref={setWebOverlayScope} testID="command-center-panel" style={styles.panel}>
|
||||
<View ref={setOverlayScope} testID="command-center-panel" style={styles.panel}>
|
||||
<View style={[styles.header, styles.searchRow]} testID="command-center-header">
|
||||
{state.scope === "files" ? (
|
||||
<ScopeChip label={t("shell.commandCenter.files")} onRemove={state.clearScope} />
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { usesDesktopBindings } from "@/keyboard";
|
||||
import { useMemo } from "react";
|
||||
import { router, type Href } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -15,12 +16,12 @@ import {
|
||||
Settings,
|
||||
} from "lucide-react-native";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
|
||||
import { useOpenAddProject } from "@/hooks/use-open-add-project";
|
||||
import { useImportSession } from "@/hooks/use-import-session";
|
||||
import { useKeyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher-context";
|
||||
import { useKeyboardShortcutsAvailable } from "@/keyboard/availability";
|
||||
import { useHardwareKeyboardPresent } from "@/keyboard";
|
||||
import { resolveShortcutKeysForAction } from "@/keyboard/keyboard-shortcuts";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
@@ -111,7 +112,7 @@ export function CommandCenterRootActions() {
|
||||
const keyboardActionDispatcher = useKeyboardActionDispatcher();
|
||||
const { t } = useTranslation();
|
||||
const { overrides } = useKeyboardShortcutOverrides();
|
||||
const shortcutsAvailable = useKeyboardShortcutsAvailable();
|
||||
const shortcutsAvailable = useHardwareKeyboardPresent();
|
||||
const openAddProject = useOpenAddProject();
|
||||
const { open: openImportSession, sheet: importSessionSheet } = useImportSession();
|
||||
const settingsRoute = useMemo<Href>(() => buildSettingsRoute(), []);
|
||||
@@ -128,7 +129,7 @@ export function CommandCenterRootActions() {
|
||||
const toggleDesktopAgentList = usePanelStore((state) => state.toggleDesktopAgentList);
|
||||
const toggleAgentList = isCompact ? toggleMobileAgentList : toggleDesktopAgentList;
|
||||
const shortcutPlatform = useMemo(
|
||||
() => ({ isMac: getShortcutOs() === "mac", isDesktop: getIsElectronRuntime() }),
|
||||
() => ({ isMac: getShortcutOs() === "mac", usesDesktopBindings }),
|
||||
[],
|
||||
);
|
||||
const actions = useMemo<CommandCenterContribution[]>(() => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { usesDesktopBindings } from "@/keyboard";
|
||||
import { useCallback, useMemo, type ReactElement } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
@@ -105,7 +106,7 @@ function staticIcon(element: ReactElement | undefined): CommandCenterIcon | unde
|
||||
}
|
||||
|
||||
function resolveWorkspaceShortcuts(overrides: ShortcutOverrides): WorkspaceCommandCenterShortcuts {
|
||||
const platform = { isMac: getShortcutOs() === "mac", isDesktop: getIsElectron() };
|
||||
const platform = { isMac: getShortcutOs() === "mac", usesDesktopBindings };
|
||||
return resolveWorkspaceCommandCenterShortcuts({ overrides, platform });
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ describe("resolveWorkspaceCommandCenterShortcuts", () => {
|
||||
expect(
|
||||
resolveWorkspaceCommandCenterShortcuts({
|
||||
overrides: {},
|
||||
platform: { isMac: true, isDesktop: true },
|
||||
platform: { isMac: true, usesDesktopBindings: true },
|
||||
}).newAgent,
|
||||
).toEqual([["mod", "shift", "A"]]);
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { WorkspaceCommandCenterShortcuts } from "./workspace-contributions"
|
||||
|
||||
interface ResolveWorkspaceCommandCenterShortcutsInput {
|
||||
overrides: ShortcutOverrides;
|
||||
platform: { isMac: boolean; isDesktop: boolean };
|
||||
platform: { isMac: boolean; usesDesktopBindings: boolean };
|
||||
}
|
||||
|
||||
export function resolveWorkspaceCommandCenterShortcuts({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useOverlayRegistration, type KeyboardInputEvent } from "@/keyboard";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
@@ -6,12 +7,7 @@ import { Keyboard, Pressable, Text, View } from "react-native";
|
||||
import type { DimensionValue, StyleProp, ViewProps, ViewStyle } from "react-native";
|
||||
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import {
|
||||
getOverlayRoot,
|
||||
OverlayLayerProvider,
|
||||
useGlobalWebOverlayLayer,
|
||||
useWebOverlayRegistration,
|
||||
} from "../lib/overlay-root";
|
||||
import { getOverlayRoot, OverlayLayerProvider, useGlobalOverlayLayer } from "../lib/overlay-root";
|
||||
import {
|
||||
BottomSheetHandle,
|
||||
useBottomSheetGestureHandlers,
|
||||
@@ -655,7 +651,7 @@ export function AdaptiveModalSheet({
|
||||
});
|
||||
const [shouldRenderWeb, setShouldRenderWeb] = useState(visible);
|
||||
const [isWebClosing, setIsWebClosing] = useState(false);
|
||||
const modalLayer = useGlobalWebOverlayLayer("modal", isWeb && !isMobile && shouldRenderWeb);
|
||||
const modalLayer = useGlobalOverlayLayer("modal", visible || shouldRenderWeb);
|
||||
const handleDismiss = useCallback(() => {
|
||||
handleSheetDismiss();
|
||||
onDismiss?.();
|
||||
@@ -683,8 +679,8 @@ export function AdaptiveModalSheet({
|
||||
[isWebClosing, modalLayer],
|
||||
);
|
||||
|
||||
const handleWebOverlayKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
const handleOverlayKeyDown = useCallback(
|
||||
(event: KeyboardInputEvent) => {
|
||||
if (event.key !== "Escape") return false;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -693,10 +689,11 @@ export function AdaptiveModalSheet({
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
const setWebOverlayScope = useWebOverlayRegistration({
|
||||
active: isWeb && !isMobile && visible,
|
||||
const setOverlayScope = useOverlayRegistration({
|
||||
keys: ["Escape"],
|
||||
active: visible,
|
||||
layer: modalLayer,
|
||||
onKeyDown: handleWebOverlayKeyDown,
|
||||
onKeyDown: handleOverlayKeyDown,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -735,18 +732,20 @@ export function AdaptiveModalSheet({
|
||||
accessible={false}
|
||||
presentation={presentation}
|
||||
>
|
||||
<CompactSheetBody
|
||||
header={header}
|
||||
onClose={onClose}
|
||||
testID={testID}
|
||||
footer={footerView}
|
||||
isKeyboardVisible={isKeyboardVisible}
|
||||
scrollable={scrollable}
|
||||
bodyStyle={bodyStyle}
|
||||
contentStyle={contentStyle}
|
||||
>
|
||||
{children}
|
||||
</CompactSheetBody>
|
||||
<OverlayLayerProvider layer={modalLayer}>
|
||||
<CompactSheetBody
|
||||
header={header}
|
||||
onClose={onClose}
|
||||
testID={testID}
|
||||
footer={footerView}
|
||||
isKeyboardVisible={isKeyboardVisible}
|
||||
scrollable={scrollable}
|
||||
bodyStyle={bodyStyle}
|
||||
contentStyle={contentStyle}
|
||||
>
|
||||
{children}
|
||||
</CompactSheetBody>
|
||||
</OverlayLayerProvider>
|
||||
</IsolatedBottomSheetModal>
|
||||
);
|
||||
}
|
||||
@@ -790,13 +789,7 @@ export function AdaptiveModalSheet({
|
||||
style={ABSOLUTE_FILL_STYLE}
|
||||
onPress={onClose}
|
||||
/>
|
||||
<View
|
||||
ref={setWebOverlayScope}
|
||||
style={desktopCardStyle}
|
||||
role="dialog"
|
||||
aria-modal
|
||||
tabIndex={-1}
|
||||
>
|
||||
<View ref={setOverlayScope} style={desktopCardStyle} role="dialog" aria-modal tabIndex={-1}>
|
||||
{cardInner}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useOverlayRegistration, type KeyboardInputEvent } from "@/keyboard";
|
||||
import { useAddHost } from "@/hosts/add-host";
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
@@ -69,19 +70,15 @@ import {
|
||||
} from "@/components/project-picker-options";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { useRevealActiveItem } from "@/components/ui/scroll-reveal";
|
||||
import { useKeyboardShortcutsAvailable } from "@/keyboard/availability";
|
||||
import { useHardwareKeyboardPresent } from "@/keyboard";
|
||||
import { getIsElectronRuntime } from "@/constants/layout";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { pickDirectory } from "@/desktop/pick-directory";
|
||||
import { useFetchQuery } from "@/data/query";
|
||||
import { getOpenProjectFailureReason, registerProjectDescriptor } from "@/hooks/open-project";
|
||||
import { useIsLocalDaemon, useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
|
||||
import { useCloneGithubProject, useOpenProject } from "@/hooks/use-open-project";
|
||||
import {
|
||||
OverlayLayerProvider,
|
||||
useGlobalWebOverlayLayer,
|
||||
useWebOverlayRegistration,
|
||||
} from "@/lib/overlay-root";
|
||||
import { OverlayLayerProvider, useGlobalOverlayLayer } from "@/lib/overlay-root";
|
||||
import {
|
||||
useHosts,
|
||||
useHostRuntimeClient,
|
||||
@@ -302,7 +299,7 @@ function FlowRow({
|
||||
}
|
||||
|
||||
function FlowHint({ keys, action }: { keys: string[]; action: string }) {
|
||||
const shortcutsAvailable = useKeyboardShortcutsAvailable();
|
||||
const shortcutsAvailable = useHardwareKeyboardPresent();
|
||||
if (!shortcutsAvailable) return null;
|
||||
|
||||
return (
|
||||
@@ -460,13 +457,10 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
|
||||
});
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
setState((current) => {
|
||||
const previous = backAddProjectPage(current);
|
||||
if (previous) return previous;
|
||||
onClose();
|
||||
return current;
|
||||
});
|
||||
}, [onClose]);
|
||||
const previous = backAddProjectPage(state);
|
||||
if (previous) setState(previous);
|
||||
else onClose();
|
||||
}, [onClose, state]);
|
||||
|
||||
const openNewWorkspaceForProject = useCallback(
|
||||
(serverId: string, project: WorkspaceProjectDescriptorPayload) => {
|
||||
@@ -815,30 +809,22 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
|
||||
[activeIndex, handleBack, rows, submitActive],
|
||||
);
|
||||
|
||||
const modalLayer = useGlobalWebOverlayLayer("modal", isWeb);
|
||||
const handleWebOverlayKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
const modalLayer = useGlobalOverlayLayer("modal", true);
|
||||
const handleOverlayKeyDown = useCallback(
|
||||
(event: KeyboardInputEvent) => {
|
||||
if (!handleKey(event.key)) return false;
|
||||
event.preventDefault();
|
||||
return true;
|
||||
},
|
||||
[handleKey],
|
||||
);
|
||||
const setWebOverlayScope = useWebOverlayRegistration({
|
||||
active: isWeb,
|
||||
const setOverlayScope = useOverlayRegistration({
|
||||
keys: ["Escape", "Enter", "ArrowUp", "ArrowDown"],
|
||||
active: true,
|
||||
layer: modalLayer,
|
||||
onKeyDown: handleWebOverlayKeyDown,
|
||||
onKeyDown: handleOverlayKeyDown,
|
||||
});
|
||||
|
||||
const handleNativeKeyPress = useCallback(
|
||||
({ nativeEvent: { key } }: { nativeEvent: { key: string } }) => {
|
||||
if (key === "ArrowDown" || key === "ArrowUp" || key === "Escape") {
|
||||
handleKey(key);
|
||||
}
|
||||
},
|
||||
[handleKey],
|
||||
);
|
||||
|
||||
const handleInputChange = useCallback((value: string) => {
|
||||
setState((current) =>
|
||||
currentAddProjectPage(current).kind === "new-directory-name"
|
||||
@@ -873,7 +859,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
|
||||
<View style={styles.overlay} testID="add-project-flow">
|
||||
<Pressable style={styles.backdrop} onPress={onClose} testID="add-project-flow-backdrop" />
|
||||
<View
|
||||
ref={setWebOverlayScope}
|
||||
ref={setOverlayScope}
|
||||
style={styles.panel}
|
||||
testID={`add-project-flow-page-${page.kind}`}
|
||||
accessibilityLabel={`Add project: ${page.kind}`}
|
||||
@@ -892,29 +878,11 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
{page.kind === "method" && isNative ? (
|
||||
// Native hardware-keyboard events need a focused responder even without a visible field.
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
onKeyPress={handleNativeKeyPress}
|
||||
onSubmitEditing={submitActive}
|
||||
showSoftInputOnFocus={false}
|
||||
caretHidden
|
||||
contextMenuHidden
|
||||
accessible={false}
|
||||
accessibilityElementsHidden
|
||||
importantForAccessibility="no-hide-descendants"
|
||||
pointerEvents="none"
|
||||
style={styles.keyboardCapture}
|
||||
testID="add-project-flow-keyboard-capture"
|
||||
/>
|
||||
) : null}
|
||||
{page.kind !== "method" ? (
|
||||
<ThemedTextInput
|
||||
ref={inputRef}
|
||||
initialValue={pageInput(page)}
|
||||
onChangeText={handleInputChange}
|
||||
onKeyPress={isWeb ? undefined : handleNativeKeyPress}
|
||||
onSubmitEditing={isWeb ? undefined : submitActive}
|
||||
placeholder={pagePlaceholder(page)}
|
||||
style={styles.input}
|
||||
@@ -996,7 +964,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
|
||||
</Modal>
|
||||
);
|
||||
|
||||
return createElement(OverlayLayerProvider, { layer: isWeb ? modalLayer : 0 }, modal);
|
||||
return createElement(OverlayLayerProvider, { layer: modalLayer }, modal);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
@@ -1070,12 +1038,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingVertical: theme.spacing[1],
|
||||
outlineStyle: "none",
|
||||
} as object,
|
||||
keyboardCapture: {
|
||||
position: "absolute",
|
||||
width: 1,
|
||||
height: 1,
|
||||
opacity: 0,
|
||||
},
|
||||
results: { flexGrow: 0, flexShrink: 1, minHeight: 0 },
|
||||
resultsContent: { paddingVertical: theme.spacing[2] },
|
||||
row: {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useOverlayRegistration, type KeyboardInputEvent } from "@/keyboard";
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
@@ -8,12 +9,12 @@ import { X } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { SPACING } from "@/styles/theme";
|
||||
import { WindowChromeRootRegion } from "@/utils/desktop-window";
|
||||
import { ZoomableImage } from "@/components/zoomable-viewport/image";
|
||||
import type { ViewportSize } from "@/components/zoomable-viewport/geometry";
|
||||
import { useGlobalWebOverlayLayer, useWebOverlayRegistration } from "@/lib/overlay-root";
|
||||
import { useGlobalOverlayLayer } from "@/lib/overlay-root";
|
||||
|
||||
export type ImageLightboxSource =
|
||||
| { type: "attachment"; metadata: AttachmentMetadata }
|
||||
@@ -34,14 +35,14 @@ export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps)
|
||||
const url = source?.type === "uri" ? source.uri : attachmentUrl;
|
||||
const contentSize = source?.type === "uri" ? source.contentSize : undefined;
|
||||
const [errored, setErrored] = useState(false);
|
||||
const modalLayer = useGlobalWebOverlayLayer("modal", isWeb && source !== null);
|
||||
const modalLayer = useGlobalOverlayLayer("modal", source !== null);
|
||||
|
||||
useEffect(() => {
|
||||
setErrored(false);
|
||||
}, [metadata?.id, url]);
|
||||
|
||||
const handleWebOverlayKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
const handleOverlayKeyDown = useCallback(
|
||||
(event: KeyboardInputEvent) => {
|
||||
if (event.key !== "Escape") return false;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -50,10 +51,11 @@ export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps)
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
const setWebOverlayScope = useWebOverlayRegistration({
|
||||
active: isWeb && source !== null,
|
||||
const setOverlayScope = useOverlayRegistration({
|
||||
keys: ["Escape"],
|
||||
active: source !== null,
|
||||
layer: modalLayer,
|
||||
onKeyDown: handleWebOverlayKeyDown,
|
||||
onKeyDown: handleOverlayKeyDown,
|
||||
});
|
||||
|
||||
const actions = useMemo(
|
||||
@@ -83,7 +85,7 @@ export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps)
|
||||
image was opened (inside a sheet tab, the bottom one is already spent). */}
|
||||
<SafeAreaProvider>
|
||||
<WindowChromeRootRegion corners="both">
|
||||
<View ref={setWebOverlayScope} style={styles.root}>
|
||||
<View ref={setOverlayScope} style={styles.root}>
|
||||
<Pressable
|
||||
testID="attachment-lightbox-backdrop"
|
||||
accessibilityRole="button"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { usesDesktopBindings } from "@/keyboard";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { getIsElectronRuntime } from "@/constants/layout";
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
@@ -24,14 +24,13 @@ export function KeyboardShortcutsDialog() {
|
||||
|
||||
const shortcutOs = getShortcutOs();
|
||||
const isMac = shortcutOs === "mac";
|
||||
const isDesktopApp = getIsElectronRuntime();
|
||||
const { overrides } = useKeyboardShortcutOverrides();
|
||||
// Effective bindings, so a shortcut the user unassigned lists no keys here
|
||||
// instead of advertising a default that no longer fires.
|
||||
const bindings = useMemo(() => buildEffectiveBindings(overrides), [overrides]);
|
||||
const sections = useMemo(
|
||||
() => buildKeyboardShortcutHelpSections({ isMac, isDesktop: isDesktopApp }, bindings),
|
||||
[bindings, isDesktopApp, isMac],
|
||||
() => buildKeyboardShortcutHelpSections({ isMac, usesDesktopBindings }, bindings),
|
||||
[bindings, isMac],
|
||||
);
|
||||
const visibleSections = useMemo(
|
||||
() => filterShortcutHelpSections({ sections, query, translate: t, shortcutOs }),
|
||||
|
||||
@@ -18,7 +18,7 @@ import { DensityIcon } from "@/components/ui/density-icon";
|
||||
import { SidebarFooterIconHighlight } from "./sidebar-footer-icon-highlight";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useAppDiagnosticStore } from "@/diagnostics/store";
|
||||
import { useKeyboardShortcutsAvailable } from "@/keyboard/availability";
|
||||
import { useHardwareKeyboardPresent } from "@/keyboard";
|
||||
import { useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
@@ -79,7 +79,7 @@ function HostVersionHint({ host }: { host: HostProfile }) {
|
||||
|
||||
export function SidebarHelpMenu() {
|
||||
const { t } = useTranslation();
|
||||
const shortcutsAvailable = useKeyboardShortcutsAvailable();
|
||||
const shortcutsAvailable = useHardwareKeyboardPresent();
|
||||
const openAppDiagnostic = useAppDiagnosticStore((state) => state.open);
|
||||
const setShortcutsDialogOpen = useKeyboardShortcutsStore((state) => state.setShortcutsDialogOpen);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
@@ -50,13 +50,16 @@ const SidebarModelContext = createContext<SidebarModel | null>(null);
|
||||
|
||||
export function SidebarModelProvider({
|
||||
active,
|
||||
keyboardNavigationActive = false,
|
||||
children,
|
||||
}: {
|
||||
active?: boolean;
|
||||
keyboardNavigationActive?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const list = useSidebarWorkspacesList({ enabled: active });
|
||||
const modelActive = active !== false || keyboardNavigationActive;
|
||||
const list = useSidebarWorkspacesList({ enabled: modelActive });
|
||||
const showBackground = useSidebarViewStore((state) => state.showBackground);
|
||||
const groupMode = useSidebarViewStore((state) => state.groupMode);
|
||||
const labelFilter = useSidebarViewStore((state) => state.labelFilter);
|
||||
@@ -98,10 +101,11 @@ export function SidebarModelProvider({
|
||||
// anything; the label filter reads `labels`, which only exists on an entry. Hydration opens a
|
||||
// live session-store subscription over every workspace on every visible host, so widening this
|
||||
// for a filter that does not need it costs a retained-but-inactive sidebar real work.
|
||||
// Hardware keyboard navigation needs the same filtered targets while the sidebar is hidden.
|
||||
const needsWorkspaceEntries = groupMode !== "project" || hasActiveLabelFilter;
|
||||
const workspaceEntriesByKey = useSidebarWorkspaceEntries(
|
||||
list.workspacePlacements,
|
||||
active !== false || needsWorkspaceEntries,
|
||||
modelActive || needsWorkspaceEntries,
|
||||
);
|
||||
const filteredWorkspaceEntriesByKey = useMemo(() => {
|
||||
const byProject = filterWorkspacesByProjects({
|
||||
|
||||
@@ -84,6 +84,7 @@ import {
|
||||
} from "./terminal-resize-debouncer";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { useKeyboardFocusSurface } from "@/keyboard";
|
||||
import {
|
||||
applyTerminalRendererReadyChange,
|
||||
resolveTerminalStreamTarget,
|
||||
@@ -458,6 +459,8 @@ export function TerminalPane({
|
||||
}
|
||||
}, [isMobile, isPaneFocused, isWorkspaceFocused, requestTerminalFocus, scopeKey, terminalId]);
|
||||
|
||||
useKeyboardFocusSurface("terminal", isTerminalPresented && isPaneFocused && terminalId != null);
|
||||
|
||||
useEffect(() => {
|
||||
const canRequest = canRequestFocusClaim({
|
||||
isWorkspaceFocused: isTerminalPresented,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useOverlayRegistration, type KeyboardInputEvent } from "@/keyboard";
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import {
|
||||
useCallback,
|
||||
@@ -62,12 +63,7 @@ import {
|
||||
} from "@/components/adaptive-modal-sheet";
|
||||
import { FloatingSurface } from "@/components/ui/floating";
|
||||
import { useDismissKeyboardOnOpen } from "@/components/ui/keyboard-dismiss";
|
||||
import {
|
||||
getOverlayRoot,
|
||||
OverlayLayerProvider,
|
||||
useOverlayLayer,
|
||||
useWebOverlayRegistration,
|
||||
} from "@/lib/overlay-root";
|
||||
import { getOverlayRoot, OverlayLayerProvider, useOverlayLayer } from "@/lib/overlay-root";
|
||||
import { buildDesktopFrameStyle } from "./combobox-frame-style";
|
||||
|
||||
export { buildDesktopFrameStyle } from "./combobox-frame-style";
|
||||
@@ -526,7 +522,6 @@ type DesktopKey = "ArrowDown" | "ArrowUp" | "Enter" | "Escape";
|
||||
|
||||
interface DesktopKeyHandlerInput {
|
||||
isOpen: boolean;
|
||||
isMobile: boolean;
|
||||
orderedVisibleOptions: ComboboxOption[];
|
||||
activeIndex: number;
|
||||
setActiveIndex: React.Dispatch<React.SetStateAction<number>>;
|
||||
@@ -871,11 +866,12 @@ function isDesktopKey(key: string): key is DesktopKey {
|
||||
function dispatchDesktopKey(
|
||||
input: DesktopKeyHandlerInput,
|
||||
key: DesktopKey,
|
||||
event?: KeyboardEvent,
|
||||
event?: KeyboardInputEvent,
|
||||
): boolean {
|
||||
if (!input.isOpen) return false;
|
||||
if (!IS_WEB && input.isMobile) return false;
|
||||
|
||||
// A native mobile bail used to live here because native had no key source, so
|
||||
// nothing could call this. It has one now, and the sheet highlights an active
|
||||
// row like the popover does, so a hardware keyboard drives both.
|
||||
if (key === "ArrowDown" || key === "ArrowUp") {
|
||||
event?.preventDefault();
|
||||
handleDesktopArrowKey(input, key);
|
||||
@@ -912,6 +908,7 @@ function resolveInitialActiveIndex(
|
||||
}
|
||||
|
||||
interface MobileBodyProps {
|
||||
overlayLayer: number;
|
||||
isOpen: boolean;
|
||||
snapPoints: string[];
|
||||
handleIndicatorStyle: { backgroundColor: string };
|
||||
@@ -990,45 +987,47 @@ function MobileComboboxSheet(props: MobileBodyProps): ReactElement {
|
||||
keyboardBlurBehavior="none"
|
||||
presentation={props.presentation}
|
||||
>
|
||||
<ComboboxSheetFrame footer={props.footer}>
|
||||
{props.header ? (
|
||||
<SheetHeaderView header={props.header} onClose={props.onClose} />
|
||||
) : (
|
||||
<>
|
||||
<View style={styles.bottomSheetHeader}>
|
||||
<Text key={props.titleColor} style={comboboxTitleStyle}>
|
||||
{props.title}
|
||||
</Text>
|
||||
</View>
|
||||
{props.stickyHeader}
|
||||
{!props.hasChildren && props.searchable ? (
|
||||
<SearchInput
|
||||
placeholder={props.searchPlaceholder}
|
||||
onChangeText={props.setSearchQueryWithCallback}
|
||||
onSubmitEditing={props.handleSubmitSearch}
|
||||
autoFocus={false}
|
||||
useBottomSheetInput
|
||||
resetKey={props.searchResetKey}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
{props.hasChildren && !props.mobileChildrenScrollEnabled ? (
|
||||
body
|
||||
) : (
|
||||
<BottomSheetScrollView
|
||||
style={styles.mobileSheetBody}
|
||||
contentContainerStyle={[
|
||||
styles.comboboxScrollContent,
|
||||
props.mobileChildrenContentContainerStyle,
|
||||
]}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{body}
|
||||
</BottomSheetScrollView>
|
||||
)}
|
||||
</ComboboxSheetFrame>
|
||||
<OverlayLayerProvider layer={props.overlayLayer}>
|
||||
<ComboboxSheetFrame footer={props.footer}>
|
||||
{props.header ? (
|
||||
<SheetHeaderView header={props.header} onClose={props.onClose} />
|
||||
) : (
|
||||
<>
|
||||
<View style={styles.bottomSheetHeader}>
|
||||
<Text key={props.titleColor} style={comboboxTitleStyle}>
|
||||
{props.title}
|
||||
</Text>
|
||||
</View>
|
||||
{props.stickyHeader}
|
||||
{!props.hasChildren && props.searchable ? (
|
||||
<SearchInput
|
||||
placeholder={props.searchPlaceholder}
|
||||
onChangeText={props.setSearchQueryWithCallback}
|
||||
onSubmitEditing={props.handleSubmitSearch}
|
||||
autoFocus={false}
|
||||
useBottomSheetInput
|
||||
resetKey={props.searchResetKey}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
{props.hasChildren && !props.mobileChildrenScrollEnabled ? (
|
||||
body
|
||||
) : (
|
||||
<BottomSheetScrollView
|
||||
style={styles.mobileSheetBody}
|
||||
contentContainerStyle={[
|
||||
styles.comboboxScrollContent,
|
||||
props.mobileChildrenContentContainerStyle,
|
||||
]}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{body}
|
||||
</BottomSheetScrollView>
|
||||
)}
|
||||
</ComboboxSheetFrame>
|
||||
</OverlayLayerProvider>
|
||||
</IsolatedBottomSheetModal>
|
||||
);
|
||||
}
|
||||
@@ -1062,7 +1061,8 @@ interface DesktopBodyProps {
|
||||
overlayLayer: number;
|
||||
isOpen: boolean;
|
||||
handleClose: () => void;
|
||||
handleDesktopKey: (key: DesktopKey, event?: KeyboardEvent) => boolean;
|
||||
setOverlayScope: (node: unknown) => void;
|
||||
handleDesktopKey: (key: DesktopKey, event?: KeyboardInputEvent) => boolean;
|
||||
refs: ReturnType<typeof useFloating>["refs"];
|
||||
shouldUseDesktopFade: boolean;
|
||||
desktopFrameStyle: StyleProp<ViewStyle>;
|
||||
@@ -1189,25 +1189,13 @@ function DesktopComboboxOptionsBody(props: {
|
||||
|
||||
function DesktopComboboxBody(props: DesktopBodyProps): ReactElement | null {
|
||||
const active = useRetainedPanelActive();
|
||||
const handleDesktopKey = props.handleDesktopKey;
|
||||
const handleWebOverlayKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (!isDesktopKey(event.key)) return false;
|
||||
return handleDesktopKey(event.key, event);
|
||||
},
|
||||
[handleDesktopKey],
|
||||
);
|
||||
const setWebOverlayScope = useWebOverlayRegistration({
|
||||
active: isWeb && props.isOpen,
|
||||
layer: props.overlayLayer,
|
||||
onKeyDown: handleWebOverlayKeyDown,
|
||||
});
|
||||
const setOverlayScope = props.setOverlayScope;
|
||||
const setFloatingRef = useCallback(
|
||||
(node: View | null) => {
|
||||
props.refs.setFloating(node);
|
||||
setWebOverlayScope(node);
|
||||
setOverlayScope(node);
|
||||
},
|
||||
[props.refs, setWebOverlayScope],
|
||||
[props.refs, setOverlayScope],
|
||||
);
|
||||
|
||||
// Remove the portal before a retained chat can freeze its closing update.
|
||||
@@ -1531,11 +1519,10 @@ export function Combobox({
|
||||
);
|
||||
|
||||
const handleDesktopKey = useCallback(
|
||||
(key: DesktopKey, event?: KeyboardEvent) => {
|
||||
(key: DesktopKey, event?: KeyboardInputEvent) => {
|
||||
return dispatchDesktopKey(
|
||||
{
|
||||
isOpen,
|
||||
isMobile,
|
||||
orderedVisibleOptions,
|
||||
activeIndex,
|
||||
setActiveIndex,
|
||||
@@ -1546,7 +1533,7 @@ export function Combobox({
|
||||
event,
|
||||
);
|
||||
},
|
||||
[activeIndex, handleClose, handleSelect, isMobile, isOpen, orderedVisibleOptions],
|
||||
[activeIndex, handleClose, handleSelect, isOpen, orderedVisibleOptions],
|
||||
);
|
||||
|
||||
useDismissKeyboardOnOpen(isOpen, isMobile);
|
||||
@@ -1583,12 +1570,20 @@ export function Combobox({
|
||||
[],
|
||||
);
|
||||
|
||||
const setOverlayScope = useOverlayRegistration({
|
||||
active: isOpen,
|
||||
layer: floatingLayer,
|
||||
keys: ["Escape", "Enter", "ArrowUp", "ArrowDown"],
|
||||
onKeyDown: (event) => (isDesktopKey(event.key) ? handleDesktopKey(event.key, event) : false),
|
||||
});
|
||||
|
||||
const effectiveSearchPlaceholder = searchPlaceholder ?? resolvedPlaceholder;
|
||||
const hasChildren = Boolean(children);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<MobileComboboxBody
|
||||
overlayLayer={floatingLayer}
|
||||
isOpen={isOpen}
|
||||
snapPoints={snapPoints}
|
||||
handleIndicatorStyle={handleIndicatorStyle}
|
||||
@@ -1628,6 +1623,7 @@ export function Combobox({
|
||||
isOpen={isOpen}
|
||||
handleClose={handleClose}
|
||||
handleDesktopKey={handleDesktopKey}
|
||||
setOverlayScope={setOverlayScope}
|
||||
refs={refs}
|
||||
shouldUseDesktopFade={shouldUseDesktopFade}
|
||||
desktopFrameStyle={desktopFrameStyle}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useOverlayRegistration, type KeyboardInputEvent } from "@/keyboard";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
@@ -14,11 +15,7 @@ import { Dimensions, View, type Role, type StyleProp, type ViewStyle } from "rea
|
||||
import { FadeIn, FadeOut } from "react-native-reanimated";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Portal } from "@gorhom/portal";
|
||||
import {
|
||||
OverlayLayerProvider,
|
||||
useOverlayLayer,
|
||||
useWebOverlayRegistration,
|
||||
} from "@/lib/overlay-root";
|
||||
import { OverlayLayerProvider, useOverlayLayer } from "@/lib/overlay-root";
|
||||
import { FloatingSurface } from "@/components/ui/floating";
|
||||
import {
|
||||
measureFloatingPanelPortalHost,
|
||||
@@ -127,7 +124,7 @@ function WebHoverCard({
|
||||
}, [clearGraceTimer, disabled]);
|
||||
|
||||
const keyPressed = useCallback(
|
||||
(event: KeyboardEvent): boolean => {
|
||||
(event: KeyboardInputEvent): boolean => {
|
||||
if (!isWeb) return false;
|
||||
const trigger = triggerRef.current as unknown as HTMLElement | null;
|
||||
if (!trigger) return false;
|
||||
@@ -139,7 +136,7 @@ function WebHoverCard({
|
||||
return true;
|
||||
} else if (
|
||||
focusInside() &&
|
||||
event.target === trigger &&
|
||||
document.activeElement === trigger &&
|
||||
["ArrowDown", "Enter", " "].includes(event.key)
|
||||
) {
|
||||
event.preventDefault();
|
||||
@@ -158,7 +155,8 @@ function WebHoverCard({
|
||||
[clearGraceTimer, focusInside, open, openNow],
|
||||
);
|
||||
|
||||
const setOverlayScope = useWebOverlayRegistration({
|
||||
const setOverlayScope = useOverlayRegistration({
|
||||
keys: ["Escape"],
|
||||
active: !disabled && open,
|
||||
layer,
|
||||
onKeyDown: keyPressed,
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { KeyboardInputEvent } from "@/keyboard";
|
||||
export function navigateMenuKeyboard(_event: KeyboardInputEvent): boolean {
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { KeyboardInputEvent } from "@/keyboard";
|
||||
export function navigateMenuKeyboard(event: KeyboardInputEvent): boolean {
|
||||
const target = document.activeElement;
|
||||
const surface = target?.closest<HTMLElement>('[data-menu-surface="true"]');
|
||||
if (!surface) return false;
|
||||
const items = Array.from(
|
||||
surface.querySelectorAll<HTMLElement>(
|
||||
'[data-menu-item="true"]:not([data-menu-disabled="true"])',
|
||||
),
|
||||
);
|
||||
if (items.length === 0) return false;
|
||||
const currentIndex = items.findIndex((item) => item === document.activeElement);
|
||||
let nextIndex: number | null = null;
|
||||
if (event.key === "ArrowDown") {
|
||||
if (currentIndex < 0) nextIndex = 0;
|
||||
else nextIndex = (currentIndex + 1) % items.length;
|
||||
}
|
||||
if (event.key === "ArrowUp") {
|
||||
if (currentIndex < 0) nextIndex = items.length - 1;
|
||||
else nextIndex = (currentIndex - 1 + items.length) % items.length;
|
||||
}
|
||||
if (event.key === "Home") nextIndex = 0;
|
||||
if (event.key === "End") nextIndex = items.length - 1;
|
||||
if (nextIndex !== null) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
items[nextIndex]?.focus();
|
||||
return true;
|
||||
}
|
||||
if ((event.key === "Enter" || event.key === " ") && currentIndex >= 0) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
items[currentIndex]?.click();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { navigateMenuKeyboard } from "./keyboard-navigation";
|
||||
import { useOverlayRegistration, type KeyboardInputEvent } from "@/keyboard";
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import {
|
||||
useCallback,
|
||||
@@ -24,12 +26,7 @@ import { StyleSheet } from "react-native-unistyles";
|
||||
import { FloatingScrollView, FloatingSurface } from "@/components/ui/floating";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import type { KeyboardFocusScope } from "@/keyboard/actions";
|
||||
import {
|
||||
getOverlayRoot,
|
||||
OverlayLayerProvider,
|
||||
useOverlayLayer,
|
||||
useWebOverlayRegistration,
|
||||
} from "@/lib/overlay-root";
|
||||
import { getOverlayRoot, OverlayLayerProvider, useOverlayLayer } from "@/lib/overlay-root";
|
||||
import {
|
||||
computePosition,
|
||||
getTransformOrigin,
|
||||
@@ -405,8 +402,8 @@ export function MenuOverlay({
|
||||
}): ReactElement | null {
|
||||
const floatingLayer = useOverlayLayer("floating");
|
||||
|
||||
const handleWebOverlayKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
const handleOverlayKeyDown = useCallback(
|
||||
(event: KeyboardInputEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -414,44 +411,15 @@ export function MenuOverlay({
|
||||
return true;
|
||||
}
|
||||
|
||||
const target = event.target instanceof Element ? event.target : null;
|
||||
const surface = target?.closest<HTMLElement>('[data-menu-surface="true"]');
|
||||
if (!surface) return false;
|
||||
const items = Array.from(
|
||||
surface.querySelectorAll<HTMLElement>(
|
||||
'[data-menu-item="true"]:not([data-menu-disabled="true"])',
|
||||
),
|
||||
);
|
||||
if (items.length === 0) return false;
|
||||
const currentIndex = items.findIndex((item) => item === document.activeElement);
|
||||
let nextIndex: number | null = null;
|
||||
if (event.key === "ArrowDown")
|
||||
nextIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % items.length;
|
||||
if (event.key === "ArrowUp")
|
||||
nextIndex =
|
||||
currentIndex < 0 ? items.length - 1 : (currentIndex - 1 + items.length) % items.length;
|
||||
if (event.key === "Home") nextIndex = 0;
|
||||
if (event.key === "End") nextIndex = items.length - 1;
|
||||
if (nextIndex !== null) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
items[nextIndex]?.focus();
|
||||
return true;
|
||||
}
|
||||
if ((event.key === "Enter" || event.key === " ") && currentIndex >= 0) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
items[currentIndex]?.click();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
return navigateMenuKeyboard(event);
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
const setWebOverlayScope = useWebOverlayRegistration({
|
||||
active: isWeb && visible,
|
||||
const setOverlayScope = useOverlayRegistration({
|
||||
keys: ["Escape"],
|
||||
active: visible,
|
||||
layer: floatingLayer,
|
||||
onKeyDown: handleWebOverlayKeyDown,
|
||||
onKeyDown: handleOverlayKeyDown,
|
||||
restoreFocusRef,
|
||||
});
|
||||
|
||||
@@ -463,7 +431,7 @@ export function MenuOverlay({
|
||||
{...{
|
||||
onContextMenu: (event: { preventDefault?: () => void }) => event.preventDefault?.(),
|
||||
}}
|
||||
ref={setWebOverlayScope}
|
||||
ref={setOverlayScope}
|
||||
collapsable={false}
|
||||
style={[
|
||||
styles.overlay,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useMemo, type ReactElement } from "react";
|
||||
import { Text, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useKeyboardShortcutsAvailable } from "@/keyboard/availability";
|
||||
import { useHardwareKeyboardPresent } from "@/keyboard";
|
||||
import { normalizeDisplayChord } from "@/components/ui/normalize-display-chord";
|
||||
import { formatShortcut, type ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
@@ -17,7 +17,7 @@ export function Shortcut({
|
||||
style?: StyleProp<ViewStyle>;
|
||||
textStyle?: StyleProp<TextStyle>;
|
||||
}): ReactElement | null {
|
||||
const shortcutsAvailable = useKeyboardShortcutsAvailable();
|
||||
const shortcutsAvailable = useHardwareKeyboardPresent();
|
||||
const displayChord = normalizeDisplayChord(chord, keys);
|
||||
const shortcutOs = getShortcutOs();
|
||||
|
||||
|
||||
@@ -125,6 +125,20 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps keyboard navigation populated when the sidebar starts hidden", async () => {
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<SidebarModelProvider active={false} keyboardNavigationActive>
|
||||
<WorkspaceShortcutTargetsSubscriber enabled />
|
||||
</SidebarModelProvider>,
|
||||
);
|
||||
});
|
||||
expect(useKeyboardShortcutsStore.getState().sidebarShortcutWorkspaceTargets).toEqual([
|
||||
{ serverId: "srv", workspaceId: "ws-1" },
|
||||
{ serverId: "srv", workspaceId: "ws-2" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("publishes workspace shortcut targets without rendering the sidebar", async () => {
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import React from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { expect, it } from "vitest";
|
||||
import { dispatchTopOverlayKeyDown } from "@/keyboard";
|
||||
import { useComposerFullscreenExit } from "./fullscreen-exit";
|
||||
|
||||
it("the native entry claims Escape until fullscreen closes", () => {
|
||||
let exits = 0;
|
||||
function Harness({ active }: { active: boolean }) {
|
||||
useComposerFullscreenExit(active, () => exits++);
|
||||
return null;
|
||||
}
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
const root = createRoot(container);
|
||||
try {
|
||||
flushSync(() => root.render(<Harness active />));
|
||||
expect(dispatchTopOverlayKeyDown(new KeyboardEvent("keydown", { key: "Escape" }))).toBe(true);
|
||||
expect(exits).toBe(1);
|
||||
flushSync(() => root.render(<Harness active={false} />));
|
||||
expect(dispatchTopOverlayKeyDown(new KeyboardEvent("keydown", { key: "Escape" }))).toBe(false);
|
||||
expect(exits).toBe(1);
|
||||
} finally {
|
||||
root.unmount();
|
||||
container.remove();
|
||||
}
|
||||
});
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useOverlayLayer, useWebOverlayRegistration } from "@/lib/overlay-root";
|
||||
import { useComposerHardwareBackExit } from "./hardware-back-exit";
|
||||
import { useOverlayRegistration } from "@/keyboard";
|
||||
import { useOverlayLayer } from "@/lib/overlay-root";
|
||||
|
||||
/**
|
||||
* Escape leaves fullscreen before app shortcuts see it. The registration only claims
|
||||
@@ -8,8 +10,10 @@ export function useComposerFullscreenExit(
|
||||
active: boolean,
|
||||
onExit: () => void,
|
||||
): ((node: unknown) => void) | undefined {
|
||||
useComposerHardwareBackExit(active, onExit);
|
||||
const layer = useOverlayLayer("modal");
|
||||
return useWebOverlayRegistration({
|
||||
return useOverlayRegistration({
|
||||
keys: ["Escape"],
|
||||
active,
|
||||
layer,
|
||||
manageFocus: false,
|
||||
|
||||
+1
-5
@@ -2,10 +2,7 @@ import { useEffect } from "react";
|
||||
import { BackHandler } from "react-native";
|
||||
|
||||
/** The hardware back button leaves fullscreen instead of the screen. */
|
||||
export function useComposerFullscreenExit(
|
||||
active: boolean,
|
||||
onExit: () => void,
|
||||
): ((node: unknown) => void) | undefined {
|
||||
export function useComposerHardwareBackExit(active: boolean, onExit: () => void): void {
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const subscription = BackHandler.addEventListener("hardwareBackPress", () => {
|
||||
@@ -14,5 +11,4 @@ export function useComposerFullscreenExit(
|
||||
});
|
||||
return () => subscription.remove();
|
||||
}, [active, onExit]);
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export function useComposerHardwareBackExit(_active: boolean, _onExit: () => void): void {}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useKeyboardFocusSurface } from "@/keyboard";
|
||||
import type { SendShortcut } from "@/hooks/use-settings";
|
||||
import {
|
||||
useComposerEditingSession,
|
||||
@@ -1288,6 +1289,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
const dictationToggleKeys = useShortcutKeys("dictation-toggle");
|
||||
const focusInputKeys = useShortcutKeys("focus-message-input");
|
||||
const [isInputFocused, setIsInputFocused] = useState(false);
|
||||
useKeyboardFocusSurface("message-input", isInputFocused && isPaneFocused);
|
||||
// Web text is DOM-owned between deferred draft publications. The action button only needs
|
||||
// this boundary, so publish empty/non-empty transitions without rerendering for every key.
|
||||
const initialHasLiveText = value.trim().length > 0;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Platform } from "react-native";
|
||||
import { getElectronHost } from "@/desktop/electron/host";
|
||||
import type { BrowserKeyboardPolicy } from "@/desktop/browser/shortcuts";
|
||||
import type { BrowserKeyboardPolicy } from "@/keyboard";
|
||||
import type { SessionInboundMessage, SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||
|
||||
type BrowserAutomationExecuteRequest = Extract<
|
||||
|
||||
@@ -12,7 +12,7 @@ import { StyleSheet } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PaneFind, findShortcutPlatform, isFindShortcut, type PaneFindHandle } from "@/pane-find";
|
||||
import { usePaneFocus } from "@/panels/pane-context";
|
||||
import { hasActiveWebOverlay } from "@/lib/overlay-root";
|
||||
import { hasActiveOverlay } from "@/keyboard";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
|
||||
import { FileFindModel } from "./model.web";
|
||||
@@ -39,7 +39,7 @@ export function FileFind({
|
||||
useEffect(() => {
|
||||
if (!isInteractive || !active) return;
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.defaultPrevented || hasActiveWebOverlay() || isImeComposingKeyboardEvent(event))
|
||||
if (event.defaultPrevented || hasActiveOverlay() || isImeComposingKeyboardEvent(event))
|
||||
return;
|
||||
if (isFindShortcut(event, findShortcutPlatform())) {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -1,432 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { usePathname, useRouter } from "expo-router";
|
||||
import { getIsElectronRuntime } from "@/constants/layout";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
|
||||
import { getResidentBrowserWebview } from "@/desktop/browser/resident-webviews";
|
||||
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
|
||||
import { useKeyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher-context";
|
||||
import {
|
||||
type ChordState,
|
||||
type KeyboardShortcutInput,
|
||||
resolveKeyboardShortcut,
|
||||
buildEffectiveBindings,
|
||||
getWorkspaceIndexJumpModifierKey,
|
||||
} from "@/keyboard/keyboard-shortcuts";
|
||||
import { resolveKeyboardFocusScope } from "@/keyboard/focus-scope";
|
||||
import {
|
||||
buildBrowserKeyboardPolicy,
|
||||
parseBrowserShortcutInput,
|
||||
shouldPublishBrowserShortcutPolicy,
|
||||
} from "@/desktop/browser/shortcuts";
|
||||
import type { KeyboardFocusScope, KeyboardShortcutPayload } from "@/keyboard/actions";
|
||||
import {
|
||||
routeKeyboardShortcut,
|
||||
type ShortcutAction,
|
||||
type ShortcutCallbackName,
|
||||
} from "@/keyboard/route-shortcut";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
import { useOpenAddProject } from "@/hooks/use-open-add-project";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { keyboardShortcutsAvailable } from "@/keyboard/availability";
|
||||
import { getDesktopHost, isElectronRuntime } from "@/desktop/host";
|
||||
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
|
||||
import { buildOpenProjectRoute } from "@/utils/host-routes";
|
||||
import { hasActiveWebOverlay } from "@/lib/overlay-root";
|
||||
import {
|
||||
type ActiveWorkspaceSelection,
|
||||
navigateToLastWorkspace,
|
||||
useActiveWorkspaceSelection,
|
||||
} from "@/stores/navigation-active-workspace-store";
|
||||
import { dispatchTopWebOverlayKeyDown, keyBelongsToDomPopover } from "@/lib/overlay-root";
|
||||
|
||||
export function useKeyboardShortcuts({
|
||||
enabled,
|
||||
isMobile,
|
||||
isWorkspaceFocusModeEnabled,
|
||||
toggleAgentList,
|
||||
toggleBothSidebars,
|
||||
exitFocusMode,
|
||||
cycleTheme,
|
||||
}: {
|
||||
enabled: boolean;
|
||||
isMobile: boolean;
|
||||
isWorkspaceFocusModeEnabled: boolean;
|
||||
toggleAgentList: () => void;
|
||||
toggleBothSidebars?: () => void;
|
||||
exitFocusMode: () => void;
|
||||
cycleTheme?: () => void;
|
||||
}) {
|
||||
const keyboardActionDispatcher = useKeyboardActionDispatcher();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const resetModifiers = useKeyboardShortcutsStore((s) => s.resetModifiers);
|
||||
const { overrides } = useKeyboardShortcutOverrides();
|
||||
const bindings = useMemo(() => buildEffectiveBindings(overrides), [overrides]);
|
||||
const shortcutsAvailable = keyboardShortcutsAvailable({ isNative, isCompact: isMobile });
|
||||
const isDesktopApp = getIsElectronRuntime();
|
||||
const isMac = getShortcutOs() === "mac";
|
||||
const chordStateRef = useRef<ChordState>({
|
||||
candidateIndices: [],
|
||||
step: 0,
|
||||
timeoutId: null,
|
||||
});
|
||||
const openProjectPickerAction = useOpenAddProject();
|
||||
const activeWorkspaceSelection = useActiveWorkspaceSelection();
|
||||
const keyboardWorkspaceSelectionRef = useRef<ActiveWorkspaceSelection | null>(null);
|
||||
|
||||
const publishBrowserShortcutPolicy = useCallback(
|
||||
(chordState?: ChordState) => {
|
||||
const policy =
|
||||
enabled && shortcutsAvailable
|
||||
? buildBrowserKeyboardPolicy({
|
||||
bindings,
|
||||
chordState,
|
||||
isMac,
|
||||
isDesktop: isDesktopApp,
|
||||
})
|
||||
: { menuPrefixes: [], prefixes: [] };
|
||||
void getDesktopHost()?.browser?.setShortcutPolicy?.(policy);
|
||||
},
|
||||
[bindings, enabled, isDesktopApp, isMac, shortcutsAvailable],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeWorkspaceSelection) {
|
||||
keyboardWorkspaceSelectionRef.current = activeWorkspaceSelection;
|
||||
}
|
||||
}, [activeWorkspaceSelection]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDesktopApp) {
|
||||
return;
|
||||
}
|
||||
|
||||
publishBrowserShortcutPolicy();
|
||||
}, [isDesktopApp, publishBrowserShortcutPolicy]);
|
||||
|
||||
// Only the modifier that actually performs the workspace-index jump on this
|
||||
// runtime should reveal the sidebar number badges (Alt on web, Cmd on
|
||||
// desktop Mac, Ctrl on desktop non-Mac). The store ORs altDown/cmdOrCtrlDown
|
||||
// to drive badge visibility, so we set the flag matching this runtime.
|
||||
// Derived from the effective bindings: `null` when the user unassigned or
|
||||
// rebound the jump shortcut, and no `event.key` ever equals null, so the
|
||||
// badges simply never appear.
|
||||
const badgeModifierKey = getWorkspaceIndexJumpModifierKey(
|
||||
{ isMac, isDesktop: isDesktopApp },
|
||||
bindings,
|
||||
);
|
||||
|
||||
// The keyup listener matches the released key against the current modifier,
|
||||
// so a modifier held while the jump binding changes could never be released
|
||||
// and the badges would stay up until a blur. Clear them when it changes.
|
||||
useEffect(() => {
|
||||
resetModifiers();
|
||||
}, [badgeModifierKey, resetModifiers]);
|
||||
|
||||
const setBadgeModifierDown = (down: boolean) => {
|
||||
const state = useKeyboardShortcutsStore.getState();
|
||||
if (isDesktopApp) {
|
||||
state.setCmdOrCtrlDown(down);
|
||||
} else {
|
||||
state.setAltDown(down);
|
||||
}
|
||||
};
|
||||
|
||||
const shouldHandle = () => {
|
||||
if (typeof document === "undefined") return false;
|
||||
if (document.visibilityState !== "visible") return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const captureCommandCenterFocusRestore = (event: KeyboardEvent) => {
|
||||
const target = event.target instanceof Element ? event.target : null;
|
||||
const targetEl =
|
||||
target?.closest?.("textarea, input, [contenteditable='true']") ??
|
||||
(target instanceof HTMLElement ? target : null);
|
||||
const active = document.activeElement;
|
||||
const activeEl = active instanceof HTMLElement ? active : null;
|
||||
setCommandCenterFocusRestoreElement((targetEl as HTMLElement | null) ?? activeEl ?? null);
|
||||
};
|
||||
|
||||
const callbacksByName: Record<ShortcutCallbackName, (() => void) | undefined> = {
|
||||
"toggle-agent-list": toggleAgentList,
|
||||
"toggle-both-sidebars": toggleBothSidebars,
|
||||
"cycle-theme": cycleTheme,
|
||||
};
|
||||
|
||||
const performShortcutAction = (
|
||||
action: ShortcutAction,
|
||||
event: KeyboardEvent | null,
|
||||
browserFocusRestoreElement: HTMLElement | null = null,
|
||||
): boolean => {
|
||||
switch (action.kind) {
|
||||
case "none":
|
||||
return false;
|
||||
case "dispatch":
|
||||
return keyboardActionDispatcher.dispatch(action.action);
|
||||
case "navigate-workspace":
|
||||
keyboardWorkspaceSelectionRef.current = {
|
||||
serverId: action.serverId,
|
||||
workspaceId: action.workspaceId,
|
||||
};
|
||||
navigateToWorkspace({ serverId: action.serverId, workspaceId: action.workspaceId });
|
||||
return true;
|
||||
case "navigate-last-workspace":
|
||||
if (navigateToLastWorkspace()) {
|
||||
return true;
|
||||
}
|
||||
router.replace(buildOpenProjectRoute());
|
||||
return true;
|
||||
case "router-replace":
|
||||
router.replace(action.route as Parameters<typeof router.replace>[0]);
|
||||
return true;
|
||||
case "router-back":
|
||||
router.back();
|
||||
return true;
|
||||
case "router-push":
|
||||
router.push(action.route as Parameters<typeof router.push>[0]);
|
||||
return true;
|
||||
case "open-project-picker":
|
||||
void openProjectPickerAction();
|
||||
return true;
|
||||
case "callback":
|
||||
callbacksByName[action.name]?.();
|
||||
return true;
|
||||
case "command-center-toggle": {
|
||||
if (action.nextOpen) {
|
||||
if (event) {
|
||||
captureCommandCenterFocusRestore(event);
|
||||
} else {
|
||||
setCommandCenterFocusRestoreElement(browserFocusRestoreElement);
|
||||
}
|
||||
}
|
||||
useKeyboardShortcutsStore
|
||||
.getState()
|
||||
.setCommandCenterOpen(action.nextOpen, action.scope ?? null);
|
||||
return true;
|
||||
}
|
||||
case "shortcuts-dialog-toggle":
|
||||
useKeyboardShortcutsStore.getState().setShortcutsDialogOpen(action.nextOpen);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
const routeAndPerformShortcut = (input: {
|
||||
action: string;
|
||||
payload: KeyboardShortcutPayload;
|
||||
domEvent: KeyboardEvent | null;
|
||||
browserFocusRestoreElement?: HTMLElement | null;
|
||||
}): boolean => {
|
||||
const store = useKeyboardShortcutsStore.getState();
|
||||
const shortcutAction = routeKeyboardShortcut(
|
||||
{ action: input.action, payload: input.payload },
|
||||
{
|
||||
pathname,
|
||||
isMobile,
|
||||
sidebarShortcutTargets: store.sidebarShortcutWorkspaceTargets,
|
||||
navigationActiveWorkspace:
|
||||
keyboardWorkspaceSelectionRef.current ?? activeWorkspaceSelection,
|
||||
commandCenterOpen: store.commandCenterOpen,
|
||||
shortcutsDialogOpen: store.shortcutsDialogOpen,
|
||||
},
|
||||
);
|
||||
const handled = performShortcutAction(
|
||||
shortcutAction,
|
||||
input.domEvent,
|
||||
input.browserFocusRestoreElement,
|
||||
);
|
||||
if (handled && isWorkspaceFocusModeEnabled && input.action.startsWith("sidebar.")) {
|
||||
exitFocusMode();
|
||||
}
|
||||
return handled;
|
||||
};
|
||||
|
||||
const resolveAndPerformShortcut = (input: {
|
||||
event: KeyboardShortcutInput;
|
||||
focusScope: KeyboardFocusScope;
|
||||
domEvent: KeyboardEvent | null;
|
||||
browserFocusRestoreElement?: HTMLElement | null;
|
||||
}) => {
|
||||
const store = useKeyboardShortcutsStore.getState();
|
||||
const previousChordState = chordStateRef.current;
|
||||
const result = resolveKeyboardShortcut({
|
||||
event: input.event,
|
||||
context: {
|
||||
isMac,
|
||||
isDesktop: isDesktopApp,
|
||||
focusScope: input.focusScope,
|
||||
commandCenterOpen: store.commandCenterOpen,
|
||||
},
|
||||
chordState: chordStateRef.current,
|
||||
onChordReset: () => {
|
||||
chordStateRef.current = {
|
||||
candidateIndices: [],
|
||||
step: 0,
|
||||
timeoutId: null,
|
||||
};
|
||||
publishBrowserShortcutPolicy();
|
||||
},
|
||||
bindings,
|
||||
});
|
||||
chordStateRef.current = result.nextChordState;
|
||||
if (
|
||||
shouldPublishBrowserShortcutPolicy({
|
||||
isBrowserInput: "browserId" in input.event,
|
||||
previousChordState,
|
||||
nextChordState: result.nextChordState,
|
||||
})
|
||||
) {
|
||||
publishBrowserShortcutPolicy(result.nextChordState);
|
||||
}
|
||||
|
||||
if (result.preventDefault && input.domEvent) {
|
||||
input.domEvent.preventDefault();
|
||||
input.domEvent.stopPropagation();
|
||||
}
|
||||
|
||||
if (!result.match) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handled = routeAndPerformShortcut({
|
||||
action: result.match.action,
|
||||
payload: result.match.payload,
|
||||
domEvent: input.domEvent,
|
||||
browserFocusRestoreElement: input.browserFocusRestoreElement,
|
||||
});
|
||||
if (!handled || !input.domEvent) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.match.preventDefault) {
|
||||
input.domEvent.preventDefault();
|
||||
}
|
||||
if (result.match.stopPropagation) {
|
||||
input.domEvent.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
// The window listeners must outlive ordinary re-renders: removing them resets
|
||||
// any in-progress chord. Stable events let them read the latest render instead
|
||||
// of being re-registered whenever a route, callback, or selection changes.
|
||||
const handleKeyDown = useStableEvent((event: KeyboardEvent) => {
|
||||
if (!shouldHandle()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (dispatchTopWebOverlayKeyDown(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (keyBelongsToDomPopover(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// During IME composition, Enter confirms the candidate selection and must
|
||||
// not route through global shortcuts like message send.
|
||||
if (isImeComposingKeyboardEvent(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const store = useKeyboardShortcutsStore.getState();
|
||||
if (store.capturingShortcut) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = event.key ?? "";
|
||||
if (
|
||||
key === "Escape" &&
|
||||
pathname.startsWith("/settings") &&
|
||||
!isMobile &&
|
||||
hasActiveWebOverlay()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (key === badgeModifierKey && !event.shiftKey) {
|
||||
setBadgeModifierDown(true);
|
||||
}
|
||||
if (key === "Shift") {
|
||||
const state = useKeyboardShortcutsStore.getState();
|
||||
if (state.altDown || state.cmdOrCtrlDown) {
|
||||
state.resetModifiers();
|
||||
}
|
||||
}
|
||||
|
||||
const focusScope = resolveKeyboardFocusScope({
|
||||
target: event.target,
|
||||
commandCenterOpen: store.commandCenterOpen,
|
||||
});
|
||||
resolveAndPerformShortcut({
|
||||
event,
|
||||
focusScope,
|
||||
domEvent: event,
|
||||
});
|
||||
});
|
||||
|
||||
const handleKeyUp = useStableEvent((event: KeyboardEvent) => {
|
||||
const key = event.key ?? "";
|
||||
if (key === badgeModifierKey) {
|
||||
setBadgeModifierDown(false);
|
||||
}
|
||||
});
|
||||
|
||||
const handleBrowserShortcutInput = useStableEvent((payload: unknown) => {
|
||||
const input = parseBrowserShortcutInput(payload);
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
resolveAndPerformShortcut({
|
||||
event: input,
|
||||
focusScope: "browser",
|
||||
domEvent: null,
|
||||
browserFocusRestoreElement: getResidentBrowserWebview(input.browserId),
|
||||
});
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
if (!shortcutsAvailable) return;
|
||||
|
||||
const handleBlurOrHide = () => {
|
||||
resetModifiers();
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown, true);
|
||||
window.addEventListener("keyup", handleKeyUp, true);
|
||||
window.addEventListener("blur", handleBlurOrHide);
|
||||
document.addEventListener("visibilitychange", handleBlurOrHide);
|
||||
|
||||
const browserShortcutSubscription = isElectronRuntime()
|
||||
? getDesktopHost()?.events?.on?.("browser-shortcut-input", handleBrowserShortcutInput)
|
||||
: null;
|
||||
return () => {
|
||||
if (chordStateRef.current.timeoutId !== null) {
|
||||
clearTimeout(chordStateRef.current.timeoutId);
|
||||
chordStateRef.current = {
|
||||
candidateIndices: [],
|
||||
step: 0,
|
||||
timeoutId: null,
|
||||
};
|
||||
}
|
||||
window.removeEventListener("keydown", handleKeyDown, true);
|
||||
window.removeEventListener("keyup", handleKeyUp, true);
|
||||
window.removeEventListener("blur", handleBlurOrHide);
|
||||
document.removeEventListener("visibilitychange", handleBlurOrHide);
|
||||
if (typeof browserShortcutSubscription === "function") {
|
||||
browserShortcutSubscription();
|
||||
} else {
|
||||
void browserShortcutSubscription?.then((dispose) => dispose());
|
||||
}
|
||||
};
|
||||
}, [
|
||||
enabled,
|
||||
handleBrowserShortcutInput,
|
||||
handleKeyDown,
|
||||
handleKeyUp,
|
||||
resetModifiers,
|
||||
shortcutsAvailable,
|
||||
]);
|
||||
}
|
||||
@@ -1,21 +1,20 @@
|
||||
import { usesDesktopBindings } from "@/keyboard";
|
||||
import { useMemo } from "react";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { resolveShortcutKeysForAction } from "@/keyboard/keyboard-shortcuts";
|
||||
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
import { getIsElectronRuntime } from "@/constants/layout";
|
||||
|
||||
/** `null` action ids are accepted so callers can keep the hook unconditional. */
|
||||
export function useShortcutKeys(actionId: string | null): ShortcutKey[][] | null {
|
||||
const { overrides } = useKeyboardShortcutOverrides();
|
||||
const isMac = getShortcutOs() === "mac";
|
||||
const isDesktopApp = getIsElectronRuntime();
|
||||
|
||||
return useMemo(() => {
|
||||
if (actionId === null) return null;
|
||||
return resolveShortcutKeysForAction(actionId, overrides, {
|
||||
isMac,
|
||||
isDesktop: isDesktopApp,
|
||||
usesDesktopBindings,
|
||||
});
|
||||
}, [actionId, overrides, isMac, isDesktopApp]);
|
||||
}, [actionId, overrides, isMac]);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useOverlayRegistration, type KeyboardInputEvent } from "@/keyboard";
|
||||
import { useAddHost } from "@/hosts/add-host";
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import { createElement, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
@@ -12,11 +13,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { HostStatusDotSlot } from "@/components/hosts/host-picker";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
|
||||
import {
|
||||
OverlayLayerProvider,
|
||||
useGlobalWebOverlayLayer,
|
||||
useWebOverlayRegistration,
|
||||
} from "@/lib/overlay-root";
|
||||
import { OverlayLayerProvider, useGlobalOverlayLayer } from "@/lib/overlay-root";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { orderHostsLocalFirst, type HostProfile } from "@/types/host-connection";
|
||||
|
||||
@@ -145,7 +142,7 @@ export function HostChooserModal() {
|
||||
const inputRef = useRef<EditingTextInputHandle>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const modalLayer = useGlobalWebOverlayLayer("modal", isWeb && request != null);
|
||||
const modalLayer = useGlobalOverlayLayer("modal", request != null);
|
||||
|
||||
const requestHosts = useMemo(() => {
|
||||
if (!request) return [];
|
||||
@@ -186,8 +183,8 @@ export function HostChooserModal() {
|
||||
[close, request],
|
||||
);
|
||||
|
||||
const handleWebOverlayKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
const handleOverlayKeyDown = useCallback(
|
||||
(event: KeyboardInputEvent) => {
|
||||
if (
|
||||
event.key !== "ArrowDown" &&
|
||||
event.key !== "ArrowUp" &&
|
||||
@@ -227,51 +224,56 @@ export function HostChooserModal() {
|
||||
},
|
||||
[activeOptionIndex, chooseHost, close, options],
|
||||
);
|
||||
const setWebOverlayScope = useWebOverlayRegistration({
|
||||
active: isWeb && request != null,
|
||||
const setOverlayScope = useOverlayRegistration({
|
||||
keys: ["Escape", "Enter", "ArrowUp", "ArrowDown"],
|
||||
active: request != null,
|
||||
layer: modalLayer,
|
||||
onKeyDown: handleWebOverlayKeyDown,
|
||||
onKeyDown: handleOverlayKeyDown,
|
||||
});
|
||||
|
||||
if (!request) return null;
|
||||
|
||||
const modal = (
|
||||
<Modal visible transparent animationType="fade" onRequestClose={close} testID="host-chooser">
|
||||
<View style={styles.overlay}>
|
||||
<Pressable style={styles.backdrop} onPress={close} />
|
||||
<View ref={setWebOverlayScope} style={styles.panel}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>{request.title}</Text>
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
initialValue={query}
|
||||
onChangeText={handleQueryChange}
|
||||
placeholder="Search hosts..."
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
style={styles.input}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
testID="host-chooser-search"
|
||||
/>
|
||||
</View>
|
||||
<ScrollView
|
||||
style={styles.results}
|
||||
contentContainerStyle={styles.resultsContent}
|
||||
keyboardShouldPersistTaps="always"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{options.length === 0 ? <Text style={styles.emptyText}>No matching hosts</Text> : null}
|
||||
{options.map((host, index) => (
|
||||
<HostChooserRow
|
||||
key={host.serverId}
|
||||
host={host}
|
||||
active={index === activeOptionIndex}
|
||||
onChooseHost={chooseHost}
|
||||
<OverlayLayerProvider layer={modalLayer}>
|
||||
<View style={styles.overlay}>
|
||||
<Pressable style={styles.backdrop} onPress={close} />
|
||||
<View ref={setOverlayScope} style={styles.panel}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>{request.title}</Text>
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
initialValue={query}
|
||||
onChangeText={handleQueryChange}
|
||||
placeholder="Search hosts..."
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
style={styles.input}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
testID="host-chooser-search"
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
<ScrollView
|
||||
style={styles.results}
|
||||
contentContainerStyle={styles.resultsContent}
|
||||
keyboardShouldPersistTaps="always"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{options.length === 0 ? (
|
||||
<Text style={styles.emptyText}>No matching hosts</Text>
|
||||
) : null}
|
||||
{options.map((host, index) => (
|
||||
<HostChooserRow
|
||||
key={host.serverId}
|
||||
host={host}
|
||||
active={index === activeOptionIndex}
|
||||
onChooseHost={chooseHost}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</OverlayLayerProvider>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
|
||||
@@ -1,11 +1,68 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { keyboardShortcutsAvailable } from "./availability";
|
||||
import { createHardwareKeyboardPresence } from "./internal/hardware-presence";
|
||||
import { buildEffectiveBindings } from "./keyboard-shortcuts";
|
||||
import { createKeyboardPipeline } from "./internal/pipeline";
|
||||
import { createOverlayRegistry } from "./internal/overlays";
|
||||
import { nativeKeyboardInput } from "./internal/sources/contract";
|
||||
|
||||
describe("keyboardShortcutsAvailable", () => {
|
||||
it("matches the environments where the shortcut dispatcher runs", () => {
|
||||
expect(keyboardShortcutsAvailable({ isNative: false, isCompact: false })).toBe(true);
|
||||
expect(keyboardShortcutsAvailable({ isNative: false, isCompact: true })).toBe(false);
|
||||
expect(keyboardShortcutsAvailable({ isNative: true, isCompact: false })).toBe(false);
|
||||
expect(keyboardShortcutsAvailable({ isNative: true, isCompact: true })).toBe(false);
|
||||
describe("hardware keyboard availability", () => {
|
||||
it("publishes connection changes and cleans up the capability subscription", () => {
|
||||
let present = false;
|
||||
const changes = new Set<() => void>();
|
||||
const presence = createHardwareKeyboardPresence({
|
||||
read: () => present,
|
||||
watch: (changed) => {
|
||||
changes.add(changed);
|
||||
return () => {
|
||||
changes.delete(changed);
|
||||
};
|
||||
},
|
||||
});
|
||||
const observed: boolean[] = [];
|
||||
const release = presence.subscribe(() => observed.push(presence.getSnapshot()));
|
||||
expect(presence.getSnapshot()).toBe(false);
|
||||
present = true;
|
||||
for (const changed of changes) changed();
|
||||
expect(presence.getSnapshot()).toBe(true);
|
||||
for (const changed of changes) changed();
|
||||
present = false;
|
||||
for (const changed of changes) changed();
|
||||
expect(observed).toEqual([true, false]);
|
||||
release();
|
||||
expect(changes.size).toBe(0);
|
||||
});
|
||||
|
||||
it("routes compact Electron input independently of hint availability", () => {
|
||||
const presence = createHardwareKeyboardPresence({ read: () => false, watch: () => () => {} });
|
||||
const actions: string[] = [];
|
||||
const pipeline = createKeyboardPipeline({
|
||||
getContext: () => ({ isMac: true, usesDesktopBindings: true, commandCenterOpen: false }),
|
||||
getBindings: () => buildEffectiveBindings({}),
|
||||
isCapturing: () => false,
|
||||
dispatch: (match) => {
|
||||
actions.push(match.action);
|
||||
return true;
|
||||
},
|
||||
overlays: createOverlayRegistry(),
|
||||
});
|
||||
pipeline.keyDown(
|
||||
nativeKeyboardInput(
|
||||
{
|
||||
key: "k",
|
||||
code: "KeyK",
|
||||
metaKey: true,
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
shiftKey: false,
|
||||
repeat: false,
|
||||
preventDefault() {},
|
||||
stopPropagation() {},
|
||||
},
|
||||
"other",
|
||||
),
|
||||
);
|
||||
expect(presence.getSnapshot()).toBe(false);
|
||||
expect(actions).toEqual(["command-center.toggle"]);
|
||||
pipeline.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,19 +1,9 @@
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isNative } from "@/constants/platform";
|
||||
|
||||
interface KeyboardShortcutEnvironment {
|
||||
isNative: boolean;
|
||||
isCompact: boolean;
|
||||
}
|
||||
|
||||
export function keyboardShortcutsAvailable({
|
||||
isNative: native,
|
||||
isCompact,
|
||||
}: KeyboardShortcutEnvironment): boolean {
|
||||
return !native && !isCompact;
|
||||
}
|
||||
|
||||
export function useKeyboardShortcutsAvailable(): boolean {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
return keyboardShortcutsAvailable({ isNative, isCompact });
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { hardwareKeyboardPresence } from "./internal/presence";
|
||||
export function useHardwareKeyboardPresent(): boolean {
|
||||
return useSyncExternalStore(
|
||||
hardwareKeyboardPresence.subscribe,
|
||||
hardwareKeyboardPresence.getSnapshot,
|
||||
() => false,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
export { useOverlayRegistration } from "./internal/overlay-registration";
|
||||
export type { KeyboardInputEvent } from "./internal/overlays";
|
||||
export {
|
||||
dispatchTopOverlayKeyDown,
|
||||
hasActiveOverlay,
|
||||
getTopOverlayKeys,
|
||||
getTopOverlayLayer,
|
||||
subscribeOverlays,
|
||||
} from "./internal/overlay-dispatch";
|
||||
export { useKeyboardFocusSurface } from "./internal/use-focus-surface";
|
||||
export { usesDesktopBindings, supportsConfigurableComposerSendKeys } from "./internal/conventions";
|
||||
export type { BrowserKeyboardPolicy } from "./internal/sources/electron-browser";
|
||||
export { useHardwareKeyboardPresent } from "./availability";
|
||||
@@ -0,0 +1,3 @@
|
||||
export const usesDesktopBindings = true;
|
||||
// Native composer Return retains its existing submit path in this feature.
|
||||
export const supportsConfigurableComposerSendKeys = false;
|
||||
@@ -0,0 +1,3 @@
|
||||
import { isElectronRuntime } from "@/desktop/host";
|
||||
export const usesDesktopBindings = isElectronRuntime();
|
||||
export const supportsConfigurableComposerSendKeys = true;
|
||||
@@ -0,0 +1,6 @@
|
||||
export function keyBelongsToDomPopover(_event: { key: string }): boolean {
|
||||
return false;
|
||||
}
|
||||
export function hasOpenDomPopover(): boolean {
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* True while an element shown with the Popover API is open. Such a layer
|
||||
* floats above the app like a registered overlay but lives outside the
|
||||
* registry: plugins draw their panels this way, since they cannot register.
|
||||
*/
|
||||
export function hasOpenDomPopover(): boolean {
|
||||
if (typeof document === "undefined") return false;
|
||||
try {
|
||||
return document.querySelector(":popover-open") !== null;
|
||||
} catch {
|
||||
// An engine without the Popover API rejects the selector, and has none open.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a key belongs to an open popover rather than to the app's shortcuts.
|
||||
* An open popover (a plugin's panel) is the top layer and closes on its own
|
||||
* Escape listener, which runs after the shortcut engine's capture listener:
|
||||
* Escape must not also interrupt the running agent.
|
||||
*/
|
||||
export function keyBelongsToDomPopover(event: Pick<KeyboardEvent, "key">): boolean {
|
||||
return event.key === "Escape" && hasOpenDomPopover();
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { hasFocusedTextInput } from "./focused-text-input";
|
||||
import type { KeyboardFocusScope } from "../actions";
|
||||
export type KeyboardFocusSurface = "message-input" | "terminal";
|
||||
|
||||
export function createKeyboardFocusScope(hasEditableFocus: () => boolean = () => false) {
|
||||
const surfaces = new Map<symbol, KeyboardFocusSurface>();
|
||||
return {
|
||||
declare(surface: KeyboardFocusSurface) {
|
||||
const token = Symbol(surface);
|
||||
surfaces.set(token, surface);
|
||||
return () => {
|
||||
surfaces.delete(token);
|
||||
};
|
||||
},
|
||||
resolve(commandCenterOpen: boolean): KeyboardFocusScope {
|
||||
if (commandCenterOpen) return "command-center";
|
||||
let focused: KeyboardFocusScope = hasEditableFocus() ? "editable" : "other";
|
||||
for (const surface of surfaces.values()) focused = surface;
|
||||
return focused;
|
||||
},
|
||||
};
|
||||
}
|
||||
export const focusSurfaces = createKeyboardFocusScope(hasFocusedTextInput);
|
||||
@@ -0,0 +1,6 @@
|
||||
// oxlint-disable-next-line no-restricted-imports -- Keyboard boundary reads focus state; it does not render an input.
|
||||
import { TextInput } from "react-native";
|
||||
|
||||
export function hasFocusedTextInput(): boolean {
|
||||
return TextInput.State.currentlyFocusedInput() !== null;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function hasFocusedTextInput(): boolean {
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
interface PresenceSource {
|
||||
read(): boolean;
|
||||
watch(changed: () => void): () => void;
|
||||
}
|
||||
export function createHardwareKeyboardPresence(source: PresenceSource) {
|
||||
let present = source.read();
|
||||
const listeners = new Set<() => void>();
|
||||
let stop: (() => void) | undefined;
|
||||
const update = () => {
|
||||
const next = source.read();
|
||||
if (present === next) return;
|
||||
present = next;
|
||||
for (const listener of listeners) listener();
|
||||
};
|
||||
return {
|
||||
getSnapshot: () => present,
|
||||
subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
if (listeners.size === 1) {
|
||||
stop = source.watch(update);
|
||||
update();
|
||||
}
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
if (listeners.size === 0) {
|
||||
stop?.();
|
||||
stop = undefined;
|
||||
}
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { RefObject } from "react";
|
||||
import type { KeyboardInputEvent } from "./overlays";
|
||||
export interface OverlayRegistration {
|
||||
active: boolean;
|
||||
layer: number;
|
||||
keys: readonly string[];
|
||||
onKeyDown(event: KeyboardInputEvent): boolean;
|
||||
restoreFocusRef?: RefObject<unknown>;
|
||||
manageFocus?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { overlays } from "./overlays";
|
||||
export const dispatchTopOverlayKeyDown = overlays.dispatch;
|
||||
export const hasActiveOverlay = overlays.hasActive;
|
||||
export const getTopOverlayKeys = overlays.getKeys;
|
||||
export const subscribeOverlays = overlays.subscribe;
|
||||
export function getTopOverlayLayer(): number {
|
||||
return overlays.getTop()?.getLayer() ?? 0;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { OverlayRegistration } from "./overlay-contract";
|
||||
import { useLayoutEffect, useRef } from "react";
|
||||
import { overlays } from "./overlays";
|
||||
|
||||
const setScope = (_node: unknown) => {};
|
||||
export function useOverlayRegistration(input: OverlayRegistration) {
|
||||
const current = useRef(input);
|
||||
current.current = input;
|
||||
useLayoutEffect(() => {
|
||||
if (!input.active) return;
|
||||
return overlays.register({
|
||||
getLayer: () => current.current.layer,
|
||||
getKeys: () => current.current.keys,
|
||||
onKeyDown: (event) => current.current.onKeyDown(event),
|
||||
});
|
||||
}, [input.active]);
|
||||
return setScope;
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import { useCallback, useLayoutEffect, useRef } from "react";
|
||||
import { overlays, type OverlayEntry, type KeyboardInputEvent } from "./overlays";
|
||||
import type { OverlayRegistration } from "./overlay-contract";
|
||||
type WebOverlayKeyHandler = (event: KeyboardInputEvent) => boolean;
|
||||
|
||||
interface WebOverlayEntry {
|
||||
getLayer: () => number;
|
||||
getKeys: () => readonly string[];
|
||||
getScope: () => HTMLElement | null;
|
||||
getKeyHandler: () => WebOverlayKeyHandler;
|
||||
restoreFocus: HTMLElement | null;
|
||||
manageFocus: boolean;
|
||||
}
|
||||
|
||||
const focusEntries = new WeakMap<OverlayEntry, WebOverlayEntry>();
|
||||
let webOverlayListenersAttached = false;
|
||||
let webOverlayFocusCheckQueued = false;
|
||||
|
||||
interface RemoveWebOverlayOptions {
|
||||
restoreFocus?: boolean;
|
||||
}
|
||||
|
||||
function getTopWebOverlay(): WebOverlayEntry | undefined {
|
||||
const top = overlays.getTop();
|
||||
return top ? focusEntries.get(top) : undefined;
|
||||
}
|
||||
|
||||
function getFocusableElements(scope: HTMLElement): HTMLElement[] {
|
||||
const selector = [
|
||||
"a[href]",
|
||||
"button:not([disabled])",
|
||||
"input:not([disabled])",
|
||||
"select:not([disabled])",
|
||||
"textarea:not([disabled])",
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
].join(",");
|
||||
|
||||
return Array.from(scope.querySelectorAll<HTMLElement>(selector)).filter(
|
||||
(element) =>
|
||||
element.getAttribute("aria-hidden") !== "true" &&
|
||||
!element.hasAttribute("hidden") &&
|
||||
element.getClientRects().length > 0,
|
||||
);
|
||||
}
|
||||
|
||||
function focusFirstElement(scope: HTMLElement): void {
|
||||
const firstMenuItem = scope.querySelector<HTMLElement>(
|
||||
'[data-menu-item="true"]:not([data-menu-disabled="true"])',
|
||||
);
|
||||
const first = firstMenuItem ?? getFocusableElements(scope)[0];
|
||||
(first ?? scope).focus();
|
||||
}
|
||||
|
||||
function handleWebOverlayFocus(event: FocusEvent): void {
|
||||
const top = getTopWebOverlay();
|
||||
const scope = top?.getScope();
|
||||
if (!top?.manageFocus || !scope || scope.contains(event.target as Node)) return;
|
||||
if (webOverlayFocusCheckQueued) return;
|
||||
|
||||
// React can autofocus a child before its parent scope ref attaches. Defer
|
||||
// enforcement until the commit finishes so a newly mounted higher overlay
|
||||
// can register without the previous scope stealing the requested focus.
|
||||
webOverlayFocusCheckQueued = true;
|
||||
queueMicrotask(() => {
|
||||
webOverlayFocusCheckQueued = false;
|
||||
const currentTop = getTopWebOverlay();
|
||||
const currentScope = currentTop?.getScope();
|
||||
if (!currentTop?.manageFocus || !currentScope || currentScope.contains(document.activeElement))
|
||||
return;
|
||||
focusFirstElement(currentScope);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gives the top painted overlay first refusal on a key before app-wide shortcuts run.
|
||||
* The window listener below is the fallback for keys nobody routes through the shortcut engine.
|
||||
*/
|
||||
function dispatchOverlayEntry(entry: WebOverlayEntry, event: KeyboardInputEvent): boolean {
|
||||
// Composition keys belong to the active text field. A modal must not turn an
|
||||
// IME candidate confirmation into an overlay shortcut before the browser has
|
||||
// committed the composed text.
|
||||
if (event.isComposing || event.key === "Process") return false;
|
||||
|
||||
const top = entry;
|
||||
const scope = entry.getScope();
|
||||
if (!scope) return top.getKeyHandler()(event);
|
||||
|
||||
if (top.manageFocus && event.key === "Tab") {
|
||||
const focusable = getFocusableElements(scope);
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
const active = document.activeElement;
|
||||
const shouldWrapBackward = event.shiftKey && (!scope.contains(active) || active === first);
|
||||
const shouldWrapForward = !event.shiftKey && (!scope.contains(active) || active === last);
|
||||
if (shouldWrapBackward || shouldWrapForward) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
(shouldWrapBackward ? last : first)?.focus();
|
||||
if (!first) scope.focus();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!top.getKeyHandler()(event)) return false;
|
||||
event.stopPropagation();
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleWebOverlayKeyDown(event: KeyboardEvent): void {
|
||||
if (overlays.dispatch(event)) event.stopImmediatePropagation();
|
||||
}
|
||||
|
||||
function attachWebOverlayListeners(): void {
|
||||
if (webOverlayListenersAttached) return;
|
||||
window.addEventListener("keydown", handleWebOverlayKeyDown, true);
|
||||
document.addEventListener("focusin", handleWebOverlayFocus, true);
|
||||
webOverlayListenersAttached = true;
|
||||
}
|
||||
|
||||
function detachWebOverlayListeners(): void {
|
||||
if (!webOverlayListenersAttached || overlays.hasActive()) return;
|
||||
window.removeEventListener("keydown", handleWebOverlayKeyDown, true);
|
||||
document.removeEventListener("focusin", handleWebOverlayFocus, true);
|
||||
webOverlayListenersAttached = false;
|
||||
}
|
||||
|
||||
function addWebOverlay(entry: WebOverlayEntry): (options?: RemoveWebOverlayOptions) => void {
|
||||
const registration: OverlayEntry = {
|
||||
getLayer: entry.getLayer,
|
||||
getKeys: entry.getKeys,
|
||||
onKeyDown: (event) => dispatchOverlayEntry(entry, event),
|
||||
};
|
||||
focusEntries.set(registration, entry);
|
||||
const remove = overlays.register(registration);
|
||||
attachWebOverlayListeners();
|
||||
|
||||
const focusFrame = window.requestAnimationFrame(() => {
|
||||
const scope = entry.getScope();
|
||||
if (
|
||||
entry.manageFocus &&
|
||||
getTopWebOverlay() === entry &&
|
||||
scope &&
|
||||
!scope.contains(document.activeElement)
|
||||
) {
|
||||
focusFirstElement(scope);
|
||||
}
|
||||
});
|
||||
|
||||
return (options) => {
|
||||
window.cancelAnimationFrame(focusFrame);
|
||||
remove();
|
||||
detachWebOverlayListeners();
|
||||
if (
|
||||
entry.manageFocus &&
|
||||
options?.restoreFocus !== false &&
|
||||
entry.restoreFocus &&
|
||||
document.contains(entry.restoreFocus)
|
||||
) {
|
||||
entry.restoreFocus.focus();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a focus scope in the same relative layer model used for painting.
|
||||
* Only the highest painted overlay receives keyboard input. By default, focus is trapped
|
||||
* there and restored to the opener when it closes; hover surfaces preserve focus instead.
|
||||
*/
|
||||
export function useOverlayRegistration({
|
||||
active,
|
||||
layer,
|
||||
keys,
|
||||
onKeyDown,
|
||||
manageFocus = true,
|
||||
restoreFocusRef: preferredRestoreFocusRef,
|
||||
}: OverlayRegistration) {
|
||||
const scopeRef = useRef<HTMLElement | null>(null);
|
||||
const keysRef = useRef(keys);
|
||||
keysRef.current = keys;
|
||||
const layerRef = useRef(layer);
|
||||
const keyHandlerRef = useRef(onKeyDown);
|
||||
const capturedRestoreFocusRef = useRef<HTMLElement | null>(null);
|
||||
const removeEntryRef = useRef<((options?: RemoveWebOverlayOptions) => void) | null>(null);
|
||||
const detachedRemoveEntryRef = useRef<((options?: RemoveWebOverlayOptions) => void) | null>(null);
|
||||
const registeredRef = useRef(false);
|
||||
const activeRef = useRef(active);
|
||||
const wasActiveRef = useRef(false);
|
||||
|
||||
activeRef.current = active;
|
||||
layerRef.current = layer;
|
||||
keyHandlerRef.current = onKeyDown;
|
||||
if (active && !wasActiveRef.current && typeof document !== "undefined") {
|
||||
const requestedRestoreTarget = preferredRestoreFocusRef?.current;
|
||||
if (requestedRestoreTarget instanceof HTMLElement) {
|
||||
capturedRestoreFocusRef.current = requestedRestoreTarget;
|
||||
} else {
|
||||
capturedRestoreFocusRef.current =
|
||||
document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
}
|
||||
}
|
||||
wasActiveRef.current = active;
|
||||
|
||||
const syncRegistration = useCallback(() => {
|
||||
const shouldRegister =
|
||||
activeRef.current && typeof window !== "undefined" && typeof document !== "undefined";
|
||||
if (!shouldRegister) {
|
||||
const shouldRestoreFocus = !activeRef.current;
|
||||
if (registeredRef.current || shouldRestoreFocus) {
|
||||
removeEntryRef.current?.({ restoreFocus: shouldRestoreFocus });
|
||||
}
|
||||
registeredRef.current = false;
|
||||
if (shouldRestoreFocus) {
|
||||
removeEntryRef.current = null;
|
||||
detachedRemoveEntryRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (registeredRef.current) return;
|
||||
|
||||
detachedRemoveEntryRef.current = null;
|
||||
const entry: WebOverlayEntry = {
|
||||
getLayer: () => layerRef.current,
|
||||
getKeys: () => keysRef.current,
|
||||
getScope: () => scopeRef.current,
|
||||
getKeyHandler: () => keyHandlerRef.current,
|
||||
restoreFocus: capturedRestoreFocusRef.current,
|
||||
manageFocus,
|
||||
};
|
||||
removeEntryRef.current = addWebOverlay(entry);
|
||||
registeredRef.current = true;
|
||||
}, [manageFocus]);
|
||||
|
||||
const setScope = useCallback(
|
||||
(node: unknown) => {
|
||||
scopeRef.current =
|
||||
typeof HTMLElement !== "undefined" && node instanceof HTMLElement ? node : null;
|
||||
if (!scopeRef.current && activeRef.current) {
|
||||
const removeEntry = removeEntryRef.current;
|
||||
removeEntry?.({ restoreFocus: false });
|
||||
detachedRemoveEntryRef.current = removeEntry;
|
||||
removeEntryRef.current = null;
|
||||
registeredRef.current = false;
|
||||
return;
|
||||
}
|
||||
// Host refs attach before descendant layout effects and autofocus. Register
|
||||
// here so the previous overlay cannot redirect that pending focus.
|
||||
syncRegistration();
|
||||
},
|
||||
[syncRegistration],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
syncRegistration();
|
||||
return () => {
|
||||
const removeEntry = removeEntryRef.current ?? detachedRemoveEntryRef.current;
|
||||
removeEntry?.();
|
||||
removeEntryRef.current = null;
|
||||
detachedRemoveEntryRef.current = null;
|
||||
registeredRef.current = false;
|
||||
};
|
||||
}, [active, syncRegistration]);
|
||||
|
||||
return setScope;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createOverlayRegistry } from "./overlays";
|
||||
|
||||
function keyEvent(key: string) {
|
||||
return {
|
||||
key,
|
||||
code: key,
|
||||
altKey: false,
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
shiftKey: false,
|
||||
repeat: false,
|
||||
isComposing: false,
|
||||
preventDefault() {},
|
||||
stopPropagation() {},
|
||||
};
|
||||
}
|
||||
|
||||
describe("overlay keyboard ownership", () => {
|
||||
it("offers a key only to the highest layer and consumes it once without DOM focus", () => {
|
||||
const registry = createOverlayRegistry();
|
||||
const calls: string[] = [];
|
||||
const remove = registry.register({
|
||||
getLayer: () => 20,
|
||||
getKeys: () => ["Escape"],
|
||||
onKeyDown: (event) => {
|
||||
calls.push(`modal:${event.key}`);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
const removeMenu = registry.register({
|
||||
getLayer: () => 30,
|
||||
getKeys: () => ["Enter"],
|
||||
onKeyDown: (event) => {
|
||||
calls.push(`menu:${event.key}`);
|
||||
return event.key === "Enter";
|
||||
},
|
||||
});
|
||||
expect(registry.dispatch(keyEvent("Escape"))).toBe(false);
|
||||
expect(calls).toEqual(["menu:Escape"]);
|
||||
expect(registry.getKeys()).toEqual(["Enter"]);
|
||||
removeMenu();
|
||||
expect(registry.dispatch(keyEvent("Escape"))).toBe(true);
|
||||
expect(calls).toEqual(["menu:Escape", "modal:Escape"]);
|
||||
remove();
|
||||
expect(registry.dispatch(keyEvent("Escape"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("overlay registration lifecycle", () => {
|
||||
it("uses the newest registration at equal layers and ignores composition", () => {
|
||||
const registry = createOverlayRegistry();
|
||||
const calls: string[] = [];
|
||||
const first = registry.register({
|
||||
getLayer: () => 20,
|
||||
getKeys: () => ["Escape"],
|
||||
onKeyDown: () => {
|
||||
calls.push("first");
|
||||
return true;
|
||||
},
|
||||
});
|
||||
const second = registry.register({
|
||||
getLayer: () => 20,
|
||||
getKeys: () => ["Enter"],
|
||||
onKeyDown: () => {
|
||||
calls.push("second");
|
||||
return true;
|
||||
},
|
||||
});
|
||||
expect(registry.dispatch({ ...keyEvent("Enter"), isComposing: true })).toBe(false);
|
||||
expect(calls).toEqual([]);
|
||||
expect(registry.dispatch(keyEvent("Enter"))).toBe(true);
|
||||
expect(calls).toEqual(["second"]);
|
||||
second();
|
||||
expect(registry.dispatch(keyEvent("Escape"))).toBe(true);
|
||||
expect(calls).toEqual(["second", "first"]);
|
||||
first();
|
||||
});
|
||||
|
||||
it("notifies subscribers when top key ownership changes", () => {
|
||||
const registry = createOverlayRegistry();
|
||||
const observed: string[][] = [];
|
||||
const unsubscribe = registry.subscribe(() => observed.push([...registry.getKeys()]));
|
||||
const release = registry.register({
|
||||
getLayer: () => 20,
|
||||
getKeys: () => ["Enter"],
|
||||
onKeyDown: () => false,
|
||||
});
|
||||
release();
|
||||
release();
|
||||
unsubscribe();
|
||||
expect(observed).toEqual([["Enter"], []]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { KeyboardShortcutInput } from "../keyboard-shortcuts";
|
||||
|
||||
export interface KeyboardInputEvent extends KeyboardShortcutInput {
|
||||
isComposing?: boolean;
|
||||
preventDefault(): void;
|
||||
stopPropagation(): void;
|
||||
}
|
||||
export interface OverlayEntry {
|
||||
getLayer(): number;
|
||||
getKeys(): readonly string[];
|
||||
onKeyDown(event: KeyboardInputEvent): boolean;
|
||||
}
|
||||
export function createOverlayRegistry() {
|
||||
const entries: OverlayEntry[] = [];
|
||||
const listeners = new Set<() => void>();
|
||||
const notify = () => {
|
||||
for (const listener of listeners) listener();
|
||||
};
|
||||
function getTop(): OverlayEntry | undefined {
|
||||
return entries.reduce<OverlayEntry | undefined>(
|
||||
(top, entry) => (!top || entry.getLayer() >= top.getLayer() ? entry : top),
|
||||
undefined,
|
||||
);
|
||||
}
|
||||
return {
|
||||
getTop,
|
||||
getKeys: () => getTop()?.getKeys() ?? [],
|
||||
hasActive: () => entries.length > 0,
|
||||
subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
},
|
||||
register(entry: OverlayEntry) {
|
||||
entries.push(entry);
|
||||
notify();
|
||||
return () => {
|
||||
const index = entries.indexOf(entry);
|
||||
if (index < 0) return;
|
||||
entries.splice(index, 1);
|
||||
notify();
|
||||
};
|
||||
},
|
||||
dispatch(event: KeyboardInputEvent): boolean {
|
||||
if (event.isComposing || event.key === "Process") return false;
|
||||
if (!getTop()?.onKeyDown(event)) return false;
|
||||
event.stopPropagation();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
export const overlays = createOverlayRegistry();
|
||||
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
resolveKeyboardShortcut,
|
||||
type ChordState,
|
||||
type KeyboardShortcutContext,
|
||||
type KeyboardShortcutMatch,
|
||||
type ParsedShortcutBinding,
|
||||
} from "../keyboard-shortcuts";
|
||||
import type { KeyboardSourceInput } from "./sources/contract";
|
||||
import type { createOverlayRegistry } from "./overlays";
|
||||
|
||||
interface KeyboardPipelineOptions {
|
||||
getContext(): Omit<KeyboardShortcutContext, "focusScope">;
|
||||
getBindings(): readonly ParsedShortcutBinding[];
|
||||
isCapturing(): boolean;
|
||||
dispatch(match: KeyboardShortcutMatch, input: KeyboardSourceInput): boolean;
|
||||
overlays: ReturnType<typeof createOverlayRegistry>;
|
||||
beforeResolve?(input: KeyboardSourceInput): boolean;
|
||||
chordChanged?(previous: ChordState, next: ChordState, input: KeyboardSourceInput | null): void;
|
||||
}
|
||||
function emptyChord(): ChordState {
|
||||
return { candidateIndices: [], step: 0, timeoutId: null };
|
||||
}
|
||||
|
||||
export function createKeyboardPipeline(options: KeyboardPipelineOptions) {
|
||||
let chord = emptyChord();
|
||||
function reset() {
|
||||
const previous = chord;
|
||||
if (chord.timeoutId !== null) clearTimeout(chord.timeoutId);
|
||||
chord = emptyChord();
|
||||
options.chordChanged?.(previous, chord, null);
|
||||
}
|
||||
return {
|
||||
dispose: reset,
|
||||
keyDown(input: KeyboardSourceInput): boolean {
|
||||
const event = input.event;
|
||||
if (options.overlays.dispatch(event)) return true;
|
||||
if (event.isComposing || event.key === "Process" || options.isCapturing()) return false;
|
||||
if (options.beforeResolve && !options.beforeResolve(input)) return false;
|
||||
const previous = chord;
|
||||
const result = resolveKeyboardShortcut({
|
||||
event,
|
||||
context: { ...options.getContext(), focusScope: input.focusScope },
|
||||
bindings: options.getBindings(),
|
||||
chordState: chord,
|
||||
onChordReset: reset,
|
||||
});
|
||||
chord = result.nextChordState;
|
||||
options.chordChanged?.(previous, chord, input);
|
||||
if (result.preventDefault) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
if (!result.match) return false;
|
||||
if (!options.dispatch(result.match, input)) return false;
|
||||
if (result.match.preventDefault) event.preventDefault();
|
||||
if (result.match.stopPropagation) event.stopPropagation();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createHardwareKeyboardPresence } from "./hardware-presence";
|
||||
import { getHardwareKeyboardPresent, subscribeHardwareKeyboardPresence } from "./sources/hardware";
|
||||
export const hardwareKeyboardPresence = createHardwareKeyboardPresence({
|
||||
read: getHardwareKeyboardPresent,
|
||||
watch: subscribeHardwareKeyboardPresence,
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { createHardwareKeyboardPresence } from "./hardware-presence";
|
||||
|
||||
export function createWebHardwareKeyboardPresence(
|
||||
pointer: MediaQueryList | null,
|
||||
events: Window | null,
|
||||
) {
|
||||
let observedKeyboard = false;
|
||||
return createHardwareKeyboardPresence({
|
||||
read: () => observedKeyboard || pointer?.matches === true,
|
||||
watch(changed) {
|
||||
if (!events) return () => {};
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (!(event.altKey || event.ctrlKey || event.metaKey || event.shiftKey)) return;
|
||||
observedKeyboard = true;
|
||||
changed();
|
||||
};
|
||||
pointer?.addEventListener("change", changed);
|
||||
events.addEventListener("keydown", onKeyDown, true);
|
||||
return () => {
|
||||
pointer?.removeEventListener("change", changed);
|
||||
events.removeEventListener("keydown", onKeyDown, true);
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
const events = typeof window === "undefined" ? null : window;
|
||||
const pointer =
|
||||
events && typeof events.matchMedia === "function"
|
||||
? events.matchMedia("(hover: hover) and (pointer: fine)")
|
||||
: null;
|
||||
export const hardwareKeyboardPresence = createWebHardwareKeyboardPresence(pointer, events);
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { KeyboardFocusScope } from "../../actions";
|
||||
import type { KeyboardInputEvent } from "../overlays";
|
||||
export interface KeyboardSourceInput {
|
||||
event: KeyboardInputEvent;
|
||||
focusScope: KeyboardFocusScope;
|
||||
captureFocusRestore(): void;
|
||||
}
|
||||
export interface KeyboardSourceHandlers {
|
||||
keyDown(input: KeyboardSourceInput): void;
|
||||
keyUp(event: KeyboardInputEvent): void;
|
||||
blur(): void;
|
||||
commandCenterOpen(): boolean;
|
||||
}
|
||||
export function nativeKeyboardInput(
|
||||
event: KeyboardInputEvent,
|
||||
focusScope: KeyboardFocusScope,
|
||||
): KeyboardSourceInput {
|
||||
return { event, focusScope, captureFocusRestore() {} };
|
||||
}
|
||||
|
||||
import type { ChordState, ParsedShortcutBinding } from "../../keyboard-shortcuts";
|
||||
export interface KeyboardPolicyInput {
|
||||
bindings: readonly ParsedShortcutBinding[];
|
||||
chordState?: ChordState;
|
||||
isMac: boolean;
|
||||
enabled: boolean;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { resolveKeyboardFocusScope } from "../../focus-scope";
|
||||
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
|
||||
import { keyBelongsToDomPopover } from "../dom-popover";
|
||||
import type { KeyboardSourceHandlers } from "./contract";
|
||||
export function attachDomKeyboardSource(handlers: KeyboardSourceHandlers): () => void {
|
||||
const down = (event: KeyboardEvent) => {
|
||||
if (document.visibilityState !== "visible" || keyBelongsToDomPopover(event)) return;
|
||||
handlers.keyDown({
|
||||
event: {
|
||||
key: event.key,
|
||||
code: event.code,
|
||||
altKey: event.altKey,
|
||||
ctrlKey: event.ctrlKey,
|
||||
metaKey: event.metaKey,
|
||||
shiftKey: event.shiftKey,
|
||||
repeat: event.repeat,
|
||||
isComposing: event.isComposing || event.keyCode === 229,
|
||||
preventDefault: () => event.preventDefault(),
|
||||
stopPropagation: () => event.stopImmediatePropagation(),
|
||||
},
|
||||
focusScope: resolveKeyboardFocusScope({
|
||||
target: event.target,
|
||||
commandCenterOpen: handlers.commandCenterOpen(),
|
||||
}),
|
||||
captureFocusRestore() {
|
||||
const target = event.target instanceof Element ? event.target : null;
|
||||
const editable = target?.closest("textarea, input, [contenteditable='true']");
|
||||
const focused = editable instanceof HTMLElement ? editable : document.activeElement;
|
||||
setCommandCenterFocusRestoreElement(focused instanceof HTMLElement ? focused : null);
|
||||
},
|
||||
});
|
||||
};
|
||||
const up = (event: KeyboardEvent) => handlers.keyUp(event);
|
||||
window.addEventListener("keydown", down, true);
|
||||
window.addEventListener("keyup", up, true);
|
||||
window.addEventListener("blur", handlers.blur);
|
||||
document.addEventListener("visibilitychange", handlers.blur);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", down, true);
|
||||
window.removeEventListener("keyup", up, true);
|
||||
window.removeEventListener("blur", handlers.blur);
|
||||
document.removeEventListener("visibilitychange", handlers.blur);
|
||||
};
|
||||
}
|
||||
+76
-13
@@ -1,10 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
attachElectronBrowserSource,
|
||||
buildBrowserKeyboardPolicy,
|
||||
parseBrowserShortcutInput,
|
||||
shouldPublishBrowserShortcutPolicy,
|
||||
} from "./shortcuts";
|
||||
import { buildEffectiveBindings, resolveKeyboardShortcut } from "../../keyboard/keyboard-shortcuts";
|
||||
} from "./electron-browser";
|
||||
import { buildEffectiveBindings, resolveKeyboardShortcut } from "../../keyboard-shortcuts";
|
||||
|
||||
describe("buildBrowserKeyboardPolicy", () => {
|
||||
it("publishes only chord starts while no browser chord is pending", () => {
|
||||
@@ -13,7 +14,11 @@ describe("buildBrowserKeyboardPolicy", () => {
|
||||
"workspace-terminal-new-ctrl-shift-t-non-mac": "Ctrl+F12 Ctrl+F11",
|
||||
});
|
||||
|
||||
const policy = buildBrowserKeyboardPolicy({ bindings, isMac: false, isDesktop: true });
|
||||
const policy = buildBrowserKeyboardPolicy({
|
||||
bindings,
|
||||
isMac: false,
|
||||
usesDesktopBindings: true,
|
||||
});
|
||||
|
||||
expect(policy.prefixes).toContainEqual({
|
||||
alt: false,
|
||||
@@ -51,7 +56,7 @@ describe("buildBrowserKeyboardPolicy", () => {
|
||||
bindings,
|
||||
chordState: { candidateIndices: [chordIndex], step: 1, timeoutId: null },
|
||||
isMac: false,
|
||||
isDesktop: true,
|
||||
usesDesktopBindings: true,
|
||||
});
|
||||
|
||||
expect(policy.prefixes).toEqual([
|
||||
@@ -85,7 +90,7 @@ describe("buildBrowserKeyboardPolicy", () => {
|
||||
context: {
|
||||
commandCenterOpen: false,
|
||||
focusScope: "browser",
|
||||
isDesktop: true,
|
||||
usesDesktopBindings: true,
|
||||
isMac: false,
|
||||
},
|
||||
chordState: { candidateIndices: [chordIndex], step: 1, timeoutId: null },
|
||||
@@ -101,7 +106,11 @@ describe("buildBrowserKeyboardPolicy", () => {
|
||||
"settings-toggle-ctrl-comma-non-mac": "Ctrl+F10 F9",
|
||||
});
|
||||
|
||||
const policy = buildBrowserKeyboardPolicy({ bindings, isMac: false, isDesktop: true });
|
||||
const policy = buildBrowserKeyboardPolicy({
|
||||
bindings,
|
||||
isMac: false,
|
||||
usesDesktopBindings: true,
|
||||
});
|
||||
|
||||
expect(policy.prefixes).not.toContainEqual({
|
||||
alt: false,
|
||||
@@ -125,7 +134,7 @@ describe("buildBrowserKeyboardPolicy", () => {
|
||||
});
|
||||
|
||||
expect(
|
||||
buildBrowserKeyboardPolicy({ bindings, isMac: true, isDesktop: true }).prefixes,
|
||||
buildBrowserKeyboardPolicy({ bindings, isMac: true, usesDesktopBindings: true }).prefixes,
|
||||
).toContainEqual({
|
||||
alt: false,
|
||||
code: "KeyY",
|
||||
@@ -141,7 +150,11 @@ describe("buildBrowserKeyboardPolicy", () => {
|
||||
"workspace-tab-close-current-ctrl-w-non-mac": "Ctrl+Y",
|
||||
});
|
||||
|
||||
const policy = buildBrowserKeyboardPolicy({ bindings, isMac: false, isDesktop: true });
|
||||
const policy = buildBrowserKeyboardPolicy({
|
||||
bindings,
|
||||
isMac: false,
|
||||
usesDesktopBindings: true,
|
||||
});
|
||||
|
||||
expect(policy.prefixes).not.toContainEqual({
|
||||
alt: false,
|
||||
@@ -164,7 +177,7 @@ describe("buildBrowserKeyboardPolicy", () => {
|
||||
it("leaves macOS browser back and forward shortcuts in the guest", () => {
|
||||
const bindings = buildEffectiveBindings({});
|
||||
|
||||
const policy = buildBrowserKeyboardPolicy({ bindings, isMac: true, isDesktop: true });
|
||||
const policy = buildBrowserKeyboardPolicy({ bindings, isMac: true, usesDesktopBindings: true });
|
||||
|
||||
for (const code of ["BracketLeft", "BracketRight"]) {
|
||||
expect(policy.prefixes).not.toContainEqual({
|
||||
@@ -180,7 +193,7 @@ describe("buildBrowserKeyboardPolicy", () => {
|
||||
|
||||
it("marks editable-only exclusions for enforcement inside the guest", () => {
|
||||
const bindings = buildEffectiveBindings({});
|
||||
const policy = buildBrowserKeyboardPolicy({ bindings, isMac: true, isDesktop: true });
|
||||
const policy = buildBrowserKeyboardPolicy({ bindings, isMac: true, usesDesktopBindings: true });
|
||||
|
||||
expect(policy.prefixes).toContainEqual({
|
||||
alt: false,
|
||||
@@ -194,7 +207,11 @@ describe("buildBrowserKeyboardPolicy", () => {
|
||||
|
||||
it("does not publish plain browser keys", () => {
|
||||
const bindings = buildEffectiveBindings({});
|
||||
const policy = buildBrowserKeyboardPolicy({ bindings, isMac: false, isDesktop: true });
|
||||
const policy = buildBrowserKeyboardPolicy({
|
||||
bindings,
|
||||
isMac: false,
|
||||
usesDesktopBindings: true,
|
||||
});
|
||||
|
||||
expect(policy.prefixes).not.toContainEqual({
|
||||
alt: false,
|
||||
@@ -214,7 +231,7 @@ describe("buildBrowserKeyboardPolicy", () => {
|
||||
|
||||
it("publishes Cmd+B with its logical key for non-QWERTY layouts", () => {
|
||||
const bindings = buildEffectiveBindings({});
|
||||
const policy = buildBrowserKeyboardPolicy({ bindings, isMac: true, isDesktop: true });
|
||||
const policy = buildBrowserKeyboardPolicy({ bindings, isMac: true, usesDesktopBindings: true });
|
||||
|
||||
expect(policy.prefixes).toContainEqual({
|
||||
alt: false,
|
||||
@@ -228,7 +245,7 @@ describe("buildBrowserKeyboardPolicy", () => {
|
||||
|
||||
it("publishes the physical code needed for macOS Option shortcuts", () => {
|
||||
const bindings = buildEffectiveBindings({});
|
||||
const policy = buildBrowserKeyboardPolicy({ bindings, isMac: true, isDesktop: true });
|
||||
const policy = buildBrowserKeyboardPolicy({ bindings, isMac: true, usesDesktopBindings: true });
|
||||
|
||||
expect(policy.prefixes).toContainEqual({
|
||||
alt: true,
|
||||
@@ -320,3 +337,49 @@ describe("parseBrowserShortcutInput", () => {
|
||||
expect(parseBrowserShortcutInput(payload)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Electron guest source through shared resolution", () => {
|
||||
it("replays guest input through user rebinds and preserves focus restoration", async () => {
|
||||
const { createKeyboardPipeline } = await import("../pipeline");
|
||||
const { createOverlayRegistry } = await import("../overlays");
|
||||
const actions: string[] = [];
|
||||
const restored: string[] = [];
|
||||
let receive: (payload: unknown) => void = () => {};
|
||||
const pipeline = createKeyboardPipeline({
|
||||
getContext: () => ({ isMac: true, usesDesktopBindings: true, commandCenterOpen: false }),
|
||||
getBindings: () => buildEffectiveBindings({ "command-center-toggle-cmd-k-mac": "Cmd+J" }),
|
||||
isCapturing: () => false,
|
||||
overlays: createOverlayRegistry(),
|
||||
dispatch: (match, input) => {
|
||||
actions.push(match.action);
|
||||
input.captureFocusRestore();
|
||||
return true;
|
||||
},
|
||||
});
|
||||
const detach = attachElectronBrowserSource(
|
||||
{
|
||||
subscribe(handler) {
|
||||
receive = handler;
|
||||
return () => {
|
||||
receive = () => {};
|
||||
};
|
||||
},
|
||||
captureFocusRestore: (id) => restored.push(id),
|
||||
},
|
||||
{ keyDown: pipeline.keyDown, keyUp() {}, blur() {}, commandCenterOpen: () => false },
|
||||
);
|
||||
receive({
|
||||
browserId: "tab-1",
|
||||
key: "j",
|
||||
code: "KeyJ",
|
||||
alt: false,
|
||||
control: false,
|
||||
meta: true,
|
||||
shift: false,
|
||||
});
|
||||
expect(actions).toEqual(["command-center.toggle"]);
|
||||
expect(restored).toEqual(["tab-1"]);
|
||||
detach();
|
||||
pipeline.dispose();
|
||||
});
|
||||
});
|
||||
+27
-4
@@ -3,8 +3,8 @@ import {
|
||||
matchesKeyboardShortcutContext,
|
||||
type KeyboardShortcutInput,
|
||||
type ParsedShortcutBinding,
|
||||
} from "../../keyboard/keyboard-shortcuts";
|
||||
import type { KeyCombo } from "../../keyboard/shortcut-string";
|
||||
} from "../../keyboard-shortcuts";
|
||||
import type { KeyCombo } from "../../shortcut-string";
|
||||
|
||||
export interface BrowserShortcutPrefix {
|
||||
alt: boolean;
|
||||
@@ -32,7 +32,7 @@ interface BrowserShortcutPolicyInput {
|
||||
bindings: readonly ParsedShortcutBinding[];
|
||||
chordState?: ChordState;
|
||||
isMac: boolean;
|
||||
isDesktop: boolean;
|
||||
usesDesktopBindings: boolean;
|
||||
}
|
||||
|
||||
export function shouldPublishBrowserShortcutPolicy(input: {
|
||||
@@ -149,7 +149,7 @@ function buildBrowserShortcutPrefixes(input: BrowserShortcutPolicyInput): Browse
|
||||
const prefixes = new Map<string, BrowserShortcutPrefix>();
|
||||
const context = {
|
||||
isMac: input.isMac,
|
||||
isDesktop: input.isDesktop,
|
||||
usesDesktopBindings: input.usesDesktopBindings,
|
||||
focusScope: "browser" as const,
|
||||
commandCenterOpen: false,
|
||||
};
|
||||
@@ -209,3 +209,26 @@ export function buildBrowserKeyboardPolicy(
|
||||
}
|
||||
return { menuPrefixes, prefixes };
|
||||
}
|
||||
|
||||
interface ElectronBrowserKeySource {
|
||||
subscribe(handler: (payload: unknown) => void): (() => void) | Promise<() => void>;
|
||||
captureFocusRestore(browserId: string): void;
|
||||
}
|
||||
export function attachElectronBrowserSource(
|
||||
source: ElectronBrowserKeySource,
|
||||
handlers: import("./contract").KeyboardSourceHandlers,
|
||||
): () => void {
|
||||
const subscription = source.subscribe((payload) => {
|
||||
const input = parseBrowserShortcutInput(payload);
|
||||
if (!input) return;
|
||||
handlers.keyDown({
|
||||
event: { ...input, preventDefault() {}, stopPropagation() {} },
|
||||
focusScope: "browser",
|
||||
captureFocusRestore: () => source.captureFocusRestore(input.browserId),
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
if (typeof subscription === "function") subscription();
|
||||
else void subscription.then((dispose) => dispose());
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { requireNativeModule } from "expo-modules-core";
|
||||
import type { HardwareKeyboardEvent } from "./hardware";
|
||||
interface HardwareKeyboardModule {
|
||||
getHardwareKeyboardPresent(): boolean;
|
||||
addListener(event: "onHardwareKeyboardPresenceChanged", handler: () => void): { remove(): void };
|
||||
setOverlayKeys(keys: readonly string[]): void;
|
||||
addListener(
|
||||
event: "onHardwareKeyboardShortcut",
|
||||
handler: (event: HardwareKeyboardEvent) => void,
|
||||
): { remove(): void };
|
||||
}
|
||||
const module = requireNativeModule<HardwareKeyboardModule>("PaseoHardwareKeyboard");
|
||||
export function subscribeHardwareKeyboard(
|
||||
handler: (event: HardwareKeyboardEvent) => void,
|
||||
): () => void {
|
||||
const subscription = module.addListener("onHardwareKeyboardShortcut", handler);
|
||||
return () => subscription.remove();
|
||||
}
|
||||
export function setHardwareKeyboardOverlayKeys(keys: readonly string[]): void {
|
||||
module.setOverlayKeys(keys);
|
||||
}
|
||||
|
||||
export function getHardwareKeyboardPresent(): boolean {
|
||||
return module.getHardwareKeyboardPresent();
|
||||
}
|
||||
export function subscribeHardwareKeyboardPresence(changed: () => void): () => void {
|
||||
const subscription = module.addListener("onHardwareKeyboardPresenceChanged", changed);
|
||||
return () => subscription.remove();
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { requireNativeModule } from "expo-modules-core";
|
||||
import type { HardwareKeyboardEvent } from "./hardware";
|
||||
interface HardwareKeyboardModule {
|
||||
getHardwareKeyboardPresent(): boolean;
|
||||
addListener(event: "onHardwareKeyboardPresenceChanged", handler: () => void): { remove(): void };
|
||||
setOverlayKeys(keys: readonly string[]): void;
|
||||
addListener(
|
||||
event: "onHardwareKeyboardShortcut",
|
||||
handler: (event: HardwareKeyboardEvent) => void,
|
||||
): { remove(): void };
|
||||
}
|
||||
const module = requireNativeModule<HardwareKeyboardModule>("PaseoHardwareKeyboard");
|
||||
export function subscribeHardwareKeyboard(
|
||||
handler: (event: HardwareKeyboardEvent) => void,
|
||||
): () => void {
|
||||
const subscription = module.addListener("onHardwareKeyboardShortcut", handler);
|
||||
return () => subscription.remove();
|
||||
}
|
||||
export function setHardwareKeyboardOverlayKeys(keys: readonly string[]): void {
|
||||
module.setOverlayKeys(keys);
|
||||
}
|
||||
|
||||
export function getHardwareKeyboardPresent(): boolean {
|
||||
return module.getHardwareKeyboardPresent();
|
||||
}
|
||||
export function subscribeHardwareKeyboardPresence(changed: () => void): () => void {
|
||||
const subscription = module.addListener("onHardwareKeyboardPresenceChanged", changed);
|
||||
return () => subscription.remove();
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { KeyboardShortcutInput } from "../../keyboard-shortcuts";
|
||||
export interface HardwareKeyboardEvent extends KeyboardShortcutInput {
|
||||
isComposing: boolean;
|
||||
phase: "down" | "up";
|
||||
}
|
||||
export function subscribeHardwareKeyboard(
|
||||
_handler: (event: HardwareKeyboardEvent) => void,
|
||||
): () => void {
|
||||
return () => {};
|
||||
}
|
||||
export function setHardwareKeyboardOverlayKeys(_keys: readonly string[]): void {}
|
||||
|
||||
export function getHardwareKeyboardPresent(): boolean {
|
||||
return false;
|
||||
}
|
||||
export function subscribeHardwareKeyboardPresence(_changed: () => void): () => void {
|
||||
return () => {};
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { KeyboardSourceHandlers } from "./contract";
|
||||
import { setHardwareKeyboardOverlayKeys, subscribeHardwareKeyboard } from "./hardware";
|
||||
import { attachHardwareKeyboardSource } from "./native";
|
||||
export function attachKeyboardSources(handlers: KeyboardSourceHandlers): () => void {
|
||||
return attachHardwareKeyboardSource(
|
||||
{ subscribe: subscribeHardwareKeyboard, setOverlayKeys: setHardwareKeyboardOverlayKeys },
|
||||
handlers,
|
||||
);
|
||||
}
|
||||
|
||||
export function publishKeyboardPolicy(_input: import("./contract").KeyboardPolicyInput): void {}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { usesDesktopBindings } from "../conventions";
|
||||
import { getDesktopHost } from "@/desktop/host";
|
||||
import { getResidentBrowserWebview } from "@/desktop/browser/resident-webviews";
|
||||
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
|
||||
import { buildBrowserKeyboardPolicy, attachElectronBrowserSource } from "./electron-browser";
|
||||
import { attachDomKeyboardSource } from "./dom.web";
|
||||
import type { KeyboardSourceHandlers } from "./contract";
|
||||
export function attachKeyboardSources(handlers: KeyboardSourceHandlers): () => void {
|
||||
const detachDom = attachDomKeyboardSource(handlers);
|
||||
const host = getDesktopHost();
|
||||
const subscribeGuest = host?.events?.on;
|
||||
const detachGuest = subscribeGuest
|
||||
? attachElectronBrowserSource(
|
||||
{
|
||||
subscribe: (handler) => subscribeGuest("browser-shortcut-input", handler),
|
||||
captureFocusRestore: (browserId) =>
|
||||
setCommandCenterFocusRestoreElement(getResidentBrowserWebview(browserId)),
|
||||
},
|
||||
handlers,
|
||||
)
|
||||
: () => {};
|
||||
return () => {
|
||||
detachDom();
|
||||
detachGuest();
|
||||
};
|
||||
}
|
||||
export function publishKeyboardPolicy(input: import("./contract").KeyboardPolicyInput): void {
|
||||
const policy = input.enabled
|
||||
? buildBrowserKeyboardPolicy({ ...input, usesDesktopBindings: usesDesktopBindings })
|
||||
: { menuPrefixes: [], prefixes: [] };
|
||||
void getDesktopHost()?.browser?.setShortcutPolicy?.(policy);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { focusSurfaces } from "../focus-surfaces";
|
||||
import { overlays } from "../overlays";
|
||||
import { nativeKeyboardInput, type KeyboardSourceHandlers } from "./contract";
|
||||
import type { HardwareKeyboardEvent } from "./hardware";
|
||||
interface HardwareKeySource {
|
||||
subscribe(handler: (event: HardwareKeyboardEvent) => void): () => void;
|
||||
setOverlayKeys(keys: readonly string[]): void;
|
||||
}
|
||||
export function attachHardwareKeyboardSource(
|
||||
source: HardwareKeySource,
|
||||
handlers: KeyboardSourceHandlers,
|
||||
): () => void {
|
||||
const updateKeys = () => source.setOverlayKeys(overlays.getKeys());
|
||||
updateKeys();
|
||||
const unsubscribeOverlays = overlays.subscribe(updateKeys);
|
||||
const unsubscribeHardware = source.subscribe((input) => {
|
||||
const event = { ...input, preventDefault() {}, stopPropagation() {} };
|
||||
if (input.phase === "up") {
|
||||
handlers.keyUp(event);
|
||||
return;
|
||||
}
|
||||
handlers.keyDown(
|
||||
nativeKeyboardInput(event, focusSurfaces.resolve(handlers.commandCenterOpen())),
|
||||
);
|
||||
});
|
||||
return () => {
|
||||
unsubscribeHardware();
|
||||
unsubscribeOverlays();
|
||||
source.setOverlayKeys([]);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { useLayoutEffect } from "react";
|
||||
import { focusSurfaces, type KeyboardFocusSurface } from "./focus-surfaces";
|
||||
export function useKeyboardFocusSurface(surface: KeyboardFocusSurface, focused: boolean): void {
|
||||
useLayoutEffect(() => {
|
||||
if (focused) return focusSurfaces.declare(surface);
|
||||
}, [surface, focused]);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
import type { KeyboardFocusSurface } from "./focus-surfaces";
|
||||
export function useKeyboardFocusSurface(_surface: KeyboardFocusSurface, _focused: boolean): void {}
|
||||
@@ -40,7 +40,7 @@ function shortcutContext(
|
||||
): KeyboardShortcutContext {
|
||||
return {
|
||||
isMac: false,
|
||||
isDesktop: false,
|
||||
usesDesktopBindings: false,
|
||||
focusScope: "other",
|
||||
commandCenterOpen: false,
|
||||
...overrides,
|
||||
@@ -128,7 +128,7 @@ interface HelpSectionCase {
|
||||
name: string;
|
||||
context: {
|
||||
isMac: boolean;
|
||||
isDesktop: boolean;
|
||||
usesDesktopBindings: boolean;
|
||||
};
|
||||
expectedKeys: Record<string, string[]>;
|
||||
}
|
||||
@@ -174,49 +174,49 @@ describe("keyboard-shortcuts", () => {
|
||||
{
|
||||
name: "matches workspace index jump on web via Alt+digit",
|
||||
event: { key: "2", code: "Digit2", altKey: true },
|
||||
context: { isDesktop: false },
|
||||
context: { usesDesktopBindings: false },
|
||||
action: "workspace.navigate.index",
|
||||
payload: { index: 2 },
|
||||
},
|
||||
{
|
||||
name: "matches workspace index jump on desktop via Mod+digit",
|
||||
event: { key: "2", code: "Digit2", metaKey: true },
|
||||
context: { isMac: true, isDesktop: true },
|
||||
context: { isMac: true, usesDesktopBindings: true },
|
||||
action: "workspace.navigate.index",
|
||||
payload: { index: 2 },
|
||||
},
|
||||
{
|
||||
name: "matches tab index jump on mac desktop via Cmd+Alt+digit",
|
||||
event: { key: "@", code: "Digit2", metaKey: true, altKey: true },
|
||||
context: { isMac: true, isDesktop: true },
|
||||
context: { isMac: true, usesDesktopBindings: true },
|
||||
action: "workspace.tab.navigate.index",
|
||||
payload: { index: 2 },
|
||||
},
|
||||
{
|
||||
name: "matches tab index jump on non-mac desktop via Alt+digit",
|
||||
event: { key: "2", code: "Digit2", altKey: true },
|
||||
context: { isMac: false, isDesktop: true },
|
||||
context: { isMac: false, usesDesktopBindings: true },
|
||||
action: "workspace.tab.navigate.index",
|
||||
payload: { index: 2 },
|
||||
},
|
||||
{
|
||||
name: "matches tab index jump on web via Alt+Shift+digit",
|
||||
event: { key: "@", code: "Digit2", altKey: true, shiftKey: true },
|
||||
context: { isDesktop: false },
|
||||
context: { usesDesktopBindings: false },
|
||||
action: "workspace.tab.navigate.index",
|
||||
payload: { index: 2 },
|
||||
},
|
||||
{
|
||||
name: "matches workspace relative navigation on web via Alt+[",
|
||||
event: { key: "[", code: "BracketLeft", altKey: true },
|
||||
context: { isDesktop: false },
|
||||
context: { usesDesktopBindings: false },
|
||||
action: "workspace.navigate.relative",
|
||||
payload: { delta: -1 },
|
||||
},
|
||||
{
|
||||
name: "matches workspace relative navigation on desktop via Mod+]",
|
||||
event: { key: "]", code: "BracketRight", ctrlKey: true },
|
||||
context: { isDesktop: true },
|
||||
context: { usesDesktopBindings: true },
|
||||
action: "workspace.navigate.relative",
|
||||
payload: { delta: 1 },
|
||||
},
|
||||
@@ -235,19 +235,19 @@ describe("keyboard-shortcuts", () => {
|
||||
{
|
||||
name: "matches Alt+Shift+W to close current tab on web",
|
||||
event: { key: "W", code: "KeyW", altKey: true, shiftKey: true },
|
||||
context: { isDesktop: false },
|
||||
context: { usesDesktopBindings: false },
|
||||
action: "workspace.tab.close.current",
|
||||
},
|
||||
{
|
||||
name: "matches Cmd+W to close current tab on mac desktop",
|
||||
event: { key: "w", code: "KeyW", metaKey: true },
|
||||
context: { isMac: true, isDesktop: true },
|
||||
context: { isMac: true, usesDesktopBindings: true },
|
||||
action: "workspace.tab.close.current",
|
||||
},
|
||||
{
|
||||
name: "matches Ctrl+W to close current tab on non-mac desktop",
|
||||
event: { key: "w", code: "KeyW", ctrlKey: true },
|
||||
context: { isMac: false, isDesktop: true },
|
||||
context: { isMac: false, usesDesktopBindings: true },
|
||||
action: "workspace.tab.close.current",
|
||||
},
|
||||
{
|
||||
@@ -371,7 +371,7 @@ describe("keyboard-shortcuts", () => {
|
||||
{
|
||||
name: "matches Alt+[ to previous workspace on macOS web when Option substitutes event.key",
|
||||
event: { key: "\u201C", code: "BracketLeft", altKey: true },
|
||||
context: { isMac: true, isDesktop: false },
|
||||
context: { isMac: true, usesDesktopBindings: false },
|
||||
action: "workspace.navigate.relative",
|
||||
payload: { delta: -1 },
|
||||
preventDefault: true,
|
||||
@@ -380,7 +380,7 @@ describe("keyboard-shortcuts", () => {
|
||||
{
|
||||
name: "matches Alt+] to next workspace on macOS web when Option substitutes event.key",
|
||||
event: { key: "\u2018", code: "BracketRight", altKey: true },
|
||||
context: { isMac: true, isDesktop: false },
|
||||
context: { isMac: true, usesDesktopBindings: false },
|
||||
action: "workspace.navigate.relative",
|
||||
payload: { delta: 1 },
|
||||
preventDefault: true,
|
||||
@@ -389,7 +389,7 @@ describe("keyboard-shortcuts", () => {
|
||||
{
|
||||
name: "matches Alt+Shift+W to close current tab on macOS web when Option substitutes event.key",
|
||||
event: { key: "\u201E", code: "KeyW", altKey: true, shiftKey: true },
|
||||
context: { isMac: true, isDesktop: false },
|
||||
context: { isMac: true, usesDesktopBindings: false },
|
||||
action: "workspace.tab.close.current",
|
||||
},
|
||||
];
|
||||
@@ -453,12 +453,12 @@ describe("keyboard-shortcuts", () => {
|
||||
{
|
||||
name: "does not close tab with Ctrl+W on mac desktop (Cmd+W only)",
|
||||
event: { key: "w", code: "KeyW", ctrlKey: true },
|
||||
context: { isMac: true, isDesktop: true },
|
||||
context: { isMac: true, usesDesktopBindings: true },
|
||||
},
|
||||
{
|
||||
name: "does not close tab with Ctrl+W on non-mac desktop when terminal is focused",
|
||||
event: { key: "w", code: "KeyW", ctrlKey: true },
|
||||
context: { isMac: false, isDesktop: true, focusScope: "terminal" },
|
||||
context: { isMac: false, usesDesktopBindings: true, focusScope: "terminal" },
|
||||
},
|
||||
{
|
||||
name: "does not match Ctrl+T on mac (Cmd only)",
|
||||
@@ -468,7 +468,7 @@ describe("keyboard-shortcuts", () => {
|
||||
{
|
||||
name: "keeps mac Option+digit available for international text input",
|
||||
event: { key: "@", code: "Digit2", altKey: true },
|
||||
context: { isMac: true, isDesktop: true, focusScope: "message-input" },
|
||||
context: { isMac: true, usesDesktopBindings: true, focusScope: "message-input" },
|
||||
},
|
||||
{
|
||||
name: "does not match Ctrl+K for command center on non-mac in terminal",
|
||||
@@ -538,7 +538,7 @@ describe("keyboard-shortcuts", () => {
|
||||
{
|
||||
name: "keeps Dvorak Cmd+V available for paste in message input",
|
||||
event: { key: "v", code: "Period", metaKey: true },
|
||||
context: { isMac: true, isDesktop: true, focusScope: "message-input" },
|
||||
context: { isMac: true, usesDesktopBindings: true, focusScope: "message-input" },
|
||||
},
|
||||
// Sanity: the macOS Option-substitution fallback must still respect
|
||||
// modifier checks — pressing Option+T alone (no Cmd) must not trigger
|
||||
@@ -595,7 +595,7 @@ describe("keyboard-shortcuts", () => {
|
||||
|
||||
const firstResult = resolveShortcut({
|
||||
event: { key: "w", code: "KeyW", ctrlKey: true },
|
||||
context: { isMac: false, isDesktop: true },
|
||||
context: { isMac: false, usesDesktopBindings: true },
|
||||
bindings,
|
||||
});
|
||||
|
||||
@@ -606,7 +606,7 @@ describe("keyboard-shortcuts", () => {
|
||||
|
||||
const secondResult = resolveShortcut({
|
||||
event: { key: "s", code: "KeyS" },
|
||||
context: { isMac: false, isDesktop: true },
|
||||
context: { isMac: false, usesDesktopBindings: true },
|
||||
chordState: firstResult.nextChordState,
|
||||
bindings,
|
||||
});
|
||||
@@ -622,7 +622,7 @@ describe("keyboard-shortcuts", () => {
|
||||
it("resolves a browser-origin shortcut with browser focus instead of host focus", () => {
|
||||
expectShortcutResolution({
|
||||
event: { key: "t", code: "KeyT", ctrlKey: true },
|
||||
context: { isDesktop: true, focusScope: "browser" },
|
||||
context: { usesDesktopBindings: true, focusScope: "browser" },
|
||||
action: "workspace.tab.menu.open",
|
||||
});
|
||||
});
|
||||
@@ -636,12 +636,12 @@ describe("keyboard-shortcuts", () => {
|
||||
// so the stream for Ctrl+K, release, Ctrl+J is four keydowns, not two.
|
||||
const firstModifier = resolveShortcut({
|
||||
event: { key: "Control", code: "ControlLeft", ctrlKey: true },
|
||||
context: { isMac: false, isDesktop: true },
|
||||
context: { isMac: false, usesDesktopBindings: true },
|
||||
bindings,
|
||||
});
|
||||
const firstStep = resolveShortcut({
|
||||
event: { key: "k", code: "KeyK", ctrlKey: true },
|
||||
context: { isMac: false, isDesktop: true },
|
||||
context: { isMac: false, usesDesktopBindings: true },
|
||||
chordState: firstModifier.nextChordState,
|
||||
bindings,
|
||||
});
|
||||
@@ -649,13 +649,13 @@ describe("keyboard-shortcuts", () => {
|
||||
|
||||
const secondModifier = resolveShortcut({
|
||||
event: { key: "Control", code: "ControlLeft", ctrlKey: true },
|
||||
context: { isMac: false, isDesktop: true },
|
||||
context: { isMac: false, usesDesktopBindings: true },
|
||||
chordState: firstStep.nextChordState,
|
||||
bindings,
|
||||
});
|
||||
const secondStep = resolveShortcut({
|
||||
event: { key: "j", code: "KeyJ", ctrlKey: true },
|
||||
context: { isMac: false, isDesktop: true },
|
||||
context: { isMac: false, usesDesktopBindings: true },
|
||||
chordState: secondModifier.nextChordState,
|
||||
bindings,
|
||||
});
|
||||
@@ -673,7 +673,7 @@ describe("keyboard-shortcuts", () => {
|
||||
|
||||
const result = resolveShortcut({
|
||||
event: { key: "w", code: "KeyW", ctrlKey: true },
|
||||
context: { isMac: false, isDesktop: true },
|
||||
context: { isMac: false, usesDesktopBindings: true },
|
||||
onChordReset,
|
||||
bindings,
|
||||
});
|
||||
@@ -703,7 +703,7 @@ describe("keyboard-shortcut help sections", () => {
|
||||
const helpCases: HelpSectionCase[] = [
|
||||
{
|
||||
name: "uses web defaults for workspace and tab jump",
|
||||
context: { isMac: true, isDesktop: false },
|
||||
context: { isMac: true, usesDesktopBindings: false },
|
||||
expectedKeys: {
|
||||
"new-agent": ["mod", "O"],
|
||||
"workspace-tab-new": ["mod", "T"],
|
||||
@@ -717,7 +717,7 @@ describe("keyboard-shortcut help sections", () => {
|
||||
},
|
||||
{
|
||||
name: "uses desktop defaults for workspace and tab jump",
|
||||
context: { isMac: true, isDesktop: true },
|
||||
context: { isMac: true, usesDesktopBindings: true },
|
||||
expectedKeys: {
|
||||
"new-agent": ["mod", "O"],
|
||||
"new-workspace": ["mod", "N"],
|
||||
@@ -735,7 +735,7 @@ describe("keyboard-shortcut help sections", () => {
|
||||
},
|
||||
{
|
||||
name: "uses non-mac desktop defaults for tab jump and close tab",
|
||||
context: { isMac: false, isDesktop: true },
|
||||
context: { isMac: false, usesDesktopBindings: true },
|
||||
expectedKeys: {
|
||||
"workspace-tab-jump-index": ["alt", "1-9"],
|
||||
"workspace-tab-close-current": ["ctrl", "W"],
|
||||
@@ -743,7 +743,7 @@ describe("keyboard-shortcut help sections", () => {
|
||||
},
|
||||
{
|
||||
name: "uses ctrl+b for the left sidebar and ctrl+period for both sidebars on non-mac",
|
||||
context: { isMac: false, isDesktop: false },
|
||||
context: { isMac: false, usesDesktopBindings: false },
|
||||
// Derived from `combo: "Ctrl+B"` / `"Ctrl+."`, so the token is `ctrl` where
|
||||
// these rows used to be hand-authored as `mod`. Both bindings are non-mac
|
||||
// only and `formatShortcut` labels either token "Ctrl" there, so the badge
|
||||
@@ -764,7 +764,7 @@ describe("keyboard-shortcut help sections", () => {
|
||||
});
|
||||
|
||||
describe("rows derive their keys from the binding that fires", () => {
|
||||
const macDesktop = { isMac: true, isDesktop: true };
|
||||
const macDesktop = { isMac: true, usesDesktopBindings: true };
|
||||
const NEW_WORKSPACE_BINDING = "workspace-new-cmd-n-mac";
|
||||
const MAC_INDEX_BINDING = "workspace-navigate-index-cmd-digit-mac";
|
||||
const PANE_FOCUS_LEFT_BINDING = "workspace-pane-focus-left-cmd-shift-left";
|
||||
@@ -849,7 +849,10 @@ describe("keyboard-shortcut help sections", () => {
|
||||
// Same story on the other side: these rows said `mod`, the non-mac combos
|
||||
// say `Ctrl`, and non-mac labels both as "Ctrl".
|
||||
it("still renders the sidebar toggles as Ctrl+B and Ctrl+. on non-mac", () => {
|
||||
const sections = buildKeyboardShortcutHelpSections({ isMac: false, isDesktop: false });
|
||||
const sections = buildKeyboardShortcutHelpSections({
|
||||
isMac: false,
|
||||
usesDesktopBindings: false,
|
||||
});
|
||||
const left = findRow(sections, "toggle-left-sidebar")?.chord;
|
||||
const both = findRow(sections, "toggle-both-sidebars")?.chord;
|
||||
expect(formatShortcut(left?.[0] ?? [], "non-mac")).toBe("Ctrl+B");
|
||||
@@ -858,7 +861,7 @@ describe("keyboard-shortcut help sections", () => {
|
||||
});
|
||||
|
||||
it("returns stable i18n keys for section titles and help rows", () => {
|
||||
const sections = buildKeyboardShortcutHelpSections({ isMac: true, isDesktop: true });
|
||||
const sections = buildKeyboardShortcutHelpSections({ isMac: true, usesDesktopBindings: true });
|
||||
const workspaces = sections.find((section) => section.id === "workspaces");
|
||||
const layout = sections.find((section) => section.id === "layout");
|
||||
const openProject = findRow(sections, "new-agent");
|
||||
@@ -875,10 +878,10 @@ describe("keyboard-shortcut help sections", () => {
|
||||
|
||||
it("gives every help row an explicit place in its section's order", () => {
|
||||
const platforms = [
|
||||
{ isMac: true, isDesktop: true },
|
||||
{ isMac: false, isDesktop: true },
|
||||
{ isMac: true, isDesktop: false },
|
||||
{ isMac: false, isDesktop: false },
|
||||
{ isMac: true, usesDesktopBindings: true },
|
||||
{ isMac: false, usesDesktopBindings: true },
|
||||
{ isMac: true, usesDesktopBindings: false },
|
||||
{ isMac: false, usesDesktopBindings: false },
|
||||
];
|
||||
const unplaced: string[] = [];
|
||||
for (const platform of platforms) {
|
||||
@@ -894,7 +897,7 @@ describe("keyboard-shortcut help sections", () => {
|
||||
});
|
||||
|
||||
it("leads the general section with the command center and file search", () => {
|
||||
const sections = buildKeyboardShortcutHelpSections({ isMac: true, isDesktop: true });
|
||||
const sections = buildKeyboardShortcutHelpSections({ isMac: true, usesDesktopBindings: true });
|
||||
|
||||
expect(sections[0]?.id).toBe("general");
|
||||
expect(sections[0]?.rows.slice(0, 2).map((row) => row.id)).toEqual([
|
||||
@@ -904,28 +907,30 @@ describe("keyboard-shortcut help sections", () => {
|
||||
});
|
||||
|
||||
it("reuses the project-picker binding ids for rebindable file search", () => {
|
||||
expect(getBindingIdForAction("search-files", { isMac: true, isDesktop: true })).toBe(
|
||||
expect(getBindingIdForAction("search-files", { isMac: true, usesDesktopBindings: true })).toBe(
|
||||
"workspace-project-pick-cmd-p-mac",
|
||||
);
|
||||
expect(getBindingIdForAction("search-files", { isMac: false, isDesktop: true })).toBe(
|
||||
expect(getBindingIdForAction("search-files", { isMac: false, usesDesktopBindings: true })).toBe(
|
||||
"workspace-project-pick-ctrl-p-non-mac",
|
||||
);
|
||||
expect(
|
||||
findRow(buildKeyboardShortcutHelpSections({ isMac: true, isDesktop: true }), "search-files")
|
||||
?.chord,
|
||||
findRow(
|
||||
buildKeyboardShortcutHelpSections({ isMac: true, usesDesktopBindings: true }),
|
||||
"search-files",
|
||||
)?.chord,
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("does not expose Enter send behavior as rebindable shortcut rows", () => {
|
||||
const sections = buildKeyboardShortcutHelpSections({ isMac: true, isDesktop: true });
|
||||
const sections = buildKeyboardShortcutHelpSections({ isMac: true, usesDesktopBindings: true });
|
||||
|
||||
expect(findRow(sections, "message-input-send")).toBeNull();
|
||||
expect(findRow(sections, "message-input-queue")).toBeNull();
|
||||
expect(
|
||||
getBindingIdForAction("message-input-send", { isMac: true, isDesktop: true }),
|
||||
getBindingIdForAction("message-input-send", { isMac: true, usesDesktopBindings: true }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
getBindingIdForAction("message-input-queue", { isMac: true, isDesktop: true }),
|
||||
getBindingIdForAction("message-input-queue", { isMac: true, usesDesktopBindings: true }),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -934,45 +939,61 @@ describe("getWorkspaceIndexJumpModifierKey", () => {
|
||||
const MAC_INDEX_BINDING = "workspace-navigate-index-cmd-digit-mac";
|
||||
|
||||
it("uses Alt on web, regardless of OS", () => {
|
||||
expect(getWorkspaceIndexJumpModifierKey({ isMac: true, isDesktop: false })).toBe("Alt");
|
||||
expect(getWorkspaceIndexJumpModifierKey({ isMac: false, isDesktop: false })).toBe("Alt");
|
||||
});
|
||||
|
||||
it("uses Cmd (Meta) on desktop Mac, not Control or Alt", () => {
|
||||
expect(getWorkspaceIndexJumpModifierKey({ isMac: true, isDesktop: true })).toBe("Meta");
|
||||
});
|
||||
|
||||
it("uses Ctrl on desktop non-Mac, not Meta or Alt", () => {
|
||||
expect(getWorkspaceIndexJumpModifierKey({ isMac: false, isDesktop: true })).toBe("Control");
|
||||
});
|
||||
|
||||
it("derives the modifier from the effective binding, not the platform", () => {
|
||||
const bindings = buildEffectiveBindings({ [MAC_INDEX_BINDING]: "Alt+Digit" });
|
||||
expect(getWorkspaceIndexJumpModifierKey({ isMac: true, isDesktop: true }, bindings)).toBe(
|
||||
expect(getWorkspaceIndexJumpModifierKey({ isMac: true, usesDesktopBindings: false })).toBe(
|
||||
"Alt",
|
||||
);
|
||||
expect(getWorkspaceIndexJumpModifierKey({ isMac: false, usesDesktopBindings: false })).toBe(
|
||||
"Alt",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses Cmd (Meta) on desktop Mac, not Control or Alt", () => {
|
||||
expect(getWorkspaceIndexJumpModifierKey({ isMac: true, usesDesktopBindings: true })).toBe(
|
||||
"Meta",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses Ctrl on desktop non-Mac, not Meta or Alt", () => {
|
||||
expect(getWorkspaceIndexJumpModifierKey({ isMac: false, usesDesktopBindings: true })).toBe(
|
||||
"Control",
|
||||
);
|
||||
});
|
||||
|
||||
it("derives the modifier from the effective binding, not the platform", () => {
|
||||
const bindings = buildEffectiveBindings({ [MAC_INDEX_BINDING]: "Alt+Digit" });
|
||||
expect(
|
||||
getWorkspaceIndexJumpModifierKey({ isMac: true, usesDesktopBindings: true }, bindings),
|
||||
).toBe("Alt");
|
||||
});
|
||||
|
||||
it("suppresses the badges when the jump shortcut is unassigned", () => {
|
||||
const bindings = buildEffectiveBindings({ [MAC_INDEX_BINDING]: UNASSIGNED_COMBO });
|
||||
expect(getWorkspaceIndexJumpModifierKey({ isMac: true, isDesktop: true }, bindings)).toBeNull();
|
||||
expect(
|
||||
getWorkspaceIndexJumpModifierKey({ isMac: true, usesDesktopBindings: true }, bindings),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("suppresses the badges when the jump shortcut is rebound to one concrete digit", () => {
|
||||
// Capture can only ever produce a concrete digit, never the 1-9 wildcard,
|
||||
// so the badges would advertise eight workspaces that no longer respond.
|
||||
const bindings = buildEffectiveBindings({ [MAC_INDEX_BINDING]: "Cmd+3" });
|
||||
expect(getWorkspaceIndexJumpModifierKey({ isMac: true, isDesktop: true }, bindings)).toBeNull();
|
||||
expect(
|
||||
getWorkspaceIndexJumpModifierKey({ isMac: true, usesDesktopBindings: true }, bindings),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("suppresses the badges for a multi-step chord", () => {
|
||||
const bindings = buildEffectiveBindings({ [MAC_INDEX_BINDING]: "Cmd+K Cmd+Digit" });
|
||||
expect(getWorkspaceIndexJumpModifierKey({ isMac: true, isDesktop: true }, bindings)).toBeNull();
|
||||
expect(
|
||||
getWorkspaceIndexJumpModifierKey({ isMac: true, usesDesktopBindings: true }, bindings),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("suppresses the badges when the combo needs a second modifier", () => {
|
||||
const bindings = buildEffectiveBindings({ [MAC_INDEX_BINDING]: "Cmd+Shift+Digit" });
|
||||
expect(getWorkspaceIndexJumpModifierKey({ isMac: true, isDesktop: true }, bindings)).toBeNull();
|
||||
expect(
|
||||
getWorkspaceIndexJumpModifierKey({ isMac: true, usesDesktopBindings: true }, bindings),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -999,7 +1020,7 @@ function withoutDefaultCombo(helpId: string): ParsedShortcutBinding[] {
|
||||
|
||||
describe("unassigned shortcuts", () => {
|
||||
const TAB_NEW_BINDING = "workspace-tab-new-ctrl-t-non-mac";
|
||||
const desktopNonMac = { isMac: false, isDesktop: true };
|
||||
const desktopNonMac = { isMac: false, usesDesktopBindings: true };
|
||||
|
||||
function findRow(sections: ReturnType<typeof buildKeyboardShortcutHelpSections>, id: string) {
|
||||
for (const section of sections) {
|
||||
@@ -1102,13 +1123,13 @@ describe("unassigned shortcuts", () => {
|
||||
const bindings = buildEffectiveBindings({ "sidebar-toggle-left-mac-cmd-b": combo! });
|
||||
|
||||
expect(
|
||||
resolveShortcut({ event, context: { isMac: true, isDesktop: true }, bindings }).match
|
||||
?.action,
|
||||
resolveShortcut({ event, context: { isMac: true, usesDesktopBindings: true }, bindings })
|
||||
.match?.action,
|
||||
).toBe("sidebar.toggle.left");
|
||||
expect(
|
||||
resolveShortcut({
|
||||
event: { key: "x", code: "KeyB", metaKey: true },
|
||||
context: { isMac: true, isDesktop: true },
|
||||
context: { isMac: true, usesDesktopBindings: true },
|
||||
bindings,
|
||||
}).match,
|
||||
).toBeNull();
|
||||
@@ -1293,7 +1314,7 @@ describe("unassigned shortcuts", () => {
|
||||
});
|
||||
|
||||
describe("direct new-tab target shortcuts", () => {
|
||||
const desktopNonMac = { isMac: false, isDesktop: true };
|
||||
const desktopNonMac = { isMac: false, usesDesktopBindings: true };
|
||||
const targetCases = [
|
||||
["a", "KeyA", "workspace.tab.target.agent"],
|
||||
["b", "KeyB", "workspace.tab.target.browser"],
|
||||
@@ -1323,7 +1344,7 @@ describe("direct new-tab target shortcuts", () => {
|
||||
it.each(targetCases)("routes Cmd+Shift+%s directly to %s", (key, code, action) => {
|
||||
const result = resolveShortcut({
|
||||
event: { key, code, metaKey: true, shiftKey: true },
|
||||
context: { isMac: true, isDesktop: true, focusScope: "other" },
|
||||
context: { isMac: true, usesDesktopBindings: true, focusScope: "other" },
|
||||
bindings: buildEffectiveBindings({}),
|
||||
});
|
||||
expect(result.match?.action).toBe(action);
|
||||
@@ -1368,19 +1389,19 @@ describe("number-group shortcut capture", () => {
|
||||
for (let index = 1; index <= 9; index++) {
|
||||
const result = resolveShortcut({
|
||||
event: { key: String(index), code: `Digit${index}`, ctrlKey: true },
|
||||
context: { isMac: true, isDesktop: true, focusScope: "message-input" },
|
||||
context: { isMac: true, usesDesktopBindings: true, focusScope: "message-input" },
|
||||
bindings,
|
||||
});
|
||||
expect(result.match?.action).toBe(action);
|
||||
expect(result.match?.payload).toEqual({ index });
|
||||
}
|
||||
expect(resolveShortcutKeysForAction(row, overrides, { isMac: true, isDesktop: true })).toEqual([
|
||||
["ctrl", "1-9"],
|
||||
]);
|
||||
expect(
|
||||
resolveShortcutKeysForAction(row, overrides, { isMac: true, usesDesktopBindings: true }),
|
||||
).toEqual([["ctrl", "1-9"]]);
|
||||
expect(
|
||||
resolveShortcut({
|
||||
event: { key: "0", code: "Digit0", ctrlKey: true },
|
||||
context: { isMac: true, isDesktop: true },
|
||||
context: { isMac: true, usesDesktopBindings: true },
|
||||
bindings,
|
||||
}).match,
|
||||
).toBeNull();
|
||||
|
||||
@@ -18,7 +18,7 @@ export type { KeyCombo } from "@/keyboard/shortcut-string";
|
||||
|
||||
export interface KeyboardShortcutContext {
|
||||
isMac: boolean;
|
||||
isDesktop: boolean;
|
||||
usesDesktopBindings: boolean;
|
||||
focusScope: KeyboardFocusScope;
|
||||
commandCenterOpen: boolean;
|
||||
}
|
||||
@@ -63,14 +63,14 @@ export interface KeyboardShortcutHelpSection {
|
||||
|
||||
interface KeyboardShortcutPlatformContext {
|
||||
isMac: boolean;
|
||||
isDesktop: boolean;
|
||||
usesDesktopBindings: boolean;
|
||||
}
|
||||
|
||||
interface ShortcutWhen {
|
||||
/** true = mac only, false = non-mac only */
|
||||
mac?: boolean;
|
||||
/** true = desktop only, false = web only */
|
||||
desktop?: boolean;
|
||||
/** Desktop keyboard conventions on Electron/native; browser conventions otherwise. */
|
||||
desktopBindings?: boolean;
|
||||
/** false = disabled when a text-editing surface is focused */
|
||||
editable?: false;
|
||||
/** false = disabled when terminal is focused */
|
||||
@@ -502,7 +502,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "workspace-tab-close-current-cmd-w-mac",
|
||||
action: "workspace.tab.close.current",
|
||||
combo: "Cmd+W",
|
||||
when: { mac: true, desktop: true, commandCenter: false },
|
||||
when: { mac: true, desktopBindings: true, commandCenter: false },
|
||||
help: {
|
||||
id: "workspace-tab-close-current",
|
||||
section: "tabs-panes",
|
||||
@@ -513,7 +513,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "workspace-tab-close-current-ctrl-w-non-mac",
|
||||
action: "workspace.tab.close.current",
|
||||
combo: "Ctrl+W",
|
||||
when: { mac: false, desktop: true, commandCenter: false, terminal: false },
|
||||
when: { mac: false, desktopBindings: true, commandCenter: false, terminal: false },
|
||||
help: {
|
||||
id: "workspace-tab-close-current",
|
||||
section: "tabs-panes",
|
||||
@@ -524,7 +524,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "workspace-tab-close-current-alt-shift-w-web",
|
||||
action: "workspace.tab.close.current",
|
||||
combo: "Alt+Shift+W",
|
||||
when: { desktop: false, commandCenter: false },
|
||||
when: { desktopBindings: false, commandCenter: false },
|
||||
help: {
|
||||
id: "workspace-tab-close-current",
|
||||
section: "tabs-panes",
|
||||
@@ -537,7 +537,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "workspace-navigate-index-cmd-digit-mac",
|
||||
action: "workspace.navigate.index",
|
||||
combo: "Cmd+Digit",
|
||||
when: { mac: true, desktop: true, commandCenter: false },
|
||||
when: { mac: true, desktopBindings: true, commandCenter: false },
|
||||
payload: { type: "index" },
|
||||
help: {
|
||||
id: "workspace-jump-index",
|
||||
@@ -550,7 +550,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "workspace-navigate-index-ctrl-digit-non-mac",
|
||||
action: "workspace.navigate.index",
|
||||
combo: "Ctrl+Digit",
|
||||
when: { mac: false, desktop: true, commandCenter: false, terminal: false },
|
||||
when: { mac: false, desktopBindings: true, commandCenter: false, terminal: false },
|
||||
payload: { type: "index" },
|
||||
help: {
|
||||
id: "workspace-jump-index",
|
||||
@@ -563,7 +563,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "workspace-navigate-index-alt-digit-web",
|
||||
action: "workspace.navigate.index",
|
||||
combo: "Alt+Digit",
|
||||
when: { desktop: false, commandCenter: false },
|
||||
when: { desktopBindings: false, commandCenter: false },
|
||||
payload: { type: "index" },
|
||||
help: {
|
||||
id: "workspace-jump-index",
|
||||
@@ -578,7 +578,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "workspace-tab-navigate-index-cmd-alt-digit-mac-desktop",
|
||||
action: "workspace.tab.navigate.index",
|
||||
combo: "Cmd+Alt+Digit",
|
||||
when: { mac: true, desktop: true, commandCenter: false },
|
||||
when: { mac: true, desktopBindings: true, commandCenter: false },
|
||||
payload: { type: "index" },
|
||||
help: {
|
||||
id: "workspace-tab-jump-index",
|
||||
@@ -591,7 +591,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "workspace-tab-navigate-index-alt-digit-desktop",
|
||||
action: "workspace.tab.navigate.index",
|
||||
combo: "Alt+Digit",
|
||||
when: { mac: false, desktop: true, commandCenter: false },
|
||||
when: { mac: false, desktopBindings: true, commandCenter: false },
|
||||
payload: { type: "index" },
|
||||
help: {
|
||||
id: "workspace-tab-jump-index",
|
||||
@@ -604,7 +604,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "workspace-tab-navigate-index-alt-shift-digit-web",
|
||||
action: "workspace.tab.navigate.index",
|
||||
combo: "Alt+Shift+Digit",
|
||||
when: { desktop: false, commandCenter: false },
|
||||
when: { desktopBindings: false, commandCenter: false },
|
||||
payload: { type: "index" },
|
||||
help: {
|
||||
id: "workspace-tab-jump-index",
|
||||
@@ -619,7 +619,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "workspace-navigate-relative-cmd-left-mac",
|
||||
action: "workspace.navigate.relative",
|
||||
combo: "Cmd+[",
|
||||
when: { mac: true, desktop: true, commandCenter: false },
|
||||
when: { mac: true, desktopBindings: true, commandCenter: false },
|
||||
payload: { type: "delta", delta: -1 },
|
||||
help: {
|
||||
id: "workspace-prev",
|
||||
@@ -631,7 +631,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "workspace-navigate-relative-ctrl-left-non-mac",
|
||||
action: "workspace.navigate.relative",
|
||||
combo: "Ctrl+[",
|
||||
when: { mac: false, desktop: true, commandCenter: false, terminal: false },
|
||||
when: { mac: false, desktopBindings: true, commandCenter: false, terminal: false },
|
||||
payload: { type: "delta", delta: -1 },
|
||||
help: {
|
||||
id: "workspace-prev",
|
||||
@@ -643,7 +643,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "workspace-navigate-relative-cmd-right-mac",
|
||||
action: "workspace.navigate.relative",
|
||||
combo: "Cmd+]",
|
||||
when: { mac: true, desktop: true, commandCenter: false },
|
||||
when: { mac: true, desktopBindings: true, commandCenter: false },
|
||||
payload: { type: "delta", delta: 1 },
|
||||
help: {
|
||||
id: "workspace-next",
|
||||
@@ -655,7 +655,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "workspace-navigate-relative-ctrl-right-non-mac",
|
||||
action: "workspace.navigate.relative",
|
||||
combo: "Ctrl+]",
|
||||
when: { mac: false, desktop: true, commandCenter: false, terminal: false },
|
||||
when: { mac: false, desktopBindings: true, commandCenter: false, terminal: false },
|
||||
payload: { type: "delta", delta: 1 },
|
||||
help: {
|
||||
id: "workspace-next",
|
||||
@@ -667,7 +667,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "workspace-navigate-relative-alt-left-web",
|
||||
action: "workspace.navigate.relative",
|
||||
combo: "Alt+[",
|
||||
when: { desktop: false, commandCenter: false },
|
||||
when: { desktopBindings: false, commandCenter: false },
|
||||
payload: { type: "delta", delta: -1 },
|
||||
help: {
|
||||
id: "workspace-prev",
|
||||
@@ -679,7 +679,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "workspace-navigate-relative-alt-right-web",
|
||||
action: "workspace.navigate.relative",
|
||||
combo: "Alt+]",
|
||||
when: { desktop: false, commandCenter: false },
|
||||
when: { desktopBindings: false, commandCenter: false },
|
||||
payload: { type: "delta", delta: 1 },
|
||||
help: {
|
||||
id: "workspace-next",
|
||||
@@ -1344,7 +1344,8 @@ export function matchesKeyboardShortcutContext(
|
||||
): boolean {
|
||||
if (!when) return true;
|
||||
if (when.mac !== undefined && when.mac !== context.isMac) return false;
|
||||
if (when.desktop !== undefined && when.desktop !== context.isDesktop) return false;
|
||||
if (when.desktopBindings !== undefined && when.desktopBindings !== context.usesDesktopBindings)
|
||||
return false;
|
||||
if (
|
||||
when.editable === false &&
|
||||
(context.focusScope === "message-input" || context.focusScope === "editable")
|
||||
@@ -1409,7 +1410,8 @@ function helpMatchesPlatform(
|
||||
context: KeyboardShortcutPlatformContext,
|
||||
): boolean {
|
||||
if (when?.mac !== undefined && when.mac !== context.isMac) return false;
|
||||
if (when?.desktop !== undefined && when.desktop !== context.isDesktop) return false;
|
||||
if (when?.desktopBindings !== undefined && when.desktopBindings !== context.usesDesktopBindings)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1573,7 +1575,7 @@ export function resolveKeyboardShortcut(input: {
|
||||
|
||||
export function getBindingIdForAction(
|
||||
actionId: string,
|
||||
platform: { isMac: boolean; isDesktop: boolean },
|
||||
platform: { isMac: boolean; usesDesktopBindings: boolean },
|
||||
): string | null {
|
||||
for (const binding of DEFAULT_BINDINGS) {
|
||||
if (binding.help?.id !== actionId) {
|
||||
@@ -1611,7 +1613,7 @@ function displayChordForBinding(binding: ParsedShortcutBinding): ShortcutKey[][]
|
||||
|
||||
export function getDefaultKeysForAction(
|
||||
actionId: string,
|
||||
platform: { isMac: boolean; isDesktop: boolean },
|
||||
platform: { isMac: boolean; usesDesktopBindings: boolean },
|
||||
bindings: readonly ParsedShortcutBinding[] = DEFAULT_BINDINGS,
|
||||
): ShortcutKey[][] | null {
|
||||
for (const binding of bindings) {
|
||||
@@ -1638,7 +1640,7 @@ export function getDefaultKeysForAction(
|
||||
export function resolveShortcutKeysForAction(
|
||||
actionId: string,
|
||||
overrides: ShortcutOverrides,
|
||||
platform: { isMac: boolean; isDesktop: boolean },
|
||||
platform: { isMac: boolean; usesDesktopBindings: boolean },
|
||||
): ShortcutKey[][] | null {
|
||||
const bindingId = getBindingIdForAction(actionId, platform);
|
||||
if (bindingId === null) {
|
||||
@@ -1680,7 +1682,7 @@ export function resolveShortcutKeysForAction(
|
||||
* the shortcut delivers.
|
||||
*/
|
||||
export function getWorkspaceIndexJumpModifierKey(
|
||||
platform: { isMac: boolean; isDesktop: boolean },
|
||||
platform: { isMac: boolean; usesDesktopBindings: boolean },
|
||||
bindings: readonly ParsedShortcutBinding[] = DEFAULT_BINDINGS,
|
||||
): "Alt" | "Meta" | "Control" | null {
|
||||
const binding = bindings.find(function (candidate) {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildEffectiveBindings } from "./keyboard-shortcuts";
|
||||
import { createKeyboardPipeline } from "./internal/pipeline";
|
||||
import { createOverlayRegistry } from "./internal/overlays";
|
||||
describe("DOM source adapter", () => {
|
||||
it("runs DOM input through the same resolver, preserving cancellation and cleanup", async () => {
|
||||
const { attachDomKeyboardSource } = await import("./internal/sources/dom.web");
|
||||
const actions: string[] = [];
|
||||
const input = document.createElement("textarea");
|
||||
input.dataset.testid = "message-input";
|
||||
document.body.append(input);
|
||||
input.focus();
|
||||
const pipeline = createKeyboardPipeline({
|
||||
getContext: () => ({ isMac: true, usesDesktopBindings: false, commandCenterOpen: false }),
|
||||
getBindings: () => buildEffectiveBindings({ "command-center-toggle-cmd-k-mac": "Cmd+J" }),
|
||||
isCapturing: () => false,
|
||||
dispatch: (match) => {
|
||||
actions.push(match.action);
|
||||
return true;
|
||||
},
|
||||
overlays: createOverlayRegistry(),
|
||||
});
|
||||
const detach = attachDomKeyboardSource({
|
||||
keyDown: pipeline.keyDown,
|
||||
keyUp() {},
|
||||
blur() {},
|
||||
commandCenterOpen: () => false,
|
||||
});
|
||||
const event = new KeyboardEvent("keydown", {
|
||||
key: "j",
|
||||
code: "KeyJ",
|
||||
metaKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
input.dispatchEvent(event);
|
||||
expect(actions).toEqual(["command-center.toggle"]);
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
detach();
|
||||
input.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "j", code: "KeyJ", metaKey: true, bubbles: true }),
|
||||
);
|
||||
expect(actions).toHaveLength(1);
|
||||
input.remove();
|
||||
pipeline.dispose();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildEffectiveBindings } from "./keyboard-shortcuts";
|
||||
import { createKeyboardPipeline } from "./internal/pipeline";
|
||||
import { createOverlayRegistry } from "./internal/overlays";
|
||||
import { nativeKeyboardInput } from "./internal/sources/contract";
|
||||
|
||||
function keyboardEvent(key: string, code: string, isMac = true) {
|
||||
return {
|
||||
key,
|
||||
code,
|
||||
altKey: false,
|
||||
ctrlKey: !isMac,
|
||||
metaKey: isMac,
|
||||
shiftKey: false,
|
||||
repeat: false,
|
||||
preventDefault() {},
|
||||
stopPropagation() {},
|
||||
};
|
||||
}
|
||||
|
||||
describe("shared keyboard source pipeline", () => {
|
||||
it.each([true, false])("resolves hardware input and user rebinds with mac=%s", (isMac) => {
|
||||
const actions: string[] = [];
|
||||
const bindings = buildEffectiveBindings({ "command-center-toggle-cmd-k-mac": "Cmd+J" });
|
||||
const pipeline = createKeyboardPipeline({
|
||||
getContext: () => ({ isMac, usesDesktopBindings: true, commandCenterOpen: false }),
|
||||
getBindings: () => bindings,
|
||||
isCapturing: () => false,
|
||||
dispatch: (match) => {
|
||||
actions.push(match.action);
|
||||
return true;
|
||||
},
|
||||
overlays: createOverlayRegistry(),
|
||||
});
|
||||
pipeline.keyDown(nativeKeyboardInput(keyboardEvent("k", "KeyK", isMac), "message-input"));
|
||||
expect(actions).toEqual(isMac ? [] : ["command-center.toggle"]);
|
||||
pipeline.keyDown(nativeKeyboardInput(keyboardEvent("j", "KeyJ", isMac), "message-input"));
|
||||
expect(actions).toEqual(["command-center.toggle"]);
|
||||
pipeline.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe("overlay and focus precedence in the source pipeline", () => {
|
||||
it("dismisses an overlay without interrupting, then interrupts only the composer", () => {
|
||||
const registry = createOverlayRegistry();
|
||||
const actions: string[] = [];
|
||||
const pipeline = createKeyboardPipeline({
|
||||
getContext: () => ({ isMac: true, usesDesktopBindings: true, commandCenterOpen: false }),
|
||||
getBindings: () => buildEffectiveBindings({}),
|
||||
isCapturing: () => false,
|
||||
dispatch: (match) => {
|
||||
actions.push(match.action);
|
||||
return true;
|
||||
},
|
||||
overlays: registry,
|
||||
});
|
||||
let dismissals = 0;
|
||||
const remove = registry.register({
|
||||
getLayer: () => 20,
|
||||
getKeys: () => ["Escape"],
|
||||
onKeyDown: (event) => {
|
||||
if (event.key !== "Escape") return false;
|
||||
dismissals++;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
const escape = { ...keyboardEvent("Escape", "Escape"), metaKey: false };
|
||||
pipeline.keyDown(nativeKeyboardInput(escape, "message-input"));
|
||||
expect(dismissals).toBe(1);
|
||||
expect(actions).toEqual([]);
|
||||
remove();
|
||||
pipeline.keyDown(nativeKeyboardInput(escape, "terminal"));
|
||||
expect(actions).toEqual([]);
|
||||
pipeline.keyDown(nativeKeyboardInput(escape, "message-input"));
|
||||
expect(actions).toEqual(["agent.interrupt"]);
|
||||
pipeline.dispose();
|
||||
});
|
||||
|
||||
it("does not turn IME confirmation into overlay selection", () => {
|
||||
const registry = createOverlayRegistry();
|
||||
let selections = 0;
|
||||
const remove = registry.register({
|
||||
getLayer: () => 20,
|
||||
getKeys: () => ["Enter"],
|
||||
onKeyDown: () => {
|
||||
selections++;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
const pipeline = createKeyboardPipeline({
|
||||
getContext: () => ({ isMac: true, usesDesktopBindings: true, commandCenterOpen: false }),
|
||||
getBindings: () => buildEffectiveBindings({}),
|
||||
isCapturing: () => false,
|
||||
dispatch: () => true,
|
||||
overlays: registry,
|
||||
});
|
||||
pipeline.keyDown(
|
||||
nativeKeyboardInput(
|
||||
{ ...keyboardEvent("Enter", "Enter"), metaKey: false, isComposing: true },
|
||||
"message-input",
|
||||
),
|
||||
);
|
||||
expect(selections).toBe(0);
|
||||
remove();
|
||||
pipeline.dispose();
|
||||
});
|
||||
|
||||
it.each([true, false])(
|
||||
"uses hardware conventions for workspace index jumps with mac=%s",
|
||||
(isMac) => {
|
||||
const payloads: unknown[] = [];
|
||||
const pipeline = createKeyboardPipeline({
|
||||
getContext: () => ({ isMac, usesDesktopBindings: true, commandCenterOpen: false }),
|
||||
getBindings: () => buildEffectiveBindings({}),
|
||||
isCapturing: () => false,
|
||||
dispatch: (match) => {
|
||||
payloads.push([match.action, match.payload]);
|
||||
return true;
|
||||
},
|
||||
overlays: createOverlayRegistry(),
|
||||
});
|
||||
pipeline.keyDown(nativeKeyboardInput(keyboardEvent("3", "Digit3", isMac), "message-input"));
|
||||
expect(payloads).toEqual([["workspace.navigate.index", { index: 3 }]]);
|
||||
pipeline.dispose();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("hardware source adapter", () => {
|
||||
it.each([true, false])(
|
||||
"delivers hardware down/up and declared focus with mac=%s",
|
||||
async (isMac) => {
|
||||
const { attachHardwareKeyboardSource } = await import("./internal/sources/native");
|
||||
const { focusSurfaces } = await import("./internal/focus-surfaces");
|
||||
const actions: string[] = [];
|
||||
const releases: string[] = [];
|
||||
const events = new Set<
|
||||
(event: import("./internal/sources/hardware").HardwareKeyboardEvent) => void
|
||||
>();
|
||||
const releaseFocus = focusSurfaces.declare("message-input");
|
||||
const pipeline = createKeyboardPipeline({
|
||||
getContext: () => ({ isMac, usesDesktopBindings: true, commandCenterOpen: false }),
|
||||
getBindings: () => buildEffectiveBindings({}),
|
||||
isCapturing: () => false,
|
||||
dispatch: (match) => {
|
||||
actions.push(match.action);
|
||||
return true;
|
||||
},
|
||||
overlays: createOverlayRegistry(),
|
||||
});
|
||||
const detach = attachHardwareKeyboardSource(
|
||||
{
|
||||
subscribe(handler) {
|
||||
events.add(handler);
|
||||
return () => {
|
||||
events.delete(handler);
|
||||
};
|
||||
},
|
||||
setOverlayKeys() {},
|
||||
},
|
||||
{
|
||||
keyDown: pipeline.keyDown,
|
||||
keyUp: (event) => releases.push(event.key),
|
||||
blur() {},
|
||||
commandCenterOpen: () => false,
|
||||
},
|
||||
);
|
||||
const down = {
|
||||
...keyboardEvent("k", "KeyK", isMac),
|
||||
isComposing: false,
|
||||
phase: "down" as const,
|
||||
};
|
||||
for (const listener of events) listener(down);
|
||||
for (const listener of events) listener({ ...down, phase: "up" });
|
||||
expect(actions).toEqual(["command-center.toggle"]);
|
||||
expect(releases).toEqual(["k"]);
|
||||
detach();
|
||||
releaseFocus();
|
||||
pipeline.dispose();
|
||||
expect(events.size).toBe(0);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createKeyboardFocusScope } from "./internal/focus-surfaces";
|
||||
|
||||
describe("declared keyboard focus", () => {
|
||||
it("recognizes ordinary native editors while preserving declared scopes", () => {
|
||||
let focused = false;
|
||||
const scope = createKeyboardFocusScope(() => focused);
|
||||
expect(scope.resolve(false)).toBe("other");
|
||||
focused = true;
|
||||
expect(scope.resolve(false)).toBe("editable");
|
||||
const release = scope.declare("message-input");
|
||||
expect(scope.resolve(false)).toBe("message-input");
|
||||
expect(scope.resolve(true)).toBe("command-center");
|
||||
release();
|
||||
expect(scope.resolve(false)).toBe("editable");
|
||||
focused = false;
|
||||
expect(scope.resolve(false)).toBe("other");
|
||||
});
|
||||
it("tracks composer and terminal focus without stale cleanup losing the new owner", () => {
|
||||
const scope = createKeyboardFocusScope();
|
||||
expect(scope.resolve(false)).toBe("other");
|
||||
const releaseTerminal = scope.declare("terminal");
|
||||
expect(scope.resolve(false)).toBe("terminal");
|
||||
const releaseComposer = scope.declare("message-input");
|
||||
releaseTerminal();
|
||||
expect(scope.resolve(false)).toBe("message-input");
|
||||
expect(scope.resolve(true)).toBe("command-center");
|
||||
releaseComposer();
|
||||
releaseComposer();
|
||||
expect(scope.resolve(false)).toBe("other");
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from "./keyboard-shortcuts";
|
||||
import { filterShortcutHelpSections, shortcutSearchText } from "./shortcut-help-search";
|
||||
|
||||
const MAC_DESKTOP = { isMac: true, isDesktop: true };
|
||||
const MAC_DESKTOP = { isMac: true, usesDesktopBindings: true };
|
||||
const NEW_WORKSPACE_BINDING = "workspace-new-cmd-n-mac";
|
||||
|
||||
/**
|
||||
|
||||
@@ -237,7 +237,9 @@ export function chordStringToShortcutKeys(s: string): ShortcutKey[][] {
|
||||
return s.split(" ").map(comboStringToShortcutKeys);
|
||||
}
|
||||
|
||||
export function heldModifiersFromEvent(event: KeyboardEvent): string | null {
|
||||
export function heldModifiersFromEvent(
|
||||
event: Pick<KeyboardEvent, "key" | "code" | "altKey" | "ctrlKey" | "metaKey" | "shiftKey">,
|
||||
): string | null {
|
||||
const parts: string[] = [];
|
||||
if (event.ctrlKey) parts.push("Ctrl");
|
||||
if (event.altKey) parts.push("Alt");
|
||||
@@ -255,7 +257,9 @@ export function isModifierKeyCode(code: string): boolean {
|
||||
return MODIFIER_CODES.has(code);
|
||||
}
|
||||
|
||||
export function keyboardEventToComboString(event: KeyboardEvent): string | null {
|
||||
export function keyboardEventToComboString(
|
||||
event: Pick<KeyboardEvent, "key" | "code" | "altKey" | "ctrlKey" | "metaKey" | "shiftKey">,
|
||||
): string | null {
|
||||
if (isModifierKeyCode(event.code)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
import { usesDesktopBindings } from "./internal/conventions";
|
||||
import { createKeyboardPipeline } from "./internal/pipeline";
|
||||
import { overlays } from "./internal/overlays";
|
||||
import { attachKeyboardSources, publishKeyboardPolicy } from "./internal/sources";
|
||||
import type { KeyboardSourceInput } from "./internal/sources/contract";
|
||||
import type { KeyboardInputEvent } from "./internal/overlays";
|
||||
import { hasActiveOverlay } from "./internal/overlay-dispatch";
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { usePathname, useRouter } from "expo-router";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
|
||||
import { useKeyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher-context";
|
||||
import {
|
||||
type ChordState,
|
||||
buildEffectiveBindings,
|
||||
getWorkspaceIndexJumpModifierKey,
|
||||
} from "@/keyboard/keyboard-shortcuts";
|
||||
import { shouldPublishBrowserShortcutPolicy } from "@/keyboard/internal/sources/electron-browser";
|
||||
import type { KeyboardShortcutPayload } from "@/keyboard/actions";
|
||||
import {
|
||||
routeKeyboardShortcut,
|
||||
type ShortcutAction,
|
||||
type ShortcutCallbackName,
|
||||
} from "@/keyboard/route-shortcut";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
import { useOpenAddProject } from "@/hooks/use-open-add-project";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
|
||||
import { buildOpenProjectRoute } from "@/utils/host-routes";
|
||||
import {
|
||||
type ActiveWorkspaceSelection,
|
||||
navigateToLastWorkspace,
|
||||
useActiveWorkspaceSelection,
|
||||
} from "@/stores/navigation-active-workspace-store";
|
||||
|
||||
export function useKeyboardShortcuts({
|
||||
enabled,
|
||||
isMobile,
|
||||
isWorkspaceFocusModeEnabled,
|
||||
toggleAgentList,
|
||||
toggleBothSidebars,
|
||||
exitFocusMode,
|
||||
cycleTheme,
|
||||
}: {
|
||||
enabled: boolean;
|
||||
isMobile: boolean;
|
||||
isWorkspaceFocusModeEnabled: boolean;
|
||||
toggleAgentList: () => void;
|
||||
toggleBothSidebars?: () => void;
|
||||
exitFocusMode: () => void;
|
||||
cycleTheme?: () => void;
|
||||
}) {
|
||||
const keyboardActionDispatcher = useKeyboardActionDispatcher();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const resetModifiers = useKeyboardShortcutsStore((s) => s.resetModifiers);
|
||||
const { overrides } = useKeyboardShortcutOverrides();
|
||||
const bindings = useMemo(() => buildEffectiveBindings(overrides), [overrides]);
|
||||
const isMac = getShortcutOs() === "mac";
|
||||
const openProjectPickerAction = useOpenAddProject();
|
||||
const activeWorkspaceSelection = useActiveWorkspaceSelection();
|
||||
const keyboardWorkspaceSelectionRef = useRef<ActiveWorkspaceSelection | null>(null);
|
||||
const publishBrowserShortcutPolicy = useCallback(
|
||||
(chordState?: ChordState) => {
|
||||
publishKeyboardPolicy({ bindings, chordState, isMac, enabled });
|
||||
},
|
||||
[bindings, enabled, isMac],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeWorkspaceSelection) {
|
||||
keyboardWorkspaceSelectionRef.current = activeWorkspaceSelection;
|
||||
}
|
||||
}, [activeWorkspaceSelection]);
|
||||
|
||||
useEffect(() => {
|
||||
publishBrowserShortcutPolicy();
|
||||
}, [publishBrowserShortcutPolicy]);
|
||||
|
||||
// Only the modifier that actually performs the workspace-index jump on this
|
||||
// runtime should reveal the sidebar number badges (Alt on web, Cmd on
|
||||
// desktop Mac, Ctrl on desktop non-Mac). The store ORs altDown/cmdOrCtrlDown
|
||||
// to drive badge visibility, so we set the flag matching this runtime.
|
||||
// Derived from the effective bindings: `null` when the user unassigned or
|
||||
// rebound the jump shortcut, and no `event.key` ever equals null, so the
|
||||
// badges simply never appear.
|
||||
const badgeModifierKey = getWorkspaceIndexJumpModifierKey(
|
||||
{ isMac, usesDesktopBindings: usesDesktopBindings },
|
||||
bindings,
|
||||
);
|
||||
|
||||
// The keyup listener matches the released key against the current modifier,
|
||||
// so a modifier held while the jump binding changes could never be released
|
||||
// and the badges would stay up until a blur. Clear them when it changes.
|
||||
useEffect(() => {
|
||||
resetModifiers();
|
||||
}, [badgeModifierKey, resetModifiers]);
|
||||
|
||||
const setBadgeModifierDown = (down: boolean) => {
|
||||
const state = useKeyboardShortcutsStore.getState();
|
||||
if (usesDesktopBindings) {
|
||||
state.setCmdOrCtrlDown(down);
|
||||
} else {
|
||||
state.setAltDown(down);
|
||||
}
|
||||
};
|
||||
|
||||
const callbacksByName: Record<ShortcutCallbackName, (() => void) | undefined> = {
|
||||
"toggle-agent-list": toggleAgentList,
|
||||
"toggle-both-sidebars": toggleBothSidebars,
|
||||
"cycle-theme": cycleTheme,
|
||||
};
|
||||
|
||||
const performShortcutAction = (
|
||||
action: ShortcutAction,
|
||||
captureFocusRestore: () => void,
|
||||
): boolean => {
|
||||
switch (action.kind) {
|
||||
case "none":
|
||||
return false;
|
||||
case "dispatch":
|
||||
return keyboardActionDispatcher.dispatch(action.action);
|
||||
case "navigate-workspace":
|
||||
keyboardWorkspaceSelectionRef.current = {
|
||||
serverId: action.serverId,
|
||||
workspaceId: action.workspaceId,
|
||||
};
|
||||
navigateToWorkspace({ serverId: action.serverId, workspaceId: action.workspaceId });
|
||||
return true;
|
||||
case "navigate-last-workspace":
|
||||
if (navigateToLastWorkspace()) {
|
||||
return true;
|
||||
}
|
||||
router.replace(buildOpenProjectRoute());
|
||||
return true;
|
||||
case "router-replace":
|
||||
router.replace(action.route as Parameters<typeof router.replace>[0]);
|
||||
return true;
|
||||
case "router-back":
|
||||
router.back();
|
||||
return true;
|
||||
case "router-push":
|
||||
router.push(action.route as Parameters<typeof router.push>[0]);
|
||||
return true;
|
||||
case "open-project-picker":
|
||||
void openProjectPickerAction();
|
||||
return true;
|
||||
case "callback":
|
||||
callbacksByName[action.name]?.();
|
||||
return true;
|
||||
case "command-center-toggle": {
|
||||
if (action.nextOpen) {
|
||||
captureFocusRestore();
|
||||
}
|
||||
useKeyboardShortcutsStore
|
||||
.getState()
|
||||
.setCommandCenterOpen(action.nextOpen, action.scope ?? null);
|
||||
return true;
|
||||
}
|
||||
case "shortcuts-dialog-toggle":
|
||||
useKeyboardShortcutsStore.getState().setShortcutsDialogOpen(action.nextOpen);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
const routeAndPerformShortcut = (input: {
|
||||
action: string;
|
||||
payload: KeyboardShortcutPayload;
|
||||
captureFocusRestore: () => void;
|
||||
}): boolean => {
|
||||
const store = useKeyboardShortcutsStore.getState();
|
||||
const shortcutAction = routeKeyboardShortcut(
|
||||
{ action: input.action, payload: input.payload },
|
||||
{
|
||||
pathname,
|
||||
isMobile,
|
||||
sidebarShortcutTargets: store.sidebarShortcutWorkspaceTargets,
|
||||
navigationActiveWorkspace:
|
||||
keyboardWorkspaceSelectionRef.current ?? activeWorkspaceSelection,
|
||||
commandCenterOpen: store.commandCenterOpen,
|
||||
shortcutsDialogOpen: store.shortcutsDialogOpen,
|
||||
},
|
||||
);
|
||||
const handled = performShortcutAction(shortcutAction, input.captureFocusRestore);
|
||||
if (handled && isWorkspaceFocusModeEnabled && input.action.startsWith("sidebar.")) {
|
||||
exitFocusMode();
|
||||
}
|
||||
return handled;
|
||||
};
|
||||
|
||||
const getContext = useStableEvent(() => ({
|
||||
isMac,
|
||||
usesDesktopBindings: usesDesktopBindings,
|
||||
commandCenterOpen: useKeyboardShortcutsStore.getState().commandCenterOpen,
|
||||
}));
|
||||
const getBindings = useStableEvent(() => bindings);
|
||||
const isCapturing = useStableEvent(() => useKeyboardShortcutsStore.getState().capturingShortcut);
|
||||
const dispatchMatch = useStableEvent(
|
||||
(match: import("./keyboard-shortcuts").KeyboardShortcutMatch, input: KeyboardSourceInput) =>
|
||||
routeAndPerformShortcut({
|
||||
action: match.action,
|
||||
payload: match.payload,
|
||||
captureFocusRestore: input.captureFocusRestore,
|
||||
}),
|
||||
);
|
||||
const chordChanged = useStableEvent(
|
||||
(previous: ChordState, next: ChordState, input: KeyboardSourceInput | null) => {
|
||||
const isBrowserInput = input !== null && "browserId" in input.event;
|
||||
if (
|
||||
!input ||
|
||||
shouldPublishBrowserShortcutPolicy({
|
||||
isBrowserInput,
|
||||
previousChordState: previous,
|
||||
nextChordState: next,
|
||||
})
|
||||
) {
|
||||
publishBrowserShortcutPolicy(next);
|
||||
}
|
||||
},
|
||||
);
|
||||
const beforeResolve = useStableEvent((input: KeyboardSourceInput) => {
|
||||
const event = input.event;
|
||||
const key = event.key ?? "";
|
||||
if (key === "Escape" && pathname.startsWith("/settings") && !isMobile && hasActiveOverlay()) {
|
||||
return false;
|
||||
}
|
||||
if (key === badgeModifierKey && !event.shiftKey) {
|
||||
setBadgeModifierDown(true);
|
||||
}
|
||||
if (key === "Shift") {
|
||||
const state = useKeyboardShortcutsStore.getState();
|
||||
if (state.altDown || state.cmdOrCtrlDown) {
|
||||
state.resetModifiers();
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const pipeline = useMemo(
|
||||
() =>
|
||||
createKeyboardPipeline({
|
||||
getContext,
|
||||
getBindings,
|
||||
isCapturing,
|
||||
dispatch: dispatchMatch,
|
||||
overlays,
|
||||
beforeResolve,
|
||||
chordChanged,
|
||||
}),
|
||||
[getContext, getBindings, isCapturing, dispatchMatch, beforeResolve, chordChanged],
|
||||
);
|
||||
|
||||
const handleKeyUp = useStableEvent((event: KeyboardInputEvent) => {
|
||||
const key = event.key ?? "";
|
||||
if (key === badgeModifierKey) {
|
||||
setBadgeModifierDown(false);
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const detach = attachKeyboardSources({
|
||||
keyDown: pipeline.keyDown,
|
||||
keyUp: handleKeyUp,
|
||||
blur: resetModifiers,
|
||||
commandCenterOpen: () => useKeyboardShortcutsStore.getState().commandCenterOpen,
|
||||
});
|
||||
return () => {
|
||||
detach();
|
||||
pipeline.dispose();
|
||||
};
|
||||
}, [enabled, pipeline, handleKeyUp, resetModifiers]);
|
||||
}
|
||||
@@ -1,19 +1,22 @@
|
||||
import { useGlobalOverlayLayer } from "./overlay-root";
|
||||
import { useOverlayRegistration } from "@/keyboard";
|
||||
import React, { type RefCallback } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
hasOpenDomPopover,
|
||||
keyBelongsToDomPopover,
|
||||
useWebOverlayRegistration,
|
||||
} from "./overlay-root";
|
||||
import { hasOpenDomPopover, keyBelongsToDomPopover } from "@/keyboard/internal/dom-popover";
|
||||
|
||||
function nextFrame(): Promise<void> {
|
||||
return new Promise((resolve) => requestAnimationFrame(() => resolve()));
|
||||
}
|
||||
|
||||
function OverlayHarness({ active, showScope }: { active: boolean; showScope: boolean }) {
|
||||
const setScope = useWebOverlayRegistration({ active, layer: 20, onKeyDown: () => false });
|
||||
const setScope = useOverlayRegistration({
|
||||
keys: ["Escape"],
|
||||
active,
|
||||
layer: 20,
|
||||
onKeyDown: () => false,
|
||||
});
|
||||
return showScope ? (
|
||||
<div data-testid="scope" ref={setScope as RefCallback<HTMLDivElement>} tabIndex={-1}>
|
||||
<input data-testid="overlay-input" />
|
||||
@@ -21,7 +24,7 @@ function OverlayHarness({ active, showScope }: { active: boolean; showScope: boo
|
||||
) : null;
|
||||
}
|
||||
|
||||
describe("useWebOverlayRegistration in the browser", () => {
|
||||
describe("useOverlayRegistration in the browser", () => {
|
||||
let root: Root;
|
||||
let container: HTMLDivElement;
|
||||
let opener: HTMLButtonElement;
|
||||
@@ -104,3 +107,93 @@ describe("Escape with a plugin popover open", () => {
|
||||
expect(keyBelongsToDomPopover({ key: "Escape" })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DOM source through the shared resolver", () => {
|
||||
it("uses user rebinds from a focused composer and cancels the DOM default", async () => {
|
||||
const { attachDomKeyboardSource } = await import("@/keyboard/internal/sources/dom.web");
|
||||
const { createKeyboardPipeline } = await import("@/keyboard/internal/pipeline");
|
||||
const { createOverlayRegistry } = await import("@/keyboard/internal/overlays");
|
||||
const { buildEffectiveBindings } = await import("@/keyboard/keyboard-shortcuts");
|
||||
const actions: string[] = [];
|
||||
const input = document.createElement("textarea");
|
||||
input.dataset.testid = "message-input-root";
|
||||
document.body.append(input);
|
||||
input.focus();
|
||||
const pipeline = createKeyboardPipeline({
|
||||
getContext: () => ({ isMac: true, usesDesktopBindings: false, commandCenterOpen: false }),
|
||||
getBindings: () => buildEffectiveBindings({ "command-center-toggle-cmd-k-mac": "Cmd+J" }),
|
||||
isCapturing: () => false,
|
||||
overlays: createOverlayRegistry(),
|
||||
dispatch: (match) => {
|
||||
actions.push(match.action);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
const detach = attachDomKeyboardSource({
|
||||
keyDown: pipeline.keyDown,
|
||||
keyUp() {},
|
||||
blur() {},
|
||||
commandCenterOpen: () => false,
|
||||
});
|
||||
const event = new KeyboardEvent("keydown", {
|
||||
key: "j",
|
||||
code: "KeyJ",
|
||||
metaKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
input.dispatchEvent(event);
|
||||
expect(actions).toEqual(["command-center.toggle"]);
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
detach();
|
||||
pipeline.dispose();
|
||||
input.remove();
|
||||
});
|
||||
});
|
||||
|
||||
it("recognizes a hardware keyboard from modified input without a fine pointer", async () => {
|
||||
const { createWebHardwareKeyboardPresence } = await import("@/keyboard/internal/presence.web");
|
||||
const presence = createWebHardwareKeyboardPresence(window.matchMedia("(width: 0px)"), window);
|
||||
const values: boolean[] = [];
|
||||
const release = presence.subscribe(() => values.push(presence.getSnapshot()));
|
||||
expect(presence.getSnapshot()).toBe(false);
|
||||
window.dispatchEvent(new KeyboardEvent("keydown", { key: "a", code: "KeyA" }));
|
||||
expect(presence.getSnapshot()).toBe(false);
|
||||
window.dispatchEvent(new KeyboardEvent("keydown", { key: "k", code: "KeyK", ctrlKey: true }));
|
||||
expect(presence.getSnapshot()).toBe(true);
|
||||
expect(values).toEqual([true]);
|
||||
release();
|
||||
});
|
||||
|
||||
function LayerHarness({ active, child }: { active: boolean; child: boolean }) {
|
||||
const layer = useGlobalOverlayLayer("modal", active);
|
||||
useOverlayRegistration({
|
||||
active: child,
|
||||
layer: 200,
|
||||
keys: ["Escape"],
|
||||
manageFocus: false,
|
||||
onKeyDown: () => false,
|
||||
});
|
||||
return <span data-testid="layer">{layer}</span>;
|
||||
}
|
||||
|
||||
it("keeps the opening layer across rerenders and captures a new layer on reopening", () => {
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
const root = createRoot(container);
|
||||
function render(active: boolean, child: boolean) {
|
||||
flushSync(() => root.render(<LayerHarness active={active} child={child} />));
|
||||
return container.textContent;
|
||||
}
|
||||
try {
|
||||
expect(render(false, false)).toBe("20");
|
||||
expect(render(true, false)).toBe("20");
|
||||
expect(render(true, true)).toBe("20");
|
||||
expect(render(true, true)).toBe("20");
|
||||
render(false, true);
|
||||
expect(render(true, true)).toBe("220");
|
||||
} finally {
|
||||
root.unmount();
|
||||
container.remove();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { dispatchTopWebOverlayKeyDown, useWebOverlayRegistration } from "./overlay-root";
|
||||
|
||||
describe("useWebOverlayRegistration", () => {
|
||||
let opener: HTMLButtonElement;
|
||||
let input: HTMLInputElement;
|
||||
let scope: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
|
||||
callback(0);
|
||||
return 0;
|
||||
});
|
||||
vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => {});
|
||||
|
||||
opener = document.createElement("button");
|
||||
input = document.createElement("input");
|
||||
scope = document.createElement("div");
|
||||
scope.tabIndex = -1;
|
||||
scope.append(input);
|
||||
document.body.append(opener, scope);
|
||||
opener.focus();
|
||||
expect(document.activeElement).toBe(opener);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
it("does not restore opener focus when an active overlay scope detaches", () => {
|
||||
const { result, unmount } = renderHook(() =>
|
||||
useWebOverlayRegistration({ active: true, layer: 20, onKeyDown: () => false }),
|
||||
);
|
||||
|
||||
act(() => result.current(scope));
|
||||
const openerFocus = vi.spyOn(opener, "focus");
|
||||
input.focus();
|
||||
expect(document.activeElement).toBe(input);
|
||||
|
||||
act(() => result.current(null));
|
||||
|
||||
expect(openerFocus).not.toHaveBeenCalled();
|
||||
expect(document.activeElement).toBe(input);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("restores opener focus when an overlay closes", () => {
|
||||
const { result, rerender, unmount } = renderHook(
|
||||
({ active }: { active: boolean }) =>
|
||||
useWebOverlayRegistration({ active, layer: 20, onKeyDown: () => false }),
|
||||
{ initialProps: { active: true } },
|
||||
);
|
||||
|
||||
act(() => result.current(scope));
|
||||
const openerFocus = vi.spyOn(opener, "focus");
|
||||
input.focus();
|
||||
expect(document.activeElement).toBe(input);
|
||||
|
||||
act(() => rerender({ active: false }));
|
||||
|
||||
expect(openerFocus).toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("restores opener focus when an active overlay unmounts after its scope detaches", () => {
|
||||
const { result, unmount } = renderHook(() =>
|
||||
useWebOverlayRegistration({ active: true, layer: 20, onKeyDown: () => false }),
|
||||
);
|
||||
|
||||
act(() => result.current(scope));
|
||||
const openerFocus = vi.spyOn(opener, "focus");
|
||||
input.focus();
|
||||
expect(document.activeElement).toBe(input);
|
||||
|
||||
act(() => result.current(null));
|
||||
expect(openerFocus).not.toHaveBeenCalled();
|
||||
|
||||
unmount();
|
||||
|
||||
expect(openerFocus).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves IME composition keys with the focused editor", () => {
|
||||
const event = new KeyboardEvent("keydown", { key: "Process", bubbles: true });
|
||||
expect(dispatchTopWebOverlayKeyDown(event)).toBe(false);
|
||||
expect(event.defaultPrevented).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,5 @@
|
||||
import {
|
||||
createContext,
|
||||
createElement,
|
||||
useCallback,
|
||||
useContext,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { createContext, createElement, useContext, useState, type ReactNode } from "react";
|
||||
import { getTopOverlayLayer } from "@/keyboard";
|
||||
|
||||
/**
|
||||
* Shared overlay root for web portals (modals, toasts, etc.)
|
||||
@@ -62,13 +54,27 @@ export function useOverlayLayer(kind: OverlayKind): number {
|
||||
* was topmost when it opened. Global hosts live outside their opener's React
|
||||
* tree, so context alone cannot preserve that relative ownership.
|
||||
*/
|
||||
export function useGlobalWebOverlayLayer(kind: OverlayKind, active: boolean): number {
|
||||
export function useGlobalOverlayLayer(kind: OverlayKind, active: boolean): number {
|
||||
const contextualLayer = useOverlayLayer(kind);
|
||||
return useMemo(() => {
|
||||
if (!active) return contextualLayer;
|
||||
const topLayer = getTopWebOverlay()?.getLayer() ?? 0;
|
||||
return Math.max(contextualLayer, topLayer + OVERLAY_Z[kind]);
|
||||
}, [active, contextualLayer, kind]);
|
||||
const captureOpening = () => ({
|
||||
active,
|
||||
contextualLayer,
|
||||
kind,
|
||||
layer: active
|
||||
? Math.max(contextualLayer, getTopOverlayLayer() + OVERLAY_Z[kind])
|
||||
: contextualLayer,
|
||||
});
|
||||
const [opening, setOpening] = useState(captureOpening);
|
||||
if (
|
||||
opening.active !== active ||
|
||||
opening.contextualLayer !== contextualLayer ||
|
||||
opening.kind !== kind
|
||||
) {
|
||||
const next = captureOpening();
|
||||
setOpening(next);
|
||||
return next.layer;
|
||||
}
|
||||
return opening.layer;
|
||||
}
|
||||
|
||||
export function useCurrentOverlayLayer(): number {
|
||||
@@ -78,304 +84,3 @@ export function useCurrentOverlayLayer(): number {
|
||||
export function OverlayLayerProvider({ layer, children }: { layer: number; children?: ReactNode }) {
|
||||
return createElement(OverlayLayerContext.Provider, { value: layer }, children);
|
||||
}
|
||||
|
||||
type WebOverlayKeyHandler = (event: KeyboardEvent) => boolean;
|
||||
|
||||
interface WebOverlayEntry {
|
||||
id: symbol;
|
||||
order: number;
|
||||
getLayer: () => number;
|
||||
getScope: () => HTMLElement | null;
|
||||
getKeyHandler: () => WebOverlayKeyHandler;
|
||||
restoreFocus: HTMLElement | null;
|
||||
manageFocus: boolean;
|
||||
}
|
||||
|
||||
const webOverlayEntries: WebOverlayEntry[] = [];
|
||||
let webOverlayOrder = 0;
|
||||
let webOverlayListenersAttached = false;
|
||||
let webOverlayFocusCheckQueued = false;
|
||||
|
||||
interface RemoveWebOverlayOptions {
|
||||
restoreFocus?: boolean;
|
||||
}
|
||||
|
||||
function getTopWebOverlay(): WebOverlayEntry | undefined {
|
||||
return webOverlayEntries.reduce<WebOverlayEntry | undefined>((top, entry) => {
|
||||
if (!top) return entry;
|
||||
const layerDifference = entry.getLayer() - top.getLayer();
|
||||
return layerDifference > 0 || (layerDifference === 0 && entry.order > top.order) ? entry : top;
|
||||
}, undefined);
|
||||
}
|
||||
|
||||
export function hasActiveWebOverlay(): boolean {
|
||||
return getTopWebOverlay() !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* True while an element shown with the Popover API is open. Such a layer
|
||||
* floats above the app like a registered overlay but lives outside the
|
||||
* registry: plugins draw their panels this way, since they cannot register.
|
||||
*/
|
||||
export function hasOpenDomPopover(): boolean {
|
||||
if (typeof document === "undefined") return false;
|
||||
try {
|
||||
return document.querySelector(":popover-open") !== null;
|
||||
} catch {
|
||||
// An engine without the Popover API rejects the selector, and has none open.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a key belongs to an open popover rather than to the app's shortcuts.
|
||||
* An open popover (a plugin's panel) is the top layer and closes on its own
|
||||
* Escape listener, which runs after the shortcut engine's capture listener:
|
||||
* Escape must not also interrupt the running agent.
|
||||
*/
|
||||
export function keyBelongsToDomPopover(event: Pick<KeyboardEvent, "key">): boolean {
|
||||
return event.key === "Escape" && hasOpenDomPopover();
|
||||
}
|
||||
|
||||
function getFocusableElements(scope: HTMLElement): HTMLElement[] {
|
||||
const selector = [
|
||||
"a[href]",
|
||||
"button:not([disabled])",
|
||||
"input:not([disabled])",
|
||||
"select:not([disabled])",
|
||||
"textarea:not([disabled])",
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
].join(",");
|
||||
|
||||
return Array.from(scope.querySelectorAll<HTMLElement>(selector)).filter(
|
||||
(element) =>
|
||||
element.getAttribute("aria-hidden") !== "true" &&
|
||||
!element.hasAttribute("hidden") &&
|
||||
element.getClientRects().length > 0,
|
||||
);
|
||||
}
|
||||
|
||||
function focusFirstElement(scope: HTMLElement): void {
|
||||
const firstMenuItem = scope.querySelector<HTMLElement>(
|
||||
'[data-menu-item="true"]:not([data-menu-disabled="true"])',
|
||||
);
|
||||
const first = firstMenuItem ?? getFocusableElements(scope)[0];
|
||||
(first ?? scope).focus();
|
||||
}
|
||||
|
||||
function handleWebOverlayFocus(event: FocusEvent): void {
|
||||
const top = getTopWebOverlay();
|
||||
const scope = top?.getScope();
|
||||
if (!top?.manageFocus || !scope || scope.contains(event.target as Node)) return;
|
||||
if (webOverlayFocusCheckQueued) return;
|
||||
|
||||
// React can autofocus a child before its parent scope ref attaches. Defer
|
||||
// enforcement until the commit finishes so a newly mounted higher overlay
|
||||
// can register without the previous scope stealing the requested focus.
|
||||
webOverlayFocusCheckQueued = true;
|
||||
queueMicrotask(() => {
|
||||
webOverlayFocusCheckQueued = false;
|
||||
const currentTop = getTopWebOverlay();
|
||||
const currentScope = currentTop?.getScope();
|
||||
if (!currentTop?.manageFocus || !currentScope || currentScope.contains(document.activeElement))
|
||||
return;
|
||||
focusFirstElement(currentScope);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gives the top painted overlay first refusal on a key before app-wide shortcuts run.
|
||||
* The window listener below is the fallback for keys nobody routes through the shortcut engine.
|
||||
*/
|
||||
export function dispatchTopWebOverlayKeyDown(event: KeyboardEvent): boolean {
|
||||
// Composition keys belong to the active text field. A modal must not turn an
|
||||
// IME candidate confirmation into an overlay shortcut before the browser has
|
||||
// committed the composed text.
|
||||
if (event.isComposing || event.key === "Process") return false;
|
||||
|
||||
const top = getTopWebOverlay();
|
||||
const scope = top?.getScope();
|
||||
if (!top || !scope) return false;
|
||||
|
||||
if (top.manageFocus && event.key === "Tab") {
|
||||
const focusable = getFocusableElements(scope);
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
const active = document.activeElement;
|
||||
const shouldWrapBackward = event.shiftKey && (!scope.contains(active) || active === first);
|
||||
const shouldWrapForward = !event.shiftKey && (!scope.contains(active) || active === last);
|
||||
if (shouldWrapBackward || shouldWrapForward) {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
(shouldWrapBackward ? last : first)?.focus();
|
||||
if (!first) scope.focus();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!top.getKeyHandler()(event)) return false;
|
||||
event.stopImmediatePropagation();
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleWebOverlayKeyDown(event: KeyboardEvent): void {
|
||||
dispatchTopWebOverlayKeyDown(event);
|
||||
}
|
||||
|
||||
function attachWebOverlayListeners(): void {
|
||||
if (webOverlayListenersAttached) return;
|
||||
window.addEventListener("keydown", handleWebOverlayKeyDown, true);
|
||||
document.addEventListener("focusin", handleWebOverlayFocus, true);
|
||||
webOverlayListenersAttached = true;
|
||||
}
|
||||
|
||||
function detachWebOverlayListeners(): void {
|
||||
if (!webOverlayListenersAttached || webOverlayEntries.length > 0) return;
|
||||
window.removeEventListener("keydown", handleWebOverlayKeyDown, true);
|
||||
document.removeEventListener("focusin", handleWebOverlayFocus, true);
|
||||
webOverlayListenersAttached = false;
|
||||
}
|
||||
|
||||
function addWebOverlay(entry: WebOverlayEntry): (options?: RemoveWebOverlayOptions) => void {
|
||||
webOverlayEntries.push(entry);
|
||||
attachWebOverlayListeners();
|
||||
|
||||
const focusFrame = window.requestAnimationFrame(() => {
|
||||
const scope = entry.getScope();
|
||||
if (
|
||||
entry.manageFocus &&
|
||||
getTopWebOverlay() === entry &&
|
||||
scope &&
|
||||
!scope.contains(document.activeElement)
|
||||
) {
|
||||
focusFirstElement(scope);
|
||||
}
|
||||
});
|
||||
|
||||
return (options) => {
|
||||
window.cancelAnimationFrame(focusFrame);
|
||||
const index = webOverlayEntries.findIndex((candidate) => candidate.id === entry.id);
|
||||
if (index !== -1) webOverlayEntries.splice(index, 1);
|
||||
detachWebOverlayListeners();
|
||||
if (
|
||||
entry.manageFocus &&
|
||||
options?.restoreFocus !== false &&
|
||||
entry.restoreFocus &&
|
||||
document.contains(entry.restoreFocus)
|
||||
) {
|
||||
entry.restoreFocus.focus();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
interface WebOverlayRegistration {
|
||||
active: boolean;
|
||||
layer: number;
|
||||
onKeyDown: WebOverlayKeyHandler;
|
||||
restoreFocusRef?: React.RefObject<unknown>;
|
||||
/** Hover surfaces claim overlay keys while preserving the user's existing focus. */
|
||||
manageFocus?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a focus scope in the same relative layer model used for painting.
|
||||
* Only the highest painted overlay receives keyboard input. By default, focus is trapped
|
||||
* there and restored to the opener when it closes; hover surfaces preserve focus instead.
|
||||
*/
|
||||
export function useWebOverlayRegistration({
|
||||
active,
|
||||
layer,
|
||||
onKeyDown,
|
||||
manageFocus = true,
|
||||
restoreFocusRef: preferredRestoreFocusRef,
|
||||
}: WebOverlayRegistration) {
|
||||
const idRef = useRef(Symbol("web-overlay"));
|
||||
const scopeRef = useRef<HTMLElement | null>(null);
|
||||
const layerRef = useRef(layer);
|
||||
const keyHandlerRef = useRef(onKeyDown);
|
||||
const capturedRestoreFocusRef = useRef<HTMLElement | null>(null);
|
||||
const removeEntryRef = useRef<((options?: RemoveWebOverlayOptions) => void) | null>(null);
|
||||
const detachedRemoveEntryRef = useRef<((options?: RemoveWebOverlayOptions) => void) | null>(null);
|
||||
const registeredRef = useRef(false);
|
||||
const activeRef = useRef(active);
|
||||
const wasActiveRef = useRef(false);
|
||||
|
||||
activeRef.current = active;
|
||||
layerRef.current = layer;
|
||||
keyHandlerRef.current = onKeyDown;
|
||||
if (active && !wasActiveRef.current && typeof document !== "undefined") {
|
||||
const requestedRestoreTarget = preferredRestoreFocusRef?.current;
|
||||
if (requestedRestoreTarget instanceof HTMLElement) {
|
||||
capturedRestoreFocusRef.current = requestedRestoreTarget;
|
||||
} else {
|
||||
capturedRestoreFocusRef.current =
|
||||
document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
}
|
||||
}
|
||||
wasActiveRef.current = active;
|
||||
|
||||
const syncRegistration = useCallback(() => {
|
||||
const shouldRegister =
|
||||
activeRef.current &&
|
||||
scopeRef.current != null &&
|
||||
typeof window !== "undefined" &&
|
||||
typeof document !== "undefined";
|
||||
if (!shouldRegister) {
|
||||
const shouldRestoreFocus = !activeRef.current;
|
||||
if (registeredRef.current || shouldRestoreFocus) {
|
||||
removeEntryRef.current?.({ restoreFocus: shouldRestoreFocus });
|
||||
}
|
||||
registeredRef.current = false;
|
||||
if (shouldRestoreFocus) {
|
||||
removeEntryRef.current = null;
|
||||
detachedRemoveEntryRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (registeredRef.current) return;
|
||||
|
||||
detachedRemoveEntryRef.current = null;
|
||||
const entry: WebOverlayEntry = {
|
||||
id: idRef.current,
|
||||
order: ++webOverlayOrder,
|
||||
getLayer: () => layerRef.current,
|
||||
getScope: () => scopeRef.current,
|
||||
getKeyHandler: () => keyHandlerRef.current,
|
||||
restoreFocus: capturedRestoreFocusRef.current,
|
||||
manageFocus,
|
||||
};
|
||||
removeEntryRef.current = addWebOverlay(entry);
|
||||
registeredRef.current = true;
|
||||
}, [manageFocus]);
|
||||
|
||||
const setScope = useCallback(
|
||||
(node: unknown) => {
|
||||
scopeRef.current =
|
||||
typeof HTMLElement !== "undefined" && node instanceof HTMLElement ? node : null;
|
||||
if (!scopeRef.current && activeRef.current) {
|
||||
const removeEntry = removeEntryRef.current;
|
||||
removeEntry?.({ restoreFocus: false });
|
||||
detachedRemoveEntryRef.current = removeEntry;
|
||||
removeEntryRef.current = null;
|
||||
registeredRef.current = false;
|
||||
}
|
||||
// Host refs attach before descendant layout effects and autofocus. Register
|
||||
// here so the previous overlay cannot redirect that pending focus.
|
||||
syncRegistration();
|
||||
},
|
||||
[syncRegistration],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
syncRegistration();
|
||||
return () => {
|
||||
const removeEntry = removeEntryRef.current ?? detachedRemoveEntryRef.current;
|
||||
removeEntry?.();
|
||||
removeEntryRef.current = null;
|
||||
detachedRemoveEntryRef.current = null;
|
||||
registeredRef.current = false;
|
||||
};
|
||||
}, [active, syncRegistration]);
|
||||
|
||||
return setScope;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useHardwareKeyboardPresent } from "@/keyboard";
|
||||
import { useAddHost } from "@/hosts/add-host";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
@@ -155,6 +156,7 @@ interface SidebarSectionItem {
|
||||
icon: ComponentType<{ size: number; color: string; strokeWidth?: number }>;
|
||||
desktopOnly?: boolean;
|
||||
webOnly?: boolean;
|
||||
hardwareKeyboardOnly?: boolean;
|
||||
/** The page body, for pages that need nothing from the settings screen. */
|
||||
Content?: ComponentType;
|
||||
}
|
||||
@@ -199,7 +201,7 @@ const SIDEBAR_SECTION_ITEMS: SidebarSectionItem[] = [
|
||||
id: "shortcuts",
|
||||
labelKey: "settings.sections.shortcuts",
|
||||
icon: Keyboard,
|
||||
desktopOnly: true,
|
||||
hardwareKeyboardOnly: true,
|
||||
Content: KeyboardShortcutsSection,
|
||||
},
|
||||
{
|
||||
@@ -227,7 +229,12 @@ const SIDEBAR_SECTION_ITEMS: SidebarSectionItem[] = [
|
||||
{ id: "about", labelKey: "settings.sections.about", icon: Info },
|
||||
];
|
||||
|
||||
function isSectionAvailable(item: SidebarSectionItem, isDesktopApp: boolean): boolean {
|
||||
function isSectionAvailable(
|
||||
item: SidebarSectionItem,
|
||||
isDesktopApp: boolean,
|
||||
hardwareKeyboardPresent: boolean,
|
||||
): boolean {
|
||||
if (item.hardwareKeyboardOnly && !hardwareKeyboardPresent) return false;
|
||||
return (!item.desktopOnly || isDesktopApp) && (!item.webOnly || isWeb);
|
||||
}
|
||||
|
||||
@@ -966,7 +973,10 @@ function SettingsSidebar({
|
||||
const hasHosts = sortedHosts.length > 0;
|
||||
const enableBuiltInDaemonOption = useEnableBuiltInDaemonOption();
|
||||
const isDesktopApp = isElectronRuntime();
|
||||
const items = SIDEBAR_SECTION_ITEMS.filter((item) => isSectionAvailable(item, isDesktopApp));
|
||||
const hardwareKeyboardPresent = useHardwareKeyboardPresent();
|
||||
const items = SIDEBAR_SECTION_ITEMS.filter((item) =>
|
||||
isSectionAvailable(item, isDesktopApp, hardwareKeyboardPresent),
|
||||
);
|
||||
const insets = useSafeAreaInsets();
|
||||
const isDesktop = layout === "desktop";
|
||||
const sidebarScroll = useSettingsSidebarScroll(isDesktop);
|
||||
@@ -1110,6 +1120,7 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
||||
const [isPlaybackTestRunning, setIsPlaybackTestRunning] = useState(false);
|
||||
const [playbackTestResult, setPlaybackTestResult] = useState<string | null>(null);
|
||||
const isDesktopApp = isElectronRuntime();
|
||||
const hardwareKeyboardPresent = useHardwareKeyboardPresent();
|
||||
const appVersion = resolveAppVersion();
|
||||
const appVersionText = formatVersionWithPrefix(appVersion);
|
||||
const isCompactLayout = useIsCompactFormFactor();
|
||||
@@ -1305,7 +1316,7 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
||||
}
|
||||
if (view.kind === "section") {
|
||||
const item = SIDEBAR_SECTION_ITEMS.find((candidate) => candidate.id === view.section);
|
||||
if (!item || !isSectionAvailable(item, isDesktopApp)) return null;
|
||||
if (!item || !isSectionAvailable(item, isDesktopApp, hardwareKeyboardPresent)) return null;
|
||||
switch (view.section) {
|
||||
case "general":
|
||||
return (
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user