feat(debugger): flash the taskbar when a breakpoint is hit in an unfocused window (#1810)

A breakpoint stop now grabs attention like IDEA's: the owning window's
taskbar button flashes while the window is in the background (new
window_flash_taskbar command wrapping request_user_attention, which never
flashes a focused window), and the flash is stopped when the window is
focused, the paused thread resumes, or the session ends. A window running
with Focus application on breakpoint still focuses instead of flashing,
and repeated hits while already flashing do not re-request attention.

Closes #1742

Signed-off-by: Inf1mum <[email protected]>
This commit is contained in:
Vadim Lazuk authored and GitHub committed 2026-11-07 01:03:58 +08:00
1 parent 1f969694a0
commit fb694aaceb
6 files changed
+191

No files matched your search

@@ -0,0 +1,122 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const ipc = vi.hoisted(() => {
const ok = <T>(data: T) => Promise.resolve({ status: "ok" as const, data });
return {
ok,
debugCapabilities: vi.fn(async () => ok(null)),
debugThreads: vi.fn(async () => ok([{ id: 1, name: "main" }])),
debugStackTrace: vi.fn(async () => ok([])),
windowFlashTaskbar: vi.fn(async (_flash: boolean) => ok(null)),
};
});
vi.mock("@/platform/ipc", async (importOriginal) => {
const real = await importOriginal<typeof import("@/platform/ipc")>();
const { ok: _ok, ...commands } = ipc;
return { ...real, commands: { ...real.commands, ...commands } };
});
import { useProject } from "@/platform/project";
import { adoptSession, handleDebugEvent, useDebug } from "../state";
import { stopTaskbarFlash } from "../taskbarFlash";
const stopped = (reason: string) =>
handleDebugEvent(1, { kind: "stopped", threadId: 1, reason, description: null, allThreadsStopped: false });
const flashes = () => ipc.windowFlashTaskbar.mock.calls.filter(([flash]) => flash).length;
const stops = () => ipc.windowFlashTaskbar.mock.calls.filter(([flash]) => !flash).length;
const hasFocus = vi.spyOn(document, "hasFocus");
beforeAll(() => {
(window as unknown as Record<string, unknown>).__TAURI_INTERNALS__ = {};
});
beforeEach(() => {
// Stop any flash left over from the previous test before the mock is cleared.
stopTaskbarFlash();
vi.clearAllMocks();
useDebug.setState({ sessions: {}, active: null, stopSeq: 0 });
useProject.setState({ settings: {} as never });
adoptSession(1, { adapter: "java-debug" });
});
afterAll(() => {
delete (window as unknown as Record<string, unknown>).__TAURI_INTERNALS__;
hasFocus.mockRestore();
useProject.setState({ settings: null });
});
describe("taskbar flash on a breakpoint in a background window", () => {
it("flashes when a breakpoint stops an unfocused window", () => {
hasFocus.mockReturnValue(false);
stopped("breakpoint");
expect(flashes()).toBe(1);
expect(stops()).toBe(0);
});
it("does not flash when the window is focused", () => {
hasFocus.mockReturnValue(true);
stopped("breakpoint");
expect(ipc.windowFlashTaskbar).not.toHaveBeenCalled();
});
it("does not flash for a stop that is not a breakpoint", () => {
hasFocus.mockReturnValue(false);
stopped("step");
stopped("exception");
expect(ipc.windowFlashTaskbar).not.toHaveBeenCalled();
});
it("does not re-request attention for repeated breakpoints while already flashing", () => {
hasFocus.mockReturnValue(false);
stopped("breakpoint");
stopped("breakpoint");
stopped("function breakpoint");
expect(flashes()).toBe(1);
expect(stops()).toBe(0);
});
it("stops the flash when the window is focused", () => {
hasFocus.mockReturnValue(false);
stopped("breakpoint");
hasFocus.mockReturnValue(true);
window.dispatchEvent(new Event("focus"));
expect(flashes()).toBe(1);
expect(stops()).toBe(1);
// Further focus events send no more stops: the flash is forgotten. A breakpoint that
// hits afterwards finds the window focused and does not flash again (criterion 3).
window.dispatchEvent(new Event("focus"));
stopped("breakpoint");
expect(stops()).toBe(1);
expect(flashes()).toBe(1);
expect(ipc.windowFlashTaskbar).toHaveBeenCalledTimes(2);
});
it("stops the flash when the session resumes", () => {
hasFocus.mockReturnValue(false);
stopped("breakpoint");
handleDebugEvent(1, { kind: "continued", threadId: 1, allThreadsContinued: true });
expect(stops()).toBe(1);
});
it("stops the flash when the session terminates", () => {
hasFocus.mockReturnValue(false);
stopped("breakpoint");
handleDebugEvent(1, { kind: "terminated" });
expect(stops()).toBe(1);
});
it("does not flash when Focus application on breakpoint takes the window instead", () => {
useProject.setState({ settings: { debugger: { focusAppOnBreakpoint: true } } as never });
hasFocus.mockReturnValue(false);
stopped("breakpoint");
expect(ipc.windowFlashTaskbar).not.toHaveBeenCalled();
});
it("never touches the shell outside Tauri", () => {
delete (window as unknown as Record<string, unknown>).__TAURI_INTERNALS__;
hasFocus.mockReturnValue(false);
stopped("breakpoint");
expect(ipc.windowFlashTaskbar).not.toHaveBeenCalled();
(window as unknown as Record<string, unknown>).__TAURI_INTERNALS__ = {};
});
});
+6
View File
@@ -29,6 +29,7 @@ import { breakpointsOf, consumeTemporary, flushBreakpoints, refreshStates, reset
import { interpolateLog, logParts, suspends } from "./breakpoints/model";
import { syncSpecial, useSpecialBreakpoints } from "./breakpoints/special";
import { refreshExceptionFilters } from "./breakpoints/exceptions";
import { flashTaskbarForBreakpoint, stopTaskbarFlash } from "./taskbarFlash";
import { missingAdapter } from "./missingAdapter";
import { startFailureMessage } from "./startFailure";
@@ -713,6 +714,7 @@ export function sessionSupports(cap: keyof DebugCapabilities, s: DebugSession |
}
function markTerminated(id: DebugSessionId) {
stopTaskbarFlash();
patch(id, { status: "terminated", endedAt: Date.now(), frames: [], selectedFrame: null, stoppedThread: null });
const st = useDebug.getState();
if (st.active === id) {
@@ -1013,13 +1015,17 @@ export function handleDebugEvent(id: DebugSessionId, event: DebugEvent) {
} catch {
// ignore
}
// Attention for a background window: focusing the app and flashing the taskbar are
// alternatives (IDEA), so a window configured to grab focus does not also flash.
if (dbg.focusAppOnBreakpoint) focusAppWindow();
else if (isBp) flashTaskbarForBreakpoint();
break;
}
case "continued": {
const cur = useDebug.getState().sessions[id]!;
if (cur.status === "terminated") break;
if (!event.allThreadsContinued && event.threadId !== (cur.selectedThread ?? cur.stoppedThread)) break;
stopTaskbarFlash();
patch(id, {
status: "running",
frames: [],
+40
View File
@@ -0,0 +1,40 @@
/**
* IDEA-style taskbar flash while a breakpoint holds this window in the background:
* the shell flashes the native window (`window_flash_taskbar`, which never flashes a
* focused window), this side decides when to ask — on a breakpoint stop while the
* window is unfocused — and stops the flash when the window is focused again or the
* paused session resumes or ends.
*/
import { call, commands, isTauri } from "@/platform/ipc";
/** Whether this window started a flash that nothing has stopped yet. */
let flashing = false;
/** The reset listener is attached once per page, on the first flash request. */
let listening = false;
/** Stops the flash this window started (no-op when none is running: no extra IPC). */
export function stopTaskbarFlash() {
if (!flashing) return;
flashing = false;
if (!isTauri()) return;
void call(commands.windowFlashTaskbar(false)).catch(() => undefined);
}
/**
* Asks the shell to flash the taskbar for a breakpoint hit in a background window.
* While already flashing, further hits do not re-request attention; a window that
* is focused right now (or became focused while the request was in flight) never
* flashes.
*/
export function flashTaskbarForBreakpoint() {
if (!isTauri()) return;
if (!listening) {
listening = true;
window.addEventListener("focus", stopTaskbarFlash);
}
if (flashing || document.hasFocus()) return;
flashing = true;
void call(commands.windowFlashTaskbar(true)).catch(() => {
flashing = false;
});
}