diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 66b4b48a183dea35afc030fd0111367728144bdc..00cbe008c4a291c709f65c77f513e7d74538ff30 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -68,6 +68,7 @@ export const SET_WSL_BACKEND_ENABLED_CHANNEL = "desktop:set-wsl-backend-enabled" export const SET_WSL_DISTRO_CHANNEL = "desktop:set-wsl-distro"; export const SET_WSL_ONLY_CHANNEL = "desktop:set-wsl-only"; export const SSH_PASSWORD_PROMPT_CANCELLED_RESULT = "ssh-password-prompt-cancelled"; +export const PREVIEW_SET_FORWARDED_SHORTCUTS_CHANNEL = "desktop:preview-set-forwarded-shortcuts"; export const PREVIEW_CREATE_TAB_CHANNEL = "desktop:preview-create-tab"; export const PREVIEW_CLOSE_TAB_CHANNEL = "desktop:preview-close-tab"; export const PREVIEW_REGISTER_WEBVIEW_CHANNEL = "desktop:preview-register-webview"; diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index 89c518580f719d65012230c641041df55bee3e47..e9b795a23261271f9b2806df126a755d33e0ff4b 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -20,6 +20,8 @@ import { PreviewAnnotationSubmissionResultSchema, DEFAULT_BROWSER_PROFILE_ID, INCOGNITO_BROWSER_PROFILE_ID, + PreviewForwardedShortcut, + MAX_KEYBINDINGS_COUNT, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; @@ -53,6 +55,16 @@ export const installPreviewEventForwarding = Effect.fn( ); }); +export const setForwardedShortcuts = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PREVIEW_SET_FORWARDED_SHORTCUTS_CHANNEL, + payload: Schema.Array(PreviewForwardedShortcut).check(Schema.isMaxLength(MAX_KEYBINDINGS_COUNT)), + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.setForwardedShortcuts")(function* (shortcuts) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.setForwardedShortcuts(shortcuts); + }), +}); + export const createTab = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_CREATE_TAB_CHANNEL, payload: DesktopPreviewCreateTabInputSchema, @@ -406,6 +418,7 @@ export const saveRecording = DesktopIpc.makeIpcMethod({ }); export const methods = [ + setForwardedShortcuts, createTab, closeTab, registerWebview, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 5ca77997af40008c456c587e32cc05728d364f71..d6e6470483fc30bf9a5e04ae38f3d4b7cad43fa7 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -299,6 +299,8 @@ contextBridge.exposeInMainWorld("desktopBridge", { }, }, preview: { + setForwardedShortcuts: (shortcuts) => + ipcRenderer.invoke(IpcChannels.PREVIEW_SET_FORWARDED_SHORTCUTS_CHANNEL, shortcuts), createTab: (tabId, defaults) => ipcRenderer.invoke(IpcChannels.PREVIEW_CREATE_TAB_CHANNEL, { tabId, diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 7d227a9e8e42dd97fb3a123fd3472da9b81c18dd..9b8bf7c50de614ae1d31c6343dba9dccd6e1725c 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -6,6 +6,7 @@ import type { DesktopPreviewRecordingInputEvent, } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { parseKeybindingShortcut } from "@t3tools/shared/keybindings"; import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -269,32 +270,34 @@ const layerFileSystem = FileSystem.layerNoop({ }), }); -const layer = PreviewManager.layer.pipe( - Layer.provideMerge( - Layer.succeed(DesktopRendererHistory.DesktopRendererHistory, { - register: () => Effect.void, - recordMetrics: () => Effect.void, - shutdown: Effect.void, - }), - ), - Layer.provideMerge(DesktopBrowserHost.layer), - Layer.provideMerge(layerBrowserSession), - Layer.provideMerge(layerEnvironment), - Layer.provideMerge(layerFileSystem), - Layer.provideMerge(Path.layer), - Layer.provideMerge(NodeCrypto.layer), - Layer.provideMerge(Layer.succeed(HostProcessPlatform, "darwin")), -); +const managerLayer = (platform: NodeJS.Platform = "darwin") => + PreviewManager.layer.pipe( + Layer.provideMerge( + Layer.succeed(DesktopRendererHistory.DesktopRendererHistory, { + register: () => Effect.void, + recordMetrics: () => Effect.void, + shutdown: Effect.void, + }), + ), + Layer.provideMerge(DesktopBrowserHost.layer), + Layer.provideMerge(layerBrowserSession), + Layer.provideMerge(layerEnvironment), + Layer.provideMerge(layerFileSystem), + Layer.provideMerge(Path.layer), + Layer.provideMerge(NodeCrypto.layer), + Layer.provideMerge(Layer.succeed(HostProcessPlatform, platform)), + ); const withManager = ( use: ( manager: PreviewManager.PreviewManager["Service"], ) => Effect.Effect, + platform: NodeJS.Platform = "darwin", ) => Effect.gen(function* () { const manager = yield* PreviewManager.PreviewManager; return yield* use(manager); - }).pipe(Effect.provide(layer), Effect.scoped); + }).pipe(Effect.provide(managerLayer(platform)), Effect.scoped); interface TestCapturedPreviewImage { readonly toJPEG: () => Buffer; @@ -574,7 +577,7 @@ describe("PreviewManager", () => { Effect.gen(function* () { const preview = makeFaviconWebContents(); const sendInputEvent = vi.fn(); - const hostWebContents = { sendInputEvent }; + const hostWebContents = { sendInputEvent, isDestroyed: () => false }; Object.assign(preview.webContents, { hostWebContents }); fromId.mockReturnValue(preview.webContents); getFocusedWebContents.mockReturnValue(preview.webContents as never); @@ -628,6 +631,108 @@ describe("PreviewManager", () => { ), ); + effectIt.effect.each([ + ["mod+shift+t", "view.reopenClosed"], + ["ctrl+alt+u", "sidebar.toggle"], + ] as const)( + "forwards only the configured command from the focused guest: %s", + ([chord, command]) => + withManager((manager) => + Effect.gen(function* () { + const preview = makeFaviconWebContents(); + const send = vi.fn(); + Object.assign(preview.webContents, { + hostWebContents: { isDestroyed: () => false, send }, + }); + fromId.mockReturnValue(preview.webContents); + getFocusedWebContents.mockReturnValue(preview.webContents as never); + yield* manager.createTab("tab_reopen"); + yield* manager.registerWebview("tab_reopen", 42); + const shortcut = parseKeybindingShortcut(chord)!; + yield* manager.setForwardedShortcuts([{ command, shortcut }]); + const beforeInput = preview.listeners.get("before-input-event")!; + const input = { + type: "keyDown", + key: shortcut.key.toUpperCase(), + meta: shortcut.metaKey || shortcut.modKey, + control: shortcut.ctrlKey, + shift: shortcut.shiftKey, + alt: shortcut.altKey, + }; + const preventDefault = vi.fn(); + beforeInput({ preventDefault } as never, input as never); + expect(preventDefault).toHaveBeenCalledOnce(); + expect(send).toHaveBeenCalledExactlyOnceWith("desktop:menu-action", command); + for (const overrides of [ + { isAutoRepeat: true }, + { type: "keyUp" }, + { shift: !input.shift }, + { key: "x" }, + ]) { + preventDefault.mockClear(); + beforeInput({ preventDefault } as never, { ...input, ...overrides } as never); + expect(preventDefault).toHaveBeenCalledTimes("isAutoRepeat" in overrides ? 1 : 0); + expect(send).toHaveBeenCalledOnce(); + } + getFocusedWebContents.mockReturnValue(null); + preventDefault.mockClear(); + beforeInput({ preventDefault } as never, input as never); + expect(preventDefault).not.toHaveBeenCalled(); + expect(send).toHaveBeenCalledOnce(); + getFocusedWebContents.mockReturnValue(preview.webContents as never); + yield* manager.setForwardedShortcuts([]); + beforeInput({ preventDefault } as never, input as never); + expect(preventDefault).not.toHaveBeenCalled(); + expect(send).toHaveBeenCalledOnce(); + }), + ), + ); + + effectIt.effect.each([ + ["linux", "ctrl+alt+[", "[", "BracketLeft", false], + ["win32", "ctrl+alt+8", "[", "Digit8", false], + ["win32", "ctrl+alt+k", "K", "KeyK", false], + ["linux", "ctrl+alt+`", "Dead", "Backquote", false], + ["darwin", "ctrl+alt+[", "[", "BracketLeft", true], + ["linux", "ctrl+alt+f7", "F7", "F7", true], + ] as const)( + "handles ambiguous preview chords on %s: %s", + ([platform, chord, key, code, expected]) => + withManager( + (manager) => + Effect.gen(function* () { + const preview = makeFaviconWebContents(); + const send = vi.fn(); + Object.assign(preview.webContents, { + hostWebContents: { isDestroyed: () => false, send }, + }); + fromId.mockReturnValue(preview.webContents); + getFocusedWebContents.mockReturnValue(preview.webContents as never); + yield* manager.createTab("tab_alt_graph"); + yield* manager.registerWebview("tab_alt_graph", 42); + yield* manager.setForwardedShortcuts([ + { command: "view.reopenClosed", shortcut: parseKeybindingShortcut(chord)! }, + ]); + const preventDefault = vi.fn(); + preview.listeners.get("before-input-event")!( + { preventDefault } as never, + { + type: "keyDown", + key, + code, + meta: false, + control: true, + shift: false, + alt: true, + } as never, + ); + expect(preventDefault).toHaveBeenCalledTimes(expected ? 1 : 0); + expect(send).toHaveBeenCalledTimes(expected ? 1 : 0); + }), + platform, + ), + ); + effectIt.effect("preserves focused browser editing in tabs and sign-in popups", () => withManager((manager) => Effect.gen(function* () { @@ -2716,7 +2821,7 @@ describe("PreviewManager", () => { expect(yield* nextType).toBe("attached"); yield* manager.closeTab("tab_1"); expect(yield* nextType).toBe("detached"); - }).pipe(Effect.provide(layer), Effect.scoped), + }).pipe(Effect.provide(managerLayer()), Effect.scoped), ); effectIt.effect("stops capture retries when the tab swaps during the retry delay", () => diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 9c797d2a4daa2a7d4bfb4d0f002aa0f105cd26a8..74b391a025b06b2012c5234632e2ab9eedd122ae 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -22,8 +22,10 @@ import type { DesktopPreviewRecordingInputEvent, DesktopPreviewScreenshotArtifact, DesktopPreviewTabDefaults, + PreviewForwardedShortcut, } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { matchesKeybindingShortcut } from "@t3tools/shared/keybindings"; import { normalizePreviewUrl } from "@t3tools/shared/preview"; import { BrowserWindow, @@ -58,7 +60,7 @@ import * as SynchronizedRef from "effect/SynchronizedRef"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopRendererHistory from "../telemetry/DesktopRendererHistory.ts"; -import { PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL } from "../ipc/channels.ts"; +import { MENU_ACTION_CHANNEL, PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL } from "../ipc/channels.ts"; import * as DesktopBrowserHost from "./DesktopBrowserHost.ts"; import * as BrowserSession from "./BrowserSession.ts"; import { @@ -555,6 +557,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const runFork = Effect.runForkWith(context); const resolvedArtifactDirectory = path.resolve(artifactDirectory); + let forwardedShortcuts: ReadonlyArray = []; const annotationThemeRef = yield* Ref.make(DEFAULT_ANNOTATION_THEME); const mainWindowRef = yield* Ref.make>(Option.none()); const tabsRef = yield* SynchronizedRef.make>(new Map()); @@ -1520,6 +1523,38 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }; const beforeInput = (event: Electron.Event, input: Electron.Input): void => { syncMenuShortcuts(wc, input); + const host = wc.hostWebContents; + const forwarded = + input.type === "keyDown" && + !input.isComposing && + host && + !host.isDestroyed() && + webContents.getFocusedWebContents() === wc && + !( + hostPlatform !== "darwin" && + input.control && + input.alt && + /^(?:[^a-zA-Z0-9]|Dead)$/u.test(input.key) + ) && + forwardedShortcuts.find(({ shortcut }) => + matchesKeybindingShortcut( + { + key: input.key, + code: input.code, + metaKey: input.meta, + ctrlKey: input.control, + shiftKey: input.shift, + altKey: input.alt, + }, + shortcut, + hostPlatform === "darwin" ? "MacIntel" : hostPlatform, + ), + ); + if (forwarded && host) { + event.preventDefault(); + if (!input.isAutoRepeat) host.send(MENU_ACTION_CHANNEL, forwarded.command); + return; + } if (isPreviewRefreshShortcut(input)) { event.preventDefault(); runFork( @@ -3324,6 +3359,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function setAudioMuted, setColorScheme, setMainWindow, + setForwardedShortcuts: (shortcuts: ReadonlyArray) => + Effect.sync(() => { + forwardedShortcuts = shortcuts; + }), startRecording, closePictureInPicture, stopRecording, @@ -3484,6 +3523,9 @@ export class PreviewManager extends Context.Service< PreviewManager, { readonly setMainWindow: (window: BrowserWindow) => Effect.Effect; + readonly setForwardedShortcuts: ( + shortcuts: ReadonlyArray, + ) => Effect.Effect; readonly getBrowserSession: ( scope?: string, persistent?: boolean, @@ -3595,6 +3637,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { return PreviewManager.of({ setMainWindow: operations.setMainWindow, + setForwardedShortcuts: operations.setForwardedShortcuts, getBrowserSession: Effect.fn("PreviewManager.getBrowserSession")( function* (scope, persistent, namespace) { const session = yield* browserSession diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index b0385cb029b57a632c6047e75cadf45fb260ac5b..df5175ef65a30fcac449b6b3f279edb1c098420c 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -257,7 +257,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { Effect.gen(function* () { const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; yield* writeKeybindingsConfig(keybindingsConfigPath, [ - { key: "mod+shift+t", command: "terminal.toggle" }, + { key: "mod+shift+y", command: "terminal.toggle" }, { key: "mod+shift+r", command: "script.run-tests.run" }, ]); @@ -271,7 +271,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { const persistedToggle = byCommand.get("terminal.toggle"); assert.isNotNull(persistedToggle); - assert.equal(persistedToggle?.key, "mod+shift+t"); + assert.equal(persistedToggle?.key, "mod+shift+y"); assert.isFalse( persisted.some((entry) => entry.command === "terminal.toggle" && entry.key === "mod+j"), ); diff --git a/apps/web/src/closedViewStore.test.ts b/apps/web/src/closedViewStore.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..6bb4631bf5725ccf2d1993f6958652be234ab636 --- /dev/null +++ b/apps/web/src/closedViewStore.test.ts @@ -0,0 +1,220 @@ +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { + type EnvironmentId, + INCOGNITO_BROWSER_PROFILE_ID, + type PreviewSessionSnapshot, + ThreadId, +} from "@t3tools/contracts"; +import { beforeEach, describe, expect, it } from "vite-plus/test"; + +import { type ClosedViewEntry, useClosedViewStore } from "./closedViewStore"; + +const refA = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-A")); +const refB = scopeThreadRef("env-2" as EnvironmentId, ThreadId.make("thread-A")); +const diff = (threadRef: typeof refA) => + ({ kind: "panel-tab", threadRef, surface: { kind: "diff", id: "diff" } }) as const; + +beforeEach(() => { + useClosedViewStore.setState({ entries: [] }); +}); + +const snapshot: PreviewSessionSnapshot = { + threadId: refA.threadId, + tabId: "private-tab", + profileId: INCOGNITO_BROWSER_PROFILE_ID, + navStatus: { _tag: "Success", url: "https://private.example", title: "Private page" }, + canGoBack: false, + canGoForward: false, + updatedAt: "2026-10-06T17:00:00.000Z", +}; + +describe("closedViewStore", () => { + it.each([null, {}, { entries: null }, { entries: {} }, { entries: "invalid" }])( + "rehydrates malformed older history %j as empty history", + async (state) => { + useClosedViewStore.getState().remember(diff(refA)); + const { storage, name } = useClosedViewStore.persist.getOptions(); + await storage!.setItem(name!, { + state: state as { entries: ClosedViewEntry[] }, + version: 0, + }); + await useClosedViewStore.persist.rehydrate(); + expect(useClosedViewStore.persist.hasHydrated()).toBe(true); + expect(useClosedViewStore.getState().entries).toEqual([]); + expect(await storage!.getItem(name!)).toEqual({ state: { entries: [] }, version: 2 }); + }, + ); + + it.each([ + { id: "bad", kind: "browser", threadRef: refA, snapshot: {} }, + { + id: "bad", + kind: "browser", + threadRef: refA, + snapshot: { ...snapshot, profileId: "default", navStatus: null }, + }, + { id: "bad", kind: "panel-tab", surface: { kind: "diff", id: "diff" } }, + { id: "bad", kind: "panel-tab", threadRef: refA }, + { id: "bad", kind: "panel-tab", threadRef: {}, surface: { kind: "diff", id: "diff" } }, + { id: "bad", kind: "panel-tab", threadRef: refA, surface: {} }, + { id: "bad", kind: "panel-tab", threadRef: refA, surface: { kind: "file", id: "file:x" } }, + { + id: "bad", + kind: "panel-tab", + threadRef: refA, + surface: { kind: "device", id: "device:x", target: {} }, + }, + { + id: "bad", + kind: "panel-tab", + threadRef: refA, + surface: { kind: "pull-request", id: "pull-request:x", repository: "owner/repo", number: 1 }, + }, + ])("discards incomplete saved views %j while retaining valid history", async (entry) => { + const id = useClosedViewStore.getState().remember(diff(refA)); + const { storage, name } = useClosedViewStore.persist.getOptions(); + await storage!.setItem(name!, { + state: { entries: [entry as ClosedViewEntry, ...useClosedViewStore.getState().entries] }, + version: 1, + }); + await useClosedViewStore.persist.rehydrate(); + expect(useClosedViewStore.persist.hasHydrated()).toBe(true); + expect(useClosedViewStore.getState().entries).toEqual([{ ...diff(refA), id }]); + expect(() => useClosedViewStore.getState().remember(diff(refB))).not.toThrow(); + }); + + it("preserves every supported saved panel type and its restore data", async () => { + const surfaces = [ + { kind: "diff", id: "diff" }, + { kind: "files", id: "files" }, + { kind: "pull-requests", id: "pull-requests" }, + { kind: "preview", id: "browser:new", resourceId: null }, + { kind: "preview", id: "browser:saved", resourceId: "saved" }, + { kind: "device", id: "device" }, + { + kind: "device", + id: "device:phone", + title: "My phone", + target: { hostId: "mac", deviceId: "phone", platform: "ios", name: "iPhone" }, + }, + { kind: "file", id: "file:x", relativePath: "x", revealLine: 12, revealRequestId: 1 }, + { + kind: "file", + id: "attachment:a", + relativePath: "report.pdf", + revealLine: null, + revealRequestId: 0, + attachment: { + type: "file", + id: "a", + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 1, + }, + }, + { + kind: "pull-request", + id: "pull-request:x", + projectId: "project", + repository: "owner/repo", + number: 1, + environmentId: "env-1", + host: "github.com", + url: "https://github.com/owner/repo/pull/1", + }, + ]; + const entries = surfaces.map((surface, index) => ({ + id: String(index), + kind: "panel-tab", + threadRef: refA, + surface, + })) as ClosedViewEntry[]; + const { storage, name } = useClosedViewStore.persist.getOptions(); + await storage!.setItem(name!, { state: { entries }, version: 0 }); + await useClosedViewStore.persist.rehydrate(); + expect(useClosedViewStore.persist.hasHydrated()).toBe(true); + expect(useClosedViewStore.getState().entries).toEqual(entries); + }); + + it("keeps private tabs available in memory without saving their metadata", async () => { + const store = useClosedViewStore.getState(); + const publicId = store.remember(diff(refA)); + const privateId = store.remember({ kind: "browser", threadRef: refA, snapshot }); + expect(useClosedViewStore.getState().entries.map((entry) => entry.id)).toEqual([ + privateId, + publicId, + ]); + const { storage, name } = useClosedViewStore.persist.getOptions(); + const saved = JSON.stringify(await storage!.getItem(name!)); + expect(saved).not.toContain("private.example"); + expect(saved).not.toContain("Private page"); + await useClosedViewStore.persist.rehydrate(); + expect(useClosedViewStore.getState().entries.map((entry) => entry.id)).toEqual([publicId]); + }); + + it("removes private metadata and malformed entries from history saved by earlier versions", async () => { + const store = useClosedViewStore.getState(); + const publicId = store.remember(diff(refA)); + const browserId = store.remember({ + kind: "browser", + threadRef: refA, + snapshot: { + ...snapshot, + tabId: "public-tab", + profileId: "default", + navStatus: { _tag: "Success", url: "https://public.example", title: "Public page" }, + }, + }); + store.remember({ kind: "browser", threadRef: refA, snapshot }); + const { storage, name } = useClosedViewStore.persist.getOptions(); + await storage!.setItem(name!, { + state: { + entries: [ + null, + {}, + { kind: "browser" }, + { kind: "browser", snapshot: null }, + ...useClosedViewStore.getState().entries, + ] as ClosedViewEntry[], + }, + version: 0, + }); + await useClosedViewStore.persist.rehydrate(); + expect(useClosedViewStore.persist.hasHydrated()).toBe(true); + expect(useClosedViewStore.getState().entries.map((entry) => entry.id)).toEqual([ + browserId, + publicId, + ]); + const saved = JSON.stringify(await storage!.getItem(name!)); + expect(saved).not.toContain("private.example"); + expect(saved).not.toContain("Private page"); + }); + + it("keeps newest first, moves a re-closed tab to the front, and caps at 20", () => { + const store = useClosedViewStore.getState(); + store.remember(diff(refA)); + store.remember(diff(refB)); + store.remember(diff(refA)); + expect(useClosedViewStore.getState().entries).toMatchObject([ + { threadRef: refA }, + { threadRef: refB }, + ]); + + for (let index = 0; index < 24; index++) { + store.remember({ + kind: "panel-tab", + threadRef: refA, + surface: { + kind: "file", + id: `file:${index}`, + relativePath: `${index}.ts`, + revealLine: null, + revealRequestId: 0, + }, + }); + } + const entries = useClosedViewStore.getState().entries; + expect(entries).toHaveLength(20); + expect(entries[0]).toMatchObject({ surface: { id: "file:23" } }); + }); +}); diff --git a/apps/web/src/closedViewStore.ts b/apps/web/src/closedViewStore.ts new file mode 100644 index 0000000000000000000000000000000000000000..76fee83a83d088590e94ba17cf3b270dd0d9e497 --- /dev/null +++ b/apps/web/src/closedViewStore.ts @@ -0,0 +1,143 @@ +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; +import { + ChatFileAttachment, + INCOGNITO_BROWSER_PROFILE_ID, + PreviewSessionSnapshot, + ScopedThreadRef, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; + +import { resolveStorage } from "./lib/storage"; +import { randomUUID } from "./lib/utils"; +import { type RightPanelSurface } from "./rightPanelStore"; + +export type ClosedView = + | { + kind: "panel-tab"; + threadRef: ScopedThreadRef; + surface: Exclude; + } + | { kind: "browser"; threadRef: ScopedThreadRef; snapshot: PreviewSessionSnapshot }; + +export type ClosedViewEntry = ClosedView & { id: string }; + +interface ClosedViewStoreState { + entries: ClosedViewEntry[]; + remember: (view: ClosedView) => string; + defer: (id: string) => void; + remove: (id: string) => void; +} + +const sameTarget = (entry: ClosedViewEntry, view: ClosedView): boolean => { + if ( + entry.kind !== view.kind || + scopedThreadKey(entry.threadRef) !== scopedThreadKey(view.threadRef) + ) { + return false; + } + switch (entry.kind) { + case "panel-tab": + return view.kind === "panel-tab" && entry.surface.id === view.surface.id; + case "browser": + return view.kind === "browser" && entry.snapshot.tabId === view.snapshot.tabId; + } +}; + +const isPersistentView = (entry: ClosedViewEntry) => + entry.kind !== "browser" || entry.snapshot.profileId !== INCOGNITO_BROWSER_PROFILE_ID; + +const isThreadRef = Schema.is(ScopedThreadRef); +const isSnapshot = Schema.is(PreviewSessionSnapshot); +const isAttachment = Schema.is(ChatFileAttachment); + +const isClosedViewEntry = (entry: unknown): entry is ClosedViewEntry => { + const view = entry as ClosedViewEntry | null; + if (!view || typeof view.id !== "string" || !isThreadRef(view.threadRef)) return false; + if (view.kind === "browser") return isSnapshot(view.snapshot); + if (view.kind !== "panel-tab") return false; + const surface = view.surface; + if (!surface || typeof surface.id !== "string") return false; + switch (surface.kind) { + case "diff": + case "files": + case "pull-requests": + return surface.id === surface.kind; + case "preview": + return surface.resourceId === null || typeof surface.resourceId === "string"; + case "file": + return ( + typeof surface.relativePath === "string" && + (surface.revealLine === null || Number.isSafeInteger(surface.revealLine)) && + Number.isSafeInteger(surface.revealRequestId) && + (surface.attachment === undefined || isAttachment(surface.attachment)) + ); + case "device": + return ( + (surface.title === undefined || typeof surface.title === "string") && + (surface.target === undefined || + (surface.target !== null && + typeof surface.target.hostId === "string" && + typeof surface.target.deviceId === "string" && + typeof surface.target.name === "string" && + (surface.target.platform === "ios" || surface.target.platform === "android"))) + ); + case "pull-request": + return ( + typeof surface.projectId === "string" && + typeof surface.repository === "string" && + Number.isSafeInteger(surface.number) && + surface.number > 0 && + (surface.environmentId === undefined || typeof surface.environmentId === "string") && + (surface.host === undefined || typeof surface.host === "string") && + (surface.url === undefined || typeof surface.url === "string") + ); + default: + return false; + } +}; + +export const useClosedViewStore = create()( + persist( + (set) => ({ + entries: [], + remember: (view) => { + const id = randomUUID(); + set((state) => ({ + entries: [ + { ...view, id }, + ...state.entries.filter((entry) => !sameTarget(entry, view)), + ].slice(0, 20), + })); + return id; + }, + defer: (id) => + set((state) => { + const entry = state.entries.find((entry) => entry.id === id); + return entry + ? { entries: [...state.entries.filter((entry) => entry.id !== id), entry] } + : state; + }), + remove: (id) => + set((state) => ({ entries: state.entries.filter((entry) => entry.id !== id) })), + }), + { + name: "t3code:closed-views:v2", + storage: createJSONStorage(() => + resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined), + ), + version: 2, + migrate: (persisted) => { + const entries = (persisted as Partial> | null) + ?.entries; + return { + entries: Array.isArray(entries) + ? entries.filter(isClosedViewEntry).filter(isPersistentView) + : [], + }; + }, + partialize: ({ entries }) => ({ entries: entries.filter(isPersistentView) }), + }, + ), +); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 9f6256ff7d763fb2dee49477498ab30dbfc5a905..068b69ce0632890cb1d01e0e44b657fc9a22a3da 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -5947,9 +5947,16 @@ export default function ChatView(props: ChatViewProps) { const finishRightPanelSurfaceClose = useCallback( (surfaces: readonly RightPanelSurface[]) => { if (!activeThreadRef) return; - cleanupRightPanelSurfaces(surfaces); const store = useRightPanelStore.getState(); - for (const surface of surfaces) { + const activeId = selectThreadRightPanelState( + store.byThreadKey, + activeThreadRef, + ).activeSurfaceId; + const ordered = surfaces.toSorted( + (left, right) => Number(left.id === activeId) - Number(right.id === activeId), + ); + for (const surface of ordered) { + cleanupRightPanelSurfaces([surface]); store.closeSurface(activeThreadRef, surface.id); } syncActivePreviewSurface(); diff --git a/apps/web/src/components/ReopenClosedViewShortcut.test.tsx b/apps/web/src/components/ReopenClosedViewShortcut.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e43fd4069b691ba2056ff6cab3c53704fbd1fcd2 --- /dev/null +++ b/apps/web/src/components/ReopenClosedViewShortcut.test.tsx @@ -0,0 +1,325 @@ +import type { ResolvedKeybindingsConfig, ScopedThreadRef } from "@t3tools/contracts"; +import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/reactivity"; +import { act } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const state = vi.hoisted(() => ({ + keybindings: [] as ResolvedKeybindingsConfig, + params: {} as Record, + paletteOpen: false, + workspaceAvailable: true, + navigate: vi.fn(), + openPreview: vi.fn(), + setShortcuts: vi.fn(async () => undefined), + toast: vi.fn(), +})); + +vi.mock("./preview/openPreviewSession", () => ({ openPreviewSession: state.openPreview })); +vi.mock("@effect/atom-react", () => ({ useAtomValue: () => state.keybindings })); +vi.mock("@tanstack/react-router", () => ({ + useNavigate: () => state.navigate, + useParams: () => state.params, +})); +vi.mock("../state/server", () => ({ primaryServerKeybindingsAtom: {} })); +vi.mock("../state/preview", () => ({ previewEnvironment: { open: state.openPreview } })); +vi.mock("../state/use-atom-command", () => ({ useAtomCommand: (command: unknown) => command })); +vi.mock("../state/shell", () => ({ environmentShell: { stateValueAtom: () => ({}) } })); +vi.mock("../connection/catalog", () => ({ environmentCatalog: { catalogValueAtom: "catalog" } })); +vi.mock("../rpc/atomRegistry", () => ({ + appAtomRegistry: { + get: (atom: unknown) => + atom === "catalog" + ? { isReady: true, entries: new Map([["remote", {}]]) } + : { status: "live" }, + }, +})); +vi.mock("../state/entities", () => ({ + readThreadShell: () => ({ projectId: "project-1", worktreePath: null }), + readProject: () => (state.workspaceAvailable ? { workspaceRoot: "/work/project" } : null), +})); +vi.mock("../composerDraftStore", () => { + const store = { + getDraftSession: () => null, + getDraftThreadByRef: () => null, + getDraftIdByRef: () => null, + }; + return { + useComposerDraftStore: Object.assign( + (select: (value: typeof store) => unknown) => select(store), + { getState: () => store }, + ), + }; +}); +vi.mock("../commandPaletteBus", () => ({ isCommandPaletteOpen: () => state.paletteOpen })); +vi.mock("../lib/editableFocus", () => ({ isEditableFocused: () => false })); +vi.mock("../lib/previewFocus", () => ({ isPreviewFocused: () => false })); +vi.mock("../lib/terminalFocus", () => ({ isTerminalFocused: () => false })); +vi.mock("../modelPickerVisibility", () => ({ isModelPickerOpen: () => false })); +vi.mock("./ui/toast", () => ({ toastManager: { add: state.toast } })); + +import { useClosedViewStore } from "../closedViewStore"; +import { selectThreadRightPanelState, useRightPanelStore } from "../rightPanelStore"; +import { useTerminalUiStateStore } from "../terminalUiStateStore"; +import { ReopenClosedViewShortcut } from "./ReopenClosedViewShortcut"; + +const ref = { environmentId: "remote", threadId: "thread-1" } as ScopedThreadRef; +const snapshot = { + threadId: ref.threadId, + tabId: "closed-tab", + navStatus: { _tag: "Success", url: "https://example.com", title: "Example" }, + canGoBack: false, + canGoForward: false, + updatedAt: "2026-09-22T12:00:00.000Z", +} as const; +class TestElement extends EventTarget { + closest(selector: string) { + return selector === "[data-keybinding-capture]" ? this : null; + } +} +let renderer: ReactTestRenderer | undefined; +let menuAction: ((action: string) => void) | undefined; + +async function render() { + await act(() => { + renderer = create(); + }); +} + +function press(overrides: Record = {}) { + const event = Object.assign(new Event("keydown", { cancelable: true }), { + key: "T", + ctrlKey: true, + metaKey: false, + shiftKey: true, + altKey: false, + repeat: false, + ...overrides, + }); + window.dispatchEvent(event); + return event; +} + +beforeEach(() => { + vi.clearAllMocks(); + Object.assign(state, { + keybindings: DEFAULT_RESOLVED_KEYBINDINGS, + params: {}, + paletteOpen: false, + workspaceAvailable: true, + }); + state.navigate.mockResolvedValue(undefined); + state.openPreview.mockReset(); + useClosedViewStore.setState({ entries: [] }); + useRightPanelStore.setState({ byThreadKey: {}, userActionRevisionByThreadKey: {} }); + useTerminalUiStateStore.setState({ + terminalUiStateByThreadKey: {}, + suppressedTerminalIdsByThreadKey: {}, + }); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("HTMLElement", TestElement); + vi.stubGlobal("navigator", { platform: "Linux" }); + vi.stubGlobal( + "window", + Object.assign(new EventTarget(), { + desktopBridge: { + preview: { setForwardedShortcuts: state.setShortcuts }, + onMenuAction: (listener: typeof menuAction) => { + menuAction = listener; + return () => { + menuAction = undefined; + }; + }, + }, + }), + ); +}); + +afterEach(async () => { + await act(() => renderer?.unmount()); + renderer = undefined; + vi.unstubAllGlobals(); +}); + +describe("root reopen shortcut", () => { + it("restores a tab from settings to its owning remote thread", async () => { + useClosedViewStore + .getState() + .remember({ kind: "panel-tab", threadRef: ref, surface: { kind: "diff", id: "diff" } }); + await render(); + await act(() => { + expect(press().defaultPrevented).toBe(true); + }); + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, ref).activeSurfaceId, + ).toBe("diff"); + expect(state.navigate).toHaveBeenCalledWith({ to: "/$environmentId/$threadId", params: ref }); + expect(useClosedViewStore.getState().entries).toEqual([]); + }); + + it("leaves native shortcuts alone without history or while the palette is open", async () => { + await render(); + expect(press().defaultPrevented).toBe(false); + await act(() => { + useClosedViewStore + .getState() + .remember({ kind: "panel-tab", threadRef: ref, surface: { kind: "diff", id: "diff" } }); + }); + state.paletteOpen = true; + expect(press().defaultPrevented).toBe(false); + expect(state.navigate).not.toHaveBeenCalled(); + expect(useClosedViewStore.getState().entries).toHaveLength(1); + }); + + it("lets the keybinding recorder capture the chord", async () => { + useClosedViewStore + .getState() + .remember({ kind: "panel-tab", threadRef: ref, surface: { kind: "diff", id: "diff" } }); + await render(); + const recorder = new TestElement(); + const event = Object.assign(new Event("keydown", { bubbles: true, cancelable: true }), { + key: "T", + ctrlKey: true, + metaKey: false, + shiftKey: true, + altKey: false, + repeat: false, + }); + Object.defineProperty(event, "target", { value: recorder }); + window.dispatchEvent(event); + expect(event.defaultPrevented).toBe(false); + expect(useClosedViewStore.getState().entries).toHaveLength(1); + }); + + it("serializes deliberate presses while a browser restore waits for RPC", async () => { + useClosedViewStore + .getState() + .remember({ kind: "panel-tab", threadRef: ref, surface: { kind: "files", id: "files" } }); + useClosedViewStore.getState().remember({ kind: "browser", threadRef: ref, snapshot }); + let finish!: (value: ReturnType) => void; + state.openPreview.mockReturnValue( + new Promise((resolve) => { + finish = resolve; + }), + ); + await render(); + await act(() => { + press(); + press(); + }); + expect(state.openPreview).toHaveBeenCalledTimes(1); + expect(state.navigate).not.toHaveBeenCalled(); + await act(() => { + finish(AsyncResult.success({ tabId: "new-tab" })); + }); + expect(state.navigate).toHaveBeenCalledTimes(2); + expect(useClosedViewStore.getState().entries).toEqual([]); + }); + + it("does not create a second browser when navigation fails after restoring it", async () => { + const store = useClosedViewStore.getState(); + const older = store.remember({ + kind: "panel-tab", + threadRef: ref, + surface: { kind: "diff", id: "diff" }, + }); + store.remember({ kind: "browser", threadRef: ref, snapshot }); + state.openPreview.mockResolvedValue(AsyncResult.success({ tabId: "new-tab" })); + state.navigate.mockRejectedValueOnce(new Error("navigation failed")); + await render(); + await act(() => { + press(); + }); + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, ref).activeSurfaceId, + ).toBe("browser:new-tab"); + expect(state.toast).toHaveBeenCalledWith( + expect.objectContaining({ title: "Could not reopen view" }), + ); + expect(useClosedViewStore.getState().entries.map((entry) => entry.id)).toEqual([older]); + await act(() => { + menuAction?.("view.reopenClosed"); + }); + expect(state.openPreview).toHaveBeenCalledOnce(); + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, ref).activeSurfaceId, + ).toBe("diff"); + expect(useClosedViewStore.getState().entries).toEqual([]); + }); + + it.each(["files", "file", "browser"] as const)( + "keeps a failed %s restore retryable without blocking older history", + async (kind) => { + const store = useClosedViewStore.getState(); + const older = store.remember({ + kind: "panel-tab", + threadRef: ref, + surface: { kind: "diff", id: "diff" }, + }); + const failed = store.remember( + kind === "browser" + ? { kind: "browser", threadRef: ref, snapshot } + : { + kind: "panel-tab", + threadRef: ref, + surface: + kind === "files" + ? { kind: "files", id: "files" } + : { + kind: "file", + id: "file:src/app.ts", + relativePath: "src/app.ts", + revealLine: null, + revealRequestId: 0, + }, + }, + ); + state.workspaceAvailable = false; + state.openPreview.mockResolvedValue(AsyncResult.failure(Cause.fail(new Error("offline")))); + await render(); + await act(() => { + press(); + }); + expect(useClosedViewStore.getState().entries.map((entry) => entry.id)).toEqual([ + older, + failed, + ]); + expect(state.navigate).not.toHaveBeenCalled(); + await act(() => { + menuAction?.("view.reopenClosed"); + }); + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, ref).activeSurfaceId, + ).toBe("diff"); + expect(useClosedViewStore.getState().entries.map((entry) => entry.id)).toEqual([failed]); + state.workspaceAvailable = true; + state.openPreview.mockResolvedValue(AsyncResult.success({ ...snapshot, tabId: "new-tab" })); + await act(() => { + press(); + }); + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, ref).activeSurfaceId, + ).toBe( + kind === "browser" ? "browser:new-tab" : kind === "file" ? "file:src/app.ts" : "files", + ); + expect(useClosedViewStore.getState().entries).toEqual([]); + }, + ); + + it("forwards the chord to a focused desktop browser only while history exists", async () => { + useClosedViewStore + .getState() + .remember({ kind: "panel-tab", threadRef: ref, surface: { kind: "diff", id: "diff" } }); + await render(); + expect(state.setShortcuts).toHaveBeenLastCalledWith([ + expect.objectContaining({ command: "view.reopenClosed" }), + ]); + await act(() => { + menuAction?.("view.reopenClosed"); + }); + expect(useClosedViewStore.getState().entries).toEqual([]); + expect(state.setShortcuts).toHaveBeenLastCalledWith([]); + }); +}); diff --git a/apps/web/src/components/ReopenClosedViewShortcut.tsx b/apps/web/src/components/ReopenClosedViewShortcut.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d8e3b65a5c9eb2a886ddfffb01191418c22015d6 --- /dev/null +++ b/apps/web/src/components/ReopenClosedViewShortcut.tsx @@ -0,0 +1,202 @@ +import { useAtomValue } from "@effect/atom-react"; +import { + scopeProjectRef, + scopedThreadKey, + scopeThreadRef, +} from "@t3tools/client-runtime/environment"; +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { useNavigate, useParams } from "@tanstack/react-router"; +import { useEffect, useEffectEvent, useRef } from "react"; + +import { isCommandPaletteOpen } from "../commandPaletteBus"; +import { useClosedViewStore } from "../closedViewStore"; +import { useComposerDraftStore } from "../composerDraftStore"; +import { environmentCatalog } from "../connection/catalog"; +import { effectiveShortcutsForCommand, resolveShortcutCommand } from "../keybindings"; +import { isEditableFocused } from "../lib/editableFocus"; +import { isPreviewFocused } from "../lib/previewFocus"; +import { isTerminalFocused } from "../lib/terminalFocus"; +import { isModelPickerOpen } from "../modelPickerVisibility"; +import { + planNextReopen, + pullRequestsSearchForRestore, + reopenClosedView, +} from "../reopenClosedView"; +import { + PULL_REQUESTS_PANEL_REF, + selectActiveRightPanel, + selectSelectedRightPanelSurface, + selectThreadRightPanelState, + useRightPanelStore, +} from "../rightPanelStore"; +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { readProject, readThreadShell } from "../state/entities"; +import { previewEnvironment } from "../state/preview"; +import { primaryServerKeybindingsAtom } from "../state/server"; +import { environmentShell } from "../state/shell"; +import { useAtomCommand } from "../state/use-atom-command"; +import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; +import { + buildDraftThreadRouteParams, + buildThreadRouteParams, + resolveThreadRouteTarget, +} from "../threadRoutes"; +import { toastManager } from "./ui/toast"; + +const isGlobalPullRequests = (ref: ScopedThreadRef) => + scopedThreadKey(ref) === scopedThreadKey(PULL_REQUESTS_PANEL_REF); + +export function ReopenClosedViewShortcut() { + const navigate = useNavigate(); + const params = useParams({ strict: false }); + const target = resolveThreadRouteTarget(params); + const draft = useComposerDraftStore((state) => + target?.kind === "draft" ? state.getDraftSession(target.draftId) : null, + ); + const threadRef = + target?.kind === "server" + ? target.threadRef + : draft + ? (draft.promotedTo ?? scopeThreadRef(draft.environmentId, draft.threadId)) + : null; + const terminalOpen = useTerminalUiStateStore( + (state) => + selectThreadTerminalUiState(state.terminalUiStateByThreadKey, threadRef).terminalOpen, + ); + const previewOpen = useRightPanelStore( + (state) => selectActiveRightPanel(state.byThreadKey, threadRef) === "preview", + ); + const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const hasHistory = useClosedViewStore((state) => state.entries.length > 0); + const openPreview = useAtomCommand(previewEnvironment.open); + const pending = useRef(Promise.resolve()); + + const reopenNext = useEffectEvent(async () => { + const catalog = appAtomRegistry.get(environmentCatalog.catalogValueAtom); + const drafts = useComposerDraftStore.getState(); + const panelsByThread = useRightPanelStore.getState().byThreadKey; + const { drop, restore } = planNextReopen(useClosedViewStore.getState().entries, (entry) => { + const ref = entry.threadRef; + const panel = selectThreadRightPanelState(panelsByThread, ref); + if (isGlobalPullRequests(ref)) { + return { + environmentKnown: true, + catalogReady: true, + ownerExists: true, + shellLive: true, + panel, + }; + } + return { + environmentKnown: catalog.entries.has(ref.environmentId), + catalogReady: catalog.isReady, + ownerExists: readThreadShell(ref) !== null || drafts.getDraftThreadByRef(ref) !== null, + shellLive: + appAtomRegistry.get(environmentShell.stateValueAtom(ref.environmentId)).status === "live", + panel, + }; + }); + for (const entry of drop) useClosedViewStore.getState().remove(entry.id); + if (!restore) return; + + const ref = restore.threadRef; + const globalPullRequests = isGlobalPullRequests(ref); + const thread = globalPullRequests ? null : readThreadShell(ref); + const owner = thread ?? drafts.getDraftThreadByRef(ref); + const project = owner ? readProject(scopeProjectRef(ref.environmentId, owner.projectId)) : null; + if (!(await reopenClosedView(restore, { openPreview, workspaceAvailable: project !== null }))) { + useClosedViewStore.getState().defer(restore.id); + return; + } + useClosedViewStore.getState().remove(restore.id); + if (globalPullRequests) { + const selected = selectSelectedRightPanelSurface( + useRightPanelStore.getState().byThreadKey, + ref, + ); + await navigate({ + to: "/pull-requests", + search: (previous) => pullRequestsSearchForRestore(previous, selected), + }); + } else { + const draftId = thread === null ? drafts.getDraftIdByRef(ref) : null; + if (draftId !== null) + await navigate({ to: "/draft/$draftId", params: buildDraftThreadRouteParams(draftId) }); + else await navigate({ to: "/$environmentId/$threadId", params: buildThreadRouteParams(ref) }); + } + }); + + const enqueueReopen = useEffectEvent(() => { + pending.current = pending.current + .then(() => reopenNext()) + .catch((error: unknown) => { + toastManager.add({ + type: "error", + title: "Could not reopen view", + description: error instanceof Error ? error.message : String(error), + }); + }); + }); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if ( + event.defaultPrevented || + isCommandPaletteOpen() || + useClosedViewStore.getState().entries.length === 0 || + (event.target instanceof HTMLElement && event.target.closest("[data-keybinding-capture]")) + ) + return; + const command = resolveShortcutCommand(event, keybindings, { + context: { + terminalFocus: isTerminalFocused(), + terminalOpen, + previewFocus: isPreviewFocused(), + previewOpen, + editableFocus: isEditableFocused(event.target), + modelPickerOpen: isModelPickerOpen(), + }, + }); + if (command !== "view.reopenClosed") return; + event.preventDefault(); + event.stopPropagation(); + if (!event.repeat) enqueueReopen(); + }; + window.addEventListener("keydown", onKeyDown, true); + const unsubscribe = window.desktopBridge?.onMenuAction((action) => { + if (action === "view.reopenClosed" && !isCommandPaletteOpen()) enqueueReopen(); + }); + return () => { + window.removeEventListener("keydown", onKeyDown, true); + unsubscribe?.(); + }; + }, [keybindings, previewOpen, terminalOpen]); + + useEffect(() => { + const preview = window.desktopBridge?.preview; + if (!preview?.setForwardedShortcuts) return; + void preview + .setForwardedShortcuts( + hasHistory + ? effectiveShortcutsForCommand(keybindings, "view.reopenClosed", { + context: { + previewFocus: true, + previewOpen: true, + terminalFocus: false, + terminalOpen, + editableFocus: false, + modelPickerOpen: false, + isDesktop: true, + isWeb: false, + }, + }).map((shortcut) => ({ command: "view.reopenClosed", shortcut })) + : [], + ) + .catch(() => undefined); + return () => { + void preview.setForwardedShortcuts?.([]).catch(() => undefined); + }; + }, [hasHistory, keybindings, terminalOpen]); + + return null; +} diff --git a/apps/web/src/components/preview/closePreviewSession.test.ts b/apps/web/src/components/preview/closePreviewSession.test.ts index 1df3e22702aa34d05949d1462ff230e3aed9d09d..75c1f19f20d50a9148b1df8619a395a2c727fbf5 100644 --- a/apps/web/src/components/preview/closePreviewSession.test.ts +++ b/apps/web/src/components/preview/closePreviewSession.test.ts @@ -13,6 +13,8 @@ import { resetPreviewStateForTests, } from "~/previewStateStore"; +import { useClosedViewStore } from "~/closedViewStore"; + import { closePreviewSession } from "./closePreviewSession"; const threadRef = { @@ -33,7 +35,10 @@ const snapshot: PreviewSessionSnapshot = { updatedAt: "2026-06-18T19:00:00.000Z", }; -beforeEach(resetPreviewStateForTests); +beforeEach(() => { + resetPreviewStateForTests(); + useClosedViewStore.setState({ entries: [] }); +}); describe("closePreviewSession", () => { it("suppresses stale server snapshots while the close is in flight", async () => { @@ -53,17 +58,29 @@ describe("closePreviewSession", () => { threadRef, }); + expect(useClosedViewStore.getState().entries).toEqual([]); expect(readThreadPreviewState(threadRef).sessions).toEqual({}); applyPreviewServerSnapshot(threadRef, snapshot); expect(readThreadPreviewState(threadRef).sessions).toEqual({}); finishClose?.(); await closing; + expect(useClosedViewStore.getState().entries).toMatchObject([ + { kind: "browser", threadRef, snapshot }, + ]); expect(closePreview).toHaveBeenCalledWith({ threadId: "thread-1", tabId: "tab-1" }); }); - it("restores the last snapshot when the server close fails", async () => { + it("restores the last snapshot and keeps full history when the server close fails", async () => { applyPreviewServerSnapshot(threadRef, snapshot); + for (let index = 0; index < 20; index++) { + useClosedViewStore.getState().remember({ + kind: "browser", + threadRef, + snapshot: { ...snapshot, tabId: `closed-${index}` }, + }); + } + const history = useClosedViewStore.getState().entries; const result = await closePreviewSession({ closePreview: async () => AsyncResult.failure(Cause.fail(new Error("close failed"))), @@ -73,6 +90,7 @@ describe("closePreviewSession", () => { }); expect(result._tag).toBe("Failure"); + expect(useClosedViewStore.getState().entries).toEqual(history); expect(readThreadPreviewState(threadRef).snapshot).toEqual(snapshot); expect(readThreadPreviewState(threadRef).sessions).toEqual({ [snapshot.tabId]: snapshot }); }); diff --git a/apps/web/src/components/preview/closePreviewSession.ts b/apps/web/src/components/preview/closePreviewSession.ts index 5073029f6d3cad09e398b6e0a72741c49e9da164..959ed3ce0f08ffbd9bb46332ed3c34a658fc3986 100644 --- a/apps/web/src/components/preview/closePreviewSession.ts +++ b/apps/web/src/components/preview/closePreviewSession.ts @@ -6,6 +6,7 @@ import type { ScopedThreadRef, } from "@t3tools/contracts"; +import { useClosedViewStore } from "~/closedViewStore"; import { beginPreviewSessionClose, cancelPreviewSessionClose } from "~/previewStateStore"; interface ClosePreviewSessionInput { @@ -32,6 +33,12 @@ export async function closePreviewSession( }); if (result._tag === "Failure") { cancelPreviewSessionClose(input.threadRef, input.snapshot, input.tabId); + } else if (input.snapshot) { + useClosedViewStore.getState().remember({ + kind: "browser", + threadRef: input.threadRef, + snapshot: input.snapshot, + }); } return result; } diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts index 40f24c9bf3bae95330938c8e671db8883352a392..f790f3681316dac94c175ee8a69aaa9f683bc591 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts @@ -263,6 +263,7 @@ describe("KeybindingsSettings.logic", () => { it("formats static and project script command labels", () => { expect(commandLabel("commandPalette.toggle")).toBe("Command Palette: Toggle"); expect(commandLabel("themeEditor.toggle")).toBe("Theme Editor: Toggle"); + expect(commandLabel("view.reopenClosed")).toBe("Reopen Closed Tab"); expect(commandLabel("script.setup-db.run")).toBe("Run Script: Setup Db"); }); diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.ts index 4897007d8fdacf311841b1a715ebed82c23ba983..cbdd456a0172cdb09fc8ffb49a6989153c32ffdb 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.ts @@ -322,6 +322,7 @@ export function commandLabel(command: KeybindingCommand): string { if (usageMetric) return `Usage: ${usageMetric.label}`; const usagePeriod = WINDOW_OPTIONS.find((option) => option.command === command); if (usagePeriod) return `Usage: Period: ${usagePeriod.label}`; + if (command === "view.reopenClosed") return "Reopen Closed Tab"; const raw = String(command); if (raw.startsWith("script.") && raw.endsWith(".run")) { return `Run Script: ${titleCaseCommandSegment(raw.slice("script.".length, -".run".length))}`; diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index c7d69443cdffd181a2f79c7985b9776877e18546..58199ddf62892c9ec0748fbd4a668e69a473042a 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -12,6 +12,7 @@ import { type ResolvedKeybindingsConfig, } from "@t3tools/contracts"; import { + effectiveShortcutsForCommand, formatShortcutLabel, isDiffToggleShortcut, isRichTextBoldShortcut, @@ -180,6 +181,40 @@ const DEFAULT_BINDINGS = compile([ }, ]); +describe("effectiveShortcutsForCommand", () => { + it("passes only effective preview shortcuts to the desktop bridge", () => { + const reopen = modShortcut("t", { shiftKey: true }); + const second = modShortcut("y", { shiftKey: true }); + const keybindings = compile([ + { shortcut: reopen, command: "view.reopenClosed" }, + { + shortcut: second, + command: "view.reopenClosed", + whenAst: whenIdentifier("previewFocus"), + }, + { + shortcut: reopen, + command: "preview.toggle", + whenAst: whenIdentifier("previewFocus"), + }, + ]); + assert.deepEqual( + effectiveShortcutsForCommand(keybindings, "view.reopenClosed", { + platform: "MacIntel", + context: { previewFocus: true, previewOpen: true }, + }), + [second], + ); + assert.deepEqual( + effectiveShortcutsForCommand(keybindings, "view.reopenClosed", { + platform: "MacIntel", + context: { previewFocus: false }, + }), + [reopen], + ); + }); +}); + describe("isTerminalToggleShortcut", () => { it("matches Cmd+J on macOS", () => { assert.isTrue( diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index 24a4676050340bc15ebc57cb000dc168cf2820fb..7864eabf755b7d8cb686f7576a6768ff82e1028a 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -8,27 +8,19 @@ import { type ModelPickerJumpKeybindingCommand, type ThreadJumpKeybindingCommand, } from "@t3tools/contracts"; +import { + isMacPlatform, + matchesKeybindingShortcut, + matchesKeybindingShortcutModifiers, + normalizeEventKey, + resolveEventKeys, + type ShortcutEventLike, + type ShortcutModifierStateLike, +} from "@t3tools/shared/keybindings"; import { isElectron } from "./env"; -import { isMacPlatform } from "./lib/utils"; -export interface ShortcutEventLike { - getModifierState?: (key: "AltGraph") => boolean; - type?: string; - code?: string; - key: string; - repeat?: boolean; - metaKey: boolean; - ctrlKey: boolean; - shiftKey: boolean; - altKey: boolean; -} - -export interface ShortcutModifierStateLike { - metaKey: boolean; - ctrlKey: boolean; - shiftKey: boolean; - altKey: boolean; -} +export type { ShortcutEventLike, ShortcutModifierStateLike } from "@t3tools/shared/keybindings"; +export { shortcutKeyFromEvent } from "@t3tools/shared/keybindings"; export interface ShortcutMatchContext { terminalFocus: boolean; @@ -57,90 +49,6 @@ const TERMINAL_WORD_FORWARD = "\u001bf"; const TERMINAL_LINE_START = "\u0001"; const TERMINAL_LINE_END = "\u0005"; const TERMINAL_DELETE_TO_LINE_START = "\u0015"; -const EVENT_CODE_SHORTCUT_KEYS: Readonly> = { - Backquote: "`", - Backslash: "\\", - BracketLeft: "[", - BracketRight: "]", - Comma: ",", - Digit0: "0", - Digit1: "1", - Digit2: "2", - Digit3: "3", - Digit4: "4", - Digit5: "5", - Digit6: "6", - Digit7: "7", - Digit8: "8", - Digit9: "9", - Equal: "=", - Minus: "-", - Period: ".", - Quote: "'", - Semicolon: ";", - Slash: "/", -}; - -function normalizeEventKey(key: string): string { - const normalized = key.toLowerCase(); - if (normalized === "esc") return "escape"; - return normalized; -} - -export function shortcutKeyFromEvent(event: Pick): string { - const layoutKey = normalizeEventKey(event.key); - if (/^[a-z]$/.test(layoutKey)) return layoutKey; - const physicalKey = event.code ? EVENT_CODE_SHORTCUT_KEYS[event.code] : undefined; - return physicalKey ?? layoutKey; -} - -function resolveEventKeys(event: ShortcutEventLike): Set { - const layoutKey = normalizeEventKey(event.key); - const keys = new Set([layoutKey]); - // The physical-position fallback exists for layouts that type non-Latin - // letters (Cyrillic, Greek) and for Option-modified symbols on macOS. - // When the layout already produces a Latin letter, match on it alone; - // otherwise a remapped physical key triggers shortcuts for two different - // letters at once and shadows system shortcuts on non-QWERTY layouts. - const letterCode = event.code?.match(/^Key([A-Z])$/)?.[1]; - if (letterCode && !/^[a-z]$/.test(layoutKey)) { - keys.add(letterCode.toLowerCase()); - } - keys.add(shortcutKeyFromEvent(event)); - return keys; -} - -function matchesShortcutModifiers( - event: ShortcutModifierStateLike, - shortcut: KeybindingShortcut, - platform = navigator.platform, -): boolean { - const useMetaForMod = isMacPlatform(platform); - const expectedMeta = shortcut.metaKey || (shortcut.modKey && useMetaForMod); - const expectedCtrl = shortcut.ctrlKey || (shortcut.modKey && !useMetaForMod); - return ( - event.metaKey === expectedMeta && - event.ctrlKey === expectedCtrl && - event.shiftKey === shortcut.shiftKey && - event.altKey === shortcut.altKey - ); -} - -function matchesShortcut( - event: ShortcutEventLike, - shortcut: KeybindingShortcut, - platform = navigator.platform, -): boolean { - if ( - !isMacPlatform(platform) && - event.getModifierState?.("AltGraph") && - !/^[a-z0-9]$/i.test(event.key) - ) - return false; - if (!matchesShortcutModifiers(event, shortcut, platform)) return false; - return resolveEventKeys(event).has(shortcut.key); -} - function resolvePlatform(options: ShortcutMatchOptions | undefined): string { return options?.platform ?? navigator.platform; } @@ -197,14 +105,15 @@ export function shortcutConflictKey( ].join("|"); } -function findEffectiveShortcutForCommand( +export function effectiveShortcutsForCommand( keybindings: ResolvedKeybindingsConfig, command: KeybindingCommand, options?: ShortcutMatchOptions, -): KeybindingShortcut | null { +): KeybindingShortcut[] { const platform = resolvePlatform(options); const context = resolveContext(options); const claimedShortcuts = new Set(); + const effective: KeybindingShortcut[] = []; for (let index = keybindings.length - 1; index >= 0; index -= 1) { const binding = keybindings[index]; @@ -218,11 +127,19 @@ function findEffectiveShortcutForCommand( claimedShortcuts.add(conflictKey); if (binding.command === command) { - return binding.shortcut; + effective.push(binding.shortcut); } } - return null; + return effective; +} + +function findEffectiveShortcutForCommand( + keybindings: ResolvedKeybindingsConfig, + command: KeybindingCommand, + options?: ShortcutMatchOptions, +): KeybindingShortcut | null { + return effectiveShortcutsForCommand(keybindings, command, options)[0] ?? null; } function matchesCommandShortcut( @@ -246,7 +163,7 @@ export function resolveShortcutCommand( const binding = keybindings[index]; if (!binding) continue; if (!matchesWhenClause(binding.whenAst, context)) continue; - if (!matchesShortcut(event, binding.shortcut, platform)) continue; + if (!matchesKeybindingShortcut(event, binding.shortcut, platform)) continue; return binding.command; } return null; @@ -339,7 +256,7 @@ export function shouldShowThreadJumpHintsForModifiers( for (const command of THREAD_JUMP_KEYBINDING_COMMANDS) { const shortcut = findEffectiveShortcutForCommand(keybindings, command, options); if (!shortcut) continue; - if (matchesShortcutModifiers(modifiers, shortcut, platform)) { + if (matchesKeybindingShortcutModifiers(modifiers, shortcut, platform)) { return true; } } diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts index 9824c0546fb2437791a7eb6a963a19f5f3d63d2d..e25ca6489d9cc067ae292c90c016b668594f59ba 100644 --- a/apps/web/src/lib/utils.ts +++ b/apps/web/src/lib/utils.ts @@ -1,4 +1,5 @@ import { MessageId, ProjectId, ThreadId } from "@t3tools/contracts"; +import { isMacPlatform } from "@t3tools/shared/keybindings"; import { type CxOptions, cx } from "class-variance-authority"; import * as Hex from "effect/encoding/Hex"; import { extendTailwindMerge } from "tailwind-merge"; @@ -12,9 +13,7 @@ export function cn(...inputs: CxOptions) { return twMerge(cx(inputs)); } -export function isMacPlatform(platform: string): boolean { - return /mac|iphone|ipad|ipod/i.test(platform); -} +export { isMacPlatform }; export function isWindowsPlatform(platform: string): boolean { return /^win(dows)?/i.test(platform); diff --git a/apps/web/src/reopenClosedView.test.ts b/apps/web/src/reopenClosedView.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..452c59cf889c0d94f47124de6230ac34a04e3cbb --- /dev/null +++ b/apps/web/src/reopenClosedView.test.ts @@ -0,0 +1,234 @@ +import { + DEFAULT_CLIENT_SETTINGS, + type PreviewSessionSnapshot, + type ScopedThreadRef, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/reactivity"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { type ClosedViewEntry, useClosedViewStore } from "./closedViewStore"; +import { __setClientSettingsForTests } from "./hooks/useSettings"; +import { readThreadPreviewState, resetPreviewStateForTests } from "./previewStateStore"; +import { + planNextReopen, + pullRequestsSearchForRestore, + type ReopenOwnerState, + reopenClosedView, +} from "./reopenClosedView"; +import { + selectThreadRightPanelState, + type ThreadRightPanelState, + useRightPanelStore, +} from "./rightPanelStore"; + +const threadRef = { + environmentId: "local" as ScopedThreadRef["environmentId"], + threadId: "thread-1" as ScopedThreadRef["threadId"], +}; +const snapshot: PreviewSessionSnapshot = { + threadId: threadRef.threadId, + tabId: "old-tab", + navStatus: { _tag: "Success", url: "https://example.com", title: "Example" }, + canGoBack: false, + canGoForward: false, + viewport: { _tag: "freeform", width: 1200, height: 800 }, + profileId: "work", + updatedAt: "2026-09-22T12:00:00.000Z", +}; + +beforeEach(() => { + __setClientSettingsForTests(DEFAULT_CLIENT_SETTINGS); + resetPreviewStateForTests(); + useClosedViewStore.setState({ entries: [] }); + useRightPanelStore.setState({ byThreadKey: {}, userActionRevisionByThreadKey: {} }); +}); + +describe("reopenClosedView", () => { + it("restores a file tab at its line without creating a browser session", async () => { + const openPreview = vi.fn(); + const reopened = await reopenClosedView( + { + kind: "panel-tab", + threadRef, + surface: { + kind: "file", + id: "file:src/app.ts", + relativePath: "src/app.ts", + revealLine: 18, + revealRequestId: 1, + }, + }, + { openPreview, workspaceAvailable: true }, + ); + expect(reopened).toBe(true); + const state = selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, threadRef); + expect(state.isOpen).toBe(true); + expect(state.surfaces).toMatchObject([{ id: "file:src/app.ts", revealLine: 18 }]); + expect(openPreview).not.toHaveBeenCalled(); + }); + + it("does not reopen workspace tabs without an available project", async () => { + const options = { + openPreview: vi.fn(), + workspaceAvailable: false, + }; + for (const surface of [ + { kind: "files", id: "files" }, + { + kind: "file", + id: "file:src/app.ts", + relativePath: "src/app.ts", + revealLine: null, + revealRequestId: 0, + }, + ] as const) { + expect(await reopenClosedView({ kind: "panel-tab", threadRef, surface }, options)).toBe( + false, + ); + } + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, threadRef).surfaces, + ).toEqual([]); + }); + + it("recreates a browser tab with saved URL, viewport and profile, then selects its new ID", async () => { + const openPreview = vi.fn(async () => AsyncResult.success({ ...snapshot, tabId: "new-tab" })); + const result = await reopenClosedView( + { kind: "browser", threadRef, snapshot }, + { openPreview, workspaceAvailable: false }, + ); + expect(result).toBe(true); + expect(openPreview).toHaveBeenCalledWith({ + environmentId: threadRef.environmentId, + input: { + threadId: threadRef.threadId, + url: "https://example.com", + viewport: snapshot.viewport, + profileId: "work", + }, + }); + expect(readThreadPreviewState(threadRef).snapshot?.tabId).toBe("new-tab"); + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, threadRef) + .activeSurfaceId, + ).toBe("browser:new-tab"); + }); + + it("leaves the panel unchanged when a browser resource fails to reopen", async () => { + const result = await reopenClosedView( + { kind: "browser", threadRef, snapshot }, + { + openPreview: async () => AsyncResult.failure(Cause.fail(new Error("offline"))), + workspaceAvailable: false, + }, + ); + expect(result).toBe(false); + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, threadRef).surfaces, + ).toEqual([]); + }); +}); + +describe("planNextReopen", () => { + const entries: ClosedViewEntry[] = [ + { id: "a", kind: "panel-tab", threadRef, surface: { kind: "diff", id: "diff" } }, + { id: "b", kind: "panel-tab", threadRef, surface: { kind: "files", id: "files" } }, + ]; + const known: ReopenOwnerState = { + environmentKnown: true, + catalogReady: true, + ownerExists: true, + shellLive: true, + panel: { isOpen: false, activeSurfaceId: null, surfaces: [] }, + }; + const openDiff: ThreadRightPanelState = { + isOpen: true, + activeSurfaceId: "diff", + surfaces: [{ kind: "diff", id: "diff" }], + }; + + it.each([ + ["restores the newest entry", [known, known], [], "a"], + [ + "drops a removed environment once the catalog is ready", + [{ ...known, environmentKnown: false }, known], + ["a"], + "b", + ], + [ + "waits for the catalog before dropping", + [{ ...known, environmentKnown: false, catalogReady: false }, known], + [], + null, + ], + [ + "skips but keeps a thread deleted from a live shell", + [{ ...known, ownerExists: false }, known], + [], + "b", + ], + [ + "waits when a cached shell lacks the thread", + [{ ...known, ownerExists: false, shellLive: false }, known], + [], + null, + ], + ["drops a tab that is already open", [{ ...known, panel: openDiff }, known], ["a"], "b"], + [ + "restores a tab whose panel is hidden", + [{ ...known, panel: { ...openDiff, isOpen: false } }], + [], + "a", + ], + ] as const)("%s", (_name, owners, dropped, restored) => { + const plan = planNextReopen( + entries.slice(0, owners.length), + (e) => owners[entries.indexOf(e)]!, + ); + expect(plan.drop.map((e) => e.id)).toEqual(dropped); + expect(plan.restore?.id ?? null).toBe(restored); + }); +}); + +describe("pullRequestsSearchForRestore", () => { + it("replaces the selection and keeps list filters", () => { + const search = pullRequestsSearchForRestore( + { + involvement: "reviewing", + state: "open", + q: "mine", + host: "filter.example.com", + selectedHost: "old", + number: 1, + }, + { + kind: "pull-request", + id: "pull-request:example", + repository: "owner/repo", + number: 42, + projectId: "project-1", + environmentId: "remote", + host: "github.example.com", + }, + ); + expect(search).toEqual({ + involvement: "reviewing", + state: "open", + q: "mine", + host: "filter.example.com", + repository: "owner/repo", + number: 42, + selectedProjectId: "project-1", + selectedHost: "github.example.com", + selectedEnvironmentId: "remote", + }); + }); + + it("clears a stale selection and fills default filters when nothing is selected", () => { + expect(pullRequestsSearchForRestore({ repository: "old/repo", number: 7 }, null)).toEqual({ + involvement: "all", + state: "open", + }); + }); +}); diff --git a/apps/web/src/reopenClosedView.ts b/apps/web/src/reopenClosedView.ts new file mode 100644 index 0000000000000000000000000000000000000000..551119a178a763c0f0b5c5fe8ec5e206b3cfe391 --- /dev/null +++ b/apps/web/src/reopenClosedView.ts @@ -0,0 +1,150 @@ +import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; + +import type { OpenPreviewMutation } from "./browser/openFileInPreview"; +import type { ClosedView, ClosedViewEntry } from "./closedViewStore"; +import type { PullRequestListPreferences } from "./components/pullRequest/pullRequestListPreferences"; +import { openPreviewSession } from "./components/preview/openPreviewSession"; +import { + type RightPanelSurface, + type ThreadRightPanelState, + useRightPanelStore, +} from "./rightPanelStore"; + +export interface ReopenOwnerState { + /** False when the entry's environment is not in the catalog. */ + environmentKnown: boolean; + catalogReady: boolean; + /** Whether the owning thread or draft still exists locally. */ + ownerExists: boolean; + /** A live shell that lacks the thread means it was deleted; a cached one may just be stale. */ + shellLive: boolean; + panel: ThreadRightPanelState; +} + +/** + * Picks the history entry the next reopen press restores, newest first. Entries whose + * environment is gone or whose tab is already open are dropped. A thread missing from a + * live shell is skipped but kept; anything not yet knowable stops the scan with no restore. + */ +export function planNextReopen( + entries: readonly ClosedViewEntry[], + ownerState: (entry: ClosedViewEntry) => ReopenOwnerState, +): { drop: ClosedViewEntry[]; restore: ClosedViewEntry | null } { + const drop: ClosedViewEntry[] = []; + for (const entry of entries) { + const owner = ownerState(entry); + if (!owner.environmentKnown) { + if (!owner.catalogReady) break; + drop.push(entry); + continue; + } + if (!owner.ownerExists) { + if (owner.shellLive) continue; + break; + } + const alreadyOpen = + entry.kind === "panel-tab" + ? owner.panel.isOpen && owner.panel.surfaces.some((s) => s.id === entry.surface.id) + : owner.panel.surfaces.some( + (s) => s.kind === "preview" && s.resourceId === entry.snapshot.tabId, + ); + if (alreadyOpen) { + drop.push(entry); + continue; + } + return { drop, restore: entry }; + } + return { drop, restore: null }; +} + +type PullRequestsSearchLike = Partial & { + repository?: string; + number?: number; + selectedProjectId?: ProjectId; + selectedHost?: string; + selectedEnvironmentId?: EnvironmentId; +}; + +/** Selects the restored pull request on the Pull Requests page, keeping the list filters. */ +export function pullRequestsSearchForRestore( + previous: S, + selected: RightPanelSurface | null, +): S & PullRequestListPreferences { + const { + repository: _repository, + number: _number, + selectedProjectId: _projectId, + selectedHost: _host, + selectedEnvironmentId: _environmentId, + ...filters + } = previous; + return { + ...filters, + involvement: previous.involvement ?? "all", + state: previous.state ?? "open", + ...(selected?.kind === "pull-request" + ? { + repository: selected.repository, + number: selected.number, + selectedProjectId: selected.projectId as ProjectId, + ...(selected.host === undefined ? {} : { selectedHost: selected.host }), + ...(selected.environmentId === undefined + ? {} + : { selectedEnvironmentId: selected.environmentId as EnvironmentId }), + } + : {}), + } as S & PullRequestListPreferences; +} + +export async function reopenClosedView( + view: ClosedView, + options: { + openPreview: OpenPreviewMutation; + workspaceAvailable: boolean; + }, +): Promise { + const panels = useRightPanelStore.getState(); + const ref = view.threadRef; + + if (view.kind === "browser") { + const url = view.snapshot.navStatus._tag === "Idle" ? undefined : view.snapshot.navStatus.url; + const result = await openPreviewSession({ + openPreview: options.openPreview, + threadRef: ref, + ...(url === undefined ? {} : { url }), + ...(view.snapshot.viewport === undefined ? {} : { viewport: view.snapshot.viewport }), + ...(view.snapshot.profileId === undefined ? {} : { profileId: view.snapshot.profileId }), + }); + if (result._tag === "Failure") return false; + panels.openBrowser(ref, result.value.tabId); + return true; + } + const surface = view.surface; + if ( + !options.workspaceAvailable && + (surface.kind === "files" || (surface.kind === "file" && !surface.attachment)) + ) + return false; + switch (surface.kind) { + case "preview": + if (surface.resourceId !== null) return false; + panels.openBrowser(ref, null); + break; + case "file": + if (surface.attachment) panels.openAttachment(ref, surface.attachment); + else panels.openFile(ref, surface.relativePath, surface.revealLine ?? undefined); + break; + case "device": + if (surface.target) { + panels.openDevice(ref, surface.target); + if (surface.title) panels.renameDevice(ref, surface.id, surface.title); + } else panels.open(ref, "device"); + break; + case "pull-request": + panels.openPullRequest(ref, surface); + break; + default: + panels.open(ref, surface.kind); + } + return true; +} diff --git a/apps/web/src/rightPanelStore.test.ts b/apps/web/src/rightPanelStore.test.ts index aa3dfa8e43d9f2b9cfa5bb9e51d845ea55cdf935..3babb2ed749a4ae6d0917ac2faf029da0a085a1d 100644 --- a/apps/web/src/rightPanelStore.test.ts +++ b/apps/web/src/rightPanelStore.test.ts @@ -2,6 +2,7 @@ import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { type EnvironmentId, ThreadId } from "@t3tools/contracts"; import { beforeEach, describe, expect, it } from "vite-plus/test"; +import { useClosedViewStore } from "./closedViewStore"; import { migratePersistedRightPanelState, pullRequestSurface, @@ -19,14 +20,113 @@ const refA = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-A")) const refB = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-B")); beforeEach(() => { + useClosedViewStore.setState({ entries: [] }); useRightPanelStore.setState({ byThreadKey: {}, threadPanelVisibilityByThreadKey: {}, userActionRevisionByThreadKey: {}, + closeRevisionByThreadKey: {}, }); }); describe("rightPanelStore", () => { + it("records single and bulk tab closes, newest first", () => { + const store = useRightPanelStore.getState(); + const pr = pullRequestSurface({ + projectId: "project-a", + repository: "pingdotgg/t3code", + number: 42, + }); + store.openFile(refA, "src/app.ts"); + store.open(refA, "device"); + store.openPullRequest(refA, pr); + store.open(refA, "diff"); + store.closeSurface(refA, pr.id); + store.closeSurfacesToRight(refA, "device"); + store.closeOtherSurfaces(refA, "file:src/app.ts"); + expect( + useClosedViewStore + .getState() + .entries.map((entry) => (entry.kind === "panel-tab" ? entry.surface.id : null)), + ).toEqual(["device", "diff", pr.id]); + }); + + it("reopens the active tab first after a bulk close", () => { + const store = useRightPanelStore.getState(); + store.open(refA, "files"); + store.open(refA, "diff"); + store.open(refA, "device"); + store.activateSurface(refA, "diff"); + store.closeAllSurfaces(refA); + + expect( + useClosedViewStore + .getState() + .entries.map((entry) => entry.kind === "panel-tab" && entry.surface.id), + ).toEqual(["diff", "device", "files"]); + }); + + it("does not save an incidental Files replacement when opening an existing file", () => { + const store = useRightPanelStore.getState(); + store.openFile(refA, "src/app.ts"); + store.open(refA, "files"); + store.openFile(refA, "src/app.ts"); + + expect(useClosedViewStore.getState().entries).toEqual([]); + }); + + it("ignores session tabs without browser snapshots and records the empty browser tab", () => { + const store = useRightPanelStore.getState(); + store.openBrowser(refA, "tab-1"); + store.closeSurface(refA, "browser:tab-1"); + expect(useClosedViewStore.getState().entries).toEqual([]); + store.openBrowser(refA, null); + store.closeSurface(refA, "browser:new"); + expect(useClosedViewStore.getState().entries).toMatchObject([ + { kind: "panel-tab", surface: { id: "browser:new", resourceId: null } }, + ]); + }); + + it("keeps a dismissed device hidden after another file opens", () => { + const store = useRightPanelStore.getState(); + const device = { + hostId: "nucbox", + deviceId: "emulator-5580", + name: "Pixel", + platform: "android", + } as const; + store.openFile(refA, "src/app.ts"); + store.closeSurface(refA, "file:src/app.ts"); + store.openDevice(refA, device); + store.closeSurface(refA, "device:nucbox:emulator-5580"); + + store.openFile(refA, "src/app.ts"); + store.openDevice(refA, device, true); + + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).surfaces, + ).toEqual([expect.objectContaining({ id: "file:src/app.ts" })]); + }); + + it("records only closed tabs when a panel is hidden or a terminal tab closes", () => { + const store = useRightPanelStore.getState(); + store.open(refA, "diff"); + store.openFile(refA, "src/app.ts"); + store.closeSurface(refA, "file:src/app.ts"); + store.close(refA); + expect(useClosedViewStore.getState().entries).toMatchObject([ + { kind: "panel-tab", threadRef: refA, surface: { id: "file:src/app.ts" } }, + ]); + store.toggleVisibility(refA); + store.toggle(refA, "diff"); + store.openTerminal(refA, "term-1"); + store.closeSurface(refA, "terminal:term-1"); + expect(useClosedViewStore.getState().entries).toMatchObject([ + { kind: "panel-tab", threadRef: refA, surface: { id: "file:src/app.ts" } }, + ]); + expect(useClosedViewStore.getState().entries).toHaveLength(1); + }); + it("gives each host/device its own tab and preserves renamed tabs", () => { const store = useRightPanelStore.getState(); const android = { diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts index 214d4a4abba508ee3bc2fe4146080ab69a09f48b..6124297ebef2ba7047abfbbdd11a856678cdfa69 100644 --- a/apps/web/src/rightPanelStore.ts +++ b/apps/web/src/rightPanelStore.ts @@ -7,11 +7,16 @@ * terminal surfaces point at terminal session ids, file surfaces point at * workspace paths, and diff/files remain singleton surfaces. */ -import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { + parseScopedThreadKey, + scopedThreadKey, + scopeThreadRef, +} from "@t3tools/client-runtime/environment"; import { EnvironmentId, ThreadId, type ScopedThreadRef } from "@t3tools/contracts"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; +import { useClosedViewStore } from "./closedViewStore"; import { resolveStorage } from "./lib/storage"; import type { ThreadPanelPresentation } from "./rightPanelLayout"; import type { ChatFileAttachment } from "./types"; @@ -119,6 +124,7 @@ interface RightPanelStoreState { threadPanelVisibilityByThreadKey: Record; /** Session-only count of user panel choices per thread. Automatic updates do not advance it. */ userActionRevisionByThreadKey: Record; + closeRevisionByThreadKey: Record; getUserActionRevision: (ref: ScopedThreadRef) => number; /** * Open a surface on behalf of the app, not the user. Refused when the user @@ -387,6 +393,7 @@ const userAction = ( state: RightPanelStoreState, threadKey: string, updater: (current: ThreadRightPanelState) => ThreadRightPanelState, + closesView = false, ): Partial => ({ ...updateThread(state, threadKey, (current) => { const next = updater(current); @@ -410,8 +417,22 @@ const userAction = ( ...state.userActionRevisionByThreadKey, [threadKey]: (state.userActionRevisionByThreadKey[threadKey] ?? 0) + 1, }, + ...(closesView + ? { + closeRevisionByThreadKey: { + ...state.closeRevisionByThreadKey, + [threadKey]: (state.closeRevisionByThreadKey[threadKey] ?? 0) + 1, + }, + } + : {}), }); +const closeAction = ( + state: RightPanelStoreState, + threadKey: string, + updater: (current: ThreadRightPanelState) => ThreadRightPanelState, +): Partial => userAction(state, threadKey, updater, true); + function normalizeRevealLine(line: number | undefined): number | null { if (line === undefined || !Number.isFinite(line)) return null; return Math.max(1, Math.trunc(line)); @@ -571,6 +592,7 @@ export const useRightPanelStore = create()( byThreadKey: {}, threadPanelVisibilityByThreadKey: {}, userActionRevisionByThreadKey: {}, + closeRevisionByThreadKey: {}, getUserActionRevision: (ref) => get().userActionRevisionByThreadKey[scopedThreadKey(ref)] ?? 0, openProactive: (ref, surface, expectedUserActionRevision) => { @@ -754,7 +776,7 @@ export const useRightPanelStore = create()( ), closeTerminal: (ref, surfaceId, terminalId) => set((state) => - userAction(state, scopedThreadKey(ref), (current) => { + closeAction(state, scopedThreadKey(ref), (current) => { const surface = current.surfaces.find( (entry) => entry.id === surfaceId && entry.kind === "terminal", ); @@ -801,25 +823,28 @@ export const useRightPanelStore = create()( ), closeSurface: (ref, surfaceId) => set((state) => - userAction(state, scopedThreadKey(ref), (current) => { + closeAction(state, scopedThreadKey(ref), (current) => { const index = current.surfaces.findIndex((surface) => surface.id === surfaceId); if (index < 0) return current; const surfaces = current.surfaces.filter((surface) => surface.id !== surfaceId); + const next = { + ...current, + isOpen: surfaces.length > 0 && current.isOpen, + surfaces, + }; if (current.activeSurfaceId !== surfaceId) { - return { ...current, isOpen: surfaces.length > 0 && current.isOpen, surfaces }; + return next; } const fallback = surfaces[Math.min(index, surfaces.length - 1)] ?? null; return { - ...current, - isOpen: surfaces.length > 0 && current.isOpen, - surfaces, + ...next, activeSurfaceId: fallback?.id ?? null, }; }), ), closeOtherSurfaces: (ref, surfaceId) => set((state) => - userAction(state, scopedThreadKey(ref), (current) => { + closeAction(state, scopedThreadKey(ref), (current) => { const surface = current.surfaces.find((entry) => entry.id === surfaceId); if (!surface || current.surfaces.length === 1) return current; return { @@ -832,7 +857,7 @@ export const useRightPanelStore = create()( ), closeSurfacesToRight: (ref, surfaceId) => set((state) => - userAction(state, scopedThreadKey(ref), (current) => { + closeAction(state, scopedThreadKey(ref), (current) => { const index = current.surfaces.findIndex((surface) => surface.id === surfaceId); if (index < 0 || index === current.surfaces.length - 1) return current; const surfaces = current.surfaces.slice(0, index + 1); @@ -848,7 +873,7 @@ export const useRightPanelStore = create()( ), closeAllSurfaces: (ref) => set((state) => - userAction(state, scopedThreadKey(ref), (current) => + closeAction(state, scopedThreadKey(ref), (current) => current.surfaces.length === 0 ? current : { ...current, isOpen: false, surfaces: [], activeSurfaceId: null }, @@ -969,7 +994,8 @@ export const useRightPanelStore = create()( if ( !(threadKey in state.byThreadKey) && !(threadKey in state.threadPanelVisibilityByThreadKey) && - !(threadKey in state.userActionRevisionByThreadKey) + !(threadKey in state.userActionRevisionByThreadKey) && + !(threadKey in state.closeRevisionByThreadKey) ) { return state; } @@ -978,10 +1004,13 @@ export const useRightPanelStore = create()( state.threadPanelVisibilityByThreadKey; const { [threadKey]: _revision, ...userActionRevisionByThreadKey } = state.userActionRevisionByThreadKey; + const { [threadKey]: _closeRevision, ...closeRevisionByThreadKey } = + state.closeRevisionByThreadKey; return { byThreadKey, threadPanelVisibilityByThreadKey, userActionRevisionByThreadKey, + closeRevisionByThreadKey, }; }), }), @@ -1009,6 +1038,32 @@ export const useRightPanelStore = create()( ), ); +useRightPanelStore.subscribe((next, previous) => { + if (next.closeRevisionByThreadKey === previous.closeRevisionByThreadKey) return; + for (const [threadKey, revision] of Object.entries(next.closeRevisionByThreadKey)) { + if (revision === previous.closeRevisionByThreadKey[threadKey]) continue; + const threadRef = parseScopedThreadKey(threadKey); + if (!threadRef) continue; + const before = previous.byThreadKey[threadKey] ?? EMPTY_THREAD_STATE; + const after = next.byThreadKey[threadKey] ?? EMPTY_THREAD_STATE; + const removed = before.surfaces.filter( + (surface) => !after.surfaces.some((entry) => entry.id === surface.id), + ); + const closedInOrder = [ + ...removed.filter((surface) => surface.id !== before.activeSurfaceId), + ...removed.filter((surface) => surface.id === before.activeSurfaceId), + ]; + for (const surface of closedInOrder) { + if ( + surface.kind === "terminal" || + (surface.kind === "preview" && surface.resourceId !== null) + ) + continue; + useClosedViewStore.getState().remember({ kind: "panel-tab", threadRef, surface }); + } + } +}); + export function selectThreadRightPanelState( byThreadKey: Record, ref: ScopedThreadRef | null | undefined, diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 68bab78c1c64ebf982c3eddcdc036a546f1fe4da..438b8c5e7a1a6f11e32c6f3d6a73c85043003652 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -31,6 +31,7 @@ import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLa import { NightlyMobileBetaNotice } from "../components/NightlyMobileBeta"; import { LegacyThreadMigrationToast } from "../components/LegacyThreadMigrationToast"; import { ThreadNotificationCoordinator } from "../components/ThreadNotificationCoordinator"; +import { ReopenClosedViewShortcut } from "../components/ReopenClosedViewShortcut"; import { ProjectCloneToastCoordinator } from "../components/ProjectCloneToastCoordinator"; import { SlowRpcRequestToastCoordinator } from "../components/SlowRpcRequestToastCoordinator"; import { ChatGptWelcomeCoordinator } from "../components/settings/ChatGptWelcomeCoordinator"; @@ -235,6 +236,7 @@ function RootRouteView() { + diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index e3773bc3adbcc2ec4235a4b531a4debb3f065ecd..488b57b997cbbb7081e3275abe1d289f326bd4aa 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -145,6 +145,13 @@ tab. When nothing remains to close, it closes the window. In a browser, `mod+w` closes the browser tab; rebind `rightPanel.close` and `terminal.close` to an available shortcut such as `alt+w`. +`mod+shift+t` reopens the last closed tab across the app, including files, +diffs, pull requests, browsers, and devices, in the order you closed them. +A browser opens in a fresh session without its old page history. Incognito tabs +can reopen until you reload or quit the app. This shortcut does not undo deleted work. +Browsers also use it to reopen browser tabs; choose another binding in Settings +if the browser takes it first. + Many defaults include `!terminalFocus` so they do not intercept terminal input. Keep that condition when remapping them if you want the same behavior. diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index ad72e3e04e30566bcb8537a0828a1e929cff14e3..081958acdebeb1c89d78c387729a50222f291deb 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -13,6 +13,7 @@ import { AdvertisedEndpoint } from "./remoteAccess.ts"; import { ExecutionEnvironmentDescriptor } from "./environment.ts"; import { type ClientSettings, type QuitConfirmationMode, SnapShotShortcut } from "./settings.ts"; import type { EditorId } from "./editor.ts"; +import type { PreviewForwardedShortcut } from "./keybindings.ts"; import type { DesktopAppActivationRequest, @@ -1226,6 +1227,7 @@ export interface DesktopBridge { export const DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER = "__t3DesktopPreviewRecordingCapture"; export interface DesktopPreviewBridge { + setForwardedShortcuts?: (shortcuts: ReadonlyArray) => Promise; createTab: (tabId: string, defaults?: DesktopPreviewTabDefaults) => Promise; closeTab: (tabId: string) => Promise; registerWebview: (tabId: string, webContentsId: number) => Promise; diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index e42396dbc582d12c6e56692860d40bc99fc0a961..579ae8676d2e2cae74f635fd2e2ed81428a85824 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -69,6 +69,7 @@ export const STATIC_KEYBINDING_COMMANDS = [ "threadPanel.toggle", "rightPanel.toggleMaximized", "rightPanel.close", + "view.reopenClosed", "pullRequest.copyNumber", "diff.toggle", "preview.toggle", @@ -156,6 +157,12 @@ export const KeybindingShortcut = Schema.Struct({ }); export type KeybindingShortcut = typeof KeybindingShortcut.Type; +export const PreviewForwardedShortcut = Schema.Struct({ + command: KeybindingCommand, + shortcut: KeybindingShortcut, +}); +export type PreviewForwardedShortcut = typeof PreviewForwardedShortcut.Type; + const KeybindingWhenNodeRef = Schema.suspend( (): Schema.Codec => KeybindingWhenNode, ); diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index 634945fe0e64aaee102f60506b76355f87c53aa9..205cbe3578d5897d4d0eb512e8431a2225238f8c 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -10,6 +10,108 @@ import { THREAD_JUMP_KEYBINDING_COMMANDS, } from "@t3tools/contracts"; +export interface ShortcutEventLike { + getModifierState?: (key: "AltGraph") => boolean; + type?: string; + code?: string; + key: string; + repeat?: boolean; + metaKey: boolean; + ctrlKey: boolean; + shiftKey: boolean; + altKey: boolean; +} + +export interface ShortcutModifierStateLike { + metaKey: boolean; + ctrlKey: boolean; + shiftKey: boolean; + altKey: boolean; +} + +const EVENT_CODE_SHORTCUT_KEYS: Readonly> = { + Backquote: "`", + Backslash: "\\", + BracketLeft: "[", + BracketRight: "]", + Comma: ",", + Digit0: "0", + Digit1: "1", + Digit2: "2", + Digit3: "3", + Digit4: "4", + Digit5: "5", + Digit6: "6", + Digit7: "7", + Digit8: "8", + Digit9: "9", + Equal: "=", + Minus: "-", + Period: ".", + Quote: "'", + Semicolon: ";", + Slash: "/", +}; + +export function isMacPlatform(platform: string): boolean { + return /mac|iphone|ipad|ipod/i.test(platform); +} + +export function normalizeEventKey(key: string): string { + const normalized = key.toLowerCase(); + if (normalized === "esc") return "escape"; + return normalized; +} + +export function shortcutKeyFromEvent(event: Pick): string { + const layoutKey = normalizeEventKey(event.key); + if (/^[a-z]$/.test(layoutKey)) return layoutKey; + const physicalKey = event.code ? EVENT_CODE_SHORTCUT_KEYS[event.code] : undefined; + return physicalKey ?? layoutKey; +} + +export function resolveEventKeys(event: ShortcutEventLike): Set { + const layoutKey = normalizeEventKey(event.key); + const keys = new Set([layoutKey]); + const letterCode = event.code?.match(/^Key([A-Z])$/)?.[1]; + if (letterCode && !/^[a-z]$/.test(layoutKey)) { + keys.add(letterCode.toLowerCase()); + } + keys.add(shortcutKeyFromEvent(event)); + return keys; +} + +export function matchesKeybindingShortcutModifiers( + event: ShortcutModifierStateLike, + shortcut: KeybindingShortcut, + platform: string, +): boolean { + const useMetaForMod = isMacPlatform(platform); + const expectedMeta = shortcut.metaKey || (shortcut.modKey && useMetaForMod); + const expectedCtrl = shortcut.ctrlKey || (shortcut.modKey && !useMetaForMod); + return ( + event.metaKey === expectedMeta && + event.ctrlKey === expectedCtrl && + event.shiftKey === shortcut.shiftKey && + event.altKey === shortcut.altKey + ); +} + +export function matchesKeybindingShortcut( + event: ShortcutEventLike, + shortcut: KeybindingShortcut, + platform: string, +): boolean { + if ( + !isMacPlatform(platform) && + event.getModifierState?.("AltGraph") && + !/^[a-z0-9]$/i.test(event.key) + ) + return false; + if (!matchesKeybindingShortcutModifiers(event, shortcut, platform)) return false; + return resolveEventKeys(event).has(shortcut.key); +} + type WhenToken = | { type: "identifier"; value: string } | { type: "not" } @@ -29,6 +131,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+n", command: "terminal.new", when: "terminalFocus" }, { key: "mod+w", command: "terminal.close", when: "terminalFocus" }, { key: "mod+w", command: "rightPanel.close", when: "!terminalFocus" }, + { key: "mod+shift+t", command: "view.reopenClosed" }, { key: "mod+d", command: "diff.toggle", when: "!terminalFocus" }, { key: "mod+shift+j", command: "preview.toggle" }, { key: "mod+r", command: "preview.refresh", when: "previewFocus" },