feat(desktop): always-on-top for main and detached windows
This commit is contained in:
1 parent
933ed6a670
commit
78d8076c53
18 files changed
+262
-11
No files matched your search
@@ -3,12 +3,21 @@ import { readFileSync } from "node:fs";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { acquireUpdateBarrier, assertUpdateSafe } from "../updatePreparation";
|
||||
import { openDetachedTabWindow } from "../detachedTabWindow";
|
||||
const mocks = vi.hoisted(() => ({ getByLabel: vi.fn(), created: vi.fn(), callbacks: new Map<string, (event?: unknown) => unknown>(), show: vi.fn(), focus: vi.fn(), destroy: vi.fn() }));
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getByLabel: vi.fn(),
|
||||
created: vi.fn(),
|
||||
callbacks: new Map<string, (event?: unknown) => unknown>(),
|
||||
show: vi.fn(),
|
||||
focus: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
setAlwaysOnTop: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => true }));
|
||||
vi.mock("@/lib/app/windowContext", () => ({ detachedWindowLabel: (id: string) => `detached-${id}`, detachedWindowUrl: () => "http://localhost/" }));
|
||||
vi.mock("@tauri-apps/api/webviewWindow", () => ({
|
||||
WebviewWindow: class {
|
||||
static getByLabel = mocks.getByLabel;
|
||||
label = "detached-test";
|
||||
constructor() {
|
||||
mocks.created();
|
||||
}
|
||||
@@ -19,6 +28,7 @@ vi.mock("@tauri-apps/api/webviewWindow", () => ({
|
||||
show = mocks.show;
|
||||
setFocus = mocks.focus;
|
||||
destroy = mocks.destroy;
|
||||
setAlwaysOnTop = mocks.setAlwaysOnTop;
|
||||
},
|
||||
}));
|
||||
afterEach(() => {
|
||||
@@ -45,7 +55,7 @@ describe("detached window update exclusion", () => {
|
||||
const operation = openDetachedTabWindow("one", "SQL");
|
||||
await vi.waitFor(() => expect(mocks.getByLabel).toHaveBeenCalled());
|
||||
expect(assertUpdateSafe).toThrow("window operation");
|
||||
resolve({ show: async () => {}, setFocus: async () => {} });
|
||||
resolve({ show: async () => {}, setFocus: async () => {}, setAlwaysOnTop: async () => {} });
|
||||
expect(await operation).toEqual({ opened: true });
|
||||
expect(assertUpdateSafe).not.toThrow();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { emitWindowAlwaysOnTopChanged, pinDetachedWindowIfMainPinned, raiseDetachedWindowsAboveMain, WINDOW_ALWAYS_ON_TOP_CHANGED_EVENT } from "../windowAlwaysOnTop";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getByLabel: vi.fn(),
|
||||
getAllWebviewWindows: vi.fn(),
|
||||
isTauriRuntime: vi.fn(() => true),
|
||||
emit: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => mocks.isTauriRuntime() }));
|
||||
vi.mock("@tauri-apps/api/webviewWindow", () => ({
|
||||
WebviewWindow: { getByLabel: mocks.getByLabel },
|
||||
getAllWebviewWindows: mocks.getAllWebviewWindows,
|
||||
}));
|
||||
vi.mock("@tauri-apps/api/event", () => ({
|
||||
emit: (...args: unknown[]) => mocks.emit(...args),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.isTauriRuntime.mockReturnValue(true);
|
||||
mocks.emit.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe("windowAlwaysOnTop", () => {
|
||||
it("pins a detached window when the main window is already on top", async () => {
|
||||
const setAlwaysOnTop = vi.fn().mockResolvedValue(undefined);
|
||||
mocks.getByLabel.mockResolvedValue({ isAlwaysOnTop: async () => true });
|
||||
await pinDetachedWindowIfMainPinned({ label: "detached-tab-one", setAlwaysOnTop });
|
||||
expect(setAlwaysOnTop).toHaveBeenCalledWith(true);
|
||||
expect(mocks.emit).toHaveBeenCalledWith(WINDOW_ALWAYS_ON_TOP_CHANGED_EVENT, {
|
||||
windowLabel: "detached-tab-one",
|
||||
alwaysOnTop: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not pin a detached window when the main window is not on top", async () => {
|
||||
const setAlwaysOnTop = vi.fn().mockResolvedValue(undefined);
|
||||
mocks.getByLabel.mockResolvedValue({ isAlwaysOnTop: async () => false });
|
||||
await pinDetachedWindowIfMainPinned({ label: "detached-tab-one", setAlwaysOnTop });
|
||||
expect(setAlwaysOnTop).not.toHaveBeenCalled();
|
||||
expect(mocks.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not emit when the pinned detached window has no label", async () => {
|
||||
const setAlwaysOnTop = vi.fn().mockResolvedValue(undefined);
|
||||
mocks.getByLabel.mockResolvedValue({ isAlwaysOnTop: async () => true });
|
||||
await pinDetachedWindowIfMainPinned({ setAlwaysOnTop });
|
||||
expect(setAlwaysOnTop).toHaveBeenCalledWith(true);
|
||||
expect(mocks.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("raises only detached-tab windows when pinning the main host", async () => {
|
||||
const detached = {
|
||||
label: "detached-tab-one",
|
||||
setAlwaysOnTop: vi.fn().mockResolvedValue(undefined),
|
||||
show: vi.fn().mockResolvedValue(undefined),
|
||||
setFocus: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const other = {
|
||||
label: "main",
|
||||
setAlwaysOnTop: vi.fn(),
|
||||
show: vi.fn(),
|
||||
setFocus: vi.fn(),
|
||||
};
|
||||
mocks.getAllWebviewWindows.mockResolvedValue([other, detached]);
|
||||
await raiseDetachedWindowsAboveMain();
|
||||
expect(detached.setAlwaysOnTop).toHaveBeenCalledWith(true);
|
||||
expect(detached.show).toHaveBeenCalledOnce();
|
||||
expect(detached.setFocus).toHaveBeenCalledOnce();
|
||||
expect(other.setAlwaysOnTop).not.toHaveBeenCalled();
|
||||
expect(mocks.emit).toHaveBeenCalledWith(WINDOW_ALWAYS_ON_TOP_CHANGED_EVENT, {
|
||||
windowLabel: "detached-tab-one",
|
||||
alwaysOnTop: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("emits always-on-top changes for a window label", async () => {
|
||||
await emitWindowAlwaysOnTopChanged("detached-tab-two", true);
|
||||
expect(mocks.emit).toHaveBeenCalledWith(WINDOW_ALWAYS_ON_TOP_CHANGED_EVENT, {
|
||||
windowLabel: "detached-tab-two",
|
||||
alwaysOnTop: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { assertUpdateAllowsInteraction, beginUpdateSensitiveOperation } from "@/lib/app/updatePreparation";
|
||||
import { detachedWindowLabel, detachedWindowUrl } from "@/lib/app/windowContext";
|
||||
import { pinDetachedWindowIfMainPinned } from "@/lib/app/windowAlwaysOnTop";
|
||||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
|
||||
export interface DetachedWindowOpenPosition {
|
||||
@@ -27,6 +28,7 @@ export async function openDetachedTabWindow(tabId: string, title: string, positi
|
||||
const label = detachedWindowLabel(tabId);
|
||||
const existing = await WebviewWindow.getByLabel(label);
|
||||
if (existing) {
|
||||
await pinDetachedWindowIfMainPinned(existing);
|
||||
await existing.show();
|
||||
await existing.setFocus();
|
||||
return { opened: true };
|
||||
@@ -77,6 +79,7 @@ export async function openDetachedTabWindow(tabId: string, title: string, positi
|
||||
const { PhysicalPosition } = await import("@tauri-apps/api/dpi");
|
||||
await child.setPosition(new PhysicalPosition(Math.round(position.x - 120), Math.round(position.y - 20)));
|
||||
}
|
||||
await pinDetachedWindowIfMainPinned(child);
|
||||
await child.show();
|
||||
await child.setFocus();
|
||||
finish({ opened: true });
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
|
||||
const DETACHED_WINDOW_LABEL_PREFIX = "detached-tab-";
|
||||
|
||||
export const WINDOW_ALWAYS_ON_TOP_CHANGED_EVENT = "dbx:window-always-on-top-changed";
|
||||
|
||||
export type WindowAlwaysOnTopChangedPayload = {
|
||||
windowLabel: string;
|
||||
alwaysOnTop: boolean;
|
||||
};
|
||||
|
||||
export async function emitWindowAlwaysOnTopChanged(windowLabel: string, alwaysOnTop: boolean): Promise<void> {
|
||||
if (!isTauriRuntime() || !windowLabel) return;
|
||||
try {
|
||||
const { emit } = await import("@tauri-apps/api/event");
|
||||
const payload: WindowAlwaysOnTopChangedPayload = { windowLabel, alwaysOnTop };
|
||||
await emit(WINDOW_ALWAYS_ON_TOP_CHANGED_EVENT, payload);
|
||||
} catch (error) {
|
||||
console.error("[DBX][window:always-on-top-event]", windowLabel, error);
|
||||
}
|
||||
}
|
||||
|
||||
/** Keep detached windows in the same always-on-top band and above the main host. */
|
||||
export async function raiseDetachedWindowsAboveMain(): Promise<void> {
|
||||
if (!isTauriRuntime()) return;
|
||||
const { getAllWebviewWindows } = await import("@tauri-apps/api/webviewWindow");
|
||||
const windows = await getAllWebviewWindows();
|
||||
for (const win of windows) {
|
||||
if (!win.label.startsWith(DETACHED_WINDOW_LABEL_PREFIX)) continue;
|
||||
try {
|
||||
await win.setAlwaysOnTop(true);
|
||||
await emitWindowAlwaysOnTopChanged(win.label, true);
|
||||
await win.show();
|
||||
await win.setFocus();
|
||||
} catch (error) {
|
||||
console.error("[DBX][window:raise-detached]", win.label, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type DetachedAlwaysOnTopTarget = {
|
||||
label?: string;
|
||||
setAlwaysOnTop: (value: boolean) => Promise<void>;
|
||||
};
|
||||
|
||||
/** If the main window is pinned, pin this detached window so it stays visible above the host. */
|
||||
export async function pinDetachedWindowIfMainPinned(detached: DetachedAlwaysOnTopTarget): Promise<void> {
|
||||
if (!isTauriRuntime()) return;
|
||||
const { WebviewWindow } = await import("@tauri-apps/api/webviewWindow");
|
||||
const main = await WebviewWindow.getByLabel("main");
|
||||
if (!main) return;
|
||||
try {
|
||||
if (await main.isAlwaysOnTop()) {
|
||||
await detached.setAlwaysOnTop(true);
|
||||
if (detached.label) {
|
||||
await emitWindowAlwaysOnTopChanged(detached.label, true);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[DBX][window:pin-detached-from-main]", error);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user