authorgravatar for 62337003+Bil0000@users.noreply.github.comBilal Bakr <62337003+Bil0000@users.noreply.github.com> 2026-10-06 22:16:21+03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-06 12:16:21-07:00
logb83d731cabbfd32c7479bb2db7e6afbfb2085ff2
treece0c7341ac419e0652d699006675bc48e77b9d96
parent29366ea29abf4ceb41ab250d06e190a22258bd18
signature Signed by PGP key B5690EEEBB952194

feat(web): reopen closed tabs across the app (#15207)

Co-authored-by: Julius Marminge <julius0216@outlook.com>

27 files changed, 1848 insertions(+), 149 deletions(-)

apps/desktop/src/ipc/channels.ts+1
......@@ -68,6 +68,7 @@ export const SET_WSL_BACKEND_ENABLED_CHANNEL = "desktop:set-wsl-backend-enabled"
6868export const SET_WSL_DISTRO_CHANNEL = "desktop:set-wsl-distro";
6969export const SET_WSL_ONLY_CHANNEL = "desktop:set-wsl-only";
7070export const SSH_PASSWORD_PROMPT_CANCELLED_RESULT = "ssh-password-prompt-cancelled";
71export const PREVIEW_SET_FORWARDED_SHORTCUTS_CHANNEL = "desktop:preview-set-forwarded-shortcuts";
7172export const PREVIEW_CREATE_TAB_CHANNEL = "desktop:preview-create-tab";
7273export const PREVIEW_CLOSE_TAB_CHANNEL = "desktop:preview-close-tab";
7374export const PREVIEW_REGISTER_WEBVIEW_CHANNEL = "desktop:preview-register-webview";
apps/desktop/src/ipc/methods/preview.ts+13
......@@ -20,6 +20,8 @@ import {
2020 PreviewAnnotationSubmissionResultSchema,
2121 DEFAULT_BROWSER_PROFILE_ID,
2222 INCOGNITO_BROWSER_PROFILE_ID,
23 PreviewForwardedShortcut,
24 MAX_KEYBINDINGS_COUNT,
2325} from "@t3tools/contracts";
2426import * as Effect from "effect/Effect";
2527import * as Schema from "effect/Schema";
......@@ -53,6 +55,16 @@ export const installPreviewEventForwarding = Effect.fn(
5355 );
5456});
5557
58export const setForwardedShortcuts = DesktopIpc.makeIpcMethod({
59 channel: IpcChannels.PREVIEW_SET_FORWARDED_SHORTCUTS_CHANNEL,
60 payload: Schema.Array(PreviewForwardedShortcut).check(Schema.isMaxLength(MAX_KEYBINDINGS_COUNT)),
61 result: Schema.Void,
62 handler: Effect.fn("desktop.ipc.preview.setForwardedShortcuts")(function* (shortcuts) {
63 const manager = yield* PreviewManager.PreviewManager;
64 yield* manager.setForwardedShortcuts(shortcuts);
65 }),
66});
67
5668export const createTab = DesktopIpc.makeIpcMethod({
5769 channel: IpcChannels.PREVIEW_CREATE_TAB_CHANNEL,
5870 payload: DesktopPreviewCreateTabInputSchema,
......@@ -406,6 +418,7 @@ export const saveRecording = DesktopIpc.makeIpcMethod({
406418});
407419
408420export const methods = [
421 setForwardedShortcuts,
409422 createTab,
410423 closeTab,
411424 registerWebview,
apps/desktop/src/preload.ts+2
......@@ -299,6 +299,8 @@ contextBridge.exposeInMainWorld("desktopBridge", {
299299 },
300300 },
301301 preview: {
302 setForwardedShortcuts: (shortcuts) =>
303 ipcRenderer.invoke(IpcChannels.PREVIEW_SET_FORWARDED_SHORTCUTS_CHANNEL, shortcuts),
302304 createTab: (tabId, defaults) =>
303305 ipcRenderer.invoke(IpcChannels.PREVIEW_CREATE_TAB_CHANNEL, {
304306 tabId,
apps/desktop/src/preview/Manager.test.ts+124-19
......@@ -6,6 +6,7 @@ import type {
66 DesktopPreviewRecordingInputEvent,
77} from "@t3tools/contracts";
88import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
9import { parseKeybindingShortcut } from "@t3tools/shared/keybindings";
910import * as Cause from "effect/Cause";
1011import * as Deferred from "effect/Deferred";
1112import * as Effect from "effect/Effect";
......@@ -269,32 +270,34 @@ const layerFileSystem = FileSystem.layerNoop({
269270 }),
270271});
271272
272const layer = PreviewManager.layer.pipe(
273 Layer.provideMerge(
274 Layer.succeed(DesktopRendererHistory.DesktopRendererHistory, {
275 register: () => Effect.void,
276 recordMetrics: () => Effect.void,
277 shutdown: Effect.void,
278 }),
279 ),
280 Layer.provideMerge(DesktopBrowserHost.layer),
281 Layer.provideMerge(layerBrowserSession),
282 Layer.provideMerge(layerEnvironment),
283 Layer.provideMerge(layerFileSystem),
284 Layer.provideMerge(Path.layer),
285 Layer.provideMerge(NodeCrypto.layer),
286 Layer.provideMerge(Layer.succeed(HostProcessPlatform, "darwin")),
287);
273const managerLayer = (platform: NodeJS.Platform = "darwin") =>
274 PreviewManager.layer.pipe(
275 Layer.provideMerge(
276 Layer.succeed(DesktopRendererHistory.DesktopRendererHistory, {
277 register: () => Effect.void,
278 recordMetrics: () => Effect.void,
279 shutdown: Effect.void,
280 }),
281 ),
282 Layer.provideMerge(DesktopBrowserHost.layer),
283 Layer.provideMerge(layerBrowserSession),
284 Layer.provideMerge(layerEnvironment),
285 Layer.provideMerge(layerFileSystem),
286 Layer.provideMerge(Path.layer),
287 Layer.provideMerge(NodeCrypto.layer),
288 Layer.provideMerge(Layer.succeed(HostProcessPlatform, platform)),
289 );
288290
289291const withManager = <A>(
290292 use: (
291293 manager: PreviewManager.PreviewManager["Service"],
292294 ) => Effect.Effect<A, PreviewManager.PreviewManagerError, Scope.Scope>,
295 platform: NodeJS.Platform = "darwin",
293296) =>
294297 Effect.gen(function* () {
295298 const manager = yield* PreviewManager.PreviewManager;
296299 return yield* use(manager);
297 }).pipe(Effect.provide(layer), Effect.scoped);
300 }).pipe(Effect.provide(managerLayer(platform)), Effect.scoped);
298301
299302interface TestCapturedPreviewImage {
300303 readonly toJPEG: () => Buffer;
......@@ -574,7 +577,7 @@ describe("PreviewManager", () => {
574577 Effect.gen(function* () {
575578 const preview = makeFaviconWebContents();
576579 const sendInputEvent = vi.fn();
577 const hostWebContents = { sendInputEvent };
580 const hostWebContents = { sendInputEvent, isDestroyed: () => false };
578581 Object.assign(preview.webContents, { hostWebContents });
579582 fromId.mockReturnValue(preview.webContents);
580583 getFocusedWebContents.mockReturnValue(preview.webContents as never);
......@@ -628,6 +631,108 @@ describe("PreviewManager", () => {
628631 ),
629632 );
630633
634 effectIt.effect.each([
635 ["mod+shift+t", "view.reopenClosed"],
636 ["ctrl+alt+u", "sidebar.toggle"],
637 ] as const)(
638 "forwards only the configured command from the focused guest: %s",
639 ([chord, command]) =>
640 withManager((manager) =>
641 Effect.gen(function* () {
642 const preview = makeFaviconWebContents();
643 const send = vi.fn();
644 Object.assign(preview.webContents, {
645 hostWebContents: { isDestroyed: () => false, send },
646 });
647 fromId.mockReturnValue(preview.webContents);
648 getFocusedWebContents.mockReturnValue(preview.webContents as never);
649 yield* manager.createTab("tab_reopen");
650 yield* manager.registerWebview("tab_reopen", 42);
651 const shortcut = parseKeybindingShortcut(chord)!;
652 yield* manager.setForwardedShortcuts([{ command, shortcut }]);
653 const beforeInput = preview.listeners.get("before-input-event")!;
654 const input = {
655 type: "keyDown",
656 key: shortcut.key.toUpperCase(),
657 meta: shortcut.metaKey || shortcut.modKey,
658 control: shortcut.ctrlKey,
659 shift: shortcut.shiftKey,
660 alt: shortcut.altKey,
661 };
662 const preventDefault = vi.fn();
663 beforeInput({ preventDefault } as never, input as never);
664 expect(preventDefault).toHaveBeenCalledOnce();
665 expect(send).toHaveBeenCalledExactlyOnceWith("desktop:menu-action", command);
666 for (const overrides of [
667 { isAutoRepeat: true },
668 { type: "keyUp" },
669 { shift: !input.shift },
670 { key: "x" },
671 ]) {
672 preventDefault.mockClear();
673 beforeInput({ preventDefault } as never, { ...input, ...overrides } as never);
674 expect(preventDefault).toHaveBeenCalledTimes("isAutoRepeat" in overrides ? 1 : 0);
675 expect(send).toHaveBeenCalledOnce();
676 }
677 getFocusedWebContents.mockReturnValue(null);
678 preventDefault.mockClear();
679 beforeInput({ preventDefault } as never, input as never);
680 expect(preventDefault).not.toHaveBeenCalled();
681 expect(send).toHaveBeenCalledOnce();
682 getFocusedWebContents.mockReturnValue(preview.webContents as never);
683 yield* manager.setForwardedShortcuts([]);
684 beforeInput({ preventDefault } as never, input as never);
685 expect(preventDefault).not.toHaveBeenCalled();
686 expect(send).toHaveBeenCalledOnce();
687 }),
688 ),
689 );
690
691 effectIt.effect.each([
692 ["linux", "ctrl+alt+[", "[", "BracketLeft", false],
693 ["win32", "ctrl+alt+8", "[", "Digit8", false],
694 ["win32", "ctrl+alt+k", "K", "KeyK", false],
695 ["linux", "ctrl+alt+`", "Dead", "Backquote", false],
696 ["darwin", "ctrl+alt+[", "[", "BracketLeft", true],
697 ["linux", "ctrl+alt+f7", "F7", "F7", true],
698 ] as const)(
699 "handles ambiguous preview chords on %s: %s",
700 ([platform, chord, key, code, expected]) =>
701 withManager(
702 (manager) =>
703 Effect.gen(function* () {
704 const preview = makeFaviconWebContents();
705 const send = vi.fn();
706 Object.assign(preview.webContents, {
707 hostWebContents: { isDestroyed: () => false, send },
708 });
709 fromId.mockReturnValue(preview.webContents);
710 getFocusedWebContents.mockReturnValue(preview.webContents as never);
711 yield* manager.createTab("tab_alt_graph");
712 yield* manager.registerWebview("tab_alt_graph", 42);
713 yield* manager.setForwardedShortcuts([
714 { command: "view.reopenClosed", shortcut: parseKeybindingShortcut(chord)! },
715 ]);
716 const preventDefault = vi.fn();
717 preview.listeners.get("before-input-event")!(
718 { preventDefault } as never,
719 {
720 type: "keyDown",
721 key,
722 code,
723 meta: false,
724 control: true,
725 shift: false,
726 alt: true,
727 } as never,
728 );
729 expect(preventDefault).toHaveBeenCalledTimes(expected ? 1 : 0);
730 expect(send).toHaveBeenCalledTimes(expected ? 1 : 0);
731 }),
732 platform,
733 ),
734 );
735
631736 effectIt.effect("preserves focused browser editing in tabs and sign-in popups", () =>
632737 withManager((manager) =>
633738 Effect.gen(function* () {
......@@ -2716,7 +2821,7 @@ describe("PreviewManager", () => {
27162821 expect(yield* nextType).toBe("attached");
27172822 yield* manager.closeTab("tab_1");
27182823 expect(yield* nextType).toBe("detached");
2719 }).pipe(Effect.provide(layer), Effect.scoped),
2824 }).pipe(Effect.provide(managerLayer()), Effect.scoped),
27202825 );
27212826
27222827 effectIt.effect("stops capture retries when the tab swaps during the retry delay", () =>
apps/desktop/src/preview/Manager.ts+44-1
......@@ -22,8 +22,10 @@ import type {
2222 DesktopPreviewRecordingInputEvent,
2323 DesktopPreviewScreenshotArtifact,
2424 DesktopPreviewTabDefaults,
25 PreviewForwardedShortcut,
2526} from "@t3tools/contracts";
2627import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
28import { matchesKeybindingShortcut } from "@t3tools/shared/keybindings";
2729import { normalizePreviewUrl } from "@t3tools/shared/preview";
2830import {
2931 BrowserWindow,
......@@ -58,7 +60,7 @@ import * as SynchronizedRef from "effect/SynchronizedRef";
5860
5961import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
6062import * as DesktopRendererHistory from "../telemetry/DesktopRendererHistory.ts";
61import { PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL } from "../ipc/channels.ts";
63import { MENU_ACTION_CHANNEL, PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL } from "../ipc/channels.ts";
6264import * as DesktopBrowserHost from "./DesktopBrowserHost.ts";
6365import * as BrowserSession from "./BrowserSession.ts";
6466import {
......@@ -555,6 +557,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
555557 const runFork = Effect.runForkWith(context);
556558 const resolvedArtifactDirectory = path.resolve(artifactDirectory);
557559
560 let forwardedShortcuts: ReadonlyArray<PreviewForwardedShortcut> = [];
558561 const annotationThemeRef = yield* Ref.make(DEFAULT_ANNOTATION_THEME);
559562 const mainWindowRef = yield* Ref.make<Option.Option<BrowserWindow>>(Option.none());
560563 const tabsRef = yield* SynchronizedRef.make<ReadonlyMap<string, PreviewTabState>>(new Map());
......@@ -1520,6 +1523,38 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
15201523 };
15211524 const beforeInput = (event: Electron.Event, input: Electron.Input): void => {
15221525 syncMenuShortcuts(wc, input);
1526 const host = wc.hostWebContents;
1527 const forwarded =
1528 input.type === "keyDown" &&
1529 !input.isComposing &&
1530 host &&
1531 !host.isDestroyed() &&
1532 webContents.getFocusedWebContents() === wc &&
1533 !(
1534 hostPlatform !== "darwin" &&
1535 input.control &&
1536 input.alt &&
1537 /^(?:[^a-zA-Z0-9]|Dead)$/u.test(input.key)
1538 ) &&
1539 forwardedShortcuts.find(({ shortcut }) =>
1540 matchesKeybindingShortcut(
1541 {
1542 key: input.key,
1543 code: input.code,
1544 metaKey: input.meta,
1545 ctrlKey: input.control,
1546 shiftKey: input.shift,
1547 altKey: input.alt,
1548 },
1549 shortcut,
1550 hostPlatform === "darwin" ? "MacIntel" : hostPlatform,
1551 ),
1552 );
1553 if (forwarded && host) {
1554 event.preventDefault();
1555 if (!input.isAutoRepeat) host.send(MENU_ACTION_CHANNEL, forwarded.command);
1556 return;
1557 }
15231558 if (isPreviewRefreshShortcut(input)) {
15241559 event.preventDefault();
15251560 runFork(
......@@ -3324,6 +3359,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
33243359 setAudioMuted,
33253360 setColorScheme,
33263361 setMainWindow,
3362 setForwardedShortcuts: (shortcuts: ReadonlyArray<PreviewForwardedShortcut>) =>
3363 Effect.sync(() => {
3364 forwardedShortcuts = shortcuts;
3365 }),
33273366 startRecording,
33283367 closePictureInPicture,
33293368 stopRecording,
......@@ -3484,6 +3523,9 @@ export class PreviewManager extends Context.Service<
34843523 PreviewManager,
34853524 {
34863525 readonly setMainWindow: (window: BrowserWindow) => Effect.Effect<void, PreviewManagerError>;
3526 readonly setForwardedShortcuts: (
3527 shortcuts: ReadonlyArray<PreviewForwardedShortcut>,
3528 ) => Effect.Effect<void>;
34873529 readonly getBrowserSession: (
34883530 scope?: string,
34893531 persistent?: boolean,
......@@ -3595,6 +3637,7 @@ export const make = Effect.gen(function* PreviewManagerMake() {
35953637
35963638 return PreviewManager.of({
35973639 setMainWindow: operations.setMainWindow,
3640 setForwardedShortcuts: operations.setForwardedShortcuts,
35983641 getBrowserSession: Effect.fn("PreviewManager.getBrowserSession")(
35993642 function* (scope, persistent, namespace) {
36003643 const session = yield* browserSession
apps/server/src/keybindings.test.ts+2-2
......@@ -257,7 +257,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => {
257257 Effect.gen(function* () {
258258 const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig;
259259 yield* writeKeybindingsConfig(keybindingsConfigPath, [
260 { key: "mod+shift+t", command: "terminal.toggle" },
260 { key: "mod+shift+y", command: "terminal.toggle" },
261261 { key: "mod+shift+r", command: "script.run-tests.run" },
262262 ]);
263263
......@@ -271,7 +271,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => {
271271
272272 const persistedToggle = byCommand.get("terminal.toggle");
273273 assert.isNotNull(persistedToggle);
274 assert.equal(persistedToggle?.key, "mod+shift+t");
274 assert.equal(persistedToggle?.key, "mod+shift+y");
275275 assert.isFalse(
276276 persisted.some((entry) => entry.command === "terminal.toggle" && entry.key === "mod+j"),
277277 );
apps/web/src/closedViewStore.test.ts created+220
......@@ -0,0 +1,220 @@
1import { scopeThreadRef } from "@t3tools/client-runtime/environment";
2import {
3 type EnvironmentId,
4 INCOGNITO_BROWSER_PROFILE_ID,
5 type PreviewSessionSnapshot,
6 ThreadId,
7} from "@t3tools/contracts";
8import { beforeEach, describe, expect, it } from "vite-plus/test";
9
10import { type ClosedViewEntry, useClosedViewStore } from "./closedViewStore";
11
12const refA = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-A"));
13const refB = scopeThreadRef("env-2" as EnvironmentId, ThreadId.make("thread-A"));
14const diff = (threadRef: typeof refA) =>
15 ({ kind: "panel-tab", threadRef, surface: { kind: "diff", id: "diff" } }) as const;
16
17beforeEach(() => {
18 useClosedViewStore.setState({ entries: [] });
19});
20
21const snapshot: PreviewSessionSnapshot = {
22 threadId: refA.threadId,
23 tabId: "private-tab",
24 profileId: INCOGNITO_BROWSER_PROFILE_ID,
25 navStatus: { _tag: "Success", url: "https://private.example", title: "Private page" },
26 canGoBack: false,
27 canGoForward: false,
28 updatedAt: "2026-10-06T17:00:00.000Z",
29};
30
31describe("closedViewStore", () => {
32 it.each([null, {}, { entries: null }, { entries: {} }, { entries: "invalid" }])(
33 "rehydrates malformed older history %j as empty history",
34 async (state) => {
35 useClosedViewStore.getState().remember(diff(refA));
36 const { storage, name } = useClosedViewStore.persist.getOptions();
37 await storage!.setItem(name!, {
38 state: state as { entries: ClosedViewEntry[] },
39 version: 0,
40 });
41 await useClosedViewStore.persist.rehydrate();
42 expect(useClosedViewStore.persist.hasHydrated()).toBe(true);
43 expect(useClosedViewStore.getState().entries).toEqual([]);
44 expect(await storage!.getItem(name!)).toEqual({ state: { entries: [] }, version: 2 });
45 },
46 );
47
48 it.each([
49 { id: "bad", kind: "browser", threadRef: refA, snapshot: {} },
50 {
51 id: "bad",
52 kind: "browser",
53 threadRef: refA,
54 snapshot: { ...snapshot, profileId: "default", navStatus: null },
55 },
56 { id: "bad", kind: "panel-tab", surface: { kind: "diff", id: "diff" } },
57 { id: "bad", kind: "panel-tab", threadRef: refA },
58 { id: "bad", kind: "panel-tab", threadRef: {}, surface: { kind: "diff", id: "diff" } },
59 { id: "bad", kind: "panel-tab", threadRef: refA, surface: {} },
60 { id: "bad", kind: "panel-tab", threadRef: refA, surface: { kind: "file", id: "file:x" } },
61 {
62 id: "bad",
63 kind: "panel-tab",
64 threadRef: refA,
65 surface: { kind: "device", id: "device:x", target: {} },
66 },
67 {
68 id: "bad",
69 kind: "panel-tab",
70 threadRef: refA,
71 surface: { kind: "pull-request", id: "pull-request:x", repository: "owner/repo", number: 1 },
72 },
73 ])("discards incomplete saved views %j while retaining valid history", async (entry) => {
74 const id = useClosedViewStore.getState().remember(diff(refA));
75 const { storage, name } = useClosedViewStore.persist.getOptions();
76 await storage!.setItem(name!, {
77 state: { entries: [entry as ClosedViewEntry, ...useClosedViewStore.getState().entries] },
78 version: 1,
79 });
80 await useClosedViewStore.persist.rehydrate();
81 expect(useClosedViewStore.persist.hasHydrated()).toBe(true);
82 expect(useClosedViewStore.getState().entries).toEqual([{ ...diff(refA), id }]);
83 expect(() => useClosedViewStore.getState().remember(diff(refB))).not.toThrow();
84 });
85
86 it("preserves every supported saved panel type and its restore data", async () => {
87 const surfaces = [
88 { kind: "diff", id: "diff" },
89 { kind: "files", id: "files" },
90 { kind: "pull-requests", id: "pull-requests" },
91 { kind: "preview", id: "browser:new", resourceId: null },
92 { kind: "preview", id: "browser:saved", resourceId: "saved" },
93 { kind: "device", id: "device" },
94 {
95 kind: "device",
96 id: "device:phone",
97 title: "My phone",
98 target: { hostId: "mac", deviceId: "phone", platform: "ios", name: "iPhone" },
99 },
100 { kind: "file", id: "file:x", relativePath: "x", revealLine: 12, revealRequestId: 1 },
101 {
102 kind: "file",
103 id: "attachment:a",
104 relativePath: "report.pdf",
105 revealLine: null,
106 revealRequestId: 0,
107 attachment: {
108 type: "file",
109 id: "a",
110 name: "report.pdf",
111 mimeType: "application/pdf",
112 sizeBytes: 1,
113 },
114 },
115 {
116 kind: "pull-request",
117 id: "pull-request:x",
118 projectId: "project",
119 repository: "owner/repo",
120 number: 1,
121 environmentId: "env-1",
122 host: "github.com",
123 url: "https://github.com/owner/repo/pull/1",
124 },
125 ];
126 const entries = surfaces.map((surface, index) => ({
127 id: String(index),
128 kind: "panel-tab",
129 threadRef: refA,
130 surface,
131 })) as ClosedViewEntry[];
132 const { storage, name } = useClosedViewStore.persist.getOptions();
133 await storage!.setItem(name!, { state: { entries }, version: 0 });
134 await useClosedViewStore.persist.rehydrate();
135 expect(useClosedViewStore.persist.hasHydrated()).toBe(true);
136 expect(useClosedViewStore.getState().entries).toEqual(entries);
137 });
138
139 it("keeps private tabs available in memory without saving their metadata", async () => {
140 const store = useClosedViewStore.getState();
141 const publicId = store.remember(diff(refA));
142 const privateId = store.remember({ kind: "browser", threadRef: refA, snapshot });
143 expect(useClosedViewStore.getState().entries.map((entry) => entry.id)).toEqual([
144 privateId,
145 publicId,
146 ]);
147 const { storage, name } = useClosedViewStore.persist.getOptions();
148 const saved = JSON.stringify(await storage!.getItem(name!));
149 expect(saved).not.toContain("private.example");
150 expect(saved).not.toContain("Private page");
151 await useClosedViewStore.persist.rehydrate();
152 expect(useClosedViewStore.getState().entries.map((entry) => entry.id)).toEqual([publicId]);
153 });
154
155 it("removes private metadata and malformed entries from history saved by earlier versions", async () => {
156 const store = useClosedViewStore.getState();
157 const publicId = store.remember(diff(refA));
158 const browserId = store.remember({
159 kind: "browser",
160 threadRef: refA,
161 snapshot: {
162 ...snapshot,
163 tabId: "public-tab",
164 profileId: "default",
165 navStatus: { _tag: "Success", url: "https://public.example", title: "Public page" },
166 },
167 });
168 store.remember({ kind: "browser", threadRef: refA, snapshot });
169 const { storage, name } = useClosedViewStore.persist.getOptions();
170 await storage!.setItem(name!, {
171 state: {
172 entries: [
173 null,
174 {},
175 { kind: "browser" },
176 { kind: "browser", snapshot: null },
177 ...useClosedViewStore.getState().entries,
178 ] as ClosedViewEntry[],
179 },
180 version: 0,
181 });
182 await useClosedViewStore.persist.rehydrate();
183 expect(useClosedViewStore.persist.hasHydrated()).toBe(true);
184 expect(useClosedViewStore.getState().entries.map((entry) => entry.id)).toEqual([
185 browserId,
186 publicId,
187 ]);
188 const saved = JSON.stringify(await storage!.getItem(name!));
189 expect(saved).not.toContain("private.example");
190 expect(saved).not.toContain("Private page");
191 });
192
193 it("keeps newest first, moves a re-closed tab to the front, and caps at 20", () => {
194 const store = useClosedViewStore.getState();
195 store.remember(diff(refA));
196 store.remember(diff(refB));
197 store.remember(diff(refA));
198 expect(useClosedViewStore.getState().entries).toMatchObject([
199 { threadRef: refA },
200 { threadRef: refB },
201 ]);
202
203 for (let index = 0; index < 24; index++) {
204 store.remember({
205 kind: "panel-tab",
206 threadRef: refA,
207 surface: {
208 kind: "file",
209 id: `file:${index}`,
210 relativePath: `${index}.ts`,
211 revealLine: null,
212 revealRequestId: 0,
213 },
214 });
215 }
216 const entries = useClosedViewStore.getState().entries;
217 expect(entries).toHaveLength(20);
218 expect(entries[0]).toMatchObject({ surface: { id: "file:23" } });
219 });
220});
apps/web/src/closedViewStore.ts created+143
......@@ -0,0 +1,143 @@
1import { scopedThreadKey } from "@t3tools/client-runtime/environment";
2import {
3 ChatFileAttachment,
4 INCOGNITO_BROWSER_PROFILE_ID,
5 PreviewSessionSnapshot,
6 ScopedThreadRef,
7} from "@t3tools/contracts";
8import * as Schema from "effect/Schema";
9import { create } from "zustand";
10import { createJSONStorage, persist } from "zustand/middleware";
11
12import { resolveStorage } from "./lib/storage";
13import { randomUUID } from "./lib/utils";
14import { type RightPanelSurface } from "./rightPanelStore";
15
16export type ClosedView =
17 | {
18 kind: "panel-tab";
19 threadRef: ScopedThreadRef;
20 surface: Exclude<RightPanelSurface, { kind: "terminal" }>;
21 }
22 | { kind: "browser"; threadRef: ScopedThreadRef; snapshot: PreviewSessionSnapshot };
23
24export type ClosedViewEntry = ClosedView & { id: string };
25
26interface ClosedViewStoreState {
27 entries: ClosedViewEntry[];
28 remember: (view: ClosedView) => string;
29 defer: (id: string) => void;
30 remove: (id: string) => void;
31}
32
33const sameTarget = (entry: ClosedViewEntry, view: ClosedView): boolean => {
34 if (
35 entry.kind !== view.kind ||
36 scopedThreadKey(entry.threadRef) !== scopedThreadKey(view.threadRef)
37 ) {
38 return false;
39 }
40 switch (entry.kind) {
41 case "panel-tab":
42 return view.kind === "panel-tab" && entry.surface.id === view.surface.id;
43 case "browser":
44 return view.kind === "browser" && entry.snapshot.tabId === view.snapshot.tabId;
45 }
46};
47
48const isPersistentView = (entry: ClosedViewEntry) =>
49 entry.kind !== "browser" || entry.snapshot.profileId !== INCOGNITO_BROWSER_PROFILE_ID;
50
51const isThreadRef = Schema.is(ScopedThreadRef);
52const isSnapshot = Schema.is(PreviewSessionSnapshot);
53const isAttachment = Schema.is(ChatFileAttachment);
54
55const isClosedViewEntry = (entry: unknown): entry is ClosedViewEntry => {
56 const view = entry as ClosedViewEntry | null;
57 if (!view || typeof view.id !== "string" || !isThreadRef(view.threadRef)) return false;
58 if (view.kind === "browser") return isSnapshot(view.snapshot);
59 if (view.kind !== "panel-tab") return false;
60 const surface = view.surface;
61 if (!surface || typeof surface.id !== "string") return false;
62 switch (surface.kind) {
63 case "diff":
64 case "files":
65 case "pull-requests":
66 return surface.id === surface.kind;
67 case "preview":
68 return surface.resourceId === null || typeof surface.resourceId === "string";
69 case "file":
70 return (
71 typeof surface.relativePath === "string" &&
72 (surface.revealLine === null || Number.isSafeInteger(surface.revealLine)) &&
73 Number.isSafeInteger(surface.revealRequestId) &&
74 (surface.attachment === undefined || isAttachment(surface.attachment))
75 );
76 case "device":
77 return (
78 (surface.title === undefined || typeof surface.title === "string") &&
79 (surface.target === undefined ||
80 (surface.target !== null &&
81 typeof surface.target.hostId === "string" &&
82 typeof surface.target.deviceId === "string" &&
83 typeof surface.target.name === "string" &&
84 (surface.target.platform === "ios" || surface.target.platform === "android")))
85 );
86 case "pull-request":
87 return (
88 typeof surface.projectId === "string" &&
89 typeof surface.repository === "string" &&
90 Number.isSafeInteger(surface.number) &&
91 surface.number > 0 &&
92 (surface.environmentId === undefined || typeof surface.environmentId === "string") &&
93 (surface.host === undefined || typeof surface.host === "string") &&
94 (surface.url === undefined || typeof surface.url === "string")
95 );
96 default:
97 return false;
98 }
99};
100
101export const useClosedViewStore = create<ClosedViewStoreState>()(
102 persist(
103 (set) => ({
104 entries: [],
105 remember: (view) => {
106 const id = randomUUID();
107 set((state) => ({
108 entries: [
109 { ...view, id },
110 ...state.entries.filter((entry) => !sameTarget(entry, view)),
111 ].slice(0, 20),
112 }));
113 return id;
114 },
115 defer: (id) =>
116 set((state) => {
117 const entry = state.entries.find((entry) => entry.id === id);
118 return entry
119 ? { entries: [...state.entries.filter((entry) => entry.id !== id), entry] }
120 : state;
121 }),
122 remove: (id) =>
123 set((state) => ({ entries: state.entries.filter((entry) => entry.id !== id) })),
124 }),
125 {
126 name: "t3code:closed-views:v2",
127 storage: createJSONStorage(() =>
128 resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined),
129 ),
130 version: 2,
131 migrate: (persisted) => {
132 const entries = (persisted as Partial<Pick<ClosedViewStoreState, "entries">> | null)
133 ?.entries;
134 return {
135 entries: Array.isArray(entries)
136 ? entries.filter(isClosedViewEntry).filter(isPersistentView)
137 : [],
138 };
139 },
140 partialize: ({ entries }) => ({ entries: entries.filter(isPersistentView) }),
141 },
142 ),
143);
apps/web/src/components/ChatView.tsx+9-2
......@@ -5947,9 +5947,16 @@ export default function ChatView(props: ChatViewProps) {
59475947 const finishRightPanelSurfaceClose = useCallback(
59485948 (surfaces: readonly RightPanelSurface[]) => {
59495949 if (!activeThreadRef) return;
5950 cleanupRightPanelSurfaces(surfaces);
59515950 const store = useRightPanelStore.getState();
5952 for (const surface of surfaces) {
5951 const activeId = selectThreadRightPanelState(
5952 store.byThreadKey,
5953 activeThreadRef,
5954 ).activeSurfaceId;
5955 const ordered = surfaces.toSorted(
5956 (left, right) => Number(left.id === activeId) - Number(right.id === activeId),
5957 );
5958 for (const surface of ordered) {
5959 cleanupRightPanelSurfaces([surface]);
59535960 store.closeSurface(activeThreadRef, surface.id);
59545961 }
59555962 syncActivePreviewSurface();
apps/web/src/components/ReopenClosedViewShortcut.test.tsx created+325
......@@ -0,0 +1,325 @@
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 useClosedViewStore
313 .getState()
314 .remember({ kind: "panel-tab", threadRef: ref, surface: { kind: "diff", id: "diff" } });
315 await render();
316 expect(state.setShortcuts).toHaveBeenLastCalledWith([
317 expect.objectContaining({ command: "view.reopenClosed" }),
318 ]);
319 await act(() => {
320 menuAction?.("view.reopenClosed");
321 });
322 expect(useClosedViewStore.getState().entries).toEqual([]);
323 expect(state.setShortcuts).toHaveBeenLastCalledWith([]);
324 });
325});
apps/web/src/components/ReopenClosedViewShortcut.tsx created+202
......@@ -0,0 +1,202 @@
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 isGlobalPullRequests = (ref: ScopedThreadRef) =>
47 scopedThreadKey(ref) === scopedThreadKey(PULL_REQUESTS_PANEL_REF);
48
49export function ReopenClosedViewShortcut() {
50 const navigate = useNavigate();
51 const params = useParams({ strict: false });
52 const target = resolveThreadRouteTarget(params);
53 const draft = useComposerDraftStore((state) =>
54 target?.kind === "draft" ? state.getDraftSession(target.draftId) : null,
55 );
56 const threadRef =
57 target?.kind === "server"
58 ? target.threadRef
59 : draft
60 ? (draft.promotedTo ?? scopeThreadRef(draft.environmentId, draft.threadId))
61 : null;
62 const terminalOpen = useTerminalUiStateStore(
63 (state) =>
64 selectThreadTerminalUiState(state.terminalUiStateByThreadKey, threadRef).terminalOpen,
65 );
66 const previewOpen = useRightPanelStore(
67 (state) => selectActiveRightPanel(state.byThreadKey, threadRef) === "preview",
68 );
69 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
70 const hasHistory = useClosedViewStore((state) => state.entries.length > 0);
71 const openPreview = useAtomCommand(previewEnvironment.open);
72 const pending = useRef(Promise.resolve());
73
74 const reopenNext = useEffectEvent(async () => {
75 const catalog = appAtomRegistry.get(environmentCatalog.catalogValueAtom);
76 const drafts = useComposerDraftStore.getState();
77 const panelsByThread = useRightPanelStore.getState().byThreadKey;
78 const { drop, restore } = planNextReopen(useClosedViewStore.getState().entries, (entry) => {
79 const ref = entry.threadRef;
80 const panel = selectThreadRightPanelState(panelsByThread, ref);
81 if (isGlobalPullRequests(ref)) {
82 return {
83 environmentKnown: true,
84 catalogReady: true,
85 ownerExists: true,
86 shellLive: true,
87 panel,
88 };
89 }
90 return {
91 environmentKnown: catalog.entries.has(ref.environmentId),
92 catalogReady: catalog.isReady,
93 ownerExists: readThreadShell(ref) !== null || drafts.getDraftThreadByRef(ref) !== null,
94 shellLive:
95 appAtomRegistry.get(environmentShell.stateValueAtom(ref.environmentId)).status === "live",
96 panel,
97 };
98 });
99 for (const entry of drop) useClosedViewStore.getState().remove(entry.id);
100 if (!restore) return;
101
102 const ref = restore.threadRef;
103 const globalPullRequests = isGlobalPullRequests(ref);
104 const thread = globalPullRequests ? null : readThreadShell(ref);
105 const owner = thread ?? drafts.getDraftThreadByRef(ref);
106 const project = owner ? readProject(scopeProjectRef(ref.environmentId, owner.projectId)) : null;
107 if (!(await reopenClosedView(restore, { openPreview, workspaceAvailable: project !== null }))) {
108 useClosedViewStore.getState().defer(restore.id);
109 return;
110 }
111 useClosedViewStore.getState().remove(restore.id);
112 if (globalPullRequests) {
113 const selected = selectSelectedRightPanelSurface(
114 useRightPanelStore.getState().byThreadKey,
115 ref,
116 );
117 await navigate({
118 to: "/pull-requests",
119 search: (previous) => pullRequestsSearchForRestore(previous, selected),
120 });
121 } else {
122 const draftId = thread === null ? drafts.getDraftIdByRef(ref) : null;
123 if (draftId !== null)
124 await navigate({ to: "/draft/$draftId", params: buildDraftThreadRouteParams(draftId) });
125 else await navigate({ to: "/$environmentId/$threadId", params: buildThreadRouteParams(ref) });
126 }
127 });
128
129 const enqueueReopen = useEffectEvent(() => {
130 pending.current = pending.current
131 .then(() => reopenNext())
132 .catch((error: unknown) => {
133 toastManager.add({
134 type: "error",
135 title: "Could not reopen view",
136 description: error instanceof Error ? error.message : String(error),
137 });
138 });
139 });
140
141 useEffect(() => {
142 const onKeyDown = (event: KeyboardEvent) => {
143 if (
144 event.defaultPrevented ||
145 isCommandPaletteOpen() ||
146 useClosedViewStore.getState().entries.length === 0 ||
147 (event.target instanceof HTMLElement && event.target.closest("[data-keybinding-capture]"))
148 )
149 return;
150 const command = resolveShortcutCommand(event, keybindings, {
151 context: {
152 terminalFocus: isTerminalFocused(),
153 terminalOpen,
154 previewFocus: isPreviewFocused(),
155 previewOpen,
156 editableFocus: isEditableFocused(event.target),
157 modelPickerOpen: isModelPickerOpen(),
158 },
159 });
160 if (command !== "view.reopenClosed") return;
161 event.preventDefault();
162 event.stopPropagation();
163 if (!event.repeat) enqueueReopen();
164 };
165 window.addEventListener("keydown", onKeyDown, true);
166 const unsubscribe = window.desktopBridge?.onMenuAction((action) => {
167 if (action === "view.reopenClosed" && !isCommandPaletteOpen()) enqueueReopen();
168 });
169 return () => {
170 window.removeEventListener("keydown", onKeyDown, true);
171 unsubscribe?.();
172 };
173 }, [keybindings, previewOpen, terminalOpen]);
174
175 useEffect(() => {
176 const preview = window.desktopBridge?.preview;
177 if (!preview?.setForwardedShortcuts) return;
178 void preview
179 .setForwardedShortcuts(
180 hasHistory
181 ? effectiveShortcutsForCommand(keybindings, "view.reopenClosed", {
182 context: {
183 previewFocus: true,
184 previewOpen: true,
185 terminalFocus: false,
186 terminalOpen,
187 editableFocus: false,
188 modelPickerOpen: false,
189 isDesktop: true,
190 isWeb: false,
191 },
192 }).map((shortcut) => ({ command: "view.reopenClosed", shortcut }))
193 : [],
194 )
195 .catch(() => undefined);
196 return () => {
197 void preview.setForwardedShortcuts?.([]).catch(() => undefined);
198 };
199 }, [hasHistory, keybindings, terminalOpen]);
200
201 return null;
202}
apps/web/src/components/preview/closePreviewSession.test.ts+20-2
......@@ -13,6 +13,8 @@ import {
1313 resetPreviewStateForTests,
1414} from "~/previewStateStore";
1515
16import { useClosedViewStore } from "~/closedViewStore";
17
1618import { closePreviewSession } from "./closePreviewSession";
1719
1820const threadRef = {
......@@ -33,7 +35,10 @@ const snapshot: PreviewSessionSnapshot = {
3335 updatedAt: "2026-06-18T19:00:00.000Z",
3436};
3537
36beforeEach(resetPreviewStateForTests);
38beforeEach(() => {
39 resetPreviewStateForTests();
40 useClosedViewStore.setState({ entries: [] });
41});
3742
3843describe("closePreviewSession", () => {
3944 it("suppresses stale server snapshots while the close is in flight", async () => {
......@@ -53,17 +58,29 @@ describe("closePreviewSession", () => {
5358 threadRef,
5459 });
5560
61 expect(useClosedViewStore.getState().entries).toEqual([]);
5662 expect(readThreadPreviewState(threadRef).sessions).toEqual({});
5763 applyPreviewServerSnapshot(threadRef, snapshot);
5864 expect(readThreadPreviewState(threadRef).sessions).toEqual({});
5965
6066 finishClose?.();
6167 await closing;
68 expect(useClosedViewStore.getState().entries).toMatchObject([
69 { kind: "browser", threadRef, snapshot },
70 ]);
6271 expect(closePreview).toHaveBeenCalledWith({ threadId: "thread-1", tabId: "tab-1" });
6372 });
6473
65 it("restores the last snapshot when the server close fails", async () => {
74 it("restores the last snapshot and keeps full history when the server close fails", async () => {
6675 applyPreviewServerSnapshot(threadRef, snapshot);
76 for (let index = 0; index < 20; index++) {
77 useClosedViewStore.getState().remember({
78 kind: "browser",
79 threadRef,
80 snapshot: { ...snapshot, tabId: `closed-${index}` },
81 });
82 }
83 const history = useClosedViewStore.getState().entries;
6784
6885 const result = await closePreviewSession({
6986 closePreview: async () => AsyncResult.failure(Cause.fail(new Error("close failed"))),
......@@ -73,6 +90,7 @@ describe("closePreviewSession", () => {
7390 });
7491
7592 expect(result._tag).toBe("Failure");
93 expect(useClosedViewStore.getState().entries).toEqual(history);
7694 expect(readThreadPreviewState(threadRef).snapshot).toEqual(snapshot);
7795 expect(readThreadPreviewState(threadRef).sessions).toEqual({ [snapshot.tabId]: snapshot });
7896 });
apps/web/src/components/preview/closePreviewSession.ts+7
......@@ -6,6 +6,7 @@ import type {
66 ScopedThreadRef,
77} from "@t3tools/contracts";
88
9import { useClosedViewStore } from "~/closedViewStore";
910import { beginPreviewSessionClose, cancelPreviewSessionClose } from "~/previewStateStore";
1011
1112interface ClosePreviewSessionInput<E> {
......@@ -32,6 +33,12 @@ export async function closePreviewSession<E>(
3233 });
3334 if (result._tag === "Failure") {
3435 cancelPreviewSessionClose(input.threadRef, input.snapshot, input.tabId);
36 } else if (input.snapshot) {
37 useClosedViewStore.getState().remember({
38 kind: "browser",
39 threadRef: input.threadRef,
40 snapshot: input.snapshot,
41 });
3542 }
3643 return result;
3744}
apps/web/src/components/settings/KeybindingsSettings.logic.test.ts+1
......@@ -263,6 +263,7 @@ describe("KeybindingsSettings.logic", () => {
263263 it("formats static and project script command labels", () => {
264264 expect(commandLabel("commandPalette.toggle")).toBe("Command Palette: Toggle");
265265 expect(commandLabel("themeEditor.toggle")).toBe("Theme Editor: Toggle");
266 expect(commandLabel("view.reopenClosed")).toBe("Reopen Closed Tab");
266267 expect(commandLabel("script.setup-db.run")).toBe("Run Script: Setup Db");
267268 });
268269
apps/web/src/components/settings/KeybindingsSettings.logic.ts+1
......@@ -322,6 +322,7 @@ export function commandLabel(command: KeybindingCommand): string {
322322 if (usageMetric) return `Usage: ${usageMetric.label}`;
323323 const usagePeriod = WINDOW_OPTIONS.find((option) => option.command === command);
324324 if (usagePeriod) return `Usage: Period: ${usagePeriod.label}`;
325 if (command === "view.reopenClosed") return "Reopen Closed Tab";
325326 const raw = String(command);
326327 if (raw.startsWith("script.") && raw.endsWith(".run")) {
327328 return `Run Script: ${titleCaseCommandSegment(raw.slice("script.".length, -".run".length))}`;
apps/web/src/keybindings.test.ts+35
......@@ -12,6 +12,7 @@ import {
1212 type ResolvedKeybindingsConfig,
1313} from "@t3tools/contracts";
1414import {
15 effectiveShortcutsForCommand,
1516 formatShortcutLabel,
1617 isDiffToggleShortcut,
1718 isRichTextBoldShortcut,
......@@ -180,6 +181,40 @@ const DEFAULT_BINDINGS = compile([
180181 },
181182]);
182183
184describe("effectiveShortcutsForCommand", () => {
185 it("passes only effective preview shortcuts to the desktop bridge", () => {
186 const reopen = modShortcut("t", { shiftKey: true });
187 const second = modShortcut("y", { shiftKey: true });
188 const keybindings = compile([
189 { shortcut: reopen, command: "view.reopenClosed" },
190 {
191 shortcut: second,
192 command: "view.reopenClosed",
193 whenAst: whenIdentifier("previewFocus"),
194 },
195 {
196 shortcut: reopen,
197 command: "preview.toggle",
198 whenAst: whenIdentifier("previewFocus"),
199 },
200 ]);
201 assert.deepEqual(
202 effectiveShortcutsForCommand(keybindings, "view.reopenClosed", {
203 platform: "MacIntel",
204 context: { previewFocus: true, previewOpen: true },
205 }),
206 [second],
207 );
208 assert.deepEqual(
209 effectiveShortcutsForCommand(keybindings, "view.reopenClosed", {
210 platform: "MacIntel",
211 context: { previewFocus: false },
212 }),
213 [reopen],
214 );
215 });
216});
217
183218describe("isTerminalToggleShortcut", () => {
184219 it("matches Cmd+J on macOS", () => {
185220 assert.isTrue(
apps/web/src/keybindings.ts+26-109
......@@ -8,27 +8,19 @@ import {
88 type ModelPickerJumpKeybindingCommand,
99 type ThreadJumpKeybindingCommand,
1010} from "@t3tools/contracts";
11import {
12 isMacPlatform,
13 matchesKeybindingShortcut,
14 matchesKeybindingShortcutModifiers,
15 normalizeEventKey,
16 resolveEventKeys,
17 type ShortcutEventLike,
18 type ShortcutModifierStateLike,
19} from "@t3tools/shared/keybindings";
1120import { isElectron } from "./env";
12import { isMacPlatform } from "./lib/utils";
13
14export interface ShortcutEventLike {
15 getModifierState?: (key: "AltGraph") => boolean;
16 type?: string;
17 code?: string;
18 key: string;
19 repeat?: boolean;
20 metaKey: boolean;
21 ctrlKey: boolean;
22 shiftKey: boolean;
23 altKey: boolean;
24}
2521
26export interface ShortcutModifierStateLike {
27 metaKey: boolean;
28 ctrlKey: boolean;
29 shiftKey: boolean;
30 altKey: boolean;
31}
22export type { ShortcutEventLike, ShortcutModifierStateLike } from "@t3tools/shared/keybindings";
23export { shortcutKeyFromEvent } from "@t3tools/shared/keybindings";
3224
3325export interface ShortcutMatchContext {
3426 terminalFocus: boolean;
......@@ -57,90 +49,6 @@ const TERMINAL_WORD_FORWARD = "\u001bf";
5749const TERMINAL_LINE_START = "\u0001";
5850const TERMINAL_LINE_END = "\u0005";
5951const TERMINAL_DELETE_TO_LINE_START = "\u0015";
60const EVENT_CODE_SHORTCUT_KEYS: Readonly<Record<string, string>> = {
61 Backquote: "`",
62 Backslash: "\\",
63 BracketLeft: "[",
64 BracketRight: "]",
65 Comma: ",",
66 Digit0: "0",
67 Digit1: "1",
68 Digit2: "2",
69 Digit3: "3",
70 Digit4: "4",
71 Digit5: "5",
72 Digit6: "6",
73 Digit7: "7",
74 Digit8: "8",
75 Digit9: "9",
76 Equal: "=",
77 Minus: "-",
78 Period: ".",
79 Quote: "'",
80 Semicolon: ";",
81 Slash: "/",
82};
83
84function normalizeEventKey(key: string): string {
85 const normalized = key.toLowerCase();
86 if (normalized === "esc") return "escape";
87 return normalized;
88}
89
90export function shortcutKeyFromEvent(event: Pick<ShortcutEventLike, "key" | "code">): string {
91 const layoutKey = normalizeEventKey(event.key);
92 if (/^[a-z]$/.test(layoutKey)) return layoutKey;
93 const physicalKey = event.code ? EVENT_CODE_SHORTCUT_KEYS[event.code] : undefined;
94 return physicalKey ?? layoutKey;
95}
96
97function resolveEventKeys(event: ShortcutEventLike): Set<string> {
98 const layoutKey = normalizeEventKey(event.key);
99 const keys = new Set([layoutKey]);
100 // The physical-position fallback exists for layouts that type non-Latin
101 // letters (Cyrillic, Greek) and for Option-modified symbols on macOS.
102 // When the layout already produces a Latin letter, match on it alone;
103 // otherwise a remapped physical key triggers shortcuts for two different
104 // letters at once and shadows system shortcuts on non-QWERTY layouts.
105 const letterCode = event.code?.match(/^Key([A-Z])$/)?.[1];
106 if (letterCode && !/^[a-z]$/.test(layoutKey)) {
107 keys.add(letterCode.toLowerCase());
108 }
109 keys.add(shortcutKeyFromEvent(event));
110 return keys;
111}
112
113function matchesShortcutModifiers(
114 event: ShortcutModifierStateLike,
115 shortcut: KeybindingShortcut,
116 platform = navigator.platform,
117): boolean {
118 const useMetaForMod = isMacPlatform(platform);
119 const expectedMeta = shortcut.metaKey || (shortcut.modKey && useMetaForMod);
120 const expectedCtrl = shortcut.ctrlKey || (shortcut.modKey && !useMetaForMod);
121 return (
122 event.metaKey === expectedMeta &&
123 event.ctrlKey === expectedCtrl &&
124 event.shiftKey === shortcut.shiftKey &&
125 event.altKey === shortcut.altKey
126 );
127}
128
129function matchesShortcut(
130 event: ShortcutEventLike,
131 shortcut: KeybindingShortcut,
132 platform = navigator.platform,
133): boolean {
134 if (
135 !isMacPlatform(platform) &&
136 event.getModifierState?.("AltGraph") &&
137 !/^[a-z0-9]$/i.test(event.key)
138 )
139 return false;
140 if (!matchesShortcutModifiers(event, shortcut, platform)) return false;
141 return resolveEventKeys(event).has(shortcut.key);
142}
143
14452function resolvePlatform(options: ShortcutMatchOptions | undefined): string {
14553 return options?.platform ?? navigator.platform;
14654}
......@@ -197,14 +105,15 @@ export function shortcutConflictKey(
197105 ].join("|");
198106}
199107
200function findEffectiveShortcutForCommand(
108export function effectiveShortcutsForCommand(
201109 keybindings: ResolvedKeybindingsConfig,
202110 command: KeybindingCommand,
203111 options?: ShortcutMatchOptions,
204): KeybindingShortcut | null {
112): KeybindingShortcut[] {
205113 const platform = resolvePlatform(options);
206114 const context = resolveContext(options);
207115 const claimedShortcuts = new Set<string>();
116 const effective: KeybindingShortcut[] = [];
208117
209118 for (let index = keybindings.length - 1; index >= 0; index -= 1) {
210119 const binding = keybindings[index];
......@@ -218,11 +127,19 @@ function findEffectiveShortcutForCommand(
218127
219128 claimedShortcuts.add(conflictKey);
220129 if (binding.command === command) {
221 return binding.shortcut;
130 effective.push(binding.shortcut);
222131 }
223132 }
224133
225 return null;
134 return effective;
135}
136
137function findEffectiveShortcutForCommand(
138 keybindings: ResolvedKeybindingsConfig,
139 command: KeybindingCommand,
140 options?: ShortcutMatchOptions,
141): KeybindingShortcut | null {
142 return effectiveShortcutsForCommand(keybindings, command, options)[0] ?? null;
226143}
227144
228145function matchesCommandShortcut(
......@@ -246,7 +163,7 @@ export function resolveShortcutCommand(
246163 const binding = keybindings[index];
247164 if (!binding) continue;
248165 if (!matchesWhenClause(binding.whenAst, context)) continue;
249 if (!matchesShortcut(event, binding.shortcut, platform)) continue;
166 if (!matchesKeybindingShortcut(event, binding.shortcut, platform)) continue;
250167 return binding.command;
251168 }
252169 return null;
......@@ -339,7 +256,7 @@ export function shouldShowThreadJumpHintsForModifiers(
339256 for (const command of THREAD_JUMP_KEYBINDING_COMMANDS) {
340257 const shortcut = findEffectiveShortcutForCommand(keybindings, command, options);
341258 if (!shortcut) continue;
342 if (matchesShortcutModifiers(modifiers, shortcut, platform)) {
259 if (matchesKeybindingShortcutModifiers(modifiers, shortcut, platform)) {
343260 return true;
344261 }
345262 }
apps/web/src/lib/utils.ts+2-3
......@@ -1,4 +1,5 @@
11import { MessageId, ProjectId, ThreadId } from "@t3tools/contracts";
2import { isMacPlatform } from "@t3tools/shared/keybindings";
23import { type CxOptions, cx } from "class-variance-authority";
34import * as Hex from "effect/encoding/Hex";
45import { extendTailwindMerge } from "tailwind-merge";
......@@ -12,9 +13,7 @@ export function cn(...inputs: CxOptions) {
1213 return twMerge(cx(inputs));
1314}
1415
15export function isMacPlatform(platform: string): boolean {
16 return /mac|iphone|ipad|ipod/i.test(platform);
17}
16export { isMacPlatform };
1817
1918export function isWindowsPlatform(platform: string): boolean {
2019 return /^win(dows)?/i.test(platform);
apps/web/src/reopenClosedView.test.ts created+234
......@@ -0,0 +1,234 @@
1import {
2 DEFAULT_CLIENT_SETTINGS,
3 type PreviewSessionSnapshot,
4 type ScopedThreadRef,
5} from "@t3tools/contracts";
6import * as Cause from "effect/Cause";
7import { AsyncResult } from "effect/reactivity";
8import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
9
10import { type ClosedViewEntry, useClosedViewStore } from "./closedViewStore";
11import { __setClientSettingsForTests } from "./hooks/useSettings";
12import { readThreadPreviewState, resetPreviewStateForTests } from "./previewStateStore";
13import {
14 planNextReopen,
15 pullRequestsSearchForRestore,
16 type ReopenOwnerState,
17 reopenClosedView,
18} from "./reopenClosedView";
19import {
20 selectThreadRightPanelState,
21 type ThreadRightPanelState,
22 useRightPanelStore,
23} from "./rightPanelStore";
24
25const threadRef = {
26 environmentId: "local" as ScopedThreadRef["environmentId"],
27 threadId: "thread-1" as ScopedThreadRef["threadId"],
28};
29const snapshot: PreviewSessionSnapshot = {
30 threadId: threadRef.threadId,
31 tabId: "old-tab",
32 navStatus: { _tag: "Success", url: "https://example.com", title: "Example" },
33 canGoBack: false,
34 canGoForward: false,
35 viewport: { _tag: "freeform", width: 1200, height: 800 },
36 profileId: "work",
37 updatedAt: "2026-09-22T12:00:00.000Z",
38};
39
40beforeEach(() => {
41 __setClientSettingsForTests(DEFAULT_CLIENT_SETTINGS);
42 resetPreviewStateForTests();
43 useClosedViewStore.setState({ entries: [] });
44 useRightPanelStore.setState({ byThreadKey: {}, userActionRevisionByThreadKey: {} });
45});
46
47describe("reopenClosedView", () => {
48 it("restores a file tab at its line without creating a browser session", async () => {
49 const openPreview = vi.fn();
50 const reopened = await reopenClosedView(
51 {
52 kind: "panel-tab",
53 threadRef,
54 surface: {
55 kind: "file",
56 id: "file:src/app.ts",
57 relativePath: "src/app.ts",
58 revealLine: 18,
59 revealRequestId: 1,
60 },
61 },
62 { openPreview, workspaceAvailable: true },
63 );
64 expect(reopened).toBe(true);
65 const state = selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, threadRef);
66 expect(state.isOpen).toBe(true);
67 expect(state.surfaces).toMatchObject([{ id: "file:src/app.ts", revealLine: 18 }]);
68 expect(openPreview).not.toHaveBeenCalled();
69 });
70
71 it("does not reopen workspace tabs without an available project", async () => {
72 const options = {
73 openPreview: vi.fn(),
74 workspaceAvailable: false,
75 };
76 for (const surface of [
77 { kind: "files", id: "files" },
78 {
79 kind: "file",
80 id: "file:src/app.ts",
81 relativePath: "src/app.ts",
82 revealLine: null,
83 revealRequestId: 0,
84 },
85 ] as const) {
86 expect(await reopenClosedView({ kind: "panel-tab", threadRef, surface }, options)).toBe(
87 false,
88 );
89 }
90 expect(
91 selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, threadRef).surfaces,
92 ).toEqual([]);
93 });
94
95 it("recreates a browser tab with saved URL, viewport and profile, then selects its new ID", async () => {
96 const openPreview = vi.fn(async () => AsyncResult.success({ ...snapshot, tabId: "new-tab" }));
97 const result = await reopenClosedView(
98 { kind: "browser", threadRef, snapshot },
99 { openPreview, workspaceAvailable: false },
100 );
101 expect(result).toBe(true);
102 expect(openPreview).toHaveBeenCalledWith({
103 environmentId: threadRef.environmentId,
104 input: {
105 threadId: threadRef.threadId,
106 url: "https://example.com",
107 viewport: snapshot.viewport,
108 profileId: "work",
109 },
110 });
111 expect(readThreadPreviewState(threadRef).snapshot?.tabId).toBe("new-tab");
112 expect(
113 selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, threadRef)
114 .activeSurfaceId,
115 ).toBe("browser:new-tab");
116 });
117
118 it("leaves the panel unchanged when a browser resource fails to reopen", async () => {
119 const result = await reopenClosedView(
120 { kind: "browser", threadRef, snapshot },
121 {
122 openPreview: async () => AsyncResult.failure(Cause.fail(new Error("offline"))),
123 workspaceAvailable: false,
124 },
125 );
126 expect(result).toBe(false);
127 expect(
128 selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, threadRef).surfaces,
129 ).toEqual([]);
130 });
131});
132
133describe("planNextReopen", () => {
134 const entries: ClosedViewEntry[] = [
135 { id: "a", kind: "panel-tab", threadRef, surface: { kind: "diff", id: "diff" } },
136 { id: "b", kind: "panel-tab", threadRef, surface: { kind: "files", id: "files" } },
137 ];
138 const known: ReopenOwnerState = {
139 environmentKnown: true,
140 catalogReady: true,
141 ownerExists: true,
142 shellLive: true,
143 panel: { isOpen: false, activeSurfaceId: null, surfaces: [] },
144 };
145 const openDiff: ThreadRightPanelState = {
146 isOpen: true,
147 activeSurfaceId: "diff",
148 surfaces: [{ kind: "diff", id: "diff" }],
149 };
150
151 it.each([
152 ["restores the newest entry", [known, known], [], "a"],
153 [
154 "drops a removed environment once the catalog is ready",
155 [{ ...known, environmentKnown: false }, known],
156 ["a"],
157 "b",
158 ],
159 [
160 "waits for the catalog before dropping",
161 [{ ...known, environmentKnown: false, catalogReady: false }, known],
162 [],
163 null,
164 ],
165 [
166 "skips but keeps a thread deleted from a live shell",
167 [{ ...known, ownerExists: false }, known],
168 [],
169 "b",
170 ],
171 [
172 "waits when a cached shell lacks the thread",
173 [{ ...known, ownerExists: false, shellLive: false }, known],
174 [],
175 null,
176 ],
177 ["drops a tab that is already open", [{ ...known, panel: openDiff }, known], ["a"], "b"],
178 [
179 "restores a tab whose panel is hidden",
180 [{ ...known, panel: { ...openDiff, isOpen: false } }],
181 [],
182 "a",
183 ],
184 ] as const)("%s", (_name, owners, dropped, restored) => {
185 const plan = planNextReopen(
186 entries.slice(0, owners.length),
187 (e) => owners[entries.indexOf(e)]!,
188 );
189 expect(plan.drop.map((e) => e.id)).toEqual(dropped);
190 expect(plan.restore?.id ?? null).toBe(restored);
191 });
192});
193
194describe("pullRequestsSearchForRestore", () => {
195 it("replaces the selection and keeps list filters", () => {
196 const search = pullRequestsSearchForRestore(
197 {
198 involvement: "reviewing",
199 state: "open",
200 q: "mine",
201 host: "filter.example.com",
202 selectedHost: "old",
203 number: 1,
204 },
205 {
206 kind: "pull-request",
207 id: "pull-request:example",
208 repository: "owner/repo",
209 number: 42,
210 projectId: "project-1",
211 environmentId: "remote",
212 host: "github.example.com",
213 },
214 );
215 expect(search).toEqual({
216 involvement: "reviewing",
217 state: "open",
218 q: "mine",
219 host: "filter.example.com",
220 repository: "owner/repo",
221 number: 42,
222 selectedProjectId: "project-1",
223 selectedHost: "github.example.com",
224 selectedEnvironmentId: "remote",
225 });
226 });
227
228 it("clears a stale selection and fills default filters when nothing is selected", () => {
229 expect(pullRequestsSearchForRestore({ repository: "old/repo", number: 7 }, null)).toEqual({
230 involvement: "all",
231 state: "open",
232 });
233 });
234});
apps/web/src/reopenClosedView.ts created+150
......@@ -0,0 +1,150 @@
1import type { EnvironmentId, ProjectId } from "@t3tools/contracts";
2
3import type { OpenPreviewMutation } from "./browser/openFileInPreview";
4import type { ClosedView, ClosedViewEntry } from "./closedViewStore";
5import type { PullRequestListPreferences } from "./components/pullRequest/pullRequestListPreferences";
6import { openPreviewSession } from "./components/preview/openPreviewSession";
7import {
8 type RightPanelSurface,
9 type ThreadRightPanelState,
10 useRightPanelStore,
11} from "./rightPanelStore";
12
13export interface ReopenOwnerState {
14 /** False when the entry's environment is not in the catalog. */
15 environmentKnown: boolean;
16 catalogReady: boolean;
17 /** Whether the owning thread or draft still exists locally. */
18 ownerExists: boolean;
19 /** A live shell that lacks the thread means it was deleted; a cached one may just be stale. */
20 shellLive: boolean;
21 panel: ThreadRightPanelState;
22}
23
24/**
25 * Picks the history entry the next reopen press restores, newest first. Entries whose
26 * environment is gone or whose tab is already open are dropped. A thread missing from a
27 * live shell is skipped but kept; anything not yet knowable stops the scan with no restore.
28 */
29export function planNextReopen(
30 entries: readonly ClosedViewEntry[],
31 ownerState: (entry: ClosedViewEntry) => ReopenOwnerState,
32): { drop: ClosedViewEntry[]; restore: ClosedViewEntry | null } {
33 const drop: ClosedViewEntry[] = [];
34 for (const entry of entries) {
35 const owner = ownerState(entry);
36 if (!owner.environmentKnown) {
37 if (!owner.catalogReady) break;
38 drop.push(entry);
39 continue;
40 }
41 if (!owner.ownerExists) {
42 if (owner.shellLive) continue;
43 break;
44 }
45 const alreadyOpen =
46 entry.kind === "panel-tab"
47 ? owner.panel.isOpen && owner.panel.surfaces.some((s) => s.id === entry.surface.id)
48 : owner.panel.surfaces.some(
49 (s) => s.kind === "preview" && s.resourceId === entry.snapshot.tabId,
50 );
51 if (alreadyOpen) {
52 drop.push(entry);
53 continue;
54 }
55 return { drop, restore: entry };
56 }
57 return { drop, restore: null };
58}
59
60type PullRequestsSearchLike = Partial<PullRequestListPreferences> & {
61 repository?: string;
62 number?: number;
63 selectedProjectId?: ProjectId;
64 selectedHost?: string;
65 selectedEnvironmentId?: EnvironmentId;
66};
67
68/** Selects the restored pull request on the Pull Requests page, keeping the list filters. */
69export function pullRequestsSearchForRestore<S extends PullRequestsSearchLike>(
70 previous: S,
71 selected: RightPanelSurface | null,
72): S & PullRequestListPreferences {
73 const {
74 repository: _repository,
75 number: _number,
76 selectedProjectId: _projectId,
77 selectedHost: _host,
78 selectedEnvironmentId: _environmentId,
79 ...filters
80 } = previous;
81 return {
82 ...filters,
83 involvement: previous.involvement ?? "all",
84 state: previous.state ?? "open",
85 ...(selected?.kind === "pull-request"
86 ? {
87 repository: selected.repository,
88 number: selected.number,
89 selectedProjectId: selected.projectId as ProjectId,
90 ...(selected.host === undefined ? {} : { selectedHost: selected.host }),
91 ...(selected.environmentId === undefined
92 ? {}
93 : { selectedEnvironmentId: selected.environmentId as EnvironmentId }),
94 }
95 : {}),
96 } as S & PullRequestListPreferences;
97}
98
99export async function reopenClosedView(
100 view: ClosedView,
101 options: {
102 openPreview: OpenPreviewMutation;
103 workspaceAvailable: boolean;
104 },
105): Promise<boolean> {
106 const panels = useRightPanelStore.getState();
107 const ref = view.threadRef;
108
109 if (view.kind === "browser") {
110 const url = view.snapshot.navStatus._tag === "Idle" ? undefined : view.snapshot.navStatus.url;
111 const result = await openPreviewSession({
112 openPreview: options.openPreview,
113 threadRef: ref,
114 ...(url === undefined ? {} : { url }),
115 ...(view.snapshot.viewport === undefined ? {} : { viewport: view.snapshot.viewport }),
116 ...(view.snapshot.profileId === undefined ? {} : { profileId: view.snapshot.profileId }),
117 });
118 if (result._tag === "Failure") return false;
119 panels.openBrowser(ref, result.value.tabId);
120 return true;
121 }
122 const surface = view.surface;
123 if (
124 !options.workspaceAvailable &&
125 (surface.kind === "files" || (surface.kind === "file" && !surface.attachment))
126 )
127 return false;
128 switch (surface.kind) {
129 case "preview":
130 if (surface.resourceId !== null) return false;
131 panels.openBrowser(ref, null);
132 break;
133 case "file":
134 if (surface.attachment) panels.openAttachment(ref, surface.attachment);
135 else panels.openFile(ref, surface.relativePath, surface.revealLine ?? undefined);
136 break;
137 case "device":
138 if (surface.target) {
139 panels.openDevice(ref, surface.target);
140 if (surface.title) panels.renameDevice(ref, surface.id, surface.title);
141 } else panels.open(ref, "device");
142 break;
143 case "pull-request":
144 panels.openPullRequest(ref, surface);
145 break;
146 default:
147 panels.open(ref, surface.kind);
148 }
149 return true;
150}
apps/web/src/rightPanelStore.test.ts+100
......@@ -2,6 +2,7 @@ import { scopeThreadRef } from "@t3tools/client-runtime/environment";
22import { type EnvironmentId, ThreadId } from "@t3tools/contracts";
33import { beforeEach, describe, expect, it } from "vite-plus/test";
44
5import { useClosedViewStore } from "./closedViewStore";
56import {
67 migratePersistedRightPanelState,
78 pullRequestSurface,
......@@ -19,14 +20,113 @@ const refA = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-A"))
1920const refB = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-B"));
2021
2122beforeEach(() => {
23 useClosedViewStore.setState({ entries: [] });
2224 useRightPanelStore.setState({
2325 byThreadKey: {},
2426 threadPanelVisibilityByThreadKey: {},
2527 userActionRevisionByThreadKey: {},
28 closeRevisionByThreadKey: {},
2629 });
2730});
2831
2932describe("rightPanelStore", () => {
33 it("records single and bulk tab closes, newest first", () => {
34 const store = useRightPanelStore.getState();
35 const pr = pullRequestSurface({
36 projectId: "project-a",
37 repository: "pingdotgg/t3code",
38 number: 42,
39 });
40 store.openFile(refA, "src/app.ts");
41 store.open(refA, "device");
42 store.openPullRequest(refA, pr);
43 store.open(refA, "diff");
44 store.closeSurface(refA, pr.id);
45 store.closeSurfacesToRight(refA, "device");
46 store.closeOtherSurfaces(refA, "file:src/app.ts");
47 expect(
48 useClosedViewStore
49 .getState()
50 .entries.map((entry) => (entry.kind === "panel-tab" ? entry.surface.id : null)),
51 ).toEqual(["device", "diff", pr.id]);
52 });
53
54 it("reopens the active tab first after a bulk close", () => {
55 const store = useRightPanelStore.getState();
56 store.open(refA, "files");
57 store.open(refA, "diff");
58 store.open(refA, "device");
59 store.activateSurface(refA, "diff");
60 store.closeAllSurfaces(refA);
61
62 expect(
63 useClosedViewStore
64 .getState()
65 .entries.map((entry) => entry.kind === "panel-tab" && entry.surface.id),
66 ).toEqual(["diff", "device", "files"]);
67 });
68
69 it("does not save an incidental Files replacement when opening an existing file", () => {
70 const store = useRightPanelStore.getState();
71 store.openFile(refA, "src/app.ts");
72 store.open(refA, "files");
73 store.openFile(refA, "src/app.ts");
74
75 expect(useClosedViewStore.getState().entries).toEqual([]);
76 });
77
78 it("ignores session tabs without browser snapshots and records the empty browser tab", () => {
79 const store = useRightPanelStore.getState();
80 store.openBrowser(refA, "tab-1");
81 store.closeSurface(refA, "browser:tab-1");
82 expect(useClosedViewStore.getState().entries).toEqual([]);
83 store.openBrowser(refA, null);
84 store.closeSurface(refA, "browser:new");
85 expect(useClosedViewStore.getState().entries).toMatchObject([
86 { kind: "panel-tab", surface: { id: "browser:new", resourceId: null } },
87 ]);
88 });
89
90 it("keeps a dismissed device hidden after another file opens", () => {
91 const store = useRightPanelStore.getState();
92 const device = {
93 hostId: "nucbox",
94 deviceId: "emulator-5580",
95 name: "Pixel",
96 platform: "android",
97 } as const;
98 store.openFile(refA, "src/app.ts");
99 store.closeSurface(refA, "file:src/app.ts");
100 store.openDevice(refA, device);
101 store.closeSurface(refA, "device:nucbox:emulator-5580");
102
103 store.openFile(refA, "src/app.ts");
104 store.openDevice(refA, device, true);
105
106 expect(
107 selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).surfaces,
108 ).toEqual([expect.objectContaining({ id: "file:src/app.ts" })]);
109 });
110
111 it("records only closed tabs when a panel is hidden or a terminal tab closes", () => {
112 const store = useRightPanelStore.getState();
113 store.open(refA, "diff");
114 store.openFile(refA, "src/app.ts");
115 store.closeSurface(refA, "file:src/app.ts");
116 store.close(refA);
117 expect(useClosedViewStore.getState().entries).toMatchObject([
118 { kind: "panel-tab", threadRef: refA, surface: { id: "file:src/app.ts" } },
119 ]);
120 store.toggleVisibility(refA);
121 store.toggle(refA, "diff");
122 store.openTerminal(refA, "term-1");
123 store.closeSurface(refA, "terminal:term-1");
124 expect(useClosedViewStore.getState().entries).toMatchObject([
125 { kind: "panel-tab", threadRef: refA, surface: { id: "file:src/app.ts" } },
126 ]);
127 expect(useClosedViewStore.getState().entries).toHaveLength(1);
128 });
129
30130 it("gives each host/device its own tab and preserves renamed tabs", () => {
31131 const store = useRightPanelStore.getState();
32132 const android = {
apps/web/src/rightPanelStore.ts+66-11
......@@ -7,11 +7,16 @@
77 * terminal surfaces point at terminal session ids, file surfaces point at
88 * workspace paths, and diff/files remain singleton surfaces.
99 */
10import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment";
10import {
11 parseScopedThreadKey,
12 scopedThreadKey,
13 scopeThreadRef,
14} from "@t3tools/client-runtime/environment";
1115import { EnvironmentId, ThreadId, type ScopedThreadRef } from "@t3tools/contracts";
1216import { create } from "zustand";
1317import { createJSONStorage, persist } from "zustand/middleware";
1418
19import { useClosedViewStore } from "./closedViewStore";
1520import { resolveStorage } from "./lib/storage";
1621import type { ThreadPanelPresentation } from "./rightPanelLayout";
1722import type { ChatFileAttachment } from "./types";
......@@ -119,6 +124,7 @@ interface RightPanelStoreState {
119124 threadPanelVisibilityByThreadKey: Record<string, ThreadPanelVisibility>;
120125 /** Session-only count of user panel choices per thread. Automatic updates do not advance it. */
121126 userActionRevisionByThreadKey: Record<string, number>;
127 closeRevisionByThreadKey: Record<string, number>;
122128 getUserActionRevision: (ref: ScopedThreadRef) => number;
123129 /**
124130 * Open a surface on behalf of the app, not the user. Refused when the user
......@@ -387,6 +393,7 @@ const userAction = (
387393 state: RightPanelStoreState,
388394 threadKey: string,
389395 updater: (current: ThreadRightPanelState) => ThreadRightPanelState,
396 closesView = false,
390397): Partial<RightPanelStoreState> => ({
391398 ...updateThread(state, threadKey, (current) => {
392399 const next = updater(current);
......@@ -410,8 +417,22 @@ const userAction = (
410417 ...state.userActionRevisionByThreadKey,
411418 [threadKey]: (state.userActionRevisionByThreadKey[threadKey] ?? 0) + 1,
412419 },
420 ...(closesView
421 ? {
422 closeRevisionByThreadKey: {
423 ...state.closeRevisionByThreadKey,
424 [threadKey]: (state.closeRevisionByThreadKey[threadKey] ?? 0) + 1,
425 },
426 }
427 : {}),
413428});
414429
430const closeAction = (
431 state: RightPanelStoreState,
432 threadKey: string,
433 updater: (current: ThreadRightPanelState) => ThreadRightPanelState,
434): Partial<RightPanelStoreState> => userAction(state, threadKey, updater, true);
435
415436function normalizeRevealLine(line: number | undefined): number | null {
416437 if (line === undefined || !Number.isFinite(line)) return null;
417438 return Math.max(1, Math.trunc(line));
......@@ -571,6 +592,7 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
571592 byThreadKey: {},
572593 threadPanelVisibilityByThreadKey: {},
573594 userActionRevisionByThreadKey: {},
595 closeRevisionByThreadKey: {},
574596 getUserActionRevision: (ref) =>
575597 get().userActionRevisionByThreadKey[scopedThreadKey(ref)] ?? 0,
576598 openProactive: (ref, surface, expectedUserActionRevision) => {
......@@ -754,7 +776,7 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
754776 ),
755777 closeTerminal: (ref, surfaceId, terminalId) =>
756778 set((state) =>
757 userAction(state, scopedThreadKey(ref), (current) => {
779 closeAction(state, scopedThreadKey(ref), (current) => {
758780 const surface = current.surfaces.find(
759781 (entry) => entry.id === surfaceId && entry.kind === "terminal",
760782 );
......@@ -801,25 +823,28 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
801823 ),
802824 closeSurface: (ref, surfaceId) =>
803825 set((state) =>
804 userAction(state, scopedThreadKey(ref), (current) => {
826 closeAction(state, scopedThreadKey(ref), (current) => {
805827 const index = current.surfaces.findIndex((surface) => surface.id === surfaceId);
806828 if (index < 0) return current;
807829 const surfaces = current.surfaces.filter((surface) => surface.id !== surfaceId);
830 const next = {
831 ...current,
832 isOpen: surfaces.length > 0 && current.isOpen,
833 surfaces,
834 };
808835 if (current.activeSurfaceId !== surfaceId) {
809 return { ...current, isOpen: surfaces.length > 0 && current.isOpen, surfaces };
836 return next;
810837 }
811838 const fallback = surfaces[Math.min(index, surfaces.length - 1)] ?? null;
812839 return {
813 ...current,
814 isOpen: surfaces.length > 0 && current.isOpen,
815 surfaces,
840 ...next,
816841 activeSurfaceId: fallback?.id ?? null,
817842 };
818843 }),
819844 ),
820845 closeOtherSurfaces: (ref, surfaceId) =>
821846 set((state) =>
822 userAction(state, scopedThreadKey(ref), (current) => {
847 closeAction(state, scopedThreadKey(ref), (current) => {
823848 const surface = current.surfaces.find((entry) => entry.id === surfaceId);
824849 if (!surface || current.surfaces.length === 1) return current;
825850 return {
......@@ -832,7 +857,7 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
832857 ),
833858 closeSurfacesToRight: (ref, surfaceId) =>
834859 set((state) =>
835 userAction(state, scopedThreadKey(ref), (current) => {
860 closeAction(state, scopedThreadKey(ref), (current) => {
836861 const index = current.surfaces.findIndex((surface) => surface.id === surfaceId);
837862 if (index < 0 || index === current.surfaces.length - 1) return current;
838863 const surfaces = current.surfaces.slice(0, index + 1);
......@@ -848,7 +873,7 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
848873 ),
849874 closeAllSurfaces: (ref) =>
850875 set((state) =>
851 userAction(state, scopedThreadKey(ref), (current) =>
876 closeAction(state, scopedThreadKey(ref), (current) =>
852877 current.surfaces.length === 0
853878 ? current
854879 : { ...current, isOpen: false, surfaces: [], activeSurfaceId: null },
......@@ -969,7 +994,8 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
969994 if (
970995 !(threadKey in state.byThreadKey) &&
971996 !(threadKey in state.threadPanelVisibilityByThreadKey) &&
972 !(threadKey in state.userActionRevisionByThreadKey)
997 !(threadKey in state.userActionRevisionByThreadKey) &&
998 !(threadKey in state.closeRevisionByThreadKey)
973999 ) {
9741000 return state;
9751001 }
......@@ -978,10 +1004,13 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
9781004 state.threadPanelVisibilityByThreadKey;
9791005 const { [threadKey]: _revision, ...userActionRevisionByThreadKey } =
9801006 state.userActionRevisionByThreadKey;
1007 const { [threadKey]: _closeRevision, ...closeRevisionByThreadKey } =
1008 state.closeRevisionByThreadKey;
9811009 return {
9821010 byThreadKey,
9831011 threadPanelVisibilityByThreadKey,
9841012 userActionRevisionByThreadKey,
1013 closeRevisionByThreadKey,
9851014 };
9861015 }),
9871016 }),
......@@ -1009,6 +1038,32 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
10091038 ),
10101039);
10111040
1041useRightPanelStore.subscribe((next, previous) => {
1042 if (next.closeRevisionByThreadKey === previous.closeRevisionByThreadKey) return;
1043 for (const [threadKey, revision] of Object.entries(next.closeRevisionByThreadKey)) {
1044 if (revision === previous.closeRevisionByThreadKey[threadKey]) continue;
1045 const threadRef = parseScopedThreadKey(threadKey);
1046 if (!threadRef) continue;
1047 const before = previous.byThreadKey[threadKey] ?? EMPTY_THREAD_STATE;
1048 const after = next.byThreadKey[threadKey] ?? EMPTY_THREAD_STATE;
1049 const removed = before.surfaces.filter(
1050 (surface) => !after.surfaces.some((entry) => entry.id === surface.id),
1051 );
1052 const closedInOrder = [
1053 ...removed.filter((surface) => surface.id !== before.activeSurfaceId),
1054 ...removed.filter((surface) => surface.id === before.activeSurfaceId),
1055 ];
1056 for (const surface of closedInOrder) {
1057 if (
1058 surface.kind === "terminal" ||
1059 (surface.kind === "preview" && surface.resourceId !== null)
1060 )
1061 continue;
1062 useClosedViewStore.getState().remember({ kind: "panel-tab", threadRef, surface });
1063 }
1064 }
1065});
1066
10121067export function selectThreadRightPanelState(
10131068 byThreadKey: Record<string, ThreadRightPanelState>,
10141069 ref: ScopedThreadRef | null | undefined,
apps/web/src/routes/__root.tsx+2
......@@ -31,6 +31,7 @@ import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLa
3131import { NightlyMobileBetaNotice } from "../components/NightlyMobileBeta";
3232import { LegacyThreadMigrationToast } from "../components/LegacyThreadMigrationToast";
3333import { ThreadNotificationCoordinator } from "../components/ThreadNotificationCoordinator";
34import { ReopenClosedViewShortcut } from "../components/ReopenClosedViewShortcut";
3435import { ProjectCloneToastCoordinator } from "../components/ProjectCloneToastCoordinator";
3536import { SlowRpcRequestToastCoordinator } from "../components/SlowRpcRequestToastCoordinator";
3637import { ChatGptWelcomeCoordinator } from "../components/settings/ChatGptWelcomeCoordinator";
......@@ -235,6 +236,7 @@ function RootRouteView() {
235236 <SshPasswordPromptDialog />
236237 <SnapShotCoordinator />
237238 <ThreadNotificationCoordinator />
239 <ReopenClosedViewShortcut />
238240 <ConfirmDialogHost />
239241 <CustomSnoozeDialogHost />
240242 <SlowRpcRequestToastCoordinator />
docs/user/keybindings.md+7
......@@ -145,6 +145,13 @@ tab. When nothing remains to close, it closes the window. In a browser, `mod+w`
145145closes the browser tab; rebind `rightPanel.close` and `terminal.close` to an available
146146shortcut such as `alt+w`.
147147
148`mod+shift+t` reopens the last closed tab across the app, including files,
149diffs, pull requests, browsers, and devices, in the order you closed them.
150A browser opens in a fresh session without its old page history. Incognito tabs
151can reopen until you reload or quit the app. This shortcut does not undo deleted work.
152Browsers also use it to reopen browser tabs; choose another binding in Settings
153if the browser takes it first.
154
148155Many defaults include `!terminalFocus` so they do not intercept terminal input.
149156Keep that condition when remapping them if you want the same behavior.
150157
packages/contracts/src/ipc.ts+2
......@@ -13,6 +13,7 @@ import { AdvertisedEndpoint } from "./remoteAccess.ts";
1313import { ExecutionEnvironmentDescriptor } from "./environment.ts";
1414import { type ClientSettings, type QuitConfirmationMode, SnapShotShortcut } from "./settings.ts";
1515import type { EditorId } from "./editor.ts";
16import type { PreviewForwardedShortcut } from "./keybindings.ts";
1617
1718import type {
1819 DesktopAppActivationRequest,
......@@ -1226,6 +1227,7 @@ export interface DesktopBridge {
12261227export const DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER = "__t3DesktopPreviewRecordingCapture";
12271228
12281229export interface DesktopPreviewBridge {
1230 setForwardedShortcuts?: (shortcuts: ReadonlyArray<PreviewForwardedShortcut>) => Promise<void>;
12291231 createTab: (tabId: string, defaults?: DesktopPreviewTabDefaults) => Promise<void>;
12301232 closeTab: (tabId: string) => Promise<void>;
12311233 registerWebview: (tabId: string, webContentsId: number) => Promise<void>;
packages/contracts/src/keybindings.ts+7
......@@ -69,6 +69,7 @@ export const STATIC_KEYBINDING_COMMANDS = [
6969 "threadPanel.toggle",
7070 "rightPanel.toggleMaximized",
7171 "rightPanel.close",
72 "view.reopenClosed",
7273 "pullRequest.copyNumber",
7374 "diff.toggle",
7475 "preview.toggle",
......@@ -156,6 +157,12 @@ export const KeybindingShortcut = Schema.Struct({
156157});
157158export type KeybindingShortcut = typeof KeybindingShortcut.Type;
158159
160export const PreviewForwardedShortcut = Schema.Struct({
161 command: KeybindingCommand,
162 shortcut: KeybindingShortcut,
163});
164export type PreviewForwardedShortcut = typeof PreviewForwardedShortcut.Type;
165
159166const KeybindingWhenNodeRef = Schema.suspend(
160167 (): Schema.Codec<KeybindingWhenNode> => KeybindingWhenNode,
161168);
packages/shared/src/keybindings.ts+103
......@@ -10,6 +10,108 @@ import {
1010 THREAD_JUMP_KEYBINDING_COMMANDS,
1111} from "@t3tools/contracts";
1212
13export interface ShortcutEventLike {
14 getModifierState?: (key: "AltGraph") => boolean;
15 type?: string;
16 code?: string;
17 key: string;
18 repeat?: boolean;
19 metaKey: boolean;
20 ctrlKey: boolean;
21 shiftKey: boolean;
22 altKey: boolean;
23}
24
25export interface ShortcutModifierStateLike {
26 metaKey: boolean;
27 ctrlKey: boolean;
28 shiftKey: boolean;
29 altKey: boolean;
30}
31
32const EVENT_CODE_SHORTCUT_KEYS: Readonly<Record<string, string>> = {
33 Backquote: "`",
34 Backslash: "\\",
35 BracketLeft: "[",
36 BracketRight: "]",
37 Comma: ",",
38 Digit0: "0",
39 Digit1: "1",
40 Digit2: "2",
41 Digit3: "3",
42 Digit4: "4",
43 Digit5: "5",
44 Digit6: "6",
45 Digit7: "7",
46 Digit8: "8",
47 Digit9: "9",
48 Equal: "=",
49 Minus: "-",
50 Period: ".",
51 Quote: "'",
52 Semicolon: ";",
53 Slash: "/",
54};
55
56export function isMacPlatform(platform: string): boolean {
57 return /mac|iphone|ipad|ipod/i.test(platform);
58}
59
60export function normalizeEventKey(key: string): string {
61 const normalized = key.toLowerCase();
62 if (normalized === "esc") return "escape";
63 return normalized;
64}
65
66export function shortcutKeyFromEvent(event: Pick<ShortcutEventLike, "key" | "code">): string {
67 const layoutKey = normalizeEventKey(event.key);
68 if (/^[a-z]$/.test(layoutKey)) return layoutKey;
69 const physicalKey = event.code ? EVENT_CODE_SHORTCUT_KEYS[event.code] : undefined;
70 return physicalKey ?? layoutKey;
71}
72
73export function resolveEventKeys(event: ShortcutEventLike): Set<string> {
74 const layoutKey = normalizeEventKey(event.key);
75 const keys = new Set([layoutKey]);
76 const letterCode = event.code?.match(/^Key([A-Z])$/)?.[1];
77 if (letterCode && !/^[a-z]$/.test(layoutKey)) {
78 keys.add(letterCode.toLowerCase());
79 }
80 keys.add(shortcutKeyFromEvent(event));
81 return keys;
82}
83
84export function matchesKeybindingShortcutModifiers(
85 event: ShortcutModifierStateLike,
86 shortcut: KeybindingShortcut,
87 platform: string,
88): boolean {
89 const useMetaForMod = isMacPlatform(platform);
90 const expectedMeta = shortcut.metaKey || (shortcut.modKey && useMetaForMod);
91 const expectedCtrl = shortcut.ctrlKey || (shortcut.modKey && !useMetaForMod);
92 return (
93 event.metaKey === expectedMeta &&
94 event.ctrlKey === expectedCtrl &&
95 event.shiftKey === shortcut.shiftKey &&
96 event.altKey === shortcut.altKey
97 );
98}
99
100export function matchesKeybindingShortcut(
101 event: ShortcutEventLike,
102 shortcut: KeybindingShortcut,
103 platform: string,
104): boolean {
105 if (
106 !isMacPlatform(platform) &&
107 event.getModifierState?.("AltGraph") &&
108 !/^[a-z0-9]$/i.test(event.key)
109 )
110 return false;
111 if (!matchesKeybindingShortcutModifiers(event, shortcut, platform)) return false;
112 return resolveEventKeys(event).has(shortcut.key);
113}
114
13115type WhenToken =
14116 | { type: "identifier"; value: string }
15117 | { type: "not" }
......@@ -29,6 +131,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [
29131 { key: "mod+n", command: "terminal.new", when: "terminalFocus" },
30132 { key: "mod+w", command: "terminal.close", when: "terminalFocus" },
31133 { key: "mod+w", command: "rightPanel.close", when: "!terminalFocus" },
134 { key: "mod+shift+t", command: "view.reopenClosed" },
32135 { key: "mod+d", command: "diff.toggle", when: "!terminalFocus" },
33136 { key: "mod+shift+j", command: "preview.toggle" },
34137 { key: "mod+r", command: "preview.refresh", when: "previewFocus" },