feat(query): jump to PostgreSQL error position from result messages

This commit is contained in:
amwps290 authored and GitHub committed 2026-09-15 17:39:59 +08:00
1 parent 571fc07d85
commit 5a0e74eec6
36 files changed
+2079 -23

No files matched your search

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed } from "vue";
import { Bot, Wrench } from "@lucide/vue";
import { Bot, LocateFixed, Wrench } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import type { BackendError } from "@/lib/backend/errorUtils";
@@ -10,20 +10,28 @@ const props = defineProps<{
errorMessage: string;
backendError?: BackendError;
connectionId?: string;
/** Driver-reported error position, when the backend provides one. */
errorPosition?: { line: number; column: number };
}>();
const emit = defineEmits<{
changeConnectionTimeout: [];
changeQueryTimeout: [];
fixWithAi: [errorMessage: string];
locateError: [];
}>();
const { t } = useI18n();
const showConnectionTimeout = computed(() => !!props.connectionId && isConnectionTimeoutErrorMessage(props.errorMessage, props.backendError));
const showQueryTimeout = computed(() => !!props.connectionId && !showConnectionTimeout.value && isQueryTimeoutErrorMessage(props.errorMessage, props.backendError));
const showLocateError = computed(() => !!props.errorPosition);
</script>
<template>
<Button v-if="showLocateError" variant="outline" size="sm" class="h-7 gap-1.5 px-2.5 text-xs" @click="emit('locateError')">
<LocateFixed class="h-3.5 w-3.5" />
{{ t("editor.locateError", { line: errorPosition!.line, column: errorPosition!.column }) }}
</Button>
<Button v-if="showConnectionTimeout" variant="outline" size="sm" class="h-7 gap-1.5 px-2.5 text-xs" @click="emit('changeConnectionTimeout')">
<Wrench class="h-3.5 w-3.5" />
{{ t("editor.changeConnectionTimeout") }}
@@ -7,17 +7,26 @@ import i18n from "@/i18n";
const mountedApps: App[] = [];
async function mountActions(errorMessage: string, onChangeConnectionTimeout = vi.fn()) {
async function mountActions(errorMessage: string, options: { onChangeConnectionTimeout?: () => void; errorPosition?: { line: number; column: number }; onLocateError?: () => void } = {}) {
const onChangeConnectionTimeout = options.onChangeConnectionTimeout ?? vi.fn();
const onLocateError = options.onLocateError ?? vi.fn();
const host = document.createElement("div");
document.body.appendChild(host);
const app = createApp({
setup: () => () => h(QueryErrorActions, { errorMessage, connectionId: "postgres-1", onChangeConnectionTimeout }),
setup: () => () =>
h(QueryErrorActions, {
errorMessage,
connectionId: "postgres-1",
errorPosition: options.errorPosition,
onChangeConnectionTimeout,
onLocateError,
}),
});
mountedApps.push(app);
app.use(i18n);
app.mount(host);
await nextTick();
return { host, onChangeConnectionTimeout };
return { host, onChangeConnectionTimeout, onLocateError };
}
afterEach(() => {
@@ -35,6 +44,23 @@ describe("QueryErrorActions", () => {
expect(onChangeConnectionTimeout).toHaveBeenCalledOnce();
});
it("offers a locate-error action when the backend reports a position", async () => {
const { host, onLocateError } = await mountActions("ERROR: relation does not exist", {
errorPosition: { line: 2, column: 6 },
});
const locateButton = Array.from(host.querySelectorAll("button")).find((button) => button.textContent?.includes("Locate error"));
expect(locateButton?.textContent).toContain("line 2, col 6");
locateButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(onLocateError).toHaveBeenCalledOnce();
});
it("hides the locate-error action without a position", async () => {
const { host } = await mountActions("ERROR: relation does not exist");
expect(host.textContent).not.toContain("Locate error");
});
it("does not offer connection timeout settings for query timeouts", async () => {
const { host } = await mountActions("Query timed out after 30 seconds");
@@ -1118,6 +1118,17 @@ function focusStatementRange(range: { from: number; to: number } | null) {
currentView.focus();
}
function focusErrorPosition(offset: number) {
const currentView = view.value;
if (!currentView || !editorViewModule) return;
const errorPos = Math.max(0, Math.min(offset, currentView.state.doc.length));
currentView.dispatch({
selection: { anchor: errorPos },
effects: [editorViewModule.EditorView.scrollIntoView(errorPos, { y: "center" })],
});
currentView.focus();
}
function onPickerActiveIndexChange(index: number) {
pickerActiveIndex.value = index;
const candidate = pickerCandidates.value[index];
@@ -7553,6 +7564,7 @@ defineExpose({
captureExecutionSnapshot,
pasteClipboardAsSqlInCondition,
focusStatementRange,
focusErrorPosition,
previewStatementRange,
refreshCompletionCache,
});
@@ -45,6 +45,7 @@ import {
RotateCcw,
SkipForward,
ListX,
LocateFixed,
} from "@lucide/vue";
import { Splitpanes, Pane } from "splitpanes";
import { DynamicScroller, DynamicScrollerItem } from "vue-virtual-scroller";
@@ -134,6 +135,7 @@ import { TABLE_FONT_SIZE_MAX, TABLE_FONT_SIZE_MIN, useSettingsStore, type DataGr
import { useToast } from "@/composables/useToast";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { canCancelQueryExecution, isActiveResultLoading, queryExecutionLabelKey } from "@/lib/sql/queryExecutionState";
import { sqlErrorEditorOffset, logSqlErrorPosition } from "@/lib/sql/errorPosition";
import {
databaseDisplayNameForTab,
executionSummaryItems,
@@ -331,6 +333,9 @@ const activeDataTabTableMeta = computed(() => tableMetaForDataTab(props.activeTa
const activeResultExecutionTarget = computed(() => queryStore.activeResultExecutionTarget(props.activeTab.id));
const activeResultConnection = computed(() => (activeResultExecutionTarget.value ? connectionStore.getConfig(activeResultExecutionTarget.value.connectionId) : props.activeConnection));
const activeResultConnectionId = computed(() => activeResultExecutionTarget.value?.connectionId ?? props.activeTab.connectionId);
// Row/column locate only makes sense for SQL editor tabs: data/preview tabs have
// no user statement to map the backend position onto.
const activeResultErrorPosition = computed(() => (props.activeTab.mode === "query" ? props.activeTab.result?.error?.errorPosition : undefined));
const activeResultDatabase = computed(() => activeResultExecutionTarget.value?.database ?? props.activeTab.database);
const activeResultSchema = computed(() => activeResultExecutionTarget.value?.schema ?? props.activeTab.schema);
const activeEffectiveDatabaseType = computed(() => effectiveDatabaseTypeForConnection(activeResultConnection.value));
@@ -1296,6 +1301,72 @@ function focusStatementRange(range: { from: number; to: number } | null): boolea
return true;
}
function focusErrorPosition(offset: number): boolean {
if (!queryEditorRef.value) return false;
queryEditorRef.value.focusErrorPosition(offset);
return true;
}
/**
* Jump to the driver-reported row/column of the active result's error. Falls back
* to a cross-surface event when this surface only renders the shared result pane
* (the editor lives in another group).
*/
function locateActiveResultError() {
const result = props.activeTab.result;
logSqlErrorPosition("locate:invoke", {
tabId: props.activeTab.id,
mode: props.activeTab.mode,
hasEditorRef: Boolean(queryEditorRef.value),
activeResultIndex: props.activeTab.activeResultIndex,
statementIndex: result?.statement_index,
hasBackendError: Boolean(result?.error),
errorPosition: result?.error?.errorPosition ?? null,
editorLength: props.activeTab.sql.length,
resultIsError: Boolean(result && isQueryExecutionErrorResult(result)),
});
const mapped = sqlErrorEditorOffset({
editorSql: props.activeTab.sql,
result,
resultIndex: result?.statement_index ?? props.activeTab.activeResultIndex,
databaseType: activeEffectiveDatabaseType.value,
parameterOptions: activeSqlStatementParameterOptions.value,
});
if (!mapped) {
logSqlErrorPosition("locate:unavailable", {
tabId: props.activeTab.id,
reason: "sqlErrorEditorOffset returned undefined (see the preceding 'unresolved:result-source-range' log)",
});
toast(t("editor.errorPositionUnavailable"), 3000);
return;
}
logSqlErrorPosition("locate:focus", { tabId: props.activeTab.id, offset: mapped.offset, line: mapped.line, column: mapped.column });
if (queryEditorRef.value) {
queryEditorRef.value.focusErrorPosition(mapped.offset);
} else {
emit("focusErrorOffset", props.activeTab.id, mapped.offset);
}
}
function locateExecutionSummaryError(item: ExecutionSummaryItem) {
const mapped = item.result
? sqlErrorEditorOffset({
editorSql: props.activeTab.sql,
result: item.result,
resultIndex: item.statementIndex,
databaseType: activeEffectiveDatabaseType.value,
parameterOptions: activeSqlStatementParameterOptions.value,
})
: undefined;
if (mapped) {
if (!focusErrorPosition(mapped.offset)) {
emit("focusErrorOffset", props.activeTab.id, mapped.offset);
}
return;
}
focusExecutionSummaryItem(item);
}
defineExpose({
focusSearch,
openGoToColumn,
@@ -1316,6 +1387,8 @@ defineExpose({
executeRedisCommand,
previewStatementRange,
focusStatementRange,
focusErrorPosition,
locateActiveResultError,
});
</script>
@@ -1867,14 +1940,26 @@ defineExpose({
:title="item.error || item.sql"
:aria-label="item.error || item.sql || t('executionSummary.noSql')"
@click="previewExecutionSummaryItem(item)"
@dblclick="focusExecutionSummaryItem(item)"
@dblclick="locateExecutionSummaryError(item)"
@keydown.enter.prevent="focusExecutionSummaryItem(item)"
/>
<div class="pointer-events-none relative z-[1] font-mono text-muted-foreground">#{{ item.statementIndex + 1 }}</div>
<div class="relative z-[1] min-w-0 cursor-pointer" @click="previewExecutionSummaryItem(item)" @dblclick="focusExecutionSummaryItem(item)">
<div class="relative z-[1] min-w-0 cursor-pointer" @click="previewExecutionSummaryItem(item)" @dblclick="locateExecutionSummaryError(item)">
<div class="truncate font-mono text-[11px] text-foreground">{{ item.sql || t("executionSummary.noSql") }}</div>
<div v-if="item.error" class="mt-0.5 flex min-w-0 items-center gap-1 text-[11px] text-destructive">
<span v-if="item.errorPosition" class="shrink-0 rounded border border-destructive/30 bg-destructive/5 px-1 tabular-nums">{{ t("executionSummary.lineColumn", { line: item.errorPosition.line, column: item.errorPosition.column }) }}</span>
<span data-native-clipboard class="min-w-0 flex-1 cursor-text select-text truncate" :title="item.error" @mousedown.stop @click.stop @dblclick.stop>{{ item.error }}</span>
<LightTooltip v-if="item.errorPosition" :text="t('editor.locateError', { line: item.errorPosition.line, column: item.errorPosition.column })" side="bottom" :delay="0" :close-delay="0" nowrap>
<button
type="button"
class="pointer-events-auto flex h-5 w-5 shrink-0 items-center justify-center rounded text-destructive/70 hover:bg-destructive/10 hover:text-destructive"
:aria-label="t('editor.locateError', { line: item.errorPosition.line, column: item.errorPosition.column })"
@mousedown.stop
@click.stop="locateExecutionSummaryError(item)"
>
<LocateFixed class="h-3 w-3" />
</button>
</LightTooltip>
<LightTooltip :text="t('grid.copy')" side="bottom" :delay="0" :close-delay="0" nowrap>
<button type="button" class="pointer-events-auto flex h-5 w-5 shrink-0 items-center justify-center rounded text-destructive/70 hover:bg-destructive/10 hover:text-destructive" :aria-label="t('grid.copy')" @mousedown.stop @click.stop="copyExecutionSummaryError(item.error)">
<Copy class="h-3 w-3" />
@@ -2034,6 +2119,8 @@ defineExpose({
:error-message="String(errorMessage)"
:backend-error="activeTab.result.error"
:connection-id="activeResultConnectionId"
:error-position="activeResultErrorPosition"
@locate-error="locateActiveResultError"
@change-connection-timeout="activeResultConnectionId && emit('openConnectionSettings', activeResultConnectionId, 'advanced')"
@change-query-timeout="activeResultConnectionId && emit('openConnectionSettings', activeResultConnectionId, 'advanced')"
@fix-with-ai="(message) => emit('fixWithAi', activeTab.id, message)"
@@ -2392,6 +2479,8 @@ defineExpose({
:error-message="String(errorMessage)"
:backend-error="activeTab.result.error"
:connection-id="activeResultConnectionId"
:error-position="activeResultErrorPosition"
@locate-error="locateActiveResultError"
@change-connection-timeout="activeResultConnectionId && emit('openConnectionSettings', activeResultConnectionId, 'advanced')"
@change-query-timeout="activeResultConnectionId && emit('openConnectionSettings', activeResultConnectionId, 'advanced')"
@fix-with-ai="(message) => emit('fixWithAi', activeTab.id, message)"
@@ -89,6 +89,7 @@ defineExpose({
executeRedisCommand: (command: string) => activeSurfaceRef.value?.executeRedisCommand(command) ?? Promise.resolve(false),
previewStatementRange: (tabId: string, range: StatementRange | null) => (activeTab.value?.id === tabId ? (activeSurfaceRef.value?.previewStatementRange(range) ?? false) : false),
focusStatementRange: (tabId: string, range: StatementRange | null) => (activeTab.value?.id === tabId ? (activeSurfaceRef.value?.focusStatementRange(range) ?? false) : false),
focusErrorPosition: (tabId: string, offset: number) => (activeTab.value?.id === tabId ? (activeSurfaceRef.value?.focusErrorPosition(offset) ?? false) : false),
});
const { t } = useI18n();
@@ -41,6 +41,7 @@ defineExpose<QueryEditorSurfaceHandle>({
executeRedisCommand: (command: string) => contentAreaRef.value?.executeRedisCommand(command) ?? Promise.resolve(false),
previewStatementRange: (range: StatementRange | null) => contentAreaRef.value?.previewStatementRange(range) ?? false,
focusStatementRange: (range: StatementRange | null) => contentAreaRef.value?.focusStatementRange(range) ?? false,
focusErrorPosition: (offset: number) => contentAreaRef.value?.focusErrorPosition(offset) ?? false,
});
</script>
@@ -33,6 +33,7 @@ defineExpose<QueryResultSurfaceHandle>({
handleModRTarget: (target: Element) => contentAreaRef.value?.handleModRTarget(target) ?? false,
previewStatementRange: (range: StatementRange | null) => contentAreaRef.value?.previewStatementRange(range) ?? false,
focusStatementRange: (range: StatementRange | null) => contentAreaRef.value?.focusStatementRange(range) ?? false,
focusErrorPosition: (offset: number) => contentAreaRef.value?.focusErrorPosition(offset) ?? false,
});
</script>
@@ -255,6 +255,14 @@ function handleFocusStatement(tabId: string, range: StatementRange | null): bool
}
return false;
}
function handleFocusErrorOffset(tabId: string, offset: number): boolean {
for (const group of groupRefs.values()) {
if (group.focusErrorPosition(tabId, offset)) {
return true;
}
}
return false;
}
</script>
<template>
@@ -348,6 +356,11 @@ function handleFocusStatement(tabId: string, range: StatementRange | null): bool
handleFocusStatement(tabId, range);
}
"
@focus-error-offset="
(tabId: string, offset: number) => {
handleFocusErrorOffset(tabId, offset);
}
"
/>
</div>
</Pane>
@@ -29,6 +29,7 @@ export interface QueryEditorSurfaceHandle {
executeRedisCommand(command: string): Promise<boolean>;
previewStatementRange(range: StatementRange | null): boolean;
focusStatementRange(range: StatementRange | null): boolean;
focusErrorPosition(offset: number): boolean;
}
export interface QueryResultSurfaceHandle {
@@ -38,6 +39,7 @@ export interface QueryResultSurfaceHandle {
handleModRTarget(target: Element): boolean;
previewStatementRange(range: StatementRange | null): boolean;
focusStatementRange(range: StatementRange | null): boolean;
focusErrorPosition(offset: number): boolean;
}
export interface ContentAreaSurfaceProps {
@@ -97,6 +99,7 @@ export interface ContentAreaSurfaceEmits {
structureEditorClose: [tabId: string];
previewStatement: [tabId: string, range: StatementRange | null];
focusStatement: [tabId: string, range: StatementRange | null];
focusErrorOffset: [tabId: string, offset: number];
openSettings: [initialTab?: string, initialSection?: string];
openConnectionSettings: [connectionId: string, initialTab: "advanced"];
toggleZenMode: [];