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 { act } from "react";
6import { create, type ReactTestRenderer } from "react-test-renderer";
7import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
8
9const state = vi.hoisted(() => ({
10 keybindings: [] as ResolvedKeybindingsConfig,
11 params: {} as Record<string, string>,
12 paletteOpen: false,
13 workspaceAvailable: true,
14 navigate: vi.fn(),
15 openPreview: vi.fn(),
16 setShortcuts: vi.fn(async () => undefined),
17 toast: vi.fn(),
18}));
19
20vi.mock("./preview/openPreviewSession", () => ({ openPreviewSession: state.openPreview }));
21vi.mock("@effect/atom-react", () => ({ useAtomValue: () => state.keybindings }));
22vi.mock("@tanstack/react-router", () => ({
23 useNavigate: () => state.navigate,
24 useParams: () => state.params,
25}));
26vi.mock("../state/server", () => ({ primaryServerKeybindingsAtom: {} }));
27vi.mock("../state/preview", () => ({ previewEnvironment: { open: state.openPreview } }));
28vi.mock("../state/use-atom-command", () => ({ useAtomCommand: (command: unknown) => command }));
29vi.mock("../state/shell", () => ({ environmentShell: { stateValueAtom: () => ({}) } }));
30vi.mock("../connection/catalog", () => ({ environmentCatalog: { catalogValueAtom: "catalog" } }));
31vi.mock("../rpc/atomRegistry", () => ({
32 appAtomRegistry: {
33 get: (atom: unknown) =>
34 atom === "catalog"
35 ? { isReady: true, entries: new Map([["remote", {}]]) }
36 : { status: "live" },
37 },
38}));
39vi.mock("../state/entities", () => ({
40 readThreadShell: () => ({ projectId: "project-1", worktreePath: null }),
41 readProject: () => (state.workspaceAvailable ? { workspaceRoot: "/work/project" } : null),
42}));
43vi.mock("../composerDraftStore", () => {
44 const store = {
45 getDraftSession: () => null,
46 getDraftThreadByRef: () => null,
47 getDraftIdByRef: () => null,
48 };
49 return {
50 useComposerDraftStore: Object.assign(
51 (select: (value: typeof store) => unknown) => select(store),
52 { getState: () => store },
53 ),
54 };
55});
56vi.mock("../commandPaletteBus", () => ({ isCommandPaletteOpen: () => state.paletteOpen }));
57vi.mock("../lib/editableFocus", () => ({ isEditableFocused: () => false }));
58vi.mock("../lib/previewFocus", () => ({ isPreviewFocused: () => false }));
59vi.mock("../lib/terminalFocus", () => ({ isTerminalFocused: () => false }));
60vi.mock("../modelPickerVisibility", () => ({ isModelPickerOpen: () => false }));
61vi.mock("./ui/toast", () => ({ toastManager: { add: state.toast } }));
62
63import { useClosedViewStore } from "../closedViewStore";
64import { selectThreadRightPanelState, useRightPanelStore } from "../rightPanelStore";
65import { useTerminalUiStateStore } from "../terminalUiStateStore";
66import { ReopenClosedViewShortcut } from "./ReopenClosedViewShortcut";
67
68const ref = { environmentId: "remote", threadId: "thread-1" } as ScopedThreadRef;
69const snapshot = {
70 threadId: ref.threadId,
71 tabId: "closed-tab",
72 navStatus: { _tag: "Success", url: "https://example.com", title: "Example" },
73 canGoBack: false,
74 canGoForward: false,
75 updatedAt: "2026-09-22T12:00:00.000Z",
76} as const;
77class TestElement extends EventTarget {
78 closest(selector: string) {
79 return selector === "[data-keybinding-capture]" ? this : null;
80 }
81}
82let renderer: ReactTestRenderer | undefined;
83let menuAction: ((action: string) => void) | undefined;
84
85async function render() {
86 await act(() => {
87 renderer = create(<ReopenClosedViewShortcut />);
88 });
89}
90
91function press(overrides: Record<string, unknown> = {}) {
92 const event = Object.assign(new Event("keydown", { cancelable: true }), {
93 key: "T",
94 ctrlKey: true,
95 metaKey: false,
96 shiftKey: true,
97 altKey: false,
98 repeat: false,
99 ...overrides,
100 });
101 window.dispatchEvent(event);
102 return event;
103}
104
105beforeEach(() => {
106 vi.clearAllMocks();
107 Object.assign(state, {
108 keybindings: DEFAULT_RESOLVED_KEYBINDINGS,
109 params: {},
110 paletteOpen: false,
111 workspaceAvailable: true,
112 });
113 state.navigate.mockResolvedValue(undefined);
114 state.openPreview.mockReset();
115 useClosedViewStore.setState({ entries: [] });
116 useRightPanelStore.setState({ byThreadKey: {}, userActionRevisionByThreadKey: {} });
117 useTerminalUiStateStore.setState({
118 terminalUiStateByThreadKey: {},
119 suppressedTerminalIdsByThreadKey: {},
120 });
121 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
122 vi.stubGlobal("HTMLElement", TestElement);
123 vi.stubGlobal("navigator", { platform: "Linux" });
124 vi.stubGlobal(
125 "window",
126 Object.assign(new EventTarget(), {
127 desktopBridge: {
128 preview: { setForwardedShortcuts: state.setShortcuts },
129 onMenuAction: (listener: typeof menuAction) => {
130 menuAction = listener;
131 return () => {
132 menuAction = undefined;
133 };
134 },
135 },
136 }),
137 );
138});
139
140afterEach(async () => {
141 await act(() => renderer?.unmount());
142 renderer = undefined;
143 vi.unstubAllGlobals();
144});
145
146describe("root reopen shortcut", () => {
147 it("restores a tab from settings to its owning remote thread", async () => {
148 useClosedViewStore
149 .getState()
150 .remember({ kind: "panel-tab", threadRef: ref, surface: { kind: "diff", id: "diff" } });
151 await render();
152 await act(() => {
153 expect(press().defaultPrevented).toBe(true);
154 });
155 expect(
156 selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, ref).activeSurfaceId,
157 ).toBe("diff");
158 expect(state.navigate).toHaveBeenCalledWith({ to: "/$environmentId/$threadId", params: ref });
159 expect(useClosedViewStore.getState().entries).toEqual([]);
160 });
161
162 it("leaves native shortcuts alone without history or while the palette is open", async () => {
163 await render();
164 expect(press().defaultPrevented).toBe(false);
165 await act(() => {
166 useClosedViewStore
167 .getState()
168 .remember({ kind: "panel-tab", threadRef: ref, surface: { kind: "diff", id: "diff" } });
169 });
170 state.paletteOpen = true;
171 expect(press().defaultPrevented).toBe(false);
172 expect(state.navigate).not.toHaveBeenCalled();
173 expect(useClosedViewStore.getState().entries).toHaveLength(1);
174 });
175
176 it("lets the keybinding recorder capture the chord", async () => {
177 useClosedViewStore
178 .getState()
179 .remember({ kind: "panel-tab", threadRef: ref, surface: { kind: "diff", id: "diff" } });
180 await render();
181 const recorder = new TestElement();
182 const event = Object.assign(new Event("keydown", { bubbles: true, cancelable: true }), {
183 key: "T",
184 ctrlKey: true,
185 metaKey: false,
186 shiftKey: true,
187 altKey: false,
188 repeat: false,
189 });
190 Object.defineProperty(event, "target", { value: recorder });
191 window.dispatchEvent(event);
192 expect(event.defaultPrevented).toBe(false);
193 expect(useClosedViewStore.getState().entries).toHaveLength(1);
194 });
195
196 it("serializes deliberate presses while a browser restore waits for RPC", async () => {
197 useClosedViewStore
198 .getState()
199 .remember({ kind: "panel-tab", threadRef: ref, surface: { kind: "files", id: "files" } });
200 useClosedViewStore.getState().remember({ kind: "browser", threadRef: ref, snapshot });
201 let finish!: (value: ReturnType<typeof AsyncResult.success>) => void;
202 state.openPreview.mockReturnValue(
203 new Promise((resolve) => {
204 finish = resolve;
205 }),
206 );
207 await render();
208 await act(() => {
209 press();
210 press();
211 });
212 expect(state.openPreview).toHaveBeenCalledTimes(1);
213 expect(state.navigate).not.toHaveBeenCalled();
214 await act(() => {
215 finish(AsyncResult.success({ tabId: "new-tab" }));
216 });
217 expect(state.navigate).toHaveBeenCalledTimes(2);
218 expect(useClosedViewStore.getState().entries).toEqual([]);
219 });
220
221 it("does not create a second browser when navigation fails after restoring it", async () => {
222 const store = useClosedViewStore.getState();
223 const older = store.remember({
224 kind: "panel-tab",
225 threadRef: ref,
226 surface: { kind: "diff", id: "diff" },
227 });
228 store.remember({ kind: "browser", threadRef: ref, snapshot });
229 state.openPreview.mockResolvedValue(AsyncResult.success({ tabId: "new-tab" }));
230 state.navigate.mockRejectedValueOnce(new Error("navigation failed"));
231 await render();
232 await act(() => {
233 press();
234 });
235 expect(
236 selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, ref).activeSurfaceId,
237 ).toBe("browser:new-tab");
238 expect(state.toast).toHaveBeenCalledWith(
239 expect.objectContaining({ title: "Could not reopen view" }),
240 );
241 expect(useClosedViewStore.getState().entries.map((entry) => entry.id)).toEqual([older]);
242 await act(() => {
243 menuAction?.("view.reopenClosed");
244 });
245 expect(state.openPreview).toHaveBeenCalledOnce();
246 expect(
247 selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, ref).activeSurfaceId,
248 ).toBe("diff");
249 expect(useClosedViewStore.getState().entries).toEqual([]);
250 });
251
252 it.each(["files", "file", "browser"] as const)(
253 "keeps a failed %s restore retryable without blocking older history",
254 async (kind) => {
255 const store = useClosedViewStore.getState();
256 const older = store.remember({
257 kind: "panel-tab",
258 threadRef: ref,
259 surface: { kind: "diff", id: "diff" },
260 });
261 const failed = store.remember(
262 kind === "browser"
263 ? { kind: "browser", threadRef: ref, snapshot }
264 : {
265 kind: "panel-tab",
266 threadRef: ref,
267 surface:
268 kind === "files"
269 ? { kind: "files", id: "files" }
270 : {
271 kind: "file",
272 id: "file:src/app.ts",
273 relativePath: "src/app.ts",
274 revealLine: null,
275 revealRequestId: 0,
276 },
277 },
278 );
279 state.workspaceAvailable = false;
280 state.openPreview.mockResolvedValue(AsyncResult.failure(Cause.fail(new Error("offline"))));
281 await render();
282 await act(() => {
283 press();
284 });
285 expect(useClosedViewStore.getState().entries.map((entry) => entry.id)).toEqual([
286 older,
287 failed,
288 ]);
289 expect(state.navigate).not.toHaveBeenCalled();
290 await act(() => {
291 menuAction?.("view.reopenClosed");
292 });
293 expect(
294 selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, ref).activeSurfaceId,
295 ).toBe("diff");
296 expect(useClosedViewStore.getState().entries.map((entry) => entry.id)).toEqual([failed]);
297 state.workspaceAvailable = true;
298 state.openPreview.mockResolvedValue(AsyncResult.success({ ...snapshot, tabId: "new-tab" }));
299 await act(() => {
300 press();
301 });
302 expect(
303 selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, ref).activeSurfaceId,
304 ).toBe(
305 kind === "browser" ? "browser:new-tab" : kind === "file" ? "file:src/app.ts" : "files",
306 );
307 expect(useClosedViewStore.getState().entries).toEqual([]);
308 },
309 );
310
311 it("forwards the chord to a focused desktop browser only while history exists", async () => {
312 const forwardedCommands = () =>
313 (state.setShortcuts.mock.lastCall as unknown as [Array<{ command: string }>])[0].map(
314 ({ command }) => command,
315 );
316 useClosedViewStore
317 .getState()
318 .remember({ kind: "panel-tab", threadRef: ref, surface: { kind: "diff", id: "diff" } });
319 await render();
320 expect(forwardedCommands()).toContain("view.reopenClosed");
321 await act(() => {
322 menuAction?.("view.reopenClosed");
323 });
324 expect(useClosedViewStore.getState().entries).toEqual([]);
325 expect(forwardedCommands()).not.toContain("view.reopenClosed");
326 });
327
328 it("always forwards the layout toggles out of a focused desktop browser", async () => {
329 await render();
330 expect(state.setShortcuts).toHaveBeenLastCalledWith([
331 expect.objectContaining({ command: "sidebar.toggle" }),
332 expect.objectContaining({ command: "rightPanel.toggle" }),
333 ]);
334 });
335});