Skip to content
Merged
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
5 changes: 5 additions & 0 deletions src/components/notebook/NotebookView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,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 Down Expand Up @@ -90,6 +91,10 @@ export function NotebookView({
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
90 changes: 90 additions & 0 deletions src/hooks/useSqlAutocompleteRegistration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
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, disposeSqlAutocomplete } 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,
);
};

const cleanup = () => {
cancelled = true;
disposeSqlAutocomplete();
};

if (options?.monaco) {
register(options.monaco);
return cleanup;
}

loader.init().then((monaco) => register(monaco));
return cleanup;
}, [
connectionId,
enabled,
options?.monaco,
schema,
tables,
activeDriver,
activeCapabilities,
schemaDataMap,
databaseDataMap,
isMultiDb,
selectedDatabases,
]);
}
29 changes: 6 additions & 23 deletions src/pages/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,8 @@ import {
import { formatDuration } from "../utils/formatTime";
import { SqlEditorWrapper } from "../components/ui/SqlEditorWrapper";
import { NotebookView } from "../components/notebook/NotebookView";
import { useSqlAutocompleteRegistration } from "../hooks/useSqlAutocompleteRegistration";
import { createNotebook, renameNotebook } from "../utils/notebookStore";
import { registerSqlAutocomplete } from "../utils/autocomplete";
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 @@ -140,16 +140,13 @@ export const Editor = () => {
const {
activeConnectionId,
connections,
tables,
views,
activeDriver,
activeSchema,
activeCapabilities,
selectedDatabases,
activeConnectionName,
activeDatabaseName,
schemaDataMap,
databaseDataMap,
} = useDatabase();
const { allDrivers } = useDrivers();
const { explorerConnectionId } = useConnectionLayoutContext();
Expand Down Expand Up @@ -2213,25 +2210,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 ?? []).map(t => ({ ...t, schema: db }))
);
}
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
69 changes: 62 additions & 7 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, getQuoteChar, quoteIdentifier } from "./identifiers";
import { getCurrentStatement, parseTablesFromQuery, type ParsedTableRef } from "./sqlAnalysis";

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

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 All @@ -123,6 +132,51 @@ export const registerSqlAutocomplete = (
endColumn: wordUntil.endColumn,
};

// When the user has already typed an opening quote, Monaco auto-closes it
// so the cursor sits inside a pair (`"|"`); the user may also have deleted
// the closing one (`"|`). Inserting a freshly quoted identifier into the
// word range would then double the opening quote (`""AccountEventLog"`) or
// leave it dangling. So when an opening quote precedes the replacement
// range, expand the range to swallow it (and the closing quote if present)
// and emit a fully-quoted identifier — yielding a canonical result for 0, 1
// or 2 surrounding quotes alike.
const quoteChar = getQuoteChar(driver);
const charAt = (column: number): string =>
column < 1
? ""
: model.getValueInRange({
startLineNumber: position.lineNumber,
startColumn: column,
endLineNumber: position.lineNumber,
endColumn: column + 1,
});
const buildIdentifierInsert = (
name: string,
baseRange: { startLineNumber: number; endLineNumber: number; startColumn: number; endColumn: number },
): {
insertText: string;
range: { startLineNumber: number; endLineNumber: number; startColumn: number; endColumn: number };
filterText?: string;
} => {
if (baseRange.startColumn <= 1 || charAt(baseRange.startColumn - 1) !== quoteChar) {
return { insertText: formatSqlIdentifier(name, driver), range: baseRange };
}
const swallowsClosing = charAt(baseRange.endColumn) === quoteChar;
const insertText = quoteIdentifier(name, driver);
return {
insertText,
// The range now starts at the opening quote, so Monaco filters items
// against the leading quote; match it by giving filterText the same
// quoted form, otherwise every suggestion gets filtered out.
filterText: insertText,
range: {
...baseRange,
startColumn: baseRange.startColumn - 1,
endColumn: swallowsClosing ? baseRange.endColumn + 1 : baseRange.endColumn,
},
};
};

// Get text until cursor position
const textUntilPosition = model.getValueInRange({
startLineNumber: position.lineNumber,
Expand All @@ -139,7 +193,6 @@ export const registerSqlAutocomplete = (
// ============================================
// 1. DOT TRIGGER (table.column, alias.column, or db.table.column)
// ============================================

// Try qualified (db.table.) first, then simple (table.)
const qualifiedDotMatch = textUntilPosition.match(/`?([a-zA-Z0-9_]+)`?\.`?([a-zA-Z0-9_]+)`?\.([a-zA-Z0-9_]*)$/);
const simpleDotMatch = qualifiedDotMatch ? null : textUntilPosition.match(/(?:["'`])?([a-zA-Z0-9_]+)(?:["'`])?\.([a-zA-Z0-9_]*)$/);
Expand Down Expand Up @@ -190,12 +243,13 @@ export const registerSqlAutocomplete = (
label: c.label,
kind: monaco.languages.CompletionItemKind.Field,
detail: c.detail,
insertText: c.label,
range: columnRange,
...buildIdentifierInsert(c.label, columnRange),
sortText: `0_${c.label}`,
})),
};
}

return { suggestions: [] };
}

// ============================================
Expand All @@ -208,6 +262,7 @@ export const registerSqlAutocomplete = (
insertText: string;
range: { startLineNumber: number; endLineNumber: number; startColumn: number; endColumn: number };
sortText: string;
filterText?: string;
}> = [];

if (tableAliases && tableAliases.size > 0) {
Expand Down Expand Up @@ -263,8 +318,7 @@ export const registerSqlAutocomplete = (
label: col.label,
kind: monaco.languages.CompletionItemKind.Field,
detail: `${col.detail} — ${table.name}${aliasHint}`,
insertText: col.label,
range,
...buildIdentifierInsert(col.label, range),
sortText: `0_${col.label}`,
});
}
Expand Down Expand Up @@ -293,8 +347,7 @@ export const registerSqlAutocomplete = (
label: t.name,
kind: monaco.languages.CompletionItemKind.Class,
detail: "Table",
insertText: t.name,
range,
...buildIdentifierInsert(t.name, range),
sortText: `1_${t.name}`
}));

Expand All @@ -308,5 +361,7 @@ export const registerSqlAutocomplete = (
},
});

sqlCompletionProvider?.dispose();
sqlCompletionProvider = provider;
return provider;
};
17 changes: 14 additions & 3 deletions src/utils/identifiers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,14 @@ export function shouldQuoteIdentifiers(
return driver === "postgres";
}

// PostgreSQL folds unquoted identifiers to lowercase and only needs quotes for
// reserved words, mixed case, or special characters — mirroring quote_ident().
const PG_SAFE_IDENTIFIER = /^[a-z_][a-z0-9_$]*$/;
const PG_RESERVED = new Set([
"select", "from", "where", "table", "user", "order", "group", "join", "and", "or",
"as", "in", "on", "by", "null", "true", "false", "default", "check", "column", "limit", "offset",
]);

/**
* Formats a SQL identifier for WHERE / ORDER BY fragments.
* Quotes only when required (PostgreSQL); otherwise returns the name unchanged.
Expand All @@ -44,9 +52,11 @@ export function formatSqlIdentifier(
identifier: string,
driver: string | null | undefined,
): string {
return shouldQuoteIdentifiers(driver)
? quoteIdentifier(identifier, driver)
: identifier;
if (!shouldQuoteIdentifiers(driver)) return identifier;
if (PG_SAFE_IDENTIFIER.test(identifier) && !PG_RESERVED.has(identifier)) {
return identifier;
}
return quoteIdentifier(identifier, driver);
}

export function quoteIdentifier(
Expand Down Expand Up @@ -76,3 +86,4 @@ export function quoteTableRef(
}
return quoteIdentifier(table, driver);
}

Loading