Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/desktop/src/preview/GuestProtocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export const ELEMENT_PICKED_CHANNEL = "preview:element-picked";
export const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured";
export const ANNOTATION_THEME_CHANNEL = "preview:annotation-theme";
export const HUMAN_INPUT_CHANNEL = "preview:human-input";
export const MOUSE_NAVIGATE_CHANNEL = "preview:mouse-navigate";
63 changes: 63 additions & 0 deletions apps/desktop/src/preview/Manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -652,6 +652,69 @@ describe("PreviewManager", () => {
),
);

effectIt.effect("navigates the guest history when the thumb-button ipc fires", () =>
withManager((manager) =>
Effect.gen(function* () {
let mouseNavigate: ((event: unknown, payload: unknown) => void) | undefined;
const goBack = vi.fn();
const goForward = vi.fn();
let canGoBack = true;
fromId.mockReturnValue({
id: 42,
isDestroyed: () => false,
getType: () => "webview",
getURL: () => "https://example.com",
getTitle: () => "Example",
isLoading: () => false,
getZoomFactor: () => 1,
setZoomFactor: vi.fn(),
on: vi.fn(),
off: vi.fn(),
ipc: {
on: vi.fn((channel: string, listener: typeof mouseNavigate) => {
if (channel === "preview:mouse-navigate") mouseNavigate = listener;
}),
off: vi.fn(),
},
send: webviewSend,
navigationHistory: {
canGoBack: () => canGoBack,
canGoForward: () => true,
goBack,
goForward,
},
setWindowOpenHandler: vi.fn(),
debugger: {
isAttached: () => false,
attach: vi.fn(),
sendCommand: vi.fn(async () => undefined),
on: vi.fn(),
off: vi.fn(),
},
} as never);

yield* manager.createTab("tab_nav");
yield* manager.registerWebview("tab_nav", 42);
expect(mouseNavigate).toBeDefined();

mouseNavigate?.({}, { direction: "back" });
yield* Effect.yieldNow;
expect(goBack).toHaveBeenCalledOnce();

mouseNavigate?.({}, { direction: "forward" });
yield* Effect.yieldNow;
expect(goForward).toHaveBeenCalledOnce();

// Ignores unknown payloads and never navigates when history is exhausted.
mouseNavigate?.({}, { direction: "sideways" });
canGoBack = false;
mouseNavigate?.({}, { direction: "back" });
yield* Effect.yieldNow;
expect(goBack).toHaveBeenCalledOnce();
}),
),
);

effectIt.effect("reveals only files inside the configured browser artifact directory", () =>
withManager((manager) =>
Effect.gen(function* () {
Expand Down
19 changes: 19 additions & 0 deletions apps/desktop/src/preview/Manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import {
CANCEL_PICK_CHANNEL,
ELEMENT_PICKED_CHANNEL,
HUMAN_INPUT_CHANNEL,
MOUSE_NAVIGATE_CHANNEL,
START_PICK_CHANNEL,
} from "./GuestProtocol.ts";
import { isPreviewAnnotationPayload } from "./PickedElementPayload.ts";
Expand Down Expand Up @@ -1208,6 +1209,22 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
const humanInput = (_event: unknown, rawSignal?: unknown): void => {
runFork(handleHumanInput(rawSignal));
};
const mouseNavigate = (_event: unknown, payload?: unknown): void => {
const direction =
typeof payload === "object" && payload !== null && "direction" in payload
? (payload as { direction?: unknown }).direction
: undefined;
if (direction !== "back" && direction !== "forward") return;
runFork(
attempt({ operation: "mouseNavigate", tabId, webContentsId: wc.id }, () => {
if (direction === "back") {
if (wc.navigationHistory.canGoBack()) wc.navigationHistory.goBack();
} else if (wc.navigationHistory.canGoForward()) {
wc.navigationHistory.goForward();
}
}).pipe(Effect.ignore),
);
};
const forwardShortcut = Effect.fn("PreviewManager.forwardShortcut")(function* (
event: Electron.Event,
input: Electron.Input,
Expand Down Expand Up @@ -1242,6 +1259,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
wc.off("did-fail-load", failed as never);
wc.off("before-input-event", beforeInput);
wc.ipc.off(HUMAN_INPUT_CHANNEL, humanInput);
wc.ipc.off(MOUSE_NAVIGATE_CHANNEL, mouseNavigate);
}).pipe(Effect.ignore),
);
const install = Effect.fn("PreviewManager.installWebContentsListeners")(function* () {
Expand All @@ -1253,6 +1271,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
wc.on("did-stop-loading", sync);
wc.on("did-fail-load", failed as never);
wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput);
wc.ipc.on(MOUSE_NAVIGATE_CHANNEL, mouseNavigate);
wc.setWindowOpenHandler(({ url }) => {
runFork(
attemptPromise({ operation: "openPreviewWindow", tabId, webContentsId: wc.id }, () =>
Expand Down
35 changes: 35 additions & 0 deletions apps/desktop/src/preview/PickPreload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
CANCEL_PICK_CHANNEL,
ELEMENT_PICKED_CHANNEL,
HUMAN_INPUT_CHANNEL,
MOUSE_NAVIGATE_CHANNEL,
START_PICK_CHANNEL,
} from "./GuestProtocol.ts";
const OVERLAY_ATTRIBUTE = "data-t3code-annotation-ui";
Expand Down Expand Up @@ -100,6 +101,40 @@ const reportHumanKeyInput = (event: KeyboardEvent): void => {
window.addEventListener("pointerdown", reportHumanPointerInput, true);
window.addEventListener("keydown", reportHumanKeyInput, true);

// Mouse thumb buttons: `button === 3` is Back, `button === 4` is Forward.
const MOUSE_BUTTON_BACK = 3;
const MOUSE_BUTTON_FORWARD = 4;

const navigationDirectionForButton = (button: number): "back" | "forward" | null => {
if (button === MOUSE_BUTTON_BACK) return "back";
if (button === MOUSE_BUTTON_FORWARD) return "forward";
return null;
};

// Chromium routes thumb-button history navigation to the *focused* WebContents,
// so hovering this guest without focusing it sends the host app's router back
// instead of the preview. Suppress Chromium's default here and drive this tab's
// history explicitly so the buttons always navigate the browser the pointer is
// over — never the host app.
const suppressNavigationButton = (event: MouseEvent): void => {
if (!event.isTrusted || navigationDirectionForButton(event.button) === null) return;
event.preventDefault();
event.stopImmediatePropagation();
};

const requestNavigationForButton = (event: MouseEvent): void => {
if (!event.isTrusted) return;
const direction = navigationDirectionForButton(event.button);
if (direction === null) return;
event.preventDefault();
event.stopImmediatePropagation();
ipcRenderer.send(MOUSE_NAVIGATE_CHANNEL, { direction });
};

window.addEventListener("mousedown", suppressNavigationButton, true);
window.addEventListener("mouseup", requestNavigationForButton, true);
window.addEventListener("auxclick", suppressNavigationButton, true);

const nextId = (prefix: string): string => {
idSequence += 1;
return `${prefix}_${idSequence.toString(36)}`;
Expand Down
Loading