Skip to content
Source
Sign inSign up

ReopenClosedViewShortcut.browser.test.tsx

279 lines9.7 KB

1import type { ResolvedKeybindingsConfig, ScopedThreadRef } from "@t3tools/contracts";
2import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings";
3import * as Cause from "effect/Cause";
4import { AsyncResult } from "effect/reactivity";
5import { describe, expect, it, vi } from "vite-plus/test";
6​
7import { act, render } from "../../test/render";
8​
9const state = vi.hoisted(() => ({
10 keybindings: [] as ResolvedKeybindingsConfig,
11 params: {} as Record<string, string>,
12 workspaceAvailable: true,
13 navigate: vi.fn(),
14 openPreview: vi.fn(),
15 setShortcuts: vi.fn(async () => undefined),
16 toast: vi.fn(),
17}));
18​
19vi.mock(import("./preview/openPreviewSession"), async (original) => ({
20 ...(await original()),
21 openPreviewSession: state.openPreview,
22}));
23vi.mock(import("@effect/atom-react"), async (original) => ({
24 ...(await original()),
25 useAtomValue: (() => state.keybindings) as never,
26}));
27vi.mock(import("@tanstack/react-router"), async (original) => ({
28 ...(await original()),
29 useNavigate: (() => state.navigate) as never,
30 useParams: (() => state.params) as never,
31}));
32vi.mock(import("../state/use-atom-command"), async (original) => ({
33 ...(await original()),
34 useAtomCommand: ((command: unknown) => command) as never,
35}));
36vi.mock(import("../state/preview"), async (original) => {
37 const actual = await original();
38 return {
39 ...actual,
40 previewEnvironment: { ...actual.previewEnvironment, open: state.openPreview as never },
41 };
42});
43vi.mock(import("../connection/catalog"), async (original) => {
44 const actual = await original();
45 const { Atom } = await import("effect/reactivity");
46 return {
47 ...actual,
48 environmentCatalog: {
49 ...actual.environmentCatalog,
50 catalogValueAtom: Atom.make({
51 isReady: true,
52 entries: new Map([["remote", { target: { _tag: "DirectConnectionTarget" } }]]),
53 }) as never,
54 },
55 };
56});
57vi.mock(import("../state/shell"), async (original) => {
58 const actual = await original();
59 const { Atom } = await import("effect/reactivity");
60 const live = Atom.make({ status: "live" });
61 return {
62 ...actual,
63 environmentShell: { ...actual.environmentShell, stateValueAtom: (() => live) as never },
64 };
65});
66vi.mock(import("../state/entities"), async (original) => ({
67 ...(await original()),
68 readThreadShell: (() => ({ projectId: "project-1", worktreePath: null })) as never,
69 readProject: (() =>
70 state.workspaceAvailable ? { workspaceRoot: "/work/project" } : null) as never,
71}));
72vi.mock(import("./ui/toast"), async (original) => {
73 const actual = await original();
74 return { ...actual, toastManager: { ...actual.toastManager, add: state.toast } };
75});
76​
77import { useClosedViewStore } from "../closedViewStore";
78import { selectThreadRightPanelState, useRightPanelStore } from "../rightPanelStore";
79import { useTerminalUiStateStore } from "../terminalUiStateStore";
80import { ReopenClosedViewShortcut } from "./ReopenClosedViewShortcut";
81​
82const ref = { environmentId: "remote", threadId: "thread-1" } as ScopedThreadRef;
83const snapshot = {
84 threadId: ref.threadId,
85 tabId: "closed-tab",
86 navStatus: { _tag: "Success", url: "https://example.com", title: "Example" },
87 canGoBack: false,
88 canGoForward: false,
89 updatedAt: "2026-09-22T12:00:00.000Z",
90} as const;
91let menuAction: ((action: string) => void) | undefined;
92​
93function setup() {
94 vi.clearAllMocks();
95 Object.assign(state, {
96 keybindings: DEFAULT_RESOLVED_KEYBINDINGS,
97 params: {},
98 workspaceAvailable: true,
99 });
100 state.navigate.mockResolvedValue(undefined);
101 state.openPreview.mockReset();
102 useClosedViewStore.setState({ entries: [] });
103 useRightPanelStore.setState({ byThreadKey: {}, userActionRevisionByThreadKey: {} });
104 useTerminalUiStateStore.setState({
105 terminalUiStateByThreadKey: {},
106 suppressedTerminalIdsByThreadKey: {},
107 });
109​
110async function renderShortcut() {
111 await using stack = new AsyncDisposableStack();
112 stack.use(vi.spyOn(navigator, "platform", "get").mockReturnValue("Linux x86_64"));
113 window.desktopBridge = {
114 preview: { setForwardedShortcuts: state.setShortcuts },
115 onMenuAction: (listener: (action: string) => void) => {
116 menuAction = listener;
117 return () => {
118 menuAction = undefined;
119 };
120 },
121 } as unknown as NonNullable<typeof window.desktopBridge>;
122 stack.defer(() => {
123 delete window.desktopBridge;
124 });
125 stack.use(await render(<ReopenClosedViewShortcut />));
126 const environment = stack.move();
127 return {
128 /** Attaches an element to the document until the test ends. */
129 append(element: HTMLElement) {
130 document.body.append(element);
131 environment.defer(() => element.remove());
132 return element;
133 },
134 [Symbol.asyncDispose]: () => environment.disposeAsync(),
135 };
137​
138function press(target: Element = document.body) {
139 const event = new KeyboardEvent("keydown", {
140 key: "T",
141 ctrlKey: true,
142 shiftKey: true,
143 bubbles: true,
144 cancelable: true,
145 });
146 target.dispatchEvent(event);
147 return event;
149​
150describe("root reopen shortcut", () => {
151 it("serializes deliberate presses while a browser restore waits for RPC", async () => {
152 setup();
153 useClosedViewStore
154 .getState()
155 .remember({ kind: "panel-tab", threadRef: ref, surface: { kind: "files", id: "files" } });
156 useClosedViewStore.getState().remember({ kind: "browser", threadRef: ref, snapshot });
157 let finish!: (value: ReturnType<typeof AsyncResult.success>) => void;
158 state.openPreview.mockReturnValue(
159 new Promise((resolve) => {
160 finish = resolve;
161 }),
162 );
163 await using _view = await renderShortcut();
164 await act(() => {
165 press();
166 press();
167 });
168 expect(state.openPreview).toHaveBeenCalledTimes(1);
169 expect(state.navigate).not.toHaveBeenCalled();
170 await act(() => {
171 finish(AsyncResult.success({ tabId: "new-tab" }));
172 });
173 expect(state.navigate).toHaveBeenCalledTimes(2);
174 expect(useClosedViewStore.getState().entries).toEqual([]);
175 });
176​
177 it("does not create a second browser when navigation fails after restoring it", async () => {
178 setup();
179 const store = useClosedViewStore.getState();
180 const older = store.remember({
181 kind: "panel-tab",
182 threadRef: ref,
183 surface: { kind: "diff", id: "diff" },
184 });
185 store.remember({ kind: "browser", threadRef: ref, snapshot });
186 state.openPreview.mockResolvedValue(AsyncResult.success({ tabId: "new-tab" }));
187 state.navigate.mockRejectedValueOnce(new Error("navigation failed"));
188 await using _view = await renderShortcut();
189 await act(() => {
190 press();
191 });
192 expect(
193 selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, ref).activeSurfaceId,
194 ).toBe("browser:new-tab");
195 expect(state.toast).toHaveBeenCalledWith(
196 expect.objectContaining({ title: "Could not reopen view" }),
197 );
198 expect(useClosedViewStore.getState().entries.map((entry) => entry.id)).toEqual([older]);
199 await act(() => {
200 menuAction?.("view.reopenClosed");
201 });
202 expect(state.openPreview).toHaveBeenCalledOnce();
203 expect(
204 selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, ref).activeSurfaceId,
205 ).toBe("diff");
206 expect(useClosedViewStore.getState().entries).toEqual([]);
207 });
208​
209 it.each(["files", "file", "browser"] as const)(
210 "keeps a failed %s restore retryable without blocking older history",
211 async (kind) => {
212 setup();
213 const store = useClosedViewStore.getState();
214 const older = store.remember({
215 kind: "panel-tab",
216 threadRef: ref,
217 surface: { kind: "diff", id: "diff" },
218 });
219 const failed = store.remember(
220 kind === "browser"
221 ? { kind: "browser", threadRef: ref, snapshot }
222 : {
223 kind: "panel-tab",
224 threadRef: ref,
225 surface:
226 kind === "files"
227 ? { kind: "files", id: "files" }
228 : {
229 kind: "file",
230 id: "file:src/app.ts",
231 relativePath: "src/app.ts",
232 revealLine: null,
233 revealRequestId: 0,
234 },
235 },
236 );
237 state.workspaceAvailable = false;
238 state.openPreview.mockResolvedValue(AsyncResult.failure(Cause.fail(new Error("offline"))));
239 await using _view = await renderShortcut();
240 await act(() => {
241 press();
242 });
243 expect(useClosedViewStore.getState().entries.map((entry) => entry.id)).toEqual([
244 older,
245 failed,
246 ]);
247 expect(state.navigate).not.toHaveBeenCalled();
248 await act(() => {
249 menuAction?.("view.reopenClosed");
250 });
251 expect(
252 selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, ref).activeSurfaceId,
253 ).toBe("diff");
254 expect(useClosedViewStore.getState().entries.map((entry) => entry.id)).toEqual([failed]);
255 state.workspaceAvailable = true;
256 state.openPreview.mockResolvedValue(AsyncResult.success({ ...snapshot, tabId: "new-tab" }));
257 await act(() => {
258 press();
259 });
260 expect(
261 selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, ref).activeSurfaceId,
262 ).toBe(
263 kind === "browser" ? "browser:new-tab" : kind === "file" ? "file:src/app.ts" : "files",
264 );
265 expect(useClosedViewStore.getState().entries).toEqual([]);
266 },
267 );
268 it("forwards address-bar focus and zoom from a focused desktop page", async () => {
269 setup();
270 await using _shortcut = await renderShortcut();
271 expect(state.setShortcuts).toHaveBeenLastCalledWith(
272 expect.arrayContaining(
273 ["preview.focusUrl", "preview.zoomIn", "preview.zoomOut", "preview.resetZoom"].map(
274 (command) => expect.objectContaining({ command }),
275 ),
276 ),
277 );
278 });
279});