Skip to content
Source
Sign inSign up

ReopenClosedViewShortcut.tsx

231 lines8.6 KB

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​
46const PREVIEW_FORWARDED_COMMANDS = [
47 "workspace.focusNext",
48 "workspace.focusPrevious",
49 "sidebar.toggle",
50 "rightPanel.toggle",
51 "rightPanel.toggleMaximized",
52 "preview.focusUrl",
53 "preview.zoomIn",
54 "preview.zoomOut",
55 "preview.resetZoom",
56 "preview.find",
57] as const;
58​
59const isGlobalPullRequests = (ref: ScopedThreadRef) =>
60 scopedThreadKey(ref) === scopedThreadKey(PULL_REQUESTS_PANEL_REF);
61​
62export function ReopenClosedViewShortcut() {
63 const navigate = useNavigate();
64 const params = useParams({ strict: false });
65 const target = resolveThreadRouteTarget(params);
66 const draft = useComposerDraftStore((state) =>
67 target?.kind === "draft" ? state.getDraftSession(target.draftId) : null,
68 );
69 const threadRef =
70 target?.kind === "server"
71 ? target.threadRef
72 : draft
73 ? (draft.promotedTo ?? scopeThreadRef(draft.environmentId, draft.threadId))
74 : null;
75 const terminalOpen = useTerminalUiStateStore(
76 (state) =>
77 selectThreadTerminalUiState(state.terminalUiStateByThreadKey, threadRef).terminalOpen,
78 );
79 const previewOpen = useRightPanelStore(
80 (state) => selectActiveRightPanel(state.byThreadKey, threadRef) === "preview",
81 );
82 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
83 const hasHistory = useClosedViewStore((state) => state.entries.length > 0);
84 const openPreview = useAtomCommand(previewEnvironment.open);
85 const pending = useRef(Promise.resolve());
86​
87 const reopenNext = useEffectEvent(async () => {
88 const catalog = appAtomRegistry.get(environmentCatalog.catalogValueAtom);
89 const drafts = useComposerDraftStore.getState();
90 const panelsByThread = useRightPanelStore.getState().byThreadKey;
91 const { drop, restore } = planNextReopen(useClosedViewStore.getState().entries, (entry) => {
92 const ref = entry.threadRef;
93 const panel = selectThreadRightPanelState(panelsByThread, ref);
94 if (isGlobalPullRequests(ref)) {
95 return {
96 environmentKnown: true,
97 catalogReady: true,
98 ownerExists: true,
99 sideChatExists: true,
100 shellLive: true,
101 panel,
102 };
103 }
104 return {
105 environmentKnown: catalog.entries.has(ref.environmentId),
106 catalogReady: catalog.isReady,
107 ownerExists: readThreadShell(ref) !== null || drafts.getDraftThreadByRef(ref) !== null,
108 sideChatExists:
109 entry.kind !== "panel-tab" ||
110 entry.surface.kind !== "side-chat" ||
111 readThreadShell(scopeThreadRef(ref.environmentId, entry.surface.threadId)) !== null,
112 shellLive:
113 appAtomRegistry.get(environmentShell.stateValueAtom(ref.environmentId)).status === "live",
114 panel,
115 };
116 });
117 for (const entry of drop) useClosedViewStore.getState().remove(entry.id);
118 if (!restore) return;
119​
120 const ref = restore.threadRef;
121 const globalPullRequests = isGlobalPullRequests(ref);
122 const thread = globalPullRequests ? null : readThreadShell(ref);
123 const owner = thread ?? drafts.getDraftThreadByRef(ref);
124 const project = owner ? readProject(scopeProjectRef(ref.environmentId, owner.projectId)) : null;
125 if (!(await reopenClosedView(restore, { openPreview, workspaceAvailable: project !== null }))) {
126 useClosedViewStore.getState().defer(restore.id);
127 return;
128 }
129 useClosedViewStore.getState().remove(restore.id);
130 if (globalPullRequests) {
131 const selected = selectSelectedRightPanelSurface(
132 useRightPanelStore.getState().byThreadKey,
133 ref,
134 );
135 await navigate({
136 to: "/pull-requests",
137 search: (previous) => pullRequestsSearchForRestore(previous, selected),
138 });
139 } else {
140 const draftId = thread === null ? drafts.getDraftIdByRef(ref) : null;
141 if (draftId !== null)
142 await navigate({ to: "/draft/$draftId", params: buildDraftThreadRouteParams(draftId) });
143 else await navigate({ to: "/$environmentId/$threadId", params: buildThreadRouteParams(ref) });
144 }
145 });
146​
147 const enqueueReopen = useEffectEvent(() => {
148 pending.current = pending.current
149 .then(() => reopenNext())
150 .catch((error: unknown) => {
151 toastManager.add({
152 type: "error",
153 title: "Could not reopen view",
154 description: error instanceof Error ? error.message : String(error),
155 });
156 });
157 });
158​
159 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to window keydown and the desktop menu action for the reopen closed view shortcut
160 useEffect(() => {
161 const onKeyDown = (event: KeyboardEvent) => {
162 if (
163 event.defaultPrevented ||
164 isCommandPaletteOpen() ||
165 useClosedViewStore.getState().entries.length === 0 ||
166 (event.target instanceof HTMLElement && event.target.closest("[data-keybinding-capture]"))
167 )
168 return;
169 const command = resolveShortcutCommand(event, keybindings, {
170 context: {
171 terminalFocus: isTerminalFocused(),
172 terminalOpen,
173 previewFocus: isPreviewFocused(),
174 previewOpen,
175 editableFocus: isEditableFocused(event.target),
176 modelPickerOpen: isModelPickerOpen(),
177 },
178 });
179 if (command !== "view.reopenClosed") return;
180 event.preventDefault();
181 event.stopPropagation();
182 if (!event.repeat) enqueueReopen();
183 };
184 window.addEventListener("keydown", onKeyDown, true);
185 const unsubscribe = window.desktopBridge?.onMenuAction((action) => {
186 if (action === "view.reopenClosed" && !isCommandPaletteOpen()) enqueueReopen();
187 });
188 return () => {
189 window.removeEventListener("keydown", onKeyDown, true);
190 unsubscribe?.();
191 };
192 }, [keybindings, previewOpen, terminalOpen]);
193​
194 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- registers forwarded shortcut chords with the desktop preview bridge and clears them on cleanup
195 useEffect(() => {
196 const preview = window.desktopBridge?.preview;
197 if (!preview) return;
198 const options = {
199 context: {
200 previewFocus: true,
201 previewOpen: true,
202 terminalFocus: false,
203 terminalOpen,
204 editableFocus: false,
205 modelPickerOpen: false,
206 isDesktop: true,
207 isWeb: false,
208 },
209 };
210 // Guest browser processes miss app keydown listeners; desktop forwards the chords as menu actions.
211 const commands = [
212 ...(hasHistory ? (["view.reopenClosed"] as const) : []),
213 ...PREVIEW_FORWARDED_COMMANDS,
214 ];
215 void preview
216 .setForwardedShortcuts(
217 commands.flatMap((command) =>
218 effectiveShortcutsForCommand(keybindings, command, options).map((shortcut) => ({
219 command,
220 shortcut,
221 })),
222 ),
223 )
224 .catch(() => undefined);
225 return () => {
226 void preview.setForwardedShortcuts([]).catch(() => undefined);
227 };
228 }, [hasHistory, keybindings, terminalOpen]);
229​
230 return null;