mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-08 17:34:51 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f75bd144a | ||
|
|
93b9ac9ded | ||
|
|
eb8b5b8f94 | ||
|
|
51b9ad7695 | ||
|
|
1c4400194a | ||
|
|
1502b7f90f | ||
|
|
16f688b726 | ||
|
|
b507fc1986 | ||
|
|
e546497a21 | ||
|
|
b807c261ca | ||
|
|
85be2121b8 |
@@ -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 |
|
||||
|
||||
+16
-13
@@ -97,19 +97,15 @@ Register the adapter in `defaultForgeRegistry` with:
|
||||
- `matchesHost` from manifest `cloudHosts`
|
||||
- `probeHost` when self-hosted/Enterprise detection is supported
|
||||
|
||||
Current change-request lookup uses two identities deliberately:
|
||||
Current change-request lookup matches the branch and repository for both open and
|
||||
terminal requests. Prefer an open request, then a terminal request at the local
|
||||
HEAD, then the most recently updated terminal request. A reused branch can show
|
||||
its previous request until a new one opens. Auto-archive separately requires an
|
||||
observed open-to-merged transition and a fresh exact match between checkout HEAD
|
||||
and the PR head, along with the existing workspace safety checks.
|
||||
|
||||
- An open PR/MR belongs to the checkout when its head branch and head repository
|
||||
match. Its remote head SHA may differ because the checkout can be ahead,
|
||||
behind, or contain commits that have not been pushed yet.
|
||||
- A merged or closed PR/MR belongs to the checkout only when its recorded head
|
||||
SHA exactly matches the checkout's current `HEAD`. Branch names are reusable;
|
||||
selecting the newest terminal request by branch alone can silently attach an
|
||||
old promotion or feature request to new work.
|
||||
|
||||
Thread the checkout head SHA through adapter cache and poll identities as well
|
||||
as the lookup itself. Otherwise a commit made on the same branch can inherit the
|
||||
previous commit's cached terminal status until the cache expires.
|
||||
Keep checkout HEAD in adapter cache and poll identities so checkout changes
|
||||
refresh the facts used by archive eligibility.
|
||||
|
||||
Cloud hosts in the manifest are a bounded public-host list, not a self-host
|
||||
allowlist. Self-hosted detection is a trust gate: Paseo only talks to a forge
|
||||
@@ -125,7 +121,8 @@ rendering stack:
|
||||
`acme.ts` exports a `ClientForgeLogicModule`:
|
||||
|
||||
- `id`
|
||||
- optional `urlGrammar` (including `referencePaths` for composer auto-attachment)
|
||||
- optional `urlGrammar` (including `referencePaths` for composer auto-attachment
|
||||
and Markdown shorthand references)
|
||||
- optional `facts` registration (schema, merge-capability, native-check fallbacks)
|
||||
|
||||
`acme.view.tsx` exports a `ClientForgeViewModule`:
|
||||
@@ -212,6 +209,12 @@ are stale, run `npm run build:server`.
|
||||
neutral extractor matches the pasted host and full repository path against
|
||||
the current remote before it invokes cwd-routed forge search. Do not add a
|
||||
central forge switch or guess a self-hosted Gitea-family brand from URL shape.
|
||||
- Shorthand references in rendered Markdown (`#12`, GitLab and Gitea `!12`,
|
||||
cross-repository `owner/repo#12`) come from the `sigil` on each
|
||||
`referencePaths` entry. Leave `sigil` off a kind the forge does not link in
|
||||
prose: GitHub has no `!`, and its `#` targets `/issues/`, which GitHub
|
||||
redirects to the pull request. `packages/app/src/git/forge-shorthand` builds
|
||||
the recognition pattern from the workspace descriptor's forge and remote.
|
||||
- GitLab pipeline status constants belong to the GitLab adapter/client module,
|
||||
not protocol.
|
||||
- GitLab `manual` jobs need `allow_failure` to distinguish optional gray jobs
|
||||
|
||||
@@ -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.
|
||||
@@ -10,7 +10,7 @@ Every block is separated by one gap, `density.blockGap`: 12 on compact layouts,
|
||||
|
||||
## Links and images
|
||||
|
||||
Workspace file links use the shared file-link resolver. The workspace supplies its client, root and open-file action once; chat can supply the agent's more specific context. URLs use the same link press path. Recognized inline-code paths stay tappable. The library callback carries the tapped occurrence’s syntax context. Preserve it when opening files: explicit Markdown links decode URL escapes, while recognized inline-code paths preserve literal percent characters and use suffix lookup. Identical URL strings in different occurrences must stay distinct. Explicit absolute workspace links receive a relative hover title; existing authored titles remain intact.
|
||||
Workspace file links use the shared file-link resolver. The workspace supplies its client, root and open-file action once; chat can supply the agent's more specific context. URLs use the same link press path. Recognized inline-code paths stay tappable. The library callback carries the tapped occurrence’s syntax context. Preserve it when opening files: explicit Markdown links decode URL escapes, while recognized inline-code paths preserve literal percent characters and use suffix lookup. Identical URL strings in different occurrences must stay distinct. Explicit absolute workspace links receive a relative hover title; existing authored titles remain intact. Inside a workspace, forge references such as `#12` and `owner/repo#12` are recognized in prose (not code) and open the workspace forge; the reference pattern comes before path recognition so `group/project#12` is not read as a path. See [forge providers](forge-providers.md#gotchas).
|
||||
|
||||
Resolve workspace image paths over the existing session RPC, then give the library a cached native file URL or web blob URL. HTTP(S) images pass through unchanged. This preserves relay, SSH and local socket connections, which do not expose a daemon HTTP endpoint. Each mounted renderer owns its image acquisition identity, so a later reply reads a rewritten path again while the earlier mounted reply retains its snapshot. Image taps open the attachment lightbox. File previews resolve relative images against the Markdown file's directory. An image alone on its line becomes its own paragraph before the library sees it, so it renders as a block on every platform; the library only treats sole-child paragraph images as blocks, and with hard soft breaks every newline is a break node, which left own-line images inline on iOS and web. Images that share a line with text stay inline at line height and keep their aspect ratio. A linked image counts as an image, so review-bot badges such as `[](url) Confidence` stay inline on web; the library's web renderer only checked direct paragraph children, so it rendered them as full-width block images (local patch in `patches/react-native-enriched-markdown+*.patch`).
|
||||
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-iAi82L6Lm75hdg/9tJYyYcHMhCJLtXGSpcNqgv9QQaw=
|
||||
sha256-VdM4lXdgNEXPpTfvHSFvF9nm302rK/ZbVkZUp9tyOfg=
|
||||
|
||||
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"]);
|
||||
});
|
||||
|
||||
@@ -27,6 +27,7 @@ import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
|
||||
import { claudeAndCodexReports } from "../support/helpers/usage-sidebar-item";
|
||||
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||
|
||||
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||
|
||||
@@ -133,6 +134,30 @@ function sidebarFooter(page: Page): Locator {
|
||||
return visibleTestId(page, "sidebar-add-project").locator("xpath=..");
|
||||
}
|
||||
|
||||
function isPluginCatalogResponse(message: string | Buffer): boolean {
|
||||
if (typeof message !== "string") return false;
|
||||
const frame = JSON.parse(message);
|
||||
return frame.type === "session" && frame.message?.type === "plugin.catalog.get.response";
|
||||
}
|
||||
|
||||
async function holdNextPluginCatalogResponse(page: Page) {
|
||||
const response = Promise.withResolvers<() => void>();
|
||||
let holdCatalog = true;
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (socket) => {
|
||||
const server = socket.connectToServer();
|
||||
socket.onMessage((message) => server.send(message));
|
||||
server.onMessage((message) => {
|
||||
if (holdCatalog && isPluginCatalogResponse(message)) {
|
||||
holdCatalog = false;
|
||||
response.resolve(() => socket.send(message));
|
||||
return;
|
||||
}
|
||||
socket.send(message);
|
||||
});
|
||||
});
|
||||
return { response: response.promise };
|
||||
}
|
||||
|
||||
test.describe("Plugin sidebar items", () => {
|
||||
let workspaceId: string;
|
||||
let cleanup: () => Promise<void>;
|
||||
@@ -151,6 +176,38 @@ test.describe("Plugin sidebar items", () => {
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
test("a restored plugin screen waits for its catalog before rendering", async ({ page }) => {
|
||||
await page.setViewportSize(WIDE);
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
const row = headerRow(page, SHOWCASE_PLUGIN_ID, "deploys");
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await row.click();
|
||||
const body = page.getByText("Deploys screen body", { exact: true });
|
||||
await expect(body).toBeVisible();
|
||||
const screenUrl = page.url();
|
||||
const catalog = await holdNextPluginCatalogResponse(page);
|
||||
|
||||
await page.reload();
|
||||
const releaseCatalog = await catalog.response;
|
||||
try {
|
||||
await expect(
|
||||
page.getByRole("progressbar", { name: "Loading plugin", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText("This plugin surface is unavailable.", { exact: true }),
|
||||
).toHaveCount(0);
|
||||
await expect(body).toHaveCount(0);
|
||||
} finally {
|
||||
releaseCatalog();
|
||||
}
|
||||
|
||||
await expect(body).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("progressbar", { name: "Loading plugin", exact: true }),
|
||||
).toHaveCount(0);
|
||||
await expect(page).toHaveURL(screenUrl);
|
||||
});
|
||||
|
||||
test("a plugin's screen, header row, footer popover and command work together", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -38,12 +38,18 @@ export interface MarkdownImageContext {
|
||||
export function useMarkdownSource(
|
||||
text: string,
|
||||
context: MarkdownImageContext,
|
||||
commandNames?: ReadonlySet<string>,
|
||||
links: { commandNames?: ReadonlySet<string>; forgeReferencePattern?: string } = {},
|
||||
) {
|
||||
const occurrenceKey = useId();
|
||||
const { commandNames, forgeReferencePattern } = links;
|
||||
const prepared = useMemo(
|
||||
() => prepareMarkdownSource(text, context.workspaceRoot, { supportsSvg: isWeb, commandNames }),
|
||||
[text, context.workspaceRoot, commandNames],
|
||||
() =>
|
||||
prepareMarkdownSource(text, context.workspaceRoot, {
|
||||
supportsSvg: isWeb,
|
||||
commandNames,
|
||||
forgeReferencePattern,
|
||||
}),
|
||||
[text, context.workspaceRoot, commandNames, forgeReferencePattern],
|
||||
);
|
||||
const sources = [...new Set(prepared.images.map((image) => image.source))];
|
||||
const acquisitions = sources.map((source) => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { toNativeLinkRecognition } from "react-native-enriched-markdown/src/util
|
||||
import { parseMarkdown } from "react-native-enriched-markdown/src/web/parseMarkdown";
|
||||
import { recognizeTextLinks } from "react-native-enriched-markdown/src/web/recognizeTextLinks";
|
||||
import { matchCommandLink } from "@/agent-commands";
|
||||
import { createForgeShorthand } from "@/git/forge-shorthand";
|
||||
|
||||
import { classifyAssistantFileLink } from "@/assistant-file-links";
|
||||
import { markInlineImages } from "react-native-enriched-markdown/src/web/utils";
|
||||
@@ -443,6 +444,47 @@ it("lets the engine own code boundaries while classifying candidate values", asy
|
||||
]);
|
||||
});
|
||||
|
||||
it("recognizes forge references in prose but not in code, links or paths", async () => {
|
||||
const shorthand = createForgeShorthand({
|
||||
forge: "gitlab",
|
||||
remoteUrl: "git@gitlab.com:g/p.git",
|
||||
})!;
|
||||
const source = [
|
||||
"Fixed in #12 and !13, see g/sub/q#14 and https://example.com/a#15.",
|
||||
"`#16` and [#17](https://example.com) and src/app/index.ts and src/a/b/c#18",
|
||||
"```\n#19\n```",
|
||||
].join("\n\n");
|
||||
const prepared = prepareMarkdownSource(source, "/repo", {
|
||||
forgeReferencePattern: shorthand.pattern,
|
||||
});
|
||||
const tree = recognizeTextLinks(
|
||||
await parseMarkdown(prepared.render(), { latexMath: false }),
|
||||
prepared.linkRecognition.text,
|
||||
prepared.linkRecognition.inlineCode,
|
||||
);
|
||||
const recognized: string[] = [];
|
||||
function collect(node: ASTNode) {
|
||||
if (node.type === "Link" && node.attributes?.recognizedLink === "true") {
|
||||
recognized.push(String(node.attributes.url));
|
||||
}
|
||||
node.children?.forEach(collect);
|
||||
}
|
||||
collect(tree);
|
||||
expect(recognized).toEqual(["#12", "!13", "g/sub/q#14", "src/app/index.ts", "src/a/b/c#18"]);
|
||||
expect(recognized.map((url) => shorthand.resolve(url))).toEqual([
|
||||
"https://gitlab.com/g/p/-/issues/12",
|
||||
"https://gitlab.com/g/p/-/merge_requests/13",
|
||||
"https://gitlab.com/g/sub/q/-/issues/14",
|
||||
null,
|
||||
"https://gitlab.com/src/a/b/c/-/issues/18",
|
||||
]);
|
||||
// A rejected native regex silently falls back to no recognition at all.
|
||||
expect(toNativeLinkRecognition(prepared.linkRecognition).text).toMatchObject({
|
||||
isDisabled: false,
|
||||
isDefault: false,
|
||||
});
|
||||
});
|
||||
|
||||
describe("bare URL sentence punctuation", () => {
|
||||
it.each([".", ",", ";", ":", "!", "?", ")", ").", "...", "?!"])(
|
||||
"leaves %s outside the link and preserves the rendered sentence",
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
} from "@/assistant-file-links";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { matchCommandLink, useAgentCommandHints, type CommandHints } from "@/agent-commands";
|
||||
import { useForgeShorthand } from "@/git/forge-shorthand";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { AttachmentLightbox, type ImageLightboxSource } from "@/components/attachment-lightbox";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
@@ -89,6 +91,7 @@ const MarkdownPart = memo(function MarkdownPart({
|
||||
}: MarkdownRendererProps & { commandHints?: CommandHints | null }) {
|
||||
const environment = useAssistantFileLinkEnvironment();
|
||||
const actions = useAssistantFileLinkActions();
|
||||
const forgeShorthand = useForgeShorthand();
|
||||
const client =
|
||||
imageContext.client ??
|
||||
(environment.client?.readFile
|
||||
@@ -102,7 +105,7 @@ const MarkdownPart = memo(function MarkdownPart({
|
||||
...imageContext,
|
||||
client,
|
||||
},
|
||||
commandHints?.names,
|
||||
{ commandNames: commandHints?.names, forgeReferencePattern: forgeShorthand?.pattern },
|
||||
);
|
||||
const [image, setImage] = useState<ImageLightboxSource | null>(null);
|
||||
const pressLink = useStableEvent(({ url, sourceType }: LinkPressEvent) => {
|
||||
@@ -111,6 +114,11 @@ const MarkdownPart = memo(function MarkdownPart({
|
||||
commandHints.insert(command);
|
||||
return;
|
||||
}
|
||||
const forgeUrl = sourceType === "text" ? forgeShorthand?.resolve(url) : null;
|
||||
if (forgeUrl) {
|
||||
void openExternalUrl(forgeUrl);
|
||||
return;
|
||||
}
|
||||
const source = linkSource(url, sourceType);
|
||||
if (onLinkPress && !actions.canResolveFile(source)) onLinkPress(url);
|
||||
else actions.open(source, "preferred");
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
} from "react-native-enriched-markdown";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useFileLink } from "@/assistant-file-links";
|
||||
import { useForgeShorthand } from "@/git/forge-shorthand";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
||||
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
@@ -31,7 +32,13 @@ function WebCodeBlock({ code, language }: CodeBlockRendererProps) {
|
||||
const LINK_HOVER_STYLE = { textDecoration: "underline" } as const;
|
||||
|
||||
function WebLink({ link, anchorProps, children }: LinkRendererProps) {
|
||||
const { tooltipPath, onHoverIn } = useFileLink(markdownLinkSource(link.url, link.sourceType));
|
||||
const forgeShorthand = useForgeShorthand();
|
||||
// A forge reference like `#12` carries its web URL so hover, copy and new-tab opens work.
|
||||
const forgeUrl = link.sourceType === "text" ? forgeShorthand?.resolve(link.url) : null;
|
||||
const { tooltipPath, onHoverIn } = useFileLink(
|
||||
forgeUrl ? markdownLinkSource(forgeUrl) : markdownLinkSource(link.url, link.sourceType),
|
||||
);
|
||||
const tooltip = forgeUrl ?? tooltipPath;
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const handleMouseEnter = useCallback(() => setHovered(true), []);
|
||||
const handleMouseLeave = useCallback(() => setHovered(false), []);
|
||||
@@ -46,6 +53,7 @@ function WebLink({ link, anchorProps, children }: LinkRendererProps) {
|
||||
const anchor = (
|
||||
<a
|
||||
{...anchorProps}
|
||||
href={forgeUrl ?? anchorProps.href}
|
||||
style={anchorStyle}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
@@ -58,10 +66,10 @@ function WebLink({ link, anchorProps, children }: LinkRendererProps) {
|
||||
<TooltipTrigger asChild onHoverIn={onHoverIn}>
|
||||
<InlineLinkTrigger>{anchor}</InlineLinkTrigger>
|
||||
</TooltipTrigger>
|
||||
{tooltipPath ? (
|
||||
{tooltip ? (
|
||||
<TooltipContent side="top" align="start" maxWidth={520}>
|
||||
<Text selectable={false} style={styles.tooltipPath}>
|
||||
{tooltipPath}
|
||||
{tooltip}
|
||||
</Text>
|
||||
</TooltipContent>
|
||||
) : null}
|
||||
|
||||
@@ -24,10 +24,12 @@ const FILE_NAME = String.raw`${PATH_SEGMENT}\.(?:${FILE_EXTENSIONS})`;
|
||||
// Bare prose paths need a known extension, a line suffix, or at least two separators.
|
||||
const PROSE_PATH = String.raw`(?:${PATH_PREFIX}${HAS_PATH_LETTER}${SEPARATED_PATH}|${HAS_PATH_LETTER}(?:${PATH_SEGMENT}(?:[\\/]${PATH_SEGMENT})*[\\/]${FILE_NAME}|${PATH_SEGMENT}(?:[\\/]${PATH_SEGMENT}){2,}|${SEPARATED_PATH}(?=:)))`;
|
||||
|
||||
function createFilePattern(path: string): RegExp {
|
||||
function createTextPattern(path: string, forgeReferencePattern?: string): RegExp {
|
||||
// Forge references come before paths so `group/project#12` is not read as a path.
|
||||
const forgeReference = forgeReferencePattern ? `|${forgeReferencePattern}` : "";
|
||||
// The native adapter mistakes a literal + in lookbehind for a quantifier; use its hex escape.
|
||||
return new RegExp(
|
||||
String.raw`(?:${BARE_URL}|(?<![a-zA-Z0-9\u0080-\uFFFF_@.%\x2b~:/\\-])(?:${path}|${FILE_NAME})${LINE_SUFFIX}(?![a-zA-Z0-9\u0080-\uFFFF_@%+:/\\-]|\.${PATH_CHARACTER}))`,
|
||||
String.raw`(?:${BARE_URL}${forgeReference}|(?<![a-zA-Z0-9\u0080-\uFFFF_@.%\x2b~:/\\-])(?:${path}|${FILE_NAME})${LINE_SUFFIX}(?![a-zA-Z0-9\u0080-\uFFFF_@%+:/\\-]|\.${PATH_CHARACTER}))`,
|
||||
"g",
|
||||
);
|
||||
}
|
||||
@@ -38,8 +40,20 @@ const URL_CHARACTER = String.raw`[^\s<>()"'\x60]`;
|
||||
const URL_GROUP = String.raw`\(${URL_CHARACTER}*\)`;
|
||||
const BARE_URL = String.raw`https?://(?:${URL_CHARACTER}|${URL_GROUP})*(?:[^\s<>()"'\x60.,;:!?]|${URL_GROUP})`;
|
||||
|
||||
const FILE_PATTERN = createFilePattern(PROSE_PATH);
|
||||
function linkMetadata(inlineCodePaths: ReadonlySet<string>) {
|
||||
const TEXT_PATTERN = createTextPattern(PROSE_PATH);
|
||||
// One entry per distinct workspace forge remote; reuse keeps the engine's regex identity stable.
|
||||
const textPatternsByForgeReference = new Map<string, RegExp>();
|
||||
function textPattern(forgeReferencePattern?: string): RegExp {
|
||||
if (!forgeReferencePattern) return TEXT_PATTERN;
|
||||
let pattern = textPatternsByForgeReference.get(forgeReferencePattern);
|
||||
if (!pattern) {
|
||||
pattern = createTextPattern(PROSE_PATH, forgeReferencePattern);
|
||||
textPatternsByForgeReference.set(forgeReferencePattern, pattern);
|
||||
}
|
||||
return pattern;
|
||||
}
|
||||
|
||||
function linkMetadata(inlineCodePaths: ReadonlySet<string>, forgeReferencePattern?: string) {
|
||||
// The engine accepts regexes on both platforms. Classify complete code spans
|
||||
// first, then pass literal matches rather than maintaining a second path grammar.
|
||||
const alternatives = [...inlineCodePaths].map((path) =>
|
||||
@@ -47,7 +61,7 @@ function linkMetadata(inlineCodePaths: ReadonlySet<string>) {
|
||||
);
|
||||
return {
|
||||
linkRecognition: {
|
||||
text: FILE_PATTERN,
|
||||
text: textPattern(forgeReferencePattern),
|
||||
inlineCode: new RegExp(
|
||||
String.raw`^(?:https?://[^\s<>]+${alternatives.length ? `|${alternatives.join("|")}` : ""})$`,
|
||||
),
|
||||
@@ -120,7 +134,12 @@ function workspaceHoverTitle(link: Link, markup: string, workspaceRoot?: string)
|
||||
export function prepareMarkdownSource(
|
||||
source: string,
|
||||
workspaceRoot?: string,
|
||||
options: { supportsSvg?: boolean; commandNames?: ReadonlySet<string> } = {},
|
||||
options: {
|
||||
supportsSvg?: boolean;
|
||||
commandNames?: ReadonlySet<string>;
|
||||
/** Regex source for forge references such as `#12`; see `@/git/forge-shorthand`. */
|
||||
forgeReferencePattern?: string;
|
||||
} = {},
|
||||
) {
|
||||
source = normalizeMarkdownHtml(source);
|
||||
const images: MarkdownImage[] = [];
|
||||
@@ -132,6 +151,7 @@ export function prepareMarkdownSource(
|
||||
}));
|
||||
const metadata = linkMetadata(
|
||||
collectInlineCodePaths(source, workspaceRoot, options.commandNames),
|
||||
options.forgeReferencePattern,
|
||||
);
|
||||
const edits: { start: number; end: number; text: string }[] = [];
|
||||
if (
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -41,8 +41,15 @@ export interface ForgeReferencePath {
|
||||
kind: "change_request" | "issue";
|
||||
/** Path between the repository path and the numeric local id. */
|
||||
infix: string;
|
||||
/**
|
||||
* Shorthand marker the forge links in prose, e.g. GitHub `#12`, GitLab `!12`.
|
||||
* Absent when the forge has no shorthand for this kind.
|
||||
*/
|
||||
sigil?: ForgeReferenceSigil;
|
||||
}
|
||||
|
||||
export type ForgeReferenceSigil = "#" | "!";
|
||||
|
||||
/** Line/range anchor shared by every GitHub-family forge (github, gitea, forgejo, codeberg). */
|
||||
export function GITHUB_LINE_ANCHOR(start: number, end?: number): string {
|
||||
return end && end > start ? `#L${start}-L${end}` : `#L${start}`;
|
||||
@@ -54,8 +61,8 @@ export const GITEA_FAMILY_URL_GRAMMAR: ForgeUrlGrammar = {
|
||||
blobInfix: "/src/branch/",
|
||||
lineAnchor: GITHUB_LINE_ANCHOR,
|
||||
referencePaths: [
|
||||
{ kind: "change_request", infix: "/pulls/" },
|
||||
{ kind: "issue", infix: "/issues/" },
|
||||
{ kind: "change_request", infix: "/pulls/", sigil: "!" },
|
||||
{ kind: "issue", infix: "/issues/", sigil: "#" },
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { createContext, useCallback, useContext, useMemo, type ReactNode } from "react";
|
||||
import { createForgeShorthand, type ForgeShorthand } from "./shorthand";
|
||||
|
||||
export { createForgeShorthand, type ForgeShorthand } from "./shorthand";
|
||||
|
||||
const ForgeShorthandContext = createContext<ForgeShorthand | null>(null);
|
||||
|
||||
/** Makes `#12`-style references in descendant Markdown open on the workspace forge. */
|
||||
export function ForgeShorthandProvider({
|
||||
forge,
|
||||
remoteUrl,
|
||||
children,
|
||||
}: {
|
||||
forge: string | null | undefined;
|
||||
remoteUrl: string | null | undefined;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const value = useMemo(() => createForgeShorthand({ forge, remoteUrl }), [forge, remoteUrl]);
|
||||
return <ForgeShorthandContext.Provider value={value}>{children}</ForgeShorthandContext.Provider>;
|
||||
}
|
||||
|
||||
export function useForgeShorthand(): ForgeShorthand | null {
|
||||
return useContext(ForgeShorthandContext);
|
||||
}
|
||||
|
||||
/** Carries the shorthand environment across a portal. */
|
||||
export function useForgeShorthandBridge() {
|
||||
const value = useContext(ForgeShorthandContext);
|
||||
return useCallback(
|
||||
(children: ReactNode) => (
|
||||
<ForgeShorthandContext.Provider value={value}>{children}</ForgeShorthandContext.Provider>
|
||||
),
|
||||
[value],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createForgeShorthand } from "./shorthand";
|
||||
|
||||
function matches(shorthand: { pattern: string }, text: string): string[] {
|
||||
return [...text.matchAll(new RegExp(shorthand.pattern, "g"))].map((match) => match[0]);
|
||||
}
|
||||
|
||||
describe("createForgeShorthand", () => {
|
||||
const github = createForgeShorthand({
|
||||
forge: "github",
|
||||
remoteUrl: "git@github.com:getpaseo/paseo.git",
|
||||
})!;
|
||||
const gitlab = createForgeShorthand({
|
||||
forge: "gitlab",
|
||||
remoteUrl: "https://gitlab.com/g/p.git",
|
||||
})!;
|
||||
|
||||
it("resolves same-repo and cross-repo references on the remote's host", () => {
|
||||
expect(github.resolve("#8613")).toBe("https://github.com/getpaseo/paseo/issues/8613");
|
||||
expect(github.resolve("acme/tools#4")).toBe("https://github.com/acme/tools/issues/4");
|
||||
expect(gitlab.resolve("!7")).toBe("https://gitlab.com/g/p/-/merge_requests/7");
|
||||
expect(gitlab.resolve("g/sub/q#3")).toBe("https://gitlab.com/g/sub/q/-/issues/3");
|
||||
});
|
||||
|
||||
it("only recognizes the sigils the forge declares", () => {
|
||||
expect(matches(github, "see #1 and !2")).toEqual(["#1"]);
|
||||
expect(github.resolve("!2")).toBeNull();
|
||||
expect(matches(gitlab, "see #1 and !2")).toEqual(["#1", "!2"]);
|
||||
});
|
||||
|
||||
it("recognizes references only at word boundaries", () => {
|
||||
expect(
|
||||
matches(
|
||||
github,
|
||||
"(#1), #2. acme/tools#3, foo#4 #5abc  https://x/#7 #0 #08 wow!9 user@host#10",
|
||||
),
|
||||
).toEqual(["#1", "#2", "acme/tools#3"]);
|
||||
});
|
||||
|
||||
it("falls back to the cloud host forge when the daemon reports none", () => {
|
||||
expect(
|
||||
createForgeShorthand({ forge: undefined, remoteUrl: "git@github.com:a/b.git" })?.resolve(
|
||||
"#1",
|
||||
),
|
||||
).toBe("https://github.com/a/b/issues/1");
|
||||
});
|
||||
|
||||
it("is absent without a remote or a known forge", () => {
|
||||
expect(createForgeShorthand({ forge: "github", remoteUrl: null })).toBeNull();
|
||||
expect(
|
||||
createForgeShorthand({ forge: undefined, remoteUrl: "git@git.corp:a/b.git" }),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { ForgeReferenceSigil } from "@/git/client-forge-module";
|
||||
import { forgeFromRemoteUrl } from "@/git/forge";
|
||||
import { getClientForgeLogicModule } from "@/git/forges";
|
||||
import { buildForgeReferenceUrl } from "@/git/forge-url";
|
||||
|
||||
/**
|
||||
* Shorthand references in prose, such as `#12`, `!12` or `owner/repo#12`,
|
||||
* resolved against one workspace remote.
|
||||
*/
|
||||
export interface ForgeShorthand {
|
||||
/** Regex source for recognizing references in Markdown text. */
|
||||
pattern: string;
|
||||
/** Web URL for a recognized reference, or null. */
|
||||
resolve(text: string): string | null;
|
||||
}
|
||||
|
||||
// Keep these in the syntax shared by JavaScript, ICU and java.util.regex: the
|
||||
// pattern runs in the native Markdown engines too.
|
||||
const REPO = String.raw`[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)+`;
|
||||
const NUMBER = String.raw`[1-9][0-9]{0,8}`;
|
||||
// A reference never continues a word, path, URL fragment or entity.
|
||||
const BEFORE = String.raw`(?<![A-Za-z0-9_./@#!&-])`;
|
||||
const AFTER = String.raw`(?![A-Za-z0-9_])`;
|
||||
const REFERENCE = new RegExp(String.raw`^(?:(${REPO}))?([#!])(${NUMBER})$`);
|
||||
|
||||
export function createForgeShorthand(input: {
|
||||
forge: string | null | undefined;
|
||||
remoteUrl: string | null | undefined;
|
||||
}): ForgeShorthand | null {
|
||||
const remoteUrl = input.remoteUrl;
|
||||
const forge = input.forge || forgeFromRemoteUrl(remoteUrl);
|
||||
if (!forge || !remoteUrl) return null;
|
||||
const sigils = forgeSigils(forge).filter(
|
||||
(sigil) => buildForgeReferenceUrl(forge, { remoteUrl, sigil, number: 1 }) !== null,
|
||||
);
|
||||
if (sigils.length === 0) return null;
|
||||
return {
|
||||
pattern: `${BEFORE}(?:${REPO})?[${sigils.join("")}]${NUMBER}${AFTER}`,
|
||||
resolve(text) {
|
||||
const match = REFERENCE.exec(text);
|
||||
if (!match) return null;
|
||||
return buildForgeReferenceUrl(forge, {
|
||||
remoteUrl,
|
||||
repo: match[1] ?? null,
|
||||
sigil: match[2] as ForgeReferenceSigil,
|
||||
number: Number(match[3]),
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function forgeSigils(forge: string): ForgeReferenceSigil[] {
|
||||
const paths = getClientForgeLogicModule(forge)?.urlGrammar?.referencePaths ?? [];
|
||||
return paths.flatMap((path) => (path.sigil ? [path.sigil] : []));
|
||||
}
|
||||
@@ -3,9 +3,77 @@ import {
|
||||
buildForgeBlobUrl,
|
||||
buildForgeBranchTreeUrl,
|
||||
buildForgeChecksUrl,
|
||||
buildForgeReferenceUrl,
|
||||
hasForgeWebUrls,
|
||||
} from "./forge-url";
|
||||
|
||||
describe("buildForgeReferenceUrl", () => {
|
||||
it.each([
|
||||
["github", "git@github.com:acme/repo.git", "#", null, "https://github.com/acme/repo/issues/12"],
|
||||
["github", "git@github.com:acme/repo.git", "!", null, null],
|
||||
[
|
||||
"github",
|
||||
"https://github.com/acme/repo",
|
||||
"#",
|
||||
"other/tools",
|
||||
"https://github.com/other/tools/issues/12",
|
||||
],
|
||||
["gitlab", "git@gitlab.com:g/sub/p.git", "#", null, "https://gitlab.com/g/sub/p/-/issues/12"],
|
||||
[
|
||||
"gitlab",
|
||||
"git@gitlab.com:g/sub/p.git",
|
||||
"!",
|
||||
null,
|
||||
"https://gitlab.com/g/sub/p/-/merge_requests/12",
|
||||
],
|
||||
[
|
||||
"gitlab",
|
||||
"https://gitlab.corp:8443/g/p.git",
|
||||
"!",
|
||||
"g/q",
|
||||
"https://gitlab.corp:8443/g/q/-/merge_requests/12",
|
||||
],
|
||||
[
|
||||
"codeberg",
|
||||
"https://codeberg.org/acme/repo.git",
|
||||
"!",
|
||||
null,
|
||||
"https://codeberg.org/acme/repo/pulls/12",
|
||||
],
|
||||
[
|
||||
"gitea",
|
||||
"http://forge.test:3000/team/repo.git",
|
||||
"#",
|
||||
null,
|
||||
"http://forge.test:3000/team/repo/issues/12",
|
||||
],
|
||||
[
|
||||
"github",
|
||||
"http://github.com/acme/repo.git",
|
||||
"#",
|
||||
null,
|
||||
"https://github.com/acme/repo/issues/12",
|
||||
],
|
||||
[
|
||||
"forgejo",
|
||||
"git@forge.example:acme/repo.git",
|
||||
"#",
|
||||
null,
|
||||
"https://forge.example/acme/repo/issues/12",
|
||||
],
|
||||
["bitbucket", "git@bitbucket.org:acme/repo.git", "#", null, null],
|
||||
["github", null, "#", null, null],
|
||||
["github", "git@github.com:acme/repo.git", "#", "../escape", null],
|
||||
] as const)("builds %s %s %s in %s", (forge, remoteUrl, sigil, repo, expected) => {
|
||||
expect(buildForgeReferenceUrl(forge, { remoteUrl, repo, sigil, number: 12 })).toBe(expected);
|
||||
});
|
||||
|
||||
it("rejects non-positive numbers", () => {
|
||||
const remoteUrl = "git@github.com:acme/repo.git";
|
||||
expect(buildForgeReferenceUrl("github", { remoteUrl, sigil: "#", number: 0 })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildForgeChecksUrl", () => {
|
||||
it.each([
|
||||
[
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
*/
|
||||
import { getForgeDefinition } from "@getpaseo/protocol/forge-manifest";
|
||||
import { normalizeHost, parseGitRemoteLocation } from "@getpaseo/protocol/git-remote";
|
||||
import type { ForgeReferenceSigil } from "@/git/client-forge-module";
|
||||
import { getClientForgeLogicModule } from "@/git/forges";
|
||||
|
||||
export interface ForgeBlobUrlInput {
|
||||
@@ -27,6 +28,14 @@ export interface ForgeBranchTreeUrlInput {
|
||||
branch: string | null | undefined;
|
||||
}
|
||||
|
||||
export interface ForgeReferenceUrlInput {
|
||||
remoteUrl: string | null | undefined;
|
||||
/** Repository path of a cross-repository reference; the remote's repository when absent. */
|
||||
repo?: string | null;
|
||||
sigil: ForgeReferenceSigil;
|
||||
number: number;
|
||||
}
|
||||
|
||||
export function buildForgeChecksUrl(forge: string, changeRequestUrl: string): string | null {
|
||||
const suffix = getClientForgeLogicModule(forge)?.urlGrammar?.changeRequestChecksSuffix;
|
||||
if (!suffix) {
|
||||
@@ -45,6 +54,8 @@ export function buildForgeChecksUrl(forge: string, changeRequestUrl: string): st
|
||||
}
|
||||
|
||||
interface ForgeWebLocation {
|
||||
/** "http" only for a self-hosted http:// remote; every other remote browses over https. */
|
||||
scheme: "http" | "https";
|
||||
host: string;
|
||||
/** Non-default port for a self-hosted http(s) origin, or undefined. */
|
||||
port?: string;
|
||||
@@ -87,16 +98,18 @@ function resolveForgeWebLocation(
|
||||
!isCloudHost && (location.transport === "http" || location.transport === "https")
|
||||
? location.port
|
||||
: undefined;
|
||||
return { host: webHost, port, repo: location.path };
|
||||
const scheme = !isCloudHost && location.transport === "http" ? "http" : "https";
|
||||
return { scheme, host: webHost, port, repo: location.path };
|
||||
}
|
||||
|
||||
function encodeBranch(branch: string): string {
|
||||
return branch.split("/").map(encodeURIComponent).join("/");
|
||||
}
|
||||
|
||||
/** Host, plus `:port` when the remote pins a non-default port. */
|
||||
function forgeAuthority(location: ForgeWebLocation): string {
|
||||
return location.port ? `${location.host}:${location.port}` : location.host;
|
||||
/** Scheme and host, plus `:port` when the remote pins a non-default port. */
|
||||
function forgeOrigin(location: ForgeWebLocation): string {
|
||||
const authority = location.port ? `${location.host}:${location.port}` : location.host;
|
||||
return `${location.scheme}://${authority}`;
|
||||
}
|
||||
|
||||
function normalizeBlobPath(path: string | null | undefined): string | null {
|
||||
@@ -131,7 +144,7 @@ export function buildForgeBranchTreeUrl(
|
||||
if (!grammar || !location || !branch || branch === "HEAD") {
|
||||
return null;
|
||||
}
|
||||
return `https://${forgeAuthority(location)}/${location.repo}${grammar.treeInfix}${encodeBranch(branch)}`;
|
||||
return `${forgeOrigin(location)}/${location.repo}${grammar.treeInfix}${encodeBranch(branch)}`;
|
||||
}
|
||||
|
||||
export function buildForgeBlobUrl(forge: string, input: ForgeBlobUrlInput): string | null {
|
||||
@@ -143,13 +156,40 @@ export function buildForgeBlobUrl(forge: string, input: ForgeBlobUrlInput): stri
|
||||
return null;
|
||||
}
|
||||
const encodedPath = filePath.split("/").map(encodeURIComponent).join("/");
|
||||
let url = `https://${forgeAuthority(location)}/${location.repo}${grammar.blobInfix}${encodeBranch(branch)}/${encodedPath}`;
|
||||
let url = `${forgeOrigin(location)}/${location.repo}${grammar.blobInfix}${encodeBranch(branch)}/${encodedPath}`;
|
||||
if (input.lineStart && input.lineStart > 0) {
|
||||
url += grammar.lineAnchor(input.lineStart, input.lineEnd);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Web URL of a shorthand reference such as `#12` or `group/project!12`. A
|
||||
* cross-repository reference stays on the remote's host. Null when the forge
|
||||
* has no shorthand for the sigil.
|
||||
*/
|
||||
export function buildForgeReferenceUrl(
|
||||
forge: string,
|
||||
input: ForgeReferenceUrlInput,
|
||||
): string | null {
|
||||
const referencePath = getClientForgeLogicModule(forge)?.urlGrammar?.referencePaths?.find(
|
||||
(path) => path.sigil === input.sigil,
|
||||
);
|
||||
const location = resolveForgeWebLocation(forge, input.remoteUrl);
|
||||
const repo = input.repo ?? location?.repo;
|
||||
if (
|
||||
!referencePath ||
|
||||
!location ||
|
||||
!repo ||
|
||||
!isValidRepoPath(repo) ||
|
||||
!Number.isSafeInteger(input.number) ||
|
||||
input.number <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return `${forgeOrigin(location)}/${repo}${referencePath.infix}${input.number}`;
|
||||
}
|
||||
|
||||
/** Whether the forge has web URL builders (i.e. a known URL grammar). */
|
||||
export function hasForgeWebUrls(forge: string): boolean {
|
||||
return getClientForgeLogicModule(forge)?.urlGrammar !== undefined;
|
||||
|
||||
@@ -89,7 +89,8 @@ export const githubForgeLogic = {
|
||||
changeRequestChecksSuffix: "/checks",
|
||||
referencePaths: [
|
||||
{ kind: "change_request", infix: "/pull/" },
|
||||
{ kind: "issue", infix: "/issues/" },
|
||||
// GitHub redirects /issues/N to /pull/N, so `#N` covers both kinds.
|
||||
{ kind: "issue", infix: "/issues/", sigil: "#" },
|
||||
],
|
||||
},
|
||||
facts: defineForgeFacts({
|
||||
|
||||
@@ -166,8 +166,8 @@ export const gitlabForgeLogic = {
|
||||
lineAnchor: gitlabLineAnchor,
|
||||
changeRequestChecksSuffix: "/pipelines",
|
||||
referencePaths: [
|
||||
{ kind: "change_request", infix: "/-/merge_requests/" },
|
||||
{ kind: "issue", infix: "/-/issues/" },
|
||||
{ kind: "change_request", infix: "/-/merge_requests/", sigil: "!" },
|
||||
{ kind: "issue", infix: "/-/issues/", sigil: "#" },
|
||||
],
|
||||
},
|
||||
facts: defineForgeFacts({
|
||||
|
||||
@@ -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();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user