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"...@@ -68,6 +68,7 @@ export const SET_WSL_BACKEND_ENABLED_CHANNEL = "desktop:set-wsl-backend-enabled"
68export const SET_WSL_DISTRO_CHANNEL = "desktop:set-wsl-distro";68export const SET_WSL_DISTRO_CHANNEL = "desktop:set-wsl-distro";
69export const SET_WSL_ONLY_CHANNEL = "desktop:set-wsl-only";69export const SET_WSL_ONLY_CHANNEL = "desktop:set-wsl-only";
70export const SSH_PASSWORD_PROMPT_CANCELLED_RESULT = "ssh-password-prompt-cancelled";70export const SSH_PASSWORD_PROMPT_CANCELLED_RESULT = "ssh-password-prompt-cancelled";
71export const PREVIEW_SET_FORWARDED_SHORTCUTS_CHANNEL = "desktop:preview-set-forwarded-shortcuts";
71export const PREVIEW_CREATE_TAB_CHANNEL = "desktop:preview-create-tab";72export const PREVIEW_CREATE_TAB_CHANNEL = "desktop:preview-create-tab";
72export const PREVIEW_CLOSE_TAB_CHANNEL = "desktop:preview-close-tab";73export const PREVIEW_CLOSE_TAB_CHANNEL = "desktop:preview-close-tab";
73export const PREVIEW_REGISTER_WEBVIEW_CHANNEL = "desktop:preview-register-webview";74export const PREVIEW_REGISTER_WEBVIEW_CHANNEL = "desktop:preview-register-webview";
apps/desktop/src/ipc/methods/preview.ts+13
...@@ -20,6 +20,8 @@ import {...@@ -20,6 +20,8 @@ import {
20 PreviewAnnotationSubmissionResultSchema,20 PreviewAnnotationSubmissionResultSchema,
21 DEFAULT_BROWSER_PROFILE_ID,21 DEFAULT_BROWSER_PROFILE_ID,
22 INCOGNITO_BROWSER_PROFILE_ID,22 INCOGNITO_BROWSER_PROFILE_ID,
23 PreviewForwardedShortcut,
24 MAX_KEYBINDINGS_COUNT,
23} from "@t3tools/contracts";25} from "@t3tools/contracts";
24import * as Effect from "effect/Effect";26import * as Effect from "effect/Effect";
25import * as Schema from "effect/Schema";27import * as Schema from "effect/Schema";
...@@ -53,6 +55,16 @@ export const installPreviewEventForwarding = Effect.fn(...@@ -53,6 +55,16 @@ export const installPreviewEventForwarding = Effect.fn(
53 );55 );
54});56});
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
56export const createTab = DesktopIpc.makeIpcMethod({68export const createTab = DesktopIpc.makeIpcMethod({
57 channel: IpcChannels.PREVIEW_CREATE_TAB_CHANNEL,69 channel: IpcChannels.PREVIEW_CREATE_TAB_CHANNEL,
58 payload: DesktopPreviewCreateTabInputSchema,70 payload: DesktopPreviewCreateTabInputSchema,
...@@ -406,6 +418,7 @@ export const saveRecording = DesktopIpc.makeIpcMethod({...@@ -406,6 +418,7 @@ export const saveRecording = DesktopIpc.makeIpcMethod({
406});418});
407419
408export const methods = [420export const methods = [
421 setForwardedShortcuts,
409 createTab,422 createTab,
410 closeTab,423 closeTab,
411 registerWebview,424 registerWebview,
apps/desktop/src/preload.ts+2
...@@ -299,6 +299,8 @@ contextBridge.exposeInMainWorld("desktopBridge", {...@@ -299,6 +299,8 @@ contextBridge.exposeInMainWorld("desktopBridge", {
299 },299 },
300 },300 },
301 preview: {301 preview: {
302 setForwardedShortcuts: (shortcuts) =>
303 ipcRenderer.invoke(IpcChannels.PREVIEW_SET_FORWARDED_SHORTCUTS_CHANNEL, shortcuts),
302 createTab: (tabId, defaults) =>304 createTab: (tabId, defaults) =>
303 ipcRenderer.invoke(IpcChannels.PREVIEW_CREATE_TAB_CHANNEL, {305 ipcRenderer.invoke(IpcChannels.PREVIEW_CREATE_TAB_CHANNEL, {
304 tabId,306 tabId,
apps/desktop/src/preview/Manager.test.ts+124-19
...@@ -6,6 +6,7 @@ import type {...@@ -6,6 +6,7 @@ import type {
6 DesktopPreviewRecordingInputEvent,6 DesktopPreviewRecordingInputEvent,
7} from "@t3tools/contracts";7} from "@t3tools/contracts";
8import { HostProcessPlatform } from "@t3tools/shared/hostProcess";8import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
9import { parseKeybindingShortcut } from "@t3tools/shared/keybindings";
9import * as Cause from "effect/Cause";10import * as Cause from "effect/Cause";
10import * as Deferred from "effect/Deferred";11import * as Deferred from "effect/Deferred";
11import * as Effect from "effect/Effect";12import * as Effect from "effect/Effect";
...@@ -269,32 +270,34 @@ const layerFileSystem = FileSystem.layerNoop({...@@ -269,32 +270,34 @@ const layerFileSystem = FileSystem.layerNoop({
269 }),270 }),
270});271});
271272
272const layer = PreviewManager.layer.pipe(273const managerLayer = (platform: NodeJS.Platform = "darwin") =>
273 Layer.provideMerge(274 PreviewManager.layer.pipe(
274 Layer.succeed(DesktopRendererHistory.DesktopRendererHistory, {275 Layer.provideMerge(
275 register: () => Effect.void,276 Layer.succeed(DesktopRendererHistory.DesktopRendererHistory, {
276 recordMetrics: () => Effect.void,277 register: () => Effect.void,
277 shutdown: Effect.void,278 recordMetrics: () => Effect.void,
278 }),279 shutdown: Effect.void,
279 ),280 }),
280 Layer.provideMerge(DesktopBrowserHost.layer),281 ),
281 Layer.provideMerge(layerBrowserSession),282 Layer.provideMerge(DesktopBrowserHost.layer),
282 Layer.provideMerge(layerEnvironment),283 Layer.provideMerge(layerBrowserSession),
283 Layer.provideMerge(layerFileSystem),284 Layer.provideMerge(layerEnvironment),
284 Layer.provideMerge(Path.layer),285 Layer.provideMerge(layerFileSystem),
285 Layer.provideMerge(NodeCrypto.layer),286 Layer.provideMerge(Path.layer),
286 Layer.provideMerge(Layer.succeed(HostProcessPlatform, "darwin")),287 Layer.provideMerge(NodeCrypto.layer),
287);288 Layer.provideMerge(Layer.succeed(HostProcessPlatform, platform)),
289 );
288290
289const withManager = <A>(291const withManager = <A>(
290 use: (292 use: (
291 manager: PreviewManager.PreviewManager["Service"],293 manager: PreviewManager.PreviewManager["Service"],
292 ) => Effect.Effect<A, PreviewManager.PreviewManagerError, Scope.Scope>,294 ) => Effect.Effect<A, PreviewManager.PreviewManagerError, Scope.Scope>,
295 platform: NodeJS.Platform = "darwin",
293) =>296) =>
294 Effect.gen(function* () {297 Effect.gen(function* () {
295 const manager = yield* PreviewManager.PreviewManager;298 const manager = yield* PreviewManager.PreviewManager;
296 return yield* use(manager);299 return yield* use(manager);
297 }).pipe(Effect.provide(layer), Effect.scoped);300 }).pipe(Effect.provide(managerLayer(platform)), Effect.scoped);
298301
299interface TestCapturedPreviewImage {302interface TestCapturedPreviewImage {
300 readonly toJPEG: () => Buffer;303 readonly toJPEG: () => Buffer;
...@@ -574,7 +577,7 @@ describe("PreviewManager", () => {...@@ -574,7 +577,7 @@ describe("PreviewManager", () => {
574 Effect.gen(function* () {577 Effect.gen(function* () {
575 const preview = makeFaviconWebContents();578 const preview = makeFaviconWebContents();
576 const sendInputEvent = vi.fn();579 const sendInputEvent = vi.fn();
577 const hostWebContents = { sendInputEvent };580 const hostWebContents = { sendInputEvent, isDestroyed: () => false };
578 Object.assign(preview.webContents, { hostWebContents });581 Object.assign(preview.webContents, { hostWebContents });
579 fromId.mockReturnValue(preview.webContents);582 fromId.mockReturnValue(preview.webContents);
580 getFocusedWebContents.mockReturnValue(preview.webContents as never);583 getFocusedWebContents.mockReturnValue(preview.webContents as never);
...@@ -628,6 +631,108 @@ describe("PreviewManager", () => {...@@ -628,6 +631,108 @@ describe("PreviewManager", () => {
628 ),631 ),
629 );632 );
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
631 effectIt.effect("preserves focused browser editing in tabs and sign-in popups", () =>736 effectIt.effect("preserves focused browser editing in tabs and sign-in popups", () =>
632 withManager((manager) =>737 withManager((manager) =>
633 Effect.gen(function* () {738 Effect.gen(function* () {
...@@ -2716,7 +2821,7 @@ describe("PreviewManager", () => {...@@ -2716,7 +2821,7 @@ describe("PreviewManager", () => {
2716 expect(yield* nextType).toBe("attached");2821 expect(yield* nextType).toBe("attached");
2717 yield* manager.closeTab("tab_1");2822 yield* manager.closeTab("tab_1");
2718 expect(yield* nextType).toBe("detached");2823 expect(yield* nextType).toBe("detached");
2719 }).pipe(Effect.provide(layer), Effect.scoped),2824 }).pipe(Effect.provide(managerLayer()), Effect.scoped),
2720 );2825 );
27212826
2722 effectIt.effect("stops capture retries when the tab swaps during the retry delay", () =>2827 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 {...@@ -22,8 +22,10 @@ import type {
22 DesktopPreviewRecordingInputEvent,22 DesktopPreviewRecordingInputEvent,
23 DesktopPreviewScreenshotArtifact,23 DesktopPreviewScreenshotArtifact,
24 DesktopPreviewTabDefaults,24 DesktopPreviewTabDefaults,
25 PreviewForwardedShortcut,
25} from "@t3tools/contracts";26} from "@t3tools/contracts";
26import { HostProcessPlatform } from "@t3tools/shared/hostProcess";27import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
28import { matchesKeybindingShortcut } from "@t3tools/shared/keybindings";
27import { normalizePreviewUrl } from "@t3tools/shared/preview";29import { normalizePreviewUrl } from "@t3tools/shared/preview";
28import {30import {
29 BrowserWindow,31 BrowserWindow,
...@@ -58,7 +60,7 @@ import * as SynchronizedRef from "effect/SynchronizedRef";...@@ -58,7 +60,7 @@ import * as SynchronizedRef from "effect/SynchronizedRef";
5860
59import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";61import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
60import * as DesktopRendererHistory from "../telemetry/DesktopRendererHistory.ts";62import * 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";
62import * as DesktopBrowserHost from "./DesktopBrowserHost.ts";64import * as DesktopBrowserHost from "./DesktopBrowserHost.ts";
63import * as BrowserSession from "./BrowserSession.ts";65import * as BrowserSession from "./BrowserSession.ts";
64import {66import {
...@@ -555,6 +557,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -555,6 +557,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
555 const runFork = Effect.runForkWith(context);557 const runFork = Effect.runForkWith(context);
556 const resolvedArtifactDirectory = path.resolve(artifactDirectory);558 const resolvedArtifactDirectory = path.resolve(artifactDirectory);
557559
560 let forwardedShortcuts: ReadonlyArray<PreviewForwardedShortcut> = [];
558 const annotationThemeRef = yield* Ref.make(DEFAULT_ANNOTATION_THEME);561 const annotationThemeRef = yield* Ref.make(DEFAULT_ANNOTATION_THEME);
559 const mainWindowRef = yield* Ref.make<Option.Option<BrowserWindow>>(Option.none());562 const mainWindowRef = yield* Ref.make<Option.Option<BrowserWindow>>(Option.none());
560 const tabsRef = yield* SynchronizedRef.make<ReadonlyMap<string, PreviewTabState>>(new Map());563 const tabsRef = yield* SynchronizedRef.make<ReadonlyMap<string, PreviewTabState>>(new Map());
...@@ -1520,6 +1523,38 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1520,6 +1523,38 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1520 };1523 };
1521 const beforeInput = (event: Electron.Event, input: Electron.Input): void => {1524 const beforeInput = (event: Electron.Event, input: Electron.Input): void => {
1522 syncMenuShortcuts(wc, input);1525 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 }
1523 if (isPreviewRefreshShortcut(input)) {1558 if (isPreviewRefreshShortcut(input)) {
1524 event.preventDefault();1559 event.preventDefault();
1525 runFork(1560 runFork(
...@@ -3324,6 +3359,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -3324,6 +3359,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
3324 setAudioMuted,3359 setAudioMuted,
3325 setColorScheme,3360 setColorScheme,
3326 setMainWindow,3361 setMainWindow,
3362 setForwardedShortcuts: (shortcuts: ReadonlyArray<PreviewForwardedShortcut>) =>
3363 Effect.sync(() => {
3364 forwardedShortcuts = shortcuts;
3365 }),
3327 startRecording,3366 startRecording,
3328 closePictureInPicture,3367 closePictureInPicture,
3329 stopRecording,3368 stopRecording,
...@@ -3484,6 +3523,9 @@ export class PreviewManager extends Context.Service<...@@ -3484,6 +3523,9 @@ export class PreviewManager extends Context.Service<
3484 PreviewManager,3523 PreviewManager,
3485 {3524 {
3486 readonly setMainWindow: (window: BrowserWindow) => Effect.Effect<void, PreviewManagerError>;3525 readonly setMainWindow: (window: BrowserWindow) => Effect.Effect<void, PreviewManagerError>;
3526 readonly setForwardedShortcuts: (
3527 shortcuts: ReadonlyArray<PreviewForwardedShortcut>,
3528 ) => Effect.Effect<void>;
3487 readonly getBrowserSession: (3529 readonly getBrowserSession: (
3488 scope?: string,3530 scope?: string,
3489 persistent?: boolean,3531 persistent?: boolean,
...@@ -3595,6 +3637,7 @@ export const make = Effect.gen(function* PreviewManagerMake() {...@@ -3595,6 +3637,7 @@ export const make = Effect.gen(function* PreviewManagerMake() {
35953637
3596 return PreviewManager.of({3638 return PreviewManager.of({
3597 setMainWindow: operations.setMainWindow,3639 setMainWindow: operations.setMainWindow,
3640 setForwardedShortcuts: operations.setForwardedShortcuts,
3598 getBrowserSession: Effect.fn("PreviewManager.getBrowserSession")(3641 getBrowserSession: Effect.fn("PreviewManager.getBrowserSession")(
3599 function* (scope, persistent, namespace) {3642 function* (scope, persistent, namespace) {
3600 const session = yield* browserSession3643 const session = yield* browserSession
apps/server/src/keybindings.test.ts+2-2
...@@ -257,7 +257,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => {...@@ -257,7 +257,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => {
257 Effect.gen(function* () {257 Effect.gen(function* () {
258 const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig;258 const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig;
259 yield* writeKeybindingsConfig(keybindingsConfigPath, [259 yield* writeKeybindingsConfig(keybindingsConfigPath, [
260 { key: "mod+shift+t", command: "terminal.toggle" },260 { key: "mod+shift+y", command: "terminal.toggle" },
261 { key: "mod+shift+r", command: "script.run-tests.run" },261 { key: "mod+shift+r", command: "script.run-tests.run" },
262 ]);262 ]);
263263
...@@ -271,7 +271,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => {...@@ -271,7 +271,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => {
271271
272 const persistedToggle = byCommand.get("terminal.toggle");272 const persistedToggle = byCommand.get("terminal.toggle");
273 assert.isNotNull(persistedToggle);273 assert.isNotNull(persistedToggle);
274 assert.equal(persistedToggle?.key, "mod+shift+t");274 assert.equal(persistedToggle?.key, "mod+shift+y");
275 assert.isFalse(275 assert.isFalse(
276 persisted.some((entry) => entry.command === "terminal.toggle" && entry.key === "mod+j"),276 persisted.some((entry) => entry.command === "terminal.toggle" && entry.key === "mod+j"),
277 );277 );
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) {...@@ -5947,9 +5947,16 @@ export default function ChatView(props: ChatViewProps) {
5947 const finishRightPanelSurfaceClose = useCallback(5947 const finishRightPanelSurfaceClose = useCallback(
5948 (surfaces: readonly RightPanelSurface[]) => {5948 (surfaces: readonly RightPanelSurface[]) => {
5949 if (!activeThreadRef) return;5949 if (!activeThreadRef) return;
5950 cleanupRightPanelSurfaces(surfaces);
5951 const store = useRightPanelStore.getState();5950 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]);
5953 store.closeSurface(activeThreadRef, surface.id);5960 store.closeSurface(activeThreadRef, surface.id);
5954 }5961 }
5955 syncActivePreviewSurface();5962 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 {...@@ -13,6 +13,8 @@ import {
13 resetPreviewStateForTests,13 resetPreviewStateForTests,
14} from "~/previewStateStore";14} from "~/previewStateStore";
1515
16import { useClosedViewStore } from "~/closedViewStore";
17
16import { closePreviewSession } from "./closePreviewSession";18import { closePreviewSession } from "./closePreviewSession";
1719
18const threadRef = {20const threadRef = {
...@@ -33,7 +35,10 @@ const snapshot: PreviewSessionSnapshot = {...@@ -33,7 +35,10 @@ const snapshot: PreviewSessionSnapshot = {
33 updatedAt: "2026-06-18T19:00:00.000Z",35 updatedAt: "2026-06-18T19:00:00.000Z",
34};36};
3537
36beforeEach(resetPreviewStateForTests);38beforeEach(() => {
39 resetPreviewStateForTests();
40 useClosedViewStore.setState({ entries: [] });
41});
3742
38describe("closePreviewSession", () => {43describe("closePreviewSession", () => {
39 it("suppresses stale server snapshots while the close is in flight", async () => {44 it("suppresses stale server snapshots while the close is in flight", async () => {
...@@ -53,17 +58,29 @@ describe("closePreviewSession", () => {...@@ -53,17 +58,29 @@ describe("closePreviewSession", () => {
53 threadRef,58 threadRef,
54 });59 });
5560
61 expect(useClosedViewStore.getState().entries).toEqual([]);
56 expect(readThreadPreviewState(threadRef).sessions).toEqual({});62 expect(readThreadPreviewState(threadRef).sessions).toEqual({});
57 applyPreviewServerSnapshot(threadRef, snapshot);63 applyPreviewServerSnapshot(threadRef, snapshot);
58 expect(readThreadPreviewState(threadRef).sessions).toEqual({});64 expect(readThreadPreviewState(threadRef).sessions).toEqual({});
5965
60 finishClose?.();66 finishClose?.();
61 await closing;67 await closing;
68 expect(useClosedViewStore.getState().entries).toMatchObject([
69 { kind: "browser", threadRef, snapshot },
70 ]);
62 expect(closePreview).toHaveBeenCalledWith({ threadId: "thread-1", tabId: "tab-1" });71 expect(closePreview).toHaveBeenCalledWith({ threadId: "thread-1", tabId: "tab-1" });
63 });72 });
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 () => {
66 applyPreviewServerSnapshot(threadRef, snapshot);75 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
68 const result = await closePreviewSession({85 const result = await closePreviewSession({
69 closePreview: async () => AsyncResult.failure(Cause.fail(new Error("close failed"))),86 closePreview: async () => AsyncResult.failure(Cause.fail(new Error("close failed"))),
...@@ -73,6 +90,7 @@ describe("closePreviewSession", () => {...@@ -73,6 +90,7 @@ describe("closePreviewSession", () => {
73 });90 });
7491
75 expect(result._tag).toBe("Failure");92 expect(result._tag).toBe("Failure");
93 expect(useClosedViewStore.getState().entries).toEqual(history);
76 expect(readThreadPreviewState(threadRef).snapshot).toEqual(snapshot);94 expect(readThreadPreviewState(threadRef).snapshot).toEqual(snapshot);
77 expect(readThreadPreviewState(threadRef).sessions).toEqual({ [snapshot.tabId]: snapshot });95 expect(readThreadPreviewState(threadRef).sessions).toEqual({ [snapshot.tabId]: snapshot });
78 });96 });
apps/web/src/components/preview/closePreviewSession.ts+7
...@@ -6,6 +6,7 @@ import type {...@@ -6,6 +6,7 @@ import type {
6 ScopedThreadRef,6 ScopedThreadRef,
7} from "@t3tools/contracts";7} from "@t3tools/contracts";
88
9import { useClosedViewStore } from "~/closedViewStore";
9import { beginPreviewSessionClose, cancelPreviewSessionClose } from "~/previewStateStore";10import { beginPreviewSessionClose, cancelPreviewSessionClose } from "~/previewStateStore";
1011
11interface ClosePreviewSessionInput<E> {12interface ClosePreviewSessionInput<E> {
...@@ -32,6 +33,12 @@ export async function closePreviewSession<E>(...@@ -32,6 +33,12 @@ export async function closePreviewSession<E>(
32 });33 });
33 if (result._tag === "Failure") {34 if (result._tag === "Failure") {
34 cancelPreviewSessionClose(input.threadRef, input.snapshot, input.tabId);35 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 });
35 }42 }
36 return result;43 return result;
37}44}
apps/web/src/components/settings/KeybindingsSettings.logic.test.ts+1
...@@ -263,6 +263,7 @@ describe("KeybindingsSettings.logic", () => {...@@ -263,6 +263,7 @@ describe("KeybindingsSettings.logic", () => {
263 it("formats static and project script command labels", () => {263 it("formats static and project script command labels", () => {
264 expect(commandLabel("commandPalette.toggle")).toBe("Command Palette: Toggle");264 expect(commandLabel("commandPalette.toggle")).toBe("Command Palette: Toggle");
265 expect(commandLabel("themeEditor.toggle")).toBe("Theme Editor: Toggle");265 expect(commandLabel("themeEditor.toggle")).toBe("Theme Editor: Toggle");
266 expect(commandLabel("view.reopenClosed")).toBe("Reopen Closed Tab");
266 expect(commandLabel("script.setup-db.run")).toBe("Run Script: Setup Db");267 expect(commandLabel("script.setup-db.run")).toBe("Run Script: Setup Db");
267 });268 });
268269
apps/web/src/components/settings/KeybindingsSettings.logic.ts+1
...@@ -322,6 +322,7 @@ export function commandLabel(command: KeybindingCommand): string {...@@ -322,6 +322,7 @@ export function commandLabel(command: KeybindingCommand): string {
322 if (usageMetric) return `Usage: ${usageMetric.label}`;322 if (usageMetric) return `Usage: ${usageMetric.label}`;
323 const usagePeriod = WINDOW_OPTIONS.find((option) => option.command === command);323 const usagePeriod = WINDOW_OPTIONS.find((option) => option.command === command);
324 if (usagePeriod) return `Usage: Period: ${usagePeriod.label}`;324 if (usagePeriod) return `Usage: Period: ${usagePeriod.label}`;
325 if (command === "view.reopenClosed") return "Reopen Closed Tab";
325 const raw = String(command);326 const raw = String(command);
326 if (raw.startsWith("script.") && raw.endsWith(".run")) {327 if (raw.startsWith("script.") && raw.endsWith(".run")) {
327 return `Run Script: ${titleCaseCommandSegment(raw.slice("script.".length, -".run".length))}`;328 return `Run Script: ${titleCaseCommandSegment(raw.slice("script.".length, -".run".length))}`;
apps/web/src/keybindings.test.ts+35
...@@ -12,6 +12,7 @@ import {...@@ -12,6 +12,7 @@ import {
12 type ResolvedKeybindingsConfig,12 type ResolvedKeybindingsConfig,
13} from "@t3tools/contracts";13} from "@t3tools/contracts";
14import {14import {
15 effectiveShortcutsForCommand,
15 formatShortcutLabel,16 formatShortcutLabel,
16 isDiffToggleShortcut,17 isDiffToggleShortcut,
17 isRichTextBoldShortcut,18 isRichTextBoldShortcut,
...@@ -180,6 +181,40 @@ const DEFAULT_BINDINGS = compile([...@@ -180,6 +181,40 @@ const DEFAULT_BINDINGS = compile([
180 },181 },
181]);182]);
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
183describe("isTerminalToggleShortcut", () => {218describe("isTerminalToggleShortcut", () => {
184 it("matches Cmd+J on macOS", () => {219 it("matches Cmd+J on macOS", () => {
185 assert.isTrue(220 assert.isTrue(
apps/web/src/keybindings.ts+26-109
...@@ -8,27 +8,19 @@ import {...@@ -8,27 +8,19 @@ import {
8 type ModelPickerJumpKeybindingCommand,8 type ModelPickerJumpKeybindingCommand,
9 type ThreadJumpKeybindingCommand,9 type ThreadJumpKeybindingCommand,
10} from "@t3tools/contracts";10} 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";
11import { isElectron } from "./env";20import { 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 {22export type { ShortcutEventLike, ShortcutModifierStateLike } from "@t3tools/shared/keybindings";
27 metaKey: boolean;23export { shortcutKeyFromEvent } from "@t3tools/shared/keybindings";
28 ctrlKey: boolean;
29 shiftKey: boolean;
30 altKey: boolean;
31}
3224
33export interface ShortcutMatchContext {25export interface ShortcutMatchContext {
34 terminalFocus: boolean;26 terminalFocus: boolean;
...@@ -57,90 +49,6 @@ const TERMINAL_WORD_FORWARD = "\u001bf";...@@ -57,90 +49,6 @@ const TERMINAL_WORD_FORWARD = "\u001bf";
57const TERMINAL_LINE_START = "\u0001";49const TERMINAL_LINE_START = "\u0001";
58const TERMINAL_LINE_END = "\u0005";50const TERMINAL_LINE_END = "\u0005";
59const TERMINAL_DELETE_TO_LINE_START = "\u0015";51const 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
144function resolvePlatform(options: ShortcutMatchOptions | undefined): string {52function resolvePlatform(options: ShortcutMatchOptions | undefined): string {
145 return options?.platform ?? navigator.platform;53 return options?.platform ?? navigator.platform;
146}54}
...@@ -197,14 +105,15 @@ export function shortcutConflictKey(...@@ -197,14 +105,15 @@ export function shortcutConflictKey(
197 ].join("|");105 ].join("|");
198}106}
199107
200function findEffectiveShortcutForCommand(108export function effectiveShortcutsForCommand(
201 keybindings: ResolvedKeybindingsConfig,109 keybindings: ResolvedKeybindingsConfig,
202 command: KeybindingCommand,110 command: KeybindingCommand,
203 options?: ShortcutMatchOptions,111 options?: ShortcutMatchOptions,
204): KeybindingShortcut | null {112): KeybindingShortcut[] {
205 const platform = resolvePlatform(options);113 const platform = resolvePlatform(options);
206 const context = resolveContext(options);114 const context = resolveContext(options);
207 const claimedShortcuts = new Set<string>();115 const claimedShortcuts = new Set<string>();
116 const effective: KeybindingShortcut[] = [];
208117
209 for (let index = keybindings.length - 1; index >= 0; index -= 1) {118 for (let index = keybindings.length - 1; index >= 0; index -= 1) {
210 const binding = keybindings[index];119 const binding = keybindings[index];
...@@ -218,11 +127,19 @@ function findEffectiveShortcutForCommand(...@@ -218,11 +127,19 @@ function findEffectiveShortcutForCommand(
218127
219 claimedShortcuts.add(conflictKey);128 claimedShortcuts.add(conflictKey);
220 if (binding.command === command) {129 if (binding.command === command) {
221 return binding.shortcut;130 effective.push(binding.shortcut);
222 }131 }
223 }132 }
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;
226}143}
227144
228function matchesCommandShortcut(145function matchesCommandShortcut(
...@@ -246,7 +163,7 @@ export function resolveShortcutCommand(...@@ -246,7 +163,7 @@ export function resolveShortcutCommand(
246 const binding = keybindings[index];163 const binding = keybindings[index];
247 if (!binding) continue;164 if (!binding) continue;
248 if (!matchesWhenClause(binding.whenAst, context)) continue;165 if (!matchesWhenClause(binding.whenAst, context)) continue;
249 if (!matchesShortcut(event, binding.shortcut, platform)) continue;166 if (!matchesKeybindingShortcut(event, binding.shortcut, platform)) continue;
250 return binding.command;167 return binding.command;
251 }168 }
252 return null;169 return null;
...@@ -339,7 +256,7 @@ export function shouldShowThreadJumpHintsForModifiers(...@@ -339,7 +256,7 @@ export function shouldShowThreadJumpHintsForModifiers(
339 for (const command of THREAD_JUMP_KEYBINDING_COMMANDS) {256 for (const command of THREAD_JUMP_KEYBINDING_COMMANDS) {
340 const shortcut = findEffectiveShortcutForCommand(keybindings, command, options);257 const shortcut = findEffectiveShortcutForCommand(keybindings, command, options);
341 if (!shortcut) continue;258 if (!shortcut) continue;
342 if (matchesShortcutModifiers(modifiers, shortcut, platform)) {259 if (matchesKeybindingShortcutModifiers(modifiers, shortcut, platform)) {
343 return true;260 return true;
344 }261 }
345 }262 }
apps/web/src/lib/utils.ts+2-3
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import { MessageId, ProjectId, ThreadId } from "@t3tools/contracts";1import { MessageId, ProjectId, ThreadId } from "@t3tools/contracts";
2import { isMacPlatform } from "@t3tools/shared/keybindings";
2import { type CxOptions, cx } from "class-variance-authority";3import { type CxOptions, cx } from "class-variance-authority";
3import * as Hex from "effect/encoding/Hex";4import * as Hex from "effect/encoding/Hex";
4import { extendTailwindMerge } from "tailwind-merge";5import { extendTailwindMerge } from "tailwind-merge";
...@@ -12,9 +13,7 @@ export function cn(...inputs: CxOptions) {...@@ -12,9 +13,7 @@ export function cn(...inputs: CxOptions) {
12 return twMerge(cx(inputs));13 return twMerge(cx(inputs));
13}14}
1415
15export function isMacPlatform(platform: string): boolean {16export { isMacPlatform };
16 return /mac|iphone|ipad|ipod/i.test(platform);
17}
1817
19export function isWindowsPlatform(platform: string): boolean {18export function isWindowsPlatform(platform: string): boolean {
20 return /^win(dows)?/i.test(platform);19 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";...@@ -2,6 +2,7 @@ import { scopeThreadRef } from "@t3tools/client-runtime/environment";
2import { type EnvironmentId, ThreadId } from "@t3tools/contracts";2import { type EnvironmentId, ThreadId } from "@t3tools/contracts";
3import { beforeEach, describe, expect, it } from "vite-plus/test";3import { beforeEach, describe, expect, it } from "vite-plus/test";
44
5import { useClosedViewStore } from "./closedViewStore";
5import {6import {
6 migratePersistedRightPanelState,7 migratePersistedRightPanelState,
7 pullRequestSurface,8 pullRequestSurface,
...@@ -19,14 +20,113 @@ const refA = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-A"))...@@ -19,14 +20,113 @@ const refA = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-A"))
19const refB = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-B"));20const refB = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-B"));
2021
21beforeEach(() => {22beforeEach(() => {
23 useClosedViewStore.setState({ entries: [] });
22 useRightPanelStore.setState({24 useRightPanelStore.setState({
23 byThreadKey: {},25 byThreadKey: {},
24 threadPanelVisibilityByThreadKey: {},26 threadPanelVisibilityByThreadKey: {},
25 userActionRevisionByThreadKey: {},27 userActionRevisionByThreadKey: {},
28 closeRevisionByThreadKey: {},
26 });29 });
27});30});
2831
29describe("rightPanelStore", () => {32describe("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
30 it("gives each host/device its own tab and preserves renamed tabs", () => {130 it("gives each host/device its own tab and preserves renamed tabs", () => {
31 const store = useRightPanelStore.getState();131 const store = useRightPanelStore.getState();
32 const android = {132 const android = {
apps/web/src/rightPanelStore.ts+66-11
...@@ -7,11 +7,16 @@...@@ -7,11 +7,16 @@
7 * terminal surfaces point at terminal session ids, file surfaces point at7 * terminal surfaces point at terminal session ids, file surfaces point at
8 * workspace paths, and diff/files remain singleton surfaces.8 * workspace paths, and diff/files remain singleton surfaces.
9 */9 */
10import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment";10import {
11 parseScopedThreadKey,
12 scopedThreadKey,
13 scopeThreadRef,
14} from "@t3tools/client-runtime/environment";
11import { EnvironmentId, ThreadId, type ScopedThreadRef } from "@t3tools/contracts";15import { EnvironmentId, ThreadId, type ScopedThreadRef } from "@t3tools/contracts";
12import { create } from "zustand";16import { create } from "zustand";
13import { createJSONStorage, persist } from "zustand/middleware";17import { createJSONStorage, persist } from "zustand/middleware";
1418
19import { useClosedViewStore } from "./closedViewStore";
15import { resolveStorage } from "./lib/storage";20import { resolveStorage } from "./lib/storage";
16import type { ThreadPanelPresentation } from "./rightPanelLayout";21import type { ThreadPanelPresentation } from "./rightPanelLayout";
17import type { ChatFileAttachment } from "./types";22import type { ChatFileAttachment } from "./types";
...@@ -119,6 +124,7 @@ interface RightPanelStoreState {...@@ -119,6 +124,7 @@ interface RightPanelStoreState {
119 threadPanelVisibilityByThreadKey: Record<string, ThreadPanelVisibility>;124 threadPanelVisibilityByThreadKey: Record<string, ThreadPanelVisibility>;
120 /** Session-only count of user panel choices per thread. Automatic updates do not advance it. */125 /** Session-only count of user panel choices per thread. Automatic updates do not advance it. */
121 userActionRevisionByThreadKey: Record<string, number>;126 userActionRevisionByThreadKey: Record<string, number>;
127 closeRevisionByThreadKey: Record<string, number>;
122 getUserActionRevision: (ref: ScopedThreadRef) => number;128 getUserActionRevision: (ref: ScopedThreadRef) => number;
123 /**129 /**
124 * Open a surface on behalf of the app, not the user. Refused when the user130 * Open a surface on behalf of the app, not the user. Refused when the user
...@@ -387,6 +393,7 @@ const userAction = (...@@ -387,6 +393,7 @@ const userAction = (
387 state: RightPanelStoreState,393 state: RightPanelStoreState,
388 threadKey: string,394 threadKey: string,
389 updater: (current: ThreadRightPanelState) => ThreadRightPanelState,395 updater: (current: ThreadRightPanelState) => ThreadRightPanelState,
396 closesView = false,
390): Partial<RightPanelStoreState> => ({397): Partial<RightPanelStoreState> => ({
391 ...updateThread(state, threadKey, (current) => {398 ...updateThread(state, threadKey, (current) => {
392 const next = updater(current);399 const next = updater(current);
...@@ -410,8 +417,22 @@ const userAction = (...@@ -410,8 +417,22 @@ const userAction = (
410 ...state.userActionRevisionByThreadKey,417 ...state.userActionRevisionByThreadKey,
411 [threadKey]: (state.userActionRevisionByThreadKey[threadKey] ?? 0) + 1,418 [threadKey]: (state.userActionRevisionByThreadKey[threadKey] ?? 0) + 1,
412 },419 },
420 ...(closesView
421 ? {
422 closeRevisionByThreadKey: {
423 ...state.closeRevisionByThreadKey,
424 [threadKey]: (state.closeRevisionByThreadKey[threadKey] ?? 0) + 1,
425 },
426 }
427 : {}),
413});428});
414429
430const closeAction = (
431 state: RightPanelStoreState,
432 threadKey: string,
433 updater: (current: ThreadRightPanelState) => ThreadRightPanelState,
434): Partial<RightPanelStoreState> => userAction(state, threadKey, updater, true);
435
415function normalizeRevealLine(line: number | undefined): number | null {436function normalizeRevealLine(line: number | undefined): number | null {
416 if (line === undefined || !Number.isFinite(line)) return null;437 if (line === undefined || !Number.isFinite(line)) return null;
417 return Math.max(1, Math.trunc(line));438 return Math.max(1, Math.trunc(line));
...@@ -571,6 +592,7 @@ export const useRightPanelStore = create<RightPanelStoreState>()(...@@ -571,6 +592,7 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
571 byThreadKey: {},592 byThreadKey: {},
572 threadPanelVisibilityByThreadKey: {},593 threadPanelVisibilityByThreadKey: {},
573 userActionRevisionByThreadKey: {},594 userActionRevisionByThreadKey: {},
595 closeRevisionByThreadKey: {},
574 getUserActionRevision: (ref) =>596 getUserActionRevision: (ref) =>
575 get().userActionRevisionByThreadKey[scopedThreadKey(ref)] ?? 0,597 get().userActionRevisionByThreadKey[scopedThreadKey(ref)] ?? 0,
576 openProactive: (ref, surface, expectedUserActionRevision) => {598 openProactive: (ref, surface, expectedUserActionRevision) => {
...@@ -754,7 +776,7 @@ export const useRightPanelStore = create<RightPanelStoreState>()(...@@ -754,7 +776,7 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
754 ),776 ),
755 closeTerminal: (ref, surfaceId, terminalId) =>777 closeTerminal: (ref, surfaceId, terminalId) =>
756 set((state) =>778 set((state) =>
757 userAction(state, scopedThreadKey(ref), (current) => {779 closeAction(state, scopedThreadKey(ref), (current) => {
758 const surface = current.surfaces.find(780 const surface = current.surfaces.find(
759 (entry) => entry.id === surfaceId && entry.kind === "terminal",781 (entry) => entry.id === surfaceId && entry.kind === "terminal",
760 );782 );
...@@ -801,25 +823,28 @@ export const useRightPanelStore = create<RightPanelStoreState>()(...@@ -801,25 +823,28 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
801 ),823 ),
802 closeSurface: (ref, surfaceId) =>824 closeSurface: (ref, surfaceId) =>
803 set((state) =>825 set((state) =>
804 userAction(state, scopedThreadKey(ref), (current) => {826 closeAction(state, scopedThreadKey(ref), (current) => {
805 const index = current.surfaces.findIndex((surface) => surface.id === surfaceId);827 const index = current.surfaces.findIndex((surface) => surface.id === surfaceId);
806 if (index < 0) return current;828 if (index < 0) return current;
807 const surfaces = current.surfaces.filter((surface) => surface.id !== surfaceId);829 const surfaces = current.surfaces.filter((surface) => surface.id !== surfaceId);
830 const next = {
831 ...current,
832 isOpen: surfaces.length > 0 && current.isOpen,
833 surfaces,
834 };
808 if (current.activeSurfaceId !== surfaceId) {835 if (current.activeSurfaceId !== surfaceId) {
809 return { ...current, isOpen: surfaces.length > 0 && current.isOpen, surfaces };836 return next;
810 }837 }
811 const fallback = surfaces[Math.min(index, surfaces.length - 1)] ?? null;838 const fallback = surfaces[Math.min(index, surfaces.length - 1)] ?? null;
812 return {839 return {
813 ...current,840 ...next,
814 isOpen: surfaces.length > 0 && current.isOpen,
815 surfaces,
816 activeSurfaceId: fallback?.id ?? null,841 activeSurfaceId: fallback?.id ?? null,
817 };842 };
818 }),843 }),
819 ),844 ),
820 closeOtherSurfaces: (ref, surfaceId) =>845 closeOtherSurfaces: (ref, surfaceId) =>
821 set((state) =>846 set((state) =>
822 userAction(state, scopedThreadKey(ref), (current) => {847 closeAction(state, scopedThreadKey(ref), (current) => {
823 const surface = current.surfaces.find((entry) => entry.id === surfaceId);848 const surface = current.surfaces.find((entry) => entry.id === surfaceId);
824 if (!surface || current.surfaces.length === 1) return current;849 if (!surface || current.surfaces.length === 1) return current;
825 return {850 return {
...@@ -832,7 +857,7 @@ export const useRightPanelStore = create<RightPanelStoreState>()(...@@ -832,7 +857,7 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
832 ),857 ),
833 closeSurfacesToRight: (ref, surfaceId) =>858 closeSurfacesToRight: (ref, surfaceId) =>
834 set((state) =>859 set((state) =>
835 userAction(state, scopedThreadKey(ref), (current) => {860 closeAction(state, scopedThreadKey(ref), (current) => {
836 const index = current.surfaces.findIndex((surface) => surface.id === surfaceId);861 const index = current.surfaces.findIndex((surface) => surface.id === surfaceId);
837 if (index < 0 || index === current.surfaces.length - 1) return current;862 if (index < 0 || index === current.surfaces.length - 1) return current;
838 const surfaces = current.surfaces.slice(0, index + 1);863 const surfaces = current.surfaces.slice(0, index + 1);
...@@ -848,7 +873,7 @@ export const useRightPanelStore = create<RightPanelStoreState>()(...@@ -848,7 +873,7 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
848 ),873 ),
849 closeAllSurfaces: (ref) =>874 closeAllSurfaces: (ref) =>
850 set((state) =>875 set((state) =>
851 userAction(state, scopedThreadKey(ref), (current) =>876 closeAction(state, scopedThreadKey(ref), (current) =>
852 current.surfaces.length === 0877 current.surfaces.length === 0
853 ? current878 ? current
854 : { ...current, isOpen: false, surfaces: [], activeSurfaceId: null },879 : { ...current, isOpen: false, surfaces: [], activeSurfaceId: null },
...@@ -969,7 +994,8 @@ export const useRightPanelStore = create<RightPanelStoreState>()(...@@ -969,7 +994,8 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
969 if (994 if (
970 !(threadKey in state.byThreadKey) &&995 !(threadKey in state.byThreadKey) &&
971 !(threadKey in state.threadPanelVisibilityByThreadKey) &&996 !(threadKey in state.threadPanelVisibilityByThreadKey) &&
972 !(threadKey in state.userActionRevisionByThreadKey)997 !(threadKey in state.userActionRevisionByThreadKey) &&
998 !(threadKey in state.closeRevisionByThreadKey)
973 ) {999 ) {
974 return state;1000 return state;
975 }1001 }
...@@ -978,10 +1004,13 @@ export const useRightPanelStore = create<RightPanelStoreState>()(...@@ -978,10 +1004,13 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
978 state.threadPanelVisibilityByThreadKey;1004 state.threadPanelVisibilityByThreadKey;
979 const { [threadKey]: _revision, ...userActionRevisionByThreadKey } =1005 const { [threadKey]: _revision, ...userActionRevisionByThreadKey } =
980 state.userActionRevisionByThreadKey;1006 state.userActionRevisionByThreadKey;
1007 const { [threadKey]: _closeRevision, ...closeRevisionByThreadKey } =
1008 state.closeRevisionByThreadKey;
981 return {1009 return {
982 byThreadKey,1010 byThreadKey,
983 threadPanelVisibilityByThreadKey,1011 threadPanelVisibilityByThreadKey,
984 userActionRevisionByThreadKey,1012 userActionRevisionByThreadKey,
1013 closeRevisionByThreadKey,
985 };1014 };
986 }),1015 }),
987 }),1016 }),
...@@ -1009,6 +1038,32 @@ export const useRightPanelStore = create<RightPanelStoreState>()(...@@ -1009,6 +1038,32 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
1009 ),1038 ),
1010);1039);
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
1012export function selectThreadRightPanelState(1067export function selectThreadRightPanelState(
1013 byThreadKey: Record<string, ThreadRightPanelState>,1068 byThreadKey: Record<string, ThreadRightPanelState>,
1014 ref: ScopedThreadRef | null | undefined,1069 ref: ScopedThreadRef | null | undefined,
apps/web/src/routes/__root.tsx+2
...@@ -31,6 +31,7 @@ import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLa...@@ -31,6 +31,7 @@ import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLa
31import { NightlyMobileBetaNotice } from "../components/NightlyMobileBeta";31import { NightlyMobileBetaNotice } from "../components/NightlyMobileBeta";
32import { LegacyThreadMigrationToast } from "../components/LegacyThreadMigrationToast";32import { LegacyThreadMigrationToast } from "../components/LegacyThreadMigrationToast";
33import { ThreadNotificationCoordinator } from "../components/ThreadNotificationCoordinator";33import { ThreadNotificationCoordinator } from "../components/ThreadNotificationCoordinator";
34import { ReopenClosedViewShortcut } from "../components/ReopenClosedViewShortcut";
34import { ProjectCloneToastCoordinator } from "../components/ProjectCloneToastCoordinator";35import { ProjectCloneToastCoordinator } from "../components/ProjectCloneToastCoordinator";
35import { SlowRpcRequestToastCoordinator } from "../components/SlowRpcRequestToastCoordinator";36import { SlowRpcRequestToastCoordinator } from "../components/SlowRpcRequestToastCoordinator";
36import { ChatGptWelcomeCoordinator } from "../components/settings/ChatGptWelcomeCoordinator";37import { ChatGptWelcomeCoordinator } from "../components/settings/ChatGptWelcomeCoordinator";
...@@ -235,6 +236,7 @@ function RootRouteView() {...@@ -235,6 +236,7 @@ function RootRouteView() {
235 <SshPasswordPromptDialog />236 <SshPasswordPromptDialog />
236 <SnapShotCoordinator />237 <SnapShotCoordinator />
237 <ThreadNotificationCoordinator />238 <ThreadNotificationCoordinator />
239 <ReopenClosedViewShortcut />
238 <ConfirmDialogHost />240 <ConfirmDialogHost />
239 <CustomSnoozeDialogHost />241 <CustomSnoozeDialogHost />
240 <SlowRpcRequestToastCoordinator />242 <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`...@@ -145,6 +145,13 @@ tab. When nothing remains to close, it closes the window. In a browser, `mod+w`
145closes the browser tab; rebind `rightPanel.close` and `terminal.close` to an available145closes the browser tab; rebind `rightPanel.close` and `terminal.close` to an available
146shortcut such as `alt+w`.146shortcut 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
148Many defaults include `!terminalFocus` so they do not intercept terminal input.155Many defaults include `!terminalFocus` so they do not intercept terminal input.
149Keep that condition when remapping them if you want the same behavior.156Keep 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";...@@ -13,6 +13,7 @@ import { AdvertisedEndpoint } from "./remoteAccess.ts";
13import { ExecutionEnvironmentDescriptor } from "./environment.ts";13import { ExecutionEnvironmentDescriptor } from "./environment.ts";
14import { type ClientSettings, type QuitConfirmationMode, SnapShotShortcut } from "./settings.ts";14import { type ClientSettings, type QuitConfirmationMode, SnapShotShortcut } from "./settings.ts";
15import type { EditorId } from "./editor.ts";15import type { EditorId } from "./editor.ts";
16import type { PreviewForwardedShortcut } from "./keybindings.ts";
1617
17import type {18import type {
18 DesktopAppActivationRequest,19 DesktopAppActivationRequest,
...@@ -1226,6 +1227,7 @@ export interface DesktopBridge {...@@ -1226,6 +1227,7 @@ export interface DesktopBridge {
1226export const DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER = "__t3DesktopPreviewRecordingCapture";1227export const DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER = "__t3DesktopPreviewRecordingCapture";
12271228
1228export interface DesktopPreviewBridge {1229export interface DesktopPreviewBridge {
1230 setForwardedShortcuts?: (shortcuts: ReadonlyArray<PreviewForwardedShortcut>) => Promise<void>;
1229 createTab: (tabId: string, defaults?: DesktopPreviewTabDefaults) => Promise<void>;1231 createTab: (tabId: string, defaults?: DesktopPreviewTabDefaults) => Promise<void>;
1230 closeTab: (tabId: string) => Promise<void>;1232 closeTab: (tabId: string) => Promise<void>;
1231 registerWebview: (tabId: string, webContentsId: number) => Promise<void>;1233 registerWebview: (tabId: string, webContentsId: number) => Promise<void>;
packages/contracts/src/keybindings.ts+7
...@@ -69,6 +69,7 @@ export const STATIC_KEYBINDING_COMMANDS = [...@@ -69,6 +69,7 @@ export const STATIC_KEYBINDING_COMMANDS = [
69 "threadPanel.toggle",69 "threadPanel.toggle",
70 "rightPanel.toggleMaximized",70 "rightPanel.toggleMaximized",
71 "rightPanel.close",71 "rightPanel.close",
72 "view.reopenClosed",
72 "pullRequest.copyNumber",73 "pullRequest.copyNumber",
73 "diff.toggle",74 "diff.toggle",
74 "preview.toggle",75 "preview.toggle",
...@@ -156,6 +157,12 @@ export const KeybindingShortcut = Schema.Struct({...@@ -156,6 +157,12 @@ export const KeybindingShortcut = Schema.Struct({
156});157});
157export type KeybindingShortcut = typeof KeybindingShortcut.Type;158export 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
159const KeybindingWhenNodeRef = Schema.suspend(166const KeybindingWhenNodeRef = Schema.suspend(
160 (): Schema.Codec<KeybindingWhenNode> => KeybindingWhenNode,167 (): Schema.Codec<KeybindingWhenNode> => KeybindingWhenNode,
161);168);
packages/shared/src/keybindings.ts+103
...@@ -10,6 +10,108 @@ import {...@@ -10,6 +10,108 @@ import {
10 THREAD_JUMP_KEYBINDING_COMMANDS,10 THREAD_JUMP_KEYBINDING_COMMANDS,
11} from "@t3tools/contracts";11} 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
13type WhenToken =115type WhenToken =
14 | { type: "identifier"; value: string }116 | { type: "identifier"; value: string }
15 | { type: "not" }117 | { type: "not" }
...@@ -29,6 +131,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [...@@ -29,6 +131,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [
29 { key: "mod+n", command: "terminal.new", when: "terminalFocus" },131 { key: "mod+n", command: "terminal.new", when: "terminalFocus" },
30 { key: "mod+w", command: "terminal.close", when: "terminalFocus" },132 { key: "mod+w", command: "terminal.close", when: "terminalFocus" },
31 { key: "mod+w", command: "rightPanel.close", when: "!terminalFocus" },133 { key: "mod+w", command: "rightPanel.close", when: "!terminalFocus" },
134 { key: "mod+shift+t", command: "view.reopenClosed" },
32 { key: "mod+d", command: "diff.toggle", when: "!terminalFocus" },135 { key: "mod+d", command: "diff.toggle", when: "!terminalFocus" },
33 { key: "mod+shift+j", command: "preview.toggle" },136 { key: "mod+shift+j", command: "preview.toggle" },
34 { key: "mod+r", command: "preview.refresh", when: "previewFocus" },137 { key: "mod+r", command: "preview.refresh", when: "previewFocus" },