1import { useAtomValue } from "@effect/atom-react";
2import {
3 scopeProjectRef,
4 scopedThreadKey,
5 scopeThreadRef,
6} from "@t3tools/client-runtime/environment";
7import type { ScopedThreadRef } from "@t3tools/contracts";
8import { useNavigate, useParams } from "@tanstack/react-router";
9import { useEffect, useEffectEvent, useRef } from "react";
10
11import { isCommandPaletteOpen } from "../commandPaletteBus";
12import { useClosedViewStore } from "../closedViewStore";
13import { useComposerDraftStore } from "../composerDraftStore";
14import { environmentCatalog } from "../connection/catalog";
15import { effectiveShortcutsForCommand, resolveShortcutCommand } from "../keybindings";
16import { isEditableFocused } from "../lib/editableFocus";
17import { isPreviewFocused } from "../lib/previewFocus";
18import { isTerminalFocused } from "../lib/terminalFocus";
19import { isModelPickerOpen } from "../modelPickerVisibility";
20import {
21 planNextReopen,
22 pullRequestsSearchForRestore,
23 reopenClosedView,
24} from "../reopenClosedView";
25import {
26 PULL_REQUESTS_PANEL_REF,
27 selectActiveRightPanel,
28 selectSelectedRightPanelSurface,
29 selectThreadRightPanelState,
30 useRightPanelStore,
31} from "../rightPanelStore";
32import { appAtomRegistry } from "../rpc/atomRegistry";
33import { readProject, readThreadShell } from "../state/entities";
34import { previewEnvironment } from "../state/preview";
35import { primaryServerKeybindingsAtom } from "../state/server";
36import { environmentShell } from "../state/shell";
37import { useAtomCommand } from "../state/use-atom-command";
38import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
39import {
40 buildDraftThreadRouteParams,
41 buildThreadRouteParams,
42 resolveThreadRouteTarget,
43} from "../threadRoutes";
44import { toastManager } from "./ui/toast";
45
46/** Layout commands a focused desktop browser page hands back to the app. */
47const PREVIEW_FORWARDED_LAYOUT_COMMANDS = [
48 "sidebar.toggle",
49 "rightPanel.toggle",
50 "rightPanel.toggleMaximized",
51] as const;
52
53const isGlobalPullRequests = (ref: ScopedThreadRef) =>
54 scopedThreadKey(ref) === scopedThreadKey(PULL_REQUESTS_PANEL_REF);
55
56export function ReopenClosedViewShortcut() {
57 const navigate = useNavigate();
58 const params = useParams({ strict: false });
59 const target = resolveThreadRouteTarget(params);
60 const draft = useComposerDraftStore((state) =>
61 target?.kind === "draft" ? state.getDraftSession(target.draftId) : null,
62 );
63 const threadRef =
64 target?.kind === "server"
65 ? target.threadRef
66 : draft
67 ? (draft.promotedTo ?? scopeThreadRef(draft.environmentId, draft.threadId))
68 : null;
69 const terminalOpen = useTerminalUiStateStore(
70 (state) =>
71 selectThreadTerminalUiState(state.terminalUiStateByThreadKey, threadRef).terminalOpen,
72 );
73 const previewOpen = useRightPanelStore(
74 (state) => selectActiveRightPanel(state.byThreadKey, threadRef) === "preview",
75 );
76 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
77 const hasHistory = useClosedViewStore((state) => state.entries.length > 0);
78 const openPreview = useAtomCommand(previewEnvironment.open);
79 const pending = useRef(Promise.resolve());
80
81 const reopenNext = useEffectEvent(async () => {
82 const catalog = appAtomRegistry.get(environmentCatalog.catalogValueAtom);
83 const drafts = useComposerDraftStore.getState();
84 const panelsByThread = useRightPanelStore.getState().byThreadKey;
85 const { drop, restore } = planNextReopen(useClosedViewStore.getState().entries, (entry) => {
86 const ref = entry.threadRef;
87 const panel = selectThreadRightPanelState(panelsByThread, ref);
88 if (isGlobalPullRequests(ref)) {
89 return {
90 environmentKnown: true,
91 catalogReady: true,
92 ownerExists: true,
93 shellLive: true,
94 panel,
95 };
96 }
97 return {
98 environmentKnown: catalog.entries.has(ref.environmentId),
99 catalogReady: catalog.isReady,
100 ownerExists: readThreadShell(ref) !== null || drafts.getDraftThreadByRef(ref) !== null,
101 shellLive:
102 appAtomRegistry.get(environmentShell.stateValueAtom(ref.environmentId)).status === "live",
103 panel,
104 };
105 });
106 for (const entry of drop) useClosedViewStore.getState().remove(entry.id);
107 if (!restore) return;
108
109 const ref = restore.threadRef;
110 const globalPullRequests = isGlobalPullRequests(ref);
111 const thread = globalPullRequests ? null : readThreadShell(ref);
112 const owner = thread ?? drafts.getDraftThreadByRef(ref);
113 const project = owner ? readProject(scopeProjectRef(ref.environmentId, owner.projectId)) : null;
114 if (!(await reopenClosedView(restore, { openPreview, workspaceAvailable: project !== null }))) {
115 useClosedViewStore.getState().defer(restore.id);
116 return;
117 }
118 useClosedViewStore.getState().remove(restore.id);
119 if (globalPullRequests) {
120 const selected = selectSelectedRightPanelSurface(
121 useRightPanelStore.getState().byThreadKey,
122 ref,
123 );
124 await navigate({
125 to: "/pull-requests",
126 search: (previous) => pullRequestsSearchForRestore(previous, selected),
127 });
128 } else {
129 const draftId = thread === null ? drafts.getDraftIdByRef(ref) : null;
130 if (draftId !== null)
131 await navigate({ to: "/draft/$draftId", params: buildDraftThreadRouteParams(draftId) });
132 else await navigate({ to: "/$environmentId/$threadId", params: buildThreadRouteParams(ref) });
133 }
134 });
135
136 const enqueueReopen = useEffectEvent(() => {
137 pending.current = pending.current
138 .then(() => reopenNext())
139 .catch((error: unknown) => {
140 toastManager.add({
141 type: "error",
142 title: "Could not reopen view",
143 description: error instanceof Error ? error.message : String(error),
144 });
145 });
146 });
147
148 useEffect(() => {
149 const onKeyDown = (event: KeyboardEvent) => {
150 if (
151 event.defaultPrevented ||
152 isCommandPaletteOpen() ||
153 useClosedViewStore.getState().entries.length === 0 ||
154 (event.target instanceof HTMLElement && event.target.closest("[data-keybinding-capture]"))
155 )
156 return;
157 const command = resolveShortcutCommand(event, keybindings, {
158 context: {
159 terminalFocus: isTerminalFocused(),
160 terminalOpen,
161 previewFocus: isPreviewFocused(),
162 previewOpen,
163 editableFocus: isEditableFocused(event.target),
164 modelPickerOpen: isModelPickerOpen(),
165 },
166 });
167 if (command !== "view.reopenClosed") return;
168 event.preventDefault();
169 event.stopPropagation();
170 if (!event.repeat) enqueueReopen();
171 };
172 window.addEventListener("keydown", onKeyDown, true);
173 const unsubscribe = window.desktopBridge?.onMenuAction((action) => {
174 if (action === "view.reopenClosed" && !isCommandPaletteOpen()) enqueueReopen();
175 });
176 return () => {
177 window.removeEventListener("keydown", onKeyDown, true);
178 unsubscribe?.();
179 };
180 }, [keybindings, previewOpen, terminalOpen]);
181
182 useEffect(() => {
183 const preview = window.desktopBridge?.preview;
184 if (!preview?.setForwardedShortcuts) return;
185 const options = {
186 context: {
187 previewFocus: true,
188 previewOpen: true,
189 terminalFocus: false,
190 terminalOpen,
191 editableFocus: false,
192 modelPickerOpen: false,
193 isDesktop: true,
194 isWeb: false,
195 },
196 };
197 // A focused browser page is a separate process, so these chords never reach
198 // the app's keydown listeners. The desktop claims them in the guest and
199 // sends each command back as a menu action.
200 const commands = [
201 ...(hasHistory ? (["view.reopenClosed"] as const) : []),
202 ...PREVIEW_FORWARDED_LAYOUT_COMMANDS,
203 ];
204 void preview
205 .setForwardedShortcuts(
206 commands.flatMap((command) =>
207 effectiveShortcutsForCommand(keybindings, command, options).map((shortcut) => ({
208 command,
209 shortcut,
210 })),
211 ),
212 )
213 .catch(() => undefined);
214 return () => {
215 void preview.setForwardedShortcuts?.([]).catch(() => undefined);
216 };
217 }, [hasHistory, keybindings, terminalOpen]);
218
219 return null;
220}