feat(desktop): always-on-top for main and detached windows

This commit is contained in:
Mo-Moore
2026-09-22 16:30:19 +08:00
committed by GitHub
parent 933ed6a670
commit 78d8076c53
18 changed files with 262 additions and 11 deletions
@@ -2,7 +2,7 @@
import { computed, ref, onMounted, onBeforeUnmount, h, nextTick, watch } from "vue";
import { useI18n } from "vue-i18n";
import { invoke } from "@tauri-apps/api/core";
import { ChevronsRight, DatabaseZap, FilePlus2, Moon, Sun, SunMoon, History, Bot, ArrowLeftRight, FileCode, BookMarked, GitCompareArrows, TableProperties, Settings, CloudDownload, Package, PlugZap, FileDown, FolderTree } from "@lucide/vue";
import { ChevronsRight, DatabaseZap, FilePlus2, Moon, Sun, SunMoon, History, Bot, ArrowLeftRight, FileCode, BookMarked, GitCompareArrows, TableProperties, Settings, CloudDownload, Package, PlugZap, FileDown, FolderTree, Pin, PinOff } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import LightDropdown from "@/components/ui/LightDropdown.vue";
@@ -79,7 +79,7 @@ const { toast } = useToast();
const settingsStore = useSettingsStore();
const toolbarItems = computed(() => settingsStore.editorSettings.toolbarItems);
const showToolbarUpdateEntry = computed(() => toolbarItems.value.checkUpdates || props.hasUpdateAvailable);
const { isMac, isDesktop, showControls, isMaximized, isFullscreen, minimize, toggleMaximize, close } = useWindowControls();
const { isMac, isDesktop, showControls, isMaximized, isFullscreen, isAlwaysOnTop, minimize, toggleMaximize, toggleAlwaysOnTop, close } = useWindowControls();
const updateTooltip = computed(() => {
if (props.hasUpdateAvailable && props.updateReady) return t("updates.restartRequiredTooltip");
if (props.hasUpdateAvailable && props.updateReadyToInstall) return t("updates.downloadedReady", { version: props.updateVersion ?? "" });
@@ -645,6 +645,24 @@ const toolbarStyle = computed(() => {
</Tooltip>
</template>
<Tooltip v-if="isDesktop">
<TooltipTrigger as-child>
<Button
variant="ghost"
size="icon"
class="toolbar-action-button relative h-8 w-8 shrink-0"
:class="{ 'toolbar-action-button--active bg-accent': isAlwaysOnTop }"
:aria-pressed="isAlwaysOnTop"
:aria-label="isAlwaysOnTop ? t('toolbar.alwaysOnTopOff') : t('toolbar.alwaysOnTop')"
@click="toggleAlwaysOnTop"
>
<Pin v-if="isAlwaysOnTop" class="toolbar-action-icon h-4 w-4 fill-current" />
<PinOff v-else class="toolbar-action-icon h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ isAlwaysOnTop ? t("toolbar.alwaysOnTopOff") : t("toolbar.alwaysOnTop") }}</TooltipContent>
</Tooltip>
<div v-show="isRightItemVisible('exportProgress')" class="contents">
<ExportProgressPopover />
</div>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ArrowLeft, Minus, Square, Copy, X, Grip } from "@lucide/vue";
import { ArrowLeft, Minus, Square, Copy, X, Grip, Pin, PinOff } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { useWindowControls } from "@/composables/useWindowControls";
@@ -17,7 +17,7 @@ const emit = defineEmits<{
}>();
const { t } = useI18n();
const { isMac, isMaximized, minimize, toggleMaximize } = useWindowControls();
const { isMac, isMaximized, isAlwaysOnTop, minimize, toggleMaximize, toggleAlwaysOnTop } = useWindowControls();
let dragging = false;
@@ -73,6 +73,19 @@ async function handleDragEnd(event: PointerEvent) {
<ArrowLeft class="h-3.5 w-3.5" />
<span>{{ t("tabs.returnToMainWindow") }}</span>
</button>
<button
type="button"
class="mr-1 inline-flex h-7 w-7 items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
:class="{ 'bg-accent text-foreground': isAlwaysOnTop }"
:title="isAlwaysOnTop ? t('toolbar.alwaysOnTopOff') : t('toolbar.alwaysOnTop')"
:aria-pressed="isAlwaysOnTop"
:aria-label="isAlwaysOnTop ? t('toolbar.alwaysOnTopOff') : t('toolbar.alwaysOnTop')"
@pointerdown.stop
@click="toggleAlwaysOnTop"
>
<Pin v-if="isAlwaysOnTop" class="h-3.5 w-3.5 fill-current" />
<PinOff v-else class="h-3.5 w-3.5" />
</button>
<template v-if="!isMac">
<button type="button" class="inline-flex h-10 w-10 items-center justify-center hover:bg-foreground/10" :title="t('tabs.minimizeWindow')" @click="minimize"><Minus class="h-4 w-4" /></button>
<button type="button" class="inline-flex h-10 w-10 items-center justify-center hover:bg-foreground/10" :title="t('tabs.maximizeWindow')" @click="toggleMaximize"><Copy v-if="isMaximized" class="h-3.5 w-3.5" /><Square v-else class="h-3.5 w-3.5" /></button>
@@ -2,6 +2,7 @@ import { ref, onMounted, onUnmounted } from "vue";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { isMacOS } from "@/lib/backend/platform";
import * as api from "@/lib/backend/api";
import { raiseDetachedWindowsAboveMain, WINDOW_ALWAYS_ON_TOP_CHANGED_EVENT, type WindowAlwaysOnTopChangedPayload } from "@/lib/app/windowAlwaysOnTop";
const MIN_UI_SCALE = 0.75;
const MAX_UI_SCALE = 2;
@@ -44,19 +45,29 @@ export function shouldDrawDesktopWindowFrame(isMac: boolean, isDesktop = true, i
export function useWindowControls() {
const isMaximized = ref(false);
const isFullscreen = ref(false);
const isAlwaysOnTop = ref(false);
const isMac = isMacOS();
const isDesktop = isTauriRuntime();
const showControls = shouldShowWindowControls(isMac, isDesktop);
let unlisten: (() => void) | null = null;
let unlistenResize: (() => void) | null = null;
let unlistenFocus: (() => void) | null = null;
let unlistenAlwaysOnTop: (() => void) | null = null;
async function updateWindowState() {
if (!isDesktop) return;
const { getCurrentWindow } = await import("@tauri-apps/api/window");
const currentWindow = getCurrentWindow();
const [maximized, fullscreen] = await Promise.all([currentWindow.isMaximized(), currentWindow.isFullscreen()]);
const [maximized, fullscreen, alwaysOnTop] = await Promise.all([currentWindow.isMaximized(), currentWindow.isFullscreen(), currentWindow.isAlwaysOnTop()]);
isMaximized.value = maximized;
isFullscreen.value = fullscreen;
isAlwaysOnTop.value = alwaysOnTop;
}
async function refreshAlwaysOnTopState() {
if (!isDesktop) return;
const { getCurrentWindow } = await import("@tauri-apps/api/window");
isAlwaysOnTop.value = await getCurrentWindow().isAlwaysOnTop();
}
async function minimize() {
@@ -70,6 +81,18 @@ export function useWindowControls() {
setTimeout(updateWindowState, 50);
}
async function toggleAlwaysOnTop() {
if (!isDesktop) return;
const { getCurrentWindow } = await import("@tauri-apps/api/window");
const currentWindow = getCurrentWindow();
const next = !isAlwaysOnTop.value;
await currentWindow.setAlwaysOnTop(next);
isAlwaysOnTop.value = await currentWindow.isAlwaysOnTop();
if (next && currentWindow.label === "main") {
await raiseDetachedWindowsAboveMain();
}
}
async function close() {
if (!isDesktop) return;
await api.requestAppClose();
@@ -79,14 +102,26 @@ export function useWindowControls() {
if (!isDesktop) return;
await updateWindowState();
const { getCurrentWindow } = await import("@tauri-apps/api/window");
const unlistenFn = await getCurrentWindow().onResized(() => {
const currentWindow = getCurrentWindow();
const windowLabel = currentWindow.label;
unlistenResize = await currentWindow.onResized(() => {
void updateWindowState();
});
unlisten = unlistenFn;
unlistenFocus = await currentWindow.onFocusChanged(({ payload: focused }) => {
if (focused) void refreshAlwaysOnTopState();
});
const { listen } = await import("@tauri-apps/api/event");
unlistenAlwaysOnTop = await listen<WindowAlwaysOnTopChangedPayload>(WINDOW_ALWAYS_ON_TOP_CHANGED_EVENT, (event) => {
const payload = event.payload;
if (!payload || payload.windowLabel !== windowLabel || typeof payload.alwaysOnTop !== "boolean") return;
isAlwaysOnTop.value = payload.alwaysOnTop;
});
});
onUnmounted(() => {
unlisten?.();
unlistenResize?.();
unlistenFocus?.();
unlistenAlwaysOnTop?.();
});
return {
@@ -95,8 +130,10 @@ export function useWindowControls() {
showControls,
isMaximized,
isFullscreen,
isAlwaysOnTop,
minimize,
toggleMaximize,
toggleAlwaysOnTop,
close,
};
}
+2
View File
@@ -233,6 +233,8 @@ export default withEnglishFallback({
driverManager: "Drayver meneceri",
updatableDriverCount: "Yenilənə bilən drayverlərin sayı",
mcpUpdateAvailable: "MCP serveri üçün yeniləmə mövcuddur",
alwaysOnTop: "Pəncərəni üstə saxla",
alwaysOnTopOff: "Üstdə saxlamanı ləğv et",
blockDangerousRedisCommands: "Təhlükəli əmrləri blokla",
multiDbExecute: "Bir neçə verilənlər bazasında icra et",
},
+2
View File
@@ -400,6 +400,8 @@ export default {
pluginCenter: "Plugin Center",
updatableDriverCount: "Updatable driver count",
mcpUpdateAvailable: "MCP server update available",
alwaysOnTop: "Keep window on top",
alwaysOnTopOff: "Don't keep on top",
blockDangerousRedisCommands: "Block dangerous commands",
multiDbExecute: "Multi-db execute",
},
+2
View File
@@ -391,6 +391,8 @@ export default withEnglishFallback({
pluginCenter: "Centro de plugins",
updatableDriverCount: "Cantidad de drivers actualizables",
mcpUpdateAvailable: "Actualización del servidor MCP disponible",
alwaysOnTop: "Mantener ventana encima",
alwaysOnTopOff: "No mantener encima",
blockDangerousRedisCommands: "Bloquear comandos peligrosos",
multiDbExecute: "Ejecución multi-base de datos",
},
+2
View File
@@ -390,6 +390,8 @@ export default withEnglishFallback({
pluginCenter: "Centro plugin",
updatableDriverCount: "Driver aggiornabili",
mcpUpdateAvailable: "Aggiornamento server MCP disponibile",
alwaysOnTop: "Mantieni in primo piano",
alwaysOnTopOff: "Non mantenere in primo piano",
blockDangerousRedisCommands: "Blocca comandi pericolosi",
multiDbExecute: "Esecuzione multi-DB",
},
+2
View File
@@ -391,6 +391,8 @@ export default withEnglishFallback({
pluginCenter: "プラグインセンター",
updatableDriverCount: "更新可能なドライバー数",
mcpUpdateAvailable: "MCPサーバーの更新があります",
alwaysOnTop: "常に手前に表示",
alwaysOnTopOff: "手前表示を解除",
blockDangerousRedisCommands: "危険なコマンドをブロック",
multiDbExecute: "複数DB実行",
},
+2
View File
@@ -397,6 +397,8 @@ export default withEnglishFallback({
driverManager: "드라이버 관리자",
updatableDriverCount: "업데이트 가능한 드라이버 수",
mcpUpdateAvailable: "MCP 서버 업데이트 가능",
alwaysOnTop: "항상 위에 표시",
alwaysOnTopOff: "항상 위 표시 해제",
blockDangerousRedisCommands: "위험한 명령 차단",
saveSqlFile: "원본 파일에 저장",
pluginCenter: "플러그인 센터",
+2
View File
@@ -391,6 +391,8 @@ export default withEnglishFallback({
pluginCenter: "Central de plugins",
updatableDriverCount: "Quantidade de drivers atualizáveis",
mcpUpdateAvailable: "Atualização do servidor MCP disponível",
alwaysOnTop: "Manter janela no topo",
alwaysOnTopOff: "Não manter no topo",
blockDangerousRedisCommands: "Bloquear comandos perigosos",
multiDbExecute: "Execução em múltiplos bancos",
},
+2
View File
@@ -235,6 +235,8 @@ export default withEnglishFallback({
driverManager: "Sürücü Yöneticisi",
updatableDriverCount: "Güncellenebilir sürücü sayısı",
mcpUpdateAvailable: "MCP sunucusu güncellemesi mevcut",
alwaysOnTop: "Pencereyi üstte tut",
alwaysOnTopOff: "Üstte tutmayı kapat",
blockDangerousRedisCommands: "Tehlikeli komutları engelle",
multiDbExecute: "Çoklu veritabanı yürütme",
},
+2
View File
@@ -316,6 +316,8 @@ export default withEnglishFallback({
pluginCenter: "插件中心",
updatableDriverCount: "可更新驱动数量",
mcpUpdateAvailable: "MCP 服务有可用更新",
alwaysOnTop: "窗口置顶",
alwaysOnTopOff: "取消置顶",
blockDangerousRedisCommands: "拦截危险命令",
multiDbExecute: "多库执行",
},
+2
View File
@@ -394,6 +394,8 @@ export default withEnglishFallback({
pluginCenter: "外掛中心",
updatableDriverCount: "可更新驅動程式數量",
mcpUpdateAvailable: "MCP 服務有可用更新",
alwaysOnTop: "視窗置頂",
alwaysOnTopOff: "取消置頂",
blockDangerousRedisCommands: "攔截危險命令",
multiDbExecute: "多庫執行",
},
@@ -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);
}
}
+2
View File
@@ -19,6 +19,8 @@
"core:window:allow-destroy",
"core:window:allow-start-dragging",
"core:window:allow-set-focus",
"core:window:allow-set-always-on-top",
"core:window:allow-is-always-on-top",
"core:window:allow-set-position",
"core:window:allow-inner-position",
"core:window:allow-scale-factor",