mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-17 14:52:36 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
533f504dfd | ||
|
|
ebc4ebc007 | ||
|
|
72d14325e6 | ||
|
|
5ead2a8811 | ||
|
|
3a10500fcf |
@@ -95,6 +95,11 @@ highest painted scope alone receives overlay keys, traps focus, and restores
|
||||
focus when it closes. Hover cards register with `manageFocus={false}`: they claim overlay keys without moving or trapping focus on pointer hover. Do not add component-local global Escape listeners: two
|
||||
stacked overlays would both close on one keypress.
|
||||
|
||||
When deferring workspace terminal autofocus, distinguish movement within the
|
||||
top registered overlay scope from a new focus owner outside it. Internal focus
|
||||
movement must keep the request pending until the overlay closes; an outside
|
||||
control cancels it so its next keystroke is not sent to the terminal.
|
||||
|
||||
If an overlay is rendered by a global host rather than beneath its opener in
|
||||
the React tree, carry the opener's current layer through the host store and
|
||||
restore it with `OverlayLayerProvider`. Otherwise painting and keyboard
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { TerminalE2EHarness } from "../support/helpers/terminal-dsl";
|
||||
import { openCommandCenter, closeCommandCenter } from "../support/helpers/command-center";
|
||||
|
||||
interface AttachOverlayProbeWindow extends Window {
|
||||
__terminalAttachOverlaySeen?: boolean;
|
||||
@@ -59,4 +60,87 @@ test.describe("retained terminal tab streams", () => {
|
||||
|
||||
expect(await terminalAttachOverlayWasSeen(page)).toBe(false);
|
||||
});
|
||||
|
||||
test("workspace number shortcuts refocus a retained terminal", async ({ page }) => {
|
||||
const first = await harness.createTerminal({ name: "focus-first" });
|
||||
const other = await harness.createOtherWorkspace();
|
||||
const second = await other.createTerminal({ name: "focus-second" });
|
||||
await harness.openTerminal(page, { terminalId: first.id });
|
||||
await other.openTerminal(page, { terminalId: second.id });
|
||||
await other.expectTerminalFocused(page);
|
||||
await harness.switchToWorkspaceByShortcut(page);
|
||||
await harness.expectTerminalFocused(page);
|
||||
await other.switchToWorkspaceByShortcut(page);
|
||||
await other.expectTerminalFocused(page);
|
||||
await other.typeCommandAndExpectOutput(page, {
|
||||
terminalId: second.id,
|
||||
command: "printf 'WORKSPACE_FOCUS_OK\\n'",
|
||||
output: "WORKSPACE_FOCUS_OK",
|
||||
});
|
||||
});
|
||||
|
||||
test("closing an overlay after returning to a workspace refocuses its terminal", async ({
|
||||
page,
|
||||
}) => {
|
||||
const first = await harness.createTerminal({ name: "overlay-first" });
|
||||
const other = await harness.createOtherWorkspace();
|
||||
const second = await other.createTerminal({ name: "overlay-second" });
|
||||
await harness.openTerminal(page, { terminalId: first.id });
|
||||
await other.openTerminal(page, { terminalId: second.id });
|
||||
await harness.switchToWorkspaceByShortcut(page);
|
||||
await harness.expectTerminalFocused(page);
|
||||
await harness.rememberWorkspaceForHistoryReturn(page);
|
||||
await other.switchToWorkspaceByShortcut(page);
|
||||
const overlay = await openCommandCenter(page);
|
||||
await harness.returnToWorkspaceThroughHistory(page);
|
||||
await expect(overlay.getByTestId("command-center-input")).toBeFocused();
|
||||
await closeCommandCenter(page);
|
||||
await harness.expectTerminalFocused(page);
|
||||
await harness.typeCommandAndExpectOutput(page, {
|
||||
terminalId: first.id,
|
||||
command: "printf 'OVERLAY_FOCUS_OK\\n'",
|
||||
output: "OVERLAY_FOCUS_OK",
|
||||
});
|
||||
});
|
||||
|
||||
test("moving focus inside an overlay preserves deferred terminal focus", async ({ page }) => {
|
||||
const first = await harness.createTerminal({ name: "overlay-move-first" });
|
||||
const other = await harness.createOtherWorkspace();
|
||||
const second = await other.createTerminal({ name: "overlay-move-second" });
|
||||
await harness.openTerminal(page, { terminalId: first.id });
|
||||
await other.openTerminal(page, { terminalId: second.id });
|
||||
await harness.switchToWorkspaceByShortcut(page);
|
||||
await harness.expectTerminalFocused(page);
|
||||
await harness.rememberWorkspaceForHistoryReturn(page);
|
||||
await other.switchToWorkspaceByShortcut(page);
|
||||
await openCommandCenter(page);
|
||||
await harness.returnToWorkspaceThroughHistory(page);
|
||||
await harness.moveFocusWithinCommandCenter(page);
|
||||
await closeCommandCenter(page);
|
||||
await harness.expectTerminalFocused(page);
|
||||
await harness.typeCommandAndExpectOutput(page, {
|
||||
terminalId: first.id,
|
||||
command: "printf 'OVERLAY_MOVE_FOCUS_OK\\n'",
|
||||
output: "OVERLAY_MOVE_FOCUS_OK",
|
||||
});
|
||||
});
|
||||
|
||||
test("deferred terminal focus does not steal focus from a button", async ({ page }) => {
|
||||
const first = await harness.createTerminal({ name: "button-first" });
|
||||
const other = await harness.createOtherWorkspace();
|
||||
const second = await other.createTerminal({ name: "button-second" });
|
||||
await harness.openTerminal(page, { terminalId: first.id });
|
||||
await other.openTerminal(page, { terminalId: second.id });
|
||||
await harness.switchToWorkspaceByShortcut(page);
|
||||
await harness.expectTerminalFocused(page);
|
||||
await harness.rememberWorkspaceForHistoryReturn(page);
|
||||
await other.switchToWorkspaceByShortcut(page);
|
||||
const overlay = await openCommandCenter(page);
|
||||
await harness.returnToWorkspaceThroughHistory(page);
|
||||
await expect(overlay.getByTestId("command-center-input")).toBeFocused();
|
||||
await harness.closeOverlayAndFocusButtonBeforeRetry(page);
|
||||
await expect(page.getByTestId("sidebar-search")).toBeFocused();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(overlay).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import type { TerminalActivity, TerminalActivityState } from "@getpaseo/protocol/terminal-activity";
|
||||
import { createTempGitRepo } from "./workspace";
|
||||
import { navigateToTerminal, setupDeterministicPrompt } from "./terminal-perf";
|
||||
@@ -30,6 +30,7 @@ export class TerminalE2EHarness {
|
||||
readonly tempRepo: TempRepo;
|
||||
readonly projectId: string;
|
||||
readonly workspaceId: string;
|
||||
private readonly otherWorkspaces: TerminalE2EHarness[] = [];
|
||||
|
||||
private constructor(input: {
|
||||
client: SeedDaemonClient;
|
||||
@@ -63,6 +64,7 @@ export class TerminalE2EHarness {
|
||||
}
|
||||
|
||||
async cleanup(): Promise<void> {
|
||||
for (const workspace of this.otherWorkspaces) await workspace.cleanup();
|
||||
await this.client.removeProject(this.projectId).catch(() => {});
|
||||
await this.client.close().catch(() => {});
|
||||
await this.tempRepo.cleanup().catch(() => {});
|
||||
@@ -131,6 +133,78 @@ export class TerminalE2EHarness {
|
||||
});
|
||||
}
|
||||
|
||||
async createOtherWorkspace(): Promise<TerminalE2EHarness> {
|
||||
const workspace = await TerminalE2EHarness.create({ tempPrefix: "terminal-workspace-focus-" });
|
||||
this.otherWorkspaces.push(workspace);
|
||||
return workspace;
|
||||
}
|
||||
|
||||
async switchToWorkspaceByShortcut(page: Page): Promise<void> {
|
||||
const rows = page.locator('[data-testid^="sidebar-workspace-row-"]').filter({ visible: true });
|
||||
const rowIds = await rows.evaluateAll((elements) =>
|
||||
elements.map((element) => element.getAttribute("data-testid")),
|
||||
);
|
||||
const index = rowIds.findIndex((id) => id?.endsWith(`:${this.workspaceId}`)) + 1;
|
||||
expect(index).toBeGreaterThan(0);
|
||||
expect(index).toBeLessThanOrEqual(9);
|
||||
// Browser Alt+Digit routes the same workspace action as desktop Cmd+Digit.
|
||||
await page.keyboard.press(`Alt+${index}`);
|
||||
await expect(page).toHaveURL(new RegExp(`/workspace/${this.workspaceId}`));
|
||||
}
|
||||
|
||||
async rememberWorkspaceForHistoryReturn(page: Page): Promise<void> {
|
||||
// Workspace shortcuts replace the current route; preserve an in-app history entry.
|
||||
await page.evaluate(() =>
|
||||
window.history.pushState(window.history.state, "", window.location.href),
|
||||
);
|
||||
}
|
||||
|
||||
async returnToWorkspaceThroughHistory(page: Page): Promise<void> {
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(new RegExp(`/workspace/${this.workspaceId}`));
|
||||
}
|
||||
|
||||
async moveFocusWithinCommandCenter(page: Page): Promise<void> {
|
||||
const panel = page.getByTestId("command-center-panel");
|
||||
await expect(panel.getByTestId("command-center-input")).toBeFocused();
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(panel.getByRole("button").first()).toBeFocused();
|
||||
}
|
||||
|
||||
async closeOverlayAndFocusButtonBeforeRetry(page: Page): Promise<void> {
|
||||
// Give the browser clock a fixed origin before pausing it in the future.
|
||||
await page.clock.install({ time: new Date("2026-01-01T00:00:00Z") });
|
||||
await page.clock.pauseAt(new Date("2026-01-01T00:01:00Z"));
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByTestId("command-center-panel")).toBeHidden();
|
||||
await page.getByTestId("sidebar-search").focus();
|
||||
await page.clock.runFor(100);
|
||||
await page.clock.resume();
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise<void>((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async expectTerminalFocused(page: Page): Promise<void> {
|
||||
await expect(
|
||||
this.terminalSurface(page).filter({ visible: true }).locator(".xterm-helper-textarea"),
|
||||
).toBeFocused();
|
||||
}
|
||||
|
||||
async typeCommandAndExpectOutput(
|
||||
page: Page,
|
||||
input: { terminalId: string; command: string; output: string },
|
||||
): Promise<void> {
|
||||
await page.keyboard.type(input.command);
|
||||
await page.keyboard.press("Enter");
|
||||
await expect
|
||||
.poll(async () => (await this.client.captureTerminal(input.terminalId)).lines)
|
||||
.toContain(input.output);
|
||||
}
|
||||
|
||||
terminalSurface(page: Page) {
|
||||
return page.locator('[data-testid="terminal-surface"]');
|
||||
}
|
||||
|
||||
@@ -2,6 +2,11 @@ import { TerminalFind, type TerminalPaneFindHandle } from "@/terminal/find";
|
||||
import type { TerminalFindResult } from "@/terminal/runtime/terminal-emulator-runtime";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import {
|
||||
hasActiveWebOverlay,
|
||||
isWithinActiveWebOverlay,
|
||||
subscribeWebOverlayChanges,
|
||||
} from "@/lib/overlay-root";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import {
|
||||
@@ -68,7 +73,7 @@ import {
|
||||
type TerminalResizeRequest,
|
||||
} from "./terminal-resize-debouncer";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import {
|
||||
applyTerminalRendererReadyChange,
|
||||
resolveTerminalStreamTarget,
|
||||
@@ -377,19 +382,67 @@ export function TerminalPane({
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isMobile || !isPaneFocused || !terminalId) {
|
||||
if (isMobile || !isPaneFocused || !isWorkspaceFocused || !terminalId) {
|
||||
lastAutoFocusKeyRef.current = null;
|
||||
return;
|
||||
}
|
||||
if (!isWorkspaceFocused) {
|
||||
if (rendererReadyStreamKey !== terminalStreamKey) {
|
||||
return;
|
||||
}
|
||||
const focusKey = `${scopeKey}:${terminalId}`;
|
||||
if (lastAutoFocusKeyRef.current !== focusKey) {
|
||||
lastAutoFocusKeyRef.current = focusKey;
|
||||
if (lastAutoFocusKeyRef.current === focusKey) return;
|
||||
function tryAutoFocus(): boolean {
|
||||
if (isWeb) {
|
||||
const active = document.activeElement;
|
||||
const hasFocusedInput =
|
||||
active instanceof HTMLElement &&
|
||||
active.matches('input, textarea, [contenteditable="true"]') &&
|
||||
!active.matches(".xterm-helper-textarea") &&
|
||||
active.getClientRects().length > 0;
|
||||
if (hasActiveWebOverlay() || hasFocusedInput) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
requestTerminalFocus();
|
||||
lastAutoFocusKeyRef.current = focusKey;
|
||||
return true;
|
||||
}
|
||||
}, [isMobile, isPaneFocused, isWorkspaceFocused, requestTerminalFocus, scopeKey, terminalId]);
|
||||
if (tryAutoFocus() || !isWeb) return;
|
||||
|
||||
let retryFrame: number | null = null;
|
||||
function stopRetrying(): void {
|
||||
document.removeEventListener("focusout", scheduleRetry);
|
||||
document.removeEventListener("focusin", handleFocusIn);
|
||||
unsubscribeOverlayChanges();
|
||||
if (retryFrame !== null) window.cancelAnimationFrame(retryFrame);
|
||||
}
|
||||
function handleFocusIn(event: FocusEvent): void {
|
||||
// Moving within the overlay does not replace the deferred workspace focus owner.
|
||||
if (!isWithinActiveWebOverlay(event.target)) stopRetrying();
|
||||
}
|
||||
function scheduleRetry(): void {
|
||||
if (retryFrame !== null) return;
|
||||
// Let blur, overlay teardown, and focus restoration settle before checking ownership.
|
||||
retryFrame = window.requestAnimationFrame(() => {
|
||||
retryFrame = null;
|
||||
if (tryAutoFocus()) stopRetrying();
|
||||
});
|
||||
}
|
||||
const unsubscribeOverlayChanges = subscribeWebOverlayChanges(scheduleRetry);
|
||||
document.addEventListener("focusout", scheduleRetry);
|
||||
// A new focus owner outside the overlay wins over deferred workspace autofocus.
|
||||
document.addEventListener("focusin", handleFocusIn);
|
||||
return stopRetrying;
|
||||
}, [
|
||||
isMobile,
|
||||
isPaneFocused,
|
||||
isWorkspaceFocused,
|
||||
rendererReadyStreamKey,
|
||||
requestTerminalFocus,
|
||||
scopeKey,
|
||||
terminalId,
|
||||
terminalStreamKey,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const canRequest = canRequestFocusClaim({
|
||||
|
||||
@@ -95,6 +95,7 @@ const webOverlayEntries: WebOverlayEntry[] = [];
|
||||
let webOverlayOrder = 0;
|
||||
let webOverlayListenersAttached = false;
|
||||
let webOverlayFocusCheckQueued = false;
|
||||
const webOverlayChangeListeners = new Set<() => void>();
|
||||
|
||||
interface RemoveWebOverlayOptions {
|
||||
restoreFocus?: boolean;
|
||||
@@ -112,6 +113,21 @@ export function hasActiveWebOverlay(): boolean {
|
||||
return getTopWebOverlay() !== undefined;
|
||||
}
|
||||
|
||||
export function isWithinActiveWebOverlay(target: EventTarget | null): boolean {
|
||||
return target instanceof Node && (getTopWebOverlay()?.getScope()?.contains(target) ?? false);
|
||||
}
|
||||
|
||||
export function subscribeWebOverlayChanges(listener: () => void): () => void {
|
||||
webOverlayChangeListeners.add(listener);
|
||||
return () => {
|
||||
webOverlayChangeListeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
function notifyWebOverlayChanges(): void {
|
||||
for (const listener of webOverlayChangeListeners) listener();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -239,6 +255,7 @@ function detachWebOverlayListeners(): void {
|
||||
function addWebOverlay(entry: WebOverlayEntry): (options?: RemoveWebOverlayOptions) => void {
|
||||
webOverlayEntries.push(entry);
|
||||
attachWebOverlayListeners();
|
||||
notifyWebOverlayChanges();
|
||||
|
||||
const focusFrame = window.requestAnimationFrame(() => {
|
||||
const scope = entry.getScope();
|
||||
@@ -265,6 +282,7 @@ function addWebOverlay(entry: WebOverlayEntry): (options?: RemoveWebOverlayOptio
|
||||
) {
|
||||
entry.restoreFocus.focus();
|
||||
}
|
||||
notifyWebOverlayChanges();
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user