feat: optimize connection error alerts

This commit is contained in:
Wu Clan
2026-05-05 17:07:58 +08:00
parent 3acd9280ac
commit cc70e4b59c
16 changed files with 352 additions and 12 deletions
+16 -4
View File
@@ -215,9 +215,15 @@ function newQuery() {
async function openConnectionQuery(connectionId: string) {
const connection = connectionStore.getConfig(connectionId);
if (!connection) return;
const options = await getDatabaseOptions(connectionId);
connectionStore.activeConnectionId = connectionId;
queryStore.createTab(connectionId, connection.database || options[0] || "");
const tabId = queryStore.createTab(connectionId, connection.database || "");
try {
await connectionStore.ensureConnected(connectionId);
const options = await getDatabaseOptions(connectionId);
if (!connection.database && options[0]) queryStore.updateDatabase(tabId, options[0]);
} catch (e: any) {
toast(t("connection.connectFailed", { message: e?.message || String(e) }), 5000);
}
}
async function changeActiveConnection(connectionId: string) {
@@ -225,9 +231,15 @@ async function changeActiveConnection(connectionId: string) {
if (!tab) return;
const connection = connectionStore.getConfig(connectionId);
if (!connection) return;
const options = await getDatabaseOptions(connectionId);
queryStore.updateConnection(tab.id, connectionId, connection.database || options[0] || "");
queryStore.updateConnection(tab.id, connectionId, connection.database || "");
connectionStore.activeConnectionId = connectionId;
try {
await connectionStore.ensureConnected(connectionId);
const options = await getDatabaseOptions(connectionId);
if (!connection.database && options[0]) queryStore.updateDatabase(tab.id, options[0]);
} catch (e: any) {
toast(t("connection.connectFailed", { message: e?.message || String(e) }), 5000);
}
}
function changeActiveDatabase(database: string) {
@@ -0,0 +1,74 @@
<script setup lang="ts">
import { computed } from "vue";
import { useI18n } from "vue-i18n";
import { AlertTriangle, X } from "lucide-vue-next";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { useConnectionStore } from "@/stores/connectionStore";
const props = withDefaults(
defineProps<{
connectionId?: string | null;
triggerClass?: string;
}>(),
{
connectionId: null,
triggerClass: "",
},
);
const { t } = useI18n();
const connectionStore = useConnectionStore();
const errorMessage = computed(() =>
props.connectionId
? connectionStore.connectionErrors[props.connectionId]
: "",
);
function clearError() {
if (props.connectionId)
connectionStore.clearConnectionError(props.connectionId);
}
</script>
<template>
<Popover v-if="errorMessage">
<PopoverTrigger as-child>
<button
type="button"
class="inline-flex h-4 w-4 shrink-0 items-center justify-center rounded text-amber-500 hover:bg-amber-500/10 hover:text-amber-600 focus:outline-none focus:ring-1 focus:ring-amber-500/40"
:class="triggerClass"
:title="t('connection.lastError')"
@click.stop
>
<AlertTriangle class="h-3.5 w-3.5" />
</button>
</PopoverTrigger>
<PopoverContent side="top" class="w-72 gap-2 p-2 text-xs" @click.stop>
<div class="flex items-start gap-2">
<div class="min-w-0 flex-1">
<div class="font-medium text-foreground">
{{ t("connection.lastError") }}
</div>
<div
class="mt-1 max-h-36 overflow-auto whitespace-pre-wrap break-words text-muted-foreground"
>
{{ errorMessage }}
</div>
</div>
<button
type="button"
class="shrink-0 rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground"
:title="t('connection.clearError')"
@click="clearError"
>
<X class="h-3.5 w-3.5" />
</button>
</div>
</PopoverContent>
</Popover>
</template>
+8 -1
View File
@@ -42,6 +42,7 @@ import { useConnectionStore } from "@/stores/connectionStore";
import { connectionIconType } from "@/lib/connectionPresentation";
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
import { useQueryStore } from "@/stores/queryStore";
import { useToast } from "@/composables/useToast";
import { buildAiContext, runAiStream, type AiAction } from "@/lib/ai";
import {
aiTestConnection,
@@ -59,6 +60,7 @@ const { t } = useI18n();
const settings = useSettingsStore();
const connectionStore = useConnectionStore();
const queryStore = useQueryStore();
const { toast } = useToast();
interface ChatMessage {
role: "user" | "assistant";
@@ -133,7 +135,7 @@ async function loadDatabases() {
await loadDatabaseOptions(props.connection.id);
}
function changeConnection(connectionId: string) {
async function changeConnection(connectionId: string) {
const conn = connectionStore.getConfig(connectionId);
if (!conn) return;
connectionStore.activeConnectionId = connectionId;
@@ -143,6 +145,11 @@ function changeConnection(connectionId: string) {
} else {
queryStore.createTab(connectionId, conn.database || "");
}
try {
await loadDatabaseOptions(connectionId);
} catch (e: any) {
toast(t("connection.connectFailed", { message: e?.message || String(e) }), 5000);
}
}
function changeDatabase(database: string) {
+13 -3
View File
@@ -60,6 +60,7 @@ import { qualifiedTableName as buildQualifiedTableName, quoteTableIdentifier } f
import { treeNodeRowAction } from "@/lib/treeNodeClick";
import { isTauriRuntime } from "@/lib/tauriRuntime";
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
import ConnectionErrorIndicator from "@/components/connection/ConnectionErrorIndicator.vue";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -304,9 +305,13 @@ async function openData() {
async function newQuery() {
const node = props.node;
if (!node.connectionId) return;
await connectionStore.ensureConnected(node.connectionId);
connectionStore.activeConnectionId = node.connectionId;
queryStore.createTab(node.connectionId, node.database || "", undefined, "query");
try {
await connectionStore.ensureConnected(node.connectionId);
connectionStore.activeConnectionId = node.connectionId;
queryStore.createTab(node.connectionId, node.database || "", undefined, "query");
} catch (e: any) {
toast(t("connection.connectFailed", { message: e?.message || String(e) }), 5000);
}
}
async function refresh() {
@@ -919,6 +924,11 @@ const isDragging = computed(() => dragState.active && dragState.draggedId === pr
"
class="w-1.5 h-1.5 rounded-full bg-green-500 shrink-0"
/>
<ConnectionErrorIndicator
v-if="node.type === 'connection'"
:connection-id="node.connectionId"
trigger-class="h-4 w-4"
/>
<button
v-if="canPin"
class="rounded p-0.5 text-muted-foreground hover:bg-muted-foreground/15 hover:text-foreground focus:opacity-100"
+19
View File
@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { PopoverRootEmits, PopoverRootProps } from 'reka-ui'
import { PopoverRoot, useForwardPropsEmits } from 'reka-ui'
const props = defineProps<PopoverRootProps>()
const emits = defineEmits<PopoverRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<PopoverRoot
v-slot="slotProps"
data-slot="popover"
v-bind="forwarded"
>
<slot v-bind="slotProps" />
</PopoverRoot>
</template>
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { PopoverAnchorProps } from 'reka-ui'
import { PopoverAnchor } from 'reka-ui'
const props = defineProps<PopoverAnchorProps>()
</script>
<template>
<PopoverAnchor
data-slot="popover-anchor"
v-bind="props"
>
<slot />
</PopoverAnchor>
</template>
@@ -0,0 +1,45 @@
<script setup lang="ts">
import type { PopoverContentEmits, PopoverContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import {
PopoverContent,
PopoverPortal,
useForwardPropsEmits,
} from 'reka-ui'
import { cn } from '@/lib/utils'
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(
defineProps<PopoverContentProps & { class?: HTMLAttributes['class'] }>(),
{
align: 'center',
sideOffset: 4,
},
)
const emits = defineEmits<PopoverContentEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<PopoverPortal>
<PopoverContent
data-slot="popover-content"
v-bind="{ ...$attrs, ...forwarded }"
:class="
cn(
'bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 flex flex-col gap-2.5 rounded-lg p-2.5 text-sm shadow-md ring-1 duration-100 z-50 w-72 origin-(--reka-popover-content-transform-origin) outline-hidden',
props.class,
)
"
>
<slot />
</PopoverContent>
</PopoverPortal>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<p
data-slot="popover-description"
:class="cn('text-muted-foreground', props.class)"
>
<slot />
</p>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="popover-header"
:class="cn('flex flex-col gap-0.5 text-sm', props.class)"
>
<slot />
</div>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="popover-title"
:class="cn('font-medium cn-font-heading', props.class)"
>
<slot />
</div>
</template>
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { PopoverTriggerProps } from 'reka-ui'
import { PopoverTrigger } from 'reka-ui'
const props = defineProps<PopoverTriggerProps>()
</script>
<template>
<PopoverTrigger
data-slot="popover-trigger"
v-bind="props"
>
<slot />
</PopoverTrigger>
</template>
+7
View File
@@ -0,0 +1,7 @@
export { default as Popover } from './Popover.vue'
export { default as PopoverAnchor } from './PopoverAnchor.vue'
export { default as PopoverContent } from './PopoverContent.vue'
export { default as PopoverDescription } from './PopoverDescription.vue'
export { default as PopoverHeader } from './PopoverHeader.vue'
export { default as PopoverTitle } from './PopoverTitle.vue'
export { default as PopoverTrigger } from './PopoverTrigger.vue'
+2
View File
@@ -76,6 +76,8 @@ export default {
connecting: "Connecting to {name}...",
connectSuccess: "Connected to {name}",
connectFailed: "Connection failed: {message}",
lastError: "Connection error",
clearError: "Clear connection error",
saveFailed: "Failed to save connection: {message}",
loadFailed: "Failed to load saved connections: {message}",
sshTunnel: "SSH Tunnel",
+2
View File
@@ -75,6 +75,8 @@ export default {
connecting: "正在连接 {name}...",
connectSuccess: "已连接 {name}",
connectFailed: "连接失败:{message}",
lastError: "连接错误",
clearError: "清除连接错误",
saveFailed: "保存连接失败:{message}",
loadFailed: "加载已保存连接失败:{message}",
sshTunnel: "SSH 隧道",
+51 -4
View File
@@ -35,6 +35,7 @@ export const useConnectionStore = defineStore("connection", () => {
const treeNodes = ref<TreeNode[]>([]);
const pinnedTreeNodeIds = ref<Set<string>>(loadPinnedTreeNodeIds());
const connectedIds = ref<Set<string>>(new Set());
const connectionErrors = ref<Record<string, string>>({});
const editingConnectionId = ref<string | null>(null);
const completionTablesCache = ref<Record<string, SqlCompletionTable[]>>({});
const completionColumnsCache = ref<Record<string, ColumnInfo[]>>({});
@@ -86,6 +87,31 @@ export const useConnectionStore = defineStore("connection", () => {
return connections.value.find((c) => c.id === connectionId);
}
function connectionErrorMessage(error: unknown): string {
if (error instanceof Error) return error.message;
return String(error);
}
function setConnectionError(connectionId: string, message: string) {
connectionErrors.value = {
...connectionErrors.value,
[connectionId]: message,
};
}
function clearConnectionError(connectionId: string) {
if (!connectionErrors.value[connectionId]) return;
const next = { ...connectionErrors.value };
delete next[connectionId];
connectionErrors.value = next;
}
function recordConnectionError(connectionId: string, error: unknown): string {
const message = connectionErrorMessage(error);
setConnectionError(connectionId, message);
return message;
}
function normalizeConnection(config: ConnectionConfig): ConnectionConfig {
const labelMap: Record<string, string> = {
mysql: "MySQL",
@@ -203,6 +229,7 @@ export const useConnectionStore = defineStore("connection", () => {
const nextConnections = connections.value.filter((c) => c.id !== id);
await persistConnections(nextConnections);
connections.value = nextConnections;
clearConnectionError(id);
sidebarLayout.value = removeConnectionFromSidebarLayout(sidebarLayout.value, id);
rebuildTreeNodes();
persistSidebarLayoutDebounced();
@@ -233,6 +260,8 @@ export const useConnectionStore = defineStore("connection", () => {
const id = await api.connectDb(config);
activeConnectionId.value = id;
connectedIds.value.add(id);
clearConnectionError(config.id);
if (id !== config.id) clearConnectionError(id);
const node: TreeNode = {
id,
@@ -249,6 +278,9 @@ export const useConnectionStore = defineStore("connection", () => {
treeNodes.value.push(node);
}
return id;
} catch (e) {
recordConnectionError(config.id, e);
throw e;
} finally {
const node = findNode(treeNodes.value, config.id);
if (node) node.isLoading = false;
@@ -272,10 +304,20 @@ export const useConnectionStore = defineStore("connection", () => {
async function ensureConnected(connectionId: string) {
if (connectedIds.value.has(connectionId)) return;
const config = getConfig(connectionId);
if (!config) throw new Error("Connection config not found");
await api.connectDb(config);
connectedIds.value.add(connectionId);
activeConnectionId.value = connectionId;
if (!config) {
const error = new Error("Connection config not found");
recordConnectionError(connectionId, error);
throw error;
}
try {
await api.connectDb(config);
connectedIds.value.add(connectionId);
activeConnectionId.value = connectionId;
clearConnectionError(connectionId);
} catch (e) {
recordConnectionError(connectionId, e);
throw e;
}
}
async function loadDatabases(connectionId: string) {
@@ -858,6 +900,7 @@ export const useConnectionStore = defineStore("connection", () => {
connections.value.push(normalized);
}
connectedIds.value.add(normalized.id);
clearConnectionError(normalized.id);
}
return {
@@ -865,6 +908,10 @@ export const useConnectionStore = defineStore("connection", () => {
activeConnectionId,
treeNodes,
connectedIds,
connectionErrors,
setConnectionError,
clearConnectionError,
recordConnectionError,
sidebarLayout,
getConfig,
isTreeNodePinned,