diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 89b74c063da4848b477643d541fca3a9139d019e..e705a42d5976f4e231789d515365799ed4df1873 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -11603,6 +11603,8 @@ export default function ChatView(props: ChatViewProps) { ({ useTheme: () => ({ resolvedTheme: "light" }) })); +vi.mock("~/browser/browserDefaults", () => ({ useBrowserDefaults: () => ({ profiles: [] }) })); + +let root: Root; +let container: HTMLDivElement; +const addFiles = vi.fn(); +const noop = () => undefined; + +beforeEach(() => { + vi.useFakeTimers(); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + disconnect() {} + unobserve() {} + }, + ); + Object.defineProperty(Element.prototype, "getAnimations", { + configurable: true, + value: () => [], + }); + vi.spyOn(navigator, "platform", "get").mockReturnValue("MacIntel"); + addFiles.mockClear(); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(async () => { + await act(() => root.unmount()); + container.remove(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +async function renderPanel(overrides: Partial> = {}) { + await act(() => + root.render( + ({ + terminalFocus: false, + terminalOpen: false, + previewFocus: false, + previewOpen: false, + isWeb: true, + isDesktop: false, + })} + surfaces={[]} + environmentId={null} + activeSurfaceId={null} + pendingSurfaceIds={new Set()} + previewSessions={{}} + desktopByTabId={{}} + terminalLabelsById={new Map()} + onActivate={noop} + onCloseSurface={noop} + onCloseOtherSurfaces={noop} + onCloseSurfacesToRight={noop} + onCloseAllSurfaces={noop} + onCopyFilePath={noop} + onAddBrowser={noop} + onAddBrowserInProfile={noop} + onAddTerminal={noop} + onAddDiff={noop} + onAddFiles={addFiles} + onAddPullRequest={noop} + onAddPullRequests={noop} + onAddDevice={noop} + browserAvailable={false} + terminalAvailable={false} + diffAvailable={false} + filesAvailable + pullRequestAvailable={false} + pullRequestsAvailable={false} + deviceAvailable={false} + {...overrides} + > + content + , + ), + ); + await act(() => vi.advanceTimersByTimeAsync(0)); + await act(() => vi.advanceTimersToNextFrame()); +} + +async function press(key: string, options: KeyboardEventInit = {}) { + const event = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true, ...options }); + await act(() => (document.activeElement ?? document.body).dispatchEvent(event)); + await act(() => vi.advanceTimersByTimeAsync(0)); + await act(() => vi.advanceTimersToNextFrame()); + return event; +} + +describe("right panel new-tab shortcut", () => { + it.each(["MacIntel", "Win32", "Linux x86_64"])( + "opens the menu on %s and chooses a tab", + async (platform) => { + vi.spyOn(navigator, "platform", "get").mockReturnValue(platform); + await renderPanel(); + const event = await press( + "t", + platform === "MacIntel" ? { metaKey: true } : { ctrlKey: true }, + ); + expect(event.defaultPrevented).toBe(true); + expect(document.querySelector('[role="menu"]')?.textContent).toContain( + "Linked pull requests", + ); + expect(document.activeElement?.closest('[role="menu"]')).not.toBeNull(); + expect( + ( + await press("t", { + metaKey: platform === "MacIntel", + ctrlKey: platform !== "MacIntel", + repeat: true, + }) + ).defaultPrevented, + ).toBe(true); + await press("f"); + expect(addFiles).toHaveBeenCalledOnce(); + expect(document.querySelector('[role="menu"]:not([data-closed])')).toBeNull(); + }, + ); + + it("leaves the shortcut alone while the mounted panel is closed", async () => { + await renderPanel({ open: false }); + expect((await press("t", { metaKey: true })).defaultPrevented).toBe(false); + expect(document.querySelector('[role="menu"]:not([data-closed])')).toBeNull(); + }); + + it.each(["Win32", "Linux x86_64"])( + "leaves Ctrl+T for the focused terminal on %s", + async (platform) => { + vi.spyOn(navigator, "platform", "get").mockReturnValue(platform); + await renderPanel({ + getShortcutContext: () => ({ + terminalFocus: true, + terminalOpen: true, + previewFocus: false, + previewOpen: false, + isWeb: true, + isDesktop: false, + }), + }); + const terminal = document.createElement("textarea"); + const onKeyDown = vi.fn(); + terminal.addEventListener("keydown", onKeyDown); + container.append(terminal); + await act(() => terminal.focus()); + const event = await press("t", { ctrlKey: true }); + expect(event.defaultPrevented).toBe(false); + expect(onKeyDown).toHaveBeenCalledWith(event); + expect(document.querySelector('[role="menu"]:not([data-closed])')).toBeNull(); + }, + ); + + it("uses a custom binding from a text field and closes on Escape", async () => { + const input = document.createElement("input"); + await renderPanel({ + keybindings: compileResolvedKeybindingsConfig([{ key: "mod+y", command: "rightPanel.new" }]), + }); + document.body.append(input); + input.focus(); + expect((await press("t", { metaKey: true })).defaultPrevented).toBe(false); + await press("y", { metaKey: true }); + expect(document.querySelector('[role="menu"]')).not.toBeNull(); + await press("Escape"); + expect(document.querySelector('[role="menu"]:not([data-closed])')).toBeNull(); + expect(document.activeElement).toBe( + container.querySelector('[aria-label="Add panel surface"]'), + ); + input.remove(); + }); + + it("does not open over another popup or during text composition", async () => { + await renderPanel(); + expect((await press("t", { metaKey: true, isComposing: true })).defaultPrevented).toBe(false); + const dialog = document.createElement("div"); + dialog.dataset.slot = "dialog-popup"; + container.append(dialog); + expect((await press("t", { metaKey: true })).defaultPrevented).toBe(false); + expect(document.querySelector('[role="menu"]')).toBeNull(); + }); + + it("supports arrow keys with an existing tab in sheet mode and clears the menu on close", async () => { + const panel = { + mode: "sheet" as const, + surfaces: [{ id: "files" as const, kind: "files" as const }], + }; + await renderPanel(panel); + await press("t", { metaKey: true }); + await press("ArrowDown"); + await press("ArrowDown"); + await press("ArrowDown"); + expect(document.activeElement?.textContent).toContain("Files"); + await press("Enter"); + expect(addFiles).toHaveBeenCalledOnce(); + await press("t", { metaKey: true }); + await renderPanel({ ...panel, open: false }); + expect(document.querySelector('[role="menu"]:not([data-closed])')).toBeNull(); + await renderPanel(panel); + expect(document.querySelector('[role="menu"]:not([data-closed])')).toBeNull(); + }); +}); diff --git a/apps/web/src/components/RightPanelTabs.test.tsx b/apps/web/src/components/RightPanelTabs.test.tsx index 25b0997813e1bce0f707254e56b509fee0ee85e5..823028eddff1e2d87f8b66fd416feeae29049bf3 100644 --- a/apps/web/src/components/RightPanelTabs.test.tsx +++ b/apps/web/src/components/RightPanelTabs.test.tsx @@ -1,6 +1,7 @@ import { EnvironmentId, type ThreadPullRequestLink } from "@t3tools/contracts"; import type { DesktopPreviewFavicon, PreviewSessionSnapshot } from "@t3tools/contracts"; import { renderToStaticMarkup } from "react-dom/server"; +import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; import { describe, expect, it } from "vite-plus/test"; import { @@ -98,6 +99,15 @@ function renderTabs( return renderToStaticMarkup( ({ + terminalFocus: false, + terminalOpen: false, + previewFocus: false, + previewOpen: false, + isWeb: true, + isDesktop: false, + })} surfaces={second ? [previewSurface, secondSurface] : [previewSurface]} environmentId={null} activeSurfaceId={previewSurface.id} diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 914028f7c8cd261a39e4251d312f95b9286b0299..4f6f3607f470c9c464431ed74eb3415ce24127ac 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -11,6 +11,7 @@ import type { PreviewSessionSnapshot, ProjectId, PullRequestState, + ResolvedKeybindingsConfig, } from "@t3tools/contracts"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; import { @@ -32,6 +33,7 @@ import { type ReactNode, useCallback, useEffect, + useEffectEvent, useMemo, useRef, useState, @@ -41,6 +43,7 @@ import { isElectron } from "~/env"; import type { DesktopPreviewOverlay } from "~/previewStateStore"; import type { RightPanelSurface } from "~/rightPanelStore"; import { cn } from "~/lib/utils"; +import { resolveShortcutCommand, type ShortcutMatchContext } from "~/keybindings"; import { readLocalApi } from "~/localApi"; import { Button } from "~/components/ui/button"; import { MorphIcon } from "~/components/MorphIcon"; @@ -83,6 +86,8 @@ interface RightPanelTabsProps { mode: PreviewPanelMode; maximized?: boolean; open?: boolean; + keybindings: ResolvedKeybindingsConfig; + getShortcutContext: () => ShortcutMatchContext; /** Forwarded to PreviewPanelShell so this surface persists its own width. */ widthStorageKey?: string; /** Forwarded to PreviewPanelShell as the initial width before a user resize. */ @@ -793,6 +798,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { const browserProfiles = useBrowserDefaults().profiles; const { resolvedTheme } = useTheme(); const tabListRef = useRef(null); + const addSurfaceTriggerRef = useRef(null); const [renamingDevice, setRenamingDevice] = useState(null); const [addSurfaceMenuOpen, setAddSurfaceMenuOpen] = useState(false); const [tabScrollState, setTabScrollState] = useState({ @@ -801,6 +807,30 @@ export function RightPanelTabs(props: RightPanelTabsProps) { canScrollRight: false, }); + if (props.open === false && addSurfaceMenuOpen) setAddSurfaceMenuOpen(false); + + const onNewSurfaceKeyDown = useEffectEvent((event: KeyboardEvent) => { + if (event.defaultPrevented || event.isComposing) return; + if ( + resolveShortcutCommand(event, props.keybindings, { + context: { ...props.getShortcutContext(), rightPanelOpen: true }, + }) !== "rightPanel.new" + ) + return; + if (!addSurfaceMenuOpen && document.querySelector(LAUNCHER_SHORTCUT_BLOCKING_LAYERS)) return; + event.preventDefault(); + event.stopPropagation(); + if (!event.repeat) { + addSurfaceTriggerRef.current?.focus(); + setAddSurfaceMenuOpen(true); + } + }); + useEffect(() => { + if (props.open === false) return; + document.addEventListener("keydown", onNewSurfaceKeyDown, true); + return () => document.removeEventListener("keydown", onNewSurfaceKeyDown, true); + }, [props.open]); + const updateTabScrollState = useCallback(() => { const viewport = tabScrollViewport(tabListRef.current); if (!viewport) return; @@ -1214,9 +1244,10 @@ export function RightPanelTabs(props: RightPanelTabsProps) { ); })} - {props.surfaces.length > 0 ? ( + {props.open !== false ? ( = [ { key: "mod+]", command: "navigation.forward", when: "!terminalFocus" }, { key: "mod+j", command: "terminal.toggle" }, { key: "mod+alt+b", command: "rightPanel.toggle" }, + { key: "mod+t", command: "rightPanel.new", when: "rightPanelOpen && !terminalFocus" }, { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, { key: "mod+shift+d", command: "terminal.splitVertical", when: "terminalFocus" }, { key: "mod+n", command: "terminal.new", when: "terminalFocus" },