| author | |
| committer | |
| log | 7c8848ebb054c1f4c1279f634633cddc37cb1fac |
| tree | b530b8d682f2e9f74bbfe2d5be2d1f94122fcd23 |
| parent | cf7bfd1c93974428262ab1419d11c972d01d65fa |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>4 files changed, 118 insertions(+), 0 deletions(-)
apps/desktop/src/preview/GuestProtocol.ts+1| ... | @@ -4,3 +4,4 @@ export const ELEMENT_PICKED_CHANNEL = "preview:element-picked"; | ... | @@ -4,3 +4,4 @@ export const ELEMENT_PICKED_CHANNEL = "preview:element-picked"; |
| 4 | export const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured"; | 4 | export const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured"; |
| 5 | export const ANNOTATION_THEME_CHANNEL = "preview:annotation-theme"; | 5 | export const ANNOTATION_THEME_CHANNEL = "preview:annotation-theme"; |
| 6 | export const HUMAN_INPUT_CHANNEL = "preview:human-input"; | 6 | export const HUMAN_INPUT_CHANNEL = "preview:human-input"; |
| 7 | export const MOUSE_NAVIGATE_CHANNEL = "preview:mouse-navigate"; |
apps/desktop/src/preview/Manager.test.ts+63| ... | @@ -2239,6 +2239,69 @@ describe("PreviewManager", () => { | ... | @@ -2239,6 +2239,69 @@ describe("PreviewManager", () => { |
| 2239 | ), | 2239 | ), |
| 2240 | ); | 2240 | ); |
| 2241 | 2241 | ||
| 2242 | effectIt.effect("navigates the guest history when the thumb-button ipc fires", () => | ||
| 2243 | withManager((manager) => | ||
| 2244 | Effect.gen(function* () { | ||
| 2245 | let mouseNavigate: ((event: unknown, payload: unknown) => void) | undefined; | ||
| 2246 | const goBack = vi.fn(); | ||
| 2247 | const goForward = vi.fn(); | ||
| 2248 | let canGoBack = true; | ||
| 2249 | fromId.mockReturnValue({ | ||
| 2250 | id: 42, | ||
| 2251 | isDestroyed: () => false, | ||
| 2252 | getType: () => "webview", | ||
| 2253 | getURL: () => "https://example.com", | ||
| 2254 | getTitle: () => "Example", | ||
| 2255 | isLoading: () => false, | ||
| 2256 | getZoomFactor: () => 1, | ||
| 2257 | setZoomFactor: vi.fn(), | ||
| 2258 | on: vi.fn(), | ||
| 2259 | off: vi.fn(), | ||
| 2260 | ipc: { | ||
| 2261 | on: vi.fn((channel: string, listener: typeof mouseNavigate) => { | ||
| 2262 | if (channel === "preview:mouse-navigate") mouseNavigate = listener; | ||
| 2263 | }), | ||
| 2264 | off: vi.fn(), | ||
| 2265 | }, | ||
| 2266 | send: webviewSend, | ||
| 2267 | navigationHistory: { | ||
| 2268 | canGoBack: () => canGoBack, | ||
| 2269 | canGoForward: () => true, | ||
| 2270 | goBack, | ||
| 2271 | goForward, | ||
| 2272 | }, | ||
| 2273 | setWindowOpenHandler: vi.fn(), | ||
| 2274 | debugger: { | ||
| 2275 | isAttached: () => false, | ||
| 2276 | attach: vi.fn(), | ||
| 2277 | sendCommand: vi.fn(async () => undefined), | ||
| 2278 | on: vi.fn(), | ||
| 2279 | off: vi.fn(), | ||
| 2280 | }, | ||
| 2281 | } as never); | ||
| 2282 | |||
| 2283 | yield* manager.createTab("tab_nav"); | ||
| 2284 | yield* manager.registerWebview("tab_nav", 42); | ||
| 2285 | expect(mouseNavigate).toBeDefined(); | ||
| 2286 | |||
| 2287 | mouseNavigate?.({}, { direction: "back" }); | ||
| 2288 | yield* Effect.yieldNow; | ||
| 2289 | expect(goBack).toHaveBeenCalledOnce(); | ||
| 2290 | |||
| 2291 | mouseNavigate?.({}, { direction: "forward" }); | ||
| 2292 | yield* Effect.yieldNow; | ||
| 2293 | expect(goForward).toHaveBeenCalledOnce(); | ||
| 2294 | |||
| 2295 | // Ignores unknown payloads and never navigates when history is exhausted. | ||
| 2296 | mouseNavigate?.({}, { direction: "sideways" }); | ||
| 2297 | canGoBack = false; | ||
| 2298 | mouseNavigate?.({}, { direction: "back" }); | ||
| 2299 | yield* Effect.yieldNow; | ||
| 2300 | expect(goBack).toHaveBeenCalledOnce(); | ||
| 2301 | }), | ||
| 2302 | ), | ||
| 2303 | ); | ||
| 2304 | |||
| 2242 | effectIt.effect("reveals only files inside the configured browser artifact directory", () => | 2305 | effectIt.effect("reveals only files inside the configured browser artifact directory", () => |
| 2243 | withManager((manager) => | 2306 | withManager((manager) => |
| 2244 | Effect.gen(function* () { | 2307 | Effect.gen(function* () { |
apps/desktop/src/preview/Manager.ts+19| ... | @@ -58,6 +58,7 @@ import { | ... | @@ -58,6 +58,7 @@ import { |
| 58 | CANCEL_PICK_CHANNEL, | 58 | CANCEL_PICK_CHANNEL, |
| 59 | ELEMENT_PICKED_CHANNEL, | 59 | ELEMENT_PICKED_CHANNEL, |
| 60 | HUMAN_INPUT_CHANNEL, | 60 | HUMAN_INPUT_CHANNEL, |
| 61 | MOUSE_NAVIGATE_CHANNEL, | ||
| 61 | START_PICK_CHANNEL, | 62 | START_PICK_CHANNEL, |
| 62 | } from "./GuestProtocol.ts"; | 63 | } from "./GuestProtocol.ts"; |
| 63 | import { isPreviewAnnotationPayload } from "./PickedElementPayload.ts"; | 64 | import { isPreviewAnnotationPayload } from "./PickedElementPayload.ts"; |
| ... | @@ -1506,6 +1507,22 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -1506,6 +1507,22 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1506 | const humanInput = (_event: unknown, rawSignal?: unknown): void => { | 1507 | const humanInput = (_event: unknown, rawSignal?: unknown): void => { |
| 1507 | runFork(handleHumanInput(rawSignal)); | 1508 | runFork(handleHumanInput(rawSignal)); |
| 1508 | }; | 1509 | }; |
| 1510 | const mouseNavigate = (_event: unknown, payload?: unknown): void => { | ||
| 1511 | const direction = | ||
| 1512 | typeof payload === "object" && payload !== null && "direction" in payload | ||
| 1513 | ? (payload as { direction?: unknown }).direction | ||
| 1514 | : undefined; | ||
| 1515 | if (direction !== "back" && direction !== "forward") return; | ||
| 1516 | runFork( | ||
| 1517 | attempt({ operation: "mouseNavigate", tabId, webContentsId: wc.id }, () => { | ||
| 1518 | if (direction === "back") { | ||
| 1519 | if (wc.navigationHistory.canGoBack()) wc.navigationHistory.goBack(); | ||
| 1520 | } else if (wc.navigationHistory.canGoForward()) { | ||
| 1521 | wc.navigationHistory.goForward(); | ||
| 1522 | } | ||
| 1523 | }).pipe(Effect.ignore), | ||
| 1524 | ); | ||
| 1525 | }; | ||
| 1509 | const forwardShortcut = Effect.fn("PreviewManager.forwardShortcut")(function* ( | 1526 | const forwardShortcut = Effect.fn("PreviewManager.forwardShortcut")(function* ( |
| 1510 | event: Electron.Event, | 1527 | event: Electron.Event, |
| 1511 | input: Electron.Input, | 1528 | input: Electron.Input, |
| ... | @@ -1552,6 +1569,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -1552,6 +1569,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1552 | wc.off("did-fail-load", failed as never); | 1569 | wc.off("did-fail-load", failed as never); |
| 1553 | wc.off("before-input-event", beforeInput); | 1570 | wc.off("before-input-event", beforeInput); |
| 1554 | wc.ipc.off(HUMAN_INPUT_CHANNEL, humanInput); | 1571 | wc.ipc.off(HUMAN_INPUT_CHANNEL, humanInput); |
| 1572 | wc.ipc.off(MOUSE_NAVIGATE_CHANNEL, mouseNavigate); | ||
| 1555 | }).pipe(Effect.ignore), | 1573 | }).pipe(Effect.ignore), |
| 1556 | ); | 1574 | ); |
| 1557 | const install = Effect.fn("PreviewManager.installWebContentsListeners")(function* () { | 1575 | const install = Effect.fn("PreviewManager.installWebContentsListeners")(function* () { |
| ... | @@ -1565,6 +1583,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -1565,6 +1583,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1565 | wc.on("did-stop-loading", sync); | 1583 | wc.on("did-stop-loading", sync); |
| 1566 | wc.on("did-fail-load", failed as never); | 1584 | wc.on("did-fail-load", failed as never); |
| 1567 | wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput); | 1585 | wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput); |
| 1586 | wc.ipc.on(MOUSE_NAVIGATE_CHANNEL, mouseNavigate); | ||
| 1568 | wc.setWindowOpenHandler(({ url }) => { | 1587 | wc.setWindowOpenHandler(({ url }) => { |
| 1569 | runFork( | 1588 | runFork( |
| 1570 | attemptPromise({ operation: "openPreviewWindow", tabId, webContentsId: wc.id }, () => | 1589 | attemptPromise({ operation: "openPreviewWindow", tabId, webContentsId: wc.id }, () => |
apps/desktop/src/preview/PickPreload.ts+35| ... | @@ -22,6 +22,7 @@ import { | ... | @@ -22,6 +22,7 @@ import { |
| 22 | CANCEL_PICK_CHANNEL, | 22 | CANCEL_PICK_CHANNEL, |
| 23 | ELEMENT_PICKED_CHANNEL, | 23 | ELEMENT_PICKED_CHANNEL, |
| 24 | HUMAN_INPUT_CHANNEL, | 24 | HUMAN_INPUT_CHANNEL, |
| 25 | MOUSE_NAVIGATE_CHANNEL, | ||
| 25 | START_PICK_CHANNEL, | 26 | START_PICK_CHANNEL, |
| 26 | } from "./GuestProtocol.ts"; | 27 | } from "./GuestProtocol.ts"; |
| 27 | const OVERLAY_ATTRIBUTE = "data-t3code-annotation-ui"; | 28 | const OVERLAY_ATTRIBUTE = "data-t3code-annotation-ui"; |
| ... | @@ -102,6 +103,40 @@ const reportHumanKeyInput = (event: KeyboardEvent): void => { | ... | @@ -102,6 +103,40 @@ const reportHumanKeyInput = (event: KeyboardEvent): void => { |
| 102 | window.addEventListener("pointerdown", reportHumanPointerInput, true); | 103 | window.addEventListener("pointerdown", reportHumanPointerInput, true); |
| 103 | window.addEventListener("keydown", reportHumanKeyInput, true); | 104 | window.addEventListener("keydown", reportHumanKeyInput, true); |
| 104 | 105 | ||
| 106 | // Mouse thumb buttons: `button === 3` is Back, `button === 4` is Forward. | ||
| 107 | const MOUSE_BUTTON_BACK = 3; | ||
| 108 | const MOUSE_BUTTON_FORWARD = 4; | ||
| 109 | |||
| 110 | const navigationDirectionForButton = (button: number): "back" | "forward" | null => { | ||
| 111 | if (button === MOUSE_BUTTON_BACK) return "back"; | ||
| 112 | if (button === MOUSE_BUTTON_FORWARD) return "forward"; | ||
| 113 | return null; | ||
| 114 | }; | ||
| 115 | |||
| 116 | // Chromium routes thumb-button history navigation to the *focused* WebContents, | ||
| 117 | // so hovering this guest without focusing it sends the host app's router back | ||
| 118 | // instead of the preview. Suppress Chromium's default here and drive this tab's | ||
| 119 | // history explicitly so the buttons always navigate the browser the pointer is | ||
| 120 | // over — never the host app. | ||
| 121 | const suppressNavigationButton = (event: MouseEvent): void => { | ||
| 122 | if (!event.isTrusted || navigationDirectionForButton(event.button) === null) return; | ||
| 123 | event.preventDefault(); | ||
| 124 | event.stopImmediatePropagation(); | ||
| 125 | }; | ||
| 126 | |||
| 127 | const requestNavigationForButton = (event: MouseEvent): void => { | ||
| 128 | if (!event.isTrusted) return; | ||
| 129 | const direction = navigationDirectionForButton(event.button); | ||
| 130 | if (direction === null) return; | ||
| 131 | event.preventDefault(); | ||
| 132 | event.stopImmediatePropagation(); | ||
| 133 | ipcRenderer.send(MOUSE_NAVIGATE_CHANNEL, { direction }); | ||
| 134 | }; | ||
| 135 | |||
| 136 | window.addEventListener("mousedown", suppressNavigationButton, true); | ||
| 137 | window.addEventListener("mouseup", requestNavigationForButton, true); | ||
| 138 | window.addEventListener("auxclick", suppressNavigationButton, true); | ||
| 139 | |||
| 105 | const nextId = (prefix: string): string => { | 140 | const nextId = (prefix: string): string => { |
| 106 | idSequence += 1; | 141 | idSequence += 1; |
| 107 | return `${prefix}_${idSequence.toString(36)}`; | 142 | return `${prefix}_${idSequence.toString(36)}`; |