mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-23 07:08:16 +00:00
Merge remote-tracking branch 'upstream/main' into codex/fix-260-482-overflow-navigation
# Conflicts: # apps/desktop/src/components/layout/AppTabBar.vue # apps/desktop/src/lib/tabPresentation.ts
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
@@ -4,6 +4,7 @@ import { useI18n } from "vue-i18n";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Lock, Loader2, ShieldCheck } from "lucide-vue-next";
|
||||
import AppLogo from "@/components/icons/AppLogo.vue";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -55,7 +56,7 @@ async function submit() {
|
||||
>
|
||||
<div class="w-[360px] space-y-8">
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<img src="/logo.png" alt="DBX" class="w-20 h-20 rounded-2xl shadow-lg shadow-blue-500/20" />
|
||||
<AppLogo class="w-20 h-20 rounded-2xl shadow-lg shadow-blue-500/20" />
|
||||
<div class="text-center">
|
||||
<h1 class="text-2xl font-bold tracking-tight">DBX</h1>
|
||||
<p class="text-sm text-muted-foreground mt-1">
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
type AiProvider,
|
||||
type AiApiStyle,
|
||||
type EditorTheme,
|
||||
type DesktopIconTheme,
|
||||
} from "@/stores/settingsStore";
|
||||
import { loadEditorTheme, editorFontTheme } from "@/lib/editorThemes";
|
||||
import { isTauriRuntime } from "@/lib/tauriRuntime";
|
||||
@@ -66,6 +67,7 @@ import type { SqlSnippet } from "@/types/database";
|
||||
import { uuid } from "@/lib/utils";
|
||||
import { DEFAULT_SQL_SNIPPETS } from "@/lib/sqlCompletion";
|
||||
import AiProviderLogo from "@/components/icons/AiProviderLogo.vue";
|
||||
import AppLogo from "@/components/icons/AppLogo.vue";
|
||||
import type { AppThemeAppearance } from "@/lib/appTheme";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
|
||||
@@ -93,6 +95,7 @@ const editExecuteMode = ref(settingsStore.editorSettings.executeMode);
|
||||
const editWordWrap = ref(settingsStore.editorSettings.wordWrap);
|
||||
const editAppLayout = ref(settingsStore.editorSettings.appLayout);
|
||||
const editShowTrayIcon = ref(settingsStore.desktopSettings.show_tray_icon);
|
||||
const editIconTheme = ref<DesktopIconTheme>(settingsStore.desktopSettings.icon_theme);
|
||||
const editShowColumnCommentsInHeader = ref(settingsStore.editorSettings.showColumnCommentsInHeader);
|
||||
const editCompactColumnHeaderActions = ref(settingsStore.editorSettings.compactColumnHeaderActions);
|
||||
const editRedisScanPageSize = ref(settingsStore.editorSettings.redisScanPageSize);
|
||||
@@ -229,6 +232,7 @@ watch(
|
||||
editWordWrap.value = settingsStore.editorSettings.wordWrap;
|
||||
editAppLayout.value = settingsStore.editorSettings.appLayout;
|
||||
editShowTrayIcon.value = settingsStore.desktopSettings.show_tray_icon;
|
||||
editIconTheme.value = settingsStore.desktopSettings.icon_theme;
|
||||
editShowColumnCommentsInHeader.value = settingsStore.editorSettings.showColumnCommentsInHeader;
|
||||
editCompactColumnHeaderActions.value = settingsStore.editorSettings.compactColumnHeaderActions;
|
||||
editRedisScanPageSize.value = settingsStore.editorSettings.redisScanPageSize;
|
||||
@@ -268,6 +272,7 @@ function hasChanges(): boolean {
|
||||
editWordWrap.value !== settingsStore.editorSettings.wordWrap ||
|
||||
editAppLayout.value !== settingsStore.editorSettings.appLayout ||
|
||||
editShowTrayIcon.value !== settingsStore.desktopSettings.show_tray_icon ||
|
||||
editIconTheme.value !== settingsStore.desktopSettings.icon_theme ||
|
||||
editShowColumnCommentsInHeader.value !== settingsStore.editorSettings.showColumnCommentsInHeader ||
|
||||
editCompactColumnHeaderActions.value !== settingsStore.editorSettings.compactColumnHeaderActions ||
|
||||
editRedisScanPageSize.value !== settingsStore.editorSettings.redisScanPageSize ||
|
||||
@@ -309,6 +314,7 @@ async function persistSettings() {
|
||||
});
|
||||
await settingsStore.updateDesktopSettings({
|
||||
show_tray_icon: editShowTrayIcon.value,
|
||||
icon_theme: editIconTheme.value,
|
||||
});
|
||||
if (sidebarObjectDisplayChanged) {
|
||||
await connectionStore.refreshAllTree();
|
||||
@@ -333,6 +339,7 @@ function resetDefaults() {
|
||||
editWordWrap.value = DEFAULT_EDITOR_SETTINGS.wordWrap;
|
||||
editAppLayout.value = DEFAULT_EDITOR_SETTINGS.appLayout;
|
||||
editShowTrayIcon.value = DEFAULT_DESKTOP_SETTINGS.show_tray_icon;
|
||||
editIconTheme.value = DEFAULT_DESKTOP_SETTINGS.icon_theme;
|
||||
editShowColumnCommentsInHeader.value = DEFAULT_EDITOR_SETTINGS.showColumnCommentsInHeader;
|
||||
editCompactColumnHeaderActions.value = DEFAULT_EDITOR_SETTINGS.compactColumnHeaderActions;
|
||||
editRedisScanPageSize.value = DEFAULT_EDITOR_SETTINGS.redisScanPageSize;
|
||||
@@ -367,6 +374,10 @@ function setSidebarObjectDisplay(value: "grouped" | "simple") {
|
||||
editSidebarObjectDisplay.value = value;
|
||||
}
|
||||
|
||||
function setIconTheme(value: DesktopIconTheme) {
|
||||
editIconTheme.value = value;
|
||||
}
|
||||
|
||||
function onShortcutChange(actionId: ShortcutActionId, value: any) {
|
||||
if (typeof value !== "string") return;
|
||||
const definition = SHORTCUT_DEFINITIONS.find((item) => item.id === actionId);
|
||||
@@ -596,6 +607,7 @@ watch(
|
||||
await settingsStore.initAiConfig();
|
||||
await settingsStore.initDesktopSettings();
|
||||
editShowTrayIcon.value = settingsStore.desktopSettings.show_tray_icon;
|
||||
editIconTheme.value = settingsStore.desktopSettings.icon_theme;
|
||||
webdavPassword.value = "";
|
||||
await refreshWebDavPasswordStatus();
|
||||
syncAiEditState();
|
||||
@@ -1169,6 +1181,43 @@ watch(
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!isWeb" class="space-y-2">
|
||||
<Label>{{ t("settings.iconTheme") }}</Label>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
class="h-auto justify-start border p-3"
|
||||
:class="editIconTheme === 'default' ? 'border-blue-300 ring-2 ring-blue-300/50' : ''"
|
||||
@click="setIconTheme('default')"
|
||||
>
|
||||
<div class="flex items-center gap-3 text-left">
|
||||
<img src="/logo.png" alt="DBX" class="h-8 w-8 rounded-md" />
|
||||
<div>
|
||||
<div class="text-sm font-medium">{{ t("settings.iconThemeDefault") }}</div>
|
||||
<div class="text-xs text-muted-foreground">{{ t("settings.iconThemeDefaultDescription") }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
class="h-auto justify-start border p-3"
|
||||
:class="editIconTheme === 'black' ? 'border-blue-300 ring-2 ring-blue-300/50' : ''"
|
||||
@click="setIconTheme('black')"
|
||||
>
|
||||
<div class="flex items-center gap-3 text-left">
|
||||
<img src="/logo-black.png" alt="DBX" class="h-8 w-8 dark:invert" />
|
||||
<div>
|
||||
<div class="text-sm font-medium">{{ t("settings.iconThemeBlack") }}</div>
|
||||
<div class="text-xs text-muted-foreground">{{ t("settings.iconThemeBlackDescription") }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!isWeb"
|
||||
class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2"
|
||||
@@ -1910,7 +1959,7 @@ watch(
|
||||
{{ t("settings.project") }}
|
||||
</div>
|
||||
<div class="mt-3 flex items-center gap-2 text-sm font-medium">
|
||||
<img src="/logo.png" alt="DBX" class="h-7 w-7 rounded-md" />
|
||||
<AppLogo class="h-7 w-7" />
|
||||
{{ t("settings.officialDocs") }}
|
||||
<ExternalLink class="ml-auto h-3.5 w-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -110,6 +110,16 @@ function stepTime(part: "hour" | "minute" | "second", delta: number) {
|
||||
updateTime(part, (current + delta + max + 1) % (max + 1));
|
||||
}
|
||||
|
||||
function flushInputValue(target: EventTarget | null) {
|
||||
if (!(target instanceof HTMLInputElement)) return;
|
||||
const part = target.dataset.temporalPart;
|
||||
if (part === "year" || part === "month" || part === "day") {
|
||||
updateDate(part, target.value);
|
||||
} else if (part === "hour" || part === "minute" || part === "second") {
|
||||
updateTime(part, target.value);
|
||||
}
|
||||
}
|
||||
|
||||
function setNull() {
|
||||
setModelValue("NULL");
|
||||
}
|
||||
@@ -144,6 +154,7 @@ function finishCancel() {
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
flushInputValue(event.target);
|
||||
finishCommit();
|
||||
} else if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
@@ -208,6 +219,7 @@ function twoDigit(value: string | number): string {
|
||||
>
|
||||
<input
|
||||
:value="dateParts.year"
|
||||
data-temporal-part="year"
|
||||
inputmode="numeric"
|
||||
class="min-w-0 bg-transparent px-1 text-center text-[13px] tabular-nums outline-none"
|
||||
@change="updateDateFromInput('year', $event)"
|
||||
@@ -231,6 +243,7 @@ function twoDigit(value: string | number): string {
|
||||
>
|
||||
<input
|
||||
:value="twoDigit(dateParts.month)"
|
||||
data-temporal-part="month"
|
||||
inputmode="numeric"
|
||||
class="min-w-0 bg-transparent px-1 text-center text-[13px] tabular-nums outline-none"
|
||||
@change="updateDateFromInput('month', $event)"
|
||||
@@ -254,6 +267,7 @@ function twoDigit(value: string | number): string {
|
||||
>
|
||||
<input
|
||||
:value="twoDigit(dateParts.day)"
|
||||
data-temporal-part="day"
|
||||
inputmode="numeric"
|
||||
class="min-w-0 bg-transparent px-1 text-center text-[13px] tabular-nums outline-none"
|
||||
@change="updateDateFromInput('day', $event)"
|
||||
@@ -279,6 +293,7 @@ function twoDigit(value: string | number): string {
|
||||
>
|
||||
<input
|
||||
:value="twoDigit(timeParts.hour)"
|
||||
data-temporal-part="hour"
|
||||
inputmode="numeric"
|
||||
class="min-w-0 bg-transparent px-1 text-center text-[13px] tabular-nums outline-none"
|
||||
@change="updateTimeFromInput('hour', $event)"
|
||||
@@ -302,6 +317,7 @@ function twoDigit(value: string | number): string {
|
||||
>
|
||||
<input
|
||||
:value="twoDigit(timeParts.minute)"
|
||||
data-temporal-part="minute"
|
||||
inputmode="numeric"
|
||||
class="min-w-0 bg-transparent px-1 text-center text-[13px] tabular-nums outline-none"
|
||||
@change="updateTimeFromInput('minute', $event)"
|
||||
@@ -329,6 +345,7 @@ function twoDigit(value: string | number): string {
|
||||
>
|
||||
<input
|
||||
:value="twoDigit(timeParts.second)"
|
||||
data-temporal-part="second"
|
||||
inputmode="numeric"
|
||||
class="min-w-0 bg-transparent px-1 text-center text-[13px] tabular-nums outline-none"
|
||||
@change="updateTimeFromInput('second', $event)"
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
const isBlackLogo = computed(() => settingsStore.desktopSettings.icon_theme === "black");
|
||||
const logoSrc = computed(() => (isBlackLogo.value ? "/logo-black.png" : "/logo.png"));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<img :src="logoSrc" alt="DBX" :class="isBlackLogo ? 'dark:invert' : ''" />
|
||||
</template>
|
||||
@@ -18,11 +18,15 @@ import {
|
||||
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
|
||||
import LightDropdown from "@/components/ui/LightDropdown.vue";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useQueryStore } from "@/stores/queryStore";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import { useTabScroll } from "@/composables/useTabScroll";
|
||||
import { connectionColor, shouldShowTabOverflowControls } from "@/lib/tabPresentation";
|
||||
import {
|
||||
connectionColor,
|
||||
shouldShowTabOverflowControls,
|
||||
tabDisplayTitle,
|
||||
tabTooltipLines,
|
||||
} from "@/lib/tabPresentation";
|
||||
import { hexToRgba } from "@/lib/color";
|
||||
import type { QueryTab } from "@/types/database";
|
||||
|
||||
@@ -37,7 +41,6 @@ const emit = defineEmits<{
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const connectionStore = useConnectionStore();
|
||||
const queryStore = useQueryStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const compactTabTitle = computed({
|
||||
@@ -170,83 +173,11 @@ const showTabOverflowControls = computed(() =>
|
||||
),
|
||||
);
|
||||
|
||||
const connectionSummariesById = computed(
|
||||
() =>
|
||||
new Map(
|
||||
connectionStore.connections.map((connection) => [
|
||||
connection.id,
|
||||
{ name: connection.name, databaseType: connection.db_type },
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
function connectionNameForTab(tab: Pick<QueryTab, "connectionId">): string {
|
||||
return connectionSummariesById.value.get(tab.connectionId)?.name || tab.connectionId;
|
||||
}
|
||||
|
||||
function databaseNameForTab(tab: Pick<QueryTab, "connectionId" | "database">): string {
|
||||
const connection = connectionSummariesById.value.get(tab.connectionId);
|
||||
if (connection?.databaseType === "redis" && tab.database !== "") return `db${tab.database}`;
|
||||
return tab.database || t("editor.noDatabase");
|
||||
}
|
||||
|
||||
function isPreviewTab(tab: Pick<QueryTab, "connectionId">): boolean {
|
||||
return !!connectionSummariesById.value.get(tab.connectionId)?.name.startsWith("[Preview]");
|
||||
}
|
||||
|
||||
function tabTitle(tab: QueryTab): string {
|
||||
const database = databaseNameForTab(tab);
|
||||
if (isPreviewTab(tab)) return tab.title;
|
||||
if (tab.mode === "data" && tab.tableMeta?.tableName) {
|
||||
if (compactTabTitle.value) return tab.tableMeta.tableName;
|
||||
const suffix =
|
||||
tab.tableMeta.schema && tab.tableMeta.schema !== tab.database
|
||||
? `@${database}.${tab.tableMeta.schema}`
|
||||
: `@${database}`;
|
||||
return `${tab.tableMeta.tableName}${suffix}`;
|
||||
}
|
||||
if (tab.mode === "query") {
|
||||
if (compactTabTitle.value) return connectionNameForTab(tab);
|
||||
return `${connectionNameForTab(tab)}@${database}`;
|
||||
}
|
||||
if (tab.mode === "mongo" && tab.sql) {
|
||||
if (compactTabTitle.value) return tab.sql;
|
||||
return `${tab.sql}@${database}`;
|
||||
}
|
||||
if (tab.mode === "redis") {
|
||||
if (compactTabTitle.value) return connectionNameForTab(tab);
|
||||
return `${connectionNameForTab(tab)}@${database}`;
|
||||
}
|
||||
if (tab.mode === "objects") {
|
||||
const schema = tab.objectBrowser?.schema;
|
||||
if (compactTabTitle.value) return schema || tab.title;
|
||||
return schema ? `${schema}@${database}` : `${tab.title}@${database}`;
|
||||
}
|
||||
return tab.title;
|
||||
}
|
||||
|
||||
function tabTooltipRows(tab: QueryTab): { label: string; value: string }[] {
|
||||
const rows: { label: string; value: string }[] = [
|
||||
{ label: t("tabs.tooltipConnection"), value: connectionNameForTab(tab) },
|
||||
{ label: t("tabs.tooltipDatabase"), value: databaseNameForTab(tab) },
|
||||
];
|
||||
if (tab.mode === "data" && tab.tableMeta?.tableName) {
|
||||
rows.push({ label: t("tabs.tooltipTable"), value: tab.tableMeta.tableName });
|
||||
}
|
||||
if (tab.mode === "mongo" && tab.sql) {
|
||||
rows.push({ label: t("tabs.tooltipCollection"), value: tab.sql });
|
||||
}
|
||||
if (tab.mode === "objects" && tab.objectBrowser?.schema) {
|
||||
rows.push({ label: t("tabs.tooltipSchema"), value: tab.objectBrowser.schema });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
const openTabMenuItems = computed(() =>
|
||||
queryStore.tabs.map((tab) => ({
|
||||
value: tab.id,
|
||||
label: tabTitle(tab),
|
||||
title: tabTitle(tab),
|
||||
label: tabDisplayTitle(tab, t),
|
||||
title: tabDisplayTitle(tab, t),
|
||||
icon: tabMenuIcon(tab),
|
||||
iconClass: tabIconClass(tab),
|
||||
})),
|
||||
@@ -354,7 +285,7 @@ const tabOverflowControlClass = computed(() =>
|
||||
<PencilRuler v-else-if="tab.mode === 'structure'" class="h-3.5 w-3.5" />
|
||||
<Code2 v-else class="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<span class="min-w-0 truncate flex-1">{{ tabTitle(tab) }}</span>
|
||||
<span class="min-w-0 truncate flex-1">{{ tabDisplayTitle(tab, t) }}</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
@@ -376,7 +307,7 @@ const tabOverflowControlClass = computed(() =>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" class="text-xs grid grid-cols-[auto_1fr] gap-x-2">
|
||||
<template v-for="line in tabTooltipRows(tab)" :key="line.label">
|
||||
<template v-for="line in tabTooltipLines(tab, t)" :key="line.label">
|
||||
<span class="text-muted-foreground">{{ line.label }}</span>
|
||||
<span>{{ line.value }}</span>
|
||||
</template>
|
||||
|
||||
@@ -22,7 +22,13 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
|
||||
import QueryEditor from "@/components/editor/QueryEditor.vue";
|
||||
import ColumnInfoPanel from "@/components/editor/ColumnInfoPanel.vue";
|
||||
import type { ColumnInfo } from "@/components/editor/ColumnInfoPanel.vue";
|
||||
const DataGrid = defineAsyncComponent(() => import("@/components/grid/DataGrid.vue"));
|
||||
const DataGrid = defineAsyncComponent(async () => {
|
||||
const startedAt = performance.now();
|
||||
console.info("[DBX][DataGrid:load:start]");
|
||||
const component = await import("@/components/grid/DataGrid.vue");
|
||||
console.info("[DBX][DataGrid:load:done]", { elapsed: `${Math.round(performance.now() - startedAt)}ms` });
|
||||
return component;
|
||||
});
|
||||
const RedisKeyBrowser = defineAsyncComponent(() => import("@/components/redis/RedisKeyBrowser.vue"));
|
||||
const MongoDocBrowser = defineAsyncComponent(() => import("@/components/mongo/MongoDocBrowser.vue"));
|
||||
const ObjectBrowser = defineAsyncComponent(() => import("@/components/objects/ObjectBrowser.vue"));
|
||||
@@ -171,6 +177,35 @@ watch(
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.activeTab.result,
|
||||
(result) => {
|
||||
if (!result) return;
|
||||
const startedAt = performance.now();
|
||||
console.info("[DBX][ContentArea:result:observed]", {
|
||||
tabId: props.activeTab.id,
|
||||
rowCount: result.rows.length,
|
||||
columnCount: result.columns.length,
|
||||
backendMs: result.execution_time_ms,
|
||||
isExecuting: props.activeTab.isExecuting,
|
||||
});
|
||||
nextTick(() => {
|
||||
console.info("[DBX][ContentArea:result:nextTick]", {
|
||||
tabId: props.activeTab.id,
|
||||
elapsed: `${Math.round(performance.now() - startedAt)}ms`,
|
||||
isExecuting: props.activeTab.isExecuting,
|
||||
});
|
||||
requestAnimationFrame(() => {
|
||||
console.info("[DBX][ContentArea:result:first-frame]", {
|
||||
tabId: props.activeTab.id,
|
||||
elapsed: `${Math.round(performance.now() - startedAt)}ms`,
|
||||
isExecuting: props.activeTab.isExecuting,
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.activeTab.isExecuting,
|
||||
(isExecuting, wasExecuting) => {
|
||||
@@ -445,19 +480,19 @@ defineExpose({ focusSearch, refreshData, handleModRTarget });
|
||||
(column: string, columnIndex: number, direction: 'asc' | 'desc' | null, whereInput?: string) =>
|
||||
emit('sort', column, columnIndex, direction, whereInput)
|
||||
"
|
||||
/>
|
||||
<div
|
||||
v-if="activeTab.result?.columns.includes('Error')"
|
||||
class="flex items-center gap-2 px-3 py-1.5 border-t bg-destructive/5"
|
||||
>
|
||||
<Bot class="h-3.5 w-3.5 text-destructive" />
|
||||
<button
|
||||
class="text-xs text-destructive hover:underline"
|
||||
@click="emit('fixWithAi', String(activeTab.result?.rows?.[0]?.[0] ?? ''))"
|
||||
>
|
||||
{{ t("ai.fixWithAi") }}
|
||||
</button>
|
||||
</div>
|
||||
<template v-if="activeTab.result?.columns.includes('Error')" #error-actions="{ errorMessage }">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="mt-2 h-7 gap-1.5 border-destructive/30 bg-background px-2.5 text-xs text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
@click="emit('fixWithAi', String(errorMessage))"
|
||||
>
|
||||
<Bot class="h-3.5 w-3.5" />
|
||||
{{ t("ai.fixWithAi") }}
|
||||
</Button>
|
||||
</template>
|
||||
</DataGrid>
|
||||
<div
|
||||
v-else-if="!activeTab.result && activeTab.isExecuting"
|
||||
class="flex-1 min-h-0 flex flex-col items-center justify-center gap-3 text-muted-foreground text-sm"
|
||||
@@ -499,7 +534,7 @@ defineExpose({ focusSearch, refreshData, handleModRTarget });
|
||||
class="inline-flex items-center rounded border border-border bg-muted/30 px-2 py-0.5 text-muted-foreground truncate"
|
||||
>
|
||||
<template v-if="activeTab.tableMeta?.schema">{{ activeTab.tableMeta.schema }}@</template
|
||||
>{{ databaseDisplayNameForTab(activeTab.connectionId, activeTab.database) }}
|
||||
>{{ databaseDisplayNameForTab(activeTab.connectionId, activeTab.database, t) }}
|
||||
</span>
|
||||
<span v-if="activeTab.tableMeta" class="ml-auto text-muted-foreground">
|
||||
{{ activeTab.tableMeta.columns.length }} {{ t("tree.columns") }}
|
||||
|
||||
@@ -65,7 +65,7 @@ import {
|
||||
setActiveTableReferencePayload,
|
||||
type QueryEditorTableReferencePayload,
|
||||
} from "@/lib/queryEditorTableDrop";
|
||||
import { editablePrimaryKeys, usesSyntheticRowIdKey } from "@/lib/tableEditing";
|
||||
import { editablePrimaryKeys } from "@/lib/tableEditing";
|
||||
import {
|
||||
supportsDatabaseCreation,
|
||||
supportsDatabaseSearch,
|
||||
@@ -531,49 +531,57 @@ async function openData() {
|
||||
if (!config) throw new Error("Connection config not found");
|
||||
|
||||
const querySchema = node.schema || node.database;
|
||||
console.info("[DBX][openData:get-columns:start]", {
|
||||
traceId,
|
||||
database: node.database,
|
||||
schema: querySchema,
|
||||
table: node.label,
|
||||
elapsed: elapsed(),
|
||||
});
|
||||
const columns = await api.getColumns(node.connectionId, node.database, querySchema, node.label);
|
||||
console.info("[DBX][openData:get-columns:done]", {
|
||||
traceId,
|
||||
columnCount: columns.length,
|
||||
primaryKeys: columns.filter((column) => column.is_primary_key).map((column) => column.name),
|
||||
elapsed: elapsed(),
|
||||
});
|
||||
const pks = editablePrimaryKeys(config.db_type, columns);
|
||||
const limit = settingsStore.editorSettings.pageSize;
|
||||
const sql = await buildTableSelectSql({
|
||||
databaseType: config.db_type,
|
||||
schema: node.schema,
|
||||
tableName: node.label,
|
||||
columns: columns.map((column) => column.name),
|
||||
primaryKeys: pks,
|
||||
columns: [],
|
||||
primaryKeys: [],
|
||||
limit,
|
||||
includeRowId: usesSyntheticRowIdKey(config.db_type, pks),
|
||||
includeRowId: false,
|
||||
});
|
||||
console.info("[DBX][openData:sql-built]", {
|
||||
traceId,
|
||||
primaryKeys: pks,
|
||||
includeRowId: usesSyntheticRowIdKey(config.db_type, pks),
|
||||
primaryKeys: [],
|
||||
includeRowId: false,
|
||||
sql,
|
||||
elapsed: elapsed(),
|
||||
});
|
||||
queryStore.updateSql(tabId, sql);
|
||||
queryStore.setTableMeta(tabId, {
|
||||
schema: node.schema,
|
||||
tableName: node.label,
|
||||
columns,
|
||||
primaryKeys: pks,
|
||||
});
|
||||
|
||||
const loadTableMeta = async () => {
|
||||
try {
|
||||
console.info("[DBX][openData:get-columns:start]", {
|
||||
traceId,
|
||||
database: node.database,
|
||||
schema: querySchema,
|
||||
table: node.label,
|
||||
elapsed: elapsed(),
|
||||
});
|
||||
const columns = await api.getColumns(node.connectionId, node.database, querySchema, node.label);
|
||||
console.info("[DBX][openData:get-columns:done]", {
|
||||
traceId,
|
||||
columnCount: columns.length,
|
||||
primaryKeys: columns.filter((column) => column.is_primary_key).map((column) => column.name),
|
||||
elapsed: elapsed(),
|
||||
});
|
||||
const pks = editablePrimaryKeys(config.db_type, columns);
|
||||
queryStore.setTableMeta(tabId, {
|
||||
schema: node.schema,
|
||||
tableName: node.label,
|
||||
columns,
|
||||
primaryKeys: pks,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("[DBX][openData:get-columns:error]", { traceId, elapsed: elapsed(), error });
|
||||
}
|
||||
};
|
||||
|
||||
console.info("[DBX][openData:execute:start]", { traceId, tabId, elapsed: elapsed() });
|
||||
await queryStore.executeTabSql(tabId, sql);
|
||||
console.info("[DBX][openData:execute:done]", { traceId, tabId, elapsed: elapsed() });
|
||||
void loadTableMeta();
|
||||
} catch (e: any) {
|
||||
console.error("[DBX][openData:error]", { traceId, elapsed: elapsed(), error: e });
|
||||
queryStore.setErrorResult(tabId, e);
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
|
||||
type CellValue = string | number | boolean | null;
|
||||
|
||||
const ROW_NUM_WIDTH = 48;
|
||||
export const DATA_GRID_ROW_NUM_WIDTH = 48;
|
||||
|
||||
export interface UseDataGridColumnResizeOptions {
|
||||
columns: ComputedRef<string[]>;
|
||||
@@ -80,21 +80,21 @@ export function useDataGridColumnResize(options: UseDataGridColumnResizeOptions)
|
||||
const widths = columnWidths.value;
|
||||
if (widths.length === 0) return widths;
|
||||
|
||||
const extraWidth = Math.max(0, gridWidth.value - ROW_NUM_WIDTH - baseTotalWidth.value);
|
||||
const extraWidth = Math.max(0, gridWidth.value - DATA_GRID_ROW_NUM_WIDTH - baseTotalWidth.value);
|
||||
if (extraWidth === 0) return widths;
|
||||
|
||||
const extraPerColumn = extraWidth / widths.length;
|
||||
return widths.map((width) => width + extraPerColumn);
|
||||
});
|
||||
|
||||
const totalWidth = computed(() => renderedColumnWidths.value.reduce((a, b) => a + b, 0) + ROW_NUM_WIDTH);
|
||||
const totalWidth = computed(() => renderedColumnWidths.value.reduce((a, b) => a + b, 0) + DATA_GRID_ROW_NUM_WIDTH);
|
||||
|
||||
const columnVars = computed(() => {
|
||||
const vars: Record<string, string> = {};
|
||||
renderedColumnWidths.value.forEach((w, i) => {
|
||||
vars[`--col-w-${i}`] = `${w}px`;
|
||||
});
|
||||
vars["--row-num-w"] = `${ROW_NUM_WIDTH}px`;
|
||||
vars["--row-num-w"] = `${DATA_GRID_ROW_NUM_WIDTH}px`;
|
||||
vars["--total-w"] = `${totalWidth.value}px`;
|
||||
return vars;
|
||||
});
|
||||
|
||||
@@ -552,6 +552,14 @@ export default {
|
||||
formattedValue: "Formatted Value",
|
||||
rawValue: "Raw Value",
|
||||
copyValue: "Copy Value",
|
||||
downloadBinaryValue: "Download Value",
|
||||
downloadSaved: "Saved to {path}",
|
||||
downloadStarted: "Download started: {fileName}",
|
||||
binaryDownload: {
|
||||
binary: "Raw binary",
|
||||
utf8: "Decoded text (UTF-8)",
|
||||
gbk: "Decoded text (GBK)",
|
||||
},
|
||||
copyRowTsv: "Copy Row (TSV)",
|
||||
copyColumnValues: "Copy Column (JSON)",
|
||||
copyColumnTsv: "Copy Column (TSV)",
|
||||
@@ -1457,6 +1465,11 @@ export default {
|
||||
appLayoutSeparatedDescription: "Clear panel separation with block-style tabs.",
|
||||
appLayoutClassic: "Classic compact",
|
||||
appLayoutClassicDescription: "Continuous tab bar with compact panel headers.",
|
||||
iconTheme: "Icon and logo style",
|
||||
iconThemeDefault: "Default",
|
||||
iconThemeDefaultDescription: "Use the color DBX app icon.",
|
||||
iconThemeBlack: "Black",
|
||||
iconThemeBlackDescription: "Use the black DBX mark for the window, tray, and logo.",
|
||||
showTrayIcon: "Show tray/menu bar icon",
|
||||
showTrayIconDescription:
|
||||
"When disabled, no icon is shown, but closing the window still hides DBX in the background as before.",
|
||||
|
||||
@@ -523,6 +523,14 @@ export default {
|
||||
formattedValue: "Valor formateado",
|
||||
rawValue: "Valor sin procesar",
|
||||
copyValue: "Copiar valor",
|
||||
downloadBinaryValue: "Descargar valor",
|
||||
downloadSaved: "Guardado en {path}",
|
||||
downloadStarted: "Descarga iniciada: {fileName}",
|
||||
binaryDownload: {
|
||||
binary: "Binario sin procesar",
|
||||
utf8: "Texto decodificado (UTF-8)",
|
||||
gbk: "Texto decodificado (GBK)",
|
||||
},
|
||||
copyRowTsv: "Copiar fila (TSV)",
|
||||
copyColumnValues: "Copiar columna (JSON)",
|
||||
copyColumnTsv: "Copiar columna (TSV)",
|
||||
@@ -1350,6 +1358,11 @@ export default {
|
||||
appLayoutSeparatedDescription: "Separación clara de paneles con pestañas en bloque.",
|
||||
appLayoutClassic: "Clásico compacto",
|
||||
appLayoutClassicDescription: "Barra de pestañas continua con encabezados de panel compactos.",
|
||||
iconTheme: "Estilo de icono y logo",
|
||||
iconThemeDefault: "Predeterminado",
|
||||
iconThemeDefaultDescription: "Usar el icono de color de DBX.",
|
||||
iconThemeBlack: "Negro",
|
||||
iconThemeBlackDescription: "Usar la marca negra de DBX en ventana, bandeja y logo.",
|
||||
showTrayIcon: "Mostrar icono en bandeja/barra de menú",
|
||||
showTrayIconDescription:
|
||||
"Si está desactivado, no se muestra el icono, pero cerrar la ventana sigue ocultando DBX en segundo plano como antes.",
|
||||
|
||||
@@ -547,6 +547,14 @@ export default {
|
||||
formattedValue: "格式化值",
|
||||
rawValue: "原始值",
|
||||
copyValue: "复制值",
|
||||
downloadBinaryValue: "下载值",
|
||||
downloadSaved: "已保存到 {path}",
|
||||
downloadStarted: "已交给浏览器下载:{fileName}",
|
||||
binaryDownload: {
|
||||
binary: "原始二进制",
|
||||
utf8: "解码文本 (UTF-8)",
|
||||
gbk: "解码文本 (GBK)",
|
||||
},
|
||||
copyRowTsv: "复制行 (TSV)",
|
||||
copyColumnValues: "复制列 (JSON)",
|
||||
copyColumnTsv: "复制列 (TSV)",
|
||||
@@ -1431,6 +1439,11 @@ export default {
|
||||
appLayoutSeparatedDescription: "面板分隔更清晰,标签页为块状样式。",
|
||||
appLayoutClassic: "经典紧凑",
|
||||
appLayoutClassicDescription: "使用连续标签栏和更紧凑的面板标题。",
|
||||
iconTheme: "图标与 Logo 样式",
|
||||
iconThemeDefault: "默认",
|
||||
iconThemeDefaultDescription: "使用彩色 DBX 应用图标。",
|
||||
iconThemeBlack: "黑色",
|
||||
iconThemeBlackDescription: "窗口、托盘和 Logo 都使用黑色 DBX 标识。",
|
||||
showTrayIcon: "显示系统托盘/菜单栏图标",
|
||||
showTrayIconDescription: "关闭后不显示图标,但关闭窗口仍会像之前一样隐藏到后台。",
|
||||
dataGridDisplay: "数据表格显示",
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { CellValue } from "@/lib/cellValue";
|
||||
import { isTauriRuntime } from "@/lib/tauriRuntime";
|
||||
|
||||
export type BinaryCellDownloadMode = "binary" | "utf8" | "gbk";
|
||||
|
||||
export interface BinaryCellDownloadPayload {
|
||||
data: Uint8Array | string;
|
||||
mimeType: string;
|
||||
extension: string;
|
||||
}
|
||||
|
||||
export interface BinaryCellDownloadResult {
|
||||
kind: "saved" | "browser-download" | "cancelled";
|
||||
path?: string;
|
||||
fileName?: string;
|
||||
}
|
||||
|
||||
export const BINARY_CELL_DOWNLOAD_MODES: BinaryCellDownloadMode[] = ["binary", "utf8", "gbk"];
|
||||
|
||||
const HEX_VALUE_RE = /^(?:0[xX]|\\x)([0-9a-fA-F\s]+)$/;
|
||||
const BINARY_TYPE_RE =
|
||||
/^(?:blob|tinyblob|mediumblob|longblob|bytea|bytes|binary|varbinary|image|raw|long\s+raw)(?:\b|\()/i;
|
||||
|
||||
export function parseBinaryCellHexValue(value: CellValue): Uint8Array | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const match = value.trim().match(HEX_VALUE_RE);
|
||||
if (!match) return null;
|
||||
|
||||
const hex = match[1].replace(/\s+/g, "");
|
||||
if (!hex || hex.length % 2 !== 0) return null;
|
||||
|
||||
const bytes = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
const parsed = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
||||
if (Number.isNaN(parsed)) return null;
|
||||
bytes[i] = parsed;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export function isBinaryCellColumnType(columnType?: string): boolean {
|
||||
const type = (columnType ?? "").trim();
|
||||
return !!type && BINARY_TYPE_RE.test(type);
|
||||
}
|
||||
|
||||
export function canDownloadBinaryCellValue(value: CellValue, columnType?: string): boolean {
|
||||
if (!parseBinaryCellHexValue(value)) return false;
|
||||
return isBinaryCellColumnType(columnType) || typeof value === "string";
|
||||
}
|
||||
|
||||
export function binaryCellDownloadPayload(value: CellValue, mode: BinaryCellDownloadMode): BinaryCellDownloadPayload {
|
||||
const bytes = parseBinaryCellHexValue(value);
|
||||
if (!bytes) {
|
||||
throw new Error("Cell value is not a hexadecimal binary value.");
|
||||
}
|
||||
|
||||
if (mode === "binary") {
|
||||
return {
|
||||
data: bytes,
|
||||
mimeType: "application/octet-stream",
|
||||
extension: "bin",
|
||||
};
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder(mode === "gbk" ? "gbk" : "utf-8", { fatal: false });
|
||||
return {
|
||||
data: decoder.decode(bytes),
|
||||
mimeType: "text/plain;charset=utf-8",
|
||||
extension: "txt",
|
||||
};
|
||||
}
|
||||
|
||||
export function binaryCellDownloadFileName(options: {
|
||||
column: string;
|
||||
rowNumber: number;
|
||||
mode: BinaryCellDownloadMode;
|
||||
extension: string;
|
||||
}): string {
|
||||
const column = options.column
|
||||
.trim()
|
||||
.replace(/[\\/:*?"<>|]+/g, "-")
|
||||
.replace(/\s+/g, "_")
|
||||
.slice(0, 48);
|
||||
const safeColumn = column || "cell";
|
||||
const suffix = options.mode === "binary" ? "" : `-${options.mode}`;
|
||||
return `${safeColumn}-row-${options.rowNumber}${suffix}.${options.extension}`;
|
||||
}
|
||||
|
||||
export async function downloadBinaryCellPayload(
|
||||
payload: BinaryCellDownloadPayload,
|
||||
fileName: string,
|
||||
): Promise<BinaryCellDownloadResult> {
|
||||
if (isTauriRuntime()) {
|
||||
const [{ save }, fs] = await Promise.all([import("@tauri-apps/plugin-dialog"), import("@tauri-apps/plugin-fs")]);
|
||||
const path = await save({
|
||||
defaultPath: fileName,
|
||||
filters: [{ name: payload.extension.toUpperCase(), extensions: [payload.extension] }],
|
||||
});
|
||||
if (!path) return { kind: "cancelled" };
|
||||
if (typeof payload.data === "string") {
|
||||
await fs.writeTextFile(path, payload.data);
|
||||
} else {
|
||||
await fs.writeFile(path, payload.data);
|
||||
}
|
||||
return { kind: "saved", path };
|
||||
}
|
||||
|
||||
const blob = new Blob([payload.data], { type: payload.mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = fileName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
return { kind: "browser-download", fileName };
|
||||
}
|
||||
@@ -789,7 +789,7 @@ export async function loadAiConfig(): Promise<AiConfig | null> {
|
||||
}
|
||||
|
||||
export async function loadDesktopSettings(): Promise<DesktopSettings> {
|
||||
return { show_tray_icon: true };
|
||||
return { show_tray_icon: true, icon_theme: "default" };
|
||||
}
|
||||
|
||||
export async function saveDesktopSettings(_settings: DesktopSettings): Promise<void> {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ObjectInfo } from "@/types/database";
|
||||
import { normalizeDatabaseObjectName } from "@/lib/tableTree";
|
||||
import { createDatabaseObjectNameComparator, normalizeDatabaseObjectName } from "@/lib/tableTree";
|
||||
|
||||
export type ObjectBrowserRow = {
|
||||
id: string;
|
||||
@@ -111,10 +111,14 @@ export function sortObjectBrowserRows(
|
||||
direction: ObjectBrowserSortDirection,
|
||||
): ObjectBrowserRow[] {
|
||||
const multiplier = direction === "asc" ? 1 : -1;
|
||||
const compareNames = createDatabaseObjectNameComparator(rows.map((row) => row.name));
|
||||
return [...rows].sort((left, right) => {
|
||||
const compared = compareObjectBrowserValue(left[key], right[key], key, direction);
|
||||
const compared =
|
||||
key === "name"
|
||||
? compareNames(left.name, right.name)
|
||||
: compareObjectBrowserValue(left[key], right[key], key, direction);
|
||||
if (compared !== 0) return compared * multiplier;
|
||||
return left.name.localeCompare(right.name, undefined, { numeric: true, sensitivity: "base" });
|
||||
return compareNames(left.name, right.name);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import type { QueryTab } from "@/types/database";
|
||||
|
||||
type Translate = (key: string, params?: Record<string, unknown>) => string;
|
||||
|
||||
export function connectionDisplayName(connectionId: string): string {
|
||||
const connectionStore = useConnectionStore();
|
||||
return connectionStore.getConfig(connectionId)?.name || connectionId;
|
||||
@@ -13,28 +14,21 @@ export function connectionColor(connectionId: string): string {
|
||||
return connectionStore.getConfig(connectionId)?.color || "";
|
||||
}
|
||||
|
||||
export function databaseDisplayNameForTab(connectionId: string, database: string): string {
|
||||
const { t } = useI18n();
|
||||
export function databaseDisplayNameForTab(connectionId: string, database: string, t: Translate): string {
|
||||
const connectionStore = useConnectionStore();
|
||||
const connection = connectionStore.getConfig(connectionId);
|
||||
if (connection?.db_type === "redis" && database !== "") return `db${database}`;
|
||||
return database || t("editor.noDatabase");
|
||||
}
|
||||
|
||||
export function tabGroupLabel(tab: Pick<QueryTab, "connectionId" | "database">): string {
|
||||
const connection = connectionDisplayName(tab.connectionId);
|
||||
const database = databaseDisplayNameForTab(tab.connectionId, tab.database);
|
||||
return database ? `${connection} / ${database}` : connection;
|
||||
}
|
||||
|
||||
export function isPreviewTab(tab: QueryTab): boolean {
|
||||
const connectionStore = useConnectionStore();
|
||||
const config = connectionStore.getConfig(tab.connectionId);
|
||||
return !!config?.name.startsWith("[Preview]");
|
||||
}
|
||||
|
||||
export function tabDisplayTitle(tab: QueryTab): string {
|
||||
const database = databaseDisplayNameForTab(tab.connectionId, tab.database);
|
||||
export function tabDisplayTitle(tab: QueryTab, t: Translate): string {
|
||||
const database = databaseDisplayNameForTab(tab.connectionId, tab.database, t);
|
||||
const settingsStore = useSettingsStore();
|
||||
const compact = settingsStore.editorSettings.compactTabTitle;
|
||||
if (isPreviewTab(tab)) return tab.title;
|
||||
@@ -66,10 +60,9 @@ export function tabDisplayTitle(tab: QueryTab): string {
|
||||
return tab.title;
|
||||
}
|
||||
|
||||
export function tabTooltipLines(tab: QueryTab): { label: string; value: string }[] {
|
||||
const { t } = useI18n();
|
||||
export function tabTooltipLines(tab: QueryTab, t: Translate): { label: string; value: string }[] {
|
||||
const connName = connectionDisplayName(tab.connectionId);
|
||||
const database = databaseDisplayNameForTab(tab.connectionId, tab.database);
|
||||
const database = databaseDisplayNameForTab(tab.connectionId, tab.database, t);
|
||||
const lines: { label: string; value: string }[] = [
|
||||
{ label: t("tabs.tooltipConnection"), value: connName },
|
||||
{ label: t("tabs.tooltipDatabase"), value: database },
|
||||
@@ -95,8 +88,7 @@ export function shouldShowTabOverflowControls(
|
||||
return tabCount > 0 && (hasTabOverflow || canScrollLeft || canScrollRight);
|
||||
}
|
||||
|
||||
export function tabModeLabel(tab: QueryTab): string {
|
||||
const { t } = useI18n();
|
||||
export function tabModeLabel(tab: QueryTab, t: Translate): string {
|
||||
if (tab.mode === "data") return t("tabs.table");
|
||||
if (tab.mode === "query") return t("tabs.sql");
|
||||
if (tab.mode === "mongo") return t("tabs.mongo");
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { ObjectInfo, TableInfo, TreeNode, TreeNodeType } from "@/types/database";
|
||||
|
||||
const databaseObjectNameCollator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
|
||||
|
||||
export function normalizeDatabaseObjectName(name: string): string {
|
||||
return name.trim();
|
||||
}
|
||||
@@ -102,15 +104,120 @@ function objectIdentityKey(objectType: string, schema: string | undefined, name:
|
||||
return `${objectType}\0${(schema || "").toLowerCase()}\0${name.toLowerCase()}`;
|
||||
}
|
||||
|
||||
type PrefixSortInfo = {
|
||||
rootName: string;
|
||||
leadingSegments: number;
|
||||
};
|
||||
|
||||
function nameSegments(name: string): string[] {
|
||||
return name.toLowerCase().split("_").filter(Boolean);
|
||||
}
|
||||
|
||||
function findSubsequence(haystack: readonly string[], needle: readonly string[]): number {
|
||||
if (!needle.length || needle.length > haystack.length) return -1;
|
||||
|
||||
for (let start = 0; start <= haystack.length - needle.length; start += 1) {
|
||||
let matched = true;
|
||||
for (let offset = 0; offset < needle.length; offset += 1) {
|
||||
if (haystack[start + offset] !== needle[offset]) {
|
||||
matched = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (matched) return start;
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
function buildPrefixSortIndex(names: readonly string[]): Map<string, PrefixSortInfo> {
|
||||
const segmentsByName = new Map<string, string[]>();
|
||||
const nameBySegmentKey = new Map<string, string>();
|
||||
for (const name of names) {
|
||||
const normalized = name.toLowerCase();
|
||||
if (segmentsByName.has(normalized)) continue;
|
||||
|
||||
const segments = nameSegments(name);
|
||||
segmentsByName.set(normalized, segments);
|
||||
const segmentKey = segments.join("_");
|
||||
if (!nameBySegmentKey.has(segmentKey)) nameBySegmentKey.set(segmentKey, normalized);
|
||||
}
|
||||
|
||||
const resolved = new Map<string, PrefixSortInfo>();
|
||||
|
||||
const resolve = (name: string): PrefixSortInfo => {
|
||||
const cached = resolved.get(name);
|
||||
if (cached) return cached;
|
||||
|
||||
const segments = segmentsByName.get(name) ?? [];
|
||||
let bestAnchor: { name: string; segmentCount: number; start: number } | undefined;
|
||||
|
||||
for (let segmentCount = segments.length - 1; segmentCount > 0 && !bestAnchor; segmentCount -= 1) {
|
||||
for (let start = 0; start <= segments.length - segmentCount; start += 1) {
|
||||
const candidateName = nameBySegmentKey.get(segments.slice(start, start + segmentCount).join("_"));
|
||||
if (!candidateName || candidateName === name) continue;
|
||||
bestAnchor = { name: candidateName, segmentCount, start };
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!bestAnchor) {
|
||||
const info = { rootName: name, leadingSegments: 0 };
|
||||
resolved.set(name, info);
|
||||
return info;
|
||||
}
|
||||
|
||||
const anchor = resolve(bestAnchor.name);
|
||||
const rootSegments = segmentsByName.get(anchor.rootName) ?? [];
|
||||
const rootStart = findSubsequence(segments, rootSegments);
|
||||
const info = {
|
||||
rootName: anchor.rootName,
|
||||
leadingSegments: rootStart >= 0 ? rootStart : bestAnchor.start + anchor.leadingSegments,
|
||||
};
|
||||
resolved.set(name, info);
|
||||
return info;
|
||||
};
|
||||
|
||||
for (const name of segmentsByName.keys()) resolve(name);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function comparePrefixPriorityNames(left: string, right: string, index: ReadonlyMap<string, PrefixSortInfo>): number {
|
||||
const leftName = left.toLowerCase();
|
||||
const rightName = right.toLowerCase();
|
||||
const leftInfo = index.get(leftName) ?? { rootName: leftName, leadingSegments: 0 };
|
||||
const rightInfo = index.get(rightName) ?? { rootName: rightName, leadingSegments: 0 };
|
||||
|
||||
const rootCompared = databaseObjectNameCollator.compare(leftInfo.rootName, rightInfo.rootName);
|
||||
if (rootCompared !== 0) return rootCompared;
|
||||
|
||||
if (leftInfo.leadingSegments !== rightInfo.leadingSegments) {
|
||||
return leftInfo.leadingSegments - rightInfo.leadingSegments;
|
||||
}
|
||||
|
||||
return databaseObjectNameCollator.compare(left, right);
|
||||
}
|
||||
|
||||
export function createDatabaseObjectNameComparator(names: readonly string[]): (left: string, right: string) => number {
|
||||
const index = buildPrefixSortIndex(names);
|
||||
return (left, right) => comparePrefixPriorityNames(left, right, index);
|
||||
}
|
||||
|
||||
export function sortDatabaseObjectsByPrefixPriority<T>(items: readonly T[], getName: (item: T) => string): T[] {
|
||||
const compareNames = createDatabaseObjectNameComparator(items.map(getName));
|
||||
return [...items].sort((left, right) => compareNames(getName(left), getName(right)));
|
||||
}
|
||||
|
||||
function buildPartitionTree(entries: TableTreeEntry[], connectionId: string, database: string): TreeNode[] {
|
||||
const orderedEntries = sortDatabaseObjectsByPrefixPriority(entries, (entry) => entry.node.label);
|
||||
const byKey = new Map<string, TableTreeEntry>();
|
||||
for (const entry of entries) {
|
||||
for (const entry of orderedEntries) {
|
||||
byKey.set(entry.key, entry);
|
||||
}
|
||||
|
||||
const childrenByParent = new Map<string, TableTreeEntry[]>();
|
||||
const childKeys = new Set<string>();
|
||||
for (const entry of entries) {
|
||||
for (const entry of orderedEntries) {
|
||||
if (entry.objectType !== "TABLE" || !entry.parentName) continue;
|
||||
const parentSchema = entry.parentSchema || entry.schema;
|
||||
const parentKey = objectIdentityKey("TABLE", parentSchema, entry.parentName);
|
||||
@@ -143,7 +250,7 @@ function buildPartitionTree(entries: TableTreeEntry[], connectionId: string, dat
|
||||
return entry.node;
|
||||
};
|
||||
|
||||
return entries.filter((entry) => !childKeys.has(entry.key)).map(materialize);
|
||||
return orderedEntries.filter((entry) => !childKeys.has(entry.key)).map(materialize);
|
||||
}
|
||||
|
||||
function partitionGroupChildren(node: TreeNode): TreeNode[] {
|
||||
@@ -244,7 +351,10 @@ export function buildSimpleObjectTreeNodes({
|
||||
}
|
||||
}
|
||||
|
||||
return [...buildPartitionTree(tableEntries, connectionId, database), ...viewNodes];
|
||||
return [
|
||||
...buildPartitionTree(tableEntries, connectionId, database),
|
||||
...sortDatabaseObjectsByPrefixPriority(viewNodes, (node) => node.label),
|
||||
];
|
||||
}
|
||||
|
||||
function normalizeObjectType(type: string): "TABLE" | "VIEW" | "PROCEDURE" | "FUNCTION" {
|
||||
@@ -336,7 +446,7 @@ export function buildGroupedObjectTreeNodes({
|
||||
objects: items,
|
||||
objectType: def.objectType as "TABLE" | "VIEW",
|
||||
})
|
||||
: items.map((obj) => {
|
||||
: sortDatabaseObjectsByPrefixPriority(items, (obj) => obj.name).map((obj) => {
|
||||
const childSchema = obj.schema ? normalizeDatabaseObjectName(obj.schema) : schema;
|
||||
return {
|
||||
id: `${nodeId}:${def.key}:${childSchema ? `${childSchema}:` : ""}${obj.name}`,
|
||||
|
||||
@@ -97,6 +97,7 @@ export interface DriverStoreUsage {
|
||||
|
||||
export interface DesktopSettings {
|
||||
show_tray_icon: boolean;
|
||||
icon_theme: "default" | "black";
|
||||
}
|
||||
|
||||
export interface WebDavConfig {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { defineStore } from "pinia";
|
||||
import { uuid } from "@/lib/utils";
|
||||
import { ref, watch, computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { DatabaseType, QueryTab } from "@/types/database";
|
||||
import type { DatabaseType, QueryResult, QueryTab } from "@/types/database";
|
||||
import { orderPinnedFirst } from "@/lib/pinnedItems";
|
||||
import { canCancelQueryExecution } from "@/lib/queryExecutionState";
|
||||
import { closeAllTabsState, closeOtherTabsState } from "@/lib/tabCloseActions";
|
||||
@@ -91,6 +91,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
tab.queryAnalysis = undefined;
|
||||
tab.querySourceColumns = undefined;
|
||||
tab.queryEditabilityReason = undefined;
|
||||
if (tab.mode === "query") tab.tableMeta = undefined;
|
||||
tab.resultEvicted = options.evicted ? true : undefined;
|
||||
}
|
||||
|
||||
@@ -266,20 +267,28 @@ export const useQueryStore = defineStore("query", () => {
|
||||
}
|
||||
|
||||
function closeOtherTabs(id: string) {
|
||||
tabs.value.filter((tab) => tab.id !== id && tab.isExecuting).forEach((tab) => void cancelTabExecution(tab.id));
|
||||
tabs.value.filter((tab) => tab.id !== id && tab.isExplaining).forEach((tab) => void cancelTabExplain(tab.id));
|
||||
tabs.value.filter((tab) => tab.id !== id).forEach((tab) => void closeResultSession(tab));
|
||||
tabs.value.filter((tab) => tab.id !== id).forEach((tab) => void closeClientConnectionSession(tab));
|
||||
tabs.value
|
||||
.filter((tab) => tab.id !== id)
|
||||
.forEach((tab) => {
|
||||
if (tab.isExecuting) void cancelTabExecution(tab.id);
|
||||
if (tab.isExplaining) void cancelTabExplain(tab.id);
|
||||
void closeResultSession(tab);
|
||||
void closeClientConnectionSession(tab);
|
||||
clearResultPayload(tab);
|
||||
});
|
||||
const next = closeOtherTabsState(tabs.value, activeTabId.value, id);
|
||||
tabs.value = next.tabs;
|
||||
activeTabId.value = next.activeTabId;
|
||||
}
|
||||
|
||||
function closeAllTabs() {
|
||||
tabs.value.filter((tab) => tab.isExecuting).forEach((tab) => void cancelTabExecution(tab.id));
|
||||
tabs.value.filter((tab) => tab.isExplaining).forEach((tab) => void cancelTabExplain(tab.id));
|
||||
tabs.value.forEach((tab) => void closeResultSession(tab));
|
||||
tabs.value.forEach((tab) => void closeClientConnectionSession(tab));
|
||||
tabs.value.forEach((tab) => {
|
||||
if (tab.isExecuting) void cancelTabExecution(tab.id);
|
||||
if (tab.isExplaining) void cancelTabExplain(tab.id);
|
||||
void closeResultSession(tab);
|
||||
void closeClientConnectionSession(tab);
|
||||
clearResultPayload(tab);
|
||||
});
|
||||
const next = closeAllTabsState(tabs.value, activeTabId.value);
|
||||
tabs.value = next.tabs;
|
||||
activeTabId.value = next.activeTabId;
|
||||
@@ -439,34 +448,59 @@ export const useQueryStore = defineStore("query", () => {
|
||||
await executeTabSql(activeTabId.value, sql, { resultBaseSql: sql, resultSortedSql: undefined });
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze query metadata for result tooltips and editability.
|
||||
*/
|
||||
async function analyzeQueryMetadata(tab: QueryTab, sql: string) {
|
||||
type QueryMetadataPatch = Pick<
|
||||
QueryTab,
|
||||
"queryAnalysis" | "querySourceColumns" | "queryEditabilityReason" | "tableMeta"
|
||||
>;
|
||||
|
||||
function applyQueryMetadataPatch(tab: QueryTab, patch: QueryMetadataPatch) {
|
||||
tab.queryAnalysis = patch.queryAnalysis;
|
||||
tab.querySourceColumns = patch.querySourceColumns;
|
||||
tab.queryEditabilityReason = patch.queryEditabilityReason;
|
||||
tab.tableMeta = patch.tableMeta;
|
||||
}
|
||||
|
||||
async function buildQueryMetadataPatch(
|
||||
tab: QueryTab,
|
||||
sql: string,
|
||||
traceId?: string,
|
||||
elapsed?: () => string,
|
||||
): Promise<QueryMetadataPatch | undefined> {
|
||||
if (tab.mode !== "query") return;
|
||||
if (!tab.result || !tab.result.columns.length) {
|
||||
tab.queryAnalysis = undefined;
|
||||
tab.querySourceColumns = undefined;
|
||||
tab.tableMeta = undefined;
|
||||
return;
|
||||
return {
|
||||
queryAnalysis: undefined,
|
||||
querySourceColumns: undefined,
|
||||
queryEditabilityReason: undefined,
|
||||
tableMeta: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
console.info("[DBX][executeTabSql:metadata:editability:start]", { traceId, elapsed: elapsed?.() });
|
||||
const editability = await api.analyzeEditableQueryEditability(sql);
|
||||
console.info("[DBX][executeTabSql:metadata:editability:done]", {
|
||||
traceId,
|
||||
editable: editability.editable,
|
||||
reason: editability.editable ? undefined : editability.reason,
|
||||
elapsed: elapsed?.(),
|
||||
});
|
||||
if (!editability.editable) {
|
||||
tab.queryAnalysis = undefined;
|
||||
tab.querySourceColumns = undefined;
|
||||
tab.queryEditabilityReason = editability.reason;
|
||||
tab.tableMeta = undefined;
|
||||
return;
|
||||
return {
|
||||
queryAnalysis: undefined,
|
||||
querySourceColumns: undefined,
|
||||
queryEditabilityReason: editability.reason,
|
||||
tableMeta: undefined,
|
||||
};
|
||||
}
|
||||
const analysis = editability.analysis;
|
||||
|
||||
if (!tab.connectionId || !tab.database) {
|
||||
tab.queryAnalysis = undefined;
|
||||
tab.querySourceColumns = undefined;
|
||||
tab.queryEditabilityReason = "metadata-unavailable";
|
||||
tab.tableMeta = undefined;
|
||||
return;
|
||||
return {
|
||||
queryAnalysis: undefined,
|
||||
querySourceColumns: undefined,
|
||||
queryEditabilityReason: "metadata-unavailable",
|
||||
tableMeta: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Resolve schema per database type
|
||||
@@ -480,10 +514,20 @@ export const useQueryStore = defineStore("query", () => {
|
||||
}
|
||||
|
||||
try {
|
||||
console.info("[DBX][executeTabSql:metadata:get-columns:start]", {
|
||||
traceId,
|
||||
schema,
|
||||
table: analysis.tableName,
|
||||
elapsed: elapsed?.(),
|
||||
});
|
||||
const columns = await api.getColumns(tab.connectionId, tab.database, schema, analysis.tableName);
|
||||
console.info("[DBX][executeTabSql:metadata:get-columns:done]", {
|
||||
traceId,
|
||||
columnCount: columns.length,
|
||||
elapsed: elapsed?.(),
|
||||
});
|
||||
const primaryKeys = editablePrimaryKeys(dbType as DatabaseType, columns);
|
||||
|
||||
tab.tableMeta = {
|
||||
const tableMeta = {
|
||||
schema: schema || undefined,
|
||||
tableName: analysis.tableName,
|
||||
columns,
|
||||
@@ -491,38 +535,71 @@ export const useQueryStore = defineStore("query", () => {
|
||||
};
|
||||
|
||||
if (primaryKeys.length === 0) {
|
||||
tab.queryAnalysis = undefined;
|
||||
tab.querySourceColumns = undefined;
|
||||
tab.queryEditabilityReason = "no-primary-key";
|
||||
return;
|
||||
return {
|
||||
queryAnalysis: undefined,
|
||||
querySourceColumns: undefined,
|
||||
queryEditabilityReason: "no-primary-key",
|
||||
tableMeta,
|
||||
};
|
||||
}
|
||||
|
||||
if (!allPrimaryKeysPresent(primaryKeys, tab.result.columns, analysis)) {
|
||||
tab.queryAnalysis = undefined;
|
||||
tab.querySourceColumns = undefined;
|
||||
tab.queryEditabilityReason = "primary-key-not-returned";
|
||||
return;
|
||||
return {
|
||||
queryAnalysis: undefined,
|
||||
querySourceColumns: undefined,
|
||||
queryEditabilityReason: "primary-key-not-returned",
|
||||
tableMeta,
|
||||
};
|
||||
}
|
||||
|
||||
if (!allEditableColumnsWriteable(analysis, tab.result.columns)) {
|
||||
tab.queryAnalysis = undefined;
|
||||
tab.querySourceColumns = undefined;
|
||||
tab.queryEditabilityReason = "aliased-columns";
|
||||
return;
|
||||
return {
|
||||
queryAnalysis: undefined,
|
||||
querySourceColumns: undefined,
|
||||
queryEditabilityReason: "aliased-columns",
|
||||
tableMeta,
|
||||
};
|
||||
}
|
||||
|
||||
tab.queryAnalysis = analysis;
|
||||
tab.querySourceColumns = sourceColumnsForResult(analysis, tab.result.columns);
|
||||
tab.queryEditabilityReason = undefined;
|
||||
return {
|
||||
queryAnalysis: analysis,
|
||||
querySourceColumns: sourceColumnsForResult(analysis, tab.result.columns),
|
||||
queryEditabilityReason: undefined,
|
||||
tableMeta,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error("[DBX] ERROR fetching columns for query metadata:", err);
|
||||
tab.queryAnalysis = undefined;
|
||||
tab.querySourceColumns = undefined;
|
||||
tab.queryEditabilityReason = "metadata-unavailable";
|
||||
tab.tableMeta = undefined;
|
||||
return {
|
||||
queryAnalysis: undefined,
|
||||
querySourceColumns: undefined,
|
||||
queryEditabilityReason: "metadata-unavailable",
|
||||
tableMeta: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function analyzeQueryMetadataInBackground(
|
||||
tabId: string,
|
||||
sql: string,
|
||||
result: QueryResult,
|
||||
traceId: string,
|
||||
elapsed: () => string,
|
||||
) {
|
||||
void (async () => {
|
||||
const tab = tabs.value.find((t) => t.id === tabId);
|
||||
if (!tab || tab.result !== result) return;
|
||||
console.info("[DBX][executeTabSql:metadata:start]", { traceId, elapsed: elapsed() });
|
||||
const patch = await buildQueryMetadataPatch(tab, sql, traceId, elapsed);
|
||||
const current = tabs.value.find((t) => t.id === tabId);
|
||||
if (patch && current?.result === result) {
|
||||
applyQueryMetadataPatch(current, patch);
|
||||
console.info("[DBX][executeTabSql:metadata:done]", { traceId, elapsed: elapsed() });
|
||||
} else {
|
||||
console.warn("[DBX][executeTabSql:metadata:stale]", { traceId, elapsed: elapsed() });
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
async function executeTabSql(
|
||||
id: string,
|
||||
sql: string,
|
||||
@@ -545,6 +622,8 @@ export const useQueryStore = defineStore("query", () => {
|
||||
tab.executionId = executionId;
|
||||
tab.lastExecutedSql = sql;
|
||||
tab.resultTotalRowCount = undefined;
|
||||
const previousResultSessionClose = closeResultSession(tab, options?.pagination?.sessionId);
|
||||
clearResultPayload(tab);
|
||||
console.info("[DBX][executeTabSql:start]", {
|
||||
traceId,
|
||||
tabId: id,
|
||||
@@ -573,7 +652,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
? conn.query_timeout_secs
|
||||
: 30;
|
||||
const settingsStore = useSettingsStore();
|
||||
await closeResultSession(tab, options?.pagination?.sessionId);
|
||||
await previousResultSessionClose;
|
||||
if (tab.mode === "query") {
|
||||
const pagination = options?.pagination ?? { limit: settingsStore.editorSettings.pageSize, offset: 0 };
|
||||
const plan = await api.prepareQueryPaginationExecutionPlan({
|
||||
@@ -694,6 +773,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
}
|
||||
|
||||
console.info("[DBX][executeTabSql:execute-multi:start]", { traceId, elapsed: elapsed() });
|
||||
const clientSessionId = useAgentResultSession ? tab.id : undefined;
|
||||
const executionOptions = {
|
||||
...(typeof pageLimit === "number"
|
||||
? useAgentResultSession
|
||||
@@ -705,13 +785,14 @@ export const useQueryStore = defineStore("query", () => {
|
||||
}
|
||||
: { maxRows: pageLimit, fetchSize: pageLimit }
|
||||
: {}),
|
||||
clientSessionId: tab.id,
|
||||
...(clientSessionId ? { clientSessionId } : {}),
|
||||
timeoutSecs: queryTimeoutSecs,
|
||||
};
|
||||
const frontendTimeoutSecs = Math.max(queryTimeoutSecs * 2, 60);
|
||||
const timeoutError = new Error(t("editor.queryTimeoutError", { seconds: frontendTimeoutSecs }));
|
||||
const executionSchema = tab.mode === "data" ? undefined : tab.schema;
|
||||
const results = await Promise.race([
|
||||
api.executeMulti(tab.connectionId, tab.database, sqlToExecute, tab.schema, executionId, executionOptions),
|
||||
api.executeMulti(tab.connectionId, tab.database, sqlToExecute, executionSchema, executionId, executionOptions),
|
||||
new Promise<never>((_, reject) => setTimeout(() => reject(timeoutError), frontendTimeoutSecs * 1000)),
|
||||
]);
|
||||
console.info("[DBX][executeTabSql:execute-multi:done]", {
|
||||
@@ -739,6 +820,14 @@ export const useQueryStore = defineStore("query", () => {
|
||||
current.resultPageOffset = pageOffset;
|
||||
current.resultCountSql = countSql;
|
||||
current.resultSessionId = current.result?.session_id ?? undefined;
|
||||
console.info("[DBX][executeTabSql:result:assigned]", {
|
||||
traceId,
|
||||
activeResultIndex: current.activeResultIndex,
|
||||
rowCount: current.result?.rows.length ?? 0,
|
||||
columnCount: current.result?.columns.length ?? 0,
|
||||
backendMs: current.result?.execution_time_ms,
|
||||
elapsed: elapsed(),
|
||||
});
|
||||
if (countSql && current.result?.rows.length) {
|
||||
// When the result set is smaller than the page size we already have
|
||||
// all rows — compute the total directly instead of running COUNT(*).
|
||||
@@ -768,9 +857,8 @@ export const useQueryStore = defineStore("query", () => {
|
||||
});
|
||||
}
|
||||
}
|
||||
console.info("[DBX][executeTabSql:metadata:start]", { traceId, elapsed: elapsed() });
|
||||
await analyzeQueryMetadata(current, queryBaseSql);
|
||||
console.info("[DBX][executeTabSql:metadata:done]", { traceId, elapsed: elapsed() });
|
||||
if (current.mode === "query" && current.result)
|
||||
analyzeQueryMetadataInBackground(id, queryBaseSql, current.result, traceId, elapsed);
|
||||
} else {
|
||||
console.warn("[DBX][executeTabSql:stale-result]", {
|
||||
traceId,
|
||||
@@ -841,7 +929,6 @@ export const useQueryStore = defineStore("query", () => {
|
||||
tab.lastExplainedSql = sql;
|
||||
try {
|
||||
const result = await api.executeQuery(tab.connectionId, tab.database, built.sql, tab.schema, executionId, {
|
||||
clientSessionId: tab.id,
|
||||
timeoutSecs: queryTimeoutSecs,
|
||||
});
|
||||
const current = tabs.value.find((t) => t.id === id);
|
||||
|
||||
@@ -38,12 +38,24 @@ export interface AiConfig {
|
||||
|
||||
export interface DesktopSettings {
|
||||
show_tray_icon: boolean;
|
||||
icon_theme: DesktopIconTheme;
|
||||
}
|
||||
|
||||
export type DesktopIconTheme = "default" | "black";
|
||||
|
||||
export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
|
||||
show_tray_icon: true,
|
||||
icon_theme: "default",
|
||||
};
|
||||
|
||||
function normalizeDesktopSettings(settings: Partial<DesktopSettings> | null | undefined): DesktopSettings {
|
||||
const iconTheme = settings?.icon_theme === "black" ? "black" : DEFAULT_DESKTOP_SETTINGS.icon_theme;
|
||||
return {
|
||||
show_tray_icon: settings?.show_tray_icon ?? DEFAULT_DESKTOP_SETTINGS.show_tray_icon,
|
||||
icon_theme: iconTheme,
|
||||
};
|
||||
}
|
||||
|
||||
export interface AiProviderPreset extends Omit<AiConfig, "apiKey"> {
|
||||
label: string;
|
||||
iconSlug?: string;
|
||||
@@ -419,7 +431,7 @@ export const useSettingsStore = defineStore("settings", () => {
|
||||
|
||||
async function initDesktopSettings() {
|
||||
if (isDesktopSettingsLoaded.value) return;
|
||||
desktopSettings.value = await api.loadDesktopSettings().catch(() => ({ ...DEFAULT_DESKTOP_SETTINGS }));
|
||||
desktopSettings.value = normalizeDesktopSettings(await api.loadDesktopSettings().catch(() => null));
|
||||
isDesktopSettingsLoaded.value = true;
|
||||
}
|
||||
|
||||
@@ -429,9 +441,9 @@ export const useSettingsStore = defineStore("settings", () => {
|
||||
...desktopSettings.value,
|
||||
...partial,
|
||||
};
|
||||
desktopSettings.value = next;
|
||||
desktopSettings.value = normalizeDesktopSettings(next);
|
||||
try {
|
||||
await api.saveDesktopSettings(next);
|
||||
await api.saveDesktopSettings(desktopSettings.value);
|
||||
} catch (error) {
|
||||
desktopSettings.value = previous;
|
||||
throw error;
|
||||
|
||||
@@ -248,16 +248,30 @@ async fn execute_select_prepared(
|
||||
start: Instant,
|
||||
row_limit: usize,
|
||||
) -> Result<QueryResult, tokio_postgres::Error> {
|
||||
let prepared_start = Instant::now();
|
||||
let stmt = client.prepare_cached(sql).await?;
|
||||
log::info!(
|
||||
"[postgres][select:prepare_cached:done] elapsed_ms={} total_ms={}",
|
||||
prepared_start.elapsed().as_millis(),
|
||||
start.elapsed().as_millis()
|
||||
);
|
||||
let columns: Vec<String> = stmt.columns().iter().map(|c| c.name().to_string()).collect();
|
||||
let column_types: Vec<String> = stmt.columns().iter().map(|c| c.type_().name().to_string()).collect();
|
||||
|
||||
let params: Vec<&(dyn tokio_postgres::types::ToSql + Sync)> = Vec::new();
|
||||
let query_start = Instant::now();
|
||||
let stream = client.query_raw(&stmt, params).await?;
|
||||
log::info!(
|
||||
"[postgres][select:query_raw:done] elapsed_ms={} total_ms={} column_count={}",
|
||||
query_start.elapsed().as_millis(),
|
||||
start.elapsed().as_millis(),
|
||||
columns.len()
|
||||
);
|
||||
tokio::pin!(stream);
|
||||
let mut result_rows: Vec<Vec<serde_json::Value>> = Vec::new();
|
||||
let mut truncated = false;
|
||||
|
||||
let rows_start = Instant::now();
|
||||
while let Some(row_result) = stream.next().await {
|
||||
if result_rows.len() >= row_limit {
|
||||
truncated = true;
|
||||
@@ -270,6 +284,13 @@ async fn execute_select_prepared(
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
log::info!(
|
||||
"[postgres][select:rows:done] elapsed_ms={} total_ms={} row_count={} truncated={}",
|
||||
rows_start.elapsed().as_millis(),
|
||||
start.elapsed().as_millis(),
|
||||
result_rows.len(),
|
||||
truncated
|
||||
);
|
||||
|
||||
Ok(QueryResult {
|
||||
columns,
|
||||
@@ -950,13 +971,40 @@ pub async fn execute_query_with_schema_and_max_rows(
|
||||
sql: &str,
|
||||
max_rows: Option<usize>,
|
||||
) -> Result<QueryResult, String> {
|
||||
let start = Instant::now();
|
||||
let checkout_start = Instant::now();
|
||||
let client = pool.get().await.map_err(|e| e.to_string())?;
|
||||
log::info!(
|
||||
"[postgres][execute_with_schema:pool:done] elapsed_ms={} total_ms={} schema={}",
|
||||
checkout_start.elapsed().as_millis(),
|
||||
start.elapsed().as_millis(),
|
||||
schema
|
||||
);
|
||||
let set_schema_start = Instant::now();
|
||||
client.execute(&format!("SET search_path TO {}", pg_quote_ident(schema)), &[]).await.map_err(pg_error_to_string)?;
|
||||
log::info!(
|
||||
"[postgres][execute_with_schema:set-search-path:done] elapsed_ms={} total_ms={}",
|
||||
set_schema_start.elapsed().as_millis(),
|
||||
start.elapsed().as_millis()
|
||||
);
|
||||
|
||||
let query_start = Instant::now();
|
||||
let result = execute_query_with_max_rows_inner(&client, sql, max_rows).await;
|
||||
log::info!(
|
||||
"[postgres][execute_with_schema:query:done] elapsed_ms={} total_ms={} ok={}",
|
||||
query_start.elapsed().as_millis(),
|
||||
start.elapsed().as_millis(),
|
||||
result.is_ok()
|
||||
);
|
||||
|
||||
// Always reset search_path so the connection is clean when returned to the pool
|
||||
let reset_start = Instant::now();
|
||||
let _ = client.execute("RESET search_path", &[]).await;
|
||||
log::info!(
|
||||
"[postgres][execute_with_schema:reset-search-path:done] elapsed_ms={} total_ms={}",
|
||||
reset_start.elapsed().as_millis(),
|
||||
start.elapsed().as_millis()
|
||||
);
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ pub mod sql;
|
||||
pub mod sql_analysis;
|
||||
pub mod sql_dialect;
|
||||
pub mod sql_editability;
|
||||
pub mod sql_file_import;
|
||||
pub mod storage;
|
||||
pub mod table_import;
|
||||
pub mod table_structure_sql;
|
||||
|
||||
+402
-3
@@ -39,6 +39,20 @@ pub enum SqlFileStatementAction {
|
||||
Skip,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum SqlFileImportStatementKind {
|
||||
Execute,
|
||||
Skip,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SqlFileImportStatement {
|
||||
pub kind: SqlFileImportStatementKind,
|
||||
pub sql: String,
|
||||
pub source_sqls: Vec<String>,
|
||||
pub source_statement_count: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct SqlParsingOptions {
|
||||
pub supports_hash_line_comments: bool,
|
||||
@@ -685,6 +699,343 @@ pub fn prepare_sql_file_statement(
|
||||
SqlFileStatementAction::Execute(body.to_string())
|
||||
}
|
||||
|
||||
pub fn optimize_sql_file_import_statements(
|
||||
statements: &[String],
|
||||
db_type: Option<DatabaseType>,
|
||||
driver_profile: Option<&str>,
|
||||
) -> Vec<SqlFileImportStatement> {
|
||||
let mut optimized = Vec::new();
|
||||
let mut pending_insert: Option<PendingInsertBatch> = None;
|
||||
|
||||
for statement in statements {
|
||||
let action = db_type
|
||||
.as_ref()
|
||||
.map(|db_type| prepare_sql_file_statement(statement, db_type, driver_profile))
|
||||
.unwrap_or_else(|| SqlFileStatementAction::Execute(statement.trim().to_string()));
|
||||
|
||||
match action {
|
||||
SqlFileStatementAction::Skip => {
|
||||
flush_pending_insert(&mut optimized, &mut pending_insert);
|
||||
optimized.push(SqlFileImportStatement {
|
||||
kind: SqlFileImportStatementKind::Skip,
|
||||
sql: statement.trim().to_string(),
|
||||
source_sqls: vec![statement.trim().to_string()],
|
||||
source_statement_count: 1,
|
||||
});
|
||||
}
|
||||
SqlFileStatementAction::Execute(sql) => {
|
||||
let options = db_type.map(SqlParsingOptions::for_database_type).unwrap_or_default();
|
||||
if let Some(insert) = parse_mergeable_insert(&sql, options) {
|
||||
match pending_insert.as_mut() {
|
||||
Some(batch) if batch.can_accept(&insert) => batch.push(insert),
|
||||
Some(_) => {
|
||||
flush_pending_insert(&mut optimized, &mut pending_insert);
|
||||
pending_insert = Some(PendingInsertBatch::new(insert));
|
||||
}
|
||||
None => {
|
||||
pending_insert = Some(PendingInsertBatch::new(insert));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
flush_pending_insert(&mut optimized, &mut pending_insert);
|
||||
optimized.push(SqlFileImportStatement {
|
||||
kind: SqlFileImportStatementKind::Execute,
|
||||
sql: sql.clone(),
|
||||
source_sqls: vec![sql],
|
||||
source_statement_count: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flush_pending_insert(&mut optimized, &mut pending_insert);
|
||||
optimized
|
||||
}
|
||||
|
||||
fn flush_pending_insert(optimized: &mut Vec<SqlFileImportStatement>, pending_insert: &mut Option<PendingInsertBatch>) {
|
||||
if let Some(batch) = pending_insert.take() {
|
||||
optimized.push(SqlFileImportStatement {
|
||||
kind: SqlFileImportStatementKind::Execute,
|
||||
sql: batch.to_sql(),
|
||||
source_sqls: batch.source_sqls,
|
||||
source_statement_count: batch.source_statement_count,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const SQL_FILE_INSERT_BATCH_MAX_STATEMENTS: usize = 500;
|
||||
const SQL_FILE_INSERT_BATCH_MAX_BYTES: usize = 4 * 1024 * 1024;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct MergeableInsert {
|
||||
prefix: String,
|
||||
prefix_key: String,
|
||||
values: String,
|
||||
sql: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct PendingInsertBatch {
|
||||
prefix: String,
|
||||
prefix_key: String,
|
||||
values: Vec<String>,
|
||||
source_sqls: Vec<String>,
|
||||
source_statement_count: usize,
|
||||
byte_len: usize,
|
||||
}
|
||||
|
||||
impl PendingInsertBatch {
|
||||
fn new(insert: MergeableInsert) -> Self {
|
||||
let byte_len = insert.prefix.len() + insert.values.len() + 16;
|
||||
Self {
|
||||
prefix: insert.prefix,
|
||||
prefix_key: insert.prefix_key,
|
||||
values: vec![insert.values],
|
||||
source_sqls: vec![insert.sql],
|
||||
source_statement_count: 1,
|
||||
byte_len,
|
||||
}
|
||||
}
|
||||
|
||||
fn can_accept(&self, insert: &MergeableInsert) -> bool {
|
||||
self.prefix_key == insert.prefix_key
|
||||
&& self.source_statement_count < SQL_FILE_INSERT_BATCH_MAX_STATEMENTS
|
||||
&& self.byte_len + insert.values.len() + 3 <= SQL_FILE_INSERT_BATCH_MAX_BYTES
|
||||
}
|
||||
|
||||
fn push(&mut self, insert: MergeableInsert) {
|
||||
self.byte_len += insert.values.len() + 3;
|
||||
self.values.push(insert.values);
|
||||
self.source_sqls.push(insert.sql);
|
||||
self.source_statement_count += 1;
|
||||
}
|
||||
|
||||
fn to_sql(&self) -> String {
|
||||
if self.source_statement_count == 1 {
|
||||
return self.source_sqls.first().cloned().unwrap_or_default();
|
||||
}
|
||||
format!("{} VALUES\n{}", self.prefix, self.values.join(",\n"))
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_mergeable_insert(sql: &str, options: SqlParsingOptions) -> Option<MergeableInsert> {
|
||||
let executable = leading_executable_sql_with_options(sql, options).trim().trim_end_matches(';').trim();
|
||||
if !starts_with_keyword(executable, "insert") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let (values_start, values_end) = find_top_level_values_keyword(executable)?;
|
||||
let prefix_without_values = executable[..values_start].trim_end();
|
||||
let values = executable[values_end..].trim();
|
||||
let values = parse_insert_values_tail(values)?;
|
||||
|
||||
let prefix = prefix_without_values.to_string();
|
||||
Some(MergeableInsert {
|
||||
prefix_key: normalize_insert_prefix_key(&prefix),
|
||||
prefix,
|
||||
values,
|
||||
sql: executable.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn find_top_level_values_keyword(sql: &str) -> Option<(usize, usize)> {
|
||||
let mut scanner = SqlScanner::default();
|
||||
let mut depth = 0usize;
|
||||
|
||||
for (idx, ch) in sql.char_indices() {
|
||||
scanner.step(sql, idx, ch);
|
||||
if scanner.is_masked() {
|
||||
continue;
|
||||
}
|
||||
|
||||
match ch {
|
||||
'(' => depth += 1,
|
||||
')' => depth = depth.saturating_sub(1),
|
||||
_ if depth == 0 => {
|
||||
for keyword in ["values", "value"] {
|
||||
if keyword_at(sql, idx, keyword) {
|
||||
return Some((idx, idx + keyword.len()));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn parse_insert_values_tail(tail: &str) -> Option<String> {
|
||||
let tail = tail.trim().trim_end_matches(';').trim();
|
||||
if tail.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut scanner = SqlScanner::default();
|
||||
let mut depth = 0usize;
|
||||
let mut saw_tuple = false;
|
||||
let mut expecting_tuple = true;
|
||||
|
||||
for (idx, ch) in tail.char_indices() {
|
||||
scanner.step(tail, idx, ch);
|
||||
if scanner.is_masked() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if expecting_tuple {
|
||||
if ch.is_whitespace() || (saw_tuple && ch == ',') {
|
||||
continue;
|
||||
}
|
||||
if ch != '(' {
|
||||
return None;
|
||||
}
|
||||
expecting_tuple = false;
|
||||
saw_tuple = true;
|
||||
depth = 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
match ch {
|
||||
'(' => depth += 1,
|
||||
')' => {
|
||||
depth = depth.checked_sub(1)?;
|
||||
if depth == 0 {
|
||||
expecting_tuple = true;
|
||||
}
|
||||
}
|
||||
',' if depth == 0 => {}
|
||||
_ if depth == 0 && !ch.is_whitespace() => return None,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if saw_tuple && depth == 0 {
|
||||
Some(tail.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct SqlScanner {
|
||||
in_single_quote: bool,
|
||||
in_double_quote: bool,
|
||||
in_backtick: bool,
|
||||
in_line_comment: bool,
|
||||
in_block_comment: bool,
|
||||
dollar_quote_tag: Option<String>,
|
||||
previous: Option<char>,
|
||||
}
|
||||
|
||||
impl SqlScanner {
|
||||
fn step(&mut self, sql: &str, idx: usize, ch: char) {
|
||||
if let Some(tag) = self.dollar_quote_tag.clone() {
|
||||
if sql[idx..].starts_with(&tag) {
|
||||
self.dollar_quote_tag = None;
|
||||
}
|
||||
self.previous = Some(ch);
|
||||
return;
|
||||
}
|
||||
|
||||
let next = next_char_at(sql, idx + ch.len_utf8());
|
||||
if self.in_line_comment {
|
||||
if ch == '\n' {
|
||||
self.in_line_comment = false;
|
||||
}
|
||||
self.previous = Some(ch);
|
||||
return;
|
||||
}
|
||||
if self.in_block_comment {
|
||||
if self.previous == Some('*') && ch == '/' {
|
||||
self.in_block_comment = false;
|
||||
}
|
||||
self.previous = Some(ch);
|
||||
return;
|
||||
}
|
||||
|
||||
if !self.in_single_quote && !self.in_double_quote && !self.in_backtick {
|
||||
if ch == '-' && next == Some('-') {
|
||||
self.in_line_comment = true;
|
||||
} else if ch == '/' && next == Some('*') {
|
||||
self.in_block_comment = true;
|
||||
} else if let Some(tag) = dollar_quote_tag_at_str(sql, idx) {
|
||||
self.dollar_quote_tag = Some(tag);
|
||||
}
|
||||
}
|
||||
|
||||
match ch {
|
||||
'\'' if !self.in_double_quote && !self.in_backtick && self.previous != Some('\\') => {
|
||||
self.in_single_quote = !self.in_single_quote;
|
||||
}
|
||||
'"' if !self.in_single_quote && !self.in_backtick && self.previous != Some('\\') => {
|
||||
self.in_double_quote = !self.in_double_quote;
|
||||
}
|
||||
'`' if !self.in_single_quote && !self.in_double_quote => {
|
||||
self.in_backtick = !self.in_backtick;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
self.previous = Some(ch);
|
||||
}
|
||||
|
||||
fn is_masked(&self) -> bool {
|
||||
self.in_single_quote
|
||||
|| self.in_double_quote
|
||||
|| self.in_backtick
|
||||
|| self.in_line_comment
|
||||
|| self.in_block_comment
|
||||
|| self.dollar_quote_tag.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
fn keyword_at(sql: &str, idx: usize, keyword: &str) -> bool {
|
||||
let end = idx + keyword.len();
|
||||
sql.get(idx..end).is_some_and(|candidate| candidate.eq_ignore_ascii_case(keyword))
|
||||
&& sql[..idx].chars().next_back().is_none_or(|ch| !is_sql_ident_char(ch))
|
||||
&& sql.get(end..).and_then(|tail| tail.chars().next()).is_none_or(|ch| !is_sql_ident_char(ch))
|
||||
}
|
||||
|
||||
fn starts_with_keyword(sql: &str, keyword: &str) -> bool {
|
||||
sql.get(..keyword.len()).is_some_and(|candidate| candidate.eq_ignore_ascii_case(keyword))
|
||||
&& sql.get(keyword.len()..).and_then(|tail| tail.chars().next()).is_none_or(|ch| !is_sql_ident_char(ch))
|
||||
}
|
||||
|
||||
fn is_sql_ident_char(ch: char) -> bool {
|
||||
ch == '_' || ch == '$' || ch.is_ascii_alphanumeric()
|
||||
}
|
||||
|
||||
fn normalize_insert_prefix_key(prefix: &str) -> String {
|
||||
let mut scanner = SqlScanner::default();
|
||||
let mut key = String::with_capacity(prefix.len());
|
||||
let mut previous_space = false;
|
||||
|
||||
for (idx, ch) in prefix.char_indices() {
|
||||
scanner.step(prefix, idx, ch);
|
||||
if scanner.in_single_quote
|
||||
|| scanner.in_double_quote
|
||||
|| scanner.in_backtick
|
||||
|| scanner.dollar_quote_tag.is_some()
|
||||
{
|
||||
key.push(ch);
|
||||
previous_space = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ch.is_whitespace() {
|
||||
if !previous_space {
|
||||
key.push(' ');
|
||||
}
|
||||
previous_space = true;
|
||||
} else {
|
||||
key.push(ch.to_ascii_lowercase());
|
||||
previous_space = false;
|
||||
}
|
||||
}
|
||||
|
||||
key.trim().to_string()
|
||||
}
|
||||
|
||||
pub fn starts_with_executable_sql_keyword(sql: &str, keywords: &[&str]) -> bool {
|
||||
starts_with_executable_sql_keyword_with_options(sql, keywords, SqlParsingOptions::default())
|
||||
}
|
||||
@@ -1041,9 +1392,10 @@ mod tests {
|
||||
use crate::models::connection::DatabaseType;
|
||||
|
||||
use super::{
|
||||
decode_sql_file_bytes, find_statement_at_cursor_for_database, prepare_sql_file_statement, split_sql_script,
|
||||
split_sql_statements_for_database, starts_with_executable_sql_keyword,
|
||||
starts_with_executable_sql_keyword_for_database, SqlFileStatementAction, SqlStatementSplitter,
|
||||
decode_sql_file_bytes, find_statement_at_cursor_for_database, optimize_sql_file_import_statements,
|
||||
prepare_sql_file_statement, split_sql_script, split_sql_statements_for_database,
|
||||
starts_with_executable_sql_keyword, starts_with_executable_sql_keyword_for_database, SqlFileStatementAction,
|
||||
SqlStatementSplitter,
|
||||
};
|
||||
|
||||
#[test]
|
||||
@@ -1271,6 +1623,53 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optimizes_adjacent_single_row_inserts_into_multi_row_insert() {
|
||||
let statements = vec![
|
||||
"INSERT INTO users (id, name) VALUES (1, 'Ada')".to_string(),
|
||||
"insert into users (id, name) values (2, 'Linus')".to_string(),
|
||||
"SELECT 1".to_string(),
|
||||
];
|
||||
|
||||
let optimized = optimize_sql_file_import_statements(&statements, Some(DatabaseType::Mysql), None);
|
||||
|
||||
assert_eq!(optimized.len(), 2);
|
||||
assert_eq!(optimized[0].source_statement_count, 2);
|
||||
assert_eq!(optimized[0].sql, "INSERT INTO users (id, name) VALUES\n(1, 'Ada'),\n(2, 'Linus')");
|
||||
assert_eq!(optimized[1].sql, "SELECT 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_insert_batches_separate_for_different_targets_or_suffixes() {
|
||||
let statements = vec![
|
||||
"INSERT INTO users (id) VALUES (1)".to_string(),
|
||||
"INSERT INTO teams (id) VALUES (1)".to_string(),
|
||||
"INSERT INTO users (id) VALUES (2) RETURNING id".to_string(),
|
||||
];
|
||||
|
||||
let optimized = optimize_sql_file_import_statements(&statements, Some(DatabaseType::Postgres), None);
|
||||
|
||||
assert_eq!(optimized.len(), 3);
|
||||
assert!(optimized.iter().all(|statement| statement.source_statement_count == 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optimized_sql_file_import_keeps_skipped_mysql_dump_statements() {
|
||||
let statements = vec![
|
||||
"LOCK TABLES `users` WRITE".to_string(),
|
||||
"INSERT INTO `users` VALUES (1)".to_string(),
|
||||
"INSERT INTO `users` VALUES (2)".to_string(),
|
||||
"UNLOCK TABLES".to_string(),
|
||||
];
|
||||
|
||||
let optimized = optimize_sql_file_import_statements(&statements, Some(DatabaseType::Mysql), None);
|
||||
|
||||
assert_eq!(optimized.len(), 3);
|
||||
assert_eq!(optimized[0].kind, super::SqlFileImportStatementKind::Skip);
|
||||
assert_eq!(optimized[1].source_statement_count, 2);
|
||||
assert_eq!(optimized[2].kind, super::SqlFileImportStatementKind::Skip);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_batches_by_go() {
|
||||
assert_eq!(super::split_sql_batches("SELECT 1\nGO\nSELECT 2"), vec!["SELECT 1", "SELECT 2"]);
|
||||
|
||||
@@ -0,0 +1,497 @@
|
||||
use std::time::Instant;
|
||||
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::connection::AppState;
|
||||
use crate::models::connection::DatabaseType;
|
||||
use crate::query::execute_sql_statement;
|
||||
use crate::sql::{
|
||||
optimize_sql_file_import_statements, statement_summary, SqlFileImportStatement, SqlFileImportStatementKind,
|
||||
SqlFileProgress, SqlFileRequest, SqlFileStatus, SqlParsingOptions, SqlStatementSplitter,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct SqlFileImportTarget {
|
||||
db_type: DatabaseType,
|
||||
driver_profile: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct StatementErrorDecision {
|
||||
progress: Vec<SqlFileProgress>,
|
||||
failure_count: usize,
|
||||
result: Result<bool, String>,
|
||||
}
|
||||
|
||||
pub async fn execute_sql_file_content(
|
||||
state: &AppState,
|
||||
request: &SqlFileRequest,
|
||||
file_content: &str,
|
||||
token: CancellationToken,
|
||||
started_at: Instant,
|
||||
mut emit: impl FnMut(SqlFileProgress),
|
||||
) -> Result<(), String> {
|
||||
let mut statement_index = 0;
|
||||
let mut success_count = 0;
|
||||
let mut failure_count = 0;
|
||||
let mut affected_rows = 0;
|
||||
|
||||
let import_target = sql_file_import_target(state, &request.connection_id).await;
|
||||
let options =
|
||||
import_target.as_ref().map(|target| SqlParsingOptions::for_database_type(target.db_type)).unwrap_or_default();
|
||||
let mut splitter = SqlStatementSplitter::with_options(options);
|
||||
let mut statements = splitter.push_chunk(file_content);
|
||||
statements.extend(splitter.finish());
|
||||
|
||||
let planned_statements = optimize_sql_file_import_statements(
|
||||
&statements,
|
||||
import_target.as_ref().map(|target| target.db_type),
|
||||
import_target.as_ref().and_then(|target| target.driver_profile.as_deref()),
|
||||
);
|
||||
|
||||
for planned_statement in planned_statements {
|
||||
if token.is_cancelled() {
|
||||
emit(sql_file_progress(
|
||||
&request.execution_id,
|
||||
SqlFileStatus::Cancelled,
|
||||
statement_index,
|
||||
success_count,
|
||||
failure_count,
|
||||
affected_rows,
|
||||
started_at,
|
||||
"",
|
||||
None,
|
||||
));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let next_statement_index = statement_index + planned_statement.source_statement_count;
|
||||
if execute_statement_with_progress(
|
||||
state,
|
||||
request,
|
||||
&token,
|
||||
started_at,
|
||||
next_statement_index,
|
||||
&planned_statement,
|
||||
&mut success_count,
|
||||
&mut failure_count,
|
||||
&mut affected_rows,
|
||||
&mut emit,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
statement_index = next_statement_index;
|
||||
}
|
||||
|
||||
emit(sql_file_progress(
|
||||
&request.execution_id,
|
||||
SqlFileStatus::Done,
|
||||
statement_index,
|
||||
success_count,
|
||||
failure_count,
|
||||
affected_rows,
|
||||
started_at,
|
||||
"",
|
||||
None,
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn sql_file_progress(
|
||||
execution_id: &str,
|
||||
status: SqlFileStatus,
|
||||
statement_index: usize,
|
||||
success_count: usize,
|
||||
failure_count: usize,
|
||||
affected_rows: u64,
|
||||
started_at: Instant,
|
||||
statement_summary: &str,
|
||||
error: Option<String>,
|
||||
) -> SqlFileProgress {
|
||||
SqlFileProgress {
|
||||
execution_id: execution_id.to_string(),
|
||||
status,
|
||||
statement_index,
|
||||
success_count,
|
||||
failure_count,
|
||||
affected_rows,
|
||||
elapsed_ms: started_at.elapsed().as_millis(),
|
||||
statement_summary: statement_summary.to_string(),
|
||||
error,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sql_file_error_progress(execution_id: &str, started_at: Instant, error: String) -> SqlFileProgress {
|
||||
sql_file_progress(execution_id, SqlFileStatus::Error, 0, 0, 0, 0, started_at, "", Some(error))
|
||||
}
|
||||
|
||||
async fn sql_file_import_target(state: &AppState, connection_id: &str) -> Option<SqlFileImportTarget> {
|
||||
let configs = state.configs.read().await;
|
||||
configs
|
||||
.get(connection_id)
|
||||
.map(|config| SqlFileImportTarget { db_type: config.db_type, driver_profile: config.driver_profile.clone() })
|
||||
}
|
||||
|
||||
async fn execute_statement_with_progress(
|
||||
state: &AppState,
|
||||
request: &SqlFileRequest,
|
||||
token: &CancellationToken,
|
||||
started_at: Instant,
|
||||
statement_index: usize,
|
||||
statement: &SqlFileImportStatement,
|
||||
success_count: &mut usize,
|
||||
failure_count: &mut usize,
|
||||
affected_rows: &mut u64,
|
||||
emit: &mut impl FnMut(SqlFileProgress),
|
||||
) -> Result<bool, String> {
|
||||
if token.is_cancelled() {
|
||||
let summary = statement_summary(&statement.sql);
|
||||
emit(sql_file_progress(
|
||||
&request.execution_id,
|
||||
SqlFileStatus::Cancelled,
|
||||
statement_index,
|
||||
*success_count,
|
||||
*failure_count,
|
||||
*affected_rows,
|
||||
started_at,
|
||||
&summary,
|
||||
None,
|
||||
));
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
if statement.kind == SqlFileImportStatementKind::Skip {
|
||||
let summary = statement_summary(&statement.sql);
|
||||
emit(sql_file_progress(
|
||||
&request.execution_id,
|
||||
SqlFileStatus::Running,
|
||||
statement_index,
|
||||
*success_count,
|
||||
*failure_count,
|
||||
*affected_rows,
|
||||
started_at,
|
||||
&summary,
|
||||
None,
|
||||
));
|
||||
*success_count += statement.source_statement_count;
|
||||
emit(sql_file_progress(
|
||||
&request.execution_id,
|
||||
SqlFileStatus::StatementDone,
|
||||
statement_index,
|
||||
*success_count,
|
||||
*failure_count,
|
||||
*affected_rows,
|
||||
started_at,
|
||||
&summary,
|
||||
None,
|
||||
));
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let summary = statement_summary(&statement.sql);
|
||||
emit(sql_file_progress(
|
||||
&request.execution_id,
|
||||
SqlFileStatus::Running,
|
||||
statement_index,
|
||||
*success_count,
|
||||
*failure_count,
|
||||
*affected_rows,
|
||||
started_at,
|
||||
&summary,
|
||||
None,
|
||||
));
|
||||
|
||||
match execute_sql_statement(
|
||||
state,
|
||||
&request.connection_id,
|
||||
&request.database,
|
||||
&statement.sql,
|
||||
None,
|
||||
Some(token.clone()),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
*success_count += statement.source_statement_count;
|
||||
*affected_rows += result.affected_rows;
|
||||
emit(sql_file_progress(
|
||||
&request.execution_id,
|
||||
SqlFileStatus::StatementDone,
|
||||
statement_index,
|
||||
*success_count,
|
||||
*failure_count,
|
||||
*affected_rows,
|
||||
started_at,
|
||||
&summary,
|
||||
None,
|
||||
));
|
||||
Ok(false)
|
||||
}
|
||||
Err(error) => {
|
||||
if statement.source_statement_count > 1 && !token.is_cancelled() {
|
||||
return execute_merged_statement_fallback_with_progress(
|
||||
state,
|
||||
request,
|
||||
token,
|
||||
started_at,
|
||||
statement_index + 1 - statement.source_statement_count,
|
||||
statement,
|
||||
success_count,
|
||||
failure_count,
|
||||
affected_rows,
|
||||
emit,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let decision = statement_error_decision(
|
||||
&request.execution_id,
|
||||
token,
|
||||
request.continue_on_error,
|
||||
started_at,
|
||||
statement_index,
|
||||
*success_count,
|
||||
*failure_count,
|
||||
*affected_rows,
|
||||
&summary,
|
||||
error,
|
||||
);
|
||||
|
||||
*failure_count = decision.failure_count;
|
||||
for progress in decision.progress {
|
||||
emit(progress);
|
||||
}
|
||||
decision.result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn execute_merged_statement_fallback_with_progress(
|
||||
state: &AppState,
|
||||
request: &SqlFileRequest,
|
||||
token: &CancellationToken,
|
||||
started_at: Instant,
|
||||
first_statement_index: usize,
|
||||
statement: &SqlFileImportStatement,
|
||||
success_count: &mut usize,
|
||||
failure_count: &mut usize,
|
||||
affected_rows: &mut u64,
|
||||
emit: &mut impl FnMut(SqlFileProgress),
|
||||
) -> Result<bool, String> {
|
||||
for (offset, source_sql) in statement.source_sqls.iter().enumerate() {
|
||||
let statement_index = first_statement_index + offset;
|
||||
if token.is_cancelled() {
|
||||
emit(sql_file_progress(
|
||||
&request.execution_id,
|
||||
SqlFileStatus::Cancelled,
|
||||
statement_index,
|
||||
*success_count,
|
||||
*failure_count,
|
||||
*affected_rows,
|
||||
started_at,
|
||||
&statement_summary(source_sql),
|
||||
None,
|
||||
));
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
let summary = statement_summary(source_sql);
|
||||
emit(sql_file_progress(
|
||||
&request.execution_id,
|
||||
SqlFileStatus::Running,
|
||||
statement_index,
|
||||
*success_count,
|
||||
*failure_count,
|
||||
*affected_rows,
|
||||
started_at,
|
||||
&summary,
|
||||
None,
|
||||
));
|
||||
|
||||
match execute_sql_statement(
|
||||
state,
|
||||
&request.connection_id,
|
||||
&request.database,
|
||||
source_sql,
|
||||
None,
|
||||
Some(token.clone()),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
*success_count += 1;
|
||||
*affected_rows += result.affected_rows;
|
||||
emit(sql_file_progress(
|
||||
&request.execution_id,
|
||||
SqlFileStatus::StatementDone,
|
||||
statement_index,
|
||||
*success_count,
|
||||
*failure_count,
|
||||
*affected_rows,
|
||||
started_at,
|
||||
&summary,
|
||||
None,
|
||||
));
|
||||
}
|
||||
Err(error) => {
|
||||
let decision = statement_error_decision(
|
||||
&request.execution_id,
|
||||
token,
|
||||
request.continue_on_error,
|
||||
started_at,
|
||||
statement_index,
|
||||
*success_count,
|
||||
*failure_count,
|
||||
*affected_rows,
|
||||
&summary,
|
||||
error,
|
||||
);
|
||||
|
||||
*failure_count = decision.failure_count;
|
||||
for progress in decision.progress {
|
||||
emit(progress);
|
||||
}
|
||||
if decision.result? {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
fn statement_error_decision(
|
||||
execution_id: &str,
|
||||
token: &CancellationToken,
|
||||
continue_on_error: bool,
|
||||
started_at: Instant,
|
||||
statement_index: usize,
|
||||
success_count: usize,
|
||||
failure_count: usize,
|
||||
affected_rows: u64,
|
||||
summary: &str,
|
||||
error: String,
|
||||
) -> StatementErrorDecision {
|
||||
if token.is_cancelled() {
|
||||
return StatementErrorDecision {
|
||||
progress: vec![sql_file_progress(
|
||||
execution_id,
|
||||
SqlFileStatus::Cancelled,
|
||||
statement_index,
|
||||
success_count,
|
||||
failure_count,
|
||||
affected_rows,
|
||||
started_at,
|
||||
summary,
|
||||
None,
|
||||
)],
|
||||
failure_count,
|
||||
result: Ok(true),
|
||||
};
|
||||
}
|
||||
|
||||
let failure_count = failure_count + 1;
|
||||
let statement_failed = sql_file_progress(
|
||||
execution_id,
|
||||
SqlFileStatus::StatementFailed,
|
||||
statement_index,
|
||||
success_count,
|
||||
failure_count,
|
||||
affected_rows,
|
||||
started_at,
|
||||
summary,
|
||||
Some(error.clone()),
|
||||
);
|
||||
|
||||
if continue_on_error {
|
||||
return StatementErrorDecision { progress: vec![statement_failed], failure_count, result: Ok(false) };
|
||||
}
|
||||
|
||||
let terminal_error = sql_file_progress(
|
||||
execution_id,
|
||||
SqlFileStatus::Error,
|
||||
statement_index,
|
||||
success_count,
|
||||
failure_count,
|
||||
affected_rows,
|
||||
started_at,
|
||||
summary,
|
||||
Some(error.clone()),
|
||||
);
|
||||
|
||||
StatementErrorDecision { progress: vec![statement_failed, terminal_error], failure_count, result: Err(error) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn stop_on_error_returns_err_with_terminal_error_progress() {
|
||||
let decision = statement_error_decision(
|
||||
"exec-1",
|
||||
&CancellationToken::new(),
|
||||
false,
|
||||
Instant::now(),
|
||||
3,
|
||||
1,
|
||||
0,
|
||||
5,
|
||||
"bad statement",
|
||||
"syntax error".to_string(),
|
||||
);
|
||||
|
||||
assert_eq!(decision.failure_count, 1);
|
||||
assert_eq!(decision.result, Err("syntax error".to_string()));
|
||||
assert_eq!(decision.progress.len(), 2);
|
||||
assert_eq!(decision.progress[0].status, SqlFileStatus::StatementFailed);
|
||||
assert_eq!(decision.progress[1].status, SqlFileStatus::Error);
|
||||
assert_eq!(decision.progress[1].error, Some("syntax error".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelled_in_flight_error_does_not_increment_failure_count() {
|
||||
let token = CancellationToken::new();
|
||||
token.cancel();
|
||||
|
||||
let decision = statement_error_decision(
|
||||
"exec-1",
|
||||
&token,
|
||||
false,
|
||||
Instant::now(),
|
||||
2,
|
||||
1,
|
||||
4,
|
||||
9,
|
||||
"slow statement",
|
||||
"Query canceled".to_string(),
|
||||
);
|
||||
|
||||
assert_eq!(decision.failure_count, 4);
|
||||
assert_eq!(decision.result, Ok(true));
|
||||
assert_eq!(decision.progress.len(), 1);
|
||||
assert_eq!(decision.progress[0].status, SqlFileStatus::Cancelled);
|
||||
assert_eq!(decision.progress[0].failure_count, 4);
|
||||
assert_eq!(decision.progress[0].error, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_payload_serializes_camel_case_status() {
|
||||
let progress =
|
||||
sql_file_progress("exec-1", SqlFileStatus::StatementDone, 1, 1, 0, 3, Instant::now(), "select 1", None);
|
||||
|
||||
let value = serde_json::to_value(progress).unwrap();
|
||||
|
||||
assert_eq!(value["executionId"], "exec-1");
|
||||
assert_eq!(value["statementIndex"], 1);
|
||||
assert_eq!(value["successCount"], 1);
|
||||
assert_eq!(value["failureCount"], 0);
|
||||
assert_eq!(value["affectedRows"], 3);
|
||||
assert_eq!(value["statementSummary"], "select 1");
|
||||
assert_eq!(value["status"], "statementDone");
|
||||
assert!(value.get("execution_id").is_none());
|
||||
}
|
||||
}
|
||||
@@ -18,11 +18,28 @@ pub struct Storage {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct DesktopSettings {
|
||||
pub show_tray_icon: bool,
|
||||
pub icon_theme: DesktopIconTheme,
|
||||
}
|
||||
|
||||
impl Default for DesktopSettings {
|
||||
fn default() -> Self {
|
||||
Self { show_tray_icon: true }
|
||||
Self { show_tray_icon: true, icon_theme: DesktopIconTheme::Default }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DesktopIconTheme {
|
||||
Default,
|
||||
Black,
|
||||
}
|
||||
|
||||
impl DesktopIconTheme {
|
||||
fn from_settings_value(value: Option<&serde_json::Value>) -> Self {
|
||||
match value.and_then(|value| value.as_str()) {
|
||||
Some("black") => Self::Black,
|
||||
_ => Self::Default,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,6 +343,10 @@ impl Storage {
|
||||
let mut settings = self.load_app_settings_json().await?;
|
||||
settings.remove("run_in_background");
|
||||
settings.insert("show_tray_icon".to_string(), serde_json::Value::Bool(desktop_settings.show_tray_icon));
|
||||
settings.insert(
|
||||
"icon_theme".to_string(),
|
||||
serde_json::to_value(desktop_settings.icon_theme).map_err(|e| e.to_string())?,
|
||||
);
|
||||
self.save_app_settings_json(&settings).await
|
||||
}
|
||||
|
||||
@@ -337,6 +358,7 @@ impl Storage {
|
||||
.and_then(|value| value.as_bool())
|
||||
.or_else(|| settings.get("run_in_background").and_then(|value| value.as_bool()))
|
||||
.unwrap_or_else(|| DesktopSettings::default().show_tray_icon),
|
||||
icon_theme: DesktopIconTheme::from_settings_value(settings.get("icon_theme")),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1063,7 +1085,7 @@ fn map_from_sql_err(err: serde_json::Error) -> rusqlite::Error {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{DesktopSettings, Storage};
|
||||
use super::{DesktopIconTheme, DesktopSettings, Storage};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
fn temp_db_path(name: &str) -> std::path::PathBuf {
|
||||
@@ -1076,7 +1098,7 @@ mod tests {
|
||||
let path = temp_db_path("desktop-settings-default");
|
||||
let storage = Storage::open(&path).await.unwrap();
|
||||
|
||||
assert_eq!(storage.load_desktop_settings().await.unwrap(), DesktopSettings { show_tray_icon: true });
|
||||
assert_eq!(storage.load_desktop_settings().await.unwrap(), DesktopSettings::default());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1087,7 +1109,10 @@ mod tests {
|
||||
settings.insert("run_in_background".to_string(), serde_json::Value::Bool(false));
|
||||
storage.save_app_settings_json(&settings).await.unwrap();
|
||||
|
||||
assert_eq!(storage.load_desktop_settings().await.unwrap(), DesktopSettings { show_tray_icon: false });
|
||||
assert_eq!(
|
||||
storage.load_desktop_settings().await.unwrap(),
|
||||
DesktopSettings { show_tray_icon: false, ..DesktopSettings::default() }
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1096,10 +1121,16 @@ mod tests {
|
||||
let storage = Storage::open(&path).await.unwrap();
|
||||
|
||||
storage.save_password_hash("hash-1").await.unwrap();
|
||||
storage.save_desktop_settings(&DesktopSettings { show_tray_icon: false }).await.unwrap();
|
||||
storage
|
||||
.save_desktop_settings(&DesktopSettings { show_tray_icon: false, icon_theme: DesktopIconTheme::Black })
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(storage.load_password_hash().await.unwrap(), Some("hash-1".to_string()));
|
||||
assert_eq!(storage.load_desktop_settings().await.unwrap(), DesktopSettings { show_tray_icon: false });
|
||||
assert_eq!(
|
||||
storage.load_desktop_settings().await.unwrap(),
|
||||
DesktopSettings { show_tray_icon: false, icon_theme: DesktopIconTheme::Black }
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1110,11 +1141,18 @@ mod tests {
|
||||
settings.insert("run_in_background".to_string(), serde_json::Value::Bool(false));
|
||||
storage.save_app_settings_json(&settings).await.unwrap();
|
||||
|
||||
storage.save_desktop_settings(&DesktopSettings { show_tray_icon: true }).await.unwrap();
|
||||
storage
|
||||
.save_desktop_settings(&DesktopSettings {
|
||||
icon_theme: DesktopIconTheme::Black,
|
||||
..DesktopSettings::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let settings = storage.load_app_settings_json().await.unwrap();
|
||||
assert_eq!(settings.get("run_in_background"), None);
|
||||
assert_eq!(settings.get("show_tray_icon").and_then(|value| value.as_bool()), Some(true));
|
||||
assert_eq!(settings.get("icon_theme").and_then(|value| value.as_str()), Some("black"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1122,11 +1160,17 @@ mod tests {
|
||||
let path = temp_db_path("password-preserve-desktop-settings");
|
||||
let storage = Storage::open(&path).await.unwrap();
|
||||
|
||||
storage.save_desktop_settings(&DesktopSettings { show_tray_icon: false }).await.unwrap();
|
||||
storage
|
||||
.save_desktop_settings(&DesktopSettings { show_tray_icon: false, icon_theme: DesktopIconTheme::Black })
|
||||
.await
|
||||
.unwrap();
|
||||
storage.save_password_hash("hash-2").await.unwrap();
|
||||
|
||||
assert_eq!(storage.load_password_hash().await.unwrap(), Some("hash-2".to_string()));
|
||||
assert_eq!(storage.load_desktop_settings().await.unwrap(), DesktopSettings { show_tray_icon: false });
|
||||
assert_eq!(
|
||||
storage.load_desktop_settings().await.unwrap(),
|
||||
DesktopSettings { show_tray_icon: false, icon_theme: DesktopIconTheme::Black }
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -65,6 +65,7 @@ async fn main() {
|
||||
password_hash: RwLock::new(password_hash),
|
||||
sessions: RwLock::new(HashSet::new()),
|
||||
sse_channels: RwLock::new(HashMap::new()),
|
||||
sql_file_executions: RwLock::new(HashMap::new()),
|
||||
login_rate_limit: tokio::sync::Mutex::new(state::LoginRateLimit { fail_count: 0, locked_until: None }),
|
||||
});
|
||||
|
||||
|
||||
@@ -206,6 +206,7 @@ mod tests {
|
||||
password_hash: RwLock::new(None),
|
||||
sessions: RwLock::new(HashSet::new()),
|
||||
sse_channels: RwLock::new(HashMap::new()),
|
||||
sql_file_executions: RwLock::new(HashMap::new()),
|
||||
login_rate_limit: Mutex::new(LoginRateLimit { fail_count: 0, locked_until: None }),
|
||||
});
|
||||
(state, dir)
|
||||
|
||||
@@ -4,29 +4,23 @@ use std::sync::Arc;
|
||||
use axum::extract::{Multipart, Path as AxumPath, State};
|
||||
use axum::response::sse::{Event, Sse};
|
||||
use axum::Json;
|
||||
use dbx_core::query;
|
||||
use dbx_core::sql;
|
||||
use dbx_core::sql::SqlFileStatementAction;
|
||||
use dbx_core::sql::{SqlFileProgress, SqlFileRequest, SqlFileStatus};
|
||||
use dbx_core::sql_file_import::{
|
||||
execute_sql_file_content, sql_file_error_progress, sql_file_progress as build_sql_file_progress,
|
||||
};
|
||||
use futures::stream::Stream;
|
||||
use serde::Deserialize;
|
||||
use tokio::sync::broadcast;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::state::WebState;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SqlFileExecuteRequest {
|
||||
pub execution_id: String,
|
||||
pub connection_id: String,
|
||||
pub database: String,
|
||||
pub file_path: String,
|
||||
pub continue_on_error: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SqlFileExecuteWrapper {
|
||||
pub request: SqlFileExecuteRequest,
|
||||
pub request: SqlFileRequest,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -70,49 +64,40 @@ pub async fn execute_sql_file(
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let req = body.request;
|
||||
let execution_id = req.execution_id.clone();
|
||||
let file_path = validated_uploaded_sql_path(&state.data_dir, &req.file_path)?;
|
||||
let token = CancellationToken::new();
|
||||
|
||||
{
|
||||
let mut executions = state.sql_file_executions.write().await;
|
||||
if executions.contains_key(&execution_id) {
|
||||
return Err(AppError(format!("SQL file execution '{execution_id}' already exists")));
|
||||
}
|
||||
executions.insert(execution_id.clone(), token.clone());
|
||||
}
|
||||
let (tx, _) = tokio::sync::broadcast::channel::<String>(256);
|
||||
state.sse_channels.write().await.insert(execution_id.clone(), tx.clone());
|
||||
|
||||
let app = state.app.clone();
|
||||
let state_clone = state.clone();
|
||||
|
||||
let file_path = validated_uploaded_sql_path(&state.data_dir, &req.file_path)?;
|
||||
|
||||
tokio::spawn(async move {
|
||||
let started_at = std::time::Instant::now();
|
||||
match std::fs::metadata(&file_path) {
|
||||
Ok(meta) if meta.len() > 200 * 1024 * 1024 => {
|
||||
let progress = dbx_core::sql::SqlFileProgress {
|
||||
execution_id: req.execution_id.clone(),
|
||||
status: dbx_core::sql::SqlFileStatus::Error,
|
||||
statement_index: 0,
|
||||
success_count: 0,
|
||||
failure_count: 0,
|
||||
affected_rows: 0,
|
||||
elapsed_ms: 0,
|
||||
statement_summary: String::new(),
|
||||
error: Some(format!("File too large: {} bytes (max {} bytes)", meta.len(), 200 * 1024 * 1024)),
|
||||
};
|
||||
if let Ok(json) = serde_json::to_string(&progress) {
|
||||
let _ = tx.send(json);
|
||||
}
|
||||
send_sql_file_progress(
|
||||
&tx,
|
||||
sql_file_error_progress(
|
||||
&req.execution_id,
|
||||
started_at,
|
||||
format!("File too large: {} bytes (max {} bytes)", meta.len(), 200 * 1024 * 1024),
|
||||
),
|
||||
);
|
||||
cleanup_sql_file_execution(&state_clone, &req.execution_id).await;
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
let progress = dbx_core::sql::SqlFileProgress {
|
||||
execution_id: req.execution_id.clone(),
|
||||
status: dbx_core::sql::SqlFileStatus::Error,
|
||||
statement_index: 0,
|
||||
success_count: 0,
|
||||
failure_count: 0,
|
||||
affected_rows: 0,
|
||||
elapsed_ms: 0,
|
||||
statement_summary: String::new(),
|
||||
error: Some(e.to_string()),
|
||||
};
|
||||
if let Ok(json) = serde_json::to_string(&progress) {
|
||||
let _ = tx.send(json);
|
||||
}
|
||||
send_sql_file_progress(&tx, sql_file_error_progress(&req.execution_id, started_at, e.to_string()));
|
||||
cleanup_sql_file_execution(&state_clone, &req.execution_id).await;
|
||||
return;
|
||||
}
|
||||
_ => {}
|
||||
@@ -124,169 +109,39 @@ pub async fn execute_sql_file(
|
||||
}) {
|
||||
Ok(content) => content,
|
||||
Err(e) => {
|
||||
let progress = dbx_core::sql::SqlFileProgress {
|
||||
execution_id: req.execution_id.clone(),
|
||||
status: dbx_core::sql::SqlFileStatus::Error,
|
||||
statement_index: 0,
|
||||
success_count: 0,
|
||||
failure_count: 0,
|
||||
affected_rows: 0,
|
||||
elapsed_ms: 0,
|
||||
statement_summary: String::new(),
|
||||
error: Some(e.to_string()),
|
||||
};
|
||||
if let Ok(json) = serde_json::to_string(&progress) {
|
||||
let _ = tx.send(json);
|
||||
}
|
||||
send_sql_file_progress(&tx, sql_file_error_progress(&req.execution_id, started_at, e.to_string()));
|
||||
cleanup_sql_file_execution(&state_clone, &req.execution_id).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Send started
|
||||
let started = dbx_core::sql::SqlFileProgress {
|
||||
execution_id: req.execution_id.clone(),
|
||||
status: dbx_core::sql::SqlFileStatus::Started,
|
||||
statement_index: 0,
|
||||
success_count: 0,
|
||||
failure_count: 0,
|
||||
affected_rows: 0,
|
||||
elapsed_ms: 0,
|
||||
statement_summary: String::new(),
|
||||
error: None,
|
||||
};
|
||||
if let Ok(json) = serde_json::to_string(&started) {
|
||||
let _ = tx.send(json);
|
||||
}
|
||||
send_sql_file_progress(
|
||||
&tx,
|
||||
build_sql_file_progress(&req.execution_id, SqlFileStatus::Started, 0, 0, 0, 0, started_at, "", None),
|
||||
);
|
||||
|
||||
let import_target = {
|
||||
let configs = app.configs.read().await;
|
||||
configs.get(&req.connection_id).map(|config| (config.db_type, config.driver_profile.clone()))
|
||||
};
|
||||
let statements = import_target
|
||||
.as_ref()
|
||||
.map(|(db_type, _)| sql::split_sql_statements_for_database(&file_content, *db_type))
|
||||
.unwrap_or_else(|| sql::split_sql_statements(&file_content));
|
||||
let start = std::time::Instant::now();
|
||||
let mut success_count = 0usize;
|
||||
let mut failure_count = 0usize;
|
||||
let mut total_affected: u64 = 0;
|
||||
let _ = execute_sql_file_content(&app, &req, &file_content, token, started_at, |progress| {
|
||||
send_sql_file_progress(&tx, progress);
|
||||
})
|
||||
.await;
|
||||
|
||||
for (i, stmt) in statements.iter().enumerate() {
|
||||
let statement_action = import_target
|
||||
.as_ref()
|
||||
.map(|(db_type, driver_profile)| {
|
||||
sql::prepare_sql_file_statement(stmt, db_type, driver_profile.as_deref())
|
||||
})
|
||||
.unwrap_or_else(|| SqlFileStatementAction::Execute(stmt.to_string()));
|
||||
let (stmt_to_execute, summary, should_execute) = match statement_action {
|
||||
SqlFileStatementAction::Execute(statement) => {
|
||||
let summary = sql::statement_summary(&statement);
|
||||
(statement, summary, true)
|
||||
}
|
||||
SqlFileStatementAction::Skip => (String::new(), sql::statement_summary(stmt), false),
|
||||
};
|
||||
|
||||
// Send running
|
||||
let running = dbx_core::sql::SqlFileProgress {
|
||||
execution_id: req.execution_id.clone(),
|
||||
status: dbx_core::sql::SqlFileStatus::Running,
|
||||
statement_index: i,
|
||||
success_count,
|
||||
failure_count,
|
||||
affected_rows: total_affected,
|
||||
elapsed_ms: start.elapsed().as_millis(),
|
||||
statement_summary: summary.clone(),
|
||||
error: None,
|
||||
};
|
||||
if let Ok(json) = serde_json::to_string(&running) {
|
||||
let _ = tx.send(json);
|
||||
}
|
||||
|
||||
if !should_execute {
|
||||
success_count += 1;
|
||||
let done = dbx_core::sql::SqlFileProgress {
|
||||
execution_id: req.execution_id.clone(),
|
||||
status: dbx_core::sql::SqlFileStatus::StatementDone,
|
||||
statement_index: i,
|
||||
success_count,
|
||||
failure_count,
|
||||
affected_rows: total_affected,
|
||||
elapsed_ms: start.elapsed().as_millis(),
|
||||
statement_summary: summary,
|
||||
error: None,
|
||||
};
|
||||
if let Ok(json) = serde_json::to_string(&done) {
|
||||
let _ = tx.send(json);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
match query::execute_sql_statement(&app, &req.connection_id, &req.database, &stmt_to_execute, None, None)
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
success_count += 1;
|
||||
total_affected += result.affected_rows;
|
||||
let done = dbx_core::sql::SqlFileProgress {
|
||||
execution_id: req.execution_id.clone(),
|
||||
status: dbx_core::sql::SqlFileStatus::StatementDone,
|
||||
statement_index: i,
|
||||
success_count,
|
||||
failure_count,
|
||||
affected_rows: total_affected,
|
||||
elapsed_ms: start.elapsed().as_millis(),
|
||||
statement_summary: summary,
|
||||
error: None,
|
||||
};
|
||||
if let Ok(json) = serde_json::to_string(&done) {
|
||||
let _ = tx.send(json);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
failure_count += 1;
|
||||
let failed = dbx_core::sql::SqlFileProgress {
|
||||
execution_id: req.execution_id.clone(),
|
||||
status: dbx_core::sql::SqlFileStatus::StatementFailed,
|
||||
statement_index: i,
|
||||
success_count,
|
||||
failure_count,
|
||||
affected_rows: total_affected,
|
||||
elapsed_ms: start.elapsed().as_millis(),
|
||||
statement_summary: summary,
|
||||
error: Some(e),
|
||||
};
|
||||
if let Ok(json) = serde_json::to_string(&failed) {
|
||||
let _ = tx.send(json);
|
||||
}
|
||||
if !req.continue_on_error {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send final done
|
||||
let final_done = dbx_core::sql::SqlFileProgress {
|
||||
execution_id: req.execution_id.clone(),
|
||||
status: dbx_core::sql::SqlFileStatus::Done,
|
||||
statement_index: statements.len(),
|
||||
success_count,
|
||||
failure_count,
|
||||
affected_rows: total_affected,
|
||||
elapsed_ms: start.elapsed().as_millis(),
|
||||
statement_summary: String::new(),
|
||||
error: None,
|
||||
};
|
||||
if let Ok(json) = serde_json::to_string(&final_done) {
|
||||
let _ = tx.send(json);
|
||||
}
|
||||
|
||||
state_clone.remove_sse_channel(&req.execution_id).await;
|
||||
cleanup_sql_file_execution(&state_clone, &req.execution_id).await;
|
||||
});
|
||||
|
||||
Ok(Json(serde_json::json!({ "executionId": execution_id })))
|
||||
}
|
||||
|
||||
fn send_sql_file_progress(tx: &broadcast::Sender<String>, progress: SqlFileProgress) {
|
||||
if let Ok(json) = serde_json::to_string(&progress) {
|
||||
let _ = tx.send(json);
|
||||
}
|
||||
}
|
||||
|
||||
async fn cleanup_sql_file_execution(state: &WebState, execution_id: &str) {
|
||||
state.remove_sse_channel(execution_id).await;
|
||||
state.sql_file_executions.write().await.remove(execution_id);
|
||||
}
|
||||
|
||||
fn safe_uploaded_sql_path(tmp_dir: &Path, file_name: &str) -> Result<PathBuf, AppError> {
|
||||
let base_name =
|
||||
file_name.rsplit(|ch| ch == '/' || ch == '\\').find(|part| !part.is_empty()).unwrap_or("upload.sql").trim();
|
||||
@@ -325,9 +180,13 @@ pub async fn cancel_sql_file(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<CancelSqlFileRequest>,
|
||||
) -> Json<serde_json::Value> {
|
||||
// Remove the channel to stop the execution loop
|
||||
state.sse_channels.write().await.remove(&req.execution_id);
|
||||
Json(serde_json::json!({ "cancelled": true }))
|
||||
let executions = state.sql_file_executions.read().await;
|
||||
if let Some(token) = executions.get(&req.execution_id) {
|
||||
token.cancel();
|
||||
Json(serde_json::json!({ "cancelled": true }))
|
||||
} else {
|
||||
Json(serde_json::json!({ "cancelled": false }))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -3,6 +3,7 @@ use std::collections::{HashMap, HashSet};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{broadcast, Mutex, RwLock};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
pub struct LoginRateLimit {
|
||||
pub fail_count: u32,
|
||||
@@ -15,6 +16,7 @@ pub struct WebState {
|
||||
pub password_hash: RwLock<Option<String>>,
|
||||
pub sessions: RwLock<HashSet<String>>,
|
||||
pub sse_channels: RwLock<HashMap<String, broadcast::Sender<String>>>,
|
||||
pub sql_file_executions: RwLock<HashMap<String, CancellationToken>>,
|
||||
pub login_rate_limit: Mutex<LoginRateLimit>,
|
||||
}
|
||||
|
||||
|
||||
@@ -218,7 +218,7 @@ MCP 查询执行默认非常保守:
|
||||
| 规则 | 默认行为 |
|
||||
|---|---|
|
||||
| 空 SQL 或无法识别的 SQL | 阻止 |
|
||||
| 超过一条 SQL 语句 | 阻止 |
|
||||
| 多条 SQL 语句 | 允许;会逐条进行安全检查并依次执行 |
|
||||
| 非只读 SQL | 阻止,除非显式允许写入 |
|
||||
| `DROP`、`TRUNCATE`、`ALTER` | 阻止,除非显式允许危险 SQL |
|
||||
| 允许写入时没有 `WHERE` 的 `UPDATE` 或 `DELETE` | 阻止 |
|
||||
|
||||
@@ -218,7 +218,7 @@ MCP query execution is intentionally conservative:
|
||||
| Rule | Default |
|
||||
|---|---|
|
||||
| Empty or unrecognized SQL | Blocked |
|
||||
| More than one SQL statement | Blocked |
|
||||
| Multiple SQL statements | Allowed; each statement is checked and executed separately |
|
||||
| Non-read SQL | Blocked unless writes are explicitly allowed |
|
||||
| `DROP`, `TRUNCATE`, or `ALTER` | Blocked unless dangerous SQL is explicitly allowed |
|
||||
| `UPDATE` or `DELETE` without `WHERE` when writes are allowed | Blocked |
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
binaryCellDownloadFileName,
|
||||
binaryCellDownloadPayload,
|
||||
canDownloadBinaryCellValue,
|
||||
isBinaryCellColumnType,
|
||||
parseBinaryCellHexValue,
|
||||
} from "../../apps/desktop/src/lib/binaryCellDownload.ts";
|
||||
|
||||
test("parseBinaryCellHexValue accepts 0x and \\x prefixed hex values", () => {
|
||||
assert.deepEqual(Array.from(parseBinaryCellHexValue("0X48656c6c6f") ?? []), [72, 101, 108, 108, 111]);
|
||||
assert.deepEqual(Array.from(parseBinaryCellHexValue("\\x00 ff") ?? []), [0, 255]);
|
||||
});
|
||||
|
||||
test("parseBinaryCellHexValue rejects non-hex and odd-length payloads", () => {
|
||||
assert.equal(parseBinaryCellHexValue("hello"), null);
|
||||
assert.equal(parseBinaryCellHexValue("0x123"), null);
|
||||
assert.equal(parseBinaryCellHexValue(null), null);
|
||||
});
|
||||
|
||||
test("binary cell download detects common blob column types", () => {
|
||||
assert.equal(isBinaryCellColumnType("BLOB"), true);
|
||||
assert.equal(isBinaryCellColumnType("RAW(2000)"), true);
|
||||
assert.equal(isBinaryCellColumnType("long raw"), true);
|
||||
assert.equal(isBinaryCellColumnType("varchar"), false);
|
||||
});
|
||||
|
||||
test("canDownloadBinaryCellValue allows displayed binary hex strings", () => {
|
||||
assert.equal(canDownloadBinaryCellValue("0x89504e47", "BLOB"), true);
|
||||
assert.equal(canDownloadBinaryCellValue("0x89504e47"), true);
|
||||
assert.equal(canDownloadBinaryCellValue("89504e47", "BLOB"), false);
|
||||
});
|
||||
|
||||
test("binaryCellDownloadPayload builds raw and decoded payloads", () => {
|
||||
const binary = binaryCellDownloadPayload("0x4869", "binary");
|
||||
assert.equal(binary.mimeType, "application/octet-stream");
|
||||
assert.equal(binary.extension, "bin");
|
||||
assert.deepEqual(Array.from(binary.data as Uint8Array), [72, 105]);
|
||||
|
||||
const text = binaryCellDownloadPayload("0x4869", "utf8");
|
||||
assert.equal(text.mimeType, "text/plain;charset=utf-8");
|
||||
assert.equal(text.extension, "txt");
|
||||
assert.equal(text.data, "Hi");
|
||||
});
|
||||
|
||||
test("binaryCellDownloadPayload decodes GBK text bytes", () => {
|
||||
const payload = binaryCellDownloadPayload("0xd6d0cec4", "gbk");
|
||||
assert.equal(payload.data, "中文");
|
||||
});
|
||||
|
||||
test("binaryCellDownloadFileName sanitizes column names", () => {
|
||||
assert.equal(
|
||||
binaryCellDownloadFileName({ column: "avatar/blob", rowNumber: 7, mode: "gbk", extension: "txt" }),
|
||||
"avatar-blob-row-7-gbk.txt",
|
||||
);
|
||||
});
|
||||
@@ -74,6 +74,25 @@ test("object browser rows preserve table timestamps and sort recent updates firs
|
||||
assert.equal(formatObjectBrowserTimestamp(rows[0].created_at), "2026-05-20 09:30:00");
|
||||
});
|
||||
|
||||
test("object browser name sort keeps base-prefixed tables before prefixed variants", () => {
|
||||
const rows = buildObjectBrowserRows({
|
||||
objects: [
|
||||
{ name: "chat_staff", object_type: "TABLE", schema: "public" },
|
||||
{ name: "chat_staff_his", object_type: "TABLE", schema: "public" },
|
||||
{ name: "staff", object_type: "TABLE", schema: "public" },
|
||||
{ name: "staff_his", object_type: "TABLE", schema: "public" },
|
||||
],
|
||||
database: "app",
|
||||
fallbackSchema: "public",
|
||||
needsSchema: true,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
sortObjectBrowserRows(rows, "name", "asc").map((row) => row.name),
|
||||
["staff", "staff_his", "chat_staff", "chat_staff_his"],
|
||||
);
|
||||
});
|
||||
|
||||
test("object browser rows mark partition-like tables when their parent table exists", () => {
|
||||
const rows = buildObjectBrowserRows({
|
||||
objects: [
|
||||
|
||||
@@ -136,6 +136,198 @@ test("evicting cached tab results releases multi-result payloads and sessions",
|
||||
}
|
||||
});
|
||||
|
||||
test("closing tabs clears removed result payloads before dropping tab references", async () => {
|
||||
const restoreStorage = installMemoryStorage();
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async () => {
|
||||
return new Response(JSON.stringify(true), { status: 200, headers: { "Content-Type": "application/json" } });
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
setActivePinia(createPinia());
|
||||
const store = useQueryStore();
|
||||
const keepId = store.createTab("conn-1", "db", "keep");
|
||||
const closeId = store.createTab("conn-1", "db", "close");
|
||||
const closingTab = store.tabs.find((item) => item.id === closeId);
|
||||
|
||||
assert.ok(closingTab);
|
||||
closingTab.result = {
|
||||
columns: ["payload"],
|
||||
rows: [[new Array(10_000).fill("x").join("")]],
|
||||
affected_rows: 0,
|
||||
execution_time_ms: 1,
|
||||
session_id: "session-close",
|
||||
};
|
||||
closingTab.results = [closingTab.result];
|
||||
closingTab.activeResultIndex = 0;
|
||||
closingTab.resultSessionId = "session-close";
|
||||
|
||||
store.closeOtherTabs(keepId);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.equal(closingTab.result, undefined);
|
||||
assert.equal(closingTab.results, undefined);
|
||||
assert.equal(closingTab.activeResultIndex, undefined);
|
||||
assert.equal(closingTab.resultSessionId, undefined);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
restoreStorage();
|
||||
}
|
||||
});
|
||||
|
||||
test("starting a new query clears the previous result payload immediately", async () => {
|
||||
const restoreStorage = installMemoryStorage();
|
||||
setActivePinia(createPinia());
|
||||
const connectionStore = useConnectionStore();
|
||||
const store = useQueryStore();
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
connectionStore.addEphemeralConnection(conn("conn-1"));
|
||||
const tabId = store.createTab("conn-1", "db", "Query");
|
||||
const tab = store.tabs.find((item) => item.id === tabId);
|
||||
assert.ok(tab);
|
||||
tab.result = {
|
||||
columns: ["old"],
|
||||
rows: [[new Array(10_000).fill("old").join("")]],
|
||||
affected_rows: 0,
|
||||
execution_time_ms: 1,
|
||||
};
|
||||
|
||||
globalThis.fetch = (async (input) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/query/prepare-pagination-plan") {
|
||||
return new Response(JSON.stringify({ sqlToExecute: "select 1", useAgentResultSession: false }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
if (url === "/api/query/execute-multi") {
|
||||
return new Response(JSON.stringify([{ columns: ["new"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 }]), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
if (url === "/api/query/analyze-editability") {
|
||||
return new Response(JSON.stringify({ editable: false, reason: "complex-source" }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
return new Response("unexpected request", { status: 500 });
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const execution = store.executeTabSql(tabId, "select 1");
|
||||
assert.equal(tab.result, undefined);
|
||||
assert.equal(tab.results, undefined);
|
||||
await execution;
|
||||
assert.deepEqual(tab.result?.columns, ["new"]);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
restoreStorage();
|
||||
}
|
||||
});
|
||||
|
||||
test("query execution finishes without waiting for metadata analysis", async () => {
|
||||
const restoreStorage = installMemoryStorage();
|
||||
setActivePinia(createPinia());
|
||||
const connectionStore = useConnectionStore();
|
||||
const store = useQueryStore();
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
connectionStore.addEphemeralConnection(conn("conn-1"));
|
||||
const tabId = store.createTab("conn-1", "db", "Query");
|
||||
const tab = store.tabs.find((item) => item.id === tabId);
|
||||
assert.ok(tab);
|
||||
|
||||
let resolveMetadata: ((value: Response) => void) | undefined;
|
||||
globalThis.fetch = (async (input) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/query/prepare-pagination-plan") {
|
||||
return new Response(JSON.stringify({ sqlToExecute: "select id from users", useAgentResultSession: false }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
if (url === "/api/query/execute-multi") {
|
||||
return new Response(JSON.stringify([{ columns: ["id"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 }]), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
if (url === "/api/query/analyze-editability") {
|
||||
return new Promise<Response>((resolve) => {
|
||||
resolveMetadata = resolve;
|
||||
});
|
||||
}
|
||||
return new Response("unexpected request", { status: 500 });
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
await store.executeTabSql(tabId, "select id from users");
|
||||
|
||||
assert.equal(tab.isExecuting, false);
|
||||
assert.equal(tab.executionId, undefined);
|
||||
assert.deepEqual(tab.result?.columns, ["id"]);
|
||||
|
||||
resolveMetadata?.(
|
||||
new Response(JSON.stringify({ editable: false, reason: "complex-source" }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
restoreStorage();
|
||||
}
|
||||
});
|
||||
|
||||
test("normal query execution does not create a tab-scoped client session", async () => {
|
||||
const restoreStorage = installMemoryStorage();
|
||||
setActivePinia(createPinia());
|
||||
const connectionStore = useConnectionStore();
|
||||
const store = useQueryStore();
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
connectionStore.addEphemeralConnection(conn("conn-1"));
|
||||
const tabId = store.createTab("conn-1", "db", "Query");
|
||||
let executeBody: any;
|
||||
|
||||
globalThis.fetch = (async (input, init) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/query/prepare-pagination-plan") {
|
||||
return new Response(JSON.stringify({ sqlToExecute: "select 1", useAgentResultSession: false }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
if (url === "/api/query/execute-multi") {
|
||||
executeBody = JSON.parse(String(init?.body ?? "{}"));
|
||||
return new Response(JSON.stringify([{ columns: ["id"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 }]), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
if (url === "/api/query/analyze-editability") {
|
||||
return new Response(JSON.stringify({ editable: false, reason: "complex-source" }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
return new Response("unexpected request", { status: 500 });
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
await store.executeTabSql(tabId, "select 1");
|
||||
|
||||
assert.equal(executeBody.clientSessionId, undefined);
|
||||
assert.equal(executeBody.timeoutSecs, 30);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
restoreStorage();
|
||||
}
|
||||
});
|
||||
|
||||
test("tab reuse is scoped by mode and schema instead of title alone", () => {
|
||||
const restoreStorage = installMemoryStorage();
|
||||
try {
|
||||
|
||||
@@ -75,6 +75,21 @@ test("buildTableTreeNodes keeps partitions visible when their parent is not load
|
||||
);
|
||||
});
|
||||
|
||||
test("buildTableTreeNodes keeps base-prefixed tables before prefixed variants", () => {
|
||||
const nodes = buildTableTreeNodes({
|
||||
nodeId: "conn:app:public",
|
||||
connectionId: "conn",
|
||||
database: "app",
|
||||
schema: "public",
|
||||
tables: [table("chat_staff"), table("chat_staff_his"), table("staff"), table("staff_his")],
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
nodes.map((node) => node.label),
|
||||
["staff", "staff_his", "chat_staff", "chat_staff_his"],
|
||||
);
|
||||
});
|
||||
|
||||
test("buildGroupedObjectTreeNodes nests partitions inside the tables group", () => {
|
||||
const groups = buildGroupedObjectTreeNodes({
|
||||
nodeId: "conn:app:public",
|
||||
@@ -96,3 +111,19 @@ test("buildGroupedObjectTreeNodes nests partitions inside the tables group", ()
|
||||
["events_2026"],
|
||||
);
|
||||
});
|
||||
|
||||
test("buildGroupedObjectTreeNodes applies prefix-priority sorting inside object groups", () => {
|
||||
const groups = buildGroupedObjectTreeNodes({
|
||||
nodeId: "conn:app:public",
|
||||
connectionId: "conn",
|
||||
database: "app",
|
||||
schema: "public",
|
||||
objects: [object("chat_staff"), object("chat_staff_his"), object("staff"), object("staff_his")],
|
||||
});
|
||||
|
||||
const tableGroup = groups.find((node) => node.type === "group-tables");
|
||||
assert.deepEqual(
|
||||
tableGroup?.children?.map((node) => node.label),
|
||||
["staff", "staff_his", "chat_staff", "chat_staff_his"],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
#!/usr/bin/env node
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import {
|
||||
buildSchemaContext,
|
||||
createBackend,
|
||||
@@ -9,6 +8,7 @@ import {
|
||||
evaluateSqlSafety,
|
||||
formatSchemaContext,
|
||||
getDbxDiagnostics,
|
||||
isMainModule,
|
||||
postBridge,
|
||||
sqlSafetyFromEnv,
|
||||
type Backend,
|
||||
@@ -406,7 +406,7 @@ async function main() {
|
||||
process.exitCode = result.exitCode;
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
if (isMainModule(import.meta.url, process.argv[1])) {
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exitCode = 1;
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, rm, symlink } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import test from "node:test";
|
||||
|
||||
const packageDir = fileURLToPath(new URL("..", import.meta.url));
|
||||
const cliSource = fileURLToPath(new URL("../src/cli.ts", import.meta.url));
|
||||
|
||||
test("prints version when invoked through an npm-style symlink", async () => {
|
||||
const bin = await symlinkedCli();
|
||||
try {
|
||||
const result = runDbx(bin, ["--version"]);
|
||||
|
||||
assert.equal(result.status, 0);
|
||||
assert.match(result.stdout, /^\d+\.\d+\.\d+\n$/);
|
||||
assert.equal(result.stderr, "");
|
||||
} finally {
|
||||
await rm(bin.dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("prints capabilities when invoked through an npm-style symlink", async () => {
|
||||
const bin = await symlinkedCli();
|
||||
try {
|
||||
const result = runDbx(bin, ["capabilities", "--json"]);
|
||||
|
||||
assert.equal(result.status, 0);
|
||||
assert.equal(result.stderr, "");
|
||||
const payload = JSON.parse(result.stdout) as { directQueryTypes: string[]; bridgeRequiredTypes: string[] };
|
||||
assert.ok(payload.directQueryTypes.includes("postgres"));
|
||||
assert.ok(payload.bridgeRequiredTypes.includes("oracle"));
|
||||
} finally {
|
||||
await rm(bin.dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
async function symlinkedCli() {
|
||||
const dir = await mkdtemp(join(tmpdir(), "dbx-cli-bin-"));
|
||||
const path = join(dir, "dbx");
|
||||
await symlink(cliSource, path);
|
||||
return { dir, path };
|
||||
}
|
||||
|
||||
function runDbx(bin: { path: string }, args: string[]) {
|
||||
return spawnSync(process.execPath, ["--import", "tsx", bin.path, ...args], {
|
||||
cwd: packageDir,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
}
|
||||
@@ -92,7 +92,7 @@ See the [DBX CLI README](../cli/README.md) for command details.
|
||||
|
||||
## SQL Safety
|
||||
|
||||
`dbx_execute_query` is read-only by default. To allow write statements such as `INSERT` or `UPDATE`, set:
|
||||
`dbx_execute_query` accepts multiple SQL statements and executes them one at a time after checking each statement. It is read-only by default. To allow write statements such as `INSERT` or `UPDATE`, set:
|
||||
|
||||
```bash
|
||||
DBX_MCP_ALLOW_WRITES=1
|
||||
@@ -213,7 +213,7 @@ dbx query local "select 1" --json
|
||||
|
||||
### SQL 安全
|
||||
|
||||
`dbx_execute_query` 默认只读。若要允许 `INSERT`、`UPDATE` 等写操作,设置:
|
||||
`dbx_execute_query` 支持多条 SQL 语句,会逐条完成安全检查并依次执行。默认只读。若要允许 `INSERT`、`UPDATE` 等写操作,设置:
|
||||
|
||||
```bash
|
||||
DBX_MCP_ALLOW_WRITES=1
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import { createRequire } from "node:module";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
buildSchemaContext,
|
||||
@@ -11,13 +10,16 @@ import {
|
||||
evaluateSqlSafety,
|
||||
formatCell,
|
||||
formatSchemaContext,
|
||||
isMainModule,
|
||||
mdTable,
|
||||
notifyReload,
|
||||
parseMongoAggregateCommand,
|
||||
postBridge,
|
||||
sqlSafetyFromEnv,
|
||||
splitSqlStatements,
|
||||
type Backend,
|
||||
type ConnectionConfig,
|
||||
type QueryResult,
|
||||
} from "@dbx-app/node-core";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
@@ -36,6 +38,13 @@ function withDatabase(config: ConnectionConfig, database?: string): ConnectionCo
|
||||
return database === undefined ? config : { ...config, database };
|
||||
}
|
||||
|
||||
function formatQueryToolResult(result: QueryResult, title?: string) {
|
||||
const prefix = title ? `${title}\n` : "";
|
||||
if (result.columns.length === 0) return text(`${prefix}Query executed. ${result.row_count} row(s) affected.`);
|
||||
const rows = result.rows.map((r) => result.columns.map((c) => formatCell(r[c])));
|
||||
return text(`${prefix}${mdTable(result.columns, rows)}\n\n${result.row_count} row(s)`);
|
||||
}
|
||||
|
||||
export const DBX_CONNECTION_TYPE_DESCRIPTION =
|
||||
"Database type: postgres, mysql, sqlite, redis, duckdb, clickhouse, sqlserver, mongodb, oracle, elasticsearch, doris, starrocks, redshift, dameng, kingbase, highgo, vastbase, goldendb, gaussdb, yashandb, databricks, saphana, teradata, vertica, firebird, exasol, opengauss, oceanbase-oracle, gbase, h2, snowflake, trino, hive, db2, informix, neo4j, cassandra, bigquery, kylin, sundb, tdengine, xugu, jdbc, access";
|
||||
|
||||
@@ -108,16 +117,19 @@ export function createDbxMcpServer(backend: Backend, options: { isWebMode?: bool
|
||||
const config = await backend.findConnection(connection_name);
|
||||
if (!config) return toolError("CONNECTION_NOT_FOUND", `Connection "${connection_name}" not found.`);
|
||||
if (config.db_type !== "mongodb") {
|
||||
const safety = evaluateSqlSafety(sql, sqlSafetyFromEnv());
|
||||
const safety = evaluateSqlSafety(sql, { ...sqlSafetyFromEnv(), allowMultipleStatements: true });
|
||||
if (!safety.allowed) return toolError("SQL_BLOCKED", safety.reason ?? "SQL blocked.");
|
||||
}
|
||||
// MongoDB shell commands don't fit the SQL safety evaluator; the backend
|
||||
// (node-core executeQuery) applies command-aware read/write gating.
|
||||
try {
|
||||
const result = await backend.executeQuery(withDatabase(config, database), sql);
|
||||
if (result.columns.length === 0) return text(`Query executed. ${result.row_count} row(s) affected.`);
|
||||
const rows = result.rows.map((r) => result.columns.map((c) => formatCell(r[c])));
|
||||
return text(`${mdTable(result.columns, rows)}\n\n${result.row_count} row(s)`);
|
||||
const statements = config.db_type === "mongodb" ? [sql] : splitSqlStatements(sql);
|
||||
const results = [];
|
||||
for (const statement of statements) {
|
||||
results.push(await backend.executeQuery(withDatabase(config, database), statement));
|
||||
}
|
||||
if (results.length === 1) return formatQueryToolResult(results[0]);
|
||||
return text(results.map((result, index) => formatQueryToolResult(result, `Statement ${index + 1}`).content[0].text).join("\n\n"));
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
return toolError("QUERY_ERROR", msg);
|
||||
@@ -235,7 +247,7 @@ export function createDbxMcpServer(backend: Backend, options: { isWebMode?: bool
|
||||
if (!safety.allowed) return toolError("SQL_BLOCKED", safety.reason ?? "Query blocked.");
|
||||
}
|
||||
} else {
|
||||
const safety = evaluateSqlSafety(sql, safetyOptions);
|
||||
const safety = evaluateSqlSafety(sql, { ...safetyOptions, allowMultipleStatements: true });
|
||||
if (!safety.allowed) return toolError("SQL_BLOCKED", safety.reason ?? "SQL blocked.");
|
||||
}
|
||||
// MongoDB shell commands bypass the SQL safety evaluator; pass MCP
|
||||
@@ -272,7 +284,7 @@ async function main() {
|
||||
await server.connect(transport);
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
if (isMainModule(import.meta.url, process.argv[1])) {
|
||||
main().catch((e) => {
|
||||
console.error("MCP Server failed to start:", e);
|
||||
process.exit(1);
|
||||
|
||||
@@ -74,6 +74,48 @@ test("execute query scopes the connection to the requested database", async () =
|
||||
assert.equal(usedDatabase, "stores_demo");
|
||||
});
|
||||
|
||||
test("execute query runs safe multi-statement SQL one statement at a time", async () => {
|
||||
const executed: string[] = [];
|
||||
const scopedBackend: Backend = {
|
||||
...backend,
|
||||
executeQuery: async (_config, sql) => {
|
||||
executed.push(sql);
|
||||
return { columns: ["value"], rows: [{ value: executed.length }], row_count: 1 };
|
||||
},
|
||||
};
|
||||
const server = createDbxMcpServer(scopedBackend, { isWebMode: true });
|
||||
|
||||
const result = await (server as any)._registeredTools.dbx_execute_query.handler({
|
||||
connection_name: "local",
|
||||
sql: "select 1; select 2;",
|
||||
});
|
||||
|
||||
assert.deepEqual(executed, ["select 1", "select 2"]);
|
||||
assert.match(result.content[0].text, /Statement 1/);
|
||||
assert.match(result.content[0].text, /Statement 2/);
|
||||
});
|
||||
|
||||
test("execute query reports the blocked statement number for unsafe multi-statement SQL", async () => {
|
||||
const oldAllowWrites = process.env.DBX_MCP_ALLOW_WRITES;
|
||||
process.env.DBX_MCP_ALLOW_WRITES = "1";
|
||||
const server = createDbxMcpServer(backend, { isWebMode: true });
|
||||
|
||||
try {
|
||||
const result = await (server as any)._registeredTools.dbx_execute_query.handler({
|
||||
connection_name: "local",
|
||||
sql: "select 1; delete from users;",
|
||||
});
|
||||
|
||||
assert.equal(result.isError, true);
|
||||
assert.match(result.content[0].text, /SQL_BLOCKED:/);
|
||||
assert.match(result.content[0].text, /Statement 2/);
|
||||
assert.match(result.content[0].text, /WHERE/);
|
||||
} finally {
|
||||
if (oldAllowWrites === undefined) delete process.env.DBX_MCP_ALLOW_WRITES;
|
||||
else process.env.DBX_MCP_ALLOW_WRITES = oldAllowWrites;
|
||||
}
|
||||
});
|
||||
|
||||
test("mongodb list tables returns collections from the selected database", async () => {
|
||||
let usedDatabase = "";
|
||||
const mongoConnection: ConnectionConfig = { ...connection, db_type: "mongodb", database: "admin" };
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"./connections": "./dist/connections.js",
|
||||
"./database": "./dist/database.js",
|
||||
"./diagnostics": "./dist/diagnostics.js",
|
||||
"./entrypoint": "./dist/entrypoint.js",
|
||||
"./format": "./dist/format.js",
|
||||
"./paths": "./dist/paths.js",
|
||||
"./schema-context": "./dist/schema-context.js",
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { realpathSync } from "node:fs";
|
||||
import { normalize, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export function isMainModule(moduleUrl: string, argvPath: string | undefined): boolean {
|
||||
if (!argvPath) return false;
|
||||
return normalizeEntryPath(fileURLToPath(moduleUrl)) === normalizeEntryPath(argvPath);
|
||||
}
|
||||
|
||||
function normalizeEntryPath(path: string): string {
|
||||
const normalized = normalize(realpathIfPossible(resolve(path)));
|
||||
return process.platform === "win32" ? normalized.toLowerCase() : normalized;
|
||||
}
|
||||
|
||||
function realpathIfPossible(path: string): string {
|
||||
try {
|
||||
return realpathSync.native(path);
|
||||
} catch {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ export * from "./bridge.js";
|
||||
export * from "./connections.js";
|
||||
export * from "./database.js";
|
||||
export * from "./diagnostics.js";
|
||||
export * from "./entrypoint.js";
|
||||
export * from "./format.js";
|
||||
export * from "./paths.js";
|
||||
export * from "./schema-context.js";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export interface SqlSafetyOptions {
|
||||
allowWrites?: boolean;
|
||||
allowDangerous?: boolean;
|
||||
allowMultipleStatements?: boolean;
|
||||
}
|
||||
|
||||
export interface SqlSafetyDecision {
|
||||
@@ -14,9 +15,26 @@ const DANGEROUS_KEYWORDS = new Set(["drop", "truncate", "alter"]);
|
||||
export function evaluateSqlSafety(sql: string, options: SqlSafetyOptions = {}): SqlSafetyDecision {
|
||||
const statements = splitSqlStatements(sql);
|
||||
if (statements.length === 0) return { allowed: false, reason: "SQL is empty." };
|
||||
if (statements.length > 1) return { allowed: false, reason: "Only one SQL statement is allowed per MCP query." };
|
||||
if (statements.length > 1 && !options.allowMultipleStatements) {
|
||||
return { allowed: false, reason: "Only one SQL statement is allowed per query." };
|
||||
}
|
||||
|
||||
const normalized = stripSqlCommentsAndStrings(statements[0]).trim();
|
||||
for (let i = 0; i < statements.length; i++) {
|
||||
const decision = evaluateSingleSqlStatementSafety(statements[i], options);
|
||||
if (!decision.allowed && statements.length > 1) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `Statement ${i + 1}: ${decision.reason ?? "SQL blocked."}`,
|
||||
};
|
||||
}
|
||||
if (!decision.allowed) return decision;
|
||||
}
|
||||
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
function evaluateSingleSqlStatementSafety(sql: string, options: SqlSafetyOptions = {}): SqlSafetyDecision {
|
||||
const normalized = stripSqlCommentsAndStrings(sql).trim();
|
||||
const firstKeyword = normalized.match(/^[a-zA-Z_]+/)?.[0]?.toLowerCase();
|
||||
if (!firstKeyword) return { allowed: false, reason: "SQL statement is not recognized." };
|
||||
|
||||
@@ -52,7 +70,7 @@ export function sqlSafetyFromEnv(env: NodeJS.ProcessEnv = process.env): SqlSafet
|
||||
};
|
||||
}
|
||||
|
||||
function splitSqlStatements(sql: string): string[] {
|
||||
export function splitSqlStatements(sql: string): string[] {
|
||||
const statements: string[] = [];
|
||||
let current = "";
|
||||
let quote: "'" | '"' | "`" | null = null;
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import test from "node:test";
|
||||
import { isMainModule } from "../src/entrypoint.js";
|
||||
|
||||
test("matches a module invoked through its real file path", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "dbx-entrypoint-"));
|
||||
try {
|
||||
const entry = join(dir, "cli.js");
|
||||
await writeFile(entry, "", "utf-8");
|
||||
|
||||
assert.equal(isMainModule(pathToFileURL(entry).href, entry), true);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("matches a module invoked through an npm-style symlink", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "dbx-entrypoint-"));
|
||||
try {
|
||||
const entry = join(dir, "dist", "cli.js");
|
||||
const bin = join(dir, "dbx");
|
||||
await mkdir(join(dir, "dist"));
|
||||
await writeFile(entry, "", "utf-8");
|
||||
await symlink(entry, bin);
|
||||
|
||||
assert.equal(isMainModule(pathToFileURL(entry).href, bin), true);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("does not match a different entry file", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "dbx-entrypoint-"));
|
||||
try {
|
||||
const entry = join(dir, "cli.js");
|
||||
const other = join(dir, "other.js");
|
||||
await writeFile(entry, "", "utf-8");
|
||||
await writeFile(other, "", "utf-8");
|
||||
|
||||
assert.equal(isMainModule(pathToFileURL(entry).href, other), false);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -29,3 +29,26 @@ test("blocks update without where when writes are enabled", () => {
|
||||
assert.match(decision.reason ?? "", /WHERE/i);
|
||||
});
|
||||
|
||||
test("blocks multiple SQL statements unless explicitly allowed", () => {
|
||||
const decision = evaluateSqlSafety("select 1; select 2");
|
||||
|
||||
assert.equal(decision.allowed, false);
|
||||
assert.match(decision.reason ?? "", /Only one SQL statement/);
|
||||
});
|
||||
|
||||
test("allows multiple read-only SQL statements when enabled", () => {
|
||||
const decision = evaluateSqlSafety("select 1; show tables", { allowMultipleStatements: true });
|
||||
|
||||
assert.equal(decision.allowed, true);
|
||||
});
|
||||
|
||||
test("checks every statement in a multi-statement SQL string", () => {
|
||||
const decision = evaluateSqlSafety("select 1; delete from users", {
|
||||
allowMultipleStatements: true,
|
||||
allowWrites: true,
|
||||
});
|
||||
|
||||
assert.equal(decision.allowed, false);
|
||||
assert.match(decision.reason ?? "", /Statement 2/i);
|
||||
assert.match(decision.reason ?? "", /WHERE/i);
|
||||
});
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
@@ -4,7 +4,7 @@ use dbx_core::storage::DesktopSettings;
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
use super::connection::AppState;
|
||||
use crate::apply_desktop_tray_preference;
|
||||
use crate::apply_desktop_settings;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn load_desktop_settings(state: State<'_, Arc<AppState>>) -> Result<DesktopSettings, String> {
|
||||
@@ -18,8 +18,8 @@ pub async fn save_desktop_settings(
|
||||
settings: DesktopSettings,
|
||||
) -> Result<(), String> {
|
||||
state.storage.save_desktop_settings(&settings).await?;
|
||||
if let Err(err) = apply_desktop_tray_preference(&app, settings.show_tray_icon) {
|
||||
eprintln!("Failed to apply desktop tray preference: {err}");
|
||||
if let Err(err) = apply_desktop_settings(&app, &settings) {
|
||||
eprintln!("Failed to apply desktop settings: {err}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
use tauri::State;
|
||||
|
||||
use crate::commands::connection::AppState;
|
||||
@@ -6,9 +7,6 @@ use dbx_core::db;
|
||||
use dbx_core::models::connection::DatabaseType;
|
||||
use dbx_core::sql::split_sql_statements;
|
||||
|
||||
// Re-export core functions for use by other modules (e.g., sql_file.rs)
|
||||
pub use dbx_core::query::execute_sql_statement;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn execute_query(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
@@ -66,6 +64,7 @@ pub async fn execute_multi(
|
||||
execution_id.as_ref().filter(|id| !id.trim().is_empty()).map(|id| state.running_queries.register(id.clone()));
|
||||
let cancel_token = registered_query.as_ref().map(|query| query.token());
|
||||
let trace_id = execution_id.as_deref().unwrap_or("no-execution-id");
|
||||
let started_at = Instant::now();
|
||||
log::info!(
|
||||
"[query][execute_multi:start] trace_id={} connection_id={} database={} schema={:?} sql={}",
|
||||
trace_id,
|
||||
@@ -94,12 +93,19 @@ pub async fn execute_multi(
|
||||
.await;
|
||||
match &result {
|
||||
Ok(results) => log::info!(
|
||||
"[query][execute_multi:done] trace_id={} result_count={} row_counts={:?}",
|
||||
"[query][execute_multi:done] trace_id={} elapsed_ms={} result_count={} row_counts={:?} backend_execution_times_ms={:?}",
|
||||
trace_id,
|
||||
started_at.elapsed().as_millis(),
|
||||
results.len(),
|
||||
results.iter().map(|result| result.rows.len()).collect::<Vec<_>>()
|
||||
results.iter().map(|result| result.rows.len()).collect::<Vec<_>>(),
|
||||
results.iter().map(|result| result.execution_time_ms).collect::<Vec<_>>()
|
||||
),
|
||||
Err(error) => log::error!(
|
||||
"[query][execute_multi:error] trace_id={} elapsed_ms={} error={}",
|
||||
trace_id,
|
||||
started_at.elapsed().as_millis(),
|
||||
error
|
||||
),
|
||||
Err(error) => log::error!("[query][execute_multi:error] trace_id={} error={}", trace_id, error),
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
@@ -8,30 +8,13 @@ use tokio::sync::RwLock;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::commands::connection::AppState;
|
||||
use crate::commands::query::execute_sql_statement;
|
||||
use dbx_core::models::connection::DatabaseType;
|
||||
use dbx_core::sql_file_import::{execute_sql_file_content, sql_file_error_progress, sql_file_progress};
|
||||
|
||||
pub use dbx_core::sql::{
|
||||
decode_sql_file_bytes, prepare_sql_file_statement, statement_summary, SqlFilePreview, SqlFileProgress,
|
||||
SqlFileRequest, SqlFileStatementAction, SqlFileStatus, SqlParsingOptions, SqlStatementSplitter,
|
||||
};
|
||||
pub use dbx_core::sql::{decode_sql_file_bytes, SqlFilePreview, SqlFileRequest, SqlFileStatus};
|
||||
|
||||
static SQL_FILE_EXECUTIONS: std::sync::LazyLock<RwLock<HashMap<String, CancellationToken>>> =
|
||||
std::sync::LazyLock::new(|| RwLock::new(HashMap::new()));
|
||||
|
||||
#[derive(Debug)]
|
||||
struct StatementErrorDecision {
|
||||
progress: Vec<SqlFileProgress>,
|
||||
failure_count: usize,
|
||||
result: Result<bool, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct SqlFileImportTarget {
|
||||
db_type: DatabaseType,
|
||||
driver_profile: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct SqlFileSummary {
|
||||
@@ -97,238 +80,26 @@ async fn execute_sql_file_inner(
|
||||
token: CancellationToken,
|
||||
started_at: Instant,
|
||||
) -> Result<(), String> {
|
||||
let mut statement_index = 0;
|
||||
let mut success_count = 0;
|
||||
let mut failure_count = 0;
|
||||
let mut affected_rows = 0;
|
||||
|
||||
let file_bytes = match tokio::fs::read(&request.file_path).await {
|
||||
Ok(bytes) => bytes,
|
||||
Err(error) => {
|
||||
let error = error.to_string();
|
||||
emit_file_io_error_progress(
|
||||
app,
|
||||
&request.execution_id,
|
||||
statement_index,
|
||||
success_count,
|
||||
failure_count,
|
||||
affected_rows,
|
||||
started_at,
|
||||
error.clone(),
|
||||
);
|
||||
emit_file_io_error_progress(app, &request.execution_id, started_at, error.clone());
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let file_content = match decode_sql_file_bytes(&file_bytes) {
|
||||
Ok(content) => content,
|
||||
Err(error) => {
|
||||
emit_file_io_error_progress(
|
||||
app,
|
||||
&request.execution_id,
|
||||
statement_index,
|
||||
success_count,
|
||||
failure_count,
|
||||
affected_rows,
|
||||
started_at,
|
||||
error.clone(),
|
||||
);
|
||||
emit_file_io_error_progress(app, &request.execution_id, started_at, error.clone());
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let import_target = sql_file_import_target(state.inner().as_ref(), &request.connection_id).await;
|
||||
let options =
|
||||
import_target.as_ref().map(|target| SqlParsingOptions::for_database_type(target.db_type)).unwrap_or_default();
|
||||
let mut splitter = SqlStatementSplitter::with_options(options);
|
||||
let mut statements = splitter.push_chunk(&file_content);
|
||||
statements.extend(splitter.finish());
|
||||
|
||||
for statement in statements {
|
||||
if token.is_cancelled() {
|
||||
emit_progress(
|
||||
app,
|
||||
&request.execution_id,
|
||||
SqlFileStatus::Cancelled,
|
||||
statement_index,
|
||||
success_count,
|
||||
failure_count,
|
||||
affected_rows,
|
||||
started_at,
|
||||
"",
|
||||
None,
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
statement_index += 1;
|
||||
if execute_statement_with_progress(
|
||||
app,
|
||||
state,
|
||||
request,
|
||||
&token,
|
||||
started_at,
|
||||
statement_index,
|
||||
&statement,
|
||||
import_target.as_ref(),
|
||||
&mut success_count,
|
||||
&mut failure_count,
|
||||
&mut affected_rows,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
emit_progress(
|
||||
app,
|
||||
&request.execution_id,
|
||||
SqlFileStatus::Done,
|
||||
statement_index,
|
||||
success_count,
|
||||
failure_count,
|
||||
affected_rows,
|
||||
started_at,
|
||||
"",
|
||||
None,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn sql_file_import_target(state: &AppState, connection_id: &str) -> Option<SqlFileImportTarget> {
|
||||
let configs = state.configs.read().await;
|
||||
configs
|
||||
.get(connection_id)
|
||||
.map(|config| SqlFileImportTarget { db_type: config.db_type, driver_profile: config.driver_profile.clone() })
|
||||
}
|
||||
|
||||
async fn execute_statement_with_progress(
|
||||
app: &AppHandle,
|
||||
state: &State<'_, Arc<AppState>>,
|
||||
request: &SqlFileRequest,
|
||||
token: &CancellationToken,
|
||||
started_at: Instant,
|
||||
statement_index: usize,
|
||||
statement: &str,
|
||||
import_target: Option<&SqlFileImportTarget>,
|
||||
success_count: &mut usize,
|
||||
failure_count: &mut usize,
|
||||
affected_rows: &mut u64,
|
||||
) -> Result<bool, String> {
|
||||
if token.is_cancelled() {
|
||||
let summary = statement_summary(statement);
|
||||
emit_progress(
|
||||
app,
|
||||
&request.execution_id,
|
||||
SqlFileStatus::Cancelled,
|
||||
statement_index,
|
||||
*success_count,
|
||||
*failure_count,
|
||||
*affected_rows,
|
||||
started_at,
|
||||
&summary,
|
||||
None,
|
||||
);
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
let statement_action = import_target
|
||||
.map(|target| prepare_sql_file_statement(statement, &target.db_type, target.driver_profile.as_deref()))
|
||||
.unwrap_or_else(|| SqlFileStatementAction::Execute(statement.to_string()));
|
||||
let executable_statement = match statement_action {
|
||||
SqlFileStatementAction::Execute(statement) => statement,
|
||||
SqlFileStatementAction::Skip => {
|
||||
let summary = statement_summary(statement);
|
||||
emit_progress(
|
||||
app,
|
||||
&request.execution_id,
|
||||
SqlFileStatus::Running,
|
||||
statement_index,
|
||||
*success_count,
|
||||
*failure_count,
|
||||
*affected_rows,
|
||||
started_at,
|
||||
&summary,
|
||||
None,
|
||||
);
|
||||
*success_count += 1;
|
||||
emit_progress(
|
||||
app,
|
||||
&request.execution_id,
|
||||
SqlFileStatus::StatementDone,
|
||||
statement_index,
|
||||
*success_count,
|
||||
*failure_count,
|
||||
*affected_rows,
|
||||
started_at,
|
||||
&summary,
|
||||
None,
|
||||
);
|
||||
return Ok(false);
|
||||
}
|
||||
};
|
||||
let summary = statement_summary(&executable_statement);
|
||||
|
||||
emit_progress(
|
||||
app,
|
||||
&request.execution_id,
|
||||
SqlFileStatus::Running,
|
||||
statement_index,
|
||||
*success_count,
|
||||
*failure_count,
|
||||
*affected_rows,
|
||||
started_at,
|
||||
&summary,
|
||||
None,
|
||||
);
|
||||
|
||||
match execute_sql_statement(
|
||||
state.inner().as_ref(),
|
||||
&request.connection_id,
|
||||
&request.database,
|
||||
&executable_statement,
|
||||
None,
|
||||
Some(token.clone()),
|
||||
)
|
||||
execute_sql_file_content(state.inner().as_ref(), request, &file_content, token, started_at, |progress| {
|
||||
let _ = app.emit("sql-file-progress", progress);
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
*success_count += 1;
|
||||
*affected_rows += result.affected_rows;
|
||||
emit_progress(
|
||||
app,
|
||||
&request.execution_id,
|
||||
SqlFileStatus::StatementDone,
|
||||
statement_index,
|
||||
*success_count,
|
||||
*failure_count,
|
||||
*affected_rows,
|
||||
started_at,
|
||||
&summary,
|
||||
None,
|
||||
);
|
||||
Ok(false)
|
||||
}
|
||||
Err(error) => {
|
||||
let decision = statement_error_decision(
|
||||
&request.execution_id,
|
||||
token,
|
||||
request.continue_on_error,
|
||||
started_at,
|
||||
statement_index,
|
||||
*success_count,
|
||||
*failure_count,
|
||||
*affected_rows,
|
||||
&summary,
|
||||
error,
|
||||
);
|
||||
|
||||
*failure_count = decision.failure_count;
|
||||
for progress in decision.progress {
|
||||
let _ = app.emit("sql-file-progress", progress);
|
||||
}
|
||||
decision.result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn register_sql_file_execution(
|
||||
@@ -348,68 +119,6 @@ fn remove_sql_file_execution(executions: &mut HashMap<String, CancellationToken>
|
||||
executions.remove(execution_id);
|
||||
}
|
||||
|
||||
fn statement_error_decision(
|
||||
execution_id: &str,
|
||||
token: &CancellationToken,
|
||||
continue_on_error: bool,
|
||||
started_at: Instant,
|
||||
statement_index: usize,
|
||||
success_count: usize,
|
||||
failure_count: usize,
|
||||
affected_rows: u64,
|
||||
summary: &str,
|
||||
error: String,
|
||||
) -> StatementErrorDecision {
|
||||
if token.is_cancelled() {
|
||||
return StatementErrorDecision {
|
||||
progress: vec![sql_file_progress(
|
||||
execution_id,
|
||||
SqlFileStatus::Cancelled,
|
||||
statement_index,
|
||||
success_count,
|
||||
failure_count,
|
||||
affected_rows,
|
||||
started_at,
|
||||
summary,
|
||||
None,
|
||||
)],
|
||||
failure_count,
|
||||
result: Ok(true),
|
||||
};
|
||||
}
|
||||
|
||||
let failure_count = failure_count + 1;
|
||||
let statement_failed = sql_file_progress(
|
||||
execution_id,
|
||||
SqlFileStatus::StatementFailed,
|
||||
statement_index,
|
||||
success_count,
|
||||
failure_count,
|
||||
affected_rows,
|
||||
started_at,
|
||||
summary,
|
||||
Some(error.clone()),
|
||||
);
|
||||
|
||||
if continue_on_error {
|
||||
return StatementErrorDecision { progress: vec![statement_failed], failure_count, result: Ok(false) };
|
||||
}
|
||||
|
||||
let terminal_error = sql_file_progress(
|
||||
execution_id,
|
||||
SqlFileStatus::Error,
|
||||
statement_index,
|
||||
success_count,
|
||||
failure_count,
|
||||
affected_rows,
|
||||
started_at,
|
||||
summary,
|
||||
Some(error.clone()),
|
||||
);
|
||||
|
||||
StatementErrorDecision { progress: vec![statement_failed, terminal_error], failure_count, result: Err(error) }
|
||||
}
|
||||
|
||||
fn emit_progress(
|
||||
app: &AppHandle,
|
||||
execution_id: &str,
|
||||
@@ -438,74 +147,8 @@ fn emit_progress(
|
||||
);
|
||||
}
|
||||
|
||||
fn emit_file_io_error_progress(
|
||||
app: &AppHandle,
|
||||
execution_id: &str,
|
||||
statement_index: usize,
|
||||
success_count: usize,
|
||||
failure_count: usize,
|
||||
affected_rows: u64,
|
||||
started_at: Instant,
|
||||
error: String,
|
||||
) {
|
||||
let _ = app.emit(
|
||||
"sql-file-progress",
|
||||
file_io_error_progress(
|
||||
execution_id,
|
||||
statement_index,
|
||||
success_count,
|
||||
failure_count,
|
||||
affected_rows,
|
||||
started_at,
|
||||
error,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
fn file_io_error_progress(
|
||||
execution_id: &str,
|
||||
statement_index: usize,
|
||||
success_count: usize,
|
||||
failure_count: usize,
|
||||
affected_rows: u64,
|
||||
started_at: Instant,
|
||||
error: String,
|
||||
) -> SqlFileProgress {
|
||||
sql_file_progress(
|
||||
execution_id,
|
||||
SqlFileStatus::Error,
|
||||
statement_index,
|
||||
success_count,
|
||||
failure_count,
|
||||
affected_rows,
|
||||
started_at,
|
||||
"",
|
||||
Some(error),
|
||||
)
|
||||
}
|
||||
|
||||
fn sql_file_progress(
|
||||
execution_id: &str,
|
||||
status: SqlFileStatus,
|
||||
statement_index: usize,
|
||||
success_count: usize,
|
||||
failure_count: usize,
|
||||
affected_rows: u64,
|
||||
started_at: Instant,
|
||||
statement_summary: &str,
|
||||
error: Option<String>,
|
||||
) -> SqlFileProgress {
|
||||
SqlFileProgress {
|
||||
execution_id: execution_id.to_string(),
|
||||
status,
|
||||
statement_index,
|
||||
success_count,
|
||||
failure_count,
|
||||
affected_rows,
|
||||
elapsed_ms: started_at.elapsed().as_millis(),
|
||||
statement_summary: statement_summary.to_string(),
|
||||
error,
|
||||
}
|
||||
fn emit_file_io_error_progress(app: &AppHandle, execution_id: &str, started_at: Instant, error: String) {
|
||||
let _ = app.emit("sql-file-progress", sql_file_error_progress(execution_id, started_at, error));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -599,14 +242,14 @@ mod execution_tests {
|
||||
|
||||
#[test]
|
||||
fn file_io_errors_build_terminal_error_progress() {
|
||||
let progress = file_io_error_progress("exec-1", 4, 2, 1, 17, Instant::now(), "read failed".to_string());
|
||||
let progress = sql_file_error_progress("exec-1", Instant::now(), "read failed".to_string());
|
||||
|
||||
assert_eq!(progress.execution_id, "exec-1");
|
||||
assert_eq!(progress.status, SqlFileStatus::Error);
|
||||
assert_eq!(progress.statement_index, 4);
|
||||
assert_eq!(progress.success_count, 2);
|
||||
assert_eq!(progress.failure_count, 1);
|
||||
assert_eq!(progress.affected_rows, 17);
|
||||
assert_eq!(progress.statement_index, 0);
|
||||
assert_eq!(progress.success_count, 0);
|
||||
assert_eq!(progress.failure_count, 0);
|
||||
assert_eq!(progress.affected_rows, 0);
|
||||
assert_eq!(progress.statement_summary, "");
|
||||
assert_eq!(progress.error, Some("read failed".to_string()));
|
||||
}
|
||||
@@ -627,70 +270,4 @@ mod execution_tests {
|
||||
assert!(original.is_cancelled());
|
||||
assert!(!replacement.is_cancelled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_on_error_returns_err_with_terminal_error_progress() {
|
||||
let decision = statement_error_decision(
|
||||
"exec-1",
|
||||
&CancellationToken::new(),
|
||||
false,
|
||||
Instant::now(),
|
||||
3,
|
||||
1,
|
||||
0,
|
||||
5,
|
||||
"bad statement",
|
||||
"syntax error".to_string(),
|
||||
);
|
||||
|
||||
assert_eq!(decision.failure_count, 1);
|
||||
assert_eq!(decision.result, Err("syntax error".to_string()));
|
||||
assert_eq!(decision.progress.len(), 2);
|
||||
assert_eq!(decision.progress[0].status, SqlFileStatus::StatementFailed);
|
||||
assert_eq!(decision.progress[1].status, SqlFileStatus::Error);
|
||||
assert_eq!(decision.progress[1].error, Some("syntax error".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelled_in_flight_error_does_not_increment_failure_count() {
|
||||
let token = CancellationToken::new();
|
||||
token.cancel();
|
||||
|
||||
let decision = statement_error_decision(
|
||||
"exec-1",
|
||||
&token,
|
||||
false,
|
||||
Instant::now(),
|
||||
2,
|
||||
1,
|
||||
4,
|
||||
9,
|
||||
"slow statement",
|
||||
"Query canceled".to_string(),
|
||||
);
|
||||
|
||||
assert_eq!(decision.failure_count, 4);
|
||||
assert_eq!(decision.result, Ok(true));
|
||||
assert_eq!(decision.progress.len(), 1);
|
||||
assert_eq!(decision.progress[0].status, SqlFileStatus::Cancelled);
|
||||
assert_eq!(decision.progress[0].failure_count, 4);
|
||||
assert_eq!(decision.progress[0].error, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_payload_serializes_camel_case_status() {
|
||||
let progress =
|
||||
sql_file_progress("exec-1", SqlFileStatus::StatementDone, 1, 1, 0, 3, Instant::now(), "select 1", None);
|
||||
|
||||
let value = serde_json::to_value(progress).unwrap();
|
||||
|
||||
assert_eq!(value["executionId"], "exec-1");
|
||||
assert_eq!(value["statementIndex"], 1);
|
||||
assert_eq!(value["successCount"], 1);
|
||||
assert_eq!(value["failureCount"], 0);
|
||||
assert_eq!(value["affectedRows"], 3);
|
||||
assert_eq!(value["statementSummary"], "select 1");
|
||||
assert_eq!(value["status"], "statementDone");
|
||||
assert!(value.get("execution_id").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
+88
-11
@@ -5,7 +5,7 @@ mod models;
|
||||
mod window_state_guard;
|
||||
|
||||
use commands::connection::AppState;
|
||||
use dbx_core::storage::Storage;
|
||||
use dbx_core::storage::{DesktopIconTheme, DesktopSettings, Storage};
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -21,6 +21,7 @@ use tauri_plugin_deep_link::DeepLinkExt;
|
||||
const DESKTOP_TRAY_ID: &str = "main-tray";
|
||||
#[cfg(target_os = "macos")]
|
||||
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");
|
||||
|
||||
fn should_hide_window_on_close(target_os: &str) -> bool {
|
||||
matches!(target_os, "macos" | "windows")
|
||||
@@ -54,15 +55,35 @@ fn open_connection_deep_links(app: &tauri::AppHandle, links: Vec<String>) {
|
||||
}
|
||||
|
||||
#[cfg_attr(not(any(target_os = "macos", target_os = "windows")), allow(dead_code))]
|
||||
fn setup_desktop_tray<R: tauri::Runtime, M: Manager<R>>(manager: &M) -> tauri::Result<()> {
|
||||
fn setup_desktop_tray<R: tauri::Runtime, M: Manager<R>>(
|
||||
manager: &M,
|
||||
icon_theme: DesktopIconTheme,
|
||||
) -> tauri::Result<()> {
|
||||
let menu = MenuBuilder::new(manager).text("show", "Show DBX").separator().text("quit", "Quit DBX").build()?;
|
||||
let mut tray =
|
||||
TrayIconBuilder::<R>::with_id(DESKTOP_TRAY_ID).tooltip("DBX").menu(&menu).show_menu_on_left_click(false);
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
tray = tray.icon(MACOS_TRAY_ICON).icon_as_template(true);
|
||||
match icon_theme {
|
||||
DesktopIconTheme::Default => {
|
||||
tray = tray.icon(MACOS_TRAY_ICON).icon_as_template(true);
|
||||
}
|
||||
DesktopIconTheme::Black => {
|
||||
tray = tray.icon(BLACK_APP_ICON).icon_as_template(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let icon = match icon_theme {
|
||||
DesktopIconTheme::Default => manager.app_handle().default_window_icon().cloned(),
|
||||
DesktopIconTheme::Black => Some(BLACK_APP_ICON),
|
||||
};
|
||||
if let Some(icon) = icon {
|
||||
tray = tray.icon(icon);
|
||||
}
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
{
|
||||
if let Some(icon) = manager.app_handle().default_window_icon().cloned() {
|
||||
tray = tray.icon(icon);
|
||||
@@ -86,12 +107,59 @@ fn setup_desktop_tray<R: tauri::Runtime, M: Manager<R>>(manager: &M) -> tauri::R
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn apply_desktop_tray_preference(app: &tauri::AppHandle, show_tray_icon: bool) -> tauri::Result<()> {
|
||||
fn apply_desktop_icon_theme(app: &tauri::AppHandle, icon_theme: DesktopIconTheme) -> tauri::Result<()> {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
match icon_theme {
|
||||
DesktopIconTheme::Default => {
|
||||
if let Some(icon) = app.default_window_icon().cloned() {
|
||||
window.set_icon(icon)?;
|
||||
}
|
||||
}
|
||||
DesktopIconTheme::Black => window.set_icon(BLACK_APP_ICON)?,
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_desktop_tray_icon_theme(app: &tauri::AppHandle, icon_theme: DesktopIconTheme) -> tauri::Result<()> {
|
||||
if let Some(tray) = app.tray_by_id(DESKTOP_TRAY_ID) {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
match icon_theme {
|
||||
DesktopIconTheme::Default => {
|
||||
tray.set_icon(Some(MACOS_TRAY_ICON))?;
|
||||
tray.set_icon_as_template(true)?;
|
||||
}
|
||||
DesktopIconTheme::Black => {
|
||||
tray.set_icon(Some(BLACK_APP_ICON))?;
|
||||
tray.set_icon_as_template(false)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let icon = match icon_theme {
|
||||
DesktopIconTheme::Default => app.default_window_icon().cloned(),
|
||||
DesktopIconTheme::Black => Some(BLACK_APP_ICON),
|
||||
};
|
||||
tray.set_icon(icon)?;
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
{
|
||||
let _ = (tray, icon_theme);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn apply_desktop_settings(app: &tauri::AppHandle, desktop_settings: &DesktopSettings) -> tauri::Result<()> {
|
||||
apply_desktop_icon_theme(app, desktop_settings.icon_theme)?;
|
||||
if matches!(std::env::consts::OS, "macos" | "windows") {
|
||||
if let Some(tray) = app.tray_by_id(DESKTOP_TRAY_ID) {
|
||||
tray.set_visible(show_tray_icon)?;
|
||||
} else if show_tray_icon {
|
||||
setup_desktop_tray(app)?;
|
||||
tray.set_visible(desktop_settings.show_tray_icon)?;
|
||||
apply_desktop_tray_icon_theme(app, desktop_settings.icon_theme)?;
|
||||
} else if desktop_settings.show_tray_icon {
|
||||
setup_desktop_tray(app, desktop_settings.icon_theme)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -121,7 +189,7 @@ mod tests {
|
||||
assert!(!should_setup_desktop_tray("linux", true));
|
||||
let source = include_str!("lib.rs");
|
||||
assert!(source.contains(
|
||||
"if should_setup_desktop_tray(std::env::consts::OS, desktop_settings.show_tray_icon) {\n setup_desktop_tray(app)?;"
|
||||
"if should_setup_desktop_tray(std::env::consts::OS, desktop_settings.show_tray_icon) {\n setup_desktop_tray(app, desktop_settings.icon_theme)?;"
|
||||
));
|
||||
}
|
||||
|
||||
@@ -133,10 +201,18 @@ mod tests {
|
||||
assert!(!source.contains(remove_call));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn can_apply_black_logo_icon_theme() {
|
||||
let source = include_str!("lib.rs");
|
||||
assert!(source.contains("const BLACK_APP_ICON"));
|
||||
assert!(source.contains("DesktopIconTheme::Black => window.set_icon(BLACK_APP_ICON)?"));
|
||||
assert!(source.contains("DesktopIconTheme::Black => Some(BLACK_APP_ICON)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_settings_save_treats_runtime_tray_update_as_best_effort() {
|
||||
let source = include_str!("commands/app_settings.rs");
|
||||
assert!(source.contains("if let Err(err) = apply_desktop_tray_preference"));
|
||||
assert!(source.contains("if let Err(err) = apply_desktop_settings"));
|
||||
assert!(!source.contains("map_err(|err| err.to_string())"));
|
||||
}
|
||||
|
||||
@@ -233,8 +309,9 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
if should_setup_desktop_tray(std::env::consts::OS, desktop_settings.show_tray_icon) {
|
||||
setup_desktop_tray(app)?;
|
||||
setup_desktop_tray(app, desktop_settings.icon_theme)?;
|
||||
}
|
||||
apply_desktop_icon_theme(app.handle(), desktop_settings.icon_theme)?;
|
||||
window_state_guard::enforce_main_window_bounds(app.handle());
|
||||
if should_show_main_window_after_setup() {
|
||||
show_main_window(app.handle());
|
||||
|
||||
Reference in New Issue
Block a user