1// @vitest-environment jsdom
2
3import { act, type ComponentProps } from "react";
4import { createRoot, type Root } from "react-dom/client";
5import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
6import {
7 compileResolvedKeybindingsConfig,
8 DEFAULT_RESOLVED_KEYBINDINGS,
9} from "@t3tools/shared/keybindings";
10
11import { RightPanelTabs } from "./RightPanelTabs";
12import { supportsWorkspaceChanges } from "./review/jjReview.logic";
13
14vi.mock("~/hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "light" }) }));
15vi.mock("~/browser/browserDefaults", () => ({ useBrowserDefaults: () => ({ profiles: [] }) }));
16
17let root: Root;
18let container: HTMLDivElement;
19const addFiles = vi.fn();
20const noop = () => undefined;
21
22beforeEach(() => {
23 vi.useFakeTimers();
24 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
25 vi.stubGlobal(
26 "ResizeObserver",
27 class {
28 observe() {}
29 disconnect() {}
30 unobserve() {}
31 },
32 );
33 Object.defineProperty(Element.prototype, "getAnimations", {
34 configurable: true,
35 value: () => [],
36 });
37 vi.spyOn(navigator, "platform", "get").mockReturnValue("MacIntel");
38 addFiles.mockClear();
39 container = document.createElement("div");
40 document.body.append(container);
41 root = createRoot(container);
42});
43
44afterEach(async () => {
45 await act(() => root.unmount());
46 container.remove();
47 vi.restoreAllMocks();
48 vi.unstubAllGlobals();
49 vi.useRealTimers();
50});
51
52async function renderPanel(overrides: Partial<ComponentProps<typeof RightPanelTabs>> = {}) {
53 await act(() =>
54 root.render(
55 <RightPanelTabs
56 mode="inline"
57 open
58 keybindings={DEFAULT_RESOLVED_KEYBINDINGS}
59 getShortcutContext={() => ({
60 terminalFocus: false,
61 terminalOpen: false,
62 previewFocus: false,
63 previewOpen: false,
64 isWeb: true,
65 isDesktop: false,
66 })}
67 surfaces={[]}
68 environmentId={null}
69 activeSurfaceId={null}
70 pendingSurfaceIds={new Set()}
71 previewSessions={{}}
72 desktopByTabId={{}}
73 terminalLabelsById={new Map()}
74 onActivate={noop}
75 onCloseSurface={noop}
76 onCloseOtherSurfaces={noop}
77 onCloseSurfacesToRight={noop}
78 onCloseAllSurfaces={noop}
79 onCopyFilePath={noop}
80 onAddBrowser={noop}
81 onAddBrowserInProfile={noop}
82 onAddTerminal={noop}
83 onAddDiff={noop}
84 onAddFiles={addFiles}
85 onAddPullRequest={noop}
86 onAddPullRequests={noop}
87 onAddDevice={noop}
88 browserAvailable={false}
89 terminalAvailable={false}
90 diffAvailable={false}
91 filesAvailable
92 pullRequestAvailable={false}
93 pullRequestsAvailable={false}
94 deviceAvailable={false}
95 {...overrides}
96 >
97 content
98 </RightPanelTabs>,
99 ),
100 );
101 await act(() => vi.advanceTimersByTimeAsync(0));
102 await act(() => vi.advanceTimersToNextFrame());
103}
104
105async function press(key: string, options: KeyboardEventInit = {}) {
106 const event = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true, ...options });
107 await act(() => (document.activeElement ?? document.body).dispatchEvent(event));
108 await act(() => vi.advanceTimersByTimeAsync(0));
109 await act(() => vi.advanceTimersToNextFrame());
110 return event;
111}
112
113describe("right panel new-tab shortcut", () => {
114 it("offers Diff in a non-colocated Jujutsu workspace and disables it in a plain folder", async () => {
115 for (const isJjWorkspace of [true, false]) {
116 await renderPanel({
117 diffAvailable: supportsWorkspaceChanges({ isRepo: false, isJjWorkspace }),
118 });
119 await press("t", { metaKey: true });
120 const item = [...document.querySelectorAll('[role="menuitem"]')].find((entry) =>
121 entry.textContent?.includes("Diff"),
122 );
123 expect(item).toBeDefined();
124 expect(item?.getAttribute("aria-disabled") === "true").toBe(!isJjWorkspace);
125 await press("Escape");
126 }
127 });
128
129 it.each(["MacIntel", "Win32", "Linux x86_64"])(
130 "opens the menu on %s and chooses a tab",
131 async (platform) => {
132 vi.spyOn(navigator, "platform", "get").mockReturnValue(platform);
133 await renderPanel();
134 const event = await press(
135 "t",
136 platform === "MacIntel" ? { metaKey: true } : { ctrlKey: true },
137 );
138 expect(event.defaultPrevented).toBe(true);
139 expect(document.querySelector('[role="menu"]')?.textContent).toContain(
140 "Linked pull requests",
141 );
142 expect(document.activeElement?.closest('[role="menu"]')).not.toBeNull();
143 expect(
144 (
145 await press("t", {
146 metaKey: platform === "MacIntel",
147 ctrlKey: platform !== "MacIntel",
148 repeat: true,
149 })
150 ).defaultPrevented,
151 ).toBe(true);
152 await press("f");
153 expect(addFiles).toHaveBeenCalledOnce();
154 expect(document.querySelector('[role="menu"]:not([data-closed])')).toBeNull();
155 },
156 );
157
158 it("leaves the shortcut alone while the mounted panel is closed", async () => {
159 await renderPanel({ open: false });
160 expect((await press("t", { metaKey: true })).defaultPrevented).toBe(false);
161 expect(document.querySelector('[role="menu"]:not([data-closed])')).toBeNull();
162 });
163
164 it.each(["Win32", "Linux x86_64"])(
165 "leaves Ctrl+T for the focused terminal on %s",
166 async (platform) => {
167 vi.spyOn(navigator, "platform", "get").mockReturnValue(platform);
168 await renderPanel({
169 getShortcutContext: () => ({
170 terminalFocus: true,
171 terminalOpen: true,
172 previewFocus: false,
173 previewOpen: false,
174 isWeb: true,
175 isDesktop: false,
176 }),
177 });
178 const terminal = document.createElement("textarea");
179 const onKeyDown = vi.fn();
180 terminal.addEventListener("keydown", onKeyDown);
181 container.append(terminal);
182 await act(() => terminal.focus());
183 const event = await press("t", { ctrlKey: true });
184 expect(event.defaultPrevented).toBe(false);
185 expect(onKeyDown).toHaveBeenCalledWith(event);
186 expect(document.querySelector('[role="menu"]:not([data-closed])')).toBeNull();
187 },
188 );
189
190 it("uses a custom binding from a text field and closes on Escape", async () => {
191 const input = document.createElement("input");
192 await renderPanel({
193 keybindings: compileResolvedKeybindingsConfig([{ key: "mod+y", command: "rightPanel.new" }]),
194 });
195 document.body.append(input);
196 input.focus();
197 expect((await press("t", { metaKey: true })).defaultPrevented).toBe(false);
198 await press("y", { metaKey: true });
199 expect(document.querySelector('[role="menu"]')).not.toBeNull();
200 await press("Escape");
201 expect(document.querySelector('[role="menu"]:not([data-closed])')).toBeNull();
202 expect(document.activeElement).toBe(
203 container.querySelector('[aria-label="Add panel surface"]'),
204 );
205 input.remove();
206 });
207
208 it("does not open over another popup or during text composition", async () => {
209 await renderPanel();
210 expect((await press("t", { metaKey: true, isComposing: true })).defaultPrevented).toBe(false);
211 const dialog = document.createElement("div");
212 dialog.dataset.slot = "dialog-popup";
213 container.append(dialog);
214 expect((await press("t", { metaKey: true })).defaultPrevented).toBe(false);
215 expect(document.querySelector('[role="menu"]')).toBeNull();
216 });
217
218 it("supports arrow keys with an existing tab in sheet mode and clears the menu on close", async () => {
219 const panel = {
220 mode: "sheet" as const,
221 surfaces: [{ id: "files" as const, kind: "files" as const }],
222 };
223 await renderPanel(panel);
224 await press("t", { metaKey: true });
225 await press("ArrowDown");
226 await press("ArrowDown");
227 await press("ArrowDown");
228 expect(document.activeElement?.textContent).toContain("Files");
229 await press("Enter");
230 expect(addFiles).toHaveBeenCalledOnce();
231 await press("t", { metaKey: true });
232 await renderPanel({ ...panel, open: false });
233 expect(document.querySelector('[role="menu"]:not([data-closed])')).toBeNull();
234 await renderPanel(panel);
235 expect(document.querySelector('[role="menu"]:not([data-closed])')).toBeNull();
236 });
237});