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 committed 2026-05-31 17:24:07 +08:00
commit e36e42723a
52 files changed
+2915 -1044

No files matched your search

@@ -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);