Unified · Split
| @@ -5,6 +5,7 @@ export const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; · 4 unmodified lines | ||
| 5 | 5 | export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; |
| 6 | 6 | export const PROBE_REMOTE_EDITORS_CHANNEL = "desktop:probe-remote-editors"; |
| 7 | 7 | export const MENU_ACTION_CHANNEL = "desktop:menu-action"; |
| 8 | +export const QUIT_SHORTCUT_CHANNEL = "desktop:quit-shortcut"; | |
| 8 | 9 | export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state"; |
| 9 | 10 | export const WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:window-fullscreen-state"; |
| 10 | 11 | export const UPDATE_STATE_CHANNEL = "desktop:update-state"; |
| @@ -10,8 +10,11 @@ import * as DesktopLifecycle from "../../app/DesktopLifecycle.ts"; · 9 unmodified lines | ||
| 10 | 10 | import * as DesktopShutdown from "../../app/DesktopShutdown.ts"; |
| 11 | 11 | import * as DesktopState from "../../app/DesktopState.ts"; |
| 12 | 12 | import * as ElectronApp from "../../electron/ElectronApp.ts"; |
| 13 | +import * as ElectronDialog from "../../electron/ElectronDialog.ts"; | |
| 13 | 14 | import * as ElectronTheme from "../../electron/ElectronTheme.ts"; |
| 15 | +import * as ElectronWindow from "../../electron/ElectronWindow.ts"; | |
| 14 | 16 | import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; |
| 17 | +import * as DesktopClientSettings from "../../settings/DesktopClientSettings.ts"; | |
| 15 | 18 | import * as DesktopWindow from "../../window/DesktopWindow.ts"; |
| 16 | 19 | import * as DesktopWslBackend from "../../wsl/DesktopWslBackend.ts"; |
| 17 | 20 | import * as DesktopWslEnvironment from "../../wsl/DesktopWslEnvironment.ts"; |
| @@ -70,6 +73,15 @@ const unusedLifecycleRuntimeLayer = Layer.mergeAll( · 52 unmodified lines | ||
| 70 | 73 | ElectronTheme.ElectronTheme, |
| 71 | 74 | ElectronTheme.ElectronTheme.of({} as ElectronTheme.ElectronTheme["Service"]), |
| 72 | 75 | ), |
| 76 | + Layer.succeed( | |
| 77 | + ElectronDialog.ElectronDialog, | |
| 78 | + ElectronDialog.ElectronDialog.of({} as ElectronDialog.ElectronDialog["Service"]), | |
| 79 | + ), | |
| 80 | + Layer.succeed( | |
| 81 | + ElectronWindow.ElectronWindow, | |
| 82 | + ElectronWindow.ElectronWindow.of({} as ElectronWindow.ElectronWindow["Service"]), | |
| 83 | + ), | |
| 84 | + DesktopClientSettings.layerTest(), | |
| 73 | 85 | ); |
| 74 | 86 | |
| 75 | 87 | describe("WSL IPC", () => { |
| @@ -117,6 +117,17 @@ contextBridge.exposeInMainWorld("desktopBridge", { · 116 unmodified lines | ||
| 117 | 117 | ipcRenderer.removeListener(IpcChannels.MENU_ACTION_CHANNEL, wrappedListener); |
| 118 | 118 | }; |
| 119 | 119 | }, |
| 120 | + onQuitShortcut: (listener) => { | |
| 121 | + const wrappedListener = (_event: Electron.IpcRendererEvent, state: unknown) => { | |
| 122 | + if (state !== "down" && state !== "up") return; | |
| 123 | + listener(state); | |
| 124 | + }; | |
| 125 | + | |
| 126 | + ipcRenderer.on(IpcChannels.QUIT_SHORTCUT_CHANNEL, wrappedListener); | |
| 127 | + return () => { | |
| 128 | + ipcRenderer.removeListener(IpcChannels.QUIT_SHORTCUT_CHANNEL, wrappedListener); | |
| 129 | + }; | |
| 130 | + }, | |
| 120 | 131 | getWindowFullscreenState: () => |
| 121 | 132 | ipcRenderer.sendSync(IpcChannels.GET_WINDOW_FULLSCREEN_STATE_CHANNEL) === true, |
| 122 | 133 | onWindowFullscreenStateChange: (listener) => { |
| @@ -13,6 +13,7 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; · 12 unmodified lines | ||
| 13 | 13 | import * as DesktopClientSettings from "./DesktopClientSettings.ts"; |
| 14 | 14 | |
| 15 | 15 | const clientSettings: ClientSettings = { |
| 16 | + confirmQuit: true, | |
| 16 | 17 | confirmThreadArchive: true, |
| 17 | 18 | confirmThreadDelete: false, |
| 18 | 19 | dismissedProviderUpdateNotificationKeys: [], |
| @@ -37,6 +37,8 @@ import * as DesktopConfig from "../app/DesktopConfig.ts"; · 36 unmodified lines | ||
| 37 | 37 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; |
| 38 | 38 | import * as DesktopState from "../app/DesktopState.ts"; |
| 39 | 39 | import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; |
| 40 | +import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts"; | |
| 41 | +import * as ElectronApp from "../electron/ElectronApp.ts"; | |
| 40 | 42 | import * as ElectronMenu from "../electron/ElectronMenu.ts"; |
| 41 | 43 | import * as ElectronShell from "../electron/ElectronShell.ts"; |
| 42 | 44 | import * as ElectronTheme from "../electron/ElectronTheme.ts"; |
| @@ -128,6 +130,14 @@ function makeFakeBrowserWindow() { · 85 unmodified lines | ||
| 128 | 130 | }; |
| 129 | 131 | } |
| 130 | 132 | |
| 133 | +const desktopClientSettingsLayer = Layer.mock(DesktopClientSettings.DesktopClientSettings)({ | |
| 134 | + get: Effect.succeed(Option.none()), | |
| 135 | +}); | |
| 136 | + | |
| 137 | +const electronAppLayer = Layer.mock(ElectronApp.ElectronApp)({ | |
| 138 | + quit: Effect.void, | |
| 139 | +}); | |
| 140 | + | |
| 131 | 141 | const desktopAssetsLayer = Layer.succeed(DesktopAssets.DesktopAssets, { |
| 132 | 142 | iconPaths: Effect.succeed({ |
| 133 | 143 | ico: Option.none<string>(), |
| @@ -253,8 +263,10 @@ function makeTestLayer(input: { · 119 unmodified lines | ||
| 253 | 263 | desktopAssetsLayer, |
| 254 | 264 | desktopEnvironmentLayer, |
| 255 | 265 | desktopAppSettingsLayer, |
| 266 | + desktopClientSettingsLayer, | |
| 256 | 267 | desktopServerExposureLayer, |
| 257 | 268 | DesktopState.layer, |
| 269 | + electronAppLayer, | |
| 258 | 270 | electronMenuLayer, |
| 259 | 271 | Layer.succeed(ElectronShell.ElectronShell, { |
| 260 | 272 | openExternal: (url) => |
| @@ -356,7 +368,9 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n · 95 unmodified lines | ||
| 356 | 368 | desktopAssetsLayer, |
| 357 | 369 | desktopEnvironmentLayer, |
| 358 | 370 | DesktopAppSettings.layerTest(), |
| 371 | + desktopClientSettingsLayer, | |
| 359 | 372 | desktopServerExposureLayer, |
| 373 | + electronAppLayer, | |
| 360 | 374 | electronMenuLayer, |
| 361 | 375 | Layer.succeed(ElectronShell.ElectronShell, { |
| 362 | 376 | openExternal: () => Effect.succeed(true), |
| @@ -8,6 +8,8 @@ import * as Ref from "effect/Ref"; · 7 unmodified lines | ||
| 8 | 8 | |
| 9 | 9 | import * as Electron from "electron"; |
| 10 | 10 | |
| 11 | +import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts"; | |
| 12 | + | |
| 11 | 13 | import * as DesktopAssets from "../app/DesktopAssets.ts"; |
| 12 | 14 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; |
| 13 | 15 | import { makeComponentLogger } from "../app/DesktopObservability.ts"; |
| @@ -16,9 +18,16 @@ import { getDesktopUrl } from "../electron/ElectronProtocol.ts"; · 2 unmodified lines | ||
| 16 | 18 | import * as ElectronShell from "../electron/ElectronShell.ts"; |
| 17 | 19 | import * as ElectronTheme from "../electron/ElectronTheme.ts"; |
| 18 | 20 | import * as ElectronWindow from "../electron/ElectronWindow.ts"; |
| 19 | −import { MENU_ACTION_CHANNEL, WINDOW_FULLSCREEN_STATE_CHANNEL } from "../ipc/channels.ts"; | |
| 21 | +import { | |
| 22 | + MENU_ACTION_CHANNEL, | |
| 23 | + QUIT_SHORTCUT_CHANNEL, | |
| 24 | + WINDOW_FULLSCREEN_STATE_CHANNEL, | |
| 25 | +} from "../ipc/channels.ts"; | |
| 20 | 26 | import * as PreviewManager from "../preview/Manager.ts"; |
| 21 | 27 | import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; |
| 28 | +import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts"; | |
| 29 | +import * as ElectronApp from "../electron/ElectronApp.ts"; | |
| 30 | +import { makeQuitHoldHandler } from "./QuitHold.ts"; | |
| 22 | 31 | |
| 23 | 32 | const TITLEBAR_HEIGHT = 40; |
| 24 | 33 | const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux |
| @@ -51,6 +60,8 @@ type DesktopWindowRuntimeServices = · 26 unmodified lines | ||
| 51 | 60 | | DesktopEnvironment.DesktopEnvironment |
| 52 | 61 | | DesktopAssets.DesktopAssets |
| 53 | 62 | | DesktopAppSettings.DesktopAppSettings |
| 63 | + | DesktopClientSettings.DesktopClientSettings | |
| 64 | + | ElectronApp.ElectronApp | |
| 54 | 65 | | ElectronMenu.ElectronMenu |
| 55 | 66 | | ElectronShell.ElectronShell |
| 56 | 67 | | ElectronTheme.ElectronTheme |
| @@ -261,6 +272,8 @@ export const make = Effect.gen(function* () { · 204 unmodified lines | ||
| 261 | 272 | const electronWindow = yield* ElectronWindow.ElectronWindow; |
| 262 | 273 | const previewManager = yield* PreviewManager.PreviewManager; |
| 263 | 274 | const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; |
| 275 | + const clientSettings = yield* DesktopClientSettings.DesktopClientSettings; | |
| 276 | + const electronApp = yield* ElectronApp.ElectronApp; | |
| 264 | 277 | // Window-side latch for the primary backend's readiness. Set by |
| 265 | 278 | // handleBackendReady (driven by the pool's onReady callback), cleared |
| 266 | 279 | // by handleBackendNotReady (driven by onShutdown). Only consumed by |
| @@ -533,7 +546,32 @@ export const make = Effect.gen(function* () { · 266 unmodified lines | ||
| 533 | 546 | // close-terminal shortcut can outlive the terminal that handled its first |
| 534 | 547 | // press, so reject repeats before they reach the native window accelerator. |
| 535 | 548 | // Deliberate presses still flow through the renderer or native menu. |
| 549 | + // Chrome-style hold-to-quit: intercept the quit accelerator before the | |
| 550 | + // native menu sees it and only quit after the shortcut is held. The | |
| 551 | + // renderer shows the "Hold to Quit" hint via QUIT_SHORTCUT_CHANNEL. | |
| 552 | + const quitHoldHandler = makeQuitHoldHandler({ | |
| 553 | + platform: environment.platform, | |
| 554 | + isEnabled: () => | |
| 555 | + runPromise( | |
| 556 | + Effect.map( | |
| 557 | + clientSettings.get, | |
| 558 | + Option.match({ | |
| 559 | + onNone: () => DEFAULT_CLIENT_SETTINGS.confirmQuit, | |
| 560 | + onSome: (settings) => settings.confirmQuit, | |
| 561 | + }), | |
| 562 | + ), | |
| 563 | + ), | |
| 564 | + notify: (state) => { | |
| 565 | + if (!window.isDestroyed()) { | |
| 566 | + window.webContents.send(QUIT_SHORTCUT_CHANNEL, state); | |
| 567 | + } | |
| 568 | + }, | |
| 569 | + quit: () => { | |
| 570 | + void runPromise(electronApp.quit); | |
| 571 | + }, | |
| 572 | + }); | |
| 536 | 573 | window.webContents.on("before-input-event", (event, input) => { |
| 574 | + quitHoldHandler(event, input); | |
| 537 | 575 | if (input.type !== "keyDown" || !input.isAutoRepeat) return; |
| 538 | 576 | const modifier = environment.platform === "darwin" ? input.meta : input.control; |
| 539 | 577 | if (modifier && !input.alt && !input.shift && input.key.toLowerCase() === "w") { |
| @@ -0,0 +1,201 @@ | ||
| 1 | +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 2 | + | |
| 3 | +import { | |
| 4 | + makeQuitHoldHandler, | |
| 5 | + QUIT_DOUBLE_TAP_MS, | |
| 6 | + QUIT_HOLD_DURATION_MS, | |
| 7 | + QUIT_HOLD_RELEASE_GRACE_MS, | |
| 8 | +} from "./QuitHold.ts"; | |
| 9 | +import type { QuitHoldKeyInput, QuitHoldState } from "./QuitHold.ts"; | |
| 10 | + | |
| 11 | +function makeInput(overrides: Partial<QuitHoldKeyInput>): QuitHoldKeyInput { | |
| 12 | + return { | |
| 13 | + type: "keyDown", | |
| 14 | + key: "q", | |
| 15 | + meta: true, | |
| 16 | + control: false, | |
| 17 | + alt: false, | |
| 18 | + shift: false, | |
| 19 | + isAutoRepeat: false, | |
| 20 | + ...overrides, | |
| 21 | + }; | |
| 22 | +} | |
| 23 | + | |
| 24 | +function makeHarness(options?: { | |
| 25 | + enabled?: boolean; | |
| 26 | + platform?: NodeJS.Platform; | |
| 27 | + isEnabled?: () => Promise<boolean>; | |
| 28 | +}) { | |
| 29 | + const notifications: Array<QuitHoldState> = []; | |
| 30 | + const quit = vi.fn(); | |
| 31 | + const handler = makeQuitHoldHandler({ | |
| 32 | + platform: options?.platform ?? "darwin", | |
| 33 | + isEnabled: options?.isEnabled ?? (() => Promise.resolve(options?.enabled ?? true)), | |
| 34 | + notify: (state) => notifications.push(state), | |
| 35 | + quit, | |
| 36 | + }); | |
| 37 | + const preventDefault = vi.fn(); | |
| 38 | + const send = async (input: QuitHoldKeyInput) => { | |
| 39 | + handler({ preventDefault }, input); | |
| 40 | + // Let the isEnabled promise settle. | |
| 41 | + await Promise.resolve(); | |
| 42 | + await Promise.resolve(); | |
| 43 | + }; | |
| 44 | + // Simulates the OS auto-repeating the held shortcut every `intervalMs`. | |
| 45 | + const holdFor = async ( | |
| 46 | + durationMs: number, | |
| 47 | + repeatOverrides: Partial<QuitHoldKeyInput> = {}, | |
| 48 | + intervalMs = 100, | |
| 49 | + ) => { | |
| 50 | + for (let elapsed = 0; elapsed < durationMs; elapsed += intervalMs) { | |
| 51 | + vi.advanceTimersByTime(intervalMs); | |
| 52 | + await send(makeInput({ isAutoRepeat: true, ...repeatOverrides })); | |
| 53 | + } | |
| 54 | + }; | |
| 55 | + return { notifications, quit, preventDefault, send, holdFor }; | |
| 56 | +} | |
| 57 | + | |
| 58 | +describe("makeQuitHoldHandler", () => { | |
| 59 | + beforeEach(() => { | |
| 60 | + vi.useFakeTimers(); | |
| 61 | + }); | |
| 62 | + afterEach(() => { | |
| 63 | + vi.useRealTimers(); | |
| 64 | + }); | |
| 65 | + | |
| 66 | + it("shows the hint on a tap without quitting, even when the release is never seen", async () => { | |
| 67 | + // macOS suppresses the letter's keyUp while Cmd is held, so a tap may | |
| 68 | + // produce no keyUp at all. Quit must still not fire. | |
| 69 | + const harness = makeHarness(); | |
| 70 | + await harness.send(makeInput({})); | |
| 71 | + expect(harness.preventDefault).toHaveBeenCalledTimes(1); | |
| 72 | + expect(harness.notifications).toEqual(["down"]); | |
| 73 | + | |
| 74 | + vi.advanceTimersByTime(QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS); | |
| 75 | + expect(harness.quit).not.toHaveBeenCalled(); | |
| 76 | + // The watchdog dismisses the hint once the press is clearly over. | |
| 77 | + expect(harness.notifications).toEqual(["down", "up"]); | |
| 78 | + }); | |
| 79 | + | |
| 80 | + it("quits once the shortcut auto-repeats past the hold duration", async () => { | |
| 81 | + const harness = makeHarness(); | |
| 82 | + await harness.send(makeInput({})); | |
| 83 | + await harness.holdFor(QUIT_HOLD_DURATION_MS - 200); | |
| 84 | + expect(harness.quit).not.toHaveBeenCalled(); | |
| 85 | + await harness.holdFor(400); | |
| 86 | + expect(harness.quit).toHaveBeenCalledTimes(1); | |
| 87 | + // Exactly one hint cycle for the whole hold. | |
| 88 | + expect(harness.notifications).toEqual(["down", "up"]); | |
| 89 | + }); | |
| 90 | + | |
| 91 | + it("does not quit when the hold stops before the duration", async () => { | |
| 92 | + const harness = makeHarness(); | |
| 93 | + await harness.send(makeInput({})); | |
| 94 | + await harness.holdFor(500); | |
| 95 | + await harness.send(makeInput({ type: "keyUp" })); | |
| 96 | + expect(harness.notifications).toEqual(["down", "up"]); | |
| 97 | + vi.advanceTimersByTime((QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS) * 2); | |
| 98 | + expect(harness.quit).not.toHaveBeenCalled(); | |
| 99 | + }); | |
| 100 | + | |
| 101 | + it("cancels the hold when the modifier is released first", async () => { | |
| 102 | + const harness = makeHarness(); | |
| 103 | + await harness.send(makeInput({})); | |
| 104 | + await harness.send(makeInput({ type: "keyUp", key: "Meta", meta: false })); | |
| 105 | + expect(harness.notifications).toEqual(["down", "up"]); | |
| 106 | + vi.advanceTimersByTime((QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS) * 2); | |
| 107 | + expect(harness.quit).not.toHaveBeenCalled(); | |
| 108 | + }); | |
| 109 | + | |
| 110 | + it("quits immediately on a single press when disabled", async () => { | |
| 111 | + const harness = makeHarness({ enabled: false }); | |
| 112 | + await harness.send(makeInput({})); | |
| 113 | + expect(harness.quit).toHaveBeenCalledTimes(1); | |
| 114 | + // The hint is dismissed in case the quit gets cancelled downstream. | |
| 115 | + expect(harness.notifications).toEqual(["down", "up"]); | |
| 116 | + }); | |
| 117 | + | |
| 118 | + it("discards a stale isEnabled resolution from a superseded press", async () => { | |
| 119 | + // Press #1's isEnabled is still pending when the user releases and | |
| 120 | + // presses again; its late resolution must not act for press #2. | |
| 121 | + const resolvers: Array<(enabled: boolean) => void> = []; | |
| 122 | + const harness = makeHarness({ | |
| 123 | + isEnabled: () => new Promise((resolve) => resolvers.push(resolve)), | |
| 124 | + }); | |
| 125 | + await harness.send(makeInput({})); | |
| 126 | + await harness.send(makeInput({ type: "keyUp" })); | |
| 127 | + // Outside the double-tap window, so the second press starts a new hold. | |
| 128 | + vi.advanceTimersByTime(QUIT_DOUBLE_TAP_MS + 100); | |
| 129 | + await harness.send(makeInput({})); | |
| 130 | + expect(resolvers).toHaveLength(2); | |
| 131 | + | |
| 132 | + // Press #1 resolves late with "disabled" — it must not quit press #2. | |
| 133 | + resolvers[0]?.(false); | |
| 134 | + await Promise.resolve(); | |
| 135 | + await Promise.resolve(); | |
| 136 | + expect(harness.quit).not.toHaveBeenCalled(); | |
| 137 | + | |
| 138 | + // Press #2 resolves enabled and completes a full hold. | |
| 139 | + resolvers[1]?.(true); | |
| 140 | + await harness.holdFor(QUIT_HOLD_DURATION_MS + 200); | |
| 141 | + expect(harness.quit).toHaveBeenCalledTimes(1); | |
| 142 | + }); | |
| 143 | + | |
| 144 | + it("quits on a quick double tap, even when the first release was never seen", async () => { | |
| 145 | + const harness = makeHarness(); | |
| 146 | + await harness.send(makeInput({})); | |
| 147 | + vi.advanceTimersByTime(QUIT_DOUBLE_TAP_MS - 100); | |
| 148 | + await harness.send(makeInput({})); | |
| 149 | + expect(harness.quit).toHaveBeenCalledTimes(1); | |
| 150 | + }); | |
| 151 | + | |
| 152 | + it("treats two slow taps as separate presses", async () => { | |
| 153 | + const harness = makeHarness(); | |
| 154 | + await harness.send(makeInput({})); | |
| 155 | + await harness.send(makeInput({ type: "keyUp" })); | |
| 156 | + vi.advanceTimersByTime(QUIT_DOUBLE_TAP_MS + 100); | |
| 157 | + await harness.send(makeInput({})); | |
| 158 | + expect(harness.quit).not.toHaveBeenCalled(); | |
| 159 | + expect(harness.notifications).toEqual(["down", "up", "down"]); | |
| 160 | + }); | |
| 161 | + | |
| 162 | + it("cancels the hold when another key interrupts it", async () => { | |
| 163 | + const harness = makeHarness(); | |
| 164 | + await harness.send(makeInput({})); | |
| 165 | + await harness.holdFor(500); | |
| 166 | + // Shift pressed mid-hold breaks the gesture... | |
| 167 | + await harness.send(makeInput({ shift: true })); | |
| 168 | + expect(harness.notifications).toEqual(["down", "up"]); | |
| 169 | + // ...so later repeats past the threshold must not quit. | |
| 170 | + await harness.holdFor(QUIT_HOLD_DURATION_MS); | |
| 171 | + expect(harness.quit).not.toHaveBeenCalled(); | |
| 172 | + }); | |
| 173 | + | |
| 174 | + it("does not count an interrupted press toward a double tap", async () => { | |
| 175 | + const harness = makeHarness(); | |
| 176 | + await harness.send(makeInput({})); | |
| 177 | + await harness.send(makeInput({ shift: true })); | |
| 178 | + // A fresh press right after the interruption starts a new hold, not a | |
| 179 | + // double-tap quit. | |
| 180 | + await harness.send(makeInput({})); | |
| 181 | + expect(harness.quit).not.toHaveBeenCalled(); | |
| 182 | + expect(harness.notifications).toEqual(["down", "up", "down"]); | |
| 183 | + }); | |
| 184 | + | |
| 185 | + it("ignores other shortcuts", async () => { | |
| 186 | + const harness = makeHarness(); | |
| 187 | + await harness.send(makeInput({ key: "w" })); | |
| 188 | + await harness.send(makeInput({ shift: true })); | |
| 189 | + await harness.send(makeInput({ meta: false })); | |
| 190 | + expect(harness.preventDefault).not.toHaveBeenCalled(); | |
| 191 | + expect(harness.notifications).toEqual([]); | |
| 192 | + }); | |
| 193 | + | |
| 194 | + it("uses control on non-mac platforms", async () => { | |
| 195 | + const harness = makeHarness({ platform: "linux" }); | |
| 196 | + await harness.send(makeInput({ meta: false, control: true })); | |
| 197 | + expect(harness.preventDefault).toHaveBeenCalledTimes(1); | |
| 198 | + await harness.holdFor(QUIT_HOLD_DURATION_MS + 200, { meta: false, control: true }); | |
| 199 | + expect(harness.quit).toHaveBeenCalledTimes(1); | |
| 200 | + }); | |
| 201 | +}); | |
| @@ -0,0 +1,148 @@ | ||
| 1 | +// @effect-diagnostics globalDate:off globalTimers:off -- Synchronous before-input-event handler; key events must be timed and the watchdog scheduled outside any Effect runtime. | |
| 2 | + | |
| 3 | +// Chrome-style hold-to-quit. The quit accelerator is intercepted in | |
| 4 | +// before-input-event (which runs before the native menu accelerator), and the | |
| 5 | +// app only quits once the shortcut has been held for QUIT_HOLD_DURATION_MS. | |
| 6 | +// A quick tap just shows the renderer's "Hold to Quit" hint, and a second tap | |
| 7 | +// within QUIT_DOUBLE_TAP_MS quits immediately. Quitting from the application | |
| 8 | +// menu itself is untouched and quits immediately. | |
| 9 | +export const QUIT_HOLD_DURATION_MS = 1200; | |
| 10 | +// A second quick tap of the shortcut is the user insisting: quit immediately. | |
| 11 | +export const QUIT_DOUBLE_TAP_MS = 500; | |
| 12 | +// "Still held" is proven by auto-repeat keydowns, not by the absence of a | |
| 13 | +// release: macOS suppresses a letter's keyUp while the command key is down, so | |
| 14 | +// a tap's release can go completely unseen and a release-based timer would | |
| 15 | +// quit anyway. The press is treated as released once no key event has arrived | |
| 16 | +// for QUIT_HOLD_RELEASE_GRACE_MS past the hold duration. Keyboards with | |
| 17 | +// auto-repeat disabled cannot hold-to-quit and fall back to the menu's Quit. | |
| 18 | +export const QUIT_HOLD_RELEASE_GRACE_MS = 600; | |
| 19 | + | |
| 20 | +export type QuitHoldState = "down" | "up"; | |
| 21 | + | |
| 22 | +export interface QuitHoldKeyInput { | |
| 23 | + readonly type: string; | |
| 24 | + readonly key: string; | |
| 25 | + readonly meta: boolean; | |
| 26 | + readonly control: boolean; | |
| 27 | + readonly alt: boolean; | |
| 28 | + readonly shift: boolean; | |
| 29 | + readonly isAutoRepeat: boolean; | |
| 30 | +} | |
| 31 | + | |
| 32 | +export interface QuitHoldOptions { | |
| 33 | + readonly platform: NodeJS.Platform; | |
| 34 | + readonly isEnabled: () => Promise<boolean>; | |
| 35 | + readonly notify: (state: QuitHoldState) => void; | |
| 36 | + readonly quit: () => void; | |
| 37 | +} | |
| 38 | + | |
| 39 | +export function makeQuitHoldHandler( | |
| 40 | + options: QuitHoldOptions, | |
| 41 | +): (event: { preventDefault: () => void }, input: QuitHoldKeyInput) => void { | |
| 42 | + const modifierKey = options.platform === "darwin" ? "meta" : "control"; | |
| 43 | + let watchdog: NodeJS.Timeout | undefined; | |
| 44 | + let holding = false; | |
| 45 | + // Set once isEnabled resolves true; auto-repeats may only quit when armed. | |
| 46 | + let armed = false; | |
| 47 | + let heldSince = 0; | |
| 48 | + let lastPressAt = 0; | |
| 49 | + // Incremented on every new press and every release/quit so a pending | |
| 50 | + // isEnabled() resolution from a superseded press cannot arm (or quit for) | |
| 51 | + // the current one. | |
| 52 | + let generation = 0; | |
| 53 | + | |
| 54 | + const clearWatchdog = () => { | |
| 55 | + if (watchdog !== undefined) { | |
| 56 | + clearTimeout(watchdog); | |
| 57 | + watchdog = undefined; | |
| 58 | + } | |
| 59 | + }; | |
| 60 | + | |
| 61 | + const release = () => { | |
| 62 | + if (!holding) return; | |
| 63 | + generation += 1; | |
| 64 | + holding = false; | |
| 65 | + armed = false; | |
| 66 | + clearWatchdog(); | |
| 67 | + options.notify("up"); | |
| 68 | + }; | |
| 69 | + | |
| 70 | + // Dismisses the overlay first: if the quit is cancelled downstream the | |
| 71 | + // renderer must not be left with a stuck "Hold to Quit" hint. | |
| 72 | + const quitNow = () => { | |
| 73 | + release(); | |
| 74 | + options.quit(); | |
| 75 | + }; | |
| 76 | + | |
| 77 | + return (event, input) => { | |
| 78 | + const key = input.key.toLowerCase(); | |
| 79 | + if (input.type === "keyUp") { | |
| 80 | + if (key === "q" || key === modifierKey) release(); | |
| 81 | + return; | |
| 82 | + } | |
| 83 | + if (input.type !== "keyDown") return; | |
| 84 | + | |
| 85 | + const modifierDown = options.platform === "darwin" ? input.meta : input.control; | |
| 86 | + if (!modifierDown || input.alt || input.shift || key !== "q") { | |
| 87 | + // Any other key (or an extra modifier) pressed mid-hold breaks the | |
| 88 | + // gesture; without this the hold timer keeps running through the | |
| 89 | + // interruption and the next qualifying repeat would quit early. The | |
| 90 | + // interrupted press also stops counting toward a double tap — but only | |
| 91 | + // here, not in release(), which runs mid-restart on an unseen-release | |
| 92 | + // re-press and must not wipe that press's own tap timestamp. | |
| 93 | + if (holding && !input.isAutoRepeat) { | |
| 94 | + lastPressAt = 0; | |
| 95 | + release(); | |
| 96 | + } | |
| 97 | + return; | |
| 98 | + } | |
| 99 | + | |
| 100 | + event.preventDefault(); | |
| 101 | + | |
| 102 | + if (input.isAutoRepeat) { | |
| 103 | + if (armed && Date.now() - heldSince >= QUIT_HOLD_DURATION_MS) { | |
| 104 | + quitNow(); | |
| 105 | + } | |
| 106 | + return; | |
| 107 | + } | |
| 108 | + | |
| 109 | + const now = Date.now(); | |
| 110 | + const previousPressAt = lastPressAt; | |
| 111 | + lastPressAt = now; | |
| 112 | + // A fresh keydown while "holding" means the key came back down after a | |
| 113 | + // release macOS never delivered — so both branches below see real taps. | |
| 114 | + if (previousPressAt !== 0 && now - previousPressAt <= QUIT_DOUBLE_TAP_MS) { | |
| 115 | + quitNow(); | |
| 116 | + return; | |
| 117 | + } | |
| 118 | + if (holding) release(); | |
| 119 | + | |
| 120 | + generation += 1; | |
| 121 | + const pressGeneration = generation; | |
| 122 | + holding = true; | |
| 123 | + heldSince = now; | |
| 124 | + options.notify("down"); | |
| 125 | + void options.isEnabled().then( | |
| 126 | + (enabled) => { | |
| 127 | + if (generation !== pressGeneration) return; | |
| 128 | + if (!enabled) { | |
| 129 | + // Hold-to-quit disabled: a single press quits immediately. | |
| 130 | + quitNow(); | |
| 131 | + return; | |
| 132 | + } | |
| 133 | + armed = true; | |
| 134 | + // No auto-repeat by then means the key was released (possibly with a | |
| 135 | + // suppressed keyUp) or repeat is disabled; either way, don't quit. | |
| 136 | + watchdog = setTimeout(() => { | |
| 137 | + watchdog = undefined; | |
| 138 | + release(); | |
| 139 | + }, QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS); | |
| 140 | + }, | |
| 141 | + // A failed settings read must never strand the quit request. | |
| 142 | + () => { | |
| 143 | + if (generation !== pressGeneration) return; | |
| 144 | + quitNow(); | |
| 145 | + }, | |
| 146 | + ); | |
| 147 | + }; | |
| 148 | +} | |
| @@ -4,6 +4,7 @@ import { describe, expect, it } from "vite-plus/test"; · 3 unmodified lines | ||
| 4 | 4 | |
| 5 | 5 | import { ElectronBrowserHost } from "./browser/ElectronBrowserHost"; |
| 6 | 6 | import { PreviewAutomationHosts } from "./components/preview/PreviewAutomationHosts"; |
| 7 | +import { QuitHoldOverlay } from "./components/QuitHoldOverlay"; | |
| 7 | 8 | import { AppAtomRegistryProvider } from "./rpc/atomRegistry"; |
| 8 | 9 | import type { AppRouter } from "./router"; |
| 9 | 10 | import { AppRoot } from "./AppRoot"; |
| @@ -16,9 +17,10 @@ describe("AppRoot", () => { · 6 unmodified lines | ||
| 16 | 17 | const children = Children.toArray( |
| 17 | 18 | (root as ReactElement<{ readonly children: ReactNode }>).props.children, |
| 18 | 19 | ); |
| 19 | − expect(children).toHaveLength(3); | |
| 20 | + expect(children).toHaveLength(4); | |
| 20 | 21 | expect(isValidElement(children[0]) && children[0].type).toBe(RouterProvider); |
| 21 | 22 | expect(isValidElement(children[1]) && children[1].type).toBe(PreviewAutomationHosts); |
| 22 | 23 | expect(isValidElement(children[2]) && children[2].type).toBe(ElectronBrowserHost); |
| 24 | + expect(isValidElement(children[3]) && children[3].type).toBe(QuitHoldOverlay); | |
| 23 | 25 | }); |
| 24 | 26 | }); |
| @@ -2,6 +2,7 @@ import { RouterProvider } from "@tanstack/react-router"; · 1 unmodified line | ||
| 2 | 2 | |
| 3 | 3 | import { ElectronBrowserHost } from "./browser/ElectronBrowserHost"; |
| 4 | 4 | import { PreviewAutomationHosts } from "./components/preview/PreviewAutomationHosts"; |
| 5 | +import { QuitHoldOverlay } from "./components/QuitHoldOverlay"; | |
| 5 | 6 | import { AppAtomRegistryProvider } from "./rpc/atomRegistry"; |
| 6 | 7 | import type { AppRouter } from "./router"; |
| 7 | 8 | |
| @@ -16,6 +17,7 @@ export function AppRoot({ router }: { readonly router: AppRouter }) { · 8 unmodified lines | ||
| 16 | 17 | <RouterProvider router={router} /> |
| 17 | 18 | <PreviewAutomationHosts /> |
| 18 | 19 | <ElectronBrowserHost /> |
| 20 | + <QuitHoldOverlay /> | |
| 19 | 21 | </AppAtomRegistryProvider> |
| 20 | 22 | ); |
| 21 | 23 | } |
| @@ -0,0 +1,47 @@ | ||
| 1 | +import { useEffect, useState } from "react"; | |
| 2 | + | |
| 3 | +import { isMacPlatform } from "../lib/utils"; | |
| 4 | + | |
| 5 | +// Matches the hold duration in apps/desktop/src/window/QuitHold.ts: the hint | |
| 6 | +// from a quick tap lingers for as long as a full hold would have taken. | |
| 7 | +const HIDE_AFTER_RELEASE_MS = 1200; | |
| 8 | + | |
| 9 | +/** | |
| 10 | + * Chrome-style "Hold ⌘Q to Quit" hint. The desktop main process intercepts | |
| 11 | + * the quit accelerator and pushes press/release states; a quick tap shows | |
| 12 | + * this pill while a full hold quits the app. | |
| 13 | + */ | |
| 14 | +export function QuitHoldOverlay() { | |
| 15 | + const [visible, setVisible] = useState(false); | |
| 16 | + | |
| 17 | + useEffect(() => { | |
| 18 | + const subscribe = window.desktopBridge?.onQuitShortcut; | |
| 19 | + if (!subscribe) return; | |
| 20 | + let hideTimer: number | undefined; | |
| 21 | + const unsubscribe = subscribe((state) => { | |
| 22 | + window.clearTimeout(hideTimer); | |
| 23 | + if (state === "down") { | |
| 24 | + setVisible(true); | |
| 25 | + return; | |
| 26 | + } | |
| 27 | + hideTimer = window.setTimeout(() => setVisible(false), HIDE_AFTER_RELEASE_MS); | |
| 28 | + }); | |
| 29 | + return () => { | |
| 30 | + window.clearTimeout(hideTimer); | |
| 31 | + unsubscribe(); | |
| 32 | + }; | |
| 33 | + }, []); | |
| 34 | + | |
| 35 | + if (!visible) return null; | |
| 36 | + const shortcut = isMacPlatform(navigator.platform) ? "⌘Q" : "Ctrl+Q"; | |
| 37 | + return ( | |
| 38 | + <div | |
| 39 | + role="status" | |
| 40 | + className="pointer-events-none fixed inset-x-0 top-[22%] z-100 flex justify-center" | |
| 41 | + > | |
| 42 | + <div className="rounded-full bg-neutral-700/95 px-8 py-4 text-2xl font-bold text-white shadow-xl"> | |
| 43 | + Hold {shortcut} to Quit | |
| 44 | + </div> | |
| 45 | + </div> | |
| 46 | + ); | |
| 47 | +} | |
| @@ -526,11 +526,15 @@ export function useSettingsRestore(onRestored?: () => void) { · 525 unmodified lines | ||
| 526 | 526 | ...(settings.confirmThreadDelete !== DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete |
| 527 | 527 | ? ["Delete confirmation"] |
| 528 | 528 | : []), |
| 529 | + ...(settings.confirmQuit !== DEFAULT_UNIFIED_SETTINGS.confirmQuit | |
| 530 | + ? ["Quit confirmation"] | |
| 531 | + : []), | |
| 529 | 532 | ...(isTextGenerationModelDirty ? ["Text generation model"] : []), |
| 530 | 533 | ], |
| 531 | 534 | [ |
| 532 | 535 | isTextGenerationModelDirty, |
| 533 | 536 | isBackgroundActivityDirty, |
| 537 | + settings.confirmQuit, | |
| 534 | 538 | settings.confirmThreadArchive, |
| 535 | 539 | settings.confirmThreadDelete, |
| 536 | 540 | settings.addProjectBaseDirectory, |
| @@ -644,6 +648,7 @@ export function useSettingsRestore(onRestored?: () => void) { · 107 unmodified lines | ||
| 644 | 648 | addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory, |
| 645 | 649 | confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive, |
| 646 | 650 | confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete, |
| 651 | + confirmQuit: DEFAULT_UNIFIED_SETTINGS.confirmQuit, | |
| 647 | 652 | textGenerationModelSelection: DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection, |
| 648 | 653 | fontFamilySans: DEFAULT_UNIFIED_SETTINGS.fontFamilySans, |
| 649 | 654 | fontFamilyComposer: DEFAULT_UNIFIED_SETTINGS.fontFamilyComposer, |
| @@ -2234,6 +2239,30 @@ export function GeneralSettingsPanel() { · 1584 unmodified lines | ||
| 2234 | 2239 | } |
| 2235 | 2240 | /> |
| 2236 | 2241 | |
| 2242 | + {isElectron ? ( | |
| 2243 | + <SettingsRow | |
| 2244 | + {...searchableSetting("quit-confirmation")} | |
| 2245 | + description="Require holding the quit shortcut before the desktop app quits. A quick tap shows a hint instead." | |
| 2246 | + resetAction={ | |
| 2247 | + settings.confirmQuit !== DEFAULT_UNIFIED_SETTINGS.confirmQuit ? ( | |
| 2248 | + <SettingResetButton | |
| 2249 | + label="quit confirmation" | |
| 2250 | + onClick={() => | |
| 2251 | + updateSettings({ confirmQuit: DEFAULT_UNIFIED_SETTINGS.confirmQuit }) | |
| 2252 | + } | |
| 2253 | + /> | |
| 2254 | + ) : null | |
| 2255 | + } | |
| 2256 | + control={ | |
| 2257 | + <Switch | |
| 2258 | + checked={settings.confirmQuit} | |
| 2259 | + onCheckedChange={(checked) => updateSettings({ confirmQuit: Boolean(checked) })} | |
| 2260 | + aria-label="Hold to quit" | |
| 2261 | + /> | |
| 2262 | + } | |
| 2263 | + /> | |
| 2264 | + ) : null} | |
| 2265 | + | |
| 2237 | 2266 | <SettingsRow |
| 2238 | 2267 | {...searchableSetting("text-generation-model")} |
| 2239 | 2268 | description="Default model for generated text like thread titles and source control content. Source control settings can override it with a dedicated source control writer model." |
| @@ -60,6 +60,11 @@ describe("searchSettings", () => { · 59 unmodified lines | ||
| 60 | 60 | expect(searchSettings(" ", ITEMS)).toEqual([]); |
| 61 | 61 | }); |
| 62 | 62 | |
| 63 | + it("hides desktop-only settings from browser search", () => { | |
| 64 | + expect(SETTINGS_SEARCH_ITEMS.some((item) => item.id === "quit-confirmation")).toBe(true); | |
| 65 | + expect(searchSettings("quit confirmation")).toEqual([]); | |
| 66 | + }); | |
| 67 | + | |
| 63 | 68 | it("keeps catalog result ids unique", () => { |
| 64 | 69 | const ids = SETTINGS_SEARCH_ITEMS.map((item) => item.id); |
| 65 | 70 | expect(new Set(ids).size).toBe(ids.length); |
| @@ -1,3 +1,5 @@ | ||
| 1 | +import { isElectron } from "~/env"; | |
| 2 | + | |
| 1 | 3 | export type SettingsPath = |
| 2 | 4 | | "/settings/general" |
| 3 | 5 | | "/settings/appearance" |
| @@ -12,6 +14,9 @@ export interface SettingsSearchItem { · 8 unmodified lines | ||
| 12 | 14 | readonly title: string; |
| 13 | 15 | readonly to: SettingsPath; |
| 14 | 16 | readonly targetId?: string; |
| 17 | + // Its row only renders in the desktop app, so a browser result would land on | |
| 18 | + // an anchor that isn't there. | |
| 19 | + readonly desktopOnly?: boolean; | |
| 15 | 20 | } |
| 16 | 21 | |
| 17 | 22 | /** |
| @@ -149,6 +154,12 @@ export const SETTINGS_SEARCH_ITEMS = [ · 131 unmodified lines | ||
| 149 | 154 | title: "Delete confirmation", |
| 150 | 155 | to: "/settings/general", |
| 151 | 156 | }, |
| 157 | + { | |
| 158 | + id: "quit-confirmation", | |
| 159 | + title: "Hold to quit", | |
| 160 | + to: "/settings/general", | |
| 161 | + desktopOnly: true, | |
| 162 | + }, | |
| 152 | 163 | { |
| 153 | 164 | id: "text-generation-model", |
| 154 | 165 | title: "Text generation model", |
| @@ -236,5 +247,9 @@ export function searchSettings( · 81 unmodified lines | ||
| 236 | 247 | const normalizedQuery = normalizeSearchText(query); |
| 237 | 248 | if (normalizedQuery.length === 0) return []; |
| 238 | 249 | |
| 239 | − return items.filter((item) => normalizeSearchText(item.title).includes(normalizedQuery)); | |
| 250 | + return items.filter( | |
| 251 | + (item) => | |
| 252 | + (isElectron || item.desktopOnly !== true) && | |
| 253 | + normalizeSearchText(item.title).includes(normalizedQuery), | |
| 254 | + ); | |
| 240 | 255 | } |
| @@ -1080,6 +1080,12 @@ export interface DesktopBridge { · 1079 unmodified lines | ||
| 1080 | 1080 | */ |
| 1081 | 1081 | probeRemoteEditors?: () => Promise<readonly EditorId[]>; |
| 1082 | 1082 | onMenuAction: (listener: (action: string) => void) => () => void; |
| 1083 | + /** | |
| 1084 | + * Hold-to-quit hint pushes: "down" when the quit shortcut is first pressed, | |
| 1085 | + * "up" when it is released before the hold completes. Optional: older | |
| 1086 | + * desktop builds never emit it. | |
| 1087 | + */ | |
| 1088 | + onQuitShortcut?: (listener: (state: "down" | "up") => void) => () => void; | |
| 1083 | 1089 | getWindowFullscreenState: () => boolean; |
| 1084 | 1090 | onWindowFullscreenStateChange: (listener: (fullscreen: boolean) => void) => () => void; |
| 1085 | 1091 | getUpdateState: () => Promise<DesktopUpdateState>; |
| @@ -112,6 +112,9 @@ export const FontFamilyPreference = Schema.String.check(Schema.isMaxLength(200)) · 111 unmodified lines | ||
| 112 | 112 | export type FontFamilyPreference = typeof FontFamilyPreference.Type; |
| 113 | 113 | |
| 114 | 114 | export const ClientSettingsSchema = Schema.Struct({ |
| 115 | + // Desktop-only: require holding the quit shortcut (Cmd/Ctrl+Q) before the | |
| 116 | + // app quits; a quick tap only shows a hint. Browser clients ignore it. | |
| 117 | + confirmQuit: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), | |
| 115 | 118 | confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), |
| 116 | 119 | confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), |
| 117 | 120 | dismissedProviderUpdateNotificationKeys: Schema.Array(TrimmedNonEmptyString).pipe( |
| @@ -756,6 +759,7 @@ export const ServerSettingsPatch = Schema.Struct({ · 638 unmodified lines | ||
| 756 | 759 | export type ServerSettingsPatch = typeof ServerSettingsPatch.Type; |
| 757 | 760 | |
| 758 | 761 | export const ClientSettingsPatch = Schema.Struct({ |
| 762 | + confirmQuit: Schema.optionalKey(Schema.Boolean), | |
| 759 | 763 | confirmThreadArchive: Schema.optionalKey(Schema.Boolean), |
| 760 | 764 | confirmThreadDelete: Schema.optionalKey(Schema.Boolean), |
| 761 | 765 | diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean), |