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:
vrustx
2026-05-31 17:24:07 +08:00
52 changed files with 2915 additions and 1044 deletions
@@ -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;
});
+13
View File
@@ -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.",
+13
View File
@@ -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.",
+13
View File
@@ -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: "数据表格显示",
+118
View File
@@ -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 };
}
+1 -1
View File
@@ -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> {
+7 -3
View File
@@ -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);
});
}
+8 -16
View File
@@ -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");
+115 -5
View File
@@ -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}`,
+1
View File
@@ -97,6 +97,7 @@ export interface DriverStoreUsage {
export interface DesktopSettings {
show_tray_icon: boolean;
icon_theme: "default" | "black";
}
export interface WebDavConfig {
+142 -55
View File
@@ -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);
+15 -3
View File
@@ -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;