feat(query): jump to PostgreSQL error position from result messages
This commit is contained in:
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: [];
|
||||
|
||||
Reference in new issue
Block a user