feat: support multiple desktop windows

This commit is contained in:
t8y2 2026-06-05 20:45:00 +08:00
parent 28e8a77fa3
commit 642fa39bb8
26 changed files with 522 additions and 436 deletions

352
Cargo.lock generated
View File

@ -385,30 +385,6 @@ dependencies = [
"regex-syntax",
]
[[package]]
name = "async-broadcast"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
dependencies = [
"event-listener",
"event-listener-strategy",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-channel"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
dependencies = [
"concurrent-queue",
"event-listener-strategy",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-compression"
version = "0.4.42"
@ -421,96 +397,6 @@ dependencies = [
"tokio",
]
[[package]]
name = "async-executor"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
dependencies = [
"async-task",
"concurrent-queue",
"fastrand",
"futures-lite",
"pin-project-lite",
"slab",
]
[[package]]
name = "async-io"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
dependencies = [
"autocfg",
"cfg-if",
"concurrent-queue",
"futures-io",
"futures-lite",
"parking",
"polling",
"rustix 1.1.4",
"slab",
"windows-sys 0.61.2",
]
[[package]]
name = "async-lock"
version = "3.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
dependencies = [
"event-listener",
"event-listener-strategy",
"pin-project-lite",
]
[[package]]
name = "async-process"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
dependencies = [
"async-channel",
"async-io",
"async-lock",
"async-signal",
"async-task",
"blocking",
"cfg-if",
"event-listener",
"futures-lite",
"rustix 1.1.4",
]
[[package]]
name = "async-recursion"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "async-signal"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"
dependencies = [
"async-io",
"async-lock",
"atomic-waker",
"cfg-if",
"futures-core",
"futures-io",
"rustix 1.1.4",
"signal-hook-registry",
"slab",
"windows-sys 0.61.2",
]
[[package]]
name = "async-stream"
version = "0.3.6"
@ -533,12 +419,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "async-task"
version = "4.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
[[package]]
name = "async-trait"
version = "0.1.89"
@ -823,19 +703,6 @@ dependencies = [
"objc2",
]
[[package]]
name = "blocking"
version = "1.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21"
dependencies = [
"async-channel",
"async-task",
"futures-io",
"futures-lite",
"piper",
]
[[package]]
name = "blowfish"
version = "0.9.1"
@ -1299,15 +1166,6 @@ version = "0.4.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789"
[[package]]
name = "concurrent-queue"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "connection-string"
version = "0.2.0"
@ -1872,7 +1730,6 @@ dependencies = [
"tauri-plugin-log",
"tauri-plugin-process",
"tauri-plugin-shell",
"tauri-plugin-single-instance",
"tauri-plugin-updater",
"tauri-plugin-window-state",
"tiberius",
@ -2422,12 +2279,6 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "endi"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
[[package]]
name = "enum_dispatch"
version = "0.3.13"
@ -2447,7 +2298,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
dependencies = [
"enumflags2_derive",
"serde",
]
[[package]]
@ -2504,27 +2354,6 @@ version = "3.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59"
[[package]]
name = "event-listener"
version = "5.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
dependencies = [
"concurrent-queue",
"parking",
"pin-project-lite",
]
[[package]]
name = "event-listener-strategy"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
dependencies = [
"event-listener",
"pin-project-lite",
]
[[package]]
name = "fallible-iterator"
version = "0.2.0"
@ -2811,19 +2640,6 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
[[package]]
name = "futures-lite"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
dependencies = [
"fastrand",
"futures-core",
"futures-io",
"parking",
"pin-project-lite",
]
[[package]]
name = "futures-macro"
version = "0.3.32"
@ -5233,16 +5049,6 @@ dependencies = [
"hashbrown 0.14.5",
]
[[package]]
name = "ordered-stream"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
dependencies = [
"futures-core",
"pin-project-lite",
]
[[package]]
name = "os_pipe"
version = "1.2.3"
@ -5353,12 +5159,6 @@ dependencies = [
"system-deps",
]
[[package]]
name = "parking"
version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
[[package]]
name = "parking_lot"
version = "0.12.5"
@ -5542,17 +5342,6 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "piper"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
dependencies = [
"atomic-waker",
"fastrand",
"futures-io",
]
[[package]]
name = "pkcs1"
version = "0.8.0-rc.4"
@ -5637,20 +5426,6 @@ dependencies = [
"miniz_oxide",
]
[[package]]
name = "polling"
version = "3.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
dependencies = [
"cfg-if",
"concurrent-queue",
"hermit-abi",
"pin-project-lite",
"rustix 1.1.4",
"windows-sys 0.61.2",
]
[[package]]
name = "poly1305"
version = "0.8.0"
@ -8059,21 +7834,6 @@ dependencies = [
"tokio",
]
[[package]]
name = "tauri-plugin-single-instance"
version = "2.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c8f29386f5e9fdc699182388a33ee80a56de436d91b67459e86afef426282af"
dependencies = [
"serde",
"serde_json",
"tauri",
"thiserror 2.0.18",
"tracing",
"windows-sys 0.60.2",
"zbus",
]
[[package]]
name = "tauri-plugin-updater"
version = "2.10.1"
@ -8864,17 +8624,6 @@ version = "1.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
[[package]]
name = "uds_windows"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.61.2",
]
[[package]]
name = "unic-char-property"
version = "0.9.0"
@ -10383,67 +10132,6 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zbus"
version = "5.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3bcbf15c8708d7fc1be0c993622e0a5cbd5e8b52bfa40afa4c3e0cd8d724ac1"
dependencies = [
"async-broadcast",
"async-executor",
"async-io",
"async-lock",
"async-process",
"async-recursion",
"async-task",
"async-trait",
"blocking",
"enumflags2",
"event-listener",
"futures-core",
"futures-lite",
"hex",
"libc",
"ordered-stream",
"rustix 1.1.4",
"serde",
"serde_repr",
"tracing",
"uds_windows",
"uuid",
"windows-sys 0.61.2",
"winnow 1.0.3",
"zbus_macros",
"zbus_names",
"zvariant",
]
[[package]]
name = "zbus_macros"
version = "5.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51fa5406ad9175a8c825a931f8cf347116b531b3634fcb0b627c290f1f2516ff"
dependencies = [
"proc-macro-crate 3.5.0",
"proc-macro2",
"quote",
"syn 2.0.117",
"zbus_names",
"zvariant",
"zvariant_utils",
]
[[package]]
name = "zbus_names"
version = "4.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d"
dependencies = [
"serde",
"winnow 1.0.3",
"zvariant",
]
[[package]]
name = "zerocopy"
version = "0.8.48"
@ -10645,43 +10333,3 @@ checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
dependencies = [
"zune-core",
]
[[package]]
name = "zvariant"
version = "5.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c1567a6ec68df868cbbfde844cfc6d81649fe5109a62b116b19fabd53e618ee"
dependencies = [
"endi",
"enumflags2",
"serde",
"winnow 1.0.3",
"zvariant_derive",
"zvariant_utils",
]
[[package]]
name = "zvariant_derive"
version = "5.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7d5b780599bbde114e39d9a0799577fad1ced5105d38515745f7b3099d8ceda"
dependencies = [
"proc-macro-crate 3.5.0",
"proc-macro2",
"quote",
"syn 2.0.117",
"zvariant_utils",
]
[[package]]
name = "zvariant_utils"
version = "3.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d464f5733ffa07a3164d656f18533caace9d0638596721355d73256a410d691"
dependencies = [
"proc-macro2",
"quote",
"serde",
"syn 2.0.117",
"winnow 1.0.3",
]

View File

@ -27,6 +27,7 @@ import { useNavigationTargets } from "@/composables/useNavigationTargets";
import { useDataGridActions } from "@/composables/useDataGridActions";
import { useTauriEvents } from "@/composables/useTauriEvents";
import { useVisibilityChange } from "@/composables/useVisibilityChange";
import { installWindowDragRegions } from "@/composables/useWindowDragRegion";
import "@/i18n";
import { translateBackendError } from "@/i18n/backend-errors";
import * as api from "@/lib/api";
@ -46,6 +47,7 @@ import {
isFocusSearchShortcut,
isModRShortcut,
isNewQueryShortcut,
isNewWindowShortcut,
isObjectSourceSaveShortcutTarget,
isResetZoomShortcut,
isRefreshDataShortcut,
@ -59,6 +61,8 @@ import { classifyAiSqlExecution } from "@/lib/aiSqlExecutionPolicy";
import { buildHistoryAiAnalysisPrompt } from "@/lib/historyAiAnalysis";
import { countAvailableAgentDriverUpdates, type AgentDriverUpdateBadgeState } from "@/lib/agentDriverUpdateBadge";
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/safeStorage";
import { openDesktopWindow } from "@/lib/desktopWindows";
import { subscribeTableMutations } from "@/lib/tableMutationBroadcast";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@ -103,6 +107,8 @@ const { setupFileDrop } = useFileDrop();
const isDesktop = isTauriRuntime();
const UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1000;
let updateCheckTimer: ReturnType<typeof setInterval> | undefined;
let unsubscribeTableMutations: (() => void) | undefined;
let uninstallWindowDragRegions: (() => void) | undefined;
const needsAuth = ref(!isDesktop);
const authenticated = ref(isDesktop);
const setupRequired = ref(false);
@ -760,6 +766,12 @@ function handleKeydown(e: KeyboardEvent) {
contentAreaRef.value?.refreshData();
return;
}
if (isNewWindowShortcut(e)) {
e.preventDefault();
e.stopPropagation();
void openDesktopWindow();
return;
}
if (isNewQueryShortcut(e, shortcuts)) {
e.preventDefault();
e.stopPropagation();
@ -888,7 +900,11 @@ onMounted(async () => {
});
applyTheme();
void applyUiScale(settingsStore.editorSettings.uiScale);
window.addEventListener("keydown", handleKeydown);
window.addEventListener("keydown", handleKeydown, true);
uninstallWindowDragRegions = installWindowDragRegions();
unsubscribeTableMutations = subscribeTableMutations((message) => {
queryStore.markTableTabsStale(message);
});
window.addEventListener("dbx-open-driver-store", openDriverStoreFromEvent);
if (isDesktop) {
document.addEventListener("contextmenu", handleContextMenu);
@ -944,7 +960,11 @@ onUnmounted(() => {
if (updateCheckTimer) {
clearInterval(updateCheckTimer);
}
window.removeEventListener("keydown", handleKeydown);
window.removeEventListener("keydown", handleKeydown, true);
uninstallWindowDragRegions?.();
uninstallWindowDragRegions = undefined;
unsubscribeTableMutations?.();
unsubscribeTableMutations = undefined;
window.removeEventListener("dbx-open-driver-store", openDriverStoreFromEvent);
document.removeEventListener("contextmenu", handleContextMenu);
});

View File

@ -690,7 +690,7 @@ const messageRenderer = computed(() => {
class="flex items-center gap-2 border-b px-3 shrink-0"
:class="settings.editorSettings.appLayout === 'classic' ? 'h-9' : 'h-10'"
>
<span class="flex flex-1 self-stretch items-center truncate text-xs font-medium" data-tauri-drag-region>
<span class="flex flex-1 self-stretch items-center truncate text-xs font-medium" data-window-drag-region>
{{ chatTitle }}
</span>
<Button variant="ghost" size="icon" class="h-6 w-6" @click="startNewChat" :title="t('ai.newChat')">

View File

@ -239,6 +239,7 @@ const props = defineProps<{
totalRowCount?: number;
loading?: boolean;
cacheKey?: string;
dataStale?: boolean;
onExecuteSql?: (sql: string) => Promise<void>;
fullExportResult?: () => Promise<QueryResult | undefined>;
customSave?: (changes: {
@ -5816,6 +5817,12 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
variant="ghost"
size="sm"
class="h-5 text-xs px-1.5 shrink-0"
:class="
props.dataStale
? 'bg-amber-500/15 text-amber-700 hover:bg-amber-500/25 hover:text-amber-800 dark:text-amber-300 dark:hover:text-amber-200'
: ''
"
:title="props.dataStale ? t('grid.dataStaleRefresh') : undefined"
:disabled="isSaving"
@click="onToolbarRefresh"
>

View File

@ -62,10 +62,10 @@ defineExpose({ focusSearch });
class="flex items-center gap-px px-3 text-xs font-medium text-muted-foreground border-b bg-muted/20"
:class="classicLayout ? 'h-9' : 'h-10'"
>
<span class="flex self-stretch items-center truncate" data-tauri-drag-region>{{
<span class="flex self-stretch items-center truncate" data-window-drag-region>{{
t("sidebar.connections")
}}</span>
<span class="flex-1 self-stretch" data-tauri-drag-region />
<span class="flex-1 self-stretch" data-window-drag-region />
<Tooltip>
<TooltipTrigger as-child>
<span class="inline-flex">

View File

@ -23,6 +23,7 @@ import { useQueryStore } from "@/stores/queryStore";
import { useSettingsStore } from "@/stores/settingsStore";
import { useTabScroll } from "@/composables/useTabScroll";
import { useTabDrag } from "@/composables/useTabDrag";
import { openDesktopWindow } from "@/lib/desktopWindows";
import {
connectionColor,
shouldShowTabOverflowControls,
@ -45,9 +46,14 @@ const emit = defineEmits<{
const { t } = useI18n();
const queryStore = useQueryStore();
const settingsStore = useSettingsStore();
const tabDrag = useTabDrag((draggedId, targetId, position) => {
queryStore.reorderTab(draggedId, targetId, position);
});
const tabDrag = useTabDrag(
(draggedId, targetId, position) => {
queryStore.reorderTab(draggedId, targetId, position);
},
(draggedId) => {
void detachTabToNewWindow(draggedId);
},
);
const editingTabId = ref<string | null>(null);
const editingTitle = ref("");
const compactTabTitle = computed({
@ -87,6 +93,13 @@ function cancelRenameTab() {
editingTabId.value = null;
}
async function detachTabToNewWindow(tabId: string) {
const tab = queryStore.tabs.find((item) => item.id === tabId);
if (!tab) return;
const opened = await openDesktopWindow({ detachedTab: tab });
if (opened) queryStore.closeTab(tab.id);
}
function getTabMenuItems(tab: QueryTab): ContextMenuItem[] {
return [
{
@ -271,6 +284,7 @@ const tabOverflowControlClass = computed(() =>
<template>
<div
v-if="queryStore.tabs.length > 0 || showDriverStore"
data-app-tab-bar
class="relative flex border-b shrink-0"
:class="
settingsStore.editorSettings.appLayout === 'classic'
@ -415,7 +429,7 @@ const tabOverflowControlClass = computed(() =>
<X class="h-3 w-3" />
</button>
</div>
<div :class="tabTailDragRegionClass" data-tauri-drag-region />
<div :class="tabTailDragRegionClass" data-window-drag-region />
</div>
<div v-if="showTabOverflowControls" class="relative z-30 flex shrink-0 items-center">
<button

View File

@ -91,7 +91,7 @@ function onToolbarDblClick(e: MouseEvent) {
<div
class="h-10 flex items-center gap-1 px-2 border-b bg-muted/30 shrink-0"
:class="{ 'pl-17.5': shouldReserveMacTrafficLightInset(isMac, isFullscreen, isDesktop) }"
data-tauri-drag-region
data-window-drag-region
@dblclick="onToolbarDblClick"
>
<Button variant="ghost" size="sm" class="h-8 px-2 text-xs gap-1" @click="emit('new-connection')">
@ -172,7 +172,7 @@ function onToolbarDblClick(e: MouseEvent) {
</span>
</Button>
<div class="flex-1" data-tauri-drag-region />
<div class="flex-1" data-window-drag-region />
<Tooltip>
<TooltipTrigger as-child>

View File

@ -505,6 +505,12 @@ defineExpose({ focusSearch, refreshData, handleModRTarget });
variant="ghost"
size="sm"
class="h-6 shrink-0 gap-1 px-2 text-xs text-muted-foreground hover:text-foreground"
:class="
activeTab.dataStale
? 'bg-amber-500/15 text-amber-700 hover:bg-amber-500/25 hover:text-amber-800 dark:text-amber-300 dark:hover:text-amber-200'
: ''
"
:title="activeTab.dataStale ? t('grid.dataStaleRefresh') : undefined"
:disabled="activeTab.isExecuting"
@click="refreshData"
>
@ -554,6 +560,7 @@ defineExpose({ focusSearch, refreshData, handleModRTarget });
:initial-order-by-input="activeTab.orderByInput"
:sql="activeTab.lastExecutedSql || activeTab.sql"
:loading="activeTab.isExecuting"
:data-stale="activeTab.dataStale"
:editable="!!activeTab.queryAnalysis"
:source-columns="activeTab.querySourceColumns"
context="results"
@ -804,12 +811,14 @@ defineExpose({ focusSearch, refreshData, handleModRTarget });
:initial-order-by-input="activeTab.orderByInput"
:sql="activeTab.sql"
:loading="activeTab.isExecuting"
:data-stale="activeTab.dataStale"
:editable="isTableDataEditable(activeEffectiveDatabaseType, activeTableMeta?.primaryKeys ?? [])"
context="table-data"
:initial-where-input="activeTab.whereInput"
:database-type="activeEffectiveDatabaseType"
:connection-id="activeTab.connectionId"
:database="activeTab.database"
:schema="activeTab.schema"
:table-meta="activeDataTabTableMeta"
:page-offset="activeTab.resultPageOffset"
:page-limit="activeTab.resultPageLimit"

View File

@ -5,8 +5,10 @@ import { rowStatusFilterAfterAddingRow, type RowStatusFilter } from "@/lib/gridR
import { supportsDataGridTransaction } from "@/lib/tableEditing";
import { useConnectionStore } from "@/stores/connectionStore";
import { useHistoryStore } from "@/stores/historyStore";
import { useQueryStore } from "@/stores/queryStore";
import type { ColumnInfo, DatabaseType } from "@/types/database";
import { DBX_NEO4J_ELEMENT_ID_COLUMN, DBX_ROWID_COLUMN } from "@/lib/tableEditing";
import { broadcastTableMutation } from "@/lib/tableMutationBroadcast";
type CellValue = string | number | boolean | null;
@ -94,6 +96,7 @@ const pendingChangesCache = new Map<string, PendingChangesSnapshot>();
export function useDataGridEditor(options: UseDataGridEditorOptions) {
const connectionStore = useConnectionStore();
const historyStore = useHistoryStore();
const queryStore = useQueryStore();
const {
result,
@ -640,6 +643,19 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
);
}
function notifyTableMutation() {
if (!connectionId.value || !database.value || !tableMeta.value) return;
const message = {
connectionId: connectionId.value,
database: database.value,
schema: tableMeta.value.schema,
tableName: tableMeta.value.tableName,
sourceTabId: cacheKey?.value,
};
queryStore.markTableTabsStale(message);
broadcastTableMutation(message);
}
async function saveChanges() {
saveError.value = "";
isSaving.value = true;
@ -662,6 +678,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
dirtyRows.value.clear();
newRows.value = [];
deletedRows.value.clear();
notifyTableMutation();
exitTransaction();
isSaving.value = false;
if (shouldReloadAfterSave) {
@ -752,6 +769,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
dirtyRows.value.clear();
newRows.value = [];
deletedRows.value.clear();
notifyTableMutation();
exitTransaction();
isSaving.value = false;
if (shouldReloadAfterSave) {

View File

@ -31,6 +31,7 @@ let pending: {
sourceEl: HTMLElement | null;
} | null = null;
let onDropCallback: ((draggedId: string, targetId: string, position: TabDropPosition) => void) | null = null;
let onDetachCallback: ((draggedId: string) => void) | null = null;
let ghostEl: HTMLElement | null = null;
function createGhost(sourceEl: HTMLElement, x: number, y: number) {
@ -95,10 +96,25 @@ function onMouseMove(event: MouseEvent) {
}
if (state.active) {
if (state.draggedId && onDetachCallback && isOutsideViewport(event)) {
const draggedId = state.draggedId;
onDetachCallback(draggedId);
reset();
return;
}
moveGhost(event.clientX, event.clientY);
}
}
function isOutsideViewport(event: MouseEvent) {
return (
event.clientX <= 0 ||
event.clientY <= 0 ||
event.clientX >= window.innerWidth - 1 ||
event.clientY >= window.innerHeight - 1
);
}
function onMouseUp() {
if (state.active && state.draggedId && state.targetId && state.dropPosition && onDropCallback) {
onDropCallback(state.draggedId, state.targetId, state.dropPosition);
@ -128,9 +144,13 @@ function ensureListeners() {
listenersAttached = true;
}
export function useTabDrag(onDrop: (draggedId: string, targetId: string, position: TabDropPosition) => void) {
export function useTabDrag(
onDrop: (draggedId: string, targetId: string, position: TabDropPosition) => void,
onDetach?: (draggedId: string) => void,
) {
ensureListeners();
onDropCallback = onDrop;
onDetachCallback = onDetach || null;
function startDrag(event: MouseEvent, tabId: string) {
if (event.button !== 0) return;

View File

@ -0,0 +1,37 @@
import { isTauriRuntime } from "@/lib/tauriRuntime";
const INTERACTIVE_SELECTOR = [
"button",
"[role='button']",
"a",
"input",
"textarea",
"select",
"[contenteditable='true']",
"[data-no-window-drag]",
].join(",");
function shouldStartWindowDrag(event: MouseEvent) {
if (event.defaultPrevented || event.button !== 0) return false;
const target = event.target;
if (!(target instanceof HTMLElement)) return false;
if (target.closest(INTERACTIVE_SELECTOR)) return false;
return !!target.closest("[data-window-drag-region]");
}
export function installWindowDragRegions() {
if (!isTauriRuntime()) return () => {};
const onMouseDown = (event: MouseEvent) => {
if (!shouldStartWindowDrag(event)) return;
event.preventDefault();
void import("@tauri-apps/api/window")
.then(({ getCurrentWindow }) => getCurrentWindow().startDragging())
.catch((error) => {
console.warn("[window-drag] startDragging failed", error);
});
};
window.addEventListener("mousedown", onMouseDown, true);
return () => window.removeEventListener("mousedown", onMouseDown, true);
}

View File

@ -628,6 +628,7 @@ export default {
queryError: "Query Error",
dataUnavailable: "Table data needs to be reloaded.",
refresh: "Refresh",
dataStaleRefresh: "This table may have changed in another window. Refresh to update.",
commit: "Commit",
rollback: "Rollback",
transactionSaveHint: "Commit {count} pending change(s) in a transaction.",

View File

@ -582,6 +582,7 @@ export default {
queryError: "Error de consulta",
dataUnavailable: "Los datos de la tabla deben volver a cargarse.",
refresh: "Actualizar",
dataStaleRefresh: "Esta tabla puede haber cambiado en otra ventana. Actualiza para ver los datos.",
commit: "Confirmar",
rollback: "Revertir",
transactionSaveHint: "Confirma {count} cambio(s) pendiente(s) en una transacción.",

View File

@ -633,6 +633,7 @@ export default {
queryError: "Errore Query",
dataUnavailable: "I dati della tabella devono essere ricaricati.",
refresh: "Aggiorna",
dataStaleRefresh: "Questa tabella potrebbe essere cambiata in un'altra finestra. Aggiorna per vedere i dati.",
commit: "Commit",
rollback: "Rollback",
transactionSaveHint: "Applica {count} modifica/he in sospeso in una transazione.",

View File

@ -623,6 +623,7 @@ export default {
queryError: "查询出错",
dataUnavailable: "表数据需要重新加载。",
refresh: "刷新",
dataStaleRefresh: "这个表可能已在其他窗口变更,刷新后可更新数据。",
commit: "提交",
rollback: "回滚",
transactionSaveHint: "在事务中提交 {count} 项待保存更改。",

View File

@ -622,6 +622,7 @@ export default {
tableInfoNoResults: "沒有相符結果",
queryError: "查詢出錯",
refresh: "重新整理",
dataStaleRefresh: "這個資料表可能已在其他視窗變更,重新整理後可更新資料。",
commit: "提交",
rollback: "回溯",
transactionSaveHint: "在事務中提交 {count} 項待儲存更改。",

View File

@ -0,0 +1,34 @@
import { serializeOpenTabs } from "@/lib/openTabsPersistence";
import { isTauriRuntime } from "@/lib/tauriRuntime";
import type { QueryTab } from "@/types/database";
import { invoke } from "@tauri-apps/api/core";
const DETACHED_TAB_STORAGE_PREFIX = "dbx-detached-tab:";
function windowLabel() {
return `main-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
}
function storeDetachedTab(tab: QueryTab): string {
const key = windowLabel();
localStorage.setItem(`${DETACHED_TAB_STORAGE_PREFIX}${key}`, JSON.stringify(serializeOpenTabs([tab])[0]));
return key;
}
export function detachedTabStorageKey(key: string) {
return `${DETACHED_TAB_STORAGE_PREFIX}${key}`;
}
export async function openDesktopWindow(options: { detachedTab?: QueryTab } = {}): Promise<boolean> {
if (!isTauriRuntime()) return false;
try {
const detachedTabKey = options.detachedTab ? storeDetachedTab(options.detachedTab) : null;
const url = detachedTabKey ? `/?detachedTab=${encodeURIComponent(detachedTabKey)}` : "/?newWindow=1";
await invoke("open_new_desktop_window", { url });
return true;
} catch (error) {
console.error("[DBX] Failed to open desktop window", error);
return false;
}
}

View File

@ -80,6 +80,10 @@ export function isNewQueryShortcut(event: ShortcutLikeEvent, shortcuts?: Partial
return matchesShortcut(event, actionShortcut("newQuery", shortcuts));
}
export function isNewWindowShortcut(event: ShortcutLikeEvent): boolean {
return matchesShortcut(event, "Mod+Shift+N");
}
export function isFocusSearchShortcut(event: ShortcutLikeEvent, shortcuts?: Partial<ShortcutSettings>): boolean {
return matchesShortcut(event, actionShortcut("focusSearch", shortcuts));
}

View File

@ -0,0 +1,49 @@
export interface TableMutationMessage {
connectionId: string;
database: string;
schema?: string;
tableName: string;
sourceTabId?: string;
sourceWindowId: string;
nonce: string;
}
const CHANNEL_NAME = "dbx-table-mutations";
const STORAGE_KEY = "dbx-table-mutated";
const windowId = crypto.randomUUID();
const channel = typeof BroadcastChannel === "undefined" ? null : new BroadcastChannel(CHANNEL_NAME);
export function currentTableMutationWindowId() {
return windowId;
}
export function broadcastTableMutation(message: Omit<TableMutationMessage, "sourceWindowId" | "nonce">) {
const payload: TableMutationMessage = {
...message,
sourceWindowId: windowId,
nonce: crypto.randomUUID(),
};
channel?.postMessage(payload);
localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
}
export function subscribeTableMutations(callback: (message: TableMutationMessage) => void) {
const onMessage = (event: MessageEvent<TableMutationMessage>) => {
if (event.data?.sourceWindowId !== windowId) callback(event.data);
};
const onStorage = (event: StorageEvent) => {
if (event.key !== STORAGE_KEY || !event.newValue) return;
try {
const message = JSON.parse(event.newValue) as TableMutationMessage;
if (message.sourceWindowId !== windowId) callback(message);
} catch {}
};
channel?.addEventListener("message", onMessage);
window.addEventListener("storage", onStorage);
return () => {
channel?.removeEventListener("message", onMessage);
window.removeEventListener("storage", onStorage);
};
}

View File

@ -32,6 +32,8 @@ import { tableMetaForDataTab } from "@/lib/tableDataTabMeta";
import { quoteTableIdentifier } from "@/lib/tableSelectSql";
import { connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection } from "@/lib/jdbcDialect";
import { queryTimeoutSecsForConnection } from "@/lib/queryTimeout";
import { detachedTabStorageKey } from "@/lib/desktopWindows";
import type { TableMutationMessage } from "@/lib/tableMutationBroadcast";
import * as api from "@/lib/api";
import { useConnectionStore } from "@/stores/connectionStore";
import { useSettingsStore } from "@/stores/settingsStore";
@ -40,6 +42,8 @@ import type { SavedSqlFile } from "@/types/database";
const STORAGE_KEY = "dbx-open-tabs";
const ACTIVE_TAB_KEY = "dbx-active-tab";
const ORACLE_LIKE_METADATA_TYPES = new Set<string>(["oracle", "dameng", "oceanbase-oracle"]);
const WINDOW_PARAMS = new URLSearchParams(window.location.search);
const DISABLE_SHARED_TAB_PERSISTENCE = WINDOW_PARAMS.has("detachedTab") || WINDOW_PARAMS.get("newWindow") === "1";
function markQueryResultRowsRaw(result: QueryResult): QueryResult {
markRaw(result.rows);
@ -91,6 +95,7 @@ function normalizeOracleLikeQueryAnalysis(
}
function saveTabs(tabs: QueryTab[], activeTabId: string | null) {
if (DISABLE_SHARED_TAB_PERSISTENCE) return;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(serializeOpenTabs(tabs)));
localStorage.setItem(ACTIVE_TAB_KEY, activeTabId || "");
@ -99,6 +104,15 @@ function saveTabs(tabs: QueryTab[], activeTabId: string | null) {
function loadSavedTabs(): { tabs: QueryTab[]; activeTabId: string | null } {
try {
const detachedTabKey = WINDOW_PARAMS.get("detachedTab");
if (detachedTabKey) {
const key = detachedTabStorageKey(detachedTabKey);
const raw = localStorage.getItem(key);
localStorage.removeItem(key);
return restoreOpenTabsState(raw ? `[${raw}]` : null, null);
}
if (WINDOW_PARAMS.get("newWindow") === "1") return { tabs: [], activeTabId: null };
return restoreOpenTabsState(localStorage.getItem(STORAGE_KEY), localStorage.getItem(ACTIVE_TAB_KEY));
} catch {
return { tabs: [], activeTabId: null };
@ -570,6 +584,45 @@ export const useQueryStore = defineStore("query", () => {
if (tab) tab.tableMeta = meta;
}
function tableIdentity(tab: QueryTab) {
const meta = tableMetaForDataTab(tab);
if (!meta?.tableName) return null;
return {
connectionId: tab.connectionId,
database: tab.database,
schema: meta.schema || "",
tableName: meta.tableName,
};
}
function isSameTable(
tab: QueryTab,
message: Pick<TableMutationMessage, "connectionId" | "database" | "schema" | "tableName">,
) {
const identity = tableIdentity(tab);
return (
!!identity &&
identity.connectionId === message.connectionId &&
identity.database === message.database &&
identity.schema === (message.schema || "") &&
identity.tableName === message.tableName
);
}
function markTableTabsStale(
message: Pick<TableMutationMessage, "connectionId" | "database" | "schema" | "tableName" | "sourceTabId">,
) {
for (const tab of tabs.value) {
if (tab.id === message.sourceTabId) continue;
if (isSameTable(tab, message)) tab.dataStale = true;
}
}
function clearDataStale(id: string) {
const tab = tabs.value.find((t) => t.id === id);
if (tab) tab.dataStale = undefined;
}
function setObjectSource(id: string, objectSource: NonNullable<QueryTab["objectSource"]>) {
const tab = tabs.value.find((t) => t.id === id);
if (tab) tab.objectSource = objectSource;
@ -820,6 +873,7 @@ export const useQueryStore = defineStore("query", () => {
tab.executionId = executionId;
tab.lastExecutedSql = sql;
tab.resultTotalRowCount = undefined;
tab.dataStale = undefined;
const previousResultSessionClose = closeResultSession(tab, options?.pagination?.sessionId);
if (!options?.preserveResultDuringExecution || !tab.result) {
clearResultPayload(tab);
@ -1493,6 +1547,8 @@ export const useQueryStore = defineStore("query", () => {
updateSchema,
updateConnection,
setTableMeta,
markTableTabsStale,
clearDataStale,
invalidateTableStructure,
tableStructureRefreshVersion,
setObjectSource,

View File

@ -362,6 +362,7 @@ export interface QueryTab {
resultTotalRowCount?: number;
resultSessionId?: string;
resultAccessedAt?: number;
dataStale?: boolean;
pinned?: boolean;
result?: QueryResult;
results?: QueryResult[];

View File

@ -130,6 +130,7 @@ impl Storage {
async fn init_schema(&self) -> Result<(), String> {
self.db.with_connection(|conn| {
configure_app_storage_connection(conn)?;
for statement in SCHEMA_STATEMENTS {
conn.execute(statement, []).map_err(|e| e.to_string())?;
}
@ -148,6 +149,14 @@ impl Storage {
}
}
fn configure_app_storage_connection(conn: &Connection) -> Result<(), String> {
conn.execute_batch(
"PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;",
)
.map_err(|e| e.to_string())
}
fn ensure_history_columns_sync(conn: &Connection) -> Result<(), String> {
const COLUMNS: &[(&str, &str)] = &[
("activity_kind", "TEXT NOT NULL DEFAULT 'query'"),
@ -1139,6 +1148,7 @@ fn map_from_sql_err(err: serde_json::Error) -> rusqlite::Error {
#[cfg(test)]
mod tests {
use super::{DesktopIconTheme, DesktopSettings, Storage};
use crate::history::HistoryEntry;
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_db_path(name: &str) -> std::path::PathBuf {
@ -1154,6 +1164,68 @@ mod tests {
assert_eq!(storage.load_desktop_settings().await.unwrap(), DesktopSettings::default());
}
#[tokio::test]
async fn app_storage_supports_two_handles_opening_and_writing_same_db() {
let path = temp_db_path("multi-instance-shared-store");
let first = Storage::open(&path).await.unwrap();
let second = Storage::open(&path).await.unwrap();
first
.save_desktop_settings(&DesktopSettings { show_tray_icon: false, icon_theme: DesktopIconTheme::Black })
.await
.unwrap();
second.save_sidebar_layout(&serde_json::json!({"conn-1": ["db", "schema"]})).await.unwrap();
let first_history = HistoryEntry {
id: "history-1".to_string(),
connection_id: "conn-1".to_string(),
connection_name: "Local".to_string(),
database: "main".to_string(),
sql: "select 1".to_string(),
executed_at: "2026-06-05T00:00:00Z".to_string(),
execution_time_ms: 1,
success: true,
error: None,
activity_kind: "query".to_string(),
operation: String::new(),
target: String::new(),
affected_rows: None,
rollback_sql: None,
details_json: None,
};
let second_history = HistoryEntry {
id: "history-2".to_string(),
connection_id: "conn-2".to_string(),
connection_name: "Local 2".to_string(),
database: "main".to_string(),
sql: "select 2".to_string(),
executed_at: "2026-06-05T00:00:01Z".to_string(),
execution_time_ms: 2,
success: true,
error: None,
activity_kind: "query".to_string(),
operation: String::new(),
target: String::new(),
affected_rows: None,
rollback_sql: None,
details_json: None,
};
let write_first = first.save_history_entry(&first_history);
let write_second = second.save_history_entry(&second_history);
let (first_result, second_result) = tokio::join!(write_first, write_second);
first_result.unwrap();
second_result.unwrap();
assert_eq!(
second.load_desktop_settings().await.unwrap(),
DesktopSettings { show_tray_icon: false, icon_theme: DesktopIconTheme::Black }
);
assert_eq!(first.load_sidebar_layout().await.unwrap(), Some(serde_json::json!({"conn-1": ["db", "schema"]})));
let history = first.load_history_entries(10, 0).await.unwrap();
assert_eq!(history.len(), 2);
}
#[tokio::test]
async fn desktop_settings_fall_back_to_legacy_background_preference() {
let path = temp_db_path("desktop-settings-legacy-background");

View File

@ -31,7 +31,6 @@ chrono = { version = "0.4", features = ["serde"] }
tauri-plugin-deep-link = "2"
tauri-plugin-dialog = "2.7.0"
tauri-plugin-fs = "2.5.0"
tauri-plugin-single-instance = "2.4.2"
tauri-plugin-shell = "2"
tauri-plugin-updater = "2"
tauri-plugin-process = "2"

View File

@ -3,7 +3,8 @@
"identifier": "default",
"description": "enables the default permissions",
"windows": [
"main"
"main",
"main-*"
],
"permissions": [
"core:default",

View File

@ -6,6 +6,7 @@ mod window_state_guard;
use commands::connection::AppState;
use dbx_core::storage::{DesktopIconTheme, DesktopSettings, Storage};
use std::path::Path;
use std::sync::Arc;
use std::time::Instant;
#[cfg(target_os = "macos")]
@ -14,7 +15,7 @@ use tauri::{
menu::MenuBuilder,
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
};
use tauri::{Emitter, Manager};
use tauri::{Emitter, Manager, WebviewUrl, WebviewWindowBuilder};
#[cfg(any(windows, target_os = "linux"))]
use tauri_plugin_deep_link::DeepLinkExt;
@ -23,6 +24,33 @@ const DESKTOP_TRAY_ID: &str = "main-tray";
const MACOS_TRAY_ICON: tauri::image::Image<'_> = tauri::include_image!("icons/tray-macos-template.png");
const BLACK_APP_ICON: tauri::image::Image<'_> = tauri::include_image!("icons/icon-black.png");
#[tauri::command]
fn open_new_desktop_window(app: tauri::AppHandle, url: Option<String>) -> Result<(), String> {
let label = format!("main-{}", uuid::Uuid::new_v4());
let url = WebviewUrl::App(url.unwrap_or_else(|| "/?newWindow=1".to_string()).into());
let mut builder = WebviewWindowBuilder::new(&app, label, url)
.title("DBX")
.inner_size(1280.0, 800.0)
.min_inner_size(900.0, 600.0)
.resizable(true)
.fullscreen(false)
.visible(true);
#[cfg(target_os = "macos")]
{
builder = builder
.title_bar_style(tauri::TitleBarStyle::Overlay)
.accept_first_mouse(true)
.traffic_light_position(tauri::LogicalPosition::new(16.0, 18.0))
.hidden_title(true);
}
let window = builder.build().map_err(|err| err.to_string())?;
window.show().map_err(|err| err.to_string())?;
window.set_focus().map_err(|err| err.to_string())?;
Ok(())
}
fn should_hide_window_on_close(target_os: &str) -> bool {
matches!(target_os, "macos" | "windows")
}
@ -35,6 +63,10 @@ fn should_show_main_window_after_setup() -> bool {
true
}
fn should_register_single_instance(_target_os: &str) -> bool {
false
}
fn show_main_window<R: tauri::Runtime>(app: &tauri::AppHandle<R>) {
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
@ -43,14 +75,81 @@ fn show_main_window<R: tauri::Runtime>(app: &tauri::AppHandle<R>) {
}
}
fn open_connection_deep_links(app: &tauri::AppHandle, links: Vec<String>) {
if links.is_empty() {
#[derive(Debug, Default, PartialEq, Eq)]
struct StartupPayload {
connection_links: Vec<String>,
sql_file_paths: Vec<String>,
db_file_paths: Vec<String>,
}
impl StartupPayload {
fn is_empty(&self) -> bool {
self.connection_links.is_empty() && self.sql_file_paths.is_empty() && self.db_file_paths.is_empty()
}
}
fn startup_payload_from_args<I, S>(args: I, cwd: &Path) -> StartupPayload
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let args: Vec<String> = args.into_iter().map(|arg| arg.as_ref().to_string()).collect();
StartupPayload {
connection_links: commands::deep_link::connection_deep_links_from_args(args.iter()),
sql_file_paths: commands::external_sql::sql_file_paths_from_args(args.iter(), cwd),
db_file_paths: commands::external_db::db_file_paths_from_args(args.iter(), cwd),
}
}
#[cfg(target_os = "macos")]
fn startup_payload_from_opened_urls(urls: &[tauri::Url]) -> StartupPayload {
let connection_links = urls
.iter()
.map(|url| url.to_string())
.filter_map(|url| commands::deep_link::connection_deep_link_from_arg(&url))
.collect();
let sql_file_paths = urls
.iter()
.filter_map(|url| url.to_file_path().ok())
.filter(|path| commands::external_sql::is_sql_file_path(path))
.map(|path| path.to_string_lossy().to_string())
.collect();
let db_file_paths = urls
.iter()
.filter_map(|url| url.to_file_path().ok())
.filter(|path| commands::external_db::is_db_file_path(path))
.map(|path| path.to_string_lossy().to_string())
.collect();
StartupPayload { connection_links, sql_file_paths, db_file_paths }
}
fn open_startup_payload(app: &tauri::AppHandle, payload: StartupPayload) {
if payload.is_empty() {
return;
}
if let Some(state) = app.try_state::<commands::deep_link::DeepLinkOpenState>() {
state.push(links.clone());
if !payload.connection_links.is_empty() {
if let Some(state) = app.try_state::<commands::deep_link::DeepLinkOpenState>() {
state.push(payload.connection_links.clone());
}
let _ = app.emit("dbx-open-connection-links", payload.connection_links);
}
let _ = app.emit("dbx-open-connection-links", links);
if !payload.sql_file_paths.is_empty() {
if let Some(state) = app.try_state::<commands::external_sql::ExternalSqlOpenState>() {
state.push(payload.sql_file_paths.clone());
}
let _ = app.emit("dbx-open-sql-files", payload.sql_file_paths);
}
if !payload.db_file_paths.is_empty() {
if let Some(state) = app.try_state::<commands::external_db::ExternalDbOpenState>() {
state.push(payload.db_file_paths.clone());
}
let _ = app.emit("dbx-open-db-files", payload.db_file_paths);
}
show_main_window(app);
}
@ -167,7 +266,42 @@ pub(crate) fn apply_desktop_settings(app: &tauri::AppHandle, desktop_settings: &
#[cfg(test)]
mod tests {
use super::{should_hide_window_on_close, should_setup_desktop_tray, should_show_main_window_after_setup};
use super::{
should_hide_window_on_close, should_register_single_instance, should_setup_desktop_tray,
should_show_main_window_after_setup, startup_payload_from_args, StartupPayload,
};
use std::path::Path;
#[test]
fn single_instance_is_disabled_for_all_desktop_platforms() {
assert!(!should_register_single_instance("windows"));
assert!(!should_register_single_instance("macos"));
assert!(!should_register_single_instance("linux"));
assert!(!should_register_single_instance("freebsd"));
}
#[test]
fn parses_all_startup_payload_types_from_args() {
let payload = startup_payload_from_args(
[
"dbx://connection/new?type=mysql&host=127.0.0.1",
"queries/report.sql",
"/tmp/local.sqlite",
"--ignored",
"/tmp/readme.txt",
],
Path::new("/work"),
);
assert_eq!(
payload,
StartupPayload {
connection_links: vec!["dbx://connection/new?type=mysql&host=127.0.0.1".to_string()],
sql_file_paths: vec!["/work/queries/report.sql".to_string()],
db_file_paths: vec!["/tmp/local.sqlite".to_string()],
}
);
}
#[test]
fn hides_window_on_close_for_windows_and_macos() {
@ -223,6 +357,20 @@ mod tests {
assert!(source
.contains("if should_show_main_window_after_setup() {\n show_main_window(app.handle());"));
}
#[test]
fn regular_startup_uses_shared_payload_dispatch() {
let source = include_str!("lib.rs");
assert!(source.contains("let startup_payload = startup_payload_from_args(std::env::args().skip(1), &cwd);"));
assert!(source.contains("open_startup_payload(app.handle(), startup_payload);"));
}
#[test]
fn single_instance_plugin_is_not_registered() {
let source = include_str!("lib.rs");
let plugin_init = concat!("tauri_plugin_", "single_instance", "::init");
assert!(!source.contains(plugin_init));
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
@ -230,33 +378,13 @@ pub fn run() {
rustls::crypto::aws_lc_rs::default_provider().install_default().expect("Failed to install rustls crypto provider");
let startup_begin = Instant::now();
debug_assert!(!should_register_single_instance(std::env::consts::OS));
tauri::Builder::default()
.plugin(tauri_plugin_deep_link::init())
.plugin(tauri_plugin_clipboard_manager::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_single_instance::init(|app, args, cwd| {
let links = commands::deep_link::connection_deep_links_from_args(args.clone());
open_connection_deep_links(app, links);
let paths = commands::external_sql::sql_file_paths_from_args(args.clone(), std::path::Path::new(&cwd));
if !paths.is_empty() {
if let Some(state) = app.try_state::<commands::external_sql::ExternalSqlOpenState>() {
state.push(paths.clone());
}
let _ = app.emit("dbx-open-sql-files", paths);
}
let db_paths = commands::external_db::db_file_paths_from_args(args, std::path::Path::new(&cwd));
if !db_paths.is_empty() {
if let Some(state) = app.try_state::<commands::external_db::ExternalDbOpenState>() {
state.push(db_paths.clone());
}
let _ = app.emit("dbx-open-db-files", db_paths);
}
show_main_window(app);
}))
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_process::init())
@ -305,8 +433,9 @@ pub fn run() {
app.manage(commands::external_sql::ExternalSqlOpenState::default());
app.manage(commands::external_db::ExternalDbOpenState::default());
app.manage(commands::deep_link::DeepLinkOpenState::default());
let startup_links = commands::deep_link::connection_deep_links_from_args(std::env::args().skip(1));
open_connection_deep_links(app.handle(), startup_links);
let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
let startup_payload = startup_payload_from_args(std::env::args().skip(1), &cwd);
open_startup_payload(app.handle(), startup_payload);
let app_handle = app.handle().clone();
commands::mcp_bridge::start(app_handle, state);
@ -526,6 +655,7 @@ pub fn run() {
commands::agents::import_agents_from_zip,
commands::agents::import_agent_jar_cmd,
commands::system_fonts::list_system_fonts,
open_new_desktop_window,
])
.build(tauri::generate_context!())
.expect("error while building tauri application")
@ -535,46 +665,7 @@ pub fn run() {
#[cfg(target_os = "macos")]
if let RunEvent::Opened { urls } = &event {
let links: Vec<String> = urls
.iter()
.map(|url| url.to_string())
.filter_map(|url| commands::deep_link::connection_deep_link_from_arg(&url))
.collect();
open_connection_deep_links(app_handle, links);
let paths: Vec<String> = urls
.iter()
.filter_map(|url| url.to_file_path().ok())
.filter(|path| commands::external_sql::is_sql_file_path(path))
.map(|path| path.to_string_lossy().to_string())
.collect();
if !paths.is_empty() {
if let Some(state) = app_handle.try_state::<commands::external_sql::ExternalSqlOpenState>() {
state.push(paths.clone());
}
let _ = app_handle.emit("dbx-open-sql-files", paths);
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
}
}
let db_paths: Vec<String> = urls
.iter()
.filter_map(|url| url.to_file_path().ok())
.filter(|path| commands::external_db::is_db_file_path(path))
.map(|path| path.to_string_lossy().to_string())
.collect();
if !db_paths.is_empty() {
if let Some(state) = app_handle.try_state::<commands::external_db::ExternalDbOpenState>() {
state.push(db_paths.clone());
}
let _ = app_handle.emit("dbx-open-db-files", db_paths);
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
}
}
open_startup_payload(app_handle, startup_payload_from_opened_urls(urls));
}
#[cfg(target_os = "macos")]

View File

@ -21,6 +21,7 @@
"fullscreen": false,
"visible": false,
"titleBarStyle": "Overlay",
"acceptFirstMouse": true,
"trafficLightPosition": {
"x": 16,
"y": 18