Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/components/notebook/NotebookView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
createNotebookFromState,
} from "../../utils/notebookStore";
import { useDatabase } from "../../hooks/useDatabase";
import { useSqlAutocompleteRegistration } from "../../hooks/useSqlAutocompleteRegistration";
import { isMultiDatabaseCapable } from "../../utils/database";
import { useSettings } from "../../hooks/useSettings";
import { useAlert } from "../../hooks/useAlert";
Expand All @@ -59,19 +60,25 @@ interface NotebookViewProps {
tab: Tab;
updateTab: (id: string, partial: Partial<Tab>) => void;
connectionId: string;
isActive: boolean;
}

export function NotebookView({
tab,
updateTab,
connectionId,
isActive,
}: NotebookViewProps) {
const { t } = useTranslation();
const { activeSchema, activeCapabilities, selectedDatabases } = useDatabase();
const isMultiDb =
isMultiDatabaseCapable(activeCapabilities) && selectedDatabases.length > 1;
const effectiveSchema =
tab.schema || activeSchema || (isMultiDb ? selectedDatabases[0] : null);
useSqlAutocompleteRegistration(connectionId, {
schema: effectiveSchema,
enabled: isActive,
});
const { settings } = useSettings();
const { showAlert } = useAlert();
const { matchesShortcut } = useKeybindings();
Expand Down
4 changes: 3 additions & 1 deletion src/contexts/DatabaseProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
} from './DatabaseContext';
import type { ReactNode } from 'react';
import type { PluginManifest } from '../types/plugins';
import { clearAutocompleteCache } from '../utils/autocomplete';
import { clearAutocompleteCache, disposeSqlAutocomplete } from '../utils/autocomplete';
import { toErrorMessage } from '../utils/errors';
import { useSettings } from '../hooks/useSettings';
import { findConnectionsForDrivers } from '../utils/connectionManager';
Expand Down Expand Up @@ -691,6 +691,7 @@ export const DatabaseProvider = ({ children }: { children: ReactNode }) => {
if (!targetId) return;

clearAutocompleteCache(targetId);
disposeSqlAutocomplete();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the root cause of point #1 above. The global provider mirrors the active editor, so only dispose it when the connection being torn down is actually the active one — otherwise disconnecting a background connection kills autocomplete in the editor you're using:

Suggested change
disposeSqlAutocomplete();
if (targetId === activeConnectionId) {
disposeSqlAutocomplete();
}

The health-check handler at L786 has the same issue, but there activeConnectionId is a stale closure — you'd need an activeConnectionIdRef (or move disposal into the active-connection-change path). Cleanest long-term: let useSqlAutocompleteRegistration own disposal in its cleanup and drop both calls from here entirely.


try {
await invoke('disconnect_connection', { connectionId: targetId });
Expand Down Expand Up @@ -782,6 +783,7 @@ export const DatabaseProvider = ({ children }: { children: ReactNode }) => {
console.warn(`[DatabaseProvider] Connection health check failed for ${connectionId}: ${event.payload.error}`);

clearAutocompleteCache(connectionId);
disposeSqlAutocomplete();

setOpenConnectionIds(prev => prev.filter(id => id !== connectionId));
setConnectionDataMap(prev => {
Expand Down
89 changes: 89 additions & 0 deletions src/hooks/useSqlAutocompleteRegistration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { useEffect } from "react";
import type { Monaco } from "@monaco-editor/react";
import { loader } from "@monaco-editor/react";
import { useDatabase } from "./useDatabase";
import { isMultiDatabaseCapable } from "../utils/database";
import { registerSqlAutocomplete } from "../utils/autocomplete";

type Options = {
monaco?: Monaco | null;
schema?: string | null;
/** When false, skips registration (e.g. inactive notebook tabs). Defaults to true. */
enabled?: boolean;
};

/**
* Keeps the global SQL completion provider in sync with the active connection.
* Pass `monaco` from the main editor when available; otherwise Monaco is loaded via loader.init (notebook).
*/
export function useSqlAutocompleteRegistration(
connectionId: string | null,
options?: Options,
) {
const {
tables,
activeDriver,
activeSchema,
activeCapabilities,
schemaDataMap,
databaseDataMap,
selectedDatabases,
} = useDatabase();

const schema = options?.schema ?? activeSchema;
const isMultiDb =
isMultiDatabaseCapable(activeCapabilities) && selectedDatabases.length > 1;

const enabled = options?.enabled ?? true;

useEffect(() => {
if (!connectionId || !enabled) return;

let cancelled = false;

const register = (monaco: Monaco) => {
if (cancelled) return;

let effectiveTables = tables;
if (activeCapabilities?.schemas && schema) {
effectiveTables = schemaDataMap[schema]?.tables ?? tables;
} else if (isMultiDb) {
effectiveTables = selectedDatabases.flatMap(
(db) => databaseDataMap[db]?.tables ?? [],
);
}

registerSqlAutocomplete(
monaco,
connectionId,
effectiveTables,
schema,
activeDriver ?? null,
);
};

if (options?.monaco) {
register(options.monaco);
return () => {
cancelled = true;
};
}

loader.init().then((monaco) => register(monaco));
return () => {
cancelled = true;
};
}, [
connectionId,
enabled,
options?.monaco,
schema,
tables,
activeDriver,
activeCapabilities,
schemaDataMap,
databaseDataMap,
isMultiDb,
selectedDatabases,
]);
}
28 changes: 7 additions & 21 deletions src/pages/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ import { SqlEditorWrapper } from "../components/ui/SqlEditorWrapper";
import { NotebookView } from "../components/notebook/NotebookView";
import { extractSqlFromCells } from "../utils/notebook";
import { createNotebook } from "../utils/notebookStore";
import { registerSqlAutocomplete } from "../utils/autocomplete";
import { useSqlAutocompleteRegistration } from "../hooks/useSqlAutocompleteRegistration";
import { type OnMount, type Monaco } from "@monaco-editor/react";
import { save } from "@tauri-apps/plugin-dialog";
import { useAlert } from "../hooks/useAlert";
Expand Down Expand Up @@ -137,16 +137,13 @@ export const Editor = () => {
const { t } = useTranslation();
const {
activeConnectionId,
tables,
views,
activeDriver,
activeSchema,
activeCapabilities,
selectedDatabases,
activeConnectionName,
activeDatabaseName,
schemaDataMap,
databaseDataMap,
} = useDatabase();
const { explorerConnectionId } = useConnectionLayoutContext();
const { settings } = useSettings();
Expand Down Expand Up @@ -2136,23 +2133,11 @@ export const Editor = () => {
});
};

useEffect(() => {
if (monacoInstance && activeConnectionId) {
let effectiveTables = tables;
if (activeCapabilities?.schemas && activeSchema) {
effectiveTables = schemaDataMap[activeSchema]?.tables ?? tables;
} else if (isMultiDb) {
effectiveTables = selectedDatabases.flatMap(db => databaseDataMap[db]?.tables ?? []);
}
const disposable = registerSqlAutocomplete(
monacoInstance,
activeConnectionId,
effectiveTables,
activeSchema,
);
return () => disposable.dispose();
}
}, [monacoInstance, activeConnectionId, tables, activeSchema, activeCapabilities, schemaDataMap, databaseDataMap, isMultiDb, selectedDatabases]);
useSqlAutocompleteRegistration(activeConnectionId, {
monaco: monacoInstance,
schema: activeSchema,
enabled: !isNotebookTab,
});

useEffect(() => {
const state = location.state as EditorState;
Expand Down Expand Up @@ -2742,6 +2727,7 @@ export const Editor = () => {
tab={tab}
updateTab={updateTab}
connectionId={activeConnectionId || ""}
isActive={isActive}
/>
</div>
);
Expand Down
44 changes: 35 additions & 9 deletions src/utils/autocomplete.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Monaco } from "@monaco-editor/react";
import { invoke } from "@tauri-apps/api/core";
import type { TableInfo } from "../contexts/DatabaseContext";
import { formatSqlIdentifier, quoteTableRef } from "./identifiers";
import { getCurrentStatement, parseTablesFromQuery } from "./sqlAnalysis";

// Lightweight column cache with TTL and size limits
Expand Down Expand Up @@ -98,11 +99,32 @@ export const clearAutocompleteCache = (connectionId?: string) => {
}
};

// Find a table by name in the list of tables
const findTableByName = (name: string, tables: TableInfo[]) =>
tables.find((t) => t.name.toLowerCase() === name.toLowerCase())?.name;

const tableInsertText = (
tableName: string,
driver?: string | null,
schema?: string | null,
) =>
schema
? quoteTableRef(tableName, driver, schema)
: formatSqlIdentifier(tableName, driver);
Comment on lines +106 to +113
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The completion session is already scoped to the active schema (getTableColumns(..., schema)), so hard-qualifying every table as "public"."table" is redundant and noisy. Drop the schema prefix and quote only when needed (pairs with the call-site suggestion below + the quoteIdentifierIfNeeded helper from the review body):

Suggested change
const tableInsertText = (
tableName: string,
driver?: string | null,
schema?: string | null,
) =>
schema
? quoteTableRef(tableName, driver, schema)
: formatSqlIdentifier(tableName, driver);
const tableInsertText = (
tableName: string,
driver?: string | null,
) =>
// Already scoped to the active schema — don't prefix "public". on every
// table. Quote only when the name actually requires it.
quoteIdentifierIfNeeded(tableName, driver);

If you want cross-schema qualification later, qualify only when schema !== defaultSchema rather than always.


let sqlCompletionProvider: { dispose: () => void } | null = null;

export const disposeSqlAutocomplete = (): void => {
sqlCompletionProvider?.dispose();
sqlCompletionProvider = null;
};

export const registerSqlAutocomplete = (
monaco: Monaco,
connectionId: string | null,
tables: TableInfo[],
schema?: string | null,
driver?: string | null,
) => {
const provider = monaco.languages.registerCompletionItemProvider("sql", {
triggerCharacters: [".", " "],
Expand Down Expand Up @@ -141,13 +163,13 @@ export const registerSqlAutocomplete = (

// Check if it's an alias or table name
let actualTableName = tableAliases?.get(typedName);

if (!actualTableName) {
// Try direct table name match
const foundTable = tables.find(t => t.name.toLowerCase() === typedName);
actualTableName = foundTable?.name;
actualTableName = findTableByName(typedName, tables);
} else {
actualTableName = findTableByName(actualTableName, tables) ?? actualTableName;
}

if (actualTableName) {
const columns = await getTableColumns(connectionId, actualTableName, schema);

Expand All @@ -163,13 +185,15 @@ export const registerSqlAutocomplete = (
label: c.label,
kind: monaco.languages.CompletionItemKind.Field,
detail: c.detail,
insertText: c.label,
insertText: formatSqlIdentifier(c.label, driver),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Columns get the same always-quote treatment — "id", "CreatedAt". Quote only when needed so plain lowercase columns stay clean while "AccountId" stays correctly quoted:

Suggested change
insertText: formatSqlIdentifier(c.label, driver),
insertText: quoteIdentifierIfNeeded(c.label, driver),

range: columnRange,
sortText: `0_${c.label}`,
}));

return { suggestions };
}

return { suggestions: [] };
}

// ============================================
Expand All @@ -188,7 +212,7 @@ export const registerSqlAutocomplete = (
// User is inside a query with FROM/JOIN - suggest columns from those tables
const tableNames = Array.from(new Set(tableAliases.values()));
const matchingTables = tableNames
.map(name => tables.find(t => t.name.toLowerCase() === name.toLowerCase()))
.map((name) => tables.find((t) => t.name.toLowerCase() === name.toLowerCase()))
.filter(Boolean) as TableInfo[];

// Limit parallel fetches to prevent memory spikes
Expand Down Expand Up @@ -223,7 +247,7 @@ export const registerSqlAutocomplete = (
label: col.label,
kind: monaco.languages.CompletionItemKind.Field,
detail: `${col.detail} — ${table.name}${aliasHint}`,
insertText: col.label,
insertText: formatSqlIdentifier(col.label, driver),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same for the context-column insert path:

Suggested change
insertText: formatSqlIdentifier(col.label, driver),
insertText: quoteIdentifierIfNeeded(col.label, driver),

range,
sortText: `0_${col.label}`,
});
Expand Down Expand Up @@ -259,7 +283,7 @@ export const registerSqlAutocomplete = (
label: t.name,
kind: monaco.languages.CompletionItemKind.Class,
detail: "Table",
insertText: t.name,
insertText: tableInsertText(t.name, driver, schema),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Match the simplified tableInsertText signature (no schema arg):

Suggested change
insertText: tableInsertText(t.name, driver, schema),
insertText: tableInsertText(t.name, driver),

quoteTableRef then becomes unused in this file — drop it from the import on line 1.

range,
sortText: `1_${t.name}`
}));
Expand All @@ -274,5 +298,7 @@ export const registerSqlAutocomplete = (
},
});

sqlCompletionProvider?.dispose();
sqlCompletionProvider = provider;
return provider;
};
Loading