diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 5fdae039e..8ff584d68 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -424,6 +424,7 @@ pub fn builder() -> tauri_specta::Builder { window::window_open_deployment, window::window_cycle, window::window_toggle_fullscreen, + window::window_flash_taskbar, window::window_set_titlebar_height, window_drag::window_start_drag, window::window_take_launch, diff --git a/src-tauri/src/commands/window.rs b/src-tauri/src/commands/window.rs index d1d99d650..fb1534d85 100644 --- a/src-tauri/src/commands/window.rs +++ b/src-tauri/src/commands/window.rs @@ -127,6 +127,21 @@ pub fn window_toggle_fullscreen(window: WebviewWindow) -> IdeResult { crate::window_groups::toggle_fullscreen(&window) } +/// Flash (`true`) or stop flashing (`false`) this window's taskbar button, like IDEA when a +/// breakpoint is hit while the window is in the background (macOS bounces the dock icon, +/// Linux sets the urgency hint). A focused window is never flashed: the user is already +/// looking at it, and re-flashing it would steal attention for nothing. +#[tauri::command] +#[specta::specta] +pub fn window_flash_taskbar(window: WebviewWindow, flash: bool) -> IdeResult<()> { + if flash && window.is_focused().unwrap_or(false) { + return Ok(()); + } + window + .request_user_attention(flash.then_some(tauri::UserAttentionType::Critical)) + .map_err(|e| IdeError::internal(e.to_string())) +} + /// Height of the web title bar in native logical pixels (CSS height × IDE zoom). #[tauri::command] #[specta::specta] diff --git a/src/bindings.ts b/src/bindings.ts index 9965fa679..16ef77944 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -863,6 +863,13 @@ export const commands = { /** Focuses the next / previous window (⌘` / ⇧⌘`). */ windowCycle: (forward: boolean) => __TAURI_INVOKE("window_cycle", { forward }), windowToggleFullscreen: () => typedError(__TAURI_INVOKE("window_toggle_fullscreen")), + /** + * Flash (`true`) or stop flashing (`false`) this window's taskbar button, like IDEA when a + * breakpoint is hit while the window is in the background (macOS bounces the dock icon, + * Linux sets the urgency hint). A focused window is never flashed: the user is already + * looking at it, and re-flashing it would steal attention for nothing. + */ + windowFlashTaskbar: (flash: boolean) => typedError(__TAURI_INVOKE("window_flash_taskbar", { flash })), /** Height of the web title bar in native logical pixels (CSS height × IDE zoom). */ windowSetTitlebarHeight: (height: number | null) => typedError(__TAURI_INVOKE("window_set_titlebar_height", { height })), /** diff --git a/src/features/debugger/__tests__/taskbarFlash.test.ts b/src/features/debugger/__tests__/taskbarFlash.test.ts new file mode 100644 index 000000000..21498a2de --- /dev/null +++ b/src/features/debugger/__tests__/taskbarFlash.test.ts @@ -0,0 +1,122 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +const ipc = vi.hoisted(() => { + const ok = (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(); + 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).__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).__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).__TAURI_INTERNALS__; + hasFocus.mockReturnValue(false); + stopped("breakpoint"); + expect(ipc.windowFlashTaskbar).not.toHaveBeenCalled(); + (window as unknown as Record).__TAURI_INTERNALS__ = {}; + }); +}); diff --git a/src/features/debugger/state.ts b/src/features/debugger/state.ts index 0582c0d11..9fd19afdb 100644 --- a/src/features/debugger/state.ts +++ b/src/features/debugger/state.ts @@ -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: [], diff --git a/src/features/debugger/taskbarFlash.ts b/src/features/debugger/taskbarFlash.ts new file mode 100644 index 000000000..bee8ee534 --- /dev/null +++ b/src/features/debugger/taskbarFlash.ts @@ -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; + }); +}